feat(手机号资产关联): AUG26-009 手机号—资产关联、十项上限与后台解绑
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:
2026-09-15 11:54:56 +08:00
parent 93e072e1e2
commit 70e680eb0a
67 changed files with 3951 additions and 147 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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,
}
}

View File

@@ -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),
}
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"`

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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{

View File

@@ -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,
}
}

View File

@@ -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

View File

@@ -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:"卡ICCIDasset_type=card时有效"`
BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备IDasset_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:"卡ICCIDasset_type=card时有效"`
BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备IDasset_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"`

View File

@@ -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 {

View File

@@ -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:"更新时间"`
}

View File

@@ -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:"解除原因1500 字符)"`
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:"解除原因1500 字符)"`
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:"任务级解绑原因1500 字符),写入每次解除的失效原因"`
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:"逐项结果,顺序与请求去重后的资产集合一致"`
}

View File

@@ -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 {

View File

@@ -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"
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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 + "路径主键即指定一条资产—手机号关系必须指定必须二次确认并填写原因1500 字符),缺一即拒绝且不解除任何关系。越权、资产不存在与已无有效关系返回同一失败文案。" + constants.PhoneAssetAssociationDeniedMessage + "。",
Tags: []string{"手机号资产关联"},
Input: new(dto.UnbindPhoneAssetAssociationRequest),
Output: new(dto.UnbindPhoneAssetAssociationResponse),
Auth: true,
})
}

View File

@@ -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 |

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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 = &current
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 claimphone 是登录时快照,
// 换绑后到下次登录前仍是旧号。该字段只作前端提示,不改变任何资源授权。
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)

View File

@@ -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]

View File

@@ -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]

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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) }