// 学生账单查询服务(sp_user_expense_get_bill → BillService.GetBill)。 // // 依据:unified/03-migration/academic-domain-design.md // - §3.4 #25 `sp_user_expense_get_bill`(dump **764094~764238**;4 张 MEMORY 临时表 → 内存结构) // - §3.8-**E2**(`create_time` 属**业务时间**,参与对账、逐值 0 容差) // - §3.8-**A18**(`type=108` 双来源:**只归因、不裁剪**——本服务的余额类汇总一律全类型计入) // - §5.1 纪律 4「横切优先」(账单恒等式 + 期初/期末不随分页变化) // // --------------------------------------------------------------------------- // dump 骨架(764116~764198,**按原文,不做"优化"**) // --------------------------------------------------------------------------- // // CREATE TEMPORARY TABLE temp_user_expense_log(type INT, logid VARCHAR(50), money DECIMAL(10,3), create_time VARCHAR(100)); // INSERT INTO temp_user_expense_log SELECT type,logid,money,create_time FROM t_user_expense_log WHERE userid=inUserid; // -- 同法 temp_courses_activity_signed / temp_courses_activity_used_log / temp_user_balance_request // // 764173 SELECT SUM(money) FROM temp_user_expense_log WHERE type=107 AND create_time BETWEEN inStartTime AND inEndTime; -- 当期推荐额 // 764175 SELECT SUM(money) FROM temp_user_expense_log WHERE type in(101,201,104,204,105,205) AND create_time BETWEEN ...; -- 当期消费 // 764177 SELECT SUM(money) FROM temp_user_expense_log as log1 WHERE create_time= from),`to` 是**不含**上界(< to)—— 对"余额/期初期末"而言。 // **若前端要"含当日",必须传次日或 `23:59:59`**(例如查 2026-03-01 当日流水,应传 // from=2026-03-01T00:00:00, to=2026-03-02T00:00:00;传 to=2026-03-01T00:00:00 会得到该日 0 条)。 // dump 用 `VARCHAR` 做 `BETWEEN`,语义随字符串格式漂移;目标态改用 `datetime(3)` 后, // **入参语义按上述定死**(这是"目标态显式化",不是行为漂移:旧系统前端一直传 `YYYY-MM-DD`, // dump 里 `BETWEEN '2026-03-01' AND '2026-03-31'` 同样不含 03-31 的时分秒部分)。 // // --------------------------------------------------------------------------- // ★ `campusIds` 入参的真相(dump 里是**死参数**) // --------------------------------------------------------------------------- // // dump 764094 声明了 `IN inCampusids VARCHAR(1000)`,但 764095~764237 的**整段过程体一次都没引用它** // (在 dump 里 grep 结果只有声明行)。因此本服务的 `CampusIDs` **只做解析/回显,不做过滤**—— // 与 dump 完全一致。绝不为此给 `t_user_expense_log` 加 campus 维度(§3.8-f 约束 2: // 流水表无 campus 列,账单恒等式只能按 user 做);若产品确需"校区视图",须另开"按 // t_courses_student / t_user_expense_school 的 join 路径"需求,不能在这里偷偷过滤。 package service import ( "context" "strconv" "strings" "time" "gorm.io/gorm" "yuansiwei/unified-backend/internal/academic/model" "yuansiwei/unified-backend/internal/academic/money" "yuansiwei/unified-backend/internal/academic/port" "yuansiwei/unified-backend/internal/academic/repository" infraerrors "yuansiwei/unified-backend/internal/infra/errors" appscope "yuansiwei/unified-backend/internal/infra/scope" ) // 账单汇总用到的流水类型集合(dump 764175 原文 `type in(101,201,104,204,105,205)`)。 var ( // billSpendTypes 「当期消费」类型集合(逐值与 dump 764175 的 IN 列表一致)。 billSpendTypes = []int32{ model.ExpenseLogTypeCourseCampus, // 101 model.ExpenseLogTypeCourseCampusB, // 201 model.ExpenseLogTypeCourseSchoolA, // 104 model.ExpenseLogTypeCourseSchoolC, // 204 model.ExpenseLogTypeCourseSchoolB, // 105 model.ExpenseLogTypeCourseSchoolD, // 205 } ) // BillService 学生账单查询服务(只读;无事务)。 // // 字段: // - repo 账单仓储(4 张来源表按 user 全量取回); // - member 成员校验器(可 nil;非 nil 时对"学生是否属于当前机构/校区"做越权预检); // - sc 请求作用域(可 nil:系统任务/双跑脚本视角)。 type BillService struct { repo *repository.BillRepo member port.MembershipChecker sc *appscope.Scope } // NewBillService 构造账单服务。 // // 入参:db *gorm.DB(必填);sc *appscope.Scope(请求作用域;系统任务可 nil)。 // 出参:*BillService(成员校验需 `WithMembershipChecker` 注入后生效)。 func NewBillService(db *gorm.DB, sc *appscope.Scope) *BillService { return &BillService{repo: repository.NewBillRepo(db), sc: sc} } // WithMembershipChecker 注入成员校验器(返自身,便于链式装配)。 // // 入参:member port.MembershipChecker(nil 表示不做越权预检——仅限双跑/回填脚本)。 // 出参:*BillService(同一实例)。 func (s *BillService) WithMembershipChecker(member port.MembershipChecker) *BillService { s.member = member return s } // GetBillInput 账单查询入参(对齐 dump 的 4 个入参 + 明细分页)。 // // 字段: // - UserID 学生(ULID;必填); // - CampusIDs 校区逗号串(dump 第二入参 `inCampusids`;**只解析回显、不过滤**,见文件头); // - From 起始时间(**含**下界); // - To 结束时间(**不含**上界——"期末"口径;「当期发生额」按 dump 的 BETWEEN 含右端); // - Page / PageSize 明细分页(<=0 由 ClampPage 归一;PageSize 上限 200)。 type GetBillInput struct { // UserID 学生(canonical_user_id / ULID 26 位)。 UserID string // CampusIDs 校区逗号串(如 "1,2,3";"" 或不传 = 不加过滤;**dump 未使用,仅回显**)。 CampusIDs string // From 起始时间(含)。 From time.Time // To 结束时间(不含)。 To time.Time // Page 页码(1 基)。 Page int // PageSize 每页条数。 PageSize int } // BillSummary 账单汇总(dump 764228-764236 的 9 个 SELECT 列 + 3 个对账辅助列)。 // // 全部金额为 `money.Amount`(分,定点),JSON 输出为"两位小数字符串"(如 "123.45")—— // 与旧库 `DECIMAL(10,3)` 的显示习惯对齐到 2 位(目标态 DDL 为 `decimal(12,2)`)。 type BillSummary struct { // PrevBalance 期初余额 = Σ(money WHERE create_time < From)(dump 764179 `previousBalance`)。 PrevBalance money.Amount `json:"prevBalance"` // EndBalance 期末余额 = Σ(money WHERE create_time < To)(dump 764177 `currentBalance`)。 EndBalance money.Amount `json:"endBalance"` // Recharge 当期充值 = Σ(money WHERE type=100 AND From <= create_time <= To)(dump 764116 `DECLARE rechargeCur CURSOR ...`,764159 `OPEN`)。 Recharge money.Amount `json:"recharge"` // Gift 当期赠送额 = Σ(signed.activity_money)(经"充值工单 × 报名"关联,dump 764116 游标第二列 `SUM(activity.activity_money)`)。 Gift money.Amount `json:"gift"` // RecommendAmount 当期老带新推荐额 = Σ(money WHERE type=107 AND 区间内)(dump 764173)。 RecommendAmount money.Amount `json:"recommendAmount"` // Spend 当期消费 = Σ(money WHERE type IN (101,201,104,204,105,205) AND 区间内)(dump 764175)。 Spend money.Amount `json:"spend"` // GiveUsed 当期使用赠送额(dump 764193 `currentUsedGiveCost` = Gift + GivePrevBalance − GiveEndBalance)。 GiveUsed money.Amount `json:"giveUsed"` // GivePrevBalance 期初赠送结余(dump 764187-764189 `previousGiveCost`;口径见 giveBalanceBefore)。 GivePrevBalance money.Amount `json:"givePrevBalance"` // GiveEndBalance 期末赠送结余(dump 764182-764184 `currentGiveCost`;口径同上)。 GiveEndBalance money.Amount `json:"giveEndBalance"` // FlowExclusiveEnd 窗口内全类型流水合计(From <= t **<** To)—— // **恒等于 EndBalance − PrevBalance**(§5.1 第 4 条:账单恒等式的可判定形式)。 FlowExclusiveEnd money.Amount `json:"flowExclusiveEnd"` // FlowInclusiveEnd 窗口内全类型流水合计(From <= t **<=** To)—— // 与「当期发生额」同口径(BETWEEN 含右端)。二者之差 == 落在 `t = To` 的那些流水。 FlowInclusiveEnd money.Amount `json:"flowInclusiveEnd"` // CampusIDs dump 第二入参的解析结果(**仅回显**:dump 全程未引用该参数,本服务不过滤)。 CampusIDs []int64 `json:"campusIds"` } // BillDetail 账单明细行(分页;按 `create_time DESC, id DESC`)。 type BillDetail struct { // ID 流水 ID(同一时刻多行的稳定排序键)。 ID int64 `json:"id"` // Type 流水类型(model.ExpenseLogType*)。 Type int32 `json:"type"` // TypeTitle 类型中文名(只用于展示;未登记类型返回 "其它")。 TypeTitle string `json:"typeTitle"` // LogID 业务单号(原 logid;可为空串)。 LogID string `json:"logId"` // Money 变更金额(元;符号已表方向,见 model.ExpenseLogType* 注释)。 Money money.Amount `json:"money"` // CreateTime 业务时间(§3.8-E2 ①类,参与对账、逐值 0 容差)。 CreateTime time.Time `json:"createTime"` } // BillResult 账单查询结果(汇总 + 明细分页两段,设计 §3.4 #25「两段返回」)。 type BillResult struct { // Summary 汇总(**不受分页影响**)。 Summary BillSummary `json:"summary"` // Details 明细(当前页)。 Details []BillDetail `json:"details"` // Total 明细总条数(分页总数)。 Total int64 `json:"total"` // Page 当前页码(已钳制)。 Page int `json:"page"` // PageSize 每页条数(已钳制,∈[1,200])。 PageSize int `json:"pageSize"` // Pages 总页数 = ceil(Total / PageSize)(分页契约要求五字段齐全:list/total/page/pageSize/pages)。 // // 算法纪律(**别用 `math.Ceil(float64)`**):整数向上取整 `(Total + PageSize - 1) / PageSize`; // `PageSize <= 0` 时恒 `0`(防除零——虽然 PageSize 已被 ClampPage 钳到 ≥1,这道守卫是给 // "将来有人绕过 Service 直接构造 BillResult" 用的)。 // 入参用**钳制后**的 PageSize 与**窗口内**的 Total,不是请求原始值。 Pages int `json:"pages"` // From / To 回显查询窗口(审计用;语义见 GetBillInput)。 From time.Time `json:"from"` To time.Time `json:"to"` } // GetBill 查询学生账单(汇总 + 明细分页)。 // // 入参: // - ctx 请求上下文; // - in GetBillInput(UserID 必填;From/To 必填且 From <= To)。 // // 出参: // - *BillResult:汇总 + 明细;明细为空时 Details 为长度 0 的非 nil 切片; // - ErrBadParam(2001) 缺参/时间窗非法;ErrUnscoped(1004) 越权(注入了 MembershipChecker 时); // ErrDBError(5002) 数据库异常或金额字面量解析失败。 // // **明细口径(v1.4 口径更正)**:`Details` 是**查询窗口内的流水**,条件与 `FlowExclusiveEnd` // **完全一致**(`from <= create_time < to`,左闭右开),按 `create_time DESC, id DESC` 分页; // `Total` 用**逐字相同**的 WHERE 统计(同快照,不二次查)。 // // 为什么改(原实现"明细不过滤、汇总按窗口"是错的):那会让同一响应里出现两个作用域—— // `summary` 是 3 月的、明细却是全部历史、`total` 是历史总数。前端选"3 月账单"翻到的是全量流水, // 属真 bug 级体验问题;而"明细与汇总口径不一致"恰恰是 §5.1 纪律 4 要防的那类"各比各的"。 // dump 没有明细列表 ⇒ 这块**无 dump 约束**,可按上述自洽口径自由设计。 // 明细与汇总的口径差**只有一处**且是显式的:落在 `t = to` 的流水进 `FlowInclusiveEnd`(含右端), // 但不进明细(右开)——前端口径表已写明,`flowInclusiveEnd` 就是给它看的。 // // 只读:本方法**不开事务**(dump 的 4 张 MEMORY 临时表是一次性快照,等价于"同一读视图"; // 目标态在单连接上顺序执行 4 次查询,落库并发写入造成的瞬间不一致由"流水只增不改"兜底)。 func (s *BillService) GetBill(ctx context.Context, in GetBillInput) (*BillResult, error) { if strings.TrimSpace(in.UserID) == "" { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: GetBill 需要 user_id")) } if in.From.IsZero() || in.To.IsZero() { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: GetBill 需要 from/to(账户区间对账的两个边界都是必填)")) } if in.From.After(in.To) { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: GetBill 的 from 不能晚于 to")) } campusIDs, err := parseCampusIDs(in.CampusIDs) if err != nil { return nil, err } // 越权预检:4 张来源表都无作用域列(或跨校区可见),必须在这里断"该学生属于当前机构/校区" if s.member != nil && s.sc != nil { if err := s.member.AssertInScope(ctx, in.UserID, s.sc.InstitutionID, s.sc.CampusID); err != nil { return nil, err } } logs, err := s.repo.LoadLogs(ctx, in.UserID) if err != nil { return nil, err } // 充值工单**只在"赠送额"这条链上用到**(dump 764116-764121 游标的 LEFT JOIN),BETWEEN 含两端。 reqNos, err := s.repo.LoadRechargeRequestNos(ctx, in.UserID, in.From, in.To) if err != nil { return nil, err } signed, err := s.repo.LoadActivitySigned(ctx, in.UserID) if err != nil { return nil, err } used, err := s.repo.LoadActivityUsedLogs(ctx, in.UserID) if err != nil { return nil, err } summary, err := buildBillSummary(logs, reqNos, signed, used, in.From, in.To) if err != nil { return nil, err } summary.CampusIDs = campusIDs page := repository.PageQuery{Page: in.Page, PageSize: in.PageSize} pageNo, pageSize := page.Normalize() // 明细与汇总同窗口:`from <= create_time < to`(右开,与 FlowExclusiveEnd 同口径); // total 与 list 用**逐字相同**的 WHERE(同快照,不二次查)。 total, err := s.repo.CountLogs(ctx, in.UserID, in.From, in.To) if err != nil { return nil, err } rows, err := s.repo.PageLogs(ctx, in.UserID, in.From, in.To, page) if err != nil { return nil, err } details := make([]BillDetail, 0, len(rows)) for i := range rows { amount, perr := money.Parse(rows[i].Money) if perr != nil { return nil, infraerrors.WithCause(infraerrors.ErrDBError, perr) } logID := "" if rows[i].LogID != nil { logID = *rows[i].LogID } details = append(details, BillDetail{ ID: rows[i].ID, Type: rows[i].Type, TypeTitle: billTypeTitle(rows[i].Type), LogID: logID, Money: amount, CreateTime: rows[i].CreateTime, }) } return &BillResult{ Summary: summary, Details: details, Total: total, Page: pageNo, PageSize: pageSize, Pages: billPages(total, pageSize), From: in.From, To: in.To, }, nil } // billPages 计算总页数(整数向上取整;纯函数,便于边界单测)。 // // 入参:total 窗口内流水条数(已按查询窗口统计);pageSize 每页条数(**已钳制**,通常 ∈[1,200])。 // 出参:总页数;`pageSize <= 0` 恒 0(防除零)。 // // 边界(两个最容易写错的地方,用例逐个钉住): // - `total = 0` → **0** 页(不是 1 页:空结果集不该显示"第 1/1 页"); // - `total = 25, pageSize = 10` → **3** 页(向上取整;用整数运算 `(25+10-1)/10`, // **不要** `math.Ceil(2.5)` 再转 int——浮点在 1e16 量级的 total 上会丢精度,且多一条 import)。 func billPages(total int64, pageSize int) int { if pageSize <= 0 { return 0 } if total <= 0 { return 0 } size := int64(pageSize) return int((total + size - 1) / size) } // buildBillSummary 由 4 份内存结构算出 dump 的 9 个汇总列 + 3 个对账辅助列(纯函数,便于单测)。 // // 入参: // - logs 该 user 全量流水(LoadLogs); // - reqNos 区间内已通过的普通充值工单 biz_no 列表(LoadRechargeRequestNos,BETWEEN 含两端); // - signed 该 user 全部活动报名行(LoadActivitySigned); // - used 该 user 全部老带新获得/使用记录(LoadActivityUsedLogs); // - from/to 查询窗口(from 含、to 不含;「当期发生额」用 BETWEEN 含 to)。 // // 出参:BillSummary;金额字面量非法 → ErrDBError。 // // 累加纪律:**全部在 Go 侧用 money.Amount 定点累加**(禁止 SQL SUM(decimal),见 BillRepo 头注释)。 func buildBillSummary( logs []model.UserExpenseLog, reqNos []string, signed []model.CoursesActivitySigned, used []model.CoursesActivityUsedLog, from, to time.Time, ) (BillSummary, error) { var out BillSummary // dump 764116 游标:充值工单参与集合(`temp_user_balance_request`,764128 装入) rechargeReqNos := make(map[string]struct{}, len(reqNos)) for _, no := range reqNos { if no == "" { continue // 空串不参与匹配(旧库 requestid 可为空串,误配会凭空造出赠送额) } rechargeReqNos[no] = struct{}{} } // 实际参与赠送额汇总的工单号:dump 的 join 路径是 `log.logid = request.id`, // 因此**还要有一条区间内的 type=100 流水**指向该工单,才可能把 signed.activity_money 计进来。 matchedReqNos := make(map[string]struct{}, len(rechargeReqNos)) for i := range logs { row := logs[i] amount, err := money.Parse(row.Money) if err != nil { return BillSummary{}, infraerrors.WithCause(infraerrors.ErrDBError, err) } // 期初 / 期末:`create_time < 边界`(不含右端,dump 764177 / 764179) if row.CreateTime.Before(from) { out.PrevBalance = out.PrevBalance.Add(amount) } if row.CreateTime.Before(to) { out.EndBalance = out.EndBalance.Add(amount) } // 窗口内合计:两个口径各算一次(差额 == 落在 t = to 的流水,见 BillSummary 字段注释) if !row.CreateTime.Before(from) { if row.CreateTime.Before(to) { out.FlowExclusiveEnd = out.FlowExclusiveEnd.Add(amount) } if !row.CreateTime.After(to) { out.FlowInclusiveEnd = out.FlowInclusiveEnd.Add(amount) } } // 三类「当期发生额」:BETWEEN = 两端闭(dump 764173 / 764175 / 764116) if row.CreateTime.Before(from) || row.CreateTime.After(to) { continue } switch { case row.Type == model.ExpenseLogTypeRecharge: out.Recharge = out.Recharge.Add(amount) if row.LogID != nil { if _, ok := rechargeReqNos[*row.LogID]; ok { matchedReqNos[*row.LogID] = struct{}{} } } case row.Type == model.ExpenseLogTypeRecommend: out.RecommendAmount = out.RecommendAmount.Add(amount) case isBillSpendType(row.Type): out.Spend = out.Spend.Add(amount) } } // 当期赠送额:Σ(signed.activity_money) where signed.request_no ∈ matchedReqNos signedByActivity := make(map[int64]money.Amount, len(signed)) for i := range signed { amount, err := money.Parse(signed[i].ActivityMoney) if err != nil { return BillSummary{}, infraerrors.WithCause(infraerrors.ErrDBError, err) } signedByActivity[signed[i].ActivityID] = signedByActivity[signed[i].ActivityID].Add(amount) if signed[i].RequestNo == nil { continue } if _, ok := matchedReqNos[*signed[i].RequestNo]; ok { out.Gift = out.Gift.Add(amount) } } // 赠送结余(dump 764182-764189):RIGHT JOIN 使"**只有出现过 used_log 的活动**"才进入汇总, // 且 used_log 的金额是**原值相加**(老带新结算写负值,重放写正值)——逐字复刻,不做符号归一。 giveEnd, err := giveBalanceBefore(used, signedByActivity, to) if err != nil { return BillSummary{}, err } givePrev, err := giveBalanceBefore(used, signedByActivity, from) if err != nil { return BillSummary{}, err } out.GiveEndBalance = giveEnd out.GivePrevBalance = givePrev // dump 764193:`SET currentUsedGiveCost = currentTotalActivityMoney + previousGiveCost - currentGiveCost` out.GiveUsed = out.Gift.Add(givePrev).Sub(giveEnd) return out, nil } // giveBalanceBefore 复刻 dump 764182-764189 的"某个 cutoff 之前的赠送金额结余"。 // // dump 原文(原文嵌套三层;这里按语义等价拆解,**箭头右侧是本函数的实现**): // // SELECT SUM(activity_money) , SUM(activity_money_used) // FROM ( SELECT signed.activity_money, usedLog.activity_money_used // FROM temp_courses_activity_signed signed // RIGHT JOIN ( SELECT activityid, SUM(activity_money_used) AS activity_money_used // FROM temp_courses_activity_used_log // WHERE create_time < cutoff GROUP BY activityid ) usedLog // ON signed.activityid = usedLog.activityid ) t1 // → 结果 = Σ(该 user 在"有 used_log(< cutoff)的活动"上的全部 signed.activity_money) // + Σ(used_log.activity_money_used where create_time < cutoff) // // **RIGHT JOIN 的三个后果**(前两条**复刻**、第三条**刻意不复刻** —— 别把三条一视同仁): // 1. 某活动若从未产生 used_log(< cutoff),其 signed.activity_money **完全不计入**结余; // —— **复刻**(dump 的语义事实,不是缺陷)。 // 2. 某活动只有 used_log、没有 signed 行时,signed 侧为 NULL,SUM 按 0 计(不报错); // —— **复刻**(同上)。 // 3. 同一活动有 **k 条 signed** 时,该活动的 `activity_money_used` 汇总会被**重复计入 k 次** // (`SUM` 建在 `signed RIGHT JOIN (… GROUP BY activityid)` 上 ⇒ SQL 扇出 / fan-out)。 // —— **刻意不复刻**(设计 §3.8-**A21**,归因 `DUMP-BUGFIX`):本函数按 `activity_id` 先去重、 // 每个活动的 signed 额与 used 额**各只计一次**。 // 真实数据**仅 1 例**(`user 32596 / activity 1`,两条各 `1000.000`);cutoff `2020-01-01` 时 // dump 得 `737.022`、Go 得 `1737.022`,**差 1000.000**;跨该活动的窗口 `GiveUsed` 同样差。 // **⚠ 不要"照抄"第 3 条** —— 那是 dump 的缺陷;补回去等于主动引入 A21 已排除的错误。 // 用例:`TestGetBillGiveBalanceDeduplicatesSignedRows`(造"同活动 2 条 signed",断言只计一次)。 // // 入参: // - used 全量 used_log; // - signedByActivity 按 activity_id 汇总的 signed.activity_money(**无时间过滤**); // - cutoff 截止时间(严格小于:`create_time < cutoff`)。 // // 出参:money.Amount 结余(可为负);金额字面量非法 → ErrDBError。 func giveBalanceBefore( used []model.CoursesActivityUsedLog, signedByActivity map[int64]money.Amount, cutoff time.Time, ) (money.Amount, error) { total := money.Amount(0) activityHit := make(map[int64]struct{}, len(used)) usedSum := money.Amount(0) for i := range used { if !used[i].CreateTime.Before(cutoff) { continue } amount, err := money.Parse(used[i].ActivityMoneyUsed) if err != nil { return 0, infraerrors.WithCause(infraerrors.ErrDBError, err) } usedSum = usedSum.Add(amount) activityHit[used[i].ActivityID] = struct{}{} } total = total.Add(usedSum) for activityID := range activityHit { total = total.Add(signedByActivity[activityID]) } return total, nil } // isBillSpendType 判断流水类型是否属于「当期消费」集合(dump 764175 的 IN 列表)。 func isBillSpendType(logType int32) bool { for _, t := range billSpendTypes { if t == logType { return true } } return false } // parseCampusIDs 解析 dump 第二入参(逗号串 `"1,2,3"`;空串 → 空切片)。 // // 入参:raw 原始字符串(可含空格)。 // 出参:[]int64 解析结果(**仅回显**,不参与过滤);非法片段 → ErrBadParam。 // // 说明:dump 该参数是**死参数**(764094 声明、过程体零引用),这里只做"格式合法"校验, // 让前端传错(如 "1,a")时立刻报错,而不是悄悄当作"没过滤"跑出一份"看起来对"的账单。 func parseCampusIDs(raw string) ([]int64, error) { trimmed := strings.TrimSpace(raw) if trimmed == "" { return []int64{}, nil } parts := strings.Split(trimmed, ",") out := make([]int64, 0, len(parts)) for _, part := range parts { item := strings.TrimSpace(part) if item == "" { continue } id, err := strconv.ParseInt(item, 10, 64) if err != nil || id <= 0 { return nil, infraerrors.WithCause(infraerrors.ErrBadParam, errString("service: campusIds 必须是正整数逗号串(非法片段: "+item+")")) } out = append(out, id) } return out, nil } // billTypeTitle 流水类型的展示名(只用于明细展示;未登记类型返回 "其它")。 // // 入参:logType 流水类型编码(model.ExpenseLogType*)。 // 出参:中文标题常量。 func billTypeTitle(logType int32) string { switch logType { case model.ExpenseLogTypeRecharge: return "充值" case model.ExpenseLogTypeCourseCampus, model.ExpenseLogTypeCourseCampusB: return "课程消费(校区)" case model.ExpenseLogTypeCourseSchoolA, model.ExpenseLogTypeCourseSchoolB, model.ExpenseLogTypeCourseSchoolC, model.ExpenseLogTypeCourseSchoolD: return "课程消费(学校)" case model.ExpenseLogTypeTransferOut: return "课费转结(转出)" case model.ExpenseLogTypeTransferIn: return "课费转结(转入)" case model.ExpenseLogTypeRecommend: return "老带新推荐" case model.ExpenseLogTypeBadDebt: return "坏账处理" case model.ExpenseLogTypeHistoryDeduct: return "历史扣费" case model.ExpenseLogTypeRefund: return "余额退费" case model.ExpenseLogTypeClearBalance: return "余额清零" case model.ExpenseLogTypeCancelRechargeAct: return "取消充值活动" default: return "其它" } }