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 通过
397 lines
16 KiB
Go
397 lines
16 KiB
Go
// 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
|
||
}
|