feat: add image optimization task model

This commit is contained in:
QiuSW
2026-07-09 00:47:35 +08:00
parent 7942add8b6
commit 3bd05b316e
16 changed files with 827 additions and 34 deletions
+1 -1
View File
@@ -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 示例
- 目标模板模块:登录、设置、主业务、审计日志、运行日志、数据统计、常用组件示例
## 环境准备
+19 -16
View File
@@ -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` 缓存表。数据量变大后再增加缓存表。
迁移要求:
+1 -1
View File
@@ -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 |
+15 -11
View File
@@ -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
目标方法:
+14 -3
View File
@@ -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
+9 -1
View File
@@ -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 天趋势 |
## 热键
+1 -1
View File
@@ -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,窗口配置已用于主窗口尺寸恢复 |
@@ -353,8 +353,277 @@ export class Hotkey {
}
}
export class ImageTask {
/**
* Creates a new ImageTask instance.
* @param {Partial<ImageTask>} [$$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<ImageTask>} */($$parsedSource));
}
}
export class ImageTaskDetail {
/**
* Creates a new ImageTaskDetail instance.
* @param {Partial<ImageTaskDetail>} [$$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<ImageTaskDetail>} */($$parsedSource));
}
}
export class ImageTaskFilter {
/**
* Creates a new ImageTaskFilter instance.
* @param {Partial<ImageTaskFilter>} [$$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<ImageTaskFilter>} */($$parsedSource));
}
}
export class ImageTaskInput {
/**
* Creates a new ImageTaskInput instance.
* @param {Partial<ImageTaskInput>} [$$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<ImageTaskInput>} */($$parsedSource));
}
}
export class ImageTaskResult {
/**
* Creates a new ImageTaskResult instance.
* @param {Partial<ImageTaskResult>} [$$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<ImageTaskResult>} */($$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;
@@ -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<void> & { cancel(): void }}
*/
export function CancelTask(id) {
let $resultPromise = /** @type {any} */($Call.ByID(3767102380, id));
return $resultPromise;
}
/**
* @param {models$0.ImageTaskInput} input
* @returns {Promise<models$0.ImageTaskDetail> & { 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<models$0.ImageTaskDetail> & { 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<models$0.ImageTask[]> & { 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);
@@ -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
};
+38
View File
@@ -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"`
}
@@ -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))
}
@@ -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)
}
}
+24
View File
@@ -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
+1
View File
@@ -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{
+19
View File
@@ -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 大小警告。