feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m2s
- 新增成对迁移 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 通过
This commit is contained in:
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user