فهرست منبع

fix(admin): unset optional blank member email on profile save

彭峰 5 ساعت پیش
والد
کامیت
36b193bee3

+ 58 - 0
docs/qa/member-profile-empty-email-2026-11-15.json

@@ -0,0 +1,58 @@
+{
+  "state": "local_fix_not_deployed",
+  "targetStudentId": 1850,
+  "cause": "Blank optional email serialized as empty unique email key, conflicting with existing blank value.",
+  "productionEvidence": {
+    "blankEmailOwners": 1,
+    "uniqueEmailIndexes": [
+      "_User_unique_email",
+      "unique_email"
+    ],
+    "originalSaveProbe": {
+      "stage": "profile-preflight",
+      "errorCode": "WRITE_BLOCKED",
+      "errorMessage": "WRITE_BLOCKED",
+      "probe": {
+        "blankEmail": true,
+        "gender": "女",
+        "grade": "赣州初二",
+        "validationError": null
+      },
+      "allWritesBlocked": true
+    },
+    "candidateSaveProbe": {
+      "stage": "profile-preflight",
+      "errorCode": "WRITE_BLOCKED",
+      "errorMessage": "WRITE_BLOCKED",
+      "probe": {
+        "blankEmail": false,
+        "emailMutation": {
+          "__op": "Delete"
+        },
+        "gender": "女",
+        "grade": "赣州初二",
+        "validationError": null
+      },
+      "allWritesBlocked": true
+    },
+    "finalReadback": {
+      "profileUnchanged": true,
+      "legacyBalancesUnchanged": true,
+      "productionGatewayUnchanged": true,
+      "genderSaved": false,
+      "gradeSaved": false,
+      "updatedAt": "2026-11-15T01:06:16.184Z"
+    }
+  },
+  "tests": {
+    "profileRegression": 3,
+    "fullServer": 594,
+    "failed": 0,
+    "cloudDefinitions": 11
+  },
+  "limits": [
+    "No actual production profile save was submitted.",
+    "Original failing-request SQL log was not captured; attribution is from payload/constraint evidence and isolated reproduction.",
+    "Parse persistence in the isolated test uses an adapter stub with real PGlite UPDATE and unique index."
+  ]
+}

+ 30 - 0
docs/qa/member-profile-empty-email-2026-11-15.md

@@ -0,0 +1,30 @@
+# 会员资料保存失败:空邮箱唯一键冲突
+
+2026-11-15,用户提供熊梓含初中(ID1850)的会员详情截图:修改性别、年级后保存提示“运营云函数执行失败”。本次查明原因并完成本地修复,未部署,未提交正式资料保存。
+
+## 证据链
+
+1. 正式账号性别、年级均未保存,updatedAt保持2026-11-15 09:06:16(北京时间)。资料审计没有本次成功保存记录。
+2. 当前网关 `updateMemberProfile` 即使邮箱未填写,也无条件 `target.set('email', data.email)`,实际待写值为 `''`。
+3. 真实库有 `_User_unique_email` 和 `unique_email` 两个email唯一索引;已有另一个账号的email为 `''`,目标账号email未设置。空字符串参与唯一约束,而多个NULL允许共存。
+4. 在授权且限时的临时诊断函数中,执行实际前置鉴权、字段校验和重复手机号检查,到该目标的save边界立即阻断写入:原代码待写邮箱确为 `''`,性别/年级合法,SDK自身validate无错误。没有调用正式用户save。
+5. 用实际业务函数、合成账号和相同PGlite唯一索引执行真实UPDATE,旧代码两项用例触发 `duplicate key value violates unique constraint "_User_unique_email"`,无效邮箱校验用例正常。这与截图的后台500兜底提示相符。原请求完整异常日志未取得,不能宣称捕获了那一笔请求的原始SQL错误。
+
+因此失败不是性别或“赣州初二”本身的问题,而是可选邮箱被错误写成了空的唯一键。数据库拒绝了整次保存,界面又用通用500文案遮住了原因。
+
+## 本地修复
+
+仅修改 `updateMemberProfile`:有邮箱时继续保存并执行既有格式校验;空白邮箱通过完整Parse SDK的unset清除,落为未设置值,不再写空字符串。legacyUserData.Email保留既有空字符串表示,此JSON字段没有顶层email唯一约束。
+
+没有删除或修改那个已占用空邮箱的其他账号,没有清理用户表,没有修改唯一索引、权限、余额或收费规则。当前网关兼容层使用完整SDK,支持unset。
+
+使用同一阻断式SDK探针检查候选:待保存email操作为 `{"__op":"Delete"}`,性别/年级校验通过,所有写入仍被阻断。它验证真实SDK序列化与前置流程,不等于完成正式HTTP保存或线上验收。
+
+## 检查与交付边界
+
+- 新增3项回归:空邮箱不冲突且性别/年级可保存、有效邮箱填写及主动清空、无效邮箱在保存/审计前拒绝。先2项失败/1项通过,修复后3/3通过;包含4个余额字段保持一致。
+- 独立审查3/3通过;完整服务端回归及云函数定义检查结果见同名JSON。
+- 最终读取正式账号完整对象与检查前完全一致,生产管理网关源码哈希也未改变。性别、年级仍未保存,课时余额未动。
+- 所有临时诊断函数执行后已删除。文件/日志、真实账号及源码快照仅保存在 `/Users/mac/.local/state/xiaoshu/qa/2026-11-15-profile-save/`,不进入Git。
+
+**状态:原因已定位,本地修复已准备;尚未上线,也未代用户提交女/赣州初二资料。**

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
scripts/deploy-admin-functions.mjs


+ 12 - 0
scripts/tests/member-profile-save.test.mjs

@@ -0,0 +1,12 @@
+import {test} from 'node:test';import assert from 'node:assert/strict';import fs from 'node:fs';import vm from 'node:vm';import {PGlite} from '@electric-sql/pglite';
+const source=fs.readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');const extract=name=>{const a=source.indexOf('function '+name+'('),start=source.slice(a-6,a)==='async '?a-6:a;assert.ok(a>=0);return source.slice(start,source.indexOf('\n',a));};
+async function fixture(run){const db=new PGlite();try{await db.exec(`CREATE TABLE "_User"("objectId" text PRIMARY KEY,company text,mobile text,phone text,email text,"realName" text,nickname text,gender text,grade text,"registeredAt" timestamptz,"legacyUserData" jsonb);CREATE UNIQUE INDEX "_User_unique_email" ON "_User"(email);INSERT INTO "_User" VALUES('member','co','13800000000',NULL,NULL,'本地学员','本地学员',NULL,NULL,now(),'{"UserPoint":12,"SilverCoin":12,"Purse":0,"UserExp":0}');INSERT INTO "_User"("objectId",company,email) VALUES('blank-owner','co','');`);
+ const before=(await db.query('SELECT * FROM "_User" WHERE "objectId"=\'member\'')).rows[0],values={...before},audit=[];let calls=0;
+ const target={id:'member',updatedAt:new Date('2026-11-15T00:00:00Z'),get:k=>k==='company'?{id:'co'}:values[k],set(k,v){values[k]=v;},unset(k){delete values[k];},toJSON:()=>({...values}),async save(){calls++;await db.query('UPDATE "_User" SET email=$1,gender=$2,grade=$3,"realName"=$4,nickname=$5,mobile=$6,"legacyUserData"=$7 WHERE "objectId"=\'member\'',[values.email??null,values.gender,values.grade,values.realName,values.nickname,values.mobile,JSON.stringify(values.legacyUserData)]);}};
+ const ctx=vm.createContext({Date,text:v=>String(v??'').trim(),number:(v,d=0)=>Number.isFinite(Number(v))?Number(v):d,fail:(status,message)=>{const e=Error(message);e.status=status;throw e;},requireRole(){},reasonOf:()=> '完善',memberTarget:async()=>target,assertVersion(){},displayName:u=>u.realName,mobileOf:u=>u.mobile,maskMobile:()=> '138****0000',userByLegacyId:async()=>({}),pointerId:p=>p?.id,dateIso:d=>d.toISOString(),audit:async(...a)=>audit.push(a),Psql:{oneOrNone:async(s,p)=>(await db.query(s,p)).rows[0]}});
+ vm.runInContext(extract('memberProfilePayload')+'\n'+extract('updateMemberProfile'),ctx);
+ const save=email=>ctx.updateMemberProfile({company:{id:'co'}},{targetId:'member',reason:'完善'},{displayName:'本地学员',nickname:'本地学员',mobile:'13800000000',email,gender:'女',grade:'赣州初二',parentUserId:73});await run({db,before,save,audit,get calls(){return calls;}});
+ }finally{await db.close();}}
+test('blank optional email does not collide with another blank email while saving gender/grade and retaining all credit values',()=>fixture(async f=>{await f.save('');const after=(await f.db.query('SELECT * FROM "_User" WHERE "objectId"=\'member\'')).rows[0];assert.equal(after.email,null);assert.equal(after.gender,'女');assert.equal(after.grade,'赣州初二');for(const key of ['UserPoint','SilverCoin','Purse','UserExp'])assert.equal(after.legacyUserData[key],f.before.legacyUserData[key]);assert.equal(f.audit.length,1);}));
+test('non-empty email is saved and explicitly clearing it removes the value rather than storing an empty unique key',()=>fixture(async f=>{await f.save('local@example.com');assert.equal((await f.db.query('SELECT email FROM "_User" WHERE "objectId"=\'member\'')).rows[0].email,'local@example.com');await f.save('');assert.equal((await f.db.query('SELECT email FROM "_User" WHERE "objectId"=\'member\'')).rows[0].email,null);}));
+test('invalid email fails before saving or auditing',()=>fixture(async f=>{await assert.rejects(f.save('bad-address'),/邮箱格式不正确/);assert.equal(f.calls,0);assert.equal(f.audit.length,0);}));

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است