feat(收口): 补齐 8 月迭代缺口并同步 Spec 与证据链
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled

- 新增六对成对迁移 000232–000237:H5 弹窗类型、退款结算标识与申请人备注、优先轮询事实字段与两个新终态、通道阈值命中留痕、手机号最近解绑人、提现资格校验留痕
- 退款:原因必填与申请人备注、来源支付与渠道流水冻结、线下处理流水号补录审计、按订单查询可选退款方式、企微审批材料补齐且新增字段缺失映射即明确失败
- 优先轮询:人工关闭、有效期到期独立周期任务、失败与过期人工重触发、事实字段与异常重试查询、资产解析端点只读投影
- 通道阈值:命中事实同事务留痕与命中记录查询;员工账单:列表筛选与详情投影;商户池:列表投影与统计周期语义;H5:弹窗类型与类别排序
- 手机号:有效关联数量与最近解绑人、短信验证码失败次数限制;导出:佣金明细十五列与报表序号列
- 时间筛选:三处新增筛选纳入统一严格解析契约,员工账单产生时间参数改名
- 同步 12 份主 Spec 需求、两端点与异步任务证据链,门禁 context-health 与 OpenSpec 校验通过
This commit is contained in:
2026-09-18 15:34:29 +08:00
parent 5e78809b93
commit 5ed6b39deb
142 changed files with 7878 additions and 964 deletions

View File

@@ -44,6 +44,7 @@ func NewConfigurationService(db *gorm.DB, audit *audit.Writer) *ConfigurationSer
type configurationInput struct {
Title string
Content string
PopupType string
Pages []string
ShopIDs []uint
DeviceTypes []string
@@ -69,7 +70,12 @@ func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5P
if request.Enabled != nil && *request.Enabled {
enabled = constants.H5PopupStatusEnabled
}
priority := 0
// 弹窗类型必填:它既参与类别排序,也决定缺省优先级的取值来源。
popupType := strings.TrimSpace(request.PopupType)
if !constants.IsH5PopupType(popupType) {
return 0, errors.New(errors.CodeInvalidParam, "运营弹窗类型必须为 promotion套餐政策推广或 announcement通用公告")
}
priority := constants.H5PopupTypeDefaultPriority(popupType)
if request.Priority != nil {
priority = *request.Priority
}
@@ -78,7 +84,7 @@ func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5P
actionType = *request.ActionType
}
normalized, err := normalizeConfigurationInput(configurationInput{
Title: request.Title, Content: request.Content, Pages: request.Pages,
Title: request.Title, Content: request.Content, PopupType: popupType, Pages: request.Pages,
ShopIDs: request.ShopIDs, DeviceTypes: request.DeviceTypes, CardTypes: request.CardTypes,
Priority: priority, Frequency: request.Frequency, ActionType: actionType,
Enabled: enabled, StartsAt: request.StartsAt, EndsAt: request.EndsAt,
@@ -88,7 +94,7 @@ func (s *ConfigurationService) Create(ctx context.Context, request dto.CreateH5P
}
now := time.Now().UTC()
record := &model.H5PopupConfiguration{
Title: normalized.Title, Content: normalized.Content,
Title: normalized.Title, Content: normalized.Content, PopupType: normalized.PopupType,
Pages: model.StringJSONBArray(normalized.Pages), ShopIDs: toJSONBStrings(normalized.ShopIDs),
DeviceTypes: model.StringJSONBArray(normalized.DeviceTypes), CardTypes: model.StringJSONBArray(normalized.CardTypes),
Priority: normalized.Priority, Frequency: normalized.Frequency, ActionType: normalized.ActionType,
@@ -136,7 +142,7 @@ func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.
beforeData := configurationAuditSnapshot(&before)
merged := configurationInput{
Title: record.Title, Content: record.Content, Pages: storePages(record),
Title: record.Title, Content: record.Content, PopupType: record.PopupType, Pages: storePages(record),
ShopIDs: storeShopIDs(record), DeviceTypes: storeDeviceTypes(record), CardTypes: storeCardTypes(record),
Priority: record.Priority, Frequency: record.Frequency, ActionType: record.ActionType,
Enabled: record.Enabled, StartsAt: record.StartsAt, EndsAt: record.EndsAt,
@@ -147,6 +153,9 @@ func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.
if request.Content != nil {
merged.Content = *request.Content
}
if request.PopupType != nil {
merged.PopupType = strings.TrimSpace(*request.PopupType)
}
if request.Pages != nil {
merged.Pages = *request.Pages
}
@@ -184,6 +193,7 @@ func (s *ConfigurationService) Update(ctx context.Context, id uint, request dto.
now := time.Now().UTC()
record.Title = normalized.Title
record.Content = normalized.Content
record.PopupType = normalized.PopupType
record.Pages = model.StringJSONBArray(normalized.Pages)
record.ShopIDs = toJSONBStrings(normalized.ShopIDs)
record.DeviceTypes = model.StringJSONBArray(normalized.DeviceTypes)
@@ -311,6 +321,11 @@ func normalizeConfigurationInput(input configurationInput) (configurationInput,
if popupURLPattern.MatchString(normalized.Content) || popupRoutePattern.MatchString(normalized.Content) {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗正文不接受 URL 或前端路由,只能使用受控动作")
}
// 类型必填且必须落在受控取值域内:它是候选排序的第一顺位类别键,非法值会让配置落进未定义类别。
normalized.PopupType = strings.TrimSpace(input.PopupType)
if !constants.IsH5PopupType(normalized.PopupType) {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗类型必须为 promotion套餐政策推广或 announcement通用公告")
}
normalized.Pages = dedupeStrings(input.Pages)
if len(normalized.Pages) == 0 {
return configurationInput{}, errors.New(errors.CodeInvalidParam, "运营弹窗至少需要一个命中页面")
@@ -471,16 +486,16 @@ func configurationAuditResourceID(id uint) *string {
// configurationAuditIdentity 生成配置身份快照,不含正文内容。
func configurationAuditIdentity(record *model.H5PopupConfiguration) map[string]any {
return map[string]any{
"id": record.ID, "title": record.Title, "pages": storePages(record),
"id": record.ID, "title": record.Title, "popup_type": record.PopupType, "pages": storePages(record),
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,
"enabled": record.Enabled, "version": record.Version,
}
}
// configurationAuditSnapshot 生成配置审计前后值快照,覆盖范围、优先级、频率、受控动作、启停、有效期与版本。
// configurationAuditSnapshot 生成配置审计前后值快照,覆盖类型、范围、优先级、频率、受控动作、启停、有效期与版本。
func configurationAuditSnapshot(record *model.H5PopupConfiguration) map[string]any {
return map[string]any{
"id": record.ID, "title": record.Title, "content": record.Content,
"id": record.ID, "title": record.Title, "content": record.Content, "popup_type": record.PopupType,
"pages": storePages(record), "shop_ids": storeShopIDs(record),
"device_types": storeDeviceTypes(record), "card_types": storeCardTypes(record),
"priority": record.Priority, "frequency": record.Frequency, "action_type": record.ActionType,