diff --git a/server/README.md b/server/README.md index 62c9782..a6a319a 100644 --- a/server/README.md +++ b/server/README.md @@ -14,7 +14,7 @@ | 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**(1000 / 5600 / 18000) | | 后台管理端 | `server_admin/`(Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token`) | | 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**(iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 | -| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗内 canvas 直接画框/点框删除/清空并保存——AI 自动标注结果直接作为标注,人工可修改/清理全部框;封面(上传自动转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象,默认 qwen-image/DashScope 付费 API);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**(`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON | +| 数据训练(唯一入口) | 后台管理端「数据训练」一个菜单承载数据集全流程:**数据集卡片列表**(封面图/描述/图片数/已标注数/**训练状态徽标**),**卡片下方直接展示训练任务进度条与状态**(无独立训练页);详情页为**图片与标注一体视图**:分页(每页 20 条)逐行「原图 ‖ 标注图」对照展示;**图片入库(手动上传/AI 生成)自动触发 RF-DETR 全图扫描标注**,进度条展示在页顶;页顶另有「全量标注」按钮可手动重标全部图片(覆盖各图已有标注);点击原图/标注图弹窗放大,弹窗内 canvas 直接画框/点框删除/清空并保存——AI 自动标注结果直接作为标注,人工可修改/清理全部框;封面(上传自动转 jpg + UUID 命名)/**描述**/AI 生成图片(provider 抽象:dashscope 通义万相付费 API / localai 训练机 local-ai qwen-image,`config.yml imageGen` 节点切换,见配置说明);AI 标注端点与训练机 SSH 为**全局配置,直接读 `config.yml`**(`localAi` / `training.ssh` 节点,改配置需重启服务);图片落服务器 `app.datasetDir`/`datasets/<数据集名>/`,DB 存元数据 + 标注 JSON | | 模型训练 | 从数据集卡片「开始训练」一键触发(参数 imgsz/epochs/batch/device 默认走 `config.yml` `training` 节点,部署级配置):进度/日志/指标监控(每 epoch 粒度)、取消;训练通道 `training` 节点可配置 subprocess(与 Go 服务同机直接起 python)/ ssh(异机执行,SSH 凭据取 `config.yml` `training.ssh` 节点),并发度 1(GPU 独占);训练脚本 `server/training/train_server.py`(随项目迁移,2026-08-26)参数化,产物(best.tflite/best.pt/曲线)拉回服务器;训练收尾自动做 **tflite 产物自检**(输入/输出 shape 校验,原 `inspect_tflite.py` 逻辑内嵌脚本),自检失败任务置失败并带出原因;`dump_graph.py` 留作训练机人工深度调试 | | 模型版本与热更新 | **每数据集一个模型**:训练完成后一键「发布」(训练任务操作列)——tflite 落 `workspace/trainings/<数据集名>.tflite` + sha256/指标/类别名入 `model_version`(按数据集独立版本序列 m1.0.0 递增)。管理端**无模型管理界面**(版本记录仅支撑客户端下发)。**App 模型热更新**:`GET /api/v1/app/update` 扩展返回 `models` 目录数组,客户端独立检查,新模型下载校验替换,失败回退旧模型——模型迭代不再重打包 APK | | 模型目录与多模型推理 | `GET /api/v1/models`(登录态)返回全部数据集当前生效模型(数据集/版本/类别/大小/sha256/下载地址),下载 URL `/download/trainings/<数据集名>.tflite`;**App 模型管理页**用户自由下载/删除/启用模型,识别时**加载全部已启用模型并行推理 + 跨模型 NMS 合并**(按类别名),内置 assets 模型兜底 | @@ -185,7 +185,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow | GET | `/admin/datasets/cover` | 封面文件(静态字节流,`datasetId` 定位) | | POST | `/admin/datasets/cover/delete` | 删除数据集封面 `{"datasetId":1}`:删文件 + 清 cover 字段 | | POST | `/admin/datasets/upload` | 上传图片(multipart/form-data:`datasetId` + `files` 多张,仅接受 `.jpg/.jpeg/.png`),存 `app.datasetDir`/`datasets//`,逐张入库 | -| POST | `/admin/datasets/generate` | AI 生成图片 `{"datasetId":1,"prompt":"...","count":1}`(count 1..8,同步执行):调 `imageGen` provider(默认 qwen-image/DashScope)逐张生成落盘 + 入库(记录 prompt),任意失败返回错误 | +| POST | `/admin/datasets/generate` | AI 生成图片 `{"datasetId":1,"prompt":"...","count":1}`(count 1..8,同步执行,每张超时 `imageGen.timeoutSeconds`):调 `imageGen` provider(dashscope / localai,config 切换)逐张生成落盘 + 入库(记录 prompt),任意失败返回错误 | | GET | `/admin/datasets/images` | 数据集图片列表 `{"datasetId":1}`,返回图片元数据(文件名/来源/prompt/创建时间) | | GET | `/admin/datasets/image` | 图片文件(静态字节流,`datasetId` + `filename` 定位,供缩略图/查看) | | POST | `/admin/datasets/images/delete` | 删除图片 `{"datasetId":1,"ids":[1,2]}`:删文件 + 删记录(AI 生成图是付费资产,前端确认文案提示) | @@ -223,7 +223,7 @@ APK 下载引导页(静态页面,源码在 `h5/index.html`,由后端 `/dow ## 使用说明 -1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;模型训练相关节点:`training`(训练通道 mode=subprocess/ssh、ssh 连接信息、训练机工作目录/venv/数据集目录、并发度 1、超时)、`imageGen`(AI 生成图片 provider,默认 dashscope + apiKey + model qwen-image-3.0)、`localAi`(二期标注用 RF-DETR 服务地址);SQLite 库由服务启动时自动建表并迁移,无需手工初始化 +1. 配置 `config.yml`:监听端口、数据库路径、登录 token 签名密钥 `auth.secret`(必填,换值即全员下线)、套餐 `plans` 节点、微信支付(appid/mchid/商户私钥/证书序列号/APIv3 密钥)、支付宝(appid/应用私钥/支付宝公钥)、管理端 `admin.token`;模型训练相关节点:`training`(训练通道 mode=subprocess/ssh、ssh 连接信息、训练机工作目录/venv/数据集目录、并发度 1、超时)、`imageGen`(AI 生成图片 provider:`dashscope` 通义万相(apiKey + model qwen-image-3.0)/ `localai` 本地 local-ai(baseUrl + model 如 qwen-image + timeoutSeconds,训练机约 5 分钟/张建议 600))、`localAi`(二期标注用 RF-DETR 服务地址);SQLite 库由服务启动时自动建表并迁移,无需手工初始化 2. `go build ./...` 编译验证 3. 本地运行 `go run main.go`;服务层白盒测试:`GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/`(独立测试库,见 `biz/service/testdata/`) 4. 后台管理端:`cd server_admin && npm run build`(构建产物输出到 `server/admin_dist/`,由后端 `/admin/` 托管);开发联调 `npm run dev`(Vite 代理 `/api` → `:8080`)。首次访问 `/admin/` 进入登录页,输入 `config.yml admin.token` 对应的管理 token(存浏览器 localStorage,随请求携带;token 不内嵌构建产物) diff --git a/server/biz/service/dataset.go b/server/biz/service/dataset.go index 57b6403..bb0e2c8 100644 --- a/server/biz/service/dataset.go +++ b/server/biz/service/dataset.go @@ -447,7 +447,7 @@ func saveImageFile(f *ghttp.UploadFile, dir string) error { return nil } -// AdminGenerateImages AI 生成图片:同步逐张生成(每张 2min 超时),逐张落盘 + 入库; +// AdminGenerateImages AI 生成图片:同步逐张生成(每张超时 imageGen.timeoutSeconds),逐张落盘 + 入库; // 中途失败返回错误并附已成功张数(已生成的图为付费资产,保留不删除)。 func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.AdminDatasetGenerateReq) (*dto.AdminDatasetGenerateRes, error) { provider := common.ImageGen(ctx) @@ -472,8 +472,10 @@ func (s *datasetService) AdminGenerateImages(ctx context.Context, req *dto.Admin now := gtime.Now() generated := 0 addedIds := make([]int64, 0, req.Count) + // 每张生成超时走配置(dashscope 默认 120s;localai 训练机约 5min/张,建议 600) + genTimeout := time.Duration(g.Cfg().MustGet(ctx, "imageGen.timeoutSeconds", 120).Int()) * time.Second for i := 0; i < req.Count; i++ { - genCtx, cancel := context.WithTimeout(ctx, 120*time.Second) + genCtx, cancel := context.WithTimeout(ctx, genTimeout) data, genErr := provider.Generate(genCtx, req.Prompt, req.Size) cancel() if genErr != nil { diff --git a/server/common/imagegen.go b/server/common/imagegen.go index f40d07f..da708ff 100644 --- a/server/common/imagegen.go +++ b/server/common/imagegen.go @@ -6,9 +6,11 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" "time" + "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" ) @@ -18,13 +20,21 @@ type ImageGenProvider interface { Generate(ctx context.Context, prompt, size string) ([]byte, error) } -// ImageGen 当前配置的图像生成 provider 单例(未配置返回 nil,调用方判 CodeImageGenNotConfigured)。 +// ImageGen 当前配置的图像生成 provider 单例(provider 必填项缺失返回 nil,调用方判 CodeImageGenNotConfigured)。 func ImageGen(ctx context.Context) ImageGenProvider { - if g.Cfg().MustGet(ctx, "imageGen.apiKey").String() == "" { - return nil - } switch g.Cfg().MustGet(ctx, "imageGen.provider", "dashscope").String() { + case "localai": + if g.Cfg().MustGet(ctx, "imageGen.baseUrl").String() == "" { + return nil + } + return &localAiImageGen{ + baseUrl: g.Cfg().MustGet(ctx, "imageGen.baseUrl").String(), + model: g.Cfg().MustGet(ctx, "imageGen.model", "qwen-image").String(), + } case "dashscope": + if g.Cfg().MustGet(ctx, "imageGen.apiKey").String() == "" { + return nil + } return &dashScopeImageGen{apiKey: g.Cfg().MustGet(ctx, "imageGen.apiKey").String()} default: return nil @@ -158,3 +168,82 @@ func truncateStr(s string, n int) string { } return s[:n] + "..." } + +// localAiImageGen local-ai(OpenAI 兼容 /v1/images/generations)实现:同步生成 → 下载产物图片。 +// 训练机 local-ai 已加载 qwen-image 文生图;返回 url 为容器内 localhost 地址,需替换为配置 baseUrl 下载。 +type localAiImageGen struct { + baseUrl string + model string +} + +func (p *localAiImageGen) Generate(ctx context.Context, prompt, size string) ([]byte, error) { + body, err := json.Marshal(map[string]any{ + "model": p.model, + "prompt": prompt, + "size": size, + "n": 1, + }) + if err != nil { + return nil, err + } + url := strings.TrimSuffix(p.baseUrl, "/") + "/v1/images/generations" + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(string(body))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, gerror.Wrap(err, "local-ai 生成请求失败") + } + defer func() { _ = resp.Body.Close() }() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, err + } + if resp.StatusCode != http.StatusOK { + msg := truncateStr(string(raw), 200) + if msg == "" { + msg = resp.Status + } + return nil, fmt.Errorf("local-ai 生成失败: HTTP %d: %s", resp.StatusCode, msg) + } + var m struct { + Data []struct { + Url string `json:"url"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("local-ai 响应解析失败: %v", err) + } + if len(m.Data) == 0 || m.Data[0].Url == "" { + return nil, fmt.Errorf("local-ai 成功但无图片 URL") + } + return p.download(ctx, m.Data[0].Url) +} + +// download 下载生成产物图片:url 为容器内地址(localhost),替换 scheme/host 为配置 baseUrl 后下载 +func (p *localAiImageGen) download(ctx context.Context, imageUrl string) ([]byte, error) { + base, err := url.Parse(p.baseUrl) + if err != nil { + return nil, err + } + u, err := url.Parse(imageUrl) + if err != nil { + return nil, err + } + u.Scheme, u.Host = base.Scheme, base.Host + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, gerror.Wrap(err, "下载生成图片失败") + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("下载生成图片失败: HTTP %d", resp.StatusCode) + } + return io.ReadAll(io.LimitReader(resp.Body, 16<<20)) +} diff --git a/server/config.yml b/server/config.yml index 924a243..a0f5fd9 100644 --- a/server/config.yml +++ b/server/config.yml @@ -31,11 +31,13 @@ app: # 模型训练体系运行时数据根目录(图片/标注/模型/训练产物;与 apkDir 同目录时共用 workspace) datasetDir: "./workspace" -# 图像生成(管理端「AI 生成图片」):未配置 apiKey 时生成接口返回「图像生成服务未配置」 +# 图像生成(管理端「AI 生成图片」):provider 必填项缺失时生成接口返回「图像生成服务未配置」 imageGen: - provider: dashscope # 当前唯一实现 - apiKey: "" # DashScope API Key - model: qwen-image-3.0 + provider: localai # dashscope | localai + baseUrl: "http://192.168.3.210:18080" # localai 必填:训练机 local-ai 地址(/v1/images/generations) + model: qwen-image # localai 上加载的模型名;dashscope 用 qwen-image-3.0 + apiKey: "" # dashscope 必填:DashScope API Key + timeoutSeconds: 600 # 每张生成超时(默认 120;localai 训练机约 5min/张,建议 600) # 训练通道(并发度 1:GPU 独占,同时仅一个 running 任务): # mode=subprocess 训练机与服务器同机;mode=ssh 异机(训练脚本/数据集经 ssh 通道同步) diff --git a/server/技术设计.md b/server/技术设计.md index 775bc14..14b2945 100644 --- a/server/技术设计.md +++ b/server/技术设计.md @@ -324,9 +324,12 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不 ### AI 生成图片(provider 抽象) -- `imageGen` 配置节点:`provider: dashscope`(当前唯一实现)+ `apiKey` + `model: qwen-image-3.0`;接口在 `common` 层抽象 `ImageGenProvider`(`Generate(ctx, prompt, size) ([]byte, error)`),后续可加本地 SD 等实现,config 切换 +- `imageGen` 配置节点:`provider: dashscope | localai`,接口在 `common` 层抽象 `ImageGenProvider`(`Generate(ctx, prompt, size) ([]byte, error)`),config 切换 + - `dashscope`(通义万相付费 API):`apiKey` + `model: qwen-image-3.0`;内部自管 120s 轮询上限 + - `localai`(本机/训练机 local-ai,`POST {baseUrl}/v1/images/generations`,OpenAI 兼容):`baseUrl` + `model`(local-ai 上加载的模型名,如 qwen-image)+ `timeoutSeconds`(训练机 qwen-image 1024x1024/30 步约 5 分钟,默认 600);返回 url 为容器内 localhost 地址,下载时替换为 `baseUrl` 的 host;step/cfg 等采样参数用 local-ai 模型配置默认值,不随接口传 + - `baseUrl`/`apiKey` 缺失时对应 provider 视为未配置,生成接口返回「图像生成服务未配置」 - 尺寸参数由前端传(下拉可选,默认 1152x2048 竖图,与既有训练图一致;另提供 704x704 档) -- 生成同步执行(count 1..8,超时 2min/张),逐张落盘 + 入库(记录 prompt 便于追溯);中途失败返回错误(已成功的图保留,不删除——付费资产原则) +- 生成同步执行(count 1..8,每张超时 `imageGen.timeoutSeconds`,默认 120s;localai 建议 600),逐张落盘 + 入库(记录 prompt 便于追溯);中途失败返回错误(已成功的图保留,不删除——付费资产原则) - prompt 由前端预设模板 + 用户微调;**遵守项目提示词规范:不得包含目标位置描述**(位置由模型自行推理,前端模板与校验文案落实此约束) ### 训练通道(runner 抽象,决策:同机/异机不确定 → 可配置) @@ -393,6 +396,6 @@ training: - iOS App Store 对数字内容强制 IAP,微信/支付宝直连有被拒风险(已决策,记录在案) - 管理端静态 token 由登录页输入(浏览器 localStorage),属内网管理凭据:`config.yml admin.token` 失配/未配置时管理接口全部拒绝;token 不进入前端构建产物与 git - 手动授权等同免费发卡,仅限运营/客诉排查场景,前端页面带确认弹窗,后端不限制频率(按需再加审计) -- 模型训练体系依赖外部环境:训练机 GPU + ultralytics venv(`training.venvPython`)、RF-DETR 服务从服务器可达(`localAi.baseUrl`)、DashScope API key(`imageGen.apiKey`)——未配置对应节点时相关管理接口返回配置缺失错误,不影响支付/授权主链路 +- 模型训练体系依赖外部环境:训练机 GPU + ultralytics venv(`training.venvPython`)、RF-DETR 服务从服务器可达(`localAi.baseUrl`)、图像生成服务可达(`imageGen`:dashscope 需外网 + apiKey,localai 需训练机 local-ai 可达 + `imageGen.baseUrl`)——未配置对应节点时相关管理接口返回配置缺失错误,不影响支付/授权主链路 - subprocess 模式下训练与在线服务同机,训练吃满 GPU 可能影响识别类在线任务(当前在线服务无 GPU 推理,风险低);异机部署切换 `training.mode: ssh` - 训练产物与数据集为磁盘占用大户(图片百 MB~GB、每轮产物几十 MB),`workspace/` 已挂载持久化;清理入口:删除数据集(付费资产需确认);模型无存档,仅 latest.tflite 被新发布覆盖,无需单独清理