修复通知
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m25s

This commit is contained in:
2026-08-11 11:18:11 +08:00
parent 5c01008d8a
commit a795ca3650
20 changed files with 167 additions and 37 deletions

View File

@@ -26,7 +26,7 @@ export JUNHONG_LOGGING_ACCESS_LOG_FILENAME="$PWD/logs/access.log"
## 数据库迁移
`scripts/migrate.sh` 使用 `DB_*` 参数,与应用的 `JUNHONG_DATABASE_*` 前缀不同只连接明确指定的隔离数据库:
空白隔离库先导入 `scripts/manual_db_release/generated/00_schema_baseline.sql`,再执行 `scripts/manual_db_release/06_mark_schema_migrations.sql` 标记版本 140最后运行后续迁移历史 1 至 114 已归档,不能直接对空库执行 `migrate.sh up``scripts/migrate.sh` 使用 `DB_*` 参数,与应用的 `JUNHONG_DATABASE_*` 前缀不同;显式传入的 `DB_*` 优先于 `.env`只连接明确指定的隔离数据库:
```bash
DB_HOST=127.0.0.1 DB_PORT=5432 DB_USER=<USER> DB_PASSWORD=<PASSWORD> \
@@ -96,7 +96,7 @@ openspec validate --all
./scripts/context-health.sh
```
自动化测试当前为 N/A用户决策当前验证依赖构建、OpenAPI、OpenSpec、结构检查、smoke、日志和独立审计。
自动化测试当前为 N/A用户决策当前验证依赖构建、OpenAPI、OpenSpec、结构检查、smoke、日志和独立审计。执行 Go 命令时的 `GOCACHE``GOMODCACHE` 必须位于 `/private/tmp`,不得写入仓库或 `.lh-harness/`
## 文档入口

View File

@@ -748,7 +748,7 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
asynq.Timeout(10*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypePackageExpiryReminder)),
)); err != nil {
return fmt.Errorf("注册套餐临期提醒定时任务失败: %w", err)
return fmt.Errorf("注册每日套餐临期提醒扫描定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register(
"0 2 * * *",

View File

@@ -1,4 +1,4 @@
// Package packageexpiry 编排套餐临期节点提醒用例。
// Package packageexpiry 编排每日套餐临期提醒用例。
package packageexpiry
import (
@@ -8,7 +8,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ReminderScanner 查询当天命中临期节点的资产。
// ReminderScanner 查询当天临期资产。
type ReminderScanner interface {
ReminderCandidates(ctx context.Context) ([]dto.ExpiringAssetItem, error)
}
@@ -18,7 +18,7 @@ type ReminderPublisher interface {
Publish(ctx context.Context, candidates []dto.ExpiringAssetItem) error
}
// ReminderService 扫描并发布每日套餐临期节点提醒。
// ReminderService 扫描并发布每日套餐临期提醒。
type ReminderService struct {
scanner ReminderScanner
publisher ReminderPublisher
@@ -29,7 +29,7 @@ func NewReminderService(scanner ReminderScanner, publisher ReminderPublisher) *R
return &ReminderService{scanner: scanner, publisher: publisher}
}
// Run 执行当天 15、7、3 天节点扫描并可靠发布通知。
// Run 执行当天临期扫描并可靠发布通知。
func (s *ReminderService) Run(ctx context.Context) error {
if s == nil || s.scanner == nil || s.publisher == nil {
return errors.New(errors.CodeInternalError, "套餐临期提醒用例未配置")

View File

@@ -155,24 +155,24 @@ func (h *AssetHandler) ListExpiring(c *fiber.Ctx) error {
})
}
// TriggerPackageExpiryReminder 手动提交套餐临期提醒扫描任务。
// TriggerPackageExpiryReminder 手动提交每日临期提醒扫描任务。
// POST /api/admin/expiring-assets/reminder-scan
func (h *AssetHandler) TriggerPackageExpiryReminder(c *fiber.Ctx) error {
if middleware.GetUserTypeFromContext(c.UserContext()) != constants.UserTypeSuperAdmin {
return errors.New(errors.CodeForbidden)
}
if h.packageExpiryTrigger == nil {
return errors.New(errors.CodeServiceUnavailable, "套餐临期提醒任务队列未配置")
return errors.New(errors.CodeServiceUnavailable, "每日临期提醒扫描任务队列未配置")
}
if err := h.packageExpiryTrigger(c.UserContext()); err != nil {
logger.GetAppLogger().Error("手动提交套餐临期提醒扫描任务失败", zap.Error(err))
return errors.Wrap(errors.CodeTaskQueueError, err, "提交套餐临期提醒扫描任务失败")
logger.GetAppLogger().Error("手动提交每日临期提醒扫描任务失败", zap.Error(err))
return errors.Wrap(errors.CodeTaskQueueError, err, "提交每日临期提醒扫描任务失败")
}
logger.GetAppLogger().Info("已手动提交套餐临期提醒扫描任务",
logger.GetAppLogger().Info("已手动提交每日临期提醒扫描任务",
zap.Uint("operator_id", middleware.GetUserIDFromContext(c.UserContext())))
return response.Success(c, dto.TriggerPackageExpiryReminderResponse{
TaskType: constants.TaskTypePackageExpiryReminder,
Message: "套餐临期提醒扫描任务已提交",
Message: "每日临期提醒扫描任务已提交",
})
}

View File

@@ -18,7 +18,7 @@ import (
var shanghaiLocation = time.FixedZone("Asia/Shanghai", 8*60*60)
// ReminderPublisher 将临期节点事实转换为店铺和个人客户通知 Outbox。
// ReminderPublisher 将每日临期事实转换为店铺和个人客户通知 Outbox。
type ReminderPublisher struct {
db *gorm.DB
outbox *outbox.Repository

View File

@@ -61,23 +61,13 @@ func (q *Query) List(ctx context.Context, request dto.ExpiringAssetListRequest)
return ListResult{Items: items[start:end], Total: total, Page: request.Page, Size: request.PageSize, Summary: summary}, nil
}
// ReminderCandidates 查询当天精确命中 15、7、3 天节点的全部资产,供每日通知任务复用。
// ReminderCandidates 查询当天全部临期资产,供每日通知任务复用。
func (q *Query) ReminderCandidates(ctx context.Context) ([]dto.ExpiringAssetItem, error) {
items, err := q.collect(ctx, normalizeListRequest(dto.ExpiringAssetListRequest{}))
if err != nil {
return nil, err
}
result := make([]dto.ExpiringAssetItem, 0)
for _, item := range items {
if item.DaysUntilFinalExpiry == nil {
continue
}
switch *item.DaysUntilFinalExpiry {
case 15, 7, 3:
result = append(result, item)
}
}
return result, nil
return items, nil
}
func (q *Query) collect(ctx context.Context, request dto.ExpiringAssetListRequest) ([]dto.ExpiringAssetItem, error) {

View File

@@ -20,8 +20,8 @@ func registerPackageExpiryRoutes(router fiber.Router, handler *admin.AssetHandle
})
Register(router, doc, basePath, "POST", "/expiring-assets/reminder-scan", handler.TriggerPackageExpiryReminder, RouteSpec{
Summary: "手动触发套餐临期提醒扫描",
Description: "仅超级管理员可调用。立即提交与每日 03:00 相同的 15、7、3 天节点扫描任务;任务异步执行并沿用通知防重,不生成临期列表快照。",
Summary: "手动触发每日临期提醒扫描",
Description: "仅超级管理员可调用。立即提交与每日 03:00 相同的每日临期提醒扫描任务:扫描最终到期时间可精确推算且剩余 0 至 15 个上海自然日的资产粉色8 至 15 天、紫色4 至 7 天、红色0 至 3 天)仅表示列表展示等级。任务异步执行并沿用通知防重,不生成临期列表快照。",
Tags: []string{"资产管理"},
Output: new(dto.TriggerPackageExpiryReminderResponse),
Auth: true,

View File

@@ -9,22 +9,22 @@ import (
packageexpiryapp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry"
)
// PackageExpiryReminderHandler 处理每日套餐临期节点提醒任务。
// PackageExpiryReminderHandler 处理每日套餐临期提醒扫描任务。
type PackageExpiryReminderHandler struct {
service *packageexpiryapp.ReminderService
logger *zap.Logger
}
// NewPackageExpiryReminderHandler 创建套餐临期提醒任务处理器。
// NewPackageExpiryReminderHandler 创建每日套餐临期提醒扫描任务处理器。
func NewPackageExpiryReminderHandler(service *packageexpiryapp.ReminderService, logger *zap.Logger) *PackageExpiryReminderHandler {
return &PackageExpiryReminderHandler{service: service, logger: logger}
}
// Handle 扫描 15、7、3 天节点并发布个人客户站内通知。
// Handle 扫描 0 至 15 天临期资产并发布站内通知。
func (h *PackageExpiryReminderHandler) Handle(ctx context.Context, _ *asynq.Task) error {
h.logger.Info("开始执行套餐临期节点提醒")
h.logger.Info("开始执行每日套餐临期提醒扫描")
if err := h.service.Run(ctx); err != nil {
h.logger.Error("套餐临期节点提醒失败", zap.Error(err))
h.logger.Error("每日套餐临期提醒扫描失败", zap.Error(err))
return err
}
return nil

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-11

View File

@@ -0,0 +1,38 @@
## Context
当前临期资产查询已统一筛选最终到期可精确推算、剩余 0 至 15 个上海自然日的卡和设备,并在同一投影中计算颜色等级。提醒候选在此结果上再次限定为 15、7、3 天,造成 14、13 至 0 天的大多数日期漏发。
## Goals / Non-Goals
**Goals:**
- 让临期窗口内的资产每天产生一次可幂等投递的提醒。
- 保持既有列表范围、颜色等级、通知接收人和 Outbox 投递链路。
**Non-Goals:**
- 不修改临期窗口长度、颜色阈值、通知模板、接收人解析或数据库结构。
- 不补写历史漏发日期;上线后的当天扫描按当前剩余天数创建一次提醒。
## Decisions
### 复用临期列表候选集
提醒候选直接保留统一临期查询返回的全部项目,不再按 15、7、3 天二次筛选。该查询已经排除负天数、超过 15 天和不可精确推算的资产,避免维护第二套范围规则。
备选方案是在提醒查询中复制范围条件;该方案会与列表规则漂移,予以排除。
### 以既有事件身份实现每日幂等
继续使用包含最终到期日期和剩余天数的事件身份。剩余天数每天变化,因此每日扫描产生当天的一次提醒;同日重试仍命中同一身份,不重复投递。
备选方案是新增扫描日期字段或去重表;现有事件身份已满足每日去重,无需新增持久化事实。
## Risks / Trade-offs
- [通知量从三个节点增加到最多十六天] → 保持当前异步 Outbox 批量投递与接收人幂等。
- [部署前已错过的历史扫描不会自动回补] → 上线后可执行一次手动扫描,生成当天仍在窗口内资产的提醒。
## Migration Plan
1. 部署 Worker 与 API 代码。
2. 执行一次手动临期扫描,验证窗口内资产生成当天提醒。
3. 回滚时恢复原代码;不涉及 Schema 或数据回滚。

View File

@@ -0,0 +1,26 @@
## Why
套餐临期提醒当前只在剩余 15、7、3 个上海自然日时发送,导致处于其他临期天数的资产没有每日提醒。临期颜色分级与提醒频率是不同规则,需恢复 0 至 15 天内每日提醒。
## What Changes
- 每日临期扫描对最终到期时间可精确推算、剩余 0 至 15 个上海自然日的资产每天创建提醒。
- 保持粉色8 至 15 天、紫色4 至 7 天、红色0 至 3 天)仅作为列表展示分级,不参与提醒候选筛选。
- 更新手动扫描、任务与接口说明,明确其执行每日临期提醒扫描。
## Capabilities
### New Capabilities
无。
### Modified Capabilities
- `notification-delivery`: 套餐临期通知的发送条件由三个固定节点改为临期窗口内每日发送。
- `asset-device`: 临期资产列表的颜色等级继续仅表示展示等级,不作为提醒触发条件。
## Impact
- `internal/query/packageexpiry` 的提醒候选筛选。
- 临期提醒任务、路由 OpenAPI 文案及站内通知 Outbox 事件数量。
- 不新增依赖、接口字段或数据库 Schema。

View File

@@ -0,0 +1,9 @@
## ADDED Requirements
### Requirement: 临期颜色等级仅用于展示
系统 SHALL 对剩余 8 至 15 个上海自然日的临期资产返回粉色等级、对剩余 4 至 7 天返回紫色等级、对剩余 0 至 3 天返回红色等级。颜色等级 MUST 不改变资产是否进入每日临期提醒扫描的条件。
#### Scenario: 红色资产仍每日提醒
- **GIVEN** 一项资产剩余 2 个上海自然日且存在有效通知接收人
- **WHEN** 查询临期资产列表并执行每日临期扫描
- **THEN** 列表返回红色等级,且扫描创建当天的套餐临期通知

View File

@@ -0,0 +1,14 @@
## ADDED Requirements
### Requirement: 套餐临期每日站内提醒
系统 SHALL 在每日扫描时,向最终到期时间可精确推算且剩余 0 至 15 个上海自然日的资产所属店铺后台接收人及其有效个人客户接收人创建套餐临期站内通知。系统 MUST 对同一资产、同一最终到期日期、同一剩余天数和同一接收人保持幂等。
#### Scenario: 临期窗口内连续两日提醒
- **GIVEN** 一项资产的最终到期时间可精确推算,昨天剩余 3 个上海自然日,今天剩余 2 个上海自然日,且两日均有有效接收人
- **WHEN** 每日临期扫描分别执行
- **THEN** 系统分别创建昨天和今天的套餐临期通知
#### Scenario: 不在临期窗口的资产
- **GIVEN** 一项资产剩余超过 15 个上海自然日、已经到期或最终到期时间不可精确推算
- **WHEN** 每日临期扫描执行
- **THEN** 系统不为该资产创建套餐临期通知

View File

@@ -0,0 +1,10 @@
## 1. 每日临期提醒
- [x] 1.1 移除提醒候选的 15、7、3 天节点筛选,复用 0 至 15 天的统一临期候选集。
- [x] 1.2 保持既有 Outbox 事件身份,确认同日重试去重且相邻日期可各发送一次。
## 2. 契约与验证
- [x] 2.1 将任务、路由及接口生成文案改为“每日临期提醒扫描”,保留颜色阈值说明。
- [x] 2.2 使用剩余 2 天的设备验证扫描生成当天通知,并验证同日重复扫描不重复创建事件。
- [x] 2.3 运行 gofmt、go build、文档生成、OpenSpec 校验与上下文健康检查。

View File

@@ -40,6 +40,15 @@
- **WHEN** 两张未删除卡具有相同的 19 位完整 ICCID
- **THEN** 数据库拒绝第二张卡的重复值
### Requirement: 临期颜色等级仅用于展示
系统 SHALL 对剩余 8 至 15 个上海自然日的临期资产返回粉色等级、对剩余 4 至 7 天返回紫色等级、对剩余 0 至 3 天返回红色等级。颜色等级 MUST 不改变资产是否进入每日临期提醒扫描的条件。
#### Scenario: 红色资产仍每日提醒
- **GIVEN** 一项资产剩余 2 个上海自然日且存在有效通知接收人
- **WHEN** 查询临期资产列表并执行每日临期扫描
- **THEN** 列表返回红色等级,且扫描创建当天的套餐临期通知
## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。

View File

@@ -42,6 +42,20 @@
- **WHEN** 店铺主钱包余额已恢复至不低于 100 元,随后扣款使其低于 100 元
- **THEN** 系统创建新的低余额通知事件
### Requirement: 套餐临期每日站内提醒
系统 SHALL 在每日扫描时,向最终到期时间可精确推算且剩余 0 至 15 个上海自然日的资产所属店铺后台接收人及其有效个人客户接收人创建套餐临期站内通知。系统 MUST 对同一资产、同一最终到期日期、同一剩余天数和同一接收人保持幂等。
#### Scenario: 临期窗口内连续两日提醒
- **GIVEN** 一项资产的最终到期时间可精确推算,昨天剩余 3 个上海自然日,今天剩余 2 个上海自然日,且两日均有有效接收人
- **WHEN** 每日临期扫描分别执行
- **THEN** 系统分别创建昨天和今天的套餐临期通知
#### Scenario: 不在临期窗口的资产
- **GIVEN** 一项资产剩余超过 15 个上海自然日、已经到期或最终到期时间不可精确推算
- **WHEN** 每日临期扫描执行
- **THEN** 系统不为该资产创建套餐临期通知
## 可达操作索引
本节只用于入口导航,不是行为 Requirement业务义务以上述 Requirements 为准。

View File

@@ -84,7 +84,7 @@ const (
TaskTypeAlertCheck = "alert:check" // 告警检查
TaskTypeDataCleanup = "data:cleanup" // 数据清理
TaskTypeNotificationCleanup = "notification:cleanup" // 站内通知保留清理
TaskTypePackageExpiryReminder = "package:expiry:reminder" // 套餐 15/7/3 天临期提醒
TaskTypePackageExpiryReminder = "package:expiry:reminder" // 每日套餐临期提醒扫描
TaskTypeDailyTrafficFlush = "traffic:daily:flush" // 每日流量落盘
TaskTypeOutboxDeliver = "outbox:deliver" // 公共 Outbox 事件投递
TaskTypeCardObservationSeries = "card_observation:series" // 卡观测事件序列尝试

View File

@@ -314,7 +314,7 @@ func (h *Handler) registerPackageExpiryReminderHandler() {
service := packageExpiryApp.NewReminderService(query, publisher)
handler := task.NewPackageExpiryReminderHandler(service, h.logger)
h.mux.HandleFunc(constants.TaskTypePackageExpiryReminder, handler.Handle)
h.logger.Info("注册套餐临期节点提醒任务处理器", zap.String("task_type", constants.TaskTypePackageExpiryReminder))
h.logger.Info("注册每日套餐临期提醒扫描任务处理器", zap.String("task_type", constants.TaskTypePackageExpiryReminder))
}
func (h *Handler) registerAutoPurchaseHandler() {

View File

@@ -15,7 +15,7 @@ done
[[ -z "$(command -v omx 2>/dev/null || true)" ]]
! grep -RIlE 'oh-my-codex|OMX:' AGENTS.md CLAUDE.md .agents .claude >/dev/null 2>&1
[[ -z "$(git ls-files .lh-harness)" ]]
[[ -z "$(find . -type f -name '*_test.go' -not -path './.git/*' -print)" ]]
[[ -z "$(find . -type f -name '*_test.go' -not -path './.git/*' -not -path './.lh-harness/*' -print)" ]]
[[ ! -e tests ]]
[[ ! -e internal/testutil ]]
[[ ! -e scripts/benchmark ]]

View File

@@ -5,11 +5,29 @@
set -e
# 加载 .env 文件 (如果存在)
# 加载 .env 文件 (如果存在)。显式传入的 DB_* 始终优先,避免隔离库参数被覆盖。
EXPLICIT_DB_HOST=${DB_HOST+x}
EXPLICIT_DB_PORT=${DB_PORT+x}
EXPLICIT_DB_USER=${DB_USER+x}
EXPLICIT_DB_PASSWORD=${DB_PASSWORD+x}
EXPLICIT_DB_NAME=${DB_NAME+x}
EXPLICIT_DB_SSLMODE=${DB_SSLMODE+x}
SAVED_DB_HOST=${DB_HOST-}
SAVED_DB_PORT=${DB_PORT-}
SAVED_DB_USER=${DB_USER-}
SAVED_DB_PASSWORD=${DB_PASSWORD-}
SAVED_DB_NAME=${DB_NAME-}
SAVED_DB_SSLMODE=${DB_SSLMODE-}
if [ -f .env ]; then
echo "正在加载 .env 文件..."
export $(grep -v '^#' .env | xargs)
fi
[ -n "$EXPLICIT_DB_HOST" ] && DB_HOST=$SAVED_DB_HOST
[ -n "$EXPLICIT_DB_PORT" ] && DB_PORT=$SAVED_DB_PORT
[ -n "$EXPLICIT_DB_USER" ] && DB_USER=$SAVED_DB_USER
[ -n "$EXPLICIT_DB_PASSWORD" ] && DB_PASSWORD=$SAVED_DB_PASSWORD
[ -n "$EXPLICIT_DB_NAME" ] && DB_NAME=$SAVED_DB_NAME
[ -n "$EXPLICIT_DB_SSLMODE" ] && DB_SSLMODE=$SAVED_DB_SSLMODE
# 默认配置
MIGRATIONS_DIR="${MIGRATIONS_DIR:-migrations}"