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 }