feat(运营报表): AUG26-015 设备激活与套餐续费日报快照、查询趋势与受控导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 15m33s

- 新增成对迁移 000231 与三张快照表:日级头行、设备粒度激活行、到期事件粒度续费行,以快照日期为唯一键
- 新增每日 03:30(Asia/Shanghai)日报快照生成任务与幂等整日替换,失败重试沿用同一目标日
- 新增六条受控入口:两张报表的汇总、日/月趋势与受控导出,配套查询层只读快照事实
- 新增 operations_activation 与 operations_renewal 两个导出场景,创建期冻结筛选与可见店铺范围、派发期冻结表头、执行期只按冻结值复核资格
- 采购数量口径按系统内未删除设备数实施并在 PRD 标注,附实测差额依据
- 同步证据链 requirement-evidence.json 与入口能力矩阵、ARCHITECTURE 与验证记录
- 归档 change add-operations-reports 并新建主 Spec openspec/specs/operations-report/spec.md
This commit is contained in:
2026-09-18 09:42:28 +08:00
parent 6333f4ad13
commit 5e78809b93
46 changed files with 5580 additions and 101 deletions

View File

@@ -0,0 +1,88 @@
package task
import (
"context"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"go.uber.org/zap"
operationsreportapp "github.com/break/junhong_cmp_fiber/internal/application/operationsreport"
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// OperationsReportSnapshotPayload 是每日运营报表快照任务的载荷。
//
// 载荷只含单一目标上海自然日。仓库内没有该任务的入队点(不提供补跑接口),
// 带载荷的调用只能来自运维侧 asynq 控制台或临时程序,此时目标日由载荷固定。
// 定时调度**不带载荷**asynq 的 Scheduler 只能注册静态 Task无法按次生成 payload
// 此时目标日 = 处理时刻所在上海自然日的前一天。
//
// 「重试沿用同一目标日期」的依据不是「沿用同一份载荷」(空载荷会在重试时刻重新推导),
// 而是「cron 时点 + 有界重试窗口」:默认重试延迟为 n^4+15+rand(0..29)*(n+1) 秒
// asynq v0.25.1 server.go 的 DefaultRetryDelayFunc本仓库 pkg/queue/server.go 显式采用),
// MaxRetry(3) 的三次重试合计 ≤ 236 秒,叠加 Timeout(30m) 仍远小于一个上海自然日;
// 服务端重试路径不经过唯一锁,因此 23 小时去重窗口不会吞掉失败重试。
// 调整 cron 时点、MaxRetry 或 Timeout 必须重新评估该前提。
type OperationsReportSnapshotPayload struct {
SnapshotDate string `json:"snapshot_date"`
}
// OperationsReportSnapshotHandler 每日运营报表快照任务处理器。
// 处理器是薄壳:解析目标日期后调用生成用例,口径与幂等全部在用例内闭合。
type OperationsReportSnapshotHandler struct {
generator *operationsreportapp.Generator
logger *zap.Logger
}
// NewOperationsReportSnapshotHandler 创建每日运营报表快照任务处理器。
func NewOperationsReportSnapshotHandler(generator *operationsreportapp.Generator, logger *zap.Logger) *OperationsReportSnapshotHandler {
return &OperationsReportSnapshotHandler{generator: generator, logger: logger}
}
// Handle 生成当日(或载荷指定的)运营报表日报快照。
func (h *OperationsReportSnapshotHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.generator == nil {
return errors.New(errors.CodeInternalError, "运营报表快照生成用例未配置")
}
snapshotDate, err := ResolveOperationsReportSnapshotDate(task, time.Now())
if err != nil {
return err
}
if err := h.generator.Generate(ctx, snapshotDate); err != nil {
if h.logger != nil {
h.logger.Error("运营报表日报快照生成失败",
zap.String("snapshot_date", domainreport.FormatSnapshotDay(snapshotDate)),
zap.Error(err))
}
return err
}
return nil
}
// ResolveOperationsReportSnapshotDate 解析本次生成的目标上海自然日。
//
// 载荷为空(定时调度)时取 now 所在上海自然日的前一自然日;
// 载荷给出目标日期时必须为 yyyy-MM-dd非法载荷直接失败不静默退化为「当天」。
func ResolveOperationsReportSnapshotDate(task *asynq.Task, now time.Time) (time.Time, error) {
if task != nil {
payload := task.Payload()
if len(payload) > 0 {
var parsed OperationsReportSnapshotPayload
if err := sonic.Unmarshal(payload, &parsed); err != nil {
return time.Time{}, errors.Wrap(errors.CodeInvalidParam, err, "运营报表快照任务载荷格式不正确")
}
if parsed.SnapshotDate != "" {
day, err := domainreport.ParseSnapshotDay(parsed.SnapshotDate)
if err != nil {
return time.Time{}, errors.New(errors.CodeInvalidParam,
"运营报表快照任务的目标日期必须为 yyyy-MM-dd 格式的上海自然日")
}
return day, nil
}
}
}
return domainreport.PreviousDay(now), nil
}