diff --git a/src/assets/mock/anonapi.ts b/src/assets/mock/anonapi.ts new file mode 100644 index 0000000..0d850d4 --- /dev/null +++ b/src/assets/mock/anonapi.ts @@ -0,0 +1,115 @@ +/** + * 匿名通道三端点的本地 Mock(契约 v2 §3.2/C6/C7,vite-plugin-mock,仅 `npm run dev:mock` 生效)。 + * + * 用途:本机没有 CAS 会话时,`/f/:code` 的**匿名通道**(双探针的第二跳 + 匿名 resolve/submit/upload) + * 在 mock 面要可渲染、可提交、可实拍"窗口内二发被拒"。配合 `api/module/anon.ts` 里那条 + * "mock 模式地址带 `mockSession=0` 即视为无会话"的开发期开关使用——真环境的会话判定仍完全由 + * `probeSession()` 探 `/api/base/GetUserInfo` 决定,本文件不参与生产链路。 + * + * 三处形状与后端逐字段对齐(前端取证只看对接形状,语义权威在 Java 侧的 AnonFillIT): + * ① resolve 出参与登录面 `/api/form/resolve` 同构(audience 是数组、options 已内联); + * ② 非匿名 code 一律回"表单不存在"(PARAM_ERROR=2 家族里的防探测文案,与不存在同一条); + * ③ submit 命中防重回 CUSTOM(5) 且 msg 含剩余分钟;成功回 `{answerId, answerNo, replayed}`。 + */ +import type { ResolvedForm } from "@/types/answer"; + +const ok = (data: unknown) => ({ code: 0, msg: "", data }); +const bizErr = (code: number, msg: string) => ({ code, msg, data: null }); + +/** 唯一一张"允许匿名"的种子表单;其余 code 都按不存在回答(防探测口径的 mock 面复演) */ +const ANON_CODE = "school_survey"; +const WINDOW_MINUTES = 10; + +const GROUPS = [ + { + key: "base", + label: "基本信息", + fields: [ + { key: "nick", label: "称呼", type: "text", required: true, maxLength: 20 }, + { + key: "grade", label: "年级", type: "select", required: true, + options: [ + { label: "高一", value: "g1" }, + { label: "高二", value: "g2" }, + { label: "高三", value: "g3" } + ] + }, + { key: "suggestion", label: "建议", type: "textarea", required: false } + ] + } +]; + +/** 防重窗口:code → 上次成功提交的毫秒时间戳(后端是 HMAC 签名的 cookie,mock 面用内存表复演) */ +const lastSubmitAt: Record = {}; +const submitted: any[] = []; +let seq = 1; + +function queryValue(query: any, key: string): string { + if (!query) return ""; + if (typeof query.get === "function") return query.get(key) || ""; + return String(query[key] ?? ""); +} + +function resolveAnon(code: string): ResolvedForm { + return { + formUuid: "mock0000000000000000000000000an1", + code, + appCode: "form-center", + name: "校园意见征集(匿名)", + version: 1, + audience: ["ANONYMOUS"], + fillLimit: 0, + answerNoEnabled: 1, + deadline: null, + deadlineReached: false, + statusDefs: [ + { code: "pending", label: "待处理", is_deleted: false }, + { code: "processed", label: "已处理", is_deleted: false } + ], + groups: GROUPS as any + } as ResolvedForm; +} + +export default [ + { + url: "/anon/form/resolve", + method: "get", + response: ({ query }: any) => { + const code = queryValue(query, "code"); + if (code !== ANON_CODE) return bizErr(2, `表单不存在:form-center/${code}`); + return ok(resolveAnon(code)); + } + }, + { + url: "/anon/form/submit", + method: "post", + response: ({ body }: any) => { + const code = String(body?.formCode ?? ""); + if (code !== ANON_CODE) return bizErr(2, `表单不存在:form-center/${code}`); + const elapsed = Date.now() - (lastSubmitAt[code] ?? 0); + if (lastSubmitAt[code] && elapsed < WINDOW_MINUTES * 60_000) { + const left = Math.max(1, Math.ceil((WINDOW_MINUTES * 60_000 - elapsed) / 60_000)); + return bizErr(5, `该表单 ${WINDOW_MINUTES} 分钟内只能提交一次,请约 ${left} 分钟后再提交:${code}`); + } + lastSubmitAt[code] = Date.now(); + submitted.push(body); + const no = seq++; + return ok({ + // 与真 answer_uuid 同长(32)——mock 面的 id 串一旦被别的调试脚本硬编码引用,长度漂移就是 P72 那类假失败 + answerId: `mockanon${String(no).padStart(4, "0")}${"0".repeat(20)}`, + answerNo: `${code}-${new Date().toISOString().slice(0, 10).replace(/-/g, "")}-000${no}`, + replayed: false + }); + } + }, + { + url: "/anon/form/upload", + method: "post", + response: ({ query }: any) => { + const code = queryValue(query, "code"); + if (code !== ANON_CODE) return bizErr(2, `表单不存在:form-center/${code}`); + // 与登录面同一份返回三件套(无 fileUrl,V24);id 是 mock 自增,真环境是 form_file 主键 + return ok({ id: seq, fileName: "匿名附件.jpg", key: `form/2026/10/mockanon${seq++}.jpg` }); + } + } +]; diff --git a/src/assets/mock/appapi.ts b/src/assets/mock/appapi.ts new file mode 100644 index 0000000..5fcada3 --- /dev/null +++ b/src/assets/mock/appapi.ts @@ -0,0 +1,110 @@ +/** + * app 注册表(快应用)的本地 Mock(契约 v2 §5.2/C10/C17,vite-plugin-mock,仅 `npm run dev:mock` 生效)。 + * + * 桩把后端四条规则也演一遍,让「应用设置 · 快应用」tab 在 mock 面能实拍到"前端没有把规则写丢": + * ① C10 格式与保留字(小写 slug 2~64、`form-center` 不可注册);② code 全局唯一; + * ③ 软删后编码不复用(`uk_app_code` 不含 is_deleted,问题单 P125 的那条口径); + * ④ 在用禁删(名下有表单即 CUSTOM(5),msg 指点先迁先删)。 + * 零角色列(V59)在这里的形状证据就是 `AppRow` 里根本没有权限字段。 + */ +import type { AppRow } from "@/types/data"; + +const ok = (data: unknown) => ({ code: 0, msg: "", data }); +const fail = (msg: string) => ({ code: 5, msg, data: null }); +const bad = (msg: string) => ({ code: 2, msg, data: null }); + +const CODE_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$/; +const RESERVED = "form-center"; + +/** 名下还有未删表单的 app_code(后端由 form 表统计,这里用一张内存表复演) */ +const formsInUse = new Set([RESERVED, "honor"]); + +interface Row extends AppRow { + deleted: boolean; +} + +const rows: Row[] = [ + { appCode: "honor", name: "荣誉管理", schId: 3260, entryUrl: "/#/app/honor/", deleted: false }, + { appCode: "affair-lib", name: "事项库", schId: 0, entryUrl: "/#/app/affair-lib/", deleted: false } +]; + +function view(row: Row): AppRow { + return { appCode: row.appCode, name: row.name, schId: row.schId, entryUrl: row.entryUrl }; +} + +function live(): AppRow[] { + return rows.filter((r) => !r.deleted).map(view); +} + +function pathParam(url: string): string { + const tail = String(url ?? "").split("?")[0].split("/").pop() ?? ""; + try { + return decodeURIComponent(tail); + } catch { + return tail; + } +} + +function bodyOf(ctx: any): any { + if (ctx?.body && Object.keys(ctx.body).length) return ctx.body; + try { + return JSON.parse(ctx?.bodyText || "{}"); + } catch { + return {}; + } +} + +export default [ + { url: "/api/form/admin/apps", method: "get", response: () => ok(live()) }, + { + url: "/api/form/admin/apps", + method: "post", + response: ({ body }: any) => { + const req = bodyOf({ body }); + const code = String(req?.appCode ?? "").trim(); + const name = String(req?.name ?? "").trim(); + if (!code) return bad("appCode 必填"); + if (!name) return bad("应用名称 name 必填"); + if (code.toLowerCase() === RESERVED) return bad(`${RESERVED} 是中心保留的 app_code,不可注册`); + if (!CODE_RE.test(code)) { + return bad(`appCode 需为 2~64 位小写 slug(^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])$):${code}`); + } + const existing = rows.find((r) => r.appCode === code); + if (existing) { + return fail(existing.deleted + ? `app_code 曾被注册且已删除,编码不复用(uk_app_code 全库唯一,请换 code):${code}` + : `app_code 已存在:${code}`); + } + const row: Row = { appCode: code, name, schId: 3260, entryUrl: `/#/app/${code}/`, deleted: false }; + rows.push(row); + return ok(view(row)); + } + }, + { + url: "/api/form/admin/apps/:appCode", + method: "put", + response: ({ url, body }: any) => { + const code = pathParam(url); + const row = rows.find((r) => r.appCode === code && !r.deleted); + if (!row) return bad(`app 不存在:${code}`); + const name = String(bodyOf({ body })?.name ?? "").trim(); + if (!name) return bad("应用名称 name 必填"); + row.name = name; + return ok(view(row)); + } + }, + { + url: "/api/form/admin/apps/:appCode", + method: "delete", + response: ({ url }: any) => { + const code = pathParam(url); + const row = rows.find((r) => r.appCode === code && !r.deleted); + if (!row) return bad(`app 不存在:${code}`); + if (formsInUse.has(code)) { + return fail(`app_code「${code}」名下还有未删除表单,先把表单迁走或删除再删注册行`); + } + row.deleted = true; + return ok(view(row)); + } + } +];