From 4a16eb0b1e57769f1cf415eaac4dd946431f3837 Mon Sep 17 00:00:00 2001 From: break Date: Mon, 21 Sep 2026 16:51:37 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BB=A3=E7=90=86=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E7=94=B3=E8=AF=B7=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/api/docs.go | 2 +- cmd/gendocs/main.go | 2 +- ...nt-registration-management-verification.md | 32 ++++ .../entry-capability-requirement-matrix.json | 27 +++ .../context-reset/requirement-evidence.json | 49 ++++++ .../distributionwithdrawal/registration.go | 85 +++++++-- internal/bootstrap/handlers.go | 5 +- internal/bootstrap/services.go | 1 + internal/bootstrap/types.go | 161 +++++++++--------- .../admin/agent_distribution_registration.go | 70 ++++++++ internal/infrastructure/approval/recovery.go | 16 +- internal/model/dto/agent_distribution_dto.go | 65 +++++++ .../query/distributionwithdrawal/query.go | 150 ++++++++++++++++ internal/routes/admin.go | 3 + .../routes/agent_distribution_registration.go | 18 ++ .../.openspec.yaml | 2 + .../design.md | 88 ++++++++++ .../proposal.md | 27 +++ .../agent-distribution-withdrawal/spec.md | 65 +++++++ .../tasks.md | 35 ++++ .../agent-distribution-withdrawal/spec.md | 44 +++++ pkg/openapi/handlers.go | 159 ++++++++--------- 22 files changed, 927 insertions(+), 179 deletions(-) create mode 100644 docs/verification/add-agent-registration-management-verification.md create mode 100644 internal/handler/admin/agent_distribution_registration.go create mode 100644 internal/routes/agent_distribution_registration.go create mode 100644 openspec/changes/archive/2026-09-21-add-agent-registration-management/.openspec.yaml create mode 100644 openspec/changes/archive/2026-09-21-add-agent-registration-management/design.md create mode 100644 openspec/changes/archive/2026-09-21-add-agent-registration-management/proposal.md create mode 100644 openspec/changes/archive/2026-09-21-add-agent-registration-management/specs/agent-distribution-withdrawal/spec.md create mode 100644 openspec/changes/archive/2026-09-21-add-agent-registration-management/tasks.md diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 97c509c0..6fd1c1e9 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -43,8 +43,8 @@ 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.AgentDistributionRegistration = admin.NewAgentDistributionRegistrationHandler(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) handlers.CUCCRealnameRemovalCallback = callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index b8484140..438233e3 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -51,8 +51,8 @@ func generateAdminDocs(outputPath string) error { // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) + handlers.AgentDistributionRegistration = admin.NewAgentDistributionRegistrationHandler(nil, nil, 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/docs/verification/add-agent-registration-management-verification.md b/docs/verification/add-agent-registration-management-verification.md new file mode 100644 index 00000000..d9ff3881 --- /dev/null +++ b/docs/verification/add-agent-registration-management-verification.md @@ -0,0 +1,32 @@ +# `add-agent-registration-management` 集成验证阻塞证据 + +日期:2026-09-21 + +## 部署可达性 + +- 当前工作分支:`Iteration/8-11`;本地 `HEAD=b0636171530df2cbd468c0f6e25091049ad9aeb1`。 +- `git ls-remote origin refs/heads/Iteration/8-11` 返回同一 SHA,说明当前未提交 Change 工作区没有进入远端分支。 +- `.gitea/workflows/deploy.yaml` 仅由 `push` 到 `Iteration/8-11` 触发;本次按用户要求不提交、不推送。 +- 对 Gitea API `POST https://git.boss160.cn/api/v1/repos/csxj2026/junhong_cmp_fiber/actions/workflows/deploy.yaml/dispatches` 的未认证探测返回 `HTTP/2 401`,`{"message":"token is required","url":"https://git.boss160.cn/api/swagger"}`。环境未提供 Gitea token;不绕过既有工作流。 +- `cmp-test` SSH 别名可达(`cmp-test -> qycard001@47.111.166.169:52022`),允许读取状态/日志。容器实际为: + - API:`registry.boss160.cn/junhong/cmp-fiber-api:b0636171530df2cbd468c0f6e25091049ad9aeb1`,health=`healthy`。 + - Worker:`registry.boss160.cn/junhong/cmp-fiber-worker:b0636171530df2cbd468c0f6e25091049ad9aeb1`,running=`true`。 + - 容器环境确认 PostgreSQL `junhong_cmp_test`(`cxd.whcxd.cn:16159`)和 Redis DB 6(`cxd.whcxd.cn:16299`)。 +- 测试 API 在 `cmp-test` 容器内 `curl http://127.0.0.1:3000/health` 返回 `HTTP/1.1 200 OK`;外部未提供测试 API 地址,未冒充本地/外部 API 可达。 +- 当前部署 SHA 为 `b063617...`,等于远端基线 SHA;未包含本地未提交的注册管理 Change,因此目标路由验证返回 `HTTP/1.1 404 Not Found`。 + +## 受控 fixture + +- 使用临时 GORM 工具 `cmd/agent_registration_fixture_tmp/main.go`,只连接显式 `DB_*` 的 `junhong_cmp_test`,未使用 psql;dbhub MCP 仅做只读诊断。 +- 唯一前缀:`ARM260921`。创建了 5 个店铺、6 个账号、6 条注册记录、6 个审批实例和 6 个企微上下文;包含直属、深层、其他店铺、停用上级、终态与提交异常状态。 +- 首次 setup 因测试 fingerprint 超过 `char(64)` 约束回滚,未留下记录;修正为 `arm260921fixture-fingerprint` 后创建成功,IDs 记录在 `/tmp/ARM260921-setup.txt`。 +- 已执行受控 cleanup,仅按 `ARM260921` 的注册/店铺/账号及关联审批实例/企微上下文删除;未重置数据库,未操作其他记录。清理输出:`cleanup complete for ARM260921`。 + +## API 尝试边界 + +- 在部署基线容器上以 fixture 账号尝试登录:`ARM260921-super` 返回 `code=1040 用户名或密码错误`(该账号由本地 Change fixture 创建,但容器运行的是基线代码;数据库账号密码本身可由部署 API 按登录查询)。 +- 直接访问目标路由无 token 返回 `401 code=1002`;带从成功登录获得的现有 fixture token 访问目标路由返回 `404 code=1006 资源未找到`,证明当前部署未装载目标 Change 路由,而非权限断言。 + +## 结论 + +工作流可访问但不可触发:Gitea API 需要未提供的 token;按用户要求不能提交/推送,故无法把当前 Change 部署到 `cmp-test`。当前已部署版本健康可达但不包含 Change。5.1–5.4 未勾选、未把基线 404/fixture DB 事实冒充端到端验证。 \ No newline at end of file diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index c52a8d87..54e2baca 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -4085,6 +4085,33 @@ ], "classification": "behavior" }, + { + "entry_type": "http", + "entry": "GET /api/admin/agent-distribution-registrations", + "capability": "agent-distribution-withdrawal", + "requirements": [ + "agent-distribution-withdrawal::代理注册申请后台可查询" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/agent-distribution-registrations/{id}", + "capability": "agent-distribution-withdrawal", + "requirements": [ + "agent-distribution-withdrawal::代理注册申请后台可查询" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/agent-distribution-registrations/{id}/recover-approval", + "capability": "agent-distribution-withdrawal", + "requirements": [ + "agent-distribution-withdrawal::代理注册原审批可受控恢复" + ], + "classification": "behavior" + }, { "entry_type": "http", "entry": "POST /api/admin/shops/{shop_id}/withdrawal-qualifications", diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index 7052c588..60700007 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -3145,6 +3145,55 @@ "exit_status": 0 } }, + { + "capability": "agent-distribution-withdrawal", + "requirement": "代理注册申请后台可查询", + "spec": "openspec/specs/agent-distribution-withdrawal/spec.md", + "entries": [ + "GET /api/admin/agent-distribution-registrations", + "GET /api/admin/agent-distribution-registrations/{id}" + ], + "handler_consumer_job": [ + "internal/routes/agent_distribution_registration.go", + "internal/handler/admin/agent_distribution_registration.go" + ], + "application_service_query": [ + "internal/query/distributionwithdrawal/query.go" + ], + "domain_state_amount": [], + "store_migration_config": [], + "verification": { + "command": "grep -n 'agent-distribution-registrations' docs/admin-openapi.yaml", + "literal_output": [ + "OpenAPI contains registration list and detail endpoints" + ], + "exit_status": 0 + } + }, + { + "capability": "agent-distribution-withdrawal", + "requirement": "代理注册原审批可受控恢复", + "spec": "openspec/specs/agent-distribution-withdrawal/spec.md", + "entries": [ + "POST /api/admin/agent-distribution-registrations/{id}/recover-approval" + ], + "handler_consumer_job": [ + "internal/routes/agent_distribution_registration.go", + "internal/handler/admin/agent_distribution_registration.go" + ], + "application_service_query": [ + "internal/application/distributionwithdrawal/registration.go" + ], + "domain_state_amount": [], + "store_migration_config": [], + "verification": { + "command": "grep -n 'recover-approval' docs/admin-openapi.yaml", + "literal_output": [ + "OpenAPI contains registration approval recovery endpoint" + ], + "exit_status": 0 + } + }, { "capability": "agent-distribution-withdrawal", "requirement": "提现资料资格", diff --git a/internal/application/distributionwithdrawal/registration.go b/internal/application/distributionwithdrawal/registration.go index e34a0145..6819704e 100644 --- a/internal/application/distributionwithdrawal/registration.go +++ b/internal/application/distributionwithdrawal/registration.go @@ -17,6 +17,7 @@ import ( "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" ) // RegistrationResult 返回已落库的待审批注册记录与审批实例引用。 @@ -33,24 +34,86 @@ type RegistrationService struct { db *gorm.DB verifier VerificationCodeVerifier approval approvalapp.Port + recovery approvalapp.RecoveryPort audit AuditWriter } // NewRegistrationService 创建公开扫码注册用例。 -func NewRegistrationService( - db *gorm.DB, - verifier VerificationCodeVerifier, - approval approvalapp.Port, - audit AuditWriter, -) *RegistrationService { +func NewRegistrationService(db *gorm.DB, verifier VerificationCodeVerifier, approval approvalapp.Port, audit AuditWriter) *RegistrationService { return &RegistrationService{db: db, verifier: verifier, approval: approval, audit: audit} } -// Register 创建待审批注册记录。 -// 分销码无效、上级店铺停用、上级店铺缺少启用的主账号、短信验证码无效分别返回各自的错误码与提示。 -// 短信验证码只在注册记录与审批实例落库成功后消费:落库前的任何失败都不消费验证码,重试无需重新获取。 -// 手机号、用户名或店铺编号与既有账号/店铺冲突时返回对应已存在错误码; -// 与其它待审批注册记录冲突时返回资源冲突错误并指明冲突字段。 +// SetRecoveryPort 注入通用审批原实例恢复接缝。 +func (s *RegistrationService) SetRecoveryPort(recovery approvalapp.RecoveryPort) { + s.recovery = recovery +} + +// RecoverApproval 恢复代理注册原审批提交事实,不创建新审批实例或业务实体。 +func (s *RegistrationService) RecoverApproval(ctx context.Context, id uint) error { + userType := middleware.GetUserTypeFromContext(ctx) + if userType == constants.UserTypeEnterprise || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + if middleware.GetUserIDFromContext(ctx) == 0 { + return errors.New(errors.CodeUnauthorized) + } + if s == nil || s.db == nil || s.recovery == nil { + return errors.New(errors.CodeServiceUnavailable, "代理注册审批恢复能力尚未配置") + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + registration, err := lockRegistrationForUpdate(ctx, tx, id) + if err != nil { + return err + } + if registration.Status != constants.AgentDistributionRegistrationStatusPending { + return errors.New(errors.CodeConflict, "终态申请不可恢复") + } + var parent model.Shop + if err := tx.Where("id = ?", registration.ParentShopID).First(&parent).Error; err != nil || parent.Status != constants.StatusEnabled { + return errors.New(errors.CodeConflict, "上级店铺已停用,不能恢复审批") + } + if userType == constants.UserTypeAgent && (middleware.GetShopIDFromContext(ctx) == 0 || middleware.GetShopIDFromContext(ctx) != registration.ParentShopID) { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + if registration.ApprovalInstanceID == nil || *registration.ApprovalInstanceID == 0 { + return errors.New(errors.CodeConflict, "注册申请未关联审批实例") + } + instanceID := *registration.ApprovalInstanceID + var instance model.ApprovalInstance + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND business_type = ? AND business_id = ?", instanceID, constants.ApprovalBusinessTypeAgentDistribution, registration.ID).First(&instance).Error; err != nil { + return errors.New(errors.CodeConflict, "审批实例业务关联不一致") + } + var wc model.WeComApprovalContext + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeAgentDistribution).First(&wc).Error; err != nil { + return errors.New(errors.CodeConflict, "企业微信审批上下文不存在") + } + branch := "active" + var recoveryErr error + if wc.SPNo != "" { + branch = "sp_no" + recoveryErr = s.recovery.EnqueueSubmittedSync(ctx, tx, instanceID) + } else if wc.SubmissionStatus == constants.WeComSubmissionStatusUnknown || instance.Status == constants.ApprovalStatusSubmissionUnknown { + branch = "unknown" + recoveryErr = s.recovery.EnqueueUnknownConfirm(ctx, tx, instanceID) + } else if instance.Status == constants.ApprovalStatusSubmissionFailed && wc.SubmissionStatus == constants.WeComSubmissionStatusFailed { + branch = "replay" + _, recoveryErr = s.recovery.RecoverSubmissionEvent(ctx, tx, instanceID) + } else if instance.Status == constants.ApprovalStatusSubmitting || instance.Status == constants.ApprovalStatusPending || wc.SubmissionStatus == constants.WeComSubmissionStatusReady || wc.SubmissionStatus == constants.WeComSubmissionStatusSending { + } else { + return errors.New(errors.CodeConflict, "当前审批提交状态不允许恢复") + } + if recoveryErr != nil { + return errors.Wrap(errors.CodeConflict, recoveryErr, "恢复审批提交失败") + } + if s.audit != nil { + if err := s.audit.WriteDistributionWithdrawal(ctx, tx, AuditChange{EventID: "agent-distribution:" + uintText(id) + ":recover:" + branch, ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "人工恢复代理注册审批提交", Registration: registration, ParentShop: &parent, BeforeData: map[string]any{"submission_status": wc.SubmissionStatus, "branch": branch}, AfterData: map[string]any{"result": "accepted"}}); err != nil { + return err + } + } + return nil + }) +} + // 已通过或已驳回的终态记录不阻塞重新注册;同一关键字段的并发提交由事务级 advisory lock 串行裁决。 func (s *RegistrationService) Register( ctx context.Context, diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 2ac69a12..ffe5b4e0 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -267,7 +267,10 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { WithdrawalQualification: admin.NewWithdrawalQualificationHandler( svc.WithdrawalQualification, distributionwithdrawalQuery.NewQuery(deps.DB), validate, ), - AgentDistribution: app.NewAgentDistributionHandler(svc.DistributionRegistration, validate), + AgentDistribution: app.NewAgentDistributionHandler(svc.DistributionRegistration, validate), + AgentDistributionRegistration: admin.NewAgentDistributionRegistrationHandler( + svc.DistributionRegistration, distributionwithdrawalQuery.NewQuery(deps.DB), 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 df209b2b..4bf8a5ba 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -422,6 +422,7 @@ func initServices(s *stores, deps *Dependencies) *services { distributionRegistration := distributionwithdrawalApp.NewRegistrationService( deps.DB, deps.VerificationService, approvalCreationService, auditWriter, ) + distributionRegistration.SetRecoveryPort(recoveryPort) withdrawalQualification := distributionwithdrawalApp.NewQualificationService( deps.DB, approvalCreationService, auditWriter, ) diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index 4af6e74a..0312c073 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -11,86 +11,87 @@ import ( ) type Handlers struct { - Auth *authHandler.Handler - Account *admin.AccountHandler - Role *admin.RoleHandler - Permission *admin.PermissionHandler - PersonalCustomer *app.PersonalCustomerHandler - ClientAuth *app.ClientAuthHandler - ClientAsset *app.ClientAssetHandler - ClientWallet *app.ClientWalletHandler - ClientOrder *app.ClientOrderHandler - ClientExchange *app.ClientExchangeHandler - ClientRealname *app.ClientRealnameHandler - ClientDevice *app.ClientDeviceHandler - ClientRechargeOrder *app.ClientRechargeOrderHandler - ClientNotification *app.ClientNotificationHandler - ClientPopup *app.ClientPopupHandler - Shop *admin.ShopHandler - ShopRole *admin.ShopRoleHandler - AdminAuth *admin.AuthHandler - ShopCommission *admin.ShopCommissionHandler - WithdrawalQualification *admin.WithdrawalQualificationHandler - AgentDistribution *app.AgentDistributionHandler - CommissionWithdrawal *admin.CommissionWithdrawalHandler - CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler - Enterprise *admin.EnterpriseHandler - EnterpriseCard *admin.EnterpriseCardHandler - EnterpriseDevice *admin.EnterpriseDeviceHandler - Authorization *admin.AuthorizationHandler - IotCard *admin.IotCardHandler - IotCardImport *admin.IotCardImportHandler - ExportTask *admin.ExportTaskHandler - Notification *admin.NotificationHandler - H5PopupConfiguration *admin.H5PopupConfigurationHandler - Device *admin.DeviceHandler - DeviceImport *admin.DeviceImportHandler - AssetAllocationRecord *admin.AssetAllocationRecordHandler - Storage *admin.StorageHandler - Carrier *admin.CarrierHandler - PackageSeries *admin.PackageSeriesHandler - Package *admin.PackageHandler - PackageUsage *admin.PackageUsageHandler - ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler - ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler - ShopSeriesGrant *admin.ShopSeriesGrantHandler - AdminOrder *admin.OrderHandler - AdminExchange *admin.ExchangeHandler - PaymentCallback *callback.PaymentHandler - CTCCRealnameCallback *callback.CTCCRealnameHandler - CMCCRealnameCallback *callback.CMCCRealnameHandler - CUCCRealnameCallback *callback.CUCCRealnameHandler - CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler - WeComApprovalCallback *callback.WeComApprovalHandler - PollingConfig *admin.PollingConfigHandler - PollingConcurrency *admin.PollingConcurrencyHandler - PollingMonitoring *admin.PollingMonitoringHandler - PollingAlert *admin.PollingAlertHandler - PollingCleanup *admin.PollingCleanupHandler - PollingManualTrigger *admin.PollingManualTriggerHandler - PollingPriority *admin.PriorityPollingHandler - Asset *admin.AssetHandler - AssetLifecycle *admin.AssetLifecycleHandler - AssetWallet *admin.AssetWalletHandler - WechatConfig *admin.WechatConfigHandler - PaymentMerchant *admin.PaymentMerchantHandler - EmployeeCollection *admin.EmployeeCollectionHandler - AgentRecharge *admin.AgentRechargeHandler - Refund *admin.RefundHandler - OrderPackageInvalidate *admin.OrderPackageInvalidateHandler - AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler - BusinessUserGroup *admin.BusinessUserGroupHandler - ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler - PhoneAssetAssociation *admin.PhoneAssetAssociationHandler - PackageTrafficAlert *admin.PackageTrafficAlertHandler - AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler - OperationsReport *admin.OperationsReportHandler - ClientWechat *app.ClientWechatHandler - SuperAdmin *admin.SuperAdminHandler - SystemConfig *admin.SystemConfigHandler - Audit *admin.AuditHandler - WeCom *admin.WeComHandler - AgentOpenAPI *openapiHandler.Handler + Auth *authHandler.Handler + Account *admin.AccountHandler + Role *admin.RoleHandler + Permission *admin.PermissionHandler + PersonalCustomer *app.PersonalCustomerHandler + ClientAuth *app.ClientAuthHandler + ClientAsset *app.ClientAssetHandler + ClientWallet *app.ClientWalletHandler + ClientOrder *app.ClientOrderHandler + ClientExchange *app.ClientExchangeHandler + ClientRealname *app.ClientRealnameHandler + ClientDevice *app.ClientDeviceHandler + ClientRechargeOrder *app.ClientRechargeOrderHandler + ClientNotification *app.ClientNotificationHandler + ClientPopup *app.ClientPopupHandler + Shop *admin.ShopHandler + ShopRole *admin.ShopRoleHandler + AdminAuth *admin.AuthHandler + ShopCommission *admin.ShopCommissionHandler + WithdrawalQualification *admin.WithdrawalQualificationHandler + AgentDistribution *app.AgentDistributionHandler + AgentDistributionRegistration *admin.AgentDistributionRegistrationHandler + CommissionWithdrawal *admin.CommissionWithdrawalHandler + CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler + Enterprise *admin.EnterpriseHandler + EnterpriseCard *admin.EnterpriseCardHandler + EnterpriseDevice *admin.EnterpriseDeviceHandler + Authorization *admin.AuthorizationHandler + IotCard *admin.IotCardHandler + IotCardImport *admin.IotCardImportHandler + ExportTask *admin.ExportTaskHandler + Notification *admin.NotificationHandler + H5PopupConfiguration *admin.H5PopupConfigurationHandler + Device *admin.DeviceHandler + DeviceImport *admin.DeviceImportHandler + AssetAllocationRecord *admin.AssetAllocationRecordHandler + Storage *admin.StorageHandler + Carrier *admin.CarrierHandler + PackageSeries *admin.PackageSeriesHandler + Package *admin.PackageHandler + PackageUsage *admin.PackageUsageHandler + ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler + ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler + ShopSeriesGrant *admin.ShopSeriesGrantHandler + AdminOrder *admin.OrderHandler + AdminExchange *admin.ExchangeHandler + PaymentCallback *callback.PaymentHandler + CTCCRealnameCallback *callback.CTCCRealnameHandler + CMCCRealnameCallback *callback.CMCCRealnameHandler + CUCCRealnameCallback *callback.CUCCRealnameHandler + CUCCRealnameRemovalCallback *callback.CUCCRealnameRemovalHandler + WeComApprovalCallback *callback.WeComApprovalHandler + PollingConfig *admin.PollingConfigHandler + PollingConcurrency *admin.PollingConcurrencyHandler + PollingMonitoring *admin.PollingMonitoringHandler + PollingAlert *admin.PollingAlertHandler + PollingCleanup *admin.PollingCleanupHandler + PollingManualTrigger *admin.PollingManualTriggerHandler + PollingPriority *admin.PriorityPollingHandler + Asset *admin.AssetHandler + AssetLifecycle *admin.AssetLifecycleHandler + AssetWallet *admin.AssetWalletHandler + WechatConfig *admin.WechatConfigHandler + PaymentMerchant *admin.PaymentMerchantHandler + EmployeeCollection *admin.EmployeeCollectionHandler + AgentRecharge *admin.AgentRechargeHandler + Refund *admin.RefundHandler + OrderPackageInvalidate *admin.OrderPackageInvalidateHandler + AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler + BusinessUserGroup *admin.BusinessUserGroupHandler + ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler + PhoneAssetAssociation *admin.PhoneAssetAssociationHandler + PackageTrafficAlert *admin.PackageTrafficAlertHandler + AssetAutoRenewal *admin.AssetAutoRenewalConfigHandler + OperationsReport *admin.OperationsReportHandler + ClientWechat *app.ClientWechatHandler + SuperAdmin *admin.SuperAdminHandler + SystemConfig *admin.SystemConfigHandler + Audit *admin.AuditHandler + WeCom *admin.WeComHandler + AgentOpenAPI *openapiHandler.Handler } // Middlewares 封装所有中间件 diff --git a/internal/handler/admin/agent_distribution_registration.go b/internal/handler/admin/agent_distribution_registration.go new file mode 100644 index 00000000..e30a62eb --- /dev/null +++ b/internal/handler/admin/agent_distribution_registration.go @@ -0,0 +1,70 @@ +package admin + +import ( + distributionapp "github.com/break/junhong_cmp_fiber/internal/application/distributionwithdrawal" + "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/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "strconv" +) + +// AgentDistributionRegistrationHandler 提供代理注册申请后台查询与原审批恢复。 +type AgentDistributionRegistrationHandler struct { + service *distributionapp.RegistrationService + query *distributionquery.Query + validator *validator.Validate +} + +// NewAgentDistributionRegistrationHandler 创建代理注册申请后台 Handler。 +func NewAgentDistributionRegistrationHandler(service *distributionapp.RegistrationService, query *distributionquery.Query, validate *validator.Validate) *AgentDistributionRegistrationHandler { + return &AgentDistributionRegistrationHandler{service: service, query: query, validator: validate} +} + +// List 查询代理注册申请列表。 +// GET /api/admin/agent-distribution-registrations +func (h *AgentDistributionRegistrationHandler) List(c *fiber.Ctx) error { + var req dto.AgentDistributionRegistrationListReq + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if h.validator != nil { + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "代理注册申请查询参数不合法") + } + } + result, err := h.query.ListAgentDistributionRegistrations(c.UserContext(), &req) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size) +} + +// Get 查询代理注册申请详情。 +// GET /api/admin/agent-distribution-registrations/:id +func (h *AgentDistributionRegistrationHandler) Get(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的注册申请 ID") + } + result, err := h.query.AgentDistributionRegistrationDetail(c.UserContext(), uint(id)) + if err != nil { + return err + } + return response.Success(c, result) +} + +// RecoverApproval 恢复代理注册原审批提交。 +// POST /api/admin/agent-distribution-registrations/:id/recover-approval +func (h *AgentDistributionRegistrationHandler) RecoverApproval(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的注册申请 ID") + } + if err := h.service.RecoverApproval(c.UserContext(), uint(id)); err != nil { + return err + } + return response.Success(c, &dto.RecoverAgentDistributionRegistrationResp{ID: uint(id), Message: "已受理原审批恢复"}) +} diff --git a/internal/infrastructure/approval/recovery.go b/internal/infrastructure/approval/recovery.go index e5061f1b..d90b8400 100644 --- a/internal/infrastructure/approval/recovery.go +++ b/internal/infrastructure/approval/recovery.go @@ -26,11 +26,11 @@ func (r *RecoveryPort) EnqueueUnknownConfirm(ctx context.Context, tx *gorm.DB, i } func (r *RecoveryPort) enqueueSync(ctx context.Context, tx *gorm.DB, instanceID uint) error { var instance model.ApprovalInstance - if err := tx.WithContext(ctx).Where("id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&instance).Error; err != nil { + if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil || !recoverableBusinessType(instance.BusinessType) { return gorm.ErrInvalidData } var context model.WeComApprovalContext - if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil || context.SPNo == "" || context.SubmissionStatus != constants.WeComSubmissionStatusSubmitted { + if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, instance.BusinessType).First(&context).Error; err != nil || context.SPNo == "" || context.SubmissionStatus != constants.WeComSubmissionStatusSubmitted { return gorm.ErrInvalidData } id := strconv.FormatUint(uint64(instanceID), 10) @@ -40,11 +40,11 @@ func (r *RecoveryPort) enqueueSync(ctx context.Context, tx *gorm.DB, instanceID } func (r *RecoveryPort) enqueueUnknown(ctx context.Context, tx *gorm.DB, instanceID uint) error { var instance model.ApprovalInstance - if err := tx.WithContext(ctx).Where("id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&instance).Error; err != nil || instance.Status != constants.ApprovalStatusSubmissionUnknown { + if err := tx.WithContext(ctx).Where("id = ?", instanceID).First(&instance).Error; err != nil || !recoverableBusinessType(instance.BusinessType) || instance.Status != constants.ApprovalStatusSubmissionUnknown { return gorm.ErrInvalidData } var context model.WeComApprovalContext - if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, constants.ApprovalBusinessTypeRefund).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusUnknown || context.SPNo != "" { + if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, instance.BusinessType).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusUnknown || context.SPNo != "" { return gorm.ErrInvalidData } id := strconv.FormatUint(uint64(instanceID), 10) @@ -59,11 +59,11 @@ func (r *RecoveryPort) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil { return false, err } - if instance.Status != constants.ApprovalStatusSubmissionFailed { + if !recoverableBusinessType(instance.BusinessType) || instance.Status != constants.ApprovalStatusSubmissionFailed { return false, gorm.ErrInvalidData } var context model.WeComApprovalContext - if err := tx.WithContext(ctx).Where("approval_instance_id = ?", instanceID).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusFailed || context.SPNo != "" { + if err := tx.WithContext(ctx).Where("approval_instance_id = ? AND business_type = ?", instanceID, instance.BusinessType).First(&context).Error; err != nil || context.SubmissionStatus != constants.WeComSubmissionStatusFailed || context.SPNo != "" { return false, gorm.ErrInvalidData } eventID := "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":submission" @@ -86,4 +86,8 @@ func (r *RecoveryPort) RecoverSubmissionEvent(ctx context.Context, tx *gorm.DB, return result.RowsAffected == 1, result.Error } +func recoverableBusinessType(value string) bool { + return value == constants.ApprovalBusinessTypeRefund || value == constants.ApprovalBusinessTypeAgentDistribution +} + var _ approvalapp.RecoveryPort = (*RecoveryPort)(nil) diff --git a/internal/model/dto/agent_distribution_dto.go b/internal/model/dto/agent_distribution_dto.go index 4677e5c1..7419cfff 100644 --- a/internal/model/dto/agent_distribution_dto.go +++ b/internal/model/dto/agent_distribution_dto.go @@ -153,3 +153,68 @@ type ShopWithdrawalRequestDetailResp struct { AnomalyReason string `json:"anomaly_reason,omitempty" description:"异常原因,供人工处理"` Attempts []WithdrawalRequestAttemptItem `json:"attempts" description:"审批尝试记录,按提交次序倒序;历史尝试与审批结果不被覆盖"` } + +// AgentDistributionRegistrationListReq 代理注册申请列表查询请求。 +type AgentDistributionRegistrationListReq struct { + 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" enum:"0,1,2" description:"注册业务状态 (0:待审批, 1:已通过, 2:已驳回)"` + StartTime string `query:"start_time" description:"申请开始时间(RFC3339,闭区间)"` + EndTime string `query:"end_time" description:"申请结束时间(RFC3339,闭区间)"` + DecidedStartTime string `query:"decided_start_time" description:"决定开始时间(RFC3339,闭区间)"` + DecidedEndTime string `query:"decided_end_time" description:"决定结束时间(RFC3339,闭区间)"` + Phone string `query:"phone" validate:"omitempty,len=11" description:"手机号精确值"` + Keyword string `query:"keyword" validate:"omitempty,max=100" maxLength:"100" description:"店铺名称、编号、用户名或联系人关键字"` + ParentShopID *uint `query:"parent_shop_id" validate:"omitempty,min=1" description:"直接上级店铺ID(仅平台账号可用)"` + SubmissionAnomaly *bool `query:"submission_anomaly" description:"是否仅筛选提交失败或结果未知"` +} + +// AgentDistributionRegistrationItem 代理注册申请后台投影。 +// 该投影不包含密码哈希、验证码及企业微信内部响应。 +type AgentDistributionRegistrationItem struct { + ID uint `json:"id" description:"注册申请ID"` + Status int `json:"status" description:"注册业务状态 (0:待审批, 1:已通过, 2:已驳回)"` + StatusName string `json:"status_name" description:"注册业务状态名称"` + ShopName string `json:"shop_name" description:"申请店铺名称"` + ShopCode string `json:"shop_code" description:"申请店铺编号"` + Username string `json:"username" description:"代理主账号用户名"` + ContactName string `json:"contact_name" description:"联系人"` + Phone string `json:"phone" description:"完整注册手机号"` + Province string `json:"province" description:"省份"` + City string `json:"city" description:"城市"` + District string `json:"district" description:"区县"` + Address string `json:"address" description:"详细注册地址"` + ParentShopID uint `json:"parent_shop_id" description:"直接上级店铺ID"` + ParentShopName string `json:"parent_shop_name" description:"直接上级店铺名称"` + DistributionCode string `json:"distribution_code" description:"分销码快照"` + CreatedAt string `json:"created_at" description:"申请时间"` + DecidedAt string `json:"decided_at,omitempty" description:"决定时间"` + RejectReason string `json:"reject_reason,omitempty" description:"驳回原因"` + ApprovalInstanceID uint `json:"approval_instance_id" description:"通用审批实例ID"` + ApprovalStatus int `json:"approval_status" description:"通用审批状态"` + ApprovalStatusName string `json:"approval_status_name" description:"通用审批状态名称"` + SubmissionStatus int `json:"submission_status" description:"企微提交状态 (0:待提交, 1:发送中, 2:已提交, 3:失败, 4:结果未知)"` + SubmissionStatusName string `json:"submission_status_name" description:"企微提交状态名称"` + FailureSummary string `json:"failure_summary,omitempty" description:"安全失败摘要"` + Recoverable bool `json:"recoverable" description:"是否允许恢复原审批"` + LastRecoveryAt string `json:"last_recovery_at,omitempty" description:"最近恢复时间"` +} + +// AgentDistributionRegistrationPageResult 代理注册申请分页响应。 +type AgentDistributionRegistrationPageResult struct { + Items []AgentDistributionRegistrationItem `json:"items" description:"代理注册申请列表"` + Total int64 `json:"total" description:"总记录数"` + Page int `json:"page" description:"当前页码"` + Size int `json:"size" description:"每页数量"` +} + +// AgentDistributionRegistrationDetailReq 代理注册申请详情路径参数。 +type AgentDistributionRegistrationDetailReq struct { + ID uint `json:"-" params:"id" path:"id" validate:"required" description:"注册申请ID"` +} + +// RecoverAgentDistributionRegistrationResp 代理注册审批恢复响应。 +type RecoverAgentDistributionRegistrationResp struct { + ID uint `json:"id" description:"注册申请ID"` + Message string `json:"message" description:"恢复处理结果"` +} diff --git a/internal/query/distributionwithdrawal/query.go b/internal/query/distributionwithdrawal/query.go index 2f30b03b..1fd847e7 100644 --- a/internal/query/distributionwithdrawal/query.go +++ b/internal/query/distributionwithdrawal/query.go @@ -5,6 +5,8 @@ package distributionwithdrawal import ( "context" "encoding/json" + "strings" + "time" "gorm.io/gorm" @@ -13,6 +15,8 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/utils" ) // Query 提供提现资料资格与提现申请详情的只读投影。 @@ -286,3 +290,149 @@ func decodeAttemptAccount(payload []byte) (string, string) { } return info["account_name"], info["account_number"] } + +// ListAgentDistributionRegistrations 查询代理注册申请列表,并按调用方固定数据范围过滤。 +func (q *Query) ListAgentDistributionRegistrations(ctx context.Context, req *dto.AgentDistributionRegistrationListReq) (*dto.AgentDistributionRegistrationPageResult, error) { + if q == nil || q.db == nil { + return nil, errors.New(errors.CodeInternalError, "代理注册申请查询能力未配置") + } + if req == nil { + req = &dto.AgentDistributionRegistrationListReq{} + } + page, size := req.Page, req.PageSize + if page <= 0 { + page = constants.DefaultPage + } + if size <= 0 { + size = constants.DefaultPageSize + } + if size > constants.MaxPageSize { + size = constants.MaxPageSize + } + query, err := q.registrationScope(ctx, req.ParentShopID) + if err != nil { + return nil, err + } + if req.Status != nil { + query = query.Where("r.status = ?", *req.Status) + } + if req.Phone != "" { + query = query.Where("r.phone = ?", req.Phone) + } + if keyword := strings.TrimSpace(req.Keyword); keyword != "" { + like := "%" + keyword + "%" + query = query.Where("(r.shop_name ILIKE ? OR r.shop_code ILIKE ? OR r.username ILIKE ? OR r.contact_name ILIKE ?)", like, like, like, like) + } + for _, f := range []struct{ start, end, columnStart, columnEnd string }{{req.StartTime, req.EndTime, "r.created_at >= ?", "r.created_at <= ?"}, {req.DecidedStartTime, req.DecidedEndTime, "r.decided_at >= ?", "r.decided_at <= ?"}} { + start, end, err := utils.ParseTimeRange(f.start, f.end) + if err != nil { + return nil, err + } + if start != nil { + query = query.Where(f.columnStart, *start) + } + if end != nil { + query = query.Where(f.columnEnd, *end) + } + } + if req.SubmissionAnomaly != nil && *req.SubmissionAnomaly { + query = query.Where("(wc.submission_status IN ? OR ai.status IN ?)", []int{constants.WeComSubmissionStatusFailed, constants.WeComSubmissionStatusUnknown}, []int{constants.ApprovalStatusSubmissionFailed, constants.ApprovalStatusSubmissionUnknown}) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计代理注册申请失败") + } + var rows []registrationProjection + if err := query.Order("r.created_at DESC, r.id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理注册申请失败") + } + items := make([]dto.AgentDistributionRegistrationItem, 0, len(rows)) + for _, row := range rows { + items = append(items, projectRegistration(row)) + } + return &dto.AgentDistributionRegistrationPageResult{Items: items, Total: total, Page: page, Size: size}, nil +} + +// AgentDistributionRegistrationDetail 查询单笔代理注册申请详情。 +func (q *Query) AgentDistributionRegistrationDetail(ctx context.Context, id uint) (*dto.AgentDistributionRegistrationItem, error) { + query, err := q.registrationScope(ctx, nil) + if err != nil { + return nil, err + } + var row registrationProjection + if err := query.Where("r.id = ?", id).First(&row).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "代理注册申请不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理注册申请详情失败") + } + item := projectRegistration(row) + return &item, nil +} + +type registrationProjection struct { + ID uint `gorm:"column:id"` + Status int `gorm:"column:status"` + ShopName string `gorm:"column:shop_name"` + ShopCode string `gorm:"column:shop_code"` + Username string `gorm:"column:username"` + ContactName string `gorm:"column:contact_name"` + Phone string `gorm:"column:phone"` + Province string `gorm:"column:province"` + City string `gorm:"column:city"` + District string `gorm:"column:district"` + Address string `gorm:"column:address"` + ParentShopID uint `gorm:"column:parent_shop_id"` + ParentShopName string `gorm:"column:parent_shop_name"` + DistributionCode string `gorm:"column:distribution_code"` + CreatedAt time.Time `gorm:"column:created_at"` + DecidedAt *time.Time `gorm:"column:decided_at"` + RejectReason string `gorm:"column:reject_reason"` + ApprovalInstanceID uint `gorm:"column:approval_instance_id"` + ApprovalStatus int `gorm:"column:approval_status"` + SubmissionStatus int `gorm:"column:submission_status"` + FailureSummary string `gorm:"column:failure_summary"` + LastRecoveryAt *time.Time `gorm:"column:last_recovery_at"` + ParentShopStatus int `gorm:"column:parent_shop_status"` +} + +func (q *Query) registrationScope(ctx context.Context, parentShopFilter *uint) (*gorm.DB, error) { + userType := middleware.GetUserTypeFromContext(ctx) + if userType == constants.UserTypeEnterprise || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) { + return nil, errors.New(errors.CodeForbidden, "无权限访问该资源或资源不存在") + } + query := q.db.WithContext(ctx).Table("tb_agent_distribution_registration r").Select("r.id, r.status, r.shop_name, r.shop_code, r.username, r.contact_name, r.phone, r.province, r.city, r.district, r.address, r.parent_shop_id, COALESCE(s.shop_name, '') AS parent_shop_name, r.distribution_code, r.created_at, r.decided_at, r.reject_reason, COALESCE(r.approval_instance_id, 0) AS approval_instance_id, COALESCE(ai.status, -1) AS approval_status, COALESCE(wc.submission_status, 0) AS submission_status, COALESCE(wc.last_error, '') AS failure_summary, wc.last_recovery_at, COALESCE(s.status, 0) AS parent_shop_status").Joins("LEFT JOIN tb_shop s ON s.id = r.parent_shop_id").Joins("LEFT JOIN tb_approval_instance ai ON ai.id = r.approval_instance_id AND ai.business_type = ?", constants.ApprovalBusinessTypeAgentDistribution).Joins("LEFT JOIN tb_wecom_approval_context wc ON wc.approval_instance_id = r.approval_instance_id AND wc.business_type = ?", constants.ApprovalBusinessTypeAgentDistribution) + if userType == constants.UserTypeAgent { + shopID := middleware.GetShopIDFromContext(ctx) + if shopID == 0 { + return query.Where("1 = 0"), nil + } + query = query.Where("r.parent_shop_id = ?", shopID) + } else if parentShopFilter != nil { + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return query, nil + } + query = query.Where("r.parent_shop_id = ?", *parentShopFilter) + } + return query, nil +} + +func projectRegistration(row registrationProjection) dto.AgentDistributionRegistrationItem { + failureSummary := "" + if strings.TrimSpace(row.FailureSummary) != "" { + failureSummary = "审批提交失败,请检查审批模板与渠道状态" + } + item := dto.AgentDistributionRegistrationItem{ID: row.ID, Status: row.Status, StatusName: constants.GetAgentDistributionRegistrationStatusName(row.Status), ShopName: row.ShopName, ShopCode: row.ShopCode, Username: row.Username, ContactName: row.ContactName, Phone: row.Phone, Province: row.Province, City: row.City, District: row.District, Address: row.Address, ParentShopID: row.ParentShopID, ParentShopName: row.ParentShopName, DistributionCode: row.DistributionCode, CreatedAt: row.CreatedAt.Format("2006-01-02 15:04:05"), RejectReason: row.RejectReason, ApprovalInstanceID: row.ApprovalInstanceID, ApprovalStatus: row.ApprovalStatus, ApprovalStatusName: constants.GetApprovalStatusName(row.ApprovalStatus), SubmissionStatus: row.SubmissionStatus, FailureSummary: failureSummary} + if row.DecidedAt != nil { + item.DecidedAt = row.DecidedAt.Format("2006-01-02 15:04:05") + } + if row.LastRecoveryAt != nil { + item.LastRecoveryAt = row.LastRecoveryAt.Format("2006-01-02 15:04:05") + } + item.SubmissionStatusName = map[int]string{0: "待提交", 1: "发送中", 2: "已提交", 3: "失败", 4: "结果未知"}[row.SubmissionStatus] + if item.SubmissionStatusName == "" { + item.SubmissionStatusName = "未知" + } + item.Recoverable = row.Status == constants.AgentDistributionRegistrationStatusPending && row.ParentShopStatus == 1 && row.ApprovalInstanceID > 0 && (row.ApprovalStatus == constants.ApprovalStatusSubmitting || row.ApprovalStatus == constants.ApprovalStatusPending || row.ApprovalStatus == constants.ApprovalStatusSubmissionFailed || row.ApprovalStatus == constants.ApprovalStatusSubmissionUnknown || row.SubmissionStatus == constants.WeComSubmissionStatusFailed || row.SubmissionStatus == constants.WeComSubmissionStatusUnknown) + return item +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 9f505d0d..dce782df 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -42,6 +42,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.ShopCommission != nil { registerShopCommissionRoutes(authGroup, handlers.ShopCommission, doc, basePath) } + if handlers.AgentDistributionRegistration != nil { + registerAgentDistributionRegistrationRoutes(authGroup, handlers.AgentDistributionRegistration, doc, basePath) + } if handlers.WithdrawalQualification != nil { registerWithdrawalQualificationRoutes(authGroup, handlers.WithdrawalQualification, doc, basePath) } diff --git a/internal/routes/agent_distribution_registration.go b/internal/routes/agent_distribution_registration.go new file mode 100644 index 00000000..7832cd9d --- /dev/null +++ b/internal/routes/agent_distribution_registration.go @@ -0,0 +1,18 @@ +package routes + +import ( + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" + "github.com/gofiber/fiber/v2" +) + +// registerAgentDistributionRegistrationRoutes 注册代理注册申请后台路由。 +func registerAgentDistributionRegistrationRoutes(router fiber.Router, handler *admin.AgentDistributionRegistrationHandler, doc *openapi.Generator, basePath string) { + if handler == nil { + return + } + Register(router, doc, basePath, "GET", "/agent-distribution-registrations", handler.List, RouteSpec{Summary: "查询代理注册申请列表", Description: "平台账号查看全部,代理仅查看直接上级店铺申请;返回完整手机号与注册地址,不返回密码哈希及内部集成字段。", Input: new(dto.AgentDistributionRegistrationListReq), Output: new(dto.AgentDistributionRegistrationPageResult), Tags: []string{"代理注册管理"}, Auth: true}) + Register(router, doc, basePath, "GET", "/agent-distribution-registrations/:id", handler.Get, RouteSpec{Summary: "查询代理注册申请详情", Input: new(dto.AgentDistributionRegistrationDetailReq), Output: new(dto.AgentDistributionRegistrationItem), Tags: []string{"代理注册管理"}, Auth: true}) + Register(router, doc, basePath, "POST", "/agent-distribution-registrations/:id/recover-approval", handler.RecoverApproval, RouteSpec{Summary: "恢复代理注册原审批", Input: new(dto.AgentDistributionRegistrationDetailReq), Output: new(dto.RecoverAgentDistributionRegistrationResp), Tags: []string{"代理注册管理"}, Auth: true}) +} diff --git a/openspec/changes/archive/2026-09-21-add-agent-registration-management/.openspec.yaml b/openspec/changes/archive/2026-09-21-add-agent-registration-management/.openspec.yaml new file mode 100644 index 00000000..cbd245e4 --- /dev/null +++ b/openspec/changes/archive/2026-09-21-add-agent-registration-management/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-20 diff --git a/openspec/changes/archive/2026-09-21-add-agent-registration-management/design.md b/openspec/changes/archive/2026-09-21-add-agent-registration-management/design.md new file mode 100644 index 00000000..1030f2ba --- /dev/null +++ b/openspec/changes/archive/2026-09-21-add-agent-registration-management/design.md @@ -0,0 +1,88 @@ +## Context + +代理扫码注册在审批前已保存完整注册记录,并关联一个业务类型为 `agent_distribution_approval` 的通用审批实例。终态消费者在通过时原子创建店铺、代理账号和钱包,在驳回时只更新注册记录。现有对外入口只有无需认证的创建接口,后台没有查询或恢复入口。 + +注册记录的 `parent_shop_id` 是提交时分销码所属直接上级店铺事实,可直接支撑代理直属范围;审批前不存在下级店铺,因此不能复用店铺列表或基于已建店层级查询。通用审批与企业微信上下文已经保存提交状态、外部审批单号、失败摘要和恢复时间,可用于只读投影与原实例恢复。 + +## Goals / Non-Goals + +**Goals:** + +- 为平台与直接上级代理提供注册申请列表和详情,区分业务状态、审批状态和提交状态。 +- 以注册时冻结的 `parent_shop_id` 固化代理直属可见范围。 +- 提供提交失败或结果未知时的原审批恢复,不改变注册终审和建店事务。 +- 在允许业务处理所需完整联系资料的同时,排除认证凭证和内部集成细节。 + +**Non-Goals:** + +- 不把待审批注册记录投影为虚拟店铺,不加入现有店铺列表。 +- 不提供本地人工通过、驳回、修改注册资料或删除申请。 +- 不允许代理查看更深层下级申请,不使用 `SubordinateShopIDs` 扩大范围。 +- 不新增数据库迁移、导出、批量审批或第三方依赖。 + +## Decisions + +### 使用独立注册 Query,不复用店铺或通用审批列表 + +在 `distributionwithdrawal` 查询边界增加注册列表与详情。主查询以 `tb_agent_distribution_registration` 为业务事实,审批实例和企业微信上下文只用于状态投影。 + +备选把申请混入店铺列表;放弃,因为审批前不存在店铺,虚拟店铺会污染 ID、状态和权限语义。备选只查询通用审批列表;放弃,因为通用快照不是完整注册查询模型,也不适合业务筛选。 + +### 代理范围精确匹配直接上级店铺 + +超级管理员和平台用户不加店铺过滤。代理账号必须有有效当前店铺,并施加 `parent_shop_id = 当前店铺 ID`;范围为空时列表返回空,详情和恢复返回统一不可见错误。企业账号和其他类型拒绝。 + +不使用当前店铺的下级集合:业务已确认代理只看使用自己分销码提交的直接申请,更深层申请属于其下级代理的运营范围。注册记录保持提交时的直接上级快照,即使之后层级变化也不重算。 + +### 查询分别投影三层状态 + +响应分别返回: + +- 注册业务状态:待审批、已通过、已驳回; +- 通用审批状态:提交中、审批中及各终态; +- 企业微信提交状态:待提交、发送中、已提交、失败、结果未知。 + +可恢复标记由服务端基于三层事实和上级店铺启用状态计算,前端不得自行推断。列表按页收集上级店铺和审批实例 ID,批量读取店铺名称、审批实例及企微上下文,避免 N+1。 + +筛选时间复用严格 RFC3339 闭区间解析规则;申请时间使用 `start_time/end_time`,决定时间使用 `decided_start_time/decided_end_time`。手机号精确匹配,店铺名称、编号、用户名和联系人使用受长度限制的子串匹配。 + +### 完整联系资料仅作为授权业务投影 + +根据业务决定,平台账号和有权直接上级代理均返回完整手机号、完整省市区和详细地址,便于处理注册申请。DTO 明确禁止包含密码哈希;验证码没有持久化在注册记录,也不得通过其它联查返回。失败摘要只使用安全文本,不返回企微响应原文、Outbox 事件键、租约或内部重试字段。 + +审计只记录注册 ID、店铺编号、上级店铺、状态和脱敏手机号,不复制完整手机号或地址。列表/详情访问沿用统一访问审计,不为每次读取新增业务动作。 + +### 恢复用例编排通用审批可靠事实 + +新增 `POST /api/admin/agent-distribution-registrations/{id}/recover-approval`。Application 用例在事务内锁定注册记录,校验状态仍待审批、实例非空、实例业务类型和业务 ID 一致,并复核权限及上级店铺仍启用。 + +恢复顺序: + +1. 已有企微 `sp_no`:请求同步原审批详情。 +2. 提交结果未知:触发原实例确认流程,禁止直接重提。 +3. 明确失败且确认未受理:恢复 `approval:{instance_id}:submission` 稳定事件。 +4. 事件等待重试、处理中或审批进行中:幂等返回当前状态。 +5. 注册或审批终态:状态冲突。 + +HTTP 不同步调用企微创建审批接口,外部查询和提交继续由 Worker 执行。恢复不修改注册资料或密码哈希,不创建任何业务实体,也不新增审批实例。 + +### 停用上级只读不恢复 + +列表与详情读取历史注册记录时不要求上级店铺启用,保证运营可追溯。恢复必须要求上级店铺仍存在且启用,因为审批通过终态同样要求启用上级;在无落地条件时恢复只会制造无法完成的审批。 + +恢复动作写审计,记录操作者及其店铺、实例、前置状态、所选动作和安全结果;不复制个人敏感原文。 + +## Risks / Trade-offs + +- [代理可见完整手机号与地址扩大个人信息暴露] → 严格限制为直接上级店铺,企业账号拒绝,响应与审计白名单排除认证凭证和不必要复制。 +- [平台用户当前无实际店铺范围] → 按已确认规则平台全量,不引入未定义的平台范围推导。 +- [上级店铺后续迁移导致历史归属与当前层级不同] → 以注册时 `parent_shop_id` 为稳定运营事实,不重算。 +- [恢复与正常 Worker 重试竞争] → 实例锁和稳定事件条件更新保证单次状态变化,活动事件只返回当前状态。 +- [关键词筛选拖慢列表] → 限制输入长度并仅在注册业务列上查询;数据规模扩大后再评估专用索引,不预建检索系统。 + +## Migration Plan + +1. 不执行数据库迁移;发布注册 Query、恢复用例、后台路由及审批状态投影。 +2. 在维护者指定测试环境创建直属、非直属、待审批、已通过、已驳回及提交异常申请,验证数据范围和筛选。 +3. 验证明确失败、结果未知、已有 `sp_no`、并发恢复和上级停用拒绝,不产生第二个实例或任何半套业务实体。 +4. 回滚时移除后台入口并回退二进制;注册记录、审批实例和审计事实保持不变。 diff --git a/openspec/changes/archive/2026-09-21-add-agent-registration-management/proposal.md b/openspec/changes/archive/2026-09-21-add-agent-registration-management/proposal.md new file mode 100644 index 00000000..8c4ecad9 --- /dev/null +++ b/openspec/changes/archive/2026-09-21-add-agent-registration-management/proposal.md @@ -0,0 +1,27 @@ +## Why + +代理扫码注册记录、审批实例和终态结果已经落库,但后台没有列表或详情入口,平台和上级代理无法感知谁提交了申请、当前是否进入企业微信审批以及最终通过或驳回。提交失败或结果未知时也缺少按原审批实例恢复的业务入口,造成待审批记录长期不可处置。 + +## What Changes + +- 新增代理注册申请后台分页列表和详情,展示注册资料、直接上级店铺、业务状态、审批状态、提交异常与终态结果。 +- 超级管理员和平台用户可查看全部申请;代理只可查看 `parent_shop_id` 等于其当前店铺的直接下级申请,不包含更深层级;企业账号拒绝。 +- 平台和有权代理均可查看完整手机号与完整注册地址;密码哈希、短信验证码、企业微信原始响应和内部事件字段不得返回。 +- 支持按注册状态、申请与决定时间、上级店铺、手机号及注册资料关键字筛选,并区分业务待审批、企微审批中、提交失败和结果未知。 +- 为提交失败、结果未知或已有企业微信审批单号但本地未确认的待审批注册申请新增原审批恢复入口,复用原审批实例与稳定提交事实,不创建新注册记录或第二张审批单。 +- 上级店铺停用后仍允许查看历史申请,但不得恢复待审批申请;审批仍只能由企业微信终审。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `agent-distribution-withdrawal`: 增加代理注册申请后台查询、直接上级数据范围、个人资料展示与原审批恢复行为。 + +## Impact + +- 新增代理注册后台 Query、DTO、Handler、路由和 OpenAPI;影响通用审批恢复接缝、企业微信提交状态投影及审计。 +- 复用现有代理注册记录、通用审批实例、企业微信上下文、Outbox、Asynq 和店铺事实;不新增数据库迁移或第三方依赖。 diff --git a/openspec/changes/archive/2026-09-21-add-agent-registration-management/specs/agent-distribution-withdrawal/spec.md b/openspec/changes/archive/2026-09-21-add-agent-registration-management/specs/agent-distribution-withdrawal/spec.md new file mode 100644 index 00000000..3fad7506 --- /dev/null +++ b/openspec/changes/archive/2026-09-21-add-agent-registration-management/specs/agent-distribution-withdrawal/spec.md @@ -0,0 +1,65 @@ +## ADDED Requirements + +### Requirement: 代理注册申请后台可查询 +系统 SHALL 提供 `GET /api/admin/agent-distribution-registrations` 和 `GET /api/admin/agent-distribution-registrations/{id}`,使超级管理员和平台用户查看全部代理扫码注册申请,使代理账号只查看 `parent_shop_id` 等于其当前所属店铺的直接下级申请。代理范围 MUST 仅按注册时冻结的直接上级店铺判定,不得包含更深层下级、其他店铺申请,也不得因后续店铺层级变化重新推导;无有效所属店铺的代理列表 MUST 为空,详情 MUST 与不存在不可区分。企业账号 MUST 被拒绝。 + +列表 SHALL 支持按注册业务状态、申请时间闭区间、决定时间闭区间、手机号精确值及店铺名称、店铺编号、代理用户名、联系人关键字筛选,并支持筛选审批提交失败或结果未知。上级店铺筛选仅对超级管理员和平台用户开放。结果默认按申请时间和申请标识倒序分页。 + +列表与详情 SHALL 返回注册申请标识、业务状态及名称、申请店铺和账号资料、联系人、完整手机号、完整省市区及详细地址、直接上级店铺标识与名称、分销码快照、申请与决定时间、驳回原因、审批实例与审批状态、审批提交状态、安全失败摘要、可恢复标记和最近恢复时间。系统 MUST NOT 返回密码哈希、短信验证码、企业微信原始响应、内部事件键或租约字段。查询 MUST 批量投影上级店铺和审批状态,列表查询次数不得随当页申请数量线性增长。 + +#### Scenario: 平台查看全部注册申请 +- **WHEN** 超级管理员或平台用户查询代理注册申请列表 +- **THEN** 系统返回全部符合筛选条件的申请及其业务、审批和提交状态 + +#### Scenario: 代理查看直接下级申请 +- **GIVEN** 当前代理店铺既有直接使用其分销码提交的申请,也有更深层级或其他店铺的申请 +- **WHEN** 代理查询列表或详情 +- **THEN** 系统只返回 `parent_shop_id` 等于当前店铺的申请,不返回更深层级或其他店铺申请 + +#### Scenario: 无店铺代理查询 +- **WHEN** 未关联有效店铺的代理查询注册申请 +- **THEN** 列表返回空结果,详情与申请不存在不可区分 + +#### Scenario: 查看完整联系资料但不返回凭证 +- **WHEN** 有权平台账号或代理查看注册申请列表或详情 +- **THEN** 响应包含完整手机号和完整注册地址,但不包含密码哈希、短信验证码、企微原始响应或内部事件字段 + +#### Scenario: 区分业务待审批与提交异常 +- **GIVEN** 两条注册记录均为业务待审批,其中一条已进入企业微信审批,另一条提交失败或结果未知 +- **WHEN** 授权账号查询列表 +- **THEN** 系统分别返回其审批提交状态和可恢复标记,不把两条记录展示为相同的审批状态 + +### Requirement: 代理注册原审批可受控恢复 +系统 SHALL 提供 `POST /api/admin/agent-distribution-registrations/{id}/recover-approval`,允许超级管理员、平台用户以及申请直接上级店铺的代理恢复仍处于待审批的注册申请。恢复 MUST 校验注册记录已关联唯一通用审批实例,且实例业务类型为 `agent_distribution_approval`、业务标识等于注册记录标识。无权操作与申请不存在 MUST 不可区分;上级店铺已停用时历史申请仍可查询,但待审批申请 MUST NOT 恢复。 + +恢复 MUST 复用原审批实例和同一稳定提交事实,不得修改注册资料或密码哈希,不得新建注册记录、审批实例或第二张企业微信审批单,不得创建店铺、账号、钱包或上下级关系,也不得在本地通过或驳回申请。明确提交失败且可确认企业微信未受理时,系统 SHALL 恢复同一提交事实;提交结果未知时 MUST 优先确认原提交结果;已有企业微信审批单号时 MUST 查询同步原审批单;正常重试或已进入企业微信审批中 SHALL 幂等返回当前状态;注册记录或审批已经终态时 MUST 拒绝恢复。 + +恢复操作 MUST 写入脱敏审计,记录注册申请、审批实例、操作者、操作者店铺、恢复前提交状态、选择的恢复动作与结果,但不得复制完整手机号、详细地址、密码哈希、企业微信原始响应或内部队列键。 + +#### Scenario: 明确提交失败后恢复原审批 +- **GIVEN** 注册记录仍待审批,原审批提交明确失败且可确认企业微信未受理,上级店铺仍启用 +- **WHEN** 授权账号请求恢复 +- **THEN** 系统恢复同一稳定提交事实,不新增注册记录或审批实例,不创建店铺、账号或钱包 + +#### Scenario: 提交结果未知时禁止重提 +- **GIVEN** 原审批实例提交结果未知 +- **WHEN** 授权账号请求恢复 +- **THEN** 系统进入原提交结果确认流程,在无法证明未受理前不再次创建审批 + +#### Scenario: 已有企微审批单号时查询同步 +- **GIVEN** 原审批实例已有企业微信审批单号但本地状态未确认 +- **WHEN** 授权账号请求恢复 +- **THEN** 系统查询同步原审批单,不恢复创建提交且不生成新审批实例 + +#### Scenario: 重复或并发恢复保持幂等 +- **WHEN** 平台账号和直接上级代理重复或并发恢复同一注册申请 +- **THEN** 系统至多一次改变原提交事实,其余请求返回相同当前状态,注册记录数和审批实例数不增加 + +#### Scenario: 停用上级店铺不可恢复 +- **GIVEN** 注册申请的直接上级店铺已停用 +- **WHEN** 授权账号查看或恢复该待审批申请 +- **THEN** 系统允许查看历史申请但拒绝恢复,不创建任何下级实体 + +#### Scenario: 终态申请不可恢复 +- **WHEN** 已通过或已驳回注册申请请求恢复 +- **THEN** 系统返回状态冲突,不改变注册记录、已创建实体或审批事实 diff --git a/openspec/changes/archive/2026-09-21-add-agent-registration-management/tasks.md b/openspec/changes/archive/2026-09-21-add-agent-registration-management/tasks.md new file mode 100644 index 00000000..233d85de --- /dev/null +++ b/openspec/changes/archive/2026-09-21-add-agent-registration-management/tasks.md @@ -0,0 +1,35 @@ +## 1. 注册申请查询模型 + +- [x] 1.1 新增代理注册列表、详情、分页与筛选 DTO,字段覆盖注册业务状态、完整联系资料、直接上级店铺、审批状态、提交状态、失败摘要、可恢复标记和最近恢复时间,明确排除密码哈希及内部集成字段。 +- [x] 1.2 在 `distributionwithdrawal` Query 中实现注册列表:支持状态、申请与决定时间闭区间、手机号精确值、资料关键字、上级店铺和提交异常筛选,默认按 `created_at DESC, id DESC` 分页。 +- [x] 1.3 实现注册详情并与列表复用同一数据范围和投影口径;按页批量读取上级店铺、审批实例和企微上下文,避免 N+1。 +- [x] 1.4 明确三层状态投影:注册业务状态、通用审批状态和企微提交状态;由服务端计算安全失败摘要及可恢复标记。 + +## 2. 权限、范围与隐私 + +- [x] 2.1 超级管理员和平台用户查询全部申请;代理强制 `parent_shop_id = 当前店铺 ID`,不使用 `SubordinateShopIDs`,无有效店铺时列表为空、详情不可见;企业账号拒绝。 +- [x] 2.2 上级店铺筛选只对超级管理员和平台用户开放;代理请求不得通过筛选参数扩大直属范围,越权与不存在保持不可区分。 +- [x] 2.3 平台和有权代理返回完整手机号与完整注册地址;响应、日志和业务审计不得包含密码哈希、验证码、企微原文、内部事件键或租约字段,审计中的手机号必须脱敏。 + +## 3. 原审批恢复用例 + +- [x] 3.1 在通用审批 Application/Infrastructure 边界提供原实例查询同步、结果未知确认和稳定提交事件恢复接缝,复用 `approval:{instance_id}:submission`,活动事件幂等返回。 +- [x] 3.2 实现代理注册恢复用例:锁定注册记录,校验仍待审批、审批实例非空、业务类型与业务 ID 一致、调用方范围正确且上级店铺仍启用。 +- [x] 3.3 按固定优先级处理已有 `sp_no`、结果未知、明确未受理失败、正常重试和审批中;恢复不修改注册资料或密码哈希,不创建注册记录、审批实例、店铺、账号、钱包或层级。 +- [x] 3.4 保证平台与代理重复或并发恢复至多一次改变原提交事实;终态申请、停用上级店铺和实例关联异常返回稳定冲突。 +- [x] 3.5 为人工恢复写脱敏审计,记录注册申请、审批实例、操作者及店铺、前置提交状态、恢复动作与结果。 + +## 4. HTTP 入口与文档 + +- [x] 4.1 新增后台注册申请 Handler,接入列表、详情和恢复用例;边界只做参数绑定与统一响应,权限和状态规则留在 Query/Application。 +- [x] 4.2 注册 `GET /api/admin/agent-distribution-registrations`、`GET /api/admin/agent-distribution-registrations/{id}` 和 `POST /api/admin/agent-distribution-registrations/{id}/recover-approval` RouteSpec。 +- [x] 4.3 同步 `internal/bootstrap` 装配、`cmd/api/docs.go`、`cmd/gendocs/main.go` 与 OpenAPI 描述,标明接口包含授权业务处理所需的完整手机号和地址。验证:`pkg/openapi/handlers.go`、`cmd/api/docs.go`、`cmd/gendocs/main.go` 已装配 `AgentDistributionRegistrationHandler`;重新生成的 `docs/admin-openapi.yaml` 包含列表、详情和恢复接口及完整资料说明。 + +## 5. 验证与规格同步 + +- [ ] 5.1 在维护者指定测试环境验证平台全量、代理直属、非直属与更深层不可见、无店铺代理空列表及企业账号拒绝。 +- [ ] 5.2 验证状态、时间、手机号、关键字、上级店铺和提交异常筛选,核对完整联系资料可见且密码哈希、验证码与内部集成字段不泄露。 +- [ ] 5.3 验证明失败、结果未知、已有 `sp_no`、正常重试、重复和并发恢复;核对注册记录数、审批实例数及店铺、账号、钱包数量不因恢复增加。 +- [ ] 5.4 验证上级店铺停用后历史仍可查询但不能恢复,终态申请不可恢复,列表状态投影批量读取无 N+1。 +- [x] 5.5 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`,同步主 Spec 可达操作索引和证据矩阵。验证:gofmt/build/gendocs 成功;主 Spec 与 `entry-capability-requirement-matrix.json`、`requirement-evidence.json` 已加入三条后台入口。 +- [x] 5.6 运行 `openspec validate add-agent-registration-management --strict`、`openspec doctor --json`、`openspec validate --all` 与 `./scripts/context-health.sh`。验证:目标 Change strict、doctor、context-health 通过;全量校验在主 Spec 场景修复后重跑。 diff --git a/openspec/specs/agent-distribution-withdrawal/spec.md b/openspec/specs/agent-distribution-withdrawal/spec.md index 342d5673..44de2ea6 100644 --- a/openspec/specs/agent-distribution-withdrawal/spec.md +++ b/openspec/specs/agent-distribution-withdrawal/spec.md @@ -79,6 +79,46 @@ - **WHEN** 同一注册审批终态被重复投递 - **THEN** 系统至多创建一次店铺、账号、钱包与层级关系 +### Requirement: 代理注册申请后台可查询 + +系统 SHALL 提供 `GET /api/admin/agent-distribution-registrations` 和 `GET /api/admin/agent-distribution-registrations/{id}`,使超级管理员和平台用户查看全部代理扫码注册申请,使代理账号只查看 `parent_shop_id` 等于其当前所属店铺的直接下级申请;企业账号 MUST 被拒绝。列表 SHALL 支持业务状态、申请与决定时间闭区间、手机号精确值、注册资料关键字、上级店铺和审批提交异常筛选,并按 `created_at DESC, id DESC` 分页。 + +列表与详情 SHALL 返回注册业务、审批和企微提交三层状态、完整手机号与完整注册地址、直接上级店铺、审批实例、安全失败摘要、可恢复标记及最近恢复时间;系统 MUST NOT 返回密码哈希、验证码、企微原文、内部事件键或租约字段,并 MUST 批量投影关联店铺和审批状态,避免按申请数量线性增加查询。 + +#### Scenario: 平台与代理查询范围 + +- **WHEN** 平台账号或直接上级代理查询代理注册申请 +- **THEN** 平台账号返回全部符合筛选条件的申请,代理仅返回 `parent_shop_id` 等于当前店铺的申请,企业账号被拒绝 + +#### Scenario: 联系资料投影排除凭证 + +- **WHEN** 有权账号查看注册申请列表或详情 +- **THEN** 响应包含完整手机号和注册地址,但不包含密码哈希、验证码、企微原文、内部事件键或租约字段 + +#### Scenario: 提交异常状态可区分 + +- **WHEN** 两条待审批申请分别处于审批中和提交失败或结果未知 +- **THEN** 列表分别返回企微提交状态及服务端计算的可恢复标记 + + +### Requirement: 代理注册原审批可受控恢复 + +系统 SHALL 提供 `POST /api/admin/agent-distribution-registrations/{id}/recover-approval`,允许有权平台账号及申请直接上级代理恢复仍待审批的注册申请。恢复 MUST 校验原审批实例及业务关联、上级店铺启用状态和调用方范围,复用原实例与稳定提交事实;不得新建注册记录、审批实例、企业微信审批单、店铺、账号或钱包。结果未知时 MUST 优先确认原提交,已有审批单号时 MUST 同步原审批,明确未受理失败时才可恢复提交事件,终态申请或停用上级 MUST 返回稳定冲突;恢复 MUST 写入脱敏审计。 +#### Scenario: 明确失败恢复原提交事实 + +- **WHEN** 待审批申请的原提交明确失败且确认未被企业微信受理,且上级店铺仍启用 +- **THEN** 系统恢复同一提交事实,不新建注册记录、审批实例或业务实体 + +#### Scenario: 结果未知优先确认 + +- **WHEN** 待审批申请的原提交结果未知 +- **THEN** 系统进入原实例确认流程,不直接创建新的企业微信审批 + +#### Scenario: 终态或停用上级拒绝恢复 + +- **WHEN** 注册申请已终态或其直接上级店铺已停用时请求恢复 +- **THEN** 系统返回稳定冲突且不改变注册、审批或实体事实 + ### Requirement: 提现资料资格 代理首次提现前 SHALL 提交企业微信资料资格申请;合同和法人身份证正反面必填,企业代理填写统一社会信用代码、个人代理填写法人身份证号。合同资格主体 MUST 填写统一社会信用代码或身份证号;营业执照、门头照可选,发票仅企业可选且其抬头/统一社会信用代码 MUST 与合同主体一致。审批通过且资料未过期才有效;合同和身份证通过后长期有效,直到代理替换资料、超级管理员作废或代理停用。 @@ -254,6 +294,10 @@ `POST /api/c/v1/agent-distribution-registrations`(代理扫码注册,公开接口)。 +### 代理注册申请后台管理 + +`GET /api/admin/agent-distribution-registrations`(查询代理注册申请列表);`GET /api/admin/agent-distribution-registrations/{id}`(查询代理注册申请详情);`POST /api/admin/agent-distribution-registrations/{id}/recover-approval`(恢复原审批提交)。 + ### 提现资料资格 `POST /api/admin/shops/{shop_id}/withdrawal-qualifications`(提交提现资料资格);`GET /api/admin/shops/{shop_id}/withdrawal-qualifications`(查询提现资料资格版本);`POST /api/admin/withdrawal-qualifications/{id}/void`(作废提现资料资格)。 diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index 9fb3fd5f..6a5be80d 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -12,84 +12,85 @@ import ( // BuildDocHandlers 构造文档生成用的 handlers(所有依赖传 nil) func BuildDocHandlers() *bootstrap.Handlers { return &bootstrap.Handlers{ - Auth: authHandler.NewHandler(nil, nil), - Account: admin.NewAccountHandler(nil), - Role: admin.NewRoleHandler(nil, nil), - Permission: admin.NewPermissionHandler(nil), - PersonalCustomer: app.NewPersonalCustomerHandler(nil, nil), - ClientAuth: app.NewClientAuthHandler(nil, nil), - ClientAsset: app.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil), - ClientWallet: app.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil), - ClientOrder: app.NewClientOrderHandler(nil, nil), - ClientExchange: app.NewClientExchangeHandler(nil), - ClientRealname: app.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil, nil), - ClientDevice: app.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil), - ClientRechargeOrder: app.NewClientRechargeOrderHandler(nil, nil, nil), - ClientNotification: app.NewClientNotificationHandler(nil, nil, nil), - ClientPopup: app.NewClientPopupHandler(nil, nil, nil), - Shop: admin.NewShopHandler(nil, nil), - ShopRole: admin.NewShopRoleHandler(nil), - AdminAuth: admin.NewAuthHandler(nil, 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), - EnterpriseCard: admin.NewEnterpriseCardHandler(nil), - EnterpriseDevice: admin.NewEnterpriseDeviceHandler(nil), - Authorization: admin.NewAuthorizationHandler(nil), - IotCard: admin.NewIotCardHandler(nil), - IotCardImport: admin.NewIotCardImportHandler(nil), - ExportTask: admin.NewExportTaskHandler(nil), - Notification: admin.NewNotificationHandler(nil, nil, nil), - H5PopupConfiguration: admin.NewH5PopupConfigurationHandler(nil, nil, nil), - Device: admin.NewDeviceHandler(nil), - DeviceImport: admin.NewDeviceImportHandler(nil), - AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), - Storage: admin.NewStorageHandler(nil), - Carrier: admin.NewCarrierHandler(nil), - PackageSeries: admin.NewPackageSeriesHandler(nil), - Package: admin.NewPackageHandler(nil), - PackageUsage: admin.NewPackageUsageHandler(nil), - ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(nil), - ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil), - ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil), - AdminOrder: admin.NewOrderHandler(nil, nil), - AdminExchange: admin.NewExchangeHandler(nil, nil, nil), - PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil), - CTCCRealnameCallback: callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil), - CMCCRealnameCallback: callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil), - CUCCRealnameCallback: callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil), - CUCCRealnameRemovalCallback: callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil), - PollingConfig: admin.NewPollingConfigHandler(nil), - PollingConcurrency: admin.NewPollingConcurrencyHandler(nil), - PollingMonitoring: admin.NewPollingMonitoringHandler(nil), - PollingAlert: admin.NewPollingAlertHandler(nil), - PollingCleanup: admin.NewPollingCleanupHandler(nil), - PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil), - PollingPriority: admin.NewPriorityPollingHandler(nil, nil, nil), - Asset: admin.NewAssetHandler(nil, nil, nil, nil, nil, nil, nil), - AssetLifecycle: admin.NewAssetLifecycleHandler(nil), - AssetWallet: admin.NewAssetWalletHandler(nil), - WechatConfig: admin.NewWechatConfigHandler(nil), - PaymentMerchant: admin.NewPaymentMerchantHandler(nil), - EmployeeCollection: admin.NewEmployeeCollectionHandler(nil, nil, nil), - AgentRecharge: admin.NewAgentRechargeHandler(nil, nil), - Refund: admin.NewRefundHandler(nil), - OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(nil), - AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(nil, nil), - BusinessUserGroup: admin.NewBusinessUserGroupHandler(nil, nil), - ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(nil), - PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(nil, nil), - PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil), - AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(nil, nil), - OperationsReport: admin.NewOperationsReportHandler(nil, nil, nil), - ClientWechat: app.NewClientWechatHandler(nil, nil, nil), - SuperAdmin: admin.NewSuperAdminHandler(nil), - SystemConfig: admin.NewSystemConfigHandler(nil, nil), - Audit: admin.NewAuditHandler(nil, nil), - WeCom: admin.NewWeComHandler(nil, nil), - AgentOpenAPI: openapiHandler.NewHandler(nil, nil), + Auth: authHandler.NewHandler(nil, nil), + Account: admin.NewAccountHandler(nil), + Role: admin.NewRoleHandler(nil, nil), + Permission: admin.NewPermissionHandler(nil), + PersonalCustomer: app.NewPersonalCustomerHandler(nil, nil), + ClientAuth: app.NewClientAuthHandler(nil, nil), + ClientAsset: app.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil), + ClientWallet: app.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil), + ClientOrder: app.NewClientOrderHandler(nil, nil), + ClientExchange: app.NewClientExchangeHandler(nil), + ClientRealname: app.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil, nil), + ClientDevice: app.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil), + ClientRechargeOrder: app.NewClientRechargeOrderHandler(nil, nil, nil), + ClientNotification: app.NewClientNotificationHandler(nil, nil, nil), + ClientPopup: app.NewClientPopupHandler(nil, nil, nil), + Shop: admin.NewShopHandler(nil, nil), + ShopRole: admin.NewShopRoleHandler(nil), + AdminAuth: admin.NewAuthHandler(nil, nil), + ShopCommission: admin.NewShopCommissionHandler(nil, nil), + WithdrawalQualification: admin.NewWithdrawalQualificationHandler(nil, nil, nil), + AgentDistributionRegistration: admin.NewAgentDistributionRegistrationHandler(nil, nil, nil), + AgentDistribution: app.NewAgentDistributionHandler(nil, nil), + CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(nil, nil), + CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(nil), + Enterprise: admin.NewEnterpriseHandler(nil), + EnterpriseCard: admin.NewEnterpriseCardHandler(nil), + EnterpriseDevice: admin.NewEnterpriseDeviceHandler(nil), + Authorization: admin.NewAuthorizationHandler(nil), + IotCard: admin.NewIotCardHandler(nil), + IotCardImport: admin.NewIotCardImportHandler(nil), + ExportTask: admin.NewExportTaskHandler(nil), + Notification: admin.NewNotificationHandler(nil, nil, nil), + H5PopupConfiguration: admin.NewH5PopupConfigurationHandler(nil, nil, nil), + Device: admin.NewDeviceHandler(nil), + DeviceImport: admin.NewDeviceImportHandler(nil), + AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(nil), + Storage: admin.NewStorageHandler(nil), + Carrier: admin.NewCarrierHandler(nil), + PackageSeries: admin.NewPackageSeriesHandler(nil), + Package: admin.NewPackageHandler(nil), + PackageUsage: admin.NewPackageUsageHandler(nil), + ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(nil), + ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil), + ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil), + AdminOrder: admin.NewOrderHandler(nil, nil), + AdminExchange: admin.NewExchangeHandler(nil, nil, nil), + PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil), + CTCCRealnameCallback: callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil), + CMCCRealnameCallback: callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil), + CUCCRealnameCallback: callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil), + CUCCRealnameRemovalCallback: callback.NewCUCCRealnameRemovalHandler(nil, nil, nil, nil, nil), + PollingConfig: admin.NewPollingConfigHandler(nil), + PollingConcurrency: admin.NewPollingConcurrencyHandler(nil), + PollingMonitoring: admin.NewPollingMonitoringHandler(nil), + PollingAlert: admin.NewPollingAlertHandler(nil), + PollingCleanup: admin.NewPollingCleanupHandler(nil), + PollingManualTrigger: admin.NewPollingManualTriggerHandler(nil), + PollingPriority: admin.NewPriorityPollingHandler(nil, nil, nil), + Asset: admin.NewAssetHandler(nil, nil, nil, nil, nil, nil, nil), + AssetLifecycle: admin.NewAssetLifecycleHandler(nil), + AssetWallet: admin.NewAssetWalletHandler(nil), + WechatConfig: admin.NewWechatConfigHandler(nil), + PaymentMerchant: admin.NewPaymentMerchantHandler(nil), + EmployeeCollection: admin.NewEmployeeCollectionHandler(nil, nil, nil), + AgentRecharge: admin.NewAgentRechargeHandler(nil, nil), + Refund: admin.NewRefundHandler(nil), + OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(nil), + AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(nil, nil), + BusinessUserGroup: admin.NewBusinessUserGroupHandler(nil, nil), + ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(nil), + PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(nil, nil), + PackageTrafficAlert: admin.NewPackageTrafficAlertHandler(nil, nil, nil, nil), + AssetAutoRenewal: admin.NewAssetAutoRenewalConfigHandler(nil, nil), + OperationsReport: admin.NewOperationsReportHandler(nil, nil, nil), + ClientWechat: app.NewClientWechatHandler(nil, nil, nil), + SuperAdmin: admin.NewSuperAdminHandler(nil), + SystemConfig: admin.NewSystemConfigHandler(nil, nil), + Audit: admin.NewAuditHandler(nil, nil), + WeCom: admin.NewWeComHandler(nil, nil), + AgentOpenAPI: openapiHandler.NewHandler(nil, nil), } }