瀏覽代碼

feat: migrate learning word state writes

彭峰 1 月之前
父節點
當前提交
93ac48463d

+ 1 - 1
docs/migration/admin-migration-status.md

@@ -15,7 +15,7 @@
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。
 - 8 个管理/CMS 函数均验证未认证请求返回 401;临时帐套管理员完成真实只读冒烟测试后已删除。
-- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:26 个已映射,65 个显式返回 501 阻塞原因。复习收入统计因迁移数据没有金额和计价规则,从原先的错误通用映射改为明确阻塞;课程词库进度读取已完成专用联查。详见 `wxapp-cloud-action-matrix.md`。
+- 第 9 个 `xiaoshu.app.gateway` 已覆盖旧 H5 源码的全部 91 个 action:27 个已映射,64 个显式返回 501 阻塞原因。复习收入统计因迁移数据没有金额和计价规则,从原先的错误通用映射改为明确阻塞;课程词库进度读取及学习次数/生词状态写入已完成。详见 `wxapp-cloud-action-matrix.md`。
 - 旧数据的 `isDeleted` 在部分物理表与 Parse Schema 不一致,把它放入 Parse 查询条件会返回 500;网关改为公司条件查询后在结果侧兼容过滤。
 - Angular H5 已开始按页回归切换:账号密码登录、版本、栏目、公开内容、8 类 addon 联表,以及学习记录详情、预约详情、生词本、21 天抗遗忘列表可走云函数;私有读取只在真实 Parse 会话下切换,其余 action 仍由显式迁移路由保留旧端点。
 

+ 6 - 6
docs/migration/wxapp-cloud-action-matrix.md

@@ -5,10 +5,10 @@
 - 基线来自旧 uni-app 源码静态扫描生成的 `SOURCE_API_ACTIONS`,共 91 个唯一 action。
 - 统一新入口为 `POST /api/functions/xiaoshu/app/gateway`。
 - 请求体统一使用 `{ "token": "<sessionToken>", "params": { "action": "...", ... } }`;执行器会占用顶层 `id`,业务参数不得放在顶层。公开接口可省略 `token`,但仍必须保留 `params`。
-- 已有云函数映射 26 个,另有 65 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。矩阵不存在未知 action。
+- 已有云函数映射 27 个,另有 64 个以 HTTP 501 和 `migration_blocked: <原因>` 显式拒绝。矩阵不存在未知 action。
 - “已映射”表示已有云函数代码与权限边界;完成 H5 切换前仍需逐页响应字段回归。
 
-## 已映射的 26 个 action
+## 已映射的 27 个 action
 
 | 领域 | action |
 |---|---|
@@ -16,7 +16,7 @@
 | 内容 | `content_get`, `content_list`, `content_list_llk`, `content_uphis`, `node_get`, `node_list` |
 | 商品只读 | `product_get`, `product_list`, `product_stock_list` |
 | 账号 | `user_get`, `user_info_name`, `user_list`, `user_login_passwd`, `user_register`, `user_update`, `user_update_pwd`, `user_update_pwdall`, `e_user_list` |
-| 学习/预约只读 | `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_record_detail`, `e_words_list`, `user_point_list` |
+| 学习/预约 | `e_add_words`, `e_ck_list`, `e_get_21list`, `e_order_detail`, `e_record_detail`, `e_words_list`, `user_point_list` |
 | 反馈 | `guestbook_add` |
 
 已实测 `content_list` 在公司帐套下返回 92,435 条,`modelId=56` 返回 108 条。读取用 Psql 并强制 `company=7pIbDBJmKx`,所有客户端输入均使用参数化查询。
@@ -41,6 +41,7 @@
 - `e_record_detail`:由 Model 56 学习记录解析 `xxqs`,再按其中的 `GeneralID` 联查 Model 52 与 `VocabularyWord`,保持原顺序并为每项返回旧页面要求的 `detail[0]`。
 - `e_words_list`:只返回指定用户 `PracticeRecord.jrscb=1` 的生词,按 `scid -> CommonModel.generalId -> VocabularyWord.id` 联查词库详情,不再误返回 89,620 条全量词库。
 - `e_ck_list`:按一个或多个词库单元返回 Model 52 词条,并按 `用户 + 单词 GeneralID` 联查 `PracticeRecord.xxcs/jrscb`,恢复旧页面的 `w_learned` 与生词状态。
+- `e_add_words`:验证词库 ID 与用户归属后批量更新 `PracticeRecord`;`save=1` 递增学习次数,`ifnew=1/0` 加入/移出生词,首次学习会创建带云端来源标识的进度记录。
 - `e_order_detail`:以预约内容 `GeneralID` 联查 `CourseAppointment`,返回数组契约、旧字段别名和可恢复的课程名称。
 - `e_get_21list`:以 `MemoryPracticeRecord` 为主记录,联查 Model 60、原学习记录和课程节点;学员按 `yhid`、教练收入列表按 `plid` 隔离。
 
@@ -82,7 +83,6 @@
 |---|---|---|
 | 内容写入 | `content_add`, `content_update` | `CommonModel` 与 addon 表双写事务 |
 | 商品写入 | `product_del`, `product_sale_change`, `product_stock_change` | 门店管理员权限与库存账本 |
-| 课程/生词写入 | `e_add_words` | 新学习词首次写入时的 ID 分配与批量原子性 |
 | 学习写入 | `stu_record_update_v2`, `unit_record_update` | 学习主记录、addon 和单元聚合的原子写入 |
 | 预约 | `e_order_tongji`, `e_order_update_v2` | 统计口径、状态机和角色权限 |
 | 发布 | `pub_add`, `pub_list` | `Pub` 业务类型、审核与可见范围 |
@@ -93,12 +93,12 @@ Angular H5 已启用混合迁移路由:
 
 - `user_login_passwd` 已优先调用云函数:已完成 Parse 密码迁移的账号保存真实 `sessionToken` 并用该会话读取 `user_get`;尚未完成密码重置的旧账号在云函数返回 401/403 时自动回退旧登录,不中断存量用户。
 - `app_update`、`node_list`、`node_get` 已切换至云函数。
-- 持有真实 Parse 会话时,`e_ck_list`、`e_record_detail`、`e_order_detail`、`e_words_list`、`e_get_21list` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
+- 持有真实 Parse 会话时,`e_add_words`、`e_ck_list`、`e_record_detail`、`e_order_detail`、`e_words_list`、`e_get_21list` 已切换至云函数;旧会话继续留在旧端点,避免存量账号在密码重置前中断。
 - 不含 addon 条件的 `content_list` 和 Model 52 公开词库已切换;已持有 Parse 会话的用户会把字段白名单内的 Model 53/54/56/58/59/60/61 addon 查询切到云函数,仍使用旧会话的存量账号自动保留旧端点。
 - 云函数返回同时提供规范化 camelCase 与旧系统 `GeneralID`/`NodeID`/`Title` 等字段别名。
 - `product_list` 尚未切换:当前 `Product` 类只有库存变体字段,缺少旧页依赖的商品名称、价格、图片和正文联表。
 
-在 65 个阻塞 action 中包含订单、支付和学习写入等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
+在 64 个阻塞 action 中包含订单、支付和学习写入等主流程时,直接全量切换会导致页面大面积中断。剩余切换条件是:
 
 1. 先对齐缺失 Schema/数据和第三方凭据。
 2. 完成剩余 action 的云函数事务、权限和字段回归。

+ 10 - 2
scripts/deploy-admin-functions.mjs

@@ -243,7 +243,7 @@ const BLOCKED = {
   vote_add: '目标 Schema 无可证明的投票记录类', vote_ask: '目标 Schema 无可证明的投票记录类', vote_question: '目标 Schema 无可证明的投票记录类',
   content_add: 'content/addon 双表写入关系尚待字段级回归', content_add_zt: 'content/addon 双表写入关系尚待字段级回归', content_update: 'content/addon 双表写入关系尚待字段级回归',
   product_del: '尚未建立门店管理员授权关系', product_stock_change: '尚未建立门店管理员授权关系', product_sale_change: '尚未建立门店管理员授权关系',
-  e_add_words: '需按 CourseBinding 与 VocabularyWord 关系重建生词状态', stu_record_update_v2: '需重建学习主记录与 addon 的原子写入', unit_record_update: '需确认单元进度聚合规则', e_order_update_v2: '需确认预约状态机与角色权限', e_order_tongji: '需确认预约统计口径',
+  stu_record_update_v2: '需重建学习主记录与 addon 的原子写入', unit_record_update: '需确认单元进度聚合规则', e_order_update_v2: '需确认预约状态机与角色权限', e_order_tongji: '需确认预约统计口径',
   e_get_21list_tj: '迁移数据未包含复习收入金额字段或可验证的计价规则',
   pub_add: '需确认 Pub 业务类型与审核规则', pub_list: '需确认 Pub 业务类型与可见范围', user_dept: '目标 Schema 无可证明的用户部门关系',
   user_rnauth_add: '实名认证需新的合规审核与敏感数据存储', user_rnauth_get: '实名认证需新的合规审核与敏感数据存储', user_rnauth_upd: '实名认证需新的合规审核与敏感数据存储',
@@ -361,6 +361,13 @@ async function newWordPage(input, current) {
 async function courseWordPage(input, current) {
   const uid = await authorizeRequestedUser(current, input.uid); const nodes = String(input.nids || input.nid || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'); if (!nodes.length) fail(400, '缺少词库单元 ID'); const paging = { index: Math.max(1, number(input.cpage || input.page, 1)), size: Math.min(10000, Math.max(1, number(input.psize || input.pageSize, 1000))) }; const values = [DEFAULT_COMPANY_ID, String(uid), nodes]; const where = 'c."company" = $1 AND CAST(c."modelId" AS text) = \'52\' AND CAST(c."nodeId" AS text) = ANY($3::text[])'; const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c WHERE ' + where, values); const rows = await Psql.query('SELECT c.*, row_to_json(v) AS "__addon", p."xxcs" AS "__learned", p."jrscb" AS "__newWord" FROM "CommonModel" c LEFT JOIN "VocabularyWord" v ON v."company" = $1 AND CAST(v."id" AS text) = CAST(c."itemId" AS text) LEFT JOIN LATERAL (SELECT pr."xxcs", pr."jrscb" FROM "PracticeRecord" pr WHERE pr."company" = $1 AND CAST(pr."yhid" AS text) = $2 AND CAST(pr."scid" AS text) = CAST(c."generalId" AS text) ORDER BY pr."updatedAt" DESC LIMIT 1) p ON TRUE WHERE ' + where + ' ORDER BY c."orderId" ASC, c."generalId" ASC LIMIT $4 OFFSET $5', values.concat([paging.size, (paging.index - 1) * paging.size])); const result = rows.map((source) => { const learned = number(source.__learned); const newWord = number(source.__newWord); delete source.__learned; delete source.__newWord; const word = normalizeWordRow(source); return { ...word, detail: word, gldy: word.GeneralID, w_learned: learned, ifnew: newWord }; }); const total = number(countRow.total); return { rows: result, page: { itemCount: total, pageCount: Math.ceil(total / paging.size), pageIndex: paging.index, pageSize: paging.size } };
 }
+async function updateLearningWords(input, current) {
+  const uid = await authorizeRequestedUser(current, input.uid); const wordIds = [...new Set(String(input.wordsId || input.wordIds || '').split(',').map((value) => String(number(value))).filter((value) => value !== '0'))]; if (!wordIds.length) fail(400, '请选择单词'); if (wordIds.length > 100) fail(400, '单次最多操作 100 个单词'); const saveLearning = number(input.save) === 1; const hasNewWordFlag = input.ifnew !== undefined && input.ifnew !== ''; const newWordFlag = hasNewWordFlag ? (number(input.ifnew) === 1 ? '1' : '0') : null; if (!saveLearning && !hasNewWordFlag) fail(400, '缺少学习或生词操作类型'); const kcid = number(input.kcid);
+  const validRows = await Psql.query('SELECT CAST("generalId" AS text) AS id FROM "CommonModel" WHERE "company" = $1 AND CAST("modelId" AS text) = \'52\' AND CAST("generalId" AS text) = ANY($2::text[])', [DEFAULT_COMPANY_ID, wordIds]); const validIds = new Set(validRows.map((row) => String(row.id))); const invalid = wordIds.filter((id) => !validIds.has(id)); if (invalid.length) fail(400, '包含不存在的词库 ID: ' + invalid.join(','));
+  const existingRows = await Psql.query('SELECT "objectId", CAST("scid" AS text) AS "scid" FROM "PracticeRecord" WHERE "company" = $1 AND CAST("yhid" AS text) = $2 AND CAST("scid" AS text) = ANY($3::text[])', [DEFAULT_COMPANY_ID, String(uid), wordIds]); const existing = await Promise.all(existingRows.map((row) => new Parse.Query('PracticeRecord').get(row.objectId, { useMasterKey: true }))); const byWord = new Map(existing.map((item) => [String(item.get('scid')), item])); const changed = []; let created = 0;
+  for (const wordId of wordIds) { let item = byWord.get(wordId); if (!item) { if (!kcid) fail(400, '首次记录单词时缺少课程 ID'); item = new Parse.Object('PracticeRecord'); item.set('company', current.get('company')); item.set('sourceKey', 'cloud:e_add_words:' + uid + ':' + wordId); item.set('yhid', uid); item.set('scid', number(wordId)); item.set('kcid', String(kcid)); item.set('xxcs', 0); item.set('jrscb', '0'); created += 1; } else if (kcid && !item.get('kcid')) item.set('kcid', String(kcid)); if (saveLearning) item.set('xxcs', number(item.get('xxcs')) + 1); if (newWordFlag !== null) item.set('jrscb', newWordFlag); changed.push(item); }
+  const saved = await Parse.Object.saveAll(changed, { useMasterKey: true }); return { affected: saved.length, created, learnedIncremented: saveLearning ? saved.length : 0, newWordState: newWordFlag === null ? undefined : number(newWordFlag), objectIds: saved.map((item) => item.id) };
+}
 function learnedDate(row) { const raw = String(row.dqrq || '').trim(); return /^\d{8}$/.test(raw) ? raw.slice(0,4) + '-' + raw.slice(4,6) + '-' + raw.slice(6,8) : String(row.kywsj || '').slice(0,10); }
 async function memoryPage(input, current) {
   const uid = await authorizeRequestedUser(current, input.uid); const paging = largePageInput(input); const income = number(input.shouru) === 1; const values = [DEFAULT_COMPANY_ID, String(uid)]; const clauses = ['c."company" = $1', 'CAST(c."modelId" AS text) = \'60\'', 'CAST(m."' + (income ? 'plid' : 'yhid') + '" AS text) = $2']; if (input.status !== undefined && input.status !== '') { values.push(String(number(input.status))); clauses.push('CAST(m."fxzt" AS text) = $' + values.length); } const where = clauses.join(' AND '); const countRow = await Psql.one('SELECT COUNT(*)::int AS total FROM "CommonModel" c JOIN "MemoryPracticeRecord" m ON m."company" = $1 AND CAST(m."id" AS text) = CAST(c."itemId" AS text) WHERE ' + where, values); const rowValues = values.concat([paging.size, (paging.index - 1) * paging.size]);
@@ -370,7 +377,7 @@ async function resolveContent(input, requireOne = false) { const fields = await
 async function handler(request, response) {
   try {
     const input = inputOf(request); const action = String(input.action || ''); if (!action) return response.status(400).json(reject('缺少 action'));
-    if (action === 'migration_status') return response.json(envelope({ implemented: ['app_update','user_login_passwd','user_register','user_info_name','user_get','user_list','e_user_list','user_update','user_update_pwd','user_update_pwdall','content_list','content_list_llk','content_get','content_uphis','node_list','node_get','product_list','product_get','product_stock_list','e_ck_list','e_get_21list','e_record_detail','e_order_detail','e_words_list','user_point_list','guestbook_add'], blocked: BLOCKED }));
+    if (action === 'migration_status') return response.json(envelope({ implemented: ['app_update','user_login_passwd','user_register','user_info_name','user_get','user_list','e_user_list','user_update','user_update_pwd','user_update_pwdall','content_list','content_list_llk','content_get','content_uphis','node_list','node_get','product_list','product_get','product_stock_list','e_add_words','e_ck_list','e_get_21list','e_record_detail','e_order_detail','e_words_list','user_point_list','guestbook_add'], blocked: BLOCKED }));
     if (BLOCKED[action]) return response.status(501).json(reject('migration_blocked: ' + BLOCKED[action]));
     if (action === 'user_login_passwd') {
       const username = String(input.name || input.username || '').trim(); const password = String(input.passwd || input.password || ''); if (!username || !password) return response.status(400).json(reject('请输入账号和密码'));
@@ -396,6 +403,7 @@ async function handler(request, response) {
     if (action === 'product_get') { const result = await sqlPage('Product', { page: 1, pageSize: 1 }, [{ field: 'id', value: number(input.id) }]); return result.rows[0] ? response.json(envelope(result.rows[0])) : response.status(404).json(reject('商品不存在')); }
     if (action === 'product_del' || action === 'product_stock_change' || action === 'product_sale_change') { const fields = await fieldsOf('Product'); const found = await findByLegacyId('Product', fields, input.id, ['id']); if (!found) return response.status(404).json(reject('商品不存在')); if (action === 'product_del') await found.destroy({ useMasterKey: true }); else { const field = action === 'product_stock_change' ? 'nums' : 'isChu'; if (fields[field]) found.set(field, action === 'product_stock_change' ? number(input.nums || input.stock) : Boolean(input.value ?? input.status)); await found.save(null, { useMasterKey: true }); } return response.json(envelope({ objectId: found.id })); }
     const legacyId = ownLegacyId(current);
+    if (action === 'e_add_words') return response.json(envelope(await updateLearningWords(input, current)));
     if (action === 'e_ck_list') { const result = await courseWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'e_get_21list') { const result = await memoryPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }
     if (action === 'e_words_list') { const result = await newWordPage(input, current); return response.json(envelope(result.rows, undefined, result.page)); }

+ 20 - 1
scripts/smoke-admin-functions.mjs

@@ -57,6 +57,7 @@ async function sourceActions() {
 
 let userId = '';
 let memberId = '';
+let memberLegacyId = 0;
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
   const companyId = sample.results?.[0]?.objectId;
@@ -79,7 +80,7 @@ try {
 
   const memberUsername = `codex_member_smoke_${Date.now()}`;
   const memberPassword = randomBytes(24).toString('base64url');
-  const memberLegacyId = 900000000 + Math.floor(Date.now() / 1000) % 90000000;
+  memberLegacyId = 900000000 + Math.floor(Date.now() / 1000) % 90000000;
   const member = await jsonRequest(`${PARSE_URL}/users`, {
     method: 'POST',
     body: JSON.stringify({ username: memberUsername, password: memberPassword, legacyUserId: memberLegacyId, company }),
@@ -155,6 +156,16 @@ try {
   if (!Array.isArray(ownEmpty.result) || ownEmpty.page?.itemCount !== 0) throw new Error('普通用户本人记录权限校验失败');
   const ownCourseWords = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_ck_list', uid: memberLegacyId, nids: 40, page: 1, pageSize: 1 });
   if (!Array.isArray(ownCourseWords.result) || ownCourseWords.result[0]?.w_learned !== 0) throw new Error('普通用户本人课程词库权限校验失败');
+  const learnedWrite = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_add_words', uid: memberLegacyId, wordsId: '2505', kcid: 16, save: 1 });
+  if (learnedWrite.result?.created !== 1 || learnedWrite.result?.learnedIncremented !== 1) throw new Error('首次学习词进度写入校验失败');
+  const learnedRead = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_ck_list', uid: memberLegacyId, nids: 40, page: 1, pageSize: 1000 });
+  if (Number(learnedRead.result?.find((word) => Number(word.GeneralID) === 2505)?.w_learned) !== 1) throw new Error('学习次数写后读校验失败');
+  await callLegacyFunction(memberLogin.sessionToken, { action: 'e_add_words', uid: memberLegacyId, wordsId: '2505', kcid: 16, ifnew: 1 });
+  const addedNewWord = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_words_list', uid: memberLegacyId, page: 1, pageSize: 10 });
+  if (addedNewWord.page?.itemCount !== 1 || Number(addedNewWord.result?.[0]?.detail?.[0]?.GeneralID) !== 2505) throw new Error('加入生词写后读校验失败');
+  await callLegacyFunction(memberLogin.sessionToken, { action: 'e_add_words', uid: memberLegacyId, wordsId: '2505', kcid: 16, ifnew: 0 });
+  const removedNewWord = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_words_list', uid: memberLegacyId, page: 1, pageSize: 10 });
+  if (removedNewWord.page?.itemCount !== 0) throw new Error('移出生词写后读校验失败');
   const deniedCourseWords = await callLegacyFunction(memberLogin.sessionToken, { action: 'e_ck_list', uid: 58, nids: 40, page: 1, pageSize: 1 }, 403);
   if (!String(deniedCourseWords.retmsg).includes('无权')) throw new Error('普通用户跨用户课程词库未被拒绝');
   const denied = await callLegacyFunction(memberLogin.sessionToken, { action: 'content_list', modelId: 56, myfield2: 'UserId=3', page: 1, pageSize: 1 }, 403);
@@ -168,6 +179,14 @@ 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 (memberLegacyId) {
+    const where = encodeURIComponent(JSON.stringify({ yhid: String(memberLegacyId) }));
+    const practices = await jsonRequest(`${PARSE_URL}/classes/PracticeRecord?where=${where}&limit=1000`, {}, true).catch(() => ({ results: [] }));
+    for (const practice of practices.results || []) await jsonRequest(`${PARSE_URL}/classes/PracticeRecord/${practice.objectId}`, { method: 'DELETE' }, true).catch((error) => {
+      console.error(`临时学习进度清理失败:${error.message}`);
+      process.exitCode = 1;
+    });
+  }
   if (memberId) {
     await jsonRequest(`${PARSE_URL}/users/${memberId}`, { method: 'DELETE' }, true).catch((error) => {
       console.error(`临时普通用户清理失败:${error.message}`);

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

@@ -80,6 +80,14 @@ describe('ApiService', () => {
     request.flush({ retcode: 0, result: [{ GeneralID: 2505, w_learned: 7 }] });
   });
 
+  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();
+    const request = http.expectOne(API_CONFIG.cloudFunctionUrl);
+    expect(request.request.body).toEqual({ token: 'r:session-token', params: { action: 'e_add_words', uid: 58, wordsId: '2505,2503', kcid: 16, ifnew: 1 } });
+    request.flush({ retcode: 0, result: { affected: 2, newWordState: 1 } });
+  });
+
   it('keeps learning detail reads on legacy until a real Parse session exists', () => {
     sessionToken = 'legacy-session';
     api.get('e_words_list', { uid: 58 }).subscribe();

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

@@ -11,6 +11,7 @@ export const H5_CLOUD_ROUTED_ACTIONS = new Set([
 ]);
 
 const SESSION_CLOUD_ROUTED_ACTIONS = new Set([
+  'e_add_words',
   'e_ck_list',
   'e_get_21list',
   'e_order_detail',