Files
junhong_cmp_fiber/internal/application/h5popup/risk_exchange.go
break 333ba4b647
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s
feat(H5弹窗): AUG26-007 风险换卡与运营弹窗投放通知
新增 000225 迁移:运营弹窗配置表 tb_h5_popup_configuration(页面/范围/优先级/频率/受控动作/启停/有效期/版本)
与 tb_notification 可空 JSONB 列 popup_snapshot。

新增通知直建窄接口 DirectWriter.CreateOrGetPersonal:与 Outbox 消费共用 prepareDelivery 的渲染、
展示期与 CreateIdempotent 规则,冲突时回查返回既有行;同步扩展个人通知查询与已读两处类型白名单,
并按个人客户入口补齐投递审计来源。

新增 H5 候选与风险换卡:GET /api/c/v1/popup-candidates 先判风险资格(广电卡 + 风险停机 +
无活动物流换货单),命中只返回风险候选;未命中再按时间/启停/页面/店铺/设备类型/卡类型范围/频率
匹配运营配置。POST /api/c/v1/risk-exchanges/:asset_id/address 锁资产行后幂等创建待发货物流换货单,
首次地址锁定,不沿用资产级群发通知。

新增后台运营弹窗配置 CRUD 与启停(仅超级管理员与平台账号),更新递增版本并刷新最近更新时间,
标题与正文统一拒绝 URL 与前端路由,全部写操作记录操作者、前后值、版本与时间。

同步 OpenAPI(cmd/gendocs、cmd/api/docs.go、pkg/openapi/handlers.go)与参数校验中文提示共用实现。
2026-09-15 15:23:52 +08:00

171 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package h5popup
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RiskExchangeService 处理个人客户自助风险换卡的地址提交。
// 幂等靠「锁定旧资产行 + 去重查询既有活动物流换货单」实现,不引入数据库唯一约束:
// 资产实例同一时刻只属于一个客户,锁资产行即可覆盖重复提交与并发提交。
type RiskExchangeService struct {
db *gorm.DB
ownership AssetOwnership
auditWriter *audit.Writer
}
// NewRiskExchangeService 创建风险换卡地址提交事务脚本。
func NewRiskExchangeService(db *gorm.DB, ownership AssetOwnership, auditWriter *audit.Writer) *RiskExchangeService {
return &RiskExchangeService{db: db, ownership: ownership, auditWriter: auditWriter}
}
// Submit 幂等提交风险换卡收货地址,创建关联旧资产的物流换货单。
// 事务内顺序固定为:锁旧资产行 → 复核风险资格 → 去重查询 → 未命中才插入。
// 重复提交返回首次创建的换货单与首次地址,不覆盖既有地址。
func (s *RiskExchangeService) Submit(ctx context.Context, customerID, assetID uint, request dto.ClientRiskExchangeAddressParams) (*dto.ClientRiskExchangeResponse, error) {
if customerID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
if assetID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "风险换卡资产ID不合法")
}
if s == nil || s.db == nil || s.ownership == nil || s.auditWriter == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "风险换卡能力尚未配置")
}
// 归属校验必须使用权威实现;资产不存在与归属失败返回同态不可见结果。
owned, err := s.ownership.OwnsAsset(ctx, customerID, constants.AssetTypeIotCard, assetID)
if err != nil {
if isAssetNotFound(err) {
return nil, invisibleAssetError()
}
return nil, err
}
if !owned {
return nil, invisibleAssetError()
}
var result *dto.ClientRiskExchangeResponse
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("id = ?", assetID).Take(&card).Error; err != nil {
if isRecordNotFound(err) {
return invisibleAssetError()
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定换卡资产失败")
}
// 锁内复核风险资格:持锁前的判定可能已被并发状态同步改变。
if card.CarrierType != constants.CarrierTypeCBN ||
strings.TrimSpace(card.GatewayExtend) != constants.GatewayCardExtendRiskStop {
return errors.New(errors.CodeH5PopupRiskNotEligible)
}
existing, err := findActiveShippingExchange(ctx, tx, constants.AssetTypeIotCard, card.ID)
if err != nil {
return err
}
if existing != nil {
result = toRiskExchangeResponse(existing)
return nil
}
order := &model.ExchangeOrder{
ExchangeNo: model.GenerateExchangeNo(),
FlowType: constants.ExchangeFlowTypeShipping,
OldAssetType: constants.AssetTypeIotCard,
OldAssetID: card.ID,
OldAssetIdentifier: card.ICCID,
RecipientName: request.RecipientName,
RecipientPhone: request.RecipientPhone,
RecipientAddress: request.RecipientAddress,
ShopID: card.ShopID,
ExchangeReason: constants.H5PopupRiskExchangeReason,
// 客户已提交收货信息,因此创建即待发货;不预设业务数据迁移,发货选新资产时仍由后台按既有流程决定。
Status: constants.ExchangeStatusPendingShip,
MigrateData: false,
MigrationStatus: constants.ExchangeMigrationStatusNotMigrated,
// H5 客户上下文没有后台账号 ID置 0 表示由客户自助发起,不冒用任何后台账号身份。
BaseModel: model.BaseModel{Creator: 0, Updater: 0},
}
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建风险换卡单失败")
}
result = toRiskExchangeResponse(order)
return s.appendRiskExchangeAudit(ctx, tx, customerID, order, &card)
})
if err != nil {
return nil, err
}
return result, nil
}
// appendRiskExchangeAudit 在同一事务内记录客户自助换卡的状态事实与旧卡引用。
func (s *RiskExchangeService) appendRiskExchangeAudit(ctx context.Context, tx *gorm.DB, customerID uint, order *model.ExchangeOrder, card *model.IotCard) error {
orderID := strconv.FormatUint(uint64(order.ID), 10)
cardID := strconv.FormatUint(uint64(card.ID), 10)
customerText := strconv.FormatUint(uint64(customerID), 10)
summary := "客户自助提交风险换卡地址"
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionCardRiskExchangeRequested, Summary: summary,
Actor: audit.ActorInput{Kind: constants.AuditActorPersonalCustomer, ID: customerText},
Source: constants.AuditSourcePersonalAPI,
// 个人客户本人业务范围;不使用 platform避免把客户自助事实记成后台操作。
ScopeType: constants.AuditScopePersonalCustomer, ScopeID: customerText,
Result: constants.AuditResultSuccess,
Metadata: map[string]any{"flow_type": constants.ExchangeFlowTypeShipping, "migrate_data": false},
Resources: []audit.ResourceInput{
{
Type: constants.AuditResourceExchangeOrder, ID: &orderID, Key: order.ExchangeNo, DisplayName: order.ExchangeNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCardExchangeOrder,
IdentitySnapshot: map[string]any{
"id": order.ID, "exchange_no": order.ExchangeNo, "flow_type": order.FlowType,
"old_asset_type": order.OldAssetType, "old_asset_id": order.OldAssetID,
"old_asset_identifier": order.OldAssetIdentifier, "shop_id": order.ShopID, "status": order.Status,
},
AfterData: map[string]any{
"status": order.Status, "migrate_data": order.MigrateData, "migration_status": order.MigrationStatus,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
},
{
Type: constants.AuditResourceIotCard, ID: &cardID, Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCardExchangeOldCard,
IdentitySnapshot: audit.IotCardIdentitySnapshot(card),
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
},
},
})
}
// toRiskExchangeResponse 将换货单投影为地址提交结果。
// 地址取记录中的既有值:重复提交返回首次地址,不做任何覆盖。
func toRiskExchangeResponse(order *model.ExchangeOrder) *dto.ClientRiskExchangeResponse {
if order == nil {
return nil
}
return &dto.ClientRiskExchangeResponse{
ID: order.ID, ExchangeNo: order.ExchangeNo,
Status: order.Status, StatusName: constants.GetExchangeStatusName(order.Status),
FlowType: order.FlowType,
OldAssetType: order.OldAssetType,
OldAssetID: order.OldAssetID,
OldAssetIdentifier: order.OldAssetIdentifier,
RecipientName: order.RecipientName,
RecipientPhone: order.RecipientPhone,
RecipientAddress: order.RecipientAddress,
MigrateData: order.MigrateData,
MigrationStatus: order.MigrationStatus,
MigrationStatusName: constants.GetExchangeMigrationStatusName(order.MigrationStatus),
ExchangeReason: order.ExchangeReason,
CreatedAt: order.CreatedAt,
}
}