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)与参数校验中文提示共用实现。
172 lines
6.5 KiB
Go
172 lines
6.5 KiB
Go
// Package h5popup 提供 H5 风险换卡与运营弹窗的候选投放、风险地址提交与运营配置维护用例。
|
||
//
|
||
// 候选查询会创建或复用个人客户通知并保持未读,即 GET 有副作用,这是产品契约的一部分:
|
||
// 运营弹窗只在客户请求页面时实时匹配、不预生成通知,而投放事实又必须与「客户确实访问过」对齐。
|
||
package h5popup
|
||
|
||
import (
|
||
"context"
|
||
stderrors "errors"
|
||
"strings"
|
||
"time"
|
||
|
||
"gorm.io/gorm"
|
||
|
||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
)
|
||
|
||
// shanghaiLocation 是每日去重键使用的上海自然日时区。
|
||
// 与 internal/query/packageexpiry 保持同一口径,避免跨自然日重投判定漂移。
|
||
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
|
||
|
||
// AssetOwnership 校验当前个人客户是否持有指定资产的有效绑定。
|
||
// 归属判定必须使用权威实现 customer_binding.OwnsAsset:换货服务内部只查设备绑定虚拟号的判定
|
||
// 对无虚拟号卡恒为假,直接复用会让无虚拟号的广电卡永远无法自助换卡。
|
||
type AssetOwnership interface {
|
||
OwnsAsset(ctx context.Context, customerID uint, assetType string, assetID uint) (bool, error)
|
||
}
|
||
|
||
// assetFacts 是候选匹配与风险资格判定依赖的当前资产事实。
|
||
type assetFacts struct {
|
||
AssetType string
|
||
AssetID uint
|
||
Identifier string
|
||
ShopID *uint
|
||
CarrierType string
|
||
DeviceType string
|
||
// RiskStopped 只在卡资产上可能为真:运营商为广电且运营商扩展状态严格等于风险停机常量。
|
||
// 已销户不参与该判定,两者合并会把已销户卡一并当作风险换卡对象。
|
||
RiskStopped bool
|
||
}
|
||
|
||
// shanghaiDate 返回上海自然日的 yyyymmdd 文本。
|
||
func shanghaiDate(now time.Time) string {
|
||
return now.In(shanghaiLocation).Format("20060102")
|
||
}
|
||
|
||
// invisibleAssetError 统一「资产不存在」与「资产不属于当前客户」的返回,避免形成可枚举差异。
|
||
func invisibleAssetError() error {
|
||
return errors.New(errors.CodeAssetNotFound)
|
||
}
|
||
|
||
// isAssetNotFound 判断错误是否表示资产不存在或不可见(归属校验失败与资产不存在同态)。
|
||
func isAssetNotFound(err error) bool {
|
||
var appErr *errors.AppError
|
||
if stderrors.As(err, &appErr) {
|
||
return appErr.Code == errors.CodeAssetNotFound
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isRecordNotFound 判断错误是否为 GORM 未命中记录。
|
||
func isRecordNotFound(err error) bool {
|
||
return stderrors.Is(err, gorm.ErrRecordNotFound)
|
||
}
|
||
|
||
// resolveAssetIdentity 按客户端提交的 identifier 定位资产:(资产类型, 资产ID)。
|
||
// 复用既有解析口径:先查全局标识注册表,再按设备与卡的既有标识回退;
|
||
// 卡标识由 IotCardStore.GetByIdentifier 统一处理(virtual_no/iccid/msisdn/iccid_19/iccid_20),
|
||
// 与资产详情解析保持一致,避免自实现查询漏掉 iccid_19/iccid_20 造成静默不投放。
|
||
// 未命中返回空类型,由调用方按不可见处理。
|
||
func (s *CandidateService) resolveAssetIdentity(ctx context.Context, identifier string) (string, uint, error) {
|
||
record, err := s.identifiers.FindByIdentifier(ctx, identifier)
|
||
if err != nil {
|
||
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产标识失败")
|
||
}
|
||
if record != nil {
|
||
return record.AssetType, record.AssetID, nil
|
||
}
|
||
device, err := s.devices.GetByIdentifier(ctx, identifier)
|
||
if err == nil && device != nil {
|
||
return constants.AssetTypeDevice, device.ID, nil
|
||
}
|
||
if err != nil && !isRecordNotFound(err) {
|
||
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
|
||
}
|
||
card, err := s.cards.GetByIdentifier(ctx, identifier)
|
||
if err == nil && card != nil {
|
||
return constants.AssetTypeIotCard, card.ID, nil
|
||
}
|
||
if err != nil && !isRecordNotFound(err) {
|
||
return "", 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡失败")
|
||
}
|
||
return "", 0, nil
|
||
}
|
||
|
||
// loadAssetFacts 读取候选匹配与风险资格判定所需的资产事实。
|
||
func (s *CandidateService) loadAssetFacts(ctx context.Context, assetType string, assetID uint) (*assetFacts, error) {
|
||
switch assetType {
|
||
case constants.AssetTypeIotCard:
|
||
card, err := s.cards.GetByID(ctx, assetID)
|
||
if err != nil {
|
||
if isRecordNotFound(err) {
|
||
return nil, invisibleAssetError()
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡资产失败")
|
||
}
|
||
facts := &assetFacts{
|
||
AssetType: constants.AssetTypeIotCard, AssetID: card.ID, Identifier: card.ICCID,
|
||
ShopID: card.ShopID, CarrierType: card.CarrierType,
|
||
RiskStopped: card.CarrierType == constants.CarrierTypeCBN &&
|
||
strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendRiskStop,
|
||
}
|
||
deviceType, err := s.boundDeviceType(ctx, card.ID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
facts.DeviceType = deviceType
|
||
return facts, nil
|
||
case constants.AssetTypeDevice:
|
||
device, err := s.devices.GetByID(ctx, assetID)
|
||
if err != nil {
|
||
if isRecordNotFound(err) {
|
||
return nil, invisibleAssetError()
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备资产失败")
|
||
}
|
||
return &assetFacts{
|
||
AssetType: constants.AssetTypeDevice, AssetID: device.ID,
|
||
Identifier: deviceIdentifier(device), ShopID: device.ShopID, DeviceType: device.DeviceType,
|
||
}, nil
|
||
default:
|
||
return nil, invisibleAssetError()
|
||
}
|
||
}
|
||
|
||
// boundDeviceType 经卡—设备绑定推导设备类型快照。
|
||
// 独立卡或未绑定设备时该维度为空;空值不匹配任何已配置范围,只有「未配置范围」表示全量。
|
||
func (s *CandidateService) boundDeviceType(ctx context.Context, cardID uint) (string, error) {
|
||
var device model.Device
|
||
err := s.db.WithContext(ctx).
|
||
Table("tb_device AS d").
|
||
Joins("JOIN tb_device_sim_binding AS b ON b.device_id = d.id").
|
||
Where("b.iot_card_id = ? AND b.bind_status = ? AND b.deleted_at IS NULL AND d.deleted_at IS NULL",
|
||
cardID, constants.BindStatusBound).
|
||
Order("b.is_current DESC, b.id DESC").
|
||
Select("d.*").
|
||
Take(&device).Error
|
||
if err != nil {
|
||
if stderrors.Is(err, gorm.ErrRecordNotFound) {
|
||
return "", nil
|
||
}
|
||
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败")
|
||
}
|
||
return device.DeviceType, nil
|
||
}
|
||
|
||
// deviceIdentifier 按虚拟号、IMEI、SN 的稳定优先级生成设备标识快照。
|
||
func deviceIdentifier(device *model.Device) string {
|
||
if device == nil {
|
||
return ""
|
||
}
|
||
if device.VirtualNo != "" {
|
||
return device.VirtualNo
|
||
}
|
||
if device.IMEI != "" {
|
||
return device.IMEI
|
||
}
|
||
return device.SN
|
||
}
|