activities.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. /**
  2. * routes/activities.ts —— 活动端点 mock(academic 契约 §2.2 端点 22~37,16 个)。
  3. *
  4. * 契约对齐(academic-frontend-contract-v1.md v1.9 + 后端 2026-11-10 答复):
  5. * - type 语义:2 充值赠送 / 3 代金券 / 4 老带新(Go models_activity.go:16-20);
  6. * - 报名行 status:0 取消 / 1 报名 / 2 参加 / 3 报课 / 4 过期(Go ActivitySignedCancelled=0);
  7. * - 报名入参:仅 userId/status?/recommendUserId?(**无 money**,报名金额=活动级 money 快照,
  8. * 后端 SignInput 无 Money 字段,dump signed_add 5 参);
  9. * - 报名 status:<1 一律归 1(dump 同口径);
  10. * - 取消报名:status/activity_money/activity_money_used 三列同时归零(mock 以 status=0 + money=0 表达);
  11. * - 3106 老带新每人限一次(**type=4** 判据)/ 3108 老带新已消费(**type=4** 且 money>0);
  12. * - 端点 31 平台视角(institutionId<=0):角色须 super_admin/finance,否则 1003;
  13. * - 端点 32 赠送不足 → 3109(Go 增强;金额≤0 仍是 no-op used:false)。
  14. *
  15. * 本文件仅在 dev + VITE_API_MOCK 下被加载(经 http.ts 动态 import 门控),不进 dist。
  16. */
  17. import { fail, ok, page } from '@/api/mock/envelope';
  18. import { toCents, fmt } from '@/api/mock/money';
  19. import { rfc3339 } from '@/api/mock/time';
  20. import { inScope, findUser, findActivity, queryActivities } from '@/api/mock/data';
  21. import type { Activity, ActivitySign } from '@/api/mock/data';
  22. import { requireRole } from '@/api/mock/routes/auth';
  23. import type { MockCtx, MockRoute } from '@/api/mock/adapter';
  24. import type { R } from '@/types/api';
  25. function checkScope(ctx: MockCtx): R<undefined> | undefined {
  26. const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 1;
  27. const campusId = ctx.query.campusId !== undefined ? Number(ctx.query.campusId) : 0;
  28. return inScope(institutionId, campusId) ? undefined : fail(1004, '作用域越界:当前账号无权访问该机构/校区');
  29. }
  30. /** 报名行 → 列表项(v1.6 C1:业务时间 `createTime`;端点 24/28 signeds[] 行)。 */
  31. function signRow(s: ActivitySign): Record<string, unknown> {
  32. return {
  33. id: s.id,
  34. activityId: s.activityId,
  35. userId: s.userId,
  36. status: s.status,
  37. recommendUserId: s.recommendUserId,
  38. money: fmt(s.money),
  39. createTime: s.createTime,
  40. };
  41. }
  42. /** 活动行 → 列表项(v1.6 C2:startAt/endAt→startTime/endTime;E6:活动级 `money` 金额字符串)。 */
  43. function activityRow(a: Activity): Record<string, unknown> {
  44. return {
  45. id: a.id,
  46. institutionId: a.institutionId,
  47. campusId: a.campusId,
  48. type: a.type,
  49. name: a.name,
  50. status: a.status,
  51. numberSign: a.numberSign,
  52. total: a.total,
  53. signed: a.signeds.filter((s) => s.status !== 0).length,
  54. money: fmt(a.money),
  55. signable: a.signable,
  56. startTime: a.startTime,
  57. endTime: a.endTime,
  58. };
  59. }
  60. export const activityRoutes: MockRoute[] = [
  61. /* 22. GET /api/admin/activities —— 活动列表 */
  62. {
  63. method: 'GET',
  64. path: '/api/admin/activities',
  65. handler: (ctx) => {
  66. const denied = checkScope(ctx);
  67. if (denied) return denied;
  68. const type = ctx.query.type !== undefined ? Number(ctx.query.type) : undefined;
  69. const status = ctx.query.status !== undefined ? Number(ctx.query.status) : undefined;
  70. // 越界枚举 → 2002(type 白名单 2/3/4;1 试听为旧遗留不接受)
  71. if (type !== undefined && ![2, 3, 4].includes(type)) return fail(2002, 'type 越界(2/3/4)');
  72. if (status !== undefined && ![0, 1, 2].includes(status)) return fail(2002, 'status 越界(0/1/2)');
  73. const rows = queryActivities({
  74. type,
  75. status,
  76. keyword: ctx.query.keyword,
  77. institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
  78. campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0,
  79. });
  80. return page(rows.map(activityRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
  81. },
  82. },
  83. /* 23. POST /api/admin/activities —— 新建 */
  84. {
  85. method: 'POST',
  86. path: '/api/admin/activities',
  87. handler: (ctx) => {
  88. const denied = checkScope(ctx);
  89. if (denied) return denied;
  90. // 请求字段是 title(契约/后端 dto_activity.go json:"title");name 仅响应侧
  91. const title = (ctx.body?.title ?? ctx.body?.name) as string | undefined;
  92. const type = Number(ctx.body?.type ?? 0);
  93. if (!title) return fail(2001, 'title 缺失');
  94. if (![2, 3, 4].includes(type)) return fail(2002, 'type 非法(2 充值赠送 / 3 代金券 / 4 老带新)');
  95. const newId = 100 + Math.floor(Math.random() * 900);
  96. // t_courses_activity 无 biz_no 列 ⇒ 不下发 bizNo
  97. return ok({ id: newId });
  98. },
  99. },
  100. /* 24. GET /api/admin/activities/:id —— 详情 */
  101. {
  102. method: 'GET',
  103. path: '/api/admin/activities/:id',
  104. handler: (ctx) => {
  105. const denied = checkScope(ctx);
  106. if (denied) return denied;
  107. const a = findActivity(Number(ctx.params.id));
  108. if (!a) return fail(3001, '活动不存在');
  109. return ok({ ...activityRow(a), signeds: a.signeds.slice(0, 200).map(signRow) });
  110. },
  111. },
  112. /* 25. PUT /api/admin/activities/:id —— 更新(整体替换:未传 content/memo/thumbnail 置空;numberSign 禁写) */
  113. {
  114. method: 'PUT',
  115. path: '/api/admin/activities/:id',
  116. handler: (ctx) => {
  117. const denied = checkScope(ctx);
  118. if (denied) return denied;
  119. const a = findActivity(Number(ctx.params.id));
  120. if (!a) return fail(3001, '活动不存在');
  121. // 整体替换语义:未传字段置空(mock 简化:仅记"本次提交带了哪些可选字段",未带即清空)
  122. if (ctx.body?.numberSign !== undefined) return fail(2002, 'numberSign 不可修改(单一参数非法,非 3140)');
  123. const title = (ctx.body?.title ?? ctx.body?.name) as string | undefined;
  124. if (!title) return fail(2001, 'title 缺失(整体替换后不能没有标题)');
  125. a.name = title;
  126. // 整体替换:未传 content/memo/thumbnail 被置空 —— mock 以"记录当前有效值"表达,前端编辑页须回填全字段
  127. (a as Activity & { content?: string; memo?: string; thumbnail?: string }).content = (ctx.body?.content as string) ?? '';
  128. (a as Activity & { content?: string; memo?: string; thumbnail?: string }).memo = (ctx.body?.memo as string) ?? '';
  129. (a as Activity & { content?: string; memo?: string; thumbnail?: string }).thumbnail = (ctx.body?.thumbnail as string) ?? '';
  130. return ok({ id: a.id });
  131. },
  132. },
  133. /* 26. POST /api/admin/activities/:id/sign —— 报名(无 money 入参;status<1 归 1;3105/3106 分支) */
  134. {
  135. method: 'POST',
  136. path: '/api/admin/activities/:id/sign',
  137. handler: (ctx) => {
  138. const denied = checkScope(ctx);
  139. if (denied) return denied;
  140. const a = findActivity(Number(ctx.params.id));
  141. if (!a) return fail(3001, '活动不存在');
  142. const userId = ctx.body?.userId as string;
  143. if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
  144. // status < 1 一律归 1(dump 同口径)
  145. const status = Math.max(1, Number(ctx.body?.status ?? 1));
  146. // 报名金额 = 活动级 money 快照(后端 SignInput 无 Money 字段,不读 body.money)
  147. // 3105:已报名该活动(存在 status!==0 的本人报名)
  148. if (a.signeds.some((s) => s.userId === userId && s.status !== 0)) return fail(3105, '已报名该活动');
  149. // 3106:老带新(type=4)每人限参加一次
  150. if (a.type === 4 && a.signeds.some((s) => s.userId === userId && s.status !== 0)) return fail(3106, '老带新每人限参加一次');
  151. a.signeds.push({
  152. id: Date.now(),
  153. activityId: a.id,
  154. userId,
  155. status,
  156. recommendUserId: (ctx.body?.recommendUserId as string) ?? null,
  157. money: a.money, // 活动级快照(非入参)
  158. createTime: rfc3339(Date.now()),
  159. });
  160. return ok({ signedId: a.signeds[a.signeds.length - 1].id });
  161. },
  162. },
  163. /* 27. POST /api/admin/activities/:id/cancel-sign —— 取消报名(status=0 + money 归零;3107/3108 分支) */
  164. {
  165. method: 'POST',
  166. path: '/api/admin/activities/:id/cancel-sign',
  167. handler: (ctx) => {
  168. const denied = checkScope(ctx);
  169. if (denied) return denied;
  170. const a = findActivity(Number(ctx.params.id));
  171. if (!a) return fail(3001, '活动不存在');
  172. const userId = ctx.body?.userId as string;
  173. if (!userId) return fail(2001, 'userId 缺失');
  174. const s = a.signeds.find((x) => x.userId === userId && x.status !== 0);
  175. if (!s) return fail(3001, '未找到有效报名');
  176. // 3107:活动已审核通过不可取消(type 2/3 充值赠送/代金券 视为已结算)
  177. if ((a.type === 2 || a.type === 3) && s.status >= 2) return fail(3107, '活动已审核通过,不可取消');
  178. // 3108:老带新(type=4)已产生消费(money 已核销 > 0)
  179. if (a.type === 4 && s.money > 0 && s.status >= 2) return fail(3108, '老带新已产生消费,不可取消');
  180. // 三列同时归零:status→0 + activity_money/activity_money_used→0
  181. s.status = 0;
  182. s.money = 0;
  183. return ok({ cancelled: true });
  184. },
  185. },
  186. /* 28. GET /api/admin/activities/:id/signeds —— 报名列表(status 可筛 0~4) */
  187. {
  188. method: 'GET',
  189. path: '/api/admin/activities/:id/signeds',
  190. handler: (ctx) => {
  191. const denied = checkScope(ctx);
  192. if (denied) return denied;
  193. const a = findActivity(Number(ctx.params.id));
  194. if (!a) return fail(3001, '活动不存在');
  195. let rows = a.signeds;
  196. if (ctx.query.status !== undefined) {
  197. const st = Number(ctx.query.status);
  198. if (![0, 1, 2, 3, 4].includes(st)) return fail(2002, 'status 越界(0~4)');
  199. rows = rows.filter((s) => s.status === st);
  200. }
  201. return page(rows.map(signRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
  202. },
  203. },
  204. /* 29. POST /api/admin/activities/:id/recommend-settle —— 老带新结算(settled:false = 正常 LEAVE,非错误) */
  205. {
  206. method: 'POST',
  207. path: '/api/admin/activities/:id/recommend-settle',
  208. handler: (ctx) => {
  209. const denied = checkScope(ctx);
  210. if (denied) return denied;
  211. const a = findActivity(Number(ctx.params.id));
  212. if (!a) return fail(3001, '活动不存在');
  213. if (ctx.body?.userId === undefined || ctx.body?.orderNo === undefined) return fail(2001, 'userId/orderNo 缺失');
  214. return ok({ settled: true, activityId: a.id });
  215. },
  216. },
  217. /* 30. POST /api/admin/activities/:id/cancel-recharge —— 取消充值活动(反向扣款,无余额上限校验) */
  218. {
  219. method: 'POST',
  220. path: '/api/admin/activities/:id/cancel-recharge',
  221. handler: (ctx) => {
  222. const denied = checkScope(ctx);
  223. if (denied) return denied;
  224. const a = findActivity(Number(ctx.params.id));
  225. if (!a) return fail(3001, '活动不存在');
  226. if (ctx.body?.userId === undefined || ctx.body?.campusId === undefined || ctx.body?.money === undefined) return fail(2001, '参数缺失');
  227. return ok({ cancelled: true });
  228. },
  229. },
  230. /* 31. GET /api/admin/activities/discounts/usable —— 可用赠送(平台视角 1003 校验) */
  231. {
  232. method: 'GET',
  233. path: '/api/admin/activities/discounts/usable',
  234. handler: (ctx) => {
  235. const denied = checkScope(ctx);
  236. if (denied) return denied;
  237. const userId = ctx.query.userId;
  238. if (!userId) return fail(2001, 'userId 缺失');
  239. const institutionId = ctx.query.institutionId !== undefined ? Number(ctx.query.institutionId) : 0;
  240. // 平台视角(institutionId<=0):角色须 super_admin/finance,否则 1003
  241. if (institutionId <= 0) {
  242. const deniedRole = requireRole(ctx, ['super_admin', 'finance']);
  243. if (deniedRole) return deniedRole;
  244. }
  245. // mock:按用户主校区返回可用赠送池
  246. const u = findUser(userId);
  247. const campus = u?.campusId ?? 11;
  248. // 赠送池表无 memo 列 ⇒ items 不带 memo;usable = Σ items(同一快照);按 activityId 升序
  249. const a = 20000;
  250. const b = 12000 + campus * 1000;
  251. return ok({
  252. userId,
  253. campusId: campus,
  254. usable: fmt(a + b),
  255. items: [
  256. { activityId: 2, amount: fmt(a) },
  257. { activityId: 4, amount: fmt(b) },
  258. ],
  259. });
  260. },
  261. },
  262. /* 32. POST /api/admin/activities/discounts/use —— 使用赠送(≤0 no-op;金额>可用池 → 3109) */
  263. {
  264. method: 'POST',
  265. path: '/api/admin/activities/discounts/use',
  266. handler: (ctx) => {
  267. const denied = checkScope(ctx);
  268. if (denied) return denied;
  269. if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined) return fail(2001, '参数缺失');
  270. // 金额 ≤0 → no-op(used:false,不报错);请求金额须为字符串两位小数(数字 → 2002)
  271. const raw = ctx.body?.amount;
  272. if (raw !== undefined) {
  273. if (typeof raw !== 'string') return fail(2002, 'amount 须为字符串两位小数');
  274. const c = toCents(raw);
  275. if (c <= 0) return ok({ used: false });
  276. // mock 可用池:活动 2 = 200.00 / 活动 4 = 120.00+(与端点 31 同池)
  277. const pool = Number(ctx.body.activityId) === 2 ? 20000 : 13000;
  278. if (c > pool) return fail(3109, '可用赠送金额不足');
  279. }
  280. return ok({ used: true });
  281. },
  282. },
  283. /* 33. POST /api/admin/activities/discounts/refund —— 退回赠送(按报课记录) */
  284. {
  285. method: 'POST',
  286. path: '/api/admin/activities/discounts/refund',
  287. handler: (ctx) => {
  288. const denied = checkScope(ctx);
  289. if (denied) return denied;
  290. if (ctx.body?.coursesStudentId === undefined) return fail(2001, 'coursesStudentId 缺失');
  291. return ok({ refunded: true });
  292. },
  293. },
  294. /* 34. POST /api/admin/activities/discounts/refund-by-activity —— 按活动退回 */
  295. {
  296. method: 'POST',
  297. path: '/api/admin/activities/discounts/refund-by-activity',
  298. handler: (ctx) => {
  299. const denied = checkScope(ctx);
  300. if (denied) return denied;
  301. if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined) return fail(2001, '参数缺失');
  302. return ok({ refunded: true });
  303. },
  304. },
  305. /* 35. POST /api/admin/activities/recharge-gifts/use —— 使用充值赠送(报名行须 status=2) */
  306. {
  307. method: 'POST',
  308. path: '/api/admin/activities/recharge-gifts/use',
  309. handler: (ctx) => {
  310. const denied = checkScope(ctx);
  311. if (denied) return denied;
  312. if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined) return fail(2001, '参数缺失');
  313. const userId = ctx.body.userId as string;
  314. const activityId = Number(ctx.body.activityId);
  315. const act = findActivity(activityId);
  316. const s = act?.signeds.find((x) => x.userId === userId);
  317. if (!s || s.status !== 2) return fail(3001, '该活动报名行未参加活动(需 status=2)');
  318. return ok({ used: true });
  319. },
  320. },
  321. /* 36. POST /api/admin/activities/recharge-gifts/refund —— 退回充值赠送 */
  322. {
  323. method: 'POST',
  324. path: '/api/admin/activities/recharge-gifts/refund',
  325. handler: (ctx) => {
  326. const denied = checkScope(ctx);
  327. if (denied) return denied;
  328. if (ctx.body?.coursesStudentId === undefined) return fail(2001, 'coursesStudentId 缺失');
  329. return ok({ refunded: true });
  330. },
  331. },
  332. /* 37. POST /api/admin/activities/:id/visits —— 回访登记(activitySignedId + memo 必填) */
  333. {
  334. method: 'POST',
  335. path: '/api/admin/activities/:id/visits',
  336. handler: (ctx) => {
  337. const denied = checkScope(ctx);
  338. if (denied) return denied;
  339. const a = findActivity(Number(ctx.params.id));
  340. if (!a) return fail(3001, '活动不存在');
  341. if (ctx.body?.activitySignedId === undefined || ctx.body?.memo === undefined) return fail(2001, 'activitySignedId/memo 缺失');
  342. return ok({ visited: true, activityId: a.id });
  343. },
  344. },
  345. ];