package teamsalarysettlement import ( "database/sql" "fmt" "strconv" "strings" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/response" "github.com/gin-gonic/gin" ) type Handler struct { audit shared.OperationLogger service *Service } // New 只做 HTTP 层组装,具体工资口径全部下沉到 Service,避免 handler 里复制账务逻辑。 func New(adminDB *sql.DB, walletDB *sql.DB, userDB *sql.DB, audit shared.OperationLogger) *Handler { return &Handler{audit: audit, service: NewService(adminDB, walletDB, userDB)} } // Service 暴露给自动任务 runner 复用同一个结算引擎,保证手动和自动结算走同一套规则。 func (h *Handler) Service() *Service { if h == nil { return nil } return h.service } // ListPending 返回当前筛选条件下可产生正向工资差值的 BD/Admin 用户。 func (h *Handler) ListPending(c *gin.Context) { req, ok := parsePendingQuery(c) if !ok { return } items, total, err := h.service.ListPending(c.Request.Context(), appctx.FromContext(c.Request.Context()), req) if err != nil { response.ServerError(c, "获取待结算工资失败") return } response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total}) } // ListRecords 返回已经生成 settlement_id 的 BD/Admin 结算记录,用于后台查账。 func (h *Handler) ListRecords(c *gin.Context) { req, ok := parseRecordQuery(c) if !ok { return } items, total, err := h.service.ListRecords(c.Request.Context(), appctx.FromContext(c.Request.Context()), req) if err != nil { response.ServerError(c, "获取 BD/Admin 结算记录失败") return } response.OK(c, response.Page{Items: items, Page: req.Page, PageSize: req.PageSize, Total: total}) } // Settle 执行后台批量手动结算;前端传入 user_ids 后,Service 会再次按候选白名单过滤。 func (h *Handler) Settle(c *gin.Context) { var req settleRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "结算参数不正确") return } req.PolicyType = normalizePolicyType(req.PolicyType) req.TriggerMode = normalizeTriggerMode(req.TriggerMode) req.CycleKey = strings.TrimSpace(req.CycleKey) req.CountryCode = strings.ToUpper(strings.TrimSpace(req.CountryCode)) if req.PolicyType == "" { response.BadRequest(c, "工资类型不正确") return } if req.TriggerMode == "" { response.BadRequest(c, "触发方式不正确") return } result, err := h.service.Settle(c.Request.Context(), appctx.FromContext(c.Request.Context()), int64(shared.ActorFromContext(c).UserID), req) if err != nil { response.ServerError(c, "工资结算失败") return } shared.OperationLog(c, h.audit, "settle-team-salary", "team_salary_settlement_records", "success", fmt.Sprintf("policy_type=%s cycle_key=%s success=%d skipped=%d failed=%d", result.PolicyType, result.CycleKey, result.SuccessCount, result.SkippedCount, result.FailureCount)) response.OK(c, result) } // parsePendingQuery 兼容 snake_case/camelCase 查询参数,便于前端直接传 API DTO 或内部模型字段。 func parsePendingQuery(c *gin.Context) (pendingQuery, bool) { options := shared.ListOptions(c) req := pendingQuery{ Page: options.Page, PageSize: options.PageSize, PolicyType: normalizePolicyType(firstQuery(c, "policy_type", "policyType")), TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")), CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")), CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))), UserFilter: shared.UserIdentityFilterFromQuery(c, ""), } var ok bool if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok { response.BadRequest(c, "区域 ID 不正确") return pendingQuery{}, false } if req.CountryID, ok = optionalInt64(c, "country_id", "countryId"); !ok { response.BadRequest(c, "国家 ID 不正确") return pendingQuery{}, false } if req.UserID, ok = optionalInt64(c, "user_id", "userId"); !ok { response.BadRequest(c, "用户 ID 不正确") return pendingQuery{}, false } if raw := strings.TrimSpace(firstQuery(c, "policy_type", "policyType")); raw != "" && req.PolicyType == "" { response.BadRequest(c, "工资类型不正确") return pendingQuery{}, false } if raw := strings.TrimSpace(firstQuery(c, "trigger_mode", "triggerMode")); raw != "" && req.TriggerMode == "" { response.BadRequest(c, "触发方式不正确") return pendingQuery{}, false } return normalizePendingQuery(req), true } // parseRecordQuery 统一记录列表的分页、枚举和时间区间校验,避免把非法筛选传到 SQL 组装层。 func parseRecordQuery(c *gin.Context) (recordQuery, bool) { options := shared.ListOptions(c) req := recordQuery{ Page: options.Page, PageSize: options.PageSize, PolicyType: normalizePolicyType(firstQuery(c, "policy_type", "policyType")), TriggerMode: normalizeTriggerMode(firstQuery(c, "trigger_mode", "triggerMode")), CycleKey: strings.TrimSpace(firstQuery(c, "cycle_key", "cycleKey")), Status: normalizeStatus(firstQuery(c, "status")), CountryCode: strings.ToUpper(strings.TrimSpace(firstQuery(c, "country_code", "countryCode"))), UserFilter: shared.UserIdentityFilterFromQuery(c, ""), } var ok bool if req.RegionID, ok = optionalInt64(c, "region_id", "regionId"); !ok { response.BadRequest(c, "区域 ID 不正确") return recordQuery{}, false } if req.CountryID, ok = optionalInt64(c, "country_id", "countryId"); !ok { response.BadRequest(c, "国家 ID 不正确") return recordQuery{}, false } if req.UserID, ok = optionalInt64(c, "user_id", "userId"); !ok { response.BadRequest(c, "用户 ID 不正确") return recordQuery{}, false } if req.PolicyID, ok = optionalUint64(c, "policy_id", "policyId"); !ok { response.BadRequest(c, "政策 ID 不正确") return recordQuery{}, false } if req.StartAtMS, ok = optionalInt64(c, "start_at_ms", "startAtMs"); !ok { response.BadRequest(c, "开始时间不正确") return recordQuery{}, false } if req.EndAtMS, ok = optionalInt64(c, "end_at_ms", "endAtMs"); !ok { response.BadRequest(c, "结束时间不正确") return recordQuery{}, false } if req.StartAtMS > 0 && req.EndAtMS > 0 && req.StartAtMS >= req.EndAtMS { response.BadRequest(c, "时间区间不正确") return recordQuery{}, false } if raw := strings.TrimSpace(firstQuery(c, "policy_type", "policyType")); raw != "" && req.PolicyType == "" { response.BadRequest(c, "工资类型不正确") return recordQuery{}, false } if raw := strings.TrimSpace(firstQuery(c, "trigger_mode", "triggerMode")); raw != "" && req.TriggerMode == "" { response.BadRequest(c, "触发方式不正确") return recordQuery{}, false } if raw := strings.TrimSpace(firstQuery(c, "status")); raw != "" && strings.ToLower(raw) != "all" && req.Status == "" { response.BadRequest(c, "状态不正确") return recordQuery{}, false } return normalizeRecordQuery(req), true } // optionalInt64 解析可选正整数查询参数;空值代表不筛选。 func optionalInt64(c *gin.Context, keys ...string) (int64, bool) { value := strings.TrimSpace(firstQuery(c, keys...)) if value == "" { return 0, true } parsed, err := strconv.ParseInt(value, 10, 64) return parsed, err == nil && parsed >= 0 } // optionalUint64 解析可选无符号整数,当前用于 policy_id。 func optionalUint64(c *gin.Context, keys ...string) (uint64, bool) { value := strings.TrimSpace(firstQuery(c, keys...)) if value == "" { return 0, true } parsed, err := strconv.ParseUint(value, 10, 64) return parsed, err == nil } // firstQuery 按优先级读取同义查询参数,避免每个字段重复写 snake/camel fallback。 func firstQuery(c *gin.Context, keys ...string) string { for _, key := range keys { if value := strings.TrimSpace(c.Query(key)); value != "" { return value } } return "" }