feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
This commit is contained in:
2026-09-18 15:34:29 +08:00
parent 5e78809b93
commit 5ed6b39deb
142 changed files with 7878 additions and 964 deletions

View File

@@ -5,8 +5,10 @@ import (
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/bytedance/sonic"
"gorm.io/datatypes"
"gorm.io/gorm"
employeecollectiondomain "github.com/break/junhong_cmp_fiber/internal/domain/employeecollection"
@@ -15,11 +17,27 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// billDateLayout 是账单筛选使用的自然日格式。
// billDateLayout 是核销申请列表筛选使用的自然日格式。
// 账单列表与统计已改用统一时间筛选start_time/end_time 严格解析),不再使用本格式;
// 核销申请列表端点不在本次统一时间筛选的受影响端点表内,按 As-Is 保留其既有日期语义。
const billDateLayout = "2006-01-02"
// parseBillDate 解析自然日筛选值;空值表示不筛选。仅供核销申请列表使用。
func parseBillDate(value string) (*time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return nil, nil
}
parsed, err := time.ParseInLocation(billDateLayout, trimmed, time.Local)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "账单筛选日期格式必须为 YYYY-MM-DD")
}
return &parsed, nil
}
// BillQuery 查询员工代收款账单列表、统计与详情。
// 列表与统计共用 applyBillScope 生成的同一个 *gorm.DB禁止两处各写一套筛选。
type BillQuery struct {
@@ -127,11 +145,143 @@ func (q *BillQuery) Detail(ctx context.Context, id uint) (*dto.EmployeeCollectio
if err != nil {
return nil, err
}
operations, err := q.billOperations(ctx, bill.ID)
if err != nil {
return nil, err
}
sourceOrder := q.billSourceSnapshot(ctx, &bill)
return &dto.EmployeeCollectionBillDetailResponse{
Bill: billResponse, Refunds: refunds, Allocations: allocations, Applications: applications,
Operations: operations, SourceOrder: sourceOrder,
}, nil
}
// billOperations 读取该账单维度的既有审计事实投影。
//
// 审计事实按资源读取而非按平台事件列表读取:账单详情对欠款人本人可见,
// 因此不得复用仅超级管理员与平台账号可用的平台审计查询(那会让本人详情的操作日志恒为空)。
// 只投影动作、操作账号名称快照、操作时间与白名单化的前后值摘要,不返回凭证内容与完整收款原文。
func (q *BillQuery) billOperations(ctx context.Context, billID uint) ([]*dto.EmployeeCollectionBillOperationLogResponse, error) {
resourceID := strconv.FormatUint(uint64(billID), 10)
var rows []billOperationRow
if err := q.db.WithContext(ctx).Table("tb_audit_event AS event").
Select("event.id, event.action_code, event.action_name, event.summary, event.actor_name, "+
"event.occurred_at, event.result, resource.relation, resource.before_data, resource.after_data").
Joins("JOIN tb_audit_event_resource AS resource ON resource.audit_event_id = event.id").
Where("resource.resource_type = ? AND resource.resource_id = ?",
constants.AuditResourceEmployeeCollectionBill, resourceID).
Order("event.occurred_at ASC, event.id ASC").
Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询账单操作日志失败")
}
result := make([]*dto.EmployeeCollectionBillOperationLogResponse, 0, len(rows))
seen := make(map[uint]int, len(rows))
for index := range rows {
row := rows[index]
// 同一事件可能同时以主要资源与关联资源引用同一张账单:按事件去重并优先保留主要资源的前后值。
if position, exists := seen[row.EventID]; exists && row.Relation != "primary" {
continue
} else if exists {
result[position] = billOperationResponse(row)
continue
}
seen[row.EventID] = len(result)
result = append(result, billOperationResponse(row))
}
return result, nil
}
// billOperationRow 是账单操作日志的审计行扫描结果。
type billOperationRow struct {
EventID uint `gorm:"column:id"`
ActionCode string `gorm:"column:action_code"`
ActionName string `gorm:"column:action_name"`
Summary string `gorm:"column:summary"`
ActorName string `gorm:"column:actor_name"`
OccurredAt time.Time `gorm:"column:occurred_at"`
Result string `gorm:"column:result"`
Relation string `gorm:"column:relation"`
BeforeData datatypes.JSON `gorm:"column:before_data"`
AfterData datatypes.JSON `gorm:"column:after_data"`
}
// billOperationResponse 把审计行投影为对外操作日志。
func billOperationResponse(row billOperationRow) *dto.EmployeeCollectionBillOperationLogResponse {
return &dto.EmployeeCollectionBillOperationLogResponse{
ActionCode: row.ActionCode, ActionName: row.ActionName, Summary: row.Summary,
OperatorName: row.ActorName, OccurredAt: row.OccurredAt, Result: row.Result,
BeforeSummary: billOperationSummary(row.BeforeData), AfterSummary: billOperationSummary(row.AfterData),
}
}
// billOperationSummaryKeys 是操作日志允许展示的账单字段白名单。
// 采用白名单而非黑名单:审计前后值随用例演进会加入新字段,黑名单一旦漏项就会把
// 收款凭证或完整收款原文带出接口。
var billOperationSummaryKeys = []string{
"bill_id", "source_type", "source_no", "status", "receivable_amount", "received_amount",
"reserved_amount", "closed_reason", "closed_at", "refund_id", "outcome", "reduced_amount",
"application_id", "attempt_no", "amount", "decided_at", "terminal_reason", "payment_method_code",
"payment_method_name", "paid_amount",
}
// billOperationSummary 按白名单提取审计前后值摘要;不可解析时返回空摘要而不阻断详情。
func billOperationSummary(raw datatypes.JSON) map[string]any {
summary := make(map[string]any)
if len(raw) == 0 {
return summary
}
decoded := make(map[string]any)
if err := sonic.Unmarshal(raw, &decoded); err != nil {
return summary
}
for _, key := range billOperationSummaryKeys {
if value, exists := decoded[key]; exists {
summary[key] = value
}
}
return summary
}
// billSourceSnapshot 读取来源订单或来源充值记录的只读快照投影。
//
// 来源记录不可读(已删除或查询失败)或字段缺失时返回空值,且 MUST NOT 阻断详情响应:
// 账单本身是权威事实,来源快照只是补充解释,缺来源不应让账单详情不可查看。
func (q *BillQuery) billSourceSnapshot(ctx context.Context, bill *model.EmployeeCollectionBill) *dto.EmployeeCollectionBillSourceSnapshotResponse {
snapshot := &dto.EmployeeCollectionBillSourceSnapshotResponse{
SourceType: bill.SourceType,
SourceTypeName: constants.GetEmployeeCollectionSourceTypeName(bill.SourceType),
SourceNo: bill.SourceNo,
}
switch bill.SourceType {
case constants.EmployeeCollectionSourceTypeOrder:
var order model.Order
if err := q.db.WithContext(ctx).
Select("id", "order_no", "actual_paid_amount", "asset_identifier", "created_at").
Where("id = ?", bill.SourceID).First(&order).Error; err != nil {
return snapshot
}
snapshot.SourceNo = order.OrderNo
if order.ActualPaidAmount != nil {
snapshot.Amount = *order.ActualPaidAmount
}
snapshot.AssetIdentifier = order.AssetIdentifier
createdAt := order.CreatedAt
snapshot.CreatedAt = &createdAt
case constants.EmployeeCollectionSourceTypeRecharge:
var record model.AgentRechargeRecord
if err := q.db.WithContext(ctx).
Select("id", "recharge_no", "amount", "created_at").
Where("id = ?", bill.SourceID).First(&record).Error; err != nil {
return snapshot
}
snapshot.SourceNo = record.RechargeNo
snapshot.Amount = record.Amount
createdAt := record.CreatedAt
snapshot.CreatedAt = &createdAt
}
return snapshot
}
// applyBillScope 生成同时应用可见性与筛选条件的账单查询。
// 列表、统计与关闭后的可见性校验都必须走这一处,避免筛选或数据范围分叉。
func (q *BillQuery) applyBillScope(ctx context.Context, filter billFilter) (*gorm.DB, error) {
@@ -142,35 +292,44 @@ func (q *BillQuery) applyBillScope(ctx context.Context, filter billFilter) (*gor
if err != nil {
return nil, err
}
return applyBillFilters(query, filter)
return applyBillFilters(ctx, q.db, query, filter)
}
// billFilter 账单列表与统计共用的筛选条件,屏蔽两种请求 DTO 的字段差异。
type billFilter struct {
BillID *uint
SourceType *string
SourceNo *string
Status *int
DebtorAccountID *uint
DebtorKeyword *string
CustomerKeyword *string
CustomerID *uint
CreatedFrom *string
CreatedTo *string
StartTime *string
EndTime *string
DecidedStart *string
DecidedEnd *string
}
// billFilterOfList 投影账单列表请求的筛选字段。
func billFilterOfList(request dto.EmployeeCollectionBillListRequest) billFilter {
return billFilter{
SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
DebtorAccountID: request.DebtorAccountID, CustomerID: request.CustomerID,
CreatedFrom: request.CreatedFrom, CreatedTo: request.CreatedTo,
BillID: request.BillID, SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
DebtorAccountID: request.DebtorAccountID, DebtorKeyword: request.DebtorKeyword,
CustomerKeyword: request.CustomerKeyword, CustomerID: request.CustomerID,
StartTime: request.StartTime, EndTime: request.EndTime,
DecidedStart: request.DecidedStartTime, DecidedEnd: request.DecidedEndTime,
}
}
// billFilterOfStatistics 投影账单统计请求的筛选字段。
func billFilterOfStatistics(request dto.EmployeeCollectionBillStatisticsRequest) billFilter {
return billFilter{
SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
DebtorAccountID: request.DebtorAccountID, CustomerID: request.CustomerID,
CreatedFrom: request.CreatedFrom, CreatedTo: request.CreatedTo,
BillID: request.BillID, SourceType: request.SourceType, SourceNo: request.SourceNo, Status: request.Status,
DebtorAccountID: request.DebtorAccountID, DebtorKeyword: request.DebtorKeyword,
CustomerKeyword: request.CustomerKeyword, CustomerID: request.CustomerID,
StartTime: request.StartTime, EndTime: request.EndTime,
DecidedStart: request.DecidedStartTime, DecidedEnd: request.DecidedEndTime,
}
}
@@ -187,7 +346,12 @@ func applyBillVisibility(ctx context.Context, query *gorm.DB) (*gorm.DB, error)
}
// applyBillFilters 应用账单列表与统计共用的筛选条件。
func applyBillFilters(query *gorm.DB, request billFilter) (*gorm.DB, error) {
// db 仅用于构造相关子查询,不得携带外层筛选,避免子查询被账单条件污染。
func applyBillFilters(ctx context.Context, db, query *gorm.DB, request billFilter) (*gorm.DB, error) {
if request.BillID != nil && *request.BillID > 0 {
// 账单编号即账单记录标识(主键),不新增编号列,也不按前缀或范围匹配。
query = query.Where("tb_employee_collection_bill.id = ?", *request.BillID)
}
if request.DebtorAccountID != nil && *request.DebtorAccountID > 0 {
query = query.Where("debtor_account_id = ?", *request.DebtorAccountID)
}
@@ -219,38 +383,97 @@ func applyBillFilters(query *gorm.DB, request billFilter) (*gorm.DB, error) {
customerID := strconv.FormatUint(uint64(*request.CustomerID), 10)
query = query.Where("customer_snapshot->>'buyer_id' = ? OR customer_snapshot->>'shop_id' = ?", customerID, customerID)
}
if request.CreatedFrom != nil {
from, err := parseBillDate(*request.CreatedFrom)
if err != nil {
return nil, err
}
if from != nil {
query = query.Where("created_at >= ?", *from)
}
debtorKeyword, err := billKeyword(request.DebtorKeyword, "欠款人姓名或账号")
if err != nil {
return nil, err
}
if request.CreatedTo != nil {
to, err := parseBillDate(*request.CreatedTo)
if err != nil {
return nil, err
if debtorKeyword != "" {
pattern := "%" + debtorKeyword + "%"
// 欠款人姓名或账号:优先匹配账单创建时冻结的账号名称快照;
// 同时匹配后台账号当前登录名,使账号改名后仍可按“账号”口径查到既有账单。
query = query.Where(
"tb_employee_collection_bill.debtor_snapshot->>'account_name' ILIKE ? OR EXISTS ("+
"SELECT 1 FROM tb_account AS debtor_account WHERE debtor_account.deleted_at IS NULL "+
"AND debtor_account.id = tb_employee_collection_bill.debtor_account_id "+
"AND debtor_account.username ILIKE ?)", pattern, pattern)
}
customerKeyword, err := billKeyword(request.CustomerKeyword, "客户名称")
if err != nil {
return nil, err
}
if customerKeyword != "" {
pattern := "%" + customerKeyword + "%"
// 客户名称来源订单取买家昵称快照代理线下充值的快照只冻结店铺ID
// 因此按店铺ID关联既有店铺表取当前店铺名称避免为筛选新增冗余快照列。
query = query.Where(
"tb_employee_collection_bill.customer_snapshot->>'buyer_nickname' ILIKE ? OR EXISTS ("+
"SELECT 1 FROM tb_shop AS customer_shop WHERE customer_shop.deleted_at IS NULL "+
"AND customer_shop.id::text = tb_employee_collection_bill.customer_snapshot->>'shop_id' "+
"AND customer_shop.shop_name ILIKE ?)", pattern, pattern)
}
// 产生时间:统一严格解析器 + 闭区间(含两端);旧参数名与旧格式不再接受。
startTime, endTime, err := utils.ParseTimeRange(optionalTimeValue(request.StartTime), optionalTimeValue(request.EndTime))
if err != nil {
return nil, err
}
if startTime != nil {
query = query.Where("created_at >= ?", *startTime)
}
if endTime != nil {
query = query.Where("created_at <= ?", *endTime)
}
// 核销通过时间经「状态为已通过的分摊」关联到申请表的审批终态时间decided_at
// MUST NOT 使用分摊表的释放时间:该列同时承担「审批通过转为已核销」与「驳回或释放预占」两种语义。
decidedStart, decidedEnd, err := utils.ParseTimeRange(optionalTimeValue(request.DecidedStart), optionalTimeValue(request.DecidedEnd))
if err != nil {
return nil, err
}
if decidedStart != nil || decidedEnd != nil {
sub := db.WithContext(ctx).
Model(&model.EmployeeCollectionApplicationAllocation{}).
Select("1").
Joins("JOIN tb_employee_collection_application AS decided_application ON decided_application.id = tb_employee_collection_application_allocation.application_id").
Where("tb_employee_collection_application_allocation.bill_id = tb_employee_collection_bill.id").
Where("tb_employee_collection_application_allocation.status = ?", constants.EmployeeCollectionAllocationStatusApproved).
Where("decided_application.decided_at IS NOT NULL")
if decidedStart != nil {
sub = sub.Where("decided_application.decided_at >= ?", *decidedStart)
}
if to != nil {
query = query.Where("created_at < ?", to.AddDate(0, 0, 1))
if decidedEnd != nil {
sub = sub.Where("decided_application.decided_at <= ?", *decidedEnd)
}
query = query.Where("EXISTS (?)", sub)
}
return query, nil
}
// parseBillDate 解析自然日筛选值;空值表示不筛选
func parseBillDate(value string) (*time.Time, error) {
trimmed := strings.TrimSpace(value)
// billKeywordMaxLength 限制模糊筛选的输入长度,避免超长子串匹配放大查询代价
const billKeywordMaxLength = 50
// billKeyword 归一化模糊筛选输入:去两端的空白后按子串匹配,空值表示不筛选。
// 超长输入按参数非法拒绝,与 DTO 的长度上限一致,避免查询层成为绕过入口。
func billKeyword(value *string, fieldName string) (string, error) {
if value == nil {
return "", nil
}
trimmed := strings.TrimSpace(*value)
if trimmed == "" {
return nil, nil
return "", nil
}
parsed, err := time.ParseInLocation(billDateLayout, trimmed, time.Local)
if err != nil {
return nil, errors.New(errors.CodeInvalidParam, "账单筛选日期格式必须为 YYYY-MM-DD")
if utf8.RuneCountInString(trimmed) > billKeywordMaxLength {
return "", errors.New(errors.CodeInvalidParam, fieldName+"筛选最长 "+strconv.Itoa(billKeywordMaxLength)+" 字符")
}
return &parsed, nil
return trimmed, nil
}
// optionalTimeValue 读取可选时间筛选值nil 与空串等价(该端不筛选)。
// MUST NOT 去空白:统一时间筛选契约把前后带空白的取值视为格式非法,
// 在这里 trim 会把本应被拒绝的取值静默接受,与既有拒绝集不一致。
func optionalTimeValue(value *string) string {
if value == nil {
return ""
}
return *value
}
// billRefunds 读取账单的来源订单退款冲销关联。
@@ -474,8 +697,16 @@ func ProjectBill(bill *model.EmployeeCollectionBill) (*dto.EmployeeCollectionBil
return nil, errors.Wrap(errors.CodeInternalError, err, "解析账单客户快照失败")
}
}
// 未核销金额 = 应收 已核销;剩余可核销金额 = 应收 已核销 审批中预占。
// 两者并列返回:前者是运营口径,后者是服务端并发核销防超额校验使用的口径。
// 已关闭账单的未核销余额作废,两者都为 0。
unsettled := int64(0)
remaining := int64(0)
if bill.Status != constants.EmployeeCollectionBillStatusClosed {
unsettled = bill.ReceivableAmount - bill.ReceivedAmount
if unsettled < 0 {
unsettled = 0
}
remaining = bill.ReceivableAmount - bill.ReceivedAmount - bill.ReservedAmount
if remaining < 0 {
remaining = 0
@@ -488,7 +719,7 @@ func ProjectBill(bill *model.EmployeeCollectionBill) (*dto.EmployeeCollectionBil
DebtorAccountID: bill.DebtorAccountID, DebtorSnapshot: debtorSnapshot,
CustomerSnapshot: customerSnapshot,
ReceivableAmount: bill.ReceivableAmount, ReceivedAmount: bill.ReceivedAmount,
ReservedAmount: bill.ReservedAmount, RemainingAmount: remaining,
UnsettledAmount: unsettled, ReservedAmount: bill.ReservedAmount, RemainingAmount: remaining,
Status: bill.Status, StatusName: constants.GetEmployeeCollectionBillStatusName(bill.Status),
ApprovalPending: bill.ReservedAmount > 0,
ClosedReason: bill.ClosedReason, ClosedAt: bill.ClosedAt,