소스 검색

fix: complete classic user registration

彭峰 1 개월 전
부모
커밋
b2648fb396

+ 2 - 1
docs/migration/wxapp-cloud-action-matrix.md

@@ -50,6 +50,7 @@
 - `e_order_update_v2`:恢复 `0→10→11→20→30` 角色状态机。状态 11 以单条 PostgreSQL CTE 原子写入课时账本并更新 `_User.legacyUserData`:类型 1/2/3/4 分别扣 `Purse/SilverCoin/UserExp/UserPoint`,类型 1/2 同步扣 0.5/1 `UserPoint`;账本 `sourceKey` 作为幂等声明。随后按学习记录 `fxrl` 补齐最多 15 组 `MemoryPracticeRecord + CommonModel`,中断重试会修复缺失主记录,当前调用失败则补偿余额、账本及本次新增复习记录。
 - `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
 - `user_get`:从 `_User.legacyUserData` 恢复 `VIP/ParentUserID/GroupID/RegTime` 与余额字段,返回旧页面同时使用的 camelCase/PascalCase 别名;不返回 `UserPwd`、支付密码、哈希或会话字段。
+- `user_register`:恢复旧页 6–18 位密码契约,在公司范围内原子分配 `legacyUserId`,写入默认 `GroupID=1`、邀请人 `ParentUserID` 及六类账户初始值,返回可立即使用的 Parse `sessionToken`。
 - `user_list`、`user_dept`:按迁移保留的 `legacyUserData.ParentUserID` 重建直属团队分页和 `v0-v5` 会员等级统计,并只允许本人纵向团队链或管理员查看。
 - `e_user_list`:按旧源码的 Model 54、`status=99`、`CourseAppointment.pl` 分组预约学员,不再依赖目标库中仅有 2 条的通用 `agent` 指针。
 - `user_point_list`:按旧接口 `stype=1..6` 分别查询 `UserExpDomP/UserSIcon/UserExpHis/UserUserPoint/UserDummyPoint/UserCredit`,恢复 `ExpHisID/HisTime/Detail` 别名;迁移资料没有提现费率,余额日志的 `purse_fee` 明确返回 `null` 并标记规则不可用。
@@ -101,7 +102,7 @@
 
 Angular H5 已启用混合迁移路由:
 
-- `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
+- `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。`user_info_name` 查重和 `user_register` 经典注册已直接切换云函数。
 - `app_update`、`node_list`、`node_get` 已切换至云函数。
 - 持有真实 Parse 会话时,`content_add`、`content_add_zt`、`content_update`、`e_add_words`、`e_ck_list`、`e_order_detail`、`e_order_tongji`、`e_order_update_v2`、`e_record_detail`、`e_user_list`、`e_words_list`、`e_get_21list`、`guestbook_add`、`stu_record_update_v2`、`user_get`、`user_list`、`user_dept`、`user_point_list` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
 - 不含 addon 条件的 `content_list` 和 Model 52 公开词库已切换;已持有 Parse 会话的用户会把字段白名单内的 Model 53/54/56/58/59/60/61 addon 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。

+ 6 - 4
scripts/deploy-admin-functions.mjs

@@ -280,6 +280,11 @@ function legacyUser(current, withToken = false) {
 function legacyUserAddon(current) {
   const data = legacyData(current); const state = objectValue(current,'isDisabled') === true ? 0 : number(firstValue(data.State,1),1); const result = { vip:number(data.VIP), state, State:state, regTime:firstValue(data.RegTime,objectValue(current,'createdAt')), purse:number(data.Purse), silverCoin:number(data.SilverCoin), userExp:number(data.UserExp), userPoint:number(data.UserPoint), boffExp:number(data.boffExp) }; result.VIP = result.vip; result.Purse = result.purse; result.SilverCoin = result.silverCoin; result.UserExp = result.userExp; result.UserPoint = result.userPoint; return result;
 }
+async function registerUser(input) {
+  const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username) fail(400,'账号不能为空'); if (password.length < 6 || password.length > 18) fail(400,'密码长度应为 6 至 18 位'); const inviteCode = String(input.code || input.inviCode || '').trim(); let parentId = 0; if (inviteCode) { const rows = await Psql.query('SELECT COALESCE("legacyUserId",("legacyUserData"->>\'UserID\')::numeric) AS id FROM "_User" WHERE "company"=$1 AND "username"=$2 AND ("isDeleted" IS NULL OR "isDeleted"=FALSE) LIMIT 1',[DEFAULT_COMPANY_ID,inviteCode]); parentId = number(rows[0] && rows[0].id); if (!parentId) fail(400,'邀请码不存在'); }
+  const company = await new Parse.Query('Company').get(DEFAULT_COMPANY_ID,{useMasterKey:true}); const created = new Parse.User(); created.setUsername(username); created.setPassword(password); created.set('company',company); created.set('type','user'); created.set('isDisabled',false); created.set('legacyGroupId',1); created.set('nickname',username); if (/^1\d{10}$/.test(username)) created.set('mobile',username); try { await created.signUp(); } catch (error) { if (number(error && error.code) === 202) fail(409,'此用户名已被使用'); throw error; }
+  const sessionToken = created.getSessionToken(); try { const regTime = new Date().toISOString(); const data = { UserID:0,UserName:username,HoneyName:username,GroupID:1,ParentUserID:parentId,VIP:0,RegTime:regTime,Purse:0,SilverCoin:0,UserExp:0,UserPoint:0,boffExp:0,State:1 }; const rows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-legacy-user-id\'))), next_id AS MATERIALIZED (SELECT COALESCE(MAX("legacyUserId"),0)+1 AS id FROM "_User",lock_row WHERE "company"=$1 AND COALESCE("legacyUserId",0)>0) UPDATE "_User" SET "legacyUserId"=next_id.id,"legacyGroupId"=1,"legacyUserData"=jsonb_set($3::jsonb,\'{UserID}\',to_jsonb(next_id.id),true),"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id',[DEFAULT_COMPANY_ID,created.id,JSON.stringify(data)]); const userId = number(rows[0] && rows[0].id); if (!userId) throw new Error('无法分配旧系统用户 ID'); await created.fetch({useMasterKey:true}); const result = legacyUser(created); result.sessionToken = sessionToken; return { result,addon:{ State:1,state:1,parentUserId:parentId } }; } catch (error) { await created.destroy({useMasterKey:true}).catch(() => undefined); throw error; }
+}
 function legacyUserRow(source) {
   const data = legacyData(source); const userId = number(firstValue(source.legacyUserId,source.userid,source.num,data.UserID)); const username = String(firstValue(source.username,data.UserName,'') || ''); const honeyName = String(firstValue(source.nickname,source.nickName,source.realName,data.HoneyName,username) || ''); const groupId = number(firstValue(source.legacyGroupId,data.GroupID)); const parentId = number(firstValue(data.ParentUserID,source.puid)); const avatar = String(firstValue(source.avatar,data.salt,'') || ''); const teamSize = number(firstValue(source.__teamSize,source.TeamSize)); const vip = number(firstValue(data.VIP,source.vip)); const regTime = firstValue(data.RegTime,source.createdAt); const row = { objectId:String(source.objectId || ''), userId, userName:username, honeyName, userFace:avatar, mobile:String(firstValue(source.mobile,source.phone,'') || ''), groupId, puid:parentId, vip, regTime, teamSize, email:String(firstValue(source.email,data.Email,'') || ''), realName:String(firstValue(source.realName,data.TrueName,'') || '') };
   return { ...row, UserID:userId, UserName:username, HoneyName:honeyName, UserFace:avatar, GroupID:groupId, ParentUserID:parentId, VIP:vip, RegTime:regTime, TeamSize:teamSize, Email:row.email, TrueName:row.realName, UserExp:number(data.UserExp), boffExp:number(data.boffExp), salt:avatar };
@@ -507,10 +512,7 @@ async function handler(request, response) {
       const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username || !password) return response.status(400).json(reject('请输入账号和密码'));
       try { const loggedIn = await Parse.User.logIn(username, password); if (loggedIn.get('passwordResetRequired') === true) return response.status(403).json(reject('旧系统账号必须先重置 Parse 密码')); return response.json(envelope(legacyUser(loggedIn, true), { State: loggedIn.get('isDisabled') === true ? 0 : 1 })); } catch (_) { return response.status(401).json(reject('账号或密码错误')); }
     }
-    if (action === 'user_register') {
-      const username = String(input.name || '').trim(); const password = String(input.passwd || ''); if (!username || password.length < 8) return response.status(400).json(reject('账号不能为空,密码至少 8 位'));
-      const created = new Parse.User(); created.setUsername(username); created.setPassword(password); created.set('company', companyPointer()); created.set('type','user'); created.set('isDisabled', false); await created.signUp(); return response.json(envelope(legacyUser(created, true), { State: 1 }));
-    }
+    if (action === 'user_register') { const registered = await registerUser(input); return response.json(envelope(registered.result,registered.addon)); }
     if (action === 'user_info_name') { const username = String(input.uname || input.name || '').trim(); if (!username) return response.status(400).json(reject('缺少用户名')); const rows = await Psql.query('SELECT "objectId" FROM "_User" WHERE "company" = $1 AND "username" = $2 LIMIT 1', [DEFAULT_COMPANY_ID, username]); const found = rows[0]; return found ? response.json(envelope({ objectId: found.objectId })) : response.status(404).json(reject('用户不存在')); }
     const publicRead = PUBLIC_READ.has(action) && !contentQueryRequiresAuth(action, input); const current = await currentUser(request, !publicRead); if (contentQueryRequiresAuth(action, input) && current) await authorizeContentAccess(current, input);
     if (action === 'user_get') { const requestedId = number(input.uid || ownLegacyId(current)); let target = current; if (requestedId && requestedId !== ownLegacyId(current)) { await authorizeTeamUser(current,requestedId); target = await legacyUserObject(requestedId); if (!target) return response.status(404).json(reject('用户不存在')); } return response.json(envelope(legacyUser(target),legacyUserAddon(target))); }

+ 17 - 0
scripts/smoke-admin-functions.mjs

@@ -56,6 +56,7 @@ async function sourceActions() {
 }
 
 let userId = '';
+let registeredUserId = '';
 let memberId = '';
 let memberLegacyId = 0;
 let teamChildId = '';
@@ -85,6 +86,18 @@ try {
   });
   if (!login.sessionToken) throw new Error('临时管理员登录未返回 sessionToken');
 
+  const registeredUsername = `codex_register_smoke_${Date.now()}`;
+  const registered = await callLegacyFunction('', { action: 'user_register', name: registeredUsername, passwd: 'Ab1234' });
+  registeredUserId = String(registered.result?.objectId || '');
+  const registeredLegacyId = Number(registered.result?.userId);
+  if (!registeredUserId || !registeredLegacyId || !registered.result?.sessionToken || Number(registered.result?.groupId) !== 1 || Number(registered.addon?.parentUserId) !== 0) throw new Error(`新用户注册基础字段异常:${JSON.stringify(registered)}`);
+  const registeredInfo = await callLegacyFunction(registered.result.sessionToken, { action: 'user_get', uid: registeredLegacyId });
+  if (Number(registeredInfo.result?.userId) !== registeredLegacyId || Number(registeredInfo.result?.groupId) !== 1 || Number(registeredInfo.addon?.Purse) !== 0 || Number(registeredInfo.addon?.UserPoint) !== 0) throw new Error('新用户注册后自查异常');
+  const registeredByName = await callLegacyFunction('', { action: 'user_info_name', uname: registeredUsername });
+  if (registeredByName.result?.objectId !== registeredUserId) throw new Error('新用户用户名查重异常');
+  await jsonRequest(`${PARSE_URL}/users/${registeredUserId}`, { method: 'DELETE' }, true);
+  registeredUserId = '';
+
   const memberUsername = `codex_member_smoke_${Date.now()}`;
   const memberPassword = randomBytes(24).toString('base64url');
   memberLegacyId = 900000000 + Math.floor(Date.now() / 1000) % 90000000;
@@ -305,6 +318,10 @@ try {
 
   console.log('Cloud smoke passed: admin auth/tenant/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
+  if (registeredUserId) await jsonRequest(`${PARSE_URL}/users/${registeredUserId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时注册用户清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
   if (temporaryGuestbookId) await jsonRequest(`${PARSE_URL}/classes/Guestbook/${temporaryGuestbookId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时留言清理失败:${error.message}`);
     process.exitCode = 1;

+ 12 - 0
src/app/core/api.service.spec.ts

@@ -198,4 +198,16 @@ describe('ApiService', () => {
     http.expectNone((candidate) => candidate.url.includes(API_CONFIG.endpoint));
     expect(actual).toEqual({ userId: 42, sessionToken: 'r:token' });
   });
+
+  it('routes account availability checks and classic registration to the cloud', () => {
+    api.post('user_info_name', { uname: '13800138000' }).subscribe({ error: () => undefined });
+    const lookup = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(lookup.request.body).toEqual({ params: { action: 'user_info_name', uname: '13800138000' } });
+    lookup.flush({ retcode: -1, retmsg: '用户不存在', result: null }, { status: 404, statusText: 'Not Found' });
+
+    api.post('user_register', { name: '13800138000', passwd: 'Ab1234', code: '' }).subscribe();
+    const register = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(register.request.body).toEqual({ params: { action: 'user_register', name: '13800138000', passwd: 'Ab1234', code: '' } });
+    register.flush({ retcode: 0, result: { userId: 42, sessionToken: 'r:token' } });
+  });
 });

+ 2 - 0
src/app/core/cloud-action-migration.ts

@@ -7,7 +7,9 @@ export const H5_CLOUD_ROUTED_ACTIONS = new Set([
   'app_update',
   'node_get',
   'node_list',
+  'user_info_name',
   'user_login_passwd',
+  'user_register',
 ]);
 
 const SESSION_CLOUD_ROUTED_ACTIONS = new Set([