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:
@@ -87,6 +87,8 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
|
||||
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
|
||||
CardID: card.ID, CarrierID: card.CarrierID,
|
||||
ReadingMB: decision.LastGatewayReadingMB, ObservedAt: observation.Metadata.ObservedAt,
|
||||
// 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。
|
||||
Source: observation.Metadata.Source,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -86,7 +86,12 @@ func (s *Service) processDueLock(ctx context.Context, item *DueLock, stats *Cycl
|
||||
}
|
||||
unlocked := false
|
||||
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, reason)
|
||||
// 复机结果与解锁时间在同一条件更新内落库:已发起复机记 resumed,仅解锁记 skipped。
|
||||
resumeResult := domain.ResumeResultResumed
|
||||
if !ready {
|
||||
resumeResult = domain.ResumeResultSkipped
|
||||
}
|
||||
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, reason, resumeResult)
|
||||
if unlockErr != nil {
|
||||
return unlockErr
|
||||
}
|
||||
@@ -133,7 +138,7 @@ func (s *Service) processUnresolvableLock(ctx context.Context, item *DueLock, st
|
||||
zap.String("reason", item.Reason))
|
||||
unlocked := false
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, item.Reason)
|
||||
claimed, unlockErr := s.unlockInTx(ctx, tx, lock.ID, item.Reason, domain.ResumeResultManual)
|
||||
if unlockErr != nil {
|
||||
return unlockErr
|
||||
}
|
||||
|
||||
@@ -61,6 +61,14 @@ func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation doma
|
||||
zap.Uint("carrier_id", carrier.ID), zap.Int("data_reset_day", carrier.DataResetDay))
|
||||
return nil
|
||||
}
|
||||
// 判定时间取本次观测时刻;观测时刻缺失时退化为系统时刻,绝不写出零值判定时间。
|
||||
judgedAt := evaluation.ObservedAt
|
||||
if judgedAt.IsZero() {
|
||||
judgedAt = s.now()
|
||||
}
|
||||
// 命中数值冻结为字面量:读数与阈值共用同一对副本,避免把可变的配置读回留痕。
|
||||
hitTrafficMB := evaluation.ReadingMB
|
||||
hitThresholdValue := threshold.Value
|
||||
lock := &model.CarrierTrafficThresholdLock{
|
||||
CarrierID: evaluation.CarrierID,
|
||||
CardID: evaluation.CardID,
|
||||
@@ -68,6 +76,18 @@ func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation doma
|
||||
Status: domain.LockStatusLocked,
|
||||
StopStatus: domain.TaskStatusPending,
|
||||
ResumeStatus: domain.TaskStatusPending,
|
||||
// 命中事实与停机锁是同一条 INSERT 的列,不会因业务数据导致留痕失败而阻断停机:
|
||||
// 命中列的取值范围由判定前置条件(读数已接受、阈值有效、已判定达量)保证,业务数据无法使
|
||||
// hit_* CHECK 失败。数据库级故障与既有流量观测一致地整体回滚,停机顺延到下一次观测重试,
|
||||
// 不会出现「已提交停机但无留痕」的半事实。
|
||||
// 读数取本次判定使用的网关累计读数(与停机判定同一来源,不是本地用量或套餐真流量);
|
||||
// 阈值与单位取本次判定实际生效的通道配置值,冻结命中时值,之后改阈值不改写历史命中。
|
||||
HitTrafficMB: &hitTrafficMB,
|
||||
HitThresholdValue: &hitThresholdValue,
|
||||
HitThresholdUnit: threshold.Unit,
|
||||
JudgedAt: judgedAt,
|
||||
// 来源按列约束归一:未归类来源记为未记录,避免留痕列 CHECK 反过来阻断停机。
|
||||
TriggerSource: domain.SafeTriggerSource(evaluation.Source),
|
||||
}
|
||||
// 唯一冲突(该周期已处理)在 createLockInTx 内以保存点隔离:不重复建锁、不重复写停机事件。
|
||||
created, err := s.createLockInTx(ctx, tx, lock)
|
||||
@@ -85,6 +105,8 @@ func (s *Service) EvaluateInTx(ctx context.Context, tx *gorm.DB, evaluation doma
|
||||
s.logger.Info("卡流量达到运营商通道阈值,已写周期锁与停机事件",
|
||||
zap.Uint("carrier_id", evaluation.CarrierID), zap.Uint("card_id", evaluation.CardID),
|
||||
zap.Uint("lock_id", lock.ID), zap.Float64("reading_mb", evaluation.ReadingMB),
|
||||
zap.Float64("threshold_value", hitThresholdValue), zap.String("threshold_unit", threshold.Unit),
|
||||
zap.Time("judged_at", judgedAt), zap.String("trigger_source", lock.TriggerSource),
|
||||
zap.Time("period_start", periodStart))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -328,12 +328,20 @@ func (s *Service) markAnomalyInTx(ctx context.Context, tx *gorm.DB, lockID uint,
|
||||
}
|
||||
|
||||
// unlockInTx 在调用方事务内按 status=locked 条件更新认领解锁,返回 false 表示已被并发推进。
|
||||
// 未满足复机条件时同事务写入可观察原因;锁行与历史任务结果一律保留,绝不删除。
|
||||
func (s *Service) unlockInTx(ctx context.Context, tx *gorm.DB, lockID uint, failureReason string) (bool, error) {
|
||||
//
|
||||
// 同一条件更新同时写入解锁时间与复机结果;未满足复机条件时同事务写入可观察原因。
|
||||
// 更新集合只覆盖锁生命周期与周期恢复留痕,绝不触碰停机命中事实(hit_* / judged_at /
|
||||
// trigger_source):命中事实只由达量判定写入,周期恢复 MUST NOT 覆盖。
|
||||
// 锁行与历史任务结果一律保留,绝不删除。
|
||||
func (s *Service) unlockInTx(ctx context.Context, tx *gorm.DB, lockID uint, failureReason, resumeResult string) (bool, error) {
|
||||
if tx == nil {
|
||||
return false, errors.New(errors.CodeInvalidStatus, "通道阈值解锁必须传入事务句柄")
|
||||
}
|
||||
updates := map[string]any{"status": domain.LockStatusUnlocked}
|
||||
updates := map[string]any{
|
||||
"status": domain.LockStatusUnlocked,
|
||||
"unlocked_at": s.now(),
|
||||
"resume_result": resumeResult,
|
||||
}
|
||||
if failureReason != "" {
|
||||
updates["failure_reason"] = safeFailureReason(failureReason)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ func (s *RegistrationService) Register(
|
||||
Status: constants.AgentDistributionRegistrationStatusPending,
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username,
|
||||
registrationApprovalForm(normalized, parent))
|
||||
registrationApprovalForm(normalized, parent, s.parentBusinessOwnerName(ctx, parent)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -252,9 +252,14 @@ func (s *RegistrationService) findDistributionParent(ctx context.Context, distri
|
||||
}
|
||||
|
||||
// verificationFailure 把验证码校验失败转换为对外错误,保留验证码服务给出的可定位提示。
|
||||
// 限流错误码必须原样保留:失败次数达到上限后的锁定必须让调用方看到「稍后再试」,
|
||||
// 其余失败仍按验证码无效统一提示。
|
||||
func verificationFailure(err error) error {
|
||||
var appErr *errors.AppError
|
||||
if stderrors.As(err, &appErr) {
|
||||
if appErr.Code == errors.CodeTooManyRequests {
|
||||
return appErr
|
||||
}
|
||||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err, appErr.Message)
|
||||
}
|
||||
return errors.Wrap(errors.CodeVerificationCodeInvalid, err)
|
||||
@@ -278,7 +283,16 @@ func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID
|
||||
|
||||
// registrationApprovalForm 生成企业微信审批表单业务快照。
|
||||
// 手机号按脱敏值写入,禁止把完整手机号或密码写入审批表单与审计。
|
||||
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop) map[string]any {
|
||||
//
|
||||
// 业务员取上级店铺当前业务员的名称快照:上级无业务员时以空值提交并显式标记为「无」,
|
||||
// MUST NOT 以提交人、上级店铺主账号或其它账号填充。该字段是只读快照,审批通过时写入新店铺的
|
||||
// 初始业务员仍按既有规则取上级业务员当时值,不因本快照与通过时值不同而改写通过结果。
|
||||
func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop, businessOwnerName string) map[string]any {
|
||||
owner := strings.TrimSpace(businessOwnerName)
|
||||
absenceMark := ""
|
||||
if owner == "" {
|
||||
absenceMark = agentDistributionBusinessOwnerAbsenceMark
|
||||
}
|
||||
return map[string]any{
|
||||
constants.ApprovalFieldDistributionCode: distributiondomain.MaskDistributionCode(input.DistributionCode),
|
||||
constants.ApprovalFieldDistributionParentShopID: parent.ID,
|
||||
@@ -290,9 +304,34 @@ func registrationApprovalForm(input distributiondomain.RegistrationInput, parent
|
||||
constants.ApprovalFieldDistributionContactName: input.ContactName,
|
||||
constants.ApprovalFieldDistributionRegion: strings.TrimSpace(
|
||||
input.Province + input.City + input.District + input.Address),
|
||||
constants.ApprovalFieldDistributionBusinessOwner: owner,
|
||||
constants.ApprovalFieldDistributionBusinessOwnerAbsence: absenceMark,
|
||||
}
|
||||
}
|
||||
|
||||
// agentDistributionBusinessOwnerAbsenceMark 是注册审批材料中「上级店铺无业务员」的显式标记。
|
||||
// 与「字段未填」区分开,使审批人看到的是确认无业务员而非漏填。
|
||||
const agentDistributionBusinessOwnerAbsenceMark = "无"
|
||||
|
||||
// parentBusinessOwnerName 读取上级店铺当前业务员的名称快照。
|
||||
// 判定与店铺业务员投影的可用口径一致:账号存在、为平台账号、启用且未删除;
|
||||
// 任一条件不满足时返回空串,由审批材料显式标记为无,MUST NOT 用其它账号填充。
|
||||
func (s *RegistrationService) parentBusinessOwnerName(ctx context.Context, parent *model.Shop) string {
|
||||
if parent == nil || parent.BusinessOwnerAccountID == nil || *parent.BusinessOwnerAccountID == 0 {
|
||||
return ""
|
||||
}
|
||||
var account model.Account
|
||||
if err := s.db.WithContext(ctx).
|
||||
Select("id", "username", "user_type", "status").
|
||||
First(&account, *parent.BusinessOwnerAccountID).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
if account.UserType != constants.UserTypePlatform || account.Status != constants.StatusEnabled || account.DeletedAt.Valid {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(account.Username)
|
||||
}
|
||||
|
||||
// attachRegistrationInstance 回写注册记录关联的审批实例,写入一次后不可修改。
|
||||
func attachRegistrationInstance(
|
||||
ctx context.Context,
|
||||
|
||||
@@ -164,7 +164,10 @@ func (s *WithdrawalService) submit(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateWithdrawalInvoice(qualification, input.InvoiceKeys); err != nil {
|
||||
if !qualification.Passed {
|
||||
return qualificationCheckError(qualification)
|
||||
}
|
||||
if err := validateWithdrawalInvoice(qualification.Qualification, input.InvoiceKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureDailyWithdrawalLimit(ctx, tx, shopID, policy.DailyWithdrawalLimit, resubmitRequestID == nil); err != nil {
|
||||
@@ -222,6 +225,10 @@ func (s *WithdrawalService) submit(
|
||||
Amount: input.Amount, Fee: fee, FeeRate: policy.FeeRate, ActualAmount: actualAmount,
|
||||
WithdrawalMethod: input.WithdrawalMethod, AccountInfo: accountInfo,
|
||||
InvoiceKeys: invoiceKeys, SubmittedByAccountID: submitter.ID,
|
||||
// 当次资料资格校验结果随尝试冻结:资格被替换或失效后,历史尝试仍返回提交时的版本与结果。
|
||||
QualificationVersionID: qualification.VersionID,
|
||||
QualificationCheckedAt: &qualification.CheckedAt,
|
||||
QualificationPassed: qualificationPassedFlag(qualification.Passed),
|
||||
}
|
||||
if err := tx.WithContext(ctx).Create(attempt).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建提现审批尝试记录失败")
|
||||
@@ -348,30 +355,86 @@ func ensureOwnAgentShopForShopID(ctx context.Context, submitter withdrawalSubmit
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadValidQualification 读取当前有效的提现资料资格;缺失或已失效时拒绝提现申请。
|
||||
// qualificationCheck 是本次提现依据的资料资格校验结果。
|
||||
// 校验结果随当次审批尝试冻结:通过时记录版本标识与校验时间,未通过时给出稳定原因编码,
|
||||
// 供提现详情查询与审计核对;历史尝试不被改写。
|
||||
type qualificationCheck struct {
|
||||
// Qualification 仅在通过时非空。
|
||||
Qualification *model.WithdrawalQualification
|
||||
Passed bool
|
||||
VersionID uint
|
||||
CheckedAt time.Time
|
||||
// FailureReason 是未通过时的稳定原因编码(资格不存在/已失效/已停用/资料未通过审批),通过时为空。
|
||||
FailureReason string
|
||||
}
|
||||
|
||||
// qualificationCheckError 把未通过的稳定原因转成对外的稳定业务错误。
|
||||
// 提示文案同时携带稳定原因编码与中文名称,使拒绝结果与审计记录都能定位到具体原因(本变更要求
|
||||
// 拒绝结果与审计记录包含稳定原因)。
|
||||
func qualificationCheckError(check qualificationCheck) error {
|
||||
name := constants.GetWithdrawalQualificationFailureReasonName(check.FailureReason)
|
||||
detail := check.FailureReason + "/" + name
|
||||
if check.FailureReason == constants.WithdrawalQualificationFailureShopDisabled {
|
||||
return errors.New(errors.CodeInvalidStatus, "提现资料资格校验未通过("+detail+"):店铺已停用,请先恢复店铺后重新提交")
|
||||
}
|
||||
return errors.New(errors.CodeInvalidStatus, "提现资料资格校验未通过("+detail+"):请先完成资料审批后重新提交")
|
||||
}
|
||||
|
||||
// loadValidQualification 读取当前有效的提现资料资格;缺失、失效、未通过审批或店铺停用时返回未通过结果。
|
||||
//
|
||||
// 校验顺序沿用既有实现:先按资料资格判定,再判定店铺启停。稳定原因取值域与迁移
|
||||
// 000237 的列语义一致(资格不存在、已失效、已停用、资料未通过审批)。
|
||||
// 仅在数据库访问失败时返回错误,判定不通过一律以结果表达,使调用方能写审计并回传稳定原因。
|
||||
func loadValidQualification(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
shopID uint,
|
||||
) (*model.WithdrawalQualification, error) {
|
||||
var qualification model.WithdrawalQualification
|
||||
) (qualificationCheck, error) {
|
||||
check := qualificationCheck{CheckedAt: time.Now().UTC()}
|
||||
var latest model.WithdrawalQualification
|
||||
err := tx.WithContext(ctx).
|
||||
Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved).
|
||||
First(&qualification).Error
|
||||
Where("shop_id = ?", shopID).
|
||||
Order("id DESC").
|
||||
First(&latest).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批")
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureMissing
|
||||
return check, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询有效提现资料资格失败")
|
||||
return check, errors.Wrap(errors.CodeDatabaseError, err, "查询提现资料资格失败")
|
||||
}
|
||||
check.VersionID = latest.ID
|
||||
if latest.Status != constants.WithdrawalQualificationStatusApproved {
|
||||
if latest.Status == constants.WithdrawalQualificationStatusInvalidated {
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureInvalidated
|
||||
} else {
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureNotApproved
|
||||
}
|
||||
return check, nil
|
||||
}
|
||||
var shop model.Shop
|
||||
if err := tx.WithContext(ctx).Select("id", "status").First(&shop, shopID).Error; err != nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批")
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
|
||||
return check, nil
|
||||
}
|
||||
return check, errors.Wrap(errors.CodeDatabaseError, err, "查询提现店铺状态失败")
|
||||
}
|
||||
if shop.Status != constants.ShopStatusEnabled {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "店铺已停用,提现资料资格已失效")
|
||||
check.FailureReason = constants.WithdrawalQualificationFailureShopDisabled
|
||||
return check, nil
|
||||
}
|
||||
return &qualification, nil
|
||||
check.Qualification = &latest
|
||||
check.Passed = true
|
||||
return check, nil
|
||||
}
|
||||
|
||||
// qualificationPassedFlag 把校验结果转为尝试表的 0/1 标记,取值与迁移 000237 的 CHECK 一致。
|
||||
func qualificationPassedFlag(passed bool) int {
|
||||
if passed {
|
||||
return constants.WithdrawalQualificationCheckPassed
|
||||
}
|
||||
return constants.WithdrawalQualificationCheckNotPassed
|
||||
}
|
||||
|
||||
// validateWithdrawalInvoice 校验申请级发票仅在企业主体且已登记发票资料时提交。
|
||||
@@ -696,6 +759,9 @@ func withdrawalAuditSnapshot(
|
||||
"latest_approval_instance_id": request.LatestApprovalInstanceID,
|
||||
"anomaly_flag": request.AnomalyFlag,
|
||||
"invoice_count": len(decodeInvoiceKeys(attempt.InvoiceKeys)),
|
||||
// 当次资料资格校验留痕:版本标识与是否通过,未通过时的稳定原因不在成功快照中重复。
|
||||
"qualification_version_id": attempt.QualificationVersionID,
|
||||
"qualification_passed": attempt.QualificationPassed,
|
||||
}
|
||||
if wallet != nil {
|
||||
snapshot["wallet_id"] = wallet.ID
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
@@ -200,6 +201,14 @@ func (s *CandidateService) matchOperationConfig(ctx context.Context, page string
|
||||
Where("(jsonb_array_length(shop_ids) = 0 OR ?::jsonb <@ shop_ids)", shopJSON).
|
||||
Where("(jsonb_array_length(device_types) = 0 OR ?::jsonb <@ device_types)", deviceJSON).
|
||||
Where("(jsonb_array_length(card_types) = 0 OR ?::jsonb <@ card_types)", cardJSON).
|
||||
// 候选排序:先按类型类别(风险换卡提醒已在调用方先行处理,此处为推广高于公告),
|
||||
// 再按显式优先级降序,同优先级取最近更新时间最新,最后按 ID 稳定排序。
|
||||
// 类别顺序 MUST 用显式表达式表达:announcement 的字典序小于 promotion,
|
||||
// 直接按 popup_type 排序会把公告排在推广之前,与 111.md §13.3 相反。
|
||||
Order(clause.OrderBy{Expression: clause.Expr{
|
||||
SQL: "CASE popup_type WHEN ? THEN 0 ELSE 1 END ASC",
|
||||
Vars: []any{constants.H5PopupTypePromotion},
|
||||
}}).
|
||||
Order("priority DESC, updated_at DESC, id DESC").
|
||||
Take(&config).Error
|
||||
if err != nil {
|
||||
|
||||
@@ -44,6 +44,7 @@ func NewConfigurationService(db *gorm.DB, audit *audit.Writer) *ConfigurationSer
|
||||
type configurationInput struct {
|
||||
Title string
|
||||
Content string
|
||||
PopupType string
|
||||
Pages []string
|
||||
ShopIDs []uint
|
||||
DeviceTypes []string
|
||||
@@ -69,7 +70,12 @@ func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5P
|
||||
if request.Enabled != nil && *request.Enabled {
|
||||
enabled = constants.H5PopupStatusEnabled
|
||||
}
|
||||
priority := 0
|
||||
// 弹窗类型必填:它既参与类别排序,也决定缺省优先级的取值来源。
|
||||
popupType := strings.TrimSpace(request.PopupType)
|
||||
if !constants.IsH5PopupType(popupType) {
|
||||
return 0, errors.New(errors.CodeInvalidParam, "运营弹窗类型必须为 promotion(套餐政策推广)或 announcement(通用公告)")
|
||||
}
|
||||
priority := constants.H5PopupTypeDefaultPriority(popupType)
|
||||
if request.Priority != nil {
|
||||
priority = *request.Priority
|
||||
}
|
||||
@@ -78,7 +84,7 @@ func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5P
|
||||
actionType = *request.ActionType
|
||||
}
|
||||
normalized, err := normalizeConfigurationInput(configurationInput{
|
||||
Title: request.Title, Content: request.Content, Pages: request.Pages,
|
||||
Title: request.Title, Content: request.Content, PopupType: popupType, Pages: request.Pages,
|
||||
ShopIDs: request.ShopIDs, DeviceTypes: request.DeviceTypes, CardTypes: request.CardTypes,
|
||||
Priority: priority, Frequency: request.Frequency, ActionType: actionType,
|
||||
Enabled: enabled, StartsAt: request.StartsAt, EndsAt: request.EndsAt,
|
||||
@@ -88,7 +94,7 @@ func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5P
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
record := &model.H5PopupConfiguration{
|
||||
Title: normalized.Title, Content: normalized.Content,
|
||||
Title: normalized.Title, Content: normalized.Content, PopupType: normalized.PopupType,
|
||||
Pages: model.StringJSONBArray(normalized.Pages), ShopIDs: toJSONBStrings(normalized.ShopIDs),
|
||||
DeviceTypes: model.StringJSONBArray(normalized.DeviceTypes), CardTypes: model.StringJSONBArray(normalized.CardTypes),
|
||||
Priority: normalized.Priority, Frequency: normalized.Frequency, ActionType: normalized.ActionType,
|
||||
@@ -136,7 +142,7 @@ func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.
|
||||
beforeData := configurationAuditSnapshot(&before)
|
||||
|
||||
merged := configurationInput{
|
||||
Title: record.Title, Content: record.Content, Pages: storePages(record),
|
||||
Title: record.Title, Content: record.Content, PopupType: record.PopupType, Pages: storePages(record),
|
||||
ShopIDs: storeShopIDs(record), DeviceTypes: storeDeviceTypes(record), CardTypes: storeCardTypes(record),
|
||||
Priority: record.Priority, Frequency: record.Frequency, ActionType: record.ActionType,
|
||||
Enabled: record.Enabled, StartsAt: record.StartsAt, EndsAt: record.EndsAt,
|
||||
@@ -147,6 +153,9 @@ func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.
|
||||
if request.Content != nil {
|
||||
merged.Content = *request.Content
|
||||
}
|
||||
if request.PopupType != nil {
|
||||
merged.PopupType = strings.TrimSpace(*request.PopupType)
|
||||
}
|
||||
if request.Pages != nil {
|
||||
merged.Pages = *request.Pages
|
||||
}
|
||||
@@ -184,6 +193,7 @@ func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.
|
||||
now := time.Now().UTC()
|
||||
record.Title = normalized.Title
|
||||
record.Content = normalized.Content
|
||||
record.PopupType = normalized.PopupType
|
||||
record.Pages = model.StringJSONBArray(normalized.Pages)
|
||||
record.ShopIDs = toJSONBStrings(normalized.ShopIDs)
|
||||
record.DeviceTypes = model.StringJSONBArray(normalized.DeviceTypes)
|
||||
@@ -311,6 +321,11 @@ func normalizeConfigurationInput(input configurationInput) (configurationInput,
|
||||
if popupURLPattern.MatchString(normalized.Content) || popupRoutePattern.MatchString(normalized.Content) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文不接受 URL 或前端路由,只能使用受控动作")
|
||||
}
|
||||
// 类型必填且必须落在受控取值域内:它是候选排序的第一顺位类别键,非法值会让配置落进未定义类别。
|
||||
normalized.PopupType = strings.TrimSpace(input.PopupType)
|
||||
if !constants.IsH5PopupType(normalized.PopupType) {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗类型必须为 promotion(套餐政策推广)或 announcement(通用公告)")
|
||||
}
|
||||
normalized.Pages = dedupeStrings(input.Pages)
|
||||
if len(normalized.Pages) == 0 {
|
||||
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗至少需要一个命中页面")
|
||||
@@ -471,16 +486,16 @@ func configurationAuditResourceID(id uint) *string {
|
||||
// configurationAuditIdentity 生成配置身份快照,不含正文内容。
|
||||
func configurationAuditIdentity(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"id": record.ID, "title": record.Title, "pages": storePages(record),
|
||||
"id": record.ID, "title": record.Title, "popup_type": record.PopupType, "pages": storePages(record),
|
||||
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
|
||||
"enabled": record.Enabled, "version": record.Version,
|
||||
}
|
||||
}
|
||||
|
||||
// configurationAuditSnapshot 生成配置审计前后值快照,覆盖范围、优先级、频率、受控动作、启停、有效期与版本。
|
||||
// configurationAuditSnapshot 生成配置审计前后值快照,覆盖类型、范围、优先级、频率、受控动作、启停、有效期与版本。
|
||||
func configurationAuditSnapshot(record *model.H5PopupConfiguration) map[string]any {
|
||||
return map[string]any{
|
||||
"id": record.ID, "title": record.Title, "content": record.Content,
|
||||
"id": record.ID, "title": record.Title, "content": record.Content, "popup_type": record.PopupType,
|
||||
"pages": storePages(record), "shop_ids": storeShopIDs(record),
|
||||
"device_types": storeDeviceTypes(record), "card_types": storeCardTypes(record),
|
||||
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
|
||||
|
||||
@@ -193,29 +193,21 @@ func (s *ManagementService) ListPools(ctx context.Context, req dto.PaymentMercha
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "统计商户池失败")
|
||||
}
|
||||
var pools []model.PaymentMerchantPool
|
||||
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&pools).Error; err != nil {
|
||||
var rows []model.PaymentMerchantPool
|
||||
if err := query.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
|
||||
}
|
||||
poolIDs := make([]uint, 0, len(pools))
|
||||
for index := range pools {
|
||||
poolIDs = append(poolIDs, pools[index].ID)
|
||||
pools := make([]*model.PaymentMerchantPool, 0, len(rows))
|
||||
for index := range rows {
|
||||
pools = append(pools, &rows[index])
|
||||
}
|
||||
membersByPool := make(map[uint][]uint, len(pools))
|
||||
if len(poolIDs) > 0 {
|
||||
var members []model.PaymentMerchantPoolMember
|
||||
if err := s.db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
|
||||
}
|
||||
for index := range members {
|
||||
member := &members[index]
|
||||
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
|
||||
}
|
||||
projections, err := loadPoolProjections(ctx, s.db, pools, time.Now())
|
||||
if err != nil {
|
||||
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
|
||||
}
|
||||
result := make([]*dto.PaymentMerchantPoolResponse, 0, len(pools))
|
||||
for index := range pools {
|
||||
pool := &pools[index]
|
||||
result = append(result, &dto.PaymentMerchantPoolResponse{ID: pool.ID, Name: pool.Name, PaymentMethod: pool.PaymentMethod, Enabled: pool.Status == model.PaymentMerchantStatusEnabled, Strategy: pool.Strategy, ThresholdAmount: pool.ThresholdAmount, ThresholdCount: pool.ThresholdCount, StatisticCycle: pool.StatisticCycle, TimePeriodValue: pool.TimePeriodValue, TimePeriodUnit: pool.TimePeriodUnit, TimePeriodStartedAt: pool.TimePeriodStartedAt, RoutingEpoch: pool.RoutingEpoch, MemberIDs: membersByPool[pool.ID], Remark: pool.Remark})
|
||||
for _, pool := range pools {
|
||||
result = append(result, poolResponse(pool, projections[pool.ID]))
|
||||
}
|
||||
return result, total, nil
|
||||
}
|
||||
@@ -232,7 +224,7 @@ func (s *ManagementService) GetPool(ctx context.Context, id uint) (*dto.PaymentM
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
return s.poolView(ctx, &pool, time.Now())
|
||||
}
|
||||
|
||||
// ListMerchants 分页查询特权商户配置。
|
||||
@@ -429,8 +421,19 @@ func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.Payme
|
||||
if !creating && poolEpochChanged(&pool, &req, previousMemberIDs) {
|
||||
pool.RoutingEpoch++
|
||||
}
|
||||
// 时间轮询池修改周期数值、单位、起始时间或成员顺序并保存成功时,起点由服务端按保存成功时间重算,
|
||||
// 忽略请求携带的旧起点,避免前端时钟或漏传导致时段漂移。
|
||||
restartTimedSlot := !creating && strings.TrimSpace(req.Strategy) == model.PaymentMerchantStrategyTime && timedSlotChanged(&pool, &req, previousMemberIDs)
|
||||
pool.Name, pool.PaymentMethod, pool.Strategy, pool.Remark = strings.TrimSpace(req.Name), strings.TrimSpace(req.PaymentMethod), strings.TrimSpace(req.Strategy), strings.TrimSpace(req.Remark)
|
||||
pool.ThresholdAmount, pool.ThresholdCount, pool.StatisticCycle, pool.TimePeriodValue, pool.TimePeriodUnit, pool.TimePeriodStartedAt = req.ThresholdAmount, req.ThresholdCount, req.StatisticCycle, req.TimePeriodValue, req.TimePeriodUnit, req.TimePeriodStartedAt
|
||||
if creating {
|
||||
// 创建按请求起点;请求缺省起点时取保存时间。
|
||||
restartTimedSlot = pool.Strategy == model.PaymentMerchantStrategyTime && pool.TimePeriodStartedAt == nil
|
||||
}
|
||||
savedAt := time.Now()
|
||||
if restartTimedSlot {
|
||||
pool.TimePeriodStartedAt = &savedAt
|
||||
}
|
||||
if req.Enabled {
|
||||
var others int64
|
||||
if err := tx.Model(&model.PaymentMerchantPool{}).Where("payment_method = ? AND status = ? AND id <> ?", pool.PaymentMethod, model.PaymentMerchantStatusEnabled, pool.ID).Count(&others).Error; err != nil {
|
||||
@@ -479,7 +482,7 @@ func (s *ManagementService) SavePool(ctx context.Context, id uint, req dto.Payme
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "保存商户池失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
return s.poolView(ctx, &pool, time.Now())
|
||||
}
|
||||
|
||||
// SetPoolEnabled enables or disables a pool after rechecking the active-pool and member invariants.
|
||||
@@ -537,7 +540,7 @@ func (s *ManagementService) SetPoolEnabled(ctx context.Context, id uint, enabled
|
||||
}
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新商户池状态失败")
|
||||
}
|
||||
return poolResponse(ctx, s.db, &pool)
|
||||
return s.poolView(ctx, &pool, time.Now())
|
||||
}
|
||||
|
||||
func validatePoolMembers(ctx context.Context, tx *gorm.DB, method string, ids []uint) error {
|
||||
@@ -582,6 +585,17 @@ func poolEpochChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerch
|
||||
return pool.StatisticCycle == nil || (*pool.StatisticCycle != "day" && *pool.StatisticCycle != "month") || !sameMemberSet(previousMemberIDs, request.MemberIDs)
|
||||
}
|
||||
|
||||
// timedSlotChanged 判断保存时间轮询池时是否需要按保存成功时间重算时段起点:
|
||||
// 周期数值、单位、起始时间或成员顺序任一变化即视为需要重算。
|
||||
func timedSlotChanged(pool *model.PaymentMerchantPool, request *dto.PaymentMerchantPoolRequest, previousMemberIDs []uint) bool {
|
||||
if !sameInt64(pool.TimePeriodValue, request.TimePeriodValue) ||
|
||||
!sameString(pool.TimePeriodUnit, request.TimePeriodUnit) ||
|
||||
!sameTime(pool.TimePeriodStartedAt, request.TimePeriodStartedAt) {
|
||||
return true
|
||||
}
|
||||
return !sameMemberOrder(previousMemberIDs, request.MemberIDs)
|
||||
}
|
||||
|
||||
func sameMemberOrder(left, right []uint) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
@@ -633,16 +647,25 @@ func sameTime(a, b *time.Time) bool {
|
||||
func merchantResponse(m *model.PaymentMerchant) *dto.PaymentMerchantResponse {
|
||||
return &dto.PaymentMerchantResponse{ID: m.ID, Name: m.Name, PaymentMethod: m.PaymentMethod, ProviderType: m.ProviderType, MerchantIdentity: m.MerchantIdentity, Credentials: m.Credentials, CredentialVersion: m.CredentialVersion, Enabled: m.Status == model.PaymentMerchantStatusEnabled, Remark: m.Remark, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}
|
||||
}
|
||||
func poolResponse(ctx context.Context, db *gorm.DB, p *model.PaymentMerchantPool) (*dto.PaymentMerchantPoolResponse, error) {
|
||||
var rows []model.PaymentMerchantPoolMember
|
||||
if err := db.WithContext(ctx).Where("pool_id = ?", p.ID).Order("sort_order ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
|
||||
// poolResponse 组装商户池响应,成员、成员计数与命中成员由只读投影提供。
|
||||
func poolResponse(p *model.PaymentMerchantPool, projection poolProjection) *dto.PaymentMerchantPoolResponse {
|
||||
response := &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: projection.MemberIDs, EnabledMemberCount: projection.EnabledMembers, MemberCount: len(projection.MemberIDs), UpdatedAt: p.UpdatedAt, Remark: p.Remark}
|
||||
if projection.CurrentMerchant != nil {
|
||||
merchantID := projection.CurrentMerchant.ID
|
||||
response.CurrentMerchantID = &merchantID
|
||||
response.CurrentMerchantName = projection.CurrentMerchant.Name
|
||||
}
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
ids = append(ids, row.MerchantID)
|
||||
return response
|
||||
}
|
||||
|
||||
// poolView 读取商户池的只读列表投影并组装响应。
|
||||
func (s *ManagementService) poolView(ctx context.Context, pool *model.PaymentMerchantPool, now time.Time) (*dto.PaymentMerchantPoolResponse, error) {
|
||||
projections, err := loadPoolProjections(ctx, s.db, []*model.PaymentMerchantPool{pool}, now)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询商户池成员失败")
|
||||
}
|
||||
return &dto.PaymentMerchantPoolResponse{ID: p.ID, Name: p.Name, PaymentMethod: p.PaymentMethod, Enabled: p.Status == model.PaymentMerchantStatusEnabled, Strategy: p.Strategy, ThresholdAmount: p.ThresholdAmount, ThresholdCount: p.ThresholdCount, StatisticCycle: p.StatisticCycle, TimePeriodValue: p.TimePeriodValue, TimePeriodUnit: p.TimePeriodUnit, TimePeriodStartedAt: p.TimePeriodStartedAt, RoutingEpoch: p.RoutingEpoch, MemberIDs: ids, Remark: p.Remark}, nil
|
||||
return poolResponse(pool, projections[pool.ID]), nil
|
||||
}
|
||||
|
||||
// GetAuthorization 查询特权全局授权配置。
|
||||
|
||||
@@ -249,58 +249,145 @@ func (l *RuntimeLoader) SelectForNewPaymentWithTx(ctx context.Context, tx *gorm.
|
||||
return &RouteSelection{Merchant: chosen, Pool: &pool}, nil
|
||||
}
|
||||
|
||||
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
|
||||
// routingOutcome 是只读选路结果。
|
||||
type routingOutcome struct {
|
||||
// Merchant 是当前命中成员。
|
||||
Merchant *model.PaymentMerchant
|
||||
// OpenNewRound 为真表示每轮累计已全部达标,调用方需以条件更新开启新一轮。
|
||||
OpenNewRound bool
|
||||
}
|
||||
|
||||
// routeStats 是已按统计世代隔离的商户成功累计。
|
||||
type routeStats struct {
|
||||
amounts map[uint]int64
|
||||
counts map[uint]int64
|
||||
}
|
||||
|
||||
// newRouteStats 创建空的成功累计聚合。
|
||||
func newRouteStats() routeStats {
|
||||
return routeStats{amounts: map[uint]int64{}, counts: map[uint]int64{}}
|
||||
}
|
||||
|
||||
// add 累加一条成功事实。
|
||||
func (s routeStats) add(row *model.PaymentMerchantRoutingSuccess) {
|
||||
s.amounts[row.MerchantID] += row.Amount
|
||||
s.counts[row.MerchantID]++
|
||||
}
|
||||
|
||||
// statisticCycle 返回金额/笔数池的统计周期。
|
||||
func statisticCycle(pool *model.PaymentMerchantPool) (string, error) {
|
||||
if pool.StatisticCycle == nil {
|
||||
return "", errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
|
||||
}
|
||||
return *pool.StatisticCycle, nil
|
||||
}
|
||||
|
||||
// thresholdValue 返回金额/笔数池的达标阈值。
|
||||
func thresholdValue(pool *model.PaymentMerchantPool) (int64, error) {
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount {
|
||||
if pool.ThresholdAmount == nil {
|
||||
return 0, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
|
||||
}
|
||||
return *pool.ThresholdAmount, nil
|
||||
}
|
||||
if pool.ThresholdCount == nil {
|
||||
return 0, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
|
||||
}
|
||||
return *pool.ThresholdCount, nil
|
||||
}
|
||||
|
||||
// selectRouteMember 只读计算当前命中成员,与支付创建共用同一套规则:
|
||||
// 金额/笔数取成员顺序中第一个未达阈值的成员;「每轮累计」全部达标时取成员顺序第一项,
|
||||
// 由调用方开启新一轮;自然日与自然月全部达标时视为当期无可用成员;时间方式按当前时段计算。
|
||||
// 池内只有一个启用成员时该成员固定命中,不因达到阈值被跳过、也不因全部达标被拒绝,
|
||||
// 该规则优先于自然周期拒绝语义,短路在全部达标判定之前。
|
||||
// 本函数 MUST NOT 写库:不创建支付单、不推进统计世代、不计入成功累计。
|
||||
func selectRouteMember(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, stats routeStats, now time.Time) (routingOutcome, error) {
|
||||
if len(merchants) == 0 {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
|
||||
return routingOutcome{}, errors.New(errors.CodeNoPaymentConfig, "暂无可用商户")
|
||||
}
|
||||
if pool.Strategy == model.PaymentMerchantStrategyTime {
|
||||
return chooseTimedMerchant(pool, merchants, now)
|
||||
merchant, err := chooseTimedMerchant(pool, merchants, now)
|
||||
if err != nil {
|
||||
return routingOutcome{}, err
|
||||
}
|
||||
return routingOutcome{Merchant: merchant}, nil
|
||||
}
|
||||
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
|
||||
return routingOutcome{}, errors.New(errors.CodeInvalidStatus, "商户池轮询策略无效")
|
||||
}
|
||||
if pool.StatisticCycle == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "商户池统计周期未配置")
|
||||
cycle, err := statisticCycle(pool)
|
||||
if err != nil {
|
||||
return routingOutcome{}, err
|
||||
}
|
||||
threshold, err := thresholdValue(pool)
|
||||
if err != nil {
|
||||
return routingOutcome{}, err
|
||||
}
|
||||
if len(merchants) == 1 {
|
||||
return routingOutcome{Merchant: merchants[0]}, nil
|
||||
}
|
||||
for _, merchant := range merchants {
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount {
|
||||
if stats.amounts[merchant.ID] < threshold {
|
||||
return routingOutcome{Merchant: merchant}, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if stats.counts[merchant.ID] < threshold {
|
||||
return routingOutcome{Merchant: merchant}, nil
|
||||
}
|
||||
}
|
||||
if cycle != "round" {
|
||||
return routingOutcome{}, errors.New(errors.CodeNoPaymentConfig, "当前周期暂无可用商户")
|
||||
}
|
||||
return routingOutcome{Merchant: merchants[0], OpenNewRound: true}, nil
|
||||
}
|
||||
|
||||
func chooseMerchant(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
|
||||
var stats routeStats
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount || pool.Strategy == model.PaymentMerchantStrategyCount {
|
||||
loaded, err := loadPoolRouteStats(ctx, tx, pool, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats = loaded
|
||||
}
|
||||
outcome, err := selectRouteMember(pool, merchants, stats, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !outcome.OpenNewRound {
|
||||
return outcome.Merchant, nil
|
||||
}
|
||||
// 每轮累计全部达标:当轮结束,以条件更新递增统计世代开启新一轮;
|
||||
// 世代已被并发改变时按并发冲突拒绝本次创建,绝不在旧世代上重复累计。
|
||||
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return outcome.Merchant, nil
|
||||
}
|
||||
|
||||
// loadPoolRouteStats 读取单个商户池当前统计世代的成功累计。
|
||||
// 自然日与自然月周期只统计支付时间不早于当期窗口起点的成功事实。
|
||||
func loadPoolRouteStats(ctx context.Context, tx *gorm.DB, pool *model.PaymentMerchantPool, now time.Time) (routeStats, error) {
|
||||
cycle, err := statisticCycle(pool)
|
||||
if err != nil {
|
||||
return routeStats{}, err
|
||||
}
|
||||
query := tx.WithContext(ctx).Where("pool_id = ? AND routing_epoch = ?", pool.ID, pool.RoutingEpoch)
|
||||
if start, limited := routingWindowStart(*pool.StatisticCycle, now); limited {
|
||||
if start, limited := routingWindowStart(cycle, now); limited {
|
||||
query = query.Where("paid_at >= ?", start)
|
||||
}
|
||||
var rows []model.PaymentMerchantRoutingSuccess
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
return routeStats{}, err
|
||||
}
|
||||
amounts := make(map[uint]int64, len(merchants))
|
||||
counts := make(map[uint]int64, len(merchants))
|
||||
for _, row := range rows {
|
||||
amounts[row.MerchantID] += row.Amount
|
||||
counts[row.MerchantID]++
|
||||
stats := newRouteStats()
|
||||
for index := range rows {
|
||||
stats.add(&rows[index])
|
||||
}
|
||||
for _, merchant := range merchants {
|
||||
if pool.Strategy == model.PaymentMerchantStrategyAmount {
|
||||
if pool.ThresholdAmount == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "金额轮询阈值未配置")
|
||||
}
|
||||
if amounts[merchant.ID] < *pool.ThresholdAmount {
|
||||
return merchant, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if pool.ThresholdCount == nil {
|
||||
return nil, errors.New(errors.CodeInvalidStatus, "笔数轮询阈值未配置")
|
||||
}
|
||||
if counts[merchant.ID] < *pool.ThresholdCount {
|
||||
return merchant, nil
|
||||
}
|
||||
}
|
||||
if *pool.StatisticCycle != "round" {
|
||||
return nil, errors.New(errors.CodeNoPaymentConfig, "当前统计周期内暂无可用商户")
|
||||
}
|
||||
if err := advanceRoutingEpoch(ctx, tx, pool); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return merchants[0], nil
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func chooseTimedMerchant(pool *model.PaymentMerchantPool, merchants []*model.PaymentMerchant, now time.Time) (*model.PaymentMerchant, error) {
|
||||
@@ -353,6 +440,115 @@ func advanceRoutingEpoch(ctx context.Context, tx *gorm.DB, pool *model.PaymentMe
|
||||
return nil
|
||||
}
|
||||
|
||||
// poolProjection 是商户池列表投影的只读计算结果。
|
||||
type poolProjection struct {
|
||||
// MemberIDs 是按顺序排列的成员商户ID。
|
||||
MemberIDs []uint
|
||||
// EnabledMembers 是成员中当前启用且支付方式与池一致的商户数量。
|
||||
EnabledMembers int
|
||||
// CurrentMerchant 是当前命中成员;池停用或无可用成员时为空。
|
||||
CurrentMerchant *model.PaymentMerchant
|
||||
}
|
||||
|
||||
// loadPoolProjections 按页批量计算成员计数与当前命中成员,供列表与详情投影复用。
|
||||
// 只读:不创建支付单、不推进统计世代、不计入成功累计;查询次数为常数,不随池数量线性增长。
|
||||
// 池停用或没有可用成员时当前命中成员为空,不影响其余字段返回。
|
||||
func loadPoolProjections(ctx context.Context, db *gorm.DB, pools []*model.PaymentMerchantPool, now time.Time) (map[uint]poolProjection, error) {
|
||||
projections := make(map[uint]poolProjection, len(pools))
|
||||
if len(pools) == 0 {
|
||||
return projections, nil
|
||||
}
|
||||
poolIDs := make([]uint, 0, len(pools))
|
||||
for _, pool := range pools {
|
||||
poolIDs = append(poolIDs, pool.ID)
|
||||
}
|
||||
var members []model.PaymentMerchantPoolMember
|
||||
if err := db.WithContext(ctx).Where("pool_id IN ?", poolIDs).Order("pool_id ASC, sort_order ASC").Find(&members).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
membersByPool := make(map[uint][]uint, len(pools))
|
||||
merchantIDs := make([]uint, 0, len(members))
|
||||
for index := range members {
|
||||
member := &members[index]
|
||||
membersByPool[member.PoolID] = append(membersByPool[member.PoolID], member.MerchantID)
|
||||
merchantIDs = append(merchantIDs, member.MerchantID)
|
||||
}
|
||||
merchants := make(map[uint]*model.PaymentMerchant)
|
||||
if len(merchantIDs) > 0 {
|
||||
var rows []model.PaymentMerchant
|
||||
if err := db.WithContext(ctx).Where("id IN ? AND status = ?", merchantIDs, model.PaymentMerchantStatusEnabled).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range rows {
|
||||
merchant := &rows[index]
|
||||
merchants[merchant.ID] = merchant
|
||||
}
|
||||
}
|
||||
// 金额与笔数启用池当前世代的成功累计一次查出:按池拼出「池 + 世代(+ 当期窗口起点)」
|
||||
// 条件,既避免逐池查询,也避免把其他世代与上一周期的成功事实读进内存。
|
||||
terms := make([]string, 0, len(pools))
|
||||
args := make([]any, 0, len(pools)*3)
|
||||
for _, pool := range pools {
|
||||
if pool.Status != model.PaymentMerchantStatusEnabled || len(membersByPool[pool.ID]) == 0 {
|
||||
continue
|
||||
}
|
||||
if pool.Strategy != model.PaymentMerchantStrategyAmount && pool.Strategy != model.PaymentMerchantStrategyCount {
|
||||
continue
|
||||
}
|
||||
cycle, err := statisticCycle(pool)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if start, limited := routingWindowStart(cycle, now); limited {
|
||||
terms = append(terms, "(pool_id = ? AND routing_epoch = ? AND paid_at >= ?)")
|
||||
args = append(args, pool.ID, pool.RoutingEpoch, start)
|
||||
continue
|
||||
}
|
||||
terms = append(terms, "(pool_id = ? AND routing_epoch = ?)")
|
||||
args = append(args, pool.ID, pool.RoutingEpoch)
|
||||
}
|
||||
statsByPool := make(map[uint]routeStats, len(pools))
|
||||
if len(terms) > 0 {
|
||||
var rows []model.PaymentMerchantRoutingSuccess
|
||||
if err := db.WithContext(ctx).Where(strings.Join(terms, " OR "), args...).Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for index := range rows {
|
||||
row := &rows[index]
|
||||
stats, ok := statsByPool[row.PoolID]
|
||||
if !ok {
|
||||
stats = newRouteStats()
|
||||
statsByPool[row.PoolID] = stats
|
||||
}
|
||||
stats.add(row)
|
||||
}
|
||||
}
|
||||
for _, pool := range pools {
|
||||
ordered := orderedEnabledMembers(pool, membersByPool[pool.ID], merchants)
|
||||
projection := poolProjection{MemberIDs: membersByPool[pool.ID], EnabledMembers: len(ordered)}
|
||||
if pool.Status == model.PaymentMerchantStatusEnabled {
|
||||
if outcome, err := selectRouteMember(pool, ordered, statsByPool[pool.ID], now); err == nil {
|
||||
projection.CurrentMerchant = outcome.Merchant
|
||||
}
|
||||
}
|
||||
projections[pool.ID] = projection
|
||||
}
|
||||
return projections, nil
|
||||
}
|
||||
|
||||
// orderedEnabledMembers 按成员顺序返回启用且支付方式与池一致的商户。
|
||||
func orderedEnabledMembers(pool *model.PaymentMerchantPool, memberIDs []uint, merchants map[uint]*model.PaymentMerchant) []*model.PaymentMerchant {
|
||||
ordered := make([]*model.PaymentMerchant, 0, len(memberIDs))
|
||||
for _, id := range memberIDs {
|
||||
merchant := merchants[id]
|
||||
if merchant == nil || merchant.Status != model.PaymentMerchantStatusEnabled || merchant.PaymentMethod != pool.PaymentMethod {
|
||||
continue
|
||||
}
|
||||
ordered = append(ordered, merchant)
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
// FreezeRoute writes only non-sensitive route facts onto the payment.
|
||||
func FreezeRoute(payment *model.Payment, route *RouteSelection) {
|
||||
if payment == nil || route == nil || route.Merchant == nil || route.Pool == nil {
|
||||
|
||||
@@ -19,16 +19,37 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// RefundMaterial 承载本次提交由调用方按既有展示口径解析出的审批材料事实。
|
||||
//
|
||||
// 材料由服务层解析后传入,使审批材料与退款展示字段共用同一份解析规则(ENG-ARCH-002:
|
||||
// 读取口径不复制);材料不可解析时以空值或零值传入,MUST NOT 阻断审批提交。
|
||||
type RefundMaterial struct {
|
||||
// AssetType 是退款关联资产类型:card 单卡、device 设备,沿用退款申请的下单快照推导。
|
||||
AssetType string
|
||||
// DeviceType 与 DeviceModel 取退款申请关联设备,无关联设备时为空串。
|
||||
DeviceType string
|
||||
DeviceModel string
|
||||
// PackageUsedMB 与 PackageTotalMB 是当前退款套餐的已用量与总量(MB,真实流量),解析不到时为 0。
|
||||
PackageUsedMB int64
|
||||
PackageTotalMB int64
|
||||
// OriginalChannelTradeNo 是创建申请时冻结的原支付渠道交易流水号,线下订单为空串。
|
||||
OriginalChannelTradeNo string
|
||||
}
|
||||
|
||||
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
|
||||
//
|
||||
// 本次提交的审批尝试记录由本用例在退款申请落库后于同一事务内构造,调用方只提供
|
||||
// 需要按冻结商户派生、应用层无法自行生成的渠道退款请求号。
|
||||
// 需要按冻结商户派生、应用层无法自行生成的渠道退款请求号,以及本次提交的申请人备注与审批材料。
|
||||
type CreateCommand struct {
|
||||
Refund *model.RefundRequest
|
||||
Order *model.Order
|
||||
SubmitterAccountID uint
|
||||
// ChannelRefundRequestNo 是原路退款本次尝试冻结的渠道退款请求号;非原路方式为空。
|
||||
ChannelRefundRequestNo string
|
||||
// ApplicantRemark 是本次提交的申请人备注,冻结进当次审批尝试快照;不写入退款主表审批备注。
|
||||
ApplicantRemark string
|
||||
// Material 是本次提交的审批材料事实,冻结进当次尝试快照与审批业务快照。
|
||||
Material RefundMaterial
|
||||
}
|
||||
|
||||
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
|
||||
@@ -76,7 +97,10 @@ func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWrite
|
||||
|
||||
// TriggerHistorical 为历史待审批退款补发一次企业微信审批。
|
||||
// 历史申请尚未接入尝试模式,因此本次补发同时建立首条尝试记录并把业务标识切换到该记录。
|
||||
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint) (*CreateResult, error) {
|
||||
//
|
||||
// 补发不为历史申请追溯退款原因必填(该校验只作用于创建与重提用例),但材料仍按既有展示口径
|
||||
// 解析后传入,使新增控件的审批材料在补发路径同样完整,避免模板缺字段导致提交失败。
|
||||
func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint, material RefundMaterial) (*CreateResult, error) {
|
||||
if s == nil || s.db == nil || s.approval == nil || s.audit == nil || refundID == 0 {
|
||||
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
|
||||
}
|
||||
@@ -129,7 +153,7 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败")
|
||||
}
|
||||
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, ¤tOrder, "")
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, ¤tOrder, "", "", material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -138,7 +162,7 @@ func (s *CreationService) TriggerHistorical(ctx context.Context, refundID uint)
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款审批尝试记录失败")
|
||||
}
|
||||
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account)
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account, "", material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -202,7 +226,7 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account)
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(command.Refund, account, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -224,7 +248,7 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
|
||||
if err := tx.WithContext(ctx).Create(command.Refund).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "创建退款申请失败")
|
||||
}
|
||||
attempt, err = buildAttempt(ctx, tx, command.Refund, command.Order, command.ChannelRefundRequestNo)
|
||||
attempt, err = buildAttempt(ctx, tx, command.Refund, command.Order, command.ChannelRefundRequestNo, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -271,6 +295,10 @@ type ResubmitCommand struct {
|
||||
Refund *model.RefundRequest
|
||||
// ChannelRefundRequestNo 是原路退款本次重提冻结的渠道退款请求号;非原路方式为空。
|
||||
ChannelRefundRequestNo string
|
||||
// ApplicantRemark 是本次重提的申请人备注,冻结进新的审批尝试快照;历史尝试备注不被改写。
|
||||
ApplicantRemark string
|
||||
// Material 是本次重提的审批材料事实,冻结进新的尝试快照与审批业务快照。
|
||||
Material RefundMaterial
|
||||
}
|
||||
|
||||
// Resubmit 修改并重提未成功退款申请,新增审批尝试记录与新的企业微信审批实例。
|
||||
@@ -318,7 +346,7 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R
|
||||
current.RefundVoucherKey = command.Refund.RefundVoucherKey
|
||||
current.CustomerAccountInfo = command.Refund.CustomerAccountInfo
|
||||
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, &order, command.ChannelRefundRequestNo)
|
||||
attempt, err := buildAttempt(ctx, tx, ¤t, &order, command.ChannelRefundRequestNo, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -331,7 +359,7 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account)
|
||||
submitterSnapshot, requestSnapshot, err := refundSnapshots(¤t, account, command.ApplicantRemark, command.Material)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -345,6 +373,8 @@ func (s *CreationService) Resubmit(ctx context.Context, refundID uint, command R
|
||||
"refund_reason": current.RefundReason,
|
||||
"refund_voucher_key": current.RefundVoucherKey,
|
||||
"customer_account_info": current.CustomerAccountInfo,
|
||||
"source_payment_no": current.SourcePaymentNo,
|
||||
"original_channel_trade_no": current.OriginalChannelTradeNo,
|
||||
"failure_reason": "",
|
||||
"failure_message": "",
|
||||
"channel_refund_status": constants.RefundChannelStatusNone,
|
||||
@@ -428,15 +458,23 @@ func (s *CreationService) loadSubmitter(ctx context.Context, accountID uint) (*m
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、客户收款信息与套餐使用快照。
|
||||
// buildAttempt 构造一条不可变审批尝试记录,冻结当次方式、金额、冻结实收、原因、申请人备注、客户收款信息与套餐使用快照。
|
||||
// attempt_no 在退款申请行已加锁的前提下于同一事务内递增,因此申请内唯一。
|
||||
// package_usage_snapshot 必须是非空 JSON 对象,因此快照只能在这里按订单事实生成,不能由调用方预置。
|
||||
func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, channelRefundRequestNo string) (*model.RefundRequestAttempt, error) {
|
||||
func buildAttempt(
|
||||
ctx context.Context,
|
||||
tx *gorm.DB,
|
||||
refund *model.RefundRequest,
|
||||
order *model.Order,
|
||||
channelRefundRequestNo string,
|
||||
applicantRemark string,
|
||||
material RefundMaterial,
|
||||
) (*model.RefundRequestAttempt, error) {
|
||||
attemptNo, err := nextAttemptNo(ctx, tx, refund.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
snapshot, err := packageUsageSnapshot(ctx, tx, refund, order)
|
||||
snapshot, err := packageUsageSnapshot(ctx, tx, refund, order, material)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -447,6 +485,7 @@ func buildAttempt(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest,
|
||||
RefundAmount: refund.RequestedRefundAmount,
|
||||
FrozenActualReceivedAmount: refund.FrozenActualReceivedAmount,
|
||||
RefundReason: refund.RefundReason,
|
||||
Remark: strings.TrimSpace(applicantRemark),
|
||||
CustomerAccountInfo: refund.CustomerAccountInfo,
|
||||
CustomerVoucherKeys: refund.RefundVoucherKey,
|
||||
PackageUsageSnapshot: snapshot,
|
||||
@@ -468,12 +507,21 @@ func nextAttemptNo(ctx context.Context, tx *gorm.DB, refundID uint) (int, error)
|
||||
return row.MaxAttemptNo + 1, nil
|
||||
}
|
||||
|
||||
// packageUsageSnapshot 冻结本次申请关联的套餐使用情况,作为企业微信审批判断材料。
|
||||
// packageUsageSnapshot 冻结本次申请关联的套餐使用情况与本次提交的审批材料,作为企业微信审批判断材料。
|
||||
// 本期退款不按套餐已用流量计算金额,因此该快照只作审批与追溯材料,不参与金额校验。
|
||||
func packageUsageSnapshot(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) (datatypes.JSON, error) {
|
||||
//
|
||||
// 用量、设备维度与原渠道交易流水号由调用方按既有展示口径解析后传入(material):材料不可解析时
|
||||
// 以空值或零值写入,使快照键始终存在而 MUST NOT 阻断审批提交。
|
||||
func packageUsageSnapshot(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order, material RefundMaterial) (datatypes.JSON, error) {
|
||||
snapshot := map[string]any{
|
||||
"order_type": order.OrderType,
|
||||
"asset_identifier": order.AssetIdentifier,
|
||||
"asset_type": material.AssetType,
|
||||
"device_type": material.DeviceType,
|
||||
"device_model": material.DeviceModel,
|
||||
"package_used_mb": material.PackageUsedMB,
|
||||
"package_total_mb": material.PackageTotalMB,
|
||||
"channel_trade_no": material.OriginalChannelTradeNo,
|
||||
}
|
||||
if refund.PackageUsageID != nil && *refund.PackageUsageID > 0 {
|
||||
var usage model.PackageUsage
|
||||
@@ -545,7 +593,10 @@ func updateRefundLatest(ctx context.Context, tx *gorm.DB, refund *model.RefundRe
|
||||
return nil
|
||||
}
|
||||
|
||||
func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byte, []byte, error) {
|
||||
// refundSnapshots 冻结提交人与审批业务快照。
|
||||
// 审批业务快照是企微表单构建的数据源,因此本变更新增的审批材料字段必须在此出现,
|
||||
// 且即使不可解析也要以空值或零值写入:控件映射已配置时缺失键会被表单构建明确拒绝。
|
||||
func refundSnapshots(refund *model.RefundRequest, account *model.Account, applicantRemark string, material RefundMaterial) ([]byte, []byte, error) {
|
||||
submitterSnapshot, err := sonic.Marshal(map[string]any{
|
||||
"account_id": account.ID, "account_name": account.Username, "user_type": account.UserType,
|
||||
})
|
||||
@@ -563,6 +614,13 @@ func refundSnapshots(refund *model.RefundRequest, account *model.Account) ([]byt
|
||||
constants.ApprovalFieldRefundVoucherKey: []string(refund.RefundVoucherKey),
|
||||
constants.ApprovalFieldRefundReason: refund.RefundReason,
|
||||
constants.ApprovalFieldPackageUsageID: refund.PackageUsageID,
|
||||
constants.ApprovalFieldRefundAssetType: material.AssetType,
|
||||
constants.ApprovalFieldRefundDeviceType: material.DeviceType,
|
||||
constants.ApprovalFieldRefundDeviceModel: material.DeviceModel,
|
||||
constants.ApprovalFieldRefundPackageUsedMB: material.PackageUsedMB,
|
||||
constants.ApprovalFieldRefundPackageTotalMB: material.PackageTotalMB,
|
||||
constants.ApprovalFieldRefundChannelTradeNo: material.OriginalChannelTradeNo,
|
||||
constants.ApprovalFieldRefundApplicantRemark: strings.TrimSpace(applicantRemark),
|
||||
constants.ApprovalFieldSubmitterID: account.ID,
|
||||
constants.ApprovalFieldSubmitterName: account.Username,
|
||||
})
|
||||
|
||||
@@ -288,9 +288,62 @@ func validateSceneMapping(businessType string, mapping []dto.WeComControlMapping
|
||||
}
|
||||
}
|
||||
}
|
||||
// 本变更新增字段必须配置映射:缺失即明确失败,避免审批材料被静默丢弃后审批人看不到关键依据。
|
||||
// 既有未映射的可选控件不受影响,保持既有静默跳过行为。
|
||||
wanted, ok := sceneBusinessFields(businessType)
|
||||
if !ok {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信审批业务类型无效")
|
||||
}
|
||||
for _, field := range wanted {
|
||||
if !field.RequiredMapping {
|
||||
continue
|
||||
}
|
||||
if _, exists := businessFields[field.Code]; !exists {
|
||||
return errors.New(errors.CodeInvalidParam, "企业微信场景缺少必须映射的业务字段: "+field.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequiredSceneField 描述本变更新增、必须配置控件映射的业务字段。
|
||||
type RequiredSceneField struct {
|
||||
// Code 是 control_mapping.business_field 应填写的稳定字段编码。
|
||||
Code string
|
||||
// Name 是字段中文名称,用于在缺失时指出具体缺哪个字段/控件。
|
||||
Name string
|
||||
}
|
||||
|
||||
// RequiredSceneFields 返回指定业务类型下必须配置控件映射的业务字段,顺序与场景白名单一致。
|
||||
//
|
||||
// 这是「必须映射字段」的唯一判定来源:场景映射校验(保存时)与表单构建(提交时)都从
|
||||
// 这里取集合,MUST NOT 再维护第二份清单。既有未标注必须映射的可选字段不在返回结果中,
|
||||
// 因此其缺失映射仍保持既有静默跳过行为。
|
||||
func RequiredSceneFields(businessType string) []RequiredSceneField {
|
||||
fields, ok := sceneBusinessFields(businessType)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
result := make([]RequiredSceneField, 0, len(fields))
|
||||
for _, field := range fields {
|
||||
if field.RequiredMapping {
|
||||
result = append(result, RequiredSceneField{Code: field.Code, Name: field.Name})
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// approvalBusinessTypes 返回全部已登记审批业务场景,供只读字段汇总复用。
|
||||
func approvalBusinessTypes() []string {
|
||||
return []string{
|
||||
constants.ApprovalBusinessTypeRefund,
|
||||
constants.ApprovalBusinessTypeOfflineRecharge,
|
||||
constants.ApprovalBusinessTypeEmployeeCollection,
|
||||
constants.ApprovalBusinessTypeAgentDistribution,
|
||||
constants.ApprovalBusinessTypeWithdrawalQualification,
|
||||
constants.ApprovalBusinessTypeCommissionWithdrawal,
|
||||
}
|
||||
}
|
||||
|
||||
func allowedSceneBusinessField(businessType, businessField string) bool {
|
||||
fields, ok := sceneBusinessFields(businessType)
|
||||
if !ok {
|
||||
@@ -334,6 +387,13 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
|
||||
{Code: constants.ApprovalFieldRefundVoucherKey, Name: "退款凭证", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "提交时上传到企微文件控件的退款凭证列表"},
|
||||
{Code: constants.ApprovalFieldRefundReason, Name: "退款原因", ValueType: constants.ApprovalFieldValueTypeString, Description: "业务提交人填写的退款原因"},
|
||||
{Code: constants.ApprovalFieldPackageUsageID, Name: "套餐使用记录 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "退款关联套餐使用记录 ID;无关联记录时可能为空"},
|
||||
{Code: constants.ApprovalFieldRefundAssetType, Name: "资产类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款关联资产类型:card 单卡、device 设备;沿用退款申请的下单快照推导", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundDeviceType, Name: "设备类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款申请关联设备的类型;资产不是设备或无关联设备时为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundDeviceModel, Name: "设备型号", ValueType: constants.ApprovalFieldValueTypeString, Description: "退款申请关联设备的型号;资产不是设备或无关联设备时为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundPackageUsedMB, Name: "当前退款套餐已用量(MB)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "当前退款套餐已用量(MB,真实流量);解析不到套餐时为 0", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundPackageTotalMB, Name: "当前退款套餐总量(MB)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "当前退款套餐总量(MB,真实流量);解析不到套餐时为 0", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundChannelTradeNo, Name: "原支付渠道交易流水号", ValueType: constants.ApprovalFieldValueTypeString, Description: "创建申请时冻结的原成功支付记录渠道交易流水号;线下订单为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldRefundApplicantRemark, Name: "申请人备注", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提交填写的申请人备注快照;未填写时为空字符串。与审批备注不是同一字段", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
@@ -364,6 +424,8 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse,
|
||||
{Code: constants.ApprovalFieldDistributionPhoneMasked, Name: "注册手机号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的注册手机号,禁止写入完整手机号"},
|
||||
{Code: constants.ApprovalFieldDistributionContactName, Name: "联系人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册填写的联系人姓名"},
|
||||
{Code: constants.ApprovalFieldDistributionRegion, Name: "注册地址摘要", ValueType: constants.ApprovalFieldValueTypeString, Description: "省市区与详细地址拼接的注册地址摘要"},
|
||||
{Code: constants.ApprovalFieldDistributionBusinessOwner, Name: "业务员", ValueType: constants.ApprovalFieldValueTypeString, Description: "上级代理店铺当前启用业务员的名称快照,用于确认将同步给新代理的初始业务员;上级无业务员时为空字符串", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldDistributionBusinessOwnerAbsence, Name: "无业务员标记", ValueType: constants.ApprovalFieldValueTypeString, Description: "上级店铺当前无业务员时提交「无」,有业务员时为空字符串,MUST NOT 用提交人或上级主账号填充", RequiredMapping: true},
|
||||
{Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"},
|
||||
{Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"},
|
||||
}, true
|
||||
@@ -421,17 +483,12 @@ func normalizeSceneMapping(mapping []dto.WeComControlMappingItem) []dto.WeComCon
|
||||
}
|
||||
|
||||
func validApprovalBusinessType(businessType string) bool {
|
||||
switch businessType {
|
||||
case constants.ApprovalBusinessTypeRefund,
|
||||
constants.ApprovalBusinessTypeOfflineRecharge,
|
||||
constants.ApprovalBusinessTypeEmployeeCollection,
|
||||
constants.ApprovalBusinessTypeAgentDistribution,
|
||||
constants.ApprovalBusinessTypeWithdrawalQualification,
|
||||
constants.ApprovalBusinessTypeCommissionWithdrawal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
for _, candidate := range approvalBusinessTypes() {
|
||||
if candidate == businessType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func approvalBusinessTypeName(businessType string) string {
|
||||
|
||||
Reference in New Issue
Block a user