feat: add image optimization task model
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 大小警告。
|
||||
|
||||
Reference in New Issue
Block a user