From 2c31a410364c99e54c3012ef839ba82ef35f62d0 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 9 Jul 2026 01:04:50 +0800 Subject: [PATCH] feat: add dashboard metrics service --- README.md | 2 +- docs/04-architecture.md | 10 +- docs/06-tasks.md | 2 +- docs/api.md | 10 +- docs/current-state.md | 17 ++- docs/method-map.md | 10 +- docs/quality-document.md | 7 +- docs/routes.md | 4 +- .../bindings/cmbone/internal/models/models.js | 119 +++++++++++++++ .../cmbone/internal/services/index.js | 2 + .../internal/services/metricsservice.js | 41 +++++ internal/models/metrics.go | 18 +++ internal/services/metrics_service.go | 144 ++++++++++++++++++ internal/services/metrics_service_test.go | 107 +++++++++++++ internal/services/store.go | 2 + main.go | 1 + progress.md | 20 +++ 17 files changed, 494 insertions(+), 22 deletions(-) create mode 100644 frontend/bindings/cmbone/internal/services/metricsservice.js create mode 100644 internal/models/metrics.go create mode 100644 internal/services/metrics_service.go create mode 100644 internal/services/metrics_service_test.go diff --git a/README.md b/README.md index f49d224..bdf5988 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ - 后端:Go 1.23.0、Wails v3.0.0-alpha.9、SQLite - 前端:Vue 3、TypeScript、Vite、Tailwind CSS、Ant Design Vue -- 当前功能:多语言、主题切换、统一设置 schema、主窗口状态恢复、本地配置、快捷键、本地登录 provider、本地模拟图片优化任务、审计日志、运行日志、OCR 示例 +- 当前功能:多语言、主题切换、统一设置 schema、主窗口状态恢复、本地配置、快捷键、本地登录 provider、本地模拟图片优化任务、审计日志、运行日志、P0 数据统计服务、OCR 示例 - 目标模板模块:登录、设置、主业务、审计日志、运行日志、数据统计、常用组件示例 ## 环境准备 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index 054da33..427ff01 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -17,7 +17,7 @@ main.go ├─ AuditLogService # 已实现审计日志边界 ├─ AppLogService # 已实现运行日志边界 ├─ ImageOptimizationService # 已实现本地模拟任务模型 - └─ MetricsService # 目标模块 + └─ MetricsService # 已实现 P0 统计聚合 frontend/src ├─ router @@ -43,6 +43,7 @@ frontend/src - `internal/services/ocr_service.go`:当前 OCR 存量示例,后续可演进为图片 AI 能力的一部分。 - `internal/services/image_optimization_service.go`:负责商品图片 AI 优化任务、结果和本地模拟 provider。 - `internal/services/log_service.go`:负责审计日志和运行日志的记录、查询和共享写入 helper。 +- `internal/services/metrics_service.go`:负责 P0 统计指标和趋势聚合。 - `platform/`:隔离 Windows / macOS 热键和系统能力差异。 已实现边界: @@ -52,10 +53,7 @@ frontend/src - `ImageOptimizationService`:商品图片 AI 优化任务模型和本地模拟任务创建已可用。 - `AuditLogService`:审计日志记录和查询已可用,登录、设置变更和图片任务创建会写审计日志。 - `AppLogService`:运行日志记录和查询已可用,图片任务创建失败和服务错误会写运行日志。 - -目标新增边界: - -- `MetricsService`:对业务任务和日志做基础聚合,输出统计卡片和趋势数据。 +- `MetricsService`:P0 统计指标和最近 N 天趋势已可查询,当前直接聚合 `image_tasks` 和 `audit_logs`。 服务之间不要互相绕过边界直接操作平台实现。新增平台能力时,优先放在 `platform/`,再由 `internal/services/` 暴露稳定方法。 @@ -198,7 +196,7 @@ app_logs( ) ``` -当前 `audit_logs` 和 `app_logs` 分别用于用户动作审计和运行排错。统计数据早期直接用 SQL 聚合,不急着落 `daily_metrics` 缓存表。数据量变大后再增加缓存表。 +当前 `audit_logs` 和 `app_logs` 分别用于用户动作审计和运行排错。`MetricsService` 已直接用 SQL 聚合 P0 指标,不急着落 `daily_metrics` 缓存表。数据量变大后再增加缓存表。 迁移要求: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index fd7c053..117acd6 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -39,7 +39,7 @@ | --- | --- | --- | --- | --- | | T-020 | 建立商品图片 AI 优化任务模型 | T-013 | `image_tasks`、`image_task_results` 迁移和基础测试通过;可创建模拟任务 | DONE | | T-021 | 实现审计日志与运行日志边界 | T-013 | 审计日志和运行日志分开;登录、设置变更、任务创建、任务失败可记录 | DONE | -| T-022 | 实现 P0 数据统计 | T-020 | 今日处理图片数、成功率、平均耗时、失败数、今日操作日志数、7 天趋势可查询 | TODO | +| T-022 | 实现 P0 数据统计 | T-020 | 今日处理图片数、成功率、平均耗时、失败数、今日操作日志数、7 天趋势可查询 | DONE | | T-023 | 实现商品图片 AI 优化页面 | T-020 | 上传/选择图片、任务列表、状态、结果预览、失败提示路径可用 | TODO | ## Phase 3 · 组件、质量与发布 diff --git a/docs/api.md b/docs/api.md index c6536ae..0394810 100644 --- a/docs/api.md +++ b/docs/api.md @@ -123,16 +123,18 @@ 运行日志用于排错,可以记录错误详情和模块名。 当前已接入图片任务创建失败和服务内部错误路径。 -## 目标新增服务 - ### MetricsService -目标方法: +文件:`internal/services/metrics_service.go` - `GetDashboardMetrics() (DashboardMetrics, error)`:返回今日处理图片数、成功率、平均耗时、失败数、今日操作日志数。 - `GetProcessingTrend(days int) ([]DailyMetric, error)`:返回最近 N 天处理趋势。 -统计早期直接基于 `image_tasks`、`image_task_results`、`audit_logs` 聚合。 +约束: + +- 统计按本机本地自然日计算,查询时转换为 UTC 字符串匹配现有 `created_at`。 +- `days <= 0` 时默认 7 天,最大 30 天。 +- 当前直接基于 `image_tasks` 和 `audit_logs` 聚合,不新增统计缓存表。 ## 前端绑定 diff --git a/docs/current-state.md b/docs/current-state.md index 0c77b36..93781c9 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,7 +6,7 @@ 项目处于可复用桌面应用模板的 Phase 1 基础模块建设阶段。目标已经从迁移后的基础工程调整为“Windows-first、可扩展到其他平台的桌面端应用模板”。 -当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架、登录 provider 边界、统一设置 schema、主窗口状态恢复、商品图片 AI 优化任务模型和日志服务边界阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口、本地登录能力、本地模拟图片优化任务能力、审计日志和运行日志记录查询能力。数据统计仍是骨架页面,尚未接入真实后端服务和统计聚合。 +当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架、登录 provider 边界、统一设置 schema、主窗口状态恢复、商品图片 AI 优化任务模型、日志服务边界和 P0 数据统计阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口、本地登录能力、本地模拟图片优化任务能力、审计日志、运行日志和 P0 统计查询能力。统计页面仍是骨架,尚未展示真实统计数据。 ## 已确认事实 @@ -42,6 +42,7 @@ - 已实现 `image_tasks` 和 `image_task_results` 表,模拟任务可写入成功状态、耗时、输出路径和前后文件大小。 - 已实现 AuditLogService:`Record`、`List`,登录、设置变更和图片任务创建会写入 `audit_logs`。 - 已实现 AppLogService:`Record`、`List`,图片任务创建失败和服务内部错误会写入 `app_logs`。 +- 已实现 MetricsService:`GetDashboardMetrics`、`GetProcessingTrend`,可查询今日处理量、成功率、平均耗时、失败数、今日审计日志数和最近 N 天趋势。 ## 当前命令 @@ -76,7 +77,7 @@ wails3 build - 模板范围容易膨胀,必须按任务小步实现。 - Wails v3 alpha 版本需要保持 CLI、Go 模块和前端 runtime 匹配。 -- 真实后端登录 provider、真实 AI provider 和统计模块尚未实现,当前仍是后续任务。 +- 真实后端登录 provider、真实 AI provider 尚未实现;统计页面展示仍待后续扩展。 - OCR 和热键依赖平台实现,跨平台修改需要分别验证。 - 当前前端以构建检查为主,暂未引入专门的前端单元测试。 - bindings 生成依赖本机安装的 `wails3`。 @@ -181,9 +182,19 @@ wails3 build - 已重新生成 Wails `.js` bindings。 - `go test ./...`、`npm run build`、`go build -o bin\cmbone.exe .` 和 `cmd /c build.bat` 通过。 +同日完成 P0 数据统计服务: + +- 新增 `internal/models/metrics.go` 和 `internal/services/metrics_service.go`。 +- 新增 `DashboardMetrics` 和 `DailyMetric` 绑定模型。 +- `MetricsService.GetDashboardMetrics` 可查询今日处理图片数、成功率、平均耗时、失败任务数和今日审计日志数。 +- `MetricsService.GetProcessingTrend` 可查询最近 N 天处理趋势,默认 7 天,最多 30 天。 +- SQLite 迁移新增 `image_tasks(created_at)` 和 `audit_logs(created_at)` 查询索引。 +- 已重新生成 Wails `.js` bindings,新增 `metricsservice.js`。 +- `go test ./...`、`npm run build`、`go build -o bin\cmbone.exe .` 和 `cmd /c build.bat` 通过。 + ## 下一步 -从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-022`:实现 P0 数据统计。 +从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-023`:实现商品图片 AI 优化页面。 ## 当前 blocker diff --git a/docs/method-map.md b/docs/method-map.md index 4c81f6f..63621ba 100644 --- a/docs/method-map.md +++ b/docs/method-map.md @@ -56,6 +56,14 @@ | `frontend/bindings/cmbone/internal/services/auditlogservice.js` | Wails binding | 前端调用审计日志服务的生成绑定 | | `frontend/bindings/cmbone/internal/services/applogservice.js` | Wails binding | 前端调用运行日志服务的生成绑定 | +## 数据统计 + +| 文件 | 方法 / 内容 | 说明 | +| --- | --- | --- | +| `internal/services/metrics_service.go` | `MetricsService` | 查询 P0 统计卡片和最近 N 天处理趋势 | +| `internal/models/metrics.go` | `DashboardMetrics` / `DailyMetric` | 统计服务绑定模型 | +| `frontend/bindings/cmbone/internal/services/metricsservice.js` | Wails binding | 前端调用统计服务的生成绑定 | + ## 目标模板模块 | 模块 | 目标服务 | 说明 | @@ -64,7 +72,7 @@ | 审计日志 | `AuditLogService` | 已实现记录和查询;登录、设置变更、图片任务创建已接入 | | 运行日志 | `AppLogService` | 已实现记录和查询;图片任务失败和服务错误已接入 | | 商品图片 AI 优化 | `ImageOptimizationService` | 已实现本地模拟任务模型;页面闭环待 `T-023` | -| 数据统计 | `MetricsService` | 聚合今日处理量、成功率、耗时、失败数和 7 天趋势 | +| 数据统计 | `MetricsService` | 已实现后端聚合;统计页面展示待后续扩展 | ## 热键 diff --git a/docs/quality-document.md b/docs/quality-document.md index dadc0e1..ef7a098 100644 --- a/docs/quality-document.md +++ b/docs/quality-document.md @@ -6,7 +6,7 @@ | --- | --- | --- | | 项目结构 | B | 已从模板整理为普通工程,目录职责基本清楚 | | 产品定位 | B | 已校准为 Windows-first 可复用桌面应用模板,后续需要代码落地 | -| 后端服务 | B+ | 现有服务边界清晰,登录 provider 和设置 schema 已落地,日志、统计和业务样例服务尚未实现 | +| 后端服务 | B+ | 现有服务边界清晰,登录 provider、设置 schema、日志、统计和业务样例服务已落地,真实 provider 和页面闭环仍需推进 | | 本地存储 | B | SQLite 初始化和基础测试已存在,目标新增表需要迁移和测试 | | 前端结构 | B+ | Vue 组件按功能拆分,窗口、布局策略和目标路由骨架已落地,设置页已接入后端 schema | | 构建验证 | B | Go 测试、前端构建、后端编译可运行 | @@ -17,7 +17,7 @@ - 模板范围膨胀:登录、设置、业务、日志、统计和组件示例都要做,但必须小步交付。 - Wails v3 alpha 版本兼容性风险。 - bindings 与 Go 服务签名不一致的运行时风险。 -- 登录 provider、AI provider、日志和统计如果没有边界,会导致后续替换困难。 +- 真实后端登录 provider 和真实 AI provider 尚未接入,当前仍是本地演示能力。 - 热键、托盘、OCR 等平台能力在 Windows 以外行为不完全一致。 - 前端缺少专门单元测试,主要依赖构建和人工 smoke。 @@ -45,6 +45,5 @@ ## 改进方向 - 搭建登录、Dashboard、图片优化、日志、统计、设置、组件示例等目标路由骨架。 -- 为登录、日志、图片任务和统计新增服务、模型、迁移和测试。 -- 为商品图片 AI 优化样例补充真实页面闭环。 +- 为商品图片 AI 优化样例和统计模块补充真实页面闭环。 - 为 Wails bindings 生成流程补充完整操作说明。 diff --git a/docs/routes.md b/docs/routes.md index 2c2afa7..f407ffc 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -13,7 +13,7 @@ | `#/dashboard` | `frontend/src/modules/dashboard/DashboardPage.vue` | 主窗口 | 工作台,展示 P0 指标和近期任务入口 | | `#/image-optimizer` | `frontend/src/modules/image-optimizer/ImageOptimizerPage.vue` | 主窗口 | 商品图片 AI 优化业务样例骨架 | | `#/logs` | `frontend/src/modules/logs/LogsPage.vue` | 主窗口 | 审计日志和运行日志统一入口骨架 | -| `#/statistics` | `frontend/src/modules/statistics/StatisticsPage.vue` | 主窗口 | 第一批统计指标和趋势骨架 | +| `#/statistics` | `frontend/src/modules/statistics/StatisticsPage.vue` | 主窗口 | 第一批统计指标和趋势入口;后端 `MetricsService` 已可查询 | | `#/settings` | `frontend/src/modules/settings/SettingsPage.vue` | 主窗口 | 设置中心,复用存量设置组件并展示统一设置 schema | | `#/components` | `frontend/src/modules/components-demo/ComponentsDemoPage.vue` | 主窗口 | 常用输入、选择、文本域、表格、筛选和状态标签示例 | | `#/second` | `frontend/src/pages/Home/Index.vue` | 第二窗口 | 当前 OCR 示例和轻量快捷功能 | @@ -28,7 +28,7 @@ | `#/login` | 登录页 | 已接入本地固定账号登录;真实后端登录待后续接入 | | `#/image-optimizer` | 商品图片 AI 优化 | 后端任务模型已接入;`T-023` 接入页面任务列表、状态、结果和失败原因 | | `#/logs` | 操作日志 | 后端审计日志和运行日志服务已接入;页面列表待后续扩展 | -| `#/statistics` | 数据统计 | `T-022` 接入趋势、成功率、耗时和失败任务 | +| `#/statistics` | 数据统计 | 后端 `MetricsService` 已接入趋势、成功率、耗时和失败任务;页面展示待后续扩展 | | `#/settings` | 设置中心 | 已接入统一设置 schema,窗口配置已用于主窗口尺寸恢复 | | `#/components` | 组件示例 | `T-030` 扩充输入框、多选框、下拉框、多行文本、表格、筛选、分页 | diff --git a/frontend/bindings/cmbone/internal/models/models.js b/frontend/bindings/cmbone/internal/models/models.js index 969c04d..bb58f95 100644 --- a/frontend/bindings/cmbone/internal/models/models.js +++ b/frontend/bindings/cmbone/internal/models/models.js @@ -546,6 +546,125 @@ export class AuthSession { } } +export class DailyMetric { + /** + * Creates a new DailyMetric instance. + * @param {Partial} [$$source = {}] - The source object to create the DailyMetric. + */ + constructor($$source = {}) { + if (!("date" in $$source)) { + /** + * @member + * @type {string} + */ + this["date"] = ""; + } + if (!("processedImages" in $$source)) { + /** + * @member + * @type {number} + */ + this["processedImages"] = 0; + } + if (!("succeededTasks" in $$source)) { + /** + * @member + * @type {number} + */ + this["succeededTasks"] = 0; + } + if (!("failedTasks" in $$source)) { + /** + * @member + * @type {number} + */ + this["failedTasks"] = 0; + } + if (!("averageDurationMs" in $$source)) { + /** + * @member + * @type {number} + */ + this["averageDurationMs"] = 0; + } + if (!("auditLogs" in $$source)) { + /** + * @member + * @type {number} + */ + this["auditLogs"] = 0; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DailyMetric instance from a string or object. + * @param {any} [$$source = {}] + * @returns {DailyMetric} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new DailyMetric(/** @type {Partial} */($$parsedSource)); + } +} + +export class DashboardMetrics { + /** + * Creates a new DashboardMetrics instance. + * @param {Partial} [$$source = {}] - The source object to create the DashboardMetrics. + */ + constructor($$source = {}) { + if (!("todayProcessedImages" in $$source)) { + /** + * @member + * @type {number} + */ + this["todayProcessedImages"] = 0; + } + if (!("successRate" in $$source)) { + /** + * @member + * @type {number} + */ + this["successRate"] = 0; + } + if (!("averageDurationMs" in $$source)) { + /** + * @member + * @type {number} + */ + this["averageDurationMs"] = 0; + } + if (!("failedTasks" in $$source)) { + /** + * @member + * @type {number} + */ + this["failedTasks"] = 0; + } + if (!("todayAuditLogs" in $$source)) { + /** + * @member + * @type {number} + */ + this["todayAuditLogs"] = 0; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DashboardMetrics instance from a string or object. + * @param {any} [$$source = {}] + * @returns {DashboardMetrics} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new DashboardMetrics(/** @type {Partial} */($$parsedSource)); + } +} + export class Hotkey { /** * Creates a new Hotkey instance. diff --git a/frontend/bindings/cmbone/internal/services/index.js b/frontend/bindings/cmbone/internal/services/index.js index 1ee0d74..d129162 100644 --- a/frontend/bindings/cmbone/internal/services/index.js +++ b/frontend/bindings/cmbone/internal/services/index.js @@ -9,6 +9,7 @@ import * as AuditLogService from "./auditlogservice.js"; import * as AuthService from "./authservice.js"; import * as HotkeyService from "./hotkeyservice.js"; import * as ImageOptimizationService from "./imageoptimizationservice.js"; +import * as MetricsService from "./metricsservice.js"; import * as OCRService from "./ocrservice.js"; import * as SystemInfo from "./systeminfo.js"; export { @@ -19,6 +20,7 @@ export { AuthService, HotkeyService, ImageOptimizationService, + MetricsService, OCRService, SystemInfo }; diff --git a/frontend/bindings/cmbone/internal/services/metricsservice.js b/frontend/bindings/cmbone/internal/services/metricsservice.js new file mode 100644 index 0000000..fb6b570 --- /dev/null +++ b/frontend/bindings/cmbone/internal/services/metricsservice.js @@ -0,0 +1,41 @@ +// @ts-check +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import {Call as $Call, Create as $Create} from "@wailsio/runtime"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as models$0 from "../models/models.js"; + +/** + * @returns {Promise & { cancel(): void }} + */ +export function GetDashboardMetrics() { + let $resultPromise = /** @type {any} */($Call.ByID(2761444903)); + let $typingPromise = /** @type {any} */($resultPromise.then(($result) => { + return $$createType0($result); + })); + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * @param {number} days + * @returns {Promise & { cancel(): void }} + */ +export function GetProcessingTrend(days) { + let $resultPromise = /** @type {any} */($Call.ByID(2069059156, days)); + let $typingPromise = /** @type {any} */($resultPromise.then(($result) => { + return $$createType2($result); + })); + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +// Private type creation functions +const $$createType0 = models$0.DashboardMetrics.createFrom; +const $$createType1 = models$0.DailyMetric.createFrom; +const $$createType2 = $Create.Array($$createType1); diff --git a/internal/models/metrics.go b/internal/models/metrics.go new file mode 100644 index 0000000..cf64504 --- /dev/null +++ b/internal/models/metrics.go @@ -0,0 +1,18 @@ +package models + +type DashboardMetrics struct { + TodayProcessedImages int64 `json:"todayProcessedImages"` + SuccessRate float64 `json:"successRate"` + AverageDurationMs float64 `json:"averageDurationMs"` + FailedTasks int64 `json:"failedTasks"` + TodayAuditLogs int64 `json:"todayAuditLogs"` +} + +type DailyMetric struct { + Date string `json:"date"` + ProcessedImages int64 `json:"processedImages"` + SucceededTasks int64 `json:"succeededTasks"` + FailedTasks int64 `json:"failedTasks"` + AverageDurationMs float64 `json:"averageDurationMs"` + AuditLogs int64 `json:"auditLogs"` +} diff --git a/internal/services/metrics_service.go b/internal/services/metrics_service.go new file mode 100644 index 0000000..e378a74 --- /dev/null +++ b/internal/services/metrics_service.go @@ -0,0 +1,144 @@ +package services + +import ( + "cmbone/internal/models" + "database/sql" + "time" +) + +const ( + defaultTrendDays = 7 + maxTrendDays = 30 + imageTaskStatusFailed = "failed" +) + +type MetricsService struct { + store *SuiStore +} + +func NewMetricsService(store *SuiStore) *MetricsService { + return &MetricsService{store: store} +} + +func (s *MetricsService) GetDashboardMetrics() (models.DashboardMetrics, error) { + start := localDayStart(time.Now()) + end := start.AddDate(0, 0, 1) + + taskMetric, err := s.queryTaskMetric(start, end) + if err != nil { + return models.DashboardMetrics{}, err + } + + auditLogs, err := s.countAuditLogs(start, end) + if err != nil { + return models.DashboardMetrics{}, err + } + + return models.DashboardMetrics{ + TodayProcessedImages: taskMetric.ProcessedImages, + SuccessRate: successRate(taskMetric.SucceededTasks, taskMetric.ProcessedImages), + AverageDurationMs: taskMetric.AverageDuration, + FailedTasks: taskMetric.FailedTasks, + TodayAuditLogs: auditLogs, + }, nil +} + +func (s *MetricsService) GetProcessingTrend(days int) ([]models.DailyMetric, error) { + days = normalizeTrendDays(days) + today := localDayStart(time.Now()) + start := today.AddDate(0, 0, -days+1) + + trend := make([]models.DailyMetric, 0, days) + for i := 0; i < days; i++ { + dayStart := start.AddDate(0, 0, i) + dayEnd := dayStart.AddDate(0, 0, 1) + + taskMetric, err := s.queryTaskMetric(dayStart, dayEnd) + if err != nil { + return nil, err + } + auditLogs, err := s.countAuditLogs(dayStart, dayEnd) + if err != nil { + return nil, err + } + + trend = append(trend, models.DailyMetric{ + Date: dayStart.Format("2006-01-02"), + ProcessedImages: taskMetric.ProcessedImages, + SucceededTasks: taskMetric.SucceededTasks, + FailedTasks: taskMetric.FailedTasks, + AverageDurationMs: taskMetric.AverageDuration, + AuditLogs: auditLogs, + }) + } + return trend, nil +} + +type taskMetric struct { + ProcessedImages int64 + SucceededTasks int64 + FailedTasks int64 + AverageDuration float64 +} + +func (s *MetricsService) queryTaskMetric(start time.Time, end time.Time) (taskMetric, error) { + var metric taskMetric + var average sql.NullFloat64 + err := s.store.DB.QueryRow(` + SELECT + COUNT(*), + COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN status = ? THEN 1 ELSE 0 END), 0), + AVG(CASE WHEN duration_ms > 0 THEN duration_ms END) + FROM image_tasks + WHERE created_at >= ? AND created_at < ? + `, imageTaskStatusSucceeded, imageTaskStatusFailed, formatMetricTime(start), formatMetricTime(end)).Scan( + &metric.ProcessedImages, + &metric.SucceededTasks, + &metric.FailedTasks, + &average, + ) + if err != nil { + return taskMetric{}, err + } + if average.Valid { + metric.AverageDuration = average.Float64 + } + return metric, nil +} + +func (s *MetricsService) countAuditLogs(start time.Time, end time.Time) (int64, error) { + var count int64 + err := s.store.DB.QueryRow(` + SELECT COUNT(*) + FROM audit_logs + WHERE created_at >= ? AND created_at < ? + `, formatMetricTime(start), formatMetricTime(end)).Scan(&count) + return count, err +} + +func normalizeTrendDays(days int) int { + if days <= 0 { + return defaultTrendDays + } + if days > maxTrendDays { + return maxTrendDays + } + return days +} + +func localDayStart(now time.Time) time.Time { + local := now.In(time.Local) + return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()) +} + +func formatMetricTime(value time.Time) string { + return value.UTC().Format(time.RFC3339) +} + +func successRate(succeeded int64, total int64) float64 { + if total <= 0 { + return 0 + } + return float64(succeeded) * 100 / float64(total) +} diff --git a/internal/services/metrics_service_test.go b/internal/services/metrics_service_test.go new file mode 100644 index 0000000..a162fb3 --- /dev/null +++ b/internal/services/metrics_service_test.go @@ -0,0 +1,107 @@ +package services + +import ( + "math" + "testing" + "time" +) + +func TestMetricsServiceGetDashboardMetrics(t *testing.T) { + store := newTestStore(t) + service := NewMetricsService(store) + today := localDayStart(time.Now()) + yesterday := today.AddDate(0, 0, -1) + + insertImageTaskForMetrics(t, store, imageTaskStatusSucceeded, 1000, today.Add(2*time.Hour)) + insertImageTaskForMetrics(t, store, imageTaskStatusSucceeded, 2000, today.Add(3*time.Hour)) + insertImageTaskForMetrics(t, store, imageTaskStatusFailed, 0, today.Add(4*time.Hour)) + insertImageTaskForMetrics(t, store, imageTaskStatusSucceeded, 900, yesterday.Add(2*time.Hour)) + insertAuditLogForMetrics(t, store, today.Add(5*time.Hour)) + insertAuditLogForMetrics(t, store, today.Add(6*time.Hour)) + insertAuditLogForMetrics(t, store, yesterday.Add(5*time.Hour)) + + metrics, err := service.GetDashboardMetrics() + if err != nil { + t.Fatalf("get dashboard metrics: %v", err) + } + + if metrics.TodayProcessedImages != 3 { + t.Fatalf("today processed images = %d, want 3", metrics.TodayProcessedImages) + } + if metrics.FailedTasks != 1 { + t.Fatalf("failed tasks = %d, want 1", metrics.FailedTasks) + } + if math.Abs(metrics.SuccessRate-66.6667) > 0.01 { + t.Fatalf("success rate = %f, want about 66.67", metrics.SuccessRate) + } + if metrics.AverageDurationMs != 1500 { + t.Fatalf("average duration = %f, want 1500", metrics.AverageDurationMs) + } + if metrics.TodayAuditLogs != 2 { + t.Fatalf("today audit logs = %d, want 2", metrics.TodayAuditLogs) + } +} + +func TestMetricsServiceGetProcessingTrend(t *testing.T) { + store := newTestStore(t) + service := NewMetricsService(store) + today := localDayStart(time.Now()) + twoDaysAgo := today.AddDate(0, 0, -2) + + insertImageTaskForMetrics(t, store, imageTaskStatusSucceeded, 1200, twoDaysAgo.Add(2*time.Hour)) + insertImageTaskForMetrics(t, store, imageTaskStatusFailed, 0, today.Add(2*time.Hour)) + insertAuditLogForMetrics(t, store, today.Add(3*time.Hour)) + + trend, err := service.GetProcessingTrend(3) + if err != nil { + t.Fatalf("get processing trend: %v", err) + } + if len(trend) != 3 { + t.Fatalf("trend len = %d, want 3", len(trend)) + } + if trend[0].Date != twoDaysAgo.Format("2006-01-02") { + t.Fatalf("first date = %q, want %q", trend[0].Date, twoDaysAgo.Format("2006-01-02")) + } + if trend[0].ProcessedImages != 1 || trend[0].SucceededTasks != 1 { + t.Fatalf("first metric = %+v, want one succeeded task", trend[0]) + } + if trend[2].ProcessedImages != 1 || trend[2].FailedTasks != 1 || trend[2].AuditLogs != 1 { + t.Fatalf("today metric = %+v, want one failed task and one audit log", trend[2]) + } +} + +func TestNormalizeTrendDays(t *testing.T) { + if got := normalizeTrendDays(0); got != defaultTrendDays { + t.Fatalf("normalize 0 = %d, want %d", got, defaultTrendDays) + } + if got := normalizeTrendDays(maxTrendDays + 1); got != maxTrendDays { + t.Fatalf("normalize max+1 = %d, want %d", got, maxTrendDays) + } + if got := normalizeTrendDays(3); got != 3 { + t.Fatalf("normalize 3 = %d, want 3", got) + } +} + +func insertImageTaskForMetrics(t *testing.T, store *SuiStore, status string, durationMs int64, createdAt time.Time) { + t.Helper() + + _, err := store.DB.Exec(` + INSERT INTO image_tasks (source_path, status, operation, duration_ms, error_message, created_at, updated_at) + VALUES (?, ?, 'enhance', ?, '', ?, ?) + `, "D:/images/sku.jpg", status, durationMs, formatMetricTime(createdAt), formatMetricTime(createdAt)) + if err != nil { + t.Fatalf("insert image task: %v", err) + } +} + +func insertAuditLogForMetrics(t *testing.T, store *SuiStore, createdAt time.Time) { + t.Helper() + + _, err := store.DB.Exec(` + INSERT INTO audit_logs (actor, action, target_type, target_id, detail, created_at) + VALUES ('admin', 'image_task.create', 'image_task', '1', 'created', ?) + `, formatMetricTime(createdAt)) + if err != nil { + t.Fatalf("insert audit log: %v", err) + } +} diff --git a/internal/services/store.go b/internal/services/store.go index a0ed829..91e08da 100644 --- a/internal/services/store.go +++ b/internal/services/store.go @@ -125,6 +125,7 @@ func Migrate(db *sql.DB) error { ); CREATE INDEX IF NOT EXISTS idx_image_tasks_status_created_at ON image_tasks(status, created_at); + CREATE INDEX IF NOT EXISTS idx_image_tasks_created_at ON image_tasks(created_at); CREATE INDEX IF NOT EXISTS idx_image_task_results_task_id ON image_task_results(task_id); CREATE TABLE IF NOT EXISTS app_logs ( @@ -138,6 +139,7 @@ func Migrate(db *sql.DB) error { CREATE INDEX IF NOT EXISTS idx_app_logs_level_created_at ON app_logs(level, created_at); CREATE INDEX IF NOT EXISTS idx_audit_logs_action_created_at ON audit_logs(action, created_at); + CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); `) if err != nil { return err diff --git a/main.go b/main.go index bb29aa6..bdc62ca 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,7 @@ func main() { application.NewService(services.NewAuditLogService(store)), application.NewService(services.NewAppLogService(store)), application.NewService(services.NewImageOptimizationService(store)), + application.NewService(services.NewMetricsService(store)), application.NewService(&services.OCRService{}), }, Assets: application.AssetOptions{ diff --git a/progress.md b/progress.md index 6a4fc06..9afc8a3 100644 --- a/progress.md +++ b/progress.md @@ -286,3 +286,23 @@ - 非阻塞警告: - `cmd /c build.bat` 仍提示 `wails3 build` 是 `wails3 task` 的 alias。 - 前端构建仍提示 Browserslist 数据过期和 chunk 大小警告。 + +### 实现 P0 数据统计 + +- 任务:完成 `T-022`,今日处理图片数、成功率、平均耗时、失败数、今日操作日志数、7 天趋势可查询。 +- 结果: + - 新增 `internal/models/metrics.go`,定义 `DashboardMetrics` 和 `DailyMetric`。 + - 新增 `internal/services/metrics_service.go`,提供 `MetricsService.GetDashboardMetrics/GetProcessingTrend`。 + - 统计按本机本地自然日计算,并用 UTC 字符串匹配现有 `created_at`。 + - SQLite 迁移新增 `image_tasks(created_at)` 和 `audit_logs(created_at)` 查询索引。 + - `main.go` 注册 `MetricsService`。 + - 重新生成 Wails `.js` bindings,新增 `metricsservice.js`。 +- 验证: + - `go test ./...` 通过。 + - `cd frontend && npm run build` 通过。 + - `go build -o bin\cmbone.exe .` 通过。 + - `cmd /c build.bat` 通过。 +- 非阻塞警告: + - 当前 PowerShell 会话 PATH 没有 Go bin,直接运行 `wails3 generate bindings` 会找不到命令;已按项目脚本方式临时补 PATH 后生成绑定。 + - `cmd /c build.bat` 仍提示 `wails3 build` 是 `wails3 task` 的 alias。 + - 前端构建仍提示 Browserslist 数据过期和 chunk 大小警告。