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:
@@ -11,6 +11,7 @@ import (
|
||||
carrierthreshold "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
carrierthresholdquery "github.com/break/junhong_cmp_fiber/internal/query/carrierthreshold"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
|
||||
@@ -22,10 +23,27 @@ import (
|
||||
type Service struct {
|
||||
carrierStore *postgres.CarrierStore
|
||||
audit systemconfigapp.AuditWriter
|
||||
// thresholdHits 是通道阈值命中记录只读查询;与运营商配置共用同一数据库连接。
|
||||
thresholdHits *carrierthresholdquery.Query
|
||||
}
|
||||
|
||||
func New(carrierStore *postgres.CarrierStore, audit systemconfigapp.AuditWriter) *Service {
|
||||
return &Service{carrierStore: carrierStore, audit: audit}
|
||||
service := &Service{carrierStore: carrierStore, audit: audit}
|
||||
if carrierStore != nil {
|
||||
service.thresholdHits = carrierthresholdquery.NewQuery(carrierStore.DB())
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
// ListThresholdHits 分页查询通道阈值命中记录。
|
||||
//
|
||||
// 账号门禁与卡数据范围在查询侧判定(ENG-AUTHZ-001);本方法只做装配,
|
||||
// 权限失败与资源不可见都按查询侧的统一拒绝返回。
|
||||
func (s *Service) ListThresholdHits(ctx context.Context, req *dto.CarrierThresholdHitListRequest) (*dto.CarrierThresholdHitPageResult, error) {
|
||||
if s == nil || s.thresholdHits == nil {
|
||||
return nil, errors.New(errors.CodeInternalError, "通道阈值命中记录查询未配置")
|
||||
}
|
||||
return s.thresholdHits.List(ctx, *req)
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*dto.CarrierResponse, error) {
|
||||
|
||||
@@ -321,7 +321,7 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, assetType stri
|
||||
return nil, errors.Wrap(errors.CodeInternalError, primaryErr, "查询主手机号失败")
|
||||
}
|
||||
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
appErr := verificationCodeFailure(err)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
@@ -413,7 +413,7 @@ func (s *Service) bindExistingPrimaryPhone(
|
||||
return nil, appErr
|
||||
}
|
||||
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
appErr := verificationCodeFailure(err)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, primary, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
@@ -485,12 +485,12 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha
|
||||
}
|
||||
|
||||
if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
appErr := verificationCodeFailure(err)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil {
|
||||
appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
appErr := verificationCodeFailure(err)
|
||||
s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号被拒绝", customer, primary, appErr)
|
||||
return nil, appErr
|
||||
}
|
||||
@@ -629,6 +629,17 @@ func personalAuditFailureResult(err error) string {
|
||||
return constants.AuditResultFailed
|
||||
}
|
||||
|
||||
// verificationCodeFailure 把验证码校验失败转换为对外错误。
|
||||
// 限流错误码必须原样保留:失败次数达到上限后的锁定是「稍后再试」而不是「验证码错误」,
|
||||
// 客户端据此区分重新获取验证码与等待窗口结束,其余失败仍按验证码无效统一提示。
|
||||
func verificationCodeFailure(err error) error {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) && appErr.Code == errors.CodeTooManyRequests {
|
||||
return appErr
|
||||
}
|
||||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
}
|
||||
|
||||
// Logout A7 退出登录
|
||||
func (s *Service) Logout(ctx context.Context, customerID uint) (*dto.LogoutResponse, error) {
|
||||
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
|
||||
|
||||
@@ -63,7 +63,8 @@ func (s *Service) Unbind(ctx context.Context, request *dto.UnbindPhoneAssetAssoc
|
||||
return err
|
||||
}
|
||||
affected, err := s.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, []uint{locked.ID},
|
||||
constants.PhoneAssetAssociationInvalidateMethodBackendSingle, request.Reason, operatorID, now)
|
||||
constants.PhoneAssetAssociationInvalidateMethodBackendSingle, request.Reason, operatorID,
|
||||
middleware.GetUsernameFromContext(ctx), now)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "解除手机号资产关联失败")
|
||||
}
|
||||
@@ -145,7 +146,8 @@ func (s *Service) unbindAsset(ctx context.Context, asset dto.BatchUnbindAssetIte
|
||||
ids = append(ids, row.ID)
|
||||
}
|
||||
affected, err := s.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, ids,
|
||||
constants.PhoneAssetAssociationInvalidateMethodBackendBatch, reason, operatorID, now)
|
||||
constants.PhoneAssetAssociationInvalidateMethodBackendBatch, reason, operatorID,
|
||||
middleware.GetUsernameFromContext(ctx), now)
|
||||
if err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "解除手机号资产关联失败")
|
||||
}
|
||||
@@ -320,6 +322,11 @@ func (s *Service) List(ctx context.Context, request *dto.ListPhoneAssetAssociati
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 当前有效关联数量必须与「最多关联十项」判定同口径,且按当页手机号集合一次聚合,禁止逐行查询。
|
||||
validCounts, err := s.loadValidAssociationCounts(ctx, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]*dto.PhoneAssetAssociationResponse, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
item := &dto.PhoneAssetAssociationResponse{
|
||||
@@ -327,18 +334,40 @@ func (s *Service) List(ctx context.Context, request *dto.ListPhoneAssetAssociati
|
||||
AssetIdentifier: identifiers[assetIdentifierKey(row.AssetType, row.AssetID)],
|
||||
Status: row.Status, StatusName: constants.GetPhoneAssetAssociationStatusName(row.Status),
|
||||
Source: row.Source, EstablishedAt: row.EstablishedAt.Format(time.RFC3339),
|
||||
ValidAssociationCount: validCounts[row.Phone],
|
||||
}
|
||||
if row.InvalidatedAt != nil {
|
||||
item.InvalidatedAt = row.InvalidatedAt.Format(time.RFC3339)
|
||||
item.InvalidationMethod = row.InvalidationMethod
|
||||
item.InvalidationMethodName = constants.PhoneAssetAssociationInvalidateMethodName(row.InvalidationMethod)
|
||||
item.InvalidationReason = row.InvalidationReason
|
||||
// 最近解绑人只取解绑当时的名称快照与账号标识:有效关系保持为空,不以创建人或更新时间填充。
|
||||
item.InvalidatorID = row.Invalidator
|
||||
item.InvalidatorName = row.InvalidatorNameSnapshot
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return &dto.PhoneAssetAssociationPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil
|
||||
}
|
||||
|
||||
// loadValidAssociationCounts 按当页涉及的手机号集合一次聚合当前有效关联数量,禁止逐手机号查询。
|
||||
func (s *Service) loadValidAssociationCounts(ctx context.Context, rows []*model.PhoneAssetAssociation) (map[string]int64, error) {
|
||||
phones := make([]string, 0, len(rows))
|
||||
seen := make(map[string]struct{}, len(rows))
|
||||
for _, row := range rows {
|
||||
if _, ok := seen[row.Phone]; ok {
|
||||
continue
|
||||
}
|
||||
seen[row.Phone] = struct{}{}
|
||||
phones = append(phones, row.Phone)
|
||||
}
|
||||
counts, err := s.associationStore.CountValidByPhones(ctx, phones)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量统计手机号有效关联数量失败")
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
// loadAssetIdentifiers 按资产类型各一次 IN 批量读取资产当前标识,禁止逐行查询。
|
||||
func (s *Service) loadAssetIdentifiers(ctx context.Context, rows []*model.PhoneAssetAssociation) (map[string]string, error) {
|
||||
result := make(map[string]string, len(rows))
|
||||
|
||||
@@ -53,6 +53,20 @@ func PriorityPollingReadScope(ctx context.Context) ([]uint, error) {
|
||||
return shopIDs, nil
|
||||
}
|
||||
|
||||
// requirePriorityPollingAdmin 判定当前账号是否为可执行人工关闭的超级管理员或平台账号。
|
||||
// 代理账号即使对该卡有数据权限也不能关闭优先项:关闭是不可逆的队列出口,
|
||||
// 与人工入队相比是更窄的授权面。拒绝一律返回统一文案,不暴露资源是否存在。
|
||||
func requirePriorityPollingAdmin(ctx context.Context) error {
|
||||
skip, err := pollingUserTypePermission(ctx)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeForbidden, pollingDeniedMessage)
|
||||
}
|
||||
if !skip {
|
||||
return errors.New(errors.CodeForbidden, pollingDeniedMessage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// canManagePollingCard 检查用户是否有权管理单张卡。
|
||||
func canManagePollingCard(ctx context.Context, iotCardStore *postgres.IotCardStore, cardID uint) error {
|
||||
skip, err := pollingUserTypePermission(ctx)
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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/middleware"
|
||||
)
|
||||
|
||||
// PriorityEnqueueService 人工优先入队用例。
|
||||
//
|
||||
// 与既有人工触发的关系:共用同包权限判定(permission.go),但**不继承**人工触发的每日次数上限与
|
||||
// 24 小时去重——它们是人工触发的防滥用配额,不是队列不变量;重复抑制由活动项合并承担,
|
||||
// 且每次人工入队独立记录审计。本用例也不修改调度优先级、不绕过既有并发上限。
|
||||
type PriorityEnqueueService struct {
|
||||
db *gorm.DB
|
||||
iotCardStore *postgres.IotCardStore
|
||||
priorityStore *postgres.PollingPriorityItemStore
|
||||
publisher priorityapp.PromptPublisher
|
||||
auditWriter *audit.Writer
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPriorityEnqueueService 创建人工优先入队用例。
|
||||
func NewPriorityEnqueueService(
|
||||
db *gorm.DB,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
priorityStore *postgres.PollingPriorityItemStore,
|
||||
publisher priorityapp.PromptPublisher,
|
||||
logger *zap.Logger,
|
||||
) *PriorityEnqueueService {
|
||||
return &PriorityEnqueueService{
|
||||
db: db,
|
||||
iotCardStore: iotCardStore,
|
||||
priorityStore: priorityStore,
|
||||
publisher: publisher,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAudit 注入统一审计 Writer。
|
||||
func (s *PriorityEnqueueService) SetAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// EnqueueResult 描述一次人工优先入队的结果:一次优先需求覆盖该卡的全部纳入任务类型。
|
||||
type EnqueueResult struct {
|
||||
CardID uint
|
||||
TaskTypes []string
|
||||
CreatedCount int
|
||||
MergedCount int
|
||||
Items []EnqueueItemResult
|
||||
}
|
||||
|
||||
// EnqueueItemResult 是单个任务类型的入队结果。
|
||||
type EnqueueItemResult struct {
|
||||
ItemID uint
|
||||
TaskType string
|
||||
Status string
|
||||
TriggerCount int
|
||||
LastTriggeredAt time.Time
|
||||
Created bool
|
||||
}
|
||||
|
||||
// Enqueue 为该卡建立或合并全部纳入轮询任务类型的优先项,并在提交后逐类型下发执行提示。
|
||||
//
|
||||
// 一次优先需求 = 该卡的全部纳入任务类型(constants.PollingPriorityTaskTypes),
|
||||
// 与执行集合一致;入队对象是卡,不做设备维度入队。
|
||||
func (s *PriorityEnqueueService) Enqueue(ctx context.Context, cardID uint, reason string, operatorID uint) (*EnqueueResult, error) {
|
||||
if cardID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
||||
}
|
||||
trimmedReason := strings.TrimSpace(reason)
|
||||
if trimmedReason == "" {
|
||||
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeInvalidParam, "人工优先入队原因不能为空")
|
||||
}
|
||||
if len([]rune(trimmedReason)) > constants.PollingPriorityManualReasonMaxLength {
|
||||
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeInvalidParam, "人工优先入队原因长度超出上限")
|
||||
}
|
||||
|
||||
// 权限判定与既有人工触发共用同一套语义:超管/平台放行、企业拒绝、代理限自身与下级、平台卡不可见。
|
||||
if err := canManagePollingCard(ctx, s.iotCardStore, cardID); err != nil {
|
||||
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil || card == nil {
|
||||
return nil, s.deny(ctx, cardID, operatorID, nil, errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||
}
|
||||
|
||||
operatorName := middleware.GetUsernameFromContext(ctx)
|
||||
taskTypes := constants.PollingPriorityTaskTypes()
|
||||
result := &EnqueueResult{CardID: cardID, TaskTypes: taskTypes}
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
store := s.priorityStore.WithTx(tx)
|
||||
for _, taskType := range taskTypes {
|
||||
item := &model.PollingPriorityItem{
|
||||
CardID: cardID,
|
||||
TaskType: taskType,
|
||||
TriggerType: constants.PollingPriorityTriggerManual,
|
||||
ManualReason: trimmedReason,
|
||||
ManualOperatorID: operatorID,
|
||||
ManualOperatorName: operatorName,
|
||||
ShopIDSnapshot: card.ShopID,
|
||||
}
|
||||
// 先读取合并前的活动项作为审计 Before 的真实快照;无活动项则 Before 为空。
|
||||
// 残余近似:并发下相邻执行可能在该读取与合并之间把该行终态化并另建新行,
|
||||
// 此时 Before 描述的是读取时点的活动项而非实际被合并行,属可接受的审计快照。
|
||||
before, beforeErr := store.FindActive(ctx, cardID, taskType)
|
||||
if beforeErr != nil {
|
||||
return beforeErr
|
||||
}
|
||||
created, insertErr := store.InsertOrMerge(ctx, item)
|
||||
if insertErr != nil {
|
||||
return insertErr
|
||||
}
|
||||
active, activeErr := store.FindActive(ctx, cardID, taskType)
|
||||
if activeErr != nil {
|
||||
return activeErr
|
||||
}
|
||||
if active == nil {
|
||||
return errors.New(errors.CodeInternalError, "优先轮询项入队后未能读回活动项")
|
||||
}
|
||||
if created {
|
||||
result.CreatedCount++
|
||||
} else {
|
||||
result.MergedCount++
|
||||
}
|
||||
result.Items = append(result.Items, EnqueueItemResult{
|
||||
ItemID: active.ID,
|
||||
TaskType: active.TaskType,
|
||||
Status: active.Status,
|
||||
TriggerCount: active.TriggerCount,
|
||||
LastTriggeredAt: active.LastTriggeredAt,
|
||||
Created: created,
|
||||
})
|
||||
// 入队事实与审计同事务:合并结果同样落库,避免「加急事实已生效但无审计」。
|
||||
if auditErr := writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityEnqueued,
|
||||
Summary: "人工优先入队",
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceID: active.ID,
|
||||
ResourceKey: pollingPriorityResourceKey(active.ID),
|
||||
DisplayName: "卡轮询优先项",
|
||||
OperatorID: operatorID,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": active.ID, "card_id": active.CardID, "task_type": active.TaskType,
|
||||
"status": active.Status, "trigger_type": active.TriggerType,
|
||||
"trigger_count": active.TriggerCount, "manual_operator_id": active.ManualOperatorID,
|
||||
},
|
||||
BeforeData: pollingPriorityBeforeData(before),
|
||||
AfterData: map[string]any{
|
||||
"status": active.Status, "trigger_type": active.TriggerType, "trigger_types": triggerTypesReadable(active.TriggerTypes),
|
||||
"trigger_count": active.TriggerCount, "last_triggered_at": active.LastTriggeredAt,
|
||||
"manual_reason": active.ManualReason, "manual_operator_id": active.ManualOperatorID,
|
||||
"source": constants.AuditSourceAdminAPI, "occurred_at": time.Now(),
|
||||
},
|
||||
Metadata: map[string]any{"created": created, "task_type": taskType, "card_id": cardID},
|
||||
Cards: []*model.IotCard{card},
|
||||
}); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 提交后逐任务类型下发执行提示:与既有手动触发队列分离的优先提示通道,每类型独立键。
|
||||
if s.publisher == nil {
|
||||
s.logger.Error("优先轮询提示通道未配置,本次入队只依赖普通轮询兜底", zap.Uint("card_id", cardID))
|
||||
} else {
|
||||
for _, taskType := range taskTypes {
|
||||
if publishErr := s.publisher.EnqueuePriority(ctx, cardID, taskType); publishErr != nil {
|
||||
// 提示通道不是权威:下发失败只退化为延迟一个普通轮询周期,不撤销已生效的入队事实。
|
||||
s.logger.Error("下发优先轮询执行提示失败",
|
||||
zap.Uint("card_id", cardID), zap.String("task_type", taskType), zap.Error(publishErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
s.logger.Info("人工优先入队成功",
|
||||
zap.Uint("card_id", cardID), zap.Int("created_count", result.CreatedCount),
|
||||
zap.Int("merged_count", result.MergedCount), zap.Uint("operator_id", operatorID))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// deny 记录一次人工优先入队被拒绝的审计,并返回原错误。
|
||||
// 拒绝发生在业务事务之外(或业务已回滚),因此沿用既有轮询模块的独立短事务模式。
|
||||
func (s *PriorityEnqueueService) deny(ctx context.Context, cardID, operatorID uint, card *model.IotCard, code int, summary string) error {
|
||||
appErr := errors.New(code, summary)
|
||||
identity := map[string]any{"card_id": cardID, "manual_operator_id": operatorID}
|
||||
var cards []*model.IotCard
|
||||
if card != nil {
|
||||
cards = []*model.IotCard{card}
|
||||
}
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityManualDenied,
|
||||
Summary: summary,
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceKey: pollingPriorityAttemptKey(cardID, operatorID),
|
||||
DisplayName: "人工优先入队",
|
||||
OperatorID: operatorID,
|
||||
Result: constants.AuditResultDenied,
|
||||
IdentitySnapshot: identity,
|
||||
Cards: cards,
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
// pollingPriorityResourceKey 返回优先项在审计里的稳定资源键。
|
||||
func pollingPriorityResourceKey(itemID uint) string {
|
||||
return strconv.FormatUint(uint64(itemID), 10)
|
||||
}
|
||||
|
||||
// pollingPriorityAttemptKey 返回无入队对象的拒绝审计资源键(按卡与操作者稳定)。
|
||||
func pollingPriorityAttemptKey(cardID, operatorID uint) string {
|
||||
return "priority:" + strconv.FormatUint(uint64(cardID), 10) + ":" + strconv.FormatUint(uint64(operatorID), 10)
|
||||
}
|
||||
|
||||
// pollingPriorityBeforeData 把合并前的活动项快照投影为审计前后值;无活动项(新建)时返回空对象。
|
||||
func pollingPriorityBeforeData(before *model.PollingPriorityItem) map[string]any {
|
||||
if before == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"status": before.Status,
|
||||
"trigger_type": before.TriggerType,
|
||||
"trigger_types": triggerTypesReadable(before.TriggerTypes),
|
||||
"trigger_count": before.TriggerCount,
|
||||
}
|
||||
}
|
||||
|
||||
// triggerTypesReadable 将存储形式的触发类型集合(两端补逗号)还原为可读的逗号分隔形式。
|
||||
func triggerTypesReadable(raw string) string {
|
||||
return strings.Trim(raw, ",")
|
||||
}
|
||||
137
internal/service/polling/priority_expiry_service.go
Normal file
137
internal/service/polling/priority_expiry_service.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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"
|
||||
)
|
||||
|
||||
// pollingPriorityExpiryBatchSize 是有效期到期单轮扫描的批量上限。
|
||||
// 只限制每轮处理量,不改变语义:剩余的超期项由下一轮计划任务继续终结。
|
||||
const pollingPriorityExpiryBatchSize = 200
|
||||
|
||||
// PriorityExpiryService 优先轮询项有效期到期处理用例。
|
||||
//
|
||||
// 由独立周期任务承载(查询与读侧 MUST NOT 隐式触发):按固定有效期长度扫描有效期起算时间
|
||||
// 已超期的未完成项,逐条以条件更新转已过期终态并出队;终态写入与审计同事务。
|
||||
// 触发类型、尝试次数与最近失败原因全部保留,不删除任何既有事实,也不发起任何上游调用。
|
||||
type PriorityExpiryService struct {
|
||||
db *gorm.DB
|
||||
priorityStore *postgres.PollingPriorityItemStore
|
||||
auditWriter *audit.Writer
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPriorityExpiryService 创建优先轮询项有效期到期处理用例。
|
||||
func NewPriorityExpiryService(
|
||||
db *gorm.DB,
|
||||
priorityStore *postgres.PollingPriorityItemStore,
|
||||
logger *zap.Logger,
|
||||
) *PriorityExpiryService {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &PriorityExpiryService{db: db, priorityStore: priorityStore, logger: logger}
|
||||
}
|
||||
|
||||
// SetAudit 注入统一审计 Writer。
|
||||
func (s *PriorityExpiryService) SetAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// ExpireOverdue 终结所有已超过固定有效期且仍未完成的优先项,返回本轮实际终结的条数。
|
||||
// 逐条独立事务:单条失败不阻断其余超期项,失败原因由调用方按任务错误处理并重试。
|
||||
func (s *PriorityExpiryService) ExpireOverdue(ctx context.Context, now time.Time) (int, error) {
|
||||
if s == nil || s.db == nil || s.priorityStore == nil {
|
||||
return 0, errors.New(errors.CodeServiceUnavailable, "优先轮询项有效期到期处理未配置")
|
||||
}
|
||||
deadline := now.Add(-constants.PollingPriorityValidity)
|
||||
items, err := s.priorityStore.ListOverdueActive(ctx, deadline, pollingPriorityExpiryBatchSize)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
expiredCount := 0
|
||||
for _, item := range items {
|
||||
if item == nil {
|
||||
continue
|
||||
}
|
||||
marked := false
|
||||
err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
hit, markErr := s.priorityStore.WithTx(tx).MarkExpired(ctx, item.ID)
|
||||
if markErr != nil {
|
||||
return markErr
|
||||
}
|
||||
if !hit {
|
||||
// 并发下该项已被执行终结或人工关闭:保留既有结论,不覆盖也不重复审计。
|
||||
return nil
|
||||
}
|
||||
marked = true
|
||||
return s.writeExpiryAudit(ctx, tx, item, now)
|
||||
})
|
||||
if err != nil {
|
||||
return expiredCount, err
|
||||
}
|
||||
if marked {
|
||||
expiredCount++
|
||||
}
|
||||
}
|
||||
if expiredCount > 0 {
|
||||
s.logger.Info("优先轮询项有效期到期处理完成",
|
||||
zap.Int("expired_count", expiredCount), zap.Int("scanned_count", len(items)))
|
||||
}
|
||||
return expiredCount, nil
|
||||
}
|
||||
|
||||
// writeExpiryAudit 以系统任务身份写入一条超期出队审计。
|
||||
// 计划任务没有后台账号,因此走任务审计形态(操作者=计划任务),而不是要求账号操作者的后台入口形态。
|
||||
func (s *PriorityExpiryService) writeExpiryAudit(ctx context.Context, tx *gorm.DB, item *model.PollingPriorityItem, now time.Time) error {
|
||||
if s == nil || s.auditWriter == nil {
|
||||
return errors.New(errors.CodeInvalidStatus, "统一审计 Writer 未配置")
|
||||
}
|
||||
return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityExpired,
|
||||
Summary: "卡轮询优先项有效期到期出队",
|
||||
TaskID: item.ID,
|
||||
TaskNo: pollingPriorityResourceKey(item.ID),
|
||||
DisplayName: "卡轮询优先项",
|
||||
Actor: audit.ActorInput{
|
||||
Kind: constants.AuditActorSystemTask,
|
||||
ID: constants.TaskTypePollingPriorityExpiry,
|
||||
Name: "优先轮询项有效期到期处理任务",
|
||||
},
|
||||
Source: constants.AuditSourceWorker,
|
||||
ScopeType: constants.AuditScopePlatform,
|
||||
Result: constants.AuditResultSuccess,
|
||||
CorrelationID: constants.TaskTypePollingPriorityExpiry,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": item.ID, "card_id": item.CardID, "task_type": item.TaskType,
|
||||
"status": item.Status, "trigger_type": item.TriggerType,
|
||||
"attempt_count": item.AttemptCount,
|
||||
},
|
||||
BeforeData: map[string]any{
|
||||
"status": item.Status, "result": item.Result, "failure_reason": item.FailureReason,
|
||||
"attempt_count": item.AttemptCount, "next_run_at": item.NextRunAt,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"status": constants.PollingPriorityStatusExpired, "dequeued_at": now,
|
||||
"priority_effective_from": item.PriorityEffectiveFrom,
|
||||
"expired_at": now,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"card_id": item.CardID, "task_type": item.TaskType,
|
||||
"priority_effective_from": item.PriorityEffectiveFrom,
|
||||
},
|
||||
})
|
||||
}
|
||||
540
internal/service/polling/priority_manual_service.go
Normal file
540
internal/service/polling/priority_manual_service.go
Normal file
@@ -0,0 +1,540 @@
|
||||
package polling
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
priorityapp "github.com/break/junhong_cmp_fiber/internal/application/prioritypolling"
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"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/middleware"
|
||||
)
|
||||
|
||||
// PriorityManualService 人工优先轮询入口用例:入队、人工关闭与人工重触发。
|
||||
//
|
||||
// 与既有人工触发的关系:共用同包权限判定(permission.go),但**不继承**人工触发的每日次数上限与
|
||||
// 24 小时去重——它们是人工触发的防滥用配额,不是队列不变量;重复抑制由活动项合并承担,
|
||||
// 且每次人工入队独立记录审计。本用例也不修改调度优先级、不绕过既有并发上限。
|
||||
//
|
||||
// 三个入口的权限口径:入队与重触发按卡校验(超级管理员、平台账号与数据范围内的代理账号),
|
||||
// 人工关闭只允许超级管理员与平台账号。三者对越权与不存在一律返回同一拒绝文案,不产生可枚举差异。
|
||||
type PriorityManualService struct {
|
||||
db *gorm.DB
|
||||
iotCardStore *postgres.IotCardStore
|
||||
priorityStore *postgres.PollingPriorityItemStore
|
||||
publisher priorityapp.PromptPublisher
|
||||
auditWriter *audit.Writer
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewPriorityManualService 创建人工优先轮询入口用例。
|
||||
func NewPriorityManualService(
|
||||
db *gorm.DB,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
priorityStore *postgres.PollingPriorityItemStore,
|
||||
publisher priorityapp.PromptPublisher,
|
||||
logger *zap.Logger,
|
||||
) *PriorityManualService {
|
||||
return &PriorityManualService{
|
||||
db: db,
|
||||
iotCardStore: iotCardStore,
|
||||
priorityStore: priorityStore,
|
||||
publisher: publisher,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SetAudit 注入统一审计 Writer。
|
||||
func (s *PriorityManualService) SetAudit(writer *audit.Writer) {
|
||||
s.auditWriter = writer
|
||||
}
|
||||
|
||||
// EnqueueResult 描述一次人工优先入队的结果:一次优先需求覆盖给定的任务类型集合。
|
||||
type EnqueueResult struct {
|
||||
CardID uint
|
||||
TaskTypes []string
|
||||
CreatedCount int
|
||||
MergedCount int
|
||||
Items []EnqueueItemResult
|
||||
}
|
||||
|
||||
// EnqueueItemResult 是单个任务类型的入队结果。
|
||||
type EnqueueItemResult struct {
|
||||
ItemID uint
|
||||
TaskType string
|
||||
Status string
|
||||
AttemptCount int
|
||||
TriggerCount int
|
||||
LastTriggeredAt time.Time
|
||||
Created bool
|
||||
}
|
||||
|
||||
// CloseResult 描述一次人工关闭的结果。
|
||||
type CloseResult struct {
|
||||
ItemID uint
|
||||
CardID uint
|
||||
TaskType string
|
||||
Status string
|
||||
DequeuedAt time.Time
|
||||
Reason string
|
||||
}
|
||||
|
||||
// RetriggerResult 描述一次人工重触发的结果:以原终态项为来源,为该卡该任务类型建立或合并活动项。
|
||||
type RetriggerResult struct {
|
||||
SourceItemID uint
|
||||
Reason string
|
||||
Enqueue *EnqueueResult
|
||||
}
|
||||
|
||||
// priorityManualEnqueue 描述一次人工优先入队请求(入队与重触发共用同一实现)。
|
||||
type priorityManualEnqueue struct {
|
||||
CardID uint
|
||||
Reason string
|
||||
OperatorID uint
|
||||
OperatorName string
|
||||
TaskTypes []string
|
||||
// DeniedActionCode 是本次入口被拒绝时登记的审计动作码。
|
||||
DeniedActionCode string
|
||||
// RetriggeredFromItemID 非 0 表示本次入队来自对某终态项的人工重触发:
|
||||
// 除逐任务类型的入队审计外追加一条重触发审计,两者与新活动项同一事务。
|
||||
RetriggeredFromItemID uint
|
||||
}
|
||||
|
||||
// 人工入口的固定中文提示:原因必填与长度上限三项入口共用同一口径。
|
||||
const (
|
||||
priorityEnqueueReasonEmptyMessage = "人工优先入队原因不能为空"
|
||||
priorityCloseReasonEmptyMessage = "人工关闭原因不能为空"
|
||||
priorityRetriggerReasonEmptyMessage = "人工重触发原因不能为空"
|
||||
priorityReasonTooLongMessage = "人工原因长度超出上限"
|
||||
priorityCloseInvalidStateMessage = "仅待执行或执行中的优先轮询项可关闭"
|
||||
priorityRetriggerInvalidStateMessage = "仅失败出队或已过期的优先轮询项可重触发"
|
||||
)
|
||||
|
||||
// Enqueue 为该卡建立或合并全部纳入轮询任务类型的优先项,并在提交后逐类型下发执行提示。
|
||||
//
|
||||
// 一次优先需求 = 该卡的全部纳入任务类型(constants.PollingPriorityTaskTypes),
|
||||
// 与执行集合一致;入队对象是卡,不做设备维度入队。
|
||||
func (s *PriorityManualService) Enqueue(ctx context.Context, cardID uint, reason string, operatorID uint) (*EnqueueResult, error) {
|
||||
trimmedReason, message := normalizePriorityReason(reason, priorityEnqueueReasonEmptyMessage)
|
||||
if message != "" {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityManualDenied, errors.CodeInvalidParam, message,
|
||||
pollingPriorityAttemptKey(cardID, operatorID),
|
||||
map[string]any{"card_id": cardID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
return s.enqueue(ctx, priorityManualEnqueue{
|
||||
CardID: cardID,
|
||||
Reason: trimmedReason,
|
||||
OperatorID: operatorID,
|
||||
OperatorName: middleware.GetUsernameFromContext(ctx),
|
||||
TaskTypes: constants.PollingPriorityTaskTypes(),
|
||||
DeniedActionCode: constants.AuditActionPollingPriorityManualDenied,
|
||||
})
|
||||
}
|
||||
|
||||
// Close 人工关闭未完成的优先项:原因必填,未完成项转已关闭终态并出队,既有执行结果与失败原因保留。
|
||||
//
|
||||
// 只允许超级管理员与平台账号:代理账号即使对该卡有数据权限也不能关闭优先项。
|
||||
// 关闭不发起任何上游调用,也不删除既有事实;关闭原因只进审计,不写入失败原因。
|
||||
func (s *PriorityManualService) Close(ctx context.Context, itemID uint, reason string, operatorID uint) (*CloseResult, error) {
|
||||
trimmedReason, message := normalizePriorityReason(reason, priorityCloseReasonEmptyMessage)
|
||||
if message != "" {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeInvalidParam, message,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
if err := requirePriorityPollingAdmin(ctx); err != nil {
|
||||
// 越权与不存在共用同一文案:不暴露他人资源是否存在。
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
item, err := s.priorityStore.GetByIDScoped(ctx, itemID, nil)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询项失败")
|
||||
}
|
||||
if !constants.PollingPriorityActive(item.Status) {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityClosed, errors.CodeInvalidStatus,
|
||||
priorityCloseInvalidStateMessage, pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "status": item.Status, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
|
||||
card := s.loadCard(ctx, item.CardID)
|
||||
closedAt := time.Now()
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
hit, closeErr := s.priorityStore.WithTx(tx).Close(ctx, itemID)
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if !hit {
|
||||
// 条件更新落空:该项已被并发执行终结,本次关闭不生效也不覆盖既有结论。
|
||||
return errors.New(errors.CodeInvalidStatus, "优先轮询项已被并发执行终结,本次关闭未生效")
|
||||
}
|
||||
// 关闭事实与审计同事务:审计写不进去就整体回滚,不出现「已关闭但无审计」。
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityClosed,
|
||||
Summary: "人工关闭卡轮询优先项",
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceID: itemID,
|
||||
ResourceKey: pollingPriorityResourceKey(itemID),
|
||||
DisplayName: "卡轮询优先项",
|
||||
OperatorID: operatorID,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": itemID, "card_id": item.CardID, "task_type": item.TaskType,
|
||||
"status": item.Status, "trigger_type": item.TriggerType,
|
||||
"attempt_count": item.AttemptCount, "manual_operator_id": operatorID,
|
||||
},
|
||||
BeforeData: map[string]any{"status": item.Status, "result": item.Result, "failure_reason": item.FailureReason},
|
||||
AfterData: map[string]any{
|
||||
"status": constants.PollingPriorityStatusClosed, "dequeued_at": closedAt,
|
||||
"reason": trimmedReason, "manual_operator_id": operatorID,
|
||||
"source": constants.AuditSourceAdminAPI, "occurred_at": closedAt,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"reason": trimmedReason, "card_id": item.CardID, "task_type": item.TaskType,
|
||||
"previous_status": item.Status,
|
||||
},
|
||||
Cards: cardSlice(card),
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Info("人工关闭优先轮询项成功",
|
||||
zap.Uint("priority_item_id", itemID), zap.Uint("operator_id", operatorID),
|
||||
zap.String("previous_status", item.Status))
|
||||
return &CloseResult{
|
||||
ItemID: itemID, CardID: item.CardID, TaskType: item.TaskType,
|
||||
Status: constants.PollingPriorityStatusClosed, DequeuedAt: closedAt, Reason: trimmedReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Retrigger 对失败出队或已过期的优先项执行人工重触发。
|
||||
//
|
||||
// 复用人工入队同一实现为该卡**该任务类型**建立新的活动项(尝试次数从零开始),
|
||||
// 原因必填并与操作者一同写入审计;不绕过活动项唯一键代表的并发上限,
|
||||
// 停复机持锁拒绝与任务类型范围仍由既有执行路径负责,本入口不发起任何上游调用。
|
||||
func (s *PriorityManualService) Retrigger(ctx context.Context, itemID uint, reason string, operatorID uint) (*RetriggerResult, error) {
|
||||
trimmedReason, message := normalizePriorityReason(reason, priorityRetriggerReasonEmptyMessage)
|
||||
if message != "" {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeInvalidParam, message,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
// 先按主键定位来源项:不存在与越权在后续按卡校验中收敛为同一拒绝文案。
|
||||
item, err := s.priorityStore.GetByIDScoped(ctx, itemID, nil)
|
||||
if err != nil {
|
||||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "manual_operator_id": operatorID}, operatorID, nil)
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询优先轮询项失败")
|
||||
}
|
||||
// 数据范围按卡在业务边界校验,且必须先于状态判断:否则可依据状态差异枚举范围外资源是否存在。
|
||||
if err := canManagePollingCard(ctx, s.iotCardStore, item.CardID); err != nil {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "card_id": item.CardID, "manual_operator_id": operatorID},
|
||||
operatorID, cardSlice(s.loadCard(ctx, item.CardID)))
|
||||
}
|
||||
if !constants.PollingPriorityRetriggerable(item.Status) {
|
||||
return nil, s.denyManual(ctx, constants.AuditActionPollingPriorityRetriggered, errors.CodeInvalidStatus,
|
||||
priorityRetriggerInvalidStateMessage, pollingPriorityResourceKey(itemID),
|
||||
map[string]any{"priority_item_id": itemID, "status": item.Status, "manual_operator_id": operatorID},
|
||||
operatorID, cardSlice(s.loadCard(ctx, item.CardID)))
|
||||
}
|
||||
|
||||
result, err := s.enqueue(ctx, priorityManualEnqueue{
|
||||
CardID: item.CardID,
|
||||
Reason: trimmedReason,
|
||||
OperatorID: operatorID,
|
||||
OperatorName: middleware.GetUsernameFromContext(ctx),
|
||||
TaskTypes: []string{item.TaskType},
|
||||
DeniedActionCode: constants.AuditActionPollingPriorityRetriggered,
|
||||
RetriggeredFromItemID: itemID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.logger.Info("人工重触发优先轮询项成功",
|
||||
zap.Uint("source_item_id", itemID), zap.Uint("card_id", item.CardID),
|
||||
zap.String("task_type", item.TaskType), zap.Uint("operator_id", operatorID))
|
||||
return &RetriggerResult{SourceItemID: itemID, Reason: trimmedReason, Enqueue: result}, nil
|
||||
}
|
||||
|
||||
// enqueue 执行人工优先入队:权限校验、逐任务类型建立或合并活动项、同事务写审计,提交后下发执行提示。
|
||||
func (s *PriorityManualService) enqueue(ctx context.Context, input priorityManualEnqueue) (*EnqueueResult, error) {
|
||||
if input.CardID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
||||
}
|
||||
// 权限判定与既有人工触发共用同一套语义:超管/平台放行、企业拒绝、代理限自身与下级、平台卡不可见。
|
||||
if err := canManagePollingCard(ctx, s.iotCardStore, input.CardID); err != nil {
|
||||
return nil, s.denyManual(ctx, input.DeniedActionCode, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityAttemptKey(input.CardID, input.OperatorID),
|
||||
map[string]any{"card_id": input.CardID, "manual_operator_id": input.OperatorID}, input.OperatorID, nil)
|
||||
}
|
||||
card, err := s.iotCardStore.GetByID(ctx, input.CardID)
|
||||
if err != nil || card == nil {
|
||||
return nil, s.denyManual(ctx, input.DeniedActionCode, errors.CodeForbidden, pollingDeniedMessage,
|
||||
pollingPriorityAttemptKey(input.CardID, input.OperatorID),
|
||||
map[string]any{"card_id": input.CardID, "manual_operator_id": input.OperatorID}, input.OperatorID, nil)
|
||||
}
|
||||
|
||||
taskTypes := input.TaskTypes
|
||||
result := &EnqueueResult{CardID: input.CardID, TaskTypes: taskTypes}
|
||||
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
|
||||
store := s.priorityStore.WithTx(tx)
|
||||
// 资产身份与代理归属在入队时冻结,口径与迁移回填一致;查询不到卡行时按「资产为卡自身」兜底。
|
||||
snapshots, snapshotErr := store.AssetSnapshots(ctx, []uint{input.CardID})
|
||||
if snapshotErr != nil {
|
||||
return snapshotErr
|
||||
}
|
||||
snapshot := snapshots[input.CardID]
|
||||
for _, taskType := range taskTypes {
|
||||
item := &model.PollingPriorityItem{
|
||||
CardID: input.CardID,
|
||||
TaskType: taskType,
|
||||
TriggerType: constants.PollingPriorityTriggerManual,
|
||||
ManualReason: input.Reason,
|
||||
ManualOperatorID: input.OperatorID,
|
||||
ManualOperatorName: input.OperatorName,
|
||||
ShopIDSnapshot: card.ShopID,
|
||||
AssetType: snapshot.AssetType,
|
||||
AssetID: optionalUintValue(snapshot.AssetID),
|
||||
DeviceNoSnapshot: snapshot.DeviceNo,
|
||||
AgentShopIDSnapshot: snapshot.AgentShopID,
|
||||
}
|
||||
// 先读取合并前的活动项作为审计 Before 的真实快照;无活动项则 Before 为空。
|
||||
// 残余近似:并发下相邻执行可能在该读取与合并之间把该行终态化并另建新行,
|
||||
// 此时 Before 描述的是读取时点的活动项而非实际被合并行,属可接受的审计快照。
|
||||
before, beforeErr := store.FindActive(ctx, input.CardID, taskType)
|
||||
if beforeErr != nil {
|
||||
return beforeErr
|
||||
}
|
||||
created, insertErr := store.InsertOrMerge(ctx, item)
|
||||
if insertErr != nil {
|
||||
return insertErr
|
||||
}
|
||||
active, activeErr := store.FindActive(ctx, input.CardID, taskType)
|
||||
if activeErr != nil {
|
||||
return activeErr
|
||||
}
|
||||
if active == nil {
|
||||
return errors.New(errors.CodeInternalError, "优先轮询项入队后未能读回活动项")
|
||||
}
|
||||
if created {
|
||||
result.CreatedCount++
|
||||
} else {
|
||||
result.MergedCount++
|
||||
}
|
||||
result.Items = append(result.Items, EnqueueItemResult{
|
||||
ItemID: active.ID,
|
||||
TaskType: active.TaskType,
|
||||
Status: active.Status,
|
||||
AttemptCount: active.AttemptCount,
|
||||
TriggerCount: active.TriggerCount,
|
||||
LastTriggeredAt: active.LastTriggeredAt,
|
||||
Created: created,
|
||||
})
|
||||
// 入队事实与审计同事务:合并结果同样落库,避免「加急事实已生效但无审计」。
|
||||
if auditErr := writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityEnqueued,
|
||||
Summary: "人工优先入队",
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceID: active.ID,
|
||||
ResourceKey: pollingPriorityResourceKey(active.ID),
|
||||
DisplayName: "卡轮询优先项",
|
||||
OperatorID: input.OperatorID,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": active.ID, "card_id": active.CardID, "task_type": active.TaskType,
|
||||
"status": active.Status, "trigger_type": active.TriggerType,
|
||||
"trigger_count": active.TriggerCount, "manual_operator_id": active.ManualOperatorID,
|
||||
},
|
||||
BeforeData: pollingPriorityBeforeData(before),
|
||||
AfterData: map[string]any{
|
||||
"status": active.Status, "trigger_type": active.TriggerType, "trigger_types": triggerTypesReadable(active.TriggerTypes),
|
||||
"trigger_count": active.TriggerCount, "last_triggered_at": active.LastTriggeredAt,
|
||||
"manual_reason": active.ManualReason, "manual_operator_id": active.ManualOperatorID,
|
||||
"source": constants.AuditSourceAdminAPI, "occurred_at": time.Now(),
|
||||
},
|
||||
Metadata: map[string]any{"created": created, "task_type": taskType, "card_id": input.CardID},
|
||||
Cards: []*model.IotCard{card},
|
||||
}); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
if input.RetriggeredFromItemID != 0 {
|
||||
if auditErr := s.writeRetriggerAudit(ctx, tx, input, active, taskType, card); auditErr != nil {
|
||||
return auditErr
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 提交后逐任务类型下发执行提示:与既有手动触发队列分离的优先提示通道,每类型独立键。
|
||||
if s.publisher == nil {
|
||||
s.logger.Error("优先轮询提示通道未配置,本次入队只依赖普通轮询兜底", zap.Uint("card_id", input.CardID))
|
||||
} else {
|
||||
for _, taskType := range taskTypes {
|
||||
if publishErr := s.publisher.EnqueuePriority(ctx, input.CardID, taskType); publishErr != nil {
|
||||
// 提示通道不是权威:下发失败只退化为延迟一个普通轮询周期,不撤销已生效的入队事实。
|
||||
s.logger.Error("下发优先轮询执行提示失败",
|
||||
zap.Uint("card_id", input.CardID), zap.String("task_type", taskType), zap.Error(publishErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
s.logger.Info("人工优先入队成功",
|
||||
zap.Uint("card_id", input.CardID), zap.Int("created_count", result.CreatedCount),
|
||||
zap.Int("merged_count", result.MergedCount), zap.Uint("operator_id", input.OperatorID))
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// writeRetriggerAudit 写入一条人工重触发审计,与新建活动项同一事务。
|
||||
func (s *PriorityManualService) writeRetriggerAudit(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
input priorityManualEnqueue,
|
||||
active *model.PollingPriorityItem,
|
||||
taskType string,
|
||||
card *model.IotCard,
|
||||
) error {
|
||||
occurredAt := time.Now()
|
||||
return writePollingAudit(ctx, tx, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: constants.AuditActionPollingPriorityRetriggered,
|
||||
Summary: "人工重触发卡轮询优先项",
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceID: active.ID,
|
||||
ResourceKey: pollingPriorityResourceKey(active.ID),
|
||||
DisplayName: "卡轮询优先项",
|
||||
OperatorID: input.OperatorID,
|
||||
IdentitySnapshot: map[string]any{
|
||||
"id": active.ID, "card_id": active.CardID, "task_type": active.TaskType,
|
||||
"status": active.Status, "trigger_type": active.TriggerType,
|
||||
"attempt_count": active.AttemptCount, "manual_operator_id": active.ManualOperatorID,
|
||||
},
|
||||
AfterData: map[string]any{
|
||||
"source_item_id": input.RetriggeredFromItemID, "reason": input.Reason,
|
||||
"status": active.Status, "attempt_count": active.AttemptCount, "attempt_limit": active.AttemptLimit,
|
||||
"trigger_type": active.TriggerType, "created": true,
|
||||
"source": constants.AuditSourceAdminAPI, "occurred_at": occurredAt,
|
||||
},
|
||||
Metadata: map[string]any{
|
||||
"source_item_id": input.RetriggeredFromItemID, "reason": input.Reason,
|
||||
"card_id": active.CardID, "task_type": taskType,
|
||||
},
|
||||
Cards: cardSlice(card),
|
||||
})
|
||||
}
|
||||
|
||||
// denyManual 记录一次人工入口被拒绝的审计,并返回原错误。
|
||||
// 拒绝发生在业务事务之外(或业务已回滚),因此沿用既有轮询模块的独立短事务模式。
|
||||
func (s *PriorityManualService) denyManual(
|
||||
ctx context.Context,
|
||||
actionCode string,
|
||||
code int,
|
||||
summary string,
|
||||
resourceKey string,
|
||||
identity map[string]any,
|
||||
operatorID uint,
|
||||
cards []*model.IotCard,
|
||||
) error {
|
||||
appErr := errors.New(code, summary)
|
||||
recordPollingFailure(ctx, s.db, s.auditWriter, audit.PollingInput{
|
||||
ActionCode: actionCode,
|
||||
Summary: summary,
|
||||
ResourceType: constants.AuditResourcePollingPriorityItem,
|
||||
ResourceKey: resourceKey,
|
||||
DisplayName: "卡轮询优先项",
|
||||
OperatorID: operatorID,
|
||||
Result: constants.AuditResultDenied,
|
||||
IdentitySnapshot: identity,
|
||||
Cards: cards,
|
||||
}, appErr)
|
||||
return appErr
|
||||
}
|
||||
|
||||
// loadCard 读取卡用于审计关联资源;读取失败只损失卡的关联资源,不阻断主流程。
|
||||
func (s *PriorityManualService) loadCard(ctx context.Context, cardID uint) *model.IotCard {
|
||||
if cardID == 0 {
|
||||
return nil
|
||||
}
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return card
|
||||
}
|
||||
|
||||
// cardSlice 把可空卡指针转成审计所需的关联资源切片。
|
||||
func cardSlice(card *model.IotCard) []*model.IotCard {
|
||||
if card == nil {
|
||||
return nil
|
||||
}
|
||||
return []*model.IotCard{card}
|
||||
}
|
||||
|
||||
// normalizePriorityReason 校验人工原因:去空白后非空且不超过事实表原因列的长度上限。
|
||||
// 返回的第二个值非空即为拒绝提示,调用方据此记录拒绝审计并拒绝请求。
|
||||
func normalizePriorityReason(reason, emptyMessage string) (string, string) {
|
||||
trimmed := strings.TrimSpace(reason)
|
||||
if trimmed == "" {
|
||||
return "", emptyMessage
|
||||
}
|
||||
if len([]rune(trimmed)) > constants.PollingPriorityManualReasonMaxLength {
|
||||
return "", priorityReasonTooLongMessage
|
||||
}
|
||||
return trimmed, ""
|
||||
}
|
||||
|
||||
// optionalUintValue 把非零 ID 转成可空指针,零值以 NULL 参与写入。
|
||||
func optionalUintValue(value uint) *uint {
|
||||
if value == 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
// pollingPriorityResourceKey 返回优先项在审计里的稳定资源键。
|
||||
func pollingPriorityResourceKey(itemID uint) string {
|
||||
return strconv.FormatUint(uint64(itemID), 10)
|
||||
}
|
||||
|
||||
// pollingPriorityAttemptKey 返回无入队对象的拒绝审计资源键(按卡与操作者稳定)。
|
||||
func pollingPriorityAttemptKey(cardID, operatorID uint) string {
|
||||
return "priority:" + strconv.FormatUint(uint64(cardID), 10) + ":" + strconv.FormatUint(uint64(operatorID), 10)
|
||||
}
|
||||
|
||||
// pollingPriorityBeforeData 把合并前的活动项快照投影为审计前后值;无活动项(新建)时返回空对象。
|
||||
func pollingPriorityBeforeData(before *model.PollingPriorityItem) map[string]any {
|
||||
if before == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return map[string]any{
|
||||
"status": before.Status,
|
||||
"trigger_type": before.TriggerType,
|
||||
"trigger_types": triggerTypesReadable(before.TriggerTypes),
|
||||
"trigger_count": before.TriggerCount,
|
||||
}
|
||||
}
|
||||
|
||||
// triggerTypesReadable 将存储形式的触发类型集合(两端补逗号)还原为可读的逗号分隔形式。
|
||||
func triggerTypesReadable(raw string) string {
|
||||
return strings.Trim(raw, ",")
|
||||
}
|
||||
@@ -64,6 +64,7 @@ func (s *Service) loadAttemptResponses(ctx context.Context, refunds []*model.Ref
|
||||
RefundAmount: attempt.RefundAmount,
|
||||
FrozenActualReceivedAmount: attempt.FrozenActualReceivedAmount,
|
||||
RefundReason: attempt.RefundReason,
|
||||
Remark: attempt.Remark,
|
||||
CustomerAccountInfo: attempt.CustomerAccountInfo,
|
||||
CustomerVoucherKey: []string(attempt.CustomerVoucherKeys),
|
||||
ChannelRefundRequestNo: attempt.ChannelRefundRequestNo,
|
||||
|
||||
71
internal/service/refund/material.go
Normal file
71
internal/service/refund/material.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package refund
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
// refundAssetType 把订单类型映射为退款资产类型:card 单卡、device 设备。
|
||||
// 与退款列表、详情响应的资产类型口径一致;空值或未知订单类型返回空串。
|
||||
func refundAssetType(orderType string) string {
|
||||
switch orderType {
|
||||
case model.OrderTypeSingleCard:
|
||||
return "card"
|
||||
case model.OrderTypeDevice:
|
||||
return "device"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// loadRefundMaterial 按既有展示口径解析本次提交的企业微信审批材料事实。
|
||||
//
|
||||
// 三个维度各自独立解析且全部尽力而为:
|
||||
// 1. 资产类型沿用退款申请的下单快照推导(refundAssetType,与详情响应同一规则);
|
||||
// 2. 设备类型与型号取退款请求关联设备,无关联设备或设备不可读时留空;
|
||||
// 3. 套餐已用量与总量复用退款展示解析规则(loadRefundPackageUsages,冻结套餐记录 → 订单主套餐 →
|
||||
// 订单任一套餐)与真流量口径,解析不到时为 0;
|
||||
// 4. 原支付渠道交易流水号取创建申请时冻结的原成功支付事实,线下订单为空。
|
||||
//
|
||||
// 审批材料是审批判断依据而非金额与状态判定输入,因此解析失败 MUST NOT 阻断审批提交:
|
||||
// 任一项不可解析时以空值或零值提交,并记录日志供排查。
|
||||
func (s *Service) loadRefundMaterial(ctx context.Context, refund *model.RefundRequest, payment *model.Payment) refundapprovalapp.RefundMaterial {
|
||||
material := refundapprovalapp.RefundMaterial{
|
||||
AssetType: refundAssetType(refund.OrderType),
|
||||
OriginalChannelTradeNo: strings.TrimSpace(refund.OriginalChannelTradeNo),
|
||||
}
|
||||
if payment != nil && strings.TrimSpace(payment.ThirdPartyTradeNo) != "" {
|
||||
material.OriginalChannelTradeNo = strings.TrimSpace(payment.ThirdPartyTradeNo)
|
||||
}
|
||||
if refund.DeviceID != nil && *refund.DeviceID > 0 {
|
||||
var device model.Device
|
||||
if err := s.db.WithContext(ctx).Select("id", "device_type", "device_model").First(&device, *refund.DeviceID).Error; err != nil {
|
||||
s.logMaterialFailure(ctx, refund.ID, "device", err)
|
||||
} else {
|
||||
material.DeviceType = strings.TrimSpace(device.DeviceType)
|
||||
material.DeviceModel = strings.TrimSpace(device.DeviceModel)
|
||||
}
|
||||
}
|
||||
usages, err := s.loadRefundPackageUsages(ctx, []*model.RefundRequest{refund})
|
||||
if err != nil {
|
||||
s.logMaterialFailure(ctx, refund.ID, "package_usage", err)
|
||||
} else if usage, exists := usages[refund.ID]; exists {
|
||||
material.PackageUsedMB = usage.UsedMB
|
||||
material.PackageTotalMB = usage.TotalMB
|
||||
}
|
||||
return material
|
||||
}
|
||||
|
||||
// logMaterialFailure 记录审批材料解析失败:材料按空值或零值降级提交,因此只记录不返回错误。
|
||||
func (s *Service) logMaterialFailure(ctx context.Context, refundID uint, dimension string, err error) {
|
||||
if s.logger == nil {
|
||||
return
|
||||
}
|
||||
s.logger.Warn("退款审批材料解析失败,按空值提交",
|
||||
zap.Uint("refund_id", refundID), zap.String("dimension", dimension), zap.Error(err))
|
||||
}
|
||||
@@ -263,3 +263,29 @@ func buildChannelRefundRequestNo(method string, config *model.WechatConfig) stri
|
||||
}
|
||||
return requestChannelRefundNo(config, time.Now())
|
||||
}
|
||||
|
||||
// frozenSourcePaymentNo 取原成功支付记录的支付单号作为冻结的来源支付单号。
|
||||
// 线下订单没有线上支付记录(payment 为 nil),返回空串以空值保存,不阻断申请创建。
|
||||
func frozenSourcePaymentNo(payment *model.Payment) string {
|
||||
if payment == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(payment.PaymentNo)
|
||||
}
|
||||
|
||||
// frozenOriginalChannelTradeNo 取原成功支付记录的渠道交易流水号作为冻结的原支付渠道交易流水号。
|
||||
// 线下订单没有线上支付记录(payment 为 nil 或流水号缺失),返回空串以空值保存。
|
||||
func frozenOriginalChannelTradeNo(payment *model.Payment) string {
|
||||
if payment == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(payment.ThirdPartyTradeNo)
|
||||
}
|
||||
|
||||
// applicantRemark 归一化请求中的申请人备注:缺省与纯空白一律视为未填写。
|
||||
func applicantRemark(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(*value)
|
||||
}
|
||||
|
||||
@@ -152,6 +152,11 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 退款原因必填只在创建与重提用例校验:历史申请与补发历史审批路径不受约束、不追溯。
|
||||
refundReason := strings.TrimSpace(req.RefundReason)
|
||||
if refundReason == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "退款原因不能为空")
|
||||
}
|
||||
if err := validateRefundMethod(decision, req.Method); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -191,8 +196,11 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
|
||||
Method: req.Method,
|
||||
CustomerAccountInfo: req.CustomerAccountInfo,
|
||||
RefundVoucherKey: refundVoucherKey,
|
||||
RefundReason: req.RefundReason,
|
||||
Status: model.RefundStatusPending,
|
||||
RefundReason: refundReason,
|
||||
// 来源支付事实在创建时冻结:线下订单没有线上支付记录,两个字段以空值保存且不阻断申请创建。
|
||||
SourcePaymentNo: frozenSourcePaymentNo(decision.Payment),
|
||||
OriginalChannelTradeNo: frozenOriginalChannelTradeNo(decision.Payment),
|
||||
Status: model.RefundStatusPending,
|
||||
}
|
||||
refund.Creator = userID
|
||||
refund.Updater = userID
|
||||
@@ -203,6 +211,8 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt
|
||||
result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{
|
||||
Refund: refund, Order: order, SubmitterAccountID: userID,
|
||||
ChannelRefundRequestNo: buildChannelRefundRequestNo(refund.Method, decision.ChannelConfig),
|
||||
ApplicantRemark: applicantRemark(req.Remark),
|
||||
Material: s.loadRefundMaterial(ctx, refund, decision.Payment),
|
||||
})
|
||||
if err != nil {
|
||||
failedRefund := *refund
|
||||
@@ -290,7 +300,9 @@ func (s *Service) TriggerApproval(ctx context.Context, id uint) (*dto.RefundResp
|
||||
if err != nil {
|
||||
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
result, err := s.refundApprovalCreation.TriggerHistorical(ctx, refund.ID)
|
||||
// 补发不追溯退款原因必填,但仍按既有展示口径解析审批材料,使新增控件的材料在补发路径同样完整。
|
||||
payment, _ := s.loadPaidPackagePayment(ctx, refund.OrderID)
|
||||
result, err := s.refundApprovalCreation.TriggerHistorical(ctx, refund.ID, s.loadRefundMaterial(ctx, refund, payment))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -832,6 +844,13 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
if req.RefundReason != nil {
|
||||
refundReason = *req.RefundReason
|
||||
}
|
||||
// 重提同样强制退款原因必填(去空白后非空);不填时沿用原有原因,原有原因为空的存量单不允许重提。
|
||||
refundReason = strings.TrimSpace(refundReason)
|
||||
if refundReason == "" {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order,
|
||||
errors.New(errors.CodeInvalidParam, "退款原因不能为空"))
|
||||
return errors.New(errors.CodeInvalidParam, "退款原因不能为空")
|
||||
}
|
||||
customerAccountInfo := refund.CustomerAccountInfo
|
||||
if req.CustomerAccountInfo != nil {
|
||||
customerAccountInfo = *req.CustomerAccountInfo
|
||||
@@ -840,8 +859,14 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, err)
|
||||
return err
|
||||
}
|
||||
// 申请人备注缺省沿用该退款单最近一次尝试冻结的备注,填写时替换;两者都只影响本次新增的尝试快照。
|
||||
remark := s.latestAttemptRemark(ctx, refund)
|
||||
if req.Remark != nil {
|
||||
remark = applicantRemark(req.Remark)
|
||||
}
|
||||
|
||||
// 重提的实收金额重新派生并冻结;提交人传入的实收金额一律忽略。
|
||||
// 来源支付事实随本次重提一并重新冻结,与重新派生的冻结实收金额保持同一事实来源。
|
||||
updated := *refund
|
||||
updated.Method = method
|
||||
updated.RequestedRefundAmount = requestedRefundAmount
|
||||
@@ -850,6 +875,8 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
updated.RefundVoucherKey = refundVoucherKey
|
||||
updated.RefundReason = refundReason
|
||||
updated.CustomerAccountInfo = customerAccountInfo
|
||||
updated.SourcePaymentNo = frozenSourcePaymentNo(decision.Payment)
|
||||
updated.OriginalChannelTradeNo = frozenOriginalChannelTradeNo(decision.Payment)
|
||||
updated.Creator = userID
|
||||
|
||||
attemptChannelRefundRequestNo := buildChannelRefundRequestNo(method, decision.ChannelConfig)
|
||||
@@ -858,6 +885,8 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
|
||||
}
|
||||
if _, err := s.refundApprovalCreation.Resubmit(ctx, id, refundapprovalapp.ResubmitCommand{
|
||||
Refund: &updated, ChannelRefundRequestNo: attemptChannelRefundRequestNo,
|
||||
ApplicantRemark: remark,
|
||||
Material: s.loadRefundMaterial(ctx, &updated, decision.Payment),
|
||||
}); err != nil {
|
||||
s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err)
|
||||
return err
|
||||
@@ -1268,20 +1297,13 @@ func normalizeRefundVoucherKey(keys []string) (model.StringJSONBArray, error) {
|
||||
|
||||
// buildRefundResponse 将退款 Model 转换为 DTO 响应
|
||||
func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
|
||||
assetType := ""
|
||||
if r.OrderType == model.OrderTypeSingleCard {
|
||||
assetType = "card"
|
||||
} else if r.OrderType == model.OrderTypeDevice {
|
||||
assetType = "device"
|
||||
}
|
||||
|
||||
resp := &dto.RefundResponse{
|
||||
ID: r.ID,
|
||||
RefundNo: r.RefundNo,
|
||||
OrderID: r.OrderID,
|
||||
OrderNo: r.OrderNo,
|
||||
AssetIdentifier: r.AssetIdentifier,
|
||||
AssetType: assetType,
|
||||
AssetType: refundAssetType(r.OrderType),
|
||||
IotCardID: r.IotCardID,
|
||||
DeviceID: r.DeviceID,
|
||||
PackageUsageID: r.PackageUsageID,
|
||||
@@ -1303,6 +1325,10 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
|
||||
ChannelRefundNo: r.ChannelRefundNo,
|
||||
ChannelRefundRequestNo: r.ChannelRefundRequestNo,
|
||||
ChannelRefundAmount: r.ChannelRefundAmount,
|
||||
SourcePaymentNo: r.SourcePaymentNo,
|
||||
OriginalChannelTradeNo: r.OriginalChannelTradeNo,
|
||||
OfflineSettlementNo: r.OfflineSettlementNo,
|
||||
OfflineSettledBy: r.OfflineSettledBy,
|
||||
FailureReason: r.FailureReason,
|
||||
FailureReasonName: constants.RefundFailureReasonName(r.FailureReason),
|
||||
FailureMessage: r.FailureMessage,
|
||||
@@ -1330,6 +1356,9 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
|
||||
if r.ChannelRefundedAt != nil {
|
||||
resp.ChannelRefundedAt = r.ChannelRefundedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
if r.OfflineSettledAt != nil {
|
||||
resp.OfflineSettledAt = r.OfflineSettledAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
203
internal/service/refund/settlement.go
Normal file
203
internal/service/refund/settlement.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package refund
|
||||
|
||||
import (
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
)
|
||||
|
||||
// latestAttemptRemark 读取该退款单最近一次审批尝试冻结的申请人备注。
|
||||
// 重提时未填写备注则沿用该值,使申请人不必重复填写;读取失败按未填写处理(备注不参与金额与状态判定)。
|
||||
func (s *Service) latestAttemptRemark(ctx context.Context, refund *model.RefundRequest) string {
|
||||
if refund == nil || refund.ID == 0 {
|
||||
return ""
|
||||
}
|
||||
var attempt model.RefundRequestAttempt
|
||||
err := s.db.WithContext(ctx).
|
||||
Select("id", "remark").
|
||||
Where("refund_id = ?", refund.ID).
|
||||
Order("attempt_no DESC, id DESC").
|
||||
First(&attempt).Error
|
||||
if err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
s.logMaterialFailure(ctx, refund.ID, "applicant_remark", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(attempt.Remark)
|
||||
}
|
||||
|
||||
// OrderOptions 按来源订单查询可选退款方式。
|
||||
//
|
||||
// 判定复用创建与重提使用的方式判定实现(decideRefundMethods),其中原路可退的凭证判定
|
||||
// 复用执行前预检所用的同一凭证判定来源(refundchannel.RefundCredentialIssue),
|
||||
// MUST NOT 引入第二套判定或独立开关。审批提交只消费已冻结材料,本查询不产生任何副作用。
|
||||
//
|
||||
// 判定所需事实缺失(无原成功支付记录、金额非正、支付方式不支持退款)时返回不可用原因而非报错;
|
||||
// 数据库或能力未配置等基础设施故障仍按错误返回。查询受订单数据范围约束,越权与订单不存在不可区分。
|
||||
func (s *Service) OrderOptions(ctx context.Context, req *dto.RefundOrderOptionsRequest) (*dto.RefundOrderOptionsResponse, error) {
|
||||
if req == nil || req.OrderID == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "订单ID不能为空")
|
||||
}
|
||||
order, err := s.orderStore.GetByID(ctx, req.OrderID)
|
||||
if err != nil {
|
||||
// 数据范围外的订单与不存在的订单返回同一结果,避免形成可枚举差异。
|
||||
return nil, errors.New(errors.CodeNotFound, "订单不存在")
|
||||
}
|
||||
|
||||
response := &dto.RefundOrderOptionsResponse{
|
||||
OrderID: order.ID,
|
||||
OrderNo: order.OrderNo,
|
||||
OrderPaymentType: order.PaymentMethod,
|
||||
Methods: []dto.RefundMethodOptionResponse{},
|
||||
RefundCapability: dto.RefundCapabilityResponse{},
|
||||
}
|
||||
decision, err := s.decideRefundMethods(ctx, order)
|
||||
if err != nil {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) && appErr.Code == errors.CodeInvalidParam {
|
||||
// 判定事实缺失:以不可用原因返回,不让只读查询报错。
|
||||
response.UnavailableReason = appErr.Message
|
||||
response.RefundCapability = dto.RefundCapabilityResponse{Available: false, Unavailable: appErr.Message}
|
||||
return response, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
response.Methods = make([]dto.RefundMethodOptionResponse, 0, len(decision.Options))
|
||||
for _, option := range decision.Options {
|
||||
response.Methods = append(response.Methods, dto.RefundMethodOptionResponse{
|
||||
Method: option.Method, MethodName: option.Name,
|
||||
Available: option.Available, Unavailable: option.Reason,
|
||||
})
|
||||
}
|
||||
response.RefundCapability = refundCapabilityProjection(decision.Options)
|
||||
if decision.Payment != nil {
|
||||
response.OriginalChannelTradeNo = strings.TrimSpace(decision.Payment.ThirdPartyTradeNo)
|
||||
response.MerchantID = decision.Payment.MerchantID
|
||||
response.MerchantName = strings.TrimSpace(decision.Payment.MerchantNameSnapshot)
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// refundCapabilityProjection 从方式判定结果投影原路退款能力校验结果。
|
||||
// 订单不含原路方式时能力不可用,原因取该订单方式矩阵的说明,避免展示与方式集合不一致的结论。
|
||||
func refundCapabilityProjection(options []refundMethodOption) dto.RefundCapabilityResponse {
|
||||
for _, option := range options {
|
||||
if option.Method != constants.RefundMethodOriginalRoute {
|
||||
continue
|
||||
}
|
||||
return dto.RefundCapabilityResponse{Available: option.Available, Unavailable: option.Reason}
|
||||
}
|
||||
return dto.RefundCapabilityResponse{Available: false, Unavailable: "该订单的退款方式矩阵不包含原路退款"}
|
||||
}
|
||||
|
||||
// RegisterOfflineSettlement 登记或更正线下退款处理流水号。
|
||||
//
|
||||
// 仅客户收款信息退款(线下到账方式)允许登记;重复调用即更正,历史值由审计留存。
|
||||
// 登记在同一事务内写审计(操作者、时间、前后值),且 MUST NOT 改变退款状态、实收金额、
|
||||
// 套餐失效与佣金回溯规则(ENG-TX-001:事实与要求成功必达的审计同事务)。
|
||||
func (s *Service) RegisterOfflineSettlement(ctx context.Context, id uint, req *dto.OfflineSettlementRequest) (*dto.RefundResponse, error) {
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
if userID == 0 {
|
||||
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
|
||||
}
|
||||
if id == 0 || req == nil {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "退款申请ID不能为空")
|
||||
}
|
||||
if s.auditWriter == nil {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款统一审计接缝未配置")
|
||||
}
|
||||
settlementNo := strings.TrimSpace(req.OfflineSettlementNo)
|
||||
if settlementNo == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "线下退款处理流水号不能为空")
|
||||
}
|
||||
if len(settlementNo) > 128 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "线下退款处理流水号长度不能超过 128")
|
||||
}
|
||||
refund, err := s.refundStore.GetByIDForOperation(ctx, id)
|
||||
if err != nil {
|
||||
// 数据范围外的退款申请与不存在返回同一结果。
|
||||
return nil, errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
if refund.Method != constants.RefundMethodCustomerAccount {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "仅客户收款信息退款方式可登记线下退款处理流水号")
|
||||
}
|
||||
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo})
|
||||
settledAt := time.Now().UTC()
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var current model.RefundRequest
|
||||
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "退款申请不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败")
|
||||
}
|
||||
if current.Method != constants.RefundMethodCustomerAccount {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅客户收款信息退款方式可登记线下退款处理流水号")
|
||||
}
|
||||
beforeState := refundSettlementState(¤t)
|
||||
result := tx.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
"offline_settlement_no": settlementNo,
|
||||
"offline_settled_at": settledAt,
|
||||
"offline_settled_by": userID,
|
||||
"updater": userID,
|
||||
"updated_at": settledAt,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, result.Error, "登记线下退款处理流水号失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return errors.New(errors.CodeConflict, "退款申请状态已变化")
|
||||
}
|
||||
if err := tx.WithContext(ctx).First(¤t, id).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款申请登记后快照失败")
|
||||
}
|
||||
primary := audit.RefundResource(¤t, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
|
||||
primary.BeforeData = beforeState
|
||||
primary.AfterData = refundSettlementState(¤t)
|
||||
primary.SubjectVisibility = constants.AuditSubjectResult
|
||||
primary.SubjectSummary = "登记线下退款处理流水号"
|
||||
if err := s.auditWriter.Append(ctx, tx, audit.AppendInput{
|
||||
ActionCode: constants.AuditActionRefundOfflineSettled, Summary: "登记线下退款处理流水号",
|
||||
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
|
||||
CorrelationID: current.RefundNo,
|
||||
Metadata: map[string]any{"operator_id": userID, "settled_at": settledAt},
|
||||
Resources: []audit.ResourceInput{primary},
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
refund = ¤t
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildRefundResponse(refund), nil
|
||||
}
|
||||
|
||||
// refundSettlementState 投影线下退款处理流水号的审计前后值。
|
||||
// 只包含本用例改动的字段,MUST NOT 夹带状态、实收金额等未改动事实,使审计前后值即登记差异。
|
||||
func refundSettlementState(refund *model.RefundRequest) map[string]any {
|
||||
state := map[string]any{
|
||||
"offline_settlement_no": refund.OfflineSettlementNo,
|
||||
"offline_settled_by": refund.OfflineSettledBy,
|
||||
}
|
||||
if refund.OfflineSettledAt != nil {
|
||||
state["offline_settled_at"] = refund.OfflineSettledAt
|
||||
}
|
||||
return state
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/config"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
@@ -127,15 +129,93 @@ end
|
||||
return 0
|
||||
`)
|
||||
|
||||
// verificationFailureWriteTimeout 是失败计数写入的独立超时:写入与请求生命周期解耦,避免客户端断开绕过计数。
|
||||
const verificationFailureWriteTimeout = 5 * time.Second
|
||||
|
||||
// verificationFailureCountScript 原子累加手机号维度的校验失败计数,并在同一步刷新计数窗口。
|
||||
// INCR 与 PEXPIRE 必须在同一脚本内完成:拆成 SETNX 再 INCR 会让并发提交在两次调用之间丢失过期时间,
|
||||
// 计数键永久驻留即等于手机号被永久锁定;同时 INCR 本身原子,并发提交不会丢失一次失败计数。
|
||||
// 每次失败都刷新窗口(滑动窗口):锁定期间的提交在比对前即被拒绝且不再计数,因此锁定不会续期,
|
||||
// 锁定上限为一个窗口,到期自动恢复。
|
||||
var verificationFailureCountScript = redis.NewScript(`
|
||||
local count = redis.call('INCR', KEYS[1])
|
||||
redis.call('PEXPIRE', KEYS[1], ARGV[1])
|
||||
return count
|
||||
`)
|
||||
|
||||
// verificationFailureCount 读取手机号当前窗口内的校验失败计数;键不存在表示窗口内尚未失败。
|
||||
func (s *Service) verificationFailureCount(ctx context.Context, phone string) (int64, error) {
|
||||
value, err := s.redisClient.Get(ctx, constants.RedisVerificationCodeFailKey(phone)).Result()
|
||||
if err == redis.Nil {
|
||||
return 0, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
// 计数键被外部写入非数字值:按窗口内无计数处理并记录,不作为限流依据。
|
||||
return 0, fmt.Errorf("校验失败计数取值非法: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// recordVerificationFailure 累加一次校验失败;计数写入失败只记录并放行,不改变本次校验结果。
|
||||
func (s *Service) recordVerificationFailure(ctx context.Context, phone string) {
|
||||
// 计数写入不随请求上下文取消而丢失:客户端提前断开不应成为绕过失败次数限制的通道。
|
||||
countCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), verificationFailureWriteTimeout)
|
||||
defer cancel()
|
||||
_, err := verificationFailureCountScript.Run(
|
||||
countCtx, s.redisClient, []string{constants.RedisVerificationCodeFailKey(phone)},
|
||||
constants.VerificationCodeFailureWindow.Milliseconds(),
|
||||
).Int64()
|
||||
if err != nil {
|
||||
s.logger.Error("累加验证码校验失败计数失败,按放行处理",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// clearVerificationFailures 在校验成功后清零手机号的失败计数;清零失败只记录,不影响校验成功结果。
|
||||
func (s *Service) clearVerificationFailures(ctx context.Context, phone string) {
|
||||
if err := s.redisClient.Del(ctx, constants.RedisVerificationCodeFailKey(phone)).Err(); err != nil {
|
||||
s.logger.Error("清零验证码校验失败计数失败,按成功放行",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// CheckCode 校验验证码但不消费。
|
||||
// 供消费必须晚于业务事实落库的链路复用;不消费时验证码仍受原有 5 分钟有效期约束。
|
||||
// 失败次数限制在验证码比对之前生效:窗口内失败达到上限后,锁定期间的校验一律拒绝(即使验证码正确),
|
||||
// 锁定随窗口到期自动解除;窗口内校验成功清零计数;校验失败不消费验证码。
|
||||
// 计数读写故障一律放行并记录,MUST NOT 因限流计数故障阻断注册、绑定、换绑、换证与登录的成功路径。
|
||||
func (s *Service) CheckCode(ctx context.Context, phone string, code string) error {
|
||||
failureCount, err := s.verificationFailureCount(ctx, phone)
|
||||
if err != nil {
|
||||
s.logger.Error("读取验证码校验失败计数失败,按放行处理",
|
||||
zap.String("phone", phone),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
if failureCount >= constants.VerificationCodeMaxFailures {
|
||||
s.logger.Warn("验证码校验失败次数达到上限,窗口内拒绝校验",
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
// 提示不含验证码正确性、剩余失败次数与内部键名。
|
||||
return errors.New(errors.CodeTooManyRequests, constants.VerificationCodeLockedMessage)
|
||||
}
|
||||
|
||||
// 从 Redis 获取验证码
|
||||
storedCode, err := s.redisClient.Get(ctx, constants.RedisVerificationCodeKey(phone)).Result()
|
||||
if err == redis.Nil {
|
||||
s.logger.Warn("验证码不存在或已过期",
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
// 已无可用验证码的提交同样是校验失败:计入失败次数,但不产生任何业务事实。
|
||||
s.recordVerificationFailure(ctx, phone)
|
||||
return errors.New(errors.CodeInvalidParam, "验证码不存在或已过期")
|
||||
}
|
||||
if err != nil {
|
||||
@@ -151,9 +231,12 @@ func (s *Service) CheckCode(ctx context.Context, phone string, code string) erro
|
||||
s.logger.Warn("验证码错误",
|
||||
zap.String("phone", phone),
|
||||
)
|
||||
s.recordVerificationFailure(ctx, phone)
|
||||
return errors.New(errors.CodeInvalidParam, "验证码错误")
|
||||
}
|
||||
|
||||
s.clearVerificationFailures(ctx, phone)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user