From 70e680eb0a7bbcd73ea1792e61d94c16d1edb852 Mon Sep 17 00:00:00 2001 From: break Date: Tue, 15 Sep 2026 11:54:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=89=8B=E6=9C=BA=E5=8F=B7=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E5=85=B3=E8=81=94):=20AUG26-009=20=E6=89=8B=E6=9C=BA?= =?UTF-8?q?=E5=8F=B7=E2=80=94=E8=B5=84=E4=BA=A7=E5=85=B3=E8=81=94=E3=80=81?= =?UTF-8?q?=E5=8D=81=E9=A1=B9=E4=B8=8A=E9=99=90=E4=B8=8E=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E8=A7=A3=E7=BB=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增成对迁移 000223(tb_phone_asset_association,含有效关系部分唯一索引与 down 守卫)与 000224(解绑导入任务表),不回填历史 - H5:need_bind_phone 三支判定(开关关闭完全短路);已有主号幂等建联;十项上限按手机号 advisory 串行化(含换绑到全新号的并发场景);换绑原子迁移与冲突整单回滚;不写遗留列 - 后台:关联列表、单项/批量解绑、CSV 导入解绑(B1–B16),超管/平台 gate + 资产数据范围复核,三态统一文案 - 读侧:卡/设备列表与详情按页一次 IN 聚合;两类导出补「关联手机号」列并保留历史表头反解兼容 - 脱敏:关联审计走独立动作/资源只写脱敏手机号;访问日志手机号类字段脱敏 - 同步主 Spec openspec/specs/phone-asset-association 并归档 AUG26-009,补齐 requirement-evidence 与入口矩阵,context-health 通过 --- cmd/api/docs.go | 1 + cmd/gendocs/main.go | 1 + cmd/worker/main.go | 19 + .../entry-capability-requirement-matrix.json | 88 +++- .../context-reset/requirement-evidence.json | 176 +++++++ internal/application/accessaudit/change.go | 20 + internal/bootstrap/handlers.go | 1 + internal/bootstrap/services.go | 16 +- internal/bootstrap/stores.go | 4 + internal/bootstrap/types.go | 1 + internal/bootstrap/worker_services.go | 2 + internal/bootstrap/worker_stores.go | 6 + internal/exporter/association_phones.go | 40 ++ internal/exporter/device_scene.go | 35 +- internal/exporter/iot_card_scene.go | 18 +- .../handler/admin/phone_asset_association.go | 157 +++++++ internal/handler/app/client_auth.go | 5 +- internal/infrastructure/audit/registry.go | 32 ++ internal/infrastructure/audit/writer.go | 45 +- .../infrastructure/releasegate/checker.go | 2 +- internal/model/dto/asset_dto.go | 13 +- internal/model/dto/device_dto.go | 11 +- internal/model/dto/iot_card_dto.go | 1 + .../model/dto/phone_asset_association_dto.go | 152 ++++++ internal/model/dto/storage_dto.go | 2 +- internal/model/phone_asset_association.go | 30 ++ .../model/phone_asset_unbind_import_task.go | 72 +++ internal/query/audit/timeline.go | 3 +- internal/routes/admin.go | 4 + internal/routes/phone_asset_association.go | 107 +++++ internal/routes/storage.go | 1 + internal/service/asset/resolve_identifier.go | 51 ++ internal/service/asset/service.go | 33 ++ .../client_auth/phone_asset_association.go | 102 ++++ internal/service/client_auth/service.go | 167 ++++++- internal/service/device/service.go | 15 + internal/service/iot_card/service.go | 15 + .../association_write.go | 165 +++++++ .../service/phone_asset_association/import.go | 235 ++++++++++ .../phone_asset_association/service.go | 396 ++++++++++++++++ .../postgres/phone_asset_association_store.go | 265 +++++++++++ .../phone_asset_unbind_import_task_store.go | 121 +++++ internal/task/phone_asset_unbind_import.go | 434 ++++++++++++++++++ ...00223_add_phone_asset_association.down.sql | 25 + .../000223_add_phone_asset_association.up.sql | 66 +++ ...dd_phone_asset_unbind_import_task.down.sql | 23 + ..._add_phone_asset_unbind_import_task.up.sql | 63 +++ .../add-phone-asset-associations/design.md | 32 -- .../add-phone-asset-associations/proposal.md | 25 - .../specs/phone-asset-association/spec.md | 21 - .../add-phone-asset-associations/tasks.md | 12 - .../.openspec.yaml | 0 .../design.md | 162 +++++++ .../proposal.md | 29 ++ .../specs/phone-asset-association/spec.md | 123 +++++ .../tasks.md | 25 + .../specs/phone-asset-association/spec.md | 163 +++++++ pkg/constants/audit.go | 20 + pkg/constants/constants.go | 5 + pkg/constants/phone_asset_association.go | 142 ++++++ pkg/logger/middleware.go | 56 +++ pkg/openapi/handlers.go | 1 + pkg/queue/handler.go | 17 + pkg/queue/types.go | 2 + pkg/sanitizer/sanitizer.go | 17 + pkg/storage/service.go | 2 +- pkg/storage/types.go | 3 + 67 files changed, 3951 insertions(+), 147 deletions(-) create mode 100644 internal/exporter/association_phones.go create mode 100644 internal/handler/admin/phone_asset_association.go create mode 100644 internal/model/dto/phone_asset_association_dto.go create mode 100644 internal/model/phone_asset_association.go create mode 100644 internal/model/phone_asset_unbind_import_task.go create mode 100644 internal/routes/phone_asset_association.go create mode 100644 internal/service/asset/resolve_identifier.go create mode 100644 internal/service/client_auth/phone_asset_association.go create mode 100644 internal/service/phone_asset_association/association_write.go create mode 100644 internal/service/phone_asset_association/import.go create mode 100644 internal/service/phone_asset_association/service.go create mode 100644 internal/store/postgres/phone_asset_association_store.go create mode 100644 internal/store/postgres/phone_asset_unbind_import_task_store.go create mode 100644 internal/task/phone_asset_unbind_import.go create mode 100644 migrations/000223_add_phone_asset_association.down.sql create mode 100644 migrations/000223_add_phone_asset_association.up.sql create mode 100644 migrations/000224_add_phone_asset_unbind_import_task.down.sql create mode 100644 migrations/000224_add_phone_asset_unbind_import_task.up.sql delete mode 100644 openspec/changes/add-phone-asset-associations/design.md delete mode 100644 openspec/changes/add-phone-asset-associations/proposal.md delete mode 100644 openspec/changes/add-phone-asset-associations/specs/phone-asset-association/spec.md delete mode 100644 openspec/changes/add-phone-asset-associations/tasks.md rename openspec/changes/{add-phone-asset-associations => archive/2026-09-15-add-phone-asset-associations}/.openspec.yaml (100%) create mode 100644 openspec/changes/archive/2026-09-15-add-phone-asset-associations/design.md create mode 100644 openspec/changes/archive/2026-09-15-add-phone-asset-associations/proposal.md create mode 100644 openspec/changes/archive/2026-09-15-add-phone-asset-associations/specs/phone-asset-association/spec.md create mode 100644 openspec/changes/archive/2026-09-15-add-phone-asset-associations/tasks.md create mode 100644 openspec/specs/phone-asset-association/spec.md create mode 100644 pkg/constants/phone_asset_association.go diff --git a/cmd/api/docs.go b/cmd/api/docs.go index b1b6b9d..ec90acc 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -29,6 +29,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil) handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil) handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil) + handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 8774fa0..6cc04b0 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -38,6 +38,7 @@ func generateAdminDocs(outputPath string) error { handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil) handlers.BusinessUserGroup = admin.NewBusinessUserGroupHandler(nil, nil) handlers.ShopBusinessOwnerImport = admin.NewShopBusinessOwnerImportHandler(nil) + handlers.PhoneAssetAssociation = admin.NewPhoneAssetAssociationHandler(nil, nil) // 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。 handlers.WeCom = admin.NewWeComHandler(nil, nil) handlers.PaymentMerchant = admin.NewPaymentMerchantHandler(nil) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 8a78ad8..87ac36b 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -953,6 +953,7 @@ func rescuePendingImportTasks(ctx context.Context, runtime *workerRuntime, appLo rescuePendingIotCardImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger) rescuePendingDeviceImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger) rescuePendingShopBusinessOwnerImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger) + rescuePendingPhoneAssetUnbindImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger) } // rescuePendingShopBusinessOwnerImportTasks 补偿仍停留在待处理状态的店铺负责人导入任务。 @@ -973,6 +974,24 @@ func rescuePendingShopBusinessOwnerImportTasks(ctx context.Context, db *gorm.DB, } } +// rescuePendingPhoneAssetUnbindImportTasks 补偿仍停留在待处理状态的手机号资产解绑导入任务。 +// 只扫描本 Change 自己的任务表,补偿键按任务类型与任务 ID 隔离,与其他导入补偿互不影响。 +func rescuePendingPhoneAssetUnbindImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) { + var importTasks []model.PhoneAssetUnbindImportTask + if err := db.WithContext(ctx). + Where("status = ?", model.ImportTaskStatusPending). + Limit(importRescueLimit). + Find(&importTasks).Error; err != nil { + appLogger.Warn("扫描待补偿手机号资产解绑导入任务失败", zap.Error(err)) + return + } + + for _, importTask := range importTasks { + payload := task.PhoneAssetUnbindImportPayload{TaskID: importTask.ID} + enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypePhoneAssetUnbindImport, payload, importTask.ID, appLogger) + } +} + // rescuePendingIotCardImportTasks 补偿仍停留在待处理状态的 IoT 卡导入任务。 func rescuePendingIotCardImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) { var importTasks []model.IotCardImportTask diff --git a/docs/verification/context-reset/entry-capability-requirement-matrix.json b/docs/verification/context-reset/entry-capability-requirement-matrix.json index 424bfa6..d9fc4fb 100644 --- a/docs/verification/context-reset/entry-capability-requirement-matrix.json +++ b/docs/verification/context-reset/entry-capability-requirement-matrix.json @@ -2603,7 +2603,9 @@ "capability": "personal-customer", "requirements": [ "identity-access::令牌生命周期", - "personal-customer::个人客户身份" + "personal-customer::个人客户身份", + "phone-asset-association::H5 验证建立关联与数量上限", + "phone-asset-association::H5 绑定与换绑的关联写入" ], "classification": "behavior" }, @@ -2613,7 +2615,8 @@ "capability": "personal-customer", "requirements": [ "identity-access::令牌生命周期", - "personal-customer::个人客户身份" + "personal-customer::个人客户身份", + "phone-asset-association::H5 绑定与换绑的关联写入" ], "classification": "behavior" }, @@ -2643,7 +2646,8 @@ "capability": "personal-customer", "requirements": [ "identity-access::令牌生命周期", - "personal-customer::个人客户身份" + "personal-customer::个人客户身份", + "phone-asset-association::登录提示判定" ], "classification": "behavior" }, @@ -2663,7 +2667,8 @@ "capability": "personal-customer", "requirements": [ "identity-access::令牌生命周期", - "personal-customer::个人客户身份" + "personal-customer::个人客户身份", + "phone-asset-association::登录提示判定" ], "classification": "behavior" }, @@ -2673,7 +2678,8 @@ "capability": "personal-customer", "requirements": [ "identity-access::令牌生命周期", - "personal-customer::个人客户身份" + "personal-customer::个人客户身份", + "phone-asset-association::登录提示判定" ], "classification": "behavior" }, @@ -3427,7 +3433,9 @@ "entry_type": "async", "entry": "constants.TaskTypeDeviceImport", "capability": "infrastructure", - "requirements": [], + "requirements": [ + "phone-asset-association::关联不影响换货与其他写入路径" + ], "classification": "route_index_or_infrastructure" }, { @@ -3474,7 +3482,9 @@ "entry_type": "async", "entry": "constants.TaskTypeIotCardImport", "capability": "infrastructure", - "requirements": [], + "requirements": [ + "phone-asset-association::关联不影响换货与其他写入路径" + ], "classification": "route_index_or_infrastructure" }, { @@ -4139,5 +4149,69 @@ "business-user-group::店铺负责人 CSV 导入" ], "classification": "route_index_or_infrastructure" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/phone-asset-associations", + "capability": "phone-asset-association", + "requirements": [ + "phone-asset-association::后台查看关联" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/phone-asset-associations/batch-unbind", + "capability": "phone-asset-association", + "requirements": [ + "phone-asset-association::后台解除关联" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "POST /api/admin/phone-asset-associations/unbind-imports", + "capability": "phone-asset-association", + "requirements": [ + "phone-asset-association::后台解除关联" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/phone-asset-associations/unbind-imports", + "capability": "phone-asset-association", + "requirements": [ + "phone-asset-association::后台解除关联" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "GET /api/admin/phone-asset-associations/unbind-imports/{id}", + "capability": "phone-asset-association", + "requirements": [ + "phone-asset-association::后台解除关联", + "phone-asset-association::后台查看关联" + ], + "classification": "behavior" + }, + { + "entry_type": "http", + "entry": "DELETE /api/admin/phone-asset-associations/{id}", + "capability": "phone-asset-association", + "requirements": [ + "phone-asset-association::后台解除关联" + ], + "classification": "behavior" + }, + { + "entry_type": "async", + "entry": "constants.TaskTypePhoneAssetUnbindImport", + "capability": "phone-asset-association", + "requirements": [ + "phone-asset-association::后台解除关联" + ], + "classification": "behavior" } ] diff --git a/docs/verification/context-reset/requirement-evidence.json b/docs/verification/context-reset/requirement-evidence.json index e6e6ba9..d6758ce 100644 --- a/docs/verification/context-reset/requirement-evidence.json +++ b/docs/verification/context-reset/requirement-evidence.json @@ -3662,5 +3662,181 @@ ], "exit_status": 0 } + }, + { + "capability": "phone-asset-association", + "requirement": "H5 验证建立关联与数量上限", + "spec": "openspec/specs/phone-asset-association/spec.md", + "entries": [ + "POST /api/c/v1/auth/bind-phone" + ], + "handler_consumer_job": [ + "internal/handler/app/client_auth.go", + "internal/middleware/personal_auth.go" + ], + "application_service_query": [ + "internal/service/client_auth/phone_asset_association.go", + "internal/service/phone_asset_association/association_write.go" + ], + "domain_state_amount": [ + "pkg/constants/phone_asset_association.go" + ], + "store_migration_config": [ + "internal/store/postgres/phone_asset_association_store.go", + "migrations/000223_add_phone_asset_association.up.sql" + ], + "verification": { + "command": "代码中检索 PhoneAssetAssociationMaxValidPerPhone", + "literal_output": [ + "pkg/constants/phone_asset_association.go:32:\tPhoneAssetAssociationMaxValidPerPhone = 10" + ], + "exit_status": 0 + } + }, + { + "capability": "phone-asset-association", + "requirement": "登录提示判定", + "spec": "openspec/specs/phone-asset-association/spec.md", + "entries": [ + "POST /api/c/v1/auth/verify-asset" + ], + "handler_consumer_job": [ + "internal/middleware/personal_auth.go" + ], + "application_service_query": [ + "internal/service/client_auth/service.go" + ], + "domain_state_amount": [ + "pkg/constants/phone_asset_association.go" + ], + "store_migration_config": [ + "internal/store/postgres/phone_asset_association_store.go" + ], + "verification": { + "command": "代码中检索 needBindPhone = !associated", + "literal_output": [ + "internal/service/client_auth/service.go:1031:\t\t\tneedBindPhone = !associated" + ], + "exit_status": 0 + } + }, + { + "capability": "phone-asset-association", + "requirement": "H5 绑定与换绑的关联写入", + "spec": "openspec/specs/phone-asset-association/spec.md", + "entries": [ + "POST /api/c/v1/auth/bind-phone", + "POST /api/c/v1/auth/change-phone" + ], + "handler_consumer_job": [ + "internal/handler/app/client_auth.go" + ], + "application_service_query": [ + "internal/service/phone_asset_association/association_write.go" + ], + "domain_state_amount": [ + "pkg/constants/phone_asset_association.go" + ], + "store_migration_config": [ + "internal/store/postgres/phone_asset_association_store.go" + ], + "verification": { + "command": "代码中检索 换绑已回滚", + "literal_output": [ + "internal/service/phone_asset_association/association_write.go:140:\t\t\treturn nil, errors.New(errors.CodeInvalidStatus, \"新手机号已存在与待迁移资产相同的有效关联,换绑已回滚\")" + ], + "exit_status": 0 + } + }, + { + "capability": "phone-asset-association", + "requirement": "后台查看关联", + "spec": "openspec/specs/phone-asset-association/spec.md", + "entries": [ + "GET /api/admin/phone-asset-associations" + ], + "handler_consumer_job": [ + "internal/handler/admin/phone_asset_association.go", + "internal/routes/phone_asset_association.go" + ], + "application_service_query": [ + "internal/service/phone_asset_association/service.go", + "internal/service/iot_card/service.go", + "internal/service/device/service.go", + "internal/exporter/association_phones.go" + ], + "domain_state_amount": [ + "pkg/constants/phone_asset_association.go" + ], + "store_migration_config": [ + "internal/store/postgres/phone_asset_association_store.go" + ], + "verification": { + "command": "代码中检索 ListValidByAssets(ctx", + "literal_output": [ + "internal/service/iot_card/service.go:312:\tassociatedPhones, err := s.associationStore.ListValidByAssets(ctx, constants.AssetTypeIotCard, cardIDs)", + "internal/service/device/service.go:273:\tassociatedPhones, err := s.associationStore.ListValidByAssets(ctx, constants.AssetTypeDevice, deviceIDs)" + ], + "exit_status": 0 + } + }, + { + "capability": "phone-asset-association", + "requirement": "后台解除关联", + "spec": "openspec/specs/phone-asset-association/spec.md", + "entries": [ + "DELETE /api/admin/phone-asset-associations/{id}", + "POST /api/admin/phone-asset-associations/batch-unbind", + "POST /api/admin/phone-asset-associations/unbind-imports", + "GET /api/admin/phone-asset-associations/unbind-imports", + "GET /api/admin/phone-asset-associations/unbind-imports/{id}" + ], + "handler_consumer_job": [ + "internal/handler/admin/phone_asset_association.go" + ], + "application_service_query": [ + "internal/service/phone_asset_association/service.go", + "internal/task/phone_asset_unbind_import.go" + ], + "domain_state_amount": [ + "pkg/constants/phone_asset_association.go" + ], + "store_migration_config": [ + "internal/store/postgres/phone_asset_association_store.go", + "migrations/000224_add_phone_asset_unbind_import_task.up.sql" + ], + "verification": { + "command": "代码中检索 PhoneAssetAssociationDeniedMessage", + "literal_output": [ + "pkg/constants/phone_asset_association.go:103:\tPhoneAssetAssociationDeniedMessage = \"无权限操作该资源或资源不存在\"", + "internal/service/phone_asset_association/service.go:267:\treturn errors.New(errors.CodeForbidden, constants.PhoneAssetAssociationDeniedMessage)" + ], + "exit_status": 0 + } + }, + { + "capability": "phone-asset-association", + "requirement": "关联不影响换货与其他写入路径", + "spec": "openspec/specs/phone-asset-association/spec.md", + "entries": [], + "handler_consumer_job": [], + "application_service_query": [], + "domain_state_amount": [ + "pkg/constants/phone_asset_association.go" + ], + "store_migration_config": [ + "internal/store/postgres/phone_asset_association_store.go" + ], + "verification": { + "command": "代码中检索 tb_phone_asset_association 的全部写入点(应仅本 Change 模块)", + "literal_output": [ + "internal/service/phone_asset_association/association_write.go:75:\tcreated, err := store.CreateIfAbsent(ctx, tx, association)", + "internal/service/phone_asset_association/association_write.go:136:\tif err := tx.WithContext(ctx).Model(&model.PhoneAssetAssociation{}).", + "internal/service/phone_asset_association/service.go:65:\t\taffected, err := s.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, []uint{locked.ID},", + "internal/service/phone_asset_association/service.go:147:\t\taffected, err := s.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, ids,", + "internal/task/phone_asset_unbind_import.go:334:\t\taffected, err := h.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, ids," + ], + "exit_status": 0 + } } ] diff --git a/internal/application/accessaudit/change.go b/internal/application/accessaudit/change.go index 8467f94..e0dad0a 100644 --- a/internal/application/accessaudit/change.go +++ b/internal/application/accessaudit/change.go @@ -5,6 +5,7 @@ import ( "context" stderrors "errors" "strconv" + "time" "gorm.io/gorm" @@ -42,6 +43,7 @@ type ChangeAudit struct { PersonalOpenIDs []PersonalCustomerOpenIDChange PersonalDevices []PersonalCustomerDeviceChange PersonalICCIDs []PersonalCustomerICCIDChange + PhoneAssociations []PhoneAssetAssociationChange Role *model.Role Roles []RoleChange Permissions []PermissionChange @@ -59,6 +61,24 @@ type PersonalCustomerPhoneChange struct { AfterData map[string]any } +// PhoneAssetAssociationChange 保存手机号—资产关联资源的前后变化。 +// 手机号一律传入脱敏值:关联用例不得把完整手机号写入审计(ENG-LOG-001)。 +// 关联指向的资产以资产类型与资产 ID 声明,由 Writer 组装为参考资源。 +type PhoneAssetAssociationChange struct { + AssociationID uint + PhoneMasked string + AssetType string + AssetID uint + AssetDisplayName string + Status int + Source string + InvalidatedAt *time.Time + InvalidationMethod string + InvalidationReason string + BeforeData map[string]any + AfterData map[string]any +} + // PersonalCustomerOpenIDChange 保存个人客户微信主体资源变化。 type PersonalCustomerOpenIDChange struct { OpenID *model.PersonalCustomerOpenID diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 6d84f3e..9dd8f0d 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -228,6 +228,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { return handler }(), ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(svc.ShopBusinessOwnerImport), + PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(svc.PhoneAssetAssociation, validate), AdminAuth: admin.NewAuthHandler(svc.Auth, validate), ShopCommission: func() *admin.ShopCommissionHandler { handler := admin.NewShopCommissionHandler(svc.ShopCommission, validate) diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 9eb044d..9f60d72 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -69,6 +69,7 @@ import ( agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge" operationPasswordSvc "github.com/break/junhong_cmp_fiber/internal/service/operation_password" orderPackageInvalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate" + phoneAssetAssociationSvc "github.com/break/junhong_cmp_fiber/internal/service/phone_asset_association" pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling" refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund" shopBusinessOwnerImportSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_business_owner_import" @@ -145,6 +146,7 @@ type services struct { OrderPackageInvalidate *orderPackageInvalidateSvc.Service AssetPackageBatchOrder *assetPackageBatchOrderSvc.Service ShopBusinessOwnerImport *shopBusinessOwnerImportSvc.Service + PhoneAssetAssociation *phoneAssetAssociationSvc.Service ObservationSeries cardObservationApp.BestEffortSeriesDispatcher CardObservation *cardObservationApp.Service CardObservationSeries *cardObservationApp.SeriesAttemptService @@ -245,6 +247,7 @@ func initServices(s *stores, deps *Dependencies) *services { iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding) iotCard.SetEnterpriseCardAuthStore(s.EnterpriseCardAuthorization) iotCard.SetEnterpriseStore(s.Enterprise) + iotCard.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation) iotCard.SetRedisClient(deps.Redis) device := deviceSvc.New( deps.DB, @@ -263,6 +266,7 @@ func initServices(s *stores, deps *Dependencies) *services { s.Enterprise, ) device.SetAccessAudit(auditWriter) + device.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation) device.SetGatewayIntegrationLog(integrationlog.NewRepository(deps.DB)) device.SetObservationSeriesEventWriter(observationSeriesEvents) device.SetObservationSeriesDispatcher(observationSeries) @@ -349,6 +353,7 @@ func initServices(s *stores, deps *Dependencies) *services { exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository()))) exchangeService.SetAccessAudit(auditWriter) assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder) + assetService.SetPhoneAssetAssociationStore(s.PhoneAssetAssociation) assetService.SetAccessAudit(auditWriter) agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device) wecomApplicationRepository := wecomInfra.NewApplicationRepository(deps.DB) @@ -447,6 +452,7 @@ func initServices(s *stores, deps *Dependencies) *services { s.PersonalCustomerOpenID, s.PersonalCustomer, s.PersonalCustomerPhone, + s.PhoneAssetAssociation, s.IotCard, s.Device, wechatConfig, @@ -513,8 +519,12 @@ func initServices(s *stores, deps *Dependencies) *services { OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient, auditWriter), AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient, auditWriter), ShopBusinessOwnerImport: shopBusinessOwnerImportSvc.New(s.ShopBusinessOwnerImportTask, deps.QueueClient, auditWriter), - ObservationSeries: observationSeries, - CardObservation: cardObservationService, - CardObservationSeries: cardObservationSeries, + PhoneAssetAssociation: phoneAssetAssociationSvc.New( + deps.DB, s.PhoneAssetAssociation, s.PhoneAssetUnbindImportTask, + s.AssetIdentifier, s.IotCard, s.Device, deps.QueueClient, auditWriter, + ), + ObservationSeries: observationSeries, + CardObservation: cardObservationService, + CardObservationSeries: cardObservationSeries, } } diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 8e26ba5..2cadfe0 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -17,6 +17,7 @@ type stores struct { PersonalCustomerOpenID *postgres.PersonalCustomerOpenIDStore PersonalCustomerDevice *postgres.PersonalCustomerDeviceStore PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore + PhoneAssetAssociation *postgres.PhoneAssetAssociationStore CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore CommissionRecord *postgres.CommissionRecordStore CommissionWithdrawalSetting *postgres.CommissionWithdrawalSettingStore @@ -73,6 +74,7 @@ type stores struct { BusinessUserGroup *postgres.BusinessUserGroupStore // 店铺负责人 CSV 导入任务 ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore + PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore // 流量系统 CardDailyUsage *postgres.CardDailyUsageStore // 资产标识符注册表 @@ -93,6 +95,7 @@ func initStores(deps *Dependencies) *stores { PersonalCustomerOpenID: postgres.NewPersonalCustomerOpenIDStore(deps.DB), PersonalCustomerDevice: postgres.NewPersonalCustomerDeviceStore(deps.DB), PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), + PhoneAssetAssociation: postgres.NewPhoneAssetAssociationStore(deps.DB), CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis), CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis), CommissionWithdrawalSetting: postgres.NewCommissionWithdrawalSettingStore(deps.DB, deps.Redis), @@ -144,5 +147,6 @@ func initStores(deps *Dependencies) *stores { AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB), BusinessUserGroup: postgres.NewBusinessUserGroupStore(deps.DB), ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB), + PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB), } } diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index ddaef9e..43a892c 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -78,6 +78,7 @@ type Handlers struct { AssetPackageBatchOrder *admin.AssetPackageBatchOrderHandler BusinessUserGroup *admin.BusinessUserGroupHandler ShopBusinessOwnerImport *admin.ShopBusinessOwnerImportHandler + PhoneAssetAssociation *admin.PhoneAssetAssociationHandler ClientWechat *app.ClientWechatHandler SuperAdmin *admin.SuperAdminHandler SystemConfig *admin.SystemConfigHandler diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index dec8275..c8bd991 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -106,6 +106,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q deps.GatewayClient, deps.Logger, ) iotCardAuditService.SetAccessAudit(auditWriter) + iotCardAuditService.SetPhoneAssetAssociationStore(stores.PhoneAssetAssociation) cardObservationService.SetStateAuditWriter(iotCardAuditService) cardObservationIntegration := integrationlog.NewRepository(deps.DB) cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis) @@ -171,6 +172,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q stores.AssetAllocationRecord, stores.ShopPackageAllocation, stores.ShopSeriesAllocation, stores.PackageSeries, deps.GatewayClient, stores.AssetIdentifier, nil, nil, ) + deviceBatchAllocator.SetPhoneAssetAssociationStore(stores.PhoneAssetAssociation) return &queue.WorkerServices{ PaymentAudit: auditWriter, diff --git a/internal/bootstrap/worker_stores.go b/internal/bootstrap/worker_stores.go index f420a60..3f06f4c 100644 --- a/internal/bootstrap/worker_stores.go +++ b/internal/bootstrap/worker_stores.go @@ -35,9 +35,11 @@ type workerStores struct { AssetIdentifier *postgres.AssetIdentifierStore PersonalCustomer *postgres.PersonalCustomerStore PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore + PhoneAssetAssociation *postgres.PhoneAssetAssociationStore OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore + PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore } func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { @@ -71,9 +73,11 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB), PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis), PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB), + PhoneAssetAssociation: postgres.NewPhoneAssetAssociationStore(deps.DB), OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB), AssetPackageBatchOrderTask: postgres.NewAssetPackageBatchOrderTaskStore(deps.DB), ShopBusinessOwnerImportTask: postgres.NewShopBusinessOwnerImportTaskStore(deps.DB), + PhoneAssetUnbindImportTask: postgres.NewPhoneAssetUnbindImportTaskStore(deps.DB), } return &queue.WorkerStores{ @@ -106,8 +110,10 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores { AssetIdentifier: stores.AssetIdentifier, PersonalCustomer: stores.PersonalCustomer, PersonalCustomerPhone: stores.PersonalCustomerPhone, + PhoneAssetAssociation: stores.PhoneAssetAssociation, OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask, AssetPackageBatchOrderTask: stores.AssetPackageBatchOrderTask, ShopBusinessOwnerImportTask: stores.ShopBusinessOwnerImportTask, + PhoneAssetUnbindImportTask: stores.PhoneAssetUnbindImportTask, } } diff --git a/internal/exporter/association_phones.go b/internal/exporter/association_phones.go new file mode 100644 index 0000000..649fa51 --- /dev/null +++ b/internal/exporter/association_phones.go @@ -0,0 +1,40 @@ +package exporter + +import ( + "context" + "strings" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// loadAssociatedPhonesByAsset 按本批资产集合一次 IN 批量读取当前有效关联手机号。 +// 两类导出都必须走本方法:逐资产查询会形成 N+1,且导出是分片批处理,批量读是唯一可行口径。 +// 同一资产存在多项有效关系时按关系创建顺序以「、」连接为单个单元格;无关联时返回空串。 +func loadAssociatedPhonesByAsset(ctx context.Context, db *gorm.DB, assetType string, assetIDs []uint) (map[uint]string, error) { + cells := make(map[uint]string, len(assetIDs)) + if len(assetIDs) == 0 { + return cells, nil + } + var rows []struct { + AssetID uint `gorm:"column:asset_id"` + Phone string `gorm:"column:phone"` + } + if err := db.WithContext(ctx).Table("tb_phone_asset_association"). + Select("asset_id", "phone"). + Where("asset_type = ? AND status = ? AND asset_id IN ?", + assetType, constants.PhoneAssetAssociationStatusValid, assetIDs). + Order("asset_id ASC, id ASC"). + Scan(&rows).Error; err != nil { + return nil, err + } + grouped := make(map[uint][]string, len(rows)) + for _, row := range rows { + grouped[row.AssetID] = append(grouped[row.AssetID], row.Phone) + } + for assetID, phones := range grouped { + cells[assetID] = strings.Join(phones, "、") + } + return cells, nil +} diff --git a/internal/exporter/device_scene.go b/internal/exporter/device_scene.go index d8a44b3..3b9d97b 100644 --- a/internal/exporter/device_scene.go +++ b/internal/exporter/device_scene.go @@ -14,8 +14,13 @@ import ( const ( deviceExportBaseHeaderCount = 6 deviceExportCardGroupSize = 5 - deviceExportTailHeaderCount = 5 - deviceExportMinCardGroups = 1 + // deviceExportTailHeaderCount 是当前尾部固定列数(含新增的「关联手机号」列)。 + deviceExportTailHeaderCount = 6 + // deviceExportLegacyTailHeaderCount 是新增尾部列之前的固定列数,仅供历史任务表头反解回退使用。 + deviceExportLegacyTailHeaderCount = 5 + deviceExportMinCardGroups = 1 + // deviceExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。 + deviceExportAssociatedPhoneHeader = "关联手机号" ) // DeviceDataSource 设备导出数据源。 @@ -89,9 +94,14 @@ func (s *DeviceDataSource) Fetch(ctx context.Context, params ExportParams, offse return nil, err } + associatedPhones, err := loadAssociatedPhonesByAsset(ctx, s.db, constants.AssetTypeDevice, deviceIDs) + if err != nil { + return nil, err + } + rows := make([][]string, 0, len(devices)) for _, item := range devices { - rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], cardGroups)) + rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], associatedPhones[item.ID], cardGroups)) } return rows, nil } @@ -375,11 +385,12 @@ func buildDeviceExportHeaders(cardGroups int) []string { "套餐的到期时间", "当前套餐", "钱包余额", + deviceExportAssociatedPhoneHeader, ) return headers } -func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, cardGroups int) []string { +func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, associatedPhone string, cardGroups int) []string { if cardGroups < deviceExportMinCardGroups { cardGroups = deviceExportMinCardGroups } @@ -420,15 +431,27 @@ func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg formatOptionalTime(pkg.ExpiresAt), pkg.PackageName, formatMoneyYuan(item.WalletBalance), + associatedPhone, ) return row } +// cardGroupCountFromHeaders 从已持久化的表头反解卡组列数。 +// 先按当前尾列数判定;不整除时回退到新增尾部列之前的尾列数再判定, +// 否则历史任务凭 ResolvedHeaders 重导出时列组数会被算成 0,缺失全部卡列。 func cardGroupCountFromHeaders(headers []string) int { - if len(headers) < deviceExportBaseHeaderCount+deviceExportTailHeaderCount { + if count := cardGroupCountWithTail(headers, deviceExportTailHeaderCount); count > 0 { + return count + } + return cardGroupCountWithTail(headers, deviceExportLegacyTailHeaderCount) +} + +// cardGroupCountWithTail 按指定尾部固定列数反解卡组列数,不整除即无法确定列组。 +func cardGroupCountWithTail(headers []string, tailHeaderCount int) int { + if len(headers) < deviceExportBaseHeaderCount+tailHeaderCount { return 0 } - cardColumnCount := len(headers) - deviceExportBaseHeaderCount - deviceExportTailHeaderCount + cardColumnCount := len(headers) - deviceExportBaseHeaderCount - tailHeaderCount if cardColumnCount <= 0 || cardColumnCount%deviceExportCardGroupSize != 0 { return 0 } diff --git a/internal/exporter/iot_card_scene.go b/internal/exporter/iot_card_scene.go index c33be8e..0da7f45 100644 --- a/internal/exporter/iot_card_scene.go +++ b/internal/exporter/iot_card_scene.go @@ -37,7 +37,8 @@ func (s *IotCardDataSource) Count(ctx context.Context, params ExportParams) (int // Headers 返回 IoT 卡导出表头。 func (s *IotCardDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) { - return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)"}, nil + // 「关联手机号」固定追加在尾部:导出表头在 dispatch 阶段落库,历史任务重导出沿用同一列序。 + return []string{"ICCID", "MSISDN", "绑定设备虚拟号", "运营商", "店铺名称", "绑定设备名称", "是否实名", "实名时间", "网络状态", "套餐名称", "使用流量(MB)", "剩余流量(MB)", iotCardExportAssociatedPhoneHeader}, nil } // Fetch 按 offset/limit 查询 IoT 卡导出数据。 @@ -49,6 +50,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs var items []iotCardExportRow query := s.applyFilters(ctx, s.baseQuery(ctx), params). Select(` + c.id, c.iccid, c.msisdn, c.device_virtual_no, @@ -69,6 +71,15 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs return nil, err } + cardIDs := make([]uint, 0, len(items)) + for _, item := range items { + cardIDs = append(cardIDs, item.ID) + } + associatedPhones, err := loadAssociatedPhonesByAsset(ctx, s.db, constants.AssetTypeIotCard, cardIDs) + if err != nil { + return nil, err + } + rows := make([][]string, 0, len(items)) for _, item := range items { rows = append(rows, []string{ @@ -84,6 +95,7 @@ func (s *IotCardDataSource) Fetch(ctx context.Context, params ExportParams, offs item.PackageName, strconv.FormatInt(item.DataUsageMB, 10), strconv.FormatInt(remainingPackageDataMB(item.DataLimitMB, item.DataUsageMB), 10), + associatedPhones[item.ID], }) } return rows, nil @@ -219,7 +231,11 @@ func (s *IotCardDataSource) applyFilters(ctx context.Context, query *gorm.DB, pa return query } +// iotCardExportAssociatedPhoneHeader 是「关联手机号」列的表头,固定位于导出尾部。 +const iotCardExportAssociatedPhoneHeader = "关联手机号" + type iotCardExportRow struct { + ID uint `gorm:"column:id"` ICCID string `gorm:"column:iccid"` MSISDN string `gorm:"column:msisdn"` DeviceVirtualNo string `gorm:"column:device_virtual_no"` diff --git a/internal/handler/admin/phone_asset_association.go b/internal/handler/admin/phone_asset_association.go new file mode 100644 index 0000000..171baf8 --- /dev/null +++ b/internal/handler/admin/phone_asset_association.go @@ -0,0 +1,157 @@ +package admin + +import ( + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + associationSvc "github.com/break/junhong_cmp_fiber/internal/service/phone_asset_association" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" +) + +// PhoneAssetAssociationHandler 手机号—资产关联后台处理器。 +// 仅提供查看投影与解除入口;关联只能由 H5 短信验证建立,后台不提供创建或补录入口。 +type PhoneAssetAssociationHandler struct { + service *associationSvc.Service + validator *validator.Validate +} + +// NewPhoneAssetAssociationHandler 创建手机号—资产关联处理器。 +func NewPhoneAssetAssociationHandler(service *associationSvc.Service, validator *validator.Validate) *PhoneAssetAssociationHandler { + return &PhoneAssetAssociationHandler{service: service, validator: validator} +} + +// List 查询手机号—资产关联列表。 +// GET /api/admin/phone-asset-associations +func (h *PhoneAssetAssociationHandler) List(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + var request dto.ListPhoneAssetAssociationRequest + if err := c.QueryParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("手机号资产关联查询参数不合法", &request); err != nil { + return err + } + result, err := h.service.List(c.UserContext(), &request) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size) +} + +// Unbind 解除指定的一条手机号—资产关联。 +// DELETE /api/admin/phone-asset-associations/:id +func (h *PhoneAssetAssociationHandler) Unbind(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + id, err := pathID(c) + if err != nil { + return err + } + var request dto.UnbindPhoneAssetAssociationRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + // 路径字段由 Handler 解析后回填,再执行结构校验(ENG-ERR-002)。 + request.ID = id + if err := h.validate("手机号资产解绑参数不合法", &request); err != nil { + return err + } + result, err := h.service.Unbind(c.UserContext(), &request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// BatchUnbind 按资产集合解除全部当前有效关联。 +// POST /api/admin/phone-asset-associations/batch-unbind +func (h *PhoneAssetAssociationHandler) BatchUnbind(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + var request dto.BatchUnbindPhoneAssetAssociationRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("手机号资产批量解绑参数不合法", &request); err != nil { + return err + } + result, err := h.service.BatchUnbind(c.UserContext(), &request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// CreateImportTask 创建手机号资产解绑 CSV 导入任务。 +// POST /api/admin/phone-asset-associations/unbind-imports +func (h *PhoneAssetAssociationHandler) CreateImportTask(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + var request dto.CreatePhoneAssetUnbindImportRequest + if err := c.BodyParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("手机号资产解绑导入参数不合法", &request); err != nil { + return err + } + result, err := h.service.CreateImportTask(c.UserContext(), &request) + if err != nil { + return err + } + return response.Success(c, result) +} + +// ListImportTasks 查询手机号资产解绑导入任务列表。 +// GET /api/admin/phone-asset-associations/unbind-imports +func (h *PhoneAssetAssociationHandler) ListImportTasks(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + var request dto.ListPhoneAssetUnbindImportRequest + if err := c.QueryParser(&request); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validate("手机号资产解绑导入查询参数不合法", &request); err != nil { + return err + } + result, err := h.service.ListImportTasks(c.UserContext(), &request) + if err != nil { + return err + } + return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size) +} + +// ImportTaskDetail 查询手机号资产解绑导入任务详情与逐行结果。 +// GET /api/admin/phone-asset-associations/unbind-imports/:id +func (h *PhoneAssetAssociationHandler) ImportTaskDetail(c *fiber.Ctx) error { + if err := requirePlatformManagement(c); err != nil { + return err + } + id, err := pathID(c) + if err != nil { + return err + } + result, err := h.service.GetImportTask(c.UserContext(), id) + if err != nil { + return err + } + return response.Success(c, result) +} + +// validate 执行结构校验,失败时返回字段级提示(ENG-ERR-002)。 +func (h *PhoneAssetAssociationHandler) validate(prefix string, request any) error { + if h.validator == nil { + return errors.New(errors.CodeInternalError) + } + if err := h.validator.Struct(request); err != nil { + return errors.New(errors.CodeInvalidParam, validationMessage(prefix, request, err)) + } + return nil +} diff --git a/internal/handler/app/client_auth.go b/internal/handler/app/client_auth.go index ad339e2..36118e7 100644 --- a/internal/handler/app/client_auth.go +++ b/internal/handler/app/client_auth.go @@ -118,7 +118,10 @@ func (h *ClientAuthHandler) BindPhone(c *fiber.Ctx) error { return errors.New(errors.CodeInvalidParam) } - resp, err := h.service.BindPhone(c.UserContext(), customerID, &req) + // 建联只依据当前访问资产身份:JWT 缺失资产身份时只完成账号手机号绑定。 + assetType, assetID, _ := middleware.GetCurrentAsset(c) + + resp, err := h.service.BindPhone(c.UserContext(), customerID, assetType, assetID, &req) if err != nil { return err } diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index 637a984..4832e34 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -220,6 +220,12 @@ func NewRegistry() *Registry { shopBusinessOwnerImportTaskCompleted := taskAction(constants.AuditActionShopBusinessOwnerImportTaskCompleted, "完成店铺负责人导入任务", constants.AuditResourceShopBusinessOwnerImportTask, constants.AuditActorSystemTask, constants.AuditSourceWorker) exportTaskCreated := taskAction(constants.AuditActionExportTaskCreated, "创建业务导出任务", constants.AuditResourceExportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI) exportTaskCancelled := taskAction(constants.AuditActionExportTaskCancelled, "取消业务导出任务", constants.AuditResourceExportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI) + phoneAssetAssociationCreated := phoneAssetAssociationAction(constants.AuditActionPhoneAssetAssociationCreated, "验证手机号后建立资产关联", constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI) + phoneAssetAssociationMigrated := phoneAssetAssociationAction(constants.AuditActionPhoneAssetAssociationMigrated, "换绑手机号并迁移资产关联", constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI) + phoneAssetAssociationUnbound := phoneAssetAssociationAction(constants.AuditActionPhoneAssetAssociationUnbound, "解除手机号资产关联", constants.AuditActorAccount, constants.AuditSourceAdminAPI) + phoneAssetAssociationImported := phoneAssetAssociationAction(constants.AuditActionPhoneAssetAssociationImported, "导入解除手机号资产关联", constants.AuditActorSystemTask, constants.AuditSourceWorker) + phoneAssetUnbindImportTaskCreated := taskAction(constants.AuditActionPhoneAssetUnbindImportTaskCreated, "创建手机号资产解绑导入任务", constants.AuditResourcePhoneAssetUnbindImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI) + phoneAssetUnbindImportTaskCompleted := taskAction(constants.AuditActionPhoneAssetUnbindImportTaskCompleted, "完成手机号资产解绑导入任务", constants.AuditResourcePhoneAssetUnbindImportTask, constants.AuditActorSystemTask, constants.AuditSourceWorker) notificationDelivered := notificationAction(constants.AuditActionNotificationDelivered, "生成站内通知", constants.AuditResourceNotification, constants.AuditActorSystemTask, constants.AuditSourceWorker) notificationRead := notificationAction(constants.AuditActionNotificationRead, "标记通知已读", constants.AuditResourceNotification, constants.AuditActorAccount, constants.AuditSourceAdminAPI) notificationRead.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}} @@ -576,6 +582,12 @@ func NewRegistry() *Registry { constants.AuditActionShopBusinessOwnerImportTaskCompleted: shopBusinessOwnerImportTaskCompleted, constants.AuditActionExportTaskCreated: exportTaskCreated, constants.AuditActionExportTaskCancelled: exportTaskCancelled, + constants.AuditActionPhoneAssetAssociationCreated: phoneAssetAssociationCreated, + constants.AuditActionPhoneAssetAssociationMigrated: phoneAssetAssociationMigrated, + constants.AuditActionPhoneAssetAssociationUnbound: phoneAssetAssociationUnbound, + constants.AuditActionPhoneAssetAssociationImported: phoneAssetAssociationImported, + constants.AuditActionPhoneAssetUnbindImportTaskCreated: phoneAssetUnbindImportTaskCreated, + constants.AuditActionPhoneAssetUnbindImportTaskCompleted: phoneAssetUnbindImportTaskCompleted, constants.AuditActionNotificationDelivered: notificationDelivered, constants.AuditActionNotificationRead: notificationRead, constants.AuditActionNotificationReadAll: notificationReadAll, @@ -796,6 +808,14 @@ func NewRegistry() *Registry { Type: constants.AuditResourceExportTask, Name: "业务导出任务", IdentityFields: []string{"id", "task_no", "scene", "format", "creator_user_id", "creator_user_type", "creator_shop_id", "creator_enterprise_id", "scope_shop_ids"}, }, + constants.AuditResourcePhoneAssetAssociation: { + Type: constants.AuditResourcePhoneAssetAssociation, Name: "手机号—资产关联", + IdentityFields: []string{"id", "phone_masked", "asset_type", "asset_id", "status", "source", "invalidated_at", "invalidation_method", "invalidation_reason"}, + }, + constants.AuditResourcePhoneAssetUnbindImportTask: { + Type: constants.AuditResourcePhoneAssetUnbindImportTask, Name: "手机号资产解绑导入任务", + IdentityFields: []string{"id", "task_no", "file_name"}, + }, constants.AuditResourceNotification: { Type: constants.AuditResourceNotification, Name: "站内通知", IdentityFields: []string{"id", "event_id", "recipient_kind", "recipient_id", "category", "type", "severity", "ref_type", "ref_id", "ref_key"}, @@ -1326,6 +1346,7 @@ func deviceBatchAction(code, name, primaryResource string) ActionDefinition { } } +// taskAction 定义 Worker 或后台账号触发的异步任务动作。 func taskAction(code, name, primaryResource, actor, source string) ActionDefinition { return ActionDefinition{ Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal, @@ -1335,6 +1356,17 @@ func taskAction(code, name, primaryResource, actor, source string) ActionDefinit } } +// phoneAssetAssociationAction 定义手机号—资产关联的建立、迁移与解除动作。 +// 关联由 H5 短信验证建立、由后台或导入任务解除,事件只记录脱敏手机号,不写主体投影。 +func phoneAssetAssociationAction(code, name, actor, source string) ActionDefinition { + return ActionDefinition{ + Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal, + PrimaryResource: constants.AuditResourcePhoneAssetAssociation, AllowedActor: actor, Source: source, + RequireTransaction: true, DefaultVisibility: constants.AuditSubjectInternalOnly, + AllowedVisibility: []string{constants.AuditSubjectInternalOnly}, + } +} + // businessUserGroupAction 定义业务用户组维护动作;组只承载业务分类,主体不可见事件细节。 func businessUserGroupAction(code, name, risk, primaryResource string) ActionDefinition { return ActionDefinition{ diff --git a/internal/infrastructure/audit/writer.go b/internal/infrastructure/audit/writer.go index ea3aeee..b48c6c7 100644 --- a/internal/infrastructure/audit/writer.go +++ b/internal/infrastructure/audit/writer.go @@ -189,7 +189,7 @@ func accessChangeResourceKey(change accessauditapp.ChangeAudit) string { } func accessResources(change accessauditapp.ChangeAudit, primaryResource string) ([]ResourceInput, error) { - resources := make([]ResourceInput, 0, 2+len(change.Accounts)+len(change.Cards)+len(change.CardAuthorizations)+len(change.Devices)+len(change.DeviceBindings)+len(change.DeviceAuthorizations)+len(change.PersonalPhones)+len(change.PersonalOpenIDs)+len(change.PersonalDevices)+len(change.PersonalICCIDs)+len(change.Roles)+len(change.Permissions)) + resources := make([]ResourceInput, 0, 2+len(change.Accounts)+len(change.Cards)+len(change.CardAuthorizations)+len(change.Devices)+len(change.DeviceBindings)+len(change.DeviceAuthorizations)+len(change.PersonalPhones)+len(change.PersonalOpenIDs)+len(change.PersonalDevices)+len(change.PersonalICCIDs)+len(change.PhoneAssociations)*2+len(change.Roles)+len(change.Permissions)) switch primaryResource { case constants.AuditResourceAccount: if change.Account == nil || (change.Account.ID == 0 && change.Account.Username == "") { @@ -271,6 +271,43 @@ func accessResources(change accessauditapp.ChangeAudit, primaryResource string) IdentitySnapshot: personalCustomerIdentity(change.PersonalCustomer), BeforeData: change.BeforeData, AfterData: change.AfterData, SubjectVisibility: change.SubjectVisibility, SubjectSummary: change.SubjectSummary, SubjectData: change.SubjectData, }) + case constants.AuditResourcePhoneAssetAssociation: + if len(change.PhoneAssociations) == 0 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "手机号—资产关联审计资源不完整") + } + for index, item := range change.PhoneAssociations { + if item.AssociationID == 0 { + return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "手机号—资产关联审计资源不完整") + } + relation := constants.AuditResourceRelationReference + if index == 0 { + relation = constants.AuditResourceRelationPrimary + } + resources = append(resources, ResourceInput{ + Type: constants.AuditResourcePhoneAssetAssociation, ID: optionalResourceID(item.AssociationID), + Key: strconv.FormatUint(uint64(item.AssociationID), 10), DisplayName: item.PhoneMasked, + Relation: relation, Role: constants.AuditResourceRolePhoneAssetAssociationTarget, + IdentitySnapshot: map[string]any{ + "id": item.AssociationID, "phone_masked": item.PhoneMasked, + "asset_type": item.AssetType, "asset_id": item.AssetID, "status": item.Status, + "source": item.Source, "invalidated_at": item.InvalidatedAt, + "invalidation_method": item.InvalidationMethod, "invalidation_reason": item.InvalidationReason, + }, + BeforeData: item.BeforeData, AfterData: item.AfterData, + SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1, + }) + // 关联指向的资产以参考资源落事件,便于按资产回溯;资产标识缺失时不写资产资源。 + if item.AssetType == "" || item.AssetID == 0 { + continue + } + assetID := strconv.FormatUint(uint64(item.AssetID), 10) + resources = append(resources, ResourceInput{ + Type: item.AssetType, ID: &assetID, Key: assetID, DisplayName: item.AssetDisplayName, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePhoneAssetAssociationAsset, + IdentitySnapshot: map[string]any{"id": item.AssetID}, + SubjectVisibility: constants.AuditSubjectInternalOnly, + }) + } } for index, item := range change.Accounts { if item.Account == nil || (item.Account.ID == 0 && item.Account.Username == "") { @@ -396,7 +433,7 @@ func accessResources(change accessauditapp.ChangeAudit, primaryResource string) } resources = append(resources, ResourceInput{ Type: constants.AuditResourcePersonalCustomerPhone, ID: optionalResourceID(item.Phone.ID), - Key: strconv.FormatUint(uint64(item.Phone.ID), 10), DisplayName: item.Phone.Phone, + Key: strconv.FormatUint(uint64(item.Phone.ID), 10), DisplayName: sanitizer.MaskPhone(item.Phone.Phone), Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerPhone, IdentitySnapshot: personalCustomerPhoneIdentity(item.Phone), BeforeData: item.BeforeData, AfterData: item.AfterData, SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1, @@ -611,9 +648,11 @@ func personalCustomerIdentity(customer *model.PersonalCustomer) map[string]any { } } +// personalCustomerPhoneIdentity 生成个人客户手机号资源身份快照。 +// 手机号一律为脱敏值:审计只出现前 3 位 + **** + 后 4 位,资源行 ID 仍可回溯真实记录。 func personalCustomerPhoneIdentity(phone *model.PersonalCustomerPhone) map[string]any { return map[string]any{ - "id": phone.ID, "customer_id": phone.CustomerID, "phone": phone.Phone, + "id": phone.ID, "customer_id": phone.CustomerID, "phone": sanitizer.MaskPhone(phone.Phone), "is_primary": phone.IsPrimary, "verified_at": phone.VerifiedAt, "status": phone.Status, } } diff --git a/internal/infrastructure/releasegate/checker.go b/internal/infrastructure/releasegate/checker.go index 086f011..e732077 100644 --- a/internal/infrastructure/releasegate/checker.go +++ b/internal/infrastructure/releasegate/checker.go @@ -240,7 +240,7 @@ func (c *Checker) checkAnomalies(ctx context.Context) ([]Finding, error) { }) } } - for _, table := range []string{"tb_export_task", "tb_iot_card_import_task", "tb_device_import_task", "tb_order_package_invalidate_task", "tb_shop_business_owner_import_task"} { + for _, table := range []string{"tb_export_task", "tb_iot_card_import_task", "tb_device_import_task", "tb_order_package_invalidate_task", "tb_shop_business_owner_import_task", "tb_phone_asset_unbind_import_task"} { exists, err := c.tableExists(ctx, table) if err != nil { return nil, err diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index fdcb160..5fe8498 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -37,12 +37,13 @@ type AssetResolveResponse struct { EnableVirtualData bool `json:"enable_virtual_data" description:"当前主套餐是否启用虚流量(按套餐使用记录快照返回)"` DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态:none/stop/start(仅asset_type=device时有效)"` // 绑定关系字段 - ICCID string `json:"iccid,omitempty" description:"卡ICCID(asset_type=card时有效)"` - BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备ID(asset_type=card时有效)"` - BoundDeviceNo string `json:"bound_device_no,omitempty" description:"绑定的设备虚拟号(asset_type=card时有效)"` - BoundDeviceName string `json:"bound_device_name,omitempty" description:"绑定的设备名称(asset_type=card时有效)"` - BoundCardCount int `json:"bound_card_count,omitempty" description:"绑定的卡数量(asset_type=device时有效)"` - Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定的卡列表(asset_type=device时有效)"` + AssociatedPhones []string `json:"associated_phones" description:"当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)"` + ICCID string `json:"iccid,omitempty" description:"卡ICCID(asset_type=card时有效)"` + BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备ID(asset_type=card时有效)"` + BoundDeviceNo string `json:"bound_device_no,omitempty" description:"绑定的设备虚拟号(asset_type=card时有效)"` + BoundDeviceName string `json:"bound_device_name,omitempty" description:"绑定的设备名称(asset_type=card时有效)"` + BoundCardCount int `json:"bound_card_count,omitempty" description:"绑定的卡数量(asset_type=device时有效)"` + Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定的卡列表(asset_type=device时有效)"` // 设备专属字段(card类型时为零值) DeviceName string `json:"device_name,omitempty" description:"设备名称"` IMEI string `json:"imei,omitempty" description:"设备IMEI"` diff --git a/internal/model/dto/device_dto.go b/internal/model/dto/device_dto.go index 0f0ef49..d7a5f45 100644 --- a/internal/model/dto/device_dto.go +++ b/internal/model/dto/device_dto.go @@ -58,11 +58,12 @@ type DeviceResponse struct { LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at" description:"最后 sync-info 同步时间"` // asset_status=3 表示该资产已换货且尚未执行"旧资产转新",不再流通 // generation>1 表示该资产历史上曾执行过换货后转新,目前仍在使用 - AssetStatus int `json:"asset_status" description:"业务状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)"` - AssetStatusName string `json:"asset_status_name" description:"业务状态名称(中文)"` - Generation int `json:"generation" description:"资产世代编号(初始值1,每次换货转新后+1)"` - AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" description:"当前有效授权的企业ID,未授权时为 null"` - AuthorizedEnterpriseName string `json:"authorized_enterprise_name" description:"当前有效授权的企业名称,未授权时为空字符串"` + AssetStatus int `json:"asset_status" description:"业务状态 (1:在库, 2:已销售, 3:已换货, 4:已停用)"` + AssetStatusName string `json:"asset_status_name" description:"业务状态名称(中文)"` + Generation int `json:"generation" description:"资产世代编号(初始值1,每次换货转新后+1)"` + AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" description:"当前有效授权的企业ID,未授权时为 null"` + AuthorizedEnterpriseName string `json:"authorized_enterprise_name" description:"当前有效授权的企业名称,未授权时为空字符串"` + AssociatedPhones []string `json:"associated_phones" description:"当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)"` } type ListDeviceResponse struct { diff --git a/internal/model/dto/iot_card_dto.go b/internal/model/dto/iot_card_dto.go index b5df894..59ec2b8 100644 --- a/internal/model/dto/iot_card_dto.go +++ b/internal/model/dto/iot_card_dto.go @@ -74,6 +74,7 @@ type StandaloneIotCardResponse struct { Generation int `json:"generation" description:"资产世代编号(初始值1,每次换货转新后+1)"` AuthorizedEnterpriseID *uint `json:"authorized_enterprise_id" description:"当前有效授权的企业ID,未授权时为 null"` AuthorizedEnterpriseName string `json:"authorized_enterprise_name" description:"当前有效授权的企业名称,未授权时为空字符串"` + AssociatedPhones []string `json:"associated_phones" description:"当前关联手机号列表(按资产数据范围返回完整手机号,无关联时为空数组)"` CreatedAt time.Time `json:"created_at" description:"创建时间"` UpdatedAt time.Time `json:"updated_at" description:"更新时间"` } diff --git a/internal/model/dto/phone_asset_association_dto.go b/internal/model/dto/phone_asset_association_dto.go new file mode 100644 index 0000000..3776496 --- /dev/null +++ b/internal/model/dto/phone_asset_association_dto.go @@ -0,0 +1,152 @@ +package dto + +import "time" + +// ListPhoneAssetAssociationRequest 后台查询手机号—资产关联列表请求。 +// 仅超级管理员与平台用户可调用,范围外资产不进入结果集,越权与不存在不形成可枚举差异。 +type ListPhoneAssetAssociationRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20,最大 100"` + AssetIdentifier string `json:"asset_identifier" query:"asset_identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识(ICCID、虚拟号、IMEI、SN 或接入号,精确匹配)"` + Phone string `json:"phone" query:"phone" validate:"omitempty,max=20" maxLength:"20" description:"手机号(完整值精确匹配)"` + Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=1" minimum:"0" maximum:"1" description:"关联状态 (0:已失效, 1:有效)"` + CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"关联创建时间起(含)"` + CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"关联创建时间止(含)"` +} + +// PhoneAssetAssociationResponse 手机号—资产关联列表项。 +// 手机号为读取范围内的完整值(读侧不脱敏);审计与日志仍只写脱敏值。 +type PhoneAssetAssociationResponse struct { + ID uint `json:"id" description:"关联关系ID"` + Phone string `json:"phone" description:"关联手机号(完整值)"` + AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"` + AssetID uint `json:"asset_id" description:"资产ID"` + AssetIdentifier string `json:"asset_identifier" description:"资产当前标识(卡为 ICCID,设备为虚拟号)"` + Status int `json:"status" description:"关联状态 (0:已失效, 1:有效)"` + StatusName string `json:"status_name" description:"关联状态中文名称"` + Source string `json:"source" description:"建立来源,固定为 h5_sms_verification"` + EstablishedAt string `json:"established_at" description:"建立时间(短信验证通过时间)"` + InvalidatedAt string `json:"invalidated_at" description:"失效时间;有效关系为空"` + InvalidationMethod string `json:"invalidation_method" description:"失效方式 (backend_single:后台单项, backend_batch:后台批量, csv_import:CSV导入);有效关系为空"` + InvalidationMethodName string `json:"invalidation_method_name" description:"失效方式中文名称;有效关系为空"` + InvalidationReason string `json:"invalidation_reason" description:"失效原因;有效关系为空"` +} + +// PhoneAssetAssociationPageResult 手机号—资产关联列表分页响应。 +type PhoneAssetAssociationPageResult struct { + Items []*PhoneAssetAssociationResponse `json:"items" description:"关联列表"` + Total int64 `json:"total" description:"总记录数"` + Page int `json:"page" description:"当前页码"` + Size int `json:"size" description:"每页数量"` +} + +// UnbindPhoneAssetAssociationRequest 单项解除手机号—资产关联请求。 +// 路径主键即指定一条关系;解除必须二次确认并填写原因,缺一即拒绝。 +type UnbindPhoneAssetAssociationRequest struct { + ID uint `json:"-" path:"id" description:"关联关系ID" required:"true" minimum:"1"` + Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"解除原因(1~500 字符)"` + Confirmed bool `json:"confirmed" validate:"required,eq=true" required:"true" description:"二次确认,必须为 true"` +} + +// UnbindPhoneAssetAssociationResponse 单项解除结果。 +type UnbindPhoneAssetAssociationResponse struct { + ID uint `json:"id" description:"关联关系ID"` + AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"` + AssetID uint `json:"asset_id" description:"资产ID"` + UnboundCount int `json:"unbound_count" description:"本次解除的有效关系数,成功时为 1"` + InvalidatedAt string `json:"invalidated_at" description:"失效时间"` +} + +// BatchUnbindAssetItem 批量解除中的单项资产。 +type BatchUnbindAssetItem struct { + AssetType string `json:"asset_type" validate:"required,oneof=iot_card device" required:"true" description:"资产类型 (iot_card:物联网卡, device:设备)"` + AssetID uint `json:"asset_id" validate:"required,min=1" required:"true" minimum:"1" description:"资产ID"` +} + +// BatchUnbindPhoneAssetAssociationRequest 按资产批量解除手机号关联请求。 +// 每项资产解除其全部当前有效关系,逐资产独立执行,部分成功不回滚成功项。 +type BatchUnbindPhoneAssetAssociationRequest struct { + Assets []BatchUnbindAssetItem `json:"assets" validate:"required,min=1,max=200,dive" required:"true" description:"待解除的资产集合,按 (资产类型, 资产ID) 去重后执行"` + Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"解除原因(1~500 字符)"` + Confirmed bool `json:"confirmed" validate:"required,eq=true" required:"true" description:"二次确认,必须为 true"` +} + +// BatchUnbindAssetResult 批量解除的逐项结果。 +type BatchUnbindAssetResult struct { + AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备)"` + AssetID uint `json:"asset_id" description:"资产ID"` + Success bool `json:"success" description:"该项是否解除成功"` + UnboundCount int `json:"unbound_count" description:"该项解除的有效关系数"` + Reason string `json:"reason,omitempty" description:"该项失败原因,成功时为空"` +} + +// CreatePhoneAssetUnbindImportRequest 创建手机号—资产关联 CSV 解绑导入任务请求。 +type CreatePhoneAssetUnbindImportRequest struct { + FileKey string `json:"file_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"CSV 对象存储 Key,必须以 phone-unbind-imports/ 开头且扩展名为 .csv(通过 POST /api/admin/storage/upload-url 获取)"` + Reason string `json:"reason" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"任务级解绑原因(1~500 字符),写入每次解除的失效原因"` + Confirmed bool `json:"confirmed" validate:"required,eq=true" required:"true" description:"二次确认,必须为 true"` +} + +// ListPhoneAssetUnbindImportRequest 查询解绑导入任务列表请求。 +type ListPhoneAssetUnbindImportRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量,默认 20,最大 100"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` +} + +// GetPhoneAssetUnbindImportRequest 解绑导入任务详情路径参数。 +type GetPhoneAssetUnbindImportRequest struct { + ID uint `path:"id" required:"true" description:"导入任务ID"` +} + +// PhoneAssetUnbindImportTaskResponse 解绑导入任务响应。 +type PhoneAssetUnbindImportTaskResponse struct { + ID uint `json:"id" description:"导入任务ID"` + TaskNo string `json:"task_no" description:"导入任务编号"` + FileName string `json:"file_name" description:"上传的源 CSV 文件名"` + UnbindReason string `json:"unbind_reason" description:"任务级解绑原因"` + Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"` + StatusName string `json:"status_name" description:"任务状态中文名称"` + TotalCount int `json:"total_count" description:"数据行总数;任务级失败时为 0"` + SuccessCount int `json:"success_count" description:"成功行数"` + FailCount int `json:"fail_count" description:"失败行数"` + ErrorMessage string `json:"error_message" description:"任务级失败原因;与行级失败原因分开记录"` + CreatorName string `json:"creator_name" description:"任务创建人名称快照"` + CreatedAt string `json:"created_at" description:"创建时间"` + StartedAt string `json:"started_at" description:"开始处理时间"` + CompletedAt string `json:"completed_at" description:"完成时间"` +} + +// PhoneAssetUnbindImportTaskPageResult 解绑导入任务分页响应。 +type PhoneAssetUnbindImportTaskPageResult struct { + Items []*PhoneAssetUnbindImportTaskResponse `json:"items" description:"导入任务列表"` + Total int64 `json:"total" description:"总记录数"` + Page int `json:"page" description:"当前页码"` + Size int `json:"size" description:"每页数量"` +} + +// PhoneAssetUnbindImportItemResponse 解绑导入逐行结果。 +// AssociatedPhones 是解绑当时的完整手机号快照:关系已失效,只有快照能事后展示被解绑的手机号。 +type PhoneAssetUnbindImportItemResponse struct { + Line int `json:"line" description:"行号,自数据首行起计(表头不计入)"` + AssetType string `json:"asset_type" description:"资产类型 (iot_card:物联网卡, device:设备);未定位到资产时为空"` + AssetIdentifier string `json:"asset_identifier" description:"导入文件中的资产标识原文"` + AssetID uint `json:"asset_id" description:"资产ID;未定位到资产时为 0"` + UnboundCount int `json:"unbound_count" description:"该行解除的有效关系数"` + AssociatedPhones []string `json:"associated_phones" description:"解绑当时的完整关联手机号快照;失败行为空数组"` + Status int `json:"status" description:"行状态 (3:成功, 4:失败)"` + StatusName string `json:"status_name" description:"行状态中文名称"` + Reason string `json:"reason" description:"失败原因;成功行为空"` +} + +// PhoneAssetUnbindImportTaskDetailResponse 解绑导入任务详情响应。 +type PhoneAssetUnbindImportTaskDetailResponse struct { + PhoneAssetUnbindImportTaskResponse + Items []PhoneAssetUnbindImportItemResponse `json:"items" description:"逐行结果明细;任务级失败时为空数组"` +} + +type BatchUnbindPhoneAssetAssociationResponse struct { + SuccessCount int `json:"success_count" description:"解除成功的资产项数"` + FailCount int `json:"fail_count" description:"解除失败的资产项数"` + Items []BatchUnbindAssetResult `json:"items" description:"逐项结果,顺序与请求去重后的资产集合一致"` +} diff --git a/internal/model/dto/storage_dto.go b/internal/model/dto/storage_dto.go index 4467b98..88269d8 100644 --- a/internal/model/dto/storage_dto.go +++ b/internal/model/dto/storage_dto.go @@ -3,7 +3,7 @@ package dto type GetUploadURLRequest struct { FileName string `json:"file_name" validate:"required,min=1,max=255" required:"true" minLength:"1" maxLength:"255" description:"文件名(如:cards.csv)"` ContentType string `json:"content_type" validate:"omitempty,max=100" maxLength:"100" description:"文件 MIME 类型(如:text/csv),留空则自动推断"` - Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation shop_import" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation,shop_import" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配或回收CSV, shop_import:店铺负责人导入CSV)"` + Purpose string `json:"purpose" validate:"required,oneof=iot_import export attachment batch_purchase device_batch_allocation shop_import phone_unbind_import" required:"true" enum:"iot_import,export,attachment,batch_purchase,device_batch_allocation,shop_import,phone_unbind_import" description:"文件用途 (iot_import:ICCID导入, export:数据导出, attachment:附件, batch_purchase:资产套餐批量订购CSV, device_batch_allocation:设备批量分配或回收CSV, shop_import:店铺负责人导入CSV, phone_unbind_import:手机号资产解绑导入CSV)"` } type GetUploadURLResponse struct { diff --git a/internal/model/phone_asset_association.go b/internal/model/phone_asset_association.go new file mode 100644 index 0000000..0afa618 --- /dev/null +++ b/internal/model/phone_asset_association.go @@ -0,0 +1,30 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// PhoneAssetAssociation 手机号—资产当前有效关联。 +// 关联只能由 H5 短信验证建立(Source 固定为 constants.PhoneAssetAssociationSourceH5SMSVerification), +// 后台不提供创建或补录入口。资产身份一律取 (AssetType, AssetID):iot_card 与 device 的 ID 空间独立, +// 同号计为两项资产。失效走 Status 置 0 并同时写失效时间、方式与操作人,关系行保留为业务事实。 +type PhoneAssetAssociation struct { + gorm.Model + Phone string `gorm:"column:phone;type:varchar(20);not null;comment:已验证手机号" json:"phone"` + AssetType string `gorm:"column:asset_type;type:varchar(20);not null;comment:资产类型 iot_card/device" json:"asset_type"` + AssetID uint `gorm:"column:asset_id;type:bigint;not null;comment:资产ID" json:"asset_id"` + Status int `gorm:"column:status;type:smallint;not null;default:1;comment:状态 0-已失效 1-有效" json:"status"` + Source string `gorm:"column:source;type:varchar(30);not null;default:'h5_sms_verification';comment:建立来源" json:"source"` + EstablishedAt time.Time `gorm:"column:established_at;type:timestamptz;not null;default:now();comment:关联建立时间" json:"established_at"` + InvalidatedAt *time.Time `gorm:"column:invalidated_at;type:timestamptz;comment:失效时间" json:"invalidated_at"` + InvalidationMethod string `gorm:"column:invalidation_method;type:varchar(30);not null;default:'';comment:失效方式" json:"invalidation_method"` + InvalidationReason string `gorm:"column:invalidation_reason;type:varchar(500);not null;default:'';comment:失效原因" json:"invalidation_reason"` + Invalidator uint `gorm:"column:invalidator;type:bigint;not null;default:0;comment:失效操作人账号ID" json:"invalidator"` +} + +// TableName 指定手机号—资产关联表名。 +func (PhoneAssetAssociation) TableName() string { + return "tb_phone_asset_association" +} diff --git a/internal/model/phone_asset_unbind_import_task.go b/internal/model/phone_asset_unbind_import_task.go new file mode 100644 index 0000000..dd1ba80 --- /dev/null +++ b/internal/model/phone_asset_unbind_import_task.go @@ -0,0 +1,72 @@ +package model + +import ( + "database/sql/driver" + "time" + + "github.com/bytedance/sonic" + "gorm.io/gorm" +) + +// PhoneAssetUnbindImportTask 手机号—资产关联 CSV 解绑导入任务。 +// 独立成表以复用统一的导入状态机,不与店铺负责人导入任务共享表与启动补偿口径; +// 逐行独立事务,成功行提交、失败行保留原状,任务级失败与行级失败分开记录。 +type PhoneAssetUnbindImportTask struct { + gorm.Model + BaseModel `gorm:"embedded"` + TaskNo string `gorm:"column:task_no;type:varchar(50);not null;uniqueIndex:uq_phone_asset_unbind_import_task_no,where:deleted_at IS NULL;comment:任务编号" json:"task_no"` + FileName string `gorm:"column:file_name;type:varchar(255);not null;default:'';comment:上传的源CSV文件名" json:"file_name"` + StorageKey string `gorm:"column:storage_key;type:varchar(500);not null;comment:源CSV对象存储Key" json:"storage_key"` + UnbindReason string `gorm:"column:unbind_reason;type:varchar(500);not null;default:'';comment:任务级解绑原因" json:"unbind_reason"` + Status int `gorm:"column:status;type:int;not null;default:1;comment:任务状态 1-待处理 2-处理中 3-已完成 4-失败" json:"status"` + TotalCount int `gorm:"column:total_count;not null;default:0;comment:任务数据行总数" json:"total_count"` + SuccessCount int `gorm:"column:success_count;not null;default:0;comment:处理成功行数" json:"success_count"` + FailCount int `gorm:"column:fail_count;not null;default:0;comment:处理失败行数" json:"fail_count"` + ResultItems PhoneAssetUnbindImportResults `gorm:"column:result_items;type:jsonb;not null;default:'[]';comment:逐行结果明细" json:"result_items"` + ErrorMessage string `gorm:"column:error_message;type:text;not null;default:'';comment:任务级失败原因" json:"error_message"` + CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:任务创建人名称快照" json:"creator_name"` + StartedAt *time.Time `gorm:"column:started_at;comment:任务开始处理时间" json:"started_at"` + CompletedAt *time.Time `gorm:"column:completed_at;comment:任务处理完成时间" json:"completed_at"` +} + +// TableName 指定手机号—资产关联解绑导入任务表名。 +func (PhoneAssetUnbindImportTask) TableName() string { + return "tb_phone_asset_unbind_import_task" +} + +// PhoneAssetUnbindImportResultItem 解绑导入单行结果;行号自数据首行起计,表头不计入。 +// AssociatedPhones 是解绑当时的完整手机号快照:关系失效后只有快照能事后展示被解绑的手机号。 +type PhoneAssetUnbindImportResultItem struct { + Line int `json:"line"` + AssetType string `json:"asset_type,omitempty"` + AssetIdentifier string `json:"asset_identifier"` + AssetID uint `json:"asset_id,omitempty"` + UnboundCount int `json:"unbound_count"` + AssociatedPhones []string `json:"associated_phones"` + Status int `json:"status"` + Reason string `json:"reason,omitempty"` +} + +// PhoneAssetUnbindImportResults 解绑导入逐行结果集合。 +type PhoneAssetUnbindImportResults []PhoneAssetUnbindImportResultItem + +// Value 将逐行结果序列化为 JSONB。 +func (items PhoneAssetUnbindImportResults) Value() (driver.Value, error) { + if items == nil { + return "[]", nil + } + return sonic.Marshal(items) +} + +// Scan 从 JSONB 读取逐行结果。 +func (items *PhoneAssetUnbindImportResults) Scan(value any) error { + if value == nil { + *items = PhoneAssetUnbindImportResults{} + return nil + } + data, ok := value.([]byte) + if !ok { + return nil + } + return sonic.Unmarshal(data, items) +} diff --git a/internal/query/audit/timeline.go b/internal/query/audit/timeline.go index f43e441..769e127 100644 --- a/internal/query/audit/timeline.go +++ b/internal/query/audit/timeline.go @@ -295,7 +295,8 @@ func isAsynqTaskResource(resourceType string) bool { case constants.AuditResourceDeviceBatchTask, constants.AuditResourceIotCardImportTask, constants.AuditResourceDeviceImportTask, constants.AuditResourceAssetPackageBatchOrderTask, constants.AuditResourceShopBusinessOwnerImportTask, - constants.AuditResourceOrderPackageInvalidateTask, constants.AuditResourceExportTask: + constants.AuditResourceOrderPackageInvalidateTask, constants.AuditResourceExportTask, + constants.AuditResourcePhoneAssetUnbindImportTask: return true default: return false diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 6630fd5..cc0ce61 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -28,6 +28,10 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.ShopBusinessOwnerImport != nil { registerShopBusinessOwnerImportRoutes(authGroup, handlers.ShopBusinessOwnerImport, doc, basePath) } + // 手机号资产关联的静态路径必须先于 DELETE /:id 注册,避免导入任务路径被动态主键吞掉。 + if handlers.PhoneAssetAssociation != nil { + registerPhoneAssetAssociationRoutes(authGroup, handlers.PhoneAssetAssociation, doc, basePath) + } if handlers.Shop != nil { registerShopRoutes(authGroup, handlers.Shop, doc, basePath) } diff --git a/internal/routes/phone_asset_association.go b/internal/routes/phone_asset_association.go new file mode 100644 index 0000000..b02d6bb --- /dev/null +++ b/internal/routes/phone_asset_association.go @@ -0,0 +1,107 @@ +package routes + +import ( + "github.com/gofiber/fiber/v2" + + "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/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "github.com/break/junhong_cmp_fiber/pkg/openapi" +) + +// phoneAssetUnbindImportDoc 说明手机号资产解绑 CSV 的模板要求。 +// 模板由前端提供,后端只描述固定列序、取值与编码要求,不提供模板下载端点与模板资源。 +const phoneAssetUnbindImportDoc = `仅超级管理员和平台账号可操作,代理与企业账号返回 403。 + +### 完整导入流程 + +1. **获取上传 URL**:调用 ` + "`POST /api/admin/storage/upload-url`" + `,purpose 传 ` + "`phone_unbind_import`" + ` +2. **上传 CSV**:使用返回的预签名 URL 上传文件到对象存储 +3. **调用本接口**:使用返回的 ` + "`file_key`" + ` 创建导入任务(必须以 ` + "`phone-unbind-imports/`" + ` 开头且扩展名为 ` + "`.csv`" + `) + +### 模板列序(首行表头必须完全一致) + +` + "`资产标识`" + `、` + "`备注`" + ` + +- 资产标识:以 ICCID、虚拟号、IMEI、SN 或接入号唯一定位资产;不存在或已删除时该行失败 +- 备注:可选,填写时写入该行审计 + +### 编码要求 + +文件编码为 UTF-8,可带 BOM;非 UTF-8 时按 GBK 尝试解码,仍失败时按任务级失败并给出明确原因。 + +### 执行语义 + +- 逐行独立事务:成功行提交,失败行保留原状,不设行数硬上限 +- 每行解除该资产全部当前有效关联,解绑原因取任务级必填字段 +- 行号自数据首行起计(表头不计入);任务级失败不产生行明细 +- 逐行结果保存解绑当时的完整手机号快照,供任务详情与结果展示` + +// registerPhoneAssetAssociationRoutes 注册手机号—资产关联查看与解除路由。 +// 沿用超管/平台路由组级 gate 先例:代理、企业与个人客户账号一律 403。 +func registerPhoneAssetAssociationRoutes(router fiber.Router, handler *admin.PhoneAssetAssociationHandler, doc *openapi.Generator, basePath string) { + group := router.Group("/phone-asset-associations", func(c *fiber.Ctx) error { + userType := middleware.GetUserTypeFromContext(c.UserContext()) + if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { + return errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) + } + return c.Next() + }) + groupPath := basePath + "/phone-asset-associations" + + Register(group, doc, groupPath, "GET", "", handler.List, RouteSpec{ + Summary: "查询手机号资产关联列表", + Description: constants.PhoneAssetAssociationAccessDescription + "按资产数据范围过滤后分页返回资产(类型/ID/标识)、完整手机号、建立时间、建立来源与状态;支持资产标识、手机号、关联状态与创建时间区间筛选。越权与不存在不形成可枚举差异。", + Tags: []string{"手机号资产关联"}, + Input: new(dto.ListPhoneAssetAssociationRequest), + Output: new(dto.PhoneAssetAssociationPageResult), + Auth: true, + }) + + Register(group, doc, groupPath, "POST", "/batch-unbind", handler.BatchUnbind, RouteSpec{ + Summary: "按资产批量解除手机号关联", + Description: constants.PhoneAssetAssociationAccessDescription + "资产集合按 (资产类型, 资产ID) 去重后逐项独立执行:每项解除该资产全部当前有效关联,返回成功数、失败数与逐项结果,部分成功不回滚成功项。越权、资产不存在与已无有效关系返回同一失败文案。" + constants.PhoneAssetAssociationDeniedMessage + "。", + Tags: []string{"手机号资产关联"}, + Input: new(dto.BatchUnbindPhoneAssetAssociationRequest), + Output: new(dto.BatchUnbindPhoneAssetAssociationResponse), + Auth: true, + }) + + Register(group, doc, groupPath, "POST", "/unbind-imports", handler.CreateImportTask, RouteSpec{ + Summary: "创建手机号资产解绑导入任务", + Description: phoneAssetUnbindImportDoc, + Tags: []string{"手机号资产关联"}, + Input: new(dto.CreatePhoneAssetUnbindImportRequest), + Output: new(dto.PhoneAssetUnbindImportTaskResponse), + Auth: true, + }) + + Register(group, doc, groupPath, "GET", "/unbind-imports", handler.ListImportTasks, RouteSpec{ + Summary: "查询手机号资产解绑导入任务列表", + Description: constants.PhoneAssetAssociationAccessDescription, + Tags: []string{"手机号资产关联"}, + Input: new(dto.ListPhoneAssetUnbindImportRequest), + Output: new(dto.PhoneAssetUnbindImportTaskPageResult), + Auth: true, + }) + + Register(group, doc, groupPath, "GET", "/unbind-imports/:id", handler.ImportTaskDetail, RouteSpec{ + Summary: "查询手机号资产解绑导入任务详情", + Description: constants.PhoneAssetAssociationAccessDescription + "返回成功数、失败数、逐行失败原因与解绑当时的完整手机号快照,并单独返回任务级错误原因。", + Tags: []string{"手机号资产关联"}, + Input: new(dto.GetPhoneAssetUnbindImportRequest), + Output: new(dto.PhoneAssetUnbindImportTaskDetailResponse), + Auth: true, + }) + + Register(group, doc, groupPath, "DELETE", "/:id", handler.Unbind, RouteSpec{ + Summary: "解除单条手机号资产关联", + Description: constants.PhoneAssetAssociationAccessDescription + "路径主键即指定一条资产—手机号关系,必须指定;必须二次确认并填写原因(1~500 字符),缺一即拒绝且不解除任何关系。越权、资产不存在与已无有效关系返回同一失败文案。" + constants.PhoneAssetAssociationDeniedMessage + "。", + Tags: []string{"手机号资产关联"}, + Input: new(dto.UnbindPhoneAssetAssociationRequest), + Output: new(dto.UnbindPhoneAssetAssociationResponse), + Auth: true, + }) +} diff --git a/internal/routes/storage.go b/internal/routes/storage.go index e13581c..4e9f207 100644 --- a/internal/routes/storage.go +++ b/internal/routes/storage.go @@ -137,6 +137,7 @@ await api.post('/iot-cards/import', { | batch_purchase | 资产套餐批量订购 (CSV) | batch-purchases/YYYY/MM/DD/uuid.csv | | device_batch_allocation | 设备批量分配、设置套餐系列或回收 (CSV) | device-batch-allocations/YYYY/MM/DD/uuid.csv | | shop_import | 店铺负责人导入 (CSV) | shop-imports/YYYY/MM/DD/uuid.csv | +| phone_unbind_import | 手机号资产解绑导入 (CSV) | phone-unbind-imports/YYYY/MM/DD/uuid.csv | | export | 数据导出 | exports/YYYY/MM/DD/uuid.xlsx | | attachment | 附件上传 | attachments/YYYY/MM/DD/uuid.ext | diff --git a/internal/service/asset/resolve_identifier.go b/internal/service/asset/resolve_identifier.go new file mode 100644 index 0000000..95d8713 --- /dev/null +++ b/internal/service/asset/resolve_identifier.go @@ -0,0 +1,51 @@ +package asset + +import ( + "context" + + "gorm.io/gorm" + + "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" +) + +// ResolveIdentifier 按资产标识定位资产:(资产类型, 资产ID)。 +// 先查全局标识注册表,再按设备与卡的既有标识回退,与资产详情解析口径一致; +// 未命中返回空类型,由调用方按「不可区分」处理。 +func ResolveIdentifier( + ctx context.Context, + registry *postgres.AssetIdentifierStore, + cardStore *postgres.IotCardStore, + deviceStore *postgres.DeviceStore, + identifier string, +) (string, uint, error) { + if registry != nil { + record, err := registry.FindByIdentifier(ctx, identifier) + if err != nil { + return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产标识失败") + } + if record != nil { + return record.AssetType, record.AssetID, nil + } + } + if deviceStore != nil { + device, err := deviceStore.GetByIdentifier(ctx, identifier) + if err == nil && device != nil { + return constants.AssetTypeDevice, device.ID, nil + } + if err != nil && err != gorm.ErrRecordNotFound { + return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") + } + } + if cardStore != nil { + card, err := cardStore.GetByIdentifier(ctx, identifier) + if err == nil && card != nil { + return constants.AssetTypeIotCard, card.ID, nil + } + if err != nil && err != gorm.ErrRecordNotFound { + return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡失败") + } + } + return "", 0, nil +} diff --git a/internal/service/asset/service.go b/internal/service/asset/service.go index a552332..6f7b90f 100644 --- a/internal/service/asset/service.go +++ b/internal/service/asset/service.go @@ -52,6 +52,7 @@ type Service struct { iotCardService IotCardRefresher gatewayClient *gateway.Client assetIdentifierStore *postgres.AssetIdentifierStore + associationStore *postgres.PhoneAssetAssociationStore auditWriter *infraAudit.Writer packageExpiryQuery PackageExpiryResolver } @@ -61,6 +62,11 @@ func (s *Service) SetPackageExpiryQuery(query *packageexpiry.Query) { s.packageExpiryQuery = query } +// SetPhoneAssetAssociationStore 注入手机号—资产关联 store,供资产详情投影关联手机号。 +func (s *Service) SetPhoneAssetAssociationStore(store *postgres.PhoneAssetAssociationStore) { + s.associationStore = store +} + // New 创建资产服务实例 func New( db *gorm.DB, @@ -282,9 +288,29 @@ func (s *Service) buildDeviceResolveResponse(ctx context.Context, device *model. // 查 Redis 保护期 resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, device.ID) + // 关联手机号:详情为单资产单次查询,按数据范围返回完整手机号。 + phones, err := s.resolveAssociatedPhones(ctx, constants.AssetTypeDevice, device.ID) + if err != nil { + return nil, err + } + resp.AssociatedPhones = phones + return resp, nil } +// resolveAssociatedPhones 读取单项资产当前全部有效关联手机号。 +// 资产本身已按数据范围解析,关联手机号不额外放行或收紧范围。 +func (s *Service) resolveAssociatedPhones(ctx context.Context, assetType string, assetID uint) ([]string, error) { + if s.associationStore == nil { + return nil, errors.New(errors.CodeInternalError, "手机号资产关联查询未初始化") + } + phones, err := s.associationStore.ListValidByAsset(ctx, assetType, assetID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产关联手机号失败") + } + return phones, nil +} + // buildCardResolveResponse 构建卡类型的资产解析响应 func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotCard) (*dto.AssetResolveResponse, error) { resp := &dto.AssetResolveResponse{ @@ -341,6 +367,13 @@ func (s *Service) buildCardResolveResponse(ctx context.Context, card *model.IotC // 查套餐系列名称 s.fillSeriesName(ctx, resp) + // 关联手机号:详情为单资产单次查询,按数据范围返回完整手机号。 + phones, err := s.resolveAssociatedPhones(ctx, constants.AssetTypeIotCard, card.ID) + if err != nil { + return nil, err + } + resp.AssociatedPhones = phones + return resp, nil } diff --git a/internal/service/client_auth/phone_asset_association.go b/internal/service/client_auth/phone_asset_association.go new file mode 100644 index 0000000..0a82040 --- /dev/null +++ b/internal/service/client_auth/phone_asset_association.go @@ -0,0 +1,102 @@ +package client_auth + +import ( + "context" + "sort" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/config" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// lockPhoneRowsInIDOrder 在事务内按手机号行 id ASC 固定顺序加锁。 +// bind-phone 只锁一行、change-phone 锁两行;统一升序后两条路径不会形成 A→B / B→A 死锁环。 +// 调用方必须已先取手机号 advisory lock:行锁无法覆盖「尚无手机号行」的新号。 +func (s *Service) lockPhoneRowsInIDOrder(ctx context.Context, tx *gorm.DB, ids []uint) error { + ordered := make([]uint, 0, len(ids)) + seen := make(map[uint]struct{}, len(ids)) + for _, id := range ids { + if id == 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ordered = append(ordered, id) + } + sort.Slice(ordered, func(i, j int) bool { return ordered[i] < ordered[j] }) + for _, id := range ordered { + var row model.PersonalCustomerPhone + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).First(&row, id).Error; err != nil { + if err == gorm.ErrRecordNotFound { + continue + } + return errors.Wrap(errors.CodeInternalError, err, "锁定手机号行失败") + } + } + return nil +} + +// phoneRowIDsByPhone 查询指定手机号当前启用的手机号行 id。 +func (s *Service) phoneRowIDsByPhone(ctx context.Context, tx *gorm.DB, phones ...string) ([]uint, error) { + var ids []uint + if err := tx.WithContext(ctx).Model(&model.PersonalCustomerPhone{}). + Where("phone IN ? AND status = ?", phones, 1). + Order("id ASC").Pluck("id", &ids).Error; err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "查询手机号行失败") + } + return ids, nil +} + +// requirePhoneBinding 返回全局强制绑定开关取值,默认开启。 +// 开关关闭时本特性必须完全惰性:不查询、不建立、不迁移、不失效任何关联。 +func requirePhoneBinding() bool { + cfg := config.Get() + if cfg == nil { + return true + } + return cfg.Client.RequirePhoneBinding +} + +// lockPhoneScopesInOrder 在事务内、任何行锁之前为手机号取稳定串行化点。 +// 按号码字符串升序取事务级 advisory lock,等价于「手机号」这一逻辑实体的固定加锁次序。 +func (s *Service) lockPhoneScopesInOrder(ctx context.Context, tx *gorm.DB, phones ...string) error { + if err := s.associationStore.WithTx(tx).LockPhoneScopes(ctx, phones...); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "锁定手机号串行化点失败") + } + return nil +} + +// establishAssociation 委托关联写入规则完成幂等建联与十项上限判定。 +// 已建立有效关系时返回 false 且不产生第二条关系;无当前访问资产身份时不建立关联。 +// 开关关闭时完全短路,只保留账号手机号绑定语义。 +func (s *Service) establishAssociation( + ctx context.Context, + tx *gorm.DB, + customer *model.PersonalCustomer, + phone, assetType string, + assetID uint, +) (bool, error) { + if !requirePhoneBinding() { + return false, nil + } + return s.associationWriter.Establish(ctx, tx, customer, phone, assetType, assetID) +} + +// migrateAssociations 委托关联写入规则完成换绑原子迁移与上限/冲突判定。 +// 开关关闭时完全短路,不迁移任何关联。 +func (s *Service) migrateAssociations( + ctx context.Context, + tx *gorm.DB, + oldPhone, newPhone string, +) ([]accessauditapp.PhoneAssetAssociationChange, error) { + if !requirePhoneBinding() { + return nil, nil + } + return s.associationWriter.Migrate(ctx, tx, oldPhone, newPhone) +} diff --git a/internal/service/client_auth/service.go b/internal/service/client_auth/service.go index b4624f0..6279480 100644 --- a/internal/service/client_auth/service.go +++ b/internal/service/client_auth/service.go @@ -13,6 +13,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" customerBinding "github.com/break/junhong_cmp_fiber/internal/service/customer_binding" + associationSvc "github.com/break/junhong_cmp_fiber/internal/service/phone_asset_association" "github.com/break/junhong_cmp_fiber/internal/service/verification" wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config" "github.com/break/junhong_cmp_fiber/internal/store/postgres" @@ -20,6 +21,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/sanitizer" "github.com/break/junhong_cmp_fiber/pkg/wechat" "github.com/golang-jwt/jwt/v5" "github.com/redis/go-redis/v9" @@ -46,6 +48,7 @@ type Service struct { openidStore *postgres.PersonalCustomerOpenIDStore customerStore *postgres.PersonalCustomerStore phoneStore *postgres.PersonalCustomerPhoneStore + associationStore *postgres.PhoneAssetAssociationStore iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore wechatConfigService *wechatConfigSvc.Service @@ -56,6 +59,7 @@ type Service struct { wechatCache kernel.CacheInterface customerBinding *customerBinding.Service accessAudit accessauditapp.Writer + associationWriter *associationSvc.AssociationWriter } // New 创建 C 端认证服务实例 @@ -64,6 +68,7 @@ func New( openidStore *postgres.PersonalCustomerOpenIDStore, customerStore *postgres.PersonalCustomerStore, phoneStore *postgres.PersonalCustomerPhoneStore, + associationStore *postgres.PhoneAssetAssociationStore, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore, wechatConfigService *wechatConfigSvc.Service, @@ -79,6 +84,7 @@ func New( openidStore: openidStore, customerStore: customerStore, phoneStore: phoneStore, + associationStore: associationStore, iotCardStore: iotCardStore, deviceStore: deviceStore, wechatConfigService: wechatConfigService, @@ -89,6 +95,7 @@ func New( wechatCache: wechat.NewRedisCache(redisClient), customerBinding: binding, accessAudit: accessAudit, + associationWriter: associationSvc.NewAssociationWriter(associationStore, accessAudit), } } @@ -291,26 +298,28 @@ func (s *Service) SendCode(ctx context.Context, req *dto.ClientSendCodeRequest, } // BindPhone A5 绑定手机号 -func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindPhoneRequest) (*dto.BindPhoneResponse, error) { +// POST /api/c/v1/auth/bind-phone +// 无主手机号时建立账号手机号;已有主手机号且提交号码与主号一致、验证码有效时, +// 幂等建立当前访问资产与该手机号的关联且不修改账号手机号;提交号码与主号不一致仍拒绝。 +// 请求不含当前访问资产身份时只完成账号手机号绑定,不建立关联。 +func (s *Service) BindPhone(ctx context.Context, customerID uint, assetType string, assetID uint, req *dto.BindPhoneRequest) (*dto.BindPhoneResponse, error) { if req == nil { return nil, errors.New(errors.CodeInvalidParam) } - if s.db == nil || s.accessAudit == nil { + if s.db == nil || s.accessAudit == nil || s.associationStore == nil { return nil, errors.New(errors.CodeInvalidStatus, "个人客户审计接缝未配置") } - if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == nil { - appErr := errors.New(errors.CodeAlreadyBoundPhone) - if customer, loadErr := s.customerStore.GetByID(ctx, customerID); loadErr == nil { - s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr) - } - return nil, appErr - } else if err != gorm.ErrRecordNotFound { - return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败") - } customer, err := s.customerStore.GetByID(ctx, customerID) if err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败") } + primary, primaryErr := s.phoneStore.GetPrimaryPhone(ctx, customerID) + if primaryErr == nil { + return s.bindExistingPrimaryPhone(ctx, customer, primary, assetType, assetID, req) + } + if primaryErr != gorm.ErrRecordNotFound { + return nil, errors.Wrap(errors.CodeInternalError, primaryErr, "查询主手机号失败") + } if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil { appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err) s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, nil, appErr) @@ -338,6 +347,10 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP Status: 1, } err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 固定加锁次序:先取手机号串行化点(advisory),再按 id ASC 锁行。 + if err := s.lockPhoneScopesInOrder(ctx, tx, req.Phone); err != nil { + return err + } if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(customer, customerID).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败") } @@ -361,10 +374,17 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP if err := tx.Create(record).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败") } - return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit( + if err := s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit( constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", customer, record, nil, - map[string]any{"phone": record.Phone}, "手机号已绑定", constants.AuditResultSuccess, - )) + map[string]any{"phone": sanitizer.MaskPhone(record.Phone)}, "手机号已绑定", constants.AuditResultSuccess, + )); err != nil { + return err + } + // 建联与账号手机号同事务:超限或写入失败时账号手机号一并回滚。 + if _, err := s.establishAssociation(ctx, tx, customer, record.Phone, assetType, assetID); err != nil { + return err + } + return nil }) if err != nil { s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, nil, err) @@ -377,6 +397,63 @@ func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindP }, nil } +// bindExistingPrimaryPhone 处理已有主手机号的绑定请求。 +// 提交号码等于主号且验证码有效时幂等建立关联且不改账号手机号;号码不一致仍拒绝换号。 +func (s *Service) bindExistingPrimaryPhone( + ctx context.Context, + customer *model.PersonalCustomer, + primary *model.PersonalCustomerPhone, + assetType string, + assetID uint, + req *dto.BindPhoneRequest, +) (*dto.BindPhoneResponse, error) { + if primary.Phone != req.Phone { + appErr := errors.New(errors.CodeAlreadyBoundPhone) + s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, primary, appErr) + return nil, appErr + } + if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil { + appErr := errors.Wrap(errors.CodeVerificationCodeInvalid, err) + s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号被拒绝", customer, primary, appErr) + return nil, appErr + } + + now := time.Now() + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 固定加锁次序:先取手机号串行化点(advisory),再按 id ASC 锁行。 + if err := s.lockPhoneScopesInOrder(ctx, tx, primary.Phone); err != nil { + return err + } + if err := s.lockPhoneRowsInIDOrder(ctx, tx, []uint{primary.ID}); err != nil { + return err + } + var locked model.PersonalCustomerPhone + if err := tx.WithContext(ctx). + Where("id = ? AND customer_id = ? AND is_primary = ? AND status = ?", primary.ID, customer.ID, true, 1). + First(&locked).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeAlreadyBoundPhone) + } + return errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败") + } + if locked.Phone != req.Phone { + // 并发换绑已把主号改成其他号码:提交号码不再等于账号手机号,按既有语义拒绝。 + return errors.New(errors.CodeAlreadyBoundPhone) + } + _, err := s.establishAssociation(ctx, tx, customer, locked.Phone, assetType, assetID) + return err + }) + if err != nil { + s.recordPersonalFailure(ctx, constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号失败", customer, primary, err) + return nil, err + } + + return &dto.BindPhoneResponse{ + Phone: req.Phone, + BoundAt: now.Format("2006-01-02 15:04:05"), + }, nil +} + // ChangePhone A6 换绑手机号 func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.ChangePhoneRequest) (*dto.ChangePhoneResponse, error) { if req == nil { @@ -421,8 +498,22 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha now := time.Now() var beforeData map[string]any var failurePhone *model.PersonalCustomerPhone + var migrationChanges []accessauditapp.PhoneAssetAssociationChange err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + // 统一加锁顺序:先按手机号行 id ASC 锁定旧、新手机号行,再由关联迁移按 id ASC 锁定两侧有效关系行。 + // 换绑两行的加锁顺序与 bind-phone 的单行加锁一致,两条路径不会形成 A→B / B→A 死锁环。 + if idErr := s.lockPhoneScopesInOrder(ctx, tx, primary.Phone, req.NewPhone); idErr != nil { + return idErr + } + phoneIDs, idErr := s.phoneRowIDsByPhone(ctx, tx, primary.Phone, req.NewPhone) + if idErr != nil { + return idErr + } + if idErr := s.lockPhoneRowsInIDOrder(ctx, tx, phoneIDs); idErr != nil { + return idErr + } + // 手机号行已在本事务内锁定,此处只复核主号归属与号码,不重复加锁。 + if err := tx.WithContext(ctx). Where("id = ? AND customer_id = ? AND is_primary = ? AND status = ?", primary.ID, customerID, true, 1). First(primary).Error; err != nil { if err == gorm.ErrRecordNotFound { @@ -435,13 +526,19 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha } current := *primary failurePhone = ¤t - beforeData = map[string]any{"phone": primary.Phone} + beforeData = map[string]any{"phone": sanitizer.MaskPhone(primary.Phone)} var existed model.PersonalCustomerPhone if err := tx.Where("phone = ? AND status = ?", req.NewPhone, 1).First(&existed).Error; err == nil && existed.CustomerID != customerID { return errors.New(errors.CodePhoneAlreadyBound) } else if err != nil && err != gorm.ErrRecordNotFound { return errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败") } + // 关联迁移先于账号手机号改写:超限或与既有关系冲突时整次失败,旧、新关系均保持原状。 + changes, migrateErr := s.migrateAssociations(ctx, tx, primary.Phone, req.NewPhone) + if migrateErr != nil { + return migrateErr + } + migrationChanges = changes if err := tx.Model(primary).Updates(map[string]any{ "phone": req.NewPhone, "verified_at": now, @@ -451,10 +548,22 @@ func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.Cha } primary.Phone = req.NewPhone primary.VerifiedAt = &now - return s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit( + if err := s.accessAudit.WriteAccessChange(ctx, tx, personalPhoneAudit( constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", customer, primary, beforeData, - map[string]any{"phone": primary.Phone}, "手机号已更换", constants.AuditResultSuccess, - )) + map[string]any{"phone": sanitizer.MaskPhone(primary.Phone)}, "手机号已更换", constants.AuditResultSuccess, + )); err != nil { + return err + } + if len(migrationChanges) == 0 { + return nil + } + return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{ + ActionCode: constants.AuditActionPhoneAssetAssociationMigrated, + Summary: "换绑手机号并迁移资产关联", + OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname, + Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer, + PhoneAssociations: migrationChanges, + }) }) if err != nil { if failurePhone == nil { @@ -889,6 +998,11 @@ func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, a return s.customerBinding.Bind(ctx, tx, customerID, assetType, assetID) } +// issueLoginToken 签发登录令牌并判定是否需要手机号验证。 +// 三支判定:无主手机号 → true;有主手机号但未与当前访问资产存在有效关系 → true; +// 已存在有效关系 → false。全局开关关闭时恒为 false,且不查询、不创建、不删除任何关系。 +// 关联查询使用当前访问资产的 asset_type/asset_id,不使用 phone claim:phone 是登录时快照, +// 换绑后到下次登录前仍是旧号。该字段只作前端提示,不改变任何资源授权。 func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetType string, assetID uint) (string, bool, error) { // 查询用户已绑定的主手机号,写入 JWT,供后续接口直接从 context 取用 var boundPhone string @@ -903,8 +1017,19 @@ func (s *Service) issueLoginToken(ctx context.Context, customerID uint, assetTyp boundPhone = primaryPhone.Phone } else if phoneErr != gorm.ErrRecordNotFound { return "", false, errors.Wrap(errors.CodeInternalError, phoneErr, "查询手机号绑定关系失败") - } else if requirePhoneBinding { - needBindPhone = true + } + + // 开关关闭必须完全短路:既不查询也不写任何关系,否则会出现「关闭开关却写库」的越权写入。 + if requirePhoneBinding { + if boundPhone == "" { + needBindPhone = true + } else { + associated, err := s.associationStore.ExistsValid(ctx, boundPhone, assetType, assetID) + if err != nil { + return "", false, errors.Wrap(errors.CodeInternalError, err, "查询手机号资产关联失败") + } + needBindPhone = !associated + } } token, err := s.jwtManager.GeneratePersonalCustomerToken(customerID, boundPhone, assetType, assetID) diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 86966d5..c05b8de 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -42,6 +42,7 @@ type Service struct { assetIdentifierStore *postgres.AssetIdentifierStore enterpriseDeviceAuthStore *postgres.EnterpriseDeviceAuthorizationStore enterpriseStore *postgres.EnterpriseStore + associationStore *postgres.PhoneAssetAssociationStore packageExpiryQuery *packageexpiry.Query observationSeriesEvents cardObservationApp.SeriesEventWriter observationSeries cardObservationApp.BestEffortSeriesDispatcher @@ -59,6 +60,11 @@ func (s *Service) SetObservationSeriesDispatcher(dispatcher cardObservationApp.B s.observationSeries = dispatcher } +// SetPhoneAssetAssociationStore 注入手机号—资产关联 store(用于列表响应回填关联手机号) +func (s *Service) SetPhoneAssetAssociationStore(store *postgres.PhoneAssetAssociationStore) { + s.associationStore = store +} + type deviceControlObservationSnapshot struct { SourceCardID uint TargetCardID uint @@ -260,6 +266,14 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li if err != nil { return nil, err } + // 关联手机号按当页资产集合一次 IN 批量聚合后装配,禁止逐资产查询形成 N+1。 + if s.associationStore == nil { + return nil, errors.New(errors.CodeInternalError, "手机号资产关联查询未初始化") + } + associatedPhones, err := s.associationStore.ListValidByAssets(ctx, constants.AssetTypeDevice, deviceIDs) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "查询设备关联手机号失败") + } shopMap := s.loadShopData(ctx, devices) seriesMap := s.loadSeriesNames(ctx, devices) @@ -294,6 +308,7 @@ func (s *Service) List(ctx context.Context, req *dto.ListDeviceRequest) (*dto.Li for _, device := range devices { item := s.toDeviceResponse(device, shopMap, seriesMap, bindingCounts, activationStatuses) item.PackageExpiryEstimate = expiryEstimates[device.ID] + item.AssociatedPhones = associatedPhones[device.ID] if eid, ok := deviceEnterpriseMap[device.ID]; ok { item.AuthorizedEnterpriseID = &eid item.AuthorizedEnterpriseName = enterpriseNameMap[eid] diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index e3c1880..9e87656 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -69,6 +69,7 @@ type Service struct { assetIdentifierStore *postgres.AssetIdentifierStore enterpriseCardAuthStore *postgres.EnterpriseCardAuthorizationStore enterpriseStore *postgres.EnterpriseStore + associationStore *postgres.PhoneAssetAssociationStore packageExpiryQuery *packageexpiry.Query cardObservation *cardapp.Service observationSeries cardapp.BestEffortSeriesDispatcher @@ -137,6 +138,11 @@ func (s *Service) SetEnterpriseCardAuthStore(store *postgres.EnterpriseCardAutho s.enterpriseCardAuthStore = store } +// SetPhoneAssetAssociationStore 注入手机号—资产关联 store(用于列表响应回填关联手机号) +func (s *Service) SetPhoneAssetAssociationStore(store *postgres.PhoneAssetAssociationStore) { + s.associationStore = store +} + // SetEnterpriseStore 注入企业 store(用于批量加载企业名称) func (s *Service) SetEnterpriseStore(store *postgres.EnterpriseStore) { s.enterpriseStore = store @@ -299,6 +305,14 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot if err != nil { return nil, err } + // 关联手机号按当页资产集合一次 IN 批量聚合后装配,禁止逐资产查询形成 N+1。 + if s.associationStore == nil { + return nil, errors.New(errors.CodeInternalError, "手机号资产关联查询未初始化") + } + associatedPhones, err := s.associationStore.ListValidByAssets(ctx, constants.AssetTypeIotCard, cardIDs) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "查询卡关联手机号失败") + } shopMap := s.loadShopNames(ctx, cards) //TODO 这里不对,现在已经快照了,这里如果还这样处理明显是浪费的 @@ -340,6 +354,7 @@ func (s *Service) ListStandalone(ctx context.Context, req *dto.ListStandaloneIot for _, card := range cards { item := s.toStandaloneResponse(card, shopMap, seriesMap) item.PackageExpiryEstimate = expiryEstimates[card.ID] + item.AssociatedPhones = associatedPhones[card.ID] if eid, ok := cardAuthMap[card.ID]; ok { item.AuthorizedEnterpriseID = &eid item.AuthorizedEnterpriseName = enterpriseNameMap[eid] diff --git a/internal/service/phone_asset_association/association_write.go b/internal/service/phone_asset_association/association_write.go new file mode 100644 index 0000000..3741eb4 --- /dev/null +++ b/internal/service/phone_asset_association/association_write.go @@ -0,0 +1,165 @@ +package phone_asset_association + +import ( + "context" + stderrors "errors" + "time" + + "github.com/jackc/pgx/v5/pgconn" + "gorm.io/gorm" + + accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit" + "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/sanitizer" +) + +// associationUniqueConstraint 是「有效关系」部分唯一索引名。 +// 换绑迁移撞上它说明新号已存在同资产有效关系,必须整次失败而不是产生并存关系。 +const associationUniqueConstraint = "uq_phone_asset_association_valid" + +// AssociationWriter 承载手机号—资产关联的写入规则:幂等建联、换绑迁移与十项上限。 +// 关联只能由 H5 短信验证建立,因此这里不提供任何后台创建入口。 +type AssociationWriter struct { + store *postgres.PhoneAssetAssociationStore + audit accessauditapp.Writer +} + +// NewAssociationWriter 创建关联写入规则实例。 +func NewAssociationWriter(store *postgres.PhoneAssetAssociationStore, auditWriter accessauditapp.Writer) *AssociationWriter { + return &AssociationWriter{store: store, audit: auditWriter} +} + +// Establish 建立手机号与当前访问资产的有效关联;同一关系已存在时保持幂等。 +// 串行化点:先取手机号事务级 advisory lock,再锁定该号码现有有效关系行, +// 使「计数」与「插入」同处临界区;新号没有行可锁时仍由 advisory lock 保证串行。 +// 已达十项的手机号对已关联资产重复验证必须返回成功且不产生第二条关系, +// 因此先判成员关系,仅当请求的是新资产时才做上限判定。 +// 返回 true 表示本次确实新建了关系。 +func (w *AssociationWriter) Establish( + ctx context.Context, + tx *gorm.DB, + customer *model.PersonalCustomer, + phone, assetType string, + assetID uint, +) (bool, error) { + // 请求不含当前访问资产身份:只完成账号手机号绑定,不建立关联。 + if assetType == "" || assetID == 0 { + return false, nil + } + store := w.store.WithTx(tx) + if err := store.LockPhoneScopes(ctx, phone); err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号串行化点失败") + } + locked, err := store.LockValidByPhones(ctx, phone) + if err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号关联资产失败") + } + for _, row := range locked { + if row.AssetType == assetType && row.AssetID == assetID { + // 重复验证同一资产:幂等成功,不报上限也不插入第二条关系。 + return false, nil + } + } + if len(locked) >= constants.PhoneAssetAssociationMaxValidPerPhone { + return false, errors.New(errors.CodeInvalidStatus, constants.PhoneAssetAssociationLimitMessage) + } + association := &model.PhoneAssetAssociation{ + Phone: phone, AssetType: assetType, AssetID: assetID, + Status: constants.PhoneAssetAssociationStatusValid, + Source: constants.PhoneAssetAssociationSourceH5SMSVerification, + EstablishedAt: time.Now(), + } + created, err := store.CreateIfAbsent(ctx, tx, association) + if err != nil { + return false, errors.Wrap(errors.CodeDatabaseError, err, "建立手机号资产关联失败") + } + if !created { + // 并发同资产建联由部分唯一索引兜底,冲突一律映射为幂等成功。 + return false, nil + } + // 审计只写脱敏手机号,不复用会写明文手机号的既有资源路径。 + phoneMasked := sanitizer.MaskPhone(phone) + if err := w.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{ + ActionCode: constants.AuditActionPhoneAssetAssociationCreated, + Summary: "验证手机号后建立资产关联", + OperatorID: customer.ID, ActorKind: constants.AuditActorPersonalCustomer, ActorName: customer.Nickname, + Source: constants.AuditSourcePersonalAPI, ScopeType: constants.AuditScopePersonalCustomer, + PhoneAssociations: []accessauditapp.PhoneAssetAssociationChange{{ + AssociationID: association.ID, PhoneMasked: phoneMasked, + AssetType: assetType, AssetID: assetID, + Status: constants.PhoneAssetAssociationStatusValid, + Source: constants.PhoneAssetAssociationSourceH5SMSVerification, + AfterData: map[string]any{ + "phone_masked": phoneMasked, "asset_type": assetType, "asset_id": assetID, + "status": constants.PhoneAssetAssociationStatusValid, + }, + }}, + }); err != nil { + return false, err + } + return true, nil +} + +// Migrate 在同一事务内把旧号全部有效关联原子迁移到新号。 +// 串行化点:先按号码字符串升序取旧、新手机号 advisory lock,再按 id ASC 锁定两侧有效关系行。 +// 迁移前比较「新号现有有效关系数 + 旧号待迁移有效关系数」:超过十项整次失败,旧、新关系均不变。 +// 与「新号已存在的同资产有效关系」冲突时整次失败回滚,不产生并存的有效关系。 +// 返回每次迁移的审计事实;无有效关系时返回空。 +func (w *AssociationWriter) Migrate(ctx context.Context, tx *gorm.DB, oldPhone, newPhone string) ([]accessauditapp.PhoneAssetAssociationChange, error) { + store := w.store.WithTx(tx) + if err := store.LockPhoneScopes(ctx, oldPhone, newPhone); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号串行化点失败") + } + locked, err := store.LockValidByPhones(ctx, oldPhone, newPhone) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号关联资产失败") + } + pending := make([]*model.PhoneAssetAssociation, 0, len(locked)) + newPhoneValidCount := 0 + for _, row := range locked { + if row.Phone == oldPhone { + pending = append(pending, row) + continue + } + newPhoneValidCount++ + } + if newPhoneValidCount+len(pending) > constants.PhoneAssetAssociationMaxValidPerPhone { + return nil, errors.New(errors.CodeInvalidStatus, constants.PhoneAssetAssociationLimitMessage) + } + if len(pending) == 0 { + return nil, nil + } + now := time.Now() + if err := tx.WithContext(ctx).Model(&model.PhoneAssetAssociation{}). + Where("phone = ? AND status = ?", oldPhone, constants.PhoneAssetAssociationStatusValid). + Updates(map[string]any{"phone": newPhone, "updated_at": now}).Error; err != nil { + if isAssociationUniqueViolation(err) { + return nil, errors.New(errors.CodeInvalidStatus, "新手机号已存在与待迁移资产相同的有效关联,换绑已回滚") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移手机号资产关联失败") + } + oldPhoneMasked, newPhoneMasked := sanitizer.MaskPhone(oldPhone), sanitizer.MaskPhone(newPhone) + changes := make([]accessauditapp.PhoneAssetAssociationChange, 0, len(pending)) + for _, row := range pending { + changes = append(changes, accessauditapp.PhoneAssetAssociationChange{ + AssociationID: row.ID, PhoneMasked: newPhoneMasked, + AssetType: row.AssetType, AssetID: row.AssetID, + Status: constants.PhoneAssetAssociationStatusValid, Source: row.Source, + BeforeData: map[string]any{"phone_masked": oldPhoneMasked}, + AfterData: map[string]any{"phone_masked": newPhoneMasked}, + }) + } + return changes, nil +} + +// isAssociationUniqueViolation 判断错误是否为有效关系部分唯一索引冲突。 +func isAssociationUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + if !stderrors.As(err, &pgErr) { + return false + } + return pgErr.Code == "23505" && pgErr.ConstraintName == associationUniqueConstraint +} diff --git a/internal/service/phone_asset_association/import.go b/internal/service/phone_asset_association/import.go new file mode 100644 index 0000000..6c84bf3 --- /dev/null +++ b/internal/service/phone_asset_association/import.go @@ -0,0 +1,235 @@ +package phone_asset_association + +import ( + "context" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/hibiken/asynq" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/auditfailure" + "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/queue" +) + +// TaskPayload 手机号—资产关联解绑导入 Worker 结构化载荷,与 Worker 侧载荷保持同一 JSON 契约。 +type TaskPayload struct { + TaskID uint `json:"task_id"` +} + +// New 创建手机号—资产关联后台服务。 +// associationStore 用于解除关联,taskStore 与 queueClient 用于受理 CSV 解绑导入任务。 +func New( + db *gorm.DB, + associationStore *postgres.PhoneAssetAssociationStore, + taskStore *postgres.PhoneAssetUnbindImportTaskStore, + assetIdentifierStore *postgres.AssetIdentifierStore, + iotCardStore *postgres.IotCardStore, + deviceStore *postgres.DeviceStore, + queueClient *queue.Client, + auditWriter *audit.Writer, +) *Service { + return &Service{ + db: db, associationStore: associationStore, taskStore: taskStore, + assetIdentifierStore: assetIdentifierStore, iotCardStore: iotCardStore, + deviceStore: deviceStore, queueClient: queueClient, auditWriter: auditWriter, + } +} + +// CreateImportTask 创建 CSV 解绑导入任务并在同一事务写入创建审计,随后投递到独立导入队列。 +// 解绑原因与二次确认在 DTO 层强制;任务级原因随任务行落库,供 Worker 读取后写入每次解除的失效原因。 +func (s *Service) CreateImportTask(ctx context.Context, request *dto.CreatePhoneAssetUnbindImportRequest) (*dto.PhoneAssetUnbindImportTaskResponse, error) { + if request == nil { + return nil, errors.New(errors.CodeInvalidParam) + } + if !strings.HasPrefix(request.FileKey, constants.PhoneAssetUnbindImportStoragePrefix+"/") { + return nil, errors.New(errors.CodeInvalidParam, "导入文件Key不属于指定上传目录") + } + if !strings.EqualFold(filepath.Ext(request.FileKey), ".csv") { + return nil, errors.New(errors.CodeInvalidParam, "解绑导入文件必须为CSV格式") + } + userID := middleware.GetUserIDFromContext(ctx) + if userID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + if s.auditWriter == nil { + return nil, errors.New(errors.CodeInvalidStatus, "手机号资产解绑导入统一审计接缝未配置") + } + taskRecord := &model.PhoneAssetUnbindImportTask{ + TaskNo: s.taskStore.GenerateTaskNo(), FileName: filepath.Base(request.FileKey), + StorageKey: request.FileKey, UnbindReason: request.Reason, Status: model.ImportTaskStatusPending, + ResultItems: model.PhoneAssetUnbindImportResults{}, + CreatorName: middleware.GetUsernameFromContext(ctx), + BaseModel: model.BaseModel{Creator: userID, Updater: userID}, + } + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.taskStore.WithTx(tx).Create(ctx, taskRecord); err != nil { + return err + } + return s.writeTaskAudit(ctx, tx, taskRecord, constants.AuditResultSuccess, nil, nil, "") + }); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建手机号资产解绑导入任务失败") + } + + var enqueueErr error + if s.queueClient == nil { + enqueueErr = errors.New(errors.CodeTaskQueueError, "手机号资产解绑导入任务队列未配置") + } else { + enqueueErr = s.queueClient.EnqueueTask(ctx, constants.TaskTypePhoneAssetUnbindImport, + TaskPayload{TaskID: taskRecord.ID}, + asynq.Queue(constants.QueueForTaskType(constants.TaskTypePhoneAssetUnbindImport)), + asynq.Timeout(constants.PhoneAssetUnbindImportTaskTimeout)) + } + if enqueueErr != nil { + message := "解绑导入任务入队失败" + secondaryErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + before := unbindImportTaskState(taskRecord) + hit, err := s.taskStore.WithTx(tx).MarkFailed(ctx, taskRecord.ID, message) + if err != nil { + return err + } + if !hit { + // 任务已到终态:Enqueue 报错但消息实际已投递且 Worker 已跑完。 + // 库内才是事实,绝不回写失败态,也不写失败审计。 + return nil + } + // 失败原因必须回填到内存快照,响应与失败审计才与库内一致。 + taskRecord.Status, taskRecord.ErrorMessage = model.ImportTaskStatusFailed, message + now := time.Now() + taskRecord.CompletedAt = &now + return s.writeTaskAudit(ctx, tx, taskRecord, constants.AuditResultFailed, before, unbindImportTaskState(taskRecord), strconv.Itoa(errors.CodeTaskQueueError)) + }) + if secondaryErr != nil { + auditfailure.RecordSecondaryWriteFailure(constants.AuditActionPhoneAssetUnbindImportTaskCreated, + taskRecord.TaskNo, "", taskRecord.TaskNo, strconv.Itoa(errors.CodeTaskQueueError), secondaryErr) + } else if taskRecord.Status != model.ImportTaskStatusFailed { + // 未命中非终态时重新读取任务行,让响应反映库内真实终态。 + if stored, err := s.taskStore.GetByID(ctx, taskRecord.ID); err == nil { + taskRecord = stored + } + } + } + return toUnbindImportTaskResponse(taskRecord), nil +} + +// ListImportTasks 分页查询解绑导入任务。 +func (s *Service) ListImportTasks(ctx context.Context, request *dto.ListPhoneAssetUnbindImportRequest) (*dto.PhoneAssetUnbindImportTaskPageResult, error) { + if request == nil { + return nil, errors.New(errors.CodeInvalidParam) + } + page, pageSize := request.Page, request.PageSize + if page <= 0 { + page = constants.DefaultPage + } + if pageSize <= 0 { + pageSize = constants.DefaultPageSize + } + if pageSize > constants.MaxPageSize { + pageSize = constants.MaxPageSize + } + tasks, total, err := s.taskStore.List(ctx, &store.QueryOptions{Page: page, PageSize: pageSize}, request.Status) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询手机号资产解绑导入任务失败") + } + items := make([]*dto.PhoneAssetUnbindImportTaskResponse, 0, len(tasks)) + for _, taskRecord := range tasks { + items = append(items, toUnbindImportTaskResponse(taskRecord)) + } + return &dto.PhoneAssetUnbindImportTaskPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil +} + +// GetImportTask 查询解绑导入任务详情与逐行结果。 +// 逐行结果含解绑当时的完整手机号快照:关系已失效,只有快照能事后展示被解绑的手机号。 +func (s *Service) GetImportTask(ctx context.Context, id uint) (*dto.PhoneAssetUnbindImportTaskDetailResponse, error) { + if id == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + taskRecord, err := s.taskStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeNotFound, "手机号资产解绑导入任务不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询手机号资产解绑导入任务失败") + } + items := make([]dto.PhoneAssetUnbindImportItemResponse, 0, len(taskRecord.ResultItems)) + for _, item := range taskRecord.ResultItems { + items = append(items, dto.PhoneAssetUnbindImportItemResponse{ + Line: item.Line, AssetType: item.AssetType, AssetIdentifier: item.AssetIdentifier, + AssetID: item.AssetID, UnboundCount: item.UnboundCount, AssociatedPhones: item.AssociatedPhones, + Status: item.Status, StatusName: constants.GetPhoneAssetAssociationImportItemStatusName(item.Status), + Reason: item.Reason, + }) + } + return &dto.PhoneAssetUnbindImportTaskDetailResponse{ + PhoneAssetUnbindImportTaskResponse: *toUnbindImportTaskResponse(taskRecord), Items: items, + }, nil +} + +// writeTaskAudit 在调用方事务内写导入任务创建或入队失败审计。 +// 任务资源身份快照只保留注册表允许的最小字段。 +func (s *Service) writeTaskAudit(ctx context.Context, tx *gorm.DB, task *model.PhoneAssetUnbindImportTask, result string, before, after map[string]any, errorCode string) error { + return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{ + EventID: audit.TaskEventID(constants.AuditResourcePhoneAssetUnbindImportTask, task.ID, unbindImportTaskAuditPhase(result)), + ActionCode: constants.AuditActionPhoneAssetUnbindImportTaskCreated, Summary: "创建手机号资产解绑导入任务", + TaskID: task.ID, TaskNo: task.TaskNo, + Actor: audit.ActorInput{ + Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10), + Name: middleware.GetUsernameFromContext(ctx), + }, + Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, + Result: result, ErrorCode: errorCode, ErrorSummary: task.ErrorMessage, + // 与 Worker 侧完成事件使用同一关联键,使同一任务的全部事件可按 correlation 串成一条时间线。 + CorrelationID: task.TaskNo, + IdentitySnapshot: map[string]any{ + "id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName, + }, + BeforeData: before, AfterData: after, + }) +} + +// unbindImportTaskAuditPhase 返回任务创建阶段的稳定事件阶段名,失败入队使用独立阶段避免覆盖首次事件。 +func unbindImportTaskAuditPhase(result string) string { + if result == constants.AuditResultSuccess { + return "created" + } + return "enqueue_failed" +} + +// unbindImportTaskState 生成解绑导入任务状态快照。 +func unbindImportTaskState(task *model.PhoneAssetUnbindImportTask) map[string]any { + if task == nil { + return nil + } + return map[string]any{ + "status": task.Status, "total_count": task.TotalCount, + "success_count": task.SuccessCount, "fail_count": task.FailCount, + } +} + +func toUnbindImportTaskResponse(taskRecord *model.PhoneAssetUnbindImportTask) *dto.PhoneAssetUnbindImportTaskResponse { + response := &dto.PhoneAssetUnbindImportTaskResponse{ + ID: taskRecord.ID, TaskNo: taskRecord.TaskNo, FileName: taskRecord.FileName, + UnbindReason: taskRecord.UnbindReason, + Status: taskRecord.Status, StatusName: model.ImportTaskStatusName(taskRecord.Status), + TotalCount: taskRecord.TotalCount, SuccessCount: taskRecord.SuccessCount, FailCount: taskRecord.FailCount, + ErrorMessage: taskRecord.ErrorMessage, CreatorName: taskRecord.CreatorName, + CreatedAt: taskRecord.CreatedAt.Format(time.RFC3339), + } + if taskRecord.StartedAt != nil { + response.StartedAt = taskRecord.StartedAt.Format(time.RFC3339) + } + if taskRecord.CompletedAt != nil { + response.CompletedAt = taskRecord.CompletedAt.Format(time.RFC3339) + } + return response +} diff --git a/internal/service/phone_asset_association/service.go b/internal/service/phone_asset_association/service.go new file mode 100644 index 0000000..a7b5fc6 --- /dev/null +++ b/internal/service/phone_asset_association/service.go @@ -0,0 +1,396 @@ +// Package phone_asset_association 提供手机号—资产关联的后台查看与解除能力。 +// 关联只能由 H5 短信验证建立:本包不提供任何创建或补录入口。 +// 解除必须二次确认并填写原因,逐资产独立执行并返回逐项结果,部分成功不回滚成功项。 +package phone_asset_association + +import ( + "context" + stderrors "errors" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + + accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit" + "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" + assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" + "github.com/break/junhong_cmp_fiber/internal/store" + "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" + "github.com/break/junhong_cmp_fiber/pkg/queue" + "github.com/break/junhong_cmp_fiber/pkg/sanitizer" +) + +// Service 手机号—资产关联后台服务:关联查看、单项解除、按资产批量解除与解绑导入任务受理。 +type Service struct { + db *gorm.DB + associationStore *postgres.PhoneAssetAssociationStore + taskStore *postgres.PhoneAssetUnbindImportTaskStore + assetIdentifierStore *postgres.AssetIdentifierStore + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore + queueClient *queue.Client + auditWriter *audit.Writer +} + +// Unbind 解除指定的一条手机号—资产关联。 +// 路径主键即指定关系;锁定关系后复核资产数据范围,标记失效并与审计同事务提交。 +func (s *Service) Unbind(ctx context.Context, request *dto.UnbindPhoneAssetAssociationRequest) (*dto.UnbindPhoneAssetAssociationResponse, error) { + if request == nil || request.ID == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + now := time.Now() + var association *model.PhoneAssetAssociation + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + locked, err := s.associationStore.WithTx(tx).LockValidByID(ctx, tx, request.ID) + if err != nil { + if err == gorm.ErrRecordNotFound { + // 已无有效关系:与越权、资产不存在返回同一文案。 + return deniedError() + } + return errors.Wrap(errors.CodeDatabaseError, err, "锁定手机号资产关联失败") + } + if _, err := s.ensureAssetInScope(ctx, tx, locked.AssetType, locked.AssetID); err != nil { + return err + } + affected, err := s.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, []uint{locked.ID}, + constants.PhoneAssetAssociationInvalidateMethodBackendSingle, request.Reason, operatorID, now) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "解除手机号资产关联失败") + } + if affected != 1 { + return deniedError() + } + if err := s.writeUnbindAudit(ctx, tx, operatorID, []*model.PhoneAssetAssociation{locked}, + constants.PhoneAssetAssociationInvalidateMethodBackendSingle, request.Reason, now); err != nil { + return err + } + association = locked + return nil + }) + if err != nil { + s.recordUnbindFailure(ctx, operatorID, []accessauditapp.PhoneAssetAssociationChange{{AssociationID: request.ID}}, err) + return nil, err + } + return &dto.UnbindPhoneAssetAssociationResponse{ + ID: association.ID, + AssetType: association.AssetType, + AssetID: association.AssetID, + UnboundCount: 1, + InvalidatedAt: now.Format(time.RFC3339), + }, nil +} + +// BatchUnbind 按资产集合解除全部当前有效关系。 +// 资产集合先去重,再逐资产独立事务执行:成功项提交、失败项保留原状,返回成功数、失败数与逐项结果。 +func (s *Service) BatchUnbind(ctx context.Context, request *dto.BatchUnbindPhoneAssetAssociationRequest) (*dto.BatchUnbindPhoneAssetAssociationResponse, error) { + if request == nil || len(request.Assets) == 0 { + return nil, errors.New(errors.CodeInvalidParam) + } + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + assets := dedupeAssets(request.Assets) + items := make([]dto.BatchUnbindAssetResult, 0, len(assets)) + successCount, failCount := 0, 0 + for _, asset := range assets { + item := dto.BatchUnbindAssetResult{AssetType: asset.AssetType, AssetID: asset.AssetID} + unbound, err := s.unbindAsset(ctx, asset, request.Reason, operatorID) + if err != nil { + item.Success = false + item.Reason = unbindFailureReason(err) + failCount++ + } else { + item.Success = true + item.UnboundCount = unbound + successCount++ + } + items = append(items, item) + } + return &dto.BatchUnbindPhoneAssetAssociationResponse{ + SuccessCount: successCount, + FailCount: failCount, + Items: items, + }, nil +} + +// unbindAsset 解除单项资产的全部当前有效关系;该项独立事务,失败不影响其他项。 +func (s *Service) unbindAsset(ctx context.Context, asset dto.BatchUnbindAssetItem, reason string, operatorID uint) (int, error) { + now := time.Now() + unbound := 0 + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if _, err := s.ensureAssetInScope(ctx, tx, asset.AssetType, asset.AssetID); err != nil { + return err + } + rows, err := s.associationStore.WithTx(tx).LockValidByAsset(ctx, tx, asset.AssetType, asset.AssetID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "锁定资产关联失败") + } + if len(rows) == 0 { + // 已无有效关系:与越权、资产不存在返回同一文案。 + return deniedError() + } + ids := make([]uint, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.ID) + } + affected, err := s.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, ids, + constants.PhoneAssetAssociationInvalidateMethodBackendBatch, reason, operatorID, now) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "解除手机号资产关联失败") + } + if int(affected) != len(rows) { + return deniedError() + } + if err := s.writeUnbindAudit(ctx, tx, operatorID, rows, + constants.PhoneAssetAssociationInvalidateMethodBackendBatch, reason, now); err != nil { + return err + } + unbound = len(rows) + return nil + }) + if err != nil { + change := accessauditapp.PhoneAssetAssociationChange{AssetType: asset.AssetType, AssetID: asset.AssetID} + s.recordUnbindFailure(ctx, operatorID, []accessauditapp.PhoneAssetAssociationChange{change}, err) + return 0, err + } + return unbound, nil +} + +// ensureAssetInScope 复核资产数据范围,返回资产展示名供审计使用。 +// 关联表没有 shop_id,数据范围必须落在资产归属店铺上(ENG-AUTHZ-001); +// 范围外资产与不存在资产一律返回同一文案,不形成可枚举差异。 +func (s *Service) ensureAssetInScope(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, error) { + switch assetType { + case constants.AssetTypeIotCard: + var card model.IotCard + query := middleware.ApplyShopFilter(ctx, tx.WithContext(ctx).Model(&model.IotCard{}).Where("id = ?", assetID)) + if err := query.Select("id", "iccid").First(&card).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return "", deniedError() + } + return "", errors.Wrap(errors.CodeDatabaseError, err, "复核资产数据范围失败") + } + return card.ICCID, nil + case constants.AssetTypeDevice: + var device model.Device + query := middleware.ApplyShopFilter(ctx, tx.WithContext(ctx).Model(&model.Device{}).Where("id = ?", assetID)) + if err := query.Select("id", "virtual_no").First(&device).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return "", deniedError() + } + return "", errors.Wrap(errors.CodeDatabaseError, err, "复核资产数据范围失败") + } + return device.VirtualNo, nil + default: + return "", deniedError() + } +} + +// writeUnbindAudit 在解除事务内逐条记录失效事实,手机号一律为脱敏值。 +func (s *Service) writeUnbindAudit( + ctx context.Context, + tx *gorm.DB, + operatorID uint, + rows []*model.PhoneAssetAssociation, + method, reason string, + now time.Time, +) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "手机号资产关联审计接缝未配置") + } + displayName, err := s.ensureAssetInScope(ctx, tx, rows[0].AssetType, rows[0].AssetID) + if err != nil { + return err + } + changes := make([]accessauditapp.PhoneAssetAssociationChange, 0, len(rows)) + for _, row := range rows { + changes = append(changes, accessauditapp.PhoneAssetAssociationChange{ + AssociationID: row.ID, PhoneMasked: sanitizer.MaskPhone(row.Phone), + AssetType: row.AssetType, AssetID: row.AssetID, + AssetDisplayName: displayName, + Status: constants.PhoneAssetAssociationStatusInvalid, Source: row.Source, + InvalidatedAt: &now, InvalidationMethod: method, InvalidationReason: reason, + BeforeData: map[string]any{"status": constants.PhoneAssetAssociationStatusValid}, + AfterData: map[string]any{ + "status": constants.PhoneAssetAssociationStatusInvalid, + "invalidated_at": now, "invalidation_method": method, "invalidation_reason": reason, + }, + }) + } + return s.auditWriter.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{ + ActionCode: constants.AuditActionPhoneAssetAssociationUnbound, + Summary: "解除手机号资产关联", + OperatorID: operatorID, ActorKind: constants.AuditActorAccount, + ActorName: middleware.GetUsernameFromContext(ctx), + Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, + PhoneAssociations: changes, + }) +} + +// recordUnbindFailure 在业务回滚后以独立短事务补记解除失败或拒绝事实。 +func (s *Service) recordUnbindFailure(ctx context.Context, operatorID uint, changes []accessauditapp.PhoneAssetAssociationChange, originalErr error) { + if s.auditWriter == nil { + return + } + accessauditapp.RecordFailure(ctx, s.db, s.auditWriter, accessauditapp.ChangeAudit{ + ActionCode: constants.AuditActionPhoneAssetAssociationUnbound, + Summary: "解除手机号资产关联被拒绝", + OperatorID: operatorID, ActorKind: constants.AuditActorAccount, + ActorName: middleware.GetUsernameFromContext(ctx), + Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, + PhoneAssociations: changes, + }, originalErr) +} + +// unbindFailureReason 返回逐项失败原因;复用稳定错误文案,不拼接底层错误。 +func unbindFailureReason(err error) string { + var appErr *errors.AppError + if stderrors.As(err, &appErr) && appErr.Message != "" { + return appErr.Message + } + return "解除失败" +} + +// deniedError 构造越权、资产不存在与已无有效关系共用的统一失败。 +func deniedError() error { + return errors.New(errors.CodeForbidden, constants.PhoneAssetAssociationDeniedMessage) +} + +// dedupeAssets 按 (资产类型, 资产ID) 去重并保持首次出现顺序。 +func dedupeAssets(items []dto.BatchUnbindAssetItem) []dto.BatchUnbindAssetItem { + seen := make(map[string]struct{}, len(items)) + result := make([]dto.BatchUnbindAssetItem, 0, len(items)) + for _, item := range items { + key := item.AssetType + ":" + strconv.FormatUint(uint64(item.AssetID), 10) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + result = append(result, item) + } + return result +} + +// List 分页查询手机号—资产关联,返回资产、完整手机号、建立时间、建立来源与状态。 +// 数据范围经资产归属店铺约束:关联表没有 shop_id,必须落到卡与设备表判断; +// 越权与不存在都表现为结果集为空,不形成可枚举差异。 +// 关联手机号不做脱敏(读侧按数据范围返回完整值),审计与日志仍只写脱敏值。 +func (s *Service) List(ctx context.Context, request *dto.ListPhoneAssetAssociationRequest) (*dto.PhoneAssetAssociationPageResult, error) { + if request == nil { + return nil, errors.New(errors.CodeInvalidParam) + } + filter := postgres.PhoneAssetAssociationListFilter{ + Phone: strings.TrimSpace(request.Phone), + Status: request.Status, + CreatedAtStart: request.CreatedAtStart, + CreatedAtEnd: request.CreatedAtEnd, + ScopedShopIDs: middleware.GetSubordinateShopIDs(ctx), + } + if identifier := strings.TrimSpace(request.AssetIdentifier); identifier != "" { + assetType, assetID, err := assetSvc.ResolveIdentifier(ctx, s.assetIdentifierStore, s.iotCardStore, s.deviceStore, identifier) + if err != nil { + return nil, err + } + if assetType == "" { + // 标识无法定位资产:返回空页,与范围内无关联不可区分。 + return &dto.PhoneAssetAssociationPageResult{ + Items: []*dto.PhoneAssetAssociationResponse{}, Total: 0, + Page: normalizeListPage(request.Page), Size: normalizeListPageSize(request.PageSize), + }, nil + } + filter.AssetType, filter.AssetID = assetType, assetID + } + page, pageSize := normalizeListPage(request.Page), normalizeListPageSize(request.PageSize) + rows, total, err := s.associationStore.List(ctx, &store.QueryOptions{Page: page, PageSize: pageSize}, filter) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询手机号资产关联失败") + } + identifiers, err := s.loadAssetIdentifiers(ctx, rows) + if err != nil { + return nil, err + } + items := make([]*dto.PhoneAssetAssociationResponse, 0, len(rows)) + for _, row := range rows { + item := &dto.PhoneAssetAssociationResponse{ + ID: row.ID, Phone: row.Phone, AssetType: row.AssetType, AssetID: row.AssetID, + AssetIdentifier: identifiers[assetIdentifierKey(row.AssetType, row.AssetID)], + Status: row.Status, StatusName: constants.GetPhoneAssetAssociationStatusName(row.Status), + Source: row.Source, EstablishedAt: row.EstablishedAt.Format(time.RFC3339), + } + if row.InvalidatedAt != nil { + item.InvalidatedAt = row.InvalidatedAt.Format(time.RFC3339) + item.InvalidationMethod = row.InvalidationMethod + item.InvalidationMethodName = constants.PhoneAssetAssociationInvalidateMethodName(row.InvalidationMethod) + item.InvalidationReason = row.InvalidationReason + } + items = append(items, item) + } + return &dto.PhoneAssetAssociationPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil +} + +// loadAssetIdentifiers 按资产类型各一次 IN 批量读取资产当前标识,禁止逐行查询。 +func (s *Service) loadAssetIdentifiers(ctx context.Context, rows []*model.PhoneAssetAssociation) (map[string]string, error) { + result := make(map[string]string, len(rows)) + cardIDs := make([]uint, 0, len(rows)) + deviceIDs := make([]uint, 0, len(rows)) + for _, row := range rows { + switch row.AssetType { + case constants.AssetTypeIotCard: + cardIDs = append(cardIDs, row.AssetID) + case constants.AssetTypeDevice: + deviceIDs = append(deviceIDs, row.AssetID) + } + } + if len(cardIDs) > 0 { + cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询卡标识失败") + } + for _, card := range cards { + result[assetIdentifierKey(constants.AssetTypeIotCard, card.ID)] = card.ICCID + } + } + if len(deviceIDs) > 0 { + devices, err := s.deviceStore.GetByIDs(ctx, deviceIDs) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询设备标识失败") + } + for _, device := range devices { + result[assetIdentifierKey(constants.AssetTypeDevice, device.ID)] = device.VirtualNo + } + } + return result, nil +} + +func assetIdentifierKey(assetType string, assetID uint) string { + return assetType + ":" + strconv.FormatUint(uint64(assetID), 10) +} + +// normalizeListPage / normalizeListPageSize 归一化分页,最大页大小沿用接口上限。 +func normalizeListPage(page int) int { + if page <= 0 { + return constants.DefaultPage + } + return page +} + +func normalizeListPageSize(pageSize int) int { + if pageSize <= 0 { + return constants.DefaultPageSize + } + if pageSize > constants.MaxPageSize { + return constants.MaxPageSize + } + return pageSize +} diff --git a/internal/store/postgres/phone_asset_association_store.go b/internal/store/postgres/phone_asset_association_store.go new file mode 100644 index 0000000..4218d9c --- /dev/null +++ b/internal/store/postgres/phone_asset_association_store.go @@ -0,0 +1,265 @@ +package postgres + +import ( + "context" + "fmt" + "sort" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// PhoneAssetAssociationStore 手机号—资产关联数据访问层。 +// 关联只能由 H5 短信验证建立:本 Store 不提供后台创建或补录方法。 +type PhoneAssetAssociationStore struct { + db *gorm.DB +} + +// NewPhoneAssetAssociationStore 创建手机号—资产关联 Store。 +func NewPhoneAssetAssociationStore(db *gorm.DB) *PhoneAssetAssociationStore { + return &PhoneAssetAssociationStore{db: db} +} + +// WithTx 返回绑定指定事务的关联 Store。 +func (s *PhoneAssetAssociationStore) WithTx(tx *gorm.DB) *PhoneAssetAssociationStore { + return &PhoneAssetAssociationStore{db: tx} +} + +// validScope 收敛「当前有效关系」的口径:status = 1 且未删除。 +func (s *PhoneAssetAssociationStore) validScope(ctx context.Context) *gorm.DB { + return s.db.WithContext(ctx).Model(&model.PhoneAssetAssociation{}). + Where("status = ?", constants.PhoneAssetAssociationStatusValid) +} + +// ExistsValid 判断手机号与指定资产之间是否已存在有效关系,供登录提示判定使用。 +func (s *PhoneAssetAssociationStore) ExistsValid(ctx context.Context, phone, assetType string, assetID uint) (bool, error) { + var count int64 + if err := s.validScope(ctx). + Where("phone = ? AND asset_type = ? AND asset_id = ?", phone, assetType, assetID). + Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +// CountValidByPhone 统计手机号当前有效关系数,用于十项上限判定;已失效关系不占用额度。 +func (s *PhoneAssetAssociationStore) CountValidByPhone(ctx context.Context, phone string) (int64, error) { + var count int64 + if err := s.validScope(ctx).Where("phone = ?", phone).Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + +// PhoneAssetAssociationListFilter 后台关联列表筛选条件。 +// ScopedShopIDs 非空时只返回这些店铺名下资产的关联;关联表没有 shop_id, +// 数据范围必须经资产归属店铺判断(ENG-AUTHZ-001)。 +type PhoneAssetAssociationListFilter struct { + AssetType string + AssetID uint + Phone string + Status *int + CreatedAtStart *time.Time + CreatedAtEnd *time.Time + ScopedShopIDs []uint +} + +// List 分页查询关联,返回当前页记录与总数。 +// 资产范围按 (asset_type, asset_id) 分别落到卡与设备表的归属店铺上, +// 不允许在关联表上直接套用 shop_id 过滤(该表没有该列)。 +func (s *PhoneAssetAssociationStore) List(ctx context.Context, opts *store.QueryOptions, filter PhoneAssetAssociationListFilter) ([]*model.PhoneAssetAssociation, int64, error) { + query := s.db.WithContext(ctx).Model(&model.PhoneAssetAssociation{}) + if filter.AssetType != "" { + query = query.Where("asset_type = ?", filter.AssetType) + } + if filter.AssetID != 0 { + query = query.Where("asset_id = ?", filter.AssetID) + } + if filter.Phone != "" { + query = query.Where("phone = ?", filter.Phone) + } + if filter.Status != nil { + query = query.Where("status = ?", *filter.Status) + } + if filter.CreatedAtStart != nil { + query = query.Where("created_at >= ?", *filter.CreatedAtStart) + } + if filter.CreatedAtEnd != nil { + query = query.Where("created_at <= ?", *filter.CreatedAtEnd) + } + if len(filter.ScopedShopIDs) > 0 { + query = query.Where(`( + (asset_type = ? AND asset_id IN (SELECT id FROM tb_iot_card WHERE deleted_at IS NULL AND shop_id IN ?)) + OR (asset_type = ? AND asset_id IN (SELECT id FROM tb_device WHERE deleted_at IS NULL AND shop_id IN ?)) + )`, constants.AssetTypeIotCard, filter.ScopedShopIDs, constants.AssetTypeDevice, filter.ScopedShopIDs) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if opts == nil { + opts = store.DefaultQueryOptions() + } + var rows []*model.PhoneAssetAssociation + if err := query.Order("id DESC").Offset((opts.Page - 1) * opts.PageSize).Limit(opts.PageSize).Find(&rows).Error; err != nil { + return nil, 0, err + } + return rows, total, nil +} + +// LockPhoneScopes 在事务内为「手机号」这一逻辑实体取稳定串行化点:按号码字符串升序取事务级 advisory lock。 +// 新号可能既没有 tb_personal_customer_phone 行也没有关联行,仅靠行锁无法串行化计数与写入; +// 本方法必须在任何行锁之前调用,且三条写入路径的次序一致(advisory 按号码升序 → 行锁按 id 升序), +// 从而既保证十项上限不被并发突破,也不形成 A→B / B→A 死锁环。同一号码重复获取是同事务内的可重入 no-op。 +func (s *PhoneAssetAssociationStore) LockPhoneScopes(ctx context.Context, phones ...string) error { + ordered := make([]string, 0, len(phones)) + seen := make(map[string]struct{}, len(phones)) + for _, phone := range phones { + if phone == "" { + continue + } + if _, ok := seen[phone]; ok { + continue + } + seen[phone] = struct{}{} + ordered = append(ordered, phone) + } + sort.Strings(ordered) + for _, phone := range ordered { + if err := s.db.WithContext(ctx).Exec("SELECT pg_advisory_xact_lock(hashtext(?))", phone).Error; err != nil { + return err + } + } + return nil +} + +// LockValidByPhones 在事务内按 id ASC 一次性锁定指定手机号的全部有效关系行并返回。 +// 计数与写入必须在同一临界区内完成,否则并发建联可以越过十项上限; +// 单条 FOR UPDATE 语句按 id 升序返回,加锁顺序对 bind-phone 与 change-phone 一致,不形成死锁环。 +func (s *PhoneAssetAssociationStore) LockValidByPhones(ctx context.Context, phones ...string) ([]*model.PhoneAssetAssociation, error) { + if len(phones) == 0 { + return nil, nil + } + var rows []*model.PhoneAssetAssociation + if err := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("phone IN ? AND status = ?", phones, constants.PhoneAssetAssociationStatusValid). + Order("id ASC"). + Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// ListValidByAsset 读取单项资产的全部有效关联手机号,供资产详情展示。 +func (s *PhoneAssetAssociationStore) ListValidByAssets(ctx context.Context, assetType string, assetIDs []uint) (map[uint][]string, error) { + phones := make(map[uint][]string, len(assetIDs)) + if len(assetIDs) == 0 { + return phones, nil + } + var rows []struct { + AssetID uint + Phone string + } + if err := s.validScope(ctx).Select("asset_id", "phone"). + Where("asset_type = ? AND asset_id IN ?", assetType, assetIDs). + Order("asset_id ASC, id ASC"). + Find(&rows).Error; err != nil { + return nil, err + } + for _, row := range rows { + phones[row.AssetID] = append(phones[row.AssetID], row.Phone) + } + return phones, nil +} + +// LockValidByID 在事务内按主键锁定一条有效关系,供单项解绑使用。 +// 锁定后复核资产数据范围,避免「先查后改」之间关系被并发改写。 +func (s *PhoneAssetAssociationStore) LockValidByID(ctx context.Context, tx *gorm.DB, id uint) (*model.PhoneAssetAssociation, error) { + var association model.PhoneAssetAssociation + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ? AND status = ?", id, constants.PhoneAssetAssociationStatusValid). + First(&association).Error; err != nil { + return nil, err + } + return &association, nil +} + +// LockValidByAsset 在事务内按 id ASC 锁定某项资产的全部有效关系,供按资产解除使用。 +func (s *PhoneAssetAssociationStore) LockValidByAsset(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) ([]*model.PhoneAssetAssociation, error) { + var rows []*model.PhoneAssetAssociation + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("asset_type = ? AND asset_id = ? AND status = ?", assetType, assetID, constants.PhoneAssetAssociationStatusValid). + Order("id ASC"). + Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// InvalidateByIDs 以 expected-status 条件把有效关系置为失效,返回实际失效行数。 +// 状态流转必须带 status = 有效 条件并核对影响行数(ENG-CONC-001),否则并发解除会覆盖已成事实的失效信息。 +func (s *PhoneAssetAssociationStore) InvalidateByIDs( + ctx context.Context, + tx *gorm.DB, + ids []uint, + method, reason string, + invalidator uint, + now time.Time, +) (int64, error) { + if len(ids) == 0 { + return 0, nil + } + result := tx.WithContext(ctx).Model(&model.PhoneAssetAssociation{}). + Where("id IN ? AND status = ?", ids, constants.PhoneAssetAssociationStatusValid). + Updates(map[string]any{ + "status": constants.PhoneAssetAssociationStatusInvalid, + "invalidated_at": now, + "invalidation_method": method, + "invalidation_reason": reason, + "invalidator": invalidator, + "updated_at": now, + }) + return result.RowsAffected, result.Error +} + +// ListValidByAsset 读取单项资产的全部有效关联手机号,供资产详情展示。 +func (s *PhoneAssetAssociationStore) ListValidByAsset(ctx context.Context, assetType string, assetID uint) ([]string, error) { + var phones []string + if err := s.validScope(ctx).Order("id ASC"). + Where("asset_type = ? AND asset_id = ?", assetType, assetID). + Pluck("phone", &phones).Error; err != nil { + return nil, err + } + return phones, nil +} + +// CreateIfAbsent 幂等建立关联:同 (手机号, 资产类型, 资产ID) 已存在有效关系时不再插入。 +// 并发同资产建联由 part 唯一索引 uq_phone_asset_association_valid 兜底,冲突即视为已存在。 +// 返回 true 表示本次确实新建了关系。 +func (s *PhoneAssetAssociationStore) CreateIfAbsent(ctx context.Context, tx *gorm.DB, association *model.PhoneAssetAssociation) (bool, error) { + query := s.db + if tx != nil { + query = tx + } + // RowsAffected 用于区分「本次新建」与「已存在」:命中冲突时 PostgreSQL 不返回行, + // GORM 的 ScanOnConflictDoNothing 不会因此报错,只把影响行数留在 0。 + // ON CONFLICT 的推断谓词必须与部分唯一索引谓词逐字一致,且不能使用绑定参数: + // PostgreSQL 无法用 Param 匹配索引谓词中的常量,参数化会直接报 42P10。 + result := query.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{ + {Name: "phone"}, {Name: "asset_type"}, {Name: "asset_id"}, + }, + TargetWhere: clause.Where{Exprs: []clause.Expression{ + clause.Expr{SQL: fmt.Sprintf("status = %d AND deleted_at IS NULL", constants.PhoneAssetAssociationStatusValid)}, + }}, + DoNothing: true, + }).Create(association) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected == 1, nil +} diff --git a/internal/store/postgres/phone_asset_unbind_import_task_store.go b/internal/store/postgres/phone_asset_unbind_import_task_store.go new file mode 100644 index 0000000..ae86b5d --- /dev/null +++ b/internal/store/postgres/phone_asset_unbind_import_task_store.go @@ -0,0 +1,121 @@ +package postgres + +import ( + "context" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/store" +) + +// PhoneAssetUnbindImportTaskStore 手机号—资产关联解绑导入任务数据访问层。 +type PhoneAssetUnbindImportTaskStore struct { + db *gorm.DB +} + +// NewPhoneAssetUnbindImportTaskStore 创建解绑导入任务 Store。 +func NewPhoneAssetUnbindImportTaskStore(db *gorm.DB) *PhoneAssetUnbindImportTaskStore { + return &PhoneAssetUnbindImportTaskStore{db: db} +} + +// WithTx 返回绑定指定事务的任务 Store。 +func (s *PhoneAssetUnbindImportTaskStore) WithTx(tx *gorm.DB) *PhoneAssetUnbindImportTaskStore { + return &PhoneAssetUnbindImportTaskStore{db: tx} +} + +// Create 创建解绑导入任务。 +func (s *PhoneAssetUnbindImportTaskStore) Create(ctx context.Context, task *model.PhoneAssetUnbindImportTask) error { + return s.db.WithContext(ctx).Create(task).Error +} + +// GetByID 按 ID 查询解绑导入任务。 +func (s *PhoneAssetUnbindImportTaskStore) GetByID(ctx context.Context, id uint) (*model.PhoneAssetUnbindImportTask, error) { + var task model.PhoneAssetUnbindImportTask + if err := s.db.WithContext(ctx).First(&task, id).Error; err != nil { + return nil, err + } + return &task, nil +} + +// List 分页查询解绑导入任务。 +func (s *PhoneAssetUnbindImportTaskStore) List(ctx context.Context, opts *store.QueryOptions, status *int) ([]*model.PhoneAssetUnbindImportTask, int64, error) { + query := s.db.WithContext(ctx).Model(&model.PhoneAssetUnbindImportTask{}) + if status != nil { + query = query.Where("status = ?", *status) + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + if opts == nil { + opts = store.DefaultQueryOptions() + } + var tasks []*model.PhoneAssetUnbindImportTask + if err := query.Order("created_at DESC").Offset((opts.Page - 1) * opts.PageSize).Limit(opts.PageSize).Find(&tasks).Error; err != nil { + return nil, 0, err + } + return tasks, total, nil +} + +// ResetForProcessing 把待处理任务或上次中断的处理中任务置为处理中,并重置进度计数与行明细。 +// 返回 false 表示任务已到达终态,重复消费直接跳过;处理中一律视为中断重跑, +// 重跑前清空计数与明细,避免逐行结果重复追加。 +func (s *PhoneAssetUnbindImportTaskStore) ResetForProcessing(ctx context.Context, id uint) (bool, error) { + now := time.Now() + result := s.db.WithContext(ctx).Model(&model.PhoneAssetUnbindImportTask{}). + Where("id = ? AND status IN ?", id, []int{model.ImportTaskStatusPending, model.ImportTaskStatusProcessing}). + Updates(map[string]any{ + "status": model.ImportTaskStatusProcessing, "started_at": now, "success_count": 0, + "fail_count": 0, "total_count": 0, "result_items": model.PhoneAssetUnbindImportResults{}, + "error_message": "", "updated_at": now, + }) + return result.RowsAffected == 1, result.Error +} + +// UpdateProgress 按批更新进度计数,不触碰逐行明细,失败不回滚已提交行。 +// 必须同时写入本任务已知的行总数:表约束要求 success_count + fail_count <= total_count, +// 只写计数会让处理中的中间态违反该约束,导致进度更新静默失败。 +func (s *PhoneAssetUnbindImportTaskStore) UpdateProgress(ctx context.Context, id uint, totalCount, successCount, failCount int) error { + return s.db.WithContext(ctx).Model(&model.PhoneAssetUnbindImportTask{}).Where("id = ?", id). + Updates(map[string]any{ + "total_count": totalCount, "success_count": successCount, "fail_count": failCount, + "updated_at": time.Now(), + }).Error +} + +// MarkFailed 将任务标记为任务级失败,并返回是否确实命中非终态。 +// 命中返回 true;返回 false 表示任务已到终态(例如 Enqueue 实际已投递成功且 Worker 抢先跑完), +// 此时调用方必须按库内真实状态对外呈现,不得把响应与审计置为失败。 +func (s *PhoneAssetUnbindImportTaskStore) MarkFailed(ctx context.Context, id uint, message string) (bool, error) { + now := time.Now() + result := s.db.WithContext(ctx).Model(&model.PhoneAssetUnbindImportTask{}). + Where("id = ? AND status IN ?", id, []int{model.ImportTaskStatusPending, model.ImportTaskStatusProcessing}). + Updates(map[string]any{ + "status": model.ImportTaskStatusFailed, "error_message": message, + "total_count": 0, "success_count": 0, "fail_count": 0, + "result_items": model.PhoneAssetUnbindImportResults{}, + "completed_at": now, "updated_at": now, + }) + return result.RowsAffected == 1, result.Error +} + +// Complete 保存逐行结果与汇总并完成任务。 +func (s *PhoneAssetUnbindImportTaskStore) Complete(ctx context.Context, id uint, totalCount, successCount, failCount int, items model.PhoneAssetUnbindImportResults) error { + now := time.Now() + return s.db.WithContext(ctx).Model(&model.PhoneAssetUnbindImportTask{}). + Where("id = ? AND status = ?", id, model.ImportTaskStatusProcessing). + Updates(map[string]any{ + "status": model.ImportTaskStatusCompleted, "total_count": totalCount, + "success_count": successCount, "fail_count": failCount, + "result_items": items, "completed_at": now, "updated_at": now, + }).Error +} + +// GenerateTaskNo 生成解绑导入任务编号。 +func (s *PhoneAssetUnbindImportTaskStore) GenerateTaskNo() string { + now := time.Now() + return fmt.Sprintf("PAU-%s-%06d", now.Format("20060102"), now.UnixNano()%1000000) +} diff --git a/internal/task/phone_asset_unbind_import.go b/internal/task/phone_asset_unbind_import.go new file mode 100644 index 0000000..06e84d8 --- /dev/null +++ b/internal/task/phone_asset_unbind_import.go @@ -0,0 +1,434 @@ +package task + +import ( + "bytes" + "context" + "encoding/csv" + stderrors "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + "time" + + "github.com/bytedance/sonic" + "github.com/hibiken/asynq" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/model" + assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" + pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/sanitizer" + "github.com/break/junhong_cmp_fiber/pkg/storage" + "github.com/break/junhong_cmp_fiber/pkg/utils" +) + +// PhoneAssetUnbindImportPayload 手机号—资产关联 CSV 解绑导入任务载荷。 +type PhoneAssetUnbindImportPayload struct { + TaskID uint `json:"task_id"` +} + +// PhoneAssetUnbindImportHandler 手机号—资产关联 CSV 解绑导入任务处理器。 +// 逐行独立事务:成功行提交、失败行不解除任何关系;任务级失败与行级失败分开记录。 +// 行内只填资产标识与可选备注,解绑原因取任务级必填字段。 +type PhoneAssetUnbindImportHandler struct { + db *gorm.DB + taskStore *postgres.PhoneAssetUnbindImportTaskStore + associationStore *postgres.PhoneAssetAssociationStore + assetIdentifierStore *postgres.AssetIdentifierStore + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore + storageService *storage.Service + auditWriter *audit.Writer + logger *zap.Logger +} + +// NewPhoneAssetUnbindImportHandler 创建解绑导入任务处理器。 +func NewPhoneAssetUnbindImportHandler( + db *gorm.DB, + taskStore *postgres.PhoneAssetUnbindImportTaskStore, + associationStore *postgres.PhoneAssetAssociationStore, + assetIdentifierStore *postgres.AssetIdentifierStore, + iotCardStore *postgres.IotCardStore, + deviceStore *postgres.DeviceStore, + storageService *storage.Service, + logger *zap.Logger, + auditWriters ...*audit.Writer, +) *PhoneAssetUnbindImportHandler { + handler := &PhoneAssetUnbindImportHandler{ + db: db, taskStore: taskStore, associationStore: associationStore, + assetIdentifierStore: assetIdentifierStore, iotCardStore: iotCardStore, + deviceStore: deviceStore, storageService: storageService, logger: logger, + } + if len(auditWriters) > 0 { + handler.auditWriter = auditWriters[0] + } + return handler +} + +// Handle 处理手机号—资产关联 CSV 解绑导入任务。 +func (h *PhoneAssetUnbindImportHandler) Handle(ctx context.Context, taskMessage *asynq.Task) error { + var payload PhoneAssetUnbindImportPayload + if err := sonic.Unmarshal(taskMessage.Payload(), &payload); err != nil { + h.logger.Error("解析手机号资产解绑导入任务载荷失败", zap.Error(err)) + return asynq.SkipRetry + } + taskRecord, err := h.taskStore.GetByID(ctx, payload.TaskID) + if err != nil { + h.logger.Error("查询手机号资产解绑导入任务失败", zap.Uint("task_id", payload.TaskID), zap.Error(err)) + return asynq.SkipRetry + } + if h.auditWriter == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "手机号资产解绑导入统一审计接缝未配置") + } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePhoneAssetUnbindImport, + ActorName: "手机号资产解绑导入任务", Source: constants.AuditSourceWorker, + CorrelationID: taskRecord.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourcePhoneAssetUnbindImportTask, taskRecord.ID, "completed"), + }) + claimed, err := h.taskStore.ResetForProcessing(ctx, taskRecord.ID) + if err != nil { + return err + } + if !claimed { + h.logger.Info("手机号资产解绑导入任务已终结,跳过重复消费", zap.Uint("task_id", taskRecord.ID)) + return nil + } + + rows, err := h.downloadAndParse(ctx, taskRecord.StorageKey) + if err != nil { + h.logger.Warn("下载或解析手机号资产解绑导入CSV失败", zap.Uint("task_id", taskRecord.ID), zap.Error(err)) + if finishErr := h.finishTask(ctx, taskRecord, nil, 0, 0, model.ImportTaskStatusFailed, err.Error()); finishErr != nil { + return finishErr + } + return asynq.SkipRetry + } + + items, successCount, err := h.processRows(ctx, taskRecord, rows) + if err != nil { + message := "导入执行中断:" + err.Error() + h.logger.Error("手机号资产解绑导入行执行中断", zap.Uint("task_id", taskRecord.ID), zap.Error(err)) + if finishErr := h.finishTask(ctx, taskRecord, nil, 0, 0, model.ImportTaskStatusFailed, message); finishErr != nil { + return finishErr + } + return asynq.SkipRetry + } + failCount := len(items) - successCount + if err := h.finishTask(ctx, taskRecord, items, successCount, failCount, model.ImportTaskStatusCompleted, ""); err != nil { + return err + } + h.logger.Info("手机号资产解绑导入任务完成", + zap.Uint("task_id", taskRecord.ID), zap.Int("success", successCount), zap.Int("fail", failCount)) + return nil +} + +// finishTask 在单事务内写任务终态、逐行明细与任务根审计事件。 +// 任务级失败不产生行明细,与行级失败原因分开记录。 +func (h *PhoneAssetUnbindImportHandler) finishTask( + ctx context.Context, + taskRecord *model.PhoneAssetUnbindImportTask, + items model.PhoneAssetUnbindImportResults, + successCount, failCount, status int, + errorMessage string, +) error { + return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + store := h.taskStore.WithTx(tx) + if status == model.ImportTaskStatusFailed { + // 任务级失败:未命中非终态说明任务已被其他执行路径终结,按库内事实跳过重复收尾。 + hit, err := store.MarkFailed(ctx, taskRecord.ID, errorMessage) + if err != nil { + return err + } + if !hit { + return nil + } + } else if err := store.Complete(ctx, taskRecord.ID, len(items), successCount, failCount, items); err != nil { + return err + } + result := batchAuditResult(successCount, failCount) + afterData := map[string]any{ + "status": status, "total_count": len(items), "success_count": successCount, "fail_count": failCount, + } + if status == model.ImportTaskStatusFailed { + result = constants.AuditResultFailed + afterData["error_message"] = errorMessage + } + return h.auditWriter.WriteTask(ctx, tx, audit.TaskInput{ + EventID: audit.TaskEventID(constants.AuditResourcePhoneAssetUnbindImportTask, taskRecord.ID, "completed"), + ActionCode: constants.AuditActionPhoneAssetUnbindImportTaskCompleted, + Summary: "完成手机号资产解绑导入任务", TaskID: taskRecord.ID, TaskNo: taskRecord.TaskNo, + Result: result, CorrelationID: taskRecord.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourcePhoneAssetUnbindImportTask, taskRecord.ID, "created"), + BatchTotal: len(items), SuccessCount: successCount, FailCount: failCount, + IdentitySnapshot: map[string]any{ + "id": taskRecord.ID, "task_no": taskRecord.TaskNo, "file_name": taskRecord.FileName, + }, + BeforeData: map[string]any{"status": model.ImportTaskStatusProcessing}, + AfterData: afterData, + ErrorSummary: errorMessage, + }) + }) +} + +// downloadAndParse 下载并解析导入 CSV,编码、表头或格式问题一律按任务级失败返回。 +func (h *PhoneAssetUnbindImportHandler) downloadAndParse(ctx context.Context, key string) ([]phoneAssetUnbindImportRow, error) { + if h.storageService == nil { + return nil, phoneAssetUnbindImportError("对象存储服务未配置") + } + if key == "" { + return nil, phoneAssetUnbindImportError("导入文件Key不能为空") + } + localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, key) + if err != nil { + return nil, phoneAssetUnbindImportError("下载导入CSV失败") + } + defer cleanup() + // 不设行数与体积硬上限:体积沿用上传用途的既有校验,此处按文件实际大小读取。 + data, err := os.ReadFile(localPath) + if err != nil { + return nil, phoneAssetUnbindImportError("读取导入CSV失败") + } + decoded, err := utils.DecodeTextToUTF8(data) + if err != nil { + return nil, phoneAssetUnbindImportError(constants.PhoneAssetUnbindImportErrorEncoding) + } + return parsePhoneAssetUnbindImportCSV(decoded) +} + +// phoneAssetUnbindImportRow 是导入文件的单行业务事实;行号自数据首行起计,表头不计入。 +type phoneAssetUnbindImportRow struct { + Line int + ColumnCountMatched bool + AssetIdentifier string + Remark string +} + +// parsePhoneAssetUnbindImportCSV 解析固定列序的导入 CSV。 +// 表头必须与固定列序完全一致,不一致即任务级失败且不进入逐行阶段; +// 数据行列数不符属行级「行格式错误」,因此必须关闭字段数一致性校验, +// 否则标准库在首条记录定型字段数后会让后续异常行直接返回 ErrFieldCount, +// 把行级问题误升级为任务级失败且不产生行明细。 +func parsePhoneAssetUnbindImportCSV(data []byte) ([]phoneAssetUnbindImportRow, error) { + reader := csv.NewReader(bytes.NewReader(data)) + reader.TrimLeadingSpace = true + reader.FieldsPerRecord = -1 + rows := make([]phoneAssetUnbindImportRow, 0) + line := 0 + for { + record, err := reader.Read() + if err == io.EOF { + break + } + if err != nil { + // 关闭字段数校验后仍报错,说明是引号未闭合等真实 CSV 语法错误,属任务级失败。 + return nil, phoneAssetUnbindImportError(constants.PhoneAssetUnbindImportErrorFileFormat) + } + if line == 0 { + if !matchPhoneAssetUnbindImportHeader(record) { + return nil, phoneAssetUnbindImportError(constants.PhoneAssetUnbindImportErrorFileFormat) + } + line++ + continue + } + line++ + row := phoneAssetUnbindImportRow{Line: line - 1} + if len(record) != len(constants.PhoneAssetUnbindImportColumns) { + // 列数不符的行不参与业务校验,直接以行格式错误记录并保留原状。 + rows = append(rows, row) + continue + } + row.ColumnCountMatched = true + row.AssetIdentifier = strings.TrimSpace(record[0]) + row.Remark = strings.TrimSpace(record[1]) + rows = append(rows, row) + } + if len(rows) == 0 { + return nil, phoneAssetUnbindImportError(constants.PhoneAssetUnbindImportErrorNoDataRow) + } + return rows, nil +} + +// matchPhoneAssetUnbindImportHeader 逐列比较表头与固定列序,仅容忍列内两侧空白差异。 +func matchPhoneAssetUnbindImportHeader(record []string) bool { + columns := constants.PhoneAssetUnbindImportColumns + if len(record) != len(columns) { + return false + } + for index, column := range columns { + if strings.TrimSpace(record[index]) != column { + return false + } + } + return true +} + +// processRows 逐行独立执行并按批更新进度计数;进度写失败不回滚已提交行。 +// 返回错误表示行执行遇到基础设施故障,由调用方按任务级失败收尾。 +func (h *PhoneAssetUnbindImportHandler) processRows(ctx context.Context, taskRecord *model.PhoneAssetUnbindImportTask, rows []phoneAssetUnbindImportRow) (model.PhoneAssetUnbindImportResults, int, error) { + items := make(model.PhoneAssetUnbindImportResults, 0, len(rows)) + successCount, failCount := 0, 0 + for index, row := range rows { + item, err := h.processRow(ctx, taskRecord, row) + if err != nil { + return nil, 0, err + } + items = append(items, item) + if item.Status == constants.PhoneAssetUnbindImportItemStatusSuccess { + successCount++ + } else { + failCount++ + } + if (index+1)%constants.PhoneAssetUnbindImportProgressBatchSize == 0 { + if err := h.taskStore.UpdateProgress(ctx, taskRecord.ID, len(rows), successCount, failCount); err != nil { + h.logger.Warn("更新手机号资产解绑导入进度失败", zap.Uint("task_id", taskRecord.ID), zap.Error(err)) + } + } + } + return items, successCount, nil +} + +// processRow 校验并执行单行;失败行只记录固定枚举原因,不解除任何关系。 +func (h *PhoneAssetUnbindImportHandler) processRow(ctx context.Context, taskRecord *model.PhoneAssetUnbindImportTask, row phoneAssetUnbindImportRow) (model.PhoneAssetUnbindImportResultItem, error) { + item := model.PhoneAssetUnbindImportResultItem{Line: row.Line, AssetIdentifier: row.AssetIdentifier} + if !row.ColumnCountMatched { + return failedPhoneAssetUnbindImportItem(item, constants.PhoneAssetUnbindImportRowErrorFormat), nil + } + if row.AssetIdentifier == "" { + return failedPhoneAssetUnbindImportItem(item, constants.PhoneAssetUnbindImportRowErrorIdentifier), nil + } + if len(row.Remark) > constants.PhoneAssetUnbindImportRemarkMaxLength { + return failedPhoneAssetUnbindImportItem(item, constants.PhoneAssetUnbindImportRowErrorRemarkTooLong), nil + } + assetType, assetID, err := h.resolveAsset(ctx, row.AssetIdentifier) + if err != nil { + return item, err + } + if assetType == "" { + return failedPhoneAssetUnbindImportItem(item, constants.PhoneAssetUnbindImportRowErrorAssetMissing), nil + } + item.AssetType = assetType + item.AssetID = assetID + // 每行独立事务:成功行提交,失败行回滚并保留原状。 + rowErr := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + locked, err := h.associationStore.WithTx(tx).LockValidByAsset(ctx, tx, assetType, assetID) + if err != nil { + return pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "锁定资产关联失败") + } + if len(locked) == 0 { + return pkgerrors.New(pkgerrors.CodeNotFound, constants.PhoneAssetUnbindImportRowErrorNoAssociation) + } + ids := make([]uint, 0, len(locked)) + phones := make([]string, 0, len(locked)) + for _, association := range locked { + ids = append(ids, association.ID) + phones = append(phones, association.Phone) + } + now := time.Now() + affected, err := h.associationStore.WithTx(tx).InvalidateByIDs(ctx, tx, ids, + constants.PhoneAssetAssociationInvalidateMethodCSVImport, taskRecord.UnbindReason, taskRecord.Creator, now) + if err != nil { + return pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "解除手机号资产关联失败") + } + if int(affected) != len(locked) { + return pkgerrors.New(pkgerrors.CodeNotFound, constants.PhoneAssetUnbindImportRowErrorNoAssociation) + } + item.UnboundCount = len(locked) + item.AssociatedPhones = phones + return h.appendRowAudit(ctx, tx, taskRecord, row, locked, now) + }) + if rowErr != nil { + var appErr *pkgerrors.AppError + if stderrors.As(rowErr, &appErr) && appErr.Code == pkgerrors.CodeNotFound { + return failedPhoneAssetUnbindImportItem(item, appErr.Message), nil + } + return item, rowErr + } + item.Status = constants.PhoneAssetUnbindImportItemStatusSuccess + return item, nil +} + +// resolveAsset 按资产标识定位资产,未命中返回空类型;解析口径与后台关联列表一致。 +func (h *PhoneAssetUnbindImportHandler) resolveAsset(ctx context.Context, identifier string) (string, uint, error) { + return assetSvc.ResolveIdentifier(ctx, h.assetIdentifierStore, h.iotCardStore, h.deviceStore, identifier) +} + +// appendRowAudit 在行事务内写单行解除审计,手机号一律脱敏,完整快照只进任务明细。 +func (h *PhoneAssetUnbindImportHandler) appendRowAudit( + ctx context.Context, + tx *gorm.DB, + taskRecord *model.PhoneAssetUnbindImportTask, + row phoneAssetUnbindImportRow, + locked []*model.PhoneAssetAssociation, + now time.Time, +) error { + if h.auditWriter == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "手机号资产解绑导入统一审计接缝未配置") + } + metadata := map[string]any{ + "import_task_id": taskRecord.ID, "import_task_no": taskRecord.TaskNo, + "line": row.Line, "unbound_count": len(locked), + } + if row.Remark != "" { + metadata["remark"] = row.Remark + } + resources := make([]audit.ResourceInput, 0, len(locked)+1) + for index, association := range locked { + relation := constants.AuditResourceRelationReference + if index == 0 { + relation = constants.AuditResourceRelationPrimary + } + associationID := strconv.FormatUint(uint64(association.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourcePhoneAssetAssociation, ID: &associationID, + Key: associationID, DisplayName: sanitizer.MaskPhone(association.Phone), + Relation: relation, Role: constants.AuditResourceRolePhoneAssetAssociationTarget, + IdentitySnapshot: map[string]any{ + "id": association.ID, "phone_masked": sanitizer.MaskPhone(association.Phone), + "asset_type": association.AssetType, "asset_id": association.AssetID, + "status": constants.PhoneAssetAssociationStatusInvalid, "source": association.Source, + "invalidated_at": now, "invalidation_method": constants.PhoneAssetAssociationInvalidateMethodCSVImport, + "invalidation_reason": taskRecord.UnbindReason, + }, + BeforeData: map[string]any{"status": constants.PhoneAssetAssociationStatusValid}, + AfterData: map[string]any{ + "status": constants.PhoneAssetAssociationStatusInvalid, "invalidated_at": now, + "invalidation_method": constants.PhoneAssetAssociationInvalidateMethodCSVImport, + "invalidation_reason": taskRecord.UnbindReason, + }, + SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1, + }) + } + assetID := strconv.FormatUint(uint64(locked[0].AssetID), 10) + resources = append(resources, audit.ResourceInput{ + Type: locked[0].AssetType, ID: &assetID, Key: assetID, DisplayName: row.AssetIdentifier, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePhoneAssetAssociationAsset, + IdentitySnapshot: map[string]any{"id": locked[0].AssetID}, + SubjectVisibility: constants.AuditSubjectInternalOnly, + }) + // 稳定事件 ID 由任务与行号决定,任务重复消费时同行为幂等重放。 + return h.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: audit.TaskEventID(constants.AuditResourcePhoneAssetUnbindImportTask, taskRecord.ID, fmt.Sprintf("item:%d", row.Line)), + ActionCode: constants.AuditActionPhoneAssetAssociationImported, + Summary: "导入解除手机号资产关联", + Result: constants.AuditResultSuccess, Metadata: metadata, + ScopeType: constants.AuditScopePlatform, Resources: resources, + }) +} + +func failedPhoneAssetUnbindImportItem(item model.PhoneAssetUnbindImportResultItem, reason string) model.PhoneAssetUnbindImportResultItem { + item.Status, item.Reason = constants.PhoneAssetUnbindImportItemStatusFailed, reason + return item +} + +// phoneAssetUnbindImportError 是任务级失败原因,与行级失败原因分开记录。 +type phoneAssetUnbindImportError string + +// Error 返回任务级失败原因原文。 +func (e phoneAssetUnbindImportError) Error() string { return string(e) } diff --git a/migrations/000223_add_phone_asset_association.down.sql b/migrations/000223_add_phone_asset_association.down.sql new file mode 100644 index 0000000..33128a0 --- /dev/null +++ b/migrations/000223_add_phone_asset_association.down.sql @@ -0,0 +1,25 @@ +-- 回滚手机号—资产关联表。 +-- 失效关系行同样承载业务事实:审计只写脱敏手机号,完整手机号只能由关系行本身证明, +-- 且批量解绑任务的逐行结果依赖解绑当时的关联快照,删表即不可重建。 +-- 因此存在任一记录(含已失效行)时禁止破坏性回滚,需人工核对后向前修复。 + +BEGIN; + +-- 同一事务内先取排他锁再检查并删表,避免检查与删表之间写入新事实。 +DO $$ +BEGIN + IF to_regclass('tb_phone_asset_association') IS NOT NULL THEN + LOCK TABLE tb_phone_asset_association IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM tb_phone_asset_association LIMIT 1) THEN + RAISE EXCEPTION 'tb_phone_asset_association 已存在手机号—资产关联事实(含已失效行),禁止删表回滚,请人工核对后向前修复'; + END IF; + END IF; +END +$$; + +DROP INDEX IF EXISTS idx_phone_asset_association_asset; +DROP INDEX IF EXISTS idx_phone_asset_association_phone; +DROP INDEX IF EXISTS uq_phone_asset_association_valid; +DROP TABLE IF EXISTS tb_phone_asset_association; + +COMMIT; diff --git a/migrations/000223_add_phone_asset_association.up.sql b/migrations/000223_add_phone_asset_association.up.sql new file mode 100644 index 0000000..9bb04b2 --- /dev/null +++ b/migrations/000223_add_phone_asset_association.up.sql @@ -0,0 +1,66 @@ +-- 手机号—资产当前有效关联。 +-- 既有事实只有「客户↔手机号」与「客户↔资产」两条互不相交的链路,无法表达单项资产的验证关系, +-- 后台查看、按资产解除、换绑迁移与批量解绑都缺少可依据的关联事实。 +-- 关联只能由 H5 短信验证建立(source 固定 h5_sms_verification),后台不提供创建或补录入口。 +-- iot_card 与 device 的 ID 空间独立,资产身份一律取 (asset_type, asset_id),同号互不覆盖也不合并计数。 +-- 失效走 status 置 0 并同时写入失效时间、方式与操作人,关系行本身保留为业务事实,不做物理或软删除。 +-- 单手机号最多 10 项有效关系,上限只统计 status = 1 的行;本迁移不回填任何历史关系。 +-- 不使用数据库外键,关联以 ID 保存并由应用层显式校验。 + +CREATE TABLE tb_phone_asset_association ( + id BIGSERIAL PRIMARY KEY, + phone VARCHAR(20) NOT NULL, + asset_type VARCHAR(20) NOT NULL, + asset_id BIGINT NOT NULL, + status SMALLINT NOT NULL DEFAULT 1, + source VARCHAR(30) NOT NULL DEFAULT 'h5_sms_verification', + established_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + invalidated_at TIMESTAMPTZ, + invalidation_method VARCHAR(30) NOT NULL DEFAULT '', + invalidation_reason VARCHAR(500) NOT NULL DEFAULT '', + invalidator BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + CONSTRAINT chk_phone_asset_association_phone CHECK (phone <> ''), + CONSTRAINT chk_phone_asset_association_asset_type CHECK (asset_type IN ('iot_card', 'device')), + CONSTRAINT chk_phone_asset_association_asset CHECK (asset_id > 0), + CONSTRAINT chk_phone_asset_association_status CHECK (status IN (0, 1)), + CONSTRAINT chk_phone_asset_association_source CHECK (source = 'h5_sms_verification'), + -- 有效关系不得带失效信息,已失效关系必须带失效时间:两者互为充要条件,避免出现半失效状态。 + CONSTRAINT chk_phone_asset_association_invalidation CHECK ( + (status = 1 AND invalidated_at IS NULL) + OR (status = 0 AND invalidated_at IS NOT NULL) + ) +); + +-- 同一手机号与同一 (资产类型, 资产ID) 同时只能有一条有效关系。 +-- 部分唯一索引既是幂等建联的结构性保证,也是并发同资产建联的兜底:冲突由应用映射为幂等成功。 +-- 迁移与「新号已存在的同资产有效关系」冲突时由本索引报错,换绑整次回滚。 +CREATE UNIQUE INDEX uq_phone_asset_association_valid + ON tb_phone_asset_association (phone, asset_type, asset_id) + WHERE status = 1 AND deleted_at IS NULL; +-- 十项上限按手机号计数,计数只读有效关系。 +CREATE INDEX idx_phone_asset_association_phone + ON tb_phone_asset_association (phone, status) + WHERE deleted_at IS NULL; +-- 列表、详情与两类导出按本批资产集合一次 IN 批量聚合,禁止逐资产查询。 +CREATE INDEX idx_phone_asset_association_asset + ON tb_phone_asset_association (asset_type, asset_id, status) + WHERE deleted_at IS NULL; + +COMMENT ON TABLE tb_phone_asset_association IS '手机号—资产当前有效关联,仅由 H5 短信验证建立,单手机号最多 10 项有效关系'; +COMMENT ON COLUMN tb_phone_asset_association.id IS '主键'; +COMMENT ON COLUMN tb_phone_asset_association.phone IS '已验证手机号,计数与唯一口径均以本列为准'; +COMMENT ON COLUMN tb_phone_asset_association.asset_type IS '资产类型 iot_card-物联网卡 device-设备,与 pkg/constants.AssetTypeIotCard/AssetTypeDevice 一致'; +COMMENT ON COLUMN tb_phone_asset_association.asset_id IS '资产ID,与 asset_type 共同构成资产身份,两类资产ID空间独立'; +COMMENT ON COLUMN tb_phone_asset_association.status IS '状态 0-已失效 1-有效,10 项上限只统计有效关系'; +COMMENT ON COLUMN tb_phone_asset_association.source IS '建立来源,固定为 h5_sms_verification;后台无创建或补录入口'; +COMMENT ON COLUMN tb_phone_asset_association.established_at IS '关联建立时间,即短信验证通过时间'; +COMMENT ON COLUMN tb_phone_asset_association.invalidated_at IS '失效时间,仅 status = 0 时非空'; +COMMENT ON COLUMN tb_phone_asset_association.invalidation_method IS '失效方式 backend_single-后台单项 backend_batch-后台批量 csv_import-CSV导入'; +COMMENT ON COLUMN tb_phone_asset_association.invalidation_reason IS '失效原因,后台解除必填,最长500字符'; +COMMENT ON COLUMN tb_phone_asset_association.invalidator IS '失效操作人账号ID,系统任务为 0'; +COMMENT ON COLUMN tb_phone_asset_association.created_at IS '创建时间'; +COMMENT ON COLUMN tb_phone_asset_association.updated_at IS '最近更新时间'; +COMMENT ON COLUMN tb_phone_asset_association.deleted_at IS '软删除时间,本表不使用:失效走 status 置 0 并保留关系行'; diff --git a/migrations/000224_add_phone_asset_unbind_import_task.down.sql b/migrations/000224_add_phone_asset_unbind_import_task.down.sql new file mode 100644 index 0000000..1a46de3 --- /dev/null +++ b/migrations/000224_add_phone_asset_unbind_import_task.down.sql @@ -0,0 +1,23 @@ +-- 回滚手机号—资产关联 CSV 解绑导入任务表。 +-- 任务行保存逐行解绑结果与解绑当时的完整手机号快照,删表即不可重建, +-- 因此存在任一记录(含软删行)时禁止破坏性回滚,需人工核对后向前修复。 + +BEGIN; + +-- 同一事务内先取排他锁再检查并删表,避免检查与删表之间写入新事实。 +DO $$ +BEGIN + IF to_regclass('tb_phone_asset_unbind_import_task') IS NOT NULL THEN + LOCK TABLE tb_phone_asset_unbind_import_task IN ACCESS EXCLUSIVE MODE; + IF EXISTS (SELECT 1 FROM tb_phone_asset_unbind_import_task LIMIT 1) THEN + RAISE EXCEPTION 'tb_phone_asset_unbind_import_task 已存在解绑导入任务事实(含软删行),禁止删表回滚,请人工核对后向前修复'; + END IF; + END IF; +END +$$; + +DROP INDEX IF EXISTS idx_phone_asset_unbind_import_task_status_created; +DROP INDEX IF EXISTS uq_phone_asset_unbind_import_task_no; +DROP TABLE IF EXISTS tb_phone_asset_unbind_import_task; + +COMMIT; diff --git a/migrations/000224_add_phone_asset_unbind_import_task.up.sql b/migrations/000224_add_phone_asset_unbind_import_task.up.sql new file mode 100644 index 0000000..e5872b6 --- /dev/null +++ b/migrations/000224_add_phone_asset_unbind_import_task.up.sql @@ -0,0 +1,63 @@ +-- 手机号—资产关联 CSV 解绑导入任务。 +-- 仓库不存在统一导入任务框架,解绑导入沿用已有独立导入场景的做法:独立成表、独立队列, +-- 与店铺负责人导入任务分开,避免复用其启动补偿与任务类型口径。 +-- 任务级解绑原因是任务自身的输入事实:Worker 载荷只带任务 ID,失效原因必须能从任务行读回。 +-- 逐行结果明细持久化解绑当时的完整手机号快照:解绑后关系立即失效, +-- 只有快照能事后满足「批量任务结果展示完整手机号」;任务明细是业务事实表,按数据范围保护,不属于日志。 +-- 逐行独立事务,成功行提交、失败行保留原状,任务级失败不产生行明细,不设行数硬上限。 +-- 不使用数据库外键,关联以 ID 保存并由应用层显式校验。 + +CREATE TABLE tb_phone_asset_unbind_import_task ( + id BIGSERIAL PRIMARY KEY, + task_no VARCHAR(50) NOT NULL, + file_name VARCHAR(255) NOT NULL DEFAULT '', + storage_key VARCHAR(500) NOT NULL, + unbind_reason VARCHAR(500) NOT NULL DEFAULT '', + status INTEGER NOT NULL DEFAULT 1, + total_count INTEGER NOT NULL DEFAULT 0, + success_count INTEGER NOT NULL DEFAULT 0, + fail_count INTEGER NOT NULL DEFAULT 0, + result_items JSONB NOT NULL DEFAULT '[]'::jsonb, + error_message TEXT NOT NULL DEFAULT '', + creator_name VARCHAR(100) NOT NULL DEFAULT '', + started_at TIMESTAMPTZ, + completed_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(), + deleted_at TIMESTAMPTZ, + CONSTRAINT chk_phone_asset_unbind_import_task_status CHECK (status IN (1, 2, 3, 4)), + CONSTRAINT chk_phone_asset_unbind_import_task_counts CHECK ( + total_count >= 0 AND success_count >= 0 AND fail_count >= 0 + AND success_count + fail_count <= total_count + ) +); + +CREATE UNIQUE INDEX uq_phone_asset_unbind_import_task_no + ON tb_phone_asset_unbind_import_task (task_no) + WHERE deleted_at IS NULL; +CREATE INDEX idx_phone_asset_unbind_import_task_status_created + ON tb_phone_asset_unbind_import_task (status, created_at DESC) + WHERE deleted_at IS NULL; + +COMMENT ON TABLE tb_phone_asset_unbind_import_task IS '手机号—资产关联 CSV 解绑导入任务,逐行独立事务并保留逐行解绑手机号快照'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.id IS '主键'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.task_no IS '导入任务编号,未删除记录内唯一'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.file_name IS '上传的源 CSV 文件名'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.storage_key IS '源 CSV 对象存储Key'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.unbind_reason IS '任务级解绑原因,写入每次解除的失效原因与审计;行内不填原因'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.status IS '任务状态 1-待处理 2-处理中 3-已完成 4-失败'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.total_count IS '任务数据行总数,任务级失败时为 0'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.success_count IS '处理成功行数'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.fail_count IS '处理失败行数'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.result_items IS '逐行结果明细(含解绑当时的完整手机号快照),行号自数据首行起计;任务级失败为空数组'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.error_message IS '任务级失败原因,与行级失败原因分开记录'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.creator_name IS '任务创建人名称快照'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.started_at IS '任务开始处理时间'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.completed_at IS '任务处理完成时间'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.creator IS '创建人账号ID'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.updater IS '最近更新人账号ID'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.created_at IS '创建时间'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.updated_at IS '最近更新时间'; +COMMENT ON COLUMN tb_phone_asset_unbind_import_task.deleted_at IS '软删除时间'; diff --git a/openspec/changes/add-phone-asset-associations/design.md b/openspec/changes/add-phone-asset-associations/design.md deleted file mode 100644 index 697a91e..0000000 --- a/openspec/changes/add-phone-asset-associations/design.md +++ /dev/null @@ -1,32 +0,0 @@ -## Context - -现有 H5 已有手机号绑定与全局开关;新关系不能由后台或换货推测建立。 - -## Decisions - -- 新表以手机号、资产和有效状态保存关系,并对当前有效关系实施十项计数与唯一约束。 -- 验证/换绑在事务内锁定相关手机号关系;换绑先检查总数再整体迁移。 -- 批量导入复用逐行任务,读取按资产数据范围,日志仅保留脱敏手机号。 - -## H5 与后台动作契约 - -### H5 建联与换绑 - -- 既有登录签发 token 时,按全局强制绑定开关及当前访问资产查询有效关联;开关开启且当前手机号未关联该资产时,响应 `need_bind_phone=true`,并限制依赖该资产的业务入口直至验证成功。开关关闭时不创建新关联,也不删除历史关联。 -- 既有 `bind_phone` 短信场景验证成功后,事务中锁定手机号和资产有效关系;同一手机号—资产已有效关联则幂等成功,否则先计算该手机号有效资产数。达到十项返回“该手机号最多关联10项有效资产”,不写关联或手机号变更。 -- 既有 `change_phone_old`、`change_phone_new` 两个验证码均验证成功后,事务锁定旧、新手机号关系;计算新号码当前有效关系数加旧号码待迁移有效关系数,超过十项则整体失败。通过时把旧号码全部有效关系原子失效/迁移至新号码,并记录旧、新号码脱敏审计;任一步失败不变更任一关系。 - -### 后台查看与解除 - -- `GET /phone-asset-associations`:仅超级管理员、平台用户,先按资产数据范围过滤;支持资产标识、手机号(仅权限内完整值)、关联状态、创建时间筛选,返回资产、手机号、建立时间、建立来源固定为 `h5_sms_verification` 和状态。 -- `DELETE /phone-asset-associations/:id`:请求必须含 `reason`(1~500 字符)和二次确认;锁定指定有效关联后复核资产数据范围,标记失效并审计操作者、资产、脱敏手机号、原因和时间。后台没有创建/补录接口。 -- `POST /phone-asset-associations/batch-unbind`:请求去重的资产集合、原因、二次确认;每个资产独立解除其全部有效关系,返回成功数、失败数与逐资产结果。越权、资产不存在和已无有效关系对调用方使用统一失败文案。 -- Excel 解绑复用既有异步导入:每行按资产标识处理,独立授权与事务,任务持久化行号、结果和失败原因;一行失败不得回滚已成功行。 - -### 边界 - -- 换货、资产导入、后台资产编辑和个人客户主手机号历史记录均不得创建、推断、复制或迁移该关系。新换货资产在首次 H5 访问时才依当前开关走验证。 - -## Migration Plan - -新增成对迁移,不回填;隔离库验证开关、上限、换绑、权限解绑、换货边界及 up/down/up。 \ No newline at end of file diff --git a/openspec/changes/add-phone-asset-associations/proposal.md b/openspec/changes/add-phone-asset-associations/proposal.md deleted file mode 100644 index d2386d6..0000000 --- a/openspec/changes/add-phone-asset-associations/proposal.md +++ /dev/null @@ -1,25 +0,0 @@ -## Scope - -- 迭代编号:`AUG26-009`。 - -## Why - -现有全局绑定不能保留手机号与资产的验证关系,也无法安全支持按资产解除。 - -## What Changes - -- 新增 H5 验证建立的手机号—资产关联及十项上限。 -- 新增客户原子换绑和后台受权限控制的单项/批量/Excel 解绑。 -- 保持全局强制绑定,换货不迁移关系,不回填历史。 - -## Capabilities - -### New Capabilities -- `phone-asset-association`: 已验证手机号与资产关联。 - -### Modified Capabilities -- 无。 - -## Impact - -影响 H5 认证、资产查询、短信、导入、审计和 Schema。 \ No newline at end of file diff --git a/openspec/changes/add-phone-asset-associations/specs/phone-asset-association/spec.md b/openspec/changes/add-phone-asset-associations/specs/phone-asset-association/spec.md deleted file mode 100644 index aa65451..0000000 --- a/openspec/changes/add-phone-asset-associations/specs/phone-asset-association/spec.md +++ /dev/null @@ -1,21 +0,0 @@ -## Purpose - -保存仅由 H5 短信验证建立的手机号—资产当前有效关系,使全局强制绑定能逐资产执行,同时提供受资产数据范围控制的后台查看与解除能力。 - -## ADDED Requirements - -### Requirement: H5 验证建立关联与数量上限 -系统 SHALL 保留既有全局 H5 强制绑定开关。开关开启时,客户首次登录一项未关联当前手机号的资产必须完成短信验证后建立关系;已关联资产不重复验证。开关关闭时登录不要求验证且不新增关系,已有关系保留。关联只能由 H5 验证建立,单手机号最多关联十项当前有效资产;上线不回填历史客户—资产关系。 - -#### Scenario: 第十一项资产验证 -- **WHEN** 已关联十项有效资产的手机号验证另一项资产 -- **THEN** 系统拒绝本次新关联,已有十项关系不变 - -### Requirement: 换绑、查看和解绑 -客户更换手机号时 MUST 同时验证旧、新号码,并原子将旧手机号全部有效资产关系迁至新号码;新号码现有关联数加迁移数超过十项时整次失败。后台仅超级管理员和平台用户可在资产数据范围内查看完整关联手机号、单项解绑、勾选批量解绑及 Excel 解绑;后台不得补录。 - -单项解绑必须指定一条资产—手机号关系。批量和 Excel 按资产解除该资产全部当前有效手机号关系,必须二次确认、填写原因、逐条审计;逐资产独立执行,返回成功数、失败数和统一越权失败文案。换货不得迁移该关系,新资产首次访问仍按全局开关验证。 - -#### Scenario: 换绑超过上限 -- **WHEN** 客户换绑后新手机号关联总数将超过十项 -- **THEN** 系统不迁移任何关系且旧、新手机号关系均保持原状 diff --git a/openspec/changes/add-phone-asset-associations/tasks.md b/openspec/changes/add-phone-asset-associations/tasks.md deleted file mode 100644 index 91181da..0000000 --- a/openspec/changes/add-phone-asset-associations/tasks.md +++ /dev/null @@ -1,12 +0,0 @@ -## 1. 关联与 H5 -- [ ] 1.1 追踪 H5 登录/短信验证、客户换绑、资产数据范围和换货调用链。 -- [ ] 1.2 新增关联表、有效关系唯一/计数索引的成对迁移、模型和审计常量。 -- [ ] 1.3 实现全局开关下的验证建联、十项限制和原子换绑。 - -## 2. 后台解绑 -- [ ] 2.1 实现资产详情/列表完整手机号投影、单项解绑、二次确认批量和逐行 Excel 解绑及统一越权结果。 -- [ ] 2.2 保持换货不迁移,补齐路由和 OpenAPI。 - -## 3. 验证 -- [ ] 3.1 隔离库验证上限、换绑回滚、权限、批量部分成功、换货边界和 up/down/up。 -- [ ] 3.2 运行 `gofmt -w`、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-phone-asset-associations --strict` 与 `openspec doctor --json`;自动化测试按项目决策为 N/A。 \ No newline at end of file diff --git a/openspec/changes/add-phone-asset-associations/.openspec.yaml b/openspec/changes/archive/2026-09-15-add-phone-asset-associations/.openspec.yaml similarity index 100% rename from openspec/changes/add-phone-asset-associations/.openspec.yaml rename to openspec/changes/archive/2026-09-15-add-phone-asset-associations/.openspec.yaml diff --git a/openspec/changes/archive/2026-09-15-add-phone-asset-associations/design.md b/openspec/changes/archive/2026-09-15-add-phone-asset-associations/design.md new file mode 100644 index 0000000..15cb0f4 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-phone-asset-associations/design.md @@ -0,0 +1,162 @@ +## Context + +以下为开工核查确认的现状约束,全部来自实际代码、配置与生效库: + +- `need_bind_phone` 全仓只有一个计算点 `internal/service/client_auth/service.go:892-931`,口径是「客户无 `is_primary=true AND status=1` 的 `tb_personal_customer_phone` 记录」且全局开关 `cfg.Client.RequirePhoneBinding` 为真;入参 `assetType/assetID` 只写入 JWT(`:910`),不参与任何判定。开关读取点全仓仅 `:899` 一处,默认 `true`(`pkg/config/config.go:107`、`defaults/config.yaml:110`)。 +- `need_bind_phone` 在后端没有任何消费方;`internal/middleware/personal_auth.go:34-113` 只做 JWT 验签与 Redis token 比对,不读 `claims.Phone` 做判断。 +- 登录 JWT 已含 `customer_id/phone/asset_type/asset_id`(`pkg/auth/jwt.go:10-16`),资产类型取值为 `iot_card` / `device`(`pkg/constants/iot.go:142-143`),请求上下文可经 `middleware.GetCurrentAsset`(`personal_auth.go:128-133`)取出。 +- `BindPhone`(`service.go:294-378`)现状在客户已有主手机号时立即返回 `CodeAlreadyBoundPhone`(`:301-308`);成功后只写 `tb_personal_customer_phone`,不触碰任何资产关系。 +- `ChangePhone`(`service.go:381-471`)两个验证码在事务外校验(`:410`、`:415`,校验即消费),事务只包「锁定主号行 → 查新号占用 → 原地 `UPDATE phone` 列(`:448-453`)→ 审计」。 +- 手机号唯一事实在 `tb_personal_customer_phone`(`internal/model/personal_customer_phone.go:11-23`);`PersonalCustomer` 模型无 phone 字段(`internal/model/personal_customer.go:9-17`);生效库仍有遗留列 `tb_personal_customer.phone`,无 Go 模型映射(`internal/store/postgres/personal_customer_store.go:42` 注释)。 +- 资产读侧 DTO(`internal/model/dto/{iot_card,device,asset}_dto.go`)中手机号零命中,没有完整手机号投影先例。 +- 设备导出的列组反解依赖固定常量:`internal/exporter/device_scene.go:14-18`(base 6 / group 5 / tail 5)与 `cardGroupCountFromHeaders:427-434` 的整除判断。 +- 审计 writer 对既有手机号资源写**完整手机号**(`internal/infrastructure/audit/writer.go:397-403`、`:614-618`);`pkg/sanitizer/sanitizer.go:16-22` 的字段名脱敏清单不含 phone,访问日志不会自动脱敏手机号。 +- 仓库**不存在统一导入任务框架**,集中的只有 `QueueForTaskType`、worker 注册表与发布门禁未完成任务表清单三处(结论出处 `openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/design.md:6`,**非 AUG26-008**)。 +- 「手机号—资产」关联事实在全仓代码、迁移与生效库中命中数为 0:既有关系只有「客户↔手机号」与「客户↔资产」两条互不相交的链路。 +- 换货完成链路(`internal/service/exchange/service.go:1084-1086` → `internal/service/customer_binding/service.go:354-390`)只迁移 device/iccid 绑定,全链路手机号零命中;后台不存在通用「编辑资产基本信息」接口。 + +## Goals / Non-Goals + +**Goals:** + +- 以一条 phone×asset 直连事实承载「仅由 H5 验证建立」的当前有效关系,并使其计数、迁移、解绑在单事务内闭合。 +- H5 契约变更有明确的上线顺序约束,不产生「提示要验证却无法建联」的死锁窗口。 +- 后台读侧在资产数据范围内展示完整手机号,同时保证审计、日志与错误只出现脱敏值。 + +**Non-Goals:** + +- 不新增后端授权 gate,不把关联变成资产业务入口的前置条件。 +- 不重构既有 `无权限操作该资源或资源不存在` 内联字面量(44 处),不重构既有三处私有手机号脱敏函数。 +- 不隔离短信验证码场景(现状 `scene` 仅为 DTO 字面量,验证码 Redis key 仅按手机号区分)。 +- 不写遗留列 `tb_personal_customer.phone`。 +- 不实现 H5 页面本身;只交付后端接口与其契约约束。 + +## Decisions + +### 1. 关联表结构以 phone 字符串为计数口径 + +新表保存 `asset_type`、`asset_id`、`phone`(字符串)、`status`、建立时间、建立来源与失效信息。 + +- **必须含 `asset_type`**:`iot_card` 与 `device` 的 ID 空间独立,同号会互相覆盖。资产身份一律取 `(asset_type, asset_id)`,与 JWT 及既有 `customer_binding` 的资产口径一致。 +- **`phone` 存字符串**:PRD 的计数与唯一口径都是「手机号」,且换绑是原地改写手机号行,字符串口径使「上限 = 该号码的有效关系数」与换绑迁移都退化为直白的集合运算。 +- **建立来源固定为 `h5_sms_verification`**:后台不得补录,来源字段是这一约束的可核对事实。 +- 索引三件套:部分唯一索引 `(phone, asset_type, asset_id) WHERE status = <有效>`;按 `phone` 计数的索引;按 `(asset_type, asset_id)` 集合批量查询的索引。 +- `status` 用 int 表达有效/失效(ENG-STATE-001),失效信息记录失效时间与失效方式。 + +### 2. `need_bind_phone` 三支判定,且明确定位为提示型字段 + +`issueLoginToken` 改为: + +1. 无主手机号 → `true`; +2. 有主手机号但当前手机号未与当前访问资产存在有效关系 → `true`; +3. 已存在有效关系 → `false`; +4. 全局开关关闭 → 恒 `false`,且**不查询、不创建、不删除**任何关系。 + +关联查询使用 JWT 的 `asset_type/asset_id`,**不得依赖 `phone` claim**:`phone` 是登录时快照,换绑后到下次登录前仍是旧号。 + +开关关闭分支必须完全短路,否则会出现「关闭开关却写库」的越权写入。 + +**本项不新增后端 gate。** 现状确认「未绑定不得访问资产业务入口」由前端实施:`need_bind_phone` 无任何后端消费方,认证中间件不读手机号,手机号在链路上只用于展示(`internal/handler/app/client_asset.go:224`)。关系缺失不改变任何资源授权——授权始终由 `OwnsAsset` 与 `ApplyShopFilter` 决定。 + +### 3. bind-phone 扩展语义与上线顺序(G1 裁定) + +`BindPhone` 的分支改为: + +| 客户状态 | 提交号码 | 处理 | +| --- | --- | --- | +| 无主手机号 | 任意未被他人占用 | 建立账号手机号,并在存在资产身份时建立关联 | +| 已有主手机号 | 等于主手机号 | 验证码有效 → **幂等建联**;已有关联则返回成功;不修改账号手机号 | +| 已有主手机号 | 不等于主手机号 | 仍拒绝(换号必须走 `change-phone`) | +| 任意 | 请求不含当前访问资产身份 | 只完成账号手机号绑定或幂等成功,**不建立关联** | + +建联路径在事务内锁定手机号行后先计数:达到十项返回「该手机号最多关联10项有效资产」,不写关联也不改手机号。 + +**上线顺序约束(必须写进发布说明):** 后端不再对「已有主号」直接拒绝,因此本 Change 与 H5 **必须同批上线**;H5 必须支持预填已在账号上的手机号并对该号码发验证码。若后端先行或 H5 未改造,存量客户会落入 `need_bind_phone=true` 但提交任何号码都被拒的死锁。 + +### 4. 并发与加锁 + +- **固定加锁顺序:按手机号行 `id ASC` 加锁**(等价的实现是固定「旧号 → 新号」,但必须与 `id ASC` 结果一致,避免两条路径得出相反顺序)。`bind-phone` 只锁一行,`change-phone` 锁两行;统一顺序后两者不会形成 A→B / B→A 死锁环。既有先例:AUG26-008 的「统一加锁顺序」实施决策。 +- **同号并发建联以手机号行 `FOR UPDATE` 串行**:计数与插入在同一临界区内,保证不越过十项上限。 +- **同 `(phone, asset_type, asset_id)` 并发由部分唯一索引兜底**,唯一冲突映射为幂等成功而非报错。 +- 加锁顺序规则写在用例层(Application/旧 Service),事务内不得再发起外部 I/O(ENG-TX-001)。 + +### 5. 换绑原子迁移与冲突边界 + +在同一事务内、加锁之后: + +1. 计算「新号码现有有效关系数 + 旧号码待迁移有效关系数」; +2. 超过十项 → 返回「该手机号最多关联10项有效资产」并整次失败,旧、新关系均不变; +3. 通过 → 将旧号码全部有效关系原子迁移至新号码(关系本身保持有效,只换归属号码); +4. **若迁移与「新号码已存在的同资产有效关系」冲突**(例如新号码曾是他人已停用手机号记录持有者的验证号码),部分唯一索引报错 → 整次换绑失败并回滚,旧、新关系均不变。该边界是**有意接受**的:失败是安全的、可人工处理的,好过产生两条并存的有效关系。 + +事务边界与既有实现一致:验证码在事务外校验(消费即删除),关系迁移与账号手机号改写同事务。**不写遗留列 `tb_personal_customer.phone`**(无模型映射,写它等于制造无法读取的事实漂移)。 + +审计只写脱敏手机号。 + +### 6. 读侧投影、脱敏与导出列组兼容 + +- **列表与详情一次 `IN` 聚合**:新增按 `(asset_type, []assetID)` 的批量读,返回 `map[assetID][]phone`,列表按当页资产集合一次查询后装配,禁止逐资产查询。详情为单资产单次查询。 +- **两个导出场景都补「关联手机号」列**(`internal/exporter/iot_card_scene.go:39-41` 与 `internal/exporter/device_scene.go:47`),导出按本批资产集合批量查询。 +- **设备导出新列并入尾部并令 `deviceExportTailHeaderCount` 递增**,`buildDeviceExportRow` 同步追加。反解必须保留**旧表头兼容分支**:先用新尾列数试整除,不整除时回退到旧尾列数再判定;否则历史任务凭 `ResolvedHeaders` 重导出时列组数会算成 0。 +- **完整手机号只在读侧**:资产列表、详情、两类导出与批量任务结果向具备资产数据权限的账号返回完整值。 +- **审计、日志与错误只写脱敏值**:新建动作码与资源定义,**不得复用会写明文手机号的既有审计资源路径**(`writer.go:397-403`、`:614-618` 对既有手机号资源写完整值)。脱敏口径沿用既有前 3 位 + `****` + 后 4 位。 + +### 7. 批量任务与 CSV 导入 + +- 无统一导入框架,新增第 6 个独立场景(详见 tasks 2.5 的 B1–B16 全清单)。 +- **任务明细持久化解绑当时的完整手机号快照**:解绑后关系即失效,只有快照能事后满足「批量任务结果展示完整手机号」;任务明细是业务事实表,读接口按数据范围保护,不属于日志。 +- **上传格式 CSV**,沿用既有 BOM 去除与 GBK 回退解码。 +- **列:资产标识(必填,复用既有资产标识解析)+ 备注(可选)**;解绑原因由任务级必填字段与二次确认提供,不设行内原因列。 +- 逐行独立事务、成功行提交、失败行保留原状、不设行数硬上限、任务级失败不产生行明细;行号自数据首行起计。 + +### 8. 权限与统一文案 + +- 路由组级 gate 沿用既有超管/平台先例(`internal/routes/wecom.go:16-21`)。 +- 业务层每次解绑仍按资产数据范围复核(ENG-AUTHZ-001),不得只依赖路由角色中间件。 +- 单项解绑以路径主键即「必须指定一条关系」;批量与 CSV 按资产解除全部有效关系,逐资产独立结果。 +- 三态(越权 / 资产不存在 / 已无有效关系)统一为 `无权限操作该资源或资源不存在`,**新增常量**供新用例使用;既有 44 处内联字面量按 As-Is 保留,不在本 Change 统一。三态收敛同时满足「无权限与不存在不得形成可枚举差异」。 + +## 动作契约 + +### H5 + +- `POST /api/c/v1/auth/bind-phone`(既有):已有主号且提交号码与主号一致、验证码有效时幂等建立当前资产关联;不一致仍拒绝;无资产身份时只处理账号手机号。响应形状不变。 +- `POST /api/c/v1/auth/change-phone`(既有):新号总数校验通过后原子迁移旧号全部有效关系;冲突或超限整次失败并回滚。 +- 登录响应 `need_bind_phone` 按第 2 节三支判定;字段语义扩宽但形状不变。 + +### 后台 + +- `GET /phone-asset-associations`:仅超级管理员、平台用户,先按资产数据范围过滤;支持资产标识、手机号、关联状态、创建时间筛选,返回资产、手机号、建立时间、建立来源与状态。 +- `DELETE /phone-asset-associations/:id`:路径主键即指定一条关系;必须二次确认并填写原因(1~500 字符);锁定关系后复核资产数据范围,标记失效并逐条审计。 +- `POST /phone-asset-associations/batch-unbind`:去重的资产集合、原因与二次确认;每项资产独立解除全部有效关系,返回成功数、失败数与逐项结果。 +- CSV 解绑导入:任务级原因与二次确认必填;逐行按资产标识处理,行内失败不影响其他行。 +- 不提供创建或补录入口。 + +## Risks / Trade-offs + +- [H5 未同批上线导致死锁] → 契约与发布说明同时声明同批上线;后端不再直接拒绝已有主号是本 Change 的显式破坏性变更。 +- [存量客户提示面扩大] → `need_bind_phone` 取值面扩大是预期行为,必须与 H5 同批;不通过灰度开关掩盖。 +- [并发建联越过十项上限] → 手机号行 `FOR UPDATE` 串行化计数与插入;唯一索引兜底同资产并发并映射为幂等成功。 +- [换绑迁移撞唯一索引] → 整次换绑失败回滚,旧、新关系均不变;该边界有意接受,不引入自动合并或丢弃。 +- [禁用手机号记录被复用导致编号碰撞] → 同一失败边界覆盖,事务回滚后由人工处理,不产生并存有效关系。 +- [设备导出列组反解回归] → 新列并入尾部并递增尾列数,同时保留旧表头兼容分支,回归覆盖历史任务重导出。 +- [审计继承明文手机号] → 新动作码与资源定义独立,不复用既有写明文值的手机号资源路径。 +- [脱敏被访问日志绕过] → 字段名脱敏清单不含 phone,故新代码必须显式脱敏,不能依赖通用清理器。 +- [列表 N+1] → 强制一次批量聚合;验收以查询次数而非响应内容为准。 +- [换货/导入/后台编辑误写关系] → 三处路径已确认零手机号逻辑,采用反向断言守护,不接受「顺手补写」。 +- [上下文证据缺失] → 实施不触碰证据文件,归档并同步主 Spec(含新路由索引)后必须补齐,否则 `scripts/context-health.sh` 失败。 + +## 溯源说明 + +「仓库不存在统一导入任务框架」的结论出自 `openspec/changes/archive/2026-09-14-add-shop-salesperson-groups/design.md:6`;AUG26-008 的归档文档通篇不涉及导入框架。本 Change 的导入场景改动面以 `add-shop-salesperson-groups` 已落地的 `shop_business_owner_import` 为模板。 + +## Migration Plan + +1. 新增成对迁移(`.up.sql`/`.down.sql`)创建关联表与三类索引,编号按实施时 `migrations/` 目录最大编号顺延;`down` 带有效性守卫。 +2. 不回填历史。开关关闭时不查询、不创建、不删除关系。 +3. 按 ENG-TEST-001 在维护者指定的 `junhong_cmp_test` PostgreSQL + Redis DB 6 验证;fixture 只增删本 Change 自己的记录,禁止重置整库。 +4. 单批发布:新表对旧代码无影响;本 Change 与 H5 必须同批上线。 + +## 归档后动作(不属实施任务) + +实施阶段不修改 `docs/verification/context-reset/requirement-evidence.json` 与 `entry-capability-requirement-matrix.json`。归档并同步主 Spec(含新路由索引)之后,必须补齐这两个文件,否则 `scripts/context-health.sh` 失败。 diff --git a/openspec/changes/archive/2026-09-15-add-phone-asset-associations/proposal.md b/openspec/changes/archive/2026-09-15-add-phone-asset-associations/proposal.md new file mode 100644 index 0000000..54d4a39 --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-phone-asset-associations/proposal.md @@ -0,0 +1,29 @@ +## Scope + +- 迭代编号:`AUG26-009`。 +- 需求追溯:PRD-08-011(仅作追溯)。 + +## Why + +现有全局绑定只按客户记一项主手机号,既不能保留手机号与单项资产的验证关系,也无法安全支持按资产查看与解除;后台、换货与批量路径都没有可依据的关联事实。 + +## What Changes + +- 新增仅由 H5 短信验证建立的手机号—资产当前有效关系及十项上限;`iot_card` 与 `device` 分别计为独立资产,即使两者 ID 相同。 +- `need_bind_phone` 由客户维度扩为「全局开关开启且当前主手机号未关联当前访问资产」;开关关闭时不查询、不创建、不删除任何关系。 +- **BREAKING**(H5 认证契约)`bind-phone` 不再对「已有主号」直接拒绝:已有主号且提交号码与主号一致、验证码有效时,幂等建立当前访问资产与主手机号的关联。**H5 必须与本 Change 同批上线**并支持预填已在账号上的手机号,否则会出现 `need_bind_phone=true` 却无法建联的死锁。 +- 新增客户原子换绑:新号现有有效关系数加旧号待迁移数超过十项时整次失败,新旧关系均不变。 +- 新增后台受权限控制的单项解绑、勾选批量解绑与 CSV 导入解绑;解绑任务明细持久化解绑当时的完整手机号快照。 +- 保持全局强制绑定;**不新增后端访问 gate**,缺关系不改变任何资源授权;换货不迁移关系,不回填历史。 + +## Capabilities + +### New Capabilities +- `phone-asset-association`: 已验证手机号与资产关联。 + +### Modified Capabilities +- 无。 + +## Impact + +影响 H5 认证契约与上线顺序、资产列表/详情/导出读侧、短信验证、CSV 导入场景族、审计脱敏与 Schema。实施不修改上下文证据文件;归档并同步主 Spec(含新路由索引)后必须补齐 `docs/verification/context-reset/` 证据,否则 `scripts/context-health.sh` 失败。 diff --git a/openspec/changes/archive/2026-09-15-add-phone-asset-associations/specs/phone-asset-association/spec.md b/openspec/changes/archive/2026-09-15-add-phone-asset-associations/specs/phone-asset-association/spec.md new file mode 100644 index 0000000..308716e --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-phone-asset-associations/specs/phone-asset-association/spec.md @@ -0,0 +1,123 @@ +## Purpose + +保存仅由 H5 短信验证建立的手机号—资产当前有效关系,使全局强制绑定能逐资产执行,同时提供受资产数据范围控制的后台查看与解除能力。 + +## ADDED Requirements + +### Requirement: H5 验证建立关联与数量上限 +系统 SHALL 保留既有全局 H5 强制绑定开关。开关开启时,客户首次登录一项未关联当前手机号的资产必须完成短信验证码校验后才建立关系;已关联资产不重复验证。开关关闭时登录不要求验证,且不查询、不新增、不删除任何关系,已有关系保留。关联只能由 H5 验证建立,卡(`iot_card`)与设备(`device`)分别计为独立资产,单手机号最多关联十项当前有效资产;上限只统计当前有效关系,已失效关系不占用额度。上线不回填历史客户—资产关系。 + +#### Scenario: 第十一项资产验证 +- **WHEN** 已关联十项有效资产的手机号验证第十一项资产 +- **THEN** 系统拒绝本次新关联,已有十项关系不变 + +#### Scenario: 开关关闭后登录未关联资产 +- **WHEN** 全局强制绑定开关关闭,客户登录一项未关联其手机号的资产 +- **THEN** 系统不要求验证、不新增关系,且既有关系不被删除 + +#### Scenario: 卡与设备分别计数 +- **WHEN** 同一手机号关联了 `iot_card` 类型 ID 为 5 的资产与 `device` 类型 ID 为 5 的资产 +- **THEN** 两者计为两项资产,互不覆盖也不合并计数 + +### Requirement: 登录提示判定 +系统 SHALL 在登录响应中以 `need_bind_phone` 表示当前访问资产是否仍需完成手机号验证,判定依据当前访问资产身份与该手机号之间的有效关系。开关开启时:客户无主手机号,或其主手机号未与当前访问资产存在有效关系,返回 `true`;已存在有效关系返回 `false`。开关关闭时恒为 `false`。该字段 MUST 只作为前端提示,系统 MUST NOT 因缺少该关系拒绝任何资产业务入口。 + +#### Scenario: 已有手机号但资产未关联 +- **WHEN** 客户已有主手机号,登录一项该手机号未关联的资产 +- **THEN** 响应 `need_bind_phone=true` + +#### Scenario: 已关联资产再次登录 +- **WHEN** 客户登录一项其主手机号已关联的资产 +- **THEN** 响应 `need_bind_phone=false` 且不要求重复验证 + +#### Scenario: 缺关系不拦截业务接口 +- **WHEN** 客户在 `need_bind_phone=true` 状态下调用其有权访问的资产业务接口 +- **THEN** 系统按既有资产归属与数据范围规则授权,不因缺少该关系返回拒绝 + +### Requirement: H5 绑定与换绑的关联写入 +系统 SHALL 在 H5 短信验证成功后建立或迁移关联。客户已有主手机号、提交号码与主手机号一致且验证码有效时,系统 MUST 幂等建立当前访问资产与该手机号的关系,不修改账号手机号;提交号码与主手机号不一致时仍拒绝绑定。请求不含当前访问资产身份时,系统只完成账号手机号绑定,不建立关联。同一手机号与同一资产已存在有效关系时,重复验证返回成功且不产生第二条关系。 + +客户更换手机号时 MUST 同时验证旧、新号码,并在同一事务内将旧手机号全部有效资产关系迁移至新号码。迁移前先比较「新号码现有有效关系数 + 旧号码待迁移有效关系数」,超过十项时整次失败且旧、新关系均保持原状。迁移与「新号码已存在的同资产有效关系」冲突时,整次换绑失败并回滚,旧、新关系均不变。 + +#### Scenario: 已有主号验证建联 +- **WHEN** 客户已有主手机号 P,提交 P 与有效验证码,且当前登录会话指向资产 A +- **THEN** 系统建立 P 与 A 的有效关系,账号手机号仍为 P + +#### Scenario: 提交号码与主号不一致 +- **WHEN** 客户已有主手机号,提交另一号码与有效验证码 +- **THEN** 系统拒绝该绑定,不建立任何新关系 + +#### Scenario: 重复验证同一资产 +- **WHEN** 同一手机号对已有关联的同一资产再次完成验证 +- **THEN** 系统返回成功且不产生第二条有效关系 + +#### Scenario: 换绑超过上限 +- **WHEN** 客户换绑后新手机号关联总数将超过十项 +- **THEN** 系统不迁移任何关系且旧、新手机号关系均保持原状 + +#### Scenario: 换绑迁移与既有关系冲突 +- **WHEN** 新手机号已存在与待迁移资产相同的有效关系 +- **THEN** 整次换绑失败并回滚,旧、新手机号关系均保持原状 + +### Requirement: 后台查看关联 +仅超级管理员和平台用户可在其资产数据范围内查看关联手机号。资产详情 MUST 列出该资产全部当前关联手机号;资产列表、卡与设备两类导出以及批量任务结果中,具备资产数据权限的账号可见完整关联手机号,不做脱敏。按资产查询 MUST 一次批量聚合完成。后台 MUST NOT 提供补录或修改关联的入口。 + +#### Scenario: 数据范围外不可见 +- **WHEN** 平台用户查询其资产数据范围之外的资产的关联手机号 +- **THEN** 系统不返回该资产的关联手机号 + +#### Scenario: 列表一次聚合 +- **WHEN** 请求一页包含多项资产的列表 +- **THEN** 系统按该页资产集合一次读取关联手机号并按资产装配,不逐资产查询 + +#### Scenario: 解绑后仍可查看完整快照 +- **WHEN** 一次批量解绑任务执行完成、关系已失效后查看该任务结果 +- **THEN** 结果中仍返回该次解除时记录的完整手机号快照 + +#### Scenario: 两类导出均含关联手机号 +- **WHEN** 导出 IoT 卡场景或设备场景 +- **THEN** 两类导出均包含关联手机号列,且设备导出列组解析对新增列保持正确 + +### Requirement: 后台解除关联 +仅超级管理员和平台用户可在资产数据范围内解除关联,必须二次确认并填写原因;代理、企业和个人客户无后台解除能力。单项解除必须指定一条资产—手机号关系;勾选批量解除与 CSV 导入解除按资产解除该项资产全部当前有效关系。逐资产独立执行并返回成功数、失败数与逐项结果。越权、资产不存在与已无有效关系三种情况 MUST 对调用方返回统一失败文案「无权限操作该资源或资源不存在」。 + +CSV 导入按资产标识定位资产,逐行独立执行:成功行提交,失败行保留原状,不设行数硬上限,任务级失败不产生行明细。解绑原因由任务级必填字段与二次确认提供,行内只填资产标识与可选备注。 + +#### Scenario: 单项解除未指定关系 +- **WHEN** 调用方请求单项解除但未指定具体关系 +- **THEN** 系统拒绝该请求 + +#### Scenario: 批量部分成功 +- **WHEN** 批量资产集合中部分资产有权且已关联、部分越权或不存在 +- **THEN** 有权项成功解除、其余项失败,返回成功数、失败数与逐项结果,不因失败项回滚成功项 + +#### Scenario: 三态统一文案 +- **WHEN** 调用方对越权资产、不存在资产或已无有效关系的资产发起解除 +- **THEN** 三种情况返回同一失败文案「无权限操作该资源或资源不存在」 + +#### Scenario: 缺二次确认或原因被拒绝 +- **WHEN** 解除请求未提交二次确认或未填写原因 +- **THEN** 系统拒绝该请求且不解除任何关系 + +#### Scenario: 导入失败行保留原状 +- **WHEN** CSV 中某行资产标识无法定位或该行资产无权解除 +- **THEN** 该行记为失败并保留原因,其他行照常提交,任务继续执行直到结束 + +#### Scenario: 审计与日志只写脱敏值 +- **WHEN** 任一解除操作成功或失败 +- **THEN** 审计记录与运行日志中的手机号仅为脱敏形式,不出现完整手机号 + +### Requirement: 关联不影响换货与其他写入路径 +换货、资产导入、后台资产编辑与个人客户主手机号变更历史均 MUST NOT 创建、推断、复制或迁移手机号—资产关系。换货完成后的新资产在客户首次 H5 访问时,按当时全局开关重新走验证。 + +#### Scenario: 换货不迁移关系 +- **WHEN** 换货完成并用新资产替换旧资产 +- **THEN** 新资产不继承旧资产的关联手机号,关联记录数不变 + +#### Scenario: 换货新资产首次登录 +- **WHEN** 全局开关开启,客户换货完成后首次登录新资产 +- **THEN** 响应 `need_bind_phone=true` + +#### Scenario: 资产导入与后台资产编辑不产生关系 +- **WHEN** 导入资产或修改后台资产属性 +- **THEN** 关联记录数不变,不产生任何手机号—资产关系 diff --git a/openspec/changes/archive/2026-09-15-add-phone-asset-associations/tasks.md b/openspec/changes/archive/2026-09-15-add-phone-asset-associations/tasks.md new file mode 100644 index 0000000..832cadf --- /dev/null +++ b/openspec/changes/archive/2026-09-15-add-phone-asset-associations/tasks.md @@ -0,0 +1,25 @@ +## 1. 关联事实与 H5 建联 + +- [x] 1.1 新增成对迁移 `migrations/000223_*`(编号按实施时目录最大编号复核,当前最大为 `000222`)、模型与常量:关联表含 `asset_type`、`asset_id`、`phone`、`status`、建立时间、建立来源(固定 `h5_sms_verification`)与失效信息;配有效关系部分唯一索引 `(phone, asset_type, asset_id)`、按 `phone` 计数索引、按 `(asset_type, asset_id)` 集合查询索引;`down` 带有效性守卫,不回填历史。 +- [x] 1.2 新增关联 store 的四个方法:`ExistsValid`(登录判定)、`CountValidByPhone`(十项上限)、`ListValidByAssets`(列表/导出一次批量聚合)、`CreateIfAbsent`(幂等建联)。 +- [x] 1.3 改造 `issueLoginToken` 为三支判定:无主号 → `true`;有主号但未与当前访问资产存在有效关系 → `true`;已关联 → `false`;全局开关关闭 → 恒 `false` 且不查询、不创建、不删除关系。关联查询使用 JWT 的 `asset_type/asset_id`,不使用 `phone` claim。保持登录响应形状不变。 +- [x] 1.4 扩展 `BindPhone`:已有主号且提交号码等于主号、验证码有效时幂等建联且不改账号手机号;号码不等于主号仍拒绝;无当前访问资产身份时只完成账号手机号绑定;建联前在事务内锁定手机号行计数,达十项返回上限文案且不写关联。 +- [x] 1.5 改造 `ChangePhone`:事务内先算「新号现有有效关系数 + 旧号待迁移数」,超十项整次失败;通过时把旧号全部有效关系原子迁移至新号;与「新号已存在的同资产有效关系」冲突时整次失败回滚;不写遗留列 `tb_personal_customer.phone`;审计只写脱敏手机号。加锁顺序统一为按手机号行 `id ASC`。 + +## 2. 读侧与后台解除 + +- [x] 2.1 资产列表与详情补关联手机号投影:按当页资产集合一次 `IN` 批量查询后装配,禁止逐资产查询;卡与设备两条 Query 路径都要覆盖。 +- [x] 2.2 IoT 卡与设备两个导出场景补「关联手机号」列:设备导出新列并入尾部并让 `deviceExportTailHeaderCount` 递增,`buildDeviceExportRow` 同步;`cardGroupCountFromHeaders` 保留旧表头反解兼容分支,保证历史任务可用 `ResolvedHeaders` 重导出。 +- [x] 2.3 实现单项解绑 `DELETE /phone-asset-associations/:id`:路径主键即指定一条关系;必须二次确认并填写原因(1~500 字符);锁关系后复核资产数据范围,标记失效并逐条审计(脱敏)。 +- [x] 2.4 实现勾选批量解绑 `POST /phone-asset-associations/batch-unbind`:资产集合去重、二次确认、原因必填;每项资产独立解除全部有效关系,返回成功数、失败数与逐项结果;部分成功不回滚成功项。 +- [x] 2.5 实现 CSV 解绑导入场景,按 B1–B16 全清单(不含 B17):B1 任务类型常量;B2 队列常量、`QueueForTaskType` 分支与队列权重;B3 新任务表成对迁移(含 `down` 守卫);B4 任务模型(含逐行结果明细 jsonb);B5 任务 store(`UpdateStatus`/`UpdateProgress`/`UpdateResult`);B6 `internal/bootstrap` 各装配文件注入;B7 worker 处理器(逐行独立事务、BOM 去除、UTF-8 校验失败按 GBK 回退、固定表头列序、行号自数据首行起计、任务级失败不产生行明细、不设行数硬上限);B8 `pkg/queue/handler.go` 处理器注册;B9 `cmd/worker/main.go` 启动补偿;B10 `internal/infrastructure/releasegate/checker.go:243` 未完成异步任务表清单追加新表;B11 应用 service(建任务、入队失败必落库);B12 Handler 与 DTO(入口限超级管理员与平台用户);B13 路由注册;B14 占位装配;B15 上传用途常量、存储映射、用途枚举与上传接口文档表四处同步;B16 审计动作码与资源注册。列:资产标识(必填,复用既有资产标识解析)+ 备注(可选),解绑原因由任务级必填字段与二次确认提供;**任务明细持久化解绑当时的完整手机号快照**(B13、B14 与 2.6 合并执行)。 +- [x] 2.6 注册本 Change 全部新增接口的路由(沿用超管/平台路由组级 gate 先例)、`cmd/api/docs.go` 与 `cmd/gendocs/main.go` 占位装配及 OpenAPI 文档(ENG-ROUTE-001),并补充三态统一失败文案常量供新用例使用;既有内联字面量按 As-Is 保留。 + +## 3. 验证与交付 + +- [x] 3.1 按 ENG-TEST-001 在维护者指定测试面验证正向行为:开关开启首次访问未关联资产返回 `need_bind_phone=true` 且完成验证后建立关系;已关联资产不再要求验证;开关关闭后登录不要求验证且不新建、不删除关系;第 11 项资产验证被拒绝且原十项不变;重复验证同一资产不产生第二条关系;换绑超限整次失败且新旧关系均不变;换绑迁移与新号既有同资产关系冲突时整次回滚。 +- [x] 3.2 验证反向断言:换货完成后关联记录数不变且新资产不继承旧资产关联;换货新资产首次登录返回 `need_bind_phone=true`;资产导入后关联记录数不变;后台资产编辑后关联记录数不变。 + - 说明:判定为完成(维护者裁定)。证据:全仓 `tb_phone_asset_association` 写入点仅本 Change 的 5 处(H5 建联、换绑迁移、后台单项解除、后台批量解除、CSV 导入解除),`exchange` / `customer_binding` / 资产导入 / 后台资产编辑模块对本表零引用且零改动;等价谓词「有主号但未与当前访问资产存在有效关系 → `need_bind_phone=true`」已实测。完整换货 / 资产导入端到端(需共享队列与对象存储)未执行,作为残留范围记录。 +- [x] 3.3 验证读侧与脱敏:资产列表按页一次聚合(以查询次数为准,禁止 N+1);卡与设备两类导出均含关联手机号列且历史任务表头仍可反解列组;批量任务结果在关系失效后仍返回完整手机号快照;审计、日志与错误中只出现脱敏手机号;越权、资产不存在、已无有效关系三态返回同一文案。 +- [x] 3.4 验证权限与批量语义:代理、企业、个人客户访问后台解绑入口返回 403;数据范围外资产不可见也不可解除;单项解绑未指定关系被拒绝;缺二次确认或原因被拒绝;批量与导入按资产解除全部有效关系,逐资产独立结果,失败行保留原状且不影响其他行,无行数硬上限。 +- [x] 3.5 验证迁移与交付命令:隔离库 `up/down/up`(有数据时 `down` 被守卫拒绝);`gofmt -w `、`go build ./cmd/api ./cmd/worker`、`go run cmd/gendocs/main.go`、`openspec validate add-phone-asset-associations --strict`、`openspec doctor --json`;自动化测试按项目决策为 N/A。fixture 只增删本 Change 自己的记录,禁止重置整库。 diff --git a/openspec/specs/phone-asset-association/spec.md b/openspec/specs/phone-asset-association/spec.md new file mode 100644 index 0000000..0a57e2a --- /dev/null +++ b/openspec/specs/phone-asset-association/spec.md @@ -0,0 +1,163 @@ +# phone-asset-association 当前行为 + +## Purpose + +保存仅由 H5 短信验证建立的手机号—资产当前有效关系,使全局强制绑定能逐资产执行,同时提供受资产数据范围控制的后台查看与解除能力。 + +## Requirements + +### Requirement: H5 验证建立关联与数量上限 + +系统 SHALL 保留既有全局 H5 强制绑定开关。开关开启时,客户首次登录一项未关联当前手机号的资产必须完成短信验证码校验后才建立关系;已关联资产不重复验证。开关关闭时登录不要求验证,且不查询、不新增、不删除任何关系,已有关系保留。关联只能由 H5 验证建立,卡(`iot_card`)与设备(`device`)分别计为独立资产,单手机号最多关联十项当前有效资产;上限只统计当前有效关系,已失效关系不占用额度。上线不回填历史客户—资产关系。 + +#### Scenario: 第十一项资产验证 + +- **WHEN** 已关联十项有效资产的手机号验证第十一项资产 +- **THEN** 系统拒绝本次新关联,已有十项关系不变 + +#### Scenario: 开关关闭后登录未关联资产 + +- **WHEN** 全局强制绑定开关关闭,客户登录一项未关联其手机号的资产 +- **THEN** 系统不要求验证、不新增关系,且既有关系不被删除 + +#### Scenario: 卡与设备分别计数 + +- **WHEN** 同一手机号关联了 `iot_card` 类型 ID 为 5 的资产与 `device` 类型 ID 为 5 的资产 +- **THEN** 两者计为两项资产,互不覆盖也不合并计数 + +### Requirement: 登录提示判定 + +系统 SHALL 在登录响应中以 `need_bind_phone` 表示当前访问资产是否仍需完成手机号验证,判定依据当前访问资产身份与该手机号之间的有效关系。开关开启时:客户无主手机号,或其主手机号未与当前访问资产存在有效关系,返回 `true`;已存在有效关系返回 `false`。开关关闭时恒为 `false`。该字段 MUST 只作为前端提示,系统 MUST NOT 因缺少该关系拒绝任何资产业务入口。 + +#### Scenario: 已有手机号但资产未关联 + +- **WHEN** 客户已有主手机号,登录一项该手机号未关联的资产 +- **THEN** 响应 `need_bind_phone=true` + +#### Scenario: 已关联资产再次登录 + +- **WHEN** 客户登录一项其主手机号已关联的资产 +- **THEN** 响应 `need_bind_phone=false` 且不要求重复验证 + +#### Scenario: 缺关系不拦截业务接口 + +- **WHEN** 客户在 `need_bind_phone=true` 状态下调用其有权访问的资产业务接口 +- **THEN** 系统按既有资产归属与数据范围规则授权,不因缺少该关系返回拒绝 + +### Requirement: H5 绑定与换绑的关联写入 + +系统 SHALL 在 H5 短信验证成功后建立或迁移关联。客户已有主手机号、提交号码与主手机号一致且验证码有效时,系统 MUST 幂等建立当前访问资产与该手机号的关系,不修改账号手机号;提交号码与主手机号不一致时仍拒绝绑定。请求不含当前访问资产身份时,系统只完成账号手机号绑定,不建立关联。同一手机号与同一资产已存在有效关系时,重复验证返回成功且不产生第二条关系。 + +客户更换手机号时 MUST 同时验证旧、新号码,并在同一事务内将旧手机号全部有效资产关系迁移至新号码。迁移前先比较「新号码现有有效关系数 + 旧号码待迁移有效关系数」,超过十项时整次失败且旧、新关系均保持原状。迁移与「新号码已存在的同资产有效关系」冲突时,整次换绑失败并回滚,旧、新关系均不变。 + +#### Scenario: 已有主号验证建联 + +- **WHEN** 客户已有主手机号 P,提交 P 与有效验证码,且当前登录会话指向资产 A +- **THEN** 系统建立 P 与 A 的有效关系,账号手机号仍为 P + +#### Scenario: 提交号码与主号不一致 + +- **WHEN** 客户已有主手机号,提交另一号码与有效验证码 +- **THEN** 系统拒绝该绑定,不建立任何新关系 + +#### Scenario: 重复验证同一资产 + +- **WHEN** 同一手机号对已有关联的同一资产再次完成验证 +- **THEN** 系统返回成功且不产生第二条有效关系 + +#### Scenario: 换绑超过上限 + +- **WHEN** 客户换绑后新手机号关联总数将超过十项 +- **THEN** 系统不迁移任何关系且旧、新手机号关系均保持原状 + +#### Scenario: 换绑迁移与既有关系冲突 + +- **WHEN** 新手机号已存在与待迁移资产相同的有效关系 +- **THEN** 整次换绑失败并回滚,旧、新手机号关系均保持原状 + +### Requirement: 后台查看关联 + +仅超级管理员和平台用户可在其资产数据范围内查看关联手机号。资产详情 MUST 列出该资产全部当前关联手机号;资产列表、卡与设备两类导出以及批量任务结果中,具备资产数据权限的账号可见完整关联手机号,不做脱敏。按资产查询 MUST 一次批量聚合完成。后台 MUST NOT 提供补录或修改关联的入口。 + +#### Scenario: 数据范围外不可见 + +- **WHEN** 平台用户查询其资产数据范围之外的资产的关联手机号 +- **THEN** 系统不返回该资产的关联手机号 + +#### Scenario: 列表一次聚合 + +- **WHEN** 请求一页包含多项资产的列表 +- **THEN** 系统按该页资产集合一次读取关联手机号并按资产装配,不逐资产查询 + +#### Scenario: 解绑后仍可查看完整快照 + +- **WHEN** 一次批量解绑任务执行完成、关系已失效后查看该任务结果 +- **THEN** 结果中仍返回该次解除时记录的完整手机号快照 + +#### Scenario: 两类导出均含关联手机号 + +- **WHEN** 导出 IoT 卡场景或设备场景 +- **THEN** 两类导出均包含关联手机号列,且设备导出列组解析对新增列保持正确 + +### Requirement: 后台解除关联 + +仅超级管理员和平台用户可在资产数据范围内解除关联,必须二次确认并填写原因;代理、企业和个人客户无后台解除能力。单项解除必须指定一条资产—手机号关系;勾选批量解除与 CSV 导入解除按资产解除该项资产全部当前有效关系。逐资产独立执行并返回成功数、失败数与逐项结果。越权、资产不存在与已无有效关系三种情况 MUST 对调用方返回统一失败文案「无权限操作该资源或资源不存在」。 + +CSV 导入按资产标识定位资产,逐行独立执行:成功行提交,失败行保留原状,不设行数硬上限,任务级失败不产生行明细。解绑原因由任务级必填字段与二次确认提供,行内只填资产标识与可选备注。 + +#### Scenario: 单项解除未指定关系 + +- **WHEN** 调用方请求单项解除但未指定具体关系 +- **THEN** 系统拒绝该请求 + +#### Scenario: 批量部分成功 + +- **WHEN** 批量资产集合中部分资产有权且已关联、部分越权或不存在 +- **THEN** 有权项成功解除、其余项失败,返回成功数、失败数与逐项结果,不因失败项回滚成功项 + +#### Scenario: 三态统一文案 + +- **WHEN** 调用方对越权资产、不存在资产或已无有效关系的资产发起解除 +- **THEN** 三种情况返回同一失败文案「无权限操作该资源或资源不存在」 + +#### Scenario: 缺二次确认或原因被拒绝 + +- **WHEN** 解除请求未提交二次确认或未填写原因 +- **THEN** 系统拒绝该请求且不解除任何关系 + +#### Scenario: 导入失败行保留原状 + +- **WHEN** CSV 中某行资产标识无法定位或该行资产无权解除 +- **THEN** 该行记为失败并保留原因,其他行照常提交,任务继续执行直到结束 + +#### Scenario: 审计与日志只写脱敏值 + +- **WHEN** 任一解除操作成功或失败 +- **THEN** 审计记录与运行日志中的手机号仅为脱敏形式,不出现完整手机号 + +### Requirement: 关联不影响换货与其他写入路径 + +换货、资产导入、后台资产编辑与个人客户主手机号变更历史均 MUST NOT 创建、推断、复制或迁移手机号—资产关系。换货完成后的新资产在客户首次 H5 访问时,按当时全局开关重新走验证。 + +#### Scenario: 换货不迁移关系 + +- **WHEN** 换货完成并用新资产替换旧资产 +- **THEN** 新资产不继承旧资产的关联手机号,关联记录数不变 + +#### Scenario: 换货新资产首次登录 + +- **WHEN** 全局开关开启,客户换货完成后首次登录新资产 +- **THEN** 响应 `need_bind_phone=true` + +#### Scenario: 资产导入与后台资产编辑不产生关系 + +- **WHEN** 导入资产或修改后台资产属性 +- **THEN** 关联记录数不变,不产生任何手机号—资产关系 + +## 可达操作索引 + +本节只用于入口导航,不是行为 Requirement;业务义务以上述 Requirements 为准。 + +### 手机号资产关联 + +`GET /api/admin/phone-asset-associations`(查询手机号资产关联列表);`POST /api/admin/phone-asset-associations/batch-unbind`(按资产批量解除手机号关联);`POST /api/admin/phone-asset-associations/unbind-imports`(创建手机号资产解绑导入任务);`GET /api/admin/phone-asset-associations/unbind-imports`(查询解绑导入任务列表);`GET /api/admin/phone-asset-associations/unbind-imports/{id}`(查询解绑导入任务详情);`DELETE /api/admin/phone-asset-associations/{id}`(解除单条手机号资产关联)。 diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index acbaef9..c6fdd2d 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -524,6 +524,18 @@ const ( AuditActionExportTaskCreated = "export_task.create" // AuditActionExportTaskCancelled 表示取消业务导出任务或提交取消请求。 AuditActionExportTaskCancelled = "export_task.cancel" + // AuditActionPhoneAssetAssociationCreated 表示 H5 短信验证通过后建立手机号—资产关联。 + AuditActionPhoneAssetAssociationCreated = "phone_asset_association.create" + // AuditActionPhoneAssetAssociationMigrated 表示换绑手机号时把旧号全部有效关联迁移到新号。 + AuditActionPhoneAssetAssociationMigrated = "phone_asset_association.migrate" + // AuditActionPhoneAssetAssociationUnbound 表示后台单项或按资产批量解除手机号—资产关联。 + AuditActionPhoneAssetAssociationUnbound = "phone_asset_association.unbind" + // AuditActionPhoneAssetAssociationImported 表示 CSV 导入单行解除手机号—资产关联。 + AuditActionPhoneAssetAssociationImported = "phone_asset_association.import_unbind" + // AuditActionPhoneAssetUnbindImportTaskCreated 表示创建 CSV 解绑导入任务。 + AuditActionPhoneAssetUnbindImportTaskCreated = "phone_asset_unbind_import_task.create" + // AuditActionPhoneAssetUnbindImportTaskCompleted 表示 CSV 解绑导入任务完成。 + AuditActionPhoneAssetUnbindImportTaskCompleted = "phone_asset_unbind_import_task.complete" // AuditActionNotificationDelivered 表示 Outbox 消费后实际生成站内通知。 AuditActionNotificationDelivered = "notification.deliver" // AuditActionNotificationRead 表示单条通知首次标记已读。 @@ -667,6 +679,10 @@ const ( AuditResourceOrderPackageInvalidateTask = "order_package_invalidate_task" // AuditResourceExportTask 表示业务导出任务资源。 AuditResourceExportTask = "export_task" + // AuditResourcePhoneAssetAssociation 表示手机号—资产关联资源。 + AuditResourcePhoneAssetAssociation = "phone_asset_association" + // AuditResourcePhoneAssetUnbindImportTask 表示手机号—资产 CSV 解绑导入任务资源。 + AuditResourcePhoneAssetUnbindImportTask = "phone_asset_unbind_import_task" // AuditResourceNotification 表示站内通知资源。 AuditResourceNotification = "notification" // AuditResourceNotificationReadBatch 表示通知批量已读资源。 @@ -863,6 +879,10 @@ const ( AuditResourceRoleEnterpriseDeviceBinding = "enterprise_device_binding" // AuditResourceRoleEnterpriseDeviceAuthorization 表示企业设备授权记录。 AuditResourceRoleEnterpriseDeviceAuthorization = "enterprise_device_authorization" + // AuditResourceRolePhoneAssetAssociationTarget 表示手机号—资产关联的主要目标。 + AuditResourceRolePhoneAssetAssociationTarget = "phone_asset_association_target" + // AuditResourceRolePhoneAssetAssociationAsset 表示关联指向的资产。 + AuditResourceRolePhoneAssetAssociationAsset = "phone_asset_association_asset" // AuditResourceRolePersonalCustomerTarget 表示个人客户操作的主体目标。 AuditResourceRolePersonalCustomerTarget = "personal_customer_target" // AuditResourceRolePersonalCustomerPhone 表示个人客户手机号关系。 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 8a98a39..f868f34 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -77,6 +77,7 @@ const ( TaskTypeAssetPackageBatchOrder = "asset:package:batch_order" // 资产套餐批量订购 // 店铺负责人 CSV 导入任务类型,独立成队列以隔离逐行执行的长时任务。 TaskTypeShopBusinessOwnerImport = "shop_business_owner:import" + TaskTypePhoneAssetUnbindImport = "phone_asset_unbind:import" // 手机号—资产关联 CSV 解绑导入 // 订单超时任务类型 TaskTypeOrderExpire = "order:expire" // 订单超时自动取消 @@ -232,6 +233,7 @@ const ( QueueOrderPackageInvalidate = TaskTypeOrderPackageInvalidate // 批量失效订单套餐任务队列 QueueAssetPackageBatchOrder = TaskTypeAssetPackageBatchOrder // 资产套餐批量订购任务队列 QueueShopBusinessOwnerImport = TaskTypeShopBusinessOwnerImport // 店铺负责人 CSV 导入任务队列 + QueuePhoneAssetUnbindImport = TaskTypePhoneAssetUnbindImport // 手机号—资产关联 CSV 解绑导入任务队列 QueueOrderExpire = TaskTypeOrderExpire // 订单超时取消任务队列 QueueAutoPurchase = TaskTypeAutoPurchaseAfterRecharge // 充值后自动购包任务队列 QueueAlertCheck = TaskTypeAlertCheck // 告警检查任务队列 @@ -291,6 +293,8 @@ func QueueForTaskType(taskType string) string { return QueueAssetPackageBatchOrder case TaskTypeShopBusinessOwnerImport: return QueueShopBusinessOwnerImport + case TaskTypePhoneAssetUnbindImport: + return QueuePhoneAssetUnbindImport case TaskTypeOrderExpire: return QueueOrderExpire case TaskTypeAutoPurchaseAfterRecharge: @@ -332,6 +336,7 @@ func DefaultTaskQueueWeights() map[string]int { QueueOrderPackageInvalidate: 5, QueueAssetPackageBatchOrder: 5, QueueShopBusinessOwnerImport: 4, + QueuePhoneAssetUnbindImport: 4, QueueOrderExpire: 4, QueueEmailSend: 4, QueueExportDispatch: 4, diff --git a/pkg/constants/phone_asset_association.go b/pkg/constants/phone_asset_association.go new file mode 100644 index 0000000..b1250c2 --- /dev/null +++ b/pkg/constants/phone_asset_association.go @@ -0,0 +1,142 @@ +package constants + +import "time" + +// 手机号—资产关联的状态取值。 +const ( + // PhoneAssetAssociationStatusInvalid 表示关联已失效,不再占用十项额度。 + PhoneAssetAssociationStatusInvalid = 0 + // PhoneAssetAssociationStatusValid 表示关联当前有效。 + PhoneAssetAssociationStatusValid = 1 +) + +// 手机号—资产关联的建立来源;关联只能由 H5 短信验证建立,后台无创建或补录入口。 +const ( + // PhoneAssetAssociationSourceH5SMSVerification 表示关联由 H5 短信验证码校验通过后建立。 + PhoneAssetAssociationSourceH5SMSVerification = "h5_sms_verification" +) + +// 手机号—资产关联的失效方式,用于审计与事后核对解除入口。 +const ( + // PhoneAssetAssociationInvalidateMethodBackendSingle 表示后台单项解除。 + PhoneAssetAssociationInvalidateMethodBackendSingle = "backend_single" + // PhoneAssetAssociationInvalidateMethodBackendBatch 表示后台勾选批量解除。 + PhoneAssetAssociationInvalidateMethodBackendBatch = "backend_batch" + // PhoneAssetAssociationInvalidateMethodCSVImport 表示 CSV 导入解除。 + PhoneAssetAssociationInvalidateMethodCSVImport = "csv_import" +) + +// 手机号—资产关联的业务口径常量。 +const ( + // PhoneAssetAssociationMaxValidPerPhone 表示单手机号可同时有效的关联资产项数上限。 + PhoneAssetAssociationMaxValidPerPhone = 10 + // PhoneAssetAssociationLimitMessage 表示超过十项上限时的统一失败文案。 + PhoneAssetAssociationLimitMessage = "该手机号最多关联10项有效资产" +) + +// 手机号—资产关联解绑入口的角色说明。 +const ( + // PhoneAssetAssociationAccessDescription 表示关联查看与解除入口的角色说明。 + PhoneAssetAssociationAccessDescription = "仅超级管理员和平台账号可访问,代理与企业账号返回 403。" +) + +// 手机号—资产关联解除的入参口径。 +const ( + // PhoneAssetAssociationUnbindReasonMaxLength 表示解除原因的最大长度。 + PhoneAssetAssociationUnbindReasonMaxLength = 500 +) + +// 手机号—资产关联 CSV 解绑导入的场景常量。 +const ( + // StoragePurposePhoneAssetUnbindImport 表示手机号资产解绑 CSV 导入上传用途。 + StoragePurposePhoneAssetUnbindImport = "phone_unbind_import" + // PhoneAssetUnbindImportStoragePrefix 表示解绑导入文件的对象存储目录。 + PhoneAssetUnbindImportStoragePrefix = "phone-unbind-imports" + // PhoneAssetUnbindImportTaskTimeout 表示单个解绑导入任务的最长执行时间。 + PhoneAssetUnbindImportTaskTimeout = 2 * time.Hour + // PhoneAssetUnbindImportProgressBatchSize 表示导入按批更新进度计数的批大小。 + PhoneAssetUnbindImportProgressBatchSize = 100 + // PhoneAssetUnbindImportItemStatusSuccess 表示导入单行已成功解除关联。 + PhoneAssetUnbindImportItemStatusSuccess = 3 + // PhoneAssetUnbindImportItemStatusFailed 表示导入单行校验或执行失败并保留原状。 + PhoneAssetUnbindImportItemStatusFailed = 4 +) + +// PhoneAssetUnbindImportColumns 表示导入文件首行必须完全一致的固定列序;解绑原因由任务级必填字段提供。 +var PhoneAssetUnbindImportColumns = []string{"资产标识", "备注"} + +// 手机号—资产关联解绑导入的任务级失败原因,与行级失败原因分开记录。 +const ( + // PhoneAssetUnbindImportErrorFileFormat 表示文件格式或表头与固定列序不符。 + PhoneAssetUnbindImportErrorFileFormat = "文件格式或表头与固定列序不符" + // PhoneAssetUnbindImportErrorEncoding 表示文件既非合法 UTF-8 也无法按 GBK 解码。 + PhoneAssetUnbindImportErrorEncoding = "文件编码无法按 UTF-8 或 GBK 解码" + // PhoneAssetUnbindImportErrorNoDataRow 表示文件没有数据行。 + PhoneAssetUnbindImportErrorNoDataRow = "文件没有数据行" +) + +// 手机号—资产关联解绑导入的行级失败原因,取值集合固定。 +const ( + // PhoneAssetUnbindImportRowErrorIdentifier 表示资产标识为空。 + PhoneAssetUnbindImportRowErrorIdentifier = "资产标识不能为空" + // PhoneAssetUnbindImportRowErrorAssetMissing 表示资产标识无法定位到资产。 + PhoneAssetUnbindImportRowErrorAssetMissing = "资产标识不存在或已删除" + // PhoneAssetUnbindImportRowErrorNoAssociation 表示该资产已无有效关联。 + PhoneAssetUnbindImportRowErrorNoAssociation = "该资产已无有效关联手机号" + // PhoneAssetUnbindImportRowErrorFormat 表示该行列数与固定列序不一致。 + PhoneAssetUnbindImportRowErrorFormat = "行格式错误" + // PhoneAssetUnbindImportRowErrorRemarkTooLong 表示备注超过长度上限。 + PhoneAssetUnbindImportRowErrorRemarkTooLong = "备注超过500字符" +) + +// 手机号—资产关联解绑导入的备注长度上限。 +const ( + // PhoneAssetUnbindImportRemarkMaxLength 表示行内备注的最大长度。 + PhoneAssetUnbindImportRemarkMaxLength = 500 +) + +// 手机号—资产关联的三态统一失败文案。 +// 越权、资产不存在与已无有效关系三种情况必须返回同一文案,避免形成可枚举差异(ENG-AUTHZ-001)。 +// 本常量只供新增用例使用;既有内联字面量按 As-Is 保留,不在本 Change 统一。 +const ( + // PhoneAssetAssociationDeniedMessage 表示越权、资产不存在或已无有效关系时的统一失败文案。 + PhoneAssetAssociationDeniedMessage = "无权限操作该资源或资源不存在" +) + +// GetPhoneAssetAssociationImportItemStatusName 返回解绑导入行状态中文名称。 +func GetPhoneAssetAssociationImportItemStatusName(status int) string { + switch status { + case PhoneAssetUnbindImportItemStatusSuccess: + return "成功" + case PhoneAssetUnbindImportItemStatusFailed: + return "失败" + default: + return "未知" + } +} + +// GetPhoneAssetAssociationStatusName 返回关联状态中文名称。 +func GetPhoneAssetAssociationStatusName(status int) string { + switch status { + case PhoneAssetAssociationStatusValid: + return "有效" + case PhoneAssetAssociationStatusInvalid: + return "已失效" + default: + return "未知" + } +} + +// PhoneAssetAssociationInvalidateMethodName 返回失效方式中文名称。 +func PhoneAssetAssociationInvalidateMethodName(method string) string { + switch method { + case PhoneAssetAssociationInvalidateMethodBackendSingle: + return "后台单项解除" + case PhoneAssetAssociationInvalidateMethodBackendBatch: + return "后台批量解除" + case PhoneAssetAssociationInvalidateMethodCSVImport: + return "CSV导入解除" + default: + return "未知" + } +} diff --git a/pkg/logger/middleware.go b/pkg/logger/middleware.go index 792c2ff..5787054 100644 --- a/pkg/logger/middleware.go +++ b/pkg/logger/middleware.go @@ -5,7 +5,9 @@ import ( "crypto/sha256" "encoding/hex" "net/url" + "strings" "time" + "unicode" "github.com/break/junhong_cmp_fiber/pkg/auditcontext" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -52,6 +54,38 @@ func truncateBody(body []byte, maxSize int) (string, bool) { return string(body[:maxSize]), true } +// phoneFieldNames 是访问日志中属于手机号类的字段名集合。 +// 手机号不在 sanitizer 的 forbidden 字段清单内,仅靠字段名通用清理无法脱敏; +// 这里单独识别并输出前 3 位 + **** + 后 4 位,禁止完整号码进入 query、请求正文或响应正文。 +// 只覆盖手机号类字段,forbidden 字段仍按原语义标记为已脱敏,两者处理互不影响。 +var phoneFieldNames = map[string]struct{}{ + "phone": {}, + "mobile": {}, + "associated_phones": {}, +} + +// normalizeLogFieldName 归一化字段名用于敏感字段判断。 +func normalizeLogFieldName(key string) string { + var builder strings.Builder + for index, char := range key { + if unicode.IsUpper(char) && index > 0 { + builder.WriteByte('_') + } + if char == '-' || char == '.' { + builder.WriteByte('_') + continue + } + builder.WriteRune(unicode.ToLower(char)) + } + return builder.String() +} + +// isPhoneField 判断字段名是否属于手机号类字段(与 forbidden 字段分开处理)。 +func isPhoneField(key string) bool { + _, ok := phoneFieldNames[normalizeLogFieldName(key)] + return ok +} + // maskSensitiveValue 按字段名判断并脱敏访问日志中的敏感值 func maskSensitiveValue(key, value string) string { if value == "" { @@ -60,6 +94,9 @@ func maskSensitiveValue(key, value string) string { if shouldMaskField(key) { return redactedValue } + if isPhoneField(key) { + return sanitizer.MaskPhone(value) + } return value } @@ -125,6 +162,25 @@ func sanitizeJSONValue(value any) { typed[key] = redactedValue continue } + if isPhoneField(key) { + // 手机号类数组逐元素脱敏;非字符串元素不保留原值。 + list, ok := item.([]any) + if !ok { + if item != nil { + typed[key] = redactedValue + } + continue + } + for index, element := range list { + if text, ok := element.(string); ok { + list[index] = sanitizer.MaskPhone(text) + continue + } + list[index] = redactedValue + } + typed[key] = list + continue + } sanitizeJSONValue(item) } case []any: diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index c0f0efa..e3f4625 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -78,6 +78,7 @@ func BuildDocHandlers() *bootstrap.Handlers { AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(nil, nil), BusinessUserGroup: admin.NewBusinessUserGroupHandler(nil, nil), ShopBusinessOwnerImport: admin.NewShopBusinessOwnerImportHandler(nil), + PhoneAssetAssociation: admin.NewPhoneAssetAssociationHandler(nil, nil), ClientWechat: app.NewClientWechatHandler(nil, nil, nil), SuperAdmin: admin.NewSuperAdminHandler(nil), SystemConfig: admin.NewSystemConfigHandler(nil, nil), diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index bb5fefc..6307b93 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -75,6 +75,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux { h.registerOrderPackageInvalidateHandler() h.registerAssetPackageBatchOrderHandler() h.registerShopBusinessOwnerImportHandler() + h.registerPhoneAssetUnbindImportHandler() h.registerExportHandlers() h.registerCommissionStatsHandlers() h.registerCommissionCalculationHandler() @@ -154,6 +155,22 @@ func (h *Handler) registerShopBusinessOwnerImportHandler() { h.logger.Info("注册店铺负责人导入任务处理器", zap.String("task_type", constants.TaskTypeShopBusinessOwnerImport)) } +func (h *Handler) registerPhoneAssetUnbindImportHandler() { + handler := task.NewPhoneAssetUnbindImportHandler( + h.db, + h.workerResult.Stores.PhoneAssetUnbindImportTask, + h.workerResult.Stores.PhoneAssetAssociation, + h.workerResult.Stores.AssetIdentifier, + h.workerResult.Stores.IotCard, + h.workerResult.Stores.Device, + h.storage, + h.logger, + audit.NewWriter(audit.NewRegistry(), nil), + ) + h.mux.HandleFunc(constants.TaskTypePhoneAssetUnbindImport, handler.Handle) + h.logger.Info("注册手机号资产解绑导入任务处理器", zap.String("task_type", constants.TaskTypePhoneAssetUnbindImport)) +} + func (h *Handler) registerDeviceImportHandler() { deviceImportHandler := task.NewDeviceImportHandler( h.db, diff --git a/pkg/queue/types.go b/pkg/queue/types.go index 18da499..bb2492d 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -51,9 +51,11 @@ type WorkerStores struct { AssetIdentifier *postgres.AssetIdentifierStore PersonalCustomer *postgres.PersonalCustomerStore PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore + PhoneAssetAssociation *postgres.PhoneAssetAssociationStore OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore AssetPackageBatchOrderTask *postgres.AssetPackageBatchOrderTaskStore ShopBusinessOwnerImportTask *postgres.ShopBusinessOwnerImportTaskStore + PhoneAssetUnbindImportTask *postgres.PhoneAssetUnbindImportTaskStore } // WorkerServices Worker 侧所有 Service 的集合 diff --git a/pkg/sanitizer/sanitizer.go b/pkg/sanitizer/sanitizer.go index b68e4c1..1a64d85 100644 --- a/pkg/sanitizer/sanitizer.go +++ b/pkg/sanitizer/sanitizer.go @@ -105,6 +105,23 @@ func SanitizeText(value string) string { return TextSummary(value) } +// 手机号脱敏保留位数:前 3 位与后 4 位。 +const ( + phoneMaskedKeepPrefix = 3 + phoneMaskedKeepSuffix = 4 +) + +// MaskPhone 生成脱敏手机号,仅保留前 3 位与后 4 位。 +// 审计、日志与错误文案一律使用本函数的结果;字段名脱敏清单不含 phone, +// 因此不能依赖 RemoveForbiddenFields 自动脱敏,必须显式调用。 +func MaskPhone(phone string) string { + phone = strings.TrimSpace(phone) + if len(phone) < phoneMaskedKeepPrefix+phoneMaskedKeepSuffix { + return "" + } + return phone[:phoneMaskedKeepPrefix] + "****" + phone[len(phone)-phoneMaskedKeepSuffix:] +} + // TextSummary 将不可信外部文本转换为不可逆大小和哈希摘要。 func TextSummary(value string) string { if value == "" { diff --git a/pkg/storage/service.go b/pkg/storage/service.go index 7f8fa29..198d1fa 100644 --- a/pkg/storage/service.go +++ b/pkg/storage/service.go @@ -36,7 +36,7 @@ func (s *Service) GenerateFileKey(purpose, fileName string) (string, error) { if ext == "" { ext = ".bin" } - if (purpose == constants.StoragePurposeAssetPackageBatchOrder || purpose == constants.StoragePurposeDeviceBatchAllocation || purpose == constants.StoragePurposeShopBusinessOwnerImport) && !strings.EqualFold(ext, ".csv") { + if (purpose == constants.StoragePurposeAssetPackageBatchOrder || purpose == constants.StoragePurposeDeviceBatchAllocation || purpose == constants.StoragePurposeShopBusinessOwnerImport || purpose == constants.StoragePurposePhoneAssetUnbindImport) && !strings.EqualFold(ext, ".csv") { return "", errors.New(errors.CodeInvalidParam, "批量业务文件必须为CSV格式") } diff --git a/pkg/storage/types.go b/pkg/storage/types.go index 8f96a59..472d3a4 100644 --- a/pkg/storage/types.go +++ b/pkg/storage/types.go @@ -22,4 +22,7 @@ var PurposeMappings = map[string]PurposeMapping{ constants.StoragePurposeShopBusinessOwnerImport: { Prefix: constants.ShopBusinessOwnerImportStoragePrefix, ContentType: "text/csv", }, + constants.StoragePurposePhoneAssetUnbindImport: { + Prefix: constants.PhoneAssetUnbindImportStoragePrefix, ContentType: "text/csv", + }, }