Files
junhong_cmp_fiber/internal/service/phone_asset_association/service.go
break 5ed6b39deb
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
2026-09-18 15:34:29 +08:00

426 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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,
middleware.GetUsernameFromContext(ctx), 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,
middleware.GetUsernameFromContext(ctx), 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
}
// 当前有效关联数量必须与「最多关联十项」判定同口径,且按当页手机号集合一次聚合,禁止逐行查询。
validCounts, err := s.loadValidAssociationCounts(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),
ValidAssociationCount: validCounts[row.Phone],
}
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
// 最近解绑人只取解绑当时的名称快照与账号标识:有效关系保持为空,不以创建人或更新时间填充。
item.InvalidatorID = row.Invalidator
item.InvalidatorName = row.InvalidatorNameSnapshot
}
items = append(items, item)
}
return &dto.PhoneAssetAssociationPageResult{Items: items, Total: total, Page: page, Size: pageSize}, nil
}
// loadValidAssociationCounts 按当页涉及的手机号集合一次聚合当前有效关联数量,禁止逐手机号查询。
func (s *Service) loadValidAssociationCounts(ctx context.Context, rows []*model.PhoneAssetAssociation) (map[string]int64, error) {
phones := make([]string, 0, len(rows))
seen := make(map[string]struct{}, len(rows))
for _, row := range rows {
if _, ok := seen[row.Phone]; ok {
continue
}
seen[row.Phone] = struct{}{}
phones = append(phones, row.Phone)
}
counts, err := s.associationStore.CountValidByPhones(ctx, phones)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量统计手机号有效关联数量失败")
}
return counts, 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
}