| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- /**
- * 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> | 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<string, unknown> {
- 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<string, unknown> {
- 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 });
- },
- },
- ];
|