diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 5f6329f..6cdeb20 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -31,6 +31,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil) + handlers.WithdrawalQualification = admin.NewWithdrawalQualificationHandler(nil, nil, nil) handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 5151176..75a32d3 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -40,6 +40,7 @@ func generateAdminDocs(outputPath string) error { handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) handlers.EmployeeCollection = admin.NewEmployeeCollectionHandler(nil, nil, nil) + handlers.WithdrawalQualification = admin.NewWithdrawalQualificationHandler(nil, nil, nil) handlers.CTCCRealnameCallback = callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CMCCRealnameCallback = callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) handlers.CUCCRealnameCallback = callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 11798ab..d3d0e95 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -18,6 +18,7 @@ import ( approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal" employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification" @@ -405,6 +406,15 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con constants.ApprovalBusinessTypeEmployeeCollection: employeecollectionApp.NewApprovalDecisionHandler( runtime.db, auditWriter, ), + constants.ApprovalBusinessTypeAgentDistribution: distributionwithdrawalApp.NewDistributionApprovalHandler( + runtime.db, auditWriter, shopInfra.NewSubordinateCache(runtime.redisClient), + ), + constants.ApprovalBusinessTypeWithdrawalQualification: distributionwithdrawalApp.NewQualificationApprovalHandler( + runtime.db, auditWriter, + ), + constants.ApprovalBusinessTypeCommissionWithdrawal: distributionwithdrawalApp.NewWithdrawalApprovalHandler( + runtime.db, auditWriter, + ), }, owner, appLogger, diff --git a/internal/application/distributionwithdrawal/audit.go b/internal/application/distributionwithdrawal/audit.go new file mode 100644 index 0000000..8f6a5f6 --- /dev/null +++ b/internal/application/distributionwithdrawal/audit.go @@ -0,0 +1,155 @@ +// Package distributionwithdrawal 收口代理分销注册、提现资料资格与提现企业微信终审的用例。 +// 三者都以审批尝试/资料版本/注册记录主键作为通用审批业务标识,终态消费幂等且可重放。 +package distributionwithdrawal + +import ( + "context" + stderrors "errors" + "strconv" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditfailure" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// VerificationCodeVerifier 是公开扫码注册复用的短信验证码校验接缝。 +// 校验成功即消费验证码,同一验证码不可二次使用。 +type VerificationCodeVerifier interface { + VerifyCode(ctx context.Context, phone string, code string) error +} + +// AuditChange 描述分销注册、提现资格与提现审批事实的实际变化。 +// 日志与审计不得记录密码、完整证件号、完整手机号或附件内容。 +type AuditChange struct { + // EventID 是审计事件稳定标识,同一业务事实重复重放时保持相同值。 + EventID string + // ActionCode 是已注册的审计动作码。 + ActionCode string + // Summary 是给人工阅读的中文摘要。 + Summary string + // CorrelationID 是来源业务链路标识。 + CorrelationID string + // Registration 是本次动作后的扫码注册记录事实。 + Registration *model.AgentDistributionRegistration + // ParentShop 是扫码注册使用的上级店铺。 + ParentShop *model.Shop + // Shop 是本次动作所属或引用的店铺。 + Shop *model.Shop + // CreatedShop 是注册审批通过时新建的店铺。 + // CreatedShop.DistributionCode 是本次为新店铺生成的随机码; + // AppliedDistributionCode 是注册时使用的上级店铺码快照,二者必须区分,不得混用。 + CreatedShop *model.Shop + // AppliedDistributionCode 是注册提交时使用的上级店铺分销码快照。 + AppliedDistributionCode string + // Qualification 是本次动作后的提现资料资格版本。 + Qualification *model.WithdrawalQualification + // Withdrawal 是本次动作后的提现申请事实。 + Withdrawal *model.CommissionWithdrawalRequest + // Attempt 是本次动作对应的提现审批尝试记录。 + Attempt *model.CommissionWithdrawalRequestAttempt + // Wallet 是本次动作影响的佣金钱包。 + Wallet *model.AgentWallet + // Transaction 是本次动作产生的钱包流水。 + Transaction *model.AgentWalletTransaction + // BeforeData 与 AfterData 是脱敏前后的字段快照。 + BeforeData map[string]any + AfterData map[string]any + // Result 是审计结果,空值按成功处理。 + Result string + // ErrorCode 与 ErrorSummary 是失败或拒绝审计的稳定错误信息。 + ErrorCode string + ErrorSummary string +} + +// AuditWriter 在业务事务内追加统一 Audit Event。 +type AuditWriter interface { + WriteDistributionWithdrawal(ctx context.Context, tx *gorm.DB, change AuditChange) error +} + +// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。 +func RecordFailure(ctx context.Context, db *gorm.DB, writer AuditWriter, change AuditChange, businessErr error) { + if writer == nil || db == nil || businessErr == nil { + return + } + appErr := changeError(businessErr) + change.Result = constants.AuditResultFailed + switch appErr.Code { + case errors.CodeForbidden, errors.CodeNotFound, errors.CodeInvalidParam, errors.CodeConflict, + errors.CodeInvalidStatus, errors.CodeInsufficientBalance, errors.CodeShopLevelExceeded: + change.Result = constants.AuditResultDenied + } + if change.ErrorCode == "" { + change.ErrorCode = strconv.Itoa(appErr.Code) + } + if change.ErrorSummary == "" { + change.ErrorSummary = appErr.Message + } + if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return writer.WriteDistributionWithdrawal(ctx, tx, change) + }); err != nil { + auditfailure.RecordSecondaryWriteFailure( + change.ActionCode, "", "", change.CorrelationID, change.ErrorCode, err, + ) + } +} + +// changeError 归一化底层错误为稳定 AppError,避免失败审计泄露底层文本。 +func changeError(err error) *errors.AppError { + var appErr *errors.AppError + if stderrors.As(err, &appErr) { + return appErr + } + return errors.New(errors.CodeInternalError, "分销注册或提现审批操作失败") +} + +// approvalSnapshots 生成通用审批的提交人快照与业务表单快照。 +func approvalSnapshots(accountID uint, accountName string, business map[string]any) ([]byte, []byte, error) { + submitter, err := marshalJSON(map[string]any{ + "account_id": accountID, "account_name": accountName, + }) + if err != nil { + return nil, nil, err + } + request, err := marshalJSON(business) + if err != nil { + return nil, nil, err + } + return submitter, request, nil +} + +// marshalJSON 使用 sonic 序列化业务快照,禁止写入密码、完整证件号或附件内容。 +func marshalJSON(value any) ([]byte, error) { + payload, err := sonic.Marshal(value) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "序列化审批业务快照失败") + } + return payload, nil +} + +// createApprovalInTx 在业务事务内创建通用审批实例并返回引用。 +func createApprovalInTx( + ctx context.Context, + tx *gorm.DB, + port approvalapp.Port, + preparation approvalapp.Preparation, + businessType string, + businessID uint, + submitterAccountID uint, + submitterSnapshot []byte, + requestSnapshot []byte, + correlationID string, +) (approvalapp.Reference, error) { + if port == nil { + return approvalapp.Reference{}, errors.New(errors.CodeServiceUnavailable, "审批能力尚未配置") + } + return port.CreateInTx(ctx, tx, approvalapp.CreateRequest{ + Preparation: preparation, BusinessType: businessType, BusinessID: businessID, + SubmitterAccountID: submitterAccountID, SubmitterSnapshot: submitterSnapshot, + RequestSnapshot: requestSnapshot, CorrelationID: correlationID, + }) +} diff --git a/internal/application/distributionwithdrawal/qualification.go b/internal/application/distributionwithdrawal/qualification.go new file mode 100644 index 0000000..774f088 --- /dev/null +++ b/internal/application/distributionwithdrawal/qualification.go @@ -0,0 +1,463 @@ +package distributionwithdrawal + +import ( + "context" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" +) + +// QualificationResult 返回已落库的资料版本与审批实例引用。 +type QualificationResult struct { + QualificationID uint + Status int + ApprovalInstanceID uint + ApprovalStatus int +} + +// QualificationService 受理提现资料资格的提交、替换、作废与停用失效。 +// 资格事实按版本不可变保存;替换合同或法人身份证即新增版本并在同一事务内失效旧有效版本。 +type QualificationService struct { + db *gorm.DB + approval approvalapp.Port + audit AuditWriter +} + +// NewQualificationService 创建提现资料资格用例。 +func NewQualificationService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *QualificationService { + return &QualificationService{db: db, approval: approval, audit: audit} +} + +// Submit 提交或替换本人代理店铺的提现资料资格。 +// 已有待审批版本时拒绝;已有效版本在合同或法人身份证未变化时拒绝重复提交。 +func (s *QualificationService) Submit( + ctx context.Context, + shopID uint, + input distributiondomain.QualificationInput, +) (*QualificationResult, error) { + if s == nil || s.db == nil || s.approval == nil || s.audit == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置") + } + if err := ensureOwnAgentShop(ctx, shopID); err != nil { + // 越权提交资格属关键拒绝,必须留痕:以目标店铺为主要资源记录拒绝事实。 + RecordFailure(ctx, s.db, s.audit, AuditChange{ + // 不手工构造 EventID:本条是失败/拒绝事实,同一店铺可被拒绝多次, + // 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。 + // 由审计 Writer 生成唯一 evt_(与既有 recordRefundFailure 的做法一致)。 + ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected, + Summary: "提交提现资料资格被拒绝:越权或非本人店铺", + Shop: failureShopResolved(ctx, s.db, shopID, nil), + }, err) + return nil, err + } + normalized, err := distributiondomain.ValidateQualificationInput(input) + if err != nil { + return nil, err + } + operatorID := middleware.GetUserIDFromContext(ctx) + submitter, err := resolveShopPrimaryAccount(ctx, s.db, shopID) + if err != nil { + return nil, err + } + correlationID := "withdrawal_qualification:" + uuid.NewString() + preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ + BusinessType: constants.ApprovalBusinessTypeWithdrawalQualification, + SubmitterAccountID: submitter.ID, CorrelationID: correlationID, + }) + if err != nil { + return nil, err + } + shop, err := loadShop(ctx, s.db, shopID) + if err != nil { + return nil, err + } + version := &model.WithdrawalQualification{ + ShopID: shopID, SubjectType: normalized.SubjectType, SubjectCode: normalized.SubjectCode, + LegalPersonIDCard: normalized.LegalPersonIDCard, ContractFileKey: normalized.ContractFileKey, + IDCardFrontFileKey: normalized.IDCardFrontFileKey, IDCardBackFileKey: normalized.IDCardBackFileKey, + BusinessLicenseFileKey: normalized.BusinessLicenseFileKey, ShopFrontFileKey: normalized.ShopFrontFileKey, + InvoiceFileKey: normalized.InvoiceFileKey, InvoiceTitle: normalized.InvoiceTitle, + InvoiceSubjectCode: normalized.InvoiceSubjectCode, + Status: constants.WithdrawalQualificationStatusPending, + Creator: operatorID, Updater: operatorID, + } + submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username, + qualificationApprovalForm(version, shop)) + if err != nil { + return nil, err + } + result := &QualificationResult{} + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + replaced, err := invalidateReplacedVersion(ctx, tx, shopID, normalized, operatorID) + if err != nil { + return err + } + if err := tx.WithContext(ctx).Create(version).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建提现资料资格版本失败") + } + reference, err := createApprovalInTx(ctx, tx, s.approval, preparation, + constants.ApprovalBusinessTypeWithdrawalQualification, version.ID, submitter.ID, + submitterSnapshot, requestSnapshot, correlationID) + if err != nil { + return err + } + if err := attachQualificationInstance(ctx, tx, version, reference.InstanceID); err != nil { + return err + } + if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + EventID: "withdrawal-qualification:" + uintText(version.ID) + ":submit", + ActionCode: constants.AuditActionWithdrawalQualificationSubmitted, + Summary: qualificationSubmitSummary(replaced), + CorrelationID: correlationID, Qualification: version, Shop: shop, + AfterData: qualificationAuditSnapshot(version), + }); err != nil { + return err + } + result.QualificationID = version.ID + result.Status = version.Status + result.ApprovalInstanceID = reference.InstanceID + result.ApprovalStatus = reference.Status + return nil + }) + if err != nil { + // 提交在创建资料版本前被拒绝(存在待审批版本或参数非法),此时没有资料版本可作主要资源, + // 以店铺为主要资源记录拒绝事实。 + RecordFailure(ctx, s.db, s.audit, AuditChange{ + // 不手工构造 EventID:本条是失败/拒绝事实,同一店铺可被拒绝多次, + // 手工 ID 会与既有的拒绝记录在 event_id 唯一约束上冲突并被静默吞掉。 + // 由审计 Writer 生成唯一 evt_(与既有 recordRefundFailure 的做法一致)。 + ActionCode: constants.AuditActionWithdrawalQualificationSubmitRejected, + Summary: "提交提现资料资格被拒绝", CorrelationID: correlationID, + Shop: shop, + }, err) + return nil, err + } + return result, nil +} + +// Void 由超级管理员填写原因后作废有效提现资料资格。 +// 原因必填;已失效或非有效版本返回稳定冲突错误。 +func (s *QualificationService) Void(ctx context.Context, id uint, reason string) error { + if s == nil || s.db == nil || s.audit == nil { + return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置") + } + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + reason = strings.TrimSpace(reason) + if reason == "" { + businessErr := errors.New(errors.CodeInvalidParam, "作废提现资料资格必须填写原因") + // 关键拒绝必须留痕:作废原因必填是权限相关拒绝,按超管作废动作记录拒绝事实。 + RecordFailure(ctx, s.db, s.audit, AuditChange{ + // 不手工构造 EventID:该拒绝与「作废成功」是同一实体的两次不同发生, + // 手工 ID 会让随后的成功作废审计被 event_id 唯一约束吞掉,造成审计与事实相反。 + ActionCode: constants.AuditActionWithdrawalQualificationVoided, + Summary: "作废提现资料资格被拒绝:未填写原因", + Qualification: &model.WithdrawalQualification{ID: id}, + }, businessErr) + return businessErr + } + operatorID := middleware.GetUserIDFromContext(ctx) + var version model.WithdrawalQualification + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + First(&version, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "提现资料资格不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败") + } + if version.Status != constants.WithdrawalQualificationStatusApproved { + return errors.New(errors.CodeConflict, "仅有效提现资料资格可作废") + } + before := qualificationAuditSnapshot(&version) + if err := invalidateVersion(ctx, tx, &version, reason, operatorID); err != nil { + return err + } + shop, err := loadShop(ctx, tx, version.ShopID) + if err != nil { + return err + } + return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + EventID: "withdrawal-qualification:" + uintText(version.ID) + ":void", + ActionCode: constants.AuditActionWithdrawalQualificationVoided, + Summary: "超级管理员作废提现资料资格", Qualification: &version, Shop: shop, + BeforeData: before, AfterData: qualificationAuditSnapshot(&version), + }) + }) + if err != nil { + // 失败审计必须可追溯且恰好有一个主要资源:带上目标资料版本(至少含 ID)。 + RecordFailure(ctx, s.db, s.audit, AuditChange{ + ActionCode: constants.AuditActionWithdrawalQualificationVoided, + Summary: "作废提现资料资格失败", + Qualification: &model.WithdrawalQualification{ID: id}, + }, err) + return err + } + return nil +} + +// InvalidateByShopDisable 在店铺停用事务内使该店铺全部有效资格失效。 +// 历史版本与审批结果保留;由调用方保证与店铺停用处于同一事务。 +func (s *QualificationService) InvalidateByShopDisable( + ctx context.Context, + tx *gorm.DB, + shopID uint, + reason string, +) error { + if tx == nil || shopID == 0 { + return nil + } + var versions []model.WithdrawalQualification + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved). + Order("id ASC").Find(&versions).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询待失效提现资料资格失败") + } + if len(versions) == 0 { + return nil + } + now := time.Now().UTC() + result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). + Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved). + Updates(map[string]any{ + "status": constants.WithdrawalQualificationStatusInvalidated, + "invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效提现资料资格失败") + } + if result.RowsAffected == 0 { + return nil + } + shop, err := loadShop(ctx, tx, shopID) + if err != nil { + return err + } + first := versions[0] + first.Status = constants.WithdrawalQualificationStatusInvalidated + first.InvalidReason = reason + first.InvalidatedAt = &now + summary := "代理店铺停用,全部有效提现资料资格失效" + if strings.Contains(reason, "删除") { + summary = "代理店铺已删除,全部有效提现资料资格失效" + } + // 不手工构造 EventID:同一店铺可先停用失效、后删除失效,属同一实体的两次不同发生, + // 手工 ID 会让第二次失效审计被吞掉。 + return s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + ActionCode: constants.AuditActionWithdrawalQualificationInvalidated, + Summary: summary, Qualification: &first, Shop: shop, + AfterData: map[string]any{ + "shop_id": shopID, "invalidated_count": result.RowsAffected, + "status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason, + }, + }) +} + +// invalidateReplacedVersion 在替换合同或法人身份证时失效旧有效版本。 +// 返回被失效的版本;没有需失效的版本时返回 nil。 +func invalidateReplacedVersion( + ctx context.Context, + tx *gorm.DB, + shopID uint, + input distributiondomain.QualificationInput, + operatorID uint, +) (*model.WithdrawalQualification, error) { + var pending int64 + if err := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). + Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusPending). + Count(&pending).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待审批提现资料资格失败") + } + if pending > 0 { + return nil, errors.New(errors.CodeConflict, "已存在待审批的提现资料资格,请等待审批结果") + } + var current model.WithdrawalQualification + err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved). + First(¤t).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定有效提现资料资格失败") + } + if !qualificationRequiresApproval(¤t, input) { + return nil, errors.New(errors.CodeConflict, "提现资料资格已生效,合同与法人身份证未变化") + } + if err := invalidateVersion(ctx, tx, ¤t, "代理替换合同或法人身份证资料", operatorID); err != nil { + return nil, err + } + return ¤t, nil +} + +// qualificationRequiresApproval 判断本次提交是否改变了合同或法人身份证事实。 +func qualificationRequiresApproval( + current *model.WithdrawalQualification, + input distributiondomain.QualificationInput, +) bool { + return current.ContractFileKey != input.ContractFileKey || + current.IDCardFrontFileKey != input.IDCardFrontFileKey || + current.IDCardBackFileKey != input.IDCardBackFileKey || + current.SubjectCode != input.SubjectCode || + current.LegalPersonIDCard != input.LegalPersonIDCard +} + +// invalidateVersion 条件更新单个资料版本为已失效。 +func invalidateVersion( + ctx context.Context, + tx *gorm.DB, + version *model.WithdrawalQualification, + reason string, + operatorID uint, +) error { + now := time.Now().UTC() + result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). + Where("id = ? AND status = ?", version.ID, version.Status). + Updates(map[string]any{ + "status": constants.WithdrawalQualificationStatusInvalidated, "invalid_reason": reason, + "invalidated_at": now, "invalidated_by": operatorID, "updater": operatorID, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效提现资料资格版本失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化") + } + version.Status = constants.WithdrawalQualificationStatusInvalidated + version.InvalidReason = reason + version.InvalidatedAt = &now + version.InvalidatedBy = operatorID + return nil +} + +// attachQualificationInstance 回写资料版本关联的审批实例,写入一次后不可修改。 +func attachQualificationInstance( + ctx context.Context, + tx *gorm.DB, + version *model.WithdrawalQualification, + instanceID uint, +) error { + result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). + Where("id = ? AND approval_instance_id IS NULL", version.ID). + Update("approval_instance_id", instanceID) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联提现资料资格审批实例失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "提现资料资格审批实例关联已变化") + } + version.ApprovalInstanceID = &instanceID + return nil +} + +// qualificationApprovalForm 生成企业微信审批表单业务快照。 +// 证件号按脱敏值写入,附件只写入对象存储 Key 引用,不写入附件内容。 +func qualificationApprovalForm(version *model.WithdrawalQualification, shop *model.Shop) map[string]any { + shopName := "" + if shop != nil { + shopName = shop.ShopName + } + return map[string]any{ + constants.ApprovalFieldQualificationShopID: version.ShopID, + constants.ApprovalFieldQualificationShopName: shopName, + constants.ApprovalFieldQualificationSubjectType: constants.GetWithdrawalQualificationSubjectTypeName(version.SubjectType), + constants.ApprovalFieldQualificationSubjectCodeMasked: distributiondomain.MaskSubjectCode(version.SubjectCode), + constants.ApprovalFieldQualificationLegalPersonMasked: distributiondomain.MaskSubjectCode(version.LegalPersonIDCard), + constants.ApprovalFieldQualificationContractKey: version.ContractFileKey, + constants.ApprovalFieldQualificationIDCardFrontKey: version.IDCardFrontFileKey, + constants.ApprovalFieldQualificationIDCardBackKey: version.IDCardBackFileKey, + constants.ApprovalFieldQualificationBusinessLicenseKey: version.BusinessLicenseFileKey, + constants.ApprovalFieldQualificationShopFrontKey: version.ShopFrontFileKey, + constants.ApprovalFieldQualificationInvoiceKey: version.InvoiceFileKey, + constants.ApprovalFieldQualificationInvoiceTitle: version.InvoiceTitle, + } +} + +// qualificationAuditSnapshot 生成资料版本审计快照,证件号按脱敏值记录,不含附件内容。 +func qualificationAuditSnapshot(version *model.WithdrawalQualification) map[string]any { + instanceID := uint(0) + if version.ApprovalInstanceID != nil { + instanceID = *version.ApprovalInstanceID + } + return map[string]any{ + "id": version.ID, "shop_id": version.ShopID, "subject_type": version.SubjectType, + "subject_code_masked": distributiondomain.MaskSubjectCode(version.SubjectCode), + "status": version.Status, "approval_instance_id": instanceID, + "invalid_reason": version.InvalidReason, + "attachment_count": 3 + boolToInt(version.BusinessLicenseFileKey != "") + + boolToInt(version.ShopFrontFileKey != "") + boolToInt(version.InvoiceFileKey != ""), + } +} + +// qualificationSubmitSummary 区分首次提交与替换提交的审计摘要。 +func qualificationSubmitSummary(replaced *model.WithdrawalQualification) string { + if replaced != nil { + return "替换合同或法人身份证资料,旧有效提现资料资格已失效" + } + return "提交提现资料资格" +} + +// ensureOwnAgentShop 校验当前账号为代理身份且目标即本人店铺。 +func ensureOwnAgentShop(ctx context.Context, shopID uint) error { + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent { + return errors.New(errors.CodeForbidden, "仅代理商用户可提交提现资料资格") + } + if shopID == 0 || shopID != middleware.GetShopIDFromContext(ctx) { + return errors.New(errors.CodeForbidden, "仅可为本人店铺提交提现资料资格") + } + return nil +} + +// resolveShopPrimaryAccount 解析店铺启用的主账号,作为审批发起主体。 +func resolveShopPrimaryAccount(ctx context.Context, db *gorm.DB, shopID uint) (*model.Account, error) { + var account model.Account + if err := db.WithContext(ctx). + Where("shop_id = ? AND status = ? AND is_primary = TRUE", shopID, constants.StatusEnabled). + First(&account).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeInvalidStatus, "店铺缺少启用的主账号,无法提交审批") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺主账号失败") + } + return &account, nil +} + +// loadShopOrNil 读取店铺事实;店铺已软删除或不存在时返回 nil,供终态收敛使用。 +func loadShopOrNil(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop { + shop, err := loadShop(ctx, db, shopID) + if err != nil { + return nil + } + return shop +} + +// loadShop 读取店铺事实,未找到返回稳定不存在错误。 +func loadShop(ctx context.Context, db *gorm.DB, shopID uint) (*model.Shop, error) { + var shop model.Shop + if err := db.WithContext(ctx).First(&shop, shopID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "店铺不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺失败") + } + return &shop, nil +} + +// boolToInt 将布尔值转换为 0/1,用于审计计数。 +func boolToInt(value bool) int { + if value { + return 1 + } + return 0 +} diff --git a/internal/application/distributionwithdrawal/qualification_approval.go b/internal/application/distributionwithdrawal/qualification_approval.go new file mode 100644 index 0000000..bc95516 --- /dev/null +++ b/internal/application/distributionwithdrawal/qualification_approval.go @@ -0,0 +1,195 @@ +package distributionwithdrawal + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// QualificationApprovalHandler 将渠道无关企业微信终态应用到提现资料资格版本。 +// 通过才使版本生效;驳回只标记该版本,不影响其他版本已记录的审批结果。 +type QualificationApprovalHandler struct { + db *gorm.DB + audit AuditWriter +} + +// NewQualificationApprovalHandler 创建提现资料资格审批终态消费者。 +func NewQualificationApprovalHandler(db *gorm.DB, audit AuditWriter) *QualificationApprovalHandler { + return &QualificationApprovalHandler{db: db, audit: audit} +} + +// Handle 幂等消费标准审批终态。 +// 业务标识为资料版本主键;先锁定版本并校验审批实例一致,再以条件更新推进状态。 +func (h *QualificationApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { + if h == nil || h.db == nil || h.audit == nil { + return errors.New(errors.CodeInternalError, "提现资料资格审批终态能力未配置") + } + if event.BusinessType != constants.ApprovalBusinessTypeWithdrawalQualification || + event.BusinessID == 0 || event.InstanceID == 0 { + return errors.New(errors.CodeInvalidParam, "提现资料资格审批终态参数无效") + } + ctx = auditcontext.With(ctx, auditcontext.Context{ + CorrelationID: event.CorrelationID, ParentEventID: event.EventID, + }) + return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var version model.WithdrawalQualification + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + First(&version, event.BusinessID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "提现资料资格版本不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现资料资格版本失败") + } + if version.ApprovalInstanceID == nil || *version.ApprovalInstanceID != event.InstanceID { + return errors.New(errors.CodeConflict, "提现资料资格版本关联的审批实例不一致") + } + if version.Status != constants.WithdrawalQualificationStatusPending { + // 已是终态(含被替换或作废):重复或乱序回调不再改变事实。 + return nil + } + // 店铺可能已被软删除:终态必须仍能收敛,不得把「店铺不存在」当成致命错误, + // 否则该版本永久卡在待审批且终态事件永久重投。审计的店铺资源此时允许为空。 + shop := loadShopOrNil(ctx, tx, version.ShopID) + before := qualificationAuditSnapshot(&version) + switch event.Decision { + case constants.ApprovalDecisionApproved: + return h.applyApproved(ctx, tx, &version, shop, before, event) + case constants.ApprovalDecisionRejected, + constants.ApprovalDecisionCancelled, + constants.ApprovalDecisionDeleted, + constants.ApprovalDecisionRevokedAfterApproved: + return h.applyRejected(ctx, tx, &version, shop, before, event) + default: + return errors.New(errors.CodeInvalidParam, "不支持的提现资料资格审批终态") + } + }) +} + +// applyApproved 使资料版本生效。 +// 代理已提交替换版本时该版本已被失效,条件更新不再命中,不会覆盖更新版本。 +func (h *QualificationApprovalHandler) applyApproved( + ctx context.Context, + tx *gorm.DB, + version *model.WithdrawalQualification, + shop *model.Shop, + before map[string]any, + event approvalapp.TerminalDecisionEvent, +) error { + now := time.Now().UTC() + if qualificationShopDisabled(ctx, tx, version.ShopID) { + // 店铺停用或不存在时资格必须失效:若停留在待审批,则「有待审批版本」门禁会让该店铺 + // 永远无法获得有效资格(作废仅接受有效版本),因此就地收敛为已失效终态并写审计。 + return h.invalidateForDisabledShop(ctx, tx, version, before, event, now) + } + result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). + Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending). + Updates(map[string]any{ + "status": constants.WithdrawalQualificationStatusApproved, + "decided_at": now, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "使提现资料资格版本生效失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化") + } + version.Status = constants.WithdrawalQualificationStatusApproved + version.DecidedAt = &now + return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + EventID: "withdrawal-qualification:" + uintText(version.ID) + ":approved", + ActionCode: constants.AuditActionWithdrawalQualificationApproved, + Summary: "企业微信通过提现资料资格,版本已生效", + CorrelationID: event.CorrelationID, Qualification: version, Shop: shop, + BeforeData: before, AfterData: qualificationAuditSnapshot(version), + }) +} + +// invalidateForDisabledShop 在店铺停用或不存在时把待审批资料版本收敛为已失效。 +// 与代理停用联动失效语义一致(invalidated_by=0 表示系统联动),使该店铺可重新提交资格。 +func (h *QualificationApprovalHandler) invalidateForDisabledShop( + ctx context.Context, + tx *gorm.DB, + version *model.WithdrawalQualification, + before map[string]any, + event approvalapp.TerminalDecisionEvent, + now time.Time, +) error { + reason := "代理店铺已停用,资格自动失效" + result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). + Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending). + Updates(map[string]any{ + "status": constants.WithdrawalQualificationStatusInvalidated, + "invalid_reason": reason, "invalidated_at": now, "invalidated_by": 0, + "decided_at": now, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "失效停用店铺的提现资料资格失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化") + } + version.Status = constants.WithdrawalQualificationStatusInvalidated + version.InvalidReason = reason + version.InvalidatedAt = &now + version.InvalidatedBy = 0 + version.DecidedAt = &now + return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + EventID: "withdrawal-qualification:" + uintText(version.ID) + ":disabled", + ActionCode: constants.AuditActionWithdrawalQualificationInvalidated, + Summary: "企业微信通过时店铺已停用,提现资料资格直接失效", + CorrelationID: event.CorrelationID, Qualification: version, + BeforeData: before, AfterData: qualificationAuditSnapshot(version), + }) +} + +// qualificationShopDisabled 判断资料版本所属店铺是否已停用或不存在。 +func qualificationShopDisabled(ctx context.Context, tx *gorm.DB, shopID uint) bool { + var enabled int64 + if err := tx.WithContext(ctx).Model(&model.Shop{}). + Where("id = ? AND status = ?", shopID, constants.ShopStatusEnabled). + Count(&enabled).Error; err != nil { + return true + } + return enabled == 0 +} + +// applyRejected 标记资料版本已驳回,不影响其他版本已记录的审批结果。 +func (h *QualificationApprovalHandler) applyRejected( + ctx context.Context, + tx *gorm.DB, + version *model.WithdrawalQualification, + shop *model.Shop, + before map[string]any, + event approvalapp.TerminalDecisionEvent, +) error { + now := time.Now().UTC() + result := tx.WithContext(ctx).Model(&model.WithdrawalQualification{}). + Where("id = ? AND status = ?", version.ID, constants.WithdrawalQualificationStatusPending). + Updates(map[string]any{ + "status": constants.WithdrawalQualificationStatusRejected, + "decided_at": now, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现资料资格版本已驳回失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "提现资料资格版本状态已变化") + } + version.Status = constants.WithdrawalQualificationStatusRejected + version.DecidedAt = &now + return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + EventID: "withdrawal-qualification:" + uintText(version.ID) + ":rejected", + ActionCode: constants.AuditActionWithdrawalQualificationRejected, + Summary: "企业微信未通过提现资料资格", + CorrelationID: event.CorrelationID, Qualification: version, Shop: shop, + BeforeData: before, AfterData: qualificationAuditSnapshot(version), + }) +} diff --git a/internal/application/distributionwithdrawal/registration.go b/internal/application/distributionwithdrawal/registration.go new file mode 100644 index 0000000..d1cfd3c --- /dev/null +++ b/internal/application/distributionwithdrawal/registration.go @@ -0,0 +1,240 @@ +package distributionwithdrawal + +import ( + "context" + "strconv" + "strings" + + "github.com/google/uuid" + "golang.org/x/crypto/bcrypt" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// RegistrationResult 返回已落库的待审批注册记录与审批实例引用。 +type RegistrationResult struct { + RegistrationID uint + Status int + ApprovalInstanceID uint + ApprovalStatus int +} + +// RegistrationService 受理公开扫码注册。 +// 只创建待审批注册记录与审批实例,不创建店铺、账号、钱包或上下级归属。 +type RegistrationService struct { + db *gorm.DB + verifier VerificationCodeVerifier + approval approvalapp.Port + audit AuditWriter +} + +// NewRegistrationService 创建公开扫码注册用例。 +func NewRegistrationService( + db *gorm.DB, + verifier VerificationCodeVerifier, + approval approvalapp.Port, + audit AuditWriter, +) *RegistrationService { + return &RegistrationService{db: db, verifier: verifier, approval: approval, audit: audit} +} + +// Register 创建待审批注册记录。 +// 无效分销码、停用上级、验证码无效或已消费统一返回“分销码不可用”,且不落库。 +// 手机号、用户名或店铺编号与既有账号/店铺重复时返回稳定冲突错误。 +func (s *RegistrationService) Register( + ctx context.Context, + input distributiondomain.RegistrationInput, + code string, +) (*RegistrationResult, error) { + if s == nil || s.db == nil || s.verifier == nil || s.approval == nil || s.audit == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置") + } + normalized, err := distributiondomain.ValidateRegistrationInput(input) + if err != nil { + return nil, err + } + code = strings.TrimSpace(code) + if code == "" { + return nil, errors.New(errors.CodeInvalidParam, "分销码不可用") + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(normalized.Password), bcrypt.DefaultCost) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败") + } + var parent *model.Shop + if err := s.db.WithContext(ctx). + Where("distribution_code = ? AND status = ?", normalized.DistributionCode, constants.ShopStatusEnabled). + First(&parent).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeInvalidParam, "分销码不可用") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询分销码所属店铺失败") + } + // 验证码校验成功即消费;无效或已消费与无效分销码返回同一对外结果。 + if err := s.verifier.VerifyCode(ctx, normalized.Phone, code); err != nil { + return nil, errors.New(errors.CodeInvalidParam, "分销码不可用") + } + submitter, err := resolveRegistrationSubmitter(ctx, s.db, parent.ID) + if err != nil { + return nil, err + } + correlationID := "agent_distribution:registration:" + uuid.NewString() + preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ + BusinessType: constants.ApprovalBusinessTypeAgentDistribution, + SubmitterAccountID: submitter.ID, CorrelationID: correlationID, + }) + if err != nil { + return nil, err + } + registration := &model.AgentDistributionRegistration{ + DistributionCode: normalized.DistributionCode, ParentShopID: parent.ID, + Phone: normalized.Phone, PasswordHash: string(passwordHash), + ShopName: normalized.ShopName, ShopCode: normalized.ShopCode, Username: normalized.Username, + ContactName: normalized.ContactName, Province: normalized.Province, + City: normalized.City, District: normalized.District, Address: normalized.Address, + Status: constants.AgentDistributionRegistrationStatusPending, + } + submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username, + registrationApprovalForm(normalized, parent)) + if err != nil { + return nil, err + } + result := &RegistrationResult{} + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Create(registration).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建待审批注册记录失败") + } + reference, err := createApprovalInTx(ctx, tx, s.approval, preparation, + constants.ApprovalBusinessTypeAgentDistribution, registration.ID, submitter.ID, + submitterSnapshot, requestSnapshot, correlationID) + if err != nil { + return err + } + if err := attachRegistrationInstance(ctx, tx, registration, reference.InstanceID); err != nil { + return err + } + // 公开注册提交不写审计:该链路在 personal.go 的 Use() 之前注册,不经任何认证中间件, + // 因而没有可信的 actor/source(Append 会以「审计操作者或入口不符合动作注册规则」失败)。 + // tasks 1.7 只要求分销码生成、注册通过/驳回与资格相关审计,提交动作不在其列, + // 故移除该非必需审计而不是伪造操作者身份。 + result.RegistrationID = registration.ID + result.Status = registration.Status + result.ApprovalInstanceID = reference.InstanceID + result.ApprovalStatus = reference.Status + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +// resolveRegistrationSubmitter 解析扫码注册的审批发起身份。 +// 公开接口没有登录账号,使用分销码所属店铺的启用主账号作为发起主体; +// 该账号非平台/超管身份,企业微信侧按既有规则回落到应用默认审批发起人。 +func resolveRegistrationSubmitter(ctx context.Context, db *gorm.DB, parentShopID uint) (*model.Account, error) { + var account model.Account + if err := db.WithContext(ctx). + Where("shop_id = ? AND status = ? AND is_primary = TRUE", parentShopID, constants.StatusEnabled). + First(&account).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeInvalidParam, "分销码不可用") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺主账号失败") + } + return &account, nil +} + +// registrationApprovalForm 生成企业微信审批表单业务快照。 +// 手机号按脱敏值写入,禁止把完整手机号或密码写入审批表单与审计。 +func registrationApprovalForm(input distributiondomain.RegistrationInput, parent *model.Shop) map[string]any { + return map[string]any{ + constants.ApprovalFieldDistributionCode: distributiondomain.MaskDistributionCode(input.DistributionCode), + constants.ApprovalFieldDistributionParentShopID: parent.ID, + constants.ApprovalFieldDistributionParentShopName: parent.ShopName, + constants.ApprovalFieldDistributionShopName: input.ShopName, + constants.ApprovalFieldDistributionShopCode: input.ShopCode, + constants.ApprovalFieldDistributionUsername: input.Username, + constants.ApprovalFieldDistributionPhoneMasked: distributiondomain.MaskPhone(input.Phone), + constants.ApprovalFieldDistributionContactName: input.ContactName, + constants.ApprovalFieldDistributionRegion: strings.TrimSpace( + input.Province + input.City + input.District + input.Address), + } +} + +// attachRegistrationInstance 回写注册记录关联的审批实例,写入一次后不可修改。 +func attachRegistrationInstance( + ctx context.Context, + tx *gorm.DB, + registration *model.AgentDistributionRegistration, + instanceID uint, +) error { + result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}). + Where("id = ? AND approval_instance_id IS NULL", registration.ID). + Update("approval_instance_id", instanceID) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联扫码注册审批实例失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "扫码注册审批实例关联已变化") + } + registration.ApprovalInstanceID = &instanceID + return nil +} + +// registrationAuditSnapshot 生成注册记录审计快照,手机号按脱敏值记录,不含密码哈希。 +func registrationAuditSnapshot(registration *model.AgentDistributionRegistration) map[string]any { + instanceID := uint(0) + if registration.ApprovalInstanceID != nil { + instanceID = *registration.ApprovalInstanceID + } + return map[string]any{ + "id": registration.ID, "parent_shop_id": registration.ParentShopID, + "distribution_code_masked": distributiondomain.MaskDistributionCode(registration.DistributionCode), + "phone_masked": distributiondomain.MaskPhone(registration.Phone), + "username": registration.Username, "shop_code": registration.ShopCode, + "status": registration.Status, "approval_instance_id": instanceID, + } +} + +// uintText 将无符号整数转换为审计标识与键的十进制文本。 +func uintText(value uint) string { + return strconv.FormatUint(uint64(value), 10) +} + +// intText 将整数转换为审计标识与键的十进制文本。 +func intText(value int) string { + return strconv.Itoa(value) +} + +// composeAuditEventID 拼接审计事件标识,并约束在审计列宽内。 +func composeAuditEventID(parts ...string) (string, error) { + eventID := strings.Join(parts, ":") + if len(eventID) > 128 { + return "", errors.New(errors.CodeInternalError, "审计事件标识超出长度限制") + } + return eventID, nil +} + +// lockRegistrationForUpdate 以行锁读取注册记录,未找到返回稳定不存在错误。 +func lockRegistrationForUpdate( + ctx context.Context, + tx *gorm.DB, + id uint, +) (*model.AgentDistributionRegistration, error) { + var registration model.AgentDistributionRegistration + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + First(®istration, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "扫码注册记录不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定扫码注册记录失败") + } + return ®istration, nil +} diff --git a/internal/application/distributionwithdrawal/registration_approval.go b/internal/application/distributionwithdrawal/registration_approval.go new file mode 100644 index 0000000..d87c36c --- /dev/null +++ b/internal/application/distributionwithdrawal/registration_approval.go @@ -0,0 +1,279 @@ +package distributionwithdrawal + +import ( + "context" + "time" + + "gorm.io/gorm" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + shopapp "github.com/break/junhong_cmp_fiber/internal/application/shop" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// SubordinateCacheInvalidator 在审批通过事务提交后清理上级店铺下级集合缓存。 +type SubordinateCacheInvalidator interface { + InvalidateSubordinateCache(ctx context.Context, shopID uint) +} + +// DistributionApprovalHandler 将渠道无关企业微信终态应用到代理扫码注册记录。 +// 通过才在单一事务内创建启用店铺、代理主账号、所需钱包、上级层级与业务员快照; +// 驳回只标记注册记录,不创建任何实体;重复或乱序回调不重复创建账号、层级或钱包。 +type DistributionApprovalHandler struct { + db *gorm.DB + audit AuditWriter + cache SubordinateCacheInvalidator +} + +// NewDistributionApprovalHandler 创建代理分销注册审批终态消费者。 +func NewDistributionApprovalHandler( + db *gorm.DB, + audit AuditWriter, + cache SubordinateCacheInvalidator, +) *DistributionApprovalHandler { + return &DistributionApprovalHandler{db: db, audit: audit, cache: cache} +} + +// Handle 幂等消费标准审批终态。 +// 业务标识为待审批注册记录主键;先锁定注册记录并校验审批实例一致,再按条件更新推进状态。 +func (h *DistributionApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { + if h == nil || h.db == nil || h.audit == nil { + return errors.New(errors.CodeInternalError, "代理分销注册审批终态能力未配置") + } + if event.BusinessType != constants.ApprovalBusinessTypeAgentDistribution || + event.BusinessID == 0 || event.InstanceID == 0 { + return errors.New(errors.CodeInvalidParam, "代理分销注册审批终态参数无效") + } + ctx = auditcontext.With(ctx, auditcontext.Context{ + CorrelationID: event.CorrelationID, ParentEventID: event.EventID, + }) + parentShopID := uint(0) + err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + registration, err := lockRegistrationForUpdate(ctx, tx, event.BusinessID) + if err != nil { + return err + } + if registration.ApprovalInstanceID == nil || *registration.ApprovalInstanceID != event.InstanceID { + return errors.New(errors.CodeConflict, "扫码注册记录关联的审批实例不一致") + } + if registration.Status != constants.AgentDistributionRegistrationStatusPending { + // 已是终态:重复或乱序回调不再改变事实。 + return nil + } + switch event.Decision { + case constants.ApprovalDecisionApproved: + parentShopID = registration.ParentShopID + return h.applyApproved(ctx, tx, registration, event) + case constants.ApprovalDecisionRejected, + constants.ApprovalDecisionCancelled, + constants.ApprovalDecisionDeleted: + return h.applyRejected(ctx, tx, registration, event) + case constants.ApprovalDecisionRevokedAfterApproved: + // 注册记录无已建立的对外资金事实;通过后撤销按驳回处理并保留渠道决策痕迹。 + return h.applyRejected(ctx, tx, registration, event) + default: + return errors.New(errors.CodeInvalidParam, "不支持的代理分销注册审批终态") + } + }) + if err != nil { + return err + } + if parentShopID != 0 && h.cache != nil { + // 缓存清理必须在事务提交后执行,避免回滚后缓存与库内事实不一致。 + h.cache.InvalidateSubordinateCache(ctx, parentShopID) + } + return nil +} + +// applyApproved 在同一事务内建立店铺、账号、钱包、层级与业务员快照。 +// 上级店铺必须仍然存在且启用;手机号或用户名已被并发注册占用时整体回滚,不留半套实体。 +func (h *DistributionApprovalHandler) applyApproved( + ctx context.Context, + tx *gorm.DB, + registration *model.AgentDistributionRegistration, + event approvalapp.TerminalDecisionEvent, +) error { + parent, err := loadEnabledParentShop(ctx, tx, registration.ParentShopID) + if err != nil { + return err + } + level := parent.Level + 1 + if level > constants.ShopMaxLevel { + return errors.New(errors.CodeShopLevelExceeded, "店铺层级不能超过 7 级") + } + role, err := loadEnabledCustomerRole(ctx, tx) + if err != nil { + return err + } + shop := &model.Shop{ + ShopName: registration.ShopName, ShopCode: registration.ShopCode, + ParentID: &parent.ID, Level: level, + ContactName: registration.ContactName, Province: registration.Province, + City: registration.City, District: registration.District, Address: registration.Address, + Status: constants.ShopStatusEnabled, + } + shop.BusinessOwnerAccountID = parent.BusinessOwnerAccountID + shop.Creator = registration.ID + shop.Updater = registration.ID + // 新店铺生成自己的分销码:注册记录上的分销码是上级店铺快照,复用会与父店铺同码并命中唯一索引。 + if err := shopapp.CreateShopWithDistributionCode(ctx, tx, shop); err != nil { + return err + } + account := &model.Account{ + Username: registration.Username, Phone: registration.Phone, + Password: registration.PasswordHash, UserType: constants.UserTypeAgent, + ShopID: &shop.ID, Status: constants.StatusEnabled, IsPrimary: true, + } + account.Creator = registration.ID + account.Updater = registration.ID + if err := tx.WithContext(ctx).Create(account).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建扫码注册代理账号失败") + } + if err := tx.WithContext(ctx).Create(&model.AccountRole{ + AccountID: account.ID, RoleID: role.ID, Status: constants.StatusEnabled, + Creator: registration.ID, Updater: registration.ID, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "为扫码注册代理账号分配角色失败") + } + if err := tx.WithContext(ctx).Create(&model.ShopRole{ + ShopID: shop.ID, RoleID: role.ID, Status: constants.StatusEnabled, + Creator: registration.ID, Updater: registration.ID, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "设置扫码注册店铺默认角色失败") + } + if err := tx.WithContext(ctx).Create([]*model.AgentWallet{ + { + ShopID: shop.ID, WalletType: constants.AgentWalletTypeMain, + CreditEnabled: role.DefaultCreditEnabled, CreditLimit: role.DefaultCreditLimit, + Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID, + }, + { + ShopID: shop.ID, WalletType: constants.AgentWalletTypeCommission, + CreditEnabled: false, CreditLimit: 0, + Currency: "CNY", Status: constants.AgentWalletStatusNormal, ShopIDTag: shop.ID, + }, + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "初始化扫码注册店铺钱包失败") + } + if err := markRegistrationApproved(ctx, tx, registration); err != nil { + return err + } + // 建店与业务员归属的访问审计不在本用例职责内:该动作面向后台账号入口, + // 由审批消费任务触发的建店无法提供其要求的操作者/数据范围投影, + // 强行写入会以「账号权限或组织审计操作者不完整」失败并中止事务。 + // 新建店铺已作为 CreatedShop 资源记录在本用例的分销审计中。 + return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + EventID: "agent-distribution:" + uintText(registration.ID) + ":approved", + ActionCode: constants.AuditActionAgentDistributionRegistrationApproved, + Summary: "企业微信通过扫码注册,已创建店铺与代理账号", + CorrelationID: event.CorrelationID, Registration: registration, + ParentShop: parent, CreatedShop: shop, + AppliedDistributionCode: registration.DistributionCode, + AfterData: registrationAuditSnapshot(registration), + }) +} + +// applyRejected 只标记注册记录终态,不创建店铺、账号、钱包或层级。 +func (h *DistributionApprovalHandler) applyRejected( + ctx context.Context, + tx *gorm.DB, + registration *model.AgentDistributionRegistration, + event approvalapp.TerminalDecisionEvent, +) error { + now := time.Now().UTC() + reason := rejectionReason(event.Decision) + result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}). + Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending). + Updates(map[string]any{ + "status": constants.AgentDistributionRegistrationStatusRejected, + "reject_reason": reason, "decided_at": now, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记扫码注册记录已驳回失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "扫码注册记录状态已变化") + } + before := registration.Status + registration.Status = constants.AgentDistributionRegistrationStatusRejected + registration.RejectReason = reason + registration.DecidedAt = &now + return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + EventID: "agent-distribution:" + uintText(registration.ID) + ":rejected", + ActionCode: constants.AuditActionAgentDistributionRegistrationRejected, + Summary: "企业微信未通过扫码注册,未创建任何实体", + CorrelationID: event.CorrelationID, Registration: registration, + BeforeData: map[string]any{"status": before}, + AfterData: registrationAuditSnapshot(registration), + }) +} + +// markRegistrationApproved 以待审批状态条件更新标记注册记录已通过,重复回调不重复推进。 +func markRegistrationApproved( + ctx context.Context, + tx *gorm.DB, + registration *model.AgentDistributionRegistration, +) error { + now := time.Now().UTC() + result := tx.WithContext(ctx).Model(&model.AgentDistributionRegistration{}). + Where("id = ? AND status = ?", registration.ID, constants.AgentDistributionRegistrationStatusPending). + Updates(map[string]any{ + "status": constants.AgentDistributionRegistrationStatusApproved, + "decided_at": now, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记扫码注册记录已通过失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "扫码注册记录状态已变化") + } + registration.Status = constants.AgentDistributionRegistrationStatusApproved + registration.DecidedAt = &now + return nil +} + +// rejectionReason 把渠道决策映射为可查询的中文驳回原因。 +func rejectionReason(decision string) string { + switch decision { + case constants.ApprovalDecisionCancelled: + return "企业微信审批已撤销" + case constants.ApprovalDecisionDeleted: + return "企业微信审批已删除" + case constants.ApprovalDecisionRevokedAfterApproved: + return "企业微信审批通过后撤销" + default: + return "企业微信审批已驳回" + } +} + +// loadEnabledParentShop 校验分销码所属店铺仍存在且启用。 +func loadEnabledParentShop(ctx context.Context, tx *gorm.DB, shopID uint) (*model.Shop, error) { + var parent model.Shop + if err := tx.WithContext(ctx).First(&parent, shopID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "上级店铺不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询上级店铺失败") + } + if parent.Status != constants.ShopStatusEnabled { + return nil, errors.New(errors.CodeInvalidStatus, "上级店铺已停用,不允许注册下级") + } + return &parent, nil +} + +// loadEnabledCustomerRole 读取启用的客户角色,用于新建代理店铺的默认角色与信用额度。 +func loadEnabledCustomerRole(ctx context.Context, tx *gorm.DB) (*model.Role, error) { + var role model.Role + if err := tx.WithContext(ctx). + Where("role_type = ? AND status = ?", constants.RoleTypeCustomer, constants.StatusEnabled). + Order("id ASC").First(&role).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeInvalidStatus, "缺少启用的客户角色,无法创建代理店铺") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询启用客户角色失败") + } + return &role, nil +} diff --git a/internal/application/distributionwithdrawal/withdrawal.go b/internal/application/distributionwithdrawal/withdrawal.go new file mode 100644 index 0000000..cd987b9 --- /dev/null +++ b/internal/application/distributionwithdrawal/withdrawal.go @@ -0,0 +1,830 @@ +package distributionwithdrawal + +import ( + "context" + "crypto/rand" + "math/big" + "strings" + "time" + + "github.com/bytedance/sonic" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution" + "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" +) + +// WithdrawalPolicy 是提现申请使用的当前配置快照,由调用方从提现配置读取。 +type WithdrawalPolicy struct { + MinAmount int64 + FeeRate int64 + DailyWithdrawalLimit int +} + +// WithdrawalInput 是提现申请或重提的规范化输入。 +type WithdrawalInput struct { + Amount int64 + WithdrawalMethod string + AccountName string + AccountNumber string + InvoiceKeys []string +} + +// WithdrawalResult 返回已原子保存的提现申请与审批尝试记录。 +type WithdrawalResult struct { + RequestID uint + WithdrawalNo string + AttemptID uint + AttemptNo int + Amount int64 + Fee int64 + FeeRate int64 + ActualAmount int64 + Status int + ApprovalInstanceID uint + ApprovalStatus int + CreatedAt time.Time +} + +// WithdrawalService 创建与重提提现申请。 +// 申请、审批尝试记录、审批实例与佣金钱包冻结在同一事务完成; +// 余额不足、资格无效或非本人代理时不创建申请、审批实例或任何冻结。 +type WithdrawalService struct { + db *gorm.DB + approval approvalapp.Port + audit AuditWriter +} + +// NewWithdrawalService 创建提现申请用例。 +func NewWithdrawalService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *WithdrawalService { + return &WithdrawalService{db: db, approval: approval, audit: audit} +} + +// Create 为本人代理店铺创建提现申请。 +func (s *WithdrawalService) Create( + ctx context.Context, + shopID uint, + policy WithdrawalPolicy, + input WithdrawalInput, +) (*WithdrawalResult, error) { + if err := s.ensureReady(); err != nil { + return nil, err + } + if err := ensureOwnAgentShop(ctx, shopID); err != nil { + return nil, errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现") + } + return s.submit(ctx, shopID, policy, nil, input) +} + +// Resubmit 由本人代理修改金额、收款信息与本次发票后重提已被企业微信驳回的提现申请。 +// 事务内先释放旧未结算尝试的冻结,再按新金额冻结;历史快照与审批结果不被覆盖。 +func (s *WithdrawalService) Resubmit( + ctx context.Context, + requestID uint, + policy WithdrawalPolicy, + input WithdrawalInput, +) (*WithdrawalResult, error) { + if err := s.ensureReady(); err != nil { + return nil, err + } + if requestID == 0 { + return nil, errors.New(errors.CodeNotFound, "提现申请不存在") + } + return s.submit(ctx, 0, policy, &requestID, input) +} + +// ensureReady 校验依赖完整,缺失时失败关闭,避免绕过企业微信终审。 +func (s *WithdrawalService) ensureReady() error { + if s == nil || s.db == nil || s.approval == nil || s.audit == nil { + return errors.New(errors.CodeServiceUnavailable, "提现申请能力尚未配置") + } + return nil +} + +// submit 在同一事务内完成资格校验、钱包加锁冻结、写申请与审批尝试记录、创建审批实例。 +func (s *WithdrawalService) submit( + ctx context.Context, + shopID uint, + policy WithdrawalPolicy, + resubmitRequestID *uint, + input WithdrawalInput, +) (*WithdrawalResult, error) { + submitter, err := currentSubmitter(ctx) + if err != nil { + return nil, err + } + if input.Amount <= 0 { + return nil, errors.New(errors.CodeInvalidParam, "提现金额必须大于 0") + } + if policy.MinAmount > 0 && input.Amount < policy.MinAmount { + return nil, errors.New(errors.CodeInvalidParam, "提现金额低于当前最低提现额度") + } + correlationID := "commission_withdrawal:" + uuid.NewString() + preparation, err := s.approval.Prepare(ctx, approvalapp.PrepareRequest{ + BusinessType: constants.ApprovalBusinessTypeCommissionWithdrawal, + SubmitterAccountID: submitter.ID, CorrelationID: correlationID, + }) + if err != nil { + return nil, err + } + result := &WithdrawalResult{} + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var request *model.CommissionWithdrawalRequest + if resubmitRequestID != nil { + request, err = lockWithdrawalRequest(ctx, tx, *resubmitRequestID) + if err != nil { + return err + } + if request.ShopID != submitter.ShopID { + return errors.New(errors.CodeNotFound, "提现申请不存在") + } + if request.Status != constants.WithdrawalStatusRejected { + return errors.New(errors.CodeConflict, "仅已被驳回的提现申请可重提") + } + if request.ApprovalInstanceID == nil { + return errors.New(errors.CodeConflict, "存量提现申请不支持企业微信重提") + } + // 重提先释放旧未结算尝试的冻结,避免产生第二笔冻结。 + if _, err := releaseUnsettledAttemptsForRequest(ctx, tx, request.ID); err != nil { + return err + } + shopID = request.ShopID + } + if err := ensureOwnAgentShopForShopID(ctx, submitter, shopID); err != nil { + return err + } + qualification, err := loadValidQualification(ctx, tx, shopID) + if err != nil { + return err + } + if err := validateWithdrawalInvoice(qualification, input.InvoiceKeys); err != nil { + return err + } + if err := ensureDailyWithdrawalLimit(ctx, tx, shopID, policy.DailyWithdrawalLimit, resubmitRequestID == nil); err != nil { + return err + } + wallet, err := lockCommissionWallet(ctx, tx, shopID) + if err != nil { + return err + } + fee := input.Amount * policy.FeeRate / 10000 + actualAmount := input.Amount - fee + if err := freezeCommissionBalance(ctx, tx, wallet, input.Amount); err != nil { + return err + } + accountInfo, err := marshalJSON(map[string]string{ + "account_name": input.AccountName, "account_number": input.AccountNumber, + }) + if err != nil { + return err + } + invoiceKeys, err := marshalJSON(normalizeInvoiceKeys(input.InvoiceKeys)) + if err != nil { + return err + } + if request == nil { + request = &model.CommissionWithdrawalRequest{ + WithdrawalNo: generateWithdrawalNo(), + ShopID: shopID, + AgentID: submitter.ID, + ApplicantID: submitter.ID, + Amount: input.Amount, + FeeRate: policy.FeeRate, + Fee: fee, + ActualAmount: actualAmount, + WithdrawalMethod: input.WithdrawalMethod, + AccountInfo: accountInfo, + Status: constants.WithdrawalStatusPending, + } + request.Creator = submitter.ID + request.Updater = submitter.ID + if err := tx.WithContext(ctx).Create(request).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建提现申请失败") + } + } else { + if err := updateWithdrawalRequestForResubmit(ctx, tx, request, input, policy, fee, actualAmount, accountInfo); err != nil { + return err + } + } + attemptNo, err := nextWithdrawalAttemptNo(ctx, tx, request.ID) + if err != nil { + return err + } + attempt := &model.CommissionWithdrawalRequestAttempt{ + RequestID: request.ID, AttemptNo: attemptNo, + Amount: input.Amount, Fee: fee, FeeRate: policy.FeeRate, ActualAmount: actualAmount, + WithdrawalMethod: input.WithdrawalMethod, AccountInfo: accountInfo, + InvoiceKeys: invoiceKeys, SubmittedByAccountID: submitter.ID, + } + if err := tx.WithContext(ctx).Create(attempt).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建提现审批尝试记录失败") + } + submitterSnapshot, requestSnapshot, err := approvalSnapshots(submitter.ID, submitter.Username, + withdrawalApprovalForm(request, attempt, shopName(ctx, tx, shopID))) + if err != nil { + return err + } + reference, err := createApprovalInTx(ctx, tx, s.approval, preparation, + constants.ApprovalBusinessTypeCommissionWithdrawal, attempt.ID, submitter.ID, + submitterSnapshot, requestSnapshot, correlationID) + if err != nil { + return err + } + if err := attachWithdrawalAttemptInstance(ctx, tx, attempt, reference.InstanceID); err != nil { + return err + } + if err := updateWithdrawalLatest(ctx, tx, request, attempt, reference.InstanceID); err != nil { + return err + } + transaction, err := recordWithdrawalFreezeTransaction(ctx, tx, wallet, request, submitter.ID, input.Amount) + if err != nil { + return err + } + eventID, err := composeAuditEventID( + "commission-withdrawal", uintText(request.ID), "attempt", intText(attempt.AttemptNo), "submit") + if err != nil { + return err + } + if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + EventID: eventID, ActionCode: constants.AuditActionCommissionWithdrawalAttemptSubmitted, + Summary: withdrawalSubmitSummary(resubmitRequestID != nil), CorrelationID: correlationID, + Withdrawal: request, Attempt: attempt, Wallet: wallet, Transaction: transaction, + AfterData: withdrawalAuditSnapshot(request, attempt, wallet), + }); err != nil { + return err + } + result.RequestID = request.ID + result.WithdrawalNo = request.WithdrawalNo + result.AttemptID = attempt.ID + result.AttemptNo = attempt.AttemptNo + result.Amount = attempt.Amount + result.Fee = attempt.Fee + result.FeeRate = attempt.FeeRate + result.ActualAmount = attempt.ActualAmount + result.Status = request.Status + result.ApprovalInstanceID = reference.InstanceID + result.ApprovalStatus = reference.Status + result.CreatedAt = request.CreatedAt + return nil + }) + if err != nil { + // 失败审计必须可追溯:带上店铺(shopID 在手上),使审计恰好有一个主要资源。 + RecordFailure(ctx, s.db, s.audit, AuditChange{ + // 不手工构造 EventID:同一店铺的提现可被拒绝多次,手工 ID 会让后续拒绝被 + // event_id 唯一约束吞掉;由审计 Writer 生成唯一 evt_。 + ActionCode: constants.AuditActionCommissionWithdrawalAttemptRejected, + Summary: "提交提现申请被拒绝", CorrelationID: correlationID, + Shop: failureShopResolved(ctx, s.db, shopID, resubmitRequestID), + }, err) + return nil, err + } + return result, nil +} + +// failureShopResolved 为失败审计解析店铺引用:优先用入参 shopID, +// Resubmit 场景下 shopID 为空则从提现申请行回查店铺,保证拒绝事实有可追溯的店铺主资源。 +func failureShopResolved(ctx context.Context, db *gorm.DB, shopID uint, requestID *uint) *model.Shop { + if shopID == 0 && requestID != nil { + var request model.CommissionWithdrawalRequest + if err := db.WithContext(ctx).Select("id", "shop_id").First(&request, *requestID).Error; err == nil { + shopID = request.ShopID + } + } + return failureShop(ctx, db, shopID) +} + +// failureShop 为失败审计解析店铺引用;店铺查询失败时退回仅含 ID 的最小引用, +// 保证拒绝事实仍有可追溯的店铺主资源。 +func failureShop(ctx context.Context, db *gorm.DB, shopID uint) *model.Shop { + if shopID == 0 { + return nil + } + if shop := loadShopOrNil(ctx, db, shopID); shop != nil { + return shop + } + // gorm.Model 的 ID 是提升字段,无法在复合字面量中设置,这里显式赋值。 + minimal := &model.Shop{} + minimal.ID = shopID + return minimal +} + +// withdrawalSubmitter 是发起提现的真实操作者。 +type withdrawalSubmitter struct { + ID uint + Username string + ShopID uint +} + +// currentSubmitter 从上下文取当前代理账号与其店铺,未认证时拒绝。 +func currentSubmitter(ctx context.Context) (withdrawalSubmitter, error) { + accountID := middleware.GetUserIDFromContext(ctx) + if accountID == 0 { + return withdrawalSubmitter{}, errors.New(errors.CodeUnauthorized, "未授权访问") + } + shopID := middleware.GetShopIDFromContext(ctx) + if shopID == 0 { + return withdrawalSubmitter{}, errors.New(errors.CodeForbidden, "代理账号缺少店铺信息") + } + return withdrawalSubmitter{ + ID: accountID, Username: middleware.GetUsernameFromContext(ctx), ShopID: shopID, + }, nil +} + +// ensureOwnAgentShopForShopID 复核代理身份与店铺归属,越权与不存在返回同一结果。 +func ensureOwnAgentShopForShopID(ctx context.Context, submitter withdrawalSubmitter, shopID uint) error { + if shopID == 0 || shopID != submitter.ShopID { + return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现") + } + if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeAgent { + return errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现") + } + return nil +} + +// loadValidQualification 读取当前有效的提现资料资格;缺失或已失效时拒绝提现申请。 +func loadValidQualification( + ctx context.Context, + tx *gorm.DB, + shopID uint, +) (*model.WithdrawalQualification, error) { + var qualification model.WithdrawalQualification + err := tx.WithContext(ctx). + Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalQualificationStatusApproved). + First(&qualification).Error + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeInvalidStatus, "提现资料资格无效,请先完成资料审批") + } + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询有效提现资料资格失败") + } + 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 shop.Status != constants.ShopStatusEnabled { + return nil, errors.New(errors.CodeInvalidStatus, "店铺已停用,提现资料资格已失效") + } + return &qualification, nil +} + +// validateWithdrawalInvoice 校验申请级发票仅在企业主体且已登记发票资料时提交。 +func validateWithdrawalInvoice(qualification *model.WithdrawalQualification, invoiceKeys []string) error { + keys := normalizeInvoiceKeys(invoiceKeys) + if len(keys) == 0 { + return nil + } + if qualification.SubjectType != constants.WithdrawalQualificationSubjectTypeEnterprise { + return errors.New(errors.CodeInvalidParam, "发票仅企业主体可提交") + } + if qualification.InvoiceSubjectCode == "" || qualification.InvoiceTitle == "" { + return errors.New(errors.CodeInvalidParam, "有效提现资料资格未登记发票资料") + } + return nil +} + +// normalizeInvoiceKeys 归一化发票对象键列表,去除空串。 +func normalizeInvoiceKeys(keys []string) []string { + result := make([]string, 0, len(keys)) + for _, key := range keys { + if trimmed := strings.TrimSpace(key); trimmed != "" { + result = append(result, trimmed) + } + } + return result +} + +// ensureDailyWithdrawalLimit 校验当日提现次数上限;重提不占用新的当日次数。 +func ensureDailyWithdrawalLimit( + ctx context.Context, + tx *gorm.DB, + shopID uint, + limit int, + countNewRequest bool, +) error { + if !countNewRequest || limit <= 0 { + return nil + } + today := time.Now().Format("2006-01-02") + var todayCount int64 + if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). + Where("shop_id = ? AND created_at >= ? AND created_at <= ?", shopID, today+" 00:00:00", today+" 23:59:59"). + Count(&todayCount).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询当日提现次数失败") + } + if int(todayCount) >= limit { + return errors.New(errors.CodeInvalidParam, "今日提现次数已达上限") + } + return nil +} + +// lockWithdrawalRequest 以行锁读取提现申请,未找到返回稳定不存在错误。 +func lockWithdrawalRequest( + ctx context.Context, + tx *gorm.DB, + id uint, +) (*model.CommissionWithdrawalRequest, error) { + var request model.CommissionWithdrawalRequest + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + First(&request, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "提现申请不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定提现申请失败") + } + return &request, nil +} + +// lockCommissionWallet 以行锁读取店铺佣金钱包。 +func lockCommissionWallet(ctx context.Context, tx *gorm.DB, shopID uint) (*model.AgentWallet, error) { + var wallet model.AgentWallet + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeCommission). + First(&wallet).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定店铺佣金钱包失败") + } + return &wallet, nil +} + +// freezeCommissionBalance 以条件更新冻结可提现余额,影响行数不为 1 时判定余额不足。 +func freezeCommissionBalance( + ctx context.Context, + tx *gorm.DB, + wallet *model.AgentWallet, + amount int64, +) error { + result := tx.WithContext(ctx).Model(&model.AgentWallet{}). + Where("id = ? AND wallet_type = ? AND balance - frozen_balance >= ?", + wallet.ID, constants.AgentWalletTypeCommission, amount). + Updates(map[string]any{ + "frozen_balance": gorm.Expr("frozen_balance + ?", amount), + "updated_at": time.Now(), + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "冻结可提现余额失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeInsufficientBalance, "可提现余额不足或并发冲突,请稍后重试") + } + wallet.FrozenBalance += amount + return nil +} + +// releaseCommissionBalance 以条件更新释放冻结余额并返回释放是否发生。 +// 释放金额取尝试记录事实,重复释放不会重复调整余额。 +func releaseCommissionBalance( + ctx context.Context, + tx *gorm.DB, + walletID uint, + amount int64, +) (bool, error) { + result := tx.WithContext(ctx).Model(&model.AgentWallet{}). + Where("id = ? AND wallet_type = ? AND frozen_balance >= ?", + walletID, constants.AgentWalletTypeCommission, amount). + Updates(map[string]any{ + "frozen_balance": gorm.Expr("frozen_balance - ?", amount), + "updated_at": time.Now(), + }) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "释放冻结余额失败") + } + return result.RowsAffected == 1, nil +} + +// nextWithdrawalAttemptNo 返回该申请的下一条审批尝试序号;申请行已加锁,序号在同一事务内唯一。 +func nextWithdrawalAttemptNo(ctx context.Context, tx *gorm.DB, requestID uint) (int, error) { + var row struct { + MaxAttemptNo int + } + if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}). + Select("COALESCE(MAX(attempt_no), 0) AS max_attempt_no"). + Where("request_id = ?", requestID).Scan(&row).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试序号失败") + } + if row.MaxAttemptNo >= constants.WithdrawalAttemptMaxCount { + return 0, errors.New(errors.CodeConflict, "提现重提次数已达上限,请联系平台处理") + } + return row.MaxAttemptNo + 1, nil +} + +// updateWithdrawalRequestForResubmit 以已驳回状态条件更新申请为最新尝试的镜像。 +func updateWithdrawalRequestForResubmit( + ctx context.Context, + tx *gorm.DB, + request *model.CommissionWithdrawalRequest, + input WithdrawalInput, + policy WithdrawalPolicy, + fee int64, + actualAmount int64, + accountInfo []byte, +) error { + result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). + Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusRejected). + Updates(map[string]any{ + "amount": input.Amount, "fee": fee, "fee_rate": policy.FeeRate, "actual_amount": actualAmount, + "withdrawal_method": input.WithdrawalMethod, "account_info": accountInfo, + "status": constants.WithdrawalStatusPending, "processed_at": nil, + "reject_reason": "", "updater": request.ApplicantID, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新提现申请重提内容失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "提现申请状态已变化,请刷新后重试") + } + request.Amount = input.Amount + request.Fee = fee + request.FeeRate = policy.FeeRate + request.ActualAmount = actualAmount + request.WithdrawalMethod = input.WithdrawalMethod + request.AccountInfo = accountInfo + request.Status = constants.WithdrawalStatusPending + request.ProcessedAt = nil + request.RejectReason = "" + return nil +} + +// attachWithdrawalAttemptInstance 回写尝试记录关联的审批实例,写入一次后不可修改。 +func attachWithdrawalAttemptInstance( + ctx context.Context, + tx *gorm.DB, + attempt *model.CommissionWithdrawalRequestAttempt, + instanceID uint, +) error { + result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}). + Where("id = ? AND approval_instance_id IS NULL", attempt.ID). + Update("approval_instance_id", instanceID) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "关联提现审批实例失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "提现审批实例关联已变化") + } + attempt.ApprovalInstanceID = &instanceID + return nil +} + +// updateWithdrawalLatest 回填申请的最新尝试与审批实例引用,仅用于列表投影。 +func updateWithdrawalLatest( + ctx context.Context, + tx *gorm.DB, + request *model.CommissionWithdrawalRequest, + attempt *model.CommissionWithdrawalRequestAttempt, + instanceID uint, +) error { + updates := map[string]any{ + "latest_attempt_id": attempt.ID, "latest_approval_instance_id": instanceID, + } + if request.ApprovalInstanceID == nil { + // 首次接入企业微信审批时记录稳定门禁标识,本地人工终审据此拒绝。 + updates["approval_instance_id"] = instanceID + } + result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). + Where("id = ?", request.ID).Updates(updates) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "回填提现申请最新审批实例失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "提现申请最新审批实例回填已变化") + } + request.LatestAttemptID = attempt.ID + request.LatestApprovalInstanceID = instanceID + if request.ApprovalInstanceID == nil { + request.ApprovalInstanceID = &instanceID + } + return nil +} + +// recordWithdrawalFreezeTransaction 写入提现冻结钱包流水,金额为负且状态为处理中。 +func recordWithdrawalFreezeTransaction( + ctx context.Context, + tx *gorm.DB, + wallet *model.AgentWallet, + request *model.CommissionWithdrawalRequest, + operatorID uint, + amount int64, +) (*model.AgentWalletTransaction, error) { + remark := "提现冻结,单号:" + request.WithdrawalNo + refType := constants.ReferenceTypeWithdrawal + refID := request.ID + transaction := &model.AgentWalletTransaction{ + AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: operatorID, + TransactionType: constants.AgentTransactionTypeWithdrawal, + Amount: -amount, + BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance - amount, + Status: constants.TransactionStatusProcessing, + ReferenceType: &refType, ReferenceID: &refID, Remark: &remark, + Creator: operatorID, ShopIDTag: request.ShopID, + } + if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现冻结钱包流水失败") + } + return transaction, nil +} + +// shopName 读取店铺名称用于审批表单展示,缺失时留空。 +func shopName(ctx context.Context, db *gorm.DB, shopID uint) string { + var shop model.Shop + if err := db.WithContext(ctx).Select("id", "shop_name").First(&shop, shopID).Error; err != nil { + return "" + } + return shop.ShopName +} + +// withdrawalApprovalForm 生成企业微信审批表单业务快照。 +// 收款账号按原值写入供审批人核验,其他敏感内容不写入。 +func withdrawalApprovalForm( + request *model.CommissionWithdrawalRequest, + attempt *model.CommissionWithdrawalRequestAttempt, + shopNameValue string, +) map[string]any { + accountName, accountNumber := decodeAccountInfo(attempt.AccountInfo) + return map[string]any{ + constants.ApprovalFieldWithdrawalNo: request.WithdrawalNo, + constants.ApprovalFieldWithdrawalAttemptNo: attempt.AttemptNo, + constants.ApprovalFieldWithdrawalShopID: request.ShopID, + constants.ApprovalFieldWithdrawalShopName: shopNameValue, + constants.ApprovalFieldWithdrawalAmount: formatAmountYuan(attempt.Amount), + constants.ApprovalFieldWithdrawalAmountCent: attempt.Amount, + constants.ApprovalFieldWithdrawalFee: formatAmountYuan(attempt.Fee), + constants.ApprovalFieldWithdrawalActualAmount: formatAmountYuan(attempt.ActualAmount), + constants.ApprovalFieldWithdrawalMethod: attempt.WithdrawalMethod, + constants.ApprovalFieldWithdrawalAccountName: accountName, + constants.ApprovalFieldWithdrawalAccountNumber: accountNumber, + constants.ApprovalFieldWithdrawalInvoiceKey: decodeInvoiceKeys(attempt.InvoiceKeys), + } +} + +// decodeAccountInfo 解析收款账户信息快照,解析失败时留空。 +func decodeAccountInfo(payload []byte) (string, string) { + var info map[string]string + if err := sonic.Unmarshal(payload, &info); err != nil { + return "", "" + } + return info["account_name"], info["account_number"] +} + +// decodeInvoiceKeys 解析发票对象键列表,解析失败时返回空列表。 +func decodeInvoiceKeys(payload []byte) []string { + var keys []string + if err := sonic.Unmarshal(payload, &keys); err != nil { + return []string{} + } + return keys +} + +// withdrawalAuditSnapshot 生成提现审计快照,不含收款账号与发票内容。 +func withdrawalAuditSnapshot( + request *model.CommissionWithdrawalRequest, + attempt *model.CommissionWithdrawalRequestAttempt, + wallet *model.AgentWallet, +) map[string]any { + snapshot := map[string]any{ + "id": request.ID, "withdrawal_no": request.WithdrawalNo, "shop_id": request.ShopID, + "amount": attempt.Amount, "fee": attempt.Fee, "fee_rate": attempt.FeeRate, + "actual_amount": attempt.ActualAmount, "withdrawal_method": attempt.WithdrawalMethod, + "status": request.Status, "attempt_id": attempt.ID, "attempt_no": attempt.AttemptNo, + "latest_approval_instance_id": request.LatestApprovalInstanceID, + "anomaly_flag": request.AnomalyFlag, + "invoice_count": len(decodeInvoiceKeys(attempt.InvoiceKeys)), + } + if wallet != nil { + snapshot["wallet_id"] = wallet.ID + snapshot["wallet_frozen_balance"] = wallet.FrozenBalance + } + return snapshot +} + +// withdrawalSubmitSummary 区分首次提交与重提的审计摘要。 +func withdrawalSubmitSummary(resubmit bool) string { + if resubmit { + return "重提佣金提现申请,已释放旧未结算冻结" + } + return "提交佣金提现申请并冻结可提现余额" +} + +// ReleaseUnsettledAttemptsInTx 幂等释放指定店铺全部未结算的提现审批尝试冻结。 +// 释放金额取 try.amount 事实,释放完成写入 released_at;已释放的尝试不会被重复释放。 +// 供后续佣金回溯在扣减佣金余额前先释放冻结,返回本次实际释放金额合计。 +func (s *WithdrawalService) ReleaseUnsettledAttemptsInTx( + ctx context.Context, + tx *gorm.DB, + shopID uint, +) (int64, error) { + if s == nil || s.db == nil || tx == nil || shopID == 0 { + return 0, errors.New(errors.CodeInvalidParam, "提现冻结释放参数无效") + } + return releaseUnsettledAttempts(ctx, tx, shopID) +} + +// releaseUnsettledAttemptsForRequest 在重提事务内释放指定申请的全部未结算尝试冻结。 +func releaseUnsettledAttemptsForRequest( + ctx context.Context, + tx *gorm.DB, + requestID uint, +) (int64, error) { + return releaseUnsettledForRequests(ctx, tx, []uint{requestID}) +} + +// releaseUnsettledAttempts 释放指定店铺范围内未结算的提现审批尝试冻结。 +// 全局加锁顺序固定为「申请 → 尝试 → 钱包」:本函数先锁申请行,再交由释放原语锁尝试行。 +func releaseUnsettledAttempts( + ctx context.Context, + tx *gorm.DB, + shopID uint, +) (int64, error) { + var requestIDs []uint + if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). + Where("shop_id = ?", shopID).Order("id ASC").Pluck("id", &requestIDs).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺提现申请失败") + } + return releaseUnsettledForRequests(ctx, tx, requestIDs) +} + +// releaseUnsettledForRequests 通过共享释放原语释放尝试冻结并解冻钱包。 +// 释放金额一律取尝试记录事实;原语的 released_at 条件更新保证重复调用不重复释放。 +func releaseUnsettledForRequests( + ctx context.Context, + tx *gorm.DB, + requestIDs []uint, +) (int64, error) { + if len(requestIDs) == 0 { + return 0, nil + } + requests := make(map[uint]*model.CommissionWithdrawalRequest, len(requestIDs)) + for _, requestID := range requestIDs { + request, err := lockWithdrawalRequest(ctx, tx, requestID) + if err != nil { + return 0, err + } + requests[requestID] = request + } + now := time.Now().UTC() + amounts, err := postgres.ReleaseUnsettledForRequestsInTx(ctx, tx, requestIDs, now) + if err != nil { + return 0, err + } + total := int64(0) + for _, requestID := range requestIDs { + amount, exists := amounts[requestID] + if !exists || amount == 0 { + continue + } + request := requests[requestID] + wallet, err := lockCommissionWallet(ctx, tx, request.ShopID) + if err != nil { + return 0, err + } + ok, err := releaseCommissionBalance(ctx, tx, wallet.ID, amount) + if err != nil { + return 0, err + } + if !ok { + return 0, errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对") + } + total += amount + } + return total, nil +} + +// generateWithdrawalNo 生成提现单号,格式:W + 时间戳 + 随机数。 +func generateWithdrawalNo() string { + return "W" + time.Now().Format("20060102150405") + randomDigits(6) +} + +// randomDigits 生成指定位数的数字随机串,用于提现单号。 +func randomDigits(length int) string { + const digits = "0123456789" + buf := make([]byte, 0, length) + limit := big.NewInt(int64(len(digits))) + for range length { + value, err := rand.Int(rand.Reader, limit) + if err != nil { + return strings.Repeat("0", length) + } + buf = append(buf, digits[value.Int64()]) + } + return string(buf) +} + +// formatAmountYuan 将分金额格式化为元字符串,仅用于展示与审批表单。 +func formatAmountYuan(amount int64) string { + negative := amount < 0 + if negative { + amount = -amount + } + value := distributiondomain.FormatCentYuan(amount) + if negative { + return "-" + value + } + return value +} diff --git a/internal/application/distributionwithdrawal/withdrawal_approval.go b/internal/application/distributionwithdrawal/withdrawal_approval.go new file mode 100644 index 0000000..ce60ed4 --- /dev/null +++ b/internal/application/distributionwithdrawal/withdrawal_approval.go @@ -0,0 +1,365 @@ +package distributionwithdrawal + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// WithdrawalApprovalHandler 将渠道无关企业微信终态应用到提现申请。 +// 通过时仅一次从冻结余额扣减并保持 WithdrawalStatusApproved=2,同时写入到账时间; +// 驳回、撤销与删除仅一次释放本次尝试的冻结余额并记录释放时间; +// 通过后撤销不回滚、不重新冻结、不自动重提,只写入正交异常标记与原因。 +type WithdrawalApprovalHandler struct { + db *gorm.DB + audit AuditWriter +} + +// NewWithdrawalApprovalHandler 创建佣金提现审批终态消费者。 +func NewWithdrawalApprovalHandler(db *gorm.DB, audit AuditWriter) *WithdrawalApprovalHandler { + return &WithdrawalApprovalHandler{db: db, audit: audit} +} + +// Handle 幂等消费标准审批终态。 +// 业务标识为提现审批尝试记录主键;先锁定尝试记录并校验审批实例一致,再按条件更新推进状态。 +func (h *WithdrawalApprovalHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { + if h == nil || h.db == nil || h.audit == nil { + return errors.New(errors.CodeInternalError, "佣金提现审批终态能力未配置") + } + if event.BusinessType != constants.ApprovalBusinessTypeCommissionWithdrawal || + event.BusinessID == 0 || event.InstanceID == 0 { + return errors.New(errors.CodeInvalidParam, "佣金提现审批终态参数无效") + } + ctx = auditcontext.With(ctx, auditcontext.Context{ + CorrelationID: event.CorrelationID, ParentEventID: event.EventID, + }) + return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 全库统一加锁顺序:申请行 → 尝试行 → 钱包行(钱包永远最后)。 + // 因此先用不加锁读取得 request_id,再按序加锁,避免与退款回扣路径形成死锁环。 + var lookup model.CommissionWithdrawalRequestAttempt + if err := tx.WithContext(ctx).Select("id", "request_id"). + First(&lookup, event.BusinessID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "提现审批尝试记录不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试记录失败") + } + request, err := lockWithdrawalRequest(ctx, tx, lookup.RequestID) + if err != nil { + return err + } + var attempt model.CommissionWithdrawalRequestAttempt + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + First(&attempt, event.BusinessID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "提现审批尝试记录不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "锁定提现审批尝试记录失败") + } + if attempt.ApprovalInstanceID == nil || *attempt.ApprovalInstanceID != event.InstanceID { + return errors.New(errors.CodeConflict, "提现审批尝试记录关联的审批实例不一致") + } + if attempt.RequestID != request.ID { + return errors.New(errors.CodeConflict, "提现审批尝试记录归属已变化") + } + if request.LatestAttemptID != attempt.ID { + // 已被更新尝试取代的历史尝试终态不再改变申请事实。 + return nil + } + switch event.Decision { + case constants.ApprovalDecisionApproved: + return h.applyApproved(ctx, tx, request, &attempt, event) + case constants.ApprovalDecisionRejected, + constants.ApprovalDecisionCancelled, + constants.ApprovalDecisionDeleted: + return h.applyClosed(ctx, tx, request, &attempt, event) + case constants.ApprovalDecisionRevokedAfterApproved: + return h.applyRevoked(ctx, tx, request, &attempt, event) + default: + return errors.New(errors.CodeInvalidParam, "不支持的提现申请审批终态") + } + }) +} + +// applyApproved 仅一次从冻结余额扣减,保持已通过状态并写入到账时间。 +// 幂等守卫为「申请仍待审核 + paid_at 为空 + 尝试未释放」的条件更新且影响行数为 1。 +func (h *WithdrawalApprovalHandler) applyApproved( + ctx context.Context, + tx *gorm.DB, + request *model.CommissionWithdrawalRequest, + attempt *model.CommissionWithdrawalRequestAttempt, + event approvalapp.TerminalDecisionEvent, +) error { + wallet, err := lockCommissionWallet(ctx, tx, request.ShopID) + if err != nil { + return err + } + now := time.Now().UTC() + if attempt.ReleasedAt != nil { + // 已结算的尝试不再扣减,避免重复扣款。 + return nil + } + if wallet.FrozenBalance < attempt.Amount { + return errors.New(errors.CodeConflict, "冻结余额不足以完成提现扣减,请人工核对") + } + // 通过即视为已到账:先以 released_at IS NULL 条件更新标记本次冻结已结算,保证重复回调不重复扣减。 + settled, err := markAttemptReleased(ctx, tx, attempt, now) + if err != nil { + return err + } + if !settled { + return nil + } + // 通过时保持状态 2 并写入到账时间,禁止使用已到账状态值 4。 + result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). + Where("id = ? AND status = ? AND paid_at IS NULL", request.ID, constants.WithdrawalStatusPending). + Updates(map[string]any{ + "status": constants.WithdrawalStatusApproved, + "paid_at": now, + "processed_at": now, + "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现申请已通过失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "提现申请状态已变化") + } + if err := deductFrozenBalance(ctx, tx, wallet, attempt.Amount); err != nil { + return err + } + transaction, err := recordWithdrawalDeductTransaction(ctx, tx, wallet, request, attempt) + if err != nil { + return err + } + attempt.ReleasedAt = &now + before := map[string]any{"status": constants.WithdrawalStatusPending, "paid_at": nil, "frozen_balance": wallet.FrozenBalance + attempt.Amount} + request.Status = constants.WithdrawalStatusApproved + request.PaidAt = &now + request.ProcessedAt = &now + return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":approved", + ActionCode: constants.AuditActionCommissionWithdrawalAttemptApproved, + Summary: "企业微信通过佣金提现,已从冻结余额扣减并记录到账时间", + CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt, + Wallet: wallet, Transaction: transaction, + BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, wallet), + }) +} + +// applyClosed 处理最终驳回、撤销与删除:仅一次释放本次尝试冻结并记录释放时间。 +// 幂等守卫为「尝试已结算时间仍为空」的条件更新且影响行数为 1。 +func (h *WithdrawalApprovalHandler) applyClosed( + ctx context.Context, + tx *gorm.DB, + request *model.CommissionWithdrawalRequest, + attempt *model.CommissionWithdrawalRequestAttempt, + event approvalapp.TerminalDecisionEvent, +) error { + if attempt.ReleasedAt != nil { + return nil + } + wallet, err := lockCommissionWallet(ctx, tx, request.ShopID) + if err != nil { + return err + } + now := time.Now().UTC() + released, err := markAttemptReleased(ctx, tx, attempt, now) + if err != nil { + return err + } + if !released { + return nil + } + ok, err := releaseCommissionBalance(ctx, tx, wallet.ID, attempt.Amount) + if err != nil { + return err + } + if !ok { + return errors.New(errors.CodeConflict, "提现冻结余额与尝试记录不一致,请人工核对") + } + reason := rejectionReason(event.Decision) + result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). + Where("id = ? AND status = ?", request.ID, constants.WithdrawalStatusPending). + Updates(map[string]any{ + "status": constants.WithdrawalStatusRejected, + "processed_at": now, + "reject_reason": reason, + "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记提现申请已驳回失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "提现申请状态已变化") + } + frozenBefore := wallet.FrozenBalance + attempt.Amount + wallet.FrozenBalance = frozenBefore - attempt.Amount + transaction, err := recordWithdrawalReleaseTransaction(ctx, tx, wallet, request, attempt, frozenBefore) + if err != nil { + return err + } + attempt.ReleasedAt = &now + before := map[string]any{"status": constants.WithdrawalStatusPending, "frozen_balance": frozenBefore} + request.Status = constants.WithdrawalStatusRejected + request.ProcessedAt = &now + request.RejectReason = reason + return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":closed", + ActionCode: constants.AuditActionCommissionWithdrawalAttemptClosed, + Summary: "企业微信未通过佣金提现,已释放本次尝试冻结余额", + CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt, + Wallet: wallet, Transaction: transaction, + BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, wallet), + }) +} + +// applyRevoked 处理通过后撤销。 +// 已通过:不回滚已到账金额、不重新冻结、不自动重提,只写正交异常标记与原因。 +// 仍在待审核(渠道乱序投递):按驳回同等处理,释放本次尝试冻结并转驳回状态。 +func (h *WithdrawalApprovalHandler) applyRevoked( + ctx context.Context, + tx *gorm.DB, + request *model.CommissionWithdrawalRequest, + attempt *model.CommissionWithdrawalRequestAttempt, + event approvalapp.TerminalDecisionEvent, +) error { + if request.Status == constants.WithdrawalStatusPending { + return h.applyClosed(ctx, tx, request, attempt, event) + } + if request.Status != constants.WithdrawalStatusApproved { + // 已驳回等终态不再改变事实。 + return nil + } + if request.AnomalyFlag == constants.WithdrawalAnomalyFlagRevokedAfterApproved { + return nil + } + now := time.Now().UTC() + reason := "企业微信通过后撤销:" + rejectionReason(event.Decision) + result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). + Where("id = ? AND status = ? AND anomaly_flag = ?", + request.ID, constants.WithdrawalStatusApproved, constants.WithdrawalAnomalyFlagNone). + Updates(map[string]any{ + "anomaly_flag": constants.WithdrawalAnomalyFlagRevokedAfterApproved, + "anomaly_reason": reason, "updater": 0, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "写入提现异常标记失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "提现申请异常标记已变化") + } + before := map[string]any{ + "status": request.Status, "anomaly_flag": constants.WithdrawalAnomalyFlagNone, + "paid_at": request.PaidAt, "amount": attempt.Amount, + } + request.AnomalyFlag = constants.WithdrawalAnomalyFlagRevokedAfterApproved + request.AnomalyReason = reason + request.UpdatedAt = now + return h.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{ + EventID: "commission-withdrawal:" + uintText(request.ID) + ":attempt:" + intText(attempt.AttemptNo) + ":anomaly", + ActionCode: constants.AuditActionCommissionWithdrawalAnomalyFlagged, + Summary: "企业微信通过后撤销,已到账金额不回滚、不重新冻结,仅写入异常标记", + CorrelationID: event.CorrelationID, Withdrawal: request, Attempt: attempt, + BeforeData: before, AfterData: withdrawalAuditSnapshot(request, attempt, nil), + }) +} + +// markAttemptReleased 以未释放条件更新写入尝试释放时间,返回是否本次完成释放。 +func markAttemptReleased( + ctx context.Context, + tx *gorm.DB, + attempt *model.CommissionWithdrawalRequestAttempt, + now time.Time, +) (bool, error) { + result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}). + Where("id = ? AND released_at IS NULL", attempt.ID). + Update("released_at", now) + if result.Error != nil { + return false, errors.Wrap(errors.CodeDatabaseError, result.Error, "写入提现尝试释放时间失败") + } + return result.RowsAffected == 1, nil +} + +// deductFrozenBalance 以冻结余额充足条件更新同时扣减余额与冻结余额。 +func deductFrozenBalance(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, amount int64) error { + result := tx.WithContext(ctx).Model(&model.AgentWallet{}). + Where("id = ? AND wallet_type = ? AND frozen_balance >= ?", + wallet.ID, constants.AgentWalletTypeCommission, amount). + Updates(map[string]any{ + "balance": gorm.Expr("balance - ?", amount), + "frozen_balance": gorm.Expr("frozen_balance - ?", amount), + "updated_at": time.Now(), + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "从冻结余额扣减提现金额失败") + } + if result.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "冻结余额不足或已被并发处理") + } + wallet.Balance -= amount + wallet.FrozenBalance -= amount + return nil +} + +// recordWithdrawalDeductTransaction 写入通过时的钱包流水,余额与冻结余额同时减少。 +func recordWithdrawalDeductTransaction( + ctx context.Context, + tx *gorm.DB, + wallet *model.AgentWallet, + request *model.CommissionWithdrawalRequest, + attempt *model.CommissionWithdrawalRequestAttempt, +) (*model.AgentWalletTransaction, error) { + remark := "企业微信终审通过,提现到账,单号:" + request.WithdrawalNo + refType := constants.ReferenceTypeWithdrawal + refID := request.ID + transaction := &model.AgentWalletTransaction{ + AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: request.ApplicantID, + TransactionType: constants.AgentTransactionTypeWithdrawal, + Amount: -attempt.Amount, + BalanceBefore: wallet.Balance + attempt.Amount, BalanceAfter: wallet.Balance, + Status: constants.TransactionStatusSuccess, + ReferenceType: &refType, ReferenceID: &refID, Remark: &remark, + Creator: request.ApplicantID, ShopIDTag: request.ShopID, + } + if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现到账钱包流水失败") + } + return transaction, nil +} + +// recordWithdrawalReleaseTransaction 写入驳回时的钱包流水,仅冻结余额减少。 +func recordWithdrawalReleaseTransaction( + ctx context.Context, + tx *gorm.DB, + wallet *model.AgentWallet, + request *model.CommissionWithdrawalRequest, + attempt *model.CommissionWithdrawalRequestAttempt, + frozenBefore int64, +) (*model.AgentWalletTransaction, error) { + remark := "企业微信未通过,释放提现冻结,单号:" + request.WithdrawalNo + refType := constants.ReferenceTypeWithdrawal + refID := request.ID + transaction := &model.AgentWalletTransaction{ + AgentWalletID: wallet.ID, ShopID: request.ShopID, UserID: request.ApplicantID, + TransactionType: constants.AgentTransactionTypeRefund, + Amount: attempt.Amount, + BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance, + Status: constants.TransactionStatusSuccess, + ReferenceType: &refType, ReferenceID: &refID, Remark: &remark, + Creator: request.ApplicantID, ShopIDTag: request.ShopID, + } + if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建提现释放钱包流水失败") + } + _ = frozenBefore + return transaction, nil +} diff --git a/internal/application/shop/create.go b/internal/application/shop/create.go index 389d164..aaf522e 100644 --- a/internal/application/shop/create.go +++ b/internal/application/shop/create.go @@ -10,6 +10,7 @@ import ( "gorm.io/gorm" accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit" + distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -112,8 +113,9 @@ func createShop(ctx context.Context, tx *gorm.DB, request *dto.CreateShopRequest } shop.Creator = operatorID shop.Updater = operatorID - if err := tx.Create(shop).Error; err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建店铺失败") + // 分销码在创建时随机生成且唯一;冲突时重新生成并重试,不提供人工指定或编辑入口。 + if err := CreateShopWithDistributionCode(ctx, tx, shop); err != nil { + return nil, err } account := &model.Account{ @@ -201,6 +203,8 @@ func (s *CreateService) fail(ctx context.Context, request *dto.CreateShopRequest func shopCreationData(shop *model.Shop) map[string]any { data := shopProfileData(shop) data["shop_code"] = shop.ShopCode + // 分销码是本 Change 新增的建店事实,按脱敏值记录,口径与审批建店路径一致。 + data["distribution_code_masked"] = distributiondomain.MaskDistributionCode(shop.DistributionCode) data["parent_id"] = shop.ParentID data["level"] = shop.Level return data @@ -285,7 +289,8 @@ func recordExists(tx *gorm.DB, target any, query string, value any) (bool, error func newShopResponse(shop *model.Shop, parentName string) *dto.ShopResponse { return &dto.ShopResponse{ - ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, ParentID: shop.ParentID, + ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, + DistributionCode: shop.DistributionCode, ParentID: shop.ParentID, BusinessOwnerAccountID: shop.BusinessOwnerAccountID, ParentShopName: parentName, Level: shop.Level, ContactName: shop.ContactName, ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City, diff --git a/internal/application/shop/distribution_code.go b/internal/application/shop/distribution_code.go new file mode 100644 index 0000000..2085537 --- /dev/null +++ b/internal/application/shop/distribution_code.go @@ -0,0 +1,95 @@ +package shop + +import ( + "context" + stderrors "errors" + "reflect" + + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" + + distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// distributionCodeConstraint 是分销码条件唯一索引名,用于识别唯一冲突并重试。 +const distributionCodeConstraint = "uk_shop_distribution_code" + +// distributionCodeSavepoint 是分销码冲突重试使用的保存点名称。 +const distributionCodeSavepoint = "shop_distribution_code_retry" + +// CreateShopWithDistributionCode 在事务内为新店铺生成全局唯一随机分销码并创建店铺。 +// +// 每次尝试都重新生成随机码;Create 命中分销码唯一约束时重新生成并重试, +// 最多 constants.ShopDistributionCodeMaxAttempts 次。其他唯一冲突(店铺编号等)不重试, +// 直接返回数据库错误。 +// +// 重试依赖真实保存点:每条 Create 包在 GORM 的嵌套事务中执行,冲突时 GORM 自动 +// 回滚到内部保存点,外层事务因此仍可用(PostgreSQL 唯一冲突会中止整个事务, +// 不回滚到保存点则后续语句必然 25P02,重试不可能生效)。这里刻意不使用裸 +// SavePoint/RollbackTo:GORM 的嵌套事务会自行处理 PrepareStmt 下的连接池切换。 +func CreateShopWithDistributionCode(ctx context.Context, tx *gorm.DB, shop *model.Shop) error { + if tx == nil || shop == nil { + return errors.New(errors.CodeInvalidParam, "创建店铺参数无效") + } + // 失败关闭:必须在调用方的事务句柄内执行,否则嵌套事务会自行开启并提交一个新事务, + // 破坏调用方的原子性(建店事务与注册审批通过事务均满足该前提)。 + if !inTransaction(tx) { + return errors.New(errors.CodeInvalidStatus, "创建店铺必须传入事务句柄") + } + for range constants.ShopDistributionCodeMaxAttempts { + code, err := distributiondomain.GenerateDistributionCode() + if err != nil { + return err + } + if occupied, err := distributionCodeOccupied(ctx, tx, code); err != nil { + return err + } else if occupied { + // 预检命中直接换码,避免把可预期的冲突交给数据库。 + continue + } + shop.DistributionCode = code + shop.ID = 0 + createErr := tx.WithContext(ctx).Transaction(func(inner *gorm.DB) error { + return inner.Create(shop).Error + }) + if createErr == nil { + return nil + } + if !isDistributionCodeConflict(createErr) { + return errors.Wrap(errors.CodeDatabaseError, createErr, "创建店铺失败") + } + // 分销码冲突:GORM 已回滚到内部保存点,外层事务仍可继续,换码重试。 + } + return errors.New(errors.CodeConflict, "生成分销码冲突,请重试") +} + +// inTransaction 判断句柄是否为已开启的事务,与 GORM 自身识别嵌套事务的方式一致。 +func inTransaction(tx *gorm.DB) bool { + if tx == nil || tx.Statement == nil { + return false + } + committer, ok := tx.Statement.ConnPool.(gorm.TxCommitter) + return ok && committer != nil && !reflect.ValueOf(committer).IsNil() +} + +// distributionCodeOccupied 预检分销码是否已被未删除店铺占用。 +func distributionCodeOccupied(ctx context.Context, tx *gorm.DB, code string) (bool, error) { + var count int64 + if err := tx.WithContext(ctx).Model(&model.Shop{}). + Where("distribution_code = ?", code).Count(&count).Error; err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "校验分销码唯一性失败") + } + return count > 0, nil +} + +// isDistributionCodeConflict 判断错误是否为分销码条件唯一索引冲突。 +func isDistributionCodeConflict(err error) bool { + var pgErr *pgconn.PgError + if !stderrors.As(err, &pgErr) { + return false + } + return pgErr.Code == "23505" && pgErr.ConstraintName == distributionCodeConstraint +} diff --git a/internal/application/shop/update.go b/internal/application/shop/update.go index 9f45ce9..05f1145 100644 --- a/internal/application/shop/update.go +++ b/internal/application/shop/update.go @@ -16,8 +16,21 @@ import ( // UpdateService 收口店铺资料与业务员归属的简单写事务脚本。 type UpdateService struct { - db *gorm.DB - audit accessauditapp.Writer + db *gorm.DB + audit accessauditapp.Writer + qualificationInvalidator WithdrawalQualificationInvalidator +} + +// WithdrawalQualificationInvalidator 在店铺停用事务内联动失效提现资料资格。 +// 接口定义在应用层,具体实现由装配注入,避免应用层依赖下游用例包。 +type WithdrawalQualificationInvalidator interface { + InvalidateByShopDisable(ctx context.Context, tx *gorm.DB, shopID uint, reason string) error +} + +// SetWithdrawalQualificationInvalidator 注入店铺停用联动的提现资料资格失效接缝。 +// 未注入时停用不联动,用于不依赖该能力的旧装配路径。 +func (s *UpdateService) SetWithdrawalQualificationInvalidator(invalidator WithdrawalQualificationInvalidator) { + s.qualificationInvalidator = invalidator } // NewUpdateService 创建店铺更新事务脚本。 @@ -81,6 +94,8 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up shop.Address = request.Address shop.Status = request.Status shop.Updater = operatorID + // 分销码创建后不可修改:Save 写全列,这里显式保留加锁读取到的原值。 + shop.DistributionCode = before.DistributionCode if err := tx.Save(&shop).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新店铺失败") } @@ -110,6 +125,14 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up if err := s.writeStateAudits(ctx, tx, &before, &shop, parentShop, operatorID); err != nil { return err } + // 店铺停用必须使该店铺全部有效提现资料资格失效,且与停用同事务提交。 + if before.Status != constants.ShopStatusDisabled && shop.Status == constants.ShopStatusDisabled && + s.qualificationInvalidator != nil { + if err := s.qualificationInvalidator.InvalidateByShopDisable( + ctx, tx, shop.ID, "代理店铺已停用,提现资料资格自动失效"); err != nil { + return err + } + } return nil }) if err != nil { diff --git a/internal/application/wecom/scene.go b/internal/application/wecom/scene.go index 0fd7128..6def466 100644 --- a/internal/application/wecom/scene.go +++ b/internal/application/wecom/scene.go @@ -353,6 +353,54 @@ func sceneBusinessFields(businessType string) ([]dto.WeComBusinessFieldResponse, {Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"}, {Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"}, }, true + case constants.ApprovalBusinessTypeAgentDistribution: + return []dto.WeComBusinessFieldResponse{ + {Code: constants.ApprovalFieldDistributionCode, Name: "分销码", ValueType: constants.ApprovalFieldValueTypeString, Description: "注册使用的上级店铺分销码脱敏值,仅用于审批人核对来源,不代表新建店铺的码"}, + {Code: constants.ApprovalFieldDistributionParentShopID, Name: "上级店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "分销码所属上级店铺的系统 ID"}, + {Code: constants.ApprovalFieldDistributionParentShopName, Name: "上级店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "分销码所属上级店铺名称快照"}, + {Code: constants.ApprovalFieldDistributionShopName, Name: "申请店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺名称快照"}, + {Code: constants.ApprovalFieldDistributionShopCode, Name: "申请店铺编号", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的店铺编号快照,通过时按既有唯一约束校验"}, + {Code: constants.ApprovalFieldDistributionUsername, Name: "代理账号用户名", ValueType: constants.ApprovalFieldValueTypeString, Description: "扫码注册申请的代理主账号用户名快照"}, + {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.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"}, + {Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"}, + }, true + case constants.ApprovalBusinessTypeWithdrawalQualification: + return []dto.WeComBusinessFieldResponse{ + {Code: constants.ApprovalFieldQualificationShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "提现资料资格所属代理店铺的系统 ID"}, + {Code: constants.ApprovalFieldQualificationShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "提现资料资格所属代理店铺名称快照"}, + {Code: constants.ApprovalFieldQualificationSubjectType, Name: "签约主体类型", ValueType: constants.ApprovalFieldValueTypeString, Description: "签约主体类型中文名:企业或个人"}, + {Code: constants.ApprovalFieldQualificationSubjectCodeMasked, Name: "签约主体代码", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的统一社会信用代码或身份证号,禁止写入完整证件号"}, + {Code: constants.ApprovalFieldQualificationLegalPersonMasked, Name: "法人身份证号", ValueType: constants.ApprovalFieldValueTypeString, Description: "脱敏后的法人身份证号,禁止写入完整证件号"}, + {Code: constants.ApprovalFieldQualificationContractKey, Name: "合同附件", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "合同对象存储 Key 列表(单个对象)"}, + {Code: constants.ApprovalFieldQualificationIDCardFrontKey, Name: "法人身份证正面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证正面对象存储 Key 列表(单个对象)"}, + {Code: constants.ApprovalFieldQualificationIDCardBackKey, Name: "法人身份证反面", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "法人身份证反面对象存储 Key 列表(单个对象)"}, + {Code: constants.ApprovalFieldQualificationBusinessLicenseKey, Name: "营业执照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "营业执照对象存储 Key 列表(单个对象,可选)"}, + {Code: constants.ApprovalFieldQualificationShopFrontKey, Name: "门头照", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "门头照对象存储 Key 列表(单个对象,可选)"}, + {Code: constants.ApprovalFieldQualificationInvoiceKey, Name: "发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "发票对象存储 Key 列表(单个对象,仅企业可选)"}, + {Code: constants.ApprovalFieldQualificationInvoiceTitle, Name: "发票抬头", ValueType: constants.ApprovalFieldValueTypeString, Description: "发票抬头,仅企业填写且必须与合同主体一致"}, + {Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"}, + {Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"}, + }, true + case constants.ApprovalBusinessTypeCommissionWithdrawal: + return []dto.WeComBusinessFieldResponse{ + {Code: constants.ApprovalFieldWithdrawalNo, Name: "提现单号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次提现申请单号"}, + {Code: constants.ApprovalFieldWithdrawalAttemptNo, Name: "提交次序", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本次为第几次提交,重提时递增"}, + {Code: constants.ApprovalFieldWithdrawalShopID, Name: "店铺 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "发起提现的代理店铺系统 ID"}, + {Code: constants.ApprovalFieldWithdrawalShopName, Name: "店铺名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "发起提现的代理店铺名称快照"}, + {Code: constants.ApprovalFieldWithdrawalAmount, Name: "提现金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的提现金额"}, + {Code: constants.ApprovalFieldWithdrawalAmountCent, Name: "提现金额(分)", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "以分为单位的提现金额整数"}, + {Code: constants.ApprovalFieldWithdrawalFee, Name: "手续费", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的本次手续费"}, + {Code: constants.ApprovalFieldWithdrawalActualAmount, Name: "实际到账金额", ValueType: constants.ApprovalFieldValueTypeMoney, Description: "以元为单位且保留两位小数的实际到账金额"}, + {Code: constants.ApprovalFieldWithdrawalMethod, Name: "收款方式", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款方式名称快照"}, + {Code: constants.ApprovalFieldWithdrawalAccountName, Name: "收款人姓名", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款人姓名"}, + {Code: constants.ApprovalFieldWithdrawalAccountNumber, Name: "收款账号", ValueType: constants.ApprovalFieldValueTypeString, Description: "本次收款账号,供审批人核验打款"}, + {Code: constants.ApprovalFieldWithdrawalInvoiceKey, Name: "申请级发票", ValueType: constants.ApprovalFieldValueTypeFileList, Description: "本次申请级发票对象存储 Key 列表,无发票时为空数组"}, + {Code: constants.ApprovalFieldSubmitterID, Name: "提交人账号 ID", ValueType: constants.ApprovalFieldValueTypeInteger, Description: "本系统真实业务提交人账号 ID"}, + {Code: constants.ApprovalFieldSubmitterName, Name: "提交人名称", ValueType: constants.ApprovalFieldValueTypeString, Description: "本系统真实业务提交人名称快照"}, + }, true default: return nil, false } @@ -376,7 +424,10 @@ func validApprovalBusinessType(businessType string) bool { switch businessType { case constants.ApprovalBusinessTypeRefund, constants.ApprovalBusinessTypeOfflineRecharge, - constants.ApprovalBusinessTypeEmployeeCollection: + constants.ApprovalBusinessTypeEmployeeCollection, + constants.ApprovalBusinessTypeAgentDistribution, + constants.ApprovalBusinessTypeWithdrawalQualification, + constants.ApprovalBusinessTypeCommissionWithdrawal: return true default: return false @@ -391,6 +442,12 @@ func approvalBusinessTypeName(businessType string) string { return "员工线下代充值审批" case constants.ApprovalBusinessTypeEmployeeCollection: return "员工代收款核销审批" + case constants.ApprovalBusinessTypeAgentDistribution: + return "代理扫码分销注册审批" + case constants.ApprovalBusinessTypeWithdrawalQualification: + return "提现资料资格审批" + case constants.ApprovalBusinessTypeCommissionWithdrawal: + return "佣金提现终审" default: return "未知审批业务类型" } diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 0908e64..f057c8d 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -24,6 +24,7 @@ import ( agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit" + distributionwithdrawalQuery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal" employeecollectionQuery "github.com/break/junhong_cmp_fiber/internal/query/employeecollection" exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange" integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration" @@ -203,7 +204,10 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { Shop: func() *admin.ShopHandler { handler := admin.NewShopHandler(svc.Shop, validate) handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit)) - handler.SetUpdateService(shopApp.NewUpdateService(deps.DB, svc.AccessAudit)) + updateService := shopApp.NewUpdateService(deps.DB, svc.AccessAudit) + // 店铺停用必须联动失效提现资料资格,接入点在同一停用事务内。 + updateService.SetWithdrawalQualificationInvalidator(svc.WithdrawalQualification) + handler.SetUpdateService(updateService) handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB)) handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB, svc.AccessAudit)) return handler @@ -211,10 +215,15 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { ShopRole: admin.NewShopRoleHandler(svc.Shop), AdminAuth: admin.NewAuthHandler(svc.Auth, validate), ShopCommission: func() *admin.ShopCommissionHandler { - handler := admin.NewShopCommissionHandler(svc.ShopCommission) + handler := admin.NewShopCommissionHandler(svc.ShopCommission, validate) handler.SetFundSummaryQuery(shopQuery.NewFundSummaryQuery(deps.DB)) + handler.SetWithdrawalQuery(distributionwithdrawalQuery.NewQuery(deps.DB)) return handler }(), + WithdrawalQualification: admin.NewWithdrawalQualificationHandler( + svc.WithdrawalQualification, distributionwithdrawalQuery.NewQuery(deps.DB), validate, + ), + AgentDistribution: app.NewAgentDistributionHandler(svc.DistributionRegistration, validate), CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal, validate), CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting), Enterprise: admin.NewEnterpriseHandler(svc.Enterprise), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 0937b09..c4ef9d4 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -8,6 +8,7 @@ import ( agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + distributionwithdrawalApp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal" employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection" exchangeApp "github.com/break/junhong_cmp_fiber/internal/application/exchange" merchantpayment "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment" @@ -89,6 +90,9 @@ type services struct { Shop *shopSvc.Service Auth *authSvc.Service ShopCommission *shopCommissionSvc.Service + DistributionRegistration *distributionwithdrawalApp.RegistrationService + WithdrawalQualification *distributionwithdrawalApp.QualificationService + WithdrawalApproval *distributionwithdrawalApp.WithdrawalService CommissionWithdrawal *commissionWithdrawalSvc.Service CommissionWithdrawalSetting *commissionWithdrawalSettingSvc.Service CommissionCalculation *commissionCalculationSvc.Service @@ -377,6 +381,18 @@ func initServices(s *stores, deps *Dependencies) *services { shopService.SetAccessAudit(deps.DB, deps.Redis, auditWriter) commissionWithdrawal := commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest) commissionWithdrawal.SetAuditWriter(auditWriter) + // 代理分销注册、提现资格与提现终审共用同一审计 Writer 与通用审批创建接缝。 + distributionRegistration := distributionwithdrawalApp.NewRegistrationService( + deps.DB, deps.VerificationService, approvalCreationService, auditWriter, + ) + withdrawalQualification := distributionwithdrawalApp.NewQualificationService( + deps.DB, approvalCreationService, auditWriter, + ) + withdrawalApproval := distributionwithdrawalApp.NewWithdrawalService( + deps.DB, approvalCreationService, auditWriter, + ) + shopCommission.SetWithdrawalApprovalService(withdrawalApproval) + shopService.SetWithdrawalQualificationInvalidator(withdrawalQualification) commissionCalculation := commissionCalculationSvc.New( deps.DB, s.CommissionRecord, @@ -431,6 +447,9 @@ func initServices(s *stores, deps *Dependencies) *services { Shop: shopService, Auth: authService, ShopCommission: shopCommission, + DistributionRegistration: distributionRegistration, + WithdrawalQualification: withdrawalQualification, + WithdrawalApproval: withdrawalApproval, CommissionWithdrawal: commissionWithdrawal, CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting), CommissionCalculation: commissionCalculation, diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 9f686fe..2b61a9d 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -29,6 +29,8 @@ type Handlers struct { ShopRole *admin.ShopRoleHandler AdminAuth *admin.AuthHandler ShopCommission *admin.ShopCommissionHandler + WithdrawalQualification *admin.WithdrawalQualificationHandler + AgentDistribution *app.AgentDistributionHandler CommissionWithdrawal *admin.CommissionWithdrawalHandler CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler Enterprise *admin.EnterpriseHandler diff --git a/internal/domain/distribution/distribution.go b/internal/domain/distribution/distribution.go new file mode 100644 index 0000000..1db770e --- /dev/null +++ b/internal/domain/distribution/distribution.go @@ -0,0 +1,188 @@ +// Package distribution 收口代理分销注册、提现资格与提现审批的领域不变量。 +// 本包不依赖 Fiber、GORM、Redis、Asynq 或具体第三方 SDK。 +package distribution + +import ( + "crypto/rand" + "encoding/hex" + "strconv" + "strings" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +const ( + distributionCodeBytes = 16 + phoneMaskedKeepPrefix = 3 + phoneMaskedKeepSuffix = 4 + codeMaskedKeepPrefix = 4 + codeMaskedKeepSuffix = 4 +) + +// GenerateDistributionCode 生成 32 位十六进制随机分销码。 +// 唯一性由数据库条件唯一索引兜底,调用方在冲突时重新生成。 +func GenerateDistributionCode() (string, error) { + buf := make([]byte, distributionCodeBytes) + if _, err := rand.Read(buf); err != nil { + return "", errors.Wrap(errors.CodeInternalError, err, "生成分销码失败") + } + return hex.EncodeToString(buf), nil +} + +// ValidateRegistrationInput 规范化并校验扫码注册输入。 +// 手机号、用户名、店铺编号与店铺名称由公开接口必填;密码长度沿用账号体系既有下限。 +func ValidateRegistrationInput(input RegistrationInput) (RegistrationInput, error) { + input.DistributionCode = strings.TrimSpace(input.DistributionCode) + input.Phone = strings.TrimSpace(input.Phone) + input.Username = strings.TrimSpace(input.Username) + input.ShopName = strings.TrimSpace(input.ShopName) + input.ShopCode = strings.TrimSpace(input.ShopCode) + input.ContactName = strings.TrimSpace(input.ContactName) + input.Province = strings.TrimSpace(input.Province) + input.City = strings.TrimSpace(input.City) + input.District = strings.TrimSpace(input.District) + input.Address = strings.TrimSpace(input.Address) + if input.DistributionCode == "" || input.Phone == "" || input.Username == "" || + input.ShopName == "" || input.ShopCode == "" { + return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "分销码不可用") + } + if len(input.Phone) != 11 { + return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "手机号格式不正确") + } + if len(input.Username) < 3 || len(input.Username) > 50 { + return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "用户名长度必须为 3 至 50 个字符") + } + if len(input.Password) < 6 || len(input.Password) > 64 { + return RegistrationInput{}, errors.New(errors.CodeInvalidParam, "密码长度必须为 6 至 64 个字符") + } + return input, nil +} + +// ValidateQualificationInput 规范化并校验提现资料资格输入。 +// 企业主体必须填写统一社会信用代码,个人主体必须填写法人身份证号; +// 发票仅企业可选,且抬头与统一社会信用代码必须与签约主体一致。 +func ValidateQualificationInput(input QualificationInput) (QualificationInput, error) { + input.SubjectCode = strings.TrimSpace(input.SubjectCode) + input.LegalPersonIDCard = strings.TrimSpace(input.LegalPersonIDCard) + input.ContractFileKey = strings.TrimSpace(input.ContractFileKey) + input.IDCardFrontFileKey = strings.TrimSpace(input.IDCardFrontFileKey) + input.IDCardBackFileKey = strings.TrimSpace(input.IDCardBackFileKey) + input.BusinessLicenseFileKey = strings.TrimSpace(input.BusinessLicenseFileKey) + input.ShopFrontFileKey = strings.TrimSpace(input.ShopFrontFileKey) + input.InvoiceFileKey = strings.TrimSpace(input.InvoiceFileKey) + input.InvoiceTitle = strings.TrimSpace(input.InvoiceTitle) + input.InvoiceSubjectCode = strings.TrimSpace(input.InvoiceSubjectCode) + + switch input.SubjectType { + case constants.WithdrawalQualificationSubjectTypeEnterprise: + if input.SubjectCode == "" || input.LegalPersonIDCard == "" { + return QualificationInput{}, errors.New(errors.CodeInvalidParam, "企业主体必须填写统一社会信用代码与法人身份证号") + } + case constants.WithdrawalQualificationSubjectTypePersonal: + if input.LegalPersonIDCard == "" { + return QualificationInput{}, errors.New(errors.CodeInvalidParam, "个人主体必须填写法人身份证号") + } + if input.SubjectCode == "" { + // 个人主体的签约主体代码即法人身份证号。 + input.SubjectCode = input.LegalPersonIDCard + } + default: + return QualificationInput{}, errors.New(errors.CodeInvalidParam, "签约主体类型无效") + } + if input.SubjectCode != input.LegalPersonIDCard && input.SubjectType == constants.WithdrawalQualificationSubjectTypePersonal { + return QualificationInput{}, errors.New(errors.CodeInvalidParam, "个人主体的签约主体代码必须与法人身份证号一致") + } + if input.ContractFileKey == "" || input.IDCardFrontFileKey == "" || input.IDCardBackFileKey == "" { + return QualificationInput{}, errors.New(errors.CodeInvalidParam, "合同与法人身份证正反面附件必须填写") + } + if input.SubjectType == constants.WithdrawalQualificationSubjectTypePersonal && + (input.InvoiceFileKey != "" || input.InvoiceTitle != "" || input.InvoiceSubjectCode != "") { + return QualificationInput{}, errors.New(errors.CodeInvalidParam, "发票资料仅企业主体可提交") + } + if input.InvoiceFileKey != "" { + if input.InvoiceTitle == "" || input.InvoiceSubjectCode == "" { + return QualificationInput{}, errors.New(errors.CodeInvalidParam, "提交发票时必须填写抬头与统一社会信用代码") + } + if input.InvoiceSubjectCode != input.SubjectCode { + return QualificationInput{}, errors.New(errors.CodeInvalidParam, "发票统一社会信用代码必须与合同主体一致") + } + } + // 附件上限由结构保证:资格只有合同、法人身份证正反面、营业执照、门头照、发票共 6 个 + // 单对象键字段,天然不超过企业微信单张审批单 6 个附件上限,无需运行时计数校验。 + return input, nil +} + +// MaskPhone 生成脱敏手机号,仅保留前 3 位与后 4 位。 +// 日志与审计不得记录完整手机号。 +func MaskPhone(phone string) string { + phone = strings.TrimSpace(phone) + if len(phone) < phoneMaskedKeepPrefix+phoneMaskedKeepSuffix { + return "" + } + return phone[:phoneMaskedKeepPrefix] + "****" + phone[len(phone)-phoneMaskedKeepSuffix:] +} + +// MaskSubjectCode 生成脱敏证件号或统一社会信用代码,仅保留前 4 位与后 4 位。 +// 日志与审计不得记录完整证件号。 +func MaskSubjectCode(code string) string { + code = strings.TrimSpace(code) + if len(code) < codeMaskedKeepPrefix+codeMaskedKeepSuffix { + return "" + } + return code[:codeMaskedKeepPrefix] + "**********" + code[len(code)-codeMaskedKeepSuffix:] +} + +// MaskDistributionCode 生成脱敏分销码,仅保留首尾片段。 +// 分销码是可枚举的公开入口标识,日志与审计只记录脱敏值。 +func MaskDistributionCode(code string) string { + code = strings.TrimSpace(code) + if len(code) < codeMaskedKeepPrefix+codeMaskedKeepSuffix { + return "" + } + return code[:codeMaskedKeepPrefix] + "****" + code[len(code)-codeMaskedKeepSuffix:] +} + +// FormatCentYuan 将分金额格式化为两位小数的元字符串,仅用于审批表单与展示。 +func FormatCentYuan(amount int64) string { + return strconv.FormatInt(amount/100, 10) + "." + + pad2(strconv.FormatInt(amount%100, 10)) +} + +// pad2 将 0 至 99 的十进制文本左补零到两位。 +func pad2(value string) string { + if len(value) >= 2 { + return value + } + return "0" + value +} + +// RegistrationInput 是公开扫码注册的规范化输入。 +type RegistrationInput struct { + DistributionCode string + Phone string + Username string + Password string + ShopName string + ShopCode string + ContactName string + Province string + City string + District string + Address string +} + +// QualificationInput 是提现资料资格的规范化输入。 +type QualificationInput struct { + SubjectType string + SubjectCode string + LegalPersonIDCard string + ContractFileKey string + IDCardFrontFileKey string + IDCardBackFileKey string + BusinessLicenseFileKey string + ShopFrontFileKey string + InvoiceFileKey string + InvoiceTitle string + InvoiceSubjectCode string +} diff --git a/internal/handler/admin/shop_commission.go b/internal/handler/admin/shop_commission.go index 8618d05..4147206 100644 --- a/internal/handler/admin/shop_commission.go +++ b/internal/handler/admin/shop_commission.go @@ -3,12 +3,15 @@ package admin import ( "strconv" + "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" "github.com/break/junhong_cmp_fiber/internal/model/dto" + distributionquery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal" shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop" shopCommissionService "github.com/break/junhong_cmp_fiber/internal/service/shop_commission" "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/response" ) @@ -16,11 +19,21 @@ import ( type ShopCommissionHandler struct { service *shopCommissionService.Service fundSummaryQuery *shopQuery.FundSummaryQuery + withdrawalQuery *distributionquery.Query + validator *validator.Validate } // NewShopCommissionHandler 创建代理商资金管理 Handler -func NewShopCommissionHandler(service *shopCommissionService.Service) *ShopCommissionHandler { - return &ShopCommissionHandler{service: service} +func NewShopCommissionHandler( + service *shopCommissionService.Service, + validate *validator.Validate, +) *ShopCommissionHandler { + return &ShopCommissionHandler{service: service, validator: validate} +} + +// SetWithdrawalQuery 注入提现申请详情查询。 +func (h *ShopCommissionHandler) SetWithdrawalQuery(query *distributionquery.Query) { + h.withdrawalQuery = query } // SetFundSummaryQuery 注入代理商资金概况 Query。 @@ -175,6 +188,62 @@ func (h *ShopCommissionHandler) CreateWithdrawal(c *fiber.Ctx) error { return response.Success(c, result) } +// ResubmitWithdrawal 重提被驳回的提现申请 +// PUT /api/admin/shops/:shop_id/withdrawal-requests/:id +func (h *ShopCommissionHandler) ResubmitWithdrawal(c *fiber.Ctx) error { + shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64) + if err != nil || shopID == 0 { + return errors.New(errors.CodeInvalidParam, "无效的店铺 ID") + } + requestID, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || requestID == 0 { + return errors.New(errors.CodeInvalidParam, "无效的提现申请 ID") + } + var req dto.ResubmitWithdrawalReq + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if h.validator == nil { + return errors.New(errors.CodeInternalError, "提现重提校验器未配置") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "提现重提参数不合法") + } + result, err := h.service.ResubmitWithdrawalRequest(c.UserContext(), uint(shopID), uint(requestID), &req) + if err != nil { + return err + } + return response.Success(c, result) +} + +// WithdrawalDetail 提现申请详情 +// GET /api/admin/shops/:shop_id/withdrawal-requests/:id +// 仅返回当前账号数据范围内的申请;超出范围与不存在返回同一结果。 +func (h *ShopCommissionHandler) WithdrawalDetail(c *fiber.Ctx) error { + shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64) + if err != nil || shopID == 0 { + return errors.New(errors.CodeInvalidParam, "无效的店铺 ID") + } + requestID, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || requestID == 0 { + return errors.New(errors.CodeInvalidParam, "无效的提现申请 ID") + } + if h.withdrawalQuery == nil { + return errors.New(errors.CodeInternalError, "提现详情查询能力未配置") + } + if err := middleware.CanManageShop(c.UserContext(), uint(shopID)); err != nil { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + detail, err := h.withdrawalQuery.WithdrawalDetail(c.UserContext(), uint(requestID)) + if err != nil { + return err + } + if detail.ShopID != uint(shopID) { + return errors.New(errors.CodeNotFound, "提现申请不存在") + } + return response.Success(c, detail) +} + // ListMainWalletTransactions 预充值钱包流水列表 // GET /api/admin/shops/:shop_id/main-wallet/transactions func (h *ShopCommissionHandler) ListMainWalletTransactions(c *fiber.Ctx) error { diff --git a/internal/handler/admin/withdrawal_qualification.go b/internal/handler/admin/withdrawal_qualification.go new file mode 100644 index 0000000..8e89392 --- /dev/null +++ b/internal/handler/admin/withdrawal_qualification.go @@ -0,0 +1,129 @@ +package admin + +import ( + "strconv" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal" + distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + distributionquery "github.com/break/junhong_cmp_fiber/internal/query/distributionwithdrawal" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// WithdrawalQualificationHandler 提现资料资格后台 Handler。 +type WithdrawalQualificationHandler struct { + service *distributionapp.QualificationService + query *distributionquery.Query + validator *validator.Validate +} + +// NewWithdrawalQualificationHandler 创建提现资料资格后台 Handler。 +func NewWithdrawalQualificationHandler( + service *distributionapp.QualificationService, + query *distributionquery.Query, + validate *validator.Validate, +) *WithdrawalQualificationHandler { + return &WithdrawalQualificationHandler{service: service, query: query, validator: validate} +} + +// SubmitWithdrawalQualification 提交或替换提现资料资格 +// POST /api/admin/shops/:shop_id/withdrawal-qualifications +// 仅本人代理店铺;替换合同或法人身份证时同一事务新增版本并使旧有效版本失效。 +func (h *WithdrawalQualificationHandler) SubmitWithdrawalQualification(c *fiber.Ctx) error { + if h.service == nil { + return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置") + } + var req dto.SubmitWithdrawalQualificationReq + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if h.validator == nil { + return errors.New(errors.CodeInternalError, "提现资料资格校验器未配置") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "提现资料资格参数不合法") + } + shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64) + if err != nil || shopID == 0 { + return errors.New(errors.CodeInvalidParam, "无效的店铺 ID") + } + result, err := h.service.Submit(c.UserContext(), uint(shopID), distributiondomain.QualificationInput{ + SubjectType: req.SubjectType, + SubjectCode: req.SubjectCode, + LegalPersonIDCard: req.LegalPersonIDCard, + ContractFileKey: req.ContractFileKey, + IDCardFrontFileKey: req.IDCardFrontFileKey, + IDCardBackFileKey: req.IDCardBackFileKey, + BusinessLicenseFileKey: req.BusinessLicenseFileKey, + ShopFrontFileKey: req.ShopFrontFileKey, + InvoiceFileKey: req.InvoiceFileKey, + InvoiceTitle: req.InvoiceTitle, + InvoiceSubjectCode: req.InvoiceSubjectCode, + }) + if err != nil { + return err + } + return response.Success(c, &dto.SubmitWithdrawalQualificationResp{ + ID: result.QualificationID, + Status: result.Status, + StatusName: constants.GetWithdrawalQualificationStatusName(result.Status), + }) +} + +// VoidWithdrawalQualification 超级管理员作废有效提现资料资格 +// POST /api/admin/withdrawal-qualifications/:id/void +func (h *WithdrawalQualificationHandler) VoidWithdrawalQualification(c *fiber.Ctx) error { + if h.service == nil { + return errors.New(errors.CodeServiceUnavailable, "提现资料资格能力尚未配置") + } + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的资格 ID") + } + var req dto.VoidWithdrawalQualificationReq + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if h.validator == nil { + return errors.New(errors.CodeInternalError, "提现资料资格校验器未配置") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "作废提现资料资格必须填写原因") + } + if err := h.service.Void(c.UserContext(), uint(id), req.Reason); err != nil { + return err + } + return response.Success(c, nil) +} + +// ListWithdrawalQualifications 查询提现资料资格版本 +// GET /api/admin/shops/:shop_id/withdrawal-qualifications +// 仅返回当前账号数据范围内的资料版本;证件号脱敏,附件只返回对象存储 Key。 +func (h *WithdrawalQualificationHandler) ListWithdrawalQualifications(c *fiber.Ctx) error { + if h.query == nil { + return errors.New(errors.CodeInternalError, "提现资料资格查询能力未配置") + } + var req dto.WithdrawalQualificationListReq + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + // 路由路径必带 shop_id;数据范围由 CanManageShop 在业务边界强制。 + shopID, err := strconv.ParseUint(c.Params("shop_id"), 10, 64) + if err != nil || shopID == 0 { + return errors.New(errors.CodeInvalidParam, "无效的店铺 ID") + } + if err := middleware.CanManageShop(c.UserContext(), uint(shopID)); err != nil { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + result, err := h.query.ListQualifications(c.UserContext(), []uint{uint(shopID)}, &req) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size) +} diff --git a/internal/handler/app/agent_distribution.go b/internal/handler/app/agent_distribution.go new file mode 100644 index 0000000..579e591 --- /dev/null +++ b/internal/handler/app/agent_distribution.go @@ -0,0 +1,68 @@ +package app + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal" + distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// AgentDistributionHandler 代理分销扫码注册公开 Handler。 +type AgentDistributionHandler struct { + service *distributionapp.RegistrationService + validator *validator.Validate +} + +// NewAgentDistributionHandler 创建代理分销扫码注册公开 Handler。 +func NewAgentDistributionHandler( + service *distributionapp.RegistrationService, + validate *validator.Validate, +) *AgentDistributionHandler { + return &AgentDistributionHandler{service: service, validator: validate} +} + +// RegisterAgentDistribution 提交代理扫码注册 +// POST /api/c/v1/agent-distribution-registrations +// 无需认证、JWT、角色或权限;只创建待审批注册记录,不返回任何账号凭证。 +// 无效分销码、停用上级、验证码无效或已消费统一返回“分销码不可用”且不落库。 +func (h *AgentDistributionHandler) RegisterAgentDistribution(c *fiber.Ctx) error { + if h.service == nil { + return errors.New(errors.CodeServiceUnavailable, "代理分销注册能力尚未配置") + } + var req dto.CreateAgentDistributionRegistrationReq + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if h.validator == nil { + return errors.New(errors.CodeInternalError, "代理分销注册校验器未配置") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "注册参数不合法") + } + result, err := h.service.Register(c.UserContext(), distributiondomain.RegistrationInput{ + DistributionCode: req.DistributionCode, + Phone: req.Phone, + Username: req.Username, + Password: req.Password, + ShopName: req.ShopName, + ShopCode: req.ShopCode, + ContactName: req.ContactName, + Province: req.Province, + City: req.City, + District: req.District, + Address: req.Address, + }, req.Code) + if err != nil { + return err + } + return response.Success(c, &dto.CreateAgentDistributionRegistrationResp{ + ID: result.RegistrationID, + Status: result.Status, + StatusName: constants.GetAgentDistributionRegistrationStatusName(result.Status), + }) +} diff --git a/internal/infrastructure/audit/approval.go b/internal/infrastructure/audit/approval.go index e672839..266c60c 100644 --- a/internal/infrastructure/audit/approval.go +++ b/internal/infrastructure/audit/approval.go @@ -129,6 +129,49 @@ func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType str "paid_amount": attempt.PaidAmount, "approval_instance_id": instanceID, }, }, nil + case constants.ApprovalBusinessTypeAgentDistribution: + var registration model.AgentDistributionRegistration + if err := tx.WithContext(ctx).First(®istration, businessID).Error; err != nil { + return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联扫码注册记录失败") + } + return ResourceInput{ + Type: constants.AuditResourceAgentDistributionRegistration, ID: &id, + Key: id, DisplayName: "扫码注册记录 " + id, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, + IdentitySnapshot: map[string]any{ + "id": registration.ID, "parent_shop_id": registration.ParentShopID, + "status": registration.Status, "approval_instance_id": instanceID, + }, + }, nil + case constants.ApprovalBusinessTypeWithdrawalQualification: + var qualification model.WithdrawalQualification + if err := tx.WithContext(ctx).First(&qualification, businessID).Error; err != nil { + return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现资料资格版本失败") + } + return ResourceInput{ + Type: constants.AuditResourceWithdrawalQualification, ID: &id, + Key: id, DisplayName: "提现资料资格版本 " + id, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, + IdentitySnapshot: map[string]any{ + "id": qualification.ID, "shop_id": qualification.ShopID, + "subject_type": qualification.SubjectType, "status": qualification.Status, + "approval_instance_id": instanceID, + }, + }, nil + case constants.ApprovalBusinessTypeCommissionWithdrawal: + var attempt model.CommissionWithdrawalRequestAttempt + if err := tx.WithContext(ctx).First(&attempt, businessID).Error; err != nil { + return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联提现审批尝试记录失败") + } + return ResourceInput{ + Type: constants.AuditResourceCommissionWithdrawalAttempt, ID: &id, + Key: id, DisplayName: "提现审批尝试 " + id, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness, + IdentitySnapshot: map[string]any{ + "id": attempt.ID, "request_id": attempt.RequestID, "attempt_no": attempt.AttemptNo, + "amount": attempt.Amount, "approval_instance_id": instanceID, + }, + }, nil default: return ResourceInput{}, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源") } diff --git a/internal/infrastructure/audit/distribution.go b/internal/infrastructure/audit/distribution.go new file mode 100644 index 0000000..b32e725 --- /dev/null +++ b/internal/infrastructure/audit/distribution.go @@ -0,0 +1,243 @@ +package audit + +import ( + "context" + "strconv" + + "gorm.io/gorm" + + distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal" + distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// WriteDistributionWithdrawal 将分销注册、提现资格与提现审批事实写入统一 Audit Event。 +// 审计只记录脱敏手机号与证件号、附件数量与对象键引用,绝不记录密码、完整证件号或附件内容。 +func (w *Writer) WriteDistributionWithdrawal( + ctx context.Context, + tx *gorm.DB, + change distributionapp.AuditChange, +) error { + resources := make([]ResourceInput, 0, 6) + if change.Registration != nil && change.Registration.ID != 0 { + resources = append(resources, registrationResource(change)) + } + if change.ParentShop != nil && change.ParentShop.ID != 0 { + resource := ShopResource(change.ParentShop, constants.AuditResourceRelationReference, + constants.AuditResourceRoleDistributionParentShop) + resource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, resource) + } + if change.Qualification != nil && change.Qualification.ID != 0 { + resources = append(resources, qualificationResource(change)) + } + if change.Withdrawal != nil && change.Withdrawal.ID != 0 { + resources = append(resources, withdrawalResource(change)) + } + if change.Attempt != nil && change.Attempt.ID != 0 { + resources = append(resources, withdrawalAttemptResource(change.Attempt)) + } + if change.Shop != nil && change.Shop.ID != 0 { + resource := ShopResource(change.Shop, constants.AuditResourceRelationReference, + constants.AuditResourceRoleQualificationShop) + resource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, resource) + } + if change.CreatedShop != nil && change.CreatedShop.ID != 0 { + shop := change.CreatedShop + resource := ShopResource(shop, constants.AuditResourceRelationAffected, + constants.AuditResourceRoleShopTarget) + resource.SubjectVisibility = constants.AuditSubjectResult + resource.SubjectSummary = change.Summary + // 新建店铺自己的分销码:注册记录上的码是上级码快照,这里必须记录本次生成的店铺码, + // 否则「分销码生成」在审批建店路径没有审计事实,与后台建店路径不对称。 + if shop.DistributionCode != "" { + resource.IdentitySnapshot["distribution_code_masked"] = + distributiondomain.MaskDistributionCode(shop.DistributionCode) + } + resource.AfterData = map[string]any{ + "shop_name": shop.ShopName, "shop_code": shop.ShopCode, + "distribution_code_masked": distributiondomain.MaskDistributionCode(shop.DistributionCode), + "applied_distribution_code_masked": distributiondomain.MaskDistributionCode(change.AppliedDistributionCode), + "status": shop.Status, + } + resources = append(resources, resource) + } + if change.Wallet != nil && change.Wallet.ID != 0 { + resource := agentWalletAuditResource(change.Wallet, constants.AuditResourceRelationAffected, + constants.AuditResourceRoleWithdrawalWallet) + resource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, resource) + } + if change.Transaction != nil && change.Transaction.ID != 0 { + resource := agentWalletTransactionResource(change.Transaction) + resource.Relation = constants.AuditResourceRelationAffected + resource.Role = constants.AuditResourceRoleWithdrawalTransaction + resource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, resource) + } + if len(resources) == 0 { + return errors.New(errors.CodeInvalidParam, "分销或提现审计缺少可追溯资源") + } + // 规范化关系:审计事件必须且恰好有一个主要资源。 + // 失败/拒绝审计在业务事务回滚后重放,此时业务对象可能尚未落库(ID 为 0)而被跳过, + // 或变更只带店铺上下文,因此这里统一收敛:优先保留业务主资源, + // 没有业务主资源时用店铺承载主资源,其余资源一律降为引用。 + primaryIndex := -1 + for index := range resources { + if resources[index].Relation == constants.AuditResourceRelationPrimary { + primaryIndex = index + break + } + } + if primaryIndex == -1 { + for index := range resources { + if resources[index].Type == constants.AuditResourceShop { + resources[index].Relation = constants.AuditResourceRelationPrimary + primaryIndex = index + break + } + } + } + if primaryIndex == -1 { + return errors.New(errors.CodeInvalidParam, "分销或提现审计缺少可追溯资源") + } + for index := range resources { + if index != primaryIndex && resources[index].Relation == constants.AuditResourceRelationPrimary { + resources[index].Relation = constants.AuditResourceRelationReference + } + } + result := change.Result + if result == "" { + result = constants.AuditResultSuccess + } + // 用 AppendAndGet 而非 Append:分销注册、资格与提现终审的审计属于「要求成功必达」的事实, + // 必须与业务事实同事务原子提交。Append 会吞掉错误,导致审计插入失败后事务被 PG 置为 + // aborted,最终只表现为难以定位的提交失败;这里显式返回错误。 + if _, err := w.AppendAndGet(ctx, tx, AppendInput{ + EventID: change.EventID, ActionCode: change.ActionCode, Summary: change.Summary, + ScopeType: constants.AuditScopePlatform, Result: result, + ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary, + CorrelationID: change.CorrelationID, Metadata: auditMetadata(change), Resources: resources, + }); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入分销或提现审计失败") + } + return nil +} + +// registrationResource 构造扫码注册记录审计资源,手机号与分销码只记录脱敏值。 +func registrationResource(change distributionapp.AuditChange) ResourceInput { + registration := change.Registration + id := strconv.FormatUint(uint64(registration.ID), 10) + instanceID := uint(0) + if registration.ApprovalInstanceID != nil { + instanceID = *registration.ApprovalInstanceID + } + return ResourceInput{ + Type: constants.AuditResourceAgentDistributionRegistration, ID: &id, Key: id, + DisplayName: "扫码注册记录 " + id, + Relation: constants.AuditResourceRelationPrimary, + Role: constants.AuditResourceRoleDistributionRegistration, + IdentitySnapshot: map[string]any{ + "id": registration.ID, "parent_shop_id": registration.ParentShopID, + "distribution_code_masked": distributiondomain.MaskDistributionCode(registration.DistributionCode), + "phone_masked": distributiondomain.MaskPhone(registration.Phone), + "username": registration.Username, "shop_code": registration.ShopCode, + "status": registration.Status, "approval_instance_id": instanceID, + "reject_reason": registration.RejectReason, + }, + BeforeData: change.BeforeData, AfterData: change.AfterData, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary, + } +} + +// qualificationResource 构造提现资料资格版本审计资源,证件号只记录脱敏值。 +func qualificationResource(change distributionapp.AuditChange) ResourceInput { + qualification := change.Qualification + id := strconv.FormatUint(uint64(qualification.ID), 10) + instanceID := uint(0) + if qualification.ApprovalInstanceID != nil { + instanceID = *qualification.ApprovalInstanceID + } + return ResourceInput{ + Type: constants.AuditResourceWithdrawalQualification, ID: &id, Key: id, + DisplayName: "提现资料资格版本 " + id, + Relation: constants.AuditResourceRelationPrimary, + Role: constants.AuditResourceRoleQualificationTarget, + IdentitySnapshot: map[string]any{ + "id": qualification.ID, "shop_id": qualification.ShopID, + "subject_type": qualification.SubjectType, + "subject_code_masked": distributiondomain.MaskSubjectCode(qualification.SubjectCode), + "status": qualification.Status, "approval_instance_id": instanceID, + "invalid_reason": qualification.InvalidReason, + }, + BeforeData: change.BeforeData, AfterData: change.AfterData, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary, + } +} + +// withdrawalResource 构造提现单审计资源,并附加本次审批尝试记录的引用资源。 +func withdrawalResource(change distributionapp.AuditChange) ResourceInput { + withdrawal := change.Withdrawal + resource := CommissionWithdrawalResource(withdrawal, constants.AuditResourceRelationPrimary, + constants.AuditResourceRoleWithdrawalTarget, change.BeforeData, change.AfterData) + resource.SubjectVisibility = constants.AuditSubjectResult + resource.SubjectSummary = change.Summary + return resource +} + +// withdrawalAttemptResource 构造提现审批尝试记录审计资源,用于审批实例关联追踪。 +func withdrawalAttemptResource(attempt *model.CommissionWithdrawalRequestAttempt) ResourceInput { + id := strconv.FormatUint(uint64(attempt.ID), 10) + instanceID := uint(0) + if attempt.ApprovalInstanceID != nil { + instanceID = *attempt.ApprovalInstanceID + } + return ResourceInput{ + Type: constants.AuditResourceCommissionWithdrawalAttempt, ID: &id, Key: id, + DisplayName: "提现审批尝试 " + id, + Relation: constants.AuditResourceRelationReference, + Role: constants.AuditResourceRoleWithdrawalAttempt, + IdentitySnapshot: map[string]any{ + "id": attempt.ID, "request_id": attempt.RequestID, "attempt_no": attempt.AttemptNo, + "amount": attempt.Amount, "fee": attempt.Fee, "actual_amount": attempt.ActualAmount, + "approval_instance_id": instanceID, "released_at": attempt.ReleasedAt, + }, + SubjectVisibility: constants.AuditSubjectInternalOnly, + } +} + +// auditMetadata 提取审计元数据,避免与资源明细重复。 +func auditMetadata(change distributionapp.AuditChange) map[string]any { + metadata := map[string]any{} + if change.Registration != nil { + metadata["registration_status"] = change.Registration.Status + } + if change.Qualification != nil { + metadata["qualification_status"] = change.Qualification.Status + metadata["qualification_subject_type"] = change.Qualification.SubjectType + } + if change.Attempt != nil { + metadata["attempt_no"] = change.Attempt.AttemptNo + metadata["amount"] = change.Attempt.Amount + metadata["fee"] = change.Attempt.Fee + metadata["actual_amount"] = change.Attempt.ActualAmount + } + if change.Withdrawal != nil { + metadata["withdrawal_status"] = change.Withdrawal.Status + metadata["anomaly_flag"] = change.Withdrawal.AnomalyFlag + } + if change.CreatedShop != nil { + metadata["created_shop_id"] = change.CreatedShop.ID + if change.CreatedShop.DistributionCode != "" { + metadata["distribution_code_masked"] = + distributiondomain.MaskDistributionCode(change.CreatedShop.DistributionCode) + } + } + if len(metadata) == 0 { + return nil + } + return metadata +} diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index 6f2a439..aee01be 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -357,6 +357,36 @@ func NewRegistry() *Registry { withdrawalRequested := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalRequested, "提交佣金提现申请") withdrawalApproved := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请") withdrawalRejected := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请") + // 提现审批尝试的提交由代理后台入口触发(account/admin_api), + // 终态由 Outbox 消费任务触发(system_task/worker),因此必须使用含两类入口的 distributionAction, + // 而不是仅允许后台账号入口的 commissionWithdrawalAction。 + withdrawalAttemptSubmitted := distributionAction( + constants.AuditActionCommissionWithdrawalAttemptSubmitted, "提交佣金提现审批尝试", constants.AuditResourceCommissionWithdrawal) + withdrawalAttemptApproved := distributionAction( + constants.AuditActionCommissionWithdrawalAttemptApproved, "企业微信通过佣金提现", constants.AuditResourceCommissionWithdrawal) + withdrawalAttemptClosed := distributionAction( + constants.AuditActionCommissionWithdrawalAttemptClosed, "企业微信驳回或撤销佣金提现", constants.AuditResourceCommissionWithdrawal) + withdrawalAnomalyFlagged := distributionAction( + constants.AuditActionCommissionWithdrawalAnomalyFlagged, "企微通过后撤销提现异常标记", constants.AuditResourceCommissionWithdrawal) + distributionRegistrationApproved := distributionAction( + constants.AuditActionAgentDistributionRegistrationApproved, "企业微信通过代理扫码注册", constants.AuditResourceAgentDistributionRegistration) + distributionRegistrationRejected := distributionAction( + constants.AuditActionAgentDistributionRegistrationRejected, "企业微信驳回代理扫码注册", constants.AuditResourceAgentDistributionRegistration) + qualificationSubmitted := distributionAction( + constants.AuditActionWithdrawalQualificationSubmitted, "提交提现资料资格", constants.AuditResourceWithdrawalQualification) + qualificationApproved := distributionAction( + constants.AuditActionWithdrawalQualificationApproved, "提现资料资格企业微信通过", constants.AuditResourceWithdrawalQualification) + qualificationRejected := distributionAction( + constants.AuditActionWithdrawalQualificationRejected, "提现资料资格企业微信驳回", constants.AuditResourceWithdrawalQualification) + qualificationVoided := distributionAction( + constants.AuditActionWithdrawalQualificationVoided, "作废提现资料资格", constants.AuditResourceWithdrawalQualification) + qualificationInvalidated := distributionAction( + constants.AuditActionWithdrawalQualificationInvalidated, "代理停用联动失效提现资料资格", constants.AuditResourceWithdrawalQualification) + // 创建前拒绝:没有业务实体可作主要资源,以店铺为主要资源,保证拒绝事实可追溯且主资源唯一。 + withdrawalAttemptRejected := distributionAction( + constants.AuditActionCommissionWithdrawalAttemptRejected, "提现提交被拒绝", constants.AuditResourceShop) + qualificationSubmitRejected := distributionAction( + constants.AuditActionWithdrawalQualificationSubmitRejected, "资格提交被拒绝", constants.AuditResourceShop) return &Registry{ actionsByOperation: map[string]ActionDefinition{ constants.AuditOperationSystemConfigUpdate: systemConfigUpdated, @@ -611,6 +641,19 @@ func NewRegistry() *Registry { constants.AuditActionCommissionWithdrawalRequested: withdrawalRequested, constants.AuditActionCommissionWithdrawalApproved: withdrawalApproved, constants.AuditActionCommissionWithdrawalRejected: withdrawalRejected, + constants.AuditActionCommissionWithdrawalAttemptSubmitted: withdrawalAttemptSubmitted, + constants.AuditActionCommissionWithdrawalAttemptApproved: withdrawalAttemptApproved, + constants.AuditActionCommissionWithdrawalAttemptClosed: withdrawalAttemptClosed, + constants.AuditActionCommissionWithdrawalAnomalyFlagged: withdrawalAnomalyFlagged, + constants.AuditActionAgentDistributionRegistrationApproved: distributionRegistrationApproved, + constants.AuditActionAgentDistributionRegistrationRejected: distributionRegistrationRejected, + constants.AuditActionWithdrawalQualificationSubmitted: qualificationSubmitted, + constants.AuditActionWithdrawalQualificationApproved: qualificationApproved, + constants.AuditActionWithdrawalQualificationRejected: qualificationRejected, + constants.AuditActionWithdrawalQualificationVoided: qualificationVoided, + constants.AuditActionWithdrawalQualificationInvalidated: qualificationInvalidated, + constants.AuditActionCommissionWithdrawalAttemptRejected: withdrawalAttemptRejected, + constants.AuditActionWithdrawalQualificationSubmitRejected: qualificationSubmitRejected, }, resources: map[string]ResourceDefinition{ constants.AuditResourceAccount: { @@ -838,6 +881,18 @@ func NewRegistry() *Registry { Type: constants.AuditResourceCommissionWithdrawal, Name: "佣金提现单", IdentityFields: []string{"id", "withdrawal_no", "shop_id", "applicant_id", "amount", "fee", "fee_rate", "actual_amount", "withdrawal_method", "payment_type", "status", "processor_id", "processed_at", "paid_at"}, }, + constants.AuditResourceCommissionWithdrawalAttempt: { + Type: constants.AuditResourceCommissionWithdrawalAttempt, Name: "提现审批尝试记录", + IdentityFields: []string{"id", "request_id", "attempt_no", "amount", "fee", "actual_amount", "approval_instance_id", "released_at"}, + }, + constants.AuditResourceAgentDistributionRegistration: { + Type: constants.AuditResourceAgentDistributionRegistration, Name: "代理扫码注册记录", + IdentityFields: []string{"id", "parent_shop_id", "distribution_code_masked", "phone_masked", "username", "shop_code", "status", "approval_instance_id", "reject_reason"}, + }, + constants.AuditResourceWithdrawalQualification: { + Type: constants.AuditResourceWithdrawalQualification, Name: "提现资料资格版本", + IdentityFields: []string{"id", "shop_id", "subject_type", "subject_code_masked", "status", "approval_instance_id", "invalid_reason"}, + }, constants.AuditResourceWeComApplication: { Type: constants.AuditResourceWeComApplication, Name: "企业微信应用配置", IdentityFields: []string{"id", "corp_id", "agent_id", "name", "status", "credentials_configured"}, @@ -1099,6 +1154,9 @@ func commissionWithdrawalAction(code, name string) ActionDefinition { RequireTransaction: true, DefaultVisibility: constants.AuditSubjectResult, AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult, constants.AuditSubjectDetail}, SubjectFields: []string{"amount", "fee", "actual_amount", "withdrawal_method", "payment_type", "status"}, + // 同一动作还由退款佣金回扣的自动拒绝提现路径发出(Worker 消费任务,system_task/worker), + // 因此必须同时允许该入口,否则该审计会以「操作者或入口不符合动作注册规则」被丢弃。 + AllowedOrigins: []ActionOrigin{{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker}}, } } @@ -1286,6 +1344,23 @@ func employeeCollectionAction(code, name, primaryResource string) ActionDefiniti } } +// distributionAction 定义分销注册、提现资格与提现终审动作;提交由后台账号或公开个人接口触发, +// 终态由 Outbox 消费、渠道回调或恢复任务触发,均要求与业务事实同事务。 +func distributionAction(code, name, primaryResource string) ActionDefinition { + return ActionDefinition{ + Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh, + PrimaryResource: primaryResource, RequireTransaction: true, + DefaultVisibility: constants.AuditSubjectResult, + AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult}, + AllowedOrigins: []ActionOrigin{ + {Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI}, + {Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker}, + {Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback}, + {Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler}, + }, + } +} + // Action 返回已注册动作定义。 func (r *Registry) Action(code string) (ActionDefinition, bool) { if r == nil { diff --git a/internal/infrastructure/shop/subordinate_cache.go b/internal/infrastructure/shop/subordinate_cache.go new file mode 100644 index 0000000..5e062d2 --- /dev/null +++ b/internal/infrastructure/shop/subordinate_cache.go @@ -0,0 +1,29 @@ +// Package shop 提供店铺相关的无状态基础设施适配。 +package shop + +import ( + "context" + + "github.com/redis/go-redis/v9" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// SubordinateCache 负责清理店铺下级集合缓存。 +// 新下级只在审批通过事务提交后出现,必须清理上级缓存,否则缓存有效期内新下级不可见。 +type SubordinateCache struct { + redis *redis.Client +} + +// NewSubordinateCache 创建店铺下级集合缓存适配器。 +func NewSubordinateCache(client *redis.Client) *SubordinateCache { + return &SubordinateCache{redis: client} +} + +// InvalidateSubordinateCache 清理指定店铺的下级集合缓存;缓存未配置时安全跳过。 +func (c *SubordinateCache) InvalidateSubordinateCache(ctx context.Context, shopID uint) { + if c == nil || c.redis == nil || shopID == 0 { + return + } + _ = c.redis.Del(ctx, constants.RedisShopSubordinatesKey(shopID)).Err() +} diff --git a/internal/model/agent_distribution.go b/internal/model/agent_distribution.go new file mode 100644 index 0000000..5cbcc4d --- /dev/null +++ b/internal/model/agent_distribution.go @@ -0,0 +1,34 @@ +package model + +import "time" + +// AgentDistributionRegistration 是代理扫码注册的待审批记录。 +// 审批通过前不创建店铺、账号、钱包或上下级归属;同一手机号驳回后再次扫码是新记录。 +type AgentDistributionRegistration struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + DistributionCode string `gorm:"column:distribution_code;type:varchar(32);not null;comment:上级店铺分销码快照" json:"distribution_code"` + ParentShopID uint `gorm:"column:parent_shop_id;not null;comment:上级店铺ID" json:"parent_shop_id"` + Phone string `gorm:"column:phone;type:varchar(20);not null;comment:注册手机号" json:"phone"` + PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null;comment:bcrypt 密码哈希" json:"-"` + ShopName string `gorm:"column:shop_name;type:varchar(100);not null;comment:申请店铺名称快照" json:"shop_name"` + ShopCode string `gorm:"column:shop_code;type:varchar(50);not null;comment:申请店铺编号快照" json:"shop_code"` + Username string `gorm:"column:username;type:varchar(50);not null;comment:申请代理主账号用户名快照" json:"username"` + ContactName string `gorm:"column:contact_name;type:varchar(50);not null;default:'';comment:联系人姓名快照" json:"contact_name"` + Province string `gorm:"column:province;type:varchar(50);not null;default:'';comment:省份快照" json:"province"` + City string `gorm:"column:city;type:varchar(50);not null;default:'';comment:城市快照" json:"city"` + District string `gorm:"column:district;type:varchar(50);not null;default:'';comment:区县快照" json:"district"` + Address string `gorm:"column:address;type:varchar(255);not null;default:'';comment:详细地址快照" json:"address"` + Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-待审批 1-已通过 2-已驳回" json:"status"` + ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:关联的通用审批实例ID" json:"approval_instance_id,omitempty"` + RejectReason string `gorm:"column:reject_reason;type:varchar(500);not null;default:'';comment:企业微信驳回原因" json:"reject_reason"` + DecidedAt *time.Time `gorm:"column:decided_at;type:timestamptz;comment:审批终态到达时间" json:"decided_at,omitempty"` + Creator uint `gorm:"column:creator;not null;default:0;comment:创建人用户ID" json:"creator"` + Updater uint `gorm:"column:updater;not null;default:0;comment:最近更新人用户ID" json:"updater"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定代理扫码注册记录表名。 +func (AgentDistributionRegistration) TableName() string { + return "tb_agent_distribution_registration" +} diff --git a/internal/model/dto/agent_distribution_dto.go b/internal/model/dto/agent_distribution_dto.go new file mode 100644 index 0000000..ae2c84b --- /dev/null +++ b/internal/model/dto/agent_distribution_dto.go @@ -0,0 +1,149 @@ +package dto + +// CreateAgentDistributionRegistrationReq 公开扫码注册请求(无需认证)。 +type CreateAgentDistributionRegistrationReq struct { + DistributionCode string `json:"distribution_code" validate:"required,min=1,max=32" required:"true" minLength:"1" maxLength:"32" description:"上级代理店铺的分销码"` + Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"注册手机号,必须已完成短信验证码校验"` + Code string `json:"code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"短信验证码,校验成功即消费"` + Password string `json:"password" validate:"required,min=6,max=64" required:"true" minLength:"6" maximum:"64" description:"代理主账号登录密码"` + Username string `json:"username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"代理主账号用户名"` + ShopName string `json:"shop_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"申请店铺名称"` + ShopCode string `json:"shop_code" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"申请店铺编号"` + ContactName string `json:"contact_name" validate:"omitempty,max=50" maxLength:"50" description:"联系人姓名"` + Province string `json:"province" validate:"omitempty,max=50" maxLength:"50" description:"省份"` + City string `json:"city" validate:"omitempty,max=50" maxLength:"50" description:"城市"` + District string `json:"district" validate:"omitempty,max=50" maxLength:"50" description:"区县"` + Address string `json:"address" validate:"omitempty,max=255" maxLength:"255" description:"详细地址"` +} + +// CreateAgentDistributionRegistrationResp 公开扫码注册响应。 +// 仅返回注册记录标识与待审批状态,不返回任何账号凭证或审批实例明细。 +type CreateAgentDistributionRegistrationResp struct { + ID uint `json:"id" description:"待审批注册记录ID"` + Status int `json:"status" description:"注册记录状态 (0:待审批, 1:已通过, 2:已驳回)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` +} + +// SubmitWithdrawalQualificationReq 提交或替换提现资料资格请求。 +type SubmitWithdrawalQualificationReq struct { + ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"` + SubjectType string `json:"subject_type" validate:"required,oneof=enterprise personal" required:"true" enum:"enterprise,personal" description:"签约主体类型 (enterprise:企业, personal:个人)"` + SubjectCode string `json:"subject_code" validate:"omitempty,max=64" maxLength:"64" description:"签约主体代码:企业填统一社会信用代码;个人留空并按法人身份证号取值"` + LegalPersonIDCard string `json:"legal_person_id_card" validate:"required,min=1,max=64" required:"true" minLength:"1" maxLength:"64" description:"法人身份证号"` + ContractFileKey string `json:"contract_file_key" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"合同附件对象存储 Key(单个对象)"` + IDCardFrontFileKey string `json:"id_card_front_file_key" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"法人身份证正面附件对象存储 Key(单个对象)"` + IDCardBackFileKey string `json:"id_card_back_file_key" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"法人身份证反面附件对象存储 Key(单个对象)"` + BusinessLicenseFileKey string `json:"business_license_file_key" validate:"omitempty,max=255" maxLength:"255" description:"营业执照附件对象存储 Key(单个对象,可选)"` + ShopFrontFileKey string `json:"shop_front_file_key" validate:"omitempty,max=255" maxLength:"255" description:"门头照附件对象存储 Key(单个对象,可选)"` + InvoiceFileKey string `json:"invoice_file_key" validate:"omitempty,max=255" maxLength:"255" description:"发票附件对象存储 Key(单个对象,仅企业可选)"` + InvoiceTitle string `json:"invoice_title" validate:"omitempty,max=200" maxLength:"200" description:"发票抬头,仅企业填写,必须与合同主体一致"` + InvoiceSubjectCode string `json:"invoice_subject_code" validate:"omitempty,max=64" maxLength:"64" description:"发票统一社会信用代码,必须与签约主体代码一致"` +} + +// SubmitWithdrawalQualificationResp 提现资料资格提交响应。 +type SubmitWithdrawalQualificationResp struct { + ID uint `json:"id" description:"资料版本ID"` + Status int `json:"status" description:"资料版本状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` +} + +// VoidWithdrawalQualificationReq 超级管理员作废提现资料资格请求。 +type VoidWithdrawalQualificationReq struct { + Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"作废原因,必填且可在资格详情查询"` +} + +// VoidWithdrawalQualificationParams 作废提现资料资格路径与请求参数。 +type VoidWithdrawalQualificationParams struct { + IDReq + VoidWithdrawalQualificationReq +} + +// WithdrawalQualificationListReq 提现资料资格列表查询请求。 +type WithdrawalQualificationListReq struct { + ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" required:"true" description:"店铺ID(路径参数,必填;查询前校验当前账号对该店铺的数据范围)"` + Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"` + PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量(默认20,最大100)"` + Status *int `query:"status" validate:"omitempty,oneof=0 1 2 3" enum:"0,1,2,3" description:"资料版本状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"` +} + +// WithdrawalQualificationItem 提现资料资格版本项。 +// 证件号按脱敏值返回,附件仅返回对象存储 Key 引用。 +type WithdrawalQualificationItem struct { + ID uint `json:"id" description:"资料版本ID"` + ShopID uint `json:"shop_id" description:"所属店铺ID"` + ShopName string `json:"shop_name" description:"所属店铺名称"` + SubjectType string `json:"subject_type" description:"签约主体类型 (enterprise:企业, personal:个人)"` + SubjectTypeName string `json:"subject_type_name" description:"签约主体类型名称(中文)"` + SubjectCodeMasked string `json:"subject_code_masked" description:"脱敏后的签约主体代码"` + LegalPersonIDCardMask string `json:"legal_person_id_card_masked" description:"脱敏后的法人身份证号"` + ContractFileKey string `json:"contract_file_key" description:"合同附件对象存储 Key"` + IDCardFrontFileKey string `json:"id_card_front_file_key" description:"法人身份证正面附件对象存储 Key"` + IDCardBackFileKey string `json:"id_card_back_file_key" description:"法人身份证反面附件对象存储 Key"` + BusinessLicenseFileKey string `json:"business_license_file_key,omitempty" description:"营业执照附件对象存储 Key"` + ShopFrontFileKey string `json:"shop_front_file_key,omitempty" description:"门头照附件对象存储 Key"` + InvoiceFileKey string `json:"invoice_file_key,omitempty" description:"发票附件对象存储 Key"` + InvoiceTitle string `json:"invoice_title,omitempty" description:"发票抬头"` + Status int `json:"status" description:"状态 (0:待审批, 1:已通过有效, 2:已驳回, 3:已失效)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` + InvalidReason string `json:"invalid_reason,omitempty" description:"失效或作废原因"` + InvalidatedAt string `json:"invalidated_at,omitempty" description:"失效时间"` + ApprovalInstanceID uint `json:"approval_instance_id" description:"关联的通用审批实例ID,0 表示尚未关联"` + ApprovalStatus int `json:"approval_status" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"` + ApprovalStatusName string `json:"approval_status_name" description:"通用审批状态名称(中文)"` + CreatedAt string `json:"created_at" description:"创建时间"` + UpdatedAt string `json:"updated_at" description:"最近更新时间"` +} + +// WithdrawalQualificationPageResult 提现资料资格分页响应。 +type WithdrawalQualificationPageResult struct { + Items []WithdrawalQualificationItem `json:"items" description:"资料版本列表(按版本从新到旧)"` + Total int64 `json:"total" description:"总记录数"` + Page int `json:"page" description:"当前页码"` + Size int `json:"size" description:"每页数量"` +} + +// ResubmitWithdrawalReq 驳回后重提提现申请请求。 +type ResubmitWithdrawalReq struct { + ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"` + ID uint `json:"-" params:"id" path:"id" validate:"required" description:"提现申请ID"` + Amount int64 `json:"amount" validate:"required,min=1" required:"true" minimum:"1" description:"提现金额(分)"` + WithdrawalMethod string `json:"withdrawal_method" validate:"required,oneof=alipay" required:"true" enum:"alipay" description:"收款类型"` + AccountName string `json:"account_name" validate:"required,max=50" required:"true" maximum:"50" description:"收款人姓名"` + AccountNumber string `json:"account_number" validate:"required,max=100" required:"true" maximum:"100" description:"收款账号"` + InvoiceKeys []string `json:"invoice_keys" description:"本次申请级发票对象存储 Key 列表;仅企业主体有效资格可提交"` +} + +// ShopWithdrawalRequestDetailReq 提现申请详情路径参数。 +type ShopWithdrawalRequestDetailReq struct { + ShopID uint `json:"-" params:"shop_id" path:"shop_id" validate:"required" description:"店铺ID"` + ID uint `json:"-" params:"id" path:"id" validate:"required" description:"提现申请ID"` +} + +// WithdrawalRequestAttemptItem 提现审批尝试记录项。 +type WithdrawalRequestAttemptItem struct { + ID uint `json:"id" description:"审批尝试记录ID"` + AttemptNo int `json:"attempt_no" description:"第几次提交,从 1 递增"` + Amount int64 `json:"amount" description:"本次提现金额(分)"` + Fee int64 `json:"fee" description:"本次手续费(分)"` + FeeRate int64 `json:"fee_rate" description:"本次手续费率(基点,100=1%)"` + ActualAmount int64 `json:"actual_amount" description:"本次实际到账金额(分)"` + WithdrawalMethod string `json:"withdrawal_method" description:"本次收款方式"` + SubmittedByID uint `json:"submitted_by_account_id" description:"本次提交账号ID"` + ApprovalInstanceID uint `json:"approval_instance_id" description:"本次尝试关联的通用审批实例ID,0 表示尚未关联"` + ApprovalStatus int `json:"approval_status" description:"通用审批实例状态 (0:提交中, 1:审批中, 2:已通过, 3:已拒绝, 4:已撤销, 5:通过后撤销, 6:已删除, 7:提交失败, 8:提交结果未知)"` + ApprovalStatusName string `json:"approval_status_name" description:"通用审批状态名称(中文)"` + ReleasedAt string `json:"released_at,omitempty" description:"本次冻结释放时间,空表示冻结仍未结算"` + CreatedAt string `json:"created_at" description:"创建时间"` +} + +// ShopWithdrawalRequestDetailResp 提现申请详情响应。 +type ShopWithdrawalRequestDetailResp struct { + ShopWithdrawalRequestItem + LatestAttemptID uint `json:"latest_attempt_id" description:"最新审批尝试记录ID,0 表示尚未接入企业微信审批"` + LatestApprovalInstanceID uint `json:"latest_approval_instance_id" description:"最新通用审批实例ID,0 表示尚未接入"` + ApprovalInstanceID uint `json:"approval_instance_id" description:"首次接入企业微信审批的审批实例ID,0 表示存量申请仍走本地人工终审"` + AnomalyFlag int `json:"anomaly_flag" description:"正交异常标记 (0:无异常, 1:通过后撤销)"` + AnomalyName string `json:"anomaly_name" description:"异常名称(中文)"` + AnomalyReason string `json:"anomaly_reason,omitempty" description:"异常原因,供人工处理"` + Attempts []WithdrawalRequestAttemptItem `json:"attempts" description:"审批尝试记录,按提交次序倒序;历史尝试与审批结果不被覆盖"` +} diff --git a/internal/model/dto/shop_dto.go b/internal/model/dto/shop_dto.go index 4771947..d61dbc1 100644 --- a/internal/model/dto/shop_dto.go +++ b/internal/model/dto/shop_dto.go @@ -86,9 +86,12 @@ func (r *UpdateShopRequest) UnmarshalJSON(data []byte) error { // ShopResponse 店铺响应 type ShopResponse struct { - ID uint `json:"id" description:"店铺ID"` - ShopName string `json:"shop_name" description:"店铺名称"` - ShopCode string `json:"shop_code" description:"店铺编号"` + ID uint `json:"id" description:"店铺ID"` + ShopName string `json:"shop_name" description:"店铺名称"` + ShopCode string `json:"shop_code" description:"店铺编号"` + // DistributionCode 是创建时生成的全局唯一随机分销码,创建后不可修改。 + // 该字段为只读;它是该店铺作为上级被扫码注册的唯一入口标识,二维码只编码该码。 + DistributionCode string `json:"distribution_code" description:"分销码(全局唯一、创建后不可修改的只读随机码;该店铺作为上级被扫码注册的唯一入口标识)"` ParentID *uint `json:"parent_id,omitempty" description:"上级店铺ID"` ParentShopName string `json:"parent_shop_name,omitempty" description:"上级店铺名称"` BusinessOwnerAccountID *uint `json:"business_owner_account_id" description:"平台业务员账号ID,null 表示未归属"` diff --git a/internal/model/dto/wecom_scene_dto.go b/internal/model/dto/wecom_scene_dto.go index eda2f2a..d4a615b 100644 --- a/internal/model/dto/wecom_scene_dto.go +++ b/internal/model/dto/wecom_scene_dto.go @@ -23,7 +23,7 @@ type InspectWeComTemplateParams struct { // WeComBusinessFieldListParams 查询企业微信审批场景可映射字段路径参数。 type WeComBusinessFieldListParams struct { - BusinessType string `path:"business_type" required:"true" enum:"refund_approval,offline_recharge_approval,employee_collection_approval" description:"业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批, employee_collection_approval:员工代收款核销审批)"` + BusinessType string `path:"business_type" required:"true" enum:"refund_approval,offline_recharge_approval,employee_collection_approval,agent_distribution_approval,withdrawal_qualification_approval,commission_withdrawal_approval" description:"业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批, employee_collection_approval:员工代收款核销审批, agent_distribution_approval:代理扫码分销注册审批, withdrawal_qualification_approval:提现资料资格审批, commission_withdrawal_approval:佣金提现终审)"` } // WeComBusinessFieldResponse 企业微信审批场景可映射业务字段响应。 @@ -67,7 +67,7 @@ type SaveWeComApprovalSceneRequest struct { // SaveWeComApprovalSceneParams 保存企业微信审批场景路径与请求参数。 type SaveWeComApprovalSceneParams struct { - BusinessType string `path:"business_type" required:"true" enum:"refund_approval,offline_recharge_approval,employee_collection_approval" description:"业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批, employee_collection_approval:员工代收款核销审批);可先调用 GET /api/admin/wecom/scenes/{business_type}/fields 查询允许映射的业务字段"` + BusinessType string `path:"business_type" required:"true" enum:"refund_approval,offline_recharge_approval,employee_collection_approval,agent_distribution_approval,withdrawal_qualification_approval,commission_withdrawal_approval" description:"业务类型 (refund_approval:退款审批, offline_recharge_approval:员工线下代充值审批, employee_collection_approval:员工代收款核销审批, agent_distribution_approval:代理扫码分销注册审批, withdrawal_qualification_approval:提现资料资格审批, commission_withdrawal_approval:佣金提现终审);可先调用 GET /api/admin/wecom/scenes/{business_type}/fields 查询允许映射的业务字段"` SaveWeComApprovalSceneRequest } diff --git a/internal/model/financial.go b/internal/model/financial.go index 63ea9fd..4ab0ba0 100644 --- a/internal/model/financial.go +++ b/internal/model/financial.go @@ -29,6 +29,13 @@ type CommissionWithdrawalRequest struct { PaidAt *time.Time `gorm:"column:paid_at;comment:到账时间" json:"paid_at"` RejectReason string `gorm:"column:reject_reason;type:text;comment:拒绝原因" json:"reject_reason"` Remark string `gorm:"column:remark;type:text;comment:备注" json:"remark"` + // LatestAttemptID 与 LatestApprovalInstanceID 仅用于列表投影,不是历史事实来源。 + LatestAttemptID uint `gorm:"column:latest_attempt_id;type:bigint;not null;default:0;comment:最新审批尝试记录ID" json:"latest_attempt_id"` + LatestApprovalInstanceID uint `gorm:"column:latest_approval_instance_id;type:bigint;not null;default:0;comment:最新通用审批实例ID" json:"latest_approval_instance_id"` + // ApprovalInstanceID 为空表示从未关联审批实例的存量申请,仍走既有本地人工终审。 + ApprovalInstanceID *uint `gorm:"column:approval_instance_id;type:bigint;comment:首次接入企业微信审批的审批实例ID" json:"approval_instance_id,omitempty"` + AnomalyFlag int `gorm:"column:anomaly_flag;type:smallint;not null;default:0;comment:正交异常标记 0-无异常 1-通过后撤销" json:"anomaly_flag"` + AnomalyReason string `gorm:"column:anomaly_reason;type:varchar(500);not null;default:'';comment:异常原因" json:"anomaly_reason"` } // TableName 指定表名 @@ -36,6 +43,30 @@ func (CommissionWithdrawalRequest) TableName() string { return "tb_commission_withdrawal_request" } +// CommissionWithdrawalRequestAttempt 是提现申请每次提交或重提对应的不可变审批材料与冻结事实。 +// 冻结与释放金额一律取本记录事实;released_at 是回溯可重放的幂等依据。 +type CommissionWithdrawalRequestAttempt struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + RequestID uint `gorm:"column:request_id;type:bigint;not null;comment:所属提现申请ID" json:"request_id"` + AttemptNo int `gorm:"column:attempt_no;type:int;not null;comment:第几次提交,从 1 递增" json:"attempt_no"` + Amount int64 `gorm:"column:amount;type:bigint;not null;comment:本次冻结提现金额(分)" json:"amount"` + Fee int64 `gorm:"column:fee;type:bigint;not null;default:0;comment:本次手续费(分)" json:"fee"` + FeeRate int64 `gorm:"column:fee_rate;type:bigint;not null;default:0;comment:本次手续费率(基点)" json:"fee_rate"` + ActualAmount int64 `gorm:"column:actual_amount;type:bigint;not null;default:0;comment:本次实际到账金额(分)" json:"actual_amount"` + WithdrawalMethod string `gorm:"column:withdrawal_method;type:varchar(20);not null;comment:本次收款方式快照" json:"withdrawal_method"` + AccountInfo datatypes.JSON `gorm:"column:account_info;type:jsonb;not null;comment:本次收款账户信息快照" json:"account_info"` + InvoiceKeys datatypes.JSON `gorm:"column:invoice_keys;type:jsonb;not null;default:'[]';comment:本次申请级发票对象存储Key列表" json:"invoice_keys"` + SubmittedByAccountID uint `gorm:"column:submitted_by_account_id;type:bigint;not null;comment:本次实际提交账号ID" json:"submitted_by_account_id"` + ApprovalInstanceID *uint `gorm:"column:approval_instance_id;type:bigint;comment:本次尝试关联的通用审批实例ID" json:"approval_instance_id,omitempty"` + ReleasedAt *time.Time `gorm:"column:released_at;comment:本次冻结释放时间,空表示未结算" json:"released_at,omitempty"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` +} + +// TableName 指定提现审批尝试记录表名。 +func (CommissionWithdrawalRequestAttempt) TableName() string { + return "tb_commission_withdrawal_request_attempt" +} + // CommissionWithdrawalSetting 佣金提现设置模型 // 提现参数配置(最低金额、手续费率、到账时间等) type CommissionWithdrawalSetting struct { diff --git a/internal/model/shop.go b/internal/model/shop.go index 82a16db..10a2bd9 100644 --- a/internal/model/shop.go +++ b/internal/model/shop.go @@ -7,9 +7,11 @@ import ( // Shop 店铺模型 type Shop struct { gorm.Model - BaseModel `gorm:"embedded"` - ShopName string `gorm:"column:shop_name;type:varchar(100);not null;comment:店铺名称" json:"shop_name"` - ShopCode string `gorm:"column:shop_code;type:varchar(50);uniqueIndex:idx_shop_code,where:deleted_at IS NULL;comment:店铺编号" json:"shop_code"` + BaseModel `gorm:"embedded"` + ShopName string `gorm:"column:shop_name;type:varchar(100);not null;comment:店铺名称" json:"shop_name"` + ShopCode string `gorm:"column:shop_code;type:varchar(50);uniqueIndex:idx_shop_code,where:deleted_at IS NULL;comment:店铺编号" json:"shop_code"` + // DistributionCode 是创建时生成的全局唯一随机分销码,创建后不可修改;不提供人工指定或编辑入口。 + DistributionCode string `gorm:"column:distribution_code;type:varchar(32);not null;default:'';comment:全局唯一随机分销码,创建时生成且不可修改" json:"distribution_code"` ParentID *uint `gorm:"column:parent_id;index;comment:上级店铺ID(NULL表示一级代理)" json:"parent_id,omitempty"` BusinessOwnerAccountID *uint `gorm:"column:business_owner_account_id;index:idx_shop_business_owner_account_id;comment:平台业务员账号ID" json:"business_owner_account_id,omitempty"` Level int `gorm:"column:level;type:int;not null;default:1;comment:层级(1-7)" json:"level"` diff --git a/internal/model/withdrawal_qualification.go b/internal/model/withdrawal_qualification.go new file mode 100644 index 0000000..06860b0 --- /dev/null +++ b/internal/model/withdrawal_qualification.go @@ -0,0 +1,36 @@ +package model + +import "time" + +// WithdrawalQualification 是代理提现资料资格的不可变版本。 +// 历史版本、附件引用与审批结果不被覆盖:替换合同或法人身份证即新增版本并使旧有效版本失效。 +type WithdrawalQualification struct { + ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + ShopID uint `gorm:"column:shop_id;not null;comment:所属代理店铺ID" json:"shop_id"` + SubjectType string `gorm:"column:subject_type;type:varchar(20);not null;comment:签约主体类型 enterprise/personal" json:"subject_type"` + SubjectCode string `gorm:"column:subject_code;type:varchar(64);not null;comment:签约主体代码" json:"subject_code"` + LegalPersonIDCard string `gorm:"column:legal_person_id_card;type:varchar(64);not null;comment:法人身份证号" json:"legal_person_id_card"` + ContractFileKey string `gorm:"column:contract_file_key;type:varchar(255);not null;comment:合同附件对象存储Key" json:"contract_file_key"` + IDCardFrontFileKey string `gorm:"column:id_card_front_file_key;type:varchar(255);not null;comment:法人身份证正面附件对象存储Key" json:"id_card_front_file_key"` + IDCardBackFileKey string `gorm:"column:id_card_back_file_key;type:varchar(255);not null;comment:法人身份证反面附件对象存储Key" json:"id_card_back_file_key"` + BusinessLicenseFileKey string `gorm:"column:business_license_file_key;type:varchar(255);not null;default:'';comment:营业执照附件对象存储Key" json:"business_license_file_key"` + ShopFrontFileKey string `gorm:"column:shop_front_file_key;type:varchar(255);not null;default:'';comment:门头照附件对象存储Key" json:"shop_front_file_key"` + InvoiceFileKey string `gorm:"column:invoice_file_key;type:varchar(255);not null;default:'';comment:发票附件对象存储Key" json:"invoice_file_key"` + InvoiceTitle string `gorm:"column:invoice_title;type:varchar(200);not null;default:'';comment:发票抬头" json:"invoice_title"` + InvoiceSubjectCode string `gorm:"column:invoice_subject_code;type:varchar(64);not null;default:'';comment:发票统一社会信用代码" json:"invoice_subject_code"` + Status int `gorm:"column:status;type:smallint;not null;default:0;comment:状态 0-待审批 1-已通过有效 2-已驳回 3-已失效或已作废" json:"status"` + InvalidReason string `gorm:"column:invalid_reason;type:varchar(500);not null;default:'';comment:失效原因" json:"invalid_reason"` + InvalidatedAt *time.Time `gorm:"column:invalidated_at;type:timestamptz;comment:失效时间" json:"invalidated_at,omitempty"` + InvalidatedBy uint `gorm:"column:invalidated_by;not null;default:0;comment:使资格失效的操作账号ID,0 表示店铺停用联动" json:"invalidated_by"` + ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:关联的通用审批实例ID" json:"approval_instance_id,omitempty"` + DecidedAt *time.Time `gorm:"column:decided_at;type:timestamptz;comment:审批终态到达时间" json:"decided_at,omitempty"` + Creator uint `gorm:"column:creator;not null;default:0;comment:创建人用户ID" json:"creator"` + Updater uint `gorm:"column:updater;not null;default:0;comment:最近更新人用户ID" json:"updater"` + CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"` +} + +// TableName 指定提现资料资格版本表名。 +func (WithdrawalQualification) TableName() string { + return "tb_withdrawal_qualification" +} diff --git a/internal/query/distributionwithdrawal/query.go b/internal/query/distributionwithdrawal/query.go new file mode 100644 index 0000000..1973bcf --- /dev/null +++ b/internal/query/distributionwithdrawal/query.go @@ -0,0 +1,279 @@ +// Package distributionwithdrawal 提供分销注册、提现资格与提现审批的只读投影查询。 +// 查询不得修改任何状态;可见性由调用方在业务边界先行校验。 +package distributionwithdrawal + +import ( + "context" + "encoding/json" + + "gorm.io/gorm" + + distributiondomain "github.com/break/junhong_cmp_fiber/internal/domain/distribution" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// Query 提供提现资料资格与提现申请详情的只读投影。 +type Query struct { + db *gorm.DB +} + +// NewQuery 创建分销与提现审批只读查询。 +func NewQuery(db *gorm.DB) *Query { + return &Query{db: db} +} + +// ListQualifications 按数据范围分页查询提现资料资格版本,证件号按脱敏值返回。 +func (q *Query) ListQualifications( + ctx context.Context, + shopIDs []uint, + req *dto.WithdrawalQualificationListReq, +) (*dto.WithdrawalQualificationPageResult, error) { + if q == nil || q.db == nil { + return nil, errors.New(errors.CodeInternalError, "提现资料资格查询能力未配置") + } + page := req.Page + if page <= 0 { + page = constants.DefaultPage + } + pageSize := req.PageSize + if pageSize <= 0 { + pageSize = constants.DefaultPageSize + } + if pageSize > constants.MaxPageSize { + pageSize = constants.MaxPageSize + } + query := q.db.WithContext(ctx).Model(&model.WithdrawalQualification{}) + if len(shopIDs) == 1 { + query = query.Where("shop_id = ?", shopIDs[0]) + } else if len(shopIDs) > 1 { + query = query.Where("shop_id IN ?", shopIDs) + } + if req.Status != nil { + query = query.Where("status = ?", *req.Status) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计提现资料资格失败") + } + var versions []model.WithdrawalQualification + if err := query.Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize). + Find(&versions).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现资料资格失败") + } + shopNames, err := q.shopNames(ctx, versions) + if err != nil { + return nil, err + } + instanceStatuses, err := q.approvalStatuses(ctx, constants.ApprovalBusinessTypeWithdrawalQualification, instanceIDsOfQualifications(versions)) + if err != nil { + return nil, err + } + items := make([]dto.WithdrawalQualificationItem, 0, len(versions)) + for index := range versions { + version := &versions[index] + instanceID := uint(0) + if version.ApprovalInstanceID != nil { + instanceID = *version.ApprovalInstanceID + } + item := dto.WithdrawalQualificationItem{ + ID: version.ID, ShopID: version.ShopID, ShopName: shopNames[version.ShopID], + SubjectType: version.SubjectType, + SubjectTypeName: constants.GetWithdrawalQualificationSubjectTypeName(version.SubjectType), + SubjectCodeMasked: distributiondomain.MaskSubjectCode(version.SubjectCode), + LegalPersonIDCardMask: distributiondomain.MaskSubjectCode(version.LegalPersonIDCard), + ContractFileKey: version.ContractFileKey, + IDCardFrontFileKey: version.IDCardFrontFileKey, + IDCardBackFileKey: version.IDCardBackFileKey, + BusinessLicenseFileKey: version.BusinessLicenseFileKey, + ShopFrontFileKey: version.ShopFrontFileKey, + InvoiceFileKey: version.InvoiceFileKey, InvoiceTitle: version.InvoiceTitle, + Status: version.Status, StatusName: constants.GetWithdrawalQualificationStatusName(version.Status), + InvalidReason: version.InvalidReason, ApprovalInstanceID: instanceID, + CreatedAt: version.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: version.UpdatedAt.Format("2006-01-02 15:04:05"), + } + if version.InvalidatedAt != nil { + item.InvalidatedAt = version.InvalidatedAt.Format("2006-01-02 15:04:05") + } + if status, ok := instanceStatuses[instanceID]; ok { + item.ApprovalStatus = status + item.ApprovalStatusName = constants.GetApprovalStatusName(status) + } else { + item.ApprovalStatusName = constants.GetApprovalStatusName(-1) + } + items = append(items, item) + } + return &dto.WithdrawalQualificationPageResult{ + Items: items, Total: total, Page: page, Size: pageSize, + }, nil +} + +// WithdrawalDetail 查询单笔提现申请详情与其全部审批尝试记录。 +func (q *Query) WithdrawalDetail( + ctx context.Context, + requestID uint, +) (*dto.ShopWithdrawalRequestDetailResp, error) { + if q == nil || q.db == nil { + return nil, errors.New(errors.CodeInternalError, "提现详情查询能力未配置") + } + var request model.CommissionWithdrawalRequest + if err := q.db.WithContext(ctx).First(&request, requestID).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "提现申请不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现申请失败") + } + var attempts []model.CommissionWithdrawalRequestAttempt + if err := q.db.WithContext(ctx). + Where("request_id = ?", request.ID).Order("attempt_no DESC").Find(&attempts).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试记录失败") + } + instanceStatuses, err := q.approvalStatuses(ctx, constants.ApprovalBusinessTypeCommissionWithdrawal, instanceIDsOfAttempts(attempts)) + if err != nil { + return nil, err + } + shopNames, err := q.shopNames(ctx, []model.WithdrawalQualification{{ShopID: request.ShopID}}) + if err != nil { + return nil, err + } + accountName, accountNumber := decodeAttemptAccount(request.AccountInfo) + processedAt := "" + if request.ProcessedAt != nil { + processedAt = request.ProcessedAt.Format("2006-01-02 15:04:05") + } + paidAt := "" + if request.PaidAt != nil { + paidAt = request.PaidAt.Format("2006-01-02 15:04:05") + } + detail := &dto.ShopWithdrawalRequestDetailResp{ + ShopWithdrawalRequestItem: dto.ShopWithdrawalRequestItem{ + ID: request.ID, WithdrawalNo: request.WithdrawalNo, Amount: request.Amount, + FeeRate: request.FeeRate, Fee: request.Fee, ActualAmount: request.ActualAmount, + Status: request.Status, StatusName: constants.GetWithdrawalStatusName(request.Status), + ShopID: request.ShopID, ShopName: shopNames[request.ShopID], + ApplicantID: request.ApplicantID, WithdrawalMethod: request.WithdrawalMethod, + PaymentType: request.PaymentType, AccountName: accountName, AccountNumber: accountNumber, + RejectReason: request.RejectReason, Remark: request.Remark, + CreatedAt: request.CreatedAt.Format("2006-01-02 15:04:05"), + ProcessedAt: processedAt, PaidAt: paidAt, + }, + LatestAttemptID: request.LatestAttemptID, + LatestApprovalInstanceID: request.LatestApprovalInstanceID, + AnomalyFlag: request.AnomalyFlag, + AnomalyName: constants.GetWithdrawalAnomalyName(request.AnomalyFlag), + AnomalyReason: request.AnomalyReason, + Attempts: make([]dto.WithdrawalRequestAttemptItem, 0, len(attempts)), + } + if request.ApprovalInstanceID != nil { + detail.ApprovalInstanceID = *request.ApprovalInstanceID + } + for index := range attempts { + attempt := &attempts[index] + instanceID := uint(0) + if attempt.ApprovalInstanceID != nil { + instanceID = *attempt.ApprovalInstanceID + } + item := dto.WithdrawalRequestAttemptItem{ + ID: attempt.ID, AttemptNo: attempt.AttemptNo, Amount: attempt.Amount, + Fee: attempt.Fee, FeeRate: attempt.FeeRate, ActualAmount: attempt.ActualAmount, + WithdrawalMethod: attempt.WithdrawalMethod, SubmittedByID: attempt.SubmittedByAccountID, + ApprovalInstanceID: instanceID, + CreatedAt: attempt.CreatedAt.Format("2006-01-02 15:04:05"), + } + if status, ok := instanceStatuses[instanceID]; ok { + item.ApprovalStatus = status + item.ApprovalStatusName = constants.GetApprovalStatusName(status) + } else { + item.ApprovalStatusName = constants.GetApprovalStatusName(-1) + } + if attempt.ReleasedAt != nil { + item.ReleasedAt = attempt.ReleasedAt.Format("2006-01-02 15:04:05") + } + detail.Attempts = append(detail.Attempts, item) + } + return detail, nil +} + +// shopNames 批量读取店铺名称,缺失店铺留空。 +func (q *Query) shopNames(ctx context.Context, versions []model.WithdrawalQualification) (map[uint]string, error) { + shopIDs := make([]uint, 0, len(versions)) + seen := make(map[uint]struct{}, len(versions)) + for _, version := range versions { + if version.ShopID == 0 { + continue + } + if _, exists := seen[version.ShopID]; exists { + continue + } + seen[version.ShopID] = struct{}{} + shopIDs = append(shopIDs, version.ShopID) + } + names := make(map[uint]string, len(shopIDs)) + if len(shopIDs) == 0 { + return names, nil + } + var shops []model.Shop + if err := q.db.WithContext(ctx).Select("id", "shop_name").Where("id IN ?", shopIDs).Find(&shops).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺名称失败") + } + for _, shop := range shops { + names[shop.ID] = shop.ShopName + } + return names, nil +} + +// approvalStatuses 批量读取通用审批实例状态,缺失实例不进入结果。 +func (q *Query) approvalStatuses( + ctx context.Context, + businessType string, + instanceIDs []uint, +) (map[uint]int, error) { + statuses := make(map[uint]int, len(instanceIDs)) + if len(instanceIDs) == 0 { + return statuses, nil + } + var instances []model.ApprovalInstance + if err := q.db.WithContext(ctx).Select("id", "status"). + Where("business_type = ? AND id IN ?", businessType, instanceIDs). + Find(&instances).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批实例状态失败") + } + for _, instance := range instances { + statuses[instance.ID] = instance.Status + } + return statuses, nil +} + +// instanceIDsOfQualifications 提取资料版本关联的审批实例 ID。 +func instanceIDsOfQualifications(versions []model.WithdrawalQualification) []uint { + ids := make([]uint, 0, len(versions)) + for index := range versions { + if versions[index].ApprovalInstanceID != nil { + ids = append(ids, *versions[index].ApprovalInstanceID) + } + } + return ids +} + +// instanceIDsOfAttempts 提取提现审批尝试关联的审批实例 ID。 +func instanceIDsOfAttempts(attempts []model.CommissionWithdrawalRequestAttempt) []uint { + ids := make([]uint, 0, len(attempts)) + for index := range attempts { + if attempts[index].ApprovalInstanceID != nil { + ids = append(ids, *attempts[index].ApprovalInstanceID) + } + } + return ids +} + +// decodeAttemptAccount 解析收款账户快照,解析失败时留空。 +func decodeAttemptAccount(payload []byte) (string, string) { + var info map[string]string + if err := json.Unmarshal(payload, &info); err != nil { + return "", "" + } + return info["account_name"], info["account_number"] +} diff --git a/internal/query/shop/business_owner.go b/internal/query/shop/business_owner.go index eeff825..6657597 100644 --- a/internal/query/shop/business_owner.go +++ b/internal/query/shop/business_owner.go @@ -135,7 +135,8 @@ func (q *BusinessOwnerQuery) project(ctx context.Context, shops []*model.Shop) ( responses := make([]*dto.ShopResponse, 0, len(shops)) for _, shop := range shops { response := &dto.ShopResponse{ - ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, ParentID: shop.ParentID, + ID: shop.ID, ShopName: shop.ShopName, ShopCode: shop.ShopCode, + DistributionCode: shop.DistributionCode, ParentID: shop.ParentID, BusinessOwnerAccountID: shop.BusinessOwnerAccountID, Level: shop.Level, ContactName: shop.ContactName, ContactPhone: shop.ContactPhone, Province: shop.Province, City: shop.City, District: shop.District, Address: shop.Address, Status: shop.Status, diff --git a/internal/routes/admin.go b/internal/routes/admin.go index c92858c..06e77bd 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -31,6 +31,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.ShopCommission != nil { registerShopCommissionRoutes(authGroup, handlers.ShopCommission, doc, basePath) } + if handlers.WithdrawalQualification != nil { + registerWithdrawalQualificationRoutes(authGroup, handlers.WithdrawalQualification, doc, basePath) + } if handlers.Shop != nil { registerShopDetailRoute(authGroup, handlers.Shop, doc, basePath) } diff --git a/internal/routes/agent_distribution_public.go b/internal/routes/agent_distribution_public.go new file mode 100644 index 0000000..96f6529 --- /dev/null +++ b/internal/routes/agent_distribution_public.go @@ -0,0 +1,25 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/handler/app" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// registerAgentDistributionPublicRoutes 注册代理分销公开路由。 +// Fiber 的 Group.Use() 会匹配该前缀下所有请求,因此公开路由必须在个人客户路由的 Use() 之前注册。 +func registerAgentDistributionPublicRoutes(router fiber.Router, handler *app.AgentDistributionHandler, doc *openapi.Generator, basePath string) { + if handler == nil { + return + } + Register(router, doc, basePath, "POST", "/agent-distribution-registrations", handler.RegisterAgentDistribution, RouteSpec{ + Summary: "代理扫码注册", + Description: "公开接口,无需认证。请求必须携带有效分销码、短信已验证手机号与密码;无效分销码、分销码所属店铺已停用、验证码无效或已被消费统一返回“分销码不可用”,且不创建注册记录、店铺或账号。通过后仍需企业微信终审才会创建店铺与代理账号。", + Tags: []string{"个人客户 - 代理分销注册"}, + Auth: false, + Input: new(dto.CreateAgentDistributionRegistrationReq), + Output: new(dto.CreateAgentDistributionRegistrationResp), + }) +} diff --git a/internal/routes/personal.go b/internal/routes/personal.go index 72e45b2..1cbf276 100644 --- a/internal/routes/personal.go +++ b/internal/routes/personal.go @@ -22,6 +22,9 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, authBasePath := "/auth" // === 公开路由(无需认证)=== + // 代理扫码注册必须在任何 Use() 调用之前注册,否则会被后续认证中间件拦截。 + registerAgentDistributionPublicRoutes(router, handlers.AgentDistribution, doc, basePath) + Register(router, doc, basePath, "GET", "/wechat/appid", handlers.ClientWechat.GetAppID, RouteSpec{ Summary: "获取当前生效的公众号 AppID", Description: "用于 C 端免登录场景获取当前生效微信配置中的公众号 AppID,响应仅返回 app_id 字段", diff --git a/internal/routes/shop.go b/internal/routes/shop.go index c49752d..678c4e8 100644 --- a/internal/routes/shop.go +++ b/internal/routes/shop.go @@ -191,6 +191,24 @@ func registerShopCommissionRoutes(router fiber.Router, handler *admin.ShopCommis Auth: true, }) + Register(shops, doc, groupPath, "PUT", "/:shop_id/withdrawal-requests/:id", handler.ResubmitWithdrawal, RouteSpec{ + Summary: "重提被驳回的提现申请", + Description: "仅本人代理店铺,且仅已被企业微信驳回的申请可重提。事务内先释放旧未结算冻结再按新金额冻结,新增审批尝试记录与新的审批实例,历史尝试与审批结果不被覆盖。", + Tags: []string{"代理商资金管理"}, + Input: new(dto.ResubmitWithdrawalReq), + Output: new(dto.CreateMyWithdrawalResp), + Auth: true, + }) + + Register(shops, doc, groupPath, "GET", "/:shop_id/withdrawal-requests/:id", handler.WithdrawalDetail, RouteSpec{ + Summary: "提现申请详情", + Description: "返回指定提现申请及其全部审批尝试记录与正交异常标记,用于详情展示;仅限有数据范围的后台账号。", + Tags: []string{"代理商资金管理"}, + Input: new(dto.ShopWithdrawalRequestDetailReq), + Output: new(dto.ShopWithdrawalRequestDetailResp), + Auth: true, + }) + commissionRecords := router.Group("/commission-records") crPath := basePath + "/commission-records" @@ -210,3 +228,39 @@ func agentFundManagement(handler fiber.Handler) fiber.Handler { return handler(c) } } + +// registerWithdrawalQualificationRoutes 注册提现资料资格路由。 +// 资格提交与查询沿用既有认证与数据范围校验,绝不公开。 +func registerWithdrawalQualificationRoutes(router fiber.Router, handler *admin.WithdrawalQualificationHandler, doc *openapi.Generator, basePath string) { + shops := router.Group("/shops") + shopsPath := basePath + "/shops" + qualifications := router.Group("/withdrawal-qualifications") + qualificationPath := basePath + "/withdrawal-qualifications" + + Register(shops, doc, shopsPath, "POST", "/:shop_id/withdrawal-qualifications", agentFundManagement(handler.SubmitWithdrawalQualification), RouteSpec{ + Summary: "提交提现资料资格", + Description: "仅本人代理店铺。合同与法人身份证正反面必填;企业必填统一社会信用代码,个人填写法人身份证号。替换合同或法人身份证时同一事务新增资料版本并使旧有效版本失效,需重新审批通过后才可提现。", + Tags: []string{"代理商提现资格"}, + Input: new(dto.SubmitWithdrawalQualificationReq), + Output: new(dto.SubmitWithdrawalQualificationResp), + Auth: true, + }) + + Register(shops, doc, shopsPath, "GET", "/:shop_id/withdrawal-qualifications", agentFundManagement(handler.ListWithdrawalQualifications), RouteSpec{ + Summary: "查询提现资料资格版本", + Description: "仅返回当前账号数据范围内店铺的资料版本,按版本从新到旧;证件号脱敏,附件只返回对象存储 Key 引用。", + Tags: []string{"代理商提现资格"}, + Input: new(dto.WithdrawalQualificationListReq), + Output: new(dto.WithdrawalQualificationPageResult), + Auth: true, + }) + + Register(qualifications, doc, qualificationPath, "POST", "/:id/void", agentFundManagement(handler.VoidWithdrawalQualification), RouteSpec{ + Summary: "作废提现资料资格", + Description: "仅超级管理员;reason 必填。作废后该资格失效且原因可查询,代理提现被拒绝直至重新审批通过。", + Tags: []string{"代理商提现资格"}, + Input: new(dto.VoidWithdrawalQualificationParams), + Output: nil, + Auth: true, + }) +} diff --git a/internal/service/commission_withdrawal/service.go b/internal/service/commission_withdrawal/service.go index ef2bf50..756b2fb 100644 --- a/internal/service/commission_withdrawal/service.go +++ b/internal/service/commission_withdrawal/service.go @@ -160,6 +160,13 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw return nil, errors.New(errors.CodeNotFound, "提现申请不存在") } + if withdrawal.ApprovalInstanceID != nil { + // 已关联企业微信审批实例的申请只接受渠道终审,本地人工终审一律拒绝且不改动任何事实。 + businessErr := errors.New(errors.CodeConflict, "提现申请已接入企业微信终审,不支持本地人工处理") + s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", businessErr) + return nil, businessErr + } + if withdrawal.Status != constants.WithdrawalStatusPending { businessErr := errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作") s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", businessErr) @@ -280,6 +287,13 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawal return nil, errors.New(errors.CodeNotFound, "提现申请不存在") } + if withdrawal.ApprovalInstanceID != nil { + // 已关联企业微信审批实例的申请只接受渠道终审,本地人工终审一律拒绝且不改动任何事实。 + businessErr := errors.New(errors.CodeConflict, "提现申请已接入企业微信终审,不支持本地人工处理") + s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请失败", businessErr) + return nil, businessErr + } + if withdrawal.Status != constants.WithdrawalStatusPending { businessErr := errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作") s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请失败", businessErr) diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index d4e80b7..0ec50eb 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -896,13 +896,20 @@ func (s *Service) deductSingleCommission(ctx context.Context, refund *model.Refu } return nil } + // 全库统一加锁顺序:申请行 → 尝试行 → 钱包行(钱包永远最后)。 + // 待审提现的加锁与释放额计算必须发生在钱包加锁之前,否则与企微终态消费者 + // (申请 → 尝试 → 钱包)形成 A→B、B→A 的死锁环。 + rejects, err := s.collectPendingWithdrawalRejects(ctx, tx, current.ShopID) + if err != nil { + return err + } var wallet model.AgentWallet if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("shop_id = ? AND wallet_type = ?", current.ShopID, constants.AgentWalletTypeCommission). First(&wallet).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款佣金钱包失败") } - if err := s.rejectPendingWithdrawals(ctx, tx, &wallet, current.ShopID, refund); err != nil { + if err := s.applyPendingWithdrawalRejects(ctx, tx, &wallet, refund, rejects); err != nil { return err } result := tx.WithContext(ctx).Model(&model.AgentWallet{}). @@ -939,39 +946,98 @@ func (s *Service) deductSingleCommission(ctx context.Context, refund *model.Refu }) } -// rejectPendingWithdrawals 回扣佣金前拒绝该店铺所有待审核提现。 -// 提现冻结的是佣金余额,退款回扣优先级更高;先解冻并拒绝,避免已回扣佣金仍被提现。 -func (s *Service) rejectPendingWithdrawals(ctx context.Context, tx *gorm.DB, wallet *model.AgentWallet, shopID uint, refund *model.RefundRequest) error { +// pendingWithdrawalReject 是一条待审提现的本次拒绝事实。 +// releaseAmount 是本次实际释放额:接入企业微信审批的申请取尝试记录事实, +// 从未关联审批实例的存量申请取申请金额;已结算尝试的本次释放额为 0。 +type pendingWithdrawalReject struct { + Withdrawal model.CommissionWithdrawalRequest + Before map[string]any + ReleaseAmount int64 +} + +// collectPendingWithdrawalRejects 在钱包加锁之前锁定该店铺全部待审提现并算出本次释放额。 +// 加锁顺序:申请行(FOR UPDATE)→ 尝试行(FOR UPDATE),钱包行留给调用方最后加锁。 +func (s *Service) collectPendingWithdrawalRejects( + ctx context.Context, + tx *gorm.DB, + shopID uint, +) ([]pendingWithdrawalReject, error) { var withdrawals []model.CommissionWithdrawalRequest if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). Where("shop_id = ? AND status = ?", shopID, constants.WithdrawalStatusPending). - Find(&withdrawals).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询待审核佣金提现失败") + Order("id ASC").Find(&withdrawals).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待审核佣金提现失败") } if len(withdrawals) == 0 { + return nil, nil + } + requestIDs := make([]uint, 0, len(withdrawals)) + for i := range withdrawals { + requestIDs = append(requestIDs, withdrawals[i].ID) + } + releasedAmounts, err := postgres.ReleaseUnsettledForRequestsInTx(ctx, tx, requestIDs, time.Now().UTC()) + if err != nil { + return nil, err + } + withAttempts, err := postgres.CountRequestsWithAttemptsInTx(ctx, tx, requestIDs) + if err != nil { + return nil, err + } + rejects := make([]pendingWithdrawalReject, 0, len(withdrawals)) + for i := range withdrawals { + withdrawal := withdrawals[i] + releaseAmount := withdrawal.Amount + if _, hasAttempt := withAttempts[withdrawal.ID]; hasAttempt { + // 接入企业微信审批的申请:释放金额取本次实际释放的尝试事实。 + releaseAmount = releasedAmounts[withdrawal.ID] + } + rejects = append(rejects, pendingWithdrawalReject{ + Withdrawal: withdrawal, Before: withdrawalRejectState(&withdrawal), ReleaseAmount: releaseAmount, + }) + } + return rejects, nil +} + +// applyPendingWithdrawalRejects 按本次实际释放额解冻钱包、置驳回并写流水与审计。 +// 释放额为 0 时不写无意义的 0 金额流水,审计也不带钱包流水资源。 +func (s *Service) applyPendingWithdrawalRejects( + ctx context.Context, + tx *gorm.DB, + wallet *model.AgentWallet, + refund *model.RefundRequest, + rejects []pendingWithdrawalReject, +) error { + if len(rejects) == 0 { return nil } var shop model.Shop - if err := tx.WithContext(ctx).First(&shop, shopID).Error; err != nil { + if err := tx.WithContext(ctx).First(&shop, wallet.ShopID).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询提现店铺失败") } - for i := range withdrawals { - w := &withdrawals[i] - before := withdrawalRejectState(w) - if err := s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, w.Amount); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "解冻提现冻结余额失败") + remark := "退款佣金回扣,自动拒绝提现" + for i := range rejects { + reject := &rejects[i] + w := &reject.Withdrawal + releaseAmount := reject.ReleaseAmount + if releaseAmount > 0 { + if err := s.agentWalletStore.UnfreezeBalanceWithTx(ctx, tx, wallet.ID, releaseAmount); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "解冻提现冻结余额失败") + } } - refType := constants.ReferenceTypeWithdrawal - remark := "退款佣金回扣,自动拒绝提现" - transaction := &model.AgentWalletTransaction{ - AgentWalletID: wallet.ID, ShopID: shopID, UserID: refund.Creator, - TransactionType: constants.AgentTransactionTypeRefund, Amount: w.Amount, - BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance, - Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceID: &w.ID, - Remark: &remark, Creator: refund.Creator, ShopIDTag: shopID, - } - if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建自动拒绝提现流水失败") + var transaction *model.AgentWalletTransaction + if releaseAmount > 0 { + // 只有本次确实释放了冻结才写流水,避免产生无意义的 0 金额流水。 + refType := constants.ReferenceTypeWithdrawal + transaction = &model.AgentWalletTransaction{ + AgentWalletID: wallet.ID, ShopID: wallet.ShopID, UserID: refund.Creator, + TransactionType: constants.AgentTransactionTypeRefund, Amount: releaseAmount, + BalanceBefore: wallet.Balance, BalanceAfter: wallet.Balance, + Status: constants.TransactionStatusSuccess, ReferenceType: &refType, ReferenceID: &w.ID, + Remark: &remark, Creator: refund.Creator, ShopIDTag: wallet.ShopID, + } + if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建自动拒绝提现流水失败") + } } now := time.Now() result := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). @@ -991,7 +1057,10 @@ func (s *Service) rejectPendingWithdrawals(ctx context.Context, tx *gorm.DB, wal w.Status = constants.WithdrawalStatusRejected w.ProcessedAt = &now w.RejectReason = remark - if err := s.appendWithdrawalRejectAudit(ctx, tx, w, wallet, transaction, &shop, before); err != nil { + frozenBefore := wallet.FrozenBalance + wallet.FrozenBalance = frozenBefore - releaseAmount + if err := s.appendWithdrawalRejectAudit(ctx, tx, w, wallet, transaction, &shop, + reject.Before, releaseAmount, frozenBefore); err != nil { return err } } @@ -1005,7 +1074,17 @@ func withdrawalRejectState(w *model.CommissionWithdrawalRequest) map[string]any } } -func (s *Service) appendWithdrawalRejectAudit(ctx context.Context, tx *gorm.DB, withdrawal *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction, shop *model.Shop, before map[string]any) error { +func (s *Service) appendWithdrawalRejectAudit( + ctx context.Context, + tx *gorm.DB, + withdrawal *model.CommissionWithdrawalRequest, + wallet *model.AgentWallet, + transaction *model.AgentWalletTransaction, + shop *model.Shop, + before map[string]any, + releaseAmount int64, + frozenBefore int64, +) error { if s.auditWriter == nil { return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置") } @@ -1013,21 +1092,32 @@ func (s *Service) appendWithdrawalRejectAudit(ctx context.Context, tx *gorm.DB, before, withdrawalRejectState(withdrawal)) primary.SubjectVisibility = constants.AuditSubjectResult primary.SubjectSummary = "退款佣金回扣自动拒绝提现" + // 审计前后冻结额一律取本次实际释放额,避免与实际释放不一致甚至为负。 walletResource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalWallet, - map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance}, - map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance - withdrawal.Amount}) + map[string]any{"balance": wallet.Balance, "frozen_balance": frozenBefore}, + map[string]any{"balance": wallet.Balance, "frozen_balance": frozenBefore - releaseAmount}) walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly - transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalTransaction) - transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly shopResource := audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalShop) shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly - return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + resources := []audit.ResourceInput{primary, walletResource, shopResource} + if transaction != nil { + transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalTransaction) + transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, transactionResource) + } + // 与分销/提现终审路径统一口径:这笔审计属于「要求成功必达」的拒绝事实, + // 必须与业务事实同事务原子提交,因此使用非吞错变体并显式返回错误。 + if _, err := s.auditWriter.AppendAndGet(ctx, tx, audit.AppendInput{ EventID: "commission-withdrawal:" + strconv.FormatUint(uint64(withdrawal.ID), 10) + ":refund-rejected", ActionCode: constants.AuditActionCommissionWithdrawalRejected, Summary: "退款佣金回扣自动拒绝提现", ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, - CorrelationID: withdrawal.WithdrawalNo, Metadata: map[string]any{"amount": withdrawal.Amount, "status": withdrawal.Status}, - Resources: []audit.ResourceInput{primary, walletResource, transactionResource, shopResource}, - }) + CorrelationID: withdrawal.WithdrawalNo, + Metadata: map[string]any{"amount": withdrawal.Amount, "released_amount": releaseAmount, "status": withdrawal.Status}, + Resources: resources, + }); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入退款自动拒绝提现审计失败") + } + return nil } // handleRefundAssetProcessing 幂等处理退款后的资产状态。 diff --git a/internal/service/shop/service.go b/internal/service/shop/service.go index 12d0755..97b8566 100644 --- a/internal/service/shop/service.go +++ b/internal/service/shop/service.go @@ -17,13 +17,14 @@ import ( ) type Service struct { - db *gorm.DB - redisClient *redis.Client - accessAudit accessauditapp.Writer - shopStore *postgres.ShopStore - accountStore *postgres.AccountStore - shopRoleStore *postgres.ShopRoleStore - roleStore *postgres.RoleStore + db *gorm.DB + redisClient *redis.Client + accessAudit accessauditapp.Writer + qualificationInvalidator WithdrawalQualificationInvalidator + shopStore *postgres.ShopStore + accountStore *postgres.AccountStore + shopRoleStore *postgres.ShopRoleStore + roleStore *postgres.RoleStore } // SetAccessAudit 注入店铺角色授权的事务、缓存和统一审计边界。 @@ -47,6 +48,17 @@ func New( } } +// WithdrawalQualificationInvalidator 在店铺停用事务内联动失效提现资料资格。 +type WithdrawalQualificationInvalidator interface { + InvalidateByShopDisable(ctx context.Context, tx *gorm.DB, shopID uint, reason string) error +} + +// SetWithdrawalQualificationInvalidator 注入店铺停用联动的提现资料资格失效接缝。 +// 未注入时停用不联动,用于不依赖该能力的旧装配路径。 +func (s *Service) SetWithdrawalQualificationInvalidator(invalidator WithdrawalQualificationInvalidator) { + s.qualificationInvalidator = invalidator +} + func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopRequest) (*dto.ShopResponse, error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { @@ -58,6 +70,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopReques return nil, errors.New(errors.CodeShopNotFound, "店铺不存在") } + previousStatus := shop.Status shop.ShopName = req.ShopName shop.ContactName = req.ContactName shop.ContactPhone = req.ContactPhone @@ -68,7 +81,7 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopReques shop.Status = req.Status shop.Updater = currentUserID - if err := s.shopStore.Update(ctx, shop); err != nil { + if err := s.persistShopWithQualificationInvalidation(ctx, shop, previousStatus); err != nil { return nil, err } @@ -81,22 +94,23 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopReques } return &dto.ShopResponse{ - ID: shop.ID, - ShopName: shop.ShopName, - ShopCode: shop.ShopCode, - ParentID: shop.ParentID, - ParentShopName: parentShopName, - Level: shop.Level, - ContactName: shop.ContactName, - ContactPhone: shop.ContactPhone, - Province: shop.Province, - City: shop.City, - District: shop.District, - Address: shop.Address, - Status: shop.Status, - StatusName: constants.GetStatusName(shop.Status), - CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"), - UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"), + ID: shop.ID, + ShopName: shop.ShopName, + ShopCode: shop.ShopCode, + DistributionCode: shop.DistributionCode, + ParentID: shop.ParentID, + ParentShopName: parentShopName, + Level: shop.Level, + ContactName: shop.ContactName, + ContactPhone: shop.ContactPhone, + Province: shop.Province, + City: shop.City, + District: shop.District, + Address: shop.Address, + Status: shop.Status, + StatusName: constants.GetStatusName(shop.Status), + CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"), }, nil } @@ -115,10 +129,31 @@ func (s *Service) Disable(ctx context.Context, id uint) error { } // 更新状态 + previousStatus := shop.Status shop.Status = constants.StatusDisabled shop.Updater = currentUserID - return s.shopStore.Update(ctx, shop) + return s.persistShopWithQualificationInvalidation(ctx, shop, previousStatus) +} + +// persistShopWithQualificationInvalidation 保存店铺状态,并在本次停用时联动失效提现资料资格。 +// 停用与失效必须同事务提交,避免店铺已停用而资格仍显示有效。 +func (s *Service) persistShopWithQualificationInvalidation( + ctx context.Context, + shop *model.Shop, + previousStatus int, +) error { + disabling := previousStatus != constants.ShopStatusDisabled && shop.Status == constants.ShopStatusDisabled + if !disabling || s.qualificationInvalidator == nil || s.db == nil { + return s.shopStore.Update(ctx, shop) + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewShopStore(tx, s.redisClient).Update(ctx, shop); err != nil { + return err + } + return s.qualificationInvalidator.InvalidateByShopDisable( + ctx, tx, shop.ID, "代理店铺已停用,提现资料资格自动失效") + }) } // Enable 启用店铺 @@ -202,22 +237,23 @@ func (s *Service) ListShopResponses(ctx context.Context, req *dto.ShopListReques } responses = append(responses, &dto.ShopResponse{ - ID: shop.ID, - ShopName: shop.ShopName, - ShopCode: shop.ShopCode, - ParentID: shop.ParentID, - ParentShopName: parentShopName, - Level: shop.Level, - ContactName: shop.ContactName, - ContactPhone: shop.ContactPhone, - Province: shop.Province, - City: shop.City, - District: shop.District, - Address: shop.Address, - Status: shop.Status, - StatusName: constants.GetStatusName(shop.Status), - CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"), - UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"), + ID: shop.ID, + ShopName: shop.ShopName, + ShopCode: shop.ShopCode, + DistributionCode: shop.DistributionCode, + ParentID: shop.ParentID, + ParentShopName: parentShopName, + Level: shop.Level, + ContactName: shop.ContactName, + ContactPhone: shop.ContactPhone, + Province: shop.Province, + City: shop.City, + District: shop.District, + Address: shop.Address, + Status: shop.Status, + StatusName: constants.GetStatusName(shop.Status), + CreatedAt: shop.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: shop.UpdatedAt.Format("2006-01-02 15:04:05"), }) } @@ -336,6 +372,15 @@ func (s *Service) Delete(ctx context.Context, id uint) error { return errors.Wrap(errors.CodeDatabaseError, err, "禁用店铺账号失败") } } + // 店铺删除与停用同等失效该店铺全部有效提现资料资格。 + // 必须在软删除之前执行:失效路径按 deleted_at IS NULL 读取店铺以写审计, + // 删除后再调用会因店铺不可见而报 NotFound,导致含有效资格的店铺永远删不掉。 + if s.qualificationInvalidator != nil { + if err := s.qualificationInvalidator.InvalidateByShopDisable( + ctx, tx, locked.ID, "代理店铺已删除,提现资料资格自动失效"); err != nil { + return err + } + } if err := tx.Delete(&model.Shop{}, id).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "删除店铺失败") } diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index 6dfdb3f..1f242a1 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -7,6 +7,7 @@ import ( "math/rand" "time" + "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal" "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" @@ -30,6 +31,7 @@ type Service struct { agentWalletTransactionStore *postgres.AgentWalletTransactionStore db *gorm.DB auditWriter *audit.Writer + withdrawalApprovalService *distributionwithdrawal.WithdrawalService logger *zap.Logger } @@ -38,6 +40,12 @@ func (s *Service) SetAuditWriter(writer *audit.Writer) { s.auditWriter = writer } +// SetWithdrawalApprovalService 注入接入企业微信终审的提现申请用例。 +// 未注入时发起提现失败关闭,避免绕过资料资格校验与审批实例创建。 +func (s *Service) SetWithdrawalApprovalService(service *distributionwithdrawal.WithdrawalService) { + s.withdrawalApprovalService = service +} + // New 创建代理商资金管理服务 func New( shopStore *postgres.ShopStore, @@ -409,7 +417,12 @@ func (s *Service) GetDailyStats(ctx context.Context, shopID uint, req *dto.Daily // CreateWithdrawalRequest 代理发起提现申请 // POST /shops/:id/withdrawal-requests +// 先校验提现配置与本人代理身份,再在同一事务内校验有效提现资料资格、冻结余额、 +// 写审批尝试记录并创建企业微信审批实例。任何校验失败都不创建申请、审批实例或冻结。 func (s *Service) CreateWithdrawalRequest(ctx context.Context, shopID uint, req *dto.CreateMyWithdrawalReq) (*dto.CreateMyWithdrawalResp, error) { + if s.withdrawalApprovalService == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "提现审批能力尚未配置") + } // 提现权限比查询更严格:必须是代理账号本人操作,不允许平台人员或顶级代理替下级提现 userType := middleware.GetUserTypeFromContext(ctx) if userType != constants.UserTypeAgent { @@ -418,130 +431,86 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, shopID uint, req if shopID != middleware.GetShopIDFromContext(ctx) { return nil, errors.New(errors.CodeForbidden, "仅可为本人店铺发起提现") } - - currentUserID := middleware.GetUserIDFromContext(ctx) - if currentUserID == 0 { - return nil, errors.New(errors.CodeForbidden, "无法获取用户信息") - } - - // 获取提现配置 - setting, err := s.commissionWithdrawalSettingStore.GetCurrent(ctx) + policy, err := s.currentWithdrawalPolicy(ctx) if err != nil { - return nil, errors.New(errors.CodeInvalidParam, "暂未开放提现功能") - } - - // 验证最低提现金额 - if req.Amount < setting.MinWithdrawalAmount { - return nil, errors.New(errors.CodeInvalidParam, fmt.Sprintf("提现金额不能低于 %.2f 元", float64(setting.MinWithdrawalAmount)/100)) - } - - // 获取佣金钱包 - wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, shopID) - if err != nil { - return nil, errors.New(errors.CodeInsufficientBalance, "钱包不存在") - } - - // 验证可用余额 - if req.Amount > wallet.GetAvailableBalance() { - return nil, errors.New(errors.CodeInsufficientBalance, "可提现余额不足") - } - - // 验证今日提现次数 - today := time.Now().Format("2006-01-02") - var todayCount int64 - s.db.WithContext(ctx).Model(&model.CommissionWithdrawalRequest{}). - Where("shop_id = ? AND created_at >= ? AND created_at <= ?", shopID, today+" 00:00:00", today+" 23:59:59"). - Count(&todayCount) - if int(todayCount) >= setting.DailyWithdrawalLimit { - return nil, errors.New(errors.CodeInvalidParam, "今日提现次数已达上限") - } - - // 计算手续费 - fee := req.Amount * setting.FeeRate / 10000 - actualAmount := req.Amount - fee - withdrawalNo := generateWithdrawalNo() - - accountInfo := map[string]string{ - "account_name": req.AccountName, - "account_number": req.AccountNumber, - } - accountInfoJSON, _ := json.Marshal(accountInfo) - - withdrawalRequest := &model.CommissionWithdrawalRequest{ - WithdrawalNo: withdrawalNo, - ShopID: shopID, - ApplicantID: currentUserID, - Amount: req.Amount, - FeeRate: setting.FeeRate, - Fee: fee, - ActualAmount: actualAmount, - WithdrawalMethod: req.WithdrawalMethod, - AccountInfo: accountInfoJSON, - Status: constants.WithdrawalStatusPending, - } - withdrawalRequest.Creator = currentUserID - withdrawalRequest.Updater = currentUserID - - err = s.db.Transaction(func(tx *gorm.DB) error { - // 使用条件更新防并发 - result := tx.WithContext(ctx).Model(&model.AgentWallet{}). - Where("id = ? AND balance - frozen_balance >= ?", wallet.ID, req.Amount). - Updates(map[string]interface{}{ - "frozen_balance": gorm.Expr("frozen_balance + ?", req.Amount), - }) - if result.Error != nil { - return errors.Wrap(errors.CodeInternalError, result.Error, "冻结余额失败") - } - if result.RowsAffected == 0 { - return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突,请稍后重试") - } - - if err := tx.WithContext(ctx).Create(withdrawalRequest).Error; err != nil { - return errors.Wrap(errors.CodeInternalError, err, "创建提现申请失败") - } - - remark := fmt.Sprintf("提现冻结,单号:%s", withdrawalNo) - refType := constants.ReferenceTypeWithdrawal - transaction := &model.AgentWalletTransaction{ - AgentWalletID: wallet.ID, - ShopID: shopID, - UserID: currentUserID, - TransactionType: constants.AgentTransactionTypeWithdrawal, - Amount: -req.Amount, - BalanceBefore: wallet.Balance, - BalanceAfter: wallet.Balance - req.Amount, - Status: constants.TransactionStatusProcessing, - ReferenceType: &refType, - ReferenceID: &withdrawalRequest.ID, - Remark: &remark, - Creator: currentUserID, - ShopIDTag: shopID, - } - - if err := tx.WithContext(ctx).Create(transaction).Error; err != nil { - return errors.Wrap(errors.CodeInternalError, err, "创建钱包流水失败") - } - - return s.appendWithdrawalRequestAudit(ctx, tx, withdrawalRequest, wallet, transaction) - }) - if err != nil { - s.recordWithdrawalRequestFailure(ctx, withdrawalRequest, wallet, err) return nil, err } + result, err := s.withdrawalApprovalService.Create(ctx, shopID, policy, distributionwithdrawal.WithdrawalInput{ + Amount: req.Amount, + WithdrawalMethod: req.WithdrawalMethod, + AccountName: req.AccountName, + AccountNumber: req.AccountNumber, + }) + if err != nil { + return nil, err + } + return withdrawalApprovalResp(result), nil +} - return &dto.CreateMyWithdrawalResp{ - ID: withdrawalRequest.ID, - WithdrawalNo: withdrawalRequest.WithdrawalNo, - Amount: withdrawalRequest.Amount, - FeeRate: withdrawalRequest.FeeRate, - Fee: withdrawalRequest.Fee, - ActualAmount: withdrawalRequest.ActualAmount, - Status: withdrawalRequest.Status, - StatusName: constants.GetWithdrawalStatusName(withdrawalRequest.Status), - CreatedAt: withdrawalRequest.CreatedAt.Format("2006-01-02 15:04:05"), +// ResubmitWithdrawalRequest 代理修改金额、收款信息与本次发票后重提已被驳回的提现申请 +// PUT /shops/:shop_id/withdrawal-requests/:id +// 事务内先释放旧未结算尝试的冻结,再按新金额冻结;历史尝试与审批结果不被覆盖。 +func (s *Service) ResubmitWithdrawalRequest(ctx context.Context, shopID uint, requestID uint, req *dto.ResubmitWithdrawalReq) (*dto.CreateMyWithdrawalResp, error) { + if s.withdrawalApprovalService == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "提现审批能力尚未配置") + } + userType := middleware.GetUserTypeFromContext(ctx) + if userType != constants.UserTypeAgent { + return nil, errors.New(errors.CodeForbidden, "仅代理商用户可重提提现") + } + if shopID == 0 || shopID != middleware.GetShopIDFromContext(ctx) { + return nil, errors.New(errors.CodeForbidden, "仅可为本人店铺重提提现") + } + // 先复核申请属于该店铺,越权与不存在返回同一结果。 + existing, err := s.commissionWithdrawalReqStore.GetByID(ctx, requestID) + if err != nil || existing == nil || existing.ShopID != shopID { + return nil, errors.New(errors.CodeNotFound, "提现申请不存在") + } + policy, err := s.currentWithdrawalPolicy(ctx) + if err != nil { + return nil, err + } + result, err := s.withdrawalApprovalService.Resubmit(ctx, requestID, policy, distributionwithdrawal.WithdrawalInput{ + Amount: req.Amount, + WithdrawalMethod: req.WithdrawalMethod, + AccountName: req.AccountName, + AccountNumber: req.AccountNumber, + InvoiceKeys: req.InvoiceKeys, + }) + if err != nil { + return nil, err + } + return withdrawalApprovalResp(result), nil +} + +// currentWithdrawalPolicy 读取当前提现配置为调用方策略快照,未开放提现时返回稳定错误。 +func (s *Service) currentWithdrawalPolicy(ctx context.Context) (distributionwithdrawal.WithdrawalPolicy, error) { + setting, err := s.commissionWithdrawalSettingStore.GetCurrent(ctx) + if err != nil { + return distributionwithdrawal.WithdrawalPolicy{}, errors.New(errors.CodeInvalidParam, "暂未开放提现功能") + } + return distributionwithdrawal.WithdrawalPolicy{ + MinAmount: setting.MinWithdrawalAmount, + FeeRate: setting.FeeRate, + DailyWithdrawalLimit: setting.DailyWithdrawalLimit, }, nil } +// withdrawalApprovalResp 把提现用例结果映射为发起提现响应。 +func withdrawalApprovalResp(result *distributionwithdrawal.WithdrawalResult) *dto.CreateMyWithdrawalResp { + return &dto.CreateMyWithdrawalResp{ + ID: result.RequestID, + WithdrawalNo: result.WithdrawalNo, + Amount: result.Amount, + FeeRate: result.FeeRate, + Fee: result.Fee, + ActualAmount: result.ActualAmount, + Status: result.Status, + StatusName: constants.GetWithdrawalStatusName(result.Status), + CreatedAt: result.CreatedAt.Format("2006-01-02 15:04:05"), + } +} + // ListMainWalletTransactions 查询代理主钱包(预充值钱包)流水 // GET /shops/:id/main-wallet/transactions func (s *Service) ListMainWalletTransactions(ctx context.Context, shopID uint, req *dto.MainWalletTransactionListRequest) (*dto.MainWalletTransactionListResponse, error) { diff --git a/internal/store/postgres/withdrawal_attempt_release.go b/internal/store/postgres/withdrawal_attempt_release.go new file mode 100644 index 0000000..5314e5e --- /dev/null +++ b/internal/store/postgres/withdrawal_attempt_release.go @@ -0,0 +1,76 @@ +package postgres + +import ( + "context" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ReleaseUnsettledForRequestsInTx 按申请释放未结算的提现审批尝试冻结事实。 +// +// 语义:对每个申请,按 `released_at IS NULL` 条件更新写入 released_at,并返回各申请 +// 本次实际释放的金额合计(金额取 attempt.amount 稳定冻结事实)。已释放的尝试不会被重复释放, +// 因此返回值对该申请是幂等的:重复调用得到空映射或 0。 +// +// 该原语只写「尝试已释放」事实,不调整钱包余额;调用方必须按返回金额在同一事务内解冻钱包, +// 使尝试事实与钱包变动保持一致。终态消费者、重提与退款佣金回扣共用本原语, +// 避免出现「尝试声称未结算而钱包已无冻结」的矛盾事实。 +// +// 加锁顺序:本函数只负责尝试行(按 ID 升序 FOR UPDATE)。调用方必须先锁申请行, +// 再进入本函数锁尝试行,最后才锁钱包行,即全库统一的「申请行 → 尝试行 → 钱包行」。 +// 三条路径均遵守该顺序:提现终态消费者(withdrawal_approval.go)、重提与释放接缝 +// (withdrawal.go:releaseUnsettledForRequests)、退款佣金回扣(refund/service.go: +// collectPendingWithdrawalRejects 在钱包加锁前收集)。 +func ReleaseUnsettledForRequestsInTx( + ctx context.Context, + tx *gorm.DB, + requestIDs []uint, + now time.Time, +) (map[uint]int64, error) { + released := make(map[uint]int64, len(requestIDs)) + if tx == nil || len(requestIDs) == 0 { + return released, nil + } + var attempts []model.CommissionWithdrawalRequestAttempt + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("request_id IN ? AND released_at IS NULL", requestIDs). + Order("id ASC").Find(&attempts).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询未结算提现审批尝试失败") + } + for index := range attempts { + attempt := &attempts[index] + // 行已由上面的 FOR UPDATE 锁定且筛选条件为 released_at IS NULL, + // 不存在并发改写,故条件更新必然命中,无需再判 RowsAffected。 + if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}). + Where("id = ? AND released_at IS NULL", attempt.ID). + Update("released_at", now).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "标记提现审批尝试已释放失败") + } + released[attempt.RequestID] += attempt.Amount + } + return released, nil +} + +// CountRequestsWithAttemptsInTx 返回给定申请中「已存在审批尝试记录」的申请 ID 集合。 +// 用于区分接入企业微信审批的申请与从未关联审批实例的存量申请。 +func CountRequestsWithAttemptsInTx(ctx context.Context, tx *gorm.DB, requestIDs []uint) (map[uint]struct{}, error) { + result := make(map[uint]struct{}, len(requestIDs)) + if tx == nil || len(requestIDs) == 0 { + return result, nil + } + var found []uint + if err := tx.WithContext(ctx).Model(&model.CommissionWithdrawalRequestAttempt{}). + Where("request_id IN ?", requestIDs). + Distinct().Pluck("request_id", &found).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批尝试记录归属失败") + } + for _, requestID := range found { + result[requestID] = struct{}{} + } + return result, nil +} diff --git a/migrations/000214_add_agent_distribution_registration.down.sql b/migrations/000214_add_agent_distribution_registration.down.sql new file mode 100644 index 0000000..028f8b9 --- /dev/null +++ b/migrations/000214_add_agent_distribution_registration.down.sql @@ -0,0 +1,21 @@ +-- 回滚代理分销码与公开扫码注册 Schema。 +-- 已产生注册事实、已生成分销码或已存在新业务类型审批场景配置时禁止破坏性回滚: +-- 注册记录无法通过重建 Schema 恢复,分销码一旦清除将导致既有二维码失效。 + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM tb_agent_distribution_registration) THEN + RAISE EXCEPTION '存在代理扫码注册记录,禁止回滚代理分销码迁移'; + END IF; + IF EXISTS (SELECT 1 FROM tb_shop WHERE distribution_code <> '') THEN + RAISE EXCEPTION '存在已生成的分销码,禁止回滚代理分销码迁移'; + END IF; +END $$; + +DROP INDEX IF EXISTS uk_agent_distribution_registration_instance; +DROP INDEX IF EXISTS idx_agent_distribution_registration_phone; +DROP INDEX IF EXISTS idx_agent_distribution_registration_parent; +DROP TABLE IF EXISTS tb_agent_distribution_registration; + +DROP INDEX IF EXISTS uk_shop_distribution_code; +ALTER TABLE tb_shop DROP COLUMN IF EXISTS distribution_code; diff --git a/migrations/000214_add_agent_distribution_registration.up.sql b/migrations/000214_add_agent_distribution_registration.up.sql new file mode 100644 index 0000000..da7ce44 --- /dev/null +++ b/migrations/000214_add_agent_distribution_registration.up.sql @@ -0,0 +1,81 @@ +-- 代理分销码与公开扫码注册:店铺全局唯一分销码、待审批注册记录。 +-- 分销码由应用层随机生成且不可修改,条件唯一索引兜底;不提供人工指定或编辑入口。 +-- 注册只落待审批记录,审批通过前不创建店铺、账号、钱包或上下级归属。 +-- 不使用数据库外键,关联以 ID 保存并由应用层显式校验。 + +ALTER TABLE tb_shop + ADD COLUMN IF NOT EXISTS distribution_code VARCHAR(32) NOT NULL DEFAULT ''; + +COMMENT ON COLUMN tb_shop.distribution_code IS '全局唯一随机分销码,创建时生成且不可修改;空字符串表示历史数据未生成'; + +CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_distribution_code + ON tb_shop (distribution_code) + WHERE distribution_code <> '' AND deleted_at IS NULL; + +CREATE TABLE tb_agent_distribution_registration ( + id BIGSERIAL PRIMARY KEY, + distribution_code VARCHAR(32) NOT NULL, + parent_shop_id BIGINT NOT NULL, + phone VARCHAR(20) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + shop_name VARCHAR(100) NOT NULL, + shop_code VARCHAR(50) NOT NULL, + username VARCHAR(50) NOT NULL, + contact_name VARCHAR(50) NOT NULL DEFAULT '', + province VARCHAR(50) NOT NULL DEFAULT '', + city VARCHAR(50) NOT NULL DEFAULT '', + district VARCHAR(50) NOT NULL DEFAULT '', + address VARCHAR(255) NOT NULL DEFAULT '', + status SMALLINT NOT NULL DEFAULT 0, + approval_instance_id BIGINT, + reject_reason VARCHAR(500) NOT NULL DEFAULT '', + decided_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_agent_distribution_registration_code CHECK (distribution_code <> ''), + CONSTRAINT chk_agent_distribution_registration_parent CHECK (parent_shop_id > 0), + CONSTRAINT chk_agent_distribution_registration_phone CHECK (phone <> ''), + CONSTRAINT chk_agent_distribution_registration_password CHECK (password_hash <> ''), + CONSTRAINT chk_agent_distribution_registration_shop CHECK ( + shop_name <> '' AND shop_code <> '' AND username <> '' + ), + CONSTRAINT chk_agent_distribution_registration_status CHECK (status IN (0, 1, 2)), + CONSTRAINT chk_agent_distribution_registration_instance CHECK ( + approval_instance_id IS NULL OR approval_instance_id > 0 + ) +); + +-- 同一手机号可存在多条记录:驳回后再次扫码是新记录、新审批实例。 +CREATE INDEX idx_agent_distribution_registration_parent + ON tb_agent_distribution_registration (parent_shop_id, status, id); +CREATE INDEX idx_agent_distribution_registration_phone + ON tb_agent_distribution_registration (phone, id DESC); + +CREATE UNIQUE INDEX uk_agent_distribution_registration_instance + ON tb_agent_distribution_registration (approval_instance_id) + WHERE approval_instance_id IS NOT NULL; + +COMMENT ON TABLE tb_agent_distribution_registration IS '代理扫码注册待审批记录,审批通过后才创建店铺与账号'; +COMMENT ON COLUMN tb_agent_distribution_registration.id IS '主键,注册记录ID,同时作为通用审批业务ID'; +COMMENT ON COLUMN tb_agent_distribution_registration.distribution_code IS '提交时使用的上级店铺分销码快照,审批通过时校验其归属店铺仍启用'; +COMMENT ON COLUMN tb_agent_distribution_registration.parent_shop_id IS '上级店铺ID,审批通过后作为新店铺的直接上级'; +COMMENT ON COLUMN tb_agent_distribution_registration.phone IS '注册手机号,审批通过时写入代理主账号;不在此记录明文密码'; +COMMENT ON COLUMN tb_agent_distribution_registration.password_hash IS '注册时生成的 bcrypt 密码哈希,审批通过时直接写入新建账号'; +COMMENT ON COLUMN tb_agent_distribution_registration.shop_name IS '申请店铺名称快照'; +COMMENT ON COLUMN tb_agent_distribution_registration.shop_code IS '申请店铺编号快照,审批通过时按既有唯一约束校验'; +COMMENT ON COLUMN tb_agent_distribution_registration.username IS '申请代理主账号用户名快照,审批通过时按既有唯一约束校验'; +COMMENT ON COLUMN tb_agent_distribution_registration.contact_name IS '联系人姓名快照'; +COMMENT ON COLUMN tb_agent_distribution_registration.province IS '省份快照'; +COMMENT ON COLUMN tb_agent_distribution_registration.city IS '城市快照'; +COMMENT ON COLUMN tb_agent_distribution_registration.district IS '区县快照'; +COMMENT ON COLUMN tb_agent_distribution_registration.address IS '详细地址快照'; +COMMENT ON COLUMN tb_agent_distribution_registration.status IS '状态:0 待审批、1 已通过、2 已驳回'; +COMMENT ON COLUMN tb_agent_distribution_registration.approval_instance_id IS '关联的通用审批实例ID,创建后不可修改'; +COMMENT ON COLUMN tb_agent_distribution_registration.reject_reason IS '企业微信最终驳回原因,通过时为空'; +COMMENT ON COLUMN tb_agent_distribution_registration.decided_at IS '审批终态到达时间'; +COMMENT ON COLUMN tb_agent_distribution_registration.creator IS '创建人用户ID,0 表示系统'; +COMMENT ON COLUMN tb_agent_distribution_registration.updater IS '最近更新人用户ID,0 表示系统'; +COMMENT ON COLUMN tb_agent_distribution_registration.created_at IS '创建时间'; +COMMENT ON COLUMN tb_agent_distribution_registration.updated_at IS '最近更新时间'; diff --git a/migrations/000215_add_withdrawal_qualification_and_attempt.down.sql b/migrations/000215_add_withdrawal_qualification_and_attempt.down.sql new file mode 100644 index 0000000..38c557a --- /dev/null +++ b/migrations/000215_add_withdrawal_qualification_and_attempt.down.sql @@ -0,0 +1,39 @@ +-- 回滚提现资格资料版本与提现审批尝试记录 Schema。 +-- 任一本 Change 的业务事实存在时禁止破坏性回滚:资金冻结与资格审批事实无法通过重建 Schema 恢复。 + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM tb_withdrawal_qualification) THEN + RAISE EXCEPTION '存在提现资格资料版本,禁止回滚提现资格与审批尝试迁移'; + END IF; + IF EXISTS (SELECT 1 FROM tb_commission_withdrawal_request_attempt) THEN + RAISE EXCEPTION '存在提现审批尝试记录,禁止回滚提现资格与审批尝试迁移'; + END IF; + IF EXISTS ( + SELECT 1 FROM tb_commission_withdrawal_request + WHERE latest_attempt_id <> 0 OR latest_approval_instance_id <> 0 + OR approval_instance_id IS NOT NULL OR anomaly_flag <> 0 + ) THEN + RAISE EXCEPTION '存在已接入企业微信终审的提现申请,禁止回滚提现资格与审批尝试迁移'; + END IF; +END $$; + +ALTER TABLE tb_commission_withdrawal_request DROP CONSTRAINT IF EXISTS chk_commission_withdrawal_request_anomaly; +ALTER TABLE tb_commission_withdrawal_request DROP CONSTRAINT IF EXISTS chk_commission_withdrawal_request_approval_instance; + +ALTER TABLE tb_commission_withdrawal_request + DROP COLUMN IF EXISTS anomaly_reason, + DROP COLUMN IF EXISTS anomaly_flag, + DROP COLUMN IF EXISTS approval_instance_id, + DROP COLUMN IF EXISTS latest_approval_instance_id, + DROP COLUMN IF EXISTS latest_attempt_id; + +DROP INDEX IF EXISTS idx_commission_withdrawal_attempt_unreleased; +DROP INDEX IF EXISTS idx_commission_withdrawal_attempt_request; +DROP INDEX IF EXISTS uk_commission_withdrawal_attempt_instance; +DROP TABLE IF EXISTS tb_commission_withdrawal_request_attempt; + +DROP INDEX IF EXISTS uk_withdrawal_qualification_instance; +DROP INDEX IF EXISTS idx_withdrawal_qualification_shop; +DROP INDEX IF EXISTS uk_withdrawal_qualification_valid; +DROP TABLE IF EXISTS tb_withdrawal_qualification; diff --git a/migrations/000215_add_withdrawal_qualification_and_attempt.up.sql b/migrations/000215_add_withdrawal_qualification_and_attempt.up.sql new file mode 100644 index 0000000..b2beb24 --- /dev/null +++ b/migrations/000215_add_withdrawal_qualification_and_attempt.up.sql @@ -0,0 +1,166 @@ +-- 提现资格资料版本与提现审批尝试记录。 +-- 资格事实按版本不可变保存:替换合同或法人身份证即新增版本,旧有效版本在同一事务内失效。 +-- 提现每次提交或重提新增一条不可变审批尝试记录,冻结与释放金额一律取尝试记录事实。 +-- 附件字段每个只保存单个对象存储 Key:必填合同、法人身份证正反面共 3 项,可选营业执照、门头照、发票共 3 项, +-- 合计不超过企业微信单张审批单 6 个附件上限。 +-- 不使用数据库外键,关联以 ID 保存并由应用层显式校验。 + +CREATE TABLE tb_withdrawal_qualification ( + id BIGSERIAL PRIMARY KEY, + shop_id BIGINT NOT NULL, + subject_type VARCHAR(20) NOT NULL, + subject_code VARCHAR(64) NOT NULL, + legal_person_id_card VARCHAR(64) NOT NULL, + contract_file_key VARCHAR(255) NOT NULL, + id_card_front_file_key VARCHAR(255) NOT NULL, + id_card_back_file_key VARCHAR(255) NOT NULL, + business_license_file_key VARCHAR(255) NOT NULL DEFAULT '', + shop_front_file_key VARCHAR(255) NOT NULL DEFAULT '', + invoice_file_key VARCHAR(255) NOT NULL DEFAULT '', + invoice_title VARCHAR(200) NOT NULL DEFAULT '', + invoice_subject_code VARCHAR(64) NOT NULL DEFAULT '', + status SMALLINT NOT NULL DEFAULT 0, + invalid_reason VARCHAR(500) NOT NULL DEFAULT '', + invalidated_at TIMESTAMPTZ, + invalidated_by BIGINT NOT NULL DEFAULT 0, + approval_instance_id BIGINT, + decided_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_withdrawal_qualification_shop CHECK (shop_id > 0), + CONSTRAINT chk_withdrawal_qualification_subject CHECK (subject_type IN ('enterprise', 'personal')), + CONSTRAINT chk_withdrawal_qualification_subject_code CHECK (subject_code <> ''), + CONSTRAINT chk_withdrawal_qualification_legal_person CHECK (legal_person_id_card <> ''), + CONSTRAINT chk_withdrawal_qualification_attachments CHECK ( + contract_file_key <> '' AND id_card_front_file_key <> '' AND id_card_back_file_key <> '' + ), + CONSTRAINT chk_withdrawal_qualification_invoice CHECK ( + invoice_file_key = '' OR (invoice_title <> '' AND invoice_subject_code <> '') + ), + CONSTRAINT chk_withdrawal_qualification_status CHECK (status IN (0, 1, 2, 3)), + CONSTRAINT chk_withdrawal_qualification_invalidated CHECK ((status = 3) = (invalidated_at IS NOT NULL)), + CONSTRAINT chk_withdrawal_qualification_instance CHECK ( + approval_instance_id IS NULL OR approval_instance_id > 0 + ) +); + +-- 同一店铺至多一个有效版本;失效与新增在同一事务内完成,因此提交即可替换。 +CREATE UNIQUE INDEX uk_withdrawal_qualification_valid + ON tb_withdrawal_qualification (shop_id) + WHERE status = 1; +CREATE INDEX idx_withdrawal_qualification_shop + ON tb_withdrawal_qualification (shop_id, status, id DESC); +CREATE UNIQUE INDEX uk_withdrawal_qualification_instance + ON tb_withdrawal_qualification (approval_instance_id) + WHERE approval_instance_id IS NOT NULL; + +COMMENT ON TABLE tb_withdrawal_qualification IS '提现资料资格版本,不可变保存历史、附件引用与审批结果'; +COMMENT ON COLUMN tb_withdrawal_qualification.id IS '主键,资料版本ID,同时作为通用审批业务ID'; +COMMENT ON COLUMN tb_withdrawal_qualification.shop_id IS '所属代理店铺ID'; +COMMENT ON COLUMN tb_withdrawal_qualification.subject_type IS '签约主体类型:enterprise 企业、personal 个人'; +COMMENT ON COLUMN tb_withdrawal_qualification.subject_code IS '签约主体代码:企业统一社会信用代码、个人身份证号(脱敏后对外展示)'; +COMMENT ON COLUMN tb_withdrawal_qualification.legal_person_id_card IS '法人身份证号,个人代理与签约主体代码一致(脱敏后对外展示)'; +COMMENT ON COLUMN tb_withdrawal_qualification.contract_file_key IS '合同附件对象存储 Key,单个对象,必填'; +COMMENT ON COLUMN tb_withdrawal_qualification.id_card_front_file_key IS '法人身份证正面附件对象存储 Key,单个对象,必填'; +COMMENT ON COLUMN tb_withdrawal_qualification.id_card_back_file_key IS '法人身份证反面附件对象存储 Key,单个对象,必填'; +COMMENT ON COLUMN tb_withdrawal_qualification.business_license_file_key IS '营业执照附件对象存储 Key,单个对象,可选'; +COMMENT ON COLUMN tb_withdrawal_qualification.shop_front_file_key IS '门头照附件对象存储 Key,单个对象,可选'; +COMMENT ON COLUMN tb_withdrawal_qualification.invoice_file_key IS '发票附件对象存储 Key,单个对象,仅企业可选'; +COMMENT ON COLUMN tb_withdrawal_qualification.invoice_title IS '发票抬头,仅企业填写,且必须与签约主体一致'; +COMMENT ON COLUMN tb_withdrawal_qualification.invoice_subject_code IS '发票统一社会信用代码,必须与签约主体代码一致'; +COMMENT ON COLUMN tb_withdrawal_qualification.status IS '状态:0 待审批、1 已通过有效、2 已驳回、3 已失效或已作废'; +COMMENT ON COLUMN tb_withdrawal_qualification.invalid_reason IS '失效原因,作废必填,代理停用联动失效时记录停用原因'; +COMMENT ON COLUMN tb_withdrawal_qualification.invalidated_at IS '失效时间,状态为 3 时必填'; +COMMENT ON COLUMN tb_withdrawal_qualification.invalidated_by IS '使资格失效的操作账号ID,0 表示由店铺停用联动触发'; +COMMENT ON COLUMN tb_withdrawal_qualification.approval_instance_id IS '关联的通用审批实例ID,创建后不可修改'; +COMMENT ON COLUMN tb_withdrawal_qualification.decided_at IS '审批终态到达时间'; +COMMENT ON COLUMN tb_withdrawal_qualification.creator IS '创建人用户ID'; +COMMENT ON COLUMN tb_withdrawal_qualification.updater IS '最近更新人用户ID'; +COMMENT ON COLUMN tb_withdrawal_qualification.created_at IS '创建时间'; +COMMENT ON COLUMN tb_withdrawal_qualification.updated_at IS '最近更新时间'; + +CREATE TABLE tb_commission_withdrawal_request_attempt ( + id BIGSERIAL PRIMARY KEY, + request_id BIGINT NOT NULL, + attempt_no INTEGER NOT NULL, + amount BIGINT NOT NULL, + fee BIGINT NOT NULL DEFAULT 0, + fee_rate BIGINT NOT NULL DEFAULT 0, + actual_amount BIGINT NOT NULL DEFAULT 0, + withdrawal_method VARCHAR(20) NOT NULL, + account_info JSONB NOT NULL, + invoice_keys JSONB NOT NULL DEFAULT '[]'::jsonb, + submitted_by_account_id BIGINT NOT NULL, + approval_instance_id BIGINT, + released_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_commission_withdrawal_attempt_no UNIQUE (request_id, attempt_no), + CONSTRAINT chk_commission_withdrawal_attempt_ids CHECK ( + request_id > 0 AND submitted_by_account_id > 0 + ), + CONSTRAINT chk_commission_withdrawal_attempt_no_positive CHECK (attempt_no >= 1), + CONSTRAINT chk_commission_withdrawal_attempt_amount CHECK ( + amount > 0 AND fee >= 0 AND fee_rate >= 0 AND actual_amount >= 0 + ), + CONSTRAINT chk_commission_withdrawal_attempt_method CHECK (withdrawal_method <> ''), + CONSTRAINT chk_commission_withdrawal_attempt_account CHECK (jsonb_typeof(account_info) = 'object'), + CONSTRAINT chk_commission_withdrawal_attempt_invoice CHECK (jsonb_typeof(invoice_keys) = 'array'), + CONSTRAINT chk_commission_withdrawal_attempt_instance CHECK ( + approval_instance_id IS NULL OR approval_instance_id > 0 + ) +); + +CREATE UNIQUE INDEX uk_commission_withdrawal_attempt_instance + ON tb_commission_withdrawal_request_attempt (approval_instance_id) + WHERE approval_instance_id IS NOT NULL; +CREATE INDEX idx_commission_withdrawal_attempt_request + ON tb_commission_withdrawal_request_attempt (request_id, attempt_no DESC); +-- 未结算尝试是后续佣金回溯的释放接缝:按申请读取未释放尝试。 +CREATE INDEX idx_commission_withdrawal_attempt_unreleased + ON tb_commission_withdrawal_request_attempt (request_id, id) + WHERE released_at IS NULL; + +COMMENT ON TABLE tb_commission_withdrawal_request_attempt IS '提现审批尝试记录,每次提交或重提一条不可变材料与冻结事实'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.id IS '主键,审批尝试记录ID,同时作为通用审批业务ID'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.request_id IS '所属提现申请ID'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.attempt_no IS '第几次提交,从 1 递增,申请内唯一'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.amount IS '本次冻结的提现金额(单位:分),释放与扣减一律取该事实'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.fee IS '本次手续费快照(单位:分)'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.fee_rate IS '本次手续费率快照(基点,100=1%)'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.actual_amount IS '本次实际到账金额快照(单位:分)'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.withdrawal_method IS '本次收款方式快照'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.account_info IS '本次收款账户信息快照(jsonb 对象)'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.invoice_keys IS '本次申请级发票对象存储 Key 列表(jsonb 数组)'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.submitted_by_account_id IS '本次实际提交账号ID'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.approval_instance_id IS '本次尝试关联的通用审批实例ID,创建后不可修改'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.released_at IS '本次冻结释放时间,空表示冻结仍未结算;是回溯可重放的幂等依据'; +COMMENT ON COLUMN tb_commission_withdrawal_request_attempt.created_at IS '创建时间'; + +ALTER TABLE tb_commission_withdrawal_request + ADD COLUMN IF NOT EXISTS latest_attempt_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS latest_approval_instance_id BIGINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS approval_instance_id BIGINT, + ADD COLUMN IF NOT EXISTS anomaly_flag SMALLINT NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS anomaly_reason VARCHAR(500) NOT NULL DEFAULT ''; + +ALTER TABLE tb_commission_withdrawal_request + DROP CONSTRAINT IF EXISTS chk_commission_withdrawal_request_approval_instance; + +ALTER TABLE tb_commission_withdrawal_request + ADD CONSTRAINT chk_commission_withdrawal_request_approval_instance + CHECK (approval_instance_id IS NULL OR approval_instance_id > 0); + +ALTER TABLE tb_commission_withdrawal_request + DROP CONSTRAINT IF EXISTS chk_commission_withdrawal_request_anomaly; + +ALTER TABLE tb_commission_withdrawal_request + ADD CONSTRAINT chk_commission_withdrawal_request_anomaly + CHECK (anomaly_flag IN (0, 1)); + +COMMENT ON COLUMN tb_commission_withdrawal_request.latest_attempt_id IS '最新审批尝试记录ID,仅用于列表投影与历史事实定位'; +COMMENT ON COLUMN tb_commission_withdrawal_request.latest_approval_instance_id IS '最新通用审批实例ID,仅用于列表投影'; +COMMENT ON COLUMN tb_commission_withdrawal_request.approval_instance_id IS '首次接入企业微信审批时关联的审批实例ID;为空表示从未关联审批实例的存量申请,仍走本地人工终审'; +COMMENT ON COLUMN tb_commission_withdrawal_request.anomaly_flag IS '正交异常标记:0 无异常、1 企业微信通过后撤销'; +COMMENT ON COLUMN tb_commission_withdrawal_request.anomaly_reason IS '异常原因,供提现详情展示与人工处理'; diff --git a/migrations/000216_extend_wecom_approval_scene_business.down.sql b/migrations/000216_extend_wecom_approval_scene_business.down.sql new file mode 100644 index 0000000..570631a --- /dev/null +++ b/migrations/000216_extend_wecom_approval_scene_business.down.sql @@ -0,0 +1,24 @@ +-- 回滚企业微信审批场景业务类型白名单扩展。 +-- 存在本 Change 新增类型的场景配置时禁止破坏性回滚:删除场景行会丢失已配置的模板控件映射, +-- 且相应业务提交将失去渠道而失败关闭。 + +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM tb_wecom_approval_scene + WHERE business_type IN ( + 'agent_distribution_approval', + 'withdrawal_qualification_approval', + 'commission_withdrawal_approval' + ) + ) THEN + RAISE EXCEPTION '存在分销注册、提现资格或提现终审审批场景配置,禁止回滚审批场景业务类型扩展'; + END IF; +END $$; + +ALTER TABLE tb_wecom_approval_scene + DROP CONSTRAINT IF EXISTS chk_wecom_approval_scene_business; + +ALTER TABLE tb_wecom_approval_scene + ADD CONSTRAINT chk_wecom_approval_scene_business + CHECK (business_type IN ('refund_approval', 'offline_recharge_approval', 'employee_collection_approval')); diff --git a/migrations/000216_extend_wecom_approval_scene_business.up.sql b/migrations/000216_extend_wecom_approval_scene_business.up.sql new file mode 100644 index 0000000..768649f --- /dev/null +++ b/migrations/000216_extend_wecom_approval_scene_business.up.sql @@ -0,0 +1,16 @@ +-- 扩展企业微信审批场景业务类型白名单,纳入分销注册、提现资格与提现终审三个场景。 +-- 既有的退款、线下代充值、员工代收款核销三个场景语义不变。 + +ALTER TABLE tb_wecom_approval_scene + DROP CONSTRAINT IF EXISTS chk_wecom_approval_scene_business; + +ALTER TABLE tb_wecom_approval_scene + ADD CONSTRAINT chk_wecom_approval_scene_business + CHECK (business_type IN ( + 'refund_approval', + 'offline_recharge_approval', + 'employee_collection_approval', + 'agent_distribution_approval', + 'withdrawal_qualification_approval', + 'commission_withdrawal_approval' + )); diff --git a/migrations/000217_backfill_shop_distribution_code.down.sql b/migrations/000217_backfill_shop_distribution_code.down.sql new file mode 100644 index 0000000..99c83ad --- /dev/null +++ b/migrations/000217_backfill_shop_distribution_code.down.sql @@ -0,0 +1,14 @@ +-- 回滚存量店铺分销码回填。 +-- 回填不可逆:分销码一旦生成并对外使用(二维码、上级归属),清空会使既有二维码失效 +-- 并使已注册下级失去上级入口。因此存在任何注册记录时拒绝回滚,不做静默清空。 +-- +-- 注意:本回滚不改变 distribution_code 的数据,也不回滚列注释——分销码已生成并可能被 +-- 二维码与注册事实依赖,清空会使既有二维码与下级注册入口失效。因此存在任何注册记录时拒绝回滚; +-- 若维护者确需清除分销码,必须先停止新入口并确认无依赖,另行人工处理。 + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM tb_agent_distribution_registration) THEN + RAISE EXCEPTION '存在代理扫码注册记录,禁止回滚分销码回填迁移'; + END IF; +END $$; diff --git a/migrations/000217_backfill_shop_distribution_code.up.sql b/migrations/000217_backfill_shop_distribution_code.up.sql new file mode 100644 index 0000000..12f0d93 --- /dev/null +++ b/migrations/000217_backfill_shop_distribution_code.up.sql @@ -0,0 +1,16 @@ +-- 回填存量店铺分销码。 +-- 000214 只建列与条件唯一索引,既有店铺的分销码为空字符串,因而无法作为上级被扫码注册, +-- 公开注册链路对存量代理恒返回「分销码不可用」。本迁移为每个空码店铺生成全局唯一随机分销码。 +-- +-- 生成方式:md5(random()::text || clock_timestamp()::text || id::text),不依赖任何扩展; +-- id 参与输入保证同一语句内各行取值不同,clock_timestamp() 逐行求值进一步降低碰撞概率。 +-- 已非空的店铺(由应用层建店或批量导入生成)保持不变,不覆盖既有码。 +-- 条件唯一索引 uk_shop_distribution_code 兜底;若出现碰撞,本迁移整体失败并由维护者重跑。 + +UPDATE tb_shop +SET distribution_code = md5(random()::text || clock_timestamp()::text || id::text), + updated_at = NOW() +WHERE distribution_code = ''; + +-- 回填后不存在空值:同步列注释,避免继续声明「空字符串表示历史数据未生成」。 +COMMENT ON COLUMN tb_shop.distribution_code IS '全局唯一随机分销码,创建时生成且不可修改;回填后所有未删除店铺均有值'; diff --git a/openspec/changes/add-agent-distribution-withdrawal-qualification/design.md b/openspec/changes/add-agent-distribution-withdrawal-qualification/design.md deleted file mode 100644 index 18bd05e..0000000 --- a/openspec/changes/add-agent-distribution-withdrawal-qualification/design.md +++ /dev/null @@ -1,32 +0,0 @@ -## Context - -现有佣金提现有申请和钱包事实;本 Change 以资格及审批实例补齐其前置条件,不以外部线下打款作为完成条件。 - -## Decisions - -- 分销码由店铺唯一约束保护;扫码注册与审批结果使用稳定审批实例幂等消费。 -- 资格申请与附件版本分表/快照,替换、作废和代理停用以状态失效,不覆盖历史审批。 -- 提现创建在事务内锁定佣金钱包并写冻结和审批提交;通过仅一次扣减/确认,驳回仅一次释放。 - -## 业务动作契约 - -### 分销码与扫码注册 - -- 代理店铺创建事务生成全局唯一、不可修改的随机 `distribution_code`;二维码只包含 H5 注册入口及该码。唯一冲突重试生成,不允许人工指定或编辑。 -- `POST /api/c/v1/agent-distribution-registrations`:提交 `distribution_code`、短信已验证手机号、密码及既有注册必填资料。服务锁定上级店铺,校验其代理启用;失效码/停用代理统一返回“分销码不可用”。成功仅创建待审批代理/店铺和企业微信审批实例,不建立下级归属。 -- 企业微信通过消费者幂等启用新代理/店铺,并在同一事务写直接上级店铺和上级当前业务员快照;驳回不启用。重复回调不重复创建层级或账号。 - -### 提现资格资料 - -- `POST /shops/:shop_id/withdrawal-qualifications`:仅本人代理店铺。请求合同、法人身份证正反面附件;企业必须提供统一社会信用代码,个人必须提供法人身份证号;可选营业执照、门头照;企业可选发票,发票抬头和统一社会信用代码必须与合同主体一致。创建新资料版本并提交企业微信,替换合同或身份证立即使旧有效资格失效。 -- 超级管理员作废资格必须填写原因;代理停用自动失效全部有效资格。合同与身份证审批通过后长期有效,直至替换、作废或停用;历史版本、附件、审批实例永不覆盖。 - -### 提现申请与审批 - -- 既有 `POST /shops/:shop_id/withdrawal-requests` 增加资格有效校验。锁定该店铺佣金钱包后冻结可提现余额、申请金额、手续费/实际到账金额、收款信息和可选发票快照;余额不足、资格无效或非本人代理均不创建申请或冻结。 -- 企业微信通过时以申请/审批实例条件更新一次确认到账;驳回时仅一次释放冻结。代理可在资格仍有效时修改被驳回申请的金额、收款信息和申请级发票并创建新审批实例;资料资格不因提现驳回失效。 -- 新业务禁用本地 `approve/reject` 终审;提交/回调未知复用既有审批恢复,不允许通过重提制造第二笔冻结。每次资格、冻结、审批终态和重提均记录审计。 - -## Migration Plan - -新增成对迁移及唯一/状态索引;隔离库验证码唯一和停用、资格失效、提现冻结/重提、重复审批回调及 up/down/up。 \ No newline at end of file diff --git a/openspec/changes/add-agent-distribution-withdrawal-qualification/proposal.md b/openspec/changes/add-agent-distribution-withdrawal-qualification/proposal.md deleted file mode 100644 index 9917aa6..0000000 --- a/openspec/changes/add-agent-distribution-withdrawal-qualification/proposal.md +++ /dev/null @@ -1,25 +0,0 @@ -## Scope - -- 迭代编号:`AUG26-008`。 - -## Why - -代理下级归属和提现资料/资金缺少企业微信终审及失效边界,无法可靠追溯。 - -## What Changes - -- 代理店铺唯一分销码和审批后下级注册。 -- 合同、法人身份证为核心的提现资格审批与失效。 -- 提现余额/资料快照、企微终审和驳回释放。 - -## Capabilities - -### New Capabilities -- `agent-distribution-withdrawal`: 分销注册、资料资格与提现审批。 - -### Modified Capabilities -- 无。 - -## Impact - -影响代理店铺、H5 注册、附件、佣金钱包、企业微信审批和 Schema。 \ No newline at end of file diff --git a/openspec/changes/add-agent-distribution-withdrawal-qualification/specs/agent-distribution-withdrawal/spec.md b/openspec/changes/add-agent-distribution-withdrawal-qualification/specs/agent-distribution-withdrawal/spec.md deleted file mode 100644 index f71c36c..0000000 --- a/openspec/changes/add-agent-distribution-withdrawal-qualification/specs/agent-distribution-withdrawal/spec.md +++ /dev/null @@ -1,26 +0,0 @@ -## Purpose - -使代理下级注册和佣金提现均以企业微信审批、资格有效性和不可变业务快照为准,避免代理层级或资金事实因资料变更、重复回调而漂移。 - -## ADDED Requirements - -### Requirement: 分销码与下级代理注册 -系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码。代理扫码后以手机号短信验证、设置密码并创建待企业微信审批的下级代理和店铺;仅审批通过时启用,并将新店铺设为分销码所属店铺的直接下级,复制上级当时业务员为初始业务员。代理停用后其码立即不可注册,既有下级和佣金关系不级联变更。 - -#### Scenario: 停用代理码注册 -- **WHEN** 客户使用已停用代理所属店铺的分销码注册 -- **THEN** 系统拒绝创建下级代理或店铺 - -### Requirement: 提现资料资格 -代理首次提现前 SHALL 提交企业微信资料资格申请;合同和法人身份证正反面必填,企业代理填写统一社会信用代码、个人代理填写法人身份证号。合同资格主体必须填写统一社会信用代码或身份证号;营业执照、门头照可选,发票仅企业可选且其抬头/统一社会信用代码必须与合同主体一致。审批通过且资料未过期才有效;合同和身份证通过后长期有效,直到代理替换资料、超级管理员作废或代理停用。 - -#### Scenario: 资料替换后提现 -- **WHEN** 有效资格的代理替换合同或法人身份证资料 -- **THEN** 原资格失效,代理必须重新审批通过后才可提现 - -### Requirement: 提现冻结与企业微信终审 -提现申请 SHALL 冻结可提现余额、金额、手续费、收款信息和可选发票快照,并创建企业微信审批。本地不得人工通过或驳回;企业微信通过即视为已到账,驳回时释放本申请冻结余额。驳回后代理可修改金额、收款信息和本次发票重新提交,每次新建审批实例;资料资格保持有效。发票为申请级材料,若上传必须按当时有效合同主体校验并冻结。 - -#### Scenario: 提现审批驳回 -- **WHEN** 企业微信最终驳回一笔提现申请 -- **THEN** 系统仅一次释放其冻结佣金余额,保留审批快照,并允许在资格仍有效时修改后重提 diff --git a/openspec/changes/add-agent-distribution-withdrawal-qualification/tasks.md b/openspec/changes/add-agent-distribution-withdrawal-qualification/tasks.md deleted file mode 100644 index ef6bee5..0000000 --- a/openspec/changes/add-agent-distribution-withdrawal-qualification/tasks.md +++ /dev/null @@ -1,13 +0,0 @@ -## 1. 分销与资格 -- [ ] 1.1 追踪代理/店铺创建、H5 短信注册、佣金提现、附件、企微审批和钱包冻结链路。 -- [ ] 1.2 新增分销码、资格申请/资料版本、提现审批快照的成对迁移、模型、状态与唯一约束。 -- [ ] 1.3 实现分销注册、停用门禁、审批后下级归属/业务员快照及审计。 -- [ ] 1.4 实现资格提交、主体/附件/发票校验、失效和企微回调。 - -## 2. 提现 -- [ ] 2.1 实现有效资格校验、钱包锁定冻结、申请快照、企微终审、驳回释放和新实例重提。 -- [ ] 2.2 注册后台/H5 路由及 OpenAPI,保障附件和数据范围。 - -## 3. 验证 -- [ ] 3.1 隔离库验证迁移、分销码停用、资格替换、冻结/释放和重复回调。 -- [ ] 3.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-agent-distribution-withdrawal-qualification --strict` 与 `openspec doctor --json`;自动化测试按项目决策为 N/A。 \ No newline at end of file diff --git a/openspec/changes/add-agent-distribution-withdrawal-qualification/.openspec.yaml b/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/.openspec.yaml similarity index 100% rename from openspec/changes/add-agent-distribution-withdrawal-qualification/.openspec.yaml rename to openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/.openspec.yaml diff --git a/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/design.md b/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/design.md new file mode 100644 index 0000000..65a522b --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/design.md @@ -0,0 +1,159 @@ +## Context + +见 `proposal.md` 了解动机。以下为开工核查确认的现状约束,全部来自实际代码与迁移: + +- 店铺无分销码;`tb_shop` 只有 `ShopStatusDisabled=0` / `ShopStatusEnabled=1`,不存在待审批状态;业务员只是 `tb_shop.business_owner_account_id` 单列,无快照。 +- 不存在任何 H5 或后台的店铺注册链路与注册实体;`/api/c/v1` 现有能力为短信验证码、登录与手机号绑定/换绑。 +- 短信验证码能力可复用:Redis `verification:code:`、校验即消费、手机号/IP/日三档限流。 +- 佣金提现已有完整的本地冻结链路(申请、`frozen_balance`、钱包流水、审计同事务)与本地人工终审路由,但完全未接入通用审批。 +- 合同、法人身份证、营业执照、门头照、发票与统一社会信用代码等资格事实在全库不存在。 +- 通用审批实例的共享唯一约束为 `uq_approval_instance_business(business_type, business_id)`,且创建路径无冲突重试或复用分支;因此同一业务类型下 `business_id` 不可复用。 +- `tb_commission_withdrawal_request` 无审批关联列;资金汇总只按 `status=2` 统计已通过提现、按 `status=1` 统计提现中。 +- 企业微信单张审批单最多 6 个附件。 +- 店铺启停只在后台登录与代理开放接口鉴权两处门禁;停用无任何级联失效先例。 + +## Goals / Non-Goals + +**Goals:** + +- 分销注册、资格审批、提现审批三者的本地事实与企业微信终态一一对应,可幂等重放、可回放历史。 +- 提现的冻结、释放、扣减在单一事务内闭合,并为后续佣金回溯提供稳定释放接缝。 +- 分销码与注册审批的落库形态不污染既有店铺层级、账号唯一性与数据范围。 + +**Non-Goals:** + +- 不实现 H5 页面、二维码渲染与前端交互,只交付公开注册后端接口。 +- 不实现 AUG26-012 佣金回溯与佣金明细导出。 +- 不重构既有店铺创建、账号、钱包与提现列表的对外契约。 +- 不新增本地人工终审配置开关,不改变其他审批场景的本地历史语义。 +- 不把财务审计时间线(`internal/query/audit/finance.go`)纳入新业务类型。 +- 不在本 Change 内接入或验证真实企业微信、短信、对象存储与支付。 + +## Decisions + +### 1. 分销码由唯一索引保护,建店事务内生成 + +代理店铺创建时生成全局唯一、不可修改的随机 `distribution_code`,以条件唯一索引兜底;冲突时重试生成,不提供人工指定或编辑入口。二维码只编码 H5 注册入口与该码,渲染与页面不在本 Change。 + +既有店铺创建点共两处,都必须生成分销码:`internal/application/shop/create.go` 的建店事务,以及批量导入脚本 `scripts/migration/lib/shop_sql.py` 生成的单事务 SQL(该脚本不经 Go 事务,只能靠生成期随机码 + 唯一索引兜底,整批失败即回滚)。漏掉后者会使导入店铺永久无码。 + +既有 `shop_code` 语义与唯一索引不变;分销码是新增的正交事实。 + +### 2. 注册落库形态:独立待审批注册记录,不预先建店铺与账号 + +采用独立注册记录,而不是“先建 `status=2` 的店铺、审批通过再改状态”。理由:店铺状态枚举只有 0/1,用 0 表达待审批会与停用语义冲突并触发既有登录门禁;`parent_id` 为空的待审批店铺会以“一级代理”污染层级列表与下级缓存;且 `tb_account` 的手机号唯一索引会让被驳回的首次注册长期占用手机号,导致同手机号无法再次注册。 + +注册记录保存分销码、上级店铺、手机号、密码哈希(bcrypt,审批通过时直接写入新建账号,注册记录不保存明文)、状态、审批实例与时间戳。审批通过前不创建店铺、账号、钱包或层级。同一手机号驳回后再次扫码即新记录、新审批实例。 + +公开接口 `POST /api/c/v1/agent-distribution-registrations` 无认证要求,路由必须注册在个人客户路由的 `Use()` 之前(Fiber 顺序匹配)。接口复用既有验证码校验与消费、限流规则;锁定上级店铺并校验其启用状态,无效码与停用上级统一返回“分销码不可用”。资格申请、资格作废、提现申请与后台查询仍走既有认证与数据范围校验,不公开。 + +### 3. 注册审批通过事务:实体与层级一次建成 + +业务类型 `agent_distribution_approval`,`business_id` = 注册记录主键。终态消费以条件更新(注册记录状态 = 待审批)保证至多一次,通过时在同一事务内: + +1. 校验分销码所属店铺仍存在且启用; +2. 创建启用店铺,写入上级店铺与层级(`level = parent.level + 1`,上限 7); +3. 创建代理主账号(写入注册时的密码哈希),创建所需钱包; +4. 写入上级当时业务员为初始业务员快照; +5. 标记注册记录已通过,记录审计。 + +事务提交后必须清理上级店铺的下级缓存,否则新下级在下级集合缓存的有效期内不可见。驳回只标记注册记录与审批结果,不创建任何实体。手机号或用户名在通过时已被并发注册占用,属于事务失败,必须整体回滚,不得留下半套实体。 + +### 4. 资格:不可变资料版本 + 独立审批业务类型 + +业务类型 `withdrawal_qualification_approval`,`business_id` = 资料版本主键。资格事实按版本不可变保存:主体类型(企业/个人)、签约主体代码、法人身份证号、合同与法人身份证正反面附件、可选营业执照与门头照、可选发票抬头与统一社会信用代码、审批实例、状态与失效原因。 + +替换合同或法人身份证 = 新增版本 + 新审批实例,并在同一事务内使旧有效版本失效。作废由超级管理员执行且必须填写原因;代理停用联动使全部有效版本失效。资格与注册资料都不引入同行重提状态机——重审即新版本,驳回后重新提交即新记录。 + +附件映射受企业微信单张审批单 6 个附件上限约束:必填 3 项(合同、法人身份证正面、反面)+ 可选 3 项(营业执照、门头照、发票),因此每个附件字段只允许单个对象键,不得支持多文件追加。 + +### 5. 提现审批尝试记录与列表投影 + +业务类型 `commission_withdrawal_approval`,`business_id` = 提现审批尝试记录主键。尝试记录保存 `request_id`、`attempt_no`、金额/手续费/费率/实际到账快照、收款信息、申请级发票快照、提交人、审批实例与释放时间,创建后不可修改。 + +提现申请行新增 `latest_attempt_id` 与 `latest_approval_instance_id`,仅作为列表投影与门禁判定使用,不是历史事实来源;请求行上的金额与状态是最近一次尝试的镜像,冻结与释放金额一律取尝试记录。 + +每次提交或重提:事务内锁定佣金钱包、为新的尝试冻结金额、写尝试记录、创建审批实例、回填 `latest_*`、写钱包流水与审计。驳回后重提先释放旧未结算尝试的冻结,再按新金额冻结,全程单事务;不允许通过重提制造第二笔冻结。 + +### 6. 终态映射 + +| 企业微信决策 | 提现请求状态 | 资金动作 | 时间与标记 | +| --- | --- | --- | --- | +| approved | 保持 `WithdrawalStatusApproved`=2 | 仅一次从冻结余额扣减 | 写 `paid_at` 与 `processed_at` | +| rejected | `WithdrawalStatusRejected`=3 | 仅一次释放本次尝试冻结 | 写 `processed_at` 与尝试记录释放时间 | +| cancelled | 同 rejected | 同 rejected | 同 rejected | +| deleted | 同 rejected | 同 rejected | 同 rejected | +| revoked_after_approved | 保持 2 | 不回滚、不重新冻结 | 写正交异常标记与原因,禁止自动重提 | + +通过不得使用 `WithdrawalStatusPaid`=4:既有资金汇总只统计 `status=2`,写 4 会使已通过提现从汇总口径中消失。资金动作全部以“尝试记录/请求行状态条件更新 + 影响行数为 1”为幂等守卫,重复、乱序与未知结果由既有交付租约与查询恢复收敛。 + +### 7. 本地人工终审不新增开关 + +不新增 `approval.legacy_withdrawal_manual_enabled` 或任何等价开关。本地通过/驳回入口以既有的普通读取(`GetByID`,带数据范围过滤,不加 `FOR UPDATE`)取出申请行,只要该行已关联审批实例(`approval_instance_id` 非空)即直接返回稳定状态冲突并落失败审计,不依赖任何开关。随后的状态写入沿用既有 `UpdateStatusWithTx`,其 `WHERE` 带 `status = 待审核` 条件;实现只返回该写入的错误、未校验影响行数,因此**条件更新在此不作为已校验的守卫**,并发下的最终一致性依赖既有的状态判定与唯一约束。存量从未关联审批实例的提现申请保持既有兼容处理;其他审批场景的本地历史语义不变。 + +### 8. 为后续佣金回溯提供释放接缝 + +佣金回溯处理待审提现时,必须先幂等释放每个未结算尝试的冻结余额,再扣减佣金余额。原因:佣金钱包约束要求冻结余额不超过余额(负值按 0 计),先扣减会使冻结违约。`attempt.amount` 是稳定冻结事实,`attempt.released_at` 是稳定释放事实,二者构成回溯可重放的幂等依据。本 Change 只提供事实与顺序约定,不实现 AUG26-012。 + + +## 实施决策(与设计文档的差异) + +本节记录实施过程中与本文档正文不同的实际决策,仅描述事实,不改变需求语义。 + +1. **公开注册提交不写审计**:公开路由注册在个人客户路由 `Use()` 之前,不经认证中间件,链路没有可信的 actor/source(审计入口规则会拒绝),且该动作不在 tasks 1.7 的审计清单内。故移除该「提交」审计动作与写入,而不是伪造操作者身份;注册通过/驳回的审计保留。 +2. **分销与提现相关审计改为必达**:审计与业务事实同事务原子提交,写入使用非吞错的 `AppendAndGet` 并返回错误,不再降级为「只记日志」。原因是失败审计被静默丢弃会让拒绝事实不可追溯(ENG-AUDIT-001)。 +3. **分销码冲突重试改用 GORM 嵌套事务**:PostgreSQL 唯一冲突会中止整个事务,必须回滚到保存点才能重试;而 GORM 在 `PrepareStmt: true` 下会临时置换 `Statement.ConnPool`,裸 `SavePoint`/`RollbackTo` 会破坏事务状态。故以 `tx.Transaction(...)` 的嵌套事务实现重试(由 GORM 负责保存点与连接池切换),保持「预检 + 唯一冲突重试 + 上限 5 次」,其他唯一冲突不重试。 +4. **统一加锁顺序为「申请行 → 尝试行 → 钱包行」**:三条写路径(提现终态消费者、重提与释放接缝、退款佣金回扣)统一该顺序,钱包永远最后加锁,避免 A→B、B→A 死锁环;退款回扣因此把「拒绝待审提现的加锁与释放额计算」提到钱包加锁之前。 +5. **新增两个拒绝类审计动作码**:`commission_withdrawal.attempt_reject` 与 `withdrawal_qualification.submit_reject`,主资源为**店铺**。原因:创建前拒绝(资格无效、余额不足、越权、存在待审批版本)没有提现单或资料版本可挂,而审计规则要求主资源类型必须等于动作注册的主资源。 +6. **店铺删除与停用统一走资格失效联动**:删除与停用在同一事务内使该店铺全部有效资料资格失效;删除路径的失效必须先于软删除执行(失效写审计时需要店铺行可见)。 +7. **代理停用/删除时企微通过资格的终态收敛**:店铺已停用或不存在时,待审批资料版本直接收敛为已失效并写审计,避免停留在待审批导致该店铺永久无法获得有效资格。 +8. **分销码存量回填**:新增迁移 `000217` 为历史空码店铺生成随机唯一码,并同步列注释;`down` 不清空数据。 + +## 待裁决项与处置 + +以下三项在实施复核中提出,经用户裁决后的最终处置: + +1. **有效资格下仅变更可选材料(发票、营业执照、门头照)的提交被拒绝**:裁决为「作废后重提可行」,保持现状。需要补录发票等可选材料时,由超级管理员填写原因作废当前有效版本,代理重新提交并重新审批通过。资格「通过后长期有效、不可同版本重提」的语义不变。 +2. **企业主体是否必填法人身份证号**:裁决为「必填」。合同签约双方均需登记法人身份证号,实现已是该语义(`legal_person_id_card` 对企业与个人均必填,DTO 校验与 `chk_withdrawal_qualification_legal_person` 双重约束)。 +3. **重提与释放接缝相对终态消费者的加锁顺序**:属前瞻性风险,已由实施决策第 4 条「统一加锁顺序为申请行 → 尝试行 → 钱包行」解决,不再作为待办。 + +## 业务动作契约 + +### 分销注册(公开) + +- `POST /api/c/v1/agent-distribution-registrations`:无认证。请求包含 `distribution_code`、手机号、短信验证码、密码与既有注册必填资料。成功返回注册记录标识与待审批状态,不返回任何账号凭证。无效码、停用上级、验证码无效或已消费统一返回“分销码不可用”,不创建记录。 + +### 提现资格(需认证) + +- `POST /api/admin/shops/:shop_id/withdrawal-qualifications`:仅本人代理店铺。请求合同与法人身份证正反面附件;企业必填统一社会信用代码,个人必填法人身份证号;可选营业执照、门头照;企业可选发票且抬头与统一社会信用代码必须与合同主体一致。成功创建新资料版本与审批实例,替换合同或身份证时同一事务失效旧有效版本。 +- `POST /api/admin/withdrawal-qualifications/:id/void`:仅超级管理员,`reason` 必填。成功后该版本失效且原因可查询。 +- 查询接口沿用既有认证与数据范围校验,仅返回本人或数据范围内店铺的资格与附件对象键引用。 + +### 提现申请与审批(需认证) + +- `POST /api/admin/shops/:shop_id/withdrawal-requests`:仅本人代理店铺,新增有效资格校验。事务内锁定佣金钱包、冻结可提现余额、写审批尝试记录与新审批实例、回填 `latest_*`。余额不足、资格无效或非本人代理均不创建申请或冻结。 +- `PUT /api/admin/shops/:shop_id/withdrawal-requests/:id`:仅本人代理店铺,仅已驳回申请可修改金额、收款信息与本次发票后重提;新增尝试记录与新审批实例,历史快照不覆盖。 +- 本地通过/驳回入口保留但受终态门禁限制;新申请不可由本地人工终审。 + +## Risks / Trade-offs + +- [企业微信回调重复、乱序、未知或通过后撤销] → 以审批实例、尝试记录、请求行状态条件更新与交付租约幂等消费,复用既有查询恢复;通过后撤销不回滚、不重新冻结、转正交异常标记并禁止自动重提。 +- [公开注册接口被滥用] → 复用既有验证码校验/消费与三档限流;无效码与停用上级统一结果避免枚举;注册阶段不创建任何账号或店铺。 +- [注册审批通过时并发占用手机号或用户名] → 通过事务整体回滚,不留半套实体;重复回调由注册记录状态条件更新拦截。 +- [层级与业务员快照漂移] → 通过事务写入上级与业务员快照;审批通过后清理上级下级缓存,避免缓存窗口内新下级不可见。 +- [附件超出企微上限] → 资格只有 6 个单对象键附件字段(合同、法人身份证正反面、营业执照、门头照、发票),结构上不可能超过企微单张审批单 6 个附件上限,无需运行时计数校验;渠道侧仍按既有规则兜底。 +- [资金汇总口径漂移] → 通过保持 `status=2`,不使用状态 4;`paid_at` 只补充到账时间,不改变既有聚合条件。 +- [重提造成第二笔冻结] → 重提在单事务内先释放旧未结算冻结再冻结新金额,且以尝试记录为资金事实。 +- [存量提现兼容] → 本地终审仅对已关联审批实例的申请拒绝,`approval_instance_id` 为空的记录走既有路径。 +- [重提与审批核心唯一约束] → 新业务类型以自身业务记录主键作为 `business_id`,共享唯一约束不变,既有两个审批场景语义不变。 +- [财务审计时间线未纳入新业务类型] → 仅缺少业务跳转、不报错;沿用既有登记,作为后续 Change 待办,不属本期范围。 + +## Migration Plan + +1. 新增成对迁移(`.up.sql`/`.down.sql`)创建待审批注册记录、提现资格资料版本、提现审批尝试记录三张表及唯一约束与查询索引,并为提现申请追加 `latest_*`、通过时间与异常标记列。迁移编号不预占,实施开始时按当时 `migrations/` 目录的最大编号顺延;不修改既有迁移。 +2. 同一批迁移扩展 `tb_wecom_approval_scene` 的 `business_type` CHECK 以纳入三个新类型;`down` 在存在新类型场景行时必须拒绝破坏性回滚并给出明确异常。 +3. 施工时列全审批业务类型注册点:业务类型与字段常量、场景 DTO 枚举与中文描述、场景用例的业务字段白名单/类型校验/中文名、数据库 CHECK、Worker 决策消费者与依赖装配、审批审计资源映射,以及分销注册、资格版本、提现尝试记录的审计资源常量与 registry 条目。 +4. 单批发布即可:新表与追加列对既有旧代码无影响;注册只创建本地待审批记录,建店只发生在审批通过事务内。 +5. 运行期前置(非迁移写入):上线前由超级管理员经 `PUT /api/admin/wecom/scenes/{business_type}` 为 `agent_distribution_approval`、`withdrawal_qualification_approval`、`commission_withdrawal_approval` 三个业务类型配置启用场景与模板控件映射;验收证据为场景列表接口 `GET /api/admin/wecom/scenes` 返回这三个业务类型的启用记录及其 `control_mapping`(各场景可映射字段清单可经 `GET /api/admin/wecom/scenes/{business_type}/fields` 核对)。迁移不写入模板;未配置时相应提交按既有规则失败关闭(`Prepare` 返回服务不可用)。 +6. 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL + Redis DB 6 验证:迁移从本地工作区以显式 `DB_*` 执行 `scripts/migrate.sh`,只创建、删除本 Change 自己的 fixture,禁止重置整库;仅连接、迁移或实际行为失败时才阻塞对应场景。 +7. 回滚时先停止新入口;已有注册、资格与提现事实保留,只有维护者确认未产生不可逆业务数据时才执行 `down`。 diff --git a/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/proposal.md b/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/proposal.md new file mode 100644 index 0000000..71db5d8 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/proposal.md @@ -0,0 +1,29 @@ +## Scope + +- 迭代编号:`AUG26-008`。 + +## Why + +代理下级归属和提现资料/资金缺少企业微信终审及失效边界,无法可靠追溯。 + +## What Changes + +- 代理店铺唯一分销码;公开扫码注册接口只创建待审批注册记录,审批通过后才在单一事务内建店铺、代理账号、钱包、上级层级与业务员快照。 +- 合同、法人身份证为核心的提现资格审批与失效;资格替换即新资料版本与新审批实例,不引入同行重提状态机。 +- 提现增加审批尝试记录快照、企微终审、驳回释放与重提;企业微信通过保持 `WithdrawalStatusApproved` 并以 `paid_at` 记录到账时间。 +- 已关联审批实例的提现不再接受本地人工终审;`approval_instance_id` 为空的存量记录保持既有兼容处理。 +- 交付公开注册后端接口;H5 页面、二维码渲染与前端交互不在本 Change。 + +## Capabilities + +### New Capabilities +- `agent-distribution-withdrawal`: 分销注册、资料资格与提现审批。 + +### Modified Capabilities +- 无。 + +## Impact + +影响代理店铺、个人客户公开接口面、附件、佣金钱包、企业微信审批与 Schema。 + +新增待审批注册记录、提现资格资料版本、提现审批尝试记录三张表,以及 `agent_distribution_approval`、`withdrawal_qualification_approval`、`commission_withdrawal_approval` 三个审批业务类型;既有店铺、账号、钱包与提现列表的对外契约与兼容语义不变。 diff --git a/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/specs/agent-distribution-withdrawal/spec.md b/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/specs/agent-distribution-withdrawal/spec.md new file mode 100644 index 0000000..93e8df0 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/specs/agent-distribution-withdrawal/spec.md @@ -0,0 +1,157 @@ +## Purpose + +使代理下级注册和佣金提现均以企业微信审批、资格有效性和不可变业务快照为准,避免代理层级或资金事实因资料变更、重复回调而漂移。 + +## ADDED Requirements + +### Requirement: 分销码与待审批代理注册 + +系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码,二维码渲染与 H5 页面不属于本能力。分销码 MUST NOT 支持人工指定或编辑。 + +系统 SHALL 提供公开后端接口 `POST /api/c/v1/agent-distribution-registrations`,不要求登录、JWT、角色或权限。请求 MUST 携带有效 `distribution_code`、短信已验证手机号、密码及既有注册必填资料;系统 MUST 复用既有短信验证码校验、消费与限流规则。系统 MUST 为该次申请创建唯一的待审批注册记录,并 MUST NOT 在审批通过前创建店铺、代理账号、钱包或上下级归属。 + +无效分销码、分销码所属店铺已停用、验证码无效或已被消费时,系统 MUST NOT 创建注册记录或审批实例,且 MUST 对外返回统一不可用结果。分销码所属店铺停用 MUST NOT 级联变更既有下级与既有佣金关系。 + +注册审批 MUST 使用业务类型 `agent_distribution_approval`,其业务标识 MUST 为待审批注册记录主键。企业微信最终通过时,系统 MUST 在同一事务内创建启用店铺、代理账号、所需钱包,写入上级店铺、初始业务员快照,标记注册记录已通过并记录审计;最终驳回时 MUST NOT 创建店铺、账号、钱包或层级,仅保留注册记录与审批结果。 + +同一手机号在驳回后再次扫码 MUST 形成新的注册记录与新的审批实例;重复或乱序回调 MUST NOT 重复创建账号、层级或钱包。 + +#### Scenario: 扫码注册进入待审批 + +- **WHEN** 客户使用有效分销码提交已验证手机号、密码与注册必填资料 +- **THEN** 系统仅创建一条待审批注册记录及企业微信审批实例,不创建店铺、账号或钱包 + +#### Scenario: 停用代理码注册 + +- **WHEN** 客户使用已停用代理所属店铺的分销码注册 +- **THEN** 系统拒绝创建注册记录与审批实例,且不产生任何店铺或账号 + +#### Scenario: 验证码无效或已消费 + +- **WHEN** 请求携带的短信验证码无效、过期或已被消费 +- **THEN** 系统拒绝创建注册记录,且不消耗该分销码的注册名额 + +#### Scenario: 审批通过建立层级与业务员快照 + +- **WHEN** 企业微信最终通过一条待审批注册记录 +- **THEN** 系统在同一事务内创建启用店铺与代理账号、写入分销码所属店铺为直接上级、复制上级当时业务员为初始业务员,并标记注册记录已通过 + +#### Scenario: 审批驳回不产生实体 + +- **WHEN** 企业微信最终驳回一条待审批注册记录 +- **THEN** 系统不创建店铺、账号、钱包或层级,且同一手机号可再次扫码形成新的注册记录 + +#### Scenario: 重复回调不重复建层级 + +- **WHEN** 同一注册审批终态被重复投递 +- **THEN** 系统至多创建一次店铺、账号、钱包与层级关系 + +### Requirement: 提现资料资格 + +代理首次提现前 SHALL 提交企业微信资料资格申请;合同和法人身份证正反面必填,企业代理填写统一社会信用代码、个人代理填写法人身份证号。合同资格主体 MUST 填写统一社会信用代码或身份证号;营业执照、门头照可选,发票仅企业可选且其抬头/统一社会信用代码 MUST 与合同主体一致。审批通过且资料未过期才有效;合同和身份证通过后长期有效,直到代理替换资料、超级管理员作废或代理停用。 + +系统 SHALL 以不可变的资料版本保存资格事实与附件引用,历史版本、附件与审批实例 MUST NOT 被覆盖。审批 MUST 使用业务类型 `withdrawal_qualification_approval`,其业务标识 MUST 为资料版本主键。代理替换合同或法人身份证 MUST 新增资料版本并创建新的审批实例,同时在同一事务内使旧有效版本失效;代理 MUST 重新审批通过后才可提现。系统 MUST NOT 为资格资料提供同一版本的重新提交或重提状态机。 + +超级管理员作废资格 MUST 填写原因;代理停用 MUST 自动使该店铺全部有效资格失效。 + +资格申请、资格作废与后台查询 MUST 沿用既有认证、自身店铺或数据范围校验,MUST NOT 公开。 + +#### Scenario: 资料替换后提现 + +- **WHEN** 有效资格的代理替换合同或法人身份证资料 +- **THEN** 旧有效版本立即失效,新版本进入企业微信审批,代理重新审批通过后才可提现 + +#### Scenario: 历史版本不被覆盖 + +- **WHEN** 同一店铺存在多个资料版本 +- **THEN** 每个版本保留各自的附件引用、审批实例与审批结果,历史版本不被改写 + +#### Scenario: 超级管理员作废资格 + +- **WHEN** 超级管理员填写原因后作废某有效资格 +- **THEN** 该资格失效且原因可查询,代理提现被拒绝直至重新审批通过 + +#### Scenario: 代理停用使资格失效 + +- **WHEN** 代理店铺被停用 +- **THEN** 该店铺全部有效资格失效,历史版本与审批结果保留 + +#### Scenario: 未认证或越权访问资格接口 + +- **WHEN** 未认证请求,或请求的店铺超出当前账号数据范围 +- **THEN** 系统拒绝访问且不披露资格是否存在 + +### Requirement: 提现冻结与企业微信终审 + +提现申请 SHALL 先校验申请店铺存在有效资料资格,再冻结可提现余额、金额、手续费、收款信息和可选发票快照,并创建企业微信审批。余额不足、资格无效或非本人代理时,系统 MUST NOT 创建提现申请、审批实例或任何冻结。发票为申请级材料,若上传 MUST 按当时有效合同主体校验并冻结至该次审批快照。本地 MUST NOT 提供人工通过或驳回终审。审批 MUST 使用业务类型 `commission_withdrawal_approval`。 + +每次提交或重提 MUST 新增一条不可变的审批尝试记录,冻结该次金额、手续费、实际到账、收款信息与申请级发票快照,并为其创建独立的审批实例;提现申请行只保存最新审批实例标识用于列表投影,MUST NOT 作为历史事实来源。企业微信通过即视为已到账:系统 MUST 保持 `WithdrawalStatusApproved`(2)并在同一事务内仅一次从冻结余额扣减,同时写入到账时间;MUST NOT 使用已到账状态值 4。 + +企业微信驳回时,系统 MUST 仅一次释放本次尝试的冻结余额并记录释放时间;`cancelled` 与 `deleted` MUST 按驳回同等处理。驳回后代理可修改金额、收款信息和本次发票重新提交,每次新建审批尝试记录与审批实例;资料资格保持有效。企业微信对已通过申请撤销时,系统 MUST NOT 回滚已到账金额、MUST NOT 重新冻结,MUST 保持通过状态并写入正交的异常标记与原因供详情展示与人工处理,MUST NOT 自动重提。 + +重复、乱序或结果未知的回调 MUST NOT 造成重复扣减、重复释放或第二笔冻结;结果未知时申请保持在途并由既有查询恢复机制收敛。 + +#### Scenario: 资格无效或余额不足不创建提现 + +- **WHEN** 代理店铺不存在有效资料资格、资格已失效,或可提现余额不足 +- **THEN** 系统拒绝创建提现申请,不创建审批实例且不冻结任何余额 + +#### Scenario: 提现审批通过 + +- **WHEN** 企业微信对一笔待审提现返回最终通过且该结果首次被消费 +- **THEN** 系统仅一次从冻结余额扣减,申请保持已通过状态并记录到账时间 + +#### Scenario: 提现审批驳回 + +- **WHEN** 企业微信最终驳回一笔提现申请 +- **THEN** 系统仅一次释放该次尝试的冻结余额并记录释放时间,保留审批快照,并允许在资格仍有效时修改后重提 + +#### Scenario: 驳回后重提 + +- **WHEN** 代理修改金额、收款信息或本次发票后重新提交被驳回的提现申请 +- **THEN** 系统新增审批尝试记录并创建新的审批实例,历史尝试、快照与审批结果不被覆盖 + +#### Scenario: 企微取消或删除 + +- **WHEN** 企业微信对待审提现返回取消或删除 +- **THEN** 系统按驳回同等处理,仅一次释放冻结余额并记录释放时间 + +#### Scenario: 通过后撤销 + +- **WHEN** 企业微信对已通过的提现申请返回通过后撤销 +- **THEN** 系统不回滚已到账金额、不重新冻结、不自动重提,仅写入异常标记与原因供详情展示 + +#### Scenario: 重复回调不重复扣减 + +- **WHEN** 同一审批终态被重复投递或乱序到达 +- **THEN** 系统至多扣减或释放一次,不产生第二笔冻结 + +### Requirement: 提现本地人工终审边界 + +系统 SHALL 拒绝本地人工通过与驳回已关联审批实例的提现申请;该判定 MUST 以申请记录中是否已关联审批实例为准,MUST NOT 依赖任何新增配置开关。本地接口拒绝时 MUST 返回稳定的状态冲突错误且 MUST NOT 改变申请状态、余额或流水。 + +`approval_instance_id` 为空的存量提现申请 MUST 保持既有本地人工处理行为不变;其他审批场景的本地历史语义 MUST NOT 被本能力改变。 + +#### Scenario: 新提现申请拒绝本地终审 + +- **WHEN** 平台账号对已关联企业微信审批实例的提现申请调用本地通过或驳回接口 +- **THEN** 系统返回状态冲突、申请状态与钱包余额不变 + +#### Scenario: 存量提现保持兼容 + +- **WHEN** 本地通过或驳回接口作用于从未关联审批实例的存量提现申请 +- **THEN** 系统按既有规则完成扣减或释放,行为与本次变更前一致 + +### Requirement: 待审提现与佣金回溯的释放接缝 + +系统 SHALL 保证后续佣金回溯处理待审提现时,每个未结算审批尝试的冻结余额可被幂等释放:释放金额 MUST 取该尝试的金额事实,释放完成 MUST 写入该尝试的释放时间事实,且释放 MUST 先于同一店铺佣金余额扣减完成。已释放的尝试 MUST NOT 被重复释放,重复处理 MUST NOT 重复增加可提现余额。 + +#### Scenario: 回溯先释放后扣减 + +- **WHEN** 佣金回溯处理存在待审提现的店铺 +- **THEN** 系统先释放每个未结算尝试的冻结余额并记录释放时间,再扣减佣金余额 + +#### Scenario: 重复释放不重复入账 + +- **WHEN** 同一未结算尝试的释放被重复执行 +- **THEN** 系统至多释放一次,冻结余额与可提现余额不被重复调整 diff --git a/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/tasks.md b/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/tasks.md new file mode 100644 index 0000000..a030503 --- /dev/null +++ b/openspec/changes/archive/2026-09-14-add-agent-distribution-withdrawal-qualification/tasks.md @@ -0,0 +1,34 @@ +## 1. 分销与注册 + +- [x] 1.1 为 `tb_shop` 新增全局唯一、不可修改的随机 `distribution_code`(新成对迁移 + 条件唯一索引,编号按实施时最大编号顺延);在 `internal/application/shop/create.go` 建店事务内生成并处理唯一冲突重试;同步 `scripts/migration/lib/shop_sql.py` 使批量导入店铺同样生成分销码;不提供人工指定或编辑入口。 +- [x] 1.2 新增 `tb_agent_distribution_registration`(分销码、上级店铺、手机号、密码哈希、状态、审批实例与时间戳)及模型、状态常量、审计资源常量与 registry 条目;不创建 `status=2` 的店铺或账号;同一手机号驳回后再次扫码为新记录。 +- [x] 1.3 实现公开接口 `POST /api/c/v1/agent-distribution-registrations`:经 `internal/routes.Register` 注册,且在 `internal/routes/personal.go` 的 `Use()` 之前;复用既有验证码校验、消费与限流;锁定上级店铺并校验启用,无效码、停用上级、验证码无效或已消费统一返回“分销码不可用”且不落库;新 Handler 同步 `pkg/openapi/handlers.go`、`cmd/api/docs.go`、`cmd/gendocs/main.go` 与 bootstrap 装配。 +- [x] 1.4 注册 `agent_distribution_approval` 决策消费者:通过时同一事务建启用店铺、代理主账号、所需钱包、上级层级与上级业务员快照,标记注册记录并写审计,提交后清理上级店铺下级缓存;驳回仅标记不建实体;条件更新保证重复回调不重复建账号、层级或钱包。 +- [x] 1.5 新增 `tb_withdrawal_qualification`(主体类型、签约主体代码、法人身份证号、合同与身份证正反面附件、可选营业执照/门头照/发票及其主体代码、状态与失效原因、审批实例)与模型、常量;附件每项单对象键,总数不超过企微 6 附件上限。 +- [x] 1.6 实现资格提交、替换、作废与停用失效:`POST /api/admin/shops/:shop_id/withdrawal-qualifications`(仅本人代理店铺)、超管作废 `POST /api/admin/withdrawal-qualifications/:id/void`(原因必填)、代理停用联动失效;替换合同或身份证时同一事务新增版本并使旧有效版本失效;注册 `withdrawal_qualification_approval` 消费者,驳回不影响既有有效版本。 +- [x] 1.7 为分销码生成、注册通过/驳回、资格提交/替换/作废/停用失效补齐事务内审计;日志与审计不记录密码、完整证件号或附件内容。 + +## 2. 提现 + +- [x] 2.1 新增 `tb_commission_withdrawal_request_attempt`(`request_id`、`attempt_no`、金额/手续费/费率/实际到账快照、收款信息、申请级发票快照、提交人、审批实例、释放时间)及唯一约束与索引;为 `tb_commission_withdrawal_request` 追加 `latest_attempt_id`、`latest_approval_instance_id`、通过时间与异常标记/原因列。 +- [x] 2.2 改造 `CreateWithdrawalRequest`:增加有效资格校验;事务内锁定佣金钱包、冻结金额、写尝试记录、创建审批实例(`business_id` = 尝试记录主键)、回填 `latest_*`、写钱包流水与审计;余额不足、资格无效或非本人代理均不创建申请或冻结。 +- [x] 2.3 实现驳回后重提:仅已驳回申请可由本人代理修改金额、收款信息与本次发票;事务内先释放旧未结算尝试的冻结,再按新金额冻结并新增尝试记录与新审批实例,历史快照与审批结果不被覆盖,不产生第二笔冻结。 +- [x] 2.4 注册 `commission_withdrawal_approval` 终态消费者:通过仅一次从冻结余额扣减、保持 `WithdrawalStatusApproved`=2 并写 `paid_at`;驳回与 `cancelled`/`deleted` 仅一次释放本次尝试冻结并写释放时间;`revoked_after_approved` 不回滚、不重新冻结、不自动重提,写正交异常标记与原因供详情展示;重复、乱序与未知结果由既有交付租约与查询恢复收敛。 +- [x] 2.5 本地人工终审边界:`internal/service/commission_withdrawal` 的通过/驳回在申请已关联审批实例时拒绝并返回状态冲突,不新增任何配置开关;`approval_instance_id` 为空的存量申请保持既有兼容处理;错误交全局 ErrorHandler。 +- [x] 2.6 注册资格、提现申请与提现详情的后台路由及 OpenAPI,保障自身店铺与数据范围校验;公开接口不得承载资格、提现或后台查询操作。 + +## 3. 审批业务类型注册点 + +- [x] 3.1 `pkg/constants/approval.go`:新增 `agent_distribution_approval`、`withdrawal_qualification_approval`、`commission_withdrawal_approval` 三个业务类型常量与各场景业务字段常量,注释写明 `business_id` 取值语义。 +- [x] 3.2 `internal/model/dto/wecom_scene_dto.go`:两处 `business_type` enum 与中文 description 同步新增三个类型(ENG-DTO-001)。 +- [x] 3.3 `internal/application/wecom/scene.go`:`sceneBusinessFields` 白名单、`validApprovalBusinessType`、业务类型中文名三处新增。 +- [x] 3.4 新成对迁移扩展 `chk_wecom_approval_scene_business` 纳入三个新类型;`down` 在存在新类型场景行时拒绝破坏性回滚并报明确异常;不修改既有迁移,编号按实施时最大编号顺延。 +- [x] 3.5 `cmd/worker/main.go`:新增三个决策 handler 的依赖装配并注册到 `BusinessDecisionHandler` dispatcher(缺注册时按既有规则返回服务不可用)。 +- [x] 3.6 `internal/infrastructure/audit/approval.go` 的 `approvalBusinessResource` 新增三个业务类型的资源映射(缺映射会 fail-closed);`pkg/constants/audit.go` 与 `internal/infrastructure/audit/registry.go` 新增分销注册记录、资格资料版本、提现审批尝试记录三个审计资源(提现单资源已存在,无需新增)。 +- [ ] 3.7 运行期前置:上线前由超级管理员经 `PUT /api/admin/wecom/scenes/{business_type}` 为三个业务类型配置启用场景与模板控件映射;迁移不写入模板,未配置时相应提交失败关闭。**待维护者执行**:配置 `agent_distribution_approval`、`withdrawal_qualification_approval`、`commission_withdrawal_approval` 的启用场景与控件映射(资格 6 个附件控件对应 `file_list`,必填合同/法人身份证正反面、可选营业执照/门头照/发票);验收证据为 `GET /api/admin/wecom/scenes` 返回三类型启用记录及 `control_mapping`,字段清单经 `GET /api/admin/wecom/scenes/{business_type}/fields` 核对。本项为运行期配置数据,不是代码交付物,故未勾选。 + +## 4. 验证 + +- [x] 4.1 按 ENG-TEST-001 在 `junhong_cmp_test` PostgreSQL + Redis DB 6 验证:迁移 `up/down/up`;从本地工作区以显式 `DB_*` 参数执行 `scripts/migrate.sh`;仅创建、删除本 Change 自己的 fixture,禁止重置整库。验证:以显式 `DB_*`(`junhong_cmp_test`)执行 `./scripts/migrate.sh up`(214/215/216/217 全部成功)→ `version` 为 217 且 `dirty=false` → `down 3` → 再 `up` 回 217;`down` 守卫按设计拒绝破坏性回滚(存在 `tb_agent_distribution_registration` 行时 000214 拒绝、存在新类型场景行时 000216 拒绝),因此整库回退只能到 `000214`,发布说明写明该边界。验证期间只创建、删除本 Change 自己的 fixture(`shop_code LIKE 'AUG26008%'`、三张新表、13 个新增动作码审计与资源行),复核残留:新表与审计残留均为 0,`tb_shop` 无空分销码且全局唯一;未重置整库。 +- [x] 4.2 受控验证本地状态机:以 Redis 预置验证码(DB 6)、`approval.Port.Prepare` 与受控 `TerminalDecisionEvent` 驱动业务消费者,验证分销码唯一与停用门禁、注册通过/驳回与重复回调、资格替换与作废、提现冻结/重提/通过/驳回/取消/删除/通过后撤销、本地终审拒绝与存量兼容、待审提现释放接缝;不调用企业微信、短信、对象存储或支付网络。验证:临时受控脚手架(Redis DB 6 显式覆盖,`.env.local` 的 DB 7 不适用)驱动真实业务消费者,结果 **227 项通过 / 0 项失败**,分 18 组:分销码唯一/随机/不可修改/冲突重试与非事务句柄守卫;公开注册验证码无效与已消费、无效码、停用上级统一拒绝且不落库;注册通过建店+账号+钱包+层级+业务员快照且新码≠上级码并可续开下级,重复回调不重复建实体,驳回不建实体且同手机号可再注册;资格提交/替换失效/驳回不改写历史/超管作废/停用与删店联动失效;提现冻结/通过(状态保持 2 且写 `paid_at`,不使用 4)/驳回/cancelled/deleted/重提单笔冻结/通过后撤销不回滚;释放接缝先释放后扣减、重复释放不重复入账;本地终审拒绝新申请与存量兼容;退款佣金回扣 × 在途企微提现(修复前复现 8 项失败);统一加锁序下 6 轮并发无死锁;负向场景拒绝审计与 14 个动作码审计真实落库、恰一个主要资源、失败/拒绝审计不再被吞(`auditfailure` 日志 0 行);数据范围与脱敏投影。脚手架为一次性产物,按清理要求已删除,未在仓库留下测试入口(自动化测试按项目决策为 N/A)。 +- [x] 4.3 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-agent-distribution-withdrawal-qualification --strict`、`openspec doctor --json` 与 `./scripts/context-health.sh`;自动化测试按项目决策为 N/A。验证:`gofmt -l` 对全部变更 Go 文件输出为空;`go build ./cmd/api ./cmd/worker` 无错误;`go vet ./internal/... ./pkg/...` 无输出;`go run cmd/gendocs/main.go` 重新生成 `docs/admin-openapi.yaml`,与工作区文件逐字节一致,新增路由与 6 值业务类型枚举齐全,既有路由未被改写或删除;`./scripts/context-health.sh` 输出「Context 健康检查通过」;`openspec validate add-agent-distribution-withdrawal-qualification --strict` 输出 `Change 'add-agent-distribution-withdrawal-qualification' is valid`;`openspec doctor --json` 为 `"healthy": true`。 diff --git a/openspec/specs/agent-distribution-withdrawal/spec.md b/openspec/specs/agent-distribution-withdrawal/spec.md new file mode 100644 index 0000000..5c14264 --- /dev/null +++ b/openspec/specs/agent-distribution-withdrawal/spec.md @@ -0,0 +1,157 @@ +# agent-distribution-withdrawal Specification + +## Purpose +使代理下级注册和佣金提现均以企业微信审批、资格有效性和不可变业务快照为准,避免代理层级或资金事实因资料变更、重复回调而漂移。 +## Requirements +### Requirement: 分销码与待审批代理注册 + +系统 SHALL 在每个代理店铺创建时生成全局唯一、不可修改的随机分销码;二维码仅编码 H5 注册入口和该码,二维码渲染与 H5 页面不属于本能力。分销码 MUST NOT 支持人工指定或编辑。 + +系统 SHALL 提供公开后端接口 `POST /api/c/v1/agent-distribution-registrations`,不要求登录、JWT、角色或权限。请求 MUST 携带有效 `distribution_code`、短信已验证手机号、密码及既有注册必填资料;系统 MUST 复用既有短信验证码校验、消费与限流规则。系统 MUST 为该次申请创建唯一的待审批注册记录,并 MUST NOT 在审批通过前创建店铺、代理账号、钱包或上下级归属。 + +无效分销码、分销码所属店铺已停用、验证码无效或已被消费时,系统 MUST NOT 创建注册记录或审批实例,且 MUST 对外返回统一不可用结果。分销码所属店铺停用 MUST NOT 级联变更既有下级与既有佣金关系。 + +注册审批 MUST 使用业务类型 `agent_distribution_approval`,其业务标识 MUST 为待审批注册记录主键。企业微信最终通过时,系统 MUST 在同一事务内创建启用店铺、代理账号、所需钱包,写入上级店铺、初始业务员快照,标记注册记录已通过并记录审计;最终驳回时 MUST NOT 创建店铺、账号、钱包或层级,仅保留注册记录与审批结果。 + +同一手机号在驳回后再次扫码 MUST 形成新的注册记录与新的审批实例;重复或乱序回调 MUST NOT 重复创建账号、层级或钱包。 + +#### Scenario: 扫码注册进入待审批 + +- **WHEN** 客户使用有效分销码提交已验证手机号、密码与注册必填资料 +- **THEN** 系统仅创建一条待审批注册记录及企业微信审批实例,不创建店铺、账号或钱包 + +#### Scenario: 停用代理码注册 + +- **WHEN** 客户使用已停用代理所属店铺的分销码注册 +- **THEN** 系统拒绝创建注册记录与审批实例,且不产生任何店铺或账号 + +#### Scenario: 验证码无效或已消费 + +- **WHEN** 请求携带的短信验证码无效、过期或已被消费 +- **THEN** 系统拒绝创建注册记录,且不消耗该分销码的注册名额 + +#### Scenario: 审批通过建立层级与业务员快照 + +- **WHEN** 企业微信最终通过一条待审批注册记录 +- **THEN** 系统在同一事务内创建启用店铺与代理账号、写入分销码所属店铺为直接上级、复制上级当时业务员为初始业务员,并标记注册记录已通过 + +#### Scenario: 审批驳回不产生实体 + +- **WHEN** 企业微信最终驳回一条待审批注册记录 +- **THEN** 系统不创建店铺、账号、钱包或层级,且同一手机号可再次扫码形成新的注册记录 + +#### Scenario: 重复回调不重复建层级 + +- **WHEN** 同一注册审批终态被重复投递 +- **THEN** 系统至多创建一次店铺、账号、钱包与层级关系 + +### Requirement: 提现资料资格 + +代理首次提现前 SHALL 提交企业微信资料资格申请;合同和法人身份证正反面必填,企业代理填写统一社会信用代码、个人代理填写法人身份证号。合同资格主体 MUST 填写统一社会信用代码或身份证号;营业执照、门头照可选,发票仅企业可选且其抬头/统一社会信用代码 MUST 与合同主体一致。审批通过且资料未过期才有效;合同和身份证通过后长期有效,直到代理替换资料、超级管理员作废或代理停用。 + +系统 SHALL 以不可变的资料版本保存资格事实与附件引用,历史版本、附件与审批实例 MUST NOT 被覆盖。审批 MUST 使用业务类型 `withdrawal_qualification_approval`,其业务标识 MUST 为资料版本主键。代理替换合同或法人身份证 MUST 新增资料版本并创建新的审批实例,同时在同一事务内使旧有效版本失效;代理 MUST 重新审批通过后才可提现。系统 MUST NOT 为资格资料提供同一版本的重新提交或重提状态机。 + +超级管理员作废资格 MUST 填写原因;代理停用 MUST 自动使该店铺全部有效资格失效。 + +资格申请、资格作废与后台查询 MUST 沿用既有认证、自身店铺或数据范围校验,MUST NOT 公开。 + +#### Scenario: 资料替换后提现 + +- **WHEN** 有效资格的代理替换合同或法人身份证资料 +- **THEN** 旧有效版本立即失效,新版本进入企业微信审批,代理重新审批通过后才可提现 + +#### Scenario: 历史版本不被覆盖 + +- **WHEN** 同一店铺存在多个资料版本 +- **THEN** 每个版本保留各自的附件引用、审批实例与审批结果,历史版本不被改写 + +#### Scenario: 超级管理员作废资格 + +- **WHEN** 超级管理员填写原因后作废某有效资格 +- **THEN** 该资格失效且原因可查询,代理提现被拒绝直至重新审批通过 + +#### Scenario: 代理停用使资格失效 + +- **WHEN** 代理店铺被停用 +- **THEN** 该店铺全部有效资格失效,历史版本与审批结果保留 + +#### Scenario: 未认证或越权访问资格接口 + +- **WHEN** 未认证请求,或请求的店铺超出当前账号数据范围 +- **THEN** 系统拒绝访问且不披露资格是否存在 + +### Requirement: 提现冻结与企业微信终审 + +提现申请 SHALL 先校验申请店铺存在有效资料资格,再冻结可提现余额、金额、手续费、收款信息和可选发票快照,并创建企业微信审批。余额不足、资格无效或非本人代理时,系统 MUST NOT 创建提现申请、审批实例或任何冻结。发票为申请级材料,若上传 MUST 按当时有效合同主体校验并冻结至该次审批快照。本地 MUST NOT 提供人工通过或驳回终审。审批 MUST 使用业务类型 `commission_withdrawal_approval`。 + +每次提交或重提 MUST 新增一条不可变的审批尝试记录,冻结该次金额、手续费、实际到账、收款信息与申请级发票快照,并为其创建独立的审批实例;提现申请行只保存最新审批实例标识用于列表投影,MUST NOT 作为历史事实来源。企业微信通过即视为已到账:系统 MUST 保持 `WithdrawalStatusApproved`(2)并在同一事务内仅一次从冻结余额扣减,同时写入到账时间;MUST NOT 使用已到账状态值 4。 + +企业微信驳回时,系统 MUST 仅一次释放本次尝试的冻结余额并记录释放时间;`cancelled` 与 `deleted` MUST 按驳回同等处理。驳回后代理可修改金额、收款信息和本次发票重新提交,每次新建审批尝试记录与审批实例;资料资格保持有效。企业微信对已通过申请撤销时,系统 MUST NOT 回滚已到账金额、MUST NOT 重新冻结,MUST 保持通过状态并写入正交的异常标记与原因供详情展示与人工处理,MUST NOT 自动重提。 + +重复、乱序或结果未知的回调 MUST NOT 造成重复扣减、重复释放或第二笔冻结;结果未知时申请保持在途并由既有查询恢复机制收敛。 + +#### Scenario: 资格无效或余额不足不创建提现 + +- **WHEN** 代理店铺不存在有效资料资格、资格已失效,或可提现余额不足 +- **THEN** 系统拒绝创建提现申请,不创建审批实例且不冻结任何余额 + +#### Scenario: 提现审批通过 + +- **WHEN** 企业微信对一笔待审提现返回最终通过且该结果首次被消费 +- **THEN** 系统仅一次从冻结余额扣减,申请保持已通过状态并记录到账时间 + +#### Scenario: 提现审批驳回 + +- **WHEN** 企业微信最终驳回一笔提现申请 +- **THEN** 系统仅一次释放该次尝试的冻结余额并记录释放时间,保留审批快照,并允许在资格仍有效时修改后重提 + +#### Scenario: 驳回后重提 + +- **WHEN** 代理修改金额、收款信息或本次发票后重新提交被驳回的提现申请 +- **THEN** 系统新增审批尝试记录并创建新的审批实例,历史尝试、快照与审批结果不被覆盖 + +#### Scenario: 企微取消或删除 + +- **WHEN** 企业微信对待审提现返回取消或删除 +- **THEN** 系统按驳回同等处理,仅一次释放冻结余额并记录释放时间 + +#### Scenario: 通过后撤销 + +- **WHEN** 企业微信对已通过的提现申请返回通过后撤销 +- **THEN** 系统不回滚已到账金额、不重新冻结、不自动重提,仅写入异常标记与原因供详情展示 + +#### Scenario: 重复回调不重复扣减 + +- **WHEN** 同一审批终态被重复投递或乱序到达 +- **THEN** 系统至多扣减或释放一次,不产生第二笔冻结 + +### Requirement: 提现本地人工终审边界 + +系统 SHALL 拒绝本地人工通过与驳回已关联审批实例的提现申请;该判定 MUST 以申请记录中是否已关联审批实例为准,MUST NOT 依赖任何新增配置开关。本地接口拒绝时 MUST 返回稳定的状态冲突错误且 MUST NOT 改变申请状态、余额或流水。 + +`approval_instance_id` 为空的存量提现申请 MUST 保持既有本地人工处理行为不变;其他审批场景的本地历史语义 MUST NOT 被本能力改变。 + +#### Scenario: 新提现申请拒绝本地终审 + +- **WHEN** 平台账号对已关联企业微信审批实例的提现申请调用本地通过或驳回接口 +- **THEN** 系统返回状态冲突、申请状态与钱包余额不变 + +#### Scenario: 存量提现保持兼容 + +- **WHEN** 本地通过或驳回接口作用于从未关联审批实例的存量提现申请 +- **THEN** 系统按既有规则完成扣减或释放,行为与本次变更前一致 + +### Requirement: 待审提现与佣金回溯的释放接缝 + +系统 SHALL 保证后续佣金回溯处理待审提现时,每个未结算审批尝试的冻结余额可被幂等释放:释放金额 MUST 取该尝试的金额事实,释放完成 MUST 写入该尝试的释放时间事实,且释放 MUST 先于同一店铺佣金余额扣减完成。已释放的尝试 MUST NOT 被重复释放,重复处理 MUST NOT 重复增加可提现余额。 + +#### Scenario: 回溯先释放后扣减 + +- **WHEN** 佣金回溯处理存在待审提现的店铺 +- **THEN** 系统先释放每个未结算尝试的冻结余额并记录释放时间,再扣减佣金余额 + +#### Scenario: 重复释放不重复入账 + +- **WHEN** 同一未结算尝试的释放被重复执行 +- **THEN** 系统至多释放一次,冻结余额与可提现余额不被重复调整 + diff --git a/pkg/constants/agent_distribution.go b/pkg/constants/agent_distribution.go new file mode 100644 index 0000000..6e2e0f2 --- /dev/null +++ b/pkg/constants/agent_distribution.go @@ -0,0 +1,29 @@ +package constants + +const ( + // AgentDistributionRegistrationStatusPending 表示注册记录等待企业微信终审。 + AgentDistributionRegistrationStatusPending = 0 + // AgentDistributionRegistrationStatusApproved 表示注册记录已通过并已建立店铺与代理账号。 + AgentDistributionRegistrationStatusApproved = 1 + // AgentDistributionRegistrationStatusRejected 表示注册记录已驳回,未创建任何实体。 + AgentDistributionRegistrationStatusRejected = 2 +) + +const ( + // ShopDistributionCodeMaxAttempts 表示建店事务内生成分销码的最大尝试次数。 + ShopDistributionCodeMaxAttempts = 5 +) + +// GetAgentDistributionRegistrationStatusName 返回注册记录状态的中文名称。 +func GetAgentDistributionRegistrationStatusName(status int) string { + switch status { + case AgentDistributionRegistrationStatusPending: + return "待审批" + case AgentDistributionRegistrationStatusApproved: + return "已通过" + case AgentDistributionRegistrationStatusRejected: + return "已驳回" + default: + return "未知" + } +} diff --git a/pkg/constants/approval.go b/pkg/constants/approval.go index a094c3e..6f6d6da 100644 --- a/pkg/constants/approval.go +++ b/pkg/constants/approval.go @@ -10,6 +10,15 @@ const ( // ApprovalBusinessTypeEmployeeCollection 表示员工代收款核销审批业务场景。 // 该场景的业务ID取核销申请的审批尝试记录主键,使同一申请每次提交各自持有独立审批实例。 ApprovalBusinessTypeEmployeeCollection = "employee_collection_approval" + // ApprovalBusinessTypeAgentDistribution 表示代理扫码分销注册审批业务场景。 + // 该场景的业务ID取待审批注册记录主键,审批通过才创建店铺与代理账号。 + ApprovalBusinessTypeAgentDistribution = "agent_distribution_approval" + // ApprovalBusinessTypeWithdrawalQualification 表示提现资料资格审批业务场景。 + // 该场景的业务ID取资料版本主键,替换资料即新版本新实例,历史版本不可覆盖。 + ApprovalBusinessTypeWithdrawalQualification = "withdrawal_qualification_approval" + // ApprovalBusinessTypeCommissionWithdrawal 表示佣金提现终审审批业务场景。 + // 该场景的业务ID取提现申请的审批尝试记录主键,使同一申请每次提交或重提各自持有独立审批实例。 + ApprovalBusinessTypeCommissionWithdrawal = "commission_withdrawal_approval" ) const ( @@ -79,6 +88,75 @@ const ( ApprovalFieldCollectionBillSummary = "collection_bill_summary" ) +const ( + // ApprovalFieldDistributionCode 表示扫码注册使用的上级店铺分销码业务字段。 + ApprovalFieldDistributionCode = "distribution_code" + // ApprovalFieldDistributionParentShopID 表示扫码注册上级店铺 ID 业务字段。 + ApprovalFieldDistributionParentShopID = "distribution_parent_shop_id" + // ApprovalFieldDistributionParentShopName 表示扫码注册上级店铺名称业务字段。 + ApprovalFieldDistributionParentShopName = "distribution_parent_shop_name" + // ApprovalFieldDistributionShopName 表示申请店铺名称业务字段。 + ApprovalFieldDistributionShopName = "distribution_shop_name" + // ApprovalFieldDistributionShopCode 表示申请店铺编号业务字段。 + ApprovalFieldDistributionShopCode = "distribution_shop_code" + // ApprovalFieldDistributionUsername 表示申请代理主账号用户名业务字段。 + ApprovalFieldDistributionUsername = "distribution_username" + // ApprovalFieldDistributionPhoneMasked 表示脱敏后的注册手机号业务字段,禁止写入完整手机号。 + ApprovalFieldDistributionPhoneMasked = "distribution_phone_masked" + // ApprovalFieldDistributionContactName 表示注册联系人姓名业务字段。 + ApprovalFieldDistributionContactName = "distribution_contact_name" + // ApprovalFieldDistributionRegion 表示注册地址摘要业务字段。 + ApprovalFieldDistributionRegion = "distribution_region" + // ApprovalFieldQualificationSubjectType 表示提现资格签约主体类型中文名业务字段。 + ApprovalFieldQualificationSubjectType = "qualification_subject_type" + // ApprovalFieldQualificationSubjectCodeMasked 表示脱敏后的签约主体代码业务字段。 + ApprovalFieldQualificationSubjectCodeMasked = "qualification_subject_code_masked" + // ApprovalFieldQualificationLegalPersonMasked 表示脱敏后的法人身份证号业务字段。 + ApprovalFieldQualificationLegalPersonMasked = "qualification_legal_person_id_card_masked" + // ApprovalFieldQualificationShopID 表示提现资格所属店铺 ID 业务字段。 + ApprovalFieldQualificationShopID = "qualification_shop_id" + // ApprovalFieldQualificationShopName 表示提现资格所属店铺名称业务字段。 + ApprovalFieldQualificationShopName = "qualification_shop_name" + // ApprovalFieldQualificationContractKey 表示合同附件对象存储 Key 业务字段。 + ApprovalFieldQualificationContractKey = "qualification_contract_key" + // ApprovalFieldQualificationIDCardFrontKey 表示法人身份证正面附件对象存储 Key 业务字段。 + ApprovalFieldQualificationIDCardFrontKey = "qualification_id_card_front_key" + // ApprovalFieldQualificationIDCardBackKey 表示法人身份证反面附件对象存储 Key 业务字段。 + ApprovalFieldQualificationIDCardBackKey = "qualification_id_card_back_key" + // ApprovalFieldQualificationBusinessLicenseKey 表示营业执照附件对象存储 Key 业务字段。 + ApprovalFieldQualificationBusinessLicenseKey = "qualification_business_license_key" + // ApprovalFieldQualificationShopFrontKey 表示门头照附件对象存储 Key 业务字段。 + ApprovalFieldQualificationShopFrontKey = "qualification_shop_front_key" + // ApprovalFieldQualificationInvoiceKey 表示发票附件对象存储 Key 业务字段。 + ApprovalFieldQualificationInvoiceKey = "qualification_invoice_key" + // ApprovalFieldQualificationInvoiceTitle 表示发票抬头业务字段。 + ApprovalFieldQualificationInvoiceTitle = "qualification_invoice_title" + // ApprovalFieldWithdrawalNo 表示提现单号业务字段。 + ApprovalFieldWithdrawalNo = "withdrawal_no" + // ApprovalFieldWithdrawalAttemptNo 表示提现第几次提交业务字段。 + ApprovalFieldWithdrawalAttemptNo = "withdrawal_attempt_no" + // ApprovalFieldWithdrawalShopID 表示提现店铺 ID 业务字段。 + ApprovalFieldWithdrawalShopID = "withdrawal_shop_id" + // ApprovalFieldWithdrawalShopName 表示提现店铺名称业务字段。 + ApprovalFieldWithdrawalShopName = "withdrawal_shop_name" + // ApprovalFieldWithdrawalAmount 表示提现金额元展示业务字段。 + ApprovalFieldWithdrawalAmount = "withdrawal_amount" + // ApprovalFieldWithdrawalAmountCent 表示提现金额分业务字段。 + ApprovalFieldWithdrawalAmountCent = "withdrawal_amount_cent" + // ApprovalFieldWithdrawalFee 表示提现手续费元展示业务字段。 + ApprovalFieldWithdrawalFee = "withdrawal_fee" + // ApprovalFieldWithdrawalActualAmount 表示提现实际到账金额元展示业务字段。 + ApprovalFieldWithdrawalActualAmount = "withdrawal_actual_amount" + // ApprovalFieldWithdrawalMethod 表示提现收款方式快照业务字段。 + ApprovalFieldWithdrawalMethod = "withdrawal_method" + // ApprovalFieldWithdrawalAccountName 表示提现收款人姓名业务字段。 + ApprovalFieldWithdrawalAccountName = "withdrawal_account_name" + // ApprovalFieldWithdrawalAccountNumber 表示提现收款账号业务字段。 + ApprovalFieldWithdrawalAccountNumber = "withdrawal_account_number" + // ApprovalFieldWithdrawalInvoiceKey 表示申请级发票附件对象存储 Key 列表业务字段。 + ApprovalFieldWithdrawalInvoiceKey = "withdrawal_invoice_key" +) + const ( // ApprovalFieldValueTypeString 表示业务字段值为普通字符串。 ApprovalFieldValueTypeString = "string" diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index 2e6e2be..02a0eaa 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -381,6 +381,34 @@ const ( AuditActionCommissionWithdrawalApproved = "commission_withdrawal.approve" // AuditActionCommissionWithdrawalRejected 表示驳回佣金提现申请。 AuditActionCommissionWithdrawalRejected = "commission_withdrawal.reject" + // AuditActionCommissionWithdrawalAttemptSubmitted 表示代理提交或重提佣金提现审批尝试。 + AuditActionCommissionWithdrawalAttemptSubmitted = "commission_withdrawal.attempt_submit" + // AuditActionCommissionWithdrawalAttemptApproved 表示企业微信终审通过佣金提现并完成冻结扣减。 + AuditActionCommissionWithdrawalAttemptApproved = "commission_withdrawal.attempt_approve" + // AuditActionCommissionWithdrawalAttemptClosed 表示企业微信驳回、撤销或删除佣金提现并释放冻结。 + AuditActionCommissionWithdrawalAttemptClosed = "commission_withdrawal.attempt_close" + // AuditActionCommissionWithdrawalAnomalyFlagged 表示企业微信通过后撤销,仅写入正交异常标记。 + AuditActionCommissionWithdrawalAnomalyFlagged = "commission_withdrawal.anomaly_flag" + // AuditActionCommissionWithdrawalAttemptRejected 表示提现提交在创建业务事实前被拒绝(资格无效、余额不足或越权)。 + // 该拒绝没有提现单可挂,因此以店铺为主要资源记录拒绝事实。 + AuditActionCommissionWithdrawalAttemptRejected = "commission_withdrawal.attempt_reject" + // AuditActionWithdrawalQualificationSubmitRejected 表示资格提交在创建资料版本前被拒绝(存在待审批版本或参数非法)。 + // 该拒绝没有资料版本可挂,因此以店铺为主要资源记录拒绝事实。 + AuditActionWithdrawalQualificationSubmitRejected = "withdrawal_qualification.submit_reject" + // AuditActionAgentDistributionRegistrationApproved 表示企业微信通过扫码注册并建立店铺与代理账号。 + AuditActionAgentDistributionRegistrationApproved = "agent_distribution.registration_approve" + // AuditActionAgentDistributionRegistrationRejected 表示企业微信驳回扫码注册,不创建任何实体。 + AuditActionAgentDistributionRegistrationRejected = "agent_distribution.registration_reject" + // AuditActionWithdrawalQualificationSubmitted 表示提交或替换提现资料资格版本。 + AuditActionWithdrawalQualificationSubmitted = "withdrawal_qualification.submit" + // AuditActionWithdrawalQualificationApproved 表示提现资料资格企业微信通过并生效。 + AuditActionWithdrawalQualificationApproved = "withdrawal_qualification.approve" + // AuditActionWithdrawalQualificationRejected 表示提现资料资格被企业微信驳回。 + AuditActionWithdrawalQualificationRejected = "withdrawal_qualification.reject" + // AuditActionWithdrawalQualificationVoided 表示超级管理员填写原因后作废提现资料资格。 + AuditActionWithdrawalQualificationVoided = "withdrawal_qualification.void" + // AuditActionWithdrawalQualificationInvalidated 表示代理店铺停用联动使有效提现资料资格失效。 + AuditActionWithdrawalQualificationInvalidated = "withdrawal_qualification.invalidate" // AuditActionSystemConfigUpdated 表示更新受控系统配置。 AuditActionSystemConfigUpdated = "system_config.updated" // AuditActionPaymentConfigCreated 表示创建支付连接配置。 @@ -670,6 +698,12 @@ const ( AuditResourceCommissionRecord = "commission_record" // AuditResourceCommissionWithdrawal 表示佣金提现单资源。 AuditResourceCommissionWithdrawal = "commission_withdrawal" + // AuditResourceCommissionWithdrawalAttempt 表示提现审批尝试记录资源。 + AuditResourceCommissionWithdrawalAttempt = "commission_withdrawal_attempt" + // AuditResourceAgentDistributionRegistration 表示代理扫码注册待审批记录资源。 + AuditResourceAgentDistributionRegistration = "agent_distribution_registration" + // AuditResourceWithdrawalQualification 表示提现资料资格版本资源。 + AuditResourceWithdrawalQualification = "withdrawal_qualification" // AuditResourceWeComApplication 表示企业微信应用配置资源。 AuditResourceWeComApplication = "wecom_application" // AuditResourceAuthentication 表示不含 Token 或 Cookie 的认证状态资源。 @@ -922,6 +956,16 @@ const ( AuditResourceRoleWithdrawalWallet = "withdrawal_wallet" // AuditResourceRoleWithdrawalTransaction 表示提现实际产生的钱包流水。 AuditResourceRoleWithdrawalTransaction = "withdrawal_transaction" + // AuditResourceRoleWithdrawalAttempt 表示提现审批尝试记录。 + AuditResourceRoleWithdrawalAttempt = "withdrawal_attempt" + // AuditResourceRoleDistributionRegistration 表示代理扫码注册待审批记录主资源。 + AuditResourceRoleDistributionRegistration = "distribution_registration" + // AuditResourceRoleDistributionParentShop 表示扫码注册的上级店铺。 + AuditResourceRoleDistributionParentShop = "distribution_parent_shop" + // AuditResourceRoleQualificationTarget 表示提现资料资格版本主资源。 + AuditResourceRoleQualificationTarget = "qualification_target" + // AuditResourceRoleQualificationShop 表示提现资料资格所属店铺。 + AuditResourceRoleQualificationShop = "qualification_shop" // AuditResourceRoleIotCardRelatedDevice 表示 IoT 卡操作关联的设备。 AuditResourceRoleIotCardRelatedDevice = "iot_card_related_device" // AuditResourceRoleIotCardDeviceBinding 表示 IoT 卡操作关联的设备卡槽绑定。 diff --git a/pkg/constants/commission_withdrawal.go b/pkg/constants/commission_withdrawal.go new file mode 100644 index 0000000..ca5fd5c --- /dev/null +++ b/pkg/constants/commission_withdrawal.go @@ -0,0 +1,24 @@ +package constants + +const ( + // WithdrawalAnomalyFlagNone 表示提现申请无正交异常。 + WithdrawalAnomalyFlagNone = 0 + // WithdrawalAnomalyFlagRevokedAfterApproved 表示企业微信对企业微信已通过的提现申请返回通过后撤销。 + // 该标记不回滚已到账金额、不重新冻结、不自动重提,仅与原因一并供详情展示与人工处理。 + WithdrawalAnomalyFlagRevokedAfterApproved = 1 +) + +const ( + // WithdrawalAttemptMaxCount 表示同一提现申请允许的审批尝试上限,防止无限重提。 + WithdrawalAttemptMaxCount = 50 +) + +// GetWithdrawalAnomalyName 返回提现异常标记的中文名称。 +func GetWithdrawalAnomalyName(flag int) string { + switch flag { + case WithdrawalAnomalyFlagRevokedAfterApproved: + return "通过后撤销" + default: + return "无异常" + } +} diff --git a/pkg/constants/withdrawal_qualification.go b/pkg/constants/withdrawal_qualification.go new file mode 100644 index 0000000..e0d9d50 --- /dev/null +++ b/pkg/constants/withdrawal_qualification.go @@ -0,0 +1,47 @@ +package constants + +const ( + // WithdrawalQualificationSubjectTypeEnterprise 表示企业签约主体。 + WithdrawalQualificationSubjectTypeEnterprise = "enterprise" + // WithdrawalQualificationSubjectTypePersonal 表示个人签约主体。 + WithdrawalQualificationSubjectTypePersonal = "personal" +) + +const ( + // WithdrawalQualificationStatusPending 表示资料版本等待企业微信终审。 + WithdrawalQualificationStatusPending = 0 + // WithdrawalQualificationStatusApproved 表示资料版本已通过且当前有效。 + WithdrawalQualificationStatusApproved = 1 + // WithdrawalQualificationStatusRejected 表示资料版本已被企业微信驳回。 + WithdrawalQualificationStatusRejected = 2 + // WithdrawalQualificationStatusInvalidated 表示资料版本已失效或已被超级管理员作废。 + WithdrawalQualificationStatusInvalidated = 3 +) + +// GetWithdrawalQualificationStatusName 返回提现资料资格状态的中文名称。 +func GetWithdrawalQualificationStatusName(status int) string { + switch status { + case WithdrawalQualificationStatusPending: + return "待审批" + case WithdrawalQualificationStatusApproved: + return "已通过有效" + case WithdrawalQualificationStatusRejected: + return "已驳回" + case WithdrawalQualificationStatusInvalidated: + return "已失效" + default: + return "未知" + } +} + +// GetWithdrawalQualificationSubjectTypeName 返回签约主体类型的中文名称。 +func GetWithdrawalQualificationSubjectTypeName(subjectType string) string { + switch subjectType { + case WithdrawalQualificationSubjectTypeEnterprise: + return "企业" + case WithdrawalQualificationSubjectTypePersonal: + return "个人" + default: + return "未知" + } +} diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 0e94165..5f1729b 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -29,7 +29,9 @@ func BuildDocHandlers() *bootstrap.Handlers { Shop: admin.NewShopHandler(nil, nil), ShopRole: admin.NewShopRoleHandler(nil), AdminAuth: admin.NewAuthHandler(nil, nil), - ShopCommission: admin.NewShopCommissionHandler(nil), + ShopCommission: admin.NewShopCommissionHandler(nil, nil), + WithdrawalQualification: admin.NewWithdrawalQualificationHandler(nil, nil, nil), + AgentDistribution: app.NewAgentDistributionHandler(nil, nil), CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(nil, nil), CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(nil), Enterprise: admin.NewEnterpriseHandler(nil), diff --git a/scripts/migration/lib/shop_sql.py b/scripts/migration/lib/shop_sql.py index 61a9827..81e7664 100644 --- a/scripts/migration/lib/shop_sql.py +++ b/scripts/migration/lib/shop_sql.py @@ -1,6 +1,7 @@ """店铺批量导入 SQL 构造。""" from __future__ import annotations +import secrets from datetime import datetime from hashlib import sha256 @@ -84,6 +85,7 @@ def _row_sql(row: ShopImportRow, plan: ShopImportPlan) -> list[str]: config = plan.config parent_expr = "NULL" if not row.parent_shop_code else f"(SELECT id FROM tb_shop WHERE shop_code = {_sql_str(row.parent_shop_code)} AND deleted_at IS NULL)" password_hash = initial_password_hash(row.phone) + distribution_code = secrets.token_hex(16) return [ f"-- CSV 第 {row.line_no} 行: {_sql_str(row.shop_name)}", "DO $shop_import$", @@ -95,9 +97,9 @@ def _row_sql(row: ShopImportRow, plan: ShopImportPlan) -> list[str]: " v_operator_name varchar;", "BEGIN", f" SELECT username INTO v_operator_name FROM tb_account WHERE id = {config.operator_id};", - " INSERT INTO tb_shop (creator, updater, shop_name, shop_code, parent_id, business_owner_account_id, level, contact_name, contact_phone, province, city, district, address, status, client_login_disabled, created_at, updated_at)", + " INSERT INTO tb_shop (creator, updater, shop_name, shop_code, distribution_code, parent_id, business_owner_account_id, level, contact_name, contact_phone, province, city, district, address, status, client_login_disabled, created_at, updated_at)", " VALUES (" - f"{config.operator_id}, {config.operator_id}, {_sql_str(row.shop_name)}, {_sql_str(row.shop_code)}, {parent_expr}, " + f"{config.operator_id}, {config.operator_id}, {_sql_str(row.shop_name)}, {_sql_str(row.shop_code)}, {_sql_str(distribution_code)}, {parent_expr}, " f"{config.salesperson_account_ids[row.salesperson]}, {row.level}, {_sql_str(row.agent_name)}, {_sql_str(row.phone)}, '', '', '', '', 1, FALSE, NOW(), NOW())", " RETURNING id, parent_id INTO v_shop_id, v_parent_id;", "",