From 3bd05b316efaa309be80b3429adec2477e714616 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Thu, 9 Jul 2026 00:47:35 +0800 Subject: [PATCH] feat: add image optimization task model --- README.md | 2 +- docs/04-architecture.md | 35 +-- docs/06-tasks.md | 2 +- docs/api.md | 26 +- docs/current-state.md | 17 +- docs/method-map.md | 10 +- docs/routes.md | 2 +- .../bindings/cmbone/internal/models/models.js | 269 +++++++++++++++++ .../services/imageoptimizationservice.js | 64 +++++ .../cmbone/internal/services/index.js | 2 + internal/models/image_task.go | 38 +++ .../services/image_optimization_service.go | 270 ++++++++++++++++++ .../image_optimization_service_test.go | 80 ++++++ internal/services/store.go | 24 ++ main.go | 1 + progress.md | 19 ++ 16 files changed, 827 insertions(+), 34 deletions(-) create mode 100644 frontend/bindings/cmbone/internal/services/imageoptimizationservice.js create mode 100644 internal/models/image_task.go create mode 100644 internal/services/image_optimization_service.go create mode 100644 internal/services/image_optimization_service_test.go diff --git a/README.md b/README.md index 3c395e7..7108592 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 542b0f2..fefdae0 100644 --- a/docs/04-architecture.md +++ b/docs/04-architecture.md @@ -16,7 +16,7 @@ main.go ├─ AuthService # 已实现 provider 边界 ├─ AuditLogService # 目标模块 ├─ AppLogService # 目标模块 - ├─ ImageOptimizationService # 目标业务样例 + ├─ ImageOptimizationService # 已实现本地模拟任务模型 └─ MetricsService # 目标模块 frontend/src @@ -41,18 +41,19 @@ frontend/src - `internal/services/settings_schema.go`:集中定义设置模块 schema、默认值、选项和初始化 SQL。 - `internal/services/hotkey_service.go`:负责热键读取、更新和重新注册。 - `internal/services/ocr_service.go`:当前 OCR 存量示例,后续可演进为图片 AI 能力的一部分。 +- `internal/services/image_optimization_service.go`:负责商品图片 AI 优化任务、结果和本地模拟 provider。 - `platform/`:隔离 Windows / macOS 热键和系统能力差异。 已实现边界: - `AuthService`:登录、退出、会话读取;内部通过 `AuthProvider` 接入本地固定账号或真实后端。当前 `LocalAuthProvider` 可用,`RemoteAuthProvider` 保留边界并返回未配置错误。 - `AppConfigService`:通过统一设置 schema 初始化和校验主题、语言、窗口行为、快捷键展示、数据目录、日志策略和登录配置。 +- `ImageOptimizationService`:商品图片 AI 优化任务模型和本地模拟任务创建已可用。 目标新增边界: - `AuditLogService`:记录和查询用户操作审计日志。 - `AppLogService`:记录和查询运行错误,必要时可落本地滚动日志文件。 -- `ImageOptimizationService`:商品图片 AI 优化任务的创建、状态、结果和失败原因。 - `MetricsService`:对业务任务和日志做基础聚合,输出统计卡片和趋势数据。 服务之间不要互相绕过边界直接操作平台实现。新增平台能力时,优先放在 `platform/`,再由 `internal/services/` 暴露稳定方法。 @@ -159,19 +160,6 @@ audit_logs( ) ``` -目标新增表: - -```sql -app_logs( - id INTEGER PRIMARY KEY, - level TEXT, - module TEXT, - message TEXT, - detail TEXT, - created_at DATETIME -) -``` - ```sql image_tasks( id INTEGER PRIMARY KEY, @@ -196,7 +184,22 @@ image_task_results( ) ``` -统计数据早期直接用 SQL 聚合,不急着落 `daily_metrics` 缓存表。数据量变大后再增加缓存表。 +当前 `image_tasks` 和 `image_task_results` 已由 `T-020` 落地,用于本地模拟图片优化任务和后续统计聚合。 + +目标新增表: + +```sql +app_logs( + id INTEGER PRIMARY KEY, + level TEXT, + module TEXT, + message TEXT, + detail TEXT, + created_at DATETIME +) +``` + +`app_logs` 仍待 `T-021` 落地。统计数据早期直接用 SQL 聚合,不急着落 `daily_metrics` 缓存表。数据量变大后再增加缓存表。 迁移要求: diff --git a/docs/06-tasks.md b/docs/06-tasks.md index 1624d9b..6b41435 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -37,7 +37,7 @@ | ID | 任务 | 依赖 | 验收要点 | 状态 | | --- | --- | --- | --- | --- | -| T-020 | 建立商品图片 AI 优化任务模型 | T-013 | `image_tasks`、`image_task_results` 迁移和基础测试通过;可创建模拟任务 | TODO | +| T-020 | 建立商品图片 AI 优化任务模型 | T-013 | `image_tasks`、`image_task_results` 迁移和基础测试通过;可创建模拟任务 | DONE | | T-021 | 实现审计日志与运行日志边界 | T-013 | 审计日志和运行日志分开;登录、设置变更、任务创建、任务失败可记录 | TODO | | T-022 | 实现 P0 数据统计 | T-020 | 今日处理图片数、成功率、平均耗时、失败数、今日操作日志数、7 天趋势可查询 | TODO | | T-023 | 实现商品图片 AI 优化页面 | T-020 | 上传/选择图片、任务列表、状态、结果预览、失败提示路径可用 | TODO | diff --git a/docs/api.md b/docs/api.md index 130e06c..efb19f0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -84,6 +84,21 @@ - `RemoteAuthProvider` 用于真实后端登录,目前保留边界并返回未配置错误。 - 登录成功、失败和退出必须写审计日志。 +### ImageOptimizationService + +文件:`internal/services/image_optimization_service.go` + +- `CreateTask(input models.ImageTaskInput) (models.ImageTaskDetail, error)`:创建本地模拟图片优化任务,并写入任务和结果。 +- `ListTasks(filter models.ImageTaskFilter) ([]models.ImageTask, error)`:按状态、操作类型分页查询任务。 +- `GetTask(id int64) (models.ImageTaskDetail, error)`:查询任务和结果详情。 +- `CancelTask(id int64) error`:取消待处理或处理中任务;当前模拟任务会立即成功,通常不可取消。 + +约束: + +- 当前 provider 是本地模拟实现,不调用真实 AI 后端。 +- 任务主表为 `image_tasks`,结果表为 `image_task_results`。 +- 模拟任务会生成成功状态、耗时、输出路径和前后文件大小字段,供后续页面和统计模块使用。 + ## 目标新增服务 ### AuditLogService @@ -106,17 +121,6 @@ 运行日志用于排错,可以记录错误详情和模块名。 -### ImageOptimizationService - -目标方法: - -- `CreateTask(input ImageTaskInput) (ImageTask, error)`:创建图片优化任务。 -- `ListTasks(filter ImageTaskFilter) ([]ImageTask, error)`:查询任务列表。 -- `GetTask(id int64) (ImageTaskDetail, error)`:查询任务详情和结果。 -- `CancelTask(id int64) error`:取消待处理或处理中任务。 - -MVP 可以先用本地模拟 provider,后续再接真实 AI 后端。 - ### MetricsService 目标方法: diff --git a/docs/current-state.md b/docs/current-state.md index 0146353..a045e46 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -6,7 +6,7 @@ 项目处于可复用桌面应用模板的 Phase 1 基础模块建设阶段。目标已经从迁移后的基础工程调整为“Windows-first、可扩展到其他平台的桌面端应用模板”。 -当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架、登录 provider 边界、统一设置 schema 和主窗口状态恢复阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口和本地登录能力。商品图片 AI 优化、完整审计日志查询、运行日志和数据统计仍是骨架页面,尚未接入真实后端服务和业务数据模型。 +当前代码已从迁移后的 Wails/Vue/SQLite 基线推进到主应用路由骨架、登录 provider 边界、统一设置 schema、主窗口状态恢复和商品图片 AI 优化任务模型阶段,已具备设置、多语言、主题、本地配置、热键、托盘、OCR 示例、模板级模块入口、本地登录能力和本地模拟图片优化任务能力。完整审计日志查询、运行日志和数据统计仍是骨架页面,尚未接入真实后端服务和业务数据模型。 ## 已确认事实 @@ -38,6 +38,8 @@ - 已实现统一设置 schema:主题、语言、窗口行为、快捷键展示、数据目录、日志策略和登录 provider 配置都有默认值、说明和基础校验。 - 已实现设置页 schema 展示:`/#/settings` 可读取后端 `GetSettingsSchema` 并展示分组、当前值、默认值、说明和只读标记。 - 已实现主窗口状态持久化:主窗口普通尺寸和最大化状态会写回 `appconfig`,下次启动恢复;第二窗口不参与该逻辑。 +- 已实现 ImageOptimizationService:`CreateTask`、`ListTasks`、`GetTask`、`CancelTask`,当前 provider 为本地模拟实现。 +- 已实现 `image_tasks` 和 `image_task_results` 表,模拟任务可写入成功状态、耗时、输出路径和前后文件大小。 ## 当前命令 @@ -72,7 +74,7 @@ wails3 build - 模板范围容易膨胀,必须按任务小步实现。 - Wails v3 alpha 版本需要保持 CLI、Go 模块和前端 runtime 匹配。 -- 真实后端登录 provider、AI provider、完整日志查询和统计模块尚未实现,当前仍是后续任务。 +- 真实后端登录 provider、真实 AI provider、完整日志查询和统计模块尚未实现,当前仍是后续任务。 - OCR 和热键依赖平台实现,跨平台修改需要分别验证。 - 当前前端以构建检查为主,暂未引入专门的前端单元测试。 - bindings 生成依赖本机安装的 `wails3`。 @@ -157,9 +159,18 @@ wails3 build - 第二窗口仍保持固定 `340x320` 小窗,不注册尺寸恢复逻辑。 - `go test ./...`、`npm run build`、`go build -o bin\cmbone.exe .` 和 `cmd /c build.bat` 通过。 +同日完成商品图片 AI 优化任务模型: + +- 新增 `internal/models/image_task.go` 和 `internal/services/image_optimization_service.go`。 +- 新增 `image_tasks`、`image_task_results` 迁移和索引。 +- 新增 `ImageOptimizationService.CreateTask/ListTasks/GetTask/CancelTask`。 +- 当前 `CreateTask` 使用本地模拟 provider,创建成功任务和结果记录。 +- 已重新生成 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-020`:建立商品图片 AI 优化任务模型。 +从 [`06-tasks.md`](06-tasks.md) 领取第一个 `TODO` 任务。目前建议先做 `T-021`:实现审计日志与运行日志边界。 ## 当前 blocker diff --git a/docs/method-map.md b/docs/method-map.md index dcf4e45..60052c6 100644 --- a/docs/method-map.md +++ b/docs/method-map.md @@ -38,6 +38,14 @@ | `internal/models/auth.go` | `AuthSession` / `AuthProviderOption` | 前端绑定模型 | | `frontend/src/modules/auth/LoginPage.vue` | 登录页 | 调用后端 AuthService,不写死账号密码 | +## 图片优化任务 + +| 文件 | 方法 / 内容 | 说明 | +| --- | --- | --- | +| `internal/services/image_optimization_service.go` | `ImageOptimizationService` | 创建本地模拟图片优化任务、查询任务和结果 | +| `internal/models/image_task.go` | `ImageTask` / `ImageTaskResult` / `ImageTaskDetail` | 图片优化任务绑定模型 | +| `frontend/bindings/cmbone/internal/services/imageoptimizationservice.js` | Wails binding | 前端调用图片优化任务服务的生成绑定 | + ## 目标模板模块 | 模块 | 目标服务 | 说明 | @@ -45,7 +53,7 @@ | 登录 | `AuthService` | 已实现本地固定账号 provider,真实后端 provider 保留边界 | | 审计日志 | `AuditLogService` | 记录登录、设置变更、任务操作、导出等用户动作 | | 运行日志 | `AppLogService` | 记录 AI 调用、数据库、文件、热键等错误 | -| 商品图片 AI 优化 | `ImageOptimizationService` | 创建任务、查询状态、查看结果和失败原因 | +| 商品图片 AI 优化 | `ImageOptimizationService` | 已实现本地模拟任务模型;页面闭环待 `T-023` | | 数据统计 | `MetricsService` | 聚合今日处理量、成功率、耗时、失败数和 7 天趋势 | ## 热键 diff --git a/docs/routes.md b/docs/routes.md index 5164633..6c35a29 100644 --- a/docs/routes.md +++ b/docs/routes.md @@ -26,7 +26,7 @@ | 路由 | 页面 | 职责 | | --- | --- | --- | | `#/login` | 登录页 | 已接入本地固定账号登录;真实后端登录待后续接入 | -| `#/image-optimizer` | 商品图片 AI 优化 | `T-020` / `T-023` 接入任务模型、任务列表、状态、结果和失败原因 | +| `#/image-optimizer` | 商品图片 AI 优化 | 后端任务模型已接入;`T-023` 接入页面任务列表、状态、结果和失败原因 | | `#/logs` | 操作日志 | `T-021` 接入审计日志和运行日志查询 | | `#/statistics` | 数据统计 | `T-022` 接入趋势、成功率、耗时和失败任务 | | `#/settings` | 设置中心 | 已接入统一设置 schema,窗口配置已用于主窗口尺寸恢复 | diff --git a/frontend/bindings/cmbone/internal/models/models.js b/frontend/bindings/cmbone/internal/models/models.js index 7c0fded..8a11af1 100644 --- a/frontend/bindings/cmbone/internal/models/models.js +++ b/frontend/bindings/cmbone/internal/models/models.js @@ -353,8 +353,277 @@ export class Hotkey { } } +export class ImageTask { + /** + * Creates a new ImageTask instance. + * @param {Partial} [$$source = {}] - The source object to create the ImageTask. + */ + constructor($$source = {}) { + if (!("id" in $$source)) { + /** + * @member + * @type {number} + */ + this["id"] = 0; + } + if (!("sourcePath" in $$source)) { + /** + * @member + * @type {string} + */ + this["sourcePath"] = ""; + } + if (!("status" in $$source)) { + /** + * @member + * @type {string} + */ + this["status"] = ""; + } + if (!("operation" in $$source)) { + /** + * @member + * @type {string} + */ + this["operation"] = ""; + } + if (!("durationMs" in $$source)) { + /** + * @member + * @type {number} + */ + this["durationMs"] = 0; + } + if (!("errorMessage" in $$source)) { + /** + * @member + * @type {string} + */ + this["errorMessage"] = ""; + } + if (!("createdAt" in $$source)) { + /** + * @member + * @type {string} + */ + this["createdAt"] = ""; + } + if (!("updatedAt" in $$source)) { + /** + * @member + * @type {string} + */ + this["updatedAt"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new ImageTask instance from a string or object. + * @param {any} [$$source = {}] + * @returns {ImageTask} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new ImageTask(/** @type {Partial} */($$parsedSource)); + } +} + +export class ImageTaskDetail { + /** + * Creates a new ImageTaskDetail instance. + * @param {Partial} [$$source = {}] - The source object to create the ImageTaskDetail. + */ + constructor($$source = {}) { + if (!("task" in $$source)) { + /** + * @member + * @type {ImageTask} + */ + this["task"] = (new ImageTask()); + } + if (!("result" in $$source)) { + /** + * @member + * @type {ImageTaskResult} + */ + this["result"] = (new ImageTaskResult()); + } + + Object.assign(this, $$source); + } + + /** + * Creates a new ImageTaskDetail instance from a string or object. + * @param {any} [$$source = {}] + * @returns {ImageTaskDetail} + */ + static createFrom($$source = {}) { + const $$createField0_0 = $$createType4; + const $$createField1_0 = $$createType5; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("task" in $$parsedSource) { + $$parsedSource["task"] = $$createField0_0($$parsedSource["task"]); + } + if ("result" in $$parsedSource) { + $$parsedSource["result"] = $$createField1_0($$parsedSource["result"]); + } + return new ImageTaskDetail(/** @type {Partial} */($$parsedSource)); + } +} + +export class ImageTaskFilter { + /** + * Creates a new ImageTaskFilter instance. + * @param {Partial} [$$source = {}] - The source object to create the ImageTaskFilter. + */ + constructor($$source = {}) { + if (!("status" in $$source)) { + /** + * @member + * @type {string} + */ + this["status"] = ""; + } + if (!("operation" in $$source)) { + /** + * @member + * @type {string} + */ + this["operation"] = ""; + } + 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 ImageTaskFilter instance from a string or object. + * @param {any} [$$source = {}] + * @returns {ImageTaskFilter} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new ImageTaskFilter(/** @type {Partial} */($$parsedSource)); + } +} + +export class ImageTaskInput { + /** + * Creates a new ImageTaskInput instance. + * @param {Partial} [$$source = {}] - The source object to create the ImageTaskInput. + */ + constructor($$source = {}) { + if (!("sourcePath" in $$source)) { + /** + * @member + * @type {string} + */ + this["sourcePath"] = ""; + } + if (!("operation" in $$source)) { + /** + * @member + * @type {string} + */ + this["operation"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new ImageTaskInput instance from a string or object. + * @param {any} [$$source = {}] + * @returns {ImageTaskInput} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new ImageTaskInput(/** @type {Partial} */($$parsedSource)); + } +} + +export class ImageTaskResult { + /** + * Creates a new ImageTaskResult instance. + * @param {Partial} [$$source = {}] - The source object to create the ImageTaskResult. + */ + constructor($$source = {}) { + if (!("id" in $$source)) { + /** + * @member + * @type {number} + */ + this["id"] = 0; + } + if (!("taskId" in $$source)) { + /** + * @member + * @type {number} + */ + this["taskId"] = 0; + } + if (!("outputPath" in $$source)) { + /** + * @member + * @type {string} + */ + this["outputPath"] = ""; + } + if (!("beforeSize" in $$source)) { + /** + * @member + * @type {number} + */ + this["beforeSize"] = 0; + } + if (!("afterSize" in $$source)) { + /** + * @member + * @type {number} + */ + this["afterSize"] = 0; + } + if (!("createdAt" in $$source)) { + /** + * @member + * @type {string} + */ + this["createdAt"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new ImageTaskResult instance from a string or object. + * @param {any} [$$source = {}] + * @returns {ImageTaskResult} + */ + static createFrom($$source = {}) { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new ImageTaskResult(/** @type {Partial} */($$parsedSource)); + } +} + // Private type creation functions const $$createType0 = AppSettingOption.createFrom; const $$createType1 = $Create.Array($$createType0); const $$createType2 = AppSettingDefinition.createFrom; const $$createType3 = $Create.Array($$createType2); +const $$createType4 = ImageTask.createFrom; +const $$createType5 = ImageTaskResult.createFrom; diff --git a/frontend/bindings/cmbone/internal/services/imageoptimizationservice.js b/frontend/bindings/cmbone/internal/services/imageoptimizationservice.js new file mode 100644 index 0000000..3b1e865 --- /dev/null +++ b/frontend/bindings/cmbone/internal/services/imageoptimizationservice.js @@ -0,0 +1,64 @@ +// @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 {number} id + * @returns {Promise & { cancel(): void }} + */ +export function CancelTask(id) { + let $resultPromise = /** @type {any} */($Call.ByID(3767102380, id)); + return $resultPromise; +} + +/** + * @param {models$0.ImageTaskInput} input + * @returns {Promise & { cancel(): void }} + */ +export function CreateTask(input) { + let $resultPromise = /** @type {any} */($Call.ByID(1860238962, input)); + let $typingPromise = /** @type {any} */($resultPromise.then(($result) => { + return $$createType0($result); + })); + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * @param {number} id + * @returns {Promise & { cancel(): void }} + */ +export function GetTask(id) { + let $resultPromise = /** @type {any} */($Call.ByID(701224676, id)); + let $typingPromise = /** @type {any} */($resultPromise.then(($result) => { + return $$createType0($result); + })); + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * @param {models$0.ImageTaskFilter} filter + * @returns {Promise & { cancel(): void }} + */ +export function ListTasks(filter) { + let $resultPromise = /** @type {any} */($Call.ByID(1714055147, filter)); + 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.ImageTaskDetail.createFrom; +const $$createType1 = models$0.ImageTask.createFrom; +const $$createType2 = $Create.Array($$createType1); diff --git a/frontend/bindings/cmbone/internal/services/index.js b/frontend/bindings/cmbone/internal/services/index.js index 7b01fb3..0dc4e63 100644 --- a/frontend/bindings/cmbone/internal/services/index.js +++ b/frontend/bindings/cmbone/internal/services/index.js @@ -6,6 +6,7 @@ import * as AppConfigService from "./appconfigservice.js"; import * as AppService from "./appservice.js"; import * as AuthService from "./authservice.js"; import * as HotkeyService from "./hotkeyservice.js"; +import * as ImageOptimizationService from "./imageoptimizationservice.js"; import * as OCRService from "./ocrservice.js"; import * as SystemInfo from "./systeminfo.js"; export { @@ -13,6 +14,7 @@ export { AppService, AuthService, HotkeyService, + ImageOptimizationService, OCRService, SystemInfo }; diff --git a/internal/models/image_task.go b/internal/models/image_task.go new file mode 100644 index 0000000..486bb10 --- /dev/null +++ b/internal/models/image_task.go @@ -0,0 +1,38 @@ +package models + +type ImageTaskInput struct { + SourcePath string `json:"sourcePath"` + Operation string `json:"operation"` +} + +type ImageTaskFilter struct { + Status string `json:"status"` + Operation string `json:"operation"` + Limit int `json:"limit"` + Offset int `json:"offset"` +} + +type ImageTask struct { + ID int64 `json:"id"` + SourcePath string `json:"sourcePath"` + Status string `json:"status"` + Operation string `json:"operation"` + DurationMs int64 `json:"durationMs"` + ErrorMessage string `json:"errorMessage"` + CreatedAt string `json:"createdAt"` + UpdatedAt string `json:"updatedAt"` +} + +type ImageTaskResult struct { + ID int64 `json:"id"` + TaskID int64 `json:"taskId"` + OutputPath string `json:"outputPath"` + BeforeSize int64 `json:"beforeSize"` + AfterSize int64 `json:"afterSize"` + CreatedAt string `json:"createdAt"` +} + +type ImageTaskDetail struct { + Task ImageTask `json:"task"` + Result ImageTaskResult `json:"result"` +} diff --git a/internal/services/image_optimization_service.go b/internal/services/image_optimization_service.go new file mode 100644 index 0000000..22b31f5 --- /dev/null +++ b/internal/services/image_optimization_service.go @@ -0,0 +1,270 @@ +package services + +import ( + "cmbone/internal/models" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + imageTaskStatusSucceeded = "succeeded" + imageTaskStatusCanceled = "canceled" + + defaultImageOperation = "enhance" + defaultTaskLimit = 50 + maxTaskLimit = 200 +) + +var ( + errImageTaskSourcePathRequired = errors.New("source path is required") + errImageTaskNotFound = errors.New("image task not found") + errImageTaskCannotCancel = errors.New("image task cannot be canceled") +) + +type ImageOptimizationService struct { + store *SuiStore +} + +func NewImageOptimizationService(store *SuiStore) *ImageOptimizationService { + return &ImageOptimizationService{store: store} +} + +func (s *ImageOptimizationService) CreateTask(input models.ImageTaskInput) (models.ImageTaskDetail, error) { + sourcePath := strings.TrimSpace(input.SourcePath) + if sourcePath == "" { + return models.ImageTaskDetail{}, errImageTaskSourcePathRequired + } + + operation := strings.TrimSpace(input.Operation) + if operation == "" { + operation = defaultImageOperation + } + + now := time.Now().UTC().Format(time.RFC3339) + beforeSize := fileSizeOrZero(sourcePath) + afterSize := simulatedAfterSize(beforeSize) + + tx, err := s.store.DB.Begin() + if err != nil { + return models.ImageTaskDetail{}, err + } + defer func() { + if err != nil { + _ = tx.Rollback() + } + }() + + result, err := tx.Exec(` + INSERT INTO image_tasks (source_path, status, operation, duration_ms, error_message, created_at, updated_at) + VALUES (?, ?, ?, ?, '', ?, ?) + `, sourcePath, imageTaskStatusSucceeded, operation, 1200, now, now) + if err != nil { + return models.ImageTaskDetail{}, err + } + + taskID, err := result.LastInsertId() + if err != nil { + return models.ImageTaskDetail{}, err + } + + outputPath := simulatedOutputPath(sourcePath, operation) + result, err = tx.Exec(` + INSERT INTO image_task_results (task_id, output_path, before_size, after_size, created_at) + VALUES (?, ?, ?, ?, ?) + `, taskID, outputPath, beforeSize, afterSize, now) + if err != nil { + return models.ImageTaskDetail{}, err + } + + resultID, err := result.LastInsertId() + if err != nil { + return models.ImageTaskDetail{}, err + } + if err = tx.Commit(); err != nil { + return models.ImageTaskDetail{}, err + } + + return models.ImageTaskDetail{ + Task: models.ImageTask{ + ID: taskID, + SourcePath: sourcePath, + Status: imageTaskStatusSucceeded, + Operation: operation, + DurationMs: 1200, + ErrorMessage: "", + CreatedAt: now, + UpdatedAt: now, + }, + Result: models.ImageTaskResult{ + ID: resultID, + TaskID: taskID, + OutputPath: outputPath, + BeforeSize: beforeSize, + AfterSize: afterSize, + CreatedAt: now, + }, + }, nil +} + +func (s *ImageOptimizationService) ListTasks(filter models.ImageTaskFilter) ([]models.ImageTask, error) { + limit := filter.Limit + if limit <= 0 { + limit = defaultTaskLimit + } + if limit > maxTaskLimit { + limit = maxTaskLimit + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + + query := strings.Builder{} + query.WriteString(` + SELECT id, source_path, status, operation, duration_ms, error_message, created_at, updated_at + FROM image_tasks + WHERE 1 = 1 + `) + + args := make([]any, 0, 4) + if strings.TrimSpace(filter.Status) != "" { + query.WriteString(" AND status = ?") + args = append(args, strings.TrimSpace(filter.Status)) + } + if strings.TrimSpace(filter.Operation) != "" { + query.WriteString(" AND operation = ?") + args = append(args, strings.TrimSpace(filter.Operation)) + } + 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() + + tasks := make([]models.ImageTask, 0) + for rows.Next() { + task, err := scanImageTask(rows) + if err != nil { + return nil, err + } + tasks = append(tasks, task) + } + if err := rows.Err(); err != nil { + return nil, err + } + return tasks, nil +} + +func (s *ImageOptimizationService) GetTask(id int64) (models.ImageTaskDetail, error) { + row := s.store.DB.QueryRow(` + SELECT + t.id, t.source_path, t.status, t.operation, t.duration_ms, t.error_message, t.created_at, t.updated_at, + r.id, r.task_id, r.output_path, r.before_size, r.after_size, r.created_at + FROM image_tasks t + LEFT JOIN image_task_results r ON r.task_id = t.id + WHERE t.id = ? + ORDER BY r.id DESC + LIMIT 1 + `, id) + + detail, err := scanImageTaskDetail(row) + if errors.Is(err, sql.ErrNoRows) { + return models.ImageTaskDetail{}, errImageTaskNotFound + } + return detail, err +} + +func (s *ImageOptimizationService) CancelTask(id int64) error { + result, err := s.store.DB.Exec(` + UPDATE image_tasks + SET status = ?, updated_at = ? + WHERE id = ? AND status IN ('pending', 'processing') + `, imageTaskStatusCanceled, time.Now().UTC().Format(time.RFC3339), id) + if err != nil { + return err + } + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return errImageTaskCannotCancel + } + return nil +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanImageTask(scanner rowScanner) (models.ImageTask, error) { + var task models.ImageTask + err := scanner.Scan( + &task.ID, + &task.SourcePath, + &task.Status, + &task.Operation, + &task.DurationMs, + &task.ErrorMessage, + &task.CreatedAt, + &task.UpdatedAt, + ) + return task, err +} + +func scanImageTaskDetail(scanner rowScanner) (models.ImageTaskDetail, error) { + var detail models.ImageTaskDetail + err := scanner.Scan( + &detail.Task.ID, + &detail.Task.SourcePath, + &detail.Task.Status, + &detail.Task.Operation, + &detail.Task.DurationMs, + &detail.Task.ErrorMessage, + &detail.Task.CreatedAt, + &detail.Task.UpdatedAt, + &detail.Result.ID, + &detail.Result.TaskID, + &detail.Result.OutputPath, + &detail.Result.BeforeSize, + &detail.Result.AfterSize, + &detail.Result.CreatedAt, + ) + return detail, err +} + +func fileSizeOrZero(path string) int64 { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return 0 + } + return info.Size() +} + +func simulatedAfterSize(beforeSize int64) int64 { + if beforeSize <= 0 { + return 0 + } + return beforeSize * 82 / 100 +} + +func simulatedOutputPath(sourcePath string, operation string) string { + dir := filepath.Dir(sourcePath) + ext := filepath.Ext(sourcePath) + name := strings.TrimSuffix(filepath.Base(sourcePath), ext) + if name == "" || name == "." { + name = "image" + } + if ext == "" { + ext = ".jpg" + } + return filepath.Join(dir, fmt.Sprintf("%s_%s_optimized%s", name, operation, ext)) +} diff --git a/internal/services/image_optimization_service_test.go b/internal/services/image_optimization_service_test.go new file mode 100644 index 0000000..d06a1a8 --- /dev/null +++ b/internal/services/image_optimization_service_test.go @@ -0,0 +1,80 @@ +package services + +import ( + "errors" + "path/filepath" + "strings" + "testing" + + "cmbone/internal/models" +) + +func TestMigrateCreatesImageTaskTables(t *testing.T) { + store := newTestStore(t) + + for _, table := range []string{"image_tasks", "image_task_results"} { + var name string + if err := store.DB.QueryRow("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", table).Scan(&name); err != nil { + t.Fatalf("query table %s: %v", table, err) + } + if name != table { + t.Fatalf("table = %q, want %q", name, table) + } + } +} + +func TestImageOptimizationServiceCreateTask(t *testing.T) { + store := newTestStore(t) + service := NewImageOptimizationService(store) + sourcePath := filepath.Join(t.TempDir(), "sku-main.jpg") + + detail, err := service.CreateTask(models.ImageTaskInput{ + SourcePath: sourcePath, + Operation: "enhance", + }) + if err != nil { + t.Fatalf("create task: %v", err) + } + + if detail.Task.ID == 0 { + t.Fatal("task id is empty") + } + if detail.Task.Status != imageTaskStatusSucceeded { + t.Fatalf("status = %q, want %q", detail.Task.Status, imageTaskStatusSucceeded) + } + if detail.Task.Operation != "enhance" { + t.Fatalf("operation = %q, want enhance", detail.Task.Operation) + } + if detail.Result.TaskID != detail.Task.ID { + t.Fatalf("result task id = %d, want %d", detail.Result.TaskID, detail.Task.ID) + } + if !strings.Contains(detail.Result.OutputPath, "sku-main_enhance_optimized.jpg") { + t.Fatalf("output path = %q, want simulated optimized path", detail.Result.OutputPath) + } + + got, err := service.GetTask(detail.Task.ID) + if err != nil { + t.Fatalf("get task: %v", err) + } + if got.Task.ID != detail.Task.ID { + t.Fatalf("got task id = %d, want %d", got.Task.ID, detail.Task.ID) + } + + tasks, err := service.ListTasks(models.ImageTaskFilter{Status: imageTaskStatusSucceeded}) + if err != nil { + t.Fatalf("list tasks: %v", err) + } + if len(tasks) != 1 { + t.Fatalf("tasks len = %d, want 1", len(tasks)) + } +} + +func TestImageOptimizationServiceCreateTaskRequiresSourcePath(t *testing.T) { + store := newTestStore(t) + service := NewImageOptimizationService(store) + + _, err := service.CreateTask(models.ImageTaskInput{}) + if !errors.Is(err, errImageTaskSourcePathRequired) { + t.Fatalf("create task error = %v, want source path required", err) + } +} diff --git a/internal/services/store.go b/internal/services/store.go index d51bbca..018bcdc 100644 --- a/internal/services/store.go +++ b/internal/services/store.go @@ -102,6 +102,30 @@ func Migrate(db *sql.DB) error { detail TEXT NOT NULL, created_at TEXT NOT NULL ); + + CREATE TABLE IF NOT EXISTS image_tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_path TEXT NOT NULL, + status TEXT NOT NULL, + operation TEXT NOT NULL, + duration_ms INTEGER NOT NULL DEFAULT 0, + error_message TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS image_task_results ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + output_path TEXT NOT NULL, + before_size INTEGER NOT NULL DEFAULT 0, + after_size INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + FOREIGN KEY(task_id) REFERENCES image_tasks(id) + ); + + 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); `) if err != nil { return err diff --git a/main.go b/main.go index 4a71aeb..d925977 100644 --- a/main.go +++ b/main.go @@ -31,6 +31,7 @@ func main() { application.NewService(services.NewSystemInfo()), application.NewService(services.NewAppConfigService(store)), application.NewService(services.NewAuthService(store)), + application.NewService(services.NewImageOptimizationService(store)), application.NewService(&services.OCRService{}), }, Assets: application.AssetOptions{ diff --git a/progress.md b/progress.md index 02b46e6..d5d5fb5 100644 --- a/progress.md +++ b/progress.md @@ -246,3 +246,22 @@ - 非阻塞警告: - `cmd /c build.bat` 仍提示 `wails3 build` 是 `wails3 task` 的 alias。 - 前端构建仍提示 Browserslist 数据过期和 chunk 大小警告。 + +### 建立商品图片 AI 优化任务模型 + +- 任务:完成 `T-020`,新增 `image_tasks`、`image_task_results` 迁移和基础测试,并支持创建模拟任务。 +- 结果: + - 新增 `internal/models/image_task.go`,定义图片任务输入、过滤器、任务、结果和详情模型。 + - 新增 `internal/services/image_optimization_service.go`,提供 `CreateTask`、`ListTasks`、`GetTask` 和 `CancelTask`。 + - SQLite 迁移新增 `image_tasks`、`image_task_results` 和基础索引。 + - `CreateTask` 当前使用本地模拟 provider,立即写入成功任务和结果记录。 + - `main.go` 注册 `ImageOptimizationService`。 + - 重新生成 Wails `.js` bindings,新增 `frontend/bindings/cmbone/internal/services/imageoptimizationservice.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 大小警告。