All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m23s
新增 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)与参数校验中文提示共用实现。
171 lines
7.6 KiB
Go
171 lines
7.6 KiB
Go
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,
|
||
}
|
||
}
|