Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕 - 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败 - 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影 - 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序 - 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列 - 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名 - 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
315 lines
13 KiB
Go
315 lines
13 KiB
Go
package h5popup
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/bytedance/sonic"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/clause"
|
||
|
||
notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification"
|
||
"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/postgres"
|
||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||
)
|
||
|
||
// activeShippingExchangeStatuses 是压制风险候选的物流换货单状态集合。
|
||
// 含已完成的 4:已完成物流换货单说明风险换卡已走完流程,此时必须停止新投放;
|
||
// 必须同时限定 flow_type=shipping,直接换货单创建即已完成,不限定会永久压制风险候选。
|
||
var activeShippingExchangeStatuses = []int{
|
||
constants.ExchangeStatusPendingInfo,
|
||
constants.ExchangeStatusPendingShip,
|
||
constants.ExchangeStatusShipped,
|
||
constants.ExchangeStatusCompleted,
|
||
}
|
||
|
||
// CandidateService 按当前资产事实投放风险换卡或运营弹窗候选。
|
||
// 查询会创建或复用通知并保持未读,即 GET 有副作用:运营弹窗只在客户请求页面时实时匹配、不预生成。
|
||
type CandidateService struct {
|
||
db *gorm.DB
|
||
identifiers *postgres.AssetIdentifierStore
|
||
cards *postgres.IotCardStore
|
||
devices *postgres.DeviceStore
|
||
ownership AssetOwnership
|
||
notifications notificationapp.DirectWriter
|
||
now func() time.Time
|
||
}
|
||
|
||
// NewCandidateService 创建 H5 弹窗候选投放用例。
|
||
// 资产标识解析复用既有 Store 方法,保证口径与资产详情、换货等入口一致。
|
||
func NewCandidateService(
|
||
db *gorm.DB,
|
||
identifiers *postgres.AssetIdentifierStore,
|
||
cards *postgres.IotCardStore,
|
||
devices *postgres.DeviceStore,
|
||
ownership AssetOwnership,
|
||
notifications notificationapp.DirectWriter,
|
||
) *CandidateService {
|
||
return &CandidateService{
|
||
db: db, identifiers: identifiers, cards: cards, devices: devices,
|
||
ownership: ownership, notifications: notifications, now: time.Now,
|
||
}
|
||
}
|
||
|
||
// GetCandidate 返回当前页面与当前资产的唯一弹窗候选;没有可投放弹窗时 candidate 为空。
|
||
// 顺序固定:先判风险换卡资格,命中则只处理风险分支;未命中再匹配运营配置。
|
||
func (s *CandidateService) GetCandidate(ctx context.Context, customerID uint, request dto.PopupCandidateRequest) (*dto.PopupCandidateResponse, error) {
|
||
if customerID == 0 {
|
||
return nil, errors.New(errors.CodeUnauthorized)
|
||
}
|
||
identifier := strings.TrimSpace(request.Identifier)
|
||
if !constants.IsH5PopupPage(request.Page) || identifier == "" {
|
||
return nil, errors.New(errors.CodeInvalidParam, "弹窗候选参数不合法")
|
||
}
|
||
if s == nil || s.db == nil || s.identifiers == nil || s.cards == nil || s.devices == nil ||
|
||
s.ownership == nil || s.notifications == nil {
|
||
return nil, errors.New(errors.CodeServiceUnavailable, "弹窗投放能力尚未配置")
|
||
}
|
||
assetType, assetID, err := s.resolveAssetIdentity(ctx, identifier)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if assetType == "" {
|
||
return nil, invisibleAssetError()
|
||
}
|
||
owned, err := s.ownership.OwnsAsset(ctx, customerID, assetType, assetID)
|
||
if err != nil {
|
||
if isAssetNotFound(err) {
|
||
return nil, invisibleAssetError()
|
||
}
|
||
return nil, err
|
||
}
|
||
if !owned {
|
||
return nil, invisibleAssetError()
|
||
}
|
||
facts, err := s.loadAssetFacts(ctx, assetType, assetID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
now := s.now().UTC()
|
||
|
||
if facts.RiskStopped {
|
||
blocked, err := findActiveShippingExchange(ctx, s.db, facts.AssetType, facts.AssetID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if blocked == nil {
|
||
candidate, err := s.deliverRiskCandidate(ctx, customerID, facts, now)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &dto.PopupCandidateResponse{Candidate: candidate}, nil
|
||
}
|
||
}
|
||
|
||
candidate, err := s.deliverOperationCandidate(ctx, customerID, request.Page, facts, now)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &dto.PopupCandidateResponse{Candidate: candidate}, nil
|
||
}
|
||
|
||
// deliverRiskCandidate 创建或复用「客户+资产+上海自然日」的风险换卡通知。
|
||
// 当日通知已存在且未读时返回同一通知;已被客户关闭(已读)时当日不再返回候选,次日条件成立会创建新通知。
|
||
func (s *CandidateService) deliverRiskCandidate(ctx context.Context, customerID uint, facts *assetFacts, now time.Time) (*dto.PopupCandidateItem, error) {
|
||
notification, err := s.notifications.CreateOrGetPersonal(ctx, riskEventKey(customerID, facts, now), customerID, notificationapp.PersonalDirectRequest{
|
||
NotificationType: constants.NotificationTypeH5PopupRiskExchange,
|
||
RefType: constants.NotificationRefTypeAsset,
|
||
RefID: strconv.FormatUint(uint64(facts.AssetID), 10),
|
||
RefKey: facts.Identifier,
|
||
ExpiresAt: popupExpiresAt(now),
|
||
PopupSnapshot: &model.NotificationPopupSnapshot{
|
||
AssetType: facts.AssetType, AssetID: facts.AssetID,
|
||
},
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if notification.IsRead {
|
||
return nil, nil
|
||
}
|
||
return toCandidateItem(notification), nil
|
||
}
|
||
|
||
// deliverOperationCandidate 匹配运营配置并按频率创建或复用运营弹窗通知。
|
||
// 只返回优先级最高一条;同优先级取最近更新时间最新,启停同样刷新该时间。
|
||
func (s *CandidateService) deliverOperationCandidate(ctx context.Context, customerID uint, page string, facts *assetFacts, now time.Time) (*dto.PopupCandidateItem, error) {
|
||
config, err := s.matchOperationConfig(ctx, page, facts, now)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if config == nil {
|
||
return nil, nil
|
||
}
|
||
notification, err := s.notifications.CreateOrGetPersonal(ctx, operationEventKey(customerID, config, now), customerID, notificationapp.PersonalDirectRequest{
|
||
NotificationType: constants.NotificationTypeH5PopupOperation,
|
||
TemplateData: map[string]string{"title": config.Title, "content": config.Content},
|
||
RefType: constants.NotificationRefTypeAsset,
|
||
RefID: strconv.FormatUint(uint64(facts.AssetID), 10),
|
||
RefKey: facts.Identifier,
|
||
ExpiresAt: popupExpiresAt(now),
|
||
PopupSnapshot: &model.NotificationPopupSnapshot{
|
||
ConfigID: config.ID, ConfigVersion: config.Version,
|
||
AssetType: facts.AssetType, AssetID: facts.AssetID, ActionType: config.ActionType,
|
||
},
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if notification.IsRead {
|
||
return nil, nil
|
||
}
|
||
return toCandidateItem(notification), nil
|
||
}
|
||
|
||
// matchOperationConfig 按时间、启停、页面、店铺、设备类型、卡类型范围匹配运营配置。
|
||
// 范围同一维度多选取任一命中;未配置该维度即全量;已配置而资产该维度无值时该配置不命中。
|
||
func (s *CandidateService) matchOperationConfig(ctx context.Context, page string, facts *assetFacts, now time.Time) (*model.H5PopupConfiguration, error) {
|
||
pageJSON, err := jsonbScalar(page)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var shopID *string
|
||
if facts.ShopID != nil {
|
||
text := strconv.FormatUint(uint64(*facts.ShopID), 10)
|
||
shopID = &text
|
||
}
|
||
shopJSON, err := jsonbScalarPointer(shopID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
deviceJSON, err := jsonbScalar(facts.DeviceType)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
cardJSON, err := jsonbScalar(facts.CarrierType)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var config model.H5PopupConfiguration
|
||
err = s.db.WithContext(ctx).Model(&model.H5PopupConfiguration{}).
|
||
Where("enabled = ?", constants.H5PopupStatusEnabled).
|
||
Where("starts_at <= ? AND ends_at >= ?", now, now).
|
||
Where("?::jsonb <@ pages", pageJSON).
|
||
Where("(jsonb_array_length(shop_ids) = 0 OR ?::jsonb <@ shop_ids)", shopJSON).
|
||
Where("(jsonb_array_length(device_types) = 0 OR ?::jsonb <@ device_types)", deviceJSON).
|
||
Where("(jsonb_array_length(card_types) = 0 OR ?::jsonb <@ card_types)", cardJSON).
|
||
// 候选排序:先按类型类别(风险换卡提醒已在调用方先行处理,此处为推广高于公告),
|
||
// 再按显式优先级降序,同优先级取最近更新时间最新,最后按 ID 稳定排序。
|
||
// 类别顺序 MUST 用显式表达式表达:announcement 的字典序小于 promotion,
|
||
// 直接按 popup_type 排序会把公告排在推广之前,与 111.md §13.3 相反。
|
||
Order(clause.OrderBy{Expression: clause.Expr{
|
||
SQL: "CASE popup_type WHEN ? THEN 0 ELSE 1 END ASC",
|
||
Vars: []any{constants.H5PopupTypePromotion},
|
||
}}).
|
||
Order("priority DESC, updated_at DESC, id DESC").
|
||
Take(&config).Error
|
||
if err != nil {
|
||
if isRecordNotFound(err) {
|
||
return nil, nil
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "匹配运营弹窗配置失败")
|
||
}
|
||
return &config, nil
|
||
}
|
||
|
||
// findActiveShippingExchange 查询指定资产是否已存在活动物流换货单。
|
||
// 取 flow_type=shipping 且状态属于待填写、待发货、已发货待确认、已完成,任一命中即视为已处理。
|
||
func findActiveShippingExchange(ctx context.Context, db *gorm.DB, assetType string, assetID uint) (*model.ExchangeOrder, error) {
|
||
var order model.ExchangeOrder
|
||
err := db.WithContext(ctx).
|
||
Where("old_asset_type = ? AND old_asset_id = ? AND flow_type = ?", assetType, assetID, constants.ExchangeFlowTypeShipping).
|
||
Where("status IN ?", activeShippingExchangeStatuses).
|
||
Order("id DESC").
|
||
Take(&order).Error
|
||
if err != nil {
|
||
if isRecordNotFound(err) {
|
||
return nil, nil
|
||
}
|
||
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询活动物流换货单失败")
|
||
}
|
||
return &order, nil
|
||
}
|
||
|
||
// riskEventKey 生成风险换卡通知事件键:客户 + 资产 + 上海自然日,复用通知唯一约束保证一天一条。
|
||
func riskEventKey(customerID uint, facts *assetFacts, now time.Time) string {
|
||
return popupEventKey(constants.H5PopupRiskEventKeyPrefix+"."+shanghaiDate(now),
|
||
strconv.FormatUint(uint64(customerID), 10), facts.AssetType, strconv.FormatUint(uint64(facts.AssetID), 10))
|
||
}
|
||
|
||
// operationEventKey 生成运营弹窗通知事件键:客户 + 配置 + 版本,daily 频率再追加上海自然日。
|
||
// 频率口径按「每客户每配置支持仅一次或每天一次」,因此键内不含资产,客户换资产不会额外获得投放。
|
||
func operationEventKey(customerID uint, config *model.H5PopupConfiguration, now time.Time) string {
|
||
prefix := constants.H5PopupOperationOnceEventKeyPrefix
|
||
if config.Frequency == constants.H5PopupFrequencyDaily {
|
||
prefix = constants.H5PopupOperationDailyEventKeyPrefix + "." + shanghaiDate(now)
|
||
}
|
||
return popupEventKey(prefix,
|
||
strconv.FormatUint(uint64(customerID), 10), strconv.FormatUint(uint64(config.ID), 10), strconv.FormatInt(config.Version, 10))
|
||
}
|
||
|
||
// popupEventKey 生成固定长度的通知事件键:前缀 + 身份摘要。
|
||
// tb_notification.event_id 为 varchar(64),身份部分用 sha256 前 12 字节十六进制压缩,
|
||
// 保证资产与客户 ID 位数增长后仍不超长,同时保持确定性以便复用既有唯一约束去重。
|
||
func popupEventKey(prefix string, parts ...string) string {
|
||
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
|
||
return prefix + "." + hex.EncodeToString(sum[:12])
|
||
}
|
||
|
||
// popupExpiresAt 返回弹窗投放通知的展示截止时间:投放时间 + 90 天。
|
||
// 弹窗类别沿用 system(展示上限 365 天),90 天在其内,事实物理保留仍按系统类别的 365 天。
|
||
func popupExpiresAt(now time.Time) *time.Time {
|
||
expiresAt := now.AddDate(0, 0, constants.H5PopupDisplayDays)
|
||
return &expiresAt
|
||
}
|
||
|
||
// jsonbScalar 将字符串编码为可直接参与 jsonb 包含判断的 JSON 标量。
|
||
func jsonbScalar(value string) (string, error) {
|
||
encoded, err := sonic.Marshal(value)
|
||
if err != nil {
|
||
return "", errors.Wrap(errors.CodeInternalError, err, "编码弹窗匹配值失败")
|
||
}
|
||
return string(encoded), nil
|
||
}
|
||
|
||
// jsonbScalarPointer 将可空字符串编码为 JSON 标量;nil 编码为 JSON null,任何已配置范围都不命中。
|
||
func jsonbScalarPointer(value *string) (string, error) {
|
||
if value == nil {
|
||
return "null", nil
|
||
}
|
||
return jsonbScalar(*value)
|
||
}
|
||
|
||
// toCandidateItem 将冻结的通知投影为客户端候选;配置标识与受控动作取通知快照
|
||
// 而不是当前配置,保证配置修改后旧通知与旧快照不被改写。
|
||
func toCandidateItem(notification *model.Notification) *dto.PopupCandidateItem {
|
||
if notification == nil {
|
||
return nil
|
||
}
|
||
item := &dto.PopupCandidateItem{
|
||
NotificationID: notification.ID, NotificationType: notification.Type,
|
||
Title: notification.Title, Body: notification.Body,
|
||
ExpiresAt: notification.ExpiresAt, CreatedAt: notification.CreatedAt,
|
||
}
|
||
if notification.Type == constants.NotificationTypeH5PopupRiskExchange {
|
||
item.PopupType = constants.H5PopupCandidateTypeRiskExchange
|
||
} else {
|
||
item.PopupType = constants.H5PopupCandidateTypeOperation
|
||
}
|
||
if snapshot := notification.PopupSnapshot; snapshot != nil {
|
||
item.AssetType = snapshot.AssetType
|
||
item.AssetID = snapshot.AssetID
|
||
item.ConfigID = snapshot.ConfigID
|
||
item.ConfigVersion = snapshot.ConfigVersion
|
||
item.ActionType = snapshot.ActionType
|
||
}
|
||
return item
|
||
}
|