/** * routes/activities.ts —— 活动端点 mock(academic 契约 §2.2 端点 22~37,16 个)。 * * 契约对齐(academic-frontend-contract-v1.md v1.9 + 后端 2026-11-10 答复): * - type 语义:2 充值赠送 / 3 代金券 / 4 老带新(Go models_activity.go:16-20); * - 报名行 status:0 取消 / 1 报名 / 2 参加 / 3 报课 / 4 过期(Go ActivitySignedCancelled=0); * - 报名入参:仅 userId/status?/recommendUserId?(**无 money**,报名金额=活动级 money 快照, * 后端 SignInput 无 Money 字段,dump signed_add 5 参); * - 报名 status:<1 一律归 1(dump 同口径); * - 取消报名:status/activity_money/activity_money_used 三列同时归零(mock 以 status=0 + money=0 表达); * - 3106 老带新每人限一次(**type=4** 判据)/ 3108 老带新已消费(**type=4** 且 money>0); * - 端点 31 平台视角(institutionId<=0):角色须 super_admin/finance,否则 1003; * - 端点 32 赠送不足 → 3109(Go 增强;金额≤0 仍是 no-op used:false)。 * * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。 */ import { fail, ok, page } from '@/api/mock/envelope'; import { toCents, fmt } from '@/api/mock/money'; import { rfc3339 } from '@/api/mock/time'; import { inScope, findUser, findActivity, queryActivities } from '@/api/mock/data'; import type { Activity, ActivitySign } from '@/api/mock/data'; import { requireRole } from '@/api/mock/routes/auth'; import type { MockCtx, MockRoute } from '@/api/mock/adapter'; import type { R } from '@/types/api'; function checkScope(ctx: MockCtx): R | undefined { const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 1; const campusId = ctx.query.campusId !== undefined ? Number(ctx.query.campusId) : 0; return inScope(institutionId, campusId) ? undefined : fail(1004, '作用域越界:当前账号无权访问该机构/校区'); } /** 报名行 → 列表项(v1.6 C1:业务时间 `createTime`;端点 24/28 signeds[] 行)。 */ function signRow(s: ActivitySign): Record { return { id: s.id, activityId: s.activityId, userId: s.userId, status: s.status, recommendUserId: s.recommendUserId, money: fmt(s.money), createTime: s.createTime, }; } /** 活动行 → 列表项(v1.6 C2:startAt/endAt→startTime/endTime;E6:活动级 `money` 金额字符串)。 */ function activityRow(a: Activity): Record { return { id: a.id, institutionId: a.institutionId, campusId: a.campusId, type: a.type, name: a.name, status: a.status, numberSign: a.numberSign, total: a.total, signed: a.signeds.filter((s) => s.status !== 0).length, money: fmt(a.money), signable: a.signable, startTime: a.startTime, endTime: a.endTime, }; } export const activityRoutes: MockRoute[] = [ /* 22. GET /api/admin/activities —— 活动列表 */ { method: 'GET', path: '/api/admin/activities', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const type = ctx.query.type !== undefined ? Number(ctx.query.type) : undefined; const status = ctx.query.status !== undefined ? Number(ctx.query.status) : undefined; // 越界枚举 → 2002(type 白名单 2/3/4;1 试听为旧遗留不接受) if (type !== undefined && ![2, 3, 4].includes(type)) return fail(2002, 'type 越界(2/3/4)'); if (status !== undefined && ![0, 1, 2].includes(status)) return fail(2002, 'status 越界(0/1/2)'); const rows = queryActivities({ type, status, keyword: ctx.query.keyword, institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1, campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0, }); return page(rows.map(activityRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10)); }, }, /* 23. POST /api/admin/activities —— 新建 */ { method: 'POST', path: '/api/admin/activities', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; // 请求字段是 title(契约/后端 dto_activity.go json:"title");name 仅响应侧 const title = (ctx.body?.title ?? ctx.body?.name) as string | undefined; const type = Number(ctx.body?.type ?? 0); if (!title) return fail(2001, 'title 缺失'); if (![2, 3, 4].includes(type)) return fail(2002, 'type 非法(2 充值赠送 / 3 代金券 / 4 老带新)'); const newId = 100 + Math.floor(Math.random() * 900); // t_courses_activity 无 biz_no 列 ⇒ 不下发 bizNo return ok({ id: newId }); }, }, /* 24. GET /api/admin/activities/:id —— 详情 */ { method: 'GET', path: '/api/admin/activities/:id', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const a = findActivity(Number(ctx.params.id)); if (!a) return fail(3001, '活动不存在'); return ok({ ...activityRow(a), signeds: a.signeds.slice(0, 200).map(signRow) }); }, }, /* 25. PUT /api/admin/activities/:id —— 更新(整体替换:未传 content/memo/thumbnail 置空;numberSign 禁写) */ { method: 'PUT', path: '/api/admin/activities/:id', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const a = findActivity(Number(ctx.params.id)); if (!a) return fail(3001, '活动不存在'); // 整体替换语义:未传字段置空(mock 简化:仅记"本次提交带了哪些可选字段",未带即清空) if (ctx.body?.numberSign !== undefined) return fail(2002, 'numberSign 不可修改(单一参数非法,非 3140)'); const title = (ctx.body?.title ?? ctx.body?.name) as string | undefined; if (!title) return fail(2001, 'title 缺失(整体替换后不能没有标题)'); a.name = title; // 整体替换:未传 content/memo/thumbnail 被置空 —— mock 以"记录当前有效值"表达,前端编辑页须回填全字段 (a as Activity & { content?: string; memo?: string; thumbnail?: string }).content = (ctx.body?.content as string) ?? ''; (a as Activity & { content?: string; memo?: string; thumbnail?: string }).memo = (ctx.body?.memo as string) ?? ''; (a as Activity & { content?: string; memo?: string; thumbnail?: string }).thumbnail = (ctx.body?.thumbnail as string) ?? ''; return ok({ id: a.id }); }, }, /* 26. POST /api/admin/activities/:id/sign —— 报名(无 money 入参;status<1 归 1;3105/3106 分支) */ { method: 'POST', path: '/api/admin/activities/:id/sign', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const a = findActivity(Number(ctx.params.id)); if (!a) return fail(3001, '活动不存在'); const userId = ctx.body?.userId as string; if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在'); // status < 1 一律归 1(dump 同口径) const status = Math.max(1, Number(ctx.body?.status ?? 1)); // 报名金额 = 活动级 money 快照(后端 SignInput 无 Money 字段,不读 body.money) // 3105:已报名该活动(存在 status!==0 的本人报名) if (a.signeds.some((s) => s.userId === userId && s.status !== 0)) return fail(3105, '已报名该活动'); // 3106:老带新(type=4)每人限参加一次 if (a.type === 4 && a.signeds.some((s) => s.userId === userId && s.status !== 0)) return fail(3106, '老带新每人限参加一次'); a.signeds.push({ id: Date.now(), activityId: a.id, userId, status, recommendUserId: (ctx.body?.recommendUserId as string) ?? null, money: a.money, // 活动级快照(非入参) createTime: rfc3339(Date.now()), }); return ok({ signedId: a.signeds[a.signeds.length - 1].id }); }, }, /* 27. POST /api/admin/activities/:id/cancel-sign —— 取消报名(status=0 + money 归零;3107/3108 分支) */ { method: 'POST', path: '/api/admin/activities/:id/cancel-sign', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const a = findActivity(Number(ctx.params.id)); if (!a) return fail(3001, '活动不存在'); const userId = ctx.body?.userId as string; if (!userId) return fail(2001, 'userId 缺失'); const s = a.signeds.find((x) => x.userId === userId && x.status !== 0); if (!s) return fail(3001, '未找到有效报名'); // 3107:活动已审核通过不可取消(type 2/3 充值赠送/代金券 视为已结算) if ((a.type === 2 || a.type === 3) && s.status >= 2) return fail(3107, '活动已审核通过,不可取消'); // 3108:老带新(type=4)已产生消费(money 已核销 > 0) if (a.type === 4 && s.money > 0 && s.status >= 2) return fail(3108, '老带新已产生消费,不可取消'); // 三列同时归零:status→0 + activity_money/activity_money_used→0 s.status = 0; s.money = 0; return ok({ cancelled: true }); }, }, /* 28. GET /api/admin/activities/:id/signeds —— 报名列表(status 可筛 0~4) */ { method: 'GET', path: '/api/admin/activities/:id/signeds', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const a = findActivity(Number(ctx.params.id)); if (!a) return fail(3001, '活动不存在'); let rows = a.signeds; if (ctx.query.status !== undefined) { const st = Number(ctx.query.status); if (![0, 1, 2, 3, 4].includes(st)) return fail(2002, 'status 越界(0~4)'); rows = rows.filter((s) => s.status === st); } return page(rows.map(signRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10)); }, }, /* 29. POST /api/admin/activities/:id/recommend-settle —— 老带新结算(settled:false = 正常 LEAVE,非错误) */ { method: 'POST', path: '/api/admin/activities/:id/recommend-settle', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const a = findActivity(Number(ctx.params.id)); if (!a) return fail(3001, '活动不存在'); if (ctx.body?.userId === undefined || ctx.body?.orderNo === undefined) return fail(2001, 'userId/orderNo 缺失'); return ok({ settled: true, activityId: a.id }); }, }, /* 30. POST /api/admin/activities/:id/cancel-recharge —— 取消充值活动(反向扣款,无余额上限校验) */ { method: 'POST', path: '/api/admin/activities/:id/cancel-recharge', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const a = findActivity(Number(ctx.params.id)); if (!a) return fail(3001, '活动不存在'); if (ctx.body?.userId === undefined || ctx.body?.campusId === undefined || ctx.body?.money === undefined) return fail(2001, '参数缺失'); return ok({ cancelled: true }); }, }, /* 31. GET /api/admin/activities/discounts/usable —— 可用赠送(平台视角 1003 校验) */ { method: 'GET', path: '/api/admin/activities/discounts/usable', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const userId = ctx.query.userId; if (!userId) return fail(2001, 'userId 缺失'); const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 0; // 平台视角(institutionId<=0):角色须 super_admin/finance,否则 1003 if (institutionId <= 0) { const deniedRole = requireRole(ctx, ['super_admin', 'finance']); if (deniedRole) return deniedRole; } // mock:按用户主校区返回可用赠送池 const u = findUser(userId); const campus = u?.campusId ?? 11; // 赠送池表无 memo 列 ⇒ items 不带 memo;usable = Σ items(同一快照);按 activityId 升序 const a = 20000; const b = 12000 + campus * 1000; return ok({ userId, campusId: campus, usable: fmt(a + b), items: [ { activityId: 2, amount: fmt(a) }, { activityId: 4, amount: fmt(b) }, ], }); }, }, /* 32. POST /api/admin/activities/discounts/use —— 使用赠送(≤0 no-op;金额>可用池 → 3109) */ { method: 'POST', path: '/api/admin/activities/discounts/use', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined) return fail(2001, '参数缺失'); // 金额 ≤0 → no-op(used:false,不报错);请求金额须为字符串两位小数(数字 → 2002) const raw = ctx.body?.amount; if (raw !== undefined) { if (typeof raw !== 'string') return fail(2002, 'amount 须为字符串两位小数'); const c = toCents(raw); if (c <= 0) return ok({ used: false }); // mock 可用池:活动 2 = 200.00 / 活动 4 = 120.00+(与端点 31 同池) const pool = Number(ctx.body.activityId) === 2 ? 20000 : 13000; if (c > pool) return fail(3109, '可用赠送金额不足'); } return ok({ used: true }); }, }, /* 33. POST /api/admin/activities/discounts/refund —— 退回赠送(按报课记录) */ { method: 'POST', path: '/api/admin/activities/discounts/refund', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; if (ctx.body?.coursesStudentId === undefined) return fail(2001, 'coursesStudentId 缺失'); return ok({ refunded: true }); }, }, /* 34. POST /api/admin/activities/discounts/refund-by-activity —— 按活动退回 */ { method: 'POST', path: '/api/admin/activities/discounts/refund-by-activity', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined) return fail(2001, '参数缺失'); return ok({ refunded: true }); }, }, /* 35. POST /api/admin/activities/recharge-gifts/use —— 使用充值赠送(报名行须 status=2) */ { method: 'POST', path: '/api/admin/activities/recharge-gifts/use', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined) return fail(2001, '参数缺失'); const userId = ctx.body.userId as string; const activityId = Number(ctx.body.activityId); const act = findActivity(activityId); const s = act?.signeds.find((x) => x.userId === userId); if (!s || s.status !== 2) return fail(3001, '该活动报名行未参加活动(需 status=2)'); return ok({ used: true }); }, }, /* 36. POST /api/admin/activities/recharge-gifts/refund —— 退回充值赠送 */ { method: 'POST', path: '/api/admin/activities/recharge-gifts/refund', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; if (ctx.body?.coursesStudentId === undefined) return fail(2001, 'coursesStudentId 缺失'); return ok({ refunded: true }); }, }, /* 37. POST /api/admin/activities/:id/visits —— 回访登记(activitySignedId + memo 必填) */ { method: 'POST', path: '/api/admin/activities/:id/visits', handler: (ctx) => { const denied = checkScope(ctx); if (denied) return denied; const a = findActivity(Number(ctx.params.id)); if (!a) return fail(3001, '活动不存在'); if (ctx.body?.activitySignedId === undefined || ctx.body?.memo === undefined) return fail(2001, 'activitySignedId/memo 缺失'); return ok({ visited: true, activityId: a.id }); }, }, ];