|
|
@@ -1,11 +1,16 @@
|
|
|
/**
|
|
|
* routes/activities.ts —— 活动端点 mock(academic 契约 §2.2 端点 22~37,16 个)。
|
|
|
*
|
|
|
- * 契约对齐(academic-frontend-contract-v1.md v1.1 冻结):
|
|
|
- * - 报名交互:活动 status=0(停用)**仍允许报名**(dump 无可用性校验,契约 §2.2 注释)→ mock 不禁用;
|
|
|
- * - 分支:3105(已报名该活动)/ 3106(老带新每人限一次)/ 3107(已审核通过不可取消)/ 3108(老带新已产生消费不可取消);
|
|
|
- * - 取消报名:activityMoney/activityMoneyUsed 归零(mock 以 signed.status=-1 + money=0 表达,前端刷新整行);
|
|
|
- * - 赠送/充值赠送可用额(usable)按校区活动赠送池返回。
|
|
|
+ * 契约对齐(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。
|
|
|
*/
|
|
|
@@ -14,6 +19,7 @@ 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';
|
|
|
|
|
|
@@ -47,7 +53,7 @@ function activityRow(a: Activity): Record<string, unknown> {
|
|
|
status: a.status,
|
|
|
numberSign: a.numberSign,
|
|
|
total: a.total,
|
|
|
- signed: a.signeds.filter((s) => s.status === 1).length,
|
|
|
+ signed: a.signeds.filter((s) => s.status !== 0).length,
|
|
|
money: fmt(a.money),
|
|
|
signable: a.signable,
|
|
|
startTime: a.startTime,
|
|
|
@@ -63,9 +69,14 @@ export const activityRoutes: MockRoute[] = [
|
|
|
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: ctx.query.type !== undefined ? Number(ctx.query.type) : undefined,
|
|
|
- status: ctx.query.status !== undefined ? Number(ctx.query.status) : undefined,
|
|
|
+ type,
|
|
|
+ status,
|
|
|
keyword: ctx.query.keyword,
|
|
|
institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
|
|
|
campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0,
|
|
|
@@ -80,11 +91,13 @@ export const activityRoutes: MockRoute[] = [
|
|
|
handler: (ctx) => {
|
|
|
const denied = checkScope(ctx);
|
|
|
if (denied) return denied;
|
|
|
- const name = ctx.body?.name as string | undefined;
|
|
|
+ // 请求字段是 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 (!name || ![2, 3, 4].includes(type)) return fail(2001, 'name 缺失或 type 非法(2/3/4)');
|
|
|
+ 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);
|
|
|
- // v1.6 B5:t_courses_activity 无 biz_no 列 ⇒ 不下发 bizNo(活动是内部配置实体,无需对外单号)
|
|
|
+ // t_courses_activity 无 biz_no 列 ⇒ 不下发 bizNo
|
|
|
return ok({ id: newId });
|
|
|
},
|
|
|
},
|
|
|
@@ -97,11 +110,10 @@ export const activityRoutes: MockRoute[] = [
|
|
|
if (denied) return denied;
|
|
|
const a = findActivity(Number(ctx.params.id));
|
|
|
if (!a) return fail(3001, '活动不存在');
|
|
|
- // v1.6 C2/E6:详情同 22 行形状(startTime/endTime + 活动级 money),内嵌 signeds(上限 200,超出走端点 28)
|
|
|
return ok({ ...activityRow(a), signeds: a.signeds.slice(0, 200).map(signRow) });
|
|
|
},
|
|
|
},
|
|
|
- /* 25. PUT /api/admin/activities/:id —— 更新(numberSign 不可写) */
|
|
|
+ /* 25. PUT /api/admin/activities/:id —— 更新(整体替换:未传 content/memo/thumbnail 置空;numberSign 禁写) */
|
|
|
{
|
|
|
method: 'PUT',
|
|
|
path: '/api/admin/activities/:id',
|
|
|
@@ -110,13 +122,19 @@ export const activityRoutes: MockRoute[] = [
|
|
|
if (denied) return denied;
|
|
|
const a = findActivity(Number(ctx.params.id));
|
|
|
if (!a) return fail(3001, '活动不存在');
|
|
|
- // v1.6 E5:numberSign 不可写属"传了不可写字段"(单一参数非法)⇒ 2002(3140 只收"参数互相矛盾")
|
|
|
- if (ctx.body?.numberSign !== undefined) return fail(2002, 'numberSign 不可修改');
|
|
|
- a.name = (ctx.body?.name as string) ?? a.name;
|
|
|
+ // 整体替换语义:未传字段置空(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 —— 报名(3105/3106 分支;status=0 仍允许) */
|
|
|
+ /* 26. POST /api/admin/activities/:id/sign —— 报名(无 money 入参;status<1 归 1;3105/3106 分支) */
|
|
|
{
|
|
|
method: 'POST',
|
|
|
path: '/api/admin/activities/:id/sign',
|
|
|
@@ -127,24 +145,26 @@ export const activityRoutes: MockRoute[] = [
|
|
|
if (!a) return fail(3001, '活动不存在');
|
|
|
const userId = ctx.body?.userId as string;
|
|
|
if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
|
|
|
- const valid = a.signeds.filter((s) => s.status === 1);
|
|
|
- // 3105:已报名该活动(存在 status=1 的本人报名)
|
|
|
- if (valid.some((s) => s.userId === userId)) return fail(3105, '已报名该活动');
|
|
|
- // 3106:老带新每人限参加一次(type=2 且本人已有任意 status=1 老带新报名)
|
|
|
- if (a.type === 2 && a.signeds.some((s) => s.userId === userId && s.status === 1)) return fail(3106, '老带新每人限参加一次');
|
|
|
+ // 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: Number(ctx.body?.status ?? 0),
|
|
|
+ status,
|
|
|
recommendUserId: (ctx.body?.recommendUserId as string) ?? null,
|
|
|
- money: toCents(String(ctx.body?.money ?? '0.00')),
|
|
|
+ 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 —— 取消报名(3107/3108;归零金额) */
|
|
|
+ /* 27. POST /api/admin/activities/:id/cancel-sign —— 取消报名(status=0 + money 归零;3107/3108 分支) */
|
|
|
{
|
|
|
method: 'POST',
|
|
|
path: '/api/admin/activities/:id/cancel-sign',
|
|
|
@@ -155,18 +175,19 @@ export const activityRoutes: MockRoute[] = [
|
|
|
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 === 1);
|
|
|
+ const s = a.signeds.find((x) => x.userId === userId && x.status !== 0);
|
|
|
if (!s) return fail(3001, '未找到有效报名');
|
|
|
- // v1.6 E7:3107 判据按"报名/活动状态"判(名额报满 = 视为已结算/审核通过),不再用"报名人数过半"近似
|
|
|
- if (a.signeds.filter((x) => x.status === 1).length >= a.total) return fail(3107, '活动已审核通过,不可取消');
|
|
|
- // 3108:老带新已产生消费不可取消(type=2 且 money 已核销 = money>0)
|
|
|
- if (a.type === 2 && s.money > 0) return fail(3108, '老带新已产生消费,不可取消');
|
|
|
- s.status = -1;
|
|
|
- s.money = 0; // activityMoney/activityMoneyUsed 归零(契约 §2.2:前端刷新整行)
|
|
|
+ // 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 —— 报名列表 */
|
|
|
+ /* 28. GET /api/admin/activities/:id/signeds —— 报名列表(status 可筛 0~4) */
|
|
|
{
|
|
|
method: 'GET',
|
|
|
path: '/api/admin/activities/:id/signeds',
|
|
|
@@ -176,11 +197,15 @@ export const activityRoutes: MockRoute[] = [
|
|
|
const a = findActivity(Number(ctx.params.id));
|
|
|
if (!a) return fail(3001, '活动不存在');
|
|
|
let rows = a.signeds;
|
|
|
- if (ctx.query.status !== undefined) rows = rows.filter((s) => s.status === Number(ctx.query.status));
|
|
|
+ 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 —— 老带新结算 */
|
|
|
+ /* 29. POST /api/admin/activities/:id/recommend-settle —— 老带新结算(settled:false = 正常 LEAVE,非错误) */
|
|
|
{
|
|
|
method: 'POST',
|
|
|
path: '/api/admin/activities/:id/recommend-settle',
|
|
|
@@ -193,7 +218,7 @@ export const activityRoutes: MockRoute[] = [
|
|
|
return ok({ settled: true, activityId: a.id });
|
|
|
},
|
|
|
},
|
|
|
- /* 30. POST /api/admin/activities/:id/cancel-recharge —— 取消充值活动 */
|
|
|
+ /* 30. POST /api/admin/activities/:id/cancel-recharge —— 取消充值活动(反向扣款,无余额上限校验) */
|
|
|
{
|
|
|
method: 'POST',
|
|
|
path: '/api/admin/activities/:id/cancel-recharge',
|
|
|
@@ -206,7 +231,7 @@ export const activityRoutes: MockRoute[] = [
|
|
|
return ok({ cancelled: true });
|
|
|
},
|
|
|
},
|
|
|
- /* 31. GET /api/admin/activities/discounts/usable —— 可用赠送 */
|
|
|
+ /* 31. GET /api/admin/activities/discounts/usable —— 可用赠送(平台视角 1003 校验) */
|
|
|
{
|
|
|
method: 'GET',
|
|
|
path: '/api/admin/activities/discounts/usable',
|
|
|
@@ -215,11 +240,16 @@ export const activityRoutes: MockRoute[] = [
|
|
|
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;
|
|
|
- // v1.6 B5:赠送池表无 memo 列 ⇒ items 不带 memo(不得用活动标题顶替)。
|
|
|
- // usable = Σ items(同一快照);按 activityId 升序。
|
|
|
+ // 赠送池表无 memo 列 ⇒ items 不带 memo;usable = Σ items(同一快照);按 activityId 升序
|
|
|
const a = 20000;
|
|
|
const b = 12000 + campus * 1000;
|
|
|
return ok({
|
|
|
@@ -233,27 +263,35 @@ export const activityRoutes: MockRoute[] = [
|
|
|
});
|
|
|
},
|
|
|
},
|
|
|
- /* 32. POST /api/admin/activities/discounts/use —— 使用赠送 */
|
|
|
+ /* 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 || ctx.body?.coursesStudentId === undefined || ctx.body?.amount === undefined) {
|
|
|
- return fail(2001, '参数缺失');
|
|
|
+ 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 —— 退回赠送 */
|
|
|
+ /* 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 || ctx.body?.amount === undefined) return fail(2001, '参数缺失');
|
|
|
+ if (ctx.body?.coursesStudentId === undefined) return fail(2001, 'coursesStudentId 缺失');
|
|
|
return ok({ refunded: true });
|
|
|
},
|
|
|
},
|
|
|
@@ -264,20 +302,23 @@ export const activityRoutes: MockRoute[] = [
|
|
|
handler: (ctx) => {
|
|
|
const denied = checkScope(ctx);
|
|
|
if (denied) return denied;
|
|
|
- if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined || ctx.body?.amount === undefined) return fail(2001, '参数缺失');
|
|
|
+ if (ctx.body?.userId === undefined || ctx.body?.activityId === undefined) return fail(2001, '参数缺失');
|
|
|
return ok({ refunded: true });
|
|
|
},
|
|
|
},
|
|
|
- /* 35. POST /api/admin/activities/recharge-gifts/use —— 使用充值赠送 */
|
|
|
+ /* 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 || ctx.body?.coursesStudentId === undefined || ctx.body?.amount === undefined) {
|
|
|
- return fail(2001, '参数缺失');
|
|
|
- }
|
|
|
+ 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 });
|
|
|
},
|
|
|
},
|
|
|
@@ -288,11 +329,11 @@ export const activityRoutes: MockRoute[] = [
|
|
|
handler: (ctx) => {
|
|
|
const denied = checkScope(ctx);
|
|
|
if (denied) return denied;
|
|
|
- if (ctx.body?.coursesStudentId === undefined || ctx.body?.amount === undefined) return fail(2001, '参数缺失');
|
|
|
+ if (ctx.body?.coursesStudentId === undefined) return fail(2001, 'coursesStudentId 缺失');
|
|
|
return ok({ refunded: true });
|
|
|
},
|
|
|
},
|
|
|
- /* 37. POST /api/admin/activities/:id/visits —— 活动到访 */
|
|
|
+ /* 37. POST /api/admin/activities/:id/visits —— 回访登记(activitySignedId + memo 必填) */
|
|
|
{
|
|
|
method: 'POST',
|
|
|
path: '/api/admin/activities/:id/visits',
|