feat: 完善登录流程、UI适配及协议文档更新
本次提交包含以下修改: 1. 新增微信登录相关服务及页面逻辑,实现首次微信绑定手机号流程 2. 优化产品匹配卡片 UI,移除不必要状态显示 3. 调整首页 scroll-view 属性及 banner 背景样式 4. 完善积分规则校验逻辑,增加时区及策略格式验证 5. 更新协议草案文档,补充资料清单及功能说明 6. 修复分析结果路由路径,适配新的匹配结果页面 7. 调整品牌 Logo 安卓端字体对齐样式
This commit is contained in:
@@ -4,6 +4,7 @@ const ts=require('D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dclou
|
||||
const repo=path.resolve(__dirname,'..');
|
||||
const compile=source=>ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS,target:ts.ScriptTarget.ES2020}}).outputText;
|
||||
const routeExports={};vm.runInNewContext(compile(fs.readFileSync(path.join(repo,'services/report-route.uts'),'utf8')),{exports:routeExports});
|
||||
const matchRoutes={};vm.runInNewContext(compile(fs.readFileSync(path.join(repo,'services/match-route.uts'),'utf8')),{exports:matchRoutes});
|
||||
const script=fs.readFileSync(path.join(repo,'packages/finance/analyzing/analyzing.uvue'),'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
|
||||
function status(overrides={}){return {id:'42',revisionId:'9',publishedRevisionId:'9',status:'succeeded',statusLabel:'分析完成',message:'',hasReport:true,stage:'completed',canRetry:false,taskId:'task9',...overrides};}
|
||||
function page(reply){
|
||||
@@ -13,7 +14,7 @@ function page(reply){
|
||||
onLoad:fn=>{onLoad=fn},onShow:fn=>{onShow=fn},onHide:fn=>{onHide=fn},onUnload:fn=>{onUnload=fn},
|
||||
setTimeout:fn=>{timers.push(fn);return timers.length},clearTimeout:()=>{},setInterval:()=>1,clearInterval:()=>{},
|
||||
uni:{redirectTo:({url})=>{navigations.push({kind:'redirect',url})},navigateTo:({url})=>{navigations.push({kind:'navigate',url})}},
|
||||
require:module=>module.endsWith('layout.uts')?{usePageLayout:()=>({})}:module.endsWith('api.uts')?{getSession:session}:module.endsWith('records.uts')?{fetchAssessmentStatus:async()=>typeof response==='function'?response():response}:module.endsWith('report-route.uts')?routeExports:(()=>{throw Error('Unexpected dependency '+module)})()};
|
||||
require:module=>module.endsWith('layout.uts')?{usePageLayout:()=>({})}:module.endsWith('api.uts')?{getSession:session}:module.endsWith('records.uts')?{fetchAssessmentStatus:async()=>typeof response==='function'?response():response}:module.endsWith('report-route.uts')?routeExports:module.endsWith('match-route.uts')?matchRoutes:(()=>{throw Error('Unexpected dependency '+module)})()};
|
||||
vm.runInNewContext(compile(script+'\nexports.refreshStatus=refreshStatus; exports.report=report; exports.statusMessage=()=>message.value;'),context);
|
||||
onLoad({assessmentId:'42'});
|
||||
return {navigations,timers,show:()=>onShow(),hide:()=>onHide(),setReply:r=>{response=r},setToken:t=>{token=t},refresh:()=>context.exports.refreshStatus(),manualReport:()=>context.exports.report(),message:()=>context.exports.statusMessage()};
|
||||
@@ -23,12 +24,13 @@ let checks=0;const check=(ok,label)=>{assert.ok(ok,label);checks++};
|
||||
(async()=>{
|
||||
let p=page(status());p.show();await flush();
|
||||
check(p.navigations.length===1,'successful analysis automatically opens its result');
|
||||
assert.deepEqual(p.navigations[0],{kind:'redirect',url:'/packages/finance/report/report?assessmentId=42&tab=matches&revisionId=9'});checks++;
|
||||
assert.deepEqual(p.navigations[0],{kind:'redirect',url:'/packages/finance/matches/matches?assessmentId=42&productIds=&revisionId=9&group=all'});checks++;
|
||||
p.manualReport();assert.deepEqual(p.navigations[1],p.navigations[0]);checks++;
|
||||
check(p.timers.length===0,'successful result does not keep polling');
|
||||
p=page(status({status:'processing',stage:'queued',revisionId:'10',publishedRevisionId:'9'}));p.show();await flush();
|
||||
check(p.navigations.length===0&&p.timers.length===1,'processing revision never opens the older published report');
|
||||
p.setReply(status({revisionId:'10',publishedRevisionId:'10'}));p.timers[0]();await flush();
|
||||
check(p.navigations.length===1&&p.navigations[0].url.endsWith('tab=matches&revisionId=10'),'poll completion opens the newly published revision');
|
||||
check(p.navigations.length===1&&p.navigations[0].url==='/packages/finance/matches/matches?assessmentId=42&productIds=&revisionId=10&group=all','poll completion opens the newly published match results');
|
||||
p=page(status({status:'failed',canRetry:true}));p.show();await flush();check(p.navigations.length===0,'failed assessment stays on recovery page');
|
||||
check(p.message().includes('分析未完成')&&!p.message().includes('可查看本次报告'),'failed analysis without server message does not announce completed report');
|
||||
p=page(status({hasReport:false,publishedRevisionId:''}));p.show();await flush();check(p.navigations.length===0,'missing report never navigates to an unfrozen result');
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// Exercise list pagination and lifecycle with synthetic frozen pages; no network/model access.
|
||||
const fs = require('node:fs'), path = require('node:path'), vm = require('node:vm'), assert = require('node:assert/strict');
|
||||
const ts = require('D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
|
||||
const source = fs.readFileSync(path.join(__dirname, '../packages/finance/matches/matches.uvue'), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
|
||||
function item(id) {
|
||||
return { id: String(id), score: 95, state: 'eligible', stateLabel: '符合已知条件', availability: 'available', availabilityLabel: '当前产品可查看', product: { id: String(id), version: 1, name: '合成产品' + id, bank: '合成银行' }, positive: [], blocking: [], cautions: [], aiText: '', aiStatus: 'not_requested', aiCanRetry: false, aiCanRequest: false, aiGeneratedAt: '' };
|
||||
}
|
||||
function frozen(ids, cursor = '', overrides = {}) {
|
||||
return { assessmentId: '9', revisionId: '11', resultVersion: 2, snapshotId: 'frozen', ruleVersion: 'product-matching-v2', orderingVersion: 'product-score-descending-v1', status: 'completed', message: '', eligible: '8', ineligible: '0', unknown: '0', items: ids.map(item), hasMore: cursor.length > 0, cursor, ...overrides };
|
||||
}
|
||||
function harness(respond) {
|
||||
let token = 'synthetic-owner', onLoad, onShow, onHide;
|
||||
const requests = [];
|
||||
const context = { exports: {}, Error, Number, Promise,
|
||||
ref: value => ({ value }), computed: read => ({ get value() { return read(); } }),
|
||||
onLoad: fn => { onLoad = fn; }, onShow: fn => { onShow = fn; }, onHide: fn => { onHide = fn; }, onUnload: () => {},
|
||||
setInterval: () => 1, clearInterval: () => {}, uni: { redirectTo: () => {} },
|
||||
require: name => name.endsWith('layout.uts') ? { usePageLayout: () => ({}) } : name.endsWith('api.uts') ? { getSession: () => ({ getString: key => key === 'token' ? token : '' }) } : name.endsWith('matches.uts') ? {
|
||||
validRecordId: value => /^[1-9][0-9]{0,9}$/.test(value),
|
||||
fetchMatchPage: async (id, group, cursor, revisionId) => { const request = { id, group, cursor, revisionId }; requests.push(request); return respond(request); }
|
||||
} : name.endsWith('compare.uts') ? { compareSelection: () => [], saveCompareSelection: () => {}, parseCompareIds: () => [], compareQuery: () => '', matchReturnPath: () => '' } : {}
|
||||
};
|
||||
// Remove the Web-only address-bar update; the lifecycle and pagination remain the production code.
|
||||
const executable = source.replace(/\s*\/\/ #ifdef WEB[\s\S]*?\/\/ #endif/g, '');
|
||||
const script = ts.transpileModule(executable + '\nexports.view={matches,loading,pageError,error,hasMore,revisionId,group}; exports.more=loadMore; exports.select=selectGroup;', { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
vm.runInNewContext(script, context);
|
||||
onLoad({ assessmentId: '9', revisionId: '11' });
|
||||
return { ...context.exports, requests, show: () => onShow(), hide: () => onHide(), switchAccount: () => { token = 'other-user'; } };
|
||||
}
|
||||
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
|
||||
const ids = h => Array.from(h.view.matches.value, row => row.id);
|
||||
const pages = { '': frozen([1, 2, 3], 'next-3'), 'next-3': frozen([4, 5, 6], 'next-6'), 'next-6': frozen([7, 8]) };
|
||||
let checks = 0;
|
||||
async function test(name, action) { await action(); checks++; console.log('PASS ' + name); }
|
||||
(async () => {
|
||||
await test('one click appends every remaining page in order', async () => {
|
||||
const h = harness(({ cursor }) => pages[cursor]); h.show(); await flush();
|
||||
assert.deepEqual(ids(h), ['1', '2', '3']); assert.equal(h.requests.length, 1);
|
||||
h.more(); await flush();
|
||||
assert.deepEqual(ids(h), ['1', '2', '3', '4', '5', '6', '7', '8']); assert.equal(h.view.hasMore.value, false); assert.equal(h.view.loading.value, false);
|
||||
assert.deepEqual(h.requests.map(r => [r.group, r.cursor, r.revisionId]), [['all', '', '11'], ['all', 'next-3', '11'], ['all', 'next-6', '11']]);
|
||||
});
|
||||
await test('failure preserves appended rows and retry resumes at the failing page', async () => {
|
||||
let fail = true;
|
||||
const h = harness(({ cursor }) => { if (cursor === 'next-6' && fail) throw Error('synthetic timeout'); return pages[cursor]; });
|
||||
h.show(); await flush(); h.more(); await flush();
|
||||
assert.deepEqual(ids(h), ['1', '2', '3', '4', '5', '6']); assert.equal(h.view.pageError.value, 'synthetic timeout'); assert.equal(h.view.loading.value, false);
|
||||
fail = false; h.more(); await flush();
|
||||
assert.deepEqual(ids(h), ['1', '2', '3', '4', '5', '6', '7', '8']); assert.equal(h.requests.at(-1).cursor, 'next-6'); assert.equal(h.view.pageError.value, '');
|
||||
});
|
||||
await test('duplicate products and changed snapshots cannot enter the existing list', async () => {
|
||||
for (const bad of [frozen([3, 4, 5], 'next-6'), frozen([4, 5, 6], 'next-6', { snapshotId: 'changed' })]) {
|
||||
const h = harness(({ cursor }) => cursor === '' ? pages[''] : bad); h.show(); await flush(); h.more(); await flush();
|
||||
assert.deepEqual(ids(h), ['1', '2', '3']); assert.ok(h.view.pageError.value.length > 0); assert.equal(h.requests.length, 2);
|
||||
}
|
||||
});
|
||||
await test('leaving or switching accounts stops subsequent pagination and discards late rows', async () => {
|
||||
for (const action of ['hide', 'switchAccount']) {
|
||||
let resolve;
|
||||
const h = harness(({ cursor }) => cursor === '' ? pages[''] : new Promise(done => { resolve = done; }));
|
||||
h.show(); await flush(); h.more(); await flush(); h[action](); resolve(pages['next-3']); await flush();
|
||||
assert.deepEqual(ids(h), []); assert.equal(h.requests.length, 2);
|
||||
}
|
||||
});
|
||||
await test('group change ignores the old request and keeps the new group', async () => {
|
||||
let resolve;
|
||||
const h = harness(({ group, cursor }) => group === 'unknown' ? frozen([20], '', { eligible: '0', unknown: '1' }) : cursor === '' ? pages[''] : new Promise(done => { resolve = done; }));
|
||||
h.show(); await flush(); h.more(); await flush(); h.select('unknown'); await flush(); resolve(pages['next-3']); await flush();
|
||||
assert.deepEqual(ids(h), ['20']); assert.equal(h.view.group.value, 'unknown'); assert.equal(h.requests.length, 3); assert.equal(h.view.loading.value, false);
|
||||
});
|
||||
await test('repeated clicks share the running load', async () => {
|
||||
let resolve;
|
||||
const h = harness(({ cursor }) => cursor === '' ? pages[''] : cursor === 'next-3' ? new Promise(done => { resolve = done; }) : pages[cursor]);
|
||||
h.show(); await flush(); h.more(); h.more(); await flush(); assert.equal(h.requests.length, 2);
|
||||
resolve(pages['next-3']); await flush(); assert.deepEqual(ids(h), ['1', '2', '3', '4', '5', '6', '7', '8']); assert.equal(h.requests.length, 3);
|
||||
});
|
||||
console.log('PASS: ' + checks + ' match list scenarios; no network/model access');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,143 @@
|
||||
// Pure UTS and page behavior checks; no real account, network or database.
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require(process.env.AIRZ_TYPESCRIPT_PATH || 'D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
class JsonObject {
|
||||
constructor(value = {}) { Object.assign(this, value); }
|
||||
getString(key) { return typeof this[key] === 'string' ? this[key] : null; }
|
||||
getNumber(key) { return typeof this[key] === 'number' ? this[key] : null; }
|
||||
getBoolean(key) { return typeof this[key] === 'boolean' ? this[key] : null; }
|
||||
}
|
||||
const json = value => Array.isArray(value) ? value.map(json) : value && typeof value === 'object' ? new JsonObject(Object.fromEntries(Object.entries(value).map(([key, item]) => [key, json(item)]))) : value;
|
||||
const plain = value => JSON.parse(JSON.stringify(value));
|
||||
const policy = { dailyLimit: 1, makeUpAllowed: false, consecutiveBonusEnabled: false, profileRewardEnabled: false, invitationRewardEnabled: false, expiryPolicy: 'none', levelPolicy: 'unchanged', benefitsPolicy: 'none' };
|
||||
const published = { status: 'published', ruleVersion: 'points-checkin-20261019-v1', checkInReward: 10, timezone: 'Asia/Shanghai', publishedRules: policy };
|
||||
const draft = { status: 'draft', ruleVersion: null, checkInReward: null, timezone: 'Asia/Shanghai', publishedRules: null };
|
||||
let ruleReply = published;
|
||||
let signed = false;
|
||||
let loseResponse = false;
|
||||
let ledgerWrites = 0;
|
||||
let calls = [];
|
||||
let session = json({ userId: '1', token: 'synthetic-points-token' });
|
||||
class ApiRequestError extends Error { constructor(message, status, errorCode) { super(message); this.status = status; this.errorCode = errorCode; } }
|
||||
const summary = () => ({ balance: signed ? 30 : 20, serverDate: '2026-10-19', timezone: 'Asia/Shanghai', signedToday: signed, checkInEnabled: ruleReply.status === 'published', ruleVersion: ruleReply.ruleVersion, checkInReward: ruleReply.checkInReward, balanceState: 'normal' });
|
||||
const api = { ApiRequestError, getSession: () => session, requestJson: async (url, method, body) => {
|
||||
calls.push({ url, method, body: plain(body) });
|
||||
if (url === '/points/rules') return json(ruleReply);
|
||||
if (url === '/points') return json(summary());
|
||||
if (url === '/points/tasks') return json({ serverDate: '2026-10-19', timezone: 'Asia/Shanghai', items: [
|
||||
{ task: 'checkin', status: ruleReply.ruleVersion == null ? 'unavailable' : signed ? 'completed' : ruleReply.status === 'published' ? 'available' : 'unavailable', reward: ruleReply.checkInReward, ruleVersion: ruleReply.ruleVersion },
|
||||
...['profile', 'invitation'].map(task => ({ task, status: 'unavailable', reward: null, ruleVersion: null }))
|
||||
] });
|
||||
if (url === '/points/ledger') return json({ items: [], snapshotId: '0', nextBeforeId: null });
|
||||
if (url === '/points/check-in') {
|
||||
const replayed = signed;
|
||||
if (!signed) { signed = true; ledgerWrites++; }
|
||||
if (loseResponse) { loseResponse = false; throw new Error('Synthetic response lost'); }
|
||||
return json({ ledgerId: '91', delta: 10, balanceAfter: 30, signedDate: body.expectedDate, ruleVersion: body.expectedRuleVersion, replayed });
|
||||
}
|
||||
throw new Error('Unexpected points request: ' + url);
|
||||
} };
|
||||
function transpile(source) {
|
||||
const result = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, reportDiagnostics: true });
|
||||
assert.equal(result.diagnostics.filter(item => item.category === ts.DiagnosticCategory.Error).length, 0);
|
||||
return result.outputText;
|
||||
}
|
||||
const service = {};
|
||||
vm.runInNewContext(transpile(fs.readFileSync(path.join(root, 'services/points.uts'), 'utf8')), {
|
||||
exports: service, UTSJSONObject: JsonObject, Date, Math, Number, Error, require: name => {
|
||||
assert.equal(name, '@/services/api.uts'); return api;
|
||||
}
|
||||
});
|
||||
function page() {
|
||||
let shown;
|
||||
let share;
|
||||
const storage = new Map();
|
||||
const modals = [], toasts = [];
|
||||
const source = fs.readFileSync(path.join(root, 'packages/account/points/points.uvue'), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
|
||||
const exports = {};
|
||||
vm.runInNewContext(transpile(source + '\ntaskDefinitions.forEach(definition => Object.setPrototypeOf(definition, UTSJSONObject.prototype));\nexports.page = { load, checkIn, showRules, pointsNote, pendingRequest, points, errorMessage, taskViews, taskCaption, doTask };'), {
|
||||
exports, UTSJSONObject: JsonObject, Date, Math, Number, Error,
|
||||
ref: value => ({ value }), computed: get => ({ get value() { return get(); } }), watch: () => {},
|
||||
onShow: callback => { shown = callback; }, onHide: () => {}, onUnload: () => {},
|
||||
onShareAppMessage: callback => { share = callback; },
|
||||
uni: { showModal: value => modals.push(value), showToast: value => toasts.push(value), navigateTo: () => {} },
|
||||
require: name => {
|
||||
if (name === '@/services/api.uts') return api;
|
||||
if (name === '@/services/points.uts') return service;
|
||||
if (name === '@/services/storage.uts') return { getUserStorage: key => storage.get(key) ?? null, setUserStorage: (key, value) => storage.set(key, json(value)), removeUserStorage: key => storage.delete(key) };
|
||||
if (name === '@/services/layout.uts') return { usePageLayout: () => ({ contentStyle: () => '', pageStyle: () => '' }) };
|
||||
if (name.endsWith('.uvue')) return {};
|
||||
throw new Error('Unexpected page dependency: ' + name);
|
||||
}
|
||||
});
|
||||
return { ...exports.page, show: shown, share: () => share?.(), storage, modals, toasts };
|
||||
}
|
||||
const flush = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
|
||||
let checks = 0, failures = 0;
|
||||
async function test(name, action) {
|
||||
ruleReply = plain(published); signed = false; loseResponse = false; ledgerWrites = 0; calls = [];
|
||||
try { await action(); checks++; console.log('PASS ' + name); }
|
||||
catch (error) { failures++; console.error('FAIL ' + name + ': ' + error.message); }
|
||||
}
|
||||
(async () => {
|
||||
await test('published phase-one policy is parsed from the server', async () => assert.deepEqual(plain(await service.fetchPointsRules()), published));
|
||||
await test('middle action shares the public home page without issuing points', async () => {
|
||||
const view = page(); view.show(); await flush();
|
||||
const task = view.taskViews.value[1];
|
||||
assert.equal(task.id, 'share'); assert.equal(task.name, '分享');
|
||||
assert.equal(view.taskCaption(task), '分享小程序');
|
||||
view.doTask(task);
|
||||
assert.deepEqual(plain(view.share()), { title: 'AI融资 · 智能融资分析与产品匹配', path: '/pages/home/home' });
|
||||
assert.equal(calls.filter(item => item.method === 'POST').length, 0);
|
||||
assert.equal(ledgerWrites, 0);
|
||||
});
|
||||
for (const [name, value] of Object.entries({ timezone: 'UTC', checkInReward: 0, ruleVersion: 'INVALID', publishedRules: null })) {
|
||||
await test('reject inconsistent rule field ' + name, async () => { ruleReply[name] = value; await assert.rejects(service.fetchPointsRules()); });
|
||||
}
|
||||
for (const [name, value] of Object.entries({ dailyLimit: 2, makeUpAllowed: true, consecutiveBonusEnabled: true, profileRewardEnabled: true, invitationRewardEnabled: true, expiryPolicy: '30-days', levelPolicy: 'upgrade', benefitsPolicy: 'cash' })) {
|
||||
await test('do not advertise unsupported policy ' + name, async () => { ruleReply.publishedRules[name] = value; await assert.rejects(service.fetchPointsRules()); });
|
||||
}
|
||||
await test('draft has no invented reward or policy', async () => { ruleReply = plain(draft); assert.deepEqual(plain(await service.fetchPointsRules()), draft); });
|
||||
await test('draft cannot smuggle a published reward', async () => { ruleReply = { ...plain(draft), checkInReward: 10 }; await assert.rejects(service.fetchPointsRules()); });
|
||||
await test('published rule dialog explains the complete approved policy', async () => {
|
||||
const view = page(); view.show(); await flush(); view.showRules();
|
||||
const content = view.modals.at(-1).content;
|
||||
for (const text of ['10', '北京时间', '0点', '不支持补签', '连续签到', '不过期', '会员等级', '兑换', '提现', 'AI']) assert.ok(content.includes(text), 'Missing policy explanation: ' + text);
|
||||
});
|
||||
await test('reward wording follows the server instead of hardcoding ten', async () => {
|
||||
ruleReply.checkInReward = 7;
|
||||
const view = page(); view.show(); await flush(); view.showRules();
|
||||
assert.match(view.modals.at(-1).content, /7 分/); assert.doesNotMatch(view.modals.at(-1).content, /10 分/);
|
||||
});
|
||||
await test('paused is distinct from unpublished and preserves rule information', async () => {
|
||||
ruleReply.status = 'paused';
|
||||
const view = page(); view.show(); await flush();
|
||||
assert.match(view.pointsNote.value, /暂停/);
|
||||
view.showRules(); assert.match(view.modals.at(-1).content, /暂停/); assert.match(view.modals.at(-1).content, /10 分/);
|
||||
await view.checkIn(); assert.equal(calls.filter(item => item.method === 'POST').length, 0);
|
||||
});
|
||||
await test('draft dialog does not present ten points as an active reward', async () => {
|
||||
ruleReply = plain(draft); const view = page(); view.show(); await flush(); view.showRules();
|
||||
assert.match(view.modals.at(-1).content, /尚未|暂未/); assert.doesNotMatch(view.modals.at(-1).content, /10|不过期/);
|
||||
});
|
||||
await test('malformed policy blocks partial page data and check-in', async () => {
|
||||
ruleReply.publishedRules.expiryPolicy = '30-days';
|
||||
const view = page(); view.show(); await flush();
|
||||
assert.equal(view.points.value, null); assert.notEqual(view.errorMessage.value, '');
|
||||
await view.checkIn(); assert.equal(calls.filter(item => item.method === 'POST').length, 0);
|
||||
});
|
||||
await test('lost response retains the original request and confirms without a second reward', async () => {
|
||||
const view = page(); view.show(); await flush(); loseResponse = true;
|
||||
await view.checkIn(); const original = plain(view.pendingRequest.value);
|
||||
assert.equal(ledgerWrites, 1); assert.equal(view.storage.size, 1);
|
||||
await view.load(); assert.equal(view.points.value.signedToday, true);
|
||||
await view.checkIn(); assert.equal(ledgerWrites, 1); assert.equal(view.pendingRequest.value, null); assert.equal(view.storage.size, 0);
|
||||
const posts = calls.filter(item => item.method === 'POST'); assert.equal(posts.length, 2); assert.deepEqual(posts[0].body, original); assert.deepEqual(posts[1].body, original);
|
||||
});
|
||||
console.log('Points phase one: ' + checks + ' passed, ' + failures + ' failed; networkCalls=0.');
|
||||
if (failures) process.exitCode = 1;
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,211 @@
|
||||
// 执行真实UTS服务及页面逻辑;仅微信授权、HTTP和设备存储采用边界替身。
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require(process.env.AIRZ_TYPESCRIPT_PATH || 'D:/Develop/HBuilderX/plugins/uniapp-uts-v1/node_modules/@dcloudio/uni-uts-v1/lib/typescript/lib/typescript.js');
|
||||
const root = path.resolve(__dirname, '..');
|
||||
class JsonObject {
|
||||
constructor(value = {}) { Object.assign(this, value); }
|
||||
getString(key) { return typeof this[key] === 'string' ? this[key] : null; }
|
||||
getNumber(key) { return typeof this[key] === 'number' ? this[key] : null; }
|
||||
getBoolean(key) { return typeof this[key] === 'boolean' ? this[key] : null; }
|
||||
}
|
||||
const json = value => Array.isArray(value) ? value.map(json) : value && typeof value === 'object' ? new JsonObject(Object.fromEntries(Object.entries(value).map(([k, v]) => [k, json(v)]))) : value;
|
||||
const hash = 'a'.repeat(64);
|
||||
const ticket = 'b'.repeat(64);
|
||||
const terms = { revisionId: '1', code: 'terms', version: '2026.10-v1', scope: 'account-terms', title: '合成用户协议', content: '合成全文', contentHash: hash, effectiveAt: '2026-10-21T00:00:00Z' };
|
||||
const privacy = { ...terms, revisionId: '2', code: 'privacy', scope: 'account-privacy' };
|
||||
const agreements = [terms, privacy].map((item, i) => json({ agreementCode: item.code, agreementVersion: item.version, contentHash: hash, requestId: 'synthetic_consent_' + i }));
|
||||
const authenticated = () => ({ status: 'authenticated', token: 'synthetic-wechat-token', userId: '7', phone: '13800000000', expiresAt: '2099-01-01T00:00:00Z', profile: { nickname: '合成用户', avatarUrl: '' }, consentsConfirmed: true });
|
||||
const pending = () => ({ status: 'bind_required', bindingTicket: ticket, expiresIn: 600 });
|
||||
function preprocess(source, platform) {
|
||||
const stack = [true];
|
||||
return source.split(/\r?\n/).filter(line => {
|
||||
let match = line.match(/^\s*\/\/\s*#(ifdef|ifndef)\s+(.+)$/);
|
||||
if (match) { const found = match[2].split(/\s*\|\|\s*/).includes(platform); stack.push(stack.at(-1) && (match[1] === 'ifdef' ? found : !found)); return false; }
|
||||
if (/^\s*\/\/\s*#endif/.test(line)) { stack.pop(); return false; }
|
||||
return stack.at(-1);
|
||||
}).join('\n');
|
||||
}
|
||||
function environment(platform = 'MP-WEIXIN') {
|
||||
let now = Date.now();
|
||||
class Clock extends Date { static now() { return now; } }
|
||||
const state = { session: null, calls: [], saves: [], redirects: [], authorizationCalls: 0, show: null, hide: null, unload: null };
|
||||
state.reply = url => {
|
||||
if (url === '/agreements/terms') return terms;
|
||||
if (url === '/agreements/privacy') return privacy;
|
||||
if (url === '/auth/wechat-login') return pending();
|
||||
if (url === '/auth/wechat/sms-code' || url === '/auth/sms-code') return { retryAfter: 60, expiresIn: 300 };
|
||||
if (url === '/auth/wechat/bind-phone') return authenticated();
|
||||
throw Error('Unexpected request ' + url);
|
||||
};
|
||||
state.authorize = options => options.success({ code: 'synthetic-wechat-code' });
|
||||
class ApiError extends Error { constructor(message, status, errorCode) { super(message); this.status = status; this.errorCode = errorCode; } }
|
||||
state.ApiError = ApiError;
|
||||
const api = { API_BASE_URL: 'https://synthetic.invalid/api/v1', ApiRequestError: ApiError,
|
||||
getSession: () => state.session,
|
||||
clearSession: () => { state.session = null; },
|
||||
saveSession: (token, phone, userId, expiresAt) => { state.session = json({ token, phone, userId, expiresAt }); state.saves.push(state.session); },
|
||||
requestJson: async (url, method, payload) => { state.calls.push({ url, method, payload: JSON.parse(JSON.stringify(payload)) }); return json(await state.reply(url, method, payload)); }
|
||||
};
|
||||
const watches = new Map();
|
||||
const uni = { login: options => { state.authorizationCalls++; state.authorize(options); }, showToast() {}, showModal() {}, navigateTo() {}, redirectTo: ({ url }) => state.redirects.push(url), setStorageSync() {}, getStorageSync() { return null; } };
|
||||
const modules = {};
|
||||
function load(relative) {
|
||||
if (modules[relative]) return modules[relative];
|
||||
const full = path.join(root, relative);
|
||||
assert.ok(fs.existsSync(full), '微信绑定服务尚未实现:' + relative);
|
||||
const exports = {}; modules[relative] = exports;
|
||||
run(preprocess(fs.readFileSync(full, 'utf8'), platform), relative, exports);
|
||||
return exports;
|
||||
}
|
||||
function dependencies(name, relative) {
|
||||
if (name === '@/services/api.uts' || name === './api.uts') return api;
|
||||
if (name === '@/services/storage.uts') return { setUserStorage() {} };
|
||||
if (name === '@/services/layout.uts') return { usePageLayout: () => ({ pageStyle() {}, contentStyle() {}, statusStyle() {} }) };
|
||||
if (name === '@/services/launch.uts') return { consumeInitialLaunch() {} };
|
||||
if (name.endsWith('.uvue')) return {};
|
||||
return load(name.startsWith('@/') ? name.slice(2) : path.posix.normalize(path.posix.join(path.posix.dirname(relative), name)));
|
||||
}
|
||||
function run(source, relative, exports) {
|
||||
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText;
|
||||
vm.runInNewContext(code, { exports, require: name => dependencies(name, relative), UTSJSONObject: JsonObject, Date: Clock, Error, Math, Number, uni,
|
||||
ref: value => { const item = {}; Object.defineProperty(item, 'value', { get: () => value, set: next => { value = next; watches.get(item)?.(); } }); return item; },
|
||||
computed: fn => ({ get value() { return fn(); } }), watch: (item, fn) => watches.set(item, fn),
|
||||
onLoad() {}, onShow: fn => { state.show = fn; }, onHide: fn => { state.hide = fn; }, onUnload: fn => { state.unload = fn; }, onUnmounted() {}, setInterval: () => 1, clearInterval() {}
|
||||
}, { filename: relative });
|
||||
}
|
||||
state.load = load;
|
||||
state.advance = ms => { now += ms; };
|
||||
state.page = () => {
|
||||
const relative = 'packages/account/login/login.uvue';
|
||||
const source = fs.readFileSync(path.join(root, relative), 'utf8').match(/<script setup lang="uts">([\s\S]*?)<\/script>/)[1];
|
||||
const exports = {};
|
||||
const names = ['login', 'beginWechatLogin', 'cancelWechatBinding', 'resendSms', 'phone', 'password', 'mode', 'smsCode', 'smsSent', 'countdown', 'loading', 'agreed', 'authenticated', 'errorMessage', 'wechatBindingTicket', 'toggleAgreement'];
|
||||
run(preprocess(source, platform) + '\nexports.page={' + names.map(name => name + ':typeof ' + name + '==="undefined"?null:' + name).join(',') + '};', relative, exports);
|
||||
return exports.page;
|
||||
};
|
||||
return state;
|
||||
}
|
||||
let checks = 0;
|
||||
async function test(name, action) { await action(); checks++; console.log('PASS ' + name); }
|
||||
async function ready(state) { const page = state.page(); await state.show(); page.toggleAgreement(); return page; }
|
||||
async function bindPage(state) { const page = await ready(state); await page.beginWechatLogin(); return page; }
|
||||
(async () => {
|
||||
await test('authorization and pending binding create no local login session', async () => {
|
||||
const e = environment(); const service = e.load('packages/account/services/wechat-login.uts');
|
||||
const result = await service.requestWechatLogin('synthetic-code', agreements);
|
||||
assert.equal(result.status, 'bind_required'); assert.equal(e.saves.length, 0);
|
||||
assert.deepEqual(e.calls[0], { url: '/auth/wechat-login', method: 'POST', payload: { platform: 'mp-weixin', code: 'synthetic-code', agreements: JSON.parse(JSON.stringify(agreements)) } });
|
||||
});
|
||||
await test('binding sends its own ticket and SMS endpoint instead of logging in a separate account', async () => {
|
||||
const e = environment(); const service = e.load('packages/account/services/wechat-login.uts');
|
||||
await service.requestWechatBindingCode(ticket, '13800000000');
|
||||
const result = await service.bindWechatPhone(ticket, '13800000000', '123456');
|
||||
assert.deepEqual(e.calls.map(call => call.url), ['/auth/wechat/sms-code', '/auth/wechat/bind-phone']);
|
||||
assert.deepEqual(e.calls[1].payload, { bindingTicket: ticket, phone: '13800000000', code: '123456' });
|
||||
assert.equal(e.saves.length, 0); service.acceptWechatSession(result);
|
||||
assert.equal(e.session.userId, '7'); assert.equal(e.session.phone, '13800000000');
|
||||
});
|
||||
await test('malformed, pending, or unconfirmed results never save a session', async () => {
|
||||
const e = environment(); const s = e.load('packages/account/services/wechat-login.uts');
|
||||
for (const value of [pending(), { ...authenticated(), consentsConfirmed: false }, { ...authenticated(), phone: '' }, { ...authenticated(), userId: '' }, { ...authenticated(), expiresAt: '' }, { ...authenticated(), token: '' }]) assert.throws(() => s.acceptWechatSession(json(value)));
|
||||
assert.equal(e.saves.length, 0);
|
||||
e.reply = () => ({ ...pending(), token: 'must-not-be-issued' });
|
||||
await assert.rejects(s.requestWechatLogin('synthetic-code', agreements));
|
||||
});
|
||||
await test('invalid phone, ticket or code is rejected before sending', async () => {
|
||||
const e = environment(); const s = e.load('packages/account/services/wechat-login.uts');
|
||||
await assert.rejects(s.requestWechatBindingCode('short', '13800000000'));
|
||||
await assert.rejects(s.requestWechatBindingCode(ticket, '1380000'));
|
||||
await assert.rejects(s.bindWechatPhone(ticket, '13800000000', '123'));
|
||||
assert.equal(e.calls.length, 0);
|
||||
});
|
||||
await test('web and native app do not invoke the unconfigured WeChat adapter', async () => {
|
||||
for (const platform of ['WEB', 'APP-ANDROID', 'APP-IOS']) {
|
||||
const e = environment(platform); const s = e.load('packages/account/services/wechat-login.uts');
|
||||
assert.equal(s.wechatLoginSupported(), false);
|
||||
await assert.rejects(s.requestWechatAuthorization()); assert.equal(e.authorizationCalls, 0);
|
||||
}
|
||||
});
|
||||
await test('missing agreements block WeChat authorization even in password mode', async () => {
|
||||
const e = environment(); e.reply = () => { throw new e.ApiError('协议未发布', 404, 'AGREEMENT_NOT_AVAILABLE'); };
|
||||
const page = e.page(); await e.show(); await page.beginWechatLogin();
|
||||
assert.equal(e.authorizationCalls, 0); assert.equal(e.saves.length, 0); assert.ok(page.errorMessage.value.length > 0);
|
||||
});
|
||||
await test('first WeChat authorization shows binding without creating a session', async () => {
|
||||
const e = environment(); const page = await bindPage(e);
|
||||
assert.equal(page.wechatBindingTicket.value, ticket); assert.equal(page.mode.value, 'phone');
|
||||
assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0);
|
||||
const commands = e.calls.find(call => call.url === '/auth/wechat-login').payload.agreements;
|
||||
assert.deepEqual(commands.map(item => item.agreementCode), ['terms', 'privacy']);
|
||||
assert.notEqual(commands[0].requestId, commands[1].requestId);
|
||||
});
|
||||
await test('verified binding saves original account exactly once and returns to the intended page', async () => {
|
||||
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000';
|
||||
await page.login(); assert.equal(page.smsSent.value, true); assert.equal(e.saves.length, 0);
|
||||
page.smsCode.value = '123456'; await page.login();
|
||||
assert.equal(e.session.userId, '7'); assert.equal(e.saves.length, 1); assert.equal(e.redirects.length, 1);
|
||||
assert.equal(e.calls.filter(call => call.url === '/consents').length, 0, 'server-confirmed agreements are not submitted twice');
|
||||
});
|
||||
await test('already bound WeChat can log in without requesting SMS', async () => {
|
||||
const e = environment(); const reply = e.reply; e.reply = url => url === '/auth/wechat-login' ? authenticated() : reply(url);
|
||||
const page = await ready(e); await page.beginWechatLogin();
|
||||
assert.equal(e.saves.length, 1); assert.equal(e.redirects.length, 1);
|
||||
assert.equal(e.calls.filter(call => call.url.includes('sms')).length, 0);
|
||||
});
|
||||
await test('cancelling pending binding returns to SMS login and removes its ticket', async () => {
|
||||
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; page.cancelWechatBinding();
|
||||
assert.equal(page.wechatBindingTicket.value, ''); assert.equal(page.smsCode.value, '');
|
||||
await page.login(); assert.equal(e.calls.at(-1).url, '/auth/sms-code'); assert.equal(e.saves.length, 0);
|
||||
});
|
||||
await test('expired ticket cannot send SMS or complete binding', async () => {
|
||||
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; e.advance(600001);
|
||||
const before = e.calls.length; await page.login();
|
||||
assert.equal(e.calls.length, before); assert.equal(page.wechatBindingTicket.value, ''); assert.match(page.errorMessage.value, /过期/);
|
||||
});
|
||||
await test('WeChat cancellation exposes an error and creates no account session', async () => {
|
||||
const e = environment(); e.authorize = options => options.fail({ errCode: 1310509, errMsg: 'cancel' });
|
||||
const page = await ready(e); await page.beginWechatLogin();
|
||||
assert.equal(e.calls.filter(call => call.url === '/auth/wechat-login').length, 0); assert.equal(e.saves.length, 0); assert.match(page.errorMessage.value, /取消/);
|
||||
});
|
||||
await test('a WeChat response arriving after page hide cannot save or redirect', async () => {
|
||||
const e = environment(); let release; const reply = e.reply;
|
||||
e.reply = url => url === '/auth/wechat-login' ? new Promise(resolve => { release = resolve; }) : reply(url);
|
||||
const page = await ready(e); const work = page.beginWechatLogin(); for (let i = 0; i < 12; i++) await Promise.resolve();
|
||||
assert.equal(typeof release, 'function'); e.hide(); release(authenticated()); await work;
|
||||
assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0); assert.equal(page.loading.value, false);
|
||||
});
|
||||
await test('a binding response cannot replace another account that logged in meanwhile', async () => {
|
||||
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
|
||||
let release; e.reply = () => new Promise(resolve => { release = resolve; });
|
||||
const work = page.login(); for (let i = 0; i < 8; i++) await Promise.resolve();
|
||||
e.session = json({ token: 'new-account-token', userId: '99' }); release(authenticated()); await work;
|
||||
assert.equal(e.session.userId, '99'); assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0);
|
||||
});
|
||||
await test('double clicking sends only one binding command while the first is pending', async () => {
|
||||
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
|
||||
let release; e.reply = () => new Promise(resolve => { release = resolve; });
|
||||
const first = page.login(); await page.login(); release(authenticated()); await first;
|
||||
assert.equal(e.calls.filter(call => call.url === '/auth/wechat/bind-phone').length, 1); assert.equal(e.saves.length, 1);
|
||||
});
|
||||
await test('binding conflict keeps the account logged out and does not automatically retry', async () => {
|
||||
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
|
||||
e.reply = () => { throw new e.ApiError('绑定存在冲突,请使用原账户登录', 409, 'WECHAT_BINDING_CONFLICT'); };
|
||||
await page.login(); assert.equal(e.saves.length, 0); assert.match(page.errorMessage.value, /冲突/);
|
||||
assert.equal(e.calls.filter(call => call.url === '/auth/wechat/bind-phone').length, 1);
|
||||
});
|
||||
await test('an updated agreement cancels pending binding and requires a fresh checkbox', async () => {
|
||||
const e = environment(); const page = await bindPage(e); page.phone.value = '13800000000'; await page.login(); page.smsCode.value = '123456';
|
||||
const reply = e.reply; e.reply = url => url === '/auth/wechat/bind-phone' ? Promise.reject(new e.ApiError('协议已更新', 409, 'AGREEMENT_CHANGED')) : reply(url);
|
||||
await page.login(); assert.equal(page.wechatBindingTicket.value, ''); assert.equal(page.agreed.value, false); assert.equal(e.saves.length, 0);
|
||||
});
|
||||
for (const kind of ['sms', 'password']) await test(kind + ' login also ignores a response arriving after leaving the page', async () => {
|
||||
const e = environment(); const page = await ready(e); page.phone.value = '13800000000'; page.password.value = 'SyntheticPass9'; page.mode.value = kind === 'sms' ? 'phone' : 'password';
|
||||
if (kind === 'sms') { await page.login(); page.smsCode.value = '123456'; }
|
||||
let release; e.reply = () => new Promise(resolve => { release = resolve; }); const work = page.login(); e.hide();
|
||||
release(authenticated()); await work; assert.equal(e.saves.length, 0); assert.equal(e.redirects.length, 0);
|
||||
});
|
||||
console.log(JSON.stringify({ checks, passed: true }));
|
||||
})().catch(error => { console.error(error.stack || error); process.exitCode = 1; });
|
||||
Reference in New Issue
Block a user