소스 검색

feat(fee): 资金域 9 处对齐新后端契约(P0 参数+P1 拦截/权限)

P0(联调会直接 2001/2002,必须改):
- 端点4 消费 type 必填(白名单 101/104/105/200/201/204/205)+ requestId 幂等
- 端点5 退费 type 必填(207);对齐 Go FundService.Refund:无 3102 校验、扣成负数
- 端点7 转结补 fromInstitutionId/toInstitutionId(四作用域全必填);3140 同校区
- 端点15 驳回 responseMsg 必填(费用页弹输入框留理由,mock 缺参 2001)
- 端点12/13 工单 type 白名单 100/101/202/203/300(reqTypeText 修正+建单类型选择器)

P1(功能不完整):
- 3101 前置拦截:消费/转结提交前 fetchAccount 查 campusBalance,余额不足前端拦(唯一防线)
- 端点2 可退额 campusId 改必填
- 端点8 坏账响应补 balance(双增口径)
- v-perm 接线:费用页写操作按钮 8 处 + mock auth 权限码对齐 40 端点清单(旧 academic.fee.* 格式作废)

mock 同步(对齐真实,避免 mock 比实现更严/更宽):
- 端点5 去 3102、套幂等;端点11 流水不按 campusId 过滤(表无校区列)
- 端点13 type 白名单 2002;端点15 responseMsg 必填 2001
- 幂等包装 requestId 为空时不享幂等(对齐后端不传=可二次入账)
Q 2 시간 전
부모
커밋
c930549f34
5개의 변경된 파일과 297개의 추가작업 그리고 88개의 파일을 삭제
  1. 57 25
      src/api/academic/finance.ts
  2. 25 2
      src/api/mock/data.ts
  3. 30 10
      src/api/mock/routes/auth.ts
  4. 90 25
      src/api/mock/routes/finance.ts
  5. 95 26
      src/views/academic/fee/index.vue

+ 57 - 25
src/api/academic/finance.ts

@@ -69,10 +69,10 @@ export function fetchAccount(userId: string, params: { institutionId: number; ca
   return http.get<R<AccountDTO>>(`/api/admin/finance/accounts/${userId}`, { params }).then((r) => r.data);
 }
 
-/** 可退额(端点 2,GET /api/admin/finance/accounts/:userId/refundable)。 */
+/** 可退额(端点 2,GET /api/admin/finance/accounts/:userId/refundable)。⚠ institutionId 与 campusId 均必填(缺参 2001)。 */
 export function fetchRefundable(
   userId: string,
-  params: { institutionId: number; campusId?: number },
+  params: { institutionId: number; campusId: number },
 ): Promise<R<{ refundableAmount: string }>> {
   return http
     .get<R<{ refundableAmount: string }>>(`/api/admin/finance/accounts/${userId}/refundable`, { params })
@@ -96,20 +96,26 @@ export function fetchDashboard(params: { institutionId: number; campusId?: numbe
 
 /* ================= 写(用户可操作;对齐旧 PHP 财务方法) ================= */
 
-/** 资金工单 DTO(端点 12) */
+/** 资金工单 DTO(端点 12;行 20 字段中前端消费的核心子集)。⚠ type 白名单 100/101/202/203/300(端点 12/13 共用)。 */
 export interface BalanceRequestDTO {
   id: number;
   bizNo: string;
+  /** 工单类型白名单:100 充值 / 101 坏账 / 202 退费 / 203 取消活动 / 300 转结 */
   type: number;
   userId: string;
   institutionId: number;
   campusId: number;
+  /** 仅 type=300(转结)有值,其余为 null */
+  toInstitutionId: number | null;
+  toCampusId: number | null;
   money: string;
   approved: number; // 0 待审 / 1 通过 / -1 驳回 / -2 撤销
+  /** 申请人(注意不是 userId,虽然可能相同) */
   applicantId: string;
-  approverId: string;
+  /** 审批人;未审批为 null */
+  approverId: string | null;
   remark: string;
-  /** 申请时间(RFC3339;v1.6 B2:requestTime ← request_time) */
+  /** 申请时间(RFC3339;v1.6 B2:requestTime ← request_time;本端点**没有** createTime/createdAt) */
   requestTime: string;
   /** 审批时间(RFC3339;未审批时 null) */
   responseTime: string | null;
@@ -139,25 +145,30 @@ export function recharge(body: {
   return http.post<R<{ logId: number; balance: string; repeated: boolean }>>('/api/admin/finance/recharges', body).then((r) => r.data);
 }
 
-/** 消费(端点 4,POST /api/admin/finance/expenditures)。 */
+/** 消费(端点 4,POST /api/admin/finance/expenditures)。⚠ type 必填,白名单 101/104/105/200/201/204/205(不在白名单 → 2002)。 */
 export function expenditure(body: {
   userId: string;
   campusId: number;
-  type?: number;
+  type: number;
   money: string;
+  requestId?: string;
+  remark?: string;
   institutionId: number;
-}): Promise<R<{ logId: number; balance: string }>> {
-  return http.post<R<{ logId: number; balance: string }>>('/api/admin/finance/expenditures', body).then((r) => r.data);
+}): Promise<R<{ logId: number; balance: string; repeated: boolean }>> {
+  return http.post<R<{ logId: number; balance: string; repeated: boolean }>>('/api/admin/finance/expenditures', body).then((r) => r.data);
 }
 
-/** 退费(端点 5,POST /api/admin/finance/refunds)。余额≤0 → 3102。 */
+/** 退费(端点 5,POST /api/admin/finance/refunds)。⚠ type 必填(通常 207)。余额≤0 → 3102。 */
 export function refund(body: {
   userId: string;
   campusId: number;
+  type: number;
   money: string;
+  requestId?: string;
+  remark?: string;
   institutionId: number;
-}): Promise<R<{ logId: number; balance: string; changed: boolean }>> {
-  return http.post<R<{ logId: number; balance: string; changed: boolean }>>('/api/admin/finance/refunds', body).then((r) => r.data);
+}): Promise<R<{ logId: number; balance: string; changed: boolean; repeated: boolean }>> {
+  return http.post<R<{ logId: number; balance: string; changed: boolean; repeated: boolean }>>('/api/admin/finance/refunds', body).then((r) => r.data);
 }
 
 /** 自定义退费结果(端点 6 §3.1;可退不足 → 3103 warn + badDebt;校区不存在 → 3104) */
@@ -188,26 +199,33 @@ export function customRefund(body: {
   return http.post<R<CustomRefundResult>>('/api/admin/finance/refunds/custom', body).then((r) => r.data);
 }
 
-/** 转结(端点 7,POST /api/admin/finance/transfers;校区间转账)。 */
+/**
+ * 转结(端点 7,POST /api/admin/finance/transfers;跨校区/跨机构转账)。
+ * ⚠ 四个作用域参数全必填:fromInstitutionId/fromCampusId/toInstitutionId/toCampusId(缺任一 → 2001)。
+ * ⚠ 后端不校验余额,余额不足会扣成负数——前端提交前须用 fetchAccount 查转出校区余额做前置拦截。
+ */
 export function transfer(body: {
   userId: string;
+  fromInstitutionId: number;
   fromCampusId: number;
+  toInstitutionId: number;
   toCampusId: number;
   money: string;
-  institutionId: number;
-}): Promise<R<{ logId106: number; logId206: number }>> {
-  return http.post<R<{ logId106: number; logId206: number }>>('/api/admin/finance/transfers', body).then((r) => r.data);
+  requestId?: string;
+  remark?: string;
+}): Promise<R<{ logId106: number; logId206: number; fromBalance: string; toBalance: string; repeated: boolean }>> {
+  return http.post<R<{ logId106: number; logId206: number; fromBalance: string; toBalance: string; repeated: boolean }>>('/api/admin/finance/transfers', body).then((r) => r.data);
 }
 
-/** 坏账(端点 8,POST /api/admin/finance/bad-debts)。 */
+/** 坏账(端点 8,POST /api/admin/finance/bad-debts)。⚠ 口径:balance 与 specialExpenses 双增,响应 balance 已含本次增量,展示直接用它。 */
 export function badDebt(body: {
   userId: string;
   campusId: number;
   money: string;
   requestId: string;
   institutionId: number;
-}): Promise<R<{ logId: number; specialExpenses: string; repeated: boolean }>> {
-  return http.post<R<{ logId: number; specialExpenses: string; repeated: boolean }>>('/api/admin/finance/bad-debts', body).then((r) => r.data);
+}): Promise<R<{ logId: number; specialExpenses: string; balance: string; repeated: boolean }>> {
+  return http.post<R<{ logId: number; specialExpenses: string; balance: string; repeated: boolean }>>('/api/admin/finance/bad-debts', body).then((r) => r.data);
 }
 
 /** 余额清零结果(端点 9 §3.2;前置不满足 → 3120) */
@@ -245,14 +263,28 @@ export function createRequest(body: {
   return http.post<R<{ id: number; bizNo: string }>>('/api/admin/finance/requests', body).then((r) => r.data);
 }
 
-/** 审批通过(端点 14,POST /api/admin/finance/requests/:id/approve)。已处理 → 3140。 */
-export function approveRequest(id: number, params: { institutionId: number; campusId?: number }): Promise<R<{ approved: number; result: { logId: number; balance: string } }>> {
-  return http.post<R<{ approved: number; result: { logId: number; balance: string } }>>(`/api/admin/finance/requests/${id}/approve`, {}, { params }).then((r) => r.data);
+/** 审批通过(端点 14,POST /api/admin/finance/requests/:id/approve)。body 可空(一键通过);已处理 → 3140。 */
+export function approveRequest(
+  id: number,
+  params: { institutionId: number; campusId?: number },
+  body?: { responseMsg?: string },
+): Promise<R<{ approved: number; type: number; bizNo: string; result: Record<string, unknown>; repeated: boolean }>> {
+  return http
+    .post<R<{ approved: number; type: number; bizNo: string; result: Record<string, unknown>; repeated: boolean }>>(
+      `/api/admin/finance/requests/${id}/approve`,
+      body ?? {},
+      { params },
+    )
+    .then((r) => r.data);
 }
 
-/** 驳回(端点 15,POST /api/admin/finance/requests/:id/reject)。 */
-export function rejectRequest(id: number, params: { institutionId: number; campusId?: number }): Promise<R<{ approved: number }>> {
-  return http.post<R<{ approved: number }>>(`/api/admin/finance/requests/${id}/reject`, {}, { params }).then((r) => r.data);
+/** 驳回(端点 15,POST /api/admin/finance/requests/:id/reject)。⚠ responseMsg 必填(驳回必须留理由,缺 → 2001)。 */
+export function rejectRequest(
+  id: number,
+  params: { institutionId: number; campusId?: number },
+  body: { responseMsg: string },
+): Promise<R<{ approved: number; id: number }>> {
+  return http.post<R<{ approved: number; id: number }>>(`/api/admin/finance/requests/${id}/reject`, body, { params }).then((r) => r.data);
 }
 
 /** 撤销(端点 16,POST /api/admin/finance/requests/:id/cancel;仅待审)。 */

+ 25 - 2
src/api/mock/data.ts

@@ -258,11 +258,16 @@ export interface BalanceRequest {
   userId: string;
   institutionId: number;
   campusId: number;
+  /** 仅 type=300(转结)有值,其余 0 */
+  toInstitutionId: number;
+  toCampusId: number;
   money: number; // 分
   approved: 0 | 1 | -1 | -2; // 0 待审 / 1 通过 / -1 驳回 / -2 撤销
   applicantId: string;
   approverId: string | null;
   remark: string;
+  /** 审批意见(驳回必填;端点 15 responseMsg) */
+  responseMsg: string;
   /** 申请时间(业务时间 ← request_time;v1.6 B2:`createdAt` 是迁移时生成的新列,不下发) */
   requestTime: string;
   /** 审批时间(业务时间 ← response_time;未审批为 null) */
@@ -275,19 +280,25 @@ const requests: BalanceRequest[] = [];
   for (let i = 0; i < 60; i++) {
     const u = pick(users.filter((x) => x.personType === 'student'));
     const approved = pick([0, 0, 1, -1, -2] as const);
+    const type = pick(requestTypes);
     const requestTime = rfc3339(BASE_MS - i * 7200_000);
+    // type=300(转结)有目标校区,其余无
+    const isTransfer = type === 300;
     requests.push({
       id: ++reqSeq,
       bizNo: `REQ${(700000 + i).toString()}`,
-      type: pick(requestTypes),
+      type,
       userId: u.userId,
       institutionId: 1,
       campusId: u.campusId,
+      toInstitutionId: isTransfer ? 1 : 0,
+      toCampusId: isTransfer ? pick(campuses.filter((c) => c.campusId !== u.campusId)).campusId : 0,
       money: randInt(300, 5000) * 100,
       approved,
       applicantId: u.userId,
       approverId: approved === 0 ? null : ulid(302),
       remark: '工单',
+      responseMsg: approved === -1 ? '材料不齐,请补充凭证后重新提交' : '',
       requestTime,
       responseTime: approved === 0 ? null : rfc3339(Date.parse(requestTime) + 3600_000),
     });
@@ -307,7 +318,16 @@ export function queryRequests(f: { type?: number; approved?: number; userId?: st
 export function findRequest(id: number): BalanceRequest | undefined {
   return requests.find((r) => r.id === id);
 }
-export function createRequest(type: number, userId: string, campusId: number, money: number, remark: string, applicantId: string): BalanceRequest {
+export function createRequest(
+  type: number,
+  userId: string,
+  campusId: number,
+  money: number,
+  remark: string,
+  applicantId: string,
+  toInstitutionId = 0,
+  toCampusId = 0,
+): BalanceRequest {
   const r: BalanceRequest = {
     id: ++reqSeq,
     bizNo: `REQ${(700000 + reqSeq).toString()}`,
@@ -315,11 +335,14 @@ export function createRequest(type: number, userId: string, campusId: number, mo
     userId,
     institutionId: 1,
     campusId,
+    toInstitutionId,
+    toCampusId,
     money,
     approved: 0,
     applicantId,
     approverId: null,
     remark,
+    responseMsg: '',
     requestTime: rfc3339(BASE_MS),
     responseTime: null,
   };

+ 30 - 10
src/api/mock/routes/auth.ts

@@ -110,19 +110,39 @@ function menuFor(role: string): MenuNode[] {
   return role === 'teacher' ? TEACHER_MENU : FULL_MENU;
 }
 
-/** 按 role 取权限码集(含 view + 常见 button;mock 从宽,保证 v-perm 按钮可见)。 */
+/** 按钮级权限码(academic 域 40 端点;与 frontend-sync/issues/2026-11-09-权限码清单-40端点.md 一致,不得自拼)。 */
+const ACADEMIC_BUTTON_PERMS = [
+  'academic.finance_accounts.get', 'academic.finance_accounts_refundable.get',
+  'academic.finance_recharges.create', 'academic.finance_expenditures.create',
+  'academic.finance_refunds.create', 'academic.finance_refunds_custom.create',
+  'academic.finance_transfers.create', 'academic.finance_bad_debts.create',
+  'academic.finance_cleared_balances.create', 'academic.finance_spends.create',
+  'academic.finance_logs.list', 'academic.finance_requests.list',
+  'academic.finance_requests.create', 'academic.finance_requests.approve',
+  'academic.finance_requests.reject', 'academic.finance_requests.cancel',
+  'academic.bill.list', 'academic.finance_dashboard.list',
+  'academic.activities.list', 'academic.activities.create', 'academic.activities.get',
+  'academic.activities.update', 'academic.activities.sign', 'academic.activities.cancel_sign',
+  'academic.activities_signeds.get', 'academic.activities.recommend_settle',
+  'academic.activities.cancel_recharge', 'academic.activities_discounts_usable.list',
+  'academic.activities_discounts_use.create', 'academic.activities_discounts_refund.create',
+  'academic.activities_discounts_refund_by_activity.create',
+  'academic.activities_recharge_gifts_use.create', 'academic.activities_recharge_gifts_refund.create',
+  'academic.activities.visits',
+  'academic.divide_into_tree.list', 'academic.divide_into_members.create',
+  'academic.divide_into_members.quota', 'academic.divide_into_logs.create',
+  'academic.divide_into_logs.delete', 'academic.divide_into_logs.list',
+];
+
+/** 按 role 取权限码集(含 view 叶子码 + 按钮码;mock 从宽,保证 v-perm 按钮可见)。 */
 function permsFor(role: string): string[] {
   const tree = menuFor(role);
   const view = leafCodes(tree);
-  // 按钮级:费用/活动/工单等常见动作
-  const buttons = [
-    'academic.fee.recharge', 'academic.fee.refund', 'academic.fee.clear',
-    'academic.fee.transfer', 'academic.fee.approve', 'academic.fee.export',
-    'academic.activity.sign', 'academic.activity.cancel-sign',
-    'academic.schedule.generate', 'academic.schedule.finish',
-    'teaching.exam.create', 'org.member.create', 'system.user.manage',
-  ];
-  return role === 'teacher' ? view.concat(['academic.schedule.finish', 'teaching.exam.create']) : view.concat(buttons);
+  // 按钮级:teacher 仅活动报名动作;其余角色给全量 academic 40 端点码(课程域第二批码未出,暂不接线)
+  const buttons = role === 'teacher'
+    ? ['academic.activities.sign', 'teaching.exam.create']
+    : ACADEMIC_BUTTON_PERMS.concat(['teaching.exam.create', 'org.member.create', 'system.user.manage']);
+  return view.concat(buttons);
 }
 
 /** mock 登录用户(超管;memberships 覆盖 3 作用域供选择页演示)。 */

+ 90 - 25
src/api/mock/routes/finance.ts

@@ -14,7 +14,7 @@
 import { fail, ok, page } from '@/api/mock/envelope';
 import { isMoney, toCents, fmt } from '@/api/mock/money';
 import { rfc3339 } from '@/api/mock/time';
-import { inScope, findUser, getAccount, getGlobalBalance, appendLog, queryLogs, queryRequests, findRequest, createRequest, idempotentKey, takeIdempotent, setIdempotent } from '@/api/mock/data';
+import { inScope, findUser, findCampus, getAccount, getGlobalBalance, appendLog, queryLogs, queryRequests, findRequest, createRequest, idempotentKey, takeIdempotent, setIdempotent } from '@/api/mock/data';
 import type { ExpenseLog } from '@/api/mock/data';
 import type { MockCtx, MockRoute } from '@/api/mock/adapter';
 import type { R } from '@/types/api';
@@ -35,8 +35,10 @@ function moneyParam(s: unknown): { cents: number } | { err: R<undefined> } {
   return { cents };
 }
 
-/** 幂等包装:同 requestId 重复 → 回放首次结果并置 repeated=true。入参:key/首次构造器。出参:R。 */
+/** 幂等包装:同 requestId 重复 → 回放首次结果并置 repeated=true。入参:key/首次构造器。出参:R。
+ *  ⚠ key 为空(未传 requestId)时**不享幂等**:不存、不放回,直接执行(对齐后端:不传 = 重复提交会二次入账)。 */
 function withIdempotent(key: string, build: () => { data: Record<string, unknown>; repeated?: boolean }): R<unknown> {
+  if (!key || key.endsWith(':')) return ok(build().data);
   const prev = takeIdempotent(key);
   if (prev) return ok({ ...prev, repeated: true });
   const first = build();
@@ -84,7 +86,7 @@ export const financeRoutes: MockRoute[] = [
       });
     },
   },
-  /* 2. GET /api/admin/finance/accounts/:userId/refundable —— {refundableAmount} */
+  /* 2. GET /api/admin/finance/accounts/:userId/refundable —— {refundableAmount}。⚠ institutionId/campusId 均必填(缺 → 2001)。 */
   {
     method: 'GET',
     path: '/api/admin/finance/accounts/:userId/refundable',
@@ -93,7 +95,8 @@ export const financeRoutes: MockRoute[] = [
       if (denied) return denied;
       const u = findUser(ctx.params.userId);
       if (!u) return fail(3001, '用户不存在');
-      const campusId = ctx.query.campusId ? Number(ctx.query.campusId) : u.campusId;
+      if (!ctx.query.campusId || Number(ctx.query.campusId) <= 0) return fail(2001, 'campusId 必填');
+      const campusId = Number(ctx.query.campusId);
       const refundable = getAccount(ctx.params.userId, campusId).campus;
       return ok({ refundableAmount: fmt(Math.max(refundable, 0)) });
     },
@@ -117,7 +120,7 @@ export const financeRoutes: MockRoute[] = [
       });
     },
   },
-  /* 4. POST /api/admin/finance/expenditures —— 消费 {logId,balance} */
+  /* 4. POST /api/admin/finance/expenditures —— 消费 {logId,balance,repeated}。⚠ type 必填且须在白名单内(否则 2002)。 */
   {
     method: 'POST',
     path: '/api/admin/finance/expenditures',
@@ -126,15 +129,25 @@ export const financeRoutes: MockRoute[] = [
       if (denied) return denied;
       const userId = ctx.body?.userId as string;
       const campusId = Number(ctx.body?.campusId ?? 0);
-      const type = Number(ctx.body?.type ?? 101);
+      const type = Number(ctx.body?.type);
       const m = moneyParam(ctx.body?.money);
       if ('err' in m) return m.err;
       if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
-      const log = appendLog(userId, campusId, type, -m.cents, '消费');
-      return ok({ logId: log.id, balance: fmt(getAccount(userId, campusId).total) });
+      // 白名单:101/104/105/200/201/204/205(与后端 FundService.Expenditure 一致;不在白名单 → 2002)
+      const EXPENDITURE_TYPES = [101, 104, 105, 200, 201, 204, 205];
+      if (!EXPENDITURE_TYPES.includes(type)) return fail(2002, 'type 不在白名单(101/104/105/200/201/204/205)');
+      // 幂等:与后端一致(requestId 可空,空则不享幂等)
+      const key = idempotentKey('expenditure', String(ctx.body?.requestId ?? ''));
+      return withIdempotent(key, () => {
+        // 3101 零接线:余额不足不校验,直接扣成负数(复刻旧 SP;前端提交前自行查余额拦截)
+        const log = appendLog(userId, campusId, type, -m.cents, '消费');
+        return { data: { logId: log.id, balance: fmt(getAccount(userId, campusId).total), repeated: false } };
+      });
     },
   },
-  /* 5. POST /api/admin/finance/refunds —— 退费 {logId,balance,changed} */
+  /* 5. POST /api/admin/finance/refunds —— 退费 {logId,balance,changed,repeated}。
+   * ⚠ type 必填且须为已知流水类型(否则 2002);**不做 3102/余额校验**(对齐 Go FundService.Refund:
+   *   3102 只在自定义退费/清零返回,本端点余额≤0 也直接动账扣成负数);changed 恒 true(契约标注但实际恒 true)。 */
   {
     method: 'POST',
     path: '/api/admin/finance/refunds',
@@ -143,14 +156,18 @@ export const financeRoutes: MockRoute[] = [
       if (denied) return denied;
       const userId = ctx.body?.userId as string;
       const campusId = Number(ctx.body?.campusId ?? 0);
+      const type = Number(ctx.body?.type);
       const m = moneyParam(ctx.body?.money);
       if ('err' in m) return m.err;
       if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
-      const before = getAccount(userId, campusId).campus;
-      if (before <= 0) return fail(3102, '账户余额为 0,不可退费');
-      const changed = m.cents > before;
-      const log = appendLog(userId, campusId, 207, -Math.min(m.cents, before), '退费');
-      return ok({ logId: log.id, balance: fmt(getAccount(userId, campusId).total), changed });
+      // 对齐 Go isKnownLogType 白名单(model/models_fund.go §10.2 全集;退费通常 207;不在白名单 → 2002)
+      const KNOWN_LOG_TYPES = [100, 101, 104, 105, 106, 107, 108, 200, 201, 204, 205, 206, 207, 208, 209];
+      if (!KNOWN_LOG_TYPES.includes(type)) return fail(2002, 'type 不在流水类型白名单内');
+      const key = idempotentKey('refund', String(ctx.body?.requestId ?? ''));
+      return withIdempotent(key, () => {
+        const log = appendLog(userId, campusId, type, -m.cents, '退费');
+        return { data: { logId: log.id, balance: fmt(getAccount(userId, campusId).total), changed: true, repeated: false } };
+      });
     },
   },
   /* 6. POST /api/admin/finance/refunds/custom —— CustomRefundResult(§3.1,核心分支) */
@@ -200,7 +217,8 @@ export const financeRoutes: MockRoute[] = [
       });
     },
   },
-  /* 7. POST /api/admin/finance/transfers —— 转结 {logId106,logId206} */
+  /* 7. POST /api/admin/finance/transfers —— 转结 {logId106,logId206,fromBalance,toBalance,repeated}。
+   * ⚠ 四个作用域参数全必填;转出=转入校区 → 3140;不校验余额(3101 零接线,可扣成负数,前端自行前置拦截)。 */
   {
     method: 'POST',
     path: '/api/admin/finance/transfers',
@@ -208,18 +226,36 @@ export const financeRoutes: MockRoute[] = [
       const denied = checkScope(ctx);
       if (denied) return denied;
       const userId = ctx.body?.userId as string;
+      const fromInstitutionId = Number(ctx.body?.fromInstitutionId ?? 0);
       const fromCampus = Number(ctx.body?.fromCampusId ?? 0);
+      const toInstitutionId = Number(ctx.body?.toInstitutionId ?? 0);
       const toCampus = Number(ctx.body?.toCampusId ?? 0);
       const m = moneyParam(ctx.body?.money);
       if ('err' in m) return m.err;
-      if (!userId || !findUser(userId) || fromCampus === 0 || toCampus === 0) return fail(2001, '参数缺失');
-      // dump 口径:106 转结出 = +money,206 转结入 = −money(★以 dump 为准)
-      const log106 = appendLog(userId, fromCampus, 106, m.cents, '转结出');
-      const log206 = appendLog(userId, toCampus, 206, -m.cents, '转结入');
-      return ok({ logId106: log106.id, logId206: log206.id });
+      if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
+      if (!fromInstitutionId || !fromCampus || !toInstitutionId || !toCampus) return fail(2001, 'fromInstitutionId/fromCampusId/toInstitutionId/toCampusId 均必填');
+      if (!findCampus(fromCampus) || !findCampus(toCampus)) return fail(3104, '校区不存在或未归属该机构');
+      if (fromCampus === toCampus && fromInstitutionId === toInstitutionId) return fail(3140, '转出与转入校区相同');
+      const key = idempotentKey('transfer', String(ctx.body?.requestId ?? ''));
+      return withIdempotent(key, () => {
+        const fromBefore = getAccount(userId, fromCampus).campus;
+        const toBefore = getAccount(userId, toCampus).campus;
+        // ★ dump 口径(Go FundService.Transfer 同款,不得"修正"):106 转出 = **+money**、206 转入 = **−money**,不动全局 balance
+        const log106 = appendLog(userId, fromCampus, 106, m.cents, '转结出');
+        const log206 = appendLog(userId, toCampus, 206, -m.cents, '转结入');
+        return {
+          data: {
+            logId106: log106.id,
+            logId206: log206.id,
+            fromBalance: fmt(fromBefore - m.cents),
+            toBalance: fmt(toBefore + m.cents),
+            repeated: false,
+          },
+        };
+      });
     },
   },
-  /* 8. POST /api/admin/finance/bad-debts —— 坏账 {logId,specialExpenses} */
+  /* 8. POST /api/admin/finance/bad-debts —— 坏账 {logId,specialExpenses,balance,repeated}。⚠ 口径:balance 与 specialExpenses 双增。 */
   {
     method: 'POST',
     path: '/api/admin/finance/bad-debts',
@@ -233,8 +269,17 @@ export const financeRoutes: MockRoute[] = [
       if (!userId || !findUser(userId)) return fail(2001, 'userId 缺失或用户不存在');
       const key = idempotentKey('bad-debt', String(ctx.body?.requestId ?? ''));
       return withIdempotent(key, () => {
+        // 口径:坏账 = balance 与 special_expenses 双增(响应 balance 已含本次增量,前端直接展示)
+        const before = getAccount(userId, campusId);
         const log = appendLog(userId, campusId, 108, m.cents, '坏账处理');
-        return { data: { logId: log.id, specialExpenses: fmt(m.cents), repeated: false } };
+        return {
+          data: {
+            logId: log.id,
+            specialExpenses: fmt(before.campus + m.cents),
+            balance: fmt(before.campus + m.cents),
+            repeated: false,
+          },
+        };
       });
     },
   },
@@ -293,13 +338,14 @@ export const financeRoutes: MockRoute[] = [
     handler: (ctx) => {
       const denied = checkScope(ctx);
       if (denied) return denied;
+      // 对齐真实:流水表 t_user_expense_log 无 institution_id/campus_id 列,**无法按校区过滤**(传 campusId 也不生效)。
+      // 故此处只传 userId/type/from/to(作用域校验已由 checkScope 完成),campusId 刻意不传。
       const rows = queryLogs({
         userId: ctx.query.userId,
         type: ctx.query.type !== undefined ? Number(ctx.query.type) : undefined,
         from: ctx.query.from,
         to: ctx.query.to,
         institutionId: ctx.query.institutionId ? Number(ctx.query.institutionId) : 1,
-        campusId: ctx.query.campusId ? Number(ctx.query.campusId) : 0,
       });
       return page(rows.map(logRow), Number(ctx.query.page ?? 1), Number(ctx.query.pageSize ?? 10));
     },
@@ -325,6 +371,9 @@ export const financeRoutes: MockRoute[] = [
         userId: r.userId,
         institutionId: r.institutionId,
         campusId: r.campusId,
+        // 仅 type=300(转结)有目标校区,其余 0(对齐真实:非转结为 null,前端 DTO 用 number|null,0 等价)
+        toInstitutionId: r.toInstitutionId || null,
+        toCampusId: r.toCampusId || null,
         money: fmt(r.money),
         approved: r.approved,
         applicantId: r.applicantId,
@@ -350,7 +399,19 @@ export const financeRoutes: MockRoute[] = [
       const m = moneyParam(ctx.body?.money);
       if ('err' in m) return m.err;
       if (!type || !userId || !findUser(userId)) return fail(2001, 'type/userId 缺失或非法');
-      const r = createRequest(type, userId, campusId, m.cents, String(ctx.body?.remark ?? ''), userId);
+      // 白名单 100/101/202/203/300(对齐真实;非白名单 → 2002)
+      const REQUEST_TYPES = [100, 101, 202, 203, 300];
+      if (!REQUEST_TYPES.includes(type)) return fail(2002, 'type 不在白名单(100/101/202/203/300)');
+      const r = createRequest(
+        type,
+        userId,
+        campusId,
+        m.cents,
+        String(ctx.body?.remark ?? ''),
+        userId,
+        Number(ctx.body?.toInstitutionId ?? 0),
+        Number(ctx.body?.toCampusId ?? 0),
+      );
       return ok({ id: r.id, bizNo: r.bizNo });
     },
   },
@@ -377,13 +438,17 @@ export const financeRoutes: MockRoute[] = [
     handler: (ctx) => {
       const denied = checkScope(ctx);
       if (denied) return denied;
+      // 对齐真实:responseMsg 必填(驳回必须留理由,缺 → 2001)
+      const msg = String(ctx.body?.responseMsg ?? '').trim();
+      if (!msg) return fail(2001, 'responseMsg 必填(驳回必须留理由)');
       const r = findRequest(Number(ctx.params.id));
       if (!r) return fail(3001, '工单不存在');
       if (r.approved !== 0) return fail(3140, '工单已处理,不可重复操作');
       r.approved = -1;
       r.approverId = '01J2M' + '302'.padStart(21, '0');
+      r.responseMsg = msg;
       r.responseTime = rfc3339(Date.now());
-      return ok({ approved: -1 });
+      return ok({ approved: -1, id: r.id });
     },
   },
   /* 16. POST /api/admin/finance/requests/:id/cancel —— 撤销(仅 approved=0) */

+ 95 - 26
src/views/academic/fee/index.vue

@@ -18,7 +18,7 @@
  *   - 幂等:写操作生成一次性 requestId(防误触重复提交回放);
  *   - 敏感操作(清零/坏账/自定义退费)二次确认(ElMessageBox.confirm,README §3.3-9)。
  */
-import { listLogs, listRequests, fetchDashboard, recharge, expenditure, refund, customRefund, transfer, badDebt, clearBalance, spend, createRequest, approveRequest, rejectRequest, cancelRequest } from '@/api/academic/finance';
+import { listLogs, listRequests, fetchDashboard, fetchAccount, recharge, expenditure, refund, customRefund, transfer, badDebt, clearBalance, spend, createRequest, approveRequest, rejectRequest, cancelRequest } from '@/api/academic/finance';
 import type { ExpenseLogDTO, BalanceRequestDTO, DashboardDTO } from '@/api/academic/finance';
 import { listCampuses } from '@/api/org/campus';
 import type { CampusDTO } from '@/api/org/campus';
@@ -44,6 +44,12 @@ function ElConfirm(text: string): Promise<void> {
   if (!B) return Promise.resolve();
   return B.confirm(text, '敏感操作确认', { confirmButtonText: '执行', cancelButtonText: '取消', type: 'warning' }).then(() => undefined).catch(() => Promise.reject(new Error('cancel')));
 }
+/** 文本输入对话框(驳回理由必填用;UMD 全局 ElMessageBox.prompt)。 */
+function ElPrompt(text: string): Promise<string> {
+  const B = (globalThis.ElementPlus ?? {}).ElMessageBox;
+  if (!B) return Promise.resolve('');
+  return (B as { prompt: (t: string, title: string, o: Record<string, unknown>) => Promise<{ value: string }> }).prompt(text, '请输入理由', { confirmButtonText: '确定', cancelButtonText: '取消', inputType: 'textarea', inputPlaceholder: '必填' }).then((r) => r.value).catch(() => Promise.reject(new Error('cancel')));
+}
 
 /* ================= 作用户 + 校区 ================= */
 const memberOpts = ref<MemberDTO[]>([]);
@@ -105,11 +111,39 @@ function onLogSearch(): void {
 /** 金额本地用 number(el-input-number 绑定);提交时 moneyPlain 转两位小数字符串(契约 §1.4) */
 const opForm = reactive({ money: null as number | null, remark: '' });
 const transferForm = reactive({ fromCampus: 0, toCampus: 0, money: null as number | null });
+/** 工单类型(端点 13 白名单:100 充值 / 101 坏账 / 202 退费 / 203 取消充值活动 / 300 转结) */
+const requestType = ref<number>(100);
+const requestTypeOptions: { value: number; label: string }[] = [
+  { value: 100, label: '充值' },
+  { value: 101, label: '坏账' },
+  { value: 202, label: '退费' },
+  { value: 203, label: '取消充值活动' },
+  { value: 300, label: '转结' },
+];
 const busy = ref(false);
 
 function nextRequestId(prefix: string): string {
   return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
 }
+/**
+ * 3101 前置拦截(唯一防线):消费/转结提交前查转出/目标校区余额。
+ * 依据 01-fund.md §3.3:3101 全仓零返回点,后端不校验余额、允许扣成负数 ⇒ 前端必须在提交前拦。
+ * 入参:userId/campusId/money(元)。出参:true=可提交,false=已提示余额不足。
+ */
+async function guardBalance(userId: string, campusId: number, moneyYuan: number): Promise<boolean> {
+  if (moneyYuan <= 0) return true;
+  const r = await fetchAccount(userId, { institutionId: scope.current.institutionId, campusId });
+  if (r.code !== 200 || !r.data) {
+    ElMessage_('error', '无法获取账户余额,操作已取消');
+    return false;
+  }
+  const balance = Number(r.data.campusBalance) || 0;
+  if (balance < moneyYuan) {
+    ElMessage_('warning', `校区余额不足:当前 ¥${r.data.campusBalance},需 ¥${moneyYuan.toFixed(2)}`);
+    return false;
+  }
+  return true;
+}
 /** 统一执行包装:置忙 → 调 api → code 判定 toast → 刷新流水/仪表盘。入参:fn。 */
 async function runOp(fn: () => Promise<{ code: number; message: string }>): Promise<void> {
   if (busy.value || !selectedUserId.value) return;
@@ -132,11 +166,16 @@ async function runOp(fn: () => Promise<{ code: number; message: string }>): Prom
 function doRecharge(): void {
   void runOp(() => recharge({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: moneyPlain(opForm.money ?? 0), requestId: nextRequestId('rc'), institutionId: scope.current.institutionId }));
 }
-function doExpenditure(): void {
-  void runOp(() => expenditure({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: moneyPlain(opForm.money ?? 0), institutionId: scope.current.institutionId }));
+/** 消费(端点 4,type 必填白名单 101/104/105/200/201/204/205;3101 前置拦截)。 */
+async function doExpenditure(): Promise<void> {
+  const ok = await guardBalance(selectedUserId.value, selectedCampusId.value, opForm.money ?? 0);
+  if (!ok) return;
+  // 默认 200(历史扣费);如需其他白名单类型,可扩展页面选择器
+  void runOp(() => expenditure({ userId: selectedUserId.value, campusId: selectedCampusId.value, type: 200, money: moneyPlain(opForm.money ?? 0), requestId: nextRequestId('ex'), institutionId: scope.current.institutionId }));
 }
+/** 退费(端点 5,type 必填,通常 207)。 */
 function doRefund(): void {
-  void runOp(() => refund({ userId: selectedUserId.value, campusId: selectedCampusId.value, money: moneyPlain(opForm.money ?? 0), institutionId: scope.current.institutionId }));
+  void runOp(() => refund({ userId: selectedUserId.value, campusId: selectedCampusId.value, type: 207, money: moneyPlain(opForm.money ?? 0), requestId: nextRequestId('rf'), institutionId: scope.current.institutionId }));
 }
 async function doCustomRefund(): Promise<void> {
   try {
@@ -165,12 +204,19 @@ async function doClear(): Promise<void> {
 function doSpend(): void {
   void runOp(() => spend({ userId: selectedUserId.value, campusId: selectedCampusId.value, spendMoney: moneyPlain(opForm.money ?? 0), institutionId: scope.current.institutionId }));
 }
-function doTransfer(): void {
+/**
+ * 转结(端点 7,四作用域参数全必填)。
+ * 3101 前置拦截:提交前查**转出校区**余额(转入校区不拦——转结语义是"把钱挪过去",转入方余额可负)。
+ */
+async function doTransfer(): Promise<void> {
   if (transferForm.fromCampus === 0 || transferForm.toCampus === 0 || transferForm.fromCampus === transferForm.toCampus) {
     ElMessage_('warning', '请选择两个不同的校区');
     return;
   }
-  void runOp(() => transfer({ userId: selectedUserId.value, fromCampusId: transferForm.fromCampus, toCampusId: transferForm.toCampus, money: moneyPlain(transferForm.money ?? 0), institutionId: scope.current.institutionId }));
+  const ok = await guardBalance(selectedUserId.value, transferForm.fromCampus, transferForm.money ?? 0);
+  if (!ok) return;
+  const inst = scope.current.institutionId;
+  void runOp(() => transfer({ userId: selectedUserId.value, fromInstitutionId: inst, fromCampusId: transferForm.fromCampus, toInstitutionId: inst, toCampusId: transferForm.toCampus, money: moneyPlain(transferForm.money ?? 0), requestId: nextRequestId('tf') }));
 }
 
 /* ================= 资金工单(端点 12~16) ================= */
@@ -181,11 +227,12 @@ const { rows: reqs, total: reqTotal, page: reqPage, pageSize: reqPageSize, loadi
 onMounted(() => {
   void fetchReqs();
 });
-const reqTypeText: Record<number, string> = { 1: '退费', 2: '清零', 3: '坏账', 4: '特殊' };
+/** 工单类型白名单(端点 12/13:100 充值 / 101 坏账 / 202 退费 / 203 取消充值活动 / 300 转结;非白名单 → 2002) */
+const reqTypeText: Record<number, string> = { 100: '充值', 101: '坏账', 202: '退费', 203: '取消充值活动', 300: '转结' };
 const reqStatusText: Record<number, string> = { 0: '待审', 1: '已通过', '-1': '已驳回', '-2': '已撤销' };
 const reqColumns: ListColumn<BalanceRequestDTO>[] = [
   { prop: 'bizNo', label: '单号', minWidth: 140 },
-  { prop: 'type', label: '类型', minWidth: 80, formatter: (r) => reqTypeText[r.type] ?? String(r.type) },
+  { prop: 'type', label: '类型', minWidth: 110, formatter: (r) => reqTypeText[r.type] ?? String(r.type) },
   { prop: 'money', label: '金额', minWidth: 100 },
   { prop: 'approved', label: '状态', minWidth: 90, formatter: (r) => reqStatusText[r.approved] ?? String(r.approved) },
   { prop: 'remark', label: '备注', minWidth: 120 },
@@ -195,29 +242,48 @@ function onReqSearch(): void {
   reqPage.value = 1;
   void fetchReqs();
 }
+/** 建工单(端点 13;type 白名单 100/101/202/203/300,页面按 requestType 选择器取值,杜绝非白名单 2002)。 */
 function doCreateRequest(): void {
-  void runOp(() => createRequest({ type: 1, userId: selectedUserId.value, campusId: selectedCampusId.value, money: moneyPlain(opForm.money ?? 0), remark: opForm.remark || '工单', institutionId: scope.current.institutionId }).then((r) => ({ code: r.code, message: r.message })));
+  void runOp(() => createRequest({ type: requestType.value, userId: selectedUserId.value, campusId: selectedCampusId.value, money: moneyPlain(opForm.money ?? 0), remark: opForm.remark || '工单', institutionId: scope.current.institutionId }).then((r) => ({ code: r.code, message: r.message })));
 }
 async function doApprove(r: BalanceRequestDTO): Promise<void> {
   await runOp(() => approveRequest(r.id, { ...scope.toQuery(), campusId: selectedCampusId.value || undefined }).then((x) => ({ code: x.code, message: x.message })));
 }
-function doReject(r: BalanceRequestDTO): void {
-  void runOp(() => rejectRequest(r.id, { ...scope.toQuery(), campusId: selectedCampusId.value || undefined }).then((x) => ({ code: x.code, message: x.message })));
+/** 驳回(端点 15;⚠ responseMsg 必填——先弹输入框留理由,取消则中止)。 */
+async function doReject(r: BalanceRequestDTO): Promise<void> {
+  let msg = '';
+  try {
+    msg = await ElPrompt('驳回必须填写理由(对账/申诉依据)');
+  } catch {
+    return;
+  }
+  const text = (msg ?? '').trim();
+  if (!text) {
+    ElMessage_('warning', '驳回理由不能为空');
+    return;
+  }
+  void runOp(() => rejectRequest(r.id, { ...scope.toQuery(), campusId: selectedCampusId.value || undefined }, { responseMsg: text }).then((x) => ({ code: x.code, message: x.message })));
 }
 function doCancelReq(r: BalanceRequestDTO): void {
   void runOp(() => cancelRequest(r.id, { ...scope.toQuery(), campusId: selectedCampusId.value || undefined }).then((x) => ({ code: x.code, message: x.message })));
 }
 
 /* ================= 操作按钮区 ================= */
-const ops = [
-  { label: '充值', fn: doRecharge },
-  { label: '消费', fn: doExpenditure },
-  { label: '退费', fn: doRefund },
-  { label: '自定义退费', fn: doCustomRefund },
-  { label: '坏账', fn: doBadDebt },
-  { label: '清零', fn: doClear },
-  { label: 'spend 扣减', fn: doSpend },
-] as const;
+/** 账户操作按钮(label + 执行函数 + 按钮级权限码,权限码取自 40 端点清单)。 */
+interface FeeOp {
+  label: string;
+  perm: string;
+  fn: () => void | Promise<void>;
+}
+const ops: FeeOp[] = [
+  { label: '充值', perm: 'academic.finance_recharges.create', fn: doRecharge },
+  { label: '消费', perm: 'academic.finance_expenditures.create', fn: doExpenditure },
+  { label: '退费', perm: 'academic.finance_refunds.create', fn: doRefund },
+  { label: '自定义退费', perm: 'academic.finance_refunds_custom.create', fn: doCustomRefund },
+  { label: '坏账', perm: 'academic.finance_bad_debts.create', fn: doBadDebt },
+  { label: '清零', perm: 'academic.finance_cleared_balances.create', fn: doClear },
+  { label: 'spend 扣减', perm: 'academic.finance_spends.create', fn: doSpend },
+];
 </script>
 
 <template>
@@ -280,7 +346,7 @@ const ops = [
             <el-input v-model="opForm.remark" placeholder="备注(工单用)" style="width: 200px" />
           </div>
           <div class="fee-page__op-btns">
-            <el-button v-for="op in ops" :key="op.label" type="primary" :disabled="!selectedUserId" :loading="busy" @click="op.fn">
+            <el-button v-for="op in ops" :key="op.label" v-perm="op.perm" type="primary" :disabled="!selectedUserId" :loading="busy" @click="op.fn">
               {{ op.label }}
             </el-button>
             <div class="fee-page__transfer">
@@ -291,8 +357,11 @@ const ops = [
                 <el-option v-for="c in campusOpts" :key="c.campusId" :value="c.campusId" :label="c.campusName" />
               </el-select>
               <el-input-number v-model="transferForm.money" :min="0" :precision="2" :step="100" controls-position="right" style="width: 120px" />
-              <el-button type="warning" :disabled="!selectedUserId" :loading="busy" @click="doTransfer">转结</el-button>
-              <el-button type="info" :disabled="!selectedUserId" :loading="busy" @click="doCreateRequest">建资金工单</el-button>
+              <el-button v-perm="'academic.finance_transfers.create'" type="warning" :disabled="!selectedUserId" :loading="busy" @click="doTransfer">转结</el-button>
+              <el-select v-model="requestType" style="width: 150px">
+                <el-option v-for="t in requestTypeOptions" :key="t.value" :value="t.value" :label="t.label" />
+              </el-select>
+              <el-button v-perm="'academic.finance_requests.create'" type="info" :disabled="!selectedUserId" :loading="busy" @click="doCreateRequest">建资金工单</el-button>
             </div>
           </div>
         </div>
@@ -322,9 +391,9 @@ const ops = [
         <div class="fee-page__req-actions">
           <div v-for="r in reqs" :key="r.id" class="fee-page__req-row">
             <span class="fee-page__req-no">{{ r.bizNo }}</span>
-            <el-button size="small" type="primary" :disabled="r.approved !== 0" :loading="busy" @click="doApprove(r)">通过</el-button>
-            <el-button size="small" type="warning" :disabled="r.approved !== 0" :loading="busy" @click="doReject(r)">驳回</el-button>
-            <el-button size="small" :disabled="r.approved !== 0" :loading="busy" @click="doCancelReq(r)">撤销</el-button>
+            <el-button v-perm="'academic.finance_requests.approve'" size="small" type="primary" :disabled="r.approved !== 0" :loading="busy" @click="doApprove(r)">通过</el-button>
+            <el-button v-perm="'academic.finance_requests.reject'" size="small" type="warning" :disabled="r.approved !== 0" :loading="busy" @click="doReject(r)">驳回</el-button>
+            <el-button v-perm="'academic.finance_requests.cancel'" size="small" :disabled="r.approved !== 0" :loading="busy" @click="doCancelReq(r)">撤销</el-button>
           </div>
         </div>
       </el-tab-pane>