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

@@ -0,0 +1,122 @@
package asset
import (
"context"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
pollingsvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ResolvePriorityPolling 读取资产或卡的优先轮询状态只读投影,落在既有资产解析端点内。
//
// 口径:卡取自身,设备取其在用绑定卡;存在待执行或执行中的优先项时「是否处于优先轮询中」为真,
// 最近触发场景取该资产最近一条事实的触发类型,最近轮询结果取该条事实的结局,
// 最近轮询时间取最近一次执行时间(未执行过时退回最近触发时间),
// 失败原因取最近一条非空的可安全展示原因。无任何优先轮询事实时以否或空值返回,
// MUST NOT 用普通轮询结果填充。
//
// 只读:只做查询,不创建、不合并、不推进、不出队任何优先项,也不触发上游调用。
// 数据范围:资产本身的可读性由资产解析用例保证;此处再按优先项事实的店铺快照下推一次,
// 读侧鉴权不通过(企业账号或范围缺失)时返回空投影,不因投影阻断资产详情,也不泄露范围外事实。
func (q *ExchangeTraceQuery) ResolvePriorityPolling(ctx context.Context, assetType string, assetID uint) (*dto.AssetPriorityPollingProjection, error) {
projection := &dto.AssetPriorityPollingProjection{}
if q == nil || q.db == nil || assetID == 0 {
return projection, nil
}
cardIDs, err := q.assetCardIDs(ctx, assetType, assetID)
if err != nil {
return nil, err
}
if len(cardIDs) == 0 {
return projection, nil
}
scope, scopeErr := pollingsvc.PriorityPollingReadScope(ctx)
if scopeErr != nil {
return projection, nil
}
summaries, err := postgres.NewPollingPriorityItemStore(q.db).ProjectPriorityPolling(ctx, cardIDs, scope)
if err != nil {
return nil, err
}
return projectPriorityPolling(summaries), nil
}
// assetCardIDs 返回该资产对应的轮询对象卡集合:卡为自身,设备为绑定状态有效的在用卡。
func (q *ExchangeTraceQuery) assetCardIDs(ctx context.Context, assetType string, assetID uint) ([]uint, error) {
switch assetType {
case constants.AssetResolveTypeCard:
return []uint{assetID}, nil
case constants.AssetTypeDevice:
var cardIDs []uint
if err := q.db.WithContext(ctx).
Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", assetID, constants.BindStatusBound).
Order("slot_position ASC").
Pluck("iot_card_id", &cardIDs).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备绑定的卡失败")
}
return cardIDs, nil
default:
// 其他资产类型没有轮询对象,投影按无事实返回。
return nil, nil
}
}
// projectPriorityPolling 把逐卡摘要聚合为资产级投影。
// 多卡资产以最近创建的一条事实决定最近触发场景、结果与轮询时间;
// 失败原因单独取最近一条非空原因,避免最近一条事实无原因时丢失可展示的失败说明。
func projectPriorityPolling(summaries map[uint]postgres.PriorityPollingCardProjection) *dto.AssetPriorityPollingProjection {
projection := &dto.AssetPriorityPollingProjection{}
var latest *model.PollingPriorityItem
var latestFailed *model.PollingPriorityItem
for _, summary := range summaries {
if summary.HasActive {
projection.InPriorityPolling = true
}
item := summary.Latest
if item == nil {
continue
}
if latest == nil || item.ID > latest.ID {
latest = item
}
if item.FailureReason != "" && (latestFailed == nil || item.ID > latestFailed.ID) {
latestFailed = item
}
}
if latest == nil {
return projection
}
projection.LastTriggerType = latest.TriggerType
projection.LastTriggerName = constants.PollingPriorityTriggerName(latest.TriggerType)
projection.LastResult = constants.PollingPriorityStatusOutcome(latest.Status)
projection.LastResultName = constants.PollingPriorityOutcomeName(projection.LastResult)
projection.LastPollingAt = priorityPollingLastPolledAt(latest)
if latest.FailureReason != "" {
projection.FailureReason = latest.FailureReason
} else if latestFailed != nil {
projection.FailureReason = latestFailed.FailureReason
}
return projection
}
// priorityPollingLastPolledAt 返回最近轮询时间:优先取执行时间,未执行过时取最近触发时间。
func priorityPollingLastPolledAt(item *model.PollingPriorityItem) *time.Time {
if item == nil {
return nil
}
switch {
case item.FinishedAt != nil:
return item.FinishedAt
case item.StartedAt != nil:
return item.StartedAt
default:
triggeredAt := item.LastTriggeredAt
return &triggeredAt
}
}

View File

@@ -0,0 +1,167 @@
// Package carrierthreshold 提供运营商通道阈值命中事实的只读查询投影。
//
// 读取不经聚合根、不修改任何状态:命中事实是达量判定与周期恢复写入的冻结证据,
// 本包只按既有权限与卡数据范围把它投影为可查询的列表,绝不回填或改写历史命中。
// 时间筛选只复用统一严格解析器pkg/utils.ParseTimeRange判定时间按闭区间筛选
// 计费周期起点按同一解析器的瞬时(退化为闭区间两端相同)精确匹配。
package carrierthreshold
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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"
)
// periodStartFieldName 是计费周期起点参数的字段名,用于按本字段重述统一格式错误。
const periodStartFieldName = "period_start"
// Query 通道阈值命中记录只读查询。
type Query struct {
db *gorm.DB
}
// NewQuery 创建通道阈值命中记录查询。
func NewQuery(db *gorm.DB) *Query {
return &Query{db: db}
}
// List 分页查询通道阈值命中记录,按判定时间倒序、同刻按 ID 倒序。
//
// 权限与数据范围都在本方法内判定仅超级管理员与平台账号可读ENG-AUTHZ-001
// 并按卡的数据范围下推过滤,范围外卡的命中记录与不存在的记录同样不可见。
func (q *Query) List(ctx context.Context, req dto.CarrierThresholdHitListRequest) (*dto.CarrierThresholdHitPageResult, error) {
if q == nil || q.db == nil {
return nil, errors.New(errors.CodeInternalError, "通道阈值命中记录查询未配置")
}
if err := requirePlatformOperator(ctx); err != nil {
return nil, err
}
start, end, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return nil, err
}
periodStart, err := parsePeriodStart(req.PeriodStart)
if err != nil {
return nil, err
}
// 每次终结都重建查询Count 与 Find 共用同一个链式对象会互相污染条件。
base := func() *gorm.DB {
query := applyCardDataScope(ctx, q.db.WithContext(ctx).Model(&model.CarrierTrafficThresholdLock{}))
if req.CarrierID != nil {
query = query.Where("carrier_id = ?", *req.CarrierID)
}
if req.CardID != nil {
query = query.Where("card_id = ?", *req.CardID)
}
if periodStart != nil {
query = query.Where("period_start = ?", *periodStart)
}
if start != nil {
query = query.Where("judged_at >= ?", *start)
}
if end != nil {
query = query.Where("judged_at <= ?", *end)
}
return query
}
var total int64
if err := base().Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计通道阈值命中记录失败")
}
page, pageSize := normalizePage(req.Page, req.PageSize)
var locks []model.CarrierTrafficThresholdLock
if err := base().Order("judged_at DESC, id DESC").
Offset((page - 1) * pageSize).Limit(pageSize).Find(&locks).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通道阈值命中记录失败")
}
list := make([]*dto.CarrierThresholdHitItem, 0, len(locks))
for index := range locks {
list = append(list, toHitItem(&locks[index]))
}
return &dto.CarrierThresholdHitPageResult{List: list, Total: total, Page: page, PageSize: pageSize}, nil
}
// toHitItem 把命中事实投影为响应项。
//
// 历史行的空读数与空阈值原样保留为空指针,由读侧按「无」展示,绝不回填零值或当前配置;
// 凭证与上游响应原文不落在本表,因此响应天然只含可安全展示的事实。
func toHitItem(lock *model.CarrierTrafficThresholdLock) *dto.CarrierThresholdHitItem {
return &dto.CarrierThresholdHitItem{
ID: lock.ID,
CarrierID: lock.CarrierID,
CardID: lock.CardID,
PeriodStart: lock.PeriodStart,
HitTrafficMB: lock.HitTrafficMB,
HitThresholdValue: lock.HitThresholdValue,
HitThresholdUnit: lock.HitThresholdUnit,
TriggerSource: lock.TriggerSource,
JudgedAt: lock.JudgedAt,
Status: lock.Status,
UnlockedAt: lock.UnlockedAt,
ResumeResult: lock.ResumeResult,
FailureReason: lock.FailureReason,
CreatedAt: lock.CreatedAt,
}
}
// requirePlatformOperator 要求调用者为超级管理员或平台账号。
// 其他账号(代理、企业与个人客户)统一返回与资源不可见一致的拒绝,不产生可枚举差异。
func requirePlatformOperator(ctx context.Context) error {
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage)
}
return nil
}
// applyCardDataScope 按卡的数据范围下推过滤。
//
// 命中事实以 card_id 关联卡事实,卡的店铺归属取自 tb_iot_card.shop_id
// 范围为空表示不受限(超级管理员与平台账号当前无实际过滤,保留显式下推以冻结语义),
// 范围外卡的命中记录对受限账号与不存在的记录同样不可见。
func applyCardDataScope(ctx context.Context, query *gorm.DB) *gorm.DB {
shopIDs := middleware.GetSubordinateShopIDs(ctx)
if len(shopIDs) == 0 {
return query
}
return query.Where("card_id IN (SELECT id FROM tb_iot_card WHERE shop_id IN ? AND deleted_at IS NULL)", shopIDs)
}
// parsePeriodStart 解析计费周期起点筛选值;未传与空串等价。
//
// 周期起点是单个瞬时,这里把同一个值同时作为闭区间两端交给统一严格解析器:格式接受范围、
// 归一 UTC 与闭区间语义因此与判定时间筛选完全一致,不复制第二套时间解析。
// 开始等于结束恒成立,解析失败只可能是格式错误,故按本字段名重述统一格式错误消息,
// 避免把计费周期参数报成 start_time。
func parsePeriodStart(value string) (*time.Time, error) {
if value == "" {
return nil, nil
}
parsed, _, err := utils.ParseTimeRange(value, value)
if err != nil {
return nil, utils.TimeFilterFormatError(periodStartFieldName)
}
return parsed, nil
}
// normalizePage 归一化分页参数并执行既有上限ENG-PAGE-001
func normalizePage(page, pageSize int) (int, int) {
if page <= 0 {
page = constants.DefaultPage
}
if pageSize <= 0 {
pageSize = constants.DefaultPageSize
}
if pageSize > constants.MaxPageSize {
pageSize = constants.MaxPageSize
}
return page, pageSize
}

View File

@@ -180,8 +180,17 @@ func (q *Query) WithdrawalDetail(
ID: attempt.ID, AttemptNo: attempt.AttemptNo, Amount: attempt.Amount,
Fee: attempt.Fee, FeeRate: attempt.FeeRate, ActualAmount: attempt.ActualAmount,
WithdrawalMethod: attempt.WithdrawalMethod, SubmittedByID: attempt.SubmittedByAccountID,
ApprovalInstanceID: instanceID,
CreatedAt: attempt.CreatedAt.Format("2006-01-02 15:04:05"),
ApprovalInstanceID: instanceID,
QualificationVersionID: attempt.QualificationVersionID,
QualificationPassed: attempt.QualificationPassed,
QualificationFailureReason: attempt.QualificationFailureReason,
CreatedAt: attempt.CreatedAt.Format("2006-01-02 15:04:05"),
}
if attempt.QualificationFailureReason != "" {
item.QualificationFailureName = constants.GetWithdrawalQualificationFailureReasonName(attempt.QualificationFailureReason)
}
if attempt.QualificationCheckedAt != nil {
item.QualificationCheckedAt = attempt.QualificationCheckedAt.Format("2006-01-02 15:04:05")
}
if status, ok := instanceStatuses[instanceID]; ok {
item.ApprovalStatus = status

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,

View File

@@ -92,12 +92,14 @@ func toConfigurationResponse(record *model.H5PopupConfiguration) *dto.H5PopupCon
}
return &dto.H5PopupConfigurationResponse{
ID: record.ID, Title: record.Title, Content: record.Content,
Pages: append([]string{}, record.Pages...),
ShopIDs: toShopIDs(record.ShopIDs),
DeviceTypes: append([]string{}, record.DeviceTypes...),
CardTypes: append([]string{}, record.CardTypes...),
Priority: record.Priority,
Frequency: record.Frequency,
PopupType: record.PopupType,
PopupTypeText: constants.GetH5PopupTypeName(record.PopupType),
Pages: append([]string{}, record.Pages...),
ShopIDs: toShopIDs(record.ShopIDs),
DeviceTypes: append([]string{}, record.DeviceTypes...),
CardTypes: append([]string{}, record.CardTypes...),
Priority: record.Priority,
Frequency: record.Frequency,
FrequencyText: constants.GetH5PopupFrequencyName(record.Frequency),
Enabled: record.Enabled == constants.H5PopupStatusEnabled,
EnabledText: constants.GetH5PopupEnabledName(record.Enabled),

View File

@@ -17,6 +17,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// Query 优先轮询项只读查询。
@@ -31,6 +32,7 @@ func NewQuery(db *gorm.DB, store *postgres.PollingPriorityItemStore) *Query {
}
// List 分页查询优先轮询项,按创建时间倒序,并按当前账号数据范围下推。
// 时间范围按入队时间created_at取闭区间复用统一严格时间解析器解析失败按参数非法返回。
func (q *Query) List(ctx context.Context, req dto.PriorityPollingItemListRequest) (*dto.PriorityPollingItemListResponse, error) {
if q == nil || q.store == nil {
return nil, errors.New(errors.CodeInternalError, "优先轮询项查询未配置")
@@ -39,11 +41,18 @@ func (q *Query) List(ctx context.Context, req dto.PriorityPollingItemListRequest
if err != nil {
return nil, err
}
createdFrom, createdTo, err := utils.ParseTimeRange(req.StartTime, req.EndTime)
if err != nil {
return nil, err
}
filter := postgres.PollingPriorityItemListFilter{
CardID: req.CardID,
TaskType: req.TaskType,
Status: req.Status,
TriggerType: req.TriggerType,
CreatedFrom: createdFrom,
CreatedTo: createdTo,
AbnormalOnly: req.AbnormalOnly != nil && *req.AbnormalOnly,
ShopIDSnapshot: scope,
Page: req.Page,
PageSize: req.PageSize,
@@ -158,35 +167,60 @@ func normalizePage(page, pageSize int) (int, int) {
}
// toPriorityPollingItemResponse 把事实行投影为读侧 DTO枚举按 constants 提供中文名称。
// 有效期与「是否已过期」一律读事实状态,不按 next_run_at 现算next_run_at 为空只表示未排期。
func toPriorityPollingItemResponse(item *model.PollingPriorityItem, iccid string) *dto.PriorityPollingItemResponse {
if item == nil {
return nil
}
return &dto.PriorityPollingItemResponse{
ID: item.ID,
CardID: item.CardID,
ICCID: iccid,
TaskType: item.TaskType,
TaskTypeName: constants.PollingPriorityTaskTypeName(item.TaskType),
Status: item.Status,
StatusName: constants.PollingPriorityStatusName(item.Status),
TriggerType: item.TriggerType,
TriggerTypeName: constants.PollingPriorityTriggerName(item.TriggerType),
TriggerTypes: strings.Trim(item.TriggerTypes, ","),
TriggerCount: item.TriggerCount,
LastTriggeredAt: item.LastTriggeredAt,
SourceOrderID: item.SourceOrderID,
SourcePackageUse: item.SourcePackageUsageID,
AttemptCount: item.AttemptCount,
ClaimedAt: item.ClaimedAt,
ManualReason: item.ManualReason,
ManualOperator: item.ManualOperatorID,
ManualOperatorNm: item.ManualOperatorName,
ShopIDSnapshot: item.ShopIDSnapshot,
Result: item.Result,
ResultName: constants.PollingPriorityResultName(item.Result),
FailureReason: item.FailureReason,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
ID: item.ID,
CardID: item.CardID,
ICCID: iccid,
AssetType: item.AssetType,
AssetID: item.AssetID,
DeviceNo: item.DeviceNoSnapshot,
TaskType: item.TaskType,
TaskTypeName: constants.PollingPriorityTaskTypeName(item.TaskType),
Status: item.Status,
StatusName: constants.PollingPriorityStatusName(item.Status),
TriggerType: item.TriggerType,
TriggerTypeName: constants.PollingPriorityTriggerName(item.TriggerType),
TriggerTypes: strings.Trim(item.TriggerTypes, ","),
TriggerCount: item.TriggerCount,
LastTriggeredAt: item.LastTriggeredAt,
SourceOrderID: item.SourceOrderID,
SourcePackageUse: item.SourcePackageUsageID,
AttemptCount: item.AttemptCount,
AttemptLimit: item.AttemptLimit,
ClaimedAt: item.ClaimedAt,
StartedAt: item.StartedAt,
FinishedAt: item.FinishedAt,
DurationMS: pollingPriorityDurationMS(item),
NextRunAt: item.NextRunAt,
PriorityEffectiveFrom: item.PriorityEffectiveFrom,
DequeuedAt: item.DequeuedAt,
ManualReason: item.ManualReason,
ManualOperator: item.ManualOperatorID,
ManualOperatorNm: item.ManualOperatorName,
ShopIDSnapshot: item.ShopIDSnapshot,
AgentShopID: item.AgentShopIDSnapshot,
Result: item.Result,
ResultName: constants.PollingPriorityResultName(item.Result),
FailureReason: item.FailureReason,
IntegrationLogID: item.IntegrationLogID,
CreatedAt: item.CreatedAt,
UpdatedAt: item.UpdatedAt,
}
}
// pollingPriorityDurationMS 返回最近一次执行的耗时毫秒数。
// 只有开始与结束时间都在时才有耗时;结束早于开始等异常数据按 0 处理,不返回负数。
func pollingPriorityDurationMS(item *model.PollingPriorityItem) int64 {
if item == nil || item.StartedAt == nil || item.FinishedAt == nil {
return 0
}
if item.FinishedAt.Before(*item.StartedAt) {
return 0
}
return item.FinishedAt.Sub(*item.StartedAt).Milliseconds()
}

View File

@@ -158,6 +158,10 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) (
if err != nil {
return nil, err
}
downstreamCounts, err := q.loadDownstreamAgentCounts(ctx, shops)
if err != nil {
return nil, err
}
responses := make([]*dto.ShopResponse, 0, len(shops))
for _, shop := range shops {
response := &dto.ShopResponse{
@@ -167,8 +171,11 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) (
ContactName: shop.ContactName, ContactPhone: shop.ContactPhone, Province: shop.Province,
City: shop.City, District: shop.District, Address: shop.Address, Status: shop.Status,
ClientLoginDisabled: shop.ClientLoginDisabled,
StatusName: constants.GetStatusName(shop.Status), CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
StatusName: constants.GetStatusName(shop.Status),
// 无下级的店铺返回 0map 缺省零值),不使用空值。
DownstreamAgentCount: downstreamCounts[shop.ID],
CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if shop.ParentID != nil {
response.ParentShopName = parentNames[*shop.ParentID]
@@ -239,6 +246,38 @@ func (q *BusinessOwnerQuery) loadBusinessOwners(ctx context.Context, ids []uint)
return result, nil
}
// loadDownstreamAgentCounts 按当页店铺批量聚合直接下级代理数量。
//
// 口径未删除且上级店铺标识等于该店铺的店铺数一次分组查询完成MUST NOT 逐店查询。
// 该数量只读,不影响上下级关系、佣金关系、通知范围或任何既有写入语义。
// 数据范围由调用方的列表与详情过滤保证:范围外店铺不进入结果,其下级数量自然不泄露。
func (q *BusinessOwnerQuery) loadDownstreamAgentCounts(ctx context.Context, shops []*model.Shop) (map[uint]int64, error) {
result := make(map[uint]int64, len(shops))
shopIDs := make([]uint, 0, len(shops))
for _, shop := range shops {
if shop != nil && shop.ID > 0 {
shopIDs = append(shopIDs, shop.ID)
}
}
if len(shopIDs) == 0 {
return result, nil
}
var rows []struct {
ParentID uint
Total int64
}
if err := q.db.WithContext(ctx).Model(&model.Shop{}).
Select("parent_id, COUNT(*) AS total").
Where("parent_id IN ?", shopIDs).
Group("parent_id").Scan(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计店铺下级代理数量失败")
}
for _, row := range rows {
result[row.ParentID] = row.Total
}
return result, nil
}
// loadBusinessUserGroups 按负责人账号批量推导当前所属业务用户组。
// 一账号至多一条未删除成员关系,因此先在成员上按 account_id 定位、再按组 ID 回表,
// 停用组与负责人为已软删账号的成员关系都照常返回,保证展示与筛选口径一致。