Просмотр исходного кода

fix: restore legacy feedback submissions

彭峰 1 месяц назад
Родитель
Сommit
550f1b7ca2

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

@@ -53,6 +53,7 @@
 - `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` 并标记规则不可用。
+- `guestbook_add`:兼容旧页的 `model` JSON(`UserID/Title/TContent/Cateid`)和 Angular 表单的扁平字段,限制 800 字并阻止普通用户冒用他人 `UserID`。
 
 迁移数据没有保存复习收入金额,也没有提供可验证的计价公式,因此 `e_get_21list_tj` 改为显式 501;收入列表仍返回复习事实,但用 `incomeRuleUnavailable=true` 标记金额不可恢复,不伪造收入。
 
@@ -102,7 +103,7 @@ Angular H5 已启用混合迁移路由:
 
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
 - `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`、`stu_record_update_v2`、`user_get`、`user_list`、`user_dept`、`user_point_list` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
+- 持有真实 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 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
 - 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
 - `product_list/product_get/product_stock_list` 已从“部分映射”纠正为显式 501:当前 `Product` 只是附表,不能满足旧商品或库存流水契约。

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

@@ -382,6 +382,9 @@ function legacyPointRow(source) { const row = safe(source); row.ExpHisID = first
 async function pointHistoryPage(input,current) {
   const stype = number(input.stype); const className = POINT_HISTORY_CLASSES[stype]; if (!className) fail(400,'虚拟币类型必须为 1-6'); const uid = await authorizeRequestedUser(current,input.uid); const paging = pageInput(input); const values = [DEFAULT_COMPANY_ID,String(uid)]; const where = '"company"=$1 AND CAST("userId" AS text)=$2'; const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "' + className + '" WHERE ' + where,values); const rows = await Psql.query('SELECT * FROM "' + className + '" WHERE ' + where + ' ORDER BY COALESCE("hisTime","updatedAt","createdAt") DESC LIMIT $3 OFFSET $4',values.concat([paging.size,(paging.index - 1) * paging.size])); const total = number(countRow.total); return { rows:rows.filter(isVisible).map(legacyPointRow),page:{ itemCount:total,pageCount:Math.ceil(total / paging.size),pageIndex:paging.index,pageSize:paging.size },addon:stype === 1 ? { purse_fee:null,purseFeeRuleUnavailable:true } : {} };
 }
+async function createGuestbook(input,current) {
+  const model = parseObject(input.model,'留言'); const uid = ownLegacyId(current); if (!uid) fail(400,'当前用户缺少旧系统用户 ID'); const requestedUid = number(firstValue(model.UserID,model.userid,input.UserID,input.userid,uid)); if (requestedUid !== uid && !isAdminUser(current)) fail(403,'不允许代替其他用户提交留言'); const name = cleanText(firstValue(input.name,model.Name,model.name,''),80); const title = cleanText(firstValue(model.Title,model.title,input.Title,input.title,name ? name + ' 反馈的意见' : '用户反馈'),200); const content = String(firstValue(model.TContent,model.tcontent,input.TContent,input.tcontent,input.content,'') || '').trim(); if (!content) fail(400,'请填写反馈内容'); if (content.length > 800) fail(400,'反馈内容不得超过 800 字'); const gid = Math.floor(100000000000000 + Math.random() * 800000000000000); const item = new Parse.Object('Guestbook'); item.set('company',current.get('company')); item.set('sourceKey','cloud:guestbook_add:' + uid + ':' + gid); item.set('gid',gid); item.set('gdate',new Date()); item.set('userid',requestedUid); item.set('title',title); item.set('tcontent',content); item.set('cateid',number(firstValue(model.Cateid,model.cateid,input.Cateid,input.cateid),22)); item.set('parentid',0); item.set('status',0); await item.save(null,{useMasterKey:true}); const result = safe(item); return { ...result,GID:gid,UserID:requestedUid,Title:title,TContent:content,Cateid:number(result.cateid),Status:0 };
+}
 async function authorizeRequestedUser(current, requestedId) {
   const targetId = number(requestedId || ownLegacyId(current)); if (!targetId) fail(400, '缺少用户 ID'); if (isAdminUser(current) || targetId === ownLegacyId(current)) return targetId;
   const fields = await fieldsOf('_User'); const target = await findByLegacyId('_User', fields, targetId, ['legacyUserId','userid','num']); const agent = target && target.get('agent'); if (!target || !agent || agent.id !== current.id) fail(403, '无权查看该学员的业务记录'); return targetId;
@@ -535,7 +538,7 @@ async function handler(request, response) {
     if (action === 'e_order_detail') { const found = await orderDetailRow(input.id, current); return found ? response.json(envelope([found])) : response.status(404).json(reject('预约记录不存在')); }
     if (action === 'e_record_detail') { const found = await recordDetailData(input, current); return found ? response.json(envelope(found.words, found.addon)) : response.status(404).json(reject('学习记录不存在')); }
     if (action === 'user_point_list') { const result = await pointHistoryPage(input,current); return response.json(envelope(result.rows,result.addon,result.page)); }
-    if (action === 'guestbook_add') { const fields = await fieldsOf('Guestbook'); const item = new Parse.Object('Guestbook'); item.set('company',companyPointer()); if (fields.userid) item.set('userid',legacyId); if (fields.title) item.set('title',String(input.title || '用户反馈')); if (fields.tcontent) item.set('tcontent',String(input.content || input.tcontent || '')); if (fields.status) item.set('status',0); await item.save(null,{useMasterKey:true}); return response.json(envelope(safe(item))); }
+    if (action === 'guestbook_add') return response.json(envelope(await createGuestbook(input,current)));
     return response.status(501).json(reject('migration_blocked: 尚未完成动作映射 ' + action));
   } catch (error) { const status = Number(error.status || (error.code === 101 ? 404 : 500)); const message = status === 501 ? 'migration_blocked: ' + error.message : status >= 500 ? '云函数执行失败' : error.message; return response.status(status).json(reject(message)); }
 }

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

@@ -63,6 +63,7 @@ let coachId = '';
 let coachLegacyId = 0;
 let temporaryAppointmentId = '';
 let temporaryAppointmentObjectId = '';
+let temporaryGuestbookId = '';
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -121,6 +122,11 @@ try {
     method: 'POST',
     body: JSON.stringify({ username: coachUsername, password: coachPassword }),
   });
+  const feedbackModel = { UserID: memberLegacyId, Title: '冒烟学员 反馈的意见', TContent: '2026-08-19 请假一天', Cateid: 22 };
+  const feedback = await callLegacyFunction(memberLogin.sessionToken, { action: 'guestbook_add', model: JSON.stringify(feedbackModel) });
+  temporaryGuestbookId = String(feedback.result?.objectId || '');
+  if (!temporaryGuestbookId || Number(feedback.result?.UserID) !== memberLegacyId || feedback.result?.Title !== feedbackModel.Title || feedback.result?.TContent !== feedbackModel.TContent || Number(feedback.result?.Cateid) !== 22) throw new Error(`旧版 model 留言写入异常:${JSON.stringify(feedback)}`);
+  await callLegacyFunction(memberLogin.sessionToken, { action: 'guestbook_add', model: JSON.stringify({ ...feedbackModel, UserID: coachLegacyId }) }, 403);
   const createdAppointment = await callLegacyFunction(login.sessionToken, { action: 'content_add', content: JSON.stringify({ ModelID: 54, nodeId: 29, inputer: coachUsername, status: 99, title: memberUsername }), addon: JSON.stringify({ szyh: memberLegacyId, kcid: 16, dslx: 1, dszt: 0, pl: coachLegacyId, fxpl: coachLegacyId, sdsd: '19:00' }) });
   temporaryAppointmentId = String(createdAppointment.result);
   const appointmentSourceKey = `cloud:content_add:${memberLegacyId}:${temporaryAppointmentId}`;
@@ -299,6 +305,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 (temporaryGuestbookId) await jsonRequest(`${PARSE_URL}/classes/Guestbook/${temporaryGuestbookId}`, { method: 'DELETE' }, true).catch((error) => {
+    console.error(`临时留言清理失败:${error.message}`);
+    process.exitCode = 1;
+  });
   for (const lessonId of temporaryLessonIds) await jsonRequest(`${PARSE_URL}/classes/LessonRecord/${lessonId}`, { method: 'DELETE' }, true).catch((error) => {
     console.error(`临时陪练课次清理失败:${error.message}`);
     process.exitCode = 1;

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

@@ -102,6 +102,15 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: [{ ExpHisID: 1, score: 2 }] });
   });
 
+  it('routes legacy model-based feedback submissions with a Parse session', () => {
+    sessionToken = 'r:session-token';
+    const model = JSON.stringify({ UserID: 58, Title: '小树 反馈的意见', TContent: '请假一天', Cateid: 22 });
+    api.post('guestbook_add', { model }).subscribe();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'guestbook_add', model } });
+    request.flush({ retcode: 0, result: { GID: 1, TContent: '请假一天' } });
+  });
+
   it('routes learning word state writes with a Parse session', () => {
     sessionToken = 'r:session-token';
     api.post('e_add_words', { uid: 58, wordsId: '2505,2503', kcid: 16, ifnew: 1 }).subscribe();

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

@@ -23,6 +23,7 @@ const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
   'e_record_detail',
   'e_user_list',
   'e_words_list',
+  'guestbook_add',
   'stu_record_update_v2',
   'user_dept',
   'user_get',