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