diff --git a/README.md b/README.md index 7108592..f49d224 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、本地模拟图片优化任务、审计日志、运行日志、OCR 示例 - 目标模板模块:登录、设置、主业务、审计日志、运行日志、数据统计、常用组件示例 ## 环境准备 diff --git a/docs/04-architecture.md b/docs/04-architecture.md index fefdae0..054da33 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -14,8 +14,8 @@ main.go ├─ SystemInfo ├─ OCRService # 当前存量示例 ├─ AuthService # 已实现 provider 边界 - ├─ AuditLogService # 目标模块 - ├─ AppLogService # 目标模块 + ├─ AuditLogService # 已实现审计日志边界 + ├─ AppLogService # 已实现运行日志边界 ├─ ImageOptimizationService # 已实现本地模拟任务模型 └─ MetricsService # 目标模块 @@ -42,6 +42,7 @@ frontend/src - `internal/services/hotkey_service.go`:负责热键读取、更新和重新注册。 - `internal/services/ocr_service.go`:当前 OCR 存量示例,后续可演进为图片 AI 能力的一部分。 - `internal/services/image_optimization_service.go`:负责商品图片 AI 优化任务、结果和本地模拟 provider。 +- `internal/services/log_service.go`:负责审计日志和运行日志的记录、查询和共享写入 helper。 - `platform/`:隔离 Windows / macOS 热键和系统能力差异。 已实现边界: @@ -49,11 +50,11 @@ frontend/src - `AuthService`:登录、退出、会话读取;内部通过 `AuthProvider` 接入本地固定账号或真实后端。当前 `LocalAuthProvider` 可用,`RemoteAuthProvider` 保留边界并返回未配置错误。 - `AppConfigService`:通过统一设置 schema 初始化和校验主题、语言、窗口行为、快捷键展示、数据目录、日志策略和登录配置。 - `ImageOptimizationService`:商品图片 AI 优化任务模型和本地模拟任务创建已可用。 +- `AuditLogService`:审计日志记录和查询已可用,登录、设置变更和图片任务创建会写审计日志。 +- `AppLogService`:运行日志记录和查询已可用,图片任务创建失败和服务错误会写运行日志。 目标新增边界: -- `AuditLogService`:记录和查询用户操作审计日志。 -- `AppLogService`:记录和查询运行错误,必要时可落本地滚动日志文件。 - `MetricsService`:对业务任务和日志做基础聚合,输出统计卡片和趋势数据。 服务之间不要互相绕过边界直接操作平台实现。新增平台能力时,优先放在 `platform/`,再由 `internal/services/` 暴露稳定方法。 @@ -186,8 +187,6 @@ image_task_results( 当前 `image_tasks` 和 `image_task_results` 已由 `T-020` 落地,用于本地模拟图片优化任务和后续统计聚合。 -目标新增表: - ```sql app_logs( id INTEGER PRIMARY KEY, @@ -199,7 +198,7 @@ app_logs( ) ``` -`app_logs` 仍待 `T-021` 落地。统计数据早期直接用 SQL 聚合,不急着落 `daily_metrics` 缓存表。数据量变大后再增加缓存表。 +当前 `audit_logs` 和 `app_logs` 分别用于用户动作审计和运行排错。统计数据早期直接用 SQL 聚合,不急着落 `daily_metrics` 缓存表。数据量变大后再增加缓存表。 迁移要求: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 6b41435..fd7c053 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -38,7 +38,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | | T-020 | 建立商品图片 AI 优化任务模型 | T-013 | `image_tasks`、`image_task_results` 迁移和基础测试通过;可创建模拟任务 | DONE | -| T-021 | 实现审计日志与运行日志边界 | T-013 | 审计日志和运行日志分开;登录、设置变更、任务创建、任务失败可记录 | TODO | +| T-021 | 实现审计日志与运行日志边界 | T-013 | 审计日志和运行日志分开;登录、设置变更、任务创建、任务失败可记录 | DONE | | T-022 | 实现 P0 数据统计 | T-020 | 今日处理图片数、成功率、平均耗时、失败数、今日操作日志数、7 天趋势可查询 | TODO | | T-023 | 实现商品图片 AI 优化页面 | T-020 | 上传/选择图片、任务列表、状态、结果预览、失败提示路径可用 | TODO | diff --git a/docs/api.md b/docs/api.md index efb19f0..c6536ae 100644 --- a/docs/api.md +++ b/docs/api.md @@ -99,27 +99,31 @@ - 任务主表为 `image_tasks`,结果表为 `image_task_results`。 - 模拟任务会生成成功状态、耗时、输出路径和前后文件大小字段,供后续页面和统计模块使用。 -## 目标新增服务 - ### AuditLogService -目标方法: +文件:`internal/services/log_service.go` - `Record(action string, targetType string, targetID string, detail string) error`:记录审计事件。 -- `List(filter AuditLogFilter) ([]AuditLog, error)`:分页查询审计日志。 -- `Export(filter AuditLogFilter) (string, error)`:导出审计日志文件。 +- `List(filter models.AuditLogFilter) ([]models.AuditLog, error)`:分页查询审计日志。 + +约束: 审计日志记录用户动作,不记录系统堆栈。 +当前已接入登录、设置变更和图片任务创建。 ### AppLogService -目标方法: +文件:`internal/services/log_service.go` - `Record(level string, module string, message string, detail string) error`:记录运行日志。 -- `List(filter AppLogFilter) ([]AppLog, error)`:分页查询运行日志。 -- `Export(filter AppLogFilter) (string, error)`:导出运行日志文件。 +- `List(filter models.AppLogFilter) ([]models.AppLog, error)`:分页查询运行日志。 + +约束: 运行日志用于排错,可以记录错误详情和模块名。 +当前已接入图片任务创建失败和服务内部错误路径。 + +## 目标新增服务 ### MetricsService diff --git a/docs/current-state.md b/docs/current-state.md index a045e46..0c77b36 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 优化任务模型和日志服务边界阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口、本地登录能力、本地模拟图片优化任务能力、审计日志和运行日志记录查询能力。数据统计仍是骨架页面,尚未接入真实后端服务和统计聚合。 ## 已确认事实 @@ -40,6 +40,8 @@ - 已实现主窗口状态持久化:主窗口普通尺寸和最大化状态会写回 `appconfig`,下次启动恢复;第二窗口不参与该逻辑。 - 已实现 ImageOptimizationService:`CreateTask`、`ListTasks`、`GetTask`、`CancelTask`,当前 provider 为本地模拟实现。 - 已实现 `image_tasks` 和 `image_task_results` 表,模拟任务可写入成功状态、耗时、输出路径和前后文件大小。 +- 已实现 AuditLogService:`Record`、`List`,登录、设置变更和图片任务创建会写入 `audit_logs`。 +- 已实现 AppLogService:`Record`、`List`,图片任务创建失败和服务内部错误会写入 `app_logs`。 ## 当前命令 @@ -74,7 +76,7 @@ wails3 build - 模板范围容易膨胀,必须按任务小步实现。 - Wails v3 alpha 版本需要保持 CLI、Go 模块和前端 runtime 匹配。 -- 真实后端登录 provider、真实 AI provider、完整日志查询和统计模块尚未实现,当前仍是后续任务。 +- 真实后端登录 provider、真实 AI provider 和统计模块尚未实现,当前仍是后续任务。 - OCR 和热键依赖平台实现,跨平台修改需要分别验证。 - 当前前端以构建检查为主,暂未引入专门的前端单元测试。 - bindings 生成依赖本机安装的 `wails3`。 @@ -168,9 +170,20 @@ wails3 build - 已重新生成 Wails `.js` bindings。 - `go test ./...`、`npm run build`、`go build -o bin\cmbone.exe .` 和 `cmd /c build.bat` 通过。 +同日完成审计日志与运行日志边界: + +- 新增 `internal/models/log.go` 和 `internal/services/log_service.go`。 +- 新增 `app_logs` 迁移和日志查询索引。 +- 新增 `AuditLogService.Record/List` 和 `AppLogService.Record/List`。 +- 登录审计改为复用统一审计写入 helper。 +- `AppConfigService.SetAppConfig` 会记录用户配置变更审计,自动窗口状态保存不会写审计。 +- `ImageOptimizationService.CreateTask` 会记录任务创建审计,创建失败和服务错误会写运行日志。 +- 已重新生成 Wails `.js` bindings。 +- `go test ./...`、`npm run build`、`go build -o bin\cmbone.exe .` 和 `cmd /c build.bat` 通过。 + ## 下一步 -从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-021`:实现审计日志与运行日志边界。 +从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-022`:实现 P0 数据统计。 ## 当前 blocker diff --git a/docs/method-map.md b/docs/method-map.md index 60052c6..4c81f6f 100644 --- a/docs/method-map.md +++ b/docs/method-map.md @@ -46,13 +46,23 @@ | `internal/models/image_task.go` | `ImageTask` / `ImageTaskResult` / `ImageTaskDetail` | 图片优化任务绑定模型 | | `frontend/bindings/cmbone/internal/services/imageoptimizationservice.js` | Wails binding | 前端调用图片优化任务服务的生成绑定 | +## 日志 + +| 文件 | 方法 / 内容 | 说明 | +| --- | --- | --- | +| `internal/services/log_service.go` | `AuditLogService` | 记录和查询用户动作审计日志 | +| `internal/services/log_service.go` | `AppLogService` | 记录和查询运行排错日志 | +| `internal/models/log.go` | `AuditLogFilter` / `AppLog` / `AppLogFilter` | 日志查询绑定模型 | +| `frontend/bindings/cmbone/internal/services/auditlogservice.js` | Wails binding | 前端调用审计日志服务的生成绑定 | +| `frontend/bindings/cmbone/internal/services/applogservice.js` | Wails binding | 前端调用运行日志服务的生成绑定 | + ## 目标模板模块 | 模块 | 目标服务 | 说明 | | --- | --- | --- | | 登录 | `AuthService` | 已实现本地固定账号 provider,真实后端 provider 保留边界 | -| 审计日志 | `AuditLogService` | 记录登录、设置变更、任务操作、导出等用户动作 | -| 运行日志 | `AppLogService` | 记录 AI 调用、数据库、文件、热键等错误 | +| 审计日志 | `AuditLogService` | 已实现记录和查询;登录、设置变更、图片任务创建已接入 | +| 运行日志 | `AppLogService` | 已实现记录和查询;图片任务失败和服务错误已接入 | | 商品图片 AI 优化 | `ImageOptimizationService` | 已实现本地模拟任务模型;页面闭环待 `T-023` | | 数据统计 | `MetricsService` | 聚合今日处理量、成功率、耗时、失败数和 7 天趋势 | diff --git a/docs/routes.md b/docs/routes.md index 6c35a29..2c2afa7 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -27,7 +27,7 @@ | --- | --- | --- | | `#/login` | 登录页 | 已接入本地固定账号登录;真实后端登录待后续接入 | | `#/image-optimizer` | 商品图片 AI 优化 | 后端任务模型已接入;`T-023` 接入页面任务列表、状态、结果和失败原因 | -| `#/logs` | 操作日志 | `T-021` 接入审计日志和运行日志查询 | +| `#/logs` | 操作日志 | 后端审计日志和运行日志服务已接入;页面列表待后续扩展 | | `#/statistics` | 数据统计 | `T-022` 接入趋势、成功率、耗时和失败任务 | | `#/settings` | 设置中心 | 已接入统一设置 schema,窗口配置已用于主窗口尺寸恢复 | | `#/components` | 组件示例 | `T-030` 扩充输入框、多选框、下拉框、多行文本、表格、筛选、分页 | diff --git a/frontend/bindings/cmbone/internal/models/models.js b/frontend/bindings/cmbone/internal/models/models.js index 8a11af1..969c04d 100644 --- a/frontend/bindings/cmbone/internal/models/models.js +++ b/frontend/bindings/cmbone/internal/models/models.js @@ -6,6 +6,118 @@ // @ts-ignore: Unused imports import {Create as $Create} from "@wailsio/runtime"; +export class AppLog { + /** + * Creates a new AppLog instance. + * @param {Partial} [$$source = {}] - The source object to create the AppLog. + */ + constructor($$source = {}) { + if (!("id" in $$source)) { + /** + * @member + * @type {number} + */ + this["id"] = 0; + } + if (!("level" in $$source)) { + /** + * @member + * @type {string} + */ + this["level"] = ""; + } + if (!("module" in $$source)) { + /** + * @member + * @type {string} + */ + this["module"] = ""; + } + if (!("message" in $$source)) { + /** + * @member + * @type {string} + */ + this["message"] = ""; + } + if (!("detail" in $$source)) { + /** + * @member + * @type {string} + */ + this["detail"] = ""; + } + if (!("createdAt" in $$source)) { + /** + * @member + * @type {string} + */ + this["createdAt"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new AppLog instance from a string or object. + * @param {any} [$$source = {}] + * @returns {AppLog} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new AppLog(/** @type {Partial} */($$parsedSource)); + } +} + +export class AppLogFilter { + /** + * Creates a new AppLogFilter instance. + * @param {Partial} [$$source = {}] - The source object to create the AppLogFilter. + */ + constructor($$source = {}) { + if (!("level" in $$source)) { + /** + * @member + * @type {string} + */ + this["level"] = ""; + } + if (!("module" in $$source)) { + /** + * @member + * @type {string} + */ + this["module"] = ""; + } + if (!("limit" in $$source)) { + /** + * @member + * @type {number} + */ + this["limit"] = 0; + } + if (!("offset" in $$source)) { + /** + * @member + * @type {number} + */ + this["offset"] = 0; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new AppLogFilter instance from a string or object. + * @param {any} [$$source = {}] + * @returns {AppLogFilter} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new AppLogFilter(/** @type {Partial} */($$parsedSource)); + } +} + export class AppSettingDefinition { /** * Creates a new AppSettingDefinition instance. @@ -182,6 +294,132 @@ export class AppSettingOption { } } +export class AuditLog { + /** + * Creates a new AuditLog instance. + * @param {Partial} [$$source = {}] - The source object to create the AuditLog. + */ + constructor($$source = {}) { + if (!("id" in $$source)) { + /** + * @member + * @type {number} + */ + this["id"] = 0; + } + if (!("actor" in $$source)) { + /** + * @member + * @type {string} + */ + this["actor"] = ""; + } + if (!("action" in $$source)) { + /** + * @member + * @type {string} + */ + this["action"] = ""; + } + if (!("targetType" in $$source)) { + /** + * @member + * @type {string} + */ + this["targetType"] = ""; + } + if (!("targetId" in $$source)) { + /** + * @member + * @type {string} + */ + this["targetId"] = ""; + } + if (!("detail" in $$source)) { + /** + * @member + * @type {string} + */ + this["detail"] = ""; + } + if (!("createdAt" in $$source)) { + /** + * @member + * @type {string} + */ + this["createdAt"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new AuditLog instance from a string or object. + * @param {any} [$$source = {}] + * @returns {AuditLog} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new AuditLog(/** @type {Partial} */($$parsedSource)); + } +} + +export class AuditLogFilter { + /** + * Creates a new AuditLogFilter instance. + * @param {Partial} [$$source = {}] - The source object to create the AuditLogFilter. + */ + constructor($$source = {}) { + if (!("actor" in $$source)) { + /** + * @member + * @type {string} + */ + this["actor"] = ""; + } + if (!("action" in $$source)) { + /** + * @member + * @type {string} + */ + this["action"] = ""; + } + if (!("targetType" in $$source)) { + /** + * @member + * @type {string} + */ + this["targetType"] = ""; + } + if (!("limit" in $$source)) { + /** + * @member + * @type {number} + */ + this["limit"] = 0; + } + if (!("offset" in $$source)) { + /** + * @member + * @type {number} + */ + this["offset"] = 0; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new AuditLogFilter instance from a string or object. + * @param {any} [$$source = {}] + * @returns {AuditLogFilter} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new AuditLogFilter(/** @type {Partial} */($$parsedSource)); + } +} + export class AuthProviderOption { /** * Creates a new AuthProviderOption instance. diff --git a/frontend/bindings/cmbone/internal/services/applogservice.js b/frontend/bindings/cmbone/internal/services/applogservice.js new file mode 100644 index 0000000..440b0bc --- /dev/null +++ b/frontend/bindings/cmbone/internal/services/applogservice.js @@ -0,0 +1,40 @@ +// @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"; + +/** + * @param {models$0.AppLogFilter} filter + * @returns {Promise & { cancel(): void }} + */ +export function List(filter) { + let $resultPromise = /** @type {any} */($Call.ByID(3950321256, filter)); + let $typingPromise = /** @type {any} */($resultPromise.then(($result) => { + return $$createType1($result); + })); + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * @param {string} level + * @param {string} $module + * @param {string} message + * @param {string} detail + * @returns {Promise & { cancel(): void }} + */ +export function Record(level, $module, message, detail) { + let $resultPromise = /** @type {any} */($Call.ByID(1131123237, level, $module, message, detail)); + return $resultPromise; +} + +// Private type creation functions +const $$createType0 = models$0.AppLog.createFrom; +const $$createType1 = $Create.Array($$createType0); diff --git a/frontend/bindings/cmbone/internal/services/auditlogservice.js b/frontend/bindings/cmbone/internal/services/auditlogservice.js new file mode 100644 index 0000000..5187da9 --- /dev/null +++ b/frontend/bindings/cmbone/internal/services/auditlogservice.js @@ -0,0 +1,40 @@ +// @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"; + +/** + * @param {models$0.AuditLogFilter} filter + * @returns {Promise & { cancel(): void }} + */ +export function List(filter) { + let $resultPromise = /** @type {any} */($Call.ByID(1985320716, filter)); + let $typingPromise = /** @type {any} */($resultPromise.then(($result) => { + return $$createType1($result); + })); + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * @param {string} action + * @param {string} targetType + * @param {string} targetID + * @param {string} detail + * @returns {Promise & { cancel(): void }} + */ +export function Record(action, targetType, targetID, detail) { + let $resultPromise = /** @type {any} */($Call.ByID(1661109649, action, targetType, targetID, detail)); + return $resultPromise; +} + +// Private type creation functions +const $$createType0 = models$0.AuditLog.createFrom; +const $$createType1 = $Create.Array($$createType0); diff --git a/frontend/bindings/cmbone/internal/services/index.js b/frontend/bindings/cmbone/internal/services/index.js index 0dc4e63..1ee0d74 100644 --- a/frontend/bindings/cmbone/internal/services/index.js +++ b/frontend/bindings/cmbone/internal/services/index.js @@ -3,7 +3,9 @@ // This file is automatically generated. DO NOT EDIT import * as AppConfigService from "./appconfigservice.js"; +import * as AppLogService from "./applogservice.js"; import * as AppService from "./appservice.js"; +import * as AuditLogService from "./auditlogservice.js"; import * as AuthService from "./authservice.js"; import * as HotkeyService from "./hotkeyservice.js"; import * as ImageOptimizationService from "./imageoptimizationservice.js"; @@ -11,7 +13,9 @@ import * as OCRService from "./ocrservice.js"; import * as SystemInfo from "./systeminfo.js"; export { AppConfigService, + AppLogService, AppService, + AuditLogService, AuthService, HotkeyService, ImageOptimizationService, diff --git a/internal/models/log.go b/internal/models/log.go new file mode 100644 index 0000000..fafacc1 --- /dev/null +++ b/internal/models/log.go @@ -0,0 +1,25 @@ +package models + +type AuditLogFilter struct { + Actor string `json:"actor"` + Action string `json:"action"` + TargetType string `json:"targetType"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type AppLog struct { + ID int64 `json:"id"` + Level string `json:"level"` + Module string `json:"module"` + Message string `json:"message"` + Detail string `json:"detail"` + CreatedAt string `json:"createdAt"` +} + +type AppLogFilter struct { + Level string `json:"level"` + Module string `json:"module"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} diff --git a/internal/services/appconfig_service.go b/internal/services/appconfig_service.go index a94fe2a..cb96123 100644 --- a/internal/services/appconfig_service.go +++ b/internal/services/appconfig_service.go @@ -5,6 +5,7 @@ import ( "database/sql" "fmt" "strconv" + "strings" ) type AppConfigService struct { @@ -39,6 +40,7 @@ func (r *AppConfigService) GetAppConfig(key string) (string, error) { // 设置或更新配置项 func (r *AppConfigService) SetAppConfig(key, value string) error { + oldValue, _ := r.GetAppConfig(key) configType := "user" description := "" if definition, ok := settingDefinitionByKey(key); ok { @@ -59,6 +61,10 @@ func (r *AppConfigService) SetAppConfig(key, value string) error { value=excluded.value, description=excluded.description `, key, configType, value, description) + if err == nil && oldValue != value && shouldAuditConfigChange(key) { + detail := fmt.Sprintf("%s: %q -> %q", key, oldValue, value) + _ = recordAuditLog(r.store, currentAuditActor(r.store), auditActionSettingsUpdate, "appconfig", key, detail) + } return err } @@ -148,3 +154,19 @@ func validateSettingValue(definition models.AppSettingDefinition, value string) } return nil } + +func shouldAuditConfigChange(key string) bool { + if strings.HasPrefix(key, "window.") || strings.HasPrefix(key, "auth.local.") { + return false + } + definition, ok := settingDefinitionByKey(key) + if !ok { + return false + } + switch definition.Category { + case "appearance", "data", "logs", "auth": + return true + default: + return false + } +} diff --git a/internal/services/auth_service.go b/internal/services/auth_service.go index da19439..0f719a3 100644 --- a/internal/services/auth_service.go +++ b/internal/services/auth_service.go @@ -198,11 +198,7 @@ func (s *AuthService) recordAudit(actor string, action string, targetType string if strings.TrimSpace(actor) == "" { actor = "anonymous" } - _, err := s.store.DB.Exec(` - INSERT INTO audit_logs (actor, action, target_type, target_id, detail, created_at) - VALUES (?, ?, ?, ?, ?, ?) - `, actor, action, targetType, targetID, detail, time.Now().UTC().Format(time.RFC3339)) - return err + return recordAuditLog(s.store, actor, action, targetType, targetID, detail) } func scanAuthSession(row *sql.Row) (models.AuthSession, error) { diff --git a/internal/services/image_optimization_service.go b/internal/services/image_optimization_service.go index 22b31f5..bc9ed9a 100644 --- a/internal/services/image_optimization_service.go +++ b/internal/services/image_optimization_service.go @@ -37,6 +37,7 @@ func NewImageOptimizationService(store *SuiStore) *ImageOptimizationService { func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (models.ImageTaskDetail, error) { sourcePath := strings.TrimSpace(input.SourcePath) if sourcePath == "" { + _ = recordAppLog(s.store, appLogLevelWarn, "image_optimization", "create task failed", errImageTaskSourcePathRequired.Error()) return models.ImageTaskDetail{}, errImageTaskSourcePathRequired } @@ -51,6 +52,7 @@ func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (mode tx, err := s.store.DB.Begin() if err != nil { + _ = recordAppLog(s.store, appLogLevelError, "image_optimization", "begin task transaction failed", err.Error()) return models.ImageTaskDetail{}, err } defer func() { @@ -64,11 +66,13 @@ func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (mode VALUES (?, ?, ?, ?, '', ?, ?) `, sourcePath, imageTaskStatusSucceeded, operation, 1200, now, now) if err != nil { + _ = recordAppLog(s.store, appLogLevelError, "image_optimization", "insert image task failed", err.Error()) return models.ImageTaskDetail{}, err } taskID, err := result.LastInsertId() if err != nil { + _ = recordAppLog(s.store, appLogLevelError, "image_optimization", "read image task id failed", err.Error()) return models.ImageTaskDetail{}, err } @@ -78,18 +82,21 @@ func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (mode VALUES (?, ?, ?, ?, ?) `, taskID, outputPath, beforeSize, afterSize, now) if err != nil { + _ = recordAppLog(s.store, appLogLevelError, "image_optimization", "insert image task result failed", err.Error()) return models.ImageTaskDetail{}, err } resultID, err := result.LastInsertId() if err != nil { + _ = recordAppLog(s.store, appLogLevelError, "image_optimization", "read image task result id failed", err.Error()) return models.ImageTaskDetail{}, err } if err = tx.Commit(); err != nil { + _ = recordAppLog(s.store, appLogLevelError, "image_optimization", "commit image task failed", err.Error()) return models.ImageTaskDetail{}, err } - return models.ImageTaskDetail{ + detail := models.ImageTaskDetail{ Task: models.ImageTask{ ID: taskID, SourcePath: sourcePath, @@ -108,7 +115,9 @@ func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (mode AfterSize: afterSize, CreatedAt: now, }, - }, nil + } + _ = recordAuditLog(s.store, currentAuditActor(s.store), auditActionImageTaskCreate, "image_task", fmt.Sprint(taskID), fmt.Sprintf("source=%s operation=%s", sourcePath, operation)) + return detail, nil } func (s *ImageOptimizationService) ListTasks(filter models.ImageTaskFilter) ([]models.ImageTask, error) { diff --git a/internal/services/log_service.go b/internal/services/log_service.go new file mode 100644 index 0000000..5c074d9 --- /dev/null +++ b/internal/services/log_service.go @@ -0,0 +1,228 @@ +package services + +import ( + "cmbone/internal/models" + "strings" + "time" +) + +const ( + auditActionSettingsUpdate = "settings.update" + auditActionImageTaskCreate = "image_task.create" + + appLogLevelDebug = "debug" + appLogLevelInfo = "info" + appLogLevelWarn = "warn" + appLogLevelError = "error" + + defaultLogLimit = 50 + maxLogLimit = 200 +) + +type AuditLogService struct { + store *SuiStore +} + +func NewAuditLogService(store *SuiStore) *AuditLogService { + return &AuditLogService{store: store} +} + +func (s *AuditLogService) Record(action string, targetType string, targetID string, detail string) error { + return recordAuditLog(s.store, currentAuditActor(s.store), action, targetType, targetID, detail) +} + +func (s *AuditLogService) List(filter models.AuditLogFilter) ([]models.AuditLog, error) { + limit, offset := normalizeLimitOffset(filter.Limit, filter.Offset) + + query := strings.Builder{} + query.WriteString(` + SELECT id, actor, action, target_type, target_id, detail, created_at + FROM audit_logs + WHERE 1 = 1 + `) + + args := make([]any, 0, 5) + if strings.TrimSpace(filter.Actor) != "" { + query.WriteString(" AND actor = ?") + args = append(args, strings.TrimSpace(filter.Actor)) + } + if strings.TrimSpace(filter.Action) != "" { + query.WriteString(" AND action = ?") + args = append(args, strings.TrimSpace(filter.Action)) + } + if strings.TrimSpace(filter.TargetType) != "" { + query.WriteString(" AND target_type = ?") + args = append(args, strings.TrimSpace(filter.TargetType)) + } + query.WriteString(" ORDER BY id DESC LIMIT ? OFFSET ?") + args = append(args, limit, offset) + + rows, err := s.store.DB.Query(query.String(), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + logs := make([]models.AuditLog, 0) + for rows.Next() { + log, err := scanAuditLog(rows) + if err != nil { + return nil, err + } + logs = append(logs, log) + } + if err := rows.Err(); err != nil { + return nil, err + } + return logs, nil +} + +type AppLogService struct { + store *SuiStore +} + +func NewAppLogService(store *SuiStore) *AppLogService { + return &AppLogService{store: store} +} + +func (s *AppLogService) Record(level string, module string, message string, detail string) error { + return recordAppLog(s.store, level, module, message, detail) +} + +func (s *AppLogService) List(filter models.AppLogFilter) ([]models.AppLog, error) { + limit, offset := normalizeLimitOffset(filter.Limit, filter.Offset) + + query := strings.Builder{} + query.WriteString(` + SELECT id, level, module, message, detail, created_at + FROM app_logs + WHERE 1 = 1 + `) + + args := make([]any, 0, 4) + if strings.TrimSpace(filter.Level) != "" { + query.WriteString(" AND level = ?") + args = append(args, normalizeAppLogLevel(filter.Level)) + } + if strings.TrimSpace(filter.Module) != "" { + query.WriteString(" AND module = ?") + args = append(args, strings.TrimSpace(filter.Module)) + } + query.WriteString(" ORDER BY id DESC LIMIT ? OFFSET ?") + args = append(args, limit, offset) + + rows, err := s.store.DB.Query(query.String(), args...) + if err != nil { + return nil, err + } + defer rows.Close() + + logs := make([]models.AppLog, 0) + for rows.Next() { + log, err := scanAppLog(rows) + if err != nil { + return nil, err + } + logs = append(logs, log) + } + if err := rows.Err(); err != nil { + return nil, err + } + return logs, nil +} + +func recordAuditLog(store *SuiStore, actor string, action string, targetType string, targetID string, detail string) error { + if store == nil || store.DB == nil { + return nil + } + if strings.TrimSpace(actor) == "" { + actor = "system" + } + _, err := store.DB.Exec(` + INSERT INTO audit_logs (actor, action, target_type, target_id, detail, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `, actor, strings.TrimSpace(action), strings.TrimSpace(targetType), strings.TrimSpace(targetID), strings.TrimSpace(detail), time.Now().UTC().Format(time.RFC3339)) + return err +} + +func recordAppLog(store *SuiStore, level string, module string, message string, detail string) error { + if store == nil || store.DB == nil { + return nil + } + _, err := store.DB.Exec(` + INSERT INTO app_logs (level, module, message, detail, created_at) + VALUES (?, ?, ?, ?, ?) + `, normalizeAppLogLevel(level), strings.TrimSpace(module), strings.TrimSpace(message), strings.TrimSpace(detail), time.Now().UTC().Format(time.RFC3339)) + return err +} + +func currentAuditActor(store *SuiStore) string { + if store == nil || store.DB == nil { + return "system" + } + var username string + err := store.DB.QueryRow(` + SELECT username + FROM auth_sessions + WHERE active = 1 AND expires_at > ? + ORDER BY id DESC + LIMIT 1 + `, time.Now().UTC().Format(time.RFC3339)).Scan(&username) + if err != nil || strings.TrimSpace(username) == "" { + return "system" + } + return username +} + +func normalizeLimitOffset(limit int, offset int) (int, int) { + if limit <= 0 { + limit = defaultLogLimit + } + if limit > maxLogLimit { + limit = maxLogLimit + } + if offset < 0 { + offset = 0 + } + return limit, offset +} + +func normalizeAppLogLevel(level string) string { + switch strings.ToLower(strings.TrimSpace(level)) { + case appLogLevelDebug: + return appLogLevelDebug + case appLogLevelWarn: + return appLogLevelWarn + case appLogLevelError: + return appLogLevelError + default: + return appLogLevelInfo + } +} + +func scanAuditLog(scanner rowScanner) (models.AuditLog, error) { + var log models.AuditLog + err := scanner.Scan( + &log.ID, + &log.Actor, + &log.Action, + &log.TargetType, + &log.TargetID, + &log.Detail, + &log.CreatedAt, + ) + return log, err +} + +func scanAppLog(scanner rowScanner) (models.AppLog, error) { + var log models.AppLog + err := scanner.Scan( + &log.ID, + &log.Level, + &log.Module, + &log.Message, + &log.Detail, + &log.CreatedAt, + ) + return log, err +} diff --git a/internal/services/log_service_test.go b/internal/services/log_service_test.go new file mode 100644 index 0000000..633117c --- /dev/null +++ b/internal/services/log_service_test.go @@ -0,0 +1,111 @@ +package services + +import ( + "testing" + + "cmbone/internal/models" +) + +func TestAuditLogServiceRecordAndList(t *testing.T) { + store := newTestStore(t) + service := NewAuditLogService(store) + + if err := service.Record("settings.update", "appconfig", "theme.mode", "changed theme"); err != nil { + t.Fatalf("record audit log: %v", err) + } + + logs, err := service.List(models.AuditLogFilter{Action: "settings.update"}) + if err != nil { + t.Fatalf("list audit logs: %v", err) + } + if len(logs) != 1 { + t.Fatalf("logs len = %d, want 1", len(logs)) + } + if logs[0].Actor != "system" { + t.Fatalf("actor = %q, want system", logs[0].Actor) + } + if logs[0].TargetID != "theme.mode" { + t.Fatalf("target id = %q, want theme.mode", logs[0].TargetID) + } +} + +func TestAppLogServiceRecordAndList(t *testing.T) { + store := newTestStore(t) + service := NewAppLogService(store) + + if err := service.Record("error", "image_optimization", "provider failed", "timeout"); err != nil { + t.Fatalf("record app log: %v", err) + } + + logs, err := service.List(models.AppLogFilter{Level: "error", Module: "image_optimization"}) + if err != nil { + t.Fatalf("list app logs: %v", err) + } + if len(logs) != 1 { + t.Fatalf("logs len = %d, want 1", len(logs)) + } + if logs[0].Message != "provider failed" { + t.Fatalf("message = %q, want provider failed", logs[0].Message) + } +} + +func TestAppConfigServiceRecordsSettingsAudit(t *testing.T) { + store := newTestStore(t) + service := NewAppConfigService(store) + + if err := service.SetAppConfig("theme.mode", "dark"); err != nil { + t.Fatalf("set theme: %v", err) + } + + var count int + if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = ? AND target_id = ?", auditActionSettingsUpdate, "theme.mode").Scan(&count); err != nil { + t.Fatalf("count settings audit logs: %v", err) + } + if count != 1 { + t.Fatalf("settings audit count = %d, want 1", count) + } +} + +func TestAppConfigServiceSkipsWindowStateAudit(t *testing.T) { + store := newTestStore(t) + service := NewAppConfigService(store) + + if err := service.SetAppConfig(settingKeyWindowWidth, "1400"); err != nil { + t.Fatalf("set window width: %v", err) + } + + var count int + if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE target_id = ?", settingKeyWindowWidth).Scan(&count); err != nil { + t.Fatalf("count window audit logs: %v", err) + } + if count != 0 { + t.Fatalf("window audit count = %d, want 0", count) + } +} + +func TestImageOptimizationServiceRecordsTaskAuditAndFailureLog(t *testing.T) { + store := newTestStore(t) + service := NewImageOptimizationService(store) + + detail, err := service.CreateTask(models.ImageTaskInput{SourcePath: "demo.jpg"}) + if err != nil { + t.Fatalf("create task: %v", err) + } + + var auditCount int + if err := store.DB.QueryRow("SELECT COUNT(*) FROM audit_logs WHERE action = ? AND target_id = ?", auditActionImageTaskCreate, detail.Task.ID).Scan(&auditCount); err != nil { + t.Fatalf("count image task audit logs: %v", err) + } + if auditCount != 1 { + t.Fatalf("image task audit count = %d, want 1", auditCount) + } + + _, _ = service.CreateTask(models.ImageTaskInput{}) + var appLogCount int + if err := store.DB.QueryRow("SELECT COUNT(*) FROM app_logs WHERE module = ? AND message = ?", "image_optimization", "create task failed").Scan(&appLogCount); err != nil { + t.Fatalf("count image task app logs: %v", err) + } + if appLogCount != 1 { + t.Fatalf("image task app log count = %d, want 1", appLogCount) + } +} diff --git a/internal/services/store.go b/internal/services/store.go index 018bcdc..a0ed829 100644 --- a/internal/services/store.go +++ b/internal/services/store.go @@ -126,6 +126,18 @@ 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_task_results_task_id ON image_task_results(task_id); + + CREATE TABLE IF NOT EXISTS app_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL, + module TEXT NOT NULL, + message TEXT NOT NULL, + detail TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + 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); `) if err != nil { return err diff --git a/main.go b/main.go index d925977..bb29aa6 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,8 @@ func main() { application.NewService(services.NewSystemInfo()), application.NewService(services.NewAppConfigService(store)), application.NewService(services.NewAuthService(store)), + application.NewService(services.NewAuditLogService(store)), + application.NewService(services.NewAppLogService(store)), application.NewService(services.NewImageOptimizationService(store)), application.NewService(&services.OCRService{}), }, diff --git a/progress.md b/progress.md index d5d5fb5..6a4fc06 100644 --- a/progress.md +++ b/progress.md @@ -265,3 +265,24 @@ - 非阻塞警告: - `cmd /c build.bat` 仍提示 `wails3 build` 是 `wails3 task` 的 alias。 - 前端构建仍提示 Browserslist 数据过期和 chunk 大小警告。 + +### 实现审计日志与运行日志边界 + +- 任务:完成 `T-021`,审计日志和运行日志分开;登录、设置变更、任务创建、任务失败可记录。 +- 结果: + - 新增 `internal/models/log.go`,定义审计日志过滤器、运行日志和运行日志过滤器。 + - 新增 `internal/services/log_service.go`,提供 `AuditLogService.Record/List` 和 `AppLogService.Record/List`。 + - SQLite 迁移新增 `app_logs` 和日志查询索引。 + - 登录审计改为复用统一审计写入 helper。 + - 设置变更会记录 `settings.update` 审计;自动窗口状态保存不会写审计。 + - 图片任务创建会记录 `image_task.create` 审计;创建失败和服务错误会写 `app_logs`。 + - `main.go` 注册 `AuditLogService` 和 `AppLogService`。 + - 重新生成 Wails `.js` bindings,新增 `auditlogservice.js` 和 `applogservice.js`。 +- 验证: + - `go test ./...` 通过。 + - `go build -o bin\cmbone.exe .` 通过。 + - `cd frontend && npm run build` 通过。 + - `cmd /c build.bat` 通过。 +- 非阻塞警告: + - `cmd /c build.bat` 仍提示 `wails3 build` 是 `wails3 task` 的 alias。 + - 前端构建仍提示 Browserslist 数据过期和 chunk 大小警告。