feat: 完善登录流程、UI适配及协议文档更新

本次提交包含以下修改:
1. 新增微信登录相关服务及页面逻辑,实现首次微信绑定手机号流程
2. 优化产品匹配卡片 UI,移除不必要状态显示
3. 调整首页 scroll-view 属性及 banner 背景样式
4. 完善积分规则校验逻辑,增加时区及策略格式验证
5. 更新协议草案文档,补充资料清单及功能说明
6. 修复分析结果路由路径,适配新的匹配结果页面
7. 调整品牌 Logo 安卓端字体对齐样式
This commit is contained in:
hujun
2026-10-22 11:24:15 +08:00
parent e5406c23a6
commit 2c9d3feab5
36 changed files with 1246 additions and 212 deletions
+5 -3
View File
@@ -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');
+78
View File
@@ -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; });
+143
View File
@@ -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; });
+211
View File
@@ -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; });