feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
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:
122
internal/query/asset/priority_polling.go
Normal file
122
internal/query/asset/priority_polling.go
Normal 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
|
||||
}
|
||||
}
|
||||
167
internal/query/carrierthreshold/query.go
Normal file
167
internal/query/carrierthreshold/query.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
// 无下级的店铺返回 0(map 缺省零值),不使用空值。
|
||||
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 回表,
|
||||
// 停用组与负责人为已软删账号的成员关系都照常返回,保证展示与筛选口径一致。
|
||||
|
||||
Reference in New Issue
Block a user