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,172 @@
package admin
import (
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
domainreport "github.com/break/junhong_cmp_fiber/internal/domain/operationsreport"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
operationsreportquery "github.com/break/junhong_cmp_fiber/internal/query/operationsreport"
exportTaskService "github.com/break/junhong_cmp_fiber/internal/service/export_task"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/utils"
)
// OperationsReportHandler 运营报表 Handler。
// 查询与导出只对超级管理员与平台账号开放路由组已有角色门禁Handler 不做任何跳过业务校验的分支。
type OperationsReportHandler struct {
query *operationsreportquery.Query
exportService *exportTaskService.Service
validator *validator.Validate
}
// NewOperationsReportHandler 创建运营报表 Handler。
func NewOperationsReportHandler(query *operationsreportquery.Query,
exportService *exportTaskService.Service, validator *validator.Validate) *OperationsReportHandler {
return &OperationsReportHandler{query: query, exportService: exportService, validator: validator}
}
// ActivationSummary 查询设备激活情况汇总。
// GET /api/admin/operations-reports/activation-summary
func (h *OperationsReportHandler) ActivationSummary(c *fiber.Ctx) error {
var req dto.OperationsActivationSummaryRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询设备激活情况汇总参数不合法", &req, err))
}
result, err := h.query.ActivationSummary(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ActivationTrend 查询设备激活情况日/月趋势。
// GET /api/admin/operations-reports/activation-trend
func (h *OperationsReportHandler) ActivationTrend(c *fiber.Ctx) error {
var req dto.OperationsActivationTrendRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询设备激活情况趋势参数不合法", &req, err))
}
result, err := h.query.ActivationTrend(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// RenewalSummary 查询套餐续费情况汇总。
// GET /api/admin/operations-reports/package-renewal-summary
func (h *OperationsReportHandler) RenewalSummary(c *fiber.Ctx) error {
var req dto.OperationsRenewalSummaryRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐续费情况汇总参数不合法", &req, err))
}
result, err := h.query.RenewalSummary(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// RenewalTrend 查询套餐续费情况日/月趋势。
// GET /api/admin/operations-reports/package-renewal-trend
func (h *OperationsReportHandler) RenewalTrend(c *fiber.Ctx) error {
var req dto.OperationsRenewalTrendRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("查询套餐续费情况趋势参数不合法", &req, err))
}
result, err := h.query.RenewalTrend(c.UserContext(), req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ExportActivationSummary 创建设备激活情况报表异步导出任务。
// POST /api/admin/operations-reports/activation-summary/export
// 受控入口:创建时冻结筛选条件、操作者与可见店铺范围,非法时间在创建期拒绝。
func (h *OperationsReportHandler) ExportActivationSummary(c *fiber.Ctx) error {
request, err := h.parseExportRequest(c, constants.ExportTaskSceneOperationsActivation, domainreport.IsActivationDimension,
"导出设备激活情况参数不合法")
if err != nil {
return err
}
return h.createExportTask(c, constants.ExportTaskSceneOperationsActivation, request)
}
// ExportRenewalSummary 创建套餐续费情况报表异步导出任务。
// POST /api/admin/operations-reports/package-renewal-summary/export
// 受控入口:创建时冻结筛选条件、操作者与可见店铺范围,非法时间在创建期拒绝。
func (h *OperationsReportHandler) ExportRenewalSummary(c *fiber.Ctx) error {
request, err := h.parseExportRequest(c, constants.ExportTaskSceneOperationsRenewal, domainreport.IsRenewalDimension,
"导出套餐续费情况参数不合法")
if err != nil {
return err
}
return h.createExportTask(c, constants.ExportTaskSceneOperationsRenewal, request)
}
// createExportTask 复用既有导出任务创建路径:创建期冻结操作者、可见店铺范围与筛选快照。
func (h *OperationsReportHandler) createExportTask(c *fiber.Ctx, scene string, request dto.ExportOperationsReportRequest) error {
createRequest := dto.CreateExportTaskRequest{
Scene: scene,
Format: request.Format,
Query: map[string]interface{}{"filters": exportOperationsReportFilters(request)},
}
result, err := h.exportService.CreateTask(c.UserContext(), &createRequest)
if err != nil {
return err
}
return response.Success(c, result)
}
// parseExportRequest 解析并校验受控导出请求。
// 时间边界在创建期用统一严格解析器校验(格式非法或开始晚于结束一律拒绝),
// 分组维度必须属于该报表支持的维度集合。
func (h *OperationsReportHandler) parseExportRequest(c *fiber.Ctx, scene string,
isDimension func(string) bool, message string) (dto.ExportOperationsReportRequest, error) {
var request dto.ExportOperationsReportRequest
if err := c.BodyParser(&request); err != nil {
return request, errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&request); err != nil {
return request, errors.New(errors.CodeInvalidParam, validation.Message(message, &request, err))
}
if _, _, err := utils.ParseTimeRange(request.StartTime, request.EndTime); err != nil {
return request, err
}
if request.GroupBy != "" && !isDimension(request.GroupBy) {
return request, errors.New(errors.CodeInvalidParam, "不支持的分组维度 "+request.GroupBy)
}
return request, nil
}
// exportOperationsReportFilters 把导出请求转换为导出任务的筛选快照。
// 时间边界按统一契约冻结为纯字符串值,创建期由导出任务服务规范化为 UTC RFC3339 秒级;
// 分组维度恒定冻结(未选择时为空串),执行期不再重新解释请求。
func exportOperationsReportFilters(request dto.ExportOperationsReportRequest) map[string]interface{} {
filters := make(map[string]interface{}, 3)
if request.StartTime != "" {
filters["start_time"] = request.StartTime
}
if request.EndTime != "" {
filters["end_time"] = request.EndTime
}
filters["group_by"] = request.GroupBy
return filters
}