diff --git a/.gitignore b/.gitignore index 76a786d..2706a3a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ .DS_Store .idea/ .vscode/ +.gstack/ + +# 运行时数据与构建产物(不提交) +server/data/ +server/workspace/ +server/main diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..75ab043 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,109 @@ +# CLAUDE.md + +## 目录结构与职责(硬性约束) + +> **`biz/` 是泛化占位名,不是固定目录命名**。表格中 `biz/` 代表「业务模块目录」,各项目必须按自身业务命名替换(本项目即 `styleagent/`,位于 `server/styleagent/`),禁止新项目照抄 `biz/`;`ui-src/`、`data/`、`workspace/` 亦为本项目目录名,各项目按自身命名。以下表格路径均以 `server/` 为根(前端另见 `app-uni/`)。 + +| 目录 | 职责 | 强约束 | +|---|---|---| +| common/ | 通用层:HTTP 服务与鉴权中间件、RouteRegister 反射路由、DAO 基类、查询缓存、锁与协程池封装、DB 组访问器、文件存储 | 不得依赖业务模块包;新增跨模块通用能力放这里 | +| styleagent/consts/ | 常量集中地:表名(table_name.go)、状态(status.go)、内容类型、默认参数与各协程池默认大小 | 业务常量一律在此集中,禁止散落 magic number;新增池默认大小在此定义 | +| styleagent/model/ | entity(表结构,与 DAO 一一对应)、dto(请求/响应结构,`g.Meta` 内嵌定义路由)、domain(领域模型:跨表聚合与服务层组装值,可被 dto/entity 引用) | entity 只做表映射,不带业务逻辑;dto 是 controller 与 HTTP 的唯一出入口;domain 收纳不属于 dto 也不属于 entity 的类型(见下) | +| styleagent/dao/ | 单表数据访问,每表一个文件 | 无业务逻辑;查询经 base_dao 缓存 | +| styleagent/service/ | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao、LLM 编排 | 不直接写 HTTP 响应(例外见下);并行任务走 common 协程池 | +| styleagent/controller/ | 接口层:接收参数、调用 service、原样返回 service 结果 | 见「分层职责规范」;禁止调用 dao | +| 前端目录 | 前台 `app-uni/` 为 uni-app (Vue 3) 多端工程(Android/iOS/微信小程序/H5);后台管理端未建 | H5 构建产物由 server 托管(main.go WebStaticDir);多端工程差异见技术设计.md | +| 运行时数据目录 | SQLite 库、上传/生成文件(本项目 `server/data/` `server/workspace/`) | 不提交 git;删除即丢失数据,改动前先确认 | + +## 分层职责规范(硬性要求) + +严格分层 `controller → service → dao`,禁止跨层调用(controller 禁止直接调 dao)。 + +| 层 | 目录 | 职责 | 禁止 | +|---|---|---|---| +| controller | server/styleagent/controller | 接收 dto 请求参数(依赖 DTO `v` tag 自动校验)调用 service,原样返回 service 结果(返回类型与 service 一致,即 dto);**传参方式:整个 `*dto.XxxReq` 直接传给 service,禁止从 dto 拆出多个属性逐个传参** | 直接调用 dao;任何组装/映射/字段搬运;手写业务规则校验(库表依赖/跨字段,应下沉 service);文件 IO;状态流转;跨表数据组装 | +| service | server/styleagent/service | 业务逻辑:规则校验、文件读写、事务、跨表组装、调用 dao;只允许返回 dto 类型(返回与 controller 输出一致的 `*dto.XxxRes`),派生值(如 scene_name/node_count)在 service 用 dto 组装 | 直接写 HTTP 响应(例外见下);返回裸 gdb.Record 或任何非 dto 类型 | +| dao | server/styleagent/dao | 构建 SQL 并执行;行→结构体转换在 dao 内部用 GoFrame 自带方法(`Record.Struct` / `Result.Structs`,按 `orm` tag)完成,对外只允许返回 entity(或单值如 int/map) | 业务逻辑;返回裸 gdb.Record——**裸 gdb.Record 不允许作为任何分层方法的返回值**(含 service 内事务读),转换只发生在 dao 内部,不外泄 | + +**例外**:SSE 流式响应、HTML/文件导出等"直接写响应体"的场景由 controller 完成——这是"值返回"的流式形式,事件序列化、心跳属 HTTP 协议职责,保留在 controller。 + +**分层锚定原则(controller 反向锚定,防跑偏)**:controller 只透传 ⇒ 接口返回类型以 dto 为准 ⇒ service 返回类型被 dto 锁死 ⇒ dao 输出被 entity 锁死。任何一层若出现"为下一层做数据搬运"(controller 映射 service 结果、service 逐键取裸 Record 字段),即违反本原则,应向上收敛:转换在 dao 内部(Record→entity)、组装在 service(entity→dto)、透传在 controller。 + +**教训(此前偏离原因,开发时引以为戒)**: +1. 自定义中转层不得替代 dto:domain 只收"不进 HTTP 出入参"的纯领域值(如结算输入 SettleState/结算结果 FinalSettle);凡出现在接口出入参中的类型一律用 dto,由 service 直接产出——否则 controller 被迫承担映射,违反"controller 只透传" +2. dao 必须完成行→结构体转换:裸 gdb.Record 的 string 键取值是魔法值,拼错列名编译期不报错,且会把键取值扩散到 service/controller;GoFrame 自带 `Record.Struct`/`Result.Structs`(按 `orm` tag)即转换手段 +3. 新分层先对齐框架惯例:GoFrame 原生分层即 dao 转 entity / service 返回 dto / controller 薄透传,自定义设计前先核对框架默认范式 + +## 分层文件对齐与代码模式(硬性要求) + +- 每张业务表对应一组 `entity / dao / service / controller / dto` 文件,数量严格对齐(核验方式:每层目录文件数 = 分层表数,分层表数 = 总表数 − 豁免表数);虚拟表(向量 vec0 / FTS5)与**流水/记录类表(如 point_log)豁免分层对齐**:不建任何独立分层文件(含 entity/dao),建表由主表 dao 统一管理(同虚拟表模式),由使用方 service 事务内直写,禁止为只写不读的审计表造分层门面;**无 HTTP 出入口的表(纯内部配置、聚合数据、状态聚合等)豁免 controller/dto 分层**:entity/dao/service 保留,数据经所属主表端点透出(如详情页聚合子表数据),禁止造无路由的空壳 controller/dto 门面;无任何读写引用的死表连表带分层整套删除,启动时 DROP 库内残留表与代码保持一致 +- **非表文件一律不进业务分层目录**:路由注册与中间件装配写在 `main.go`(公共中间件与 RouteRegister 在 `common/http.go`);建表在 dao `init()`(CREATE TABLE IF NOT EXISTS + 索引 + 迁移),死表 DROP 由启动时与代码对齐;鉴权等跨模块通用能力放 `common/`;跨表业务流程归入所属表文件(如闯关 Choose 属 level 表)——分层目录出现非表文件即违反对齐,禁止以非表名开独立分层文件 +- **不建 parser/rag 等技术目录**:纯技术能力(文档解析、中文分词、向量序列化)平铺在 `common/`;业务编排(分块、检索、工作流)归入对应 service 文件 +- entity:每文件一张表,`orm` 标签与列名一致,时间字段用 `*gtime.Time`,只做表映射 +- dto:请求/响应结构,`g.Meta` 内嵌定义路由;只描述 HTTP 出入参,不承载领域逻辑 +- **domain(目录 `biz/model/domain/`,package domain)**:仅收纳**不进 HTTP 出入参**的纯领域值(如结算输入 SettleState/结算结果 FinalSettle,service 内部流转 + 单测使用);判断标准:类型是否出现在接口出入参中——出现即用 dto。entity 对表、dto 对 HTTP、domain 对纯领域;service 返回 dto(允许 import dto),dao 返回 entity(禁止外泄 gdb.Record) +- dao:单例 `var Xxx = &xxxDao{}`,`init()` 内 `CREATE TABLE IF NOT EXISTS` + 索引 + 迁移;通用 CRUD 复用 `common/base_dao.go`(InsertAndReturnId / GetOneByPk / UpdateByPk) +- controller:路由**反射注册**(`common.RouteRegister`,按 controller 结构体名转 kebab-case 组前缀),path/method 唯一来源为 dto 的 `g.Meta`(携带 path/method/summary);新增接口只需写 dto + controller 方法,禁止在 main.go 手动逐条注册;跨组方法以 `H5` 前缀命名(h5 组只注册 H5 开头方法,admin 组跳过);直接写响应体的例外场景(如支付回调裸文本 "success")注册在 controller 内并注释说明 +- **接口只允许 GET / POST**:写操作传 JSON body(或 multipart),读操作走 query params;无 PUT/DELETE +- dao 查询缓存:查询用 `gdb.CacheOption`(TTL 来自配置),**写操作后必须清对应缓存**,否则出现"库里已改、查询还是旧值" + +## 错误处理规范(硬性要求) + +- 所有可能失败的调用必须显式处理返回的 error:向上返回(保留上下文用 `gerror.Wrap`/`Newf`)或记录日志,禁止 `_, _ =` 静默丢弃——吞错会掩盖故障根因,修复问题必须先定位错误路径,不得以忽略 error 换取编译通过 +- defer 关闭等无法向上返回的资源清理错误,用 `defer func() { _ = x.Close() }()` 显式声明忽略意图,禁止裸 `defer x.Close()` 隐式吞错 + +## 并发规范 + +- **可并行的场景**:纯 IO 任务——读查询、LLM/Embedding 调用、文件读取。SQLite 写一律回主 goroutine 串行(无 WAL 时并发写会 `database is locked`,锁定风险归零,并发只赢在 IO 等待上) +- **新增并行点的固定三处**:common 加池封装(grpool)→ `biz/consts` 加默认大小 → `config.yml` 加 `key: 并发度`(缺失或非法时回退默认值) +- 禁止直接用裸 `go` 启动并行工作负载,一律走 `common` 的池 +- **防死锁**:等待链单向「主 → A池 → B池」,被等待池的任务内不得再等待任何池(会饿死 worker);池无 Wait 方法,等待用调用方 `sync.WaitGroup`,任务结果经 buffered channel 回主 goroutine +- **共享状态安全(内存)**:池内任务并发执行,共享实例(service 单例、model 句柄等)只允许**只读**访问;可变字段必须在**提交池之前**由主 goroutine 一次性预置,任务内禁止写共享字段——Go map 并发写直接 `fatal error: concurrent map writes`,无锁、无降级、不可恢复,只能崩溃重启。需要可变共享状态时按优先级:① 无共享(任务内新建、buffered channel 传递结果)② 锁(`sync.Mutex`/`RWMutex`,锁内只做内存操作,LLM/DB 等 IO 放锁外)③ `sync/atomic`(仅限 int 类标量计数/标志,如 `atomic.AddInt64`,并发计数禁用普通 `++`;复合结构不要用 atomic,指针 CAS 属例外) +- **锁的使用**:互斥场景唯一入口是 `common.WithLock[T any](ctx, key, expire, retries, retryInterval, fn func() (T, error)) (T, error)`——泛型回调,业务返回值经 T 原样透出给下游;内部按 config.yml 自动选择锁实现(配置了 `redis` 节点 → redis 锁,跨实例互斥,SET NX EX + token 对比删除防误删他人锁;未配置 → gcache 内存锁,单实例互斥),禁止直接用 gcache/gredis 自己实现加锁。拿不到锁(被占用,`ErrLockHeld`)最多重试 `retries` 次、每次间隔 `retryInterval`(`retries=0` 立即失败;ctx 取消/超时同样终止等待);中间件故障不重试直接返回。锁自动释放:无论 fn 成功、失败还是 panic,defer 释放。`expire` 必须 > 0(进程崩溃兜底不死锁),fn 耗时必须在 expire 前完成,fn 内禁止长耗时 IO(LLM/DB 调用);锁粒度按业务唯一键尽量小 +- **裸 `go` 允许的例外**:`go func(){ wg.Wait(); close(ch) }()` 收尾惯用法、SSE 心跳、流式管道(Stream 读写)等长生命周期/非工作负载协程 + +## 文档职责(三文档体系) + +| 文档 | 职责 | 何时补充/更新 | +|---|---|---| +| CLAUDE.md(本文件) | 公司通用开发规范:分层职责、代码模式、并发/事务/缓存约束、流程 | 规范变化时 | +| README.md | 项目功能介绍:架构、数据流、表清单、功能模块、API 清单、使用说明 | 功能增减时 | +| 技术设计.md | 实现细节与技术决策:DDL、检索参数、风险与备选方案 | 关键技术决策/参数变化时 | + +## 开发流程(文档驱动,硬性要求) + +永远以文档驱动开发:用户提出开发需求 → 先给出实现方案(技术选型、影响面、改动清单) → **用户确认后先补充文档再动手写代码**。补充哪个文档取决于内容性质:规范 → 本文件,功能 → README,实现细节/技术决策 → 技术设计.md。禁止未经确认直接开发,禁止先写代码后补文档。 + +## 数据访问规范(硬性要求) + +- **事务**:涉及多张表的增删改操作必须包数据库事务,禁止逐表裸调用。**事务必须在 service 层**(`g.DB().Transaction` 包裹与事务内写方法如 `XxxInTx`,service 持有 tx 句柄编排多表),dao 层只做单表无状态 CRUD,不持事务;`tx.Begin` 后必须用 `defer` 防护已提交后的二次 Rollback +- **SQL 单表约束**:每个 SQL 只允许访问一张表,禁止 JOIN 与跨表子查询(`IN (SELECT ...)` / `EXISTS`);跨表数据一律拆为多条单表 SQL + 应用层内存组装——先取外键 id 列表,再对目标表 `IN` 查询;`IN` 参数须按 ≤100 分批(SQLite 变量数上限 999) +- **禁止 N+1 查询**:禁止在循环中逐条查库。循环场景一律改为批处理——一次 `ListByXxx` 取回后按外键在内存分组 +- **缓存一致性**:DAO 查询走缓存(TTL 来自 `database.cache.ttl`),写操作后必须清对应缓存 +- **批处理 SQL**:批量写入用 `InsertAll` 类方法,批量删除用 `IN` 子句,禁止循环单条 INSERT/DELETE +- **配置即使用**:config.yml 中出现 redis / mq 等中间件配置时,代码必须实际接入使用,禁止"配置了但代码不用"或"代码写死但配置缺失" +- **消息/回调幂等**:接入 MQ / Webhook 时,消费与回调处理必须幂等——MQ 至少一次语义、webhook 失败重试都可能重复投递同一事件,禁止依赖"只投一次"假设。幂等手段:以业务唯一键(如 `任务ID + 事件类型`)先查重或建唯一约束再落库,重复事件直接忽略;重试与补偿逻辑同样要防重复执行 + +## 运维部署规范(硬性要求) + +- **部署形态**:Docker 单镜像部署,前后端一体单端口(8080);运行时数据目录挂载持久化,容器重建不丢数据。部署文件为 `server/Dockerfile`,启动与使用见 README +- **数据即文件**:文件型存储,备份 = 打包「数据库目录(小而关键)+ 上传文件目录(大而可重建)」两个目录;迁移 = 拷贝到新机器即可 +- **运行时数据与代码分离**:数据目录不提交 git;**删除即丢数据,改动前先确认** +- **配置即文件**:项目配置文件为唯一配置入口(监听端口、并发度等),环境变量覆盖无效 + +## 金额单位规范(硬性要求) + +- **金额一律以「分」为单位存储与传输**:所有金额字段(零售价、进价、单价、小计、总金额)在数据库与接口中使用整数分(entity/dto 字段类型 `int64`),禁止浮点元 +- **展示转换在前端**:前端展示 ÷100 转元保留两位小数,提交 ×100 转分;禁止后端做元↔分转换,后端只处理整数分 +- **金额计算**:`数量(浮点克) × 单价(分)` 后必须 `common.RoundInt` 四舍五入到整数分,禁止裸浮点累加 +- **量纲区分**:数量/库存/用量为浮点克(REAL),金额为整数分;混算时必须显式转换 +- 存量库迁移以 `PRAGMA user_version` 版本化标记,禁止重复执行 + +## 约定 + +- controller 方法签名固定为 `(ctx, *dto.XxxReq) (*dto.XxxRes, error)`,实例注册模式 `var Xxx = &xxx{}` +- **参数校验优先用 GoFrame DTO 校验**:请求结构体用 `v` tag(required / regex / in 等)声明,框架自动校验并返回错误,controller 不手写校验;仅 DTO 表达不了的业务规则(跨字段依赖、查库校验如重名、取值范围依赖配置)放 service。JSON 格式解析可留在 controller 或下沉 service,但须保持与调用点一致 +- 响应组装(实体 → DTO 字段映射)在 service 进行,controller 只透传 +- service 方法签名 ctx 开头,错误统一用 `gerror` +- 编译验证:`go build ./...` +- **H5 客户侧文案合规(硬性)**:H5 页面与微信模板消息面向终端消费者,软件方无医疗资质,禁止出现 诊所/开方/处方/药方/医嘱/药品/服药/诊疗 等医疗行为用语——暗示诊疗即违规;一律用中性话术:「健康打卡」「调理」「饮食禁忌提醒」「联系服务机构」等;登录页与浏览器标题等可被搜索引擎收录的对外表面保持中性品牌名 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a23634 --- /dev/null +++ b/README.md @@ -0,0 +1,200 @@ +# 我的形象穿搭(slogan) + +AI 形象穿搭应用:用户上传个人照片与服装照片,指定日期地点后由大模型生成穿搭方案(含发型与场合建议),支持 3D 化身查看、效果图生成、CPS 联盟商品推荐与会员订阅。 + +## 技术栈与架构 + +- **前端** `app-uni/`:uni-app (Vue 3 + Vite + Pinia) 多端工程(Android/iOS/微信小程序/H5),H5 构建产物由后端托管 +- **后端** `server/`:Go 1.22+ / GoFrame v2 / SQLite / JWT +- 依赖服务:OpenAI 兼容大模型(穿搭方案生成)、通义万相(效果图出图)、Tripo(图像转 3D 化身)、和风天气 + 高德地理编码、虎皮棋聚合支付、美团/京东/淘宝 CPS 联盟 +- **部署**:Docker 单镜像,前后端一体单端口 **8080**(`server/Dockerfile`) + +``` +app-uni/ ──H5 构建产物──▶ server 静态托管(:8080) +server/ ──▶ 4 个 SQLite 库(data/)+ 文件存储(workspace/) +``` + +## 快速开始 + +```bash +# 后端(server/ 目录) +go mod tidy +go build -o slogan-agent . +./slogan-agent # 监听 :8080,首次启动自动建库建表(server/data/ 下 4 个 SQLite) + +# 前端 H5 开发(app-uni/ 目录,BASE_URL 指向 http://127.0.0.1:8080) +npm install +npm run dev:h5 +``` + +- 测试账号:`wenwu901` / `123456`(登录页自带「测试账号一键登录」按钮) +- OpenAPI 文档:`http://127.0.0.1:8080/api.json` +- 联调与测试规范:**必须使用真实用户数据**(测试账号),禁止用临时注册新账号验证业务链路 + +## 配置说明(server/config.yml) + +| 配置节 | 说明 | +|---|---| +| `database.*` | 4 个 SQLite 分组(default/plan/pay/cps),落盘 `data/`;`cache.ttl` 查询缓存秒数 | +| `server.address` | 监听地址(:8080);`clientMaxBodySize` 上传大小上限 | +| `llm` | 大模型(OpenAI 兼容),未配置时生成任务失败并返回明确错误 | +| `imagegen` | 效果图供应商:`wanx`(通义万相,需 `wanx_api_key`) | +| `weather.geo` | 和风天气 v7 Key + 高德地理编码 Key,未配置时穿搭生成跳过天气/地理推荐 | +| `payment` | 虎皮棋聚合支付(`xunhu_appid/appsecret` 为空则支付功能降级关闭) | +| `avatar` | Tripo 3D 生成(`tripo_api_key` 为空则 /avatar/build 返回失败提示) | +| `render` | 化身帧序列预渲染(Node + headless-gl) | +| `cps` | 美团/京东/淘宝联盟(key 全空则联盟入口优雅降级隐藏) | +| `ad` | 广告激励限频(自然日):`effect_extra` 2 次 / `vip_trial` 1 次 | + +> 注:`wanx_api_key` 与 `llm.api_key` 为真实 Key 占位,提交前请勿携带真实密钥。 + +## 数据库(4 库 22 表) + +### slogan.db(default,用户域) +| 表 | 用途 | +|---|---| +| slogan_user | 用户账号(username/phone/password/role) | +| slogan_user_photo | 用户照片(type: 1 大头照 / 2 全身正面 / 3 全身侧面 / 4 全身背面) | +| slogan_wardrobe_item | 衣橱单品(分类/季节/风格标签/颜色) | +| slogan_body_measurement | 身形参数(身高/体重/三围/肩宽/肤色) | +| slogan_avatar_model | 3D 化身(Tripo 构建状态 + GLB/帧序列 URL) | +| slogan_scoring_rule | 方案评分规则(5 维权重,可配置) | +| slogan_partner_store | 合作门店(形象设计/服装门店) | + +### slogan_plan.db(plan,穿搭域) +| 表 | 用途 | +|---|---| +| slogan_hairstyle_asset | 发型资产库(GLB + 预览图,seed 8 款) | +| slogan_outfit_generation_task | 穿搭生成任务(状态机 pending→planning→scoring→rendering→done/failed) | +| slogan_outfit_plan | 穿搭方案(日期地点/标题/来源/评分/主方案标记/发型引用) | +| slogan_plan_outfit_item | 方案穿衣清单(slot 槽位,来源:衣橱 or AI 新品) | +| slogan_plan_effect_image | 方案效果图(角度: front/side/back,万相出图) | +| slogan_plan_review | 方案反馈(fav/unfav) | + +### slogan_pay.db(pay,支付域) +| 表 | 用途 | +|---|---| +| slogan_user_member | 用户会员(套餐/到期时间) | +| slogan_member_plan | 会员套餐(金额分/时长/权益,可配置) | +| slogan_payment_order | 支付订单(渠道/状态/回调原始报文) | +| slogan_pay_notify_log | 支付回调日志(幂等落库) | +| slogan_ad_reward_log | 广告激励领取记录(限频) | + +### slogan_cps.db(cps,联盟域) +| 表 | 用途 | +|---|---| +| slogan_cps_category | 联盟统一分类树(三源归一) | +| slogan_cps_product | 联盟商品池(美团/京东/淘宝,金额单位:分) | +| slogan_cps_click_log | 商品点击/转链记录 | +| slogan_scene_category_map | 业务场景 → 联盟分类映射(发型/买同款/升级款/延伸优惠/会员权益) | + +## 功能模块与接口(35 个) + +统一响应格式 `{"code":0,"message":"OK","data":...}`,`code != 0` 为业务错误;除公开接口外需 `Authorization: Bearer `(JWT,7 天有效)。 + +| 模块 | 接口 | 说明 | 公开 | +|---|---|---|---| +| 用户 | `POST /user/register` | 注册 | 是 | +| 用户 | `POST /user/login` | 登录返回 token | 是 | +| 用户 | `POST /user/change-password` | 修改密码 | | +| 用户 | `GET /user/profile` | 我的资料 | | +| 照片 | `POST /user-photo/upload` | 上传照片 | | +| 照片 | `GET /user-photo/list` | 照片列表(type 筛选) | | +| 照片 | `POST /user-photo/delete` | 删除照片 | | +| 衣橱 | `POST /wardrobe/upload` | 上传服装(multipart: file + category/season/style_tags/color_info) | | +| 衣橱 | `GET /wardrobe/list` | 衣橱列表 | | +| 衣橱 | `POST /wardrobe/update` | 更新服装信息 | | +| 衣橱 | `POST /wardrobe/delete` | 删除服装 | | +| 身形 | `POST /body-measurement/save` | 保存身形参数(身高/体重/三围/肩宽/肤色) | | +| 身形 | `GET /body-measurement/get` | 查询身形参数 | | +| 化身 | `POST /avatar/build` | 构建 3D 化身(Tripo 图像转 3D,异步) | | +| 化身 | `GET /avatar/get` | 我的化身(GLB/帧序列/构建状态) | | +| 发型 | `GET /hairstyle/list` | 发型资产库 | 是 | +| 穿搭 | `POST /outfit/generate` | 生成穿搭方案(异步任务) | | +| 穿搭 | `GET /outfit/task/status` | 任务状态 | | +| 穿搭 | `GET /outfit/plan/list` | 方案列表 | | +| 穿搭 | `GET /outfit/plan/detail` | 方案详情(穿衣清单 + 发型 + 效果图) | | +| 穿搭 | `POST /outfit/plan/select-main` | 选定主方案(触发 3 视角效果图生成) | | +| 穿搭 | `POST /outfit/plan/review` | 方案反馈 | | +| 门店 | `GET /partner-store/list` | 合作门店(type: 0 全部 / 1 形象设计 / 2 服装门店) | | +| 会员 | `GET /member/plan/list` | 会员套餐列表 | | +| 会员 | `GET /member/status` | 我的会员状态(含剩余广告权益) | | +| 会员 | `POST /member/order/create` | 创建支付订单 | | +| 会员 | `GET /member/order/status` | 订单状态(轮询) | | +| 会员 | `POST /member/order/notify` | 支付回调(裸文本 "success",幂等) | 是 | +| 广告 | `POST /ad/reward/claim` | 领取广告激励(看视频领效果图次数/体验会员,自然日限频) | | +| CPS | `GET /cps/category/list` | 统一分类列表 | | +| CPS | `GET /cps/product/list` | 商品池分页列表(分类/城市/场景筛选) | | +| CPS | `GET /cps/my/recent` | 我的优惠记录(点击去重) | | +| CPS | `POST /cps/product/link` | 商品转链(返回可打开链接) | | +| CPS | `GET /cps/plan/recommend` | 方案驱动推荐(延伸优惠) | | +| CPS | `GET /cps/wardrobe/upgrade` | 衣橱升级款 | | +| 静态 | `GET /workspace/*` | 上传文件与模板资产(鉴权放行) | | + +## 核心业务流程 + +### 穿搭生成(outfit/generate) +``` +pending → planning(天气获取 → 规则预筛 3 套候选 → LLM 规划 1 次调用) + → scoring(规则引擎 5 维评分:天气 25/场合 25/色彩 20/完整度 20/风格 10,阈值 75) + → 全低分 → LLM 兜底创作(1 次调用,recommend 方案) + → 落库 outfit_plan + plan_outfit_item → done +``` +- 衣橱不足 3 件、日期倒挂、Key 未配置等均在任务结果中返回明确错误 +- 服务重启时未完成任务标记 failed(避免重复消耗模型费用) + +### 效果图 +- 选定主方案后异步生成 正面/侧面/背面 3 张(通义万相 wan2.7-image-pro),内容 hash 缓存 24h,每日限 3 次(`scoring_rule` 表 `effect_limit` 维度) +- 效果图次数可通过广告激励补充(`/ad/reward/claim`,`effect_extra` 自然日 2 次) + +### 3D 化身 +- 基于三视角全身照(正面/侧面/背面)→ Tripo 图像转 3D 生成 GLB → 本地 Node(headless-gl)渲染 36 帧旋转预览 +- 构建为异步任务,`GET /avatar/get` 轮询状态;服务重启未完成任务标记失败 + +### 会员支付 +- 套餐下单 → 虎皮棋聚合支付(alipay/wechat)→ `pay_url` 拉起支付 → 前端轮询订单状态 → 回调 `/member/order/notify` 幂等更新会员 +- `xunhu_appid/appsecret` 未配置时支付功能降级关闭 + +### CPS 联盟 +- 美团/京东/淘宝三联盟商品定时同步(`cps.sync_cron`,默认每日 4 点),分类归一到 `slogan_cps_category` +- 业务场景映射(发型卡/买同款/到店试穿/延伸优惠/找升级款/会员权益)→ 场景推荐接口 +- key 全空时联盟入口优雅降级隐藏 + +## 目录结构 + +``` +server/ + main.go 入口:路由注册 + workspace 静态服务 + 任务恢复 + CPS 定时同步 + common/ 通用层:HTTP/RouteRegister/JWT 鉴权/DAO 基类/缓存/锁/协程池/DB 组访问器 + styleagent/ + controller/ Controller 层(反射路由,struct 名 → kebab-case URL) + service/ 业务层(生成编排/化身/衣橱/效果图/支付/会员/CPS/广告) + dao/ 每表一 DAO(init 自动建表 + 索引 + seed) + model/entity/ 实体(与表一一对应) + model/dto/ 请求/响应结构(g.Meta 定义路由) + agent/ LLM 调用(OpenAI 兼容)+ 方案规划/兜底 + 万相出图 + scoring/ 规则评分引擎(零 LLM 成本) + weather/ 和风天气 + 高德地理编码 + avatar/ Tripo 3D 客户端 + GLB 帧渲染 + cps/ 三联盟客户端(美团/京东/淘宝) + consts/ 常量(表名/状态/数据库组) + scripts/ avatar-render(GLB 帧渲染,Dockerfile 依赖) +app-uni/ uni-app (Vue3) 多端工程 +``` + +## 部署 + +```bash +docker build -t slogan-agent server/ +docker run -d -p 8080:8080 \ + -v /data/slogan/data:/app/data \ # 数据库目录(小而关键) + -v /data/slogan/workspace:/app/workspace \ # 上传/生成文件(大而可重建) + slogan-agent +``` + +- **备份 = 打包 `data/`(数据库)+ `workspace/`(文件)两个目录**;迁移 = 拷贝到新机器 +- 生产部署前在 config.yml 填写 llm/weather/geo/imagegen/payment/avatar/cps 的真实 Key + +## 开发规范 + +分层职责(controller → service → dao)、并发/事务/缓存/金额单位等硬性约束见根目录 **CLAUDE.md**;实现细节与技术决策见 **技术设计.md**。 diff --git a/app/.gitignore b/app/.gitignore deleted file mode 100644 index 03b86d2..0000000 --- a/app/.gitignore +++ /dev/null @@ -1,9 +0,0 @@ -# Flutter/Dart -.dart_tool/ -build/ -.flutter-plugins -.flutter-plugins-dependencies -*.iml -.idea/ -# 系统 -.DS_Store diff --git a/app/.metadata b/app/.metadata deleted file mode 100644 index ea0a198..0000000 --- a/app/.metadata +++ /dev/null @@ -1,45 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "058e0af2c2b57e369d905a03ac9748b0ebf543c6" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: android - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: ios - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: linux - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: macos - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: web - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - platform: windows - create_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - base_revision: 058e0af2c2b57e369d905a03ac9748b0ebf543c6 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/app/README.md b/app/README.md deleted file mode 100644 index 8ddca5b..0000000 --- a/app/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# slogan_app - -A new Flutter project. - -## Getting Started - -This project is a starting point for a Flutter application. - -A few resources to get you started if this is your first Flutter project: - -- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) -- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) - -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. diff --git a/app/analysis_options.yaml b/app/analysis_options.yaml deleted file mode 100644 index 0d29021..0000000 --- a/app/analysis_options.yaml +++ /dev/null @@ -1,28 +0,0 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. -# -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. - -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. -include: package:flutter_lints/flutter.yaml - -linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at https://dart.dev/lints. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. - rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options diff --git a/app/android/.gitignore b/app/android/.gitignore deleted file mode 100644 index be3943c..0000000 --- a/app/android/.gitignore +++ /dev/null @@ -1,14 +0,0 @@ -gradle-wrapper.jar -/.gradle -/captures/ -/gradlew -/gradlew.bat -/local.properties -GeneratedPluginRegistrant.java -.cxx/ - -# Remember to never publicly share your keystore. -# See https://flutter.dev/to/reference-keystore -key.properties -**/*.keystore -**/*.jks diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts deleted file mode 100644 index 0346753..0000000 --- a/app/android/app/build.gradle.kts +++ /dev/null @@ -1,45 +0,0 @@ -plugins { - id("com.android.application") - // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. - id("dev.flutter.flutter-gradle-plugin") -} - -android { - namespace = "com.slogan.slogan_app" - compileSdk = flutter.compileSdkVersion - ndkVersion = flutter.ndkVersion - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.slogan.slogan_app" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = flutter.minSdkVersion - targetSdk = flutter.targetSdkVersion - versionCode = flutter.versionCode - versionName = flutter.versionName - } - - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") - } - } -} - -kotlin { - compilerOptions { - jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 - } -} - -flutter { - source = "../.." -} diff --git a/app/android/app/src/debug/AndroidManifest.xml b/app/android/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 399f698..0000000 --- a/app/android/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index d01b264..0000000 --- a/app/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - diff --git a/app/android/app/src/main/kotlin/com/slogan/slogan_app/MainActivity.kt b/app/android/app/src/main/kotlin/com/slogan/slogan_app/MainActivity.kt deleted file mode 100644 index b68d5d5..0000000 --- a/app/android/app/src/main/kotlin/com/slogan/slogan_app/MainActivity.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.slogan.slogan_app - -import io.flutter.embedding.android.FlutterActivity - -class MainActivity : FlutterActivity() diff --git a/app/android/app/src/main/res/drawable-v21/launch_background.xml b/app/android/app/src/main/res/drawable-v21/launch_background.xml deleted file mode 100644 index f74085f..0000000 --- a/app/android/app/src/main/res/drawable-v21/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/app/android/app/src/main/res/drawable/launch_background.xml b/app/android/app/src/main/res/drawable/launch_background.xml deleted file mode 100644 index 304732f..0000000 --- a/app/android/app/src/main/res/drawable/launch_background.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - diff --git a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index db77bb4..0000000 Binary files a/app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ diff --git a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index 17987b7..0000000 Binary files a/app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ diff --git a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index 09d4391..0000000 Binary files a/app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ diff --git a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index d5f1c8d..0000000 Binary files a/app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png deleted file mode 100644 index 4d6372e..0000000 Binary files a/app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ diff --git a/app/android/app/src/main/res/values-night/styles.xml b/app/android/app/src/main/res/values-night/styles.xml deleted file mode 100644 index 06952be..0000000 --- a/app/android/app/src/main/res/values-night/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/app/android/app/src/main/res/values/styles.xml b/app/android/app/src/main/res/values/styles.xml deleted file mode 100644 index cb1ef88..0000000 --- a/app/android/app/src/main/res/values/styles.xml +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - diff --git a/app/android/app/src/profile/AndroidManifest.xml b/app/android/app/src/profile/AndroidManifest.xml deleted file mode 100644 index 399f698..0000000 --- a/app/android/app/src/profile/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/android/build.gradle.kts b/app/android/build.gradle.kts deleted file mode 100644 index dbee657..0000000 --- a/app/android/build.gradle.kts +++ /dev/null @@ -1,24 +0,0 @@ -allprojects { - repositories { - google() - mavenCentral() - } -} - -val newBuildDir: Directory = - rootProject.layout.buildDirectory - .dir("../../build") - .get() -rootProject.layout.buildDirectory.value(newBuildDir) - -subprojects { - val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) - project.layout.buildDirectory.value(newSubprojectBuildDir) -} -subprojects { - project.evaluationDependsOn(":app") -} - -tasks.register("clean") { - delete(rootProject.layout.buildDirectory) -} diff --git a/app/android/gradle.properties b/app/android/gradle.properties deleted file mode 100644 index e96108c..0000000 --- a/app/android/gradle.properties +++ /dev/null @@ -1,6 +0,0 @@ -org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError -android.useAndroidX=true -# This newDsl flag was added by the Flutter template -android.newDsl=false -# This builtInKotlin flag was added by the Flutter template -android.builtInKotlin=false diff --git a/app/android/gradle/wrapper/gradle-wrapper.properties b/app/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 2d428bf..0000000 --- a/app/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip diff --git a/app/android/settings.gradle.kts b/app/android/settings.gradle.kts deleted file mode 100644 index c21f0c5..0000000 --- a/app/android/settings.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -pluginManagement { - val flutterSdkPath = - run { - val properties = java.util.Properties() - file("local.properties").inputStream().use { properties.load(it) } - val flutterSdkPath = properties.getProperty("flutter.sdk") - require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } - flutterSdkPath - } - - includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id("dev.flutter.flutter-plugin-loader") version "1.0.0" - id("com.android.application") version "9.0.1" apply false - id("org.jetbrains.kotlin.android") version "2.3.20" apply false -} - -include(":app") diff --git a/app/docs/superpowers/plans/2026-07-31-commerce-p0-app.md b/app/docs/superpowers/plans/2026-07-31-commerce-p0-app.md deleted file mode 100644 index 7cdbdc6..0000000 --- a/app/docs/superpowers/plans/2026-07-31-commerce-p0-app.md +++ /dev/null @@ -1,969 +0,0 @@ -# 商业化 P0 实现计划(客户端)· slogan-app - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 会员中心(套餐展示 → 下单 → 系统浏览器支付 → 轮询确认)+ 广告激励入口(Mock 激励视频 → 领取加次/体验会员),后端未配置时接口报错自动隐藏充值入口。 - -**Architecture:** /commercial 页重构为会员中心(home 第 4 Tab 与路由都指向它)。新增 `lib/core/ads/`(AdsService 抽象 + Mock 实现,P1 换穿山甲)、`lib/features/member/`(provider + 会员中心页 + 支付页)。支付用 `url_launcher` 打开系统浏览器,`/pay` 页 2s 轮询订单状态(60s 上限),成功后刷新会员状态。iOS 端隐藏充值入口(App Store 政策),保留广告激励。 - -**Tech Stack:** Flutter 3.44 / Riverpod 3 / go_router 17 / dio 5 / url_launcher ^6.3.0 - -**关联 spec:** `docs/superpowers/specs/2026-07-31-commerce-monetization-design.md` 支柱 A/B 客户端部分。 - -**验证方式(沿用 MVP 惯例):** `dart analyze`(中文路径下 flutter analyze 崩溃)、`flutter test`、`flutter build web --release` 全量编译、mock 后端冒烟。 - ---- - -## 任务总览与文件映射 - -| 任务 | 文件 | -|---|---| -| T1 | `pubspec.yaml`、`lib/core/ads/ads_service.dart`、`test/features/member/benefits_test.dart` | -| T2 | `lib/features/member/member_provider.dart` | -| T3 | `lib/features/member/member_center_page.dart`(新建)、`lib/features/commercial/commercial_page.dart`(删除)、`lib/features/home/home_page.dart`、`lib/main.dart` | -| T4 | `lib/features/member/pay_page.dart`、`lib/main.dart` 路由 | -| T5 | analyze + test + build + 冒烟 | - ---- - -### Task 1: url_launcher 依赖 + 广告抽象 + 权益文案纯函数(TDD) - -**Files:** -- Modify: `pubspec.yaml` -- Create: `lib/core/ads/ads_service.dart` -- Test: `test/features/member/benefits_test.dart` - -- [ ] **Step 1: 写失败测试**(权益 key → 中文文案;benefitTexts 尚未存在) - -`test/features/member/benefits_test.dart`: -```dart -import 'package:flutter_test/flutter_test.dart'; -import 'package:slogan_app/features/member/member_provider.dart'; - -void main() { - test('benefitTexts 映射已知权益文案,未知 key 原样保留', () { - final info = MemberInfo( - isVip: true, - expireAt: '2026-08-30 12:00:00', - planName: '月卡', - benefits: const ['effect_unlimited', 'unknown_key'], - ); - expect(benefitTexts(info), ['无限效果图', 'unknown_key']); - }); - - test('benefitTexts 空权益返回空列表', () { - final info = MemberInfo( - isVip: false, - expireAt: '', - planName: '', - benefits: const [], - ); - expect(benefitTexts(info), isEmpty); - }); -} -``` - -- [ ] **Step 2: 运行确认失败** - -Run: `dart analyze lib test 2>&1 | head -5` Expected: 报 `member_provider.dart` 不存在 / import 失败 - -- [ ] **Step 3: pubspec 加 url_launcher** - -```yaml - url_launcher: ^6.3.0 -``` - -- [ ] **Step 4: 广告抽象 `lib/core/ads/ads_service.dart`** - -```dart -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -/// 广告服务抽象:P0 用 Mock(保证业务链路可开发可测),P1 换穿山甲 SDK -abstract class AdsService { - bool get enabled; - Future showRewarded(); -} - -/// 本地模拟激励视频(约 1 秒"播放"后返回完整观看) -class MockAdsService implements AdsService { - @override - bool get enabled => true; - - @override - Future showRewarded() async { - await Future.delayed(const Duration(milliseconds: 900)); - return true; - } -} - -final adsServiceProvider = Provider((ref) { - // P1:AppConfig.pangleAppId 非空时替换为 PangleAdsService(穿山甲 SDK 实现) - return MockAdsService(); -}); -``` - -- [ ] **Step 5: 提交** - -```bash -git add pubspec.yaml lib/core/ads test/features/member -git commit -m "feat: 广告服务抽象(Mock 激励视频)+ url_launcher 依赖" -``` - ---- - -### Task 2: member provider(会员状态/套餐/下单/轮询/领奖) - -**Files:** -- Create: `lib/features/member/member_provider.dart` - -- [ ] **Step 1: 实现 member_provider.dart**(含 Task 1 测试依赖的 `MemberInfo` 与 `benefitTexts`) - -```dart -import 'dart:convert'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/network/api_client.dart'; - -class MemberInfo { - final bool isVip; - final String expireAt; - final String planName; - final List benefits; - - const MemberInfo({ - required this.isVip, - required this.expireAt, - required this.planName, - required this.benefits, - }); - - factory MemberInfo.fromJson(Map e) => MemberInfo( - isVip: e['is_vip'] as bool? ?? false, - expireAt: e['expire_at'] as String? ?? '', - planName: e['plan_name'] as String? ?? '', - benefits: (e['benefits'] as List? ?? []).cast(), - ); -} - -/// 权益 key → 文案 -const benefitLabels = { - 'effect_unlimited': '无限效果图', - 'ai_priority': '优先 AI 方案', - 'cps_commission_x15': '返现加成 1.5x', - 'store_discount': '门店折扣', -}; - -List benefitTexts(MemberInfo m) => - m.benefits.map((b) => benefitLabels[b] ?? b).toList(); - -class MemberNotifier extends AsyncNotifier { - @override - Future build() async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/member/status'); - return MemberInfo.fromJson(data ?? {}); - } - - Future refresh() async { - state = await AsyncValue.guard(build); - } - - /// 领取广告激励(服务端限频);adType: effect_extra | vip_trial - /// 返回当日剩余次数;超出限频抛 ApiException - Future claimReward(String adType) async { - final api = ref.read(apiClientProvider); - final data = - await api.post>('/ad/reward/claim', {'ad_type': adType}); - await refresh(); // vip_trial 可能开通体验会员 - return (data?['reward']?['remaining_today'] as num?)?.toInt() ?? 0; - } -} - -final memberProvider = - AsyncNotifierProvider(MemberNotifier.new); - -class MemberPlan { - final int id; - final String name; - final int priceFen; - final int durationDays; - final List features; - - const MemberPlan({ - required this.id, - required this.name, - required this.priceFen, - required this.durationDays, - required this.features, - }); - - factory MemberPlan.fromJson(Map e) => MemberPlan( - id: (e['id'] as num).toInt(), - name: e['name'] as String? ?? '', - priceFen: (e['price_fen'] as num?)?.toInt() ?? 0, - durationDays: (e['duration_days'] as num?)?.toInt() ?? 30, - features: _parseFeatures(e['features'] as String? ?? ''), - ); - - static List _parseFeatures(String s) { - try { - return (jsonDecode(s) as List).cast(); - } catch (_) { - return const []; - } - } - - String get priceText => - '¥${(priceFen / 100).toStringAsFixed(priceFen % 100 == 0 ? 0 : 1)}'; -} - -final memberPlanProvider = FutureProvider>((ref) async { - final api = ref.read(apiClientProvider); - final list = await api.get>('/member/plan/list'); - return (list ?? []) - .map((e) => MemberPlan.fromJson(e as Map)) - .toList(); -}); - -class OrderResult { - final String orderNo; - final String payUrl; - - const OrderResult({required this.orderNo, required this.payUrl}); -} - -/// 创建支付订单(后端调虎皮棋下单,返回收银台 URL) -Future createMemberOrder(WidgetRef ref, int planId) async { - final api = ref.read(apiClientProvider); - final data = - await api.post>('/member/order/create', {'plan_id': planId}); - return OrderResult( - orderNo: data?['order_no'] as String? ?? '', - payUrl: data?['pay_url'] as String? ?? '', - ); -} - -/// 订单状态(支付页 2s 轮询):pending | paid | closed -Future fetchOrderStatus(WidgetRef ref, String orderNo) async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/member/order/status', - query: {'order_no': orderNo}); - return data?['status'] as String? ?? ''; -} -``` - -- [ ] **Step 2: 运行测试 + 静态检查** - -Run: `dart analyze lib/features/member lib/core/ads 2>&1 | tail -3` Expected: 无 issue -Run: `flutter test test/features/member/benefits_test.dart` Expected: 2 个用例 PASS - -- [ ] **Step 3: 提交** - -```bash -git add lib/features/member -git commit -m "feat: 会员 provider(状态/套餐/下单/轮询/领奖)" -``` - ---- - -### Task 3: 会员中心页(commercial 重构)+ 入口切换 - -**Files:** -- Create: `lib/features/member/member_center_page.dart` -- Delete: `lib/features/commercial/commercial_page.dart` -- Modify: `lib/features/home/home_page.dart` -- Modify: `lib/main.dart` - -- [ ] **Step 1: 创建 member_center_page.dart**(完整代码,含会员卡/套餐弹层/广告激励卡/合作门店) - -```dart -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/ads/ads_service.dart'; -import '../../core/config/app_config.dart'; -import '../../shared/widgets/error_view.dart'; -import '../../shared/widgets/loading_view.dart'; -import 'member_provider.dart'; -import 'pay_page.dart'; - -class PartnerStoreInfo { - final int id; - final String name; - final int type; // 1 造型/发型店,2 服装店 - final String address; - final String commissionPolicy; - - const PartnerStoreInfo({ - required this.id, - required this.name, - required this.type, - required this.address, - required this.commissionPolicy, - }); -} - -class StoreNotifier extends AsyncNotifier> { - @override - Future> build() async { - final api = ref.read(apiClientProvider); - final list = await api.get>('/partner-store/list'); - return list - .map((e) => PartnerStoreInfo( - id: (e['id'] as num).toInt(), - name: e['name'] as String? ?? '', - type: (e['type'] as num?)?.toInt() ?? 1, - address: e['address'] as String? ?? '', - commissionPolicy: e['commission_policy'] as String? ?? '', - )) - .toList(); - } - - Future refresh() async { - state = const AsyncLoading(); - state = await AsyncValue.guard(build); - } -} - -final storeProvider = - AsyncNotifierProvider>(StoreNotifier.new); - -const _storeTypeLabels = {1: '造型', 2: '服装'}; - -/// 会员中心:会员状态/套餐充值/广告激励 + 合作门店(P0;最近优惠 P1) -class MemberCenterPage extends ConsumerStatefulWidget { - const MemberCenterPage({super.key}); - - @override - ConsumerState createState() => _MemberCenterPageState(); -} - -class _MemberCenterPageState extends ConsumerState { - int? _typeFilter; - bool _rewarding = false; - - bool get _isIOS => Platform.isIOS; - - Future _openPlans() async { - final plans = await showModalBottomSheet>( - context: context, - builder: (ctx) => const _PlanSheet(), - ); - if (plans == null || !mounted) return; - try { - final result = await createMemberOrder(ref, plans.id); - if (!mounted || result.payUrl.isEmpty) return; - await context.push('/pay', extra: PayArgs(orderNo: result.orderNo, payUrl: result.payUrl)); - ref.read(memberProvider.notifier).refresh(); - } catch (e) { - if (!mounted) return; - Fluttertoast.showToast( - msg: e.toString().replaceFirst('Exception: ', '')); - } - } - - Future _claimReward(String adType, String successMsg) async { - if (_rewarding) return; - final ads = ref.read(adsServiceProvider); - if (!ads.enabled) { - Fluttertoast.showToast(msg: '广告功能暂未开通'); - return; - } - setState(() => _rewarding = true); - try { - final watched = await ads.showRewarded(); - if (!watched) { - Fluttertoast.showToast(msg: '未完整观看,无法领取'); - return; - } - final remaining = - await ref.read(memberProvider.notifier).claimReward(adType); - if (!mounted) return; - Fluttertoast.showToast(msg: '$successMsg(今日剩余 $remaining 次)'); - } catch (e) { - if (!mounted) return; - Fluttertoast.showToast( - msg: e.toString().replaceFirst('Exception: ', '')); - } finally { - if (mounted) setState(() => _rewarding = false); - } - } - - @override - Widget build(BuildContext context) { - final member = ref.watch(memberProvider); - final stores = ref.watch(storeProvider); - final scheme = Theme.of(context).colorScheme; - return ListView( - padding: const EdgeInsets.all(16), - children: [ - _MemberCard( - member: member, - isIOS: _isIOS, - onOpenPlans: _openPlans, - ), - if (member.valueOrNull?.isVip == false) ...[ - const SizedBox(height: 12), - _AdsRewardCard( - rewarding: _rewarding, - onClaim: (adType, msg) => _claimReward(adType, msg), - ), - ], - const SizedBox(height: 16), - Row( - children: [ - Text('合作门店', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), - const Spacer(), - ChoiceChip( - label: const Text('全部'), - selected: _typeFilter == null, - onSelected: (_) => setState(() => _typeFilter = null), - ), - const SizedBox(width: 8), - for (final entry in _storeTypeLabels.entries) ...[ - ChoiceChip( - label: Text(entry.value), - selected: _typeFilter == entry.key, - onSelected: (_) => setState(() => _typeFilter = entry.key), - ), - const SizedBox(width: 8), - ], - ], - ), - const SizedBox(height: 8), - stores.when( - loading: () => const Padding( - padding: EdgeInsets.only(top: 32), child: LoadingView(text: '加载门店...')), - error: (e, _) => Padding( - padding: const EdgeInsets.only(top: 32), - child: ErrorView( - message: e.toString().replaceFirst('Exception: ', ''), - onRetry: () => ref.read(storeProvider.notifier).refresh(), - ), - ), - data: (list) { - final shown = _typeFilter == null - ? list - : list.where((s) => s.type == _typeFilter).toList(); - if (shown.isEmpty) { - return const Padding( - padding: EdgeInsets.only(top: 32), - child: Center( - child: Text('附近暂无可合作门店', - style: TextStyle(color: Colors.grey))), - ); - } - return Column( - children: [ - for (final s in shown) ...[ - Card( - child: ListTile( - leading: CircleAvatar( - backgroundColor: scheme.primaryContainer, - child: Icon( - s.type == 1 ? Icons.content_cut : Icons.checkroom), - ), - title: Text(s.name), - subtitle: Text('${s.address}\n${s.commissionPolicy}'), - isThreeLine: true, - trailing: - const Icon(Icons.chevron_right, color: Colors.grey), - ), - ), - const SizedBox(height: 8), - ], - ], - ); - }, - ), - ], - ); - } -} - -class _MemberCard extends ConsumerWidget { - final AsyncValue member; - final bool isIOS; - final VoidCallback onOpenPlans; - - const _MemberCard( - {required this.member, required this.isIOS, required this.onOpenPlans}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scheme = Theme.of(context).colorScheme; - return Card( - color: scheme.primaryContainer, - child: Padding( - padding: const EdgeInsets.all(16), - child: member.when( - loading: () => const Text('加载会员状态...', - style: TextStyle(fontSize: 13)), - error: (e, _) => Text('会员状态加载失败:$e', - style: const TextStyle(fontSize: 12)), - data: (m) { - if (m.isVip) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Icon(Icons.workspace_premium, size: 30, color: scheme.primary), - const SizedBox(width: 10), - const Text('形象会员', - style: - TextStyle(fontSize: 17, fontWeight: FontWeight.bold)), - const Spacer(), - Chip( - label: Text(m.planName), - labelStyle: - TextStyle(color: scheme.primary, fontSize: 12), - visualDensity: VisualDensity.compact, - ), - ]), - const SizedBox(height: 6), - Text('有效期至 ${m.expireAt}', - style: TextStyle(fontSize: 12, color: scheme.primary)), - if (benefitTexts(m).isNotEmpty) ...[ - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - for (final b in benefitTexts(m)) - Chip( - label: Text(b), - labelStyle: const TextStyle(fontSize: 11), - visualDensity: VisualDensity.compact, - ), - ], - ), - ], - ], - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row(children: [ - Icon(Icons.workspace_premium, size: 30), - SizedBox(width: 10), - Text('形象会员', - style: - TextStyle(fontSize: 17, fontWeight: FontWeight.bold)), - ]), - const SizedBox(height: 6), - const Text('会员专享:无限次效果图生成 · 优先 AI 方案 · 门店专属折扣', - style: TextStyle(fontSize: 12)), - const SizedBox(height: 10), - if (isIOS) - const Text('iOS 端暂不支持充值(App Store 政策),可观看广告获得体验会员', - style: TextStyle(fontSize: 11, color: Colors.grey)) - else - FilledButton.icon( - onPressed: onOpenPlans, - icon: const Icon(Icons.payment, size: 18), - label: const Text('开通会员'), - ), - ], - ); - }, - ), - ), - ); - } -} - -class _AdsRewardCard extends ConsumerWidget { - final bool rewarding; - final void Function(String adType, String msg) onClaim; - - const _AdsRewardCard({required this.rewarding, required this.onClaim}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Card( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('免费获取权益', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.ondemand_video, color: Colors.deepPurple), - title: const Text('看视频 · 效果图 +1'), - subtitle: const Text('每日最多 2 次,次日重置'), - trailing: OutlinedButton( - onPressed: rewarding - ? null - : () => onClaim('effect_extra', '已获得 1 次效果图'), - child: const Text('看视频'), - ), - ), - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.ondemand_video, color: Colors.teal), - title: const Text('看视频 · 体验会员 1 天'), - subtitle: const Text('每日最多 1 次,含无限效果图'), - trailing: OutlinedButton( - onPressed: rewarding - ? null - : () => onClaim('vip_trial', '已获得 1 天体验会员'), - child: const Text('看视频'), - ), - ), - ], - ), - ), - ); - } -} - -class _PlanSheet extends ConsumerWidget { - const _PlanSheet(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final plans = ref.watch(memberPlanProvider); - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text('选择会员套餐', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - plans.when( - loading: () => const Padding( - padding: EdgeInsets.all(24), child: LoadingView()), - error: (e, _) => Text('套餐加载失败:$e', - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.grey)), - data: (list) => list.isEmpty - ? const Padding( - padding: EdgeInsets.all(24), - child: Text('暂未开放套餐', textAlign: TextAlign.center), - ) - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final p in list) - ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8)), - tileColor: Theme.of(context) - .colorScheme - .primaryContainer - .withValues(alpha: 0.5), - title: Text(p.name, - style: const TextStyle( - fontSize: 15, fontWeight: FontWeight.w600)), - subtitle: Text( - '${p.durationDays} 天 · ${p.features.map((f) => benefitLabels[f] ?? f).join(' · ')}', - style: const TextStyle(fontSize: 12), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - trailing: Text(p.priceText, - style: TextStyle( - color: - Theme.of(context).colorScheme.primary, - fontSize: 16, - fontWeight: FontWeight.bold)), - onTap: () => Navigator.pop(context, p), - ), - ], - ), - ), - ], - ), - ), - ); - } -} -``` - -> 注意:`_openPlans` 里 `plans` 变量名与 `memberPlanProvider` 无关;`showModalBottomSheet` 返回选中的套餐。依赖 `AppConfig` 在 import 中(本页未用到可删,`apiClientProvider` 来自 `api_client.dart`,StoreNotifier 里用到——需 import `../../core/network/api_client.dart`)。 - -- [ ] **Step 2: 删除旧页并切换入口** - -```bash -rm lib/features/commercial/commercial_page.dart -``` - -`home_page.dart` 修改:import 换 `../member/member_center_page.dart`,第 4 Tab 用 `MemberCenterPage()`,标题与 label 改为「会员中心」: - -```dart -import '../member/member_center_page.dart'; -// ... -static const _titles = ['我的形象', '我的衣橱', '穿搭方案', '会员中心']; -// ... - MemberCenterPage(), -// ... - NavigationDestination( - icon: Icon(Icons.store_outlined), - selectedIcon: Icon(Icons.store), - label: '会员'), -``` - -- [ ] **Step 3: main.dart /commercial 路由指向新页** - -`lib/main.dart`:`import '../features/member/member_center_page.dart';`,第 78-82 行 `/commercial` 的 builder 改为 `MemberCenterPage()`。 - -- [ ] **Step 4: 静态检查** - -Run: `dart analyze lib 2>&1 | tail -5` Expected: 无 error(可能提示 unused import `app_config.dart`,删掉即可) - -- [ ] **Step 5: 提交** - -```bash -git add lib/features/member/member_center_page.dart lib/features/home/home_page.dart lib/main.dart -git add -u lib/features/commercial -git commit -m "feat: 会员中心页(会员卡/套餐弹层/广告激励/合作门店)" -``` - ---- - -### Task 4: 支付页(轮询确认结果) - -**Files:** -- Create: `lib/features/member/pay_page.dart` -- Modify: `lib/main.dart` - -- [ ] **Step 1: 创建 pay_page.dart** - -```dart -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fluttertoast/fluttertoast.dart'; -import 'package:go_router/go_router.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import 'member_provider.dart'; - -class PayArgs { - final String orderNo; - final String payUrl; - - const PayArgs({required this.orderNo, required this.payUrl}); -} - -enum PayPhase { launching, paying, paid, timeout, failed } - -/// 支付页:打开系统浏览器收银台,2s 轮询订单状态(上限 60s) -class PayPage extends ConsumerStatefulWidget { - final PayArgs args; - - const PayPage({super.key, required this.args}); - - @override - ConsumerState createState() => _PayPageState(); -} - -class _PayPageState extends ConsumerState { - PayPhase _phase = PayPhase.launching; - Timer? _timer; - int _elapsed = 0; - - @override - void initState() { - super.initState(); - _start(); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - Future _start() async { - try { - final ok = await launchUrl(Uri.parse(widget.args.payUrl), - mode: LaunchMode.externalApplication); - if (!ok) { - setState(() => _phase = PayPhase.failed); - return; - } - setState(() => _phase = PayPhase.paying); - } catch (e) { - setState(() => _phase = PayPhase.failed); - return; - } - _timer = Timer.periodic(const Duration(seconds: 2), (_) => _check()); - } - - Future _check() async { - _elapsed += 2; - try { - final status = await fetchOrderStatus(ref, widget.args.orderNo); - if (status == 'paid') { - _timer?.cancel(); - if (!mounted) return; - setState(() => _phase = PayPhase.paid); - Fluttertoast.showToast(msg: '会员开通成功'); - return; - } - if (_elapsed >= 60) { - _timer?.cancel(); - if (!mounted) return; - setState(() => _phase = PayPhase.timeout); - } - } catch (_) { - // 轮询失败不中断,下次再试 - } - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Scaffold( - appBar: AppBar(title: const Text('会员支付')), - body: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - switch (_phase) { - PayPhase.launching || - PayPhase.paying => Column(children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - const Text('请在浏览器中完成支付,正在确认结果…'), - const SizedBox(height: 8), - Text('订单号 ${widget.args.orderNo}', - style: const TextStyle(color: Colors.grey, fontSize: 12)), - const SizedBox(height: 16), - OutlinedButton( - onPressed: () => _check(), - child: const Text('我已完成支付'), - ), - ]), - PayPhase.paid => Column(children: [ - Icon(Icons.check_circle, size: 64, color: scheme.primary), - const SizedBox(height: 12), - const Text('支付成功,会员已开通!', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - FilledButton( - onPressed: () => context.pop(), - child: const Text('返回会员中心'), - ), - ]), - PayPhase.timeout => Column(children: [ - Icon(Icons.hourglass_empty, size: 64, color: Colors.orange), - const SizedBox(height: 12), - const Text('支付结果确认中'), - const SizedBox(height: 8), - const Text('可稍后到会员中心查看开通状态,以支付结果为准', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey, fontSize: 12)), - const SizedBox(height: 16), - OutlinedButton( - onPressed: () => context.pop(), - child: const Text('返回'), - ), - ]), - PayPhase.failed => Column(children: [ - Icon(Icons.error_outline, size: 64, color: scheme.error), - const SizedBox(height: 12), - const Text('无法打开支付页面'), - const SizedBox(height: 16), - OutlinedButton( - onPressed: () => context.pop(), - child: const Text('返回'), - ), - ]), - }, - ], - ), - ), - ), - ); - } -} -``` - -- [ ] **Step 2: main.dart 注册路由** - -```dart - GoRoute( - path: '/pay', - builder: (context, state) => - PayPage(args: state.extra! as PayArgs), - ), -``` - -(import `../features/member/pay_page.dart`) - -- [ ] **Step 3: 静态检查** - -Run: `dart analyze lib 2>&1 | tail -5` Expected: 无 error - -- [ ] **Step 4: 提交** - -```bash -git add lib/features/member/pay_page.dart lib/main.dart -git commit -m "feat: 支付页(系统浏览器收银台 + 2s 轮询确认)" -``` - ---- - -### Task 5: 全量验证与冒烟 - -- [ ] **Step 1: 单元测试** - -Run: `flutter test` Expected: 全部 PASS(含新增 benefits 2 个用例) - -- [ ] **Step 2: 全量编译(web 兜底)** - -Run: `cd /Users/zhangbin/Desktop/d盘/work/slogan/slogan-app && flutter build web --release` Expected: 构建成功(若提示 stale cache,先 `flutter clean && flutter pub get`) - -- [ ] **Step 3: 冒烟(起后端,mock 支付/广告降级路径)** - -后端按后端计划 T9 起服务(不配 mock 时): -```bash -# 会员状态:未登录返回 401;已登录非会员返回 is_vip=false -# 套餐列表:返回 2 个套餐 -# 下单:报"支付未开通" → App 开通按钮 toast 提示,不崩溃 -# 广告:claim 前 2 次成功(remaining 1/0),第 3 次报"今日次数已用完" → toast 展示 -``` - -- [ ] **Step 4: 提交** - -```bash -git add -A -git status # 确认无残留 -git log --oneline -5 -``` - ---- - -## 自检清单 - -- [ ] /commercial(home 第 4 Tab 与路由)指向会员中心,旧 commercial_page 已删除 -- [ ] iOS 隐藏充值入口(Platform.isIOS),广告激励保留 -- [ ] 支付页轮询 2s/60s 上限,paid/timeout/failed 三态完整;返回后刷新会员状态 -- [ ] 广告入口走 adsServiceProvider 抽象,Mock 可用,穿山甲 P1 替换点已注明 -- [ ] 所有后端接口错误 toast 展示 message,不崩溃;未开通时入口不渲染/隐藏 -- [ ] `dart analyze` 无 error、`flutter test` 全过、web 编译成功 - -## 后续计划(P1,不在本计划内) - -方案页三处 CPS 入口(做同款发型/买同款/到店试穿)、/cps-product-list 商品列表、衣橱「找升级款」、最近优惠、穿山甲 SDK 替换 Mock、webview 内嵌收银台。 diff --git a/app/docs/superpowers/plans/2026-07-31-slogan-app-mvp.md b/app/docs/superpowers/plans/2026-07-31-slogan-app-mvp.md deleted file mode 100644 index 0940afc..0000000 --- a/app/docs/superpowers/plans/2026-07-31-slogan-app-mvp.md +++ /dev/null @@ -1,372 +0,0 @@ -# slogan-app MVP 实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 实现 slogan-app Flutter MVP:登录 → 4 Tab 框架 → 拍照上传 → 衣橱管理 → 化身查看 → 穿搭生成(日期/地点 → 轮询 → 方案流 3D+2D 切换查看)。 - -**Architecture:** Flutter 单仓,Riverpod 状态管理,Dio 网络层(JWT 拦截器 + 统一响应),AvatarViewer 渲染抽象层(three_dart v1 实现,flutter_scene 后续替换),核心交互为方案 PageView 横滑 + 3D 化身拖拽旋转。 - -**Tech Stack:** Flutter (stable) / Riverpod / Dio / go_router / three_dart + three_dart_jsm / cached_network_image / camera + image_picker / shared_preferences - -**后端对接:** slogan-agent(Go)API,见 slogan-agent 仓库 `docs/superpowers/specs/2026-07-31-slogan-agent-design.md` 第 11 节路由表。 - ---- - -### Task 1: 项目骨架 - -**Files:** -- Run: `flutter create` 初始化(org 自定,项目名 slogan_app) -- Modify: `pubspec.yaml`(依赖) -- Create: `lib/main.dart`(入口 + 主题 + go_router 路由表) -- Create: `lib/core/config/app_config.dart` - -- [ ] **Step 1: 初始化** - -```bash -cd slogan-app -flutter create --org com.slogan --project-name slogan_app . -``` - -- [ ] **Step 2: pubspec.yaml 依赖** - -```yaml -dependencies: - flutter_riverpod: ^2.6.0 - dio: ^5.7.0 - go_router: ^14.0.0 - cached_network_image: ^3.4.0 - image_picker: ^1.1.0 - camera: ^0.11.0 - shared_preferences: ^2.3.0 - three_dart: ^0.2.0 - three_dart_jsm: ^0.2.0 - fluttertoast: ^8.2.0 -``` - -(版本以 pub.dev 最新稳定为准,`flutter pub add` 逐个添加) - -- [ ] **Step 3: main.dart**:MaterialApp + Theme(Material 3,seed 主色)+ go_router 路由(login / home(4 tab) / photo-guide / plan-flow / plan-detail);启动时读取 token → 无 token 重定向登录页 - -- [ ] **Step 4: 验证** `flutter analyze` 无错误 + `flutter test` 默认通过 - -- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: flutter skeleton with router and theme"` - ---- - -### Task 2: 网络层(ApiClient + JWT 拦截器) - -**Files:** -- Create: `lib/core/network/api_client.dart` -- Create: `lib/core/network/api_exception.dart` -- Create: `lib/core/storage/token_storage.dart` -- Test: `test/core/network/api_client_test.dart` - -- [ ] **Step 1: 写失败测试**(mock Dio adapter:401 触发登出回调;code!=0 抛 ApiException 带 message;成功解析 data) - -- [ ] **Step 2: 确认失败** `flutter test` - -- [ ] **Step 3: 实现 api_client.dart** - -```dart -class ApiClient { - ApiClient({Dio? dio, required TokenStorage tokenStorage}) - : _tokenStorage = tokenStorage { - _dio = dio ?? Dio(BaseOptions( - baseUrl: AppConfig.baseUrl, - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 30), - )); - _dio.interceptors.add(InterceptorsWrapper( - onRequest: (options, handler) { - final token = _tokenStorage.token; - if (token != null) options.headers['Authorization'] = 'Bearer $token'; - handler.next(options); - }, - onError: (e, handler) { - if (e.response?.statusCode == 401) onUnauthorized?.call(); - handler.next(e); - }, - )); - } - - Future post(String path, Map body, - {T Function(dynamic data)? parse}) async { - final res = await _dio.post(path, data: body); - return _unwrap(res, parse); - } - Future get(String path, {Map? query, T Function(dynamic data)? parse}) async { ... } - Future upload(String path, Map fields, String fileField, String filePath, {String Function(dynamic)? parse}) async { ... } - - T _unwrap(Response res, ...) { - final code = res.data['code'] as int; - final message = res.data['message'] as String? ?? ''; - if (code != 0) throw ApiException(code, message); - return parse?.call(res.data['data']) ?? res.data['data'] as T; - } - - VoidCallback? onUnauthorized; -} -``` - -- [ ] **Step 4: TokenStorage**(shared_preferences 封装:token 读写 + 清空) - -- [ ] **Step 5: 测试通过** + `flutter analyze` + **Commit** - ---- - -### Task 3: 认证(登录页 + 状态) - -**Files:** -- Create: `lib/core/auth/auth_provider.dart` -- Create: `lib/features/auth/login_page.dart` -- Test: `test/core/auth/auth_provider_test.dart` - -- [ ] **Step 1: 写失败测试**(ProviderContainer:登录成功 → token 持久化 + 状态 authenticated;失败 → 状态 error 携带 message) - -- [ ] **Step 2: 实现 auth_provider.dart**(AsyncNotifier:login(account, password) → ApiClient.post('/user/login') → 存 token) - -- [ ] **Step 3: login_page.dart**:账号/密码输入 + 登录按钮 + 加载态 + 错误提示(fluttertoast);登录成功 go_router push 替换到 home - -- [ ] **Step 4: 测试通过** + **Commit** - ---- - -### Task 4: 4 Tab 主框架 - -**Files:** -- Create: `lib/features/home/home_page.dart`(BottomNavigationBar + IndexedStack 4 Tab) -- Create: `lib/features/profile/profile_page.dart`(占位) -- Create: `lib/features/wardrobe/wardrobe_page.dart`(占位) -- Create: `lib/features/outfit/outfit_page.dart`(占位) -- Create: `lib/features/commercial/commercial_page.dart`(占位) - -- [ ] **Step 1: 实现 4 Tab 框架**:底部导航(我的形象/我的衣橱/穿搭方案/门店电商)+ 图标 + IndexedStack 保状态 - -- [ ] **Step 2: Widget 测试**:切 Tab 显示对应页面 - -- [ ] **Step 3: 测试通过** + **Commit** - ---- - -### Task 5: 拍照引导 + 照片上传(Tab1) - -**Files:** -- Create: `lib/features/profile/photo_guide_page.dart`(引导 + 拍摄) -- Create: `lib/features/profile/photo_guide_item.dart`(单张拍摄卡片) -- Create: `lib/features/profile/photo_upload_provider.dart` -- Create: `lib/features/profile/profile_page.dart`(集成:照片齐备度展示 + 上传入口) -- Test: `test/features/profile/photo_upload_provider_test.dart` - -- [ ] **Step 1: 写失败测试**(provider:mock ApiClient.upload 成功 → 状态更新;失败 → error) - -- [ ] **Step 2: 实现 provider**:4 个类型照片上传(type 1-4),逐张上传成功后标记完成;本地压缩(`image_picker` 自带 maxWidth: 2048) - -- [ ] **Step 3: photo_guide_page.dart**:4 张卡片(大头照/全身正面/侧面/背面,各含拍摄示例说明文案 + 相机按钮 image_picker 拍摄)+ 上传进度 + 完成态跳转 - -- [ ] **Step 4: profile_page.dart**:显示 4 张照片状态(已传/未传)+ "进入拍摄引导" 按钮 + 身形参数入口(Task 6) - -- [ ] **Step 5: 测试通过** + **Commit** - ---- - -### Task 6: 身形参数 + 化身状态(Tab1) - -**Files:** -- Create: `lib/features/profile/body_tune_page.dart`(滑杆微调) -- Create: `lib/features/profile/body_provider.dart` -- Create: `lib/features/profile/avatar_provider.dart` -- Test: `test/features/profile/avatar_provider_test.dart` - -- [ ] **Step 1: 写失败测试**(avatar provider:get → build → 状态 done + glbUrl 非空) - -- [ ] **Step 2: body_tune_page.dart**:身高(145-200cm)/体重/肤色(1-5) 滑杆,保存 → POST /body-measurement/save - -- [ ] **Step 3: avatar_provider.dart**:页面进入时 GET /avatar/get → 无记录则提示先传照片 → POST /avatar/build → 轮询 build_status(每 2s × 最多 30 次)→ done 后展示 glb_url - -- [ ] **Step 4: profile_page.dart** 集成:身形参数卡片 + 化身构建按钮 + 构建状态展示 - -- [ ] **Step 5: 测试通过** + **Commit** - ---- - -### Task 7: 衣橱管理(Tab2) - -**Files:** -- Create: `lib/features/wardrobe/wardrobe_provider.dart` -- Create: `lib/features/wardrobe/wardrobe_page.dart`(网格) -- Create: `lib/features/wardrobe/wardrobe_upload_page.dart`(上传表单:照片 + 分类 + 季节 + 风格标签) -- Test: `test/features/wardrobe/wardrobe_provider_test.dart` - -- [ ] **Step 1: 写失败测试**(provider:upload 成功追加列表;delete 移除;list 加载) - -- [ ] **Step 2: 实现 provider** + 上传表单页(DropdownButton 分类[上衣/下装/鞋/配饰] + 季节 + 标签输入) - -- [ ] **Step 3: 网格页**:GridView 服装照片 + 长按删除确认 + 空态引导("衣橱空空如也,去上传第一件衣服吧") - -- [ ] **Step 4: 测试通过** + **Commit** - ---- - -### Task 8: 生成入口(Tab3 上半) - -**Files:** -- Create: `lib/features/outfit/generate_page.dart` -- Create: `lib/features/outfit/outfit_generate_provider.dart` -- Test: `test/features/outfit/outfit_generate_provider_test.dart` - -- [ ] **Step 1: 写失败测试**(provider:generate 成功返回 taskId;开始轮询状态) - -- [ ] **Step 2: 生成入口页**:日期范围(showDateRangePicker)+ 地点(TextField + 定位按钮[geolocator 可选,v1 手动输入])+ "生成穿搭" 按钮(校验:日期非空/地点非空/衣橱非空提示) - -- [ ] **Step 3: 生成确认后** → 跳转任务状态页(Task 9) - -- [ ] **Step 4: 测试通过** + **Commit** - ---- - -### Task 9: 任务轮询 + 方案流(Tab3 核心) - -**Files:** -- Create: `lib/features/outfit/task_status_page.dart` -- Create: `lib/features/outfit/plan_flow_page.dart`(PageView 横滑) -- Create: `lib/features/outfit/plan_provider.dart` -- Test: `test/features/outfit/plan_provider_test.dart` - -- [ ] **Step 1: 写失败测试**(provider:task 轮询 done → 加载 plan list;failed → error) - -- [ ] **Step 2: task_status_page.dart**:轮询 GET /outfit/task/status(Timer.periodic 3s),状态文案映射(pending=准备中/planning=方案规划中/scoring=方案评分中/rendering=效果图生成中/done=完成/failed=失败+error 展示);done → 跳转方案流;失败显示重试 - -- [ ] **Step 3: plan_provider.dart**:GET /outfit/plan/list + detail(含 items/images/hairstyle) - -- [ ] **Step 4: plan_flow_page.dart**:PageView.builder 每页一张方案卡片: - - 3D 化身区(AvatarViewer,Task 10) - - 方案摘要(标题/评分 Chip/来源标签:衣橱组合=蓝 / AI 推荐=橙) - - 发型切换(横排发型 chip)+ 发色取色(HSV 面板) - - 底部条目列表(slot 图标 + 名称 + 描述;推荐条目带"查看商品"入口,v1 占位) - - "选为主方案" 按钮 → POST select-main → 效果图页(Task 11) - -- [ ] **Step 5: 测试通过** + **Commit** - ---- - -### Task 10: AvatarViewer 抽象 + three_dart 实现 - -**Files:** -- Create: `lib/features/outfit/viewer/avatar_viewer.dart`(接口 + controller) -- Create: `lib/features/outfit/viewer/avatar_viewer_three_dart.dart`(three_dart 实现) -- Create: `lib/features/outfit/viewer/avatar_viewer_placeholder.dart`(降级占位:头像图 + 手势提示) -- Create: `lib/features/outfit/viewer/viewer_factory.dart` -- Test: `test/features/outfit/viewer/avatar_viewer_placeholder_test.dart` - -- [ ] **Step 1: 定义抽象接口** - -```dart -/// 渲染层抽象:业务代码只依赖此接口,flutter_scene 进 stable 后提供第二实现 -abstract class AvatarViewerController { - Future loadAvatar(String glbUrl); // 头像+体型主体 - Future loadHairstyle(String glbUrl); // 发型层 - void setHairColor(Color color); // 发色 PBR baseColor - void rotateBy(double dx, double dy); - void zoomBy(double scale); - void resetView(); -} - -class AvatarViewer extends StatefulWidget { - final AvatarViewerController Function() controllerFactory; - ... -} -``` - -- [ ] **Step 2: 实现 three_dart 版**:`three_dart` + `three_dart_jsm` GLTFLoader 加载 GLB → Scene 显示(DirectionalLight + AmbientLight + OrbitControls 式手动手势:onPanUpdate → rotateBy 旋转 Object3D,onScaleUpdate → zoomBy 缩放相机/模型)—— 参考 `three_js_advanced_loaders` 示例;GLB 本地缓存(Task 13) - -- [ ] **Step 3: 降级实现**:加载失败(网络/格式)→ placeholder(用户大头照 Image + "3D 模型加载失败,显示照片效果" 文案) - -- [ ] **Step 4: viewer_factory.dart**:`AvatarViewer createViewer()` → three_dart 实现(有 GLB url 时)/ placeholder(无 url 时) - -- [ ] **Step 5: Widget 测试**(placeholder 渲染)+ `flutter analyze` + **Commit** - ---- - -### Task 11: 效果图查看 + 方案详情(Tab3 下半) - -**Files:** -- Create: `lib/features/outfit/effect_image_page.dart` -- Create: `lib/features/outfit/plan_detail_provider.dart` -- Test: `test/features/outfit/plan_detail_provider_test.dart` - -- [ ] **Step 1: 写失败测试**(provider:select-main → 轮询 detail.images 直到 3 张完成) - -- [ ] **Step 2: select-main 后跳转效果图页**:3 视角(正面/侧面/背面)Tab/滑块切换,cached_network_image 加载,生成中展示进度(轮询 plan/detail images status) - -- [ ] **Step 3: 方案详情页**(从列表进入):完整 detail 渲染(items 图片/名称/描述 + 效果图 + 收藏按钮 POST review) - -- [ ] **Step 4: 测试通过** + **Commit** - ---- - -### Task 12: 门店/电商(Tab4,MVP 列表展示) - -**Files:** -- Create: `lib/features/commercial/store_page.dart`(附近门店列表) -- Create: `lib/features/commercial/store_provider.dart` -- Create: `lib/features/commercial/subscription_page.dart`(订阅占位) -- Test: `test/features/commercial/store_provider_test.dart` - -- [ ] **Step 1: 写失败测试**(provider:GET /partner-store/list 解析列表) - -- [ ] **Step 2: 门店列表页**:类型筛选 chip(形象设计/服装门店)+ ListView 卡片(名称/类型/地址/距离占位) - -- [ ] **Step 3: 订阅页**:标准版/Pro 权益卡片 + "开通"按钮(v1 占位 toast"支付功能开发中") - -- [ ] **Step 4: 测试通过** + **Commit** - ---- - -### Task 13: 缓存与性能 - -**Files:** -- Modify: `lib/core/storage/`(新增 glb_cache.dart) -- Create: `lib/core/storage/glb_cache.dart`(GLB 本地 LRU) -- Test: `test/core/storage/glb_cache_test.dart` - -- [ ] **Step 1: 写失败测试**(缓存:put → hit;LRU 超限淘汰;过期清理) - -- [ ] **Step 2: 实现**:文件缓存目录 `getApplicationSupportDirectory()/glb_cache/`,key=url hash,256MB 上限(超限按最后访问时间淘汰);方案流预取下一页 GLB - -- [ ] **Step 3: 测试通过** + **Commit** - ---- - -### Task 14: 错误处理与加载状态组件 - -**Files:** -- Create: `lib/shared/widgets/loading_view.dart`(骨架屏) -- Create: `lib/shared/widgets/error_view.dart`(错误 + 重试) -- Create: `lib/shared/widgets/empty_view.dart`(空态 + 引导动作) - -- [ ] **Step 1: 三个组件**(loading 骨架、error 文案+重试回调、empty 图标+文案+按钮) - -- [ ] **Step 2: 接入**:profile/wardrobe/outfit/commercial 各页加载态/空态/错误态替换 - -- [ ] **Step 3: Widget 测试** + **Commit** - ---- - -### Task 15: 集成冒烟(联调 slogan-agent) - -- [ ] **Step 1: 本地起 slogan-agent**(`go run main.go`,3007 端口,mock 效果图供应商) - -- [ ] **Step 2: App 联调**(iOS 模拟器 + Android 模拟器各一遍): - 1. 登录 → 2. 拍照引导上传 4 张(相册选图代替拍摄)→ 3. 身形参数 → 4. 构建化身 → 5. 衣橱上传 3+ 件 → 6. 生成穿搭(日期+地点)→ 7. 任务轮询 → 8. 方案流(3D 查看/发型切换/横滑)→ 9. 选主方案 → 10. 效果图 3 视角查看 - -- [ ] **Step 3: 修复联调问题**(网络/字段/状态映射),`flutter analyze` 0 错误 - -- [ ] **Step 4: Commit** `git commit -m "feat: mvp complete, verified against slogan-agent"` - ---- - -## Self-Review 备注 - -- 后端字段命名 snake_case(JSON),Dart 侧解析用 map 取值避免命名映射负担 -- 效果图生成在后端为异步任务,App 端轮询 plan/detail 的 images 状态(rendering→done) -- three_dart 若 iOS 渲染异常(着色器兼容),降级路径 placeholder 保证 MVP 可用;GLB 渲染验证放 Task 10 明确检查 diff --git a/app/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md b/app/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md deleted file mode 100644 index 858e6e4..0000000 --- a/app/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md +++ /dev/null @@ -1,204 +0,0 @@ -# 商业化四支柱设计(客户端)· slogan-app - -> **目标:** 以「个人形象设计」为主流程,把四支柱收入入口从「方案/单品」里长出来:VIP 会员充值、穿山甲广告激励、线下门店引流(美团联盟)、线上商品(京东/淘宝 CPS)。不做泛化场景广场。 -> **核心原则:** 客户端零硬编码业务配置;所有商业化入口以「接口可用」为开关 —— 后端未配置 key 时接口报错/返回空 → App 自动隐藏对应入口,主流程(生成方案 → 查看)不受影响。 - -## 1. 信息架构改造 - -``` -改造前:/commercial 孤立 tab(会员占位卡 + 门店列表) -改造后: - ├─ /commercial 会员中心(P0) 会员状态卡 · 套餐 · 广告激励 · 合作门店 · 最近优惠 - ├─ /plan-viewer 方案页(P0) 发型卡「做同款发型」· 穿衣清单「买同款/到店试穿」· 场合「延伸优惠」 - ├─ /wardrobe 衣橱(P1) 长按「找升级款」 - └─ 全局(P2) 开屏广告 / 信息流广告位(效果图页底部) -``` - -**开关原则**:每个商业化入口包一层 `commercialGate`(统一查询后端配置/捕获接口错误),未开通 → 按钮不渲染。启动时不做额外网络调用,**入口可见性由首次打开该页面的接口结果决定**(零新增请求)。 - -## 2. 支柱 A:会员中心(/commercial 重构) - -### 2.1 页面结构 - -``` -/ commercial(ConsumerStatefulWidget,保留现有门店列表与类型筛选) - ├─ 会员状态卡:头像/会员名 · is_vip · expire_at(倒计时)· 权益 chips(无限效果图/优先AI/返现1.5x/门店折扣) - │ ├─ 未开通 →「立即开通」按钮 → 打开套餐 bottom sheet - │ └─ 已开通 →「会员码」按钮(store_discount 到店出示,P1) - ├─ 广告激励卡(非会员时显示): - │ ├─ 看视频 +1 效果图(每日 2 次,剩余次数显示) - │ └─ 看视频 1 天体验会员(每日 1 次) - ├─ 合作门店列表(现有 /partner-store/list 品牌合作门店保留,含「会员价」角标 P1; - │ 美团联盟到店券走 /cps/product/list,两条链路互不替换) - └─ 最近优惠(/cps/my/recent,P1,未开通则隐藏整卡) -``` - -### 2.2 支付时序(App 侧) - -``` -点击套餐 → POST /member/order/create {plan_id} → 返回 {order_no, pay_url} - → 打开 PayWebViewPage(内嵌 webview_flutter,iOS 用 WKWebView) - → WebView 加载 pay_url,监听 url 变化(payment 完成跳转) - → 同时 Timer 每 2s GET /member/order/status?order_no=... 轮询(上限 60s) - → status=paid → 关闭 WebView → 刷新 memberProvider → 成功 toast - → 超时 → 提示「支付结果确认中,请稍后在会员中心查看」(状态以服务端为准) -``` - -- 支付页依赖 `webview_flutter`(P0 引入,国内 App 常规做法);若平台编译受限,降级方案:`url_launcher` 唤起系统浏览器支付,返回 App 后仍走轮询(**P0 默认 url_launcher 方案,webview_flutter 留 P1**,减小依赖风险) - -### 2.3 状态与 Provider - -| Provider | 类型 | 数据 | 接口 | -|---|---|---|---| -| `memberProvider` | AsyncNotifier | isVip, expireAt, planName, benefits[] | GET /member/status | -| `memberPlanProvider` | FutureProvider | 套餐列表 | GET /member/plan/list | -| `orderCreateProvider` | Notifier.family(planId) | orderNo, payUrl | POST /member/order/create | -| `orderStatusProvider` | FutureProvider.family(orderNo) | status | GET /member/order/status | - -- `memberProvider` 缓存登录态期间;`refresh()` 在支付成功、广告领奖后调用 -- 权益 chips 文案从套餐 `features` JSON 解析 → 本地文案 map(`effect_unlimited→无限效果图` 等) - -## 3. 支柱 B:广告激励(lib/core/ads/ 新建) - -### 3.1 抽象(供应商隔离) - -```dart -// lib/core/ads/ads_provider.dart -abstract class AdsService { - bool get enabled; // appid 未配置 → false,App 隐藏广告入口 - Future showRewarded(); // 激励视频,返回是否完整观看 -} - -// lib/core/ads/pangle_ads_service.dart —— 穿山甲实现(P1 接入 SDK,P0 仅接口 + mock) -// P0:MockAdsService —— 本地模拟 3 秒「播放」返回 true,保证主链路可开发可测 -``` - -- **初始化**:`AdsConfig`(AppConfig 常量:pangleAppId 默认空)→ `adsServiceProvider` 单例 -- **降级**:appid 空 / SDK 初始化失败 → `enabled=false` → 会员中心激励卡、广告位全部不渲染 - -### 3.2 激励流程(服务端防刷,客户端只展示) - -``` -点击「看视频」→ adsService.showRewarded() - → 完整观看 → POST /ad/reward/claim {ad_type: effect_extra | vip_trial} - → 成功 → 展示奖励弹窗(+1 效果图 / 1 天体验会员)→ refresh 会员卡 - → 失败(限频/未配置)→ 隐藏式错误(后端返回「今日次数已用完」→ 入口变灰) -``` - -- 剩余次数展示:`/ad/reward/claim` 响应带回 `{reward: {ad_type, remaining_today}}`,App 本地缓存当日显示;或 P0 简单化 —— 仅在领取失败时提示次数用完 -- **效果图配额联动**:后端限额 = 基础 3 + 当日额外次数;App 端文案统一显示「今日剩余 X 次」(P1 后端在生成接口响应中带 `remaining` 字段,P0 保持现状提示) - -### 3.3 广告位(P2,本期只留占位) - -- 开屏广告:`/home` 进入时加载(P2) -- 信息流广告:`/plan-effect` 效果图 GridView 底部插一条(P2) - -## 4. 支柱 C/D:方案驱动 CPS 入口 - -### 4.1 方案页(/plan-viewer)三处入口 - -| 位置 | 按钮 | scene | 请求 | 跳转 | -|---|---|---|---|---| -| 发型卡尾部 | 「做同款发型」 | haircut | GET /cps/plan/recommend {plan_id, scene: haircut} | /cps-product-list(美团丽人/理发券) | -| 穿衣清单每项 trailing | 「买同款」 | item_buy | 京东搜索单品名 | /cps-product-list(电商商品) | -| 穿衣清单每项 trailing | 「到店试穿」 | item_upgrade | 美团服装类目 | /cps-product-list(门店券) | -| 场合卡(P1,detail 有 occasion 字段后) | 「延伸优惠」 | occasion | 映射表推荐 | /cps-product-list | - -- **可见性**:推荐接口返回空列表 / 接口报错(CPS 未开通)→ 该按钮隐藏;发型卡无发型名(默认发型)→ 隐藏「做同款」 -- **交互**:推荐返回列表 → push `/cps-product-list?source=&category_code=&city=`(带标题「做同款发型 · 丽人」);列表点击 → POST /cps/product/link {product_id, scene, plan_id} → 打开 deeplink -- **打开方式**:`url_launcher`(系统浏览器,携 pid 转链 URL;电商商品优先深链 App,P1 增强) - -### 4.2 衣橱升级款(/wardrobe 长按菜单加一项) - -``` -长按衣物卡 → 菜单:删除 / 找升级款 -「找升级款」→ GET /cps/wardrobe/upgrade {item_id} → /cps-product-list(标题「升级款 · 上衣」) -``` - -### 4.3 商品列表页 /cps-product-list - -``` -AppBar 标题(由入口传入)+ 分类 chips(/cps/category/list,P1) -商品卡:封面图(AppConfig.resolveUrl)· 名称 · 价格(分→元)· 店铺 · 佣金标 -上滑加载更多(page 分页,has_more 判定) -点击 → POST /cps/product/link → deeplink → url_launcher 打开 -``` - -### 4.4 Provider - -| Provider | 类型 | 接口 | -|---|---|---| -| `cpsRecommendProvider` | FutureProvider.family((planId, scene)) | GET /cps/plan/recommend | -| `cpsProductProvider` | AsyncNotifierProvider.family((source, categoryCode, city)) | GET /cps/product/list 分页 | -| `cpsUpgradeProvider` | FutureProvider.family(itemId) | GET /cps/wardrobe/upgrade | -| `cpsLinkAction` | Notifier | POST /cps/product/link | - -## 5. 会员权益在客户端的呈现 - -| 权益 | 客户端表现 | -|---|---| -| effect_unlimited | 效果图页配额提示隐藏(「每日限 3 次」文案在 isVip 时不显示) | -| ai_priority | 生成页状态文案「VIP 优先排队」(MVP 仅文案) | -| cps_commission_x15 | 商品卡佣金标签「返现加成 1.5x」(VIP 用户) | -| store_discount | 门店列表「会员价」角标 + 会员码页(P1) | - -## 6. 配置(AppConfig 常量,均为本地编译期配置) - -```dart -// lib/core/config/app_config.dart 追加 -static const String pangleAppId = ''; // 穿山甲 AppId,空 = 广告功能关闭 -static const bool cpsEnabled = true; // 兜底开关;最终以接口结果为准 -``` - -- 商业化开关**最终以接口为准**(后端 config 未配置 → 接口错误 → App 隐藏入口),本地常量只控制「广告 SDK 是否初始化」 - -## 7. API 映射与错误处理 - -| 后端接口 | 客户端方法 | 错误处理 | -|---|---|---| -| GET /member/status | memberProvider.build | 401 → 重登;其他 → 默认非会员 | -| GET /member/plan/list | memberPlanProvider | 错误 → 套餐区显示「暂未开通」 | -| POST /member/order/create | 下单动作 | 错误 → toast「支付未开通」 | -| GET /member/order/status | 轮询 | 404/错误 → 结束轮询提示稍后查看 | -| POST /member/order/notify | -(后端回调,客户端不参与) | - | -| POST /ad/reward/claim | 领奖动作 | 错误 → toast 后端 message(限频等) | -| GET /cps/plan/recommend | cpsRecommendProvider | 空/错误 → 入口隐藏 | -| GET /cps/product/list | cpsProductProvider | 空/错误 → 列表空态「暂未开放」 | -| POST /cps/product/link | cpsLinkAction | 错误 → toast「跳转失败」 | -| GET /cps/my/recent | recentProvider | 错误 → 整卡隐藏 | - -- 所有新接口走现有 `apiClientProvider`(Dio 封装,自动带 token),无需改动网络层 - -## 8. 路由与依赖变更 - -``` -/lib/main.dart 新增 route: - /cps-product-list(extra: CpsListArgs{title, source, categoryCode, city}) - /pay-webview(extra: PayWebviewArgs{orderNo, payUrl},P1 webview_flutter) -依赖(P0):url_launcher(打开 deeplink / 系统浏览器支付) -依赖(P1):webview_flutter(内嵌收银台)、穿山甲 SDK(pangle 插件) -``` - -- 穿山甲 SDK Flutter 插件社区维护不稳定 → **P1 先验证 iOS/Android 编译,若插件不可用则改为原生 module 接入(P2)**;P0 用 MockAdsService 保证业务链路先闭环 - -## 9. 分期与对齐 - -| 分期 | 客户端内容 | 依赖 | -|---|---|---| -| **P0** | /commercial 重构会员中心(状态/套餐/下单/轮询)+ MockAds + 广告激励入口 + 方案页「做同款发型/买同款/到店试穿」+ /cps-product-list + url_launcher + cps 入口隐藏逻辑 | 后端 P0(会员+广告接口) | -| **P1** | 场合卡「延伸优惠」+ 衣橱「找升级款」+ 最近优惠 + 会员码/门店折扣角标 + webview_flutter 内嵌收银台 + 穿山甲 SDK 接入(替换 Mock) | 后端 P1(CPS 引擎) | -| **P2** | 开屏/信息流广告位 + 穿山甲插件不可用时的原生 module 兜底 + 收益/返现展示 | 后端 P2 | - -## 10. 合规(客户端侧) - -- **iOS 充值**:App Store 虚拟商品政策风险 → iOS 端隐藏会员套餐充值入口(`Platform.isIOS` 判断),保留广告激励 + 门店引流;「会员价」到店核销不受影响 -- **广告**:隐私政策文案补充穿山甲 SDK 信息收集披露;提供「个性化广告关闭」设置项(穿山甲 SDK 提供,P1) -- **跳转**:CPS 转链一律走联盟 deeplink,不在 App 内二次改链 - -## 11. 开发规范约束(沿用 slogan-app 现有规范) - -- Riverpod 3:AsyncNotifier/Notifier/FutureProvider.family;provider 文件放 `lib/features//_provider.dart` -- 新页面组件放 `lib/features/commercial/`(会员中心)、`lib/features/cps/`(商品列表);广告抽象放 `lib/core/ads/` -- 图片 URL 一律 `AppConfig.resolveUrl()`;价格字段分 → 元转换写死规则(`(fen / 100).toStringAsFixed(0)`) -- 所有「隐藏入口」逻辑集中在入口组件内一行判定,不扩散到业务逻辑 -- 新页面必配 empty/error/loading 三态(复用 shared/widgets) diff --git a/app/docs/superpowers/specs/2026-07-31-slogan-app-design.md b/app/docs/superpowers/specs/2026-07-31-slogan-app-design.md deleted file mode 100644 index fc4b1e3..0000000 --- a/app/docs/superpowers/specs/2026-07-31-slogan-app-design.md +++ /dev/null @@ -1,174 +0,0 @@ -# slogan-app 客户端设计方案 - -> 日期:2026-07-31 -> 关联:slogan-agent 服务端方案(Go + GoFrame)见 slogan-agent 仓库对应文档 - -## 1. 项目概述 - -slogan 是一个"人形象设计"应用:用户上传大头照和全身多角度照片、维护个人服装资产(衣橱),指定日期范围和地点后一键生成最适合的穿搭方案(含发型、发色、服装穿搭),方案以 3D 化身 + 2D 效果图双形态呈现,手指滑动切换方案与查看角度。 - -本仓库为客户端(slogan-app),Flutter 实现,一次编写双端(iOS/Android)运行,追求类原生用户操作体验。 - -## 2. 技术选型 - -| 决策点 | 选择 | 理由 | -|--------|------|------| -| 语言/框架 | Flutter (Dart) | 一次编写双端;自绘引擎保证体验一致;动画/手势流畅 | -| 状态管理 | Riverpod | 类型安全、可测试,适合表单/异步任务/缓存类状态 | -| 网络层 | Dio + 拦截器 | JWT 自动注入、统一响应解析 `{code,message,data}`、超时重试 | -| 3D 渲染 | three_dart v1 + 渲染抽象层 | 稳定发布版(v0.3.0,GLTF/GLB loader,纯 Dart 双端);flutter_scene 进 stable 后经抽象层无缝替换 | -| 图片缓存 | cached_network_image | 效果图/服装照片懒加载缓存 | -| 拍照/相册 | camera + image_picker | 大头照/全身多角度拍摄引导 | -| 本地存储 | shared_preferences | token/偏好;身形微调参数本地实时生效 | -| 手势 | 原生 GestureDetector 组合 | 方案横滑切换(PageView)+ 化身旋转拖拽/捏合缩放 + 惯性滚动 | - -**3D 渲染层风险控制**:`AvatarViewer` 接口抽象(loadGLB / setHairstyle / setHairColor / rotate / zoom / switchOutfit),v1 实现为 three_dart;flutter_scene(官方,基于 Flutter GPU)进入 stable 后提供第二实现,业务代码零改动。 - -## 3. 项目结构 - -``` -slogan-app/ -├── lib/ -│ ├── main.dart # 入口 + 路由 + 主题 -│ ├── core/ -│ │ ├── network/ # Dio 封装:JWT 拦截器/统一响应/错误码映射 -│ │ ├── auth/ # 登录页 + token 管理(账号密码,复用服务端 /user/login) -│ │ ├── config/ # API 地址/环境 -│ │ ├── storage/ # shared_preferences 封装(token/偏好) -│ │ └── router/ # go_router(未登录 → 登录页) -│ ├── features/ -│ │ ├── profile/ # Tab1 我的形象 -│ │ │ ├── photo_guide/ # 拍照引导页(大头照/全身多角度拍摄指引) -│ │ │ ├── avatar_viewer/ # 3D 化身查看(AvatarViewer 抽象层实现) -│ │ │ └── body_tune/ # 滑杆微调(身高/胖瘦/肤色,本地实时) -│ │ ├── wardrobe/ # Tab2 我的衣橱 -│ │ │ ├── upload/ # 服装照片上传 + 分类标签 -│ │ │ └── item_grid/ # 服装资产网格/详情 -│ │ ├── outfit/ # Tab3 穿搭方案 -│ │ │ ├── generate/ # 生成入口(日期范围选择器/地点选择) -│ │ │ ├── task_status/ # 生成任务进度(轮询) -│ │ │ ├── plan_flow/ # 方案流(PageView 横滑切换方案) -│ │ │ ├── viewer_3d/ # 3D 方案查看(发型切换/发色取色/旋转/捏合) -│ │ │ ├── effect_images/ # 2D 效果图(正面/侧面/背面切换) -│ │ │ └── review/ # 收藏/反馈 -│ │ └── commercial/ # Tab4 门店/电商 -│ │ ├── stores/ # LBS 附近门店(形象设计/服装) -│ │ ├── leads/ # 导流订单/到店核销 -│ │ ├── products/ # CPS 商品跳转 -│ │ └── subscription/ # 会员订阅 -│ └── shared/ # 组件/主题/工具(日期选择器/评分展示等) -├── assets/ -│ ├── avatars/ # 模板/发型 GLB 缓存(与后端 assets 对应,v1 从服务端拉取) -│ └── images/ # 图标/占位图 -└── test/ # 单元/widget 测试 -``` - -## 4. 页面与交互设计(4 Tab 主框架) - -``` -┌─────────────────────────────────────────┐ -│ Tab 架构(底部导航,类原生体验) │ -│ ┌────────┬────────┬────────┬─────────┐ │ -│ │ 我的形象│ 我的衣橱│ 穿搭方案 │ 门店/电商│ │ -│ └────────┴────────┴────────┴─────────┘ │ -└─────────────────────────────────────────┘ -``` - -### Tab1 我的形象 -- 首次进入:拍照引导流程(大头照 + 全身正面/侧面/背面 4 张,含姿势示例图) -- 化身构建进度(后端任务轮询) -- 3D 化身查看:单指拖拽旋转、双指捏合缩放 -- 滑杆微调:身高/胖瘦/肤色,调参即时反映(本地计算,GLB 缩放参数 + 肤色材质) - -### Tab2 我的衣橱 -- 服装照片上传(多选)+ 分类(上衣/下装/鞋/配饰)+ 季节/风格标签自动识别(后端 AI 辅助,本地可手改) -- 网格展示 + 详情编辑/删除 - -### Tab3 穿搭方案(核心) -- 生成入口:日期范围选择器 + 地点选择(定位/搜索) -- 生成任务进度页(规则规划 → 方案评分 → 完成) -- 方案流:PageView 左右滑动切换 3 套方案;每套方案卡片 = 3D 化身 + 方案摘要(评分/来源标签:衣橱组合/AI 推荐) -- 3D 查看:发型点击切换 + 发色取色盘(HSV 调色实时渲染)+ 拖拽旋转 + 捏合缩放 -- 主方案选定 → 触发 2D 效果图生成(3 视角:正面/侧面/背面,切换查看) -- 方案条目明细:每件服装(衣橱照片 或 电商商品图)+ 单品操作(跳转商品/加入衣橱) -- 收藏/反馈 → 回流 Agent 优化下次生成 - -### Tab4 门店/电商 -- LBS 附近合作门店(类型筛选:形象设计/服装),导航/预约/到店核销 -- CPS 商品推荐列表(跳转电商) -- 会员订阅入口与权益展示 - -## 5. 3D 查看器设计(核心模块) - -### AvatarViewer 抽象接口 - -```dart -abstract class AvatarViewer extends StatelessWidget { - // 由具体实现提供(three_dart v1 / flutter_scene v2) -} - -abstract class AvatarViewerController { - Future loadAvatar(AvatarSpec spec); // 头像 + 体型 + 皮肤贴图 - Future loadHairstyle(String glbUrl); // 加载发型 - void setHairColor(Color color); // 发色(PBR baseColor 调色) - void setOutfit(List layers); // 换装(简模 GLB 层) - void rotateBy(double dx, double dy); // 旋转 - void zoomBy(double scale); // 缩放 - void resetView(); -} -``` - -- **v1 实现(three_dart)**:加载服务端 `avatar_model.glb`(头部/身体/发型分离分层),发型切换 = 换层 + 发色材质 HSV 调整 -- **性能**:GLB 经服务端 glTF-Transform 压缩;发型资产首次加载后本地缓存;页面级预取下一套方案 -- **降级**:GLB 加载失败 → 显示用户大头照 + 方案文本卡片(核心功能不受 3D 影响) - -### 手势实现 -- 方案切换:`PageView`(水平滑动 + 惯性 + 阻尼边缘效果) -- 化身旋转:`GestureDetector.onPanUpdate` → controller.rotateBy,松手无惯性(或轻量衰减) -- 缩放:`ScaleGestureRecognizer` 双指捏合,1.0-4.0 范围限制 -- 发色:`HSVColorPicker` 自定义取色盘,onChanged 实时 setHairColor - -## 6. 网络层与状态管理 - -- `ApiClient`(Dio):baseUrl 可配置、token 注入、`code==0` 判定、401 自动登出、超时 30s -- 任务轮询:`outfit/task/status` 每 3s 轮询(Riverpod `StreamProvider`),任务完成自动停止 -- 上传:Multipart,进度条反馈 -- 缓存:效果图/服装照片 cached_network_image;化身 GLB 本地文件缓存(LRU,256MB 上限) - -## 7. 拍摄引导设计 - -- 大头照:正面、面部无遮挡、光线均匀说明图;相机取景框对齐提示 -- 全身照:距镜 2-3 米、全身入框、正面/侧面/背面 三角度示例图(类原生"手势引导"UI) -- 照片本地压缩(宽边 ≤ 2048)后上传 - -## 8. 错误处理与加载状态 - -- 统一 `ErrorView` / `LoadingView` 组件(骨架屏) -- 生成任务失败:明确错误文案("衣橱为空,请先添加服装"等)+ 重试按钮 -- 网络离线:离线提示 + 本地缓存优先展示(方案历史本地快照) -- 轮询超时(>10 分钟):提示"生成时间较长"并提供结果通知路径(v2 推送) - -## 9. 测试策略 - -- 单元测试:手势计算/发色 HSV 调色/方案缓存 key 逻辑 -- Widget 测试:方案流滑动切换、3D 查看器骨架降级、拍照引导流程 -- 集成(v1 手工 + 冒烟脚本):登录 → 上传 → 生成 → 查看全链路 -- 渲染层测试:AvatarViewer 接口 mock,业务测试不依赖具体 3D 实现 - -## 10. 与后端 API 对接清单 - -见 slogan-agent 方案第 11 节 API 路由表。App 端关键时序: - -``` -登录 → 上传照片(4张) → 填写身形 → [build 化身(异步)] → 上传衣橱服装 - → 生成穿搭 {日期范围, 地点} → 轮询任务 → 方案流(3D 即时查看) - → 选主方案 → 效果图生成(异步) → 3 视角查看 → 收藏/跳转商品/门店预约 -``` - -## 11. 开发规范约束(App 端) - -- 状态管理统一 Riverpod,禁止 setState 在页面间传递业务状态 -- 所有网络请求必须走 `ApiClient`,禁止散落 Dio 实例 -- 3D 渲染只允许通过 `AvatarViewer` 抽象层,禁止业务代码直接依赖 three_dart 类型 -- 命名:目录 `features//`,组件 `shared/`;文件 snake_case,类 PascalCase -- 测试随功能同步编写(TDD:先写失败测试再实现) diff --git a/app/ios/.gitignore b/app/ios/.gitignore deleted file mode 100644 index 7a7f987..0000000 --- a/app/ios/.gitignore +++ /dev/null @@ -1,34 +0,0 @@ -**/dgph -*.mode1v3 -*.mode2v3 -*.moved-aside -*.pbxuser -*.perspectivev3 -**/*sync/ -.sconsign.dblite -.tags* -**/.vagrant/ -**/DerivedData/ -Icon? -**/Pods/ -**/.symlinks/ -profile -xcuserdata -**/.generated/ -Flutter/App.framework -Flutter/Flutter.framework -Flutter/Flutter.podspec -Flutter/Generated.xcconfig -Flutter/ephemeral/ -Flutter/app.flx -Flutter/app.zip -Flutter/flutter_assets/ -Flutter/flutter_export_environment.sh -ServiceDefinitions.json -Runner/GeneratedPluginRegistrant.* - -# Exceptions to above rules. -!default.mode1v3 -!default.mode2v3 -!default.pbxuser -!default.perspectivev3 diff --git a/app/ios/Flutter/AppFrameworkInfo.plist b/app/ios/Flutter/AppFrameworkInfo.plist deleted file mode 100644 index 391a902..0000000 --- a/app/ios/Flutter/AppFrameworkInfo.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - App - CFBundleIdentifier - io.flutter.flutter.app - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - App - CFBundlePackageType - FMWK - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1.0 - - diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig deleted file mode 100644 index 592ceee..0000000 --- a/app/ios/Flutter/Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig deleted file mode 100644 index 592ceee..0000000 --- a/app/ios/Flutter/Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "Generated.xcconfig" diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 5afece9..0000000 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,644 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXBuildFile section */ - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 97C146E61CF9000F007C117D /* Project object */; - proxyType = 1; - remoteGlobalIDString = 97C146ED1CF9000F007C117D; - remoteInfo = Runner; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 9705A1C41CF9048500538489 /* Embed Frameworks */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Embed Frameworks"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; - 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; - 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; - 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 97C146EB1CF9000F007C117D /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C8082294A63A400263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C807B294A618700263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 9740EEB11CF90186004384FC /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 9740EEB31CF90195004384FC /* Generated.xcconfig */, - ); - name = Flutter; - sourceTree = ""; - }; - 97C146E51CF9000F007C117D = { - isa = PBXGroup; - children = ( - 9740EEB11CF90186004384FC /* Flutter */, - 97C146F01CF9000F007C117D /* Runner */, - 97C146EF1CF9000F007C117D /* Products */, - 331C8082294A63A400263BE5 /* RunnerTests */, - ); - sourceTree = ""; - }; - 97C146EF1CF9000F007C117D /* Products */ = { - isa = PBXGroup; - children = ( - 97C146EE1CF9000F007C117D /* Runner.app */, - 331C8081294A63A400263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 97C146F01CF9000F007C117D /* Runner */ = { - isa = PBXGroup; - children = ( - 97C146FA1CF9000F007C117D /* Main.storyboard */, - 97C146FD1CF9000F007C117D /* Assets.xcassets */, - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, - 97C147021CF9000F007C117D /* Info.plist */, - 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, - 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, - 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, - 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, - 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, - ); - path = Runner; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C8080294A63A400263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C807D294A63A400263BE5 /* Sources */, - 331C807F294A63A400263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C8086294A63A400263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 97C146ED1CF9000F007C117D /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 9740EEB61CF901F6004384FC /* Run Script */, - 97C146EA1CF9000F007C117D /* Sources */, - 97C146EB1CF9000F007C117D /* Frameworks */, - 97C146EC1CF9000F007C117D /* Resources */, - 9705A1C41CF9048500538489 /* Embed Frameworks */, - 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); - productName = Runner; - productReference = 97C146EE1CF9000F007C117D /* Runner.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 97C146E61CF9000F007C117D /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C8080294A63A400263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 97C146ED1CF9000F007C117D; - }; - 97C146ED1CF9000F007C117D = { - CreatedOnToolsVersion = 7.3.1; - LastSwiftMigration = 1100; - }; - }; - }; - buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 97C146E51CF9000F007C117D; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); - productRefGroup = 97C146EF1CF9000F007C117D /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 97C146ED1CF9000F007C117D /* Runner */, - 331C8080294A63A400263BE5 /* RunnerTests */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C807F294A63A400263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EC1CF9000F007C117D /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, - 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, - 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, - 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C807D294A63A400263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 97C146EA1CF9000F007C117D /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, - 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, - 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 97C146ED1CF9000F007C117D /* Runner */; - targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 97C146FA1CF9000F007C117D /* Main.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C146FB1CF9000F007C117D /* Base */, - ); - name = Main.storyboard; - sourceTree = ""; - }; - 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { - isa = PBXVariantGroup; - children = ( - 97C147001CF9000F007C117D /* Base */, - ); - name = LaunchScreen.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 249021D3217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Profile; - }; - 249021D4217E4FDB00AE95B9 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Profile; - }; - 331C8088294A63A400263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Debug; - }; - 331C8089294A63A400263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Release; - }; - 331C808A294A63A400263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; - }; - name = Profile; - }; - 97C147031CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = iphoneos; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 97C147041CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_COMMA = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_STRICT_PROTOTYPES = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CLANG_WARN_UNREACHABLE_CODE = YES; - CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; - "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - COPY_PHASE_STRIP = NO; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu99; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNDECLARED_SELECTOR = YES; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = iphoneos; - SUPPORTED_PLATFORMS = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - 97C147061CF9000F007C117D /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Debug; - }; - 97C147071CF9000F007C117D /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - ENABLE_BITCODE = NO; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; - SWIFT_VERSION = 5.0; - VERSIONING_SYSTEM = "apple-generic"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C8088294A63A400263BE5 /* Debug */, - 331C8089294A63A400263BE5 /* Release */, - 331C808A294A63A400263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147031CF9000F007C117D /* Debug */, - 97C147041CF9000F007C117D /* Release */, - 249021D3217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 97C147061CF9000F007C117D /* Debug */, - 97C147071CF9000F007C117D /* Release */, - 249021D4217E4FDB00AE95B9 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 97C146E61CF9000F007C117D /* Project object */; -} diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 919434a..0000000 --- a/app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c..0000000 --- a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index c3fedb2..0000000 --- a/app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,119 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/ios/Runner.xcworkspace/contents.xcworkspacedata b/app/ios/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a1..0000000 --- a/app/ios/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index f9b0d7c..0000000 --- a/app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - PreviewsEnabled - - - diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift deleted file mode 100644 index c30b367..0000000 --- a/app/ios/Runner/AppDelegate.swift +++ /dev/null @@ -1,16 +0,0 @@ -import Flutter -import UIKit - -@main -@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { - GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - } -} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index d36b1fa..0000000 --- a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,122 +0,0 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png deleted file mode 100644 index dc9ada4..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png deleted file mode 100644 index 7353c41..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png deleted file mode 100644 index 797d452..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png deleted file mode 100644 index 6ed2d93..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png deleted file mode 100644 index 4cd7b00..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png deleted file mode 100644 index fe73094..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png deleted file mode 100644 index 321773c..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png deleted file mode 100644 index 797d452..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png deleted file mode 100644 index 502f463..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png deleted file mode 100644 index 0ec3034..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png deleted file mode 100644 index 0ec3034..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png deleted file mode 100644 index e9f5fea..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png deleted file mode 100644 index 84ac32a..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png deleted file mode 100644 index 8953cba..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png deleted file mode 100644 index 0467bf1..0000000 Binary files a/app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json deleted file mode 100644 index 0bedcf2..0000000 --- a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "LaunchImage.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "LaunchImage@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png deleted file mode 100644 index 9da19ea..0000000 Binary files a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and /dev/null differ diff --git a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md deleted file mode 100644 index 89c2725..0000000 --- a/app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Launch Screen Assets - -You can customize the launch screen with your own desired assets by replacing the image files in this directory. - -You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard b/app/ios/Runner/Base.lproj/LaunchScreen.storyboard deleted file mode 100644 index f2e259c..0000000 --- a/app/ios/Runner/Base.lproj/LaunchScreen.storyboard +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/ios/Runner/Base.lproj/Main.storyboard b/app/ios/Runner/Base.lproj/Main.storyboard deleted file mode 100644 index f3c2851..0000000 --- a/app/ios/Runner/Base.lproj/Main.storyboard +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist deleted file mode 100644 index 638e7c3..0000000 --- a/app/ios/Runner/Info.plist +++ /dev/null @@ -1,70 +0,0 @@ - - - - - CADisableMinimumFrameDurationOnPhone - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - Slogan App - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - slogan_app - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleSignature - ???? - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSRequiresIPhoneOS - - UIApplicationSceneManifest - - UIApplicationSupportsMultipleScenes - - UISceneConfigurations - - UIWindowSceneSessionRoleApplication - - - UISceneClassName - UIWindowScene - UISceneConfigurationName - flutter - UISceneDelegateClassName - $(PRODUCT_MODULE_NAME).SceneDelegate - UISceneStoryboardFile - Main - - - - - UIApplicationSupportsIndirectInputEvents - - UILaunchStoryboardName - LaunchScreen - UIMainStoryboardFile - Main - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UISupportedInterfaceOrientations~ipad - - UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - - diff --git a/app/ios/Runner/Runner-Bridging-Header.h b/app/ios/Runner/Runner-Bridging-Header.h deleted file mode 100644 index 308a2a5..0000000 --- a/app/ios/Runner/Runner-Bridging-Header.h +++ /dev/null @@ -1 +0,0 @@ -#import "GeneratedPluginRegistrant.h" diff --git a/app/ios/Runner/SceneDelegate.swift b/app/ios/Runner/SceneDelegate.swift deleted file mode 100644 index b9ce8ea..0000000 --- a/app/ios/Runner/SceneDelegate.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Flutter -import UIKit - -class SceneDelegate: FlutterSceneDelegate { - -} diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift deleted file mode 100644 index 86a7c3b..0000000 --- a/app/ios/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Flutter -import UIKit -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/app/lib/core/ads/ads_service.dart b/app/lib/core/ads/ads_service.dart deleted file mode 100644 index 84a70ad..0000000 --- a/app/lib/core/ads/ads_service.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../config/app_config.dart'; - -/// 广告服务抽象:P0 用 Mock(保证业务链路可开发可测),P1 换穿山甲 SDK -abstract class AdsService { - /// 广告是否开通(AppConfig.pangleAppId 非空);未开通时广告位与激励均不渲染 - bool get enabled; - Future showRewarded(); - - /// 横幅/信息流广告位(Mock 返回占位卡,P1 换原生广告组件) - Widget showBanner(BuildContext context); -} - -/// 本地模拟广告(约 1 秒"播放"后返回完整观看) -class MockAdsService implements AdsService { - @override - bool get enabled => AppConfig.pangleAppId.isNotEmpty; - - @override - Future showRewarded() async { - await Future.delayed(const Duration(milliseconds: 900)); - return true; - } - - @override - Widget showBanner(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - height: 90, - margin: const EdgeInsets.fromLTRB(12, 0, 12, 12), - decoration: BoxDecoration( - color: scheme.primaryContainer.withValues(alpha: 0.5), - borderRadius: BorderRadius.circular(12), - ), - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.campaign_outlined, size: 20), - SizedBox(width: 8), - Text('广告位(P1 接入穿山甲)', style: TextStyle(fontSize: 12)), - ], - ), - ); - } -} - -final adsServiceProvider = Provider((ref) { - // P1:AppConfig.pangleAppId 非空时替换为 PangleAdsService(穿山甲 SDK 实现) - return MockAdsService(); -}); diff --git a/app/lib/core/auth/auth_provider.dart b/app/lib/core/auth/auth_provider.dart deleted file mode 100644 index 9ebc6d6..0000000 --- a/app/lib/core/auth/auth_provider.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../network/api_client.dart'; -import '../storage/token_storage.dart'; - -class AuthState { - final bool authenticated; - final int? userId; - final String? name; - - const AuthState({ - this.authenticated = false, - this.userId, - this.name, - }); -} - -final tokenStorageProvider = Provider((ref) => TokenStorage()); - -final apiClientProvider = Provider((ref) { - final client = ApiClient(tokenStorage: ref.watch(tokenStorageProvider)); - client.onUnauthorized = () { - ref.read(authProvider.notifier).logout(); - }; - return client; -}); - -class AuthNotifier extends AsyncNotifier { - @override - Future build() async { - final storage = ref.watch(tokenStorageProvider); - await storage.load(); - return AuthState(authenticated: storage.hasToken); - } - - Future login(String account, String password) async { - state = const AsyncLoading(); - try { - final client = ref.read(apiClientProvider); - final data = await client.post>('/user/login', { - 'account': account, - 'password': password, - }); - final token = data['token'] as String? ?? ''; - final user = data['user'] as Map? ?? {}; - await ref.read(tokenStorageProvider).save(token); - state = AsyncData(AuthState( - authenticated: true, - userId: (user['id'] as num?)?.toInt(), - name: user['name'] as String?, - )); - } catch (e) { - state = AsyncError(e, StackTrace.current); - } - } - - Future logout() async { - await ref.read(tokenStorageProvider).clear(); - state = const AsyncData(AuthState()); - } -} - -final authProvider = - AsyncNotifierProvider(AuthNotifier.new); diff --git a/app/lib/core/config/app_config.dart b/app/lib/core/config/app_config.dart deleted file mode 100644 index 536aa17..0000000 --- a/app/lib/core/config/app_config.dart +++ /dev/null @@ -1,22 +0,0 @@ -/// 全局配置 -class AppConfig { - /// 后端地址:iOS 模拟器用 127.0.0.1;Android 模拟器用 10.0.2.2;真机填局域网 IP - static const String baseUrl = 'http://127.0.0.1:3007'; - - /// 后端返回的相对路径(/workspace/...)拼成完整 URL - static String resolveUrl(String path) { - if (path.startsWith('http')) return path; - return '$baseUrl$path'; - } - - /// 穿山甲 App ID(空 = 广告未开通,广告位不渲染) - static const String pangleAppId = ''; - - /// 测试账号:联调/测试用真实用户数据,默认 wenwu901/123456, - /// 可用 --dart-define=TEST_ACCOUNT=xxx --dart-define=TEST_PASSWORD=xxx 覆盖(build_web.sh 透传环境变量); - /// 置空则登录页不显示测试登录入口 - static const String testAccount = - String.fromEnvironment('TEST_ACCOUNT', defaultValue: 'wenwu901'); - static const String testPassword = - String.fromEnvironment('TEST_PASSWORD', defaultValue: '123456'); -} diff --git a/app/lib/core/network/api_client.dart b/app/lib/core/network/api_client.dart deleted file mode 100644 index 1b02423..0000000 --- a/app/lib/core/network/api_client.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../config/app_config.dart'; -import '../storage/token_storage.dart'; -import 'api_exception.dart'; - -/// 统一网络客户端:JWT 拦截器 + 统一响应解包(code != 0 抛 ApiException) -class ApiClient { - final TokenStorage _tokenStorage; - late final Dio _dio; - - /// 401 时回调(登出) - void Function()? onUnauthorized; - - // ignore: prefer_initializing_formals - ApiClient({required TokenStorage tokenStorage, Dio? dio}) - : _tokenStorage = tokenStorage { - _dio = dio ?? - Dio(BaseOptions( - baseUrl: AppConfig.baseUrl, - connectTimeout: const Duration(seconds: 10), - receiveTimeout: const Duration(seconds: 60), - )); - _dio.interceptors.add(InterceptorsWrapper( - onRequest: (options, handler) { - final token = _tokenStorage.token; - if (token != null && token.isNotEmpty) { - options.headers['Authorization'] = 'Bearer $token'; - } - handler.next(options); - }, - onError: (e, handler) { - if (e.response?.statusCode == 401) onUnauthorized?.call(); - handler.next(e); - }, - )); - } - - Future post(String path, Map body, - {T Function(dynamic data)? parse}) async { - final res = await _dio.post(path, data: body); - return _unwrap(res, parse); - } - - Future get(String path, - {Map? query, T Function(dynamic data)? parse}) async { - final res = await _dio.get(path, queryParameters: query); - return _unwrap(res, parse); - } - - /// multipart 上传:fields 为文本字段,fileField 为文件字段名 - Future upload(String path, Map fields, - String fileField, String filePath, - {T Function(dynamic data)? parse}) async { - final form = FormData.fromMap({ - ...fields, - fileField: await MultipartFile.fromFile(filePath), - }); - final res = await _dio.post(path, data: form); - return _unwrap(res, parse); - } - - T _unwrap(Response res, T Function(dynamic data)? parse) { - final data = res.data; - if (data is! Map) { - throw ApiException(-1, '响应格式错误'); - } - final code = data['code'] as int? ?? -1; - final message = data['message'] as String? ?? ''; - if (code != 0) { - throw ApiException(code, message); - } - final payload = data['data']; - if (parse != null) return parse(payload); - return payload as T; - } -} diff --git a/app/lib/core/network/api_exception.dart b/app/lib/core/network/api_exception.dart deleted file mode 100644 index dbe6ca0..0000000 --- a/app/lib/core/network/api_exception.dart +++ /dev/null @@ -1,10 +0,0 @@ -/// 业务异常(后端统一响应 code != 0) -class ApiException implements Exception { - final int code; - final String message; - - ApiException(this.code, this.message); - - @override - String toString() => message; -} diff --git a/app/lib/core/storage/token_storage.dart b/app/lib/core/storage/token_storage.dart deleted file mode 100644 index 2227df7..0000000 --- a/app/lib/core/storage/token_storage.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:shared_preferences/shared_preferences.dart'; - -/// JWT token 本地存储 -class TokenStorage { - static const _key = 'auth_token'; - String? _token; - - String? get token => _token; - - bool get hasToken => _token != null && _token!.isNotEmpty; - - Future load() async { - final prefs = await SharedPreferences.getInstance(); - _token = prefs.getString(_key); - } - - Future save(String token) async { - _token = token; - final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_key, token); - } - - Future clear() async { - _token = null; - final prefs = await SharedPreferences.getInstance(); - await prefs.remove(_key); - } -} diff --git a/app/lib/features/auth/login_page.dart b/app/lib/features/auth/login_page.dart deleted file mode 100644 index c99a25e..0000000 --- a/app/lib/features/auth/login_page.dart +++ /dev/null @@ -1,171 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/auth/auth_provider.dart'; -import '../../core/config/app_config.dart'; -import '../../shared/app_toast.dart'; - -class LoginPage extends ConsumerStatefulWidget { - const LoginPage({super.key}); - - @override - ConsumerState createState() => _LoginPageState(); -} - -class _LoginPageState extends ConsumerState { - final _accountCtrl = TextEditingController(); - final _passwordCtrl = TextEditingController(); - - @override - void dispose() { - _accountCtrl.dispose(); - _passwordCtrl.dispose(); - super.dispose(); - } - - Future _submit([String? acc, String? pwd]) async { - final account = (acc ?? _accountCtrl.text).trim(); - final password = pwd ?? _passwordCtrl.text; - if (account.isEmpty || password.isEmpty) { - showToast('请输入账号和密码'); - return; - } - await ref.read(authProvider.notifier).login(account, password); - final state = ref.read(authProvider); - if (state.hasError) { - showToast(state.error.toString().replaceFirst('Exception: ', '')); - return; - } - if (state.value?.authenticated == true && mounted) { - context.go('/home'); - } - } - - Future _showRegisterDialog() async { - final account = TextEditingController(); - final password = TextEditingController(); - final name = TextEditingController(); - final result = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('注册新账号'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: account, - decoration: const InputDecoration(labelText: '账号')), - TextField( - controller: password, - obscureText: true, - decoration: const InputDecoration(labelText: '密码(至少6位)')), - TextField( - controller: name, - decoration: const InputDecoration(labelText: '昵称')), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('取消')), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('注册')), - ], - ), - ); - if (result != true) return; - try { - final api = ref.read(apiClientProvider); - await api.post>('/user/register', { - 'account': account.text.trim(), - 'password': password.text, - 'name': name.text.trim(), - }); - showToast('注册成功,正在登录...'); - _accountCtrl.text = account.text.trim(); - _passwordCtrl.text = password.text; - await _submit(); - } catch (e) { - showToast('注册失败:${e.toString().replaceFirst('Exception: ', '')}'); - } - } - - @override - Widget build(BuildContext context) { - final auth = ref.watch(authProvider); - final loading = auth.isLoading; - return Scaffold( - body: SafeArea( - child: Center( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Icon(Icons.face_retouching_natural, - size: 72, color: Theme.of(context).colorScheme.primary), - const SizedBox(height: 12), - const Text('我的形象穿搭', - textAlign: TextAlign.center, - style: - TextStyle(fontSize: 24, fontWeight: FontWeight.bold)), - const Text('拍出你的个人形象,生成专属穿搭方案', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey, fontSize: 13)), - const SizedBox(height: 40), - TextField( - controller: _accountCtrl, - decoration: const InputDecoration( - labelText: '账号', - prefixIcon: Icon(Icons.person_outline), - border: OutlineInputBorder()), - ), - const SizedBox(height: 16), - TextField( - controller: _passwordCtrl, - obscureText: true, - decoration: const InputDecoration( - labelText: '密码', - prefixIcon: Icon(Icons.lock_outline), - border: OutlineInputBorder()), - onSubmitted: (_) => _submit(), - ), - const SizedBox(height: 32), - FilledButton( - onPressed: loading ? null : _submit, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14)), - child: loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('登录'), - ), - if (AppConfig.testAccount.isNotEmpty) ...[ - const SizedBox(height: 8), - OutlinedButton.icon( - onPressed: loading - ? null - : () => _submit( - AppConfig.testAccount, AppConfig.testPassword), - icon: const Icon(Icons.science_outlined, size: 18), - label: Text('测试账号一键登录(${AppConfig.testAccount})'), - ), - ], - const SizedBox(height: 8), - TextButton( - onPressed: loading ? null : _showRegisterDialog, - child: const Text('还没有账号?注册新账号'), - ), - ], - ), - ), - ), - ), - ); - } -} diff --git a/app/lib/features/cps/cps_product_list_page.dart b/app/lib/features/cps/cps_product_list_page.dart deleted file mode 100644 index 9291f12..0000000 --- a/app/lib/features/cps/cps_product_list_page.dart +++ /dev/null @@ -1,237 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../../core/config/app_config.dart'; -import '../../features/member/member_provider.dart'; -import '../../shared/app_toast.dart'; -import '../../shared/widgets/empty_view.dart'; -import '../../shared/widgets/error_view.dart'; -import '../../shared/widgets/loading_view.dart'; -import 'cps_provider.dart'; - -/// 联盟选品池:分类 chips + 上滑分页 + 转链跳转 -class CpsProductListPage extends ConsumerStatefulWidget { - final CpsListArgs args; - - const CpsProductListPage({required this.args, super.key}); - - @override - ConsumerState createState() => - _CpsProductListPageState(); -} - -class _CpsProductListPageState extends ConsumerState { - final _scrollController = ScrollController(); - String? _catCode; // null = 全部 - - @override - void initState() { - super.initState(); - _scrollController.addListener(() { - if (_scrollController.position.pixels >= - _scrollController.position.maxScrollExtent - 200) { - ref - .read(cpsProductProvider(widget.args).notifier) - .loadMore(); - } - }); - } - - @override - void dispose() { - _scrollController.dispose(); - super.dispose(); - } - - Future _openLink(CpsProductInfo product, String scene, int planId) async { - try { - final link = await ref.read(cpsLinkProvider).link(ref, - productId: product.id, scene: scene, planId: planId); - if (link.isEmpty) { - showToast('暂无可跳转链接'); - return; - } - final uri = Uri.parse(link); - if (uri.scheme.startsWith('http') && - await canLaunchUrl(uri)) { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } else { - // 淘口令等非 URL 内容:复制到剪贴板 - await _copy(link); - } - } catch (e) { - if (!mounted) return; - showToast('跳转失败:${e.toString().replaceFirst('Exception: ', '')}'); - } - } - - Future _copy(String text) async { - final data = ClipboardData(text: text); - await Clipboard.setData(data); - if (mounted) showToast('已复制,打开淘宝即可购买'); - } - - @override - Widget build(BuildContext context) { - final categories = ref.watch(cpsCategoryProvider); - final products = ref.watch(cpsProductProvider(widget.args)); - final member = ref.watch(memberProvider).value; - final isVip = member?.isVip ?? false; - return Scaffold( - appBar: AppBar(title: Text(widget.args.title)), - body: Column( - children: [ - categories.when( - loading: () => const SizedBox(height: 48), - error: (_, _) => const SizedBox(height: 48), - data: (cats) { - final shown = widget.args.categoryCode.isNotEmpty - ? cats.where((c) => c.code == widget.args.categoryCode).toList() - : cats; - if (shown.isEmpty) return const SizedBox(height: 48); - return SizedBox( - height: 48, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - children: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: const Text('全部'), - selected: _catCode == null, - onSelected: (_) => setState(() => _catCode = null), - ), - ), - for (final c in shown) - Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(c.name), - selected: _catCode == c.code, - onSelected: (_) => setState(() => _catCode = c.code), - ), - ), - ], - ), - ); - }, - ), - Expanded( - child: products.when( - loading: () => const LoadingView(text: '加载商品...'), - error: (e, _) => ErrorView( - message: e.toString().replaceFirst('Exception: ', ''), - onRetry: () => - ref.refresh(cpsProductProvider(widget.args)), - ), - data: (list) { - final shown = _catCode == null - ? list - : list.where((p) => p.categoryCode == _catCode).toList(); - if (shown.isEmpty) { - return const EmptyView(message: '暂无联盟商品'); - } - return GridView.builder( - controller: _scrollController, - padding: const EdgeInsets.all(12), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 0.72, - ), - itemCount: shown.length, - itemBuilder: (ctx, i) { - final p = shown[i]; - return Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => - _openLink(p, widget.args.scene, 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Stack( - fit: StackFit.expand, - children: [ - p.coverUrl.isEmpty - ? const Icon(Icons.shopping_bag_outlined, - size: 48, color: Colors.grey) - : Image.network( - AppConfig.resolveUrl(p.coverUrl), - fit: BoxFit.cover, - errorBuilder: (_, _, _) => const Icon( - Icons.broken_image_outlined, - size: 48, - color: Colors.grey), - ), - if (isVip) - Positioned( - top: 6, - right: 6, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.amber.shade600, - borderRadius: - BorderRadius.circular(6), - ), - child: const Text('VIP 返现 1.5x', - style: TextStyle( - color: Colors.white, - fontSize: 10)), - ), - ), - ], - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - p.name, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - '¥${p.priceText}', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: Colors.red.shade600), - ), - if (p.commissionText.isNotEmpty) - Text( - p.commissionText, - style: const TextStyle( - fontSize: 11, color: Colors.grey), - ), - ], - ), - ), - ], - ), - ), - ); - }, - ); - }, - ), - ), - ], - ), - ); - } -} diff --git a/app/lib/features/cps/cps_provider.dart b/app/lib/features/cps/cps_provider.dart deleted file mode 100644 index 95d528a..0000000 --- a/app/lib/features/cps/cps_provider.dart +++ /dev/null @@ -1,201 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/auth/auth_provider.dart'; - -/// CPS 联盟商品(后端 /cps/* 模型) -class CpsProductInfo { - final int id; - final String source; - final String outerId; - final String categoryCode; - final String name; - final String coverUrl; - final int priceFen; - final String shopName; - final int commissionRate; // 万分比 - final String city; - - const CpsProductInfo({ - required this.id, - required this.source, - required this.outerId, - required this.categoryCode, - required this.name, - required this.coverUrl, - required this.priceFen, - required this.shopName, - required this.commissionRate, - required this.city, - }); - - String get priceText => (priceFen / 100).toStringAsFixed(0); - - String get commissionText { - final pct = commissionRate / 100; - return pct > 0 ? '佣金 ${pct.toStringAsFixed(1)}%' : ''; - } -} - -CpsProductInfo _parseProduct(Map e) => CpsProductInfo( - id: (e['id'] as num).toInt(), - source: e['source'] as String? ?? '', - outerId: e['outer_id'] as String? ?? '', - categoryCode: e['category_code'] as String? ?? '', - name: e['name'] as String? ?? '', - coverUrl: e['cover_url'] as String? ?? '', - priceFen: (e['price_fen'] as num?)?.toInt() ?? 0, - shopName: e['shop_name'] as String? ?? '', - commissionRate: (e['commission_rate'] as num?)?.toInt() ?? 0, - city: e['city'] as String? ?? '', - ); - -/// 联盟分类(chips) -class CpsCategoryInfo { - final String code; - final String name; - final String source; - - const CpsCategoryInfo({ - required this.code, - required this.name, - required this.source, - }); -} - -final cpsCategoryProvider = FutureProvider>((ref) async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/cps/category/list'); - final list = data['list'] as List? ?? []; - return list - .map((e) => CpsCategoryInfo( - code: e['code'] as String? ?? '', - name: e['name'] as String? ?? '', - source: e['source'] as String? ?? '', - )) - .toList(); -}); - -/// 商品列表页参数 -class CpsListArgs { - final String title; - final String source; - final String categoryCode; - final String city; - final String scene; // 点击日志场景标记(haircut/item_buy/...) - - const CpsListArgs({ - required this.title, - this.source = '', - this.categoryCode = '', - this.city = '', - this.scene = '', - }); - - @override - bool operator ==(Object other) => - other is CpsListArgs && - other.source == source && - other.categoryCode == categoryCode && - other.city == city && - other.scene == scene; - - @override - int get hashCode => Object.hash(source, categoryCode, city, scene); -} - -/// 选品池分页(上滑加载更多;Riverpod 3 family 参数经构造函数注入) -class CpsProductListNotifier extends AsyncNotifier> { - CpsProductListNotifier(this.arg); - - final CpsListArgs arg; - int _page = 1; - bool _hasMore = true; - - @override - Future> build() async { - _page = 1; - _hasMore = true; - return _fetch(1); - } - - Future loadMore() async { - if (!_hasMore || state.isLoading) return; - _page += 1; - try { - final next = await _fetch(_page); - state = AsyncData([...?state.value, ...next]); - } catch (_) { - _page -= 1; // 失败回退页码,允许下次重试 - } - } - - Future> _fetch(int page) async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/cps/product/list', - query: { - if (arg.source.isNotEmpty) 'source': arg.source, - if (arg.categoryCode.isNotEmpty) 'category_code': arg.categoryCode, - if (arg.city.isNotEmpty) 'city': arg.city, - 'page': page, - }); - _hasMore = data['has_more'] as bool? ?? false; - return (data['list'] as List? ?? []) - .map((e) => _parseProduct(e as Map)) - .toList(); - } -} - -final cpsProductProvider = AsyncNotifierProvider.family< - CpsProductListNotifier, List, CpsListArgs>( - CpsProductListNotifier.new); - -/// 方案驱动推荐(plan_id + scene) -final cpsRecommendProvider = FutureProvider.family, - ({int planId, String scene})>((ref, args) async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/cps/plan/recommend', - query: {'plan_id': args.planId, 'scene': args.scene}); - return (data['list'] as List? ?? []) - .map((e) => _parseProduct(e as Map)) - .toList(); -}); - -/// 衣橱升级款 -final cpsUpgradeProvider = - FutureProvider.family, int>((ref, itemId) async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/cps/wardrobe/upgrade', - query: {'item_id': itemId}); - return (data['list'] as List? ?? []) - .map((e) => _parseProduct(e as Map)) - .toList(); -}); - -/// 转链动作(POST /cps/product/link,返回 deeplink) -class CpsLinkAction { - Future link( - WidgetRef ref, { - required int productId, - String scene = '', - int planId = 0, - }) async { - final api = ref.read(apiClientProvider); - final data = await api.post>('/cps/product/link', { - 'product_id': productId, - 'scene': scene, - 'plan_id': planId, - }); - return data['deeplink'] as String? ?? ''; - } -} - -final cpsLinkProvider = Provider((ref) => CpsLinkAction()); - -/// 最近优惠(会员中心) -final cpsRecentProvider = FutureProvider>((ref) async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/cps/my/recent'); - return (data['list'] as List? ?? []) - .map((e) => _parseProduct(e as Map)) - .toList(); -}); diff --git a/app/lib/features/home/home_page.dart b/app/lib/features/home/home_page.dart deleted file mode 100644 index a7edeea..0000000 --- a/app/lib/features/home/home_page.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/ads/ads_service.dart'; -import '../mine/mine_page.dart'; -import '../outfit/outfit_page.dart'; - -/// 主框架:2 Tab(穿搭 = 造型方案制作向导 / 我的 = 个人信息管理入口) -class HomePage extends ConsumerStatefulWidget { - const HomePage({super.key}); - - @override - ConsumerState createState() => _HomePageState(); -} - -class _HomePageState extends ConsumerState { - int _index = 0; - bool _splashShown = false; - - @override - void initState() { - super.initState(); - // 开屏广告:仅广告开通时展示一次(P0 Mock 下 pangleAppId 为空 → 跳过) - WidgetsBinding.instance.addPostFrameCallback((_) { - if (_splashShown) return; - if (!ref.read(adsServiceProvider).enabled) return; - _splashShown = true; - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => _SplashAdOverlay( - onSkip: () => Navigator.of(context).pop(), - ), - ); - }); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - body: IndexedStack( - index: _index, - children: const [ - OutfitPage(), - MinePage(), - ], - ), - bottomNavigationBar: NavigationBar( - selectedIndex: _index, - onDestinationSelected: (i) => setState(() => _index = i), - destinations: const [ - NavigationDestination( - icon: Icon(Icons.auto_awesome_outlined), - selectedIcon: Icon(Icons.auto_awesome), - label: '穿搭'), - NavigationDestination( - icon: Icon(Icons.person_outline), - selectedIcon: Icon(Icons.person), - label: '我的'), - ], - ), - ); - } -} - -/// 开屏广告:3 秒倒计时后自动关闭,右上角可手动跳过 -class _SplashAdOverlay extends StatefulWidget { - final VoidCallback onSkip; - - const _SplashAdOverlay({required this.onSkip}); - - @override - State<_SplashAdOverlay> createState() => _SplashAdOverlayState(); -} - -class _SplashAdOverlayState extends State<_SplashAdOverlay> { - Timer? _timer; - int _seconds = 3; - - @override - void initState() { - super.initState(); - _timer = Timer.periodic(const Duration(seconds: 1), (_) { - if (_seconds <= 1) { - _timer?.cancel(); - if (mounted) widget.onSkip(); - return; - } - setState(() => _seconds -= 1); - }); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return PopScope( - canPop: false, - child: ColoredBox( - color: Theme.of(context).colorScheme.primaryContainer, - child: Stack( - children: [ - const Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.campaign_outlined, size: 64), - SizedBox(height: 12), - Text('开屏广告位', - style: - TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - SizedBox(height: 4), - Text('P1 接入穿山甲开屏广告', - style: TextStyle(fontSize: 12, color: Colors.grey)), - ], - ), - ), - Positioned( - top: 24, - right: 24, - child: OutlinedButton( - onPressed: widget.onSkip, - child: Text('跳过($_seconds)'), - ), - ), - ], - ), - ), - ); - } -} diff --git a/app/lib/features/member/member_provider.dart b/app/lib/features/member/member_provider.dart deleted file mode 100644 index a74e8ef..0000000 --- a/app/lib/features/member/member_provider.dart +++ /dev/null @@ -1,134 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/auth/auth_provider.dart'; - -/// 会员 provider:会员状态/套餐/下单/轮询/领奖 -class MemberInfo { - final bool isVip; - final String expireAt; - final String planName; - final List benefits; - - const MemberInfo({ - required this.isVip, - required this.expireAt, - required this.planName, - required this.benefits, - }); - - factory MemberInfo.fromJson(Map e) => MemberInfo( - isVip: e['is_vip'] as bool? ?? false, - expireAt: e['expire_at'] as String? ?? '', - planName: e['plan_name'] as String? ?? '', - benefits: (e['benefits'] as List? ?? []).cast(), - ); -} - -/// 权益 key → 文案 -const benefitLabels = { - 'effect_unlimited': '无限效果图', - 'ai_priority': '优先 AI 方案', - 'cps_commission_x15': '返现加成 1.5x', - 'store_discount': '门店折扣', -}; - -List benefitTexts(MemberInfo m) => - m.benefits.map((b) => benefitLabels[b] ?? b).toList(); - -class MemberNotifier extends AsyncNotifier { - @override - Future build() async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/member/status'); - return MemberInfo.fromJson(data); - } - - Future refresh() async { - state = await AsyncValue.guard(build); - } - - /// 领取广告激励(服务端限频);adType: effect_extra | vip_trial - /// 返回当日剩余次数;超出限频抛 ApiException - Future claimReward(String adType) async { - final api = ref.read(apiClientProvider); - final data = - await api.post>('/ad/reward/claim', {'ad_type': adType}); - await refresh(); // vip_trial 可能开通体验会员 - return (data['reward']?['remaining_today'] as num?)?.toInt() ?? 0; - } -} - -final memberProvider = - AsyncNotifierProvider(MemberNotifier.new); - -class MemberPlan { - final int id; - final String name; - final int priceFen; - final int durationDays; - final List features; - - const MemberPlan({ - required this.id, - required this.name, - required this.priceFen, - required this.durationDays, - required this.features, - }); - - factory MemberPlan.fromJson(Map e) => MemberPlan( - id: (e['id'] as num).toInt(), - name: e['name'] as String? ?? '', - priceFen: (e['price_fen'] as num?)?.toInt() ?? 0, - durationDays: (e['duration_days'] as num?)?.toInt() ?? 30, - features: _parseFeatures(e['features'] as String? ?? ''), - ); - - static List _parseFeatures(String s) { - try { - return (jsonDecode(s) as List).cast(); - } catch (_) { - return const []; - } - } - - String get priceText => - '¥${(priceFen / 100).toStringAsFixed(priceFen % 100 == 0 ? 0 : 1)}'; -} - -final memberPlanProvider = FutureProvider>((ref) async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/member/plan/list'); - final list = data['list'] as List? ?? []; - return list - .map((e) => MemberPlan.fromJson(e as Map)) - .toList(); -}); - -class OrderResult { - final String orderNo; - final String payUrl; - - const OrderResult({required this.orderNo, required this.payUrl}); -} - -/// 创建支付订单(后端调虎皮棋下单,返回收银台 URL) -Future createMemberOrder(WidgetRef ref, int planId) async { - final api = ref.read(apiClientProvider); - final data = - await api.post>('/member/order/create', {'plan_id': planId}); - return OrderResult( - orderNo: data['order_no'] as String? ?? '', - payUrl: data['pay_url'] as String? ?? '', - ); -} - -/// 订单状态(支付页 2s 轮询):pending | paid | closed -Future fetchOrderStatus(WidgetRef ref, String orderNo) async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/member/order/status', - query: {'order_no': orderNo}); - return data['status'] as String? ?? ''; -} diff --git a/app/lib/features/member/pay_page.dart b/app/lib/features/member/pay_page.dart deleted file mode 100644 index c4eefa4..0000000 --- a/app/lib/features/member/pay_page.dart +++ /dev/null @@ -1,152 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../../shared/app_toast.dart'; -import 'member_provider.dart'; - -class PayArgs { - final String orderNo; - final String payUrl; - - const PayArgs({required this.orderNo, required this.payUrl}); -} - -enum PayPhase { launching, paying, paid, timeout, failed } - -/// 支付页:打开系统浏览器收银台,2s 轮询订单状态(上限 60s) -class PayPage extends ConsumerStatefulWidget { - final PayArgs args; - - const PayPage({super.key, required this.args}); - - @override - ConsumerState createState() => _PayPageState(); -} - -class _PayPageState extends ConsumerState { - PayPhase _phase = PayPhase.launching; - Timer? _timer; - int _elapsed = 0; - - @override - void initState() { - super.initState(); - _start(); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - Future _start() async { - try { - final ok = await launchUrl(Uri.parse(widget.args.payUrl), - mode: LaunchMode.externalApplication); - if (!ok) { - setState(() => _phase = PayPhase.failed); - return; - } - setState(() => _phase = PayPhase.paying); - } catch (e) { - setState(() => _phase = PayPhase.failed); - return; - } - _timer = Timer.periodic(const Duration(seconds: 2), (_) => _check()); - } - - Future _check() async { - _elapsed += 2; - try { - final status = await fetchOrderStatus(ref, widget.args.orderNo); - if (status == 'paid') { - _timer?.cancel(); - if (!mounted) return; - setState(() => _phase = PayPhase.paid); - showToast('会员开通成功'); - return; - } - if (_elapsed >= 60) { - _timer?.cancel(); - if (!mounted) return; - setState(() => _phase = PayPhase.timeout); - } - } catch (_) { - // 轮询失败不中断,下次再试 - } - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Scaffold( - appBar: AppBar(title: const Text('会员支付')), - body: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - switch (_phase) { - PayPhase.launching || - PayPhase.paying => Column(children: [ - const CircularProgressIndicator(), - const SizedBox(height: 16), - const Text('请在浏览器中完成支付,正在确认结果…'), - const SizedBox(height: 8), - Text('订单号 ${widget.args.orderNo}', - style: const TextStyle(color: Colors.grey, fontSize: 12)), - const SizedBox(height: 16), - OutlinedButton( - onPressed: () => _check(), - child: const Text('我已完成支付'), - ), - ]), - PayPhase.paid => Column(children: [ - Icon(Icons.check_circle, size: 64, color: scheme.primary), - const SizedBox(height: 12), - const Text('支付成功,会员已开通!', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 16), - FilledButton( - onPressed: () => context.pop(), - child: const Text('返回会员中心'), - ), - ]), - PayPhase.timeout => Column(children: [ - Icon(Icons.hourglass_empty, size: 64, color: Colors.orange), - const SizedBox(height: 12), - const Text('支付结果确认中'), - const SizedBox(height: 8), - const Text('可稍后到会员中心查看开通状态,以支付结果为准', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey, fontSize: 12)), - const SizedBox(height: 16), - OutlinedButton( - onPressed: () => context.pop(), - child: const Text('返回'), - ), - ]), - PayPhase.failed => Column(children: [ - Icon(Icons.error_outline, size: 64, color: scheme.error), - const SizedBox(height: 12), - const Text('无法打开支付页面'), - const SizedBox(height: 16), - OutlinedButton( - onPressed: () => context.pop(), - child: const Text('返回'), - ), - ]), - }, - ], - ), - ), - ), - ); - } -} diff --git a/app/lib/features/mine/mine_page.dart b/app/lib/features/mine/mine_page.dart deleted file mode 100644 index dc4d867..0000000 --- a/app/lib/features/mine/mine_page.dart +++ /dev/null @@ -1,457 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/ads/ads_service.dart'; -import '../../core/auth/auth_provider.dart'; -import '../../core/config/app_config.dart'; -import '../../features/cps/cps_provider.dart'; -import '../../shared/app_toast.dart'; -import '../../shared/widgets/loading_view.dart'; -import '../member/member_provider.dart'; -import '../member/pay_page.dart'; - -/// 我的:个人信息管理(会员卡/免费权益/最近优惠 + 形象/衣橱入口 + 退出登录) -class MinePage extends ConsumerStatefulWidget { - const MinePage({super.key}); - - @override - ConsumerState createState() => _MinePageState(); -} - -class _MinePageState extends ConsumerState { - bool _rewarding = false; - - // web 上无 Platform(dart:io 不可用),且 web 端默认走系统浏览器收银台 - bool get _isIOS => - !kIsWeb && defaultTargetPlatform == TargetPlatform.iOS; - - Future _openPlans() async { - final plans = await showModalBottomSheet( - context: context, - builder: (ctx) => const _PlanSheet(), - ); - if (plans == null || !mounted) return; - try { - final result = await createMemberOrder(ref, plans.id); - if (!mounted || result.payUrl.isEmpty) return; - await context.push( - '/pay', extra: PayArgs(orderNo: result.orderNo, payUrl: result.payUrl)); - ref.read(memberProvider.notifier).refresh(); - } catch (e) { - if (!mounted) return; - showToast(e.toString().replaceFirst('Exception: ', '')); - } - } - - Future _claimReward(String adType, String successMsg) async { - if (_rewarding) return; - final ads = ref.read(adsServiceProvider); - if (!ads.enabled) { - showToast('广告功能暂未开通'); - return; - } - setState(() => _rewarding = true); - try { - final watched = await ads.showRewarded(); - if (!watched) { - showToast('未完整观看,无法领取'); - return; - } - final remaining = - await ref.read(memberProvider.notifier).claimReward(adType); - if (!mounted) return; - showToast('$successMsg(今日剩余 $remaining 次)'); - } catch (e) { - if (!mounted) return; - showToast(e.toString().replaceFirst('Exception: ', '')); - } finally { - if (mounted) setState(() => _rewarding = false); - } - } - - @override - Widget build(BuildContext context) { - final member = ref.watch(memberProvider); - - return Scaffold( - appBar: AppBar(title: const Text('我的')), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - _MemberCard( - member: member, - isIOS: _isIOS, - onOpenPlans: _openPlans, - ), - const SizedBox(height: 12), - const _RecentDealsCard(), - const SizedBox(height: 16), - Card( - child: Column( - children: [ - ListTile( - leading: const Icon(Icons.face_retouching_natural), - title: const Text('我的形象'), - subtitle: const Text('拍摄照片 · 身形参数 · 生成 3D 化身'), - trailing: - const Icon(Icons.chevron_right, color: Colors.grey), - onTap: () => context.push('/avatar-build'), - ), - const Divider(height: 1, indent: 56), - ListTile( - leading: const Icon(Icons.checkroom), - title: const Text('我的衣橱'), - subtitle: const Text('管理服装单品,AI 生成时自动搭配'), - trailing: - const Icon(Icons.chevron_right, color: Colors.grey), - onTap: () => context.push('/wardrobe'), - ), - ], - ), - ), - if (member.value?.isVip == false) ...[ - const SizedBox(height: 12), - _AdsRewardCard( - rewarding: _rewarding, - onClaim: (adType, msg) => _claimReward(adType, msg), - ), - ], - const SizedBox(height: 24), - OutlinedButton.icon( - onPressed: () async { - await ref.read(authProvider.notifier).logout(); - if (context.mounted) context.go('/login'); - }, - icon: const Icon(Icons.logout), - label: const Text('退出登录'), - style: OutlinedButton.styleFrom(foregroundColor: Colors.red), - ), - ], - ), - ); - } -} - -class _MemberCard extends ConsumerWidget { - final AsyncValue member; - final bool isIOS; - final VoidCallback onOpenPlans; - - const _MemberCard( - {required this.member, required this.isIOS, required this.onOpenPlans}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scheme = Theme.of(context).colorScheme; - return Card( - color: scheme.primaryContainer, - child: Padding( - padding: const EdgeInsets.all(16), - child: member.when( - loading: () => const Text('加载会员状态...', - style: TextStyle(fontSize: 13)), - error: (e, _) => Text('会员状态加载失败:$e', - style: const TextStyle(fontSize: 12)), - data: (m) { - if (m.isVip) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Icon(Icons.workspace_premium, - size: 30, color: scheme.primary), - const SizedBox(width: 10), - const Text('形象会员', - style: TextStyle( - fontSize: 17, fontWeight: FontWeight.bold)), - const Spacer(), - Chip( - label: Text(m.planName), - labelStyle: - TextStyle(color: scheme.primary, fontSize: 12), - visualDensity: VisualDensity.compact, - ), - ]), - const SizedBox(height: 6), - Text('有效期至 ${m.expireAt}', - style: - TextStyle(fontSize: 12, color: scheme.primary)), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: FilledButton.tonalIcon( - onPressed: () => _showMemberCode(context, m), - icon: const Icon(Icons.qr_code_2, size: 18), - label: const Text('会员码'), - ), - ), - if (benefitTexts(m).isNotEmpty) ...[ - const SizedBox(height: 8), - Wrap( - spacing: 6, - runSpacing: 6, - children: [ - for (final b in benefitTexts(m)) - Chip( - label: Text(b), - labelStyle: const TextStyle(fontSize: 11), - visualDensity: VisualDensity.compact, - ), - ], - ), - ], - ], - ); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Row(children: [ - Icon(Icons.workspace_premium, size: 30), - SizedBox(width: 10), - Text('形象会员', - style: TextStyle( - fontSize: 17, fontWeight: FontWeight.bold)), - ]), - const SizedBox(height: 6), - const Text('会员专享:无限次效果图生成 · 优先 AI 方案 · 门店专属折扣', - style: TextStyle(fontSize: 12)), - const SizedBox(height: 10), - if (isIOS) - const Text('iOS 端暂不支持充值(App Store 政策),可观看广告获得体验会员', - style: TextStyle(fontSize: 11, color: Colors.grey)) - else - FilledButton.icon( - onPressed: onOpenPlans, - icon: const Icon(Icons.payment, size: 18), - label: const Text('开通会员'), - ), - ], - ); - }, - ), - ), - ); - } -} - -/// 会员码(纯客户端展示,门店出示核销) -void _showMemberCode(BuildContext context, MemberInfo m) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('会员码'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 120, - height: 120, - decoration: BoxDecoration( - color: Theme.of(ctx).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: const Icon(Icons.qr_code_2, size: 96), - ), - const SizedBox(height: 12), - Text('${m.planName} · 有效期至 ${m.expireAt}', - style: const TextStyle(fontSize: 12, color: Colors.grey)), - const SizedBox(height: 4), - const Text('到店出示即可享受会员价与专属折扣', - style: TextStyle(fontSize: 12)), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('关闭'), - ), - ], - ), - ); -} - -/// 最近优惠卡:接口错误或空列表时整卡隐藏 -class _RecentDealsCard extends ConsumerWidget { - const _RecentDealsCard(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final deals = ref.watch(cpsRecentProvider).value; - if (deals == null || deals.isEmpty) return const SizedBox.shrink(); - return Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('最近优惠', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - for (final p in deals.take(5)) - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: p.coverUrl.isEmpty - ? const SizedBox( - width: 40, - height: 40, - child: Icon(Icons.shopping_bag_outlined, - color: Colors.grey), - ) - : Image.network( - AppConfig.resolveUrl(p.coverUrl), - width: 40, - height: 40, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => const SizedBox( - width: 40, - height: 40, - child: Icon(Icons.broken_image_outlined, - color: Colors.grey), - ), - ), - ), - title: Text(p.name, - maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text( - '¥${p.priceText}${p.shopName.isNotEmpty ? ' · ${p.shopName}' : ''}', - maxLines: 1, - overflow: TextOverflow.ellipsis), - trailing: - const Icon(Icons.chevron_right, size: 18, color: Colors.grey), - onTap: () => context.push('/cps-product-list', - extra: CpsListArgs( - title: '最近优惠', - source: p.source, - categoryCode: p.categoryCode, - city: p.city, - scene: 'member_benefit', - )), - ), - ], - ), - ), - ); - } -} - -class _AdsRewardCard extends ConsumerWidget { - final bool rewarding; - final void Function(String adType, String msg) onClaim; - - const _AdsRewardCard({required this.rewarding, required this.onClaim}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - return Card( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('免费获取权益', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.ondemand_video, color: Colors.deepPurple), - title: const Text('看视频 · 效果图 +1'), - subtitle: const Text('每日最多 2 次,次日重置'), - trailing: OutlinedButton( - onPressed: rewarding - ? null - : () => onClaim('effect_extra', '已获得 1 次效果图'), - child: const Text('看视频'), - ), - ), - ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.ondemand_video, color: Colors.teal), - title: const Text('看视频 · 体验会员 1 天'), - subtitle: const Text('每日最多 1 次,含无限效果图'), - trailing: OutlinedButton( - onPressed: rewarding - ? null - : () => onClaim('vip_trial', '已获得 1 天体验会员'), - child: const Text('看视频'), - ), - ), - ], - ), - ), - ); - } -} - -class _PlanSheet extends ConsumerWidget { - const _PlanSheet(); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final plans = ref.watch(memberPlanProvider); - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const Text('选择会员套餐', - textAlign: TextAlign.center, - style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - plans.when( - loading: () => const Padding( - padding: EdgeInsets.all(24), child: LoadingView()), - error: (e, _) => Text('套餐加载失败:$e', - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.grey)), - data: (list) => list.isEmpty - ? const Padding( - padding: EdgeInsets.all(24), - child: Text('暂未开放套餐', textAlign: TextAlign.center), - ) - : Column( - mainAxisSize: MainAxisSize.min, - children: [ - for (final p in list) - ListTile( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8)), - tileColor: Theme.of(context) - .colorScheme - .primaryContainer - .withValues(alpha: 0.5), - title: Text(p.name, - style: const TextStyle( - fontSize: 15, fontWeight: FontWeight.w600)), - subtitle: Text( - '${p.durationDays} 天 · ${p.features.map((f) => benefitLabels[f] ?? f).join(' · ')}', - style: const TextStyle(fontSize: 12), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - trailing: Text(p.priceText, - style: TextStyle( - color: - Theme.of(context).colorScheme.primary, - fontSize: 16, - fontWeight: FontWeight.bold)), - onTap: () => Navigator.pop(context, p), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/app/lib/features/outfit/outfit_page.dart b/app/lib/features/outfit/outfit_page.dart deleted file mode 100644 index 58fdb59..0000000 --- a/app/lib/features/outfit/outfit_page.dart +++ /dev/null @@ -1,317 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../shared/app_toast.dart'; -import '../../shared/widgets/empty_view.dart'; -import '../../shared/widgets/error_view.dart'; -import '../../shared/widgets/loading_view.dart'; -import 'outfit_provider.dart'; - -/// 穿搭:任务参数(日期范围/地点)→ 生成造型方案;个人信息在「我的形象」管理 -class OutfitPage extends ConsumerStatefulWidget { - const OutfitPage({super.key}); - - @override - ConsumerState createState() => _OutfitPageState(); -} - -class _OutfitPageState extends ConsumerState { - DateTime? _startDate; - DateTime? _endDate; - final _locationCtrl = TextEditingController(); - String _occasion = '通勤'; - bool _submitting = false; - - static const _occasions = ['通勤', '约会', '聚会', '运动']; - - @override - void dispose() { - _locationCtrl.dispose(); - super.dispose(); - } - - Future _pickStartDate() async { - final now = DateTime.now(); - final date = await showDatePicker( - context: context, - firstDate: now, - lastDate: now.add(const Duration(days: 30)), - initialDate: _startDate ?? now, - ); - if (date == null) return; - setState(() { - _startDate = date; - if (_endDate != null && _endDate!.isBefore(date)) { - _endDate = date; - } - }); - } - - Future _pickEndDate() async { - final now = DateTime.now(); - final first = _startDate ?? now; - final date = await showDatePicker( - context: context, - firstDate: first, - lastDate: first.add(const Duration(days: 30)), - initialDate: _endDate ?? first, - ); - if (date != null) setState(() => _endDate = date); - } - - String _fmt(DateTime d) => - '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; - - Future _generate() async { - final start = _startDate; - final end = _endDate; - final location = _locationCtrl.text.trim(); - if (start == null) { - showToast('请选择开始日期'); - return; - } - if (end == null) { - showToast('请选择结束日期'); - return; - } - if (location.isEmpty) { - showToast('请输入地点'); - return; - } - if (end.difference(start).inDays < 1) { - showToast('日期范围至少 1 天'); - return; - } - setState(() => _submitting = true); - final ok = await ref.read(generateProvider.notifier).generate( - startDate: _fmt(start), - endDate: _fmt(end), - location: location, - occasion: _occasion, - ); - if (!mounted) return; - setState(() => _submitting = false); - if (ok) { - showToast('生成完成'); - ref.read(generateProvider.notifier).reset(); - context.push('/plan-viewer'); - } - } - - @override - Widget build(BuildContext context) { - final plans = ref.watch(outfitPlanProvider); - final gen = ref.watch(generateProvider); - - return Scaffold( - appBar: AppBar(title: const Text('造型穿搭')), - body: RefreshIndicator( - onRefresh: () => ref.read(outfitPlanProvider.notifier).refresh(), - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: _submitting ? null : _pickStartDate, - icon: const Icon(Icons.date_range_outlined), - label: Text(_startDate == null - ? '开始日期' - : _fmt(_startDate!)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton.icon( - onPressed: _submitting ? null : _pickEndDate, - icon: const Icon(Icons.date_range_outlined), - label: Text(_endDate == null - ? '结束日期' - : _fmt(_endDate!)), - ), - ), - ], - ), - const SizedBox(height: 12), - TextField( - controller: _locationCtrl, - enabled: !_submitting, - decoration: const InputDecoration( - labelText: '地点(如:上海·外滩)', - hintText: '影响天气与穿搭建议', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - const Text('场景', - style: - TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - for (final o in _occasions) - ChoiceChip( - label: Text(o), - selected: _occasion == o, - onSelected: _submitting - ? null - : (_) => setState(() => _occasion = o), - ), - ], - ), - const SizedBox(height: 16), - if (gen.phase == GeneratePhase.running) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - children: [ - const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2)), - const SizedBox(width: 8), - Expanded( - child: Text(gen.statusText, - style: const TextStyle(fontSize: 13)), - ), - ], - ), - ), - SizedBox( - width: double.infinity, - child: FilledButton( - onPressed: _submitting ? null : _generate, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - textStyle: const TextStyle( - fontSize: 16, fontWeight: FontWeight.w600), - ), - child: const Text('生成穿搭方案'), - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - Text('已有方案', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - plans.when( - loading: () => const Padding( - padding: EdgeInsets.only(top: 48), - child: LoadingView(text: '加载方案...')), - error: (e, _) => Padding( - padding: const EdgeInsets.only(top: 48), - child: ErrorView( - message: e.toString().replaceFirst('Exception: ', ''), - onRetry: () => ref.read(outfitPlanProvider.notifier).refresh(), - ), - ), - data: (list) { - if (list.isEmpty) { - return const Padding( - padding: EdgeInsets.only(top: 48), - child: EmptyView( - message: '还没有穿搭方案,先设置日期与地点生成吧'), - ); - } - return Column( - children: [ - for (final p in list) ...[ - _PlanCard(plan: p), - const SizedBox(height: 8), - ], - ], - ); - }, - ), - ], - ), - ), - ); - } -} - -class _PlanCard extends ConsumerWidget { - final OutfitPlanInfo plan; - - const _PlanCard({required this.plan}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scheme = Theme.of(context).colorScheme; - return Card( - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => context.push('/plan-viewer'), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: scheme.primaryContainer, - borderRadius: BorderRadius.circular(10), - ), - child: Icon(Icons.checkroom, color: scheme.primary), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text(plan.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 15)), - ), - if (plan.isMain) ...[ - const SizedBox(width: 6), - const Chip( - label: Text('主方案'), - labelStyle: - TextStyle(color: Colors.white, fontSize: 11), - backgroundColor: Colors.indigo, - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - ), - ], - ], - ), - const SizedBox(height: 4), - Text( - '${plan.dateRange} · ${plan.location} · 评分 ${plan.score}' - '${plan.fromAi ? ' · AI 生成' : ' · 规则生成'}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - const TextStyle(color: Colors.grey, fontSize: 12), - ), - ], - ), - ), - const Icon(Icons.chevron_right, color: Colors.grey), - ], - ), - ), - ), - ); - } -} diff --git a/app/lib/features/outfit/outfit_provider.dart b/app/lib/features/outfit/outfit_provider.dart deleted file mode 100644 index de3e16c..0000000 --- a/app/lib/features/outfit/outfit_provider.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/auth/auth_provider.dart'; - -class OutfitPlanInfo { - final int id; - final String title; - final String source; // ai / rule - final int score; - final int mainFlag; - final String dateRange; - final String location; - final int hairstyleId; - final String hairColor; - final String occasion; - - const OutfitPlanInfo({ - required this.id, - required this.title, - required this.source, - required this.score, - required this.mainFlag, - required this.dateRange, - required this.location, - required this.hairstyleId, - required this.hairColor, - this.occasion = '', - }); - - bool get isMain => mainFlag == 1; - bool get fromAi => source == 'ai'; -} - -class PlanItemInfo { - final int id; - final String slot; // 上衣/下装/鞋/配饰 - final String source; // wardrobe / new - final int wardrobeItemId; - final String name; - final String desc; - - const PlanItemInfo({ - required this.id, - required this.slot, - required this.source, - required this.wardrobeItemId, - required this.name, - required this.desc, - }); - - bool get fromWardrobe => source == 'wardrobe'; -} - -class PlanEffectImageInfo { - final int id; - final String angle; // front / side / back - final String url; - final String status; // pending / rendering / done / failed - - const PlanEffectImageInfo({ - required this.id, - required this.angle, - required this.url, - required this.status, - }); - - bool get done => status == 'done' && url.isNotEmpty; -} - -class PlanDetail { - final OutfitPlanInfo plan; - final List items; - final List images; - final String hairstyleName; - - const PlanDetail({ - required this.plan, - required this.items, - required this.images, - required this.hairstyleName, - }); -} - -/// 方案列表 -class OutfitPlanNotifier extends AsyncNotifier> { - @override - Future> build() async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/outfit/plan/list'); - final list = data['list'] as List? ?? []; - return list - .map((e) => OutfitPlanInfo( - id: (e['id'] as num).toInt(), - title: e['title'] as String? ?? '', - source: e['source'] as String? ?? 'rule', - score: (e['score'] as num?)?.toInt() ?? 0, - mainFlag: (e['main_flag'] as num?)?.toInt() ?? 0, - dateRange: e['date_range'] as String? ?? '', - location: e['location'] as String? ?? '', - hairstyleId: (e['hairstyle_id'] as num?)?.toInt() ?? 0, - hairColor: e['hair_color'] as String? ?? '', - )) - .toList(); - } - - Future refresh() async { - state = const AsyncLoading(); - state = await AsyncValue.guard(build); - } - - Future selectMain(int planId) async { - final api = ref.read(apiClientProvider); - await api.post('/outfit/plan/select-main', {'plan_id': planId}); - await refresh(); - } - - Future review(int planId, String action) async { - final api = ref.read(apiClientProvider); - await api.post('/outfit/plan/review', {'plan_id': planId, 'action': action}); - } -} - -final outfitPlanProvider = - AsyncNotifierProvider>( - OutfitPlanNotifier.new); - -/// 生成任务状态(轮询后端任务状态机) -enum GeneratePhase { idle, running, done, failed } - -class GenerateState { - final GeneratePhase phase; - final String statusText; - final String error; - - const GenerateState({ - this.phase = GeneratePhase.idle, - this.statusText = '', - this.error = '', - }); -} - -class GenerateNotifier extends Notifier { - @override - GenerateState build() => const GenerateState(); - - Future generate({ - required String startDate, - required String endDate, - required String location, - required String occasion, - }) async { - state = - const GenerateState(phase: GeneratePhase.running, statusText: '任务创建中...'); - final api = ref.read(apiClientProvider); - final data = await api.post>('/outfit/generate', { - 'start_date': startDate, - 'end_date': endDate, - 'location': location, - 'occasion': occasion, - }); - final taskId = (data['task_id'] as num).toInt(); - - for (var i = 0; i < 90; i++) { - await Future.delayed(const Duration(seconds: 2)); - final st = await api.get>('/outfit/task/status', - query: {'task_id': taskId}); - final status = st['status'] as String? ?? ''; - final err = st['error'] as String? ?? ''; - state = GenerateState( - phase: GeneratePhase.running, statusText: _statusText(status)); - if (status == 'done') { - state = const GenerateState(phase: GeneratePhase.done, statusText: '完成'); - break; - } - if (status == 'failed') { - state = GenerateState( - phase: GeneratePhase.failed, - error: err.isEmpty ? '生成失败,请稍后重试' : err); - return false; - } - } - await ref.read(outfitPlanProvider.notifier).refresh(); - return state.phase == GeneratePhase.done; - } - - String _statusText(String status) { - switch (status) { - case 'pending': - return '排队中...'; - case 'planning': - return 'AI 规划穿搭中...'; - case 'scoring': - return '方案评分中...'; - case 'rendering': - return '生成效果图中...'; - case 'failed': - return '生成失败'; - default: - return '处理中...'; - } - } - - void reset() => state = const GenerateState(); -} - -final generateProvider = - NotifierProvider(GenerateNotifier.new); - -/// 方案详情 -final planDetailProvider = FutureProvider.family((ref, planId) async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/outfit/plan/detail', - query: {'plan_id': planId}); - final planData = data['plan'] as Map? ?? {}; - final items = (data['items'] as List? ?? []) - .map((e) => PlanItemInfo( - id: (e['id'] as num).toInt(), - slot: e['slot'] as String? ?? '', - source: e['source'] as String? ?? 'new', - wardrobeItemId: (e['wardrobe_item_id'] as num?)?.toInt() ?? 0, - name: e['name'] as String? ?? '', - desc: e['desc'] as String? ?? '', - )) - .toList(); - final images = (data['images'] as List? ?? []) - .map((e) => PlanEffectImageInfo( - id: (e['id'] as num).toInt(), - angle: e['angle'] as String? ?? '', - url: e['url'] as String? ?? '', - status: e['status'] as String? ?? 'pending', - )) - .toList(); - final hairstyle = data['hairstyle'] as Map?; - return PlanDetail( - plan: OutfitPlanInfo( - id: (planData['id'] as num).toInt(), - title: planData['title'] as String? ?? '', - source: planData['source'] as String? ?? 'rule', - score: (planData['score'] as num?)?.toInt() ?? 0, - mainFlag: (planData['main_flag'] as num?)?.toInt() ?? 0, - dateRange: planData['date_range'] as String? ?? '', - location: planData['location'] as String? ?? '', - hairstyleId: (planData['hairstyle_id'] as num?)?.toInt() ?? 0, - hairColor: planData['hair_color'] as String? ?? '', - occasion: planData['occasion'] as String? ?? '', - ), - items: items, - images: images, - hairstyleName: hairstyle?['name'] as String? ?? '', - ); -}); diff --git a/app/lib/features/outfit/plan_effect_page.dart b/app/lib/features/outfit/plan_effect_page.dart deleted file mode 100644 index a3e4a9a..0000000 --- a/app/lib/features/outfit/plan_effect_page.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/ads/ads_service.dart'; -import '../../core/config/app_config.dart'; -import '../../shared/widgets/empty_view.dart'; -import '../../shared/widgets/loading_view.dart'; -import 'outfit_provider.dart'; - -const _angleLabels = { - 'front': '正面', - 'side': '侧面', - 'back': '背面', -}; - -/// 效果图查看:3 视角(正面/侧面/背面)切换 -class PlanEffectPage extends ConsumerStatefulWidget { - final List images; - - const PlanEffectPage({super.key, required this.images}); - - @override - ConsumerState createState() => _PlanEffectPageState(); -} - -class _PlanEffectPageState extends ConsumerState { - String? _selected; // 当前角度,null = 展示全部 - - @override - Widget build(BuildContext context) { - final done = widget.images.where((i) => i.done).toList(); - final pending = widget.images.where((i) => !i.done).toList(); - final angles = widget.images.map((i) => i.angle).toSet(); - final ads = ref.watch(adsServiceProvider); - - return Scaffold( - appBar: AppBar(title: const Text('效果图')), - body: Column( - children: [ - if (angles.length > 1) - SizedBox( - height: 48, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - children: [ - Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: const Text('全部'), - selected: _selected == null, - onSelected: (_) => setState(() => _selected = null), - ), - ), - for (final a in angles) - Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(_angleLabels[a] ?? a), - selected: _selected == a, - onSelected: (_) => setState(() => _selected = a), - ), - ), - ], - ), - ), - Expanded( - child: done.isEmpty - ? (pending.isEmpty - ? const EmptyView( - message: '暂无效果图,选定主方案后自动生成(每日限 3 次)') - : const LoadingView(text: '效果图生成中,请稍后刷新...')) - : GridView.builder( - padding: const EdgeInsets.all(12), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 0.8, - ), - itemCount: _selected == null - ? done.length - : done.where((i) => i.angle == _selected).length, - itemBuilder: (ctx, idx) { - final shown = _selected == null - ? done - : done.where((i) => i.angle == _selected).toList(); - final img = shown[idx]; - return Card( - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: Image.network( - AppConfig.resolveUrl(img.url), - fit: BoxFit.cover, - errorBuilder: (_, _, _) => const Icon( - Icons.broken_image_outlined, - size: 48, - color: Colors.grey), - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: Text(_angleLabels[img.angle] ?? img.angle, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold)), - ), - ], - ), - ); - }, - ), - ), - // 信息流广告位:广告未开通(pangleAppId 为空)不渲染 - if (ads.enabled) ads.showBanner(context), - ], - ), - ); - } -} diff --git a/app/lib/features/outfit/plan_viewer_page.dart b/app/lib/features/outfit/plan_viewer_page.dart deleted file mode 100644 index e5f473c..0000000 --- a/app/lib/features/outfit/plan_viewer_page.dart +++ /dev/null @@ -1,309 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../features/cps/cps_provider.dart'; -import '../../shared/widgets/avatar_viewer.dart'; -import '../../shared/widgets/loading_view.dart'; -import '../profile/avatar_provider.dart'; -import 'outfit_provider.dart'; - -/// 方案流:上部 3D 化身展示 + 下部方案左右滑动切换,清单含商业化入口(到店试穿/买同款) -class PlanViewerPage extends ConsumerWidget { - const PlanViewerPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final plans = ref.watch(outfitPlanProvider); - return Scaffold( - appBar: AppBar(title: const Text('穿搭方案')), - body: Column( - children: [ - _buildAvatarBar(context, ref), - Expanded( - child: plans.when( - loading: () => const LoadingView(text: '加载方案...'), - error: (e, _) => Center(child: Text('加载失败:$e')), - data: (list) { - if (list.isEmpty) { - return const Center(child: Text('暂无方案')); - } - return PageView.builder( - itemCount: list.length, - itemBuilder: (ctx, i) => _PlanDetailView( - planId: list[i].id, key: ValueKey(list[i].id)), - ); - }, - ), - ), - ], - ), - ); - } - - /// 上部:当前用户的 3D 化身展示(帧轮播,未构建时占位) - Widget _buildAvatarBar(BuildContext context, WidgetRef ref) { - final avatar = ref.watch(avatarProvider); - return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: avatar.when( - loading: () => const SizedBox( - height: 200, - child: Center(child: CircularProgressIndicator()), - ), - error: (e, _) => const SizedBox.shrink(), - data: (a) => AvatarViewer( - framesUrl: a.framesUrl, - title: '我的 3D 化身', - subtitle: a.built ? '' : '尚未构建,可在「我的」页完成照片与身形后生成', - height: 200, - ), - ), - ); - } -} - -class _PlanDetailView extends ConsumerWidget { - final int planId; - - const _PlanDetailView({required this.planId, super.key}); - - /// 商业化入口:推荐为空时也跳转(source/品类为空 → 列表页展示空态) - void _openCpsList( - BuildContext context, String title, String scene, List rec) { - final first = rec.isEmpty ? null : rec.first; - context.push('/cps-product-list', extra: CpsListArgs( - title: title, - source: first?.source ?? '', - categoryCode: first?.categoryCode ?? '', - city: first?.city ?? '', - scene: scene, - )); - } - - @override - Widget build(BuildContext context, WidgetRef ref) { - final detail = ref.watch(planDetailProvider(planId)); - final scheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.all(16), - child: detail.when( - loading: () => const LoadingView(text: '加载方案详情...'), - error: (e, _) => Center(child: Text('加载失败:$e')), - data: (d) { - final plan = d.plan; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(plan.title, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold)), - ), - if (plan.isMain) - const Chip( - label: Text('主方案'), - labelStyle: - TextStyle(color: Colors.white, fontSize: 11), - backgroundColor: Colors.indigo, - visualDensity: VisualDensity.compact, - ), - if (!plan.isMain) - Chip( - label: Text(plan.fromAi ? 'AI 生成' : '规则生成'), - labelStyle: TextStyle( - color: scheme.primary, fontSize: 11), - backgroundColor: scheme.primaryContainer, - visualDensity: VisualDensity.compact, - ), - ], - ), - const SizedBox(height: 4), - Text('评分 ${plan.score}', - style: const TextStyle(color: Colors.grey)), - const SizedBox(height: 12), - // 穿搭清单:标题行 + 场景延伸优惠入口 - Row( - children: [ - Text('穿搭清单', - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.bold, - color: scheme.primary)), - const Spacer(), - if (plan.occasion.isNotEmpty) - OutlinedButton.icon( - onPressed: () => _openCpsList(context, - '${plan.occasion}延伸优惠', 'occasion', const []), - icon: const Icon(Icons.local_offer_outlined, - size: 16), - label: const Text('延伸优惠'), - ), - ], - ), - const SizedBox(height: 8), - // 发型作为清单首项(含"做同款发型"入口) - if (d.hairstyleName.isNotEmpty || plan.hairColor.isNotEmpty) - _HairstyleEntryCard( - planId: plan.id, - hairstyleName: d.hairstyleName.isNotEmpty - ? d.hairstyleName - : '默认', - hairColor: plan.hairColor, - onOpen: (title, scene, rec) => - _openCpsList(context, title, scene, rec), - ), - if (d.items.isEmpty) - const Text('暂无穿搭单品', - style: TextStyle(color: Colors.grey)), - for (final item in d.items) - _ItemEntryCard( - planId: plan.id, - item: item, - onOpen: (title, scene, rec) => - _openCpsList(context, title, scene, rec), - ), - ], - ), - ), - ), - ], - ); - }, - ), - ); - } -} - -/// 穿搭清单项:发型(含"做同款发型"入口,推荐为空也跳转) -class _HairstyleEntryCard extends ConsumerWidget { - final int planId; - final String hairstyleName; - final String hairColor; - final void Function(String title, String scene, List rec) - onOpen; - - const _HairstyleEntryCard({ - required this.planId, - required this.hairstyleName, - required this.hairColor, - required this.onOpen, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scheme = Theme.of(context).colorScheme; - final rec = ref - .watch(cpsRecommendProvider((planId: planId, scene: 'haircut'))) - .value; - return Card( - color: scheme.primaryContainer.withValues(alpha: 0.4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ListTile( - dense: true, - leading: const Icon(Icons.content_cut, size: 20), - title: Text( - '发型:$hairstyleName${hairColor.isNotEmpty ? ' · 发色:$hairColor' : ''}', - style: const TextStyle(fontSize: 13), - ), - ), - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: () => onOpen('做同款发型', 'haircut', rec ?? []), - icon: const Icon(Icons.storefront_outlined, size: 16), - label: const Text('做同款发型'), - ), - ), - ], - ), - ); - } -} - -/// 穿搭清单项:衣橱已有单品无入口;AI 推荐新品 → 去购买(推荐为空也跳转) -class _ItemEntryCard extends ConsumerWidget { - final int planId; - final PlanItemInfo item; - final void Function(String title, String scene, List rec) - onOpen; - - const _ItemEntryCard({ - required this.planId, - required this.item, - required this.onOpen, - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final scene = item.fromWardrobe ? 'item_upgrade' : 'item_buy'; - final rec = ref - .watch(cpsRecommendProvider((planId: planId, scene: scene))) - .value; - return Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ListTile( - dense: true, - leading: Icon(_slotIcon(item.slot)), - title: Text(item.name), - subtitle: Text(item.desc), - trailing: item.fromWardrobe - ? const Chip( - label: Text('衣橱'), - labelStyle: TextStyle( - color: Colors.white, fontSize: 11), - backgroundColor: Colors.teal, - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - ) - : const Chip( - label: Text('新品'), - labelStyle: TextStyle( - color: Colors.white, fontSize: 11), - backgroundColor: Colors.orange, - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - ), - ), - // 衣橱已有单品无需入口;仅 AI 推荐新品展示购买入口 - if (!item.fromWardrobe) - Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: () => onOpen('去购买', scene, rec ?? []), - icon: const Icon(Icons.shopping_cart_outlined, size: 16), - label: const Text('去购买'), - ), - ), - ], - ), - ); - } - - IconData _slotIcon(String slot) { - switch (slot) { - case '上衣': - return Icons.checkroom; - case '下装': - return Icons.airline_seat_legroom_normal; - case '鞋': - return Icons.directions_walk; - case '配饰': - return Icons.watch_outlined; - default: - return Icons.style_outlined; - } - } -} diff --git a/app/lib/features/profile/avatar_build_flow_page.dart b/app/lib/features/profile/avatar_build_flow_page.dart deleted file mode 100644 index 2e2a29f..0000000 --- a/app/lib/features/profile/avatar_build_flow_page.dart +++ /dev/null @@ -1,545 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:image_picker/image_picker.dart'; - -import '../../shared/app_toast.dart'; -import '../../shared/widgets/loading_view.dart'; -import 'avatar_provider.dart'; -import 'body_provider.dart'; -import 'photo_upload_provider.dart'; - -/// 肤色选项(1-5,与后端 SkinTone 对应) -const skinToneColors = { - 1: Color(0xFFF6E3D4), - 2: Color(0xFFEAC9A8), - 3: Color(0xFFD9A87C), - 4: Color(0xFFB97E55), - 5: Color(0xFF8D5B38), -}; - -/// 我的形象三步流程:① 拍摄三视角全身照 → ② 填写身形参数 → ③ 生成 3D 化身 -class AvatarBuildFlowPage extends ConsumerStatefulWidget { - const AvatarBuildFlowPage({super.key}); - - @override - ConsumerState createState() => - _AvatarBuildFlowPageState(); -} - -class _AvatarBuildFlowPageState extends ConsumerState { - final _picker = ImagePicker(); - int _step = 1; - int? _uploadingType; - bool _busy = false; - String? _buildError; - - int _height = 170; - int _weight = 60; - int _skinTone = 3; - int _bust = 88; - int _waist = 70; - int _hip = 92; - int _shoulder = 42; - - @override - void initState() { - super.initState(); - final cur = ref.read(bodyProvider).value; - if (cur != null) { - _height = cur.height; - _weight = cur.weight; - _skinTone = cur.skinTone; - _bust = cur.bust; - _waist = cur.waist; - _hip = cur.hip; - _shoulder = cur.shoulder; - } - } - - Future _pickAndUpload(int type) async { - final source = await showModalBottomSheet( - context: context, - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.photo_camera_outlined), - title: const Text('拍照'), - onTap: () => Navigator.pop(ctx, ImageSource.camera), - ), - ListTile( - leading: const Icon(Icons.photo_library_outlined), - title: const Text('从相册选择'), - onTap: () => Navigator.pop(ctx, ImageSource.gallery), - ), - ], - ), - ), - ); - if (source == null || !mounted) return; - - final file = await _picker.pickImage( - source: source, maxWidth: 2048, imageQuality: 85); - if (file == null) return; - - setState(() => _uploadingType = type); - try { - await ref.read(photoProvider.notifier).upload(type, file.path); - if (!mounted) return; - showToast('${PhotoType.labels[type]}上传成功'); - } catch (e) { - if (!mounted) return; - showToast('上传失败:${e.toString().replaceFirst('Exception: ', '')}'); - } finally { - if (mounted) setState(() => _uploadingType = null); - } - } - - Future _saveAndBuild() async { - setState(() { - _busy = true; - _buildError = null; - }); - try { - await ref - .read(bodyProvider.notifier) - .save(_height, _weight, _skinTone, _bust, _waist, _hip, _shoulder); - await ref.read(avatarProvider.notifier).buildAvatar(); - if (!mounted) return; - final a = ref.read(avatarProvider).value; - if (a?.built == true) { - showToast('3D 化身已生成'); - context.go('/avatar-viewer'); - } else if (a?.buildStatus == 'failed') { - setState(() => _buildError = a!.error); - } else { - showToast('构建超时,请稍后重试'); - } - } catch (e) { - if (mounted) { - setState( - () => _buildError = e.toString().replaceFirst('Exception: ', '')); - } - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - final built = ref.watch(avatarProvider).value?.built ?? false; - return Scaffold( - appBar: AppBar( - title: const Text('我的形象'), - actions: [ - if (built) - IconButton( - tooltip: '查看已生成的 3D 化身', - icon: const Icon(Icons.view_in_ar), - onPressed: () => context.go('/avatar-viewer'), - ), - ], - ), - body: Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: _StepIndicator( - current: _step, - onTap: (i) => setState(() => _step = i)), - ), - Expanded( - child: switch (_step) { - 1 => _buildStep1(), - 2 => _buildStep2(), - _ => _buildStep3(), - }, - ), - ], - ), - ); - } - - // ---- 步 1:拍摄三视角全身照 ---- - Widget _buildStep1() { - final photos = ref.watch(photoProvider); - return photos.when( - loading: () => const LoadingView(text: '加载照片状态...'), - error: (e, _) => Center(child: Text('加载失败:$e')), - data: (list) { - final allDone = - PhotoType.all.every((t) => list.any((p) => p.type == t)); - return ListView( - padding: const EdgeInsets.all(16), - children: [ - const Text('拍摄 3 张全身照(正面/侧面/背面),用于构建你的 3D 形象', - style: TextStyle(color: Colors.grey, fontSize: 13)), - const SizedBox(height: 12), - for (final type in PhotoType.all) ...[ - _PhotoCard( - type: type, - uploaded: list.any((p) => p.type == type), - uploading: _uploadingType == type, - onTap: - _uploadingType == null ? () => _pickAndUpload(type) : null, - ), - const SizedBox(height: 12), - ], - const SizedBox(height: 8), - FilledButton.icon( - onPressed: allDone && _uploadingType == null - ? () => setState(() => _step = 2) - : null, - icon: const Icon(Icons.arrow_forward), - label: Text(allDone ? '照片已完成,下一步' : '还需拍摄剩余照片'), - ), - ], - ); - }, - ); - } - - // ---- 步 2:身形参数 ---- - Widget _buildStep2() { - final body = ref.watch(bodyProvider); - return body.when( - loading: () => const LoadingView(text: '加载身形参数...'), - error: (e, _) => Center(child: Text('加载失败:$e')), - data: (_) => ListView( - padding: const EdgeInsets.all(16), - children: [ - _SliderRow( - label: '身高', - value: '$_height cm', - min: 145, - max: 200, - current: _height.toDouble(), - onChanged: (v) => setState(() => _height = v.round()), - ), - _SliderRow( - label: '体重', - value: '$_weight kg', - min: 40, - max: 120, - current: _weight.toDouble(), - onChanged: (v) => setState(() => _weight = v.round()), - ), - _SliderRow( - label: '胸围', - value: '$_bust cm', - min: 60, - max: 130, - current: _bust.toDouble(), - onChanged: (v) => setState(() => _bust = v.round()), - ), - _SliderRow( - label: '腰围', - value: '$_waist cm', - min: 50, - max: 110, - current: _waist.toDouble(), - onChanged: (v) => setState(() => _waist = v.round()), - ), - _SliderRow( - label: '臀围', - value: '$_hip cm', - min: 60, - max: 130, - current: _hip.toDouble(), - onChanged: (v) => setState(() => _hip = v.round()), - ), - _SliderRow( - label: '肩宽', - value: '$_shoulder cm', - min: 30, - max: 60, - current: _shoulder.toDouble(), - onChanged: (v) => setState(() => _shoulder = v.round()), - ), - const SizedBox(height: 16), - const Text('肤色', style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Row( - children: [ - for (var i = 1; i <= 5; i++) - Expanded( - child: GestureDetector( - onTap: () => setState(() => _skinTone = i), - child: Container( - height: 48, - margin: const EdgeInsets.symmetric(horizontal: 3), - decoration: BoxDecoration( - color: skinToneColors[i], - borderRadius: BorderRadius.circular(10), - border: Border.all( - width: _skinTone == i ? 3 : 1, - color: _skinTone == i - ? Theme.of(context).colorScheme.primary - : Colors.grey.shade300, - ), - ), - child: _skinTone == i - ? const Icon(Icons.check, color: Colors.white) - : null, - ), - ), - ), - ], - ), - const SizedBox(height: 32), - FilledButton.icon( - onPressed: () => setState(() => _step = 3), - icon: const Icon(Icons.arrow_forward), - label: const Text('下一步'), - ), - ], - ), - ); - } - - // ---- 步 3:确认并生成 ---- - Widget _buildStep3() { - final photos = ref.watch(photoProvider); - final photoDone = photos.value?.isNotEmpty ?? false; - final doneCount = photoDone - ? PhotoType.all - .where((t) => - photos.value!.any((p) => p.type == t)) - .length - : 0; - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('形象资料', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 15, - color: Theme.of(context).colorScheme.primary)), - const SizedBox(height: 8), - _InfoLine( - label: '照片', - value: '$doneCount/${PhotoType.all.length} 已上传'), - _InfoLine( - label: '身高', - value: '$_height cm'), - _InfoLine( - label: '体重', - value: '$_weight kg'), - _InfoLine( - label: '胸围/腰围/臀围', - value: '$_bust / $_waist / $_hip cm'), - _InfoLine(label: '肩宽', value: '$_shoulder cm'), - _InfoLine(label: '肤色', value: '$_skinTone 档'), - ], - ), - ), - ), - if (_buildError != null) ...[ - const SizedBox(height: 12), - Card( - color: Theme.of(context).colorScheme.errorContainer, - child: Padding( - padding: const EdgeInsets.all(12), - child: Text('生成失败:$_buildError', - style: TextStyle( - fontSize: 13, - color: Theme.of(context).colorScheme.onErrorContainer)), - ), - ), - ], - const SizedBox(height: 24), - FilledButton.icon( - onPressed: _busy ? null : _saveAndBuild, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14)), - icon: _busy - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.auto_awesome), - label: Text(_busy ? '正在生成 3D 化身...' : '生成我的 3D 化身'), - ), - const SizedBox(height: 8), - const Text('生成约需 1-3 分钟,请勿关闭页面;完成后可在查看页观看 3D 形象', - textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey, fontSize: 12)), - ], - ); - } -} - -/// 顶部步骤指示器:① 拍摄照片 ② 身形参数 ③ 生成 -class _StepIndicator extends StatelessWidget { - final int current; - final ValueChanged onTap; - - const _StepIndicator({required this.current, required this.onTap}); - - @override - Widget build(BuildContext context) { - const titles = ['拍摄照片', '身形参数', '生成']; - return Row( - children: [ - for (var i = 0; i < 3; i++) ...[ - if (i > 0) - const Expanded( - child: Divider(indent: 8, endIndent: 8), - ), - InkWell( - onTap: () => onTap(i + 1), - borderRadius: BorderRadius.circular(20), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Column( - children: [ - CircleAvatar( - radius: 14, - backgroundColor: i + 1 <= current - ? Theme.of(context).colorScheme.primary - : Colors.grey.shade300, - child: Text('${i + 1}', - style: TextStyle( - fontSize: 13, - color: i + 1 <= current - ? Colors.white - : Colors.grey.shade600)), - ), - const SizedBox(height: 4), - Text(titles[i], - style: TextStyle( - fontSize: 12, - fontWeight: - i + 1 == current ? FontWeight.bold : null, - color: i + 1 == current - ? Theme.of(context).colorScheme.primary - : Colors.grey)), - ], - ), - ), - ), - ], - ], - ); - } -} - -class _PhotoCard extends StatelessWidget { - final int type; - final bool uploaded; - final bool uploading; - final VoidCallback? onTap; - - const _PhotoCard({ - required this.type, - required this.uploaded, - required this.uploading, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Card( - clipBehavior: Clip.antiAlias, - child: ListTile( - onTap: onTap, - leading: CircleAvatar( - backgroundColor: - uploaded ? Colors.green.shade100 : scheme.primaryContainer, - child: uploading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2)) - : Icon(uploaded ? Icons.check : Icons.add_a_photo_outlined), - ), - title: Text(PhotoType.labels[type]!), - subtitle: Text(PhotoType.descs[type]!), - trailing: uploaded - ? const Chip( - label: Text('已上传'), - backgroundColor: Colors.green, - labelStyle: TextStyle(color: Colors.white, fontSize: 12), - ) - : null, - ), - ); - } -} - -class _SliderRow extends StatelessWidget { - final String label; - final String value; - final double min; - final double max; - final double current; - final ValueChanged onChanged; - - const _SliderRow({ - required this.label, - required this.value, - required this.min, - required this.max, - required this.current, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), - const Spacer(), - Text(value, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.bold)), - ], - ), - Slider( - value: current.clamp(min, max), - min: min, - max: max, - divisions: (max - min).round(), - label: value, - onChanged: onChanged, - ), - ], - ); - } -} - -class _InfoLine extends StatelessWidget { - final String label; - final String value; - - const _InfoLine({required this.label, required this.value}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - Text(label, style: const TextStyle(fontSize: 13)), - const Spacer(), - Text(value, - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), - ], - ), - ); - } -} diff --git a/app/lib/features/profile/avatar_provider.dart b/app/lib/features/profile/avatar_provider.dart deleted file mode 100644 index 87499d8..0000000 --- a/app/lib/features/profile/avatar_provider.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/auth/auth_provider.dart'; - -class AvatarState { - final int faceTemplateId; - final int bodyTemplateId; - final int skinToneIndex; - final String glbUrl; - final String framesUrl; - final String buildStatus; // pending / processing / done / failed - final String error; - - const AvatarState({ - this.faceTemplateId = 0, - this.bodyTemplateId = 0, - this.skinToneIndex = 0, - this.glbUrl = '', - this.framesUrl = '', - this.buildStatus = '', - this.error = '', - }); - - bool get built => buildStatus == 'done' && glbUrl.isNotEmpty; -} - -class AvatarNotifier extends AsyncNotifier { - @override - Future build() async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/avatar/get'); - return _fromData(data); - } - - AvatarState _fromData(Map data) => AvatarState( - faceTemplateId: (data['face_template_id'] as num?)?.toInt() ?? 0, - bodyTemplateId: (data['body_template_id'] as num?)?.toInt() ?? 0, - skinToneIndex: (data['skin_tone_index'] as num?)?.toInt() ?? 0, - glbUrl: data['glb_url'] as String? ?? '', - framesUrl: data['frames_url'] as String? ?? '', - buildStatus: data['build_status'] as String? ?? '', - error: data['error'] as String? ?? '', - ); - - Future refresh() async { - state = const AsyncLoading(); - state = await AsyncValue.guard(build); - } - - /// 触发构建;若服务端异步则轮询直到 done/failed - Future buildAvatar() async { - state = const AsyncLoading(); - try { - final api = ref.read(apiClientProvider); - final data = - await api.post>('/avatar/build', {}); - final status = data['status'] as String? ?? ''; - if (status == 'pending' || status == 'processing') { - await _poll(); - } else { - await refresh(); - } - } catch (e) { - state = AsyncError(e, StackTrace.current); - } - } - - Future _poll() async { - for (var i = 0; i < 30; i++) { - await Future.delayed(const Duration(seconds: 2)); - final api = ref.read(apiClientProvider); - final data = await api.get>('/avatar/get'); - final status = data['build_status'] as String? ?? ''; - if (status == 'done' || status == 'failed') { - state = AsyncData(_fromData(data)); - return; - } - } - state = AsyncError(StateError('化身构建超时,请稍后重试'), StackTrace.empty); - } -} - -final avatarProvider = - AsyncNotifierProvider(AvatarNotifier.new); diff --git a/app/lib/features/profile/avatar_viewer_page.dart b/app/lib/features/profile/avatar_viewer_page.dart deleted file mode 100644 index b408709..0000000 --- a/app/lib/features/profile/avatar_viewer_page.dart +++ /dev/null @@ -1,73 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../shared/app_toast.dart'; -import '../../shared/widgets/avatar_viewer.dart'; -import '../../shared/widgets/loading_view.dart'; -import 'avatar_provider.dart'; - -/// 3D 化身查看页 -class AvatarViewerPage extends ConsumerWidget { - const AvatarViewerPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final avatar = ref.watch(avatarProvider); - return Scaffold( - appBar: AppBar(title: const Text('我的 3D 化身')), - body: avatar.when( - loading: () => const LoadingView(text: '加载化身...'), - error: (e, _) => Center(child: Text('加载失败:$e')), - data: (a) => ListView( - padding: const EdgeInsets.all(16), - children: [ - AvatarViewer( - glbUrl: a.glbUrl, - framesUrl: a.framesUrl, - title: '我的 3D 化身', - subtitle: a.built - ? '脸型模板 ${a.faceTemplateId} · 体型模板 ${a.bodyTemplateId} · 肤色 ${a.skinToneIndex}' - : '尚未构建化身', - ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('构建说明', - style: TextStyle( - fontSize: 15, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - const Text( - '化身基于你的三视角全身照(正面/侧面/背面)由 AI 生成 3D 形象,' - '构建过程通常需要 1-3 分钟。', - style: TextStyle(fontSize: 13, color: Colors.grey), - ), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () async { - await ref - .read(avatarProvider.notifier) - .buildAvatar(); - if (context.mounted) { - showToast('化身构建完成'); - } - }, - icon: const Icon(Icons.refresh), - label: const Text('重新构建化身'), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } -} diff --git a/app/lib/features/profile/body_provider.dart b/app/lib/features/profile/body_provider.dart deleted file mode 100644 index 397b30b..0000000 --- a/app/lib/features/profile/body_provider.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/auth/auth_provider.dart'; - -class BodyState { - final int height; - final int weight; - final int skinTone; - final int bust; - final int waist; - final int hip; - final int shoulder; - - const BodyState({ - this.height = 170, - this.weight = 60, - this.skinTone = 3, - this.bust = 88, - this.waist = 70, - this.hip = 92, - this.shoulder = 42, - }); -} - -class BodyNotifier extends AsyncNotifier { - @override - Future build() async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/body-measurement/get'); - return BodyState( - height: (data['height'] as num?)?.toInt() ?? 170, - weight: (data['weight'] as num?)?.toInt() ?? 60, - skinTone: (data['skin_tone'] as num?)?.toInt() ?? 3, - bust: (data['bust'] as num?)?.toInt() ?? 88, - waist: (data['waist'] as num?)?.toInt() ?? 70, - hip: (data['hip'] as num?)?.toInt() ?? 92, - shoulder: (data['shoulder'] as num?)?.toInt() ?? 42, - ); - } - - Future save(int height, int weight, int skinTone, int bust, - int waist, int hip, int shoulder) async { - final api = ref.read(apiClientProvider); - await api.post('/body-measurement/save', { - 'height': height, - 'weight': weight, - 'skin_tone': skinTone, - 'bust': bust, - 'waist': waist, - 'hip': hip, - 'shoulder': shoulder, - }); - state = AsyncData(BodyState( - height: height, - weight: weight, - skinTone: skinTone, - bust: bust, - waist: waist, - hip: hip, - shoulder: shoulder)); - } -} - -final bodyProvider = - AsyncNotifierProvider(BodyNotifier.new); diff --git a/app/lib/features/profile/photo_upload_provider.dart b/app/lib/features/profile/photo_upload_provider.dart deleted file mode 100644 index 850273d..0000000 --- a/app/lib/features/profile/photo_upload_provider.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/auth/auth_provider.dart'; - -/// 照片类型(与后端 consts.PhotoType 对应) -class PhotoType { - static const int headshot = 1; - static const int fullFront = 2; - static const int fullSide = 3; - static const int fullBack = 4; - - static const Map labels = { - headshot: '大头照', - fullFront: '全身正面', - fullSide: '全身侧面', - fullBack: '全身背面', - }; - - static const Map descs = { - headshot: '清晰正脸、光线充足', - fullFront: '站立正面全身,拍全脚底', - fullSide: '站立侧面全身,自然放松', - fullBack: '站立背面全身,露出轮廓', - }; - - /// 构建 3D 化身所需视角(Tripo 多视角转 3D) - static const List all = [fullFront, fullSide, fullBack]; -} - -class UserPhotoInfo { - final int id; - final int type; - final String url; - - const UserPhotoInfo({ - required this.id, - required this.type, - required this.url, - }); -} - -class PhotoNotifier extends AsyncNotifier> { - @override - Future> build() async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/user-photo/list'); - final list = data['list'] as List? ?? []; - return list - .map((e) => UserPhotoInfo( - id: (e['id'] as num).toInt(), - type: (e['type'] as num).toInt(), - url: e['url'] as String? ?? '', - )) - .toList(); - } - - Future upload(int type, String filePath) async { - final api = ref.read(apiClientProvider); - await api.upload('/user-photo/upload', {'type': type}, 'file', filePath); - await refresh(); - } - - Future delete(int id) async { - final api = ref.read(apiClientProvider); - await api.post('/user-photo/delete', {'id': id}); - await refresh(); - } - - Future refresh() async { - state = const AsyncLoading(); - state = await AsyncValue.guard(build); - } - - /// 某类型是否已上传 - bool hasType(int type) => state.value?.any((p) => p.type == type) ?? false; -} - -final photoProvider = - AsyncNotifierProvider>(PhotoNotifier.new); diff --git a/app/lib/features/wardrobe/wardrobe_page.dart b/app/lib/features/wardrobe/wardrobe_page.dart deleted file mode 100644 index 8996c44..0000000 --- a/app/lib/features/wardrobe/wardrobe_page.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../core/config/app_config.dart'; -import '../../shared/app_toast.dart'; -import '../../features/cps/cps_provider.dart'; -import '../../shared/widgets/empty_view.dart'; -import '../../shared/widgets/error_view.dart'; -import '../../shared/widgets/loading_view.dart'; -import 'wardrobe_provider.dart'; - -/// 衣橱:服装网格 + 长按删除 + 上传入口 -class WardrobePage extends ConsumerStatefulWidget { - const WardrobePage({super.key}); - - @override - ConsumerState createState() => _WardrobePageState(); -} - -class _WardrobePageState extends ConsumerState { - String? _filter; // 当前分类筛选,null = 全部 - - /// 长按菜单:找升级款 / 删除 - Future _showItemMenu(WardrobeItemInfo item) async { - final action = await showModalBottomSheet( - context: context, - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.upgrade_outlined), - title: const Text('找升级款'), - onTap: () => Navigator.pop(ctx, 'upgrade'), - ), - ListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('删除'), - onTap: () => Navigator.pop(ctx, 'delete'), - ), - ], - ), - ), - ); - if (action == 'upgrade') { - await _openUpgrade(item); - } else if (action == 'delete') { - await _confirmDelete(item); - } - } - - /// 升级款推荐:空结果提示后不跳转 - Future _openUpgrade(WardrobeItemInfo item) async { - final rec = await ref.read(cpsUpgradeProvider(item.id).future); - if (!mounted) return; - if (rec.isEmpty) { - showToast('暂无升级款'); - return; - } - final first = rec.first; - context.push('/cps-product-list', extra: CpsListArgs( - title: '${item.category}升级款', - source: first.source, - categoryCode: first.categoryCode, - city: first.city, - scene: 'wardrobe_upgrade', - )); - } - - Future _confirmDelete(WardrobeItemInfo item) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('删除这件服装?'), - content: Text('「${item.category}」删除后不可恢复'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('取消')), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text('删除')), - ], - ), - ); - if (ok == true) { - await ref.read(wardrobeProvider.notifier).delete(item.id); - } - } - - @override - Widget build(BuildContext context) { - final items = ref.watch(wardrobeProvider); - return Scaffold( - appBar: AppBar(title: const Text('我的衣橱')), - body: Column( - children: [ - SizedBox( - height: 48, - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - children: [ - for (final c in [null, ...wardrobeCategories]) - Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(c ?? '全部'), - selected: _filter == c, - onSelected: (_) => setState(() => _filter = c), - ), - ), - ], - ), - ), - Expanded( - child: items.when( - loading: () => const LoadingView(text: '加载衣橱...'), - error: (e, _) => ErrorView( - message: e.toString().replaceFirst('Exception: ', ''), - onRetry: () => ref.read(wardrobeProvider.notifier).refresh(), - ), - data: (list) { - final shown = _filter == null - ? list - : list.where((i) => i.category == _filter).toList(); - if (shown.isEmpty) { - return EmptyView( - message: '衣橱还是空的,上传你的服装吧', - actionText: '上传服装', - onAction: () => context.go('/wardrobe-upload')); - } - return GridView.builder( - padding: const EdgeInsets.all(12), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisSpacing: 12, - crossAxisSpacing: 12, - childAspectRatio: 0.8, - ), - itemCount: shown.length, - itemBuilder: (ctx, i) { - final item = shown[i]; - return GestureDetector( - onLongPress: () => _showItemMenu(item), - child: Card( - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: item.photoUrl.isEmpty - ? const Icon(Icons.checkroom, - size: 48, color: Colors.grey) - : Image.network( - AppConfig.resolveUrl(item.photoUrl), - fit: BoxFit.cover, - errorBuilder: (_, _, _) => const Icon( - Icons.broken_image_outlined, - size: 48, - color: Colors.grey), - ), - ), - Padding( - padding: const EdgeInsets.all(8), - child: Text( - '${item.category}${item.season.isNotEmpty ? ' · ${item.season}' : ''}', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 13, fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - ); - }, - ); - }, - ), - ), - ], - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => context.go('/wardrobe-upload'), - icon: const Icon(Icons.add), - label: const Text('上传'), - ), - ); - } -} diff --git a/app/lib/features/wardrobe/wardrobe_provider.dart b/app/lib/features/wardrobe/wardrobe_provider.dart deleted file mode 100644 index a1bc6ee..0000000 --- a/app/lib/features/wardrobe/wardrobe_provider.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import '../../core/auth/auth_provider.dart'; - -/// 服装分类(与后端校验一致) -const wardrobeCategories = ['上衣', '下装', '鞋', '配饰']; - -const wardrobeSeasons = ['春', '夏', '秋', '冬', '四季']; - -class WardrobeItemInfo { - final int id; - final String photoUrl; - final String category; - final String season; - final String styleTags; - final String colorInfo; - - const WardrobeItemInfo({ - required this.id, - required this.photoUrl, - required this.category, - required this.season, - required this.styleTags, - required this.colorInfo, - }); -} - -class WardrobeNotifier extends AsyncNotifier> { - @override - Future> build() async { - final api = ref.read(apiClientProvider); - final data = await api.get>('/wardrobe/list'); - final list = data['list'] as List? ?? []; - return list - .map((e) => WardrobeItemInfo( - id: (e['id'] as num).toInt(), - photoUrl: e['photo_url'] as String? ?? '', - category: e['category'] as String? ?? '', - season: e['season'] as String? ?? '', - styleTags: e['style_tags'] as String? ?? '', - colorInfo: e['color_info'] as String? ?? '', - )) - .toList(); - } - - Future upload( - String filePath, { - required String category, - String season = '', - String styleTags = '', - String colorInfo = '', - }) async { - final api = ref.read(apiClientProvider); - await api.upload('/wardrobe/upload', { - 'category': category, - 'season': season, - 'style_tags': styleTags, - 'color_info': colorInfo, - }, 'file', filePath); - await refresh(); - } - - Future delete(int id) async { - final api = ref.read(apiClientProvider); - await api.post('/wardrobe/delete', {'id': id}); - await refresh(); - } - - Future refresh() async { - state = const AsyncLoading(); - state = await AsyncValue.guard(build); - } -} - -final wardrobeProvider = - AsyncNotifierProvider>( - WardrobeNotifier.new); diff --git a/app/lib/features/wardrobe/wardrobe_upload_page.dart b/app/lib/features/wardrobe/wardrobe_upload_page.dart deleted file mode 100644 index 1e43e7c..0000000 --- a/app/lib/features/wardrobe/wardrobe_upload_page.dart +++ /dev/null @@ -1,162 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; -import 'package:image_picker/image_picker.dart'; - -import '../../shared/app_toast.dart'; -import 'wardrobe_provider.dart'; - -/// 服装上传:选图 + 分类/季节/风格/颜色信息 -class WardrobeUploadPage extends ConsumerStatefulWidget { - const WardrobeUploadPage({super.key}); - - @override - ConsumerState createState() => _WardrobeUploadPageState(); -} - -class _WardrobeUploadPageState extends ConsumerState { - final _picker = ImagePicker(); - final _styleCtrl = TextEditingController(); - final _colorCtrl = TextEditingController(); - - String? _imagePath; - String? _category; - String? _season; - bool _uploading = false; - - @override - void dispose() { - _styleCtrl.dispose(); - _colorCtrl.dispose(); - super.dispose(); - } - - Future _pickImage() async { - final file = await _picker.pickImage( - source: ImageSource.gallery, maxWidth: 2048, imageQuality: 85); - if (file == null) return; - setState(() => _imagePath = file.path); - } - - Future _submit() async { - if (_imagePath == null) { - showToast('请先选择服装照片'); - return; - } - if (_category == null) { - showToast('请选择分类'); - return; - } - setState(() => _uploading = true); - try { - await ref.read(wardrobeProvider.notifier).upload( - _imagePath!, - category: _category!, - season: _season ?? '', - styleTags: _styleCtrl.text.trim(), - colorInfo: _colorCtrl.text.trim(), - ); - if (!mounted) return; - showToast('上传成功'); - context.go('/wardrobe'); - } catch (e) { - if (!mounted) return; - showToast('上传失败:${e.toString().replaceFirst('Exception: ', '')}'); - } finally { - if (mounted) setState(() => _uploading = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('上传服装')), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - GestureDetector( - onTap: _uploading ? null : _pickImage, - child: Container( - height: 220, - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey.shade300), - ), - clipBehavior: Clip.antiAlias, - child: _imagePath == null - ? Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.add_photo_alternate_outlined, - size: 48, color: Colors.grey.shade500), - const SizedBox(height: 8), - const Text('点击选择服装照片', - style: TextStyle(color: Colors.grey)), - ], - ) - : Image.file(File(_imagePath!), fit: BoxFit.cover), - ), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _category, - decoration: const InputDecoration( - labelText: '分类', border: OutlineInputBorder()), - items: [ - for (final c in wardrobeCategories) - DropdownMenuItem(value: c, child: Text(c)), - ], - onChanged: _uploading - ? null - : (v) => setState(() => _category = v), - ), - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: _season, - decoration: const InputDecoration( - labelText: '适用季节(选填)', border: OutlineInputBorder()), - items: [ - for (final s in wardrobeSeasons) - DropdownMenuItem(value: s, child: Text(s)), - ], - onChanged: - _uploading ? null : (v) => setState(() => _season = v), - ), - const SizedBox(height: 12), - TextField( - controller: _styleCtrl, - enabled: !_uploading, - decoration: const InputDecoration( - labelText: '风格标签(选填,如:通勤、休闲)', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _colorCtrl, - enabled: !_uploading, - decoration: const InputDecoration( - labelText: '颜色描述(选填,如:黑色)', - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 24), - FilledButton( - onPressed: _uploading ? null : _submit, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 14)), - child: _uploading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('上传到衣橱'), - ), - ], - ), - ); - } -} diff --git a/app/lib/main.dart b/app/lib/main.dart deleted file mode 100644 index 609b1f5..0000000 --- a/app/lib/main.dart +++ /dev/null @@ -1,113 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import 'core/auth/auth_provider.dart'; -import 'shared/app_toast.dart'; -import 'features/auth/login_page.dart'; -import 'features/cps/cps_product_list_page.dart'; -import 'features/cps/cps_provider.dart'; -import 'features/home/home_page.dart'; -import 'features/member/pay_page.dart'; -import 'features/outfit/outfit_page.dart'; -import 'features/outfit/outfit_provider.dart'; -import 'features/outfit/plan_effect_page.dart'; -import 'features/outfit/plan_viewer_page.dart'; -import 'features/profile/avatar_build_flow_page.dart'; -import 'features/profile/avatar_viewer_page.dart'; -import 'features/wardrobe/wardrobe_page.dart'; -import 'features/wardrobe/wardrobe_upload_page.dart'; - -void main() { - runApp(const ProviderScope(child: SloganApp())); -} - -class SloganApp extends ConsumerWidget { - const SloganApp({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final router = ref.watch(routerProvider); - final auth = ref.watch(authProvider); - // 路由守卫:token 失效/登出 → 回登录页;token 有效且在登录页 → 进主框架 - final path = - router.routerDelegate.currentConfiguration.uri.path; - final authed = auth.value?.authenticated ?? false; - if (!auth.isLoading) { - if (!authed && path != '/login') { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) router.go('/login'); - }); - } else if (authed && path == '/login') { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) router.go('/home'); - }); - } - } - return MaterialApp.router( - title: '我的形象穿搭', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF5C6BC0)), - useMaterial3: true, - appBarTheme: const AppBarTheme(centerTitle: true), - ), - routerConfig: router, - ); - } -} - -/// 路由表:启动无 token 进登录页;有 token 进主框架 -final routerProvider = Provider((ref) { - return GoRouter( - navigatorKey: appNavigatorKey, - initialLocation: '/login', - routes: [ - GoRoute( - path: '/login', - builder: (ctx, state) => const LoginPage(), - ), - GoRoute( - path: '/home', - builder: (ctx, state) => const HomePage(), - ), - GoRoute( - path: '/avatar-build', - builder: (ctx, state) => const AvatarBuildFlowPage(), - ), - GoRoute( - path: '/wardrobe', - builder: (ctx, state) => const WardrobePage(), - ), - GoRoute( - path: '/wardrobe-upload', - builder: (ctx, state) => const WardrobeUploadPage(), - ), - GoRoute( - path: '/outfit', - builder: (ctx, state) => const OutfitPage(), - ), - GoRoute( - path: '/pay', - builder: (context, state) => PayPage(args: state.extra! as PayArgs), - ), - GoRoute( - path: '/plan-viewer', - builder: (ctx, state) => const PlanViewerPage(), - ), - GoRoute( - path: '/plan-effect', - builder: (ctx, state) => - PlanEffectPage(images: state.extra as List), - ), - GoRoute( - path: '/avatar-viewer', - builder: (ctx, state) => const AvatarViewerPage(), - ), - GoRoute( - path: '/cps-product-list', - builder: (ctx, state) => - CpsProductListPage(args: state.extra! as CpsListArgs), - ), - ], - ); -}); diff --git a/app/lib/shared/app_toast.dart b/app/lib/shared/app_toast.dart deleted file mode 100644 index c44aed7..0000000 --- a/app/lib/shared/app_toast.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -/// 全局 Navigator key,供不依赖页面 context 的 toast 等场景使用 -final GlobalKey appNavigatorKey = GlobalKey(); - -OverlayEntry? _entry; -Timer? _timer; - -/// 屏幕正中央的 toast,展示时长按文字长度分档:≤8 字 2 秒,≤20 字 3 秒,更长 4 秒 -void showToast(String msg) { - _timer?.cancel(); - _entry?.remove(); - _entry = null; - - final overlay = appNavigatorKey.currentState?.overlay; - if (overlay == null) return; - - final entry = OverlayEntry( - builder: (_) => Positioned.fill( - child: IgnorePointer( - child: Center( - child: Container( - constraints: const BoxConstraints(maxWidth: 280), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.78), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - msg, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - height: 1.4, - ), - ), - ), - ), - ), - ), - ); - overlay.insert(entry); - _entry = entry; - _timer = Timer(_durationFor(msg), () { - if (_entry == entry) { - entry.remove(); - _entry = null; - } - }); -} - -Duration _durationFor(String msg) { - final len = msg.length; - if (len <= 8) return const Duration(seconds: 2); - if (len <= 20) return const Duration(seconds: 3); - return const Duration(seconds: 4); -} diff --git a/app/lib/shared/widgets/avatar_viewer.dart b/app/lib/shared/widgets/avatar_viewer.dart deleted file mode 100644 index d39c4db..0000000 --- a/app/lib/shared/widgets/avatar_viewer.dart +++ /dev/null @@ -1,139 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; - -import '../../core/config/app_config.dart'; - -/// 3D 化身查看组件 -/// -/// 服务端预渲染帧序列(36 帧绕 Y 轴旋转 PNG)轮播模拟 3D; -/// framesUrl 为空时降级为静态占位。 -class AvatarViewer extends StatefulWidget { - final String? glbUrl; - final String? framesUrl; - final String title; - final String subtitle; - final double height; - - const AvatarViewer({ - super.key, - this.glbUrl, - this.framesUrl, - required this.title, - required this.subtitle, - this.height = 360, - }); - - @override - State createState() => _AvatarViewerState(); -} - -class _AvatarViewerState extends State { - static const int _frameCount = 36; - - Timer? _timer; - int _frame = 0; - - @override - void initState() { - super.initState(); - if (widget.framesUrl != null && widget.framesUrl!.isNotEmpty) { - _timer = Timer.periodic(const Duration(milliseconds: 120), (_) { - setState(() => _frame = (_frame + 1) % _frameCount); - }); - } - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final framesUrl = widget.framesUrl; - if (framesUrl != null && framesUrl.isNotEmpty) { - final frameUrl = AppConfig.resolveUrl( - '$framesUrl/frame_${_frame.toString().padLeft(3, '0')}.png'); - final scheme = Theme.of(context).colorScheme; - return Container( - height: widget.height, - width: double.infinity, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - scheme.primaryContainer, - scheme.primary.withValues(alpha: 0.3), - ], - ), - borderRadius: BorderRadius.circular(16), - ), - child: Stack( - alignment: Alignment.bottomCenter, - children: [ - Positioned.fill( - child: Image.network( - frameUrl, - fit: BoxFit.contain, - errorBuilder: (_, _, _) => _placeholder(context), - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Text( - widget.subtitle, - style: const TextStyle( - color: Colors.white, fontSize: 12, shadows: [ - Shadow(color: Colors.black45, blurRadius: 4), - ]), - ), - ), - ], - ), - ); - } - return _placeholder(context); - } - - Widget _placeholder(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - height: widget.height, - width: double.infinity, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - scheme.primaryContainer, - scheme.primary.withValues(alpha: 0.3), - ], - ), - borderRadius: BorderRadius.circular(16), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.accessibility_new, size: 72, color: scheme.primary), - const SizedBox(height: 12), - Text(widget.title, - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.bold)), - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - widget.subtitle.isEmpty ? '3D 渲染服务未就绪' : widget.subtitle, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.grey, fontSize: 12), - ), - ), - ], - ), - ); - } -} diff --git a/app/lib/shared/widgets/empty_view.dart b/app/lib/shared/widgets/empty_view.dart deleted file mode 100644 index 47f5aa3..0000000 --- a/app/lib/shared/widgets/empty_view.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -/// 空态 + 引导动作 -class EmptyView extends StatelessWidget { - final String message; - final String? actionText; - final VoidCallback? onAction; - - const EmptyView({ - super.key, - required this.message, - this.actionText, - this.onAction, - }); - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.inbox_outlined, size: 48, color: Colors.grey.shade400), - const SizedBox(height: 12), - Text(message, style: const TextStyle(color: Colors.grey)), - if (actionText != null && onAction != null) ...[ - const SizedBox(height: 16), - FilledButton(onPressed: onAction, child: Text(actionText!)), - ], - ], - ), - ); - } -} diff --git a/app/lib/shared/widgets/error_view.dart b/app/lib/shared/widgets/error_view.dart deleted file mode 100644 index cd08195..0000000 --- a/app/lib/shared/widgets/error_view.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:flutter/material.dart'; - -/// 错误态 + 重试 -class ErrorView extends StatelessWidget { - final String message; - final VoidCallback? onRetry; - - const ErrorView({super.key, required this.message, this.onRetry}); - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.error_outline, size: 48, color: Colors.grey.shade400), - const SizedBox(height: 12), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Text(message, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.grey)), - ), - if (onRetry != null) ...[ - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: onRetry, icon: const Icon(Icons.refresh), label: const Text('重试')), - ], - ], - ), - ); - } -} diff --git a/app/lib/shared/widgets/loading_view.dart b/app/lib/shared/widgets/loading_view.dart deleted file mode 100644 index cde48b0..0000000 --- a/app/lib/shared/widgets/loading_view.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter/material.dart'; - -/// 加载骨架屏 -class LoadingView extends StatelessWidget { - final String? text; - - const LoadingView({super.key, this.text}); - - @override - Widget build(BuildContext context) { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const CircularProgressIndicator(), - if (text != null) ...[ - const SizedBox(height: 12), - Text(text!, style: const TextStyle(color: Colors.grey)), - ], - ], - ), - ); - } -} diff --git a/app/linux/.gitignore b/app/linux/.gitignore deleted file mode 100644 index d3896c9..0000000 --- a/app/linux/.gitignore +++ /dev/null @@ -1 +0,0 @@ -flutter/ephemeral diff --git a/app/linux/CMakeLists.txt b/app/linux/CMakeLists.txt deleted file mode 100644 index 80c8953..0000000 --- a/app/linux/CMakeLists.txt +++ /dev/null @@ -1,128 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "slogan_app") -# The unique GTK application identifier for this application. See: -# https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.slogan.slogan_app") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(SET CMP0063 NEW) - -# Load bundled libraries from the lib/ directory relative to the binary. -set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") - -# Root filesystem for cross-building. -if(FLUTTER_TARGET_PLATFORM_SYSROOT) - set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) - set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) - set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) - set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) -endif() - -# Define build configuration options. -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") -endif() - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_14) - target_compile_options(${TARGET} PRIVATE -Wall -Werror) - target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") - target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) - -# Only the install-generated bundle's copy of the executable will launch -# correctly, since the resources must in the right relative locations. To avoid -# people trying to run the unbundled copy, put it in a subdirectory instead of -# the default top-level location. -set_target_properties(${BINARY_NAME} - PROPERTIES - RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" -) - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# By default, "installing" just makes a relocatable bundle in the build -# directory. -set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -# Start with a clean build bundle directory every time. -install(CODE " - file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") - " COMPONENT Runtime) - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) - install(FILES "${bundled_library}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endforeach(bundled_library) - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") - install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() diff --git a/app/linux/flutter/CMakeLists.txt b/app/linux/flutter/CMakeLists.txt deleted file mode 100644 index d5bd016..0000000 --- a/app/linux/flutter/CMakeLists.txt +++ /dev/null @@ -1,88 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.10) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. - -# Serves the same purpose as list(TRANSFORM ... PREPEND ...), -# which isn't available in 3.10. -function(list_prepend LIST_NAME PREFIX) - set(NEW_LIST "") - foreach(element ${${LIST_NAME}}) - list(APPEND NEW_LIST "${PREFIX}${element}") - endforeach(element) - set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) -endfunction() - -# === Flutter Library === -# System-level dependencies. -find_package(PkgConfig REQUIRED) -pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) -pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) -pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) - -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "fl_basic_message_channel.h" - "fl_binary_codec.h" - "fl_binary_messenger.h" - "fl_dart_project.h" - "fl_engine.h" - "fl_json_message_codec.h" - "fl_json_method_codec.h" - "fl_message_codec.h" - "fl_method_call.h" - "fl_method_channel.h" - "fl_method_codec.h" - "fl_method_response.h" - "fl_plugin_registrar.h" - "fl_plugin_registry.h" - "fl_standard_message_codec.h" - "fl_standard_method_codec.h" - "fl_string_codec.h" - "fl_value.h" - "fl_view.h" - "flutter_linux.h" -) -list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") -target_link_libraries(flutter INTERFACE - PkgConfig::GTK - PkgConfig::GLIB - PkgConfig::GIO -) -add_dependencies(flutter flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CMAKE_CURRENT_BINARY_DIR}/_phony_ - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" - ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} -) diff --git a/app/linux/flutter/generated_plugin_registrant.cc b/app/linux/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 7299b5c..0000000 --- a/app/linux/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,19 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include - -void fl_register_plugins(FlPluginRegistry* registry) { - g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); - file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); - url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); -} diff --git a/app/linux/flutter/generated_plugin_registrant.h b/app/linux/flutter/generated_plugin_registrant.h deleted file mode 100644 index e0f0a47..0000000 --- a/app/linux/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void fl_register_plugins(FlPluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/linux/flutter/generated_plugins.cmake b/app/linux/flutter/generated_plugins.cmake deleted file mode 100644 index 786ff5c..0000000 --- a/app/linux/flutter/generated_plugins.cmake +++ /dev/null @@ -1,25 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - file_selector_linux - url_launcher_linux -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/app/linux/runner/CMakeLists.txt b/app/linux/runner/CMakeLists.txt deleted file mode 100644 index e97dabc..0000000 --- a/app/linux/runner/CMakeLists.txt +++ /dev/null @@ -1,26 +0,0 @@ -cmake_minimum_required(VERSION 3.13) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} - "main.cc" - "my_application.cc" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the application ID. -add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") - -# Add dependency libraries. Add any application-specific dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter) -target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) - -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/app/linux/runner/main.cc b/app/linux/runner/main.cc deleted file mode 100644 index e7c5c54..0000000 --- a/app/linux/runner/main.cc +++ /dev/null @@ -1,6 +0,0 @@ -#include "my_application.h" - -int main(int argc, char** argv) { - g_autoptr(MyApplication) app = my_application_new(); - return g_application_run(G_APPLICATION(app), argc, argv); -} diff --git a/app/linux/runner/my_application.cc b/app/linux/runner/my_application.cc deleted file mode 100644 index 2115543..0000000 --- a/app/linux/runner/my_application.cc +++ /dev/null @@ -1,148 +0,0 @@ -#include "my_application.h" - -#include -#ifdef GDK_WINDOWING_X11 -#include -#endif - -#include "flutter/generated_plugin_registrant.h" - -struct _MyApplication { - GtkApplication parent_instance; - char** dart_entrypoint_arguments; -}; - -G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) - -// Called when first Flutter frame received. -static void first_frame_cb(MyApplication* self, FlView* view) { - gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); -} - -// Implements GApplication::activate. -static void my_application_activate(GApplication* application) { - MyApplication* self = MY_APPLICATION(application); - GtkWindow* window = - GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); - - // Use a header bar when running in GNOME as this is the common style used - // by applications and is the setup most users will be using (e.g. Ubuntu - // desktop). - // If running on X and not using GNOME then just use a traditional title bar - // in case the window manager does more exotic layout, e.g. tiling. - // If running on Wayland assume the header bar will work (may need changing - // if future cases occur). - gboolean use_header_bar = TRUE; -#ifdef GDK_WINDOWING_X11 - GdkScreen* screen = gtk_window_get_screen(window); - if (GDK_IS_X11_SCREEN(screen)) { - const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); - if (g_strcmp0(wm_name, "GNOME Shell") != 0) { - use_header_bar = FALSE; - } - } -#endif - if (use_header_bar) { - GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); - gtk_widget_show(GTK_WIDGET(header_bar)); - gtk_header_bar_set_title(header_bar, "slogan_app"); - gtk_header_bar_set_show_close_button(header_bar, TRUE); - gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); - } else { - gtk_window_set_title(window, "slogan_app"); - } - - gtk_window_set_default_size(window, 1280, 720); - - g_autoptr(FlDartProject) project = fl_dart_project_new(); - fl_dart_project_set_dart_entrypoint_arguments( - project, self->dart_entrypoint_arguments); - - FlView* view = fl_view_new(project); - GdkRGBA background_color; - // Background defaults to black, override it here if necessary, e.g. #00000000 - // for transparent. - gdk_rgba_parse(&background_color, "#000000"); - fl_view_set_background_color(view, &background_color); - gtk_widget_show(GTK_WIDGET(view)); - gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); - - // Show the window when Flutter renders. - // Requires the view to be realized so we can start rendering. - g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), - self); - gtk_widget_realize(GTK_WIDGET(view)); - - fl_register_plugins(FL_PLUGIN_REGISTRY(view)); - - gtk_widget_grab_focus(GTK_WIDGET(view)); -} - -// Implements GApplication::local_command_line. -static gboolean my_application_local_command_line(GApplication* application, - gchar*** arguments, - int* exit_status) { - MyApplication* self = MY_APPLICATION(application); - // Strip out the first argument as it is the binary name. - self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); - - g_autoptr(GError) error = nullptr; - if (!g_application_register(application, nullptr, &error)) { - g_warning("Failed to register: %s", error->message); - *exit_status = 1; - return TRUE; - } - - g_application_activate(application); - *exit_status = 0; - - return TRUE; -} - -// Implements GApplication::startup. -static void my_application_startup(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application startup. - - G_APPLICATION_CLASS(my_application_parent_class)->startup(application); -} - -// Implements GApplication::shutdown. -static void my_application_shutdown(GApplication* application) { - // MyApplication* self = MY_APPLICATION(object); - - // Perform any actions required at application shutdown. - - G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); -} - -// Implements GObject::dispose. -static void my_application_dispose(GObject* object) { - MyApplication* self = MY_APPLICATION(object); - g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); - G_OBJECT_CLASS(my_application_parent_class)->dispose(object); -} - -static void my_application_class_init(MyApplicationClass* klass) { - G_APPLICATION_CLASS(klass)->activate = my_application_activate; - G_APPLICATION_CLASS(klass)->local_command_line = - my_application_local_command_line; - G_APPLICATION_CLASS(klass)->startup = my_application_startup; - G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; - G_OBJECT_CLASS(klass)->dispose = my_application_dispose; -} - -static void my_application_init(MyApplication* self) {} - -MyApplication* my_application_new() { - // Set the program name to the application ID, which helps various systems - // like GTK and desktop environments map this running application to its - // corresponding .desktop file. This ensures better integration by allowing - // the application to be recognized beyond its binary name. - g_set_prgname(APPLICATION_ID); - - return MY_APPLICATION(g_object_new(my_application_get_type(), - "application-id", APPLICATION_ID, "flags", - G_APPLICATION_NON_UNIQUE, nullptr)); -} diff --git a/app/linux/runner/my_application.h b/app/linux/runner/my_application.h deleted file mode 100644 index db16367..0000000 --- a/app/linux/runner/my_application.h +++ /dev/null @@ -1,21 +0,0 @@ -#ifndef FLUTTER_MY_APPLICATION_H_ -#define FLUTTER_MY_APPLICATION_H_ - -#include - -G_DECLARE_FINAL_TYPE(MyApplication, - my_application, - MY, - APPLICATION, - GtkApplication) - -/** - * my_application_new: - * - * Creates a new Flutter-based application. - * - * Returns: a new #MyApplication. - */ -MyApplication* my_application_new(); - -#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/app/macos/.gitignore b/app/macos/.gitignore deleted file mode 100644 index 746adbb..0000000 --- a/app/macos/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -# Flutter-related -**/Flutter/ephemeral/ -**/Pods/ - -# Xcode-related -**/dgph -**/xcuserdata/ diff --git a/app/macos/Flutter/Flutter-Debug.xcconfig b/app/macos/Flutter/Flutter-Debug.xcconfig deleted file mode 100644 index c2efd0b..0000000 --- a/app/macos/Flutter/Flutter-Debug.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/Flutter-Release.xcconfig b/app/macos/Flutter/Flutter-Release.xcconfig deleted file mode 100644 index c2efd0b..0000000 --- a/app/macos/Flutter/Flutter-Release.xcconfig +++ /dev/null @@ -1 +0,0 @@ -#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/app/macos/Flutter/GeneratedPluginRegistrant.swift b/app/macos/Flutter/GeneratedPluginRegistrant.swift deleted file mode 100644 index 21ae5af..0000000 --- a/app/macos/Flutter/GeneratedPluginRegistrant.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import file_selector_macos -import shared_preferences_foundation -import url_launcher_macos - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) -} diff --git a/app/macos/Runner.xcodeproj/project.pbxproj b/app/macos/Runner.xcodeproj/project.pbxproj deleted file mode 100644 index 50ffd6b..0000000 --- a/app/macos/Runner.xcodeproj/project.pbxproj +++ /dev/null @@ -1,729 +0,0 @@ -// !$*UTF8*$! -{ - archiveVersion = 1; - classes = { - }; - objectVersion = 54; - objects = { - -/* Begin PBXAggregateTarget section */ - 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { - isa = PBXAggregateTarget; - buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; - buildPhases = ( - 33CC111E2044C6BF0003C045 /* ShellScript */, - ); - dependencies = ( - ); - name = "Flutter Assemble"; - productName = FLX; - }; -/* End PBXAggregateTarget section */ - -/* Begin PBXBuildFile section */ - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; -/* End PBXBuildFile section */ - -/* Begin PBXContainerItemProxy section */ - 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC10EC2044A3C60003C045; - remoteInfo = Runner; - }; - 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 33CC10E52044A3C60003C045 /* Project object */; - proxyType = 1; - remoteGlobalIDString = 33CC111A2044C6BA0003C045; - remoteInfo = FLX; - }; -/* End PBXContainerItemProxy section */ - -/* Begin PBXCopyFilesBuildPhase section */ - 33CC110E2044A8840003C045 /* Bundle Framework */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 10; - files = ( - ); - name = "Bundle Framework"; - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXCopyFilesBuildPhase section */ - -/* Begin PBXFileReference section */ - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* slogan_app.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "slogan_app.app"; sourceTree = BUILT_PRODUCTS_DIR; }; - 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; - 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; - 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; - 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; - 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; - 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; - 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; - 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; -/* End PBXFileReference section */ - -/* Begin PBXFrameworksBuildPhase section */ - 331C80D2294CF70F00263BE5 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EA2044A3C60003C045 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - -/* Begin PBXGroup section */ - 331C80D6294CF71000263BE5 /* RunnerTests */ = { - isa = PBXGroup; - children = ( - 331C80D7294CF71000263BE5 /* RunnerTests.swift */, - ); - path = RunnerTests; - sourceTree = ""; - }; - 33BA886A226E78AF003329D5 /* Configs */ = { - isa = PBXGroup; - children = ( - 33E5194F232828860026EE4D /* AppInfo.xcconfig */, - 9740EEB21CF90195004384FC /* Debug.xcconfig */, - 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, - 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, - ); - path = Configs; - sourceTree = ""; - }; - 33CC10E42044A3C60003C045 = { - isa = PBXGroup; - children = ( - 33FAB671232836740065AC1E /* Runner */, - 33CEB47122A05771004F2AC0 /* Flutter */, - 331C80D6294CF71000263BE5 /* RunnerTests */, - 33CC10EE2044A3C60003C045 /* Products */, - D73912EC22F37F3D000D13A0 /* Frameworks */, - ); - sourceTree = ""; - }; - 33CC10EE2044A3C60003C045 /* Products */ = { - isa = PBXGroup; - children = ( - 33CC10ED2044A3C60003C045 /* slogan_app.app */, - 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, - ); - name = Products; - sourceTree = ""; - }; - 33CC11242044D66E0003C045 /* Resources */ = { - isa = PBXGroup; - children = ( - 33CC10F22044A3C60003C045 /* Assets.xcassets */, - 33CC10F42044A3C60003C045 /* MainMenu.xib */, - 33CC10F72044A3C60003C045 /* Info.plist */, - ); - name = Resources; - path = ..; - sourceTree = ""; - }; - 33CEB47122A05771004F2AC0 /* Flutter */ = { - isa = PBXGroup; - children = ( - 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, - 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, - 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, - 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, - 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, - ); - path = Flutter; - sourceTree = ""; - }; - 33FAB671232836740065AC1E /* Runner */ = { - isa = PBXGroup; - children = ( - 33CC10F02044A3C60003C045 /* AppDelegate.swift */, - 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, - 33E51913231747F40026EE4D /* DebugProfile.entitlements */, - 33E51914231749380026EE4D /* Release.entitlements */, - 33CC11242044D66E0003C045 /* Resources */, - 33BA886A226E78AF003329D5 /* Configs */, - ); - path = Runner; - sourceTree = ""; - }; - D73912EC22F37F3D000D13A0 /* Frameworks */ = { - isa = PBXGroup; - children = ( - ); - name = Frameworks; - sourceTree = ""; - }; -/* End PBXGroup section */ - -/* Begin PBXNativeTarget section */ - 331C80D4294CF70F00263BE5 /* RunnerTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; - buildPhases = ( - 331C80D1294CF70F00263BE5 /* Sources */, - 331C80D2294CF70F00263BE5 /* Frameworks */, - 331C80D3294CF70F00263BE5 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - 331C80DA294CF71000263BE5 /* PBXTargetDependency */, - ); - name = RunnerTests; - productName = RunnerTests; - productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; - 33CC10EC2044A3C60003C045 /* Runner */ = { - isa = PBXNativeTarget; - buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; - buildPhases = ( - 33CC10E92044A3C60003C045 /* Sources */, - 33CC10EA2044A3C60003C045 /* Frameworks */, - 33CC10EB2044A3C60003C045 /* Resources */, - 33CC110E2044A8840003C045 /* Bundle Framework */, - 3399D490228B24CF009A79C7 /* ShellScript */, - ); - buildRules = ( - ); - dependencies = ( - 33CC11202044C79F0003C045 /* PBXTargetDependency */, - ); - name = Runner; - packageProductDependencies = ( - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, - ); - productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* slogan_app.app */; - productType = "com.apple.product-type.application"; - }; -/* End PBXNativeTarget section */ - -/* Begin PBXProject section */ - 33CC10E52044A3C60003C045 /* Project object */ = { - isa = PBXProject; - attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1510; - ORGANIZATIONNAME = ""; - TargetAttributes = { - 331C80D4294CF70F00263BE5 = { - CreatedOnToolsVersion = 14.0; - TestTargetID = 33CC10EC2044A3C60003C045; - }; - 33CC10EC2044A3C60003C045 = { - CreatedOnToolsVersion = 9.2; - LastSwiftMigration = 1100; - ProvisioningStyle = Automatic; - SystemCapabilities = { - com.apple.Sandbox = { - enabled = 1; - }; - }; - }; - 33CC111A2044C6BA0003C045 = { - CreatedOnToolsVersion = 9.2; - ProvisioningStyle = Manual; - }; - }; - }; - buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; - developmentRegion = en; - hasScannedForEncodings = 0; - knownRegions = ( - en, - Base, - ); - mainGroup = 33CC10E42044A3C60003C045; - packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, - ); - productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; - projectDirPath = ""; - projectRoot = ""; - targets = ( - 33CC10EC2044A3C60003C045 /* Runner */, - 331C80D4294CF70F00263BE5 /* RunnerTests */, - 33CC111A2044C6BA0003C045 /* Flutter Assemble */, - ); - }; -/* End PBXProject section */ - -/* Begin PBXResourcesBuildPhase section */ - 331C80D3294CF70F00263BE5 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10EB2044A3C60003C045 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, - 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXResourcesBuildPhase section */ - -/* Begin PBXShellScriptBuildPhase section */ - 3399D490228B24CF009A79C7 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; - }; - 33CC111E2044C6BF0003C045 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - Flutter/ephemeral/FlutterInputs.xcfilelist, - ); - inputPaths = ( - Flutter/ephemeral/tripwire, - ); - outputFileListPaths = ( - Flutter/ephemeral/FlutterOutputs.xcfilelist, - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; - }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ - 331C80D1294CF70F00263BE5 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 33CC10E92044A3C60003C045 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, - 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, - 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXSourcesBuildPhase section */ - -/* Begin PBXTargetDependency section */ - 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC10EC2044A3C60003C045 /* Runner */; - targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; - }; - 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; - targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; - }; -/* End PBXTargetDependency section */ - -/* Begin PBXVariantGroup section */ - 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { - isa = PBXVariantGroup; - children = ( - 33CC10F52044A3C60003C045 /* Base */, - ); - name = MainMenu.xib; - path = Runner; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - -/* Begin XCBuildConfiguration section */ - 331C80DB294CF71000263BE5 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/slogan_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/slogan_app"; - }; - name = Debug; - }; - 331C80DC294CF71000263BE5 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/slogan_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/slogan_app"; - }; - name = Release; - }; - 331C80DD294CF71000263BE5 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CURRENT_PROJECT_VERSION = 1; - GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp.RunnerTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/slogan_app.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/slogan_app"; - }; - name = Profile; - }; - 338D0CE9231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Profile; - }; - 338D0CEA231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Profile; - }; - 338D0CEB231458BD00FA5F75 /* Profile */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Profile; - }; - 33CC10F92044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = dwarf; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_TESTABILITY = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_DYNAMIC_NO_PIC = NO; - GCC_NO_COMMON_BLOCKS = YES; - GCC_OPTIMIZATION_LEVEL = 0; - GCC_PREPROCESSOR_DEFINITIONS = ( - "DEBUG=1", - "$(inherited)", - ); - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = YES; - ONLY_ACTIVE_ARCH = YES; - SDKROOT = macosx; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - }; - name = Debug; - }; - 33CC10FA2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; - buildSettings = { - ALWAYS_SEARCH_USER_PATHS = NO; - ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_CXX_LIBRARY = "libc++"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_ARC = YES; - CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; - CLANG_WARN_BOOL_CONVERSION = YES; - CLANG_WARN_CONSTANT_CONVERSION = YES; - CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; - CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_EMPTY_BODY = YES; - CLANG_WARN_ENUM_CONVERSION = YES; - CLANG_WARN_INFINITE_RECURSION = YES; - CLANG_WARN_INT_CONVERSION = YES; - CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; - CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; - CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; - CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; - CLANG_WARN_SUSPICIOUS_MOVE = YES; - CODE_SIGN_IDENTITY = "-"; - COPY_PHASE_STRIP = NO; - DEAD_CODE_STRIPPING = YES; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - ENABLE_NS_ASSERTIONS = NO; - ENABLE_STRICT_OBJC_MSGSEND = YES; - ENABLE_USER_SCRIPT_SANDBOXING = NO; - GCC_C_LANGUAGE_STANDARD = gnu11; - GCC_NO_COMMON_BLOCKS = YES; - GCC_WARN_64_TO_32_BIT_CONVERSION = YES; - GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; - GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; - GCC_WARN_UNUSED_FUNCTION = YES; - GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; - MTL_ENABLE_DEBUG_INFO = NO; - SDKROOT = macosx; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - }; - name = Release; - }; - 33CC10FC2044A3C60003C045 /* Debug */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - }; - name = Debug; - }; - 33CC10FD2044A3C60003C045 /* Release */ = { - isa = XCBuildConfiguration; - baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; - CODE_SIGN_STYLE = Automatic; - COMBINE_HIDPI_IMAGES = YES; - INFOPLIST_FILE = Runner/Info.plist; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/../Frameworks", - ); - PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; - }; - name = Release; - }; - 33CC111C2044C6BA0003C045 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Manual; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Debug; - }; - 33CC111D2044C6BA0003C045 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - CODE_SIGN_STYLE = Automatic; - PRODUCT_NAME = "$(TARGET_NAME)"; - }; - name = Release; - }; -/* End XCBuildConfiguration section */ - -/* Begin XCConfigurationList section */ - 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 331C80DB294CF71000263BE5 /* Debug */, - 331C80DC294CF71000263BE5 /* Release */, - 331C80DD294CF71000263BE5 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10F92044A3C60003C045 /* Debug */, - 33CC10FA2044A3C60003C045 /* Release */, - 338D0CE9231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC10FC2044A3C60003C045 /* Debug */, - 33CC10FD2044A3C60003C045 /* Release */, - 338D0CEA231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - 33CC111C2044C6BA0003C045 /* Debug */, - 33CC111D2044C6BA0003C045 /* Release */, - 338D0CEB231458BD00FA5F75 /* Profile */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; -/* End XCConfigurationList section */ - -/* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { - isa = XCLocalSwiftPackageReference; - relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; - }; -/* End XCLocalSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { - isa = XCSwiftPackageProductDependency; - productName = FlutterGeneratedPluginSwiftPackage; - }; -/* End XCSwiftPackageProductDependency section */ - }; - rootObject = 33CC10E52044A3C60003C045 /* Project object */; -} diff --git a/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/app/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme deleted file mode 100644 index 7f98d90..0000000 --- a/app/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/macos/Runner.xcworkspace/contents.xcworkspacedata b/app/macos/Runner.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 1d526a1..0000000 --- a/app/macos/Runner.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,7 +0,0 @@ - - - - - diff --git a/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/app/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/app/macos/Runner/AppDelegate.swift b/app/macos/Runner/AppDelegate.swift deleted file mode 100644 index b3c1761..0000000 --- a/app/macos/Runner/AppDelegate.swift +++ /dev/null @@ -1,13 +0,0 @@ -import Cocoa -import FlutterMacOS - -@main -class AppDelegate: FlutterAppDelegate { - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true - } - - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true - } -} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index a2ec33f..0000000 --- a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" - }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png deleted file mode 100644 index 82b6f9d..0000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png deleted file mode 100644 index 13b35eb..0000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png deleted file mode 100644 index 0a3f5fa..0000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png deleted file mode 100644 index bdb5722..0000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png deleted file mode 100644 index f083318..0000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png deleted file mode 100644 index 326c0e7..0000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and /dev/null differ diff --git a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png deleted file mode 100644 index 2f1632c..0000000 Binary files a/app/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and /dev/null differ diff --git a/app/macos/Runner/Base.lproj/MainMenu.xib b/app/macos/Runner/Base.lproj/MainMenu.xib deleted file mode 100644 index 80e867a..0000000 --- a/app/macos/Runner/Base.lproj/MainMenu.xib +++ /dev/null @@ -1,343 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/macos/Runner/Configs/AppInfo.xcconfig b/app/macos/Runner/Configs/AppInfo.xcconfig deleted file mode 100644 index 44e3bdf..0000000 --- a/app/macos/Runner/Configs/AppInfo.xcconfig +++ /dev/null @@ -1,14 +0,0 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. - -// The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = slogan_app - -// The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.slogan.sloganApp - -// The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2026 com.slogan. All rights reserved. diff --git a/app/macos/Runner/Configs/Debug.xcconfig b/app/macos/Runner/Configs/Debug.xcconfig deleted file mode 100644 index 36b0fd9..0000000 --- a/app/macos/Runner/Configs/Debug.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Debug.xcconfig" -#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Release.xcconfig b/app/macos/Runner/Configs/Release.xcconfig deleted file mode 100644 index dff4f49..0000000 --- a/app/macos/Runner/Configs/Release.xcconfig +++ /dev/null @@ -1,2 +0,0 @@ -#include "../../Flutter/Flutter-Release.xcconfig" -#include "Warnings.xcconfig" diff --git a/app/macos/Runner/Configs/Warnings.xcconfig b/app/macos/Runner/Configs/Warnings.xcconfig deleted file mode 100644 index 42bcbf4..0000000 --- a/app/macos/Runner/Configs/Warnings.xcconfig +++ /dev/null @@ -1,13 +0,0 @@ -WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings -GCC_WARN_UNDECLARED_SELECTOR = YES -CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES -CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE -CLANG_WARN__DUPLICATE_METHOD_MATCH = YES -CLANG_WARN_PRAGMA_PACK = YES -CLANG_WARN_STRICT_PROTOTYPES = YES -CLANG_WARN_COMMA = YES -GCC_WARN_STRICT_SELECTOR_MATCH = YES -CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES -CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES -GCC_WARN_SHADOW = YES -CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/app/macos/Runner/DebugProfile.entitlements b/app/macos/Runner/DebugProfile.entitlements deleted file mode 100644 index dddb8a3..0000000 --- a/app/macos/Runner/DebugProfile.entitlements +++ /dev/null @@ -1,12 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.cs.allow-jit - - com.apple.security.network.server - - - diff --git a/app/macos/Runner/Info.plist b/app/macos/Runner/Info.plist deleted file mode 100644 index 4789daa..0000000 --- a/app/macos/Runner/Info.plist +++ /dev/null @@ -1,32 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIconFile - - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - $(FLUTTER_BUILD_NAME) - CFBundleVersion - $(FLUTTER_BUILD_NUMBER) - LSMinimumSystemVersion - $(MACOSX_DEPLOYMENT_TARGET) - NSHumanReadableCopyright - $(PRODUCT_COPYRIGHT) - NSMainNibFile - MainMenu - NSPrincipalClass - NSApplication - - diff --git a/app/macos/Runner/MainFlutterWindow.swift b/app/macos/Runner/MainFlutterWindow.swift deleted file mode 100644 index 3cc05eb..0000000 --- a/app/macos/Runner/MainFlutterWindow.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Cocoa -import FlutterMacOS - -class MainFlutterWindow: NSWindow { - override func awakeFromNib() { - let flutterViewController = FlutterViewController() - let windowFrame = self.frame - self.contentViewController = flutterViewController - self.setFrame(windowFrame, display: true) - - RegisterGeneratedPlugins(registry: flutterViewController) - - super.awakeFromNib() - } -} diff --git a/app/macos/Runner/Release.entitlements b/app/macos/Runner/Release.entitlements deleted file mode 100644 index 852fa1a..0000000 --- a/app/macos/Runner/Release.entitlements +++ /dev/null @@ -1,8 +0,0 @@ - - - - - com.apple.security.app-sandbox - - - diff --git a/app/macos/RunnerTests/RunnerTests.swift b/app/macos/RunnerTests/RunnerTests.swift deleted file mode 100644 index 61f3bd1..0000000 --- a/app/macos/RunnerTests/RunnerTests.swift +++ /dev/null @@ -1,12 +0,0 @@ -import Cocoa -import FlutterMacOS -import XCTest - -class RunnerTests: XCTestCase { - - func testExample() { - // If you add code to the Runner application, consider adding tests here. - // See https://developer.apple.com/documentation/xctest for more information about using XCTest. - } - -} diff --git a/app/pubspec.lock b/app/pubspec.lock deleted file mode 100644 index e2642ce..0000000 --- a/app/pubspec.lock +++ /dev/null @@ -1,850 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - sha256: a49d6cf99e8d8e7a8e93668d09ced0bbdb954d0b4fccc2f5f9241c6b87fad95c - url: "https://pub.flutter-io.cn" - source: hosted - version: "99.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - sha256: "663efa951fb8a45e06f491223a604c93820598f20e6a99c25617a1576065e8b7" - url: "https://pub.flutter-io.cn" - source: hosted - version: "12.1.0" - args: - dependency: transitive - description: - name: args - sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.7.0" - async: - dependency: transitive - description: - name: async - sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.13.1" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.2" - characters: - dependency: transitive - description: - name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.4.1" - cli_config: - dependency: transitive - description: - name: cli_config - sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.0" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.2" - collection: - dependency: transitive - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.19.1" - convert: - dependency: transitive - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.1.2" - coverage: - dependency: transitive - description: - name: coverage - sha256: "956a3de0725ca232ad353565a8290d3357592bf4250f6f298a185e2d949c5d3d" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.15.1" - cross_file: - dependency: transitive - description: - name: cross_file - sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.3.5+4" - crypto: - dependency: transitive - description: - name: crypto - sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.7" - cupertino_icons: - dependency: "direct main" - description: - name: cupertino_icons - sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.0.9" - dio: - dependency: "direct main" - description: - name: dio - sha256: ea2bad3c89a27635ce2d85cce4d6b199da49a5a48ec77b03e45b65a3b90922b0 - url: "https://pub.flutter-io.cn" - source: hosted - version: "5.10.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: dd58dc3861eb36edb13b217efc006a1c21e5bbc341de8c229b85634fa5e362e4 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.2.0" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.2.0" - file: - dependency: transitive - description: - name: file - sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.flutter-io.cn" - source: hosted - version: "7.0.1" - file_selector_linux: - dependency: transitive - description: - name: file_selector_linux - sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.4" - file_selector_macos: - dependency: transitive - description: - name: file_selector_macos - sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.5" - file_selector_platform_interface: - dependency: transitive - description: - name: file_selector_platform_interface - sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.7.0" - file_selector_windows: - dependency: transitive - description: - name: file_selector_windows - sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.9.3+5" - fixnum: - dependency: transitive - description: - name: fixnum - sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "6.0.0" - flutter_plugin_android_lifecycle: - dependency: transitive - description: - name: flutter_plugin_android_lifecycle - sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.0.35" - flutter_riverpod: - dependency: "direct main" - description: - name: flutter_riverpod - sha256: "9255e1e3ad6e38906a1b4f8287678f95f378744c5b46b1985588543f3f19046e" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.3.2" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - frontend_server_client: - dependency: transitive - description: - name: frontend_server_client - sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.flutter-io.cn" - source: hosted - version: "4.0.0" - glob: - dependency: transitive - description: - name: glob - sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.3" - go_router: - dependency: "direct main" - description: - name: go_router - sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" - url: "https://pub.flutter-io.cn" - source: hosted - version: "17.3.0" - http: - dependency: transitive - description: - name: http - sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.6.0" - http_multi_server: - dependency: transitive - description: - name: http_multi_server - sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.2.2" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.flutter-io.cn" - source: hosted - version: "4.1.2" - image_picker: - dependency: "direct main" - description: - name: image_picker - sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.2.3" - image_picker_android: - dependency: transitive - description: - name: image_picker_android - sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.8.13+19" - image_picker_for_web: - dependency: transitive - description: - name: image_picker_for_web - sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.1.1" - image_picker_ios: - dependency: transitive - description: - name: image_picker_ios - sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.8.13+6" - image_picker_linux: - dependency: transitive - description: - name: image_picker_linux - sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.2" - image_picker_macos: - dependency: transitive - description: - name: image_picker_macos - sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.2+1" - image_picker_platform_interface: - dependency: transitive - description: - name: image_picker_platform_interface - sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.11.1" - image_picker_windows: - dependency: transitive - description: - name: image_picker_windows - sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.2.2" - io: - dependency: transitive - description: - name: io - sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.0.5" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.flutter-io.cn" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.flutter-io.cn" - source: hosted - version: "6.1.0" - logging: - dependency: transitive - description: - name: logging - sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.3.0" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.12.19" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.13.0" - meta: - dependency: transitive - description: - name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.18.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.0.0" - node_preamble: - dependency: transitive - description: - name: node_preamble - sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.0.2" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.2.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.9.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.2.2" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.3" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.3.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.8" - pool: - dependency: transitive - description: - name: pool - sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.5.2" - pub_semver: - dependency: transitive - description: - name: pub_semver - sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.2.0" - riverpod: - dependency: transitive - description: - name: riverpod - sha256: "17100416c51db7810c71a7bb2c34d1f881faa0074fd452afb0c4db6f8f126c76" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.3.2" - shared_preferences: - dependency: "direct main" - description: - name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.5.5" - shared_preferences_android: - dependency: transitive - description: - name: shared_preferences_android - sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.27" - shared_preferences_foundation: - dependency: transitive - description: - name: shared_preferences_foundation - sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.5.6" - shared_preferences_linux: - dependency: transitive - description: - name: shared_preferences_linux - sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.1" - shared_preferences_platform_interface: - dependency: transitive - description: - name: shared_preferences_platform_interface - sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.2" - shared_preferences_web: - dependency: transitive - description: - name: shared_preferences_web - sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.3" - shared_preferences_windows: - dependency: transitive - description: - name: shared_preferences_windows - sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.1" - shelf: - dependency: transitive - description: - name: shelf - sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.4.2" - shelf_packages_handler: - dependency: transitive - description: - name: shelf_packages_handler - sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.2" - shelf_static: - dependency: transitive - description: - name: shelf_static - sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.3" - shelf_web_socket: - dependency: transitive - description: - name: shelf_web_socket - sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_map_stack_trace: - dependency: transitive - description: - name: source_map_stack_trace - sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.2" - source_maps: - dependency: transitive - description: - name: source_maps - sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.10.13" - source_span: - dependency: transitive - description: - name: source_span - sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.10.2" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.12.1" - state_notifier: - dependency: transitive - description: - name: state_notifier - sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.0.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.1.4" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.2.2" - test: - dependency: transitive - description: - name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.31.0" - test_api: - dependency: transitive - description: - name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.7.11" - test_core: - dependency: transitive - description: - name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" - url: "https://pub.flutter-io.cn" - source: hosted - version: "0.6.17" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.4.0" - url_launcher: - dependency: "direct main" - description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.flutter-io.cn" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32 - url: "https://pub.flutter-io.cn" - source: hosted - version: "6.3.32" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" - url: "https://pub.flutter-io.cn" - source: hosted - version: "6.4.1" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.2.2" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.2.5" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.4.3" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.1.5" - uuid: - dependency: transitive - description: - name: uuid - sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" - url: "https://pub.flutter-io.cn" - source: hosted - version: "4.6.0" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.flutter-io.cn" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.flutter-io.cn" - source: hosted - version: "15.2.0" - watcher: - dependency: transitive - description: - name: watcher - sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.2.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.1" - web_socket: - dependency: transitive - description: - name: web_socket - sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.0.1" - web_socket_channel: - dependency: transitive - description: - name: web_socket_channel - sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.0.3" - webkit_inspection_protocol: - dependency: transitive - description: - name: webkit_inspection_protocol - sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.2.1" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.flutter-io.cn" - source: hosted - version: "1.1.0" - yaml: - dependency: transitive - description: - name: yaml - sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.flutter-io.cn" - source: hosted - version: "3.1.3" -sdks: - dart: ">=3.12.2 <4.0.0" - flutter: ">=3.44.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml deleted file mode 100644 index 5beb391..0000000 --- a/app/pubspec.yaml +++ /dev/null @@ -1,97 +0,0 @@ -name: slogan_app -description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.0.0+1 - -environment: - sdk: ^3.12.2 - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.8 - flutter_riverpod: ^3.3.2 - dio: ^5.10.0 - go_router: ^17.3.0 - image_picker: ^1.2.3 - shared_preferences: ^2.5.5 - url_launcher: ^6.3.0 - # v2 3D 渲染接入时引入:three_dart ^0.0.16 + three_dart_jsm ^0.0.10 + flutter_gl ^0.0.20 - # (flutter_gl 0.0.21 在 Flutter 3.44 下无法编译:platformViewRegistry 已移除) - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^6.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/app/test/core/auth/auth_provider_test.dart b/app/test/core/auth/auth_provider_test.dart deleted file mode 100644 index 6410f99..0000000 --- a/app/test/core/auth/auth_provider_test.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:slogan_app/core/auth/auth_provider.dart'; -import 'package:slogan_app/core/network/api_client.dart'; -import 'package:slogan_app/core/storage/token_storage.dart'; - -import '../network/api_client_test.dart'; - -void main() { - test('登录成功:token 持久化 + 状态 authenticated', () async { - final adapter = MockAdapter(); - adapter.onRequest = (o) => Response( - requestOptions: o, - data: { - 'code': 0, - 'message': 'OK', - 'data': { - 'token': 'jwt-token', - 'user': {'id': 7, 'name': '测试'} - } - }); - final storage = InMemoryTokenStorage(); - final container = ProviderContainer(overrides: [ - tokenStorageProvider.overrideWithValue(storage), - apiClientProvider.overrideWith((ref) => ApiClient( - tokenStorage: storage, - dio: Dio(BaseOptions(baseUrl: 'http://test')) - ..httpClientAdapter = adapter)), - ]); - addTearDown(container.dispose); - - await container.read(authProvider.notifier).login('a', '123456'); - final state = container.read(authProvider); - expect(state.value?.authenticated, true); - expect(state.value?.userId, 7); - expect(storage.token, 'jwt-token'); - }); - - test('登录失败:状态 error 携带 message', () async { - final adapter = MockAdapter(); - adapter.onRequest = (o) => Response( - requestOptions: o, - data: {'code': 50, 'message': '账号不存在', 'data': null}); - final storage = InMemoryTokenStorage(); - final container = ProviderContainer(overrides: [ - tokenStorageProvider.overrideWithValue(storage), - apiClientProvider.overrideWith((ref) => ApiClient( - tokenStorage: storage, - dio: Dio(BaseOptions(baseUrl: 'http://test')) - ..httpClientAdapter = adapter)), - ]); - addTearDown(container.dispose); - - await container.read(authProvider.notifier).login('a', '123456'); - final state = container.read(authProvider); - expect(state.hasError, true); - expect(state.error.toString(), contains('账号不存在')); - expect(state.value?.authenticated, false); - }); -} - -/// 内存版 TokenStorage(测试用,无需 SharedPreferences 插件) -class InMemoryTokenStorage extends TokenStorage { - String? _t; - - @override - String? get token => _t; - - @override - Future load() async {} - - @override - Future save(String token) async => _t = token; - - @override - Future clear() async => _t = null; -} diff --git a/app/test/core/network/api_client_test.dart b/app/test/core/network/api_client_test.dart deleted file mode 100644 index 20bef76..0000000 --- a/app/test/core/network/api_client_test.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:dio/dio.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:slogan_app/core/network/api_client.dart'; -import 'package:slogan_app/core/network/api_exception.dart'; -import 'package:slogan_app/core/storage/token_storage.dart'; - -/// 可编程 mock 的 Dio adapter -class MockAdapter implements HttpClientAdapter { - Response Function(RequestOptions options)? onRequest; - - @override - Future fetch(RequestOptions options, - Stream? requestStream, Future? cancelFuture) async { - final r = onRequest?.call(options); - if (r == null) return ResponseBody.fromString('', 500); - return ResponseBody.fromString( - jsonEncode(r.data), r.statusCode ?? 200, - headers: { - Headers.contentTypeHeader: [Headers.jsonContentType] - }); - } - - @override - void close({bool force = false}) {} -} - -class _FakeTokenStorage extends TokenStorage { - String? _t; - - _FakeTokenStorage(this._t); - - @override - String? get token => _t; - - @override - Future load() async {} - - @override - Future save(String token) async => _t = token; - - @override - Future clear() async => _t = null; -} - -void main() { - late MockAdapter adapter; - late ApiClient client; - - setUp(() { - adapter = MockAdapter(); - client = ApiClient( - tokenStorage: _FakeTokenStorage('test-token'), - dio: Dio(BaseOptions(baseUrl: 'http://test'))..httpClientAdapter = adapter); - }); - - test('成功响应解析 data', () async { - adapter.onRequest = (o) => Response( - requestOptions: o, - data: {'code': 0, 'message': 'OK', 'data': {'id': 1}}); - final id = await client.post>('/x', {}); - expect(id['id'], 1); - }); - - test('code != 0 抛 ApiException 带 message', () async { - adapter.onRequest = (o) => Response( - requestOptions: o, - data: {'code': 50, 'message': '衣橱服装不足', 'data': null}); - expect( - () => client.get>('/x'), - throwsA(isA() - .having((e) => e.code, 'code', 50) - .having((e) => e.message, 'message', '衣橱服装不足')), - ); - }); - - test('请求携带 Bearer token', () async { - String? auth; - adapter.onRequest = (o) { - auth = o.headers['Authorization']; - return Response( - requestOptions: o, data: {'code': 0, 'message': 'OK', 'data': null}); - }; - await client.get('/x'); - expect(auth, 'Bearer test-token'); - }); - - test('401 触发登出回调', () async { - var called = false; - client.onUnauthorized = () => called = true; - adapter.onRequest = (o) => Response( - requestOptions: o, - statusCode: 401, - data: {'code': 401, 'message': '未登录', 'data': null}); - try { - await client.get('/x'); - } catch (_) {} - expect(called, true); - }); -} diff --git a/app/test/features/member/benefits_test.dart b/app/test/features/member/benefits_test.dart deleted file mode 100644 index 4a10a15..0000000 --- a/app/test/features/member/benefits_test.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; -import 'package:slogan_app/features/member/member_provider.dart'; - -void main() { - test('benefitTexts 映射已知权益文案,未知 key 原样保留', () { - final info = MemberInfo( - isVip: true, - expireAt: '2026-08-30 12:00:00', - planName: '月卡', - benefits: const ['effect_unlimited', 'unknown_key'], - ); - expect(benefitTexts(info), ['无限效果图', 'unknown_key']); - }); - - test('benefitTexts 空权益返回空列表', () { - final info = MemberInfo( - isVip: false, - expireAt: '', - planName: '', - benefits: const [], - ); - expect(benefitTexts(info), isEmpty); - }); -} diff --git a/app/test/shared/app_toast_test.dart b/app/test/shared/app_toast_test.dart deleted file mode 100644 index a10cdbc..0000000 --- a/app/test/shared/app_toast_test.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:slogan_app/shared/app_toast.dart'; - -void main() { - testWidgets('showToast 显示在屏幕正中心', (tester) async { - await tester.pumpWidget(MaterialApp( - navigatorKey: appNavigatorKey, - home: const Scaffold(body: Center(child: Text('页面'))), - )); - - showToast('上传成功'); - await tester.pump(); - - final text = find.text('上传成功'); - expect(text, findsOneWidget); - final center = tester.getCenter(text); - final screen = tester.getSize(find.byType(MaterialApp)); - expect(center.dx, closeTo(screen.width / 2, 1)); - expect(center.dy, closeTo(screen.height / 2, 1)); - - // 等 toast 消失,避免测试结束时残留 Timer - await tester.pump(const Duration(seconds: 3)); - }); - - testWidgets('短文本 2 秒后消失', (tester) async { - await tester.pumpWidget(MaterialApp( - navigatorKey: appNavigatorKey, - home: const Scaffold(body: Center(child: Text('页面'))), - )); - - showToast('上传成功'); - await tester.pump(); - expect(find.text('上传成功'), findsOneWidget); - - // 2 秒整之前仍在 - await tester.pump(const Duration(milliseconds: 1900)); - expect(find.text('上传成功'), findsOneWidget); - - // 超过 2 秒后消失 - await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('上传成功'), findsNothing); - }); - - testWidgets('长文本 4 秒后消失', (tester) async { - await tester.pumpWidget(MaterialApp( - navigatorKey: appNavigatorKey, - home: const Scaffold(body: Center(child: Text('页面'))), - )); - - showToast('这是一个超过二十个字符的非常长的提示消息内容展示测试'); - await tester.pump(); - expect(find.text('这是一个超过二十个字符的非常长的提示消息内容展示测试'), - findsOneWidget); - - await tester.pump(const Duration(seconds: 3)); - expect(find.text('这是一个超过二十个字符的非常长的提示消息内容展示测试'), - findsOneWidget); - - await tester.pump(const Duration(seconds: 1, milliseconds: 500)); - expect(find.text('这是一个超过二十个字符的非常长的提示消息内容展示测试'), - findsNothing); - }); -} diff --git a/app/web/favicon.png b/app/web/favicon.png deleted file mode 100644 index 8aaa46a..0000000 Binary files a/app/web/favicon.png and /dev/null differ diff --git a/app/web/icons/Icon-192.png b/app/web/icons/Icon-192.png deleted file mode 100644 index b749bfe..0000000 Binary files a/app/web/icons/Icon-192.png and /dev/null differ diff --git a/app/web/icons/Icon-512.png b/app/web/icons/Icon-512.png deleted file mode 100644 index 88cfd48..0000000 Binary files a/app/web/icons/Icon-512.png and /dev/null differ diff --git a/app/web/icons/Icon-maskable-192.png b/app/web/icons/Icon-maskable-192.png deleted file mode 100644 index eb9b4d7..0000000 Binary files a/app/web/icons/Icon-maskable-192.png and /dev/null differ diff --git a/app/web/icons/Icon-maskable-512.png b/app/web/icons/Icon-maskable-512.png deleted file mode 100644 index d69c566..0000000 Binary files a/app/web/icons/Icon-maskable-512.png and /dev/null differ diff --git a/app/web/index.html b/app/web/index.html deleted file mode 100644 index 436d7a7..0000000 --- a/app/web/index.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - slogan_app - - - - - - - diff --git a/app/web/manifest.json b/app/web/manifest.json deleted file mode 100644 index 9f70f69..0000000 --- a/app/web/manifest.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "slogan_app", - "short_name": "slogan_app", - "start_url": ".", - "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", - "description": "A new Flutter project.", - "orientation": "portrait-primary", - "prefer_related_applications": false, - "icons": [ - { - "src": "icons/Icon-192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "icons/Icon-512.png", - "sizes": "512x512", - "type": "image/png" - }, - { - "src": "icons/Icon-maskable-192.png", - "sizes": "192x192", - "type": "image/png", - "purpose": "maskable" - }, - { - "src": "icons/Icon-maskable-512.png", - "sizes": "512x512", - "type": "image/png", - "purpose": "maskable" - } - ] -} diff --git a/app/windows/.gitignore b/app/windows/.gitignore deleted file mode 100644 index d492d0d..0000000 --- a/app/windows/.gitignore +++ /dev/null @@ -1,17 +0,0 @@ -flutter/ephemeral/ - -# Visual Studio user-specific files. -*.suo -*.user -*.userosscache -*.sln.docstates - -# Visual Studio build-related files. -x64/ -x86/ - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!*.[Cc]ache/ diff --git a/app/windows/CMakeLists.txt b/app/windows/CMakeLists.txt deleted file mode 100644 index 5aa7dd8..0000000 --- a/app/windows/CMakeLists.txt +++ /dev/null @@ -1,108 +0,0 @@ -# Project-level configuration. -cmake_minimum_required(VERSION 3.14) -project(slogan_app LANGUAGES CXX) - -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. -set(BINARY_NAME "slogan_app") - -# Explicitly opt in to modern CMake behaviors to avoid warnings with recent -# versions of CMake. -cmake_policy(VERSION 3.14...3.25) - -# Define build configuration option. -get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) -if(IS_MULTICONFIG) - set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" - CACHE STRING "" FORCE) -else() - if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE "Debug" CACHE - STRING "Flutter build mode" FORCE) - set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS - "Debug" "Profile" "Release") - endif() -endif() -# Define settings for the Profile build mode. -set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") -set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") -set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") -set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") - -# Use Unicode for all projects. -add_definitions(-DUNICODE -D_UNICODE) - -# Compilation settings that should be applied to most targets. -# -# Be cautious about adding new options here, as plugins use this function by -# default. In most cases, you should add new options to specific targets instead -# of modifying this function. -function(APPLY_STANDARD_SETTINGS TARGET) - target_compile_features(${TARGET} PUBLIC cxx_std_17) - target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") - target_compile_options(${TARGET} PRIVATE /EHsc) - target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") - target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") -endfunction() - -# Flutter library and tool build rules. -set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") -add_subdirectory(${FLUTTER_MANAGED_DIR}) - -# Application build; see runner/CMakeLists.txt. -add_subdirectory("runner") - - -# Generated plugin build rules, which manage building the plugins and adding -# them to the application. -include(flutter/generated_plugins.cmake) - - -# === Installation === -# Support files are copied into place next to the executable, so that it can -# run in place. This is done instead of making a separate bundle (as on Linux) -# so that building and running from within Visual Studio will work. -set(BUILD_BUNDLE_DIR "$") -# Make the "install" step default, as it's required to run. -set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) -if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) -endif() - -set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") -set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") - -install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - COMPONENT Runtime) - -install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -if(PLUGIN_BUNDLED_LIBRARIES) - install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) -endif() - -# Copy the native assets provided by the build.dart from all packages. -set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") -install(DIRECTORY "${NATIVE_ASSETS_DIR}" - DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" - COMPONENT Runtime) - -# Fully re-copy the assets directory on each build to avoid having stale files -# from a previous install. -set(FLUTTER_ASSET_DIR_NAME "flutter_assets") -install(CODE " - file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") - " COMPONENT Runtime) -install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" - DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) - -# Install the AOT library on non-Debug builds only. -install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" - CONFIGURATIONS Profile;Release - COMPONENT Runtime) diff --git a/app/windows/flutter/CMakeLists.txt b/app/windows/flutter/CMakeLists.txt deleted file mode 100644 index 903f489..0000000 --- a/app/windows/flutter/CMakeLists.txt +++ /dev/null @@ -1,109 +0,0 @@ -# This file controls Flutter-level build steps. It should not be edited. -cmake_minimum_required(VERSION 3.14) - -set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") - -# Configuration provided via flutter tool. -include(${EPHEMERAL_DIR}/generated_config.cmake) - -# TODO: Move the rest of this into files in ephemeral. See -# https://github.com/flutter/flutter/issues/57146. -set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") - -# Set fallback configurations for older versions of the flutter tool. -if (NOT DEFINED FLUTTER_TARGET_PLATFORM) - set(FLUTTER_TARGET_PLATFORM "windows-x64") -endif() - -# === Flutter Library === -set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") - -# Published to parent scope for install step. -set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) -set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) -set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) -set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) - -list(APPEND FLUTTER_LIBRARY_HEADERS - "flutter_export.h" - "flutter_windows.h" - "flutter_messenger.h" - "flutter_plugin_registrar.h" - "flutter_texture_registrar.h" -) -list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") -add_library(flutter INTERFACE) -target_include_directories(flutter INTERFACE - "${EPHEMERAL_DIR}" -) -target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") -add_dependencies(flutter flutter_assemble) - -# === Wrapper === -list(APPEND CPP_WRAPPER_SOURCES_CORE - "core_implementations.cc" - "standard_codec.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_PLUGIN - "plugin_registrar.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") -list(APPEND CPP_WRAPPER_SOURCES_APP - "flutter_engine.cc" - "flutter_view_controller.cc" -) -list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") - -# Wrapper sources needed for a plugin. -add_library(flutter_wrapper_plugin STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} -) -apply_standard_settings(flutter_wrapper_plugin) -set_target_properties(flutter_wrapper_plugin PROPERTIES - POSITION_INDEPENDENT_CODE ON) -set_target_properties(flutter_wrapper_plugin PROPERTIES - CXX_VISIBILITY_PRESET hidden) -target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) -target_include_directories(flutter_wrapper_plugin PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_plugin flutter_assemble) - -# Wrapper sources needed for the runner. -add_library(flutter_wrapper_app STATIC - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_APP} -) -apply_standard_settings(flutter_wrapper_app) -target_link_libraries(flutter_wrapper_app PUBLIC flutter) -target_include_directories(flutter_wrapper_app PUBLIC - "${WRAPPER_ROOT}/include" -) -add_dependencies(flutter_wrapper_app flutter_assemble) - -# === Flutter tool backend === -# _phony_ is a non-existent file to force this command to run every time, -# since currently there's no way to get a full input/output list from the -# flutter tool. -set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") -set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) -add_custom_command( - OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} - ${PHONY_OUTPUT} - COMMAND ${CMAKE_COMMAND} -E env - ${FLUTTER_TOOL_ENVIRONMENT} - "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" - ${FLUTTER_TARGET_PLATFORM} $ - VERBATIM -) -add_custom_target(flutter_assemble DEPENDS - "${FLUTTER_LIBRARY}" - ${FLUTTER_LIBRARY_HEADERS} - ${CPP_WRAPPER_SOURCES_CORE} - ${CPP_WRAPPER_SOURCES_PLUGIN} - ${CPP_WRAPPER_SOURCES_APP} -) diff --git a/app/windows/flutter/generated_plugin_registrant.cc b/app/windows/flutter/generated_plugin_registrant.cc deleted file mode 100644 index 043a96f..0000000 --- a/app/windows/flutter/generated_plugin_registrant.cc +++ /dev/null @@ -1,17 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#include "generated_plugin_registrant.h" - -#include -#include - -void RegisterPlugins(flutter::PluginRegistry* registry) { - FileSelectorWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FileSelectorWindows")); - UrlLauncherWindowsRegisterWithRegistrar( - registry->GetRegistrarForPlugin("UrlLauncherWindows")); -} diff --git a/app/windows/flutter/generated_plugin_registrant.h b/app/windows/flutter/generated_plugin_registrant.h deleted file mode 100644 index dc139d8..0000000 --- a/app/windows/flutter/generated_plugin_registrant.h +++ /dev/null @@ -1,15 +0,0 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#ifndef GENERATED_PLUGIN_REGISTRANT_ -#define GENERATED_PLUGIN_REGISTRANT_ - -#include - -// Registers Flutter plugins. -void RegisterPlugins(flutter::PluginRegistry* registry); - -#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/app/windows/flutter/generated_plugins.cmake b/app/windows/flutter/generated_plugins.cmake deleted file mode 100644 index a95e267..0000000 --- a/app/windows/flutter/generated_plugins.cmake +++ /dev/null @@ -1,25 +0,0 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - file_selector_windows - url_launcher_windows -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) diff --git a/app/windows/runner/CMakeLists.txt b/app/windows/runner/CMakeLists.txt deleted file mode 100644 index 394917c..0000000 --- a/app/windows/runner/CMakeLists.txt +++ /dev/null @@ -1,40 +0,0 @@ -cmake_minimum_required(VERSION 3.14) -project(runner LANGUAGES CXX) - -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. -add_executable(${BINARY_NAME} WIN32 - "flutter_window.cpp" - "main.cpp" - "utils.cpp" - "win32_window.cpp" - "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" - "Runner.rc" - "runner.exe.manifest" -) - -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. -apply_standard_settings(${BINARY_NAME}) - -# Add preprocessor definitions for the build version. -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") -target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") - -# Disable Windows macros that collide with C++ standard library functions. -target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") - -# Add dependency libraries and include directories. Add any application-specific -# dependencies here. -target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) -target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") -target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") - -# Run the Flutter tool portions of the build. This must not be removed. -add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/app/windows/runner/Runner.rc b/app/windows/runner/Runner.rc deleted file mode 100644 index fa24afc..0000000 --- a/app/windows/runner/Runner.rc +++ /dev/null @@ -1,121 +0,0 @@ -// Microsoft Visual C++ generated resource script. -// -#pragma code_page(65001) -#include "resource.h" - -#define APSTUDIO_READONLY_SYMBOLS -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 2 resource. -// -#include "winres.h" - -///////////////////////////////////////////////////////////////////////////// -#undef APSTUDIO_READONLY_SYMBOLS - -///////////////////////////////////////////////////////////////////////////// -// English (United States) resources - -#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) -LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US - -#ifdef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// TEXTINCLUDE -// - -1 TEXTINCLUDE -BEGIN - "resource.h\0" -END - -2 TEXTINCLUDE -BEGIN - "#include ""winres.h""\r\n" - "\0" -END - -3 TEXTINCLUDE -BEGIN - "\r\n" - "\0" -END - -#endif // APSTUDIO_INVOKED - - -///////////////////////////////////////////////////////////////////////////// -// -// Icon -// - -// Icon with lowest ID value placed first to ensure application icon -// remains consistent on all systems. -IDI_APP_ICON ICON "resources\\app_icon.ico" - - -///////////////////////////////////////////////////////////////////////////// -// -// Version -// - -#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) -#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD -#else -#define VERSION_AS_NUMBER 1,0,0,0 -#endif - -#if defined(FLUTTER_VERSION) -#define VERSION_AS_STRING FLUTTER_VERSION -#else -#define VERSION_AS_STRING "1.0.0" -#endif - -VS_VERSION_INFO VERSIONINFO - FILEVERSION VERSION_AS_NUMBER - PRODUCTVERSION VERSION_AS_NUMBER - FILEFLAGSMASK VS_FFI_FILEFLAGSMASK -#ifdef _DEBUG - FILEFLAGS VS_FF_DEBUG -#else - FILEFLAGS 0x0L -#endif - FILEOS VOS__WINDOWS32 - FILETYPE VFT_APP - FILESUBTYPE 0x0L -BEGIN - BLOCK "StringFileInfo" - BEGIN - BLOCK "040904e4" - BEGIN - VALUE "CompanyName", "com.slogan" "\0" - VALUE "FileDescription", "slogan_app" "\0" - VALUE "FileVersion", VERSION_AS_STRING "\0" - VALUE "InternalName", "slogan_app" "\0" - VALUE "LegalCopyright", "Copyright (C) 2026 com.slogan. All rights reserved." "\0" - VALUE "OriginalFilename", "slogan_app.exe" "\0" - VALUE "ProductName", "slogan_app" "\0" - VALUE "ProductVersion", VERSION_AS_STRING "\0" - END - END - BLOCK "VarFileInfo" - BEGIN - VALUE "Translation", 0x409, 1252 - END -END - -#endif // English (United States) resources -///////////////////////////////////////////////////////////////////////////// - - - -#ifndef APSTUDIO_INVOKED -///////////////////////////////////////////////////////////////////////////// -// -// Generated from the TEXTINCLUDE 3 resource. -// - - -///////////////////////////////////////////////////////////////////////////// -#endif // not APSTUDIO_INVOKED diff --git a/app/windows/runner/flutter_window.cpp b/app/windows/runner/flutter_window.cpp deleted file mode 100644 index 955ee30..0000000 --- a/app/windows/runner/flutter_window.cpp +++ /dev/null @@ -1,71 +0,0 @@ -#include "flutter_window.h" - -#include - -#include "flutter/generated_plugin_registrant.h" - -FlutterWindow::FlutterWindow(const flutter::DartProject& project) - : project_(project) {} - -FlutterWindow::~FlutterWindow() {} - -bool FlutterWindow::OnCreate() { - if (!Win32Window::OnCreate()) { - return false; - } - - RECT frame = GetClientArea(); - - // The size here must match the window dimensions to avoid unnecessary surface - // creation / destruction in the startup path. - flutter_controller_ = std::make_unique( - frame.right - frame.left, frame.bottom - frame.top, project_); - // Ensure that basic setup of the controller was successful. - if (!flutter_controller_->engine() || !flutter_controller_->view()) { - return false; - } - RegisterPlugins(flutter_controller_->engine()); - SetChildContent(flutter_controller_->view()->GetNativeWindow()); - - flutter_controller_->engine()->SetNextFrameCallback([&]() { - this->Show(); - }); - - // Flutter can complete the first frame before the "show window" callback is - // registered. The following call ensures a frame is pending to ensure the - // window is shown. It is a no-op if the first frame hasn't completed yet. - flutter_controller_->ForceRedraw(); - - return true; -} - -void FlutterWindow::OnDestroy() { - if (flutter_controller_) { - flutter_controller_ = nullptr; - } - - Win32Window::OnDestroy(); -} - -LRESULT -FlutterWindow::MessageHandler(HWND hwnd, UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - // Give Flutter, including plugins, an opportunity to handle window messages. - if (flutter_controller_) { - std::optional result = - flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, - lparam); - if (result) { - return *result; - } - } - - switch (message) { - case WM_FONTCHANGE: - flutter_controller_->engine()->ReloadSystemFonts(); - break; - } - - return Win32Window::MessageHandler(hwnd, message, wparam, lparam); -} diff --git a/app/windows/runner/flutter_window.h b/app/windows/runner/flutter_window.h deleted file mode 100644 index 6da0652..0000000 --- a/app/windows/runner/flutter_window.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef RUNNER_FLUTTER_WINDOW_H_ -#define RUNNER_FLUTTER_WINDOW_H_ - -#include -#include - -#include - -#include "win32_window.h" - -// A window that does nothing but host a Flutter view. -class FlutterWindow : public Win32Window { - public: - // Creates a new FlutterWindow hosting a Flutter view running |project|. - explicit FlutterWindow(const flutter::DartProject& project); - virtual ~FlutterWindow(); - - protected: - // Win32Window: - bool OnCreate() override; - void OnDestroy() override; - LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, - LPARAM const lparam) noexcept override; - - private: - // The project to run. - flutter::DartProject project_; - - // The Flutter instance hosted by this window. - std::unique_ptr flutter_controller_; -}; - -#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/app/windows/runner/main.cpp b/app/windows/runner/main.cpp deleted file mode 100644 index 6a71b3b..0000000 --- a/app/windows/runner/main.cpp +++ /dev/null @@ -1,43 +0,0 @@ -#include -#include -#include - -#include "flutter_window.h" -#include "utils.h" - -int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, - _In_ wchar_t *command_line, _In_ int show_command) { - // Attach to console when present (e.g., 'flutter run') or create a - // new console when running with a debugger. - if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { - CreateAndAttachConsole(); - } - - // Initialize COM, so that it is available for use in the library and/or - // plugins. - ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); - - flutter::DartProject project(L"data"); - - std::vector command_line_arguments = - GetCommandLineArguments(); - - project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); - - FlutterWindow window(project); - Win32Window::Point origin(10, 10); - Win32Window::Size size(1280, 720); - if (!window.Create(L"slogan_app", origin, size)) { - return EXIT_FAILURE; - } - window.SetQuitOnClose(true); - - ::MSG msg; - while (::GetMessage(&msg, nullptr, 0, 0)) { - ::TranslateMessage(&msg); - ::DispatchMessage(&msg); - } - - ::CoUninitialize(); - return EXIT_SUCCESS; -} diff --git a/app/windows/runner/resource.h b/app/windows/runner/resource.h deleted file mode 100644 index 66a65d1..0000000 --- a/app/windows/runner/resource.h +++ /dev/null @@ -1,16 +0,0 @@ -//{{NO_DEPENDENCIES}} -// Microsoft Visual C++ generated include file. -// Used by Runner.rc -// -#define IDI_APP_ICON 101 - -// Next default values for new objects -// -#ifdef APSTUDIO_INVOKED -#ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 102 -#define _APS_NEXT_COMMAND_VALUE 40001 -#define _APS_NEXT_CONTROL_VALUE 1001 -#define _APS_NEXT_SYMED_VALUE 101 -#endif -#endif diff --git a/app/windows/runner/resources/app_icon.ico b/app/windows/runner/resources/app_icon.ico deleted file mode 100644 index c04e20c..0000000 Binary files a/app/windows/runner/resources/app_icon.ico and /dev/null differ diff --git a/app/windows/runner/runner.exe.manifest b/app/windows/runner/runner.exe.manifest deleted file mode 100644 index 153653e..0000000 --- a/app/windows/runner/runner.exe.manifest +++ /dev/null @@ -1,14 +0,0 @@ - - - - - PerMonitorV2 - - - - - - - - - diff --git a/app/windows/runner/utils.cpp b/app/windows/runner/utils.cpp deleted file mode 100644 index 3cb7146..0000000 --- a/app/windows/runner/utils.cpp +++ /dev/null @@ -1,69 +0,0 @@ -#include "utils.h" - -#include -#include -#include -#include - -#include - -void CreateAndAttachConsole() { - if (::AllocConsole()) { - FILE *unused; - if (freopen_s(&unused, "CONOUT$", "w", stdout)) { - _dup2(_fileno(stdout), 1); - } - if (freopen_s(&unused, "CONOUT$", "w", stderr)) { - _dup2(_fileno(stdout), 2); - } - std::ios::sync_with_stdio(); - FlutterDesktopResyncOutputStreams(); - } -} - -std::vector GetCommandLineArguments() { - // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. - int argc; - wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); - if (argv == nullptr) { - return std::vector(); - } - - std::vector command_line_arguments; - - // Skip the first argument as it's the binary name. - for (int i = 1; i < argc; i++) { - command_line_arguments.push_back(Utf8FromUtf16(argv[i])); - } - - ::LocalFree(argv); - - return command_line_arguments; -} - -std::string Utf8FromUtf16(const wchar_t* utf16_string) { - if (utf16_string == nullptr) { - return std::string(); - } - // First, find the length of the string with a safe upper bound (CWE-126). - // UNICODE_STRING_MAX_CHARS (32767) is the maximum length of a UNICODE_STRING. - int input_length = static_cast(wcsnlen(utf16_string, UNICODE_STRING_MAX_CHARS)); - // Now use that bounded length to determine the required buffer size. - // When an explicit length is passed, WideCharToMultiByte does not include - // the null terminator in its returned size. - int target_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, nullptr, 0, nullptr, nullptr); - std::string utf8_string; - if (target_length == 0 || static_cast(target_length) > utf8_string.max_size()) { - return utf8_string; - } - utf8_string.resize(target_length); - int converted_length = ::WideCharToMultiByte( - CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, - input_length, utf8_string.data(), target_length, nullptr, nullptr); - if (converted_length == 0) { - return std::string(); - } - return utf8_string; -} diff --git a/app/windows/runner/utils.h b/app/windows/runner/utils.h deleted file mode 100644 index 3879d54..0000000 --- a/app/windows/runner/utils.h +++ /dev/null @@ -1,19 +0,0 @@ -#ifndef RUNNER_UTILS_H_ -#define RUNNER_UTILS_H_ - -#include -#include - -// Creates a console for the process, and redirects stdout and stderr to -// it for both the runner and the Flutter library. -void CreateAndAttachConsole(); - -// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string -// encoded in UTF-8. Returns an empty std::string on failure. -std::string Utf8FromUtf16(const wchar_t* utf16_string); - -// Gets the command line arguments passed in as a std::vector, -// encoded in UTF-8. Returns an empty std::vector on failure. -std::vector GetCommandLineArguments(); - -#endif // RUNNER_UTILS_H_ diff --git a/app/windows/runner/win32_window.cpp b/app/windows/runner/win32_window.cpp deleted file mode 100644 index 60608d0..0000000 --- a/app/windows/runner/win32_window.cpp +++ /dev/null @@ -1,288 +0,0 @@ -#include "win32_window.h" - -#include -#include - -#include "resource.h" - -namespace { - -/// Window attribute that enables dark mode window decorations. -/// -/// Redefined in case the developer's machine has a Windows SDK older than -/// version 10.0.22000.0. -/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute -#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE -#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 -#endif - -constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; - -/// Registry key for app theme preference. -/// -/// A value of 0 indicates apps should use dark mode. A non-zero or missing -/// value indicates apps should use light mode. -constexpr const wchar_t kGetPreferredBrightnessRegKey[] = - L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; -constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; - -// The number of Win32Window objects that currently exist. -static int g_active_window_count = 0; - -using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); - -// Scale helper to convert logical scaler values to physical using passed in -// scale factor -int Scale(int source, double scale_factor) { - return static_cast(source * scale_factor); -} - -// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. -// This API is only needed for PerMonitor V1 awareness mode. -void EnableFullDpiSupportIfAvailable(HWND hwnd) { - HMODULE user32_module = LoadLibraryA("User32.dll"); - if (!user32_module) { - return; - } - auto enable_non_client_dpi_scaling = - reinterpret_cast( - GetProcAddress(user32_module, "EnableNonClientDpiScaling")); - if (enable_non_client_dpi_scaling != nullptr) { - enable_non_client_dpi_scaling(hwnd); - } - FreeLibrary(user32_module); -} - -} // namespace - -// Manages the Win32Window's window class registration. -class WindowClassRegistrar { - public: - ~WindowClassRegistrar() = default; - - // Returns the singleton registrar instance. - static WindowClassRegistrar* GetInstance() { - if (!instance_) { - instance_ = new WindowClassRegistrar(); - } - return instance_; - } - - // Returns the name of the window class, registering the class if it hasn't - // previously been registered. - const wchar_t* GetWindowClass(); - - // Unregisters the window class. Should only be called if there are no - // instances of the window. - void UnregisterWindowClass(); - - private: - WindowClassRegistrar() = default; - - static WindowClassRegistrar* instance_; - - bool class_registered_ = false; -}; - -WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; - -const wchar_t* WindowClassRegistrar::GetWindowClass() { - if (!class_registered_) { - WNDCLASS window_class{}; - window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); - window_class.lpszClassName = kWindowClassName; - window_class.style = CS_HREDRAW | CS_VREDRAW; - window_class.cbClsExtra = 0; - window_class.cbWndExtra = 0; - window_class.hInstance = GetModuleHandle(nullptr); - window_class.hIcon = - LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); - window_class.hbrBackground = 0; - window_class.lpszMenuName = nullptr; - window_class.lpfnWndProc = Win32Window::WndProc; - RegisterClass(&window_class); - class_registered_ = true; - } - return kWindowClassName; -} - -void WindowClassRegistrar::UnregisterWindowClass() { - UnregisterClass(kWindowClassName, nullptr); - class_registered_ = false; -} - -Win32Window::Win32Window() { - ++g_active_window_count; -} - -Win32Window::~Win32Window() { - --g_active_window_count; - Destroy(); -} - -bool Win32Window::Create(const std::wstring& title, - const Point& origin, - const Size& size) { - Destroy(); - - const wchar_t* window_class = - WindowClassRegistrar::GetInstance()->GetWindowClass(); - - const POINT target_point = {static_cast(origin.x), - static_cast(origin.y)}; - HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); - UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); - double scale_factor = dpi / 96.0; - - HWND window = CreateWindow( - window_class, title.c_str(), WS_OVERLAPPEDWINDOW, - Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), - Scale(size.width, scale_factor), Scale(size.height, scale_factor), - nullptr, nullptr, GetModuleHandle(nullptr), this); - - if (!window) { - return false; - } - - UpdateTheme(window); - - return OnCreate(); -} - -bool Win32Window::Show() { - return ShowWindow(window_handle_, SW_SHOWNORMAL); -} - -// static -LRESULT CALLBACK Win32Window::WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - if (message == WM_NCCREATE) { - auto window_struct = reinterpret_cast(lparam); - SetWindowLongPtr(window, GWLP_USERDATA, - reinterpret_cast(window_struct->lpCreateParams)); - - auto that = static_cast(window_struct->lpCreateParams); - EnableFullDpiSupportIfAvailable(window); - that->window_handle_ = window; - } else if (Win32Window* that = GetThisFromHandle(window)) { - return that->MessageHandler(window, message, wparam, lparam); - } - - return DefWindowProc(window, message, wparam, lparam); -} - -LRESULT -Win32Window::MessageHandler(HWND hwnd, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept { - switch (message) { - case WM_DESTROY: - window_handle_ = nullptr; - Destroy(); - if (quit_on_close_) { - PostQuitMessage(0); - } - return 0; - - case WM_DPICHANGED: { - auto newRectSize = reinterpret_cast(lparam); - LONG newWidth = newRectSize->right - newRectSize->left; - LONG newHeight = newRectSize->bottom - newRectSize->top; - - SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, - newHeight, SWP_NOZORDER | SWP_NOACTIVATE); - - return 0; - } - case WM_SIZE: { - RECT rect = GetClientArea(); - if (child_content_ != nullptr) { - // Size and position the child window. - MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, - rect.bottom - rect.top, TRUE); - } - return 0; - } - - case WM_ACTIVATE: - if (child_content_ != nullptr) { - SetFocus(child_content_); - } - return 0; - - case WM_DWMCOLORIZATIONCOLORCHANGED: - UpdateTheme(hwnd); - return 0; - } - - return DefWindowProc(window_handle_, message, wparam, lparam); -} - -void Win32Window::Destroy() { - OnDestroy(); - - if (window_handle_) { - DestroyWindow(window_handle_); - window_handle_ = nullptr; - } - if (g_active_window_count == 0) { - WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); - } -} - -Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { - return reinterpret_cast( - GetWindowLongPtr(window, GWLP_USERDATA)); -} - -void Win32Window::SetChildContent(HWND content) { - child_content_ = content; - SetParent(content, window_handle_); - RECT frame = GetClientArea(); - - MoveWindow(content, frame.left, frame.top, frame.right - frame.left, - frame.bottom - frame.top, true); - - SetFocus(child_content_); -} - -RECT Win32Window::GetClientArea() { - RECT frame; - GetClientRect(window_handle_, &frame); - return frame; -} - -HWND Win32Window::GetHandle() { - return window_handle_; -} - -void Win32Window::SetQuitOnClose(bool quit_on_close) { - quit_on_close_ = quit_on_close; -} - -bool Win32Window::OnCreate() { - // No-op; provided for subclasses. - return true; -} - -void Win32Window::OnDestroy() { - // No-op; provided for subclasses. -} - -void Win32Window::UpdateTheme(HWND const window) { - DWORD light_mode; - DWORD light_mode_size = sizeof(light_mode); - LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, - kGetPreferredBrightnessRegValue, - RRF_RT_REG_DWORD, nullptr, &light_mode, - &light_mode_size); - - if (result == ERROR_SUCCESS) { - BOOL enable_dark_mode = light_mode == 0; - DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, - &enable_dark_mode, sizeof(enable_dark_mode)); - } -} diff --git a/app/windows/runner/win32_window.h b/app/windows/runner/win32_window.h deleted file mode 100644 index e901dde..0000000 --- a/app/windows/runner/win32_window.h +++ /dev/null @@ -1,102 +0,0 @@ -#ifndef RUNNER_WIN32_WINDOW_H_ -#define RUNNER_WIN32_WINDOW_H_ - -#include - -#include -#include -#include - -// A class abstraction for a high DPI-aware Win32 Window. Intended to be -// inherited from by classes that wish to specialize with custom -// rendering and input handling -class Win32Window { - public: - struct Point { - unsigned int x; - unsigned int y; - Point(unsigned int x, unsigned int y) : x(x), y(y) {} - }; - - struct Size { - unsigned int width; - unsigned int height; - Size(unsigned int width, unsigned int height) - : width(width), height(height) {} - }; - - Win32Window(); - virtual ~Win32Window(); - - // Creates a win32 window with |title| that is positioned and sized using - // |origin| and |size|. New windows are created on the default monitor. Window - // sizes are specified to the OS in physical pixels, hence to ensure a - // consistent size this function will scale the inputted width and height as - // as appropriate for the default monitor. The window is invisible until - // |Show| is called. Returns true if the window was created successfully. - bool Create(const std::wstring& title, const Point& origin, const Size& size); - - // Show the current window. Returns true if the window was successfully shown. - bool Show(); - - // Release OS resources associated with window. - void Destroy(); - - // Inserts |content| into the window tree. - void SetChildContent(HWND content); - - // Returns the backing Window handle to enable clients to set icon and other - // window properties. Returns nullptr if the window has been destroyed. - HWND GetHandle(); - - // If true, closing this window will quit the application. - void SetQuitOnClose(bool quit_on_close); - - // Return a RECT representing the bounds of the current client area. - RECT GetClientArea(); - - protected: - // Processes and route salient window messages for mouse handling, - // size change and DPI. Delegates handling of these to member overloads that - // inheriting classes can handle. - virtual LRESULT MessageHandler(HWND window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Called when CreateAndShow is called, allowing subclass window-related - // setup. Subclasses should return false if setup fails. - virtual bool OnCreate(); - - // Called when Destroy is called. - virtual void OnDestroy(); - - private: - friend class WindowClassRegistrar; - - // OS callback called by message pump. Handles the WM_NCCREATE message which - // is passed when the non-client area is being created and enables automatic - // non-client DPI scaling so that the non-client area automatically - // responds to changes in DPI. All other messages are handled by - // MessageHandler. - static LRESULT CALLBACK WndProc(HWND const window, - UINT const message, - WPARAM const wparam, - LPARAM const lparam) noexcept; - - // Retrieves a class instance pointer for |window| - static Win32Window* GetThisFromHandle(HWND const window) noexcept; - - // Update the window frame's theme to match the system theme. - static void UpdateTheme(HWND const window); - - bool quit_on_close_ = false; - - // window handle for top level window. - HWND window_handle_ = nullptr; - - // window handle for hosted content. - HWND child_content_ = nullptr; -}; - -#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/server/Dockerfile b/server/Dockerfile index 926b948..a5e06a1 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,17 +1,15 @@ # ============================================================ -# 统一构建:Flutter web 前端 + Go 后端 + Node 渲染器 +# 统一构建:uni-app H5 前端 + Go 后端 # 构建上下文必须为仓库根目录(docker build -f server/Dockerfile .) # ============================================================ -# ---------- 前端:Flutter web 构建 ---------- -FROM ghcr.io/cirruslabs/flutter:stable AS web-builder -ENV PUB_HOSTED_URL=https://pub.flutter-io.cn -ENV FLUTTER_STORAGE_BASE_URL=https://storage.flutter-io.cn +# ---------- 前端:uni-app H5 构建 ---------- +FROM node:20-alpine AS web-builder WORKDIR /web -COPY app/pubspec.yaml app/pubspec.lock ./ -RUN flutter pub get -COPY app/ . -RUN flutter build web --release +COPY app-uni/package.json app-uni/package-lock.json ./ +RUN npm ci --registry=https://registry.npmmirror.com +COPY app-uni/ . +RUN npm run build:h5 # ---------- 后端:Go 编译 ---------- FROM golang:alpine AS builder @@ -28,26 +26,16 @@ RUN go mod download COPY server/ . RUN go build -ldflags="-s -w" -o main ./main.go -# ---------- 渲染器:Node + headless-gl(需原生编译) ---------- -FROM node:20-alpine AS renderer -RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ - && apk add --no-cache git python3 make g++ mesa mesa-dev ca-certificates tzdata -WORKDIR /render -COPY server/scripts/avatar-render/package*.json ./ -RUN npm ci --omit=dev --registry=https://registry.npmmirror.com - # ---------- 运行时 ---------- FROM alpine:3.19 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ - && apk add --no-cache ca-certificates tzdata libstdc++ libgcc mesa nodejs + && apk add --no-cache ca-certificates tzdata libstdc++ libgcc ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone WORKDIR /app -COPY --from=web-builder /web/build/web ./web +COPY --from=web-builder /web/dist/build/h5 ./web COPY --from=builder /build/config.yml . COPY --from=builder /build/main . -COPY --from=renderer /render/node_modules ./scripts/avatar-render/node_modules -COPY server/scripts/avatar-render/ ./scripts/avatar-render/ RUN mkdir -p /app/workspace /app/data -EXPOSE 3007 +EXPOSE 8080 ENTRYPOINT ["./main"] diff --git a/server/common/base_dao.go b/server/common/base_dao.go index 6bff01c..8b25ed6 100644 --- a/server/common/base_dao.go +++ b/server/common/base_dao.go @@ -24,6 +24,7 @@ func InsertAndReturnId(ctx context.Context, table string, data any) (id int64, e if err != nil { return 0, err } + CacheClear(ctx, g.DB(), table) if r == nil { return 0, nil } @@ -32,7 +33,7 @@ func InsertAndReturnId(ctx context.Context, table string, data any) (id int64, e func GetOneByPk[T any](ctx context.Context, table string, pk int64) (res *T, err error) { r, err := g.DB().Model(table).Ctx(ctx). - Cache(gdb.CacheOption{Duration: CacheTTL(), Name: table + "_GetOneByPk_" + gconv.String(pk)}). + Cache(gdb.CacheOption{Duration: CacheTTL(), Name: CacheName(table, "GetOneByPk", pk)}). Where("id", pk).One() if err != nil { return nil, err @@ -46,10 +47,18 @@ func GetOneByPk[T any](ctx context.Context, table string, pk int64) (res *T, err func UpdateByPk(ctx context.Context, table string, pk int64, data any) error { _, err := g.DB().Model(table).Ctx(ctx).Data(data).Where("id", pk).Update() - return err + if err != nil { + return err + } + CacheClear(ctx, g.DB(), table) + return nil } func DeleteByPk(ctx context.Context, table string, pk int64) error { _, err := g.DB().Model(table).Ctx(ctx).Unscoped().Where("id", pk).Delete() - return err + if err != nil { + return err + } + CacheClear(ctx, g.DB(), table) + return nil } diff --git a/server/common/cache.go b/server/common/cache.go index 8251e9c..f761916 100644 --- a/server/common/cache.go +++ b/server/common/cache.go @@ -2,10 +2,13 @@ package common import ( "context" + "strings" "sync" "time" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/util/gconv" ) var ( @@ -20,3 +23,35 @@ func CacheTTL() time.Duration { }) return cacheTTL } + +// CacheName 生成 dao 查询缓存名:统一以 "table@" 开头, +// CacheClear 按表前缀精确清理的前提(gdb 缓存键 = "SelectCache:" + name) +func CacheName(table, op string, params ...any) string { + s := table + "@" + op + for _, p := range params { + s += "_" + gconv.String(p) + } + return s +} + +// CacheClear 清理某表全部查询缓存,dao 写操作成功后必须调用(否则"库里已改、查询还是旧值")。 +// gdb.DB 接口未暴露 Core.ClearCache,此处等价实现:遍历缓存键,删除 "SelectCache:@" 前缀条目。 +func CacheClear(ctx context.Context, db gdb.DB, table string) { + keys, err := db.GetCache().KeyStrings(ctx) + if err != nil { + g.Log().Warningf(ctx, "清理 %s 查询缓存失败(读取键): %v", table, err) + return + } + prefix := "SelectCache:" + table + "@" + var toRemove []any + for _, k := range keys { + if strings.HasPrefix(k, prefix) { + toRemove = append(toRemove, k) + } + } + if len(toRemove) > 0 { + if err := db.GetCache().Removes(ctx, toRemove); err != nil { + g.Log().Warningf(ctx, "清理 %s 查询缓存失败: %v", table, err) + } + } +} diff --git a/server/common/db.go b/server/common/db.go new file mode 100644 index 0000000..794c06c --- /dev/null +++ b/server/common/db.go @@ -0,0 +1,12 @@ +package common + +import ( + "github.com/gogf/gf/v2/database/gdb" + "github.com/gogf/gf/v2/frame/g" +) + +// 数据库组访问器:DAO 按业务域拆分到独立 SQLite 文件(config.yml database.*),经所属组访问。 +// 归属 common(非表基础设施),禁止在业务分层目录出现非表文件。 +func DbPlan() gdb.DB { return g.DB("plan") } +func DbPay() gdb.DB { return g.DB("pay") } +func DbCps() gdb.DB { return g.DB("cps") } diff --git a/server/common/pool.go b/server/common/pool.go new file mode 100644 index 0000000..ae18514 --- /dev/null +++ b/server/common/pool.go @@ -0,0 +1,34 @@ +package common + +import ( + "context" + "sync" + + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/grpool" +) + +// 协程池封装(grpool):异步任务一律经 Submit 提交,禁止裸 go 启动并行工作负载。 +// 并发度来源:config.yml pool.(缺失或非法回退调用方传入的业务默认值,定义在 styleagent/consts)。 +// 防死锁:等待链单向(主 → 池),池内任务不得再等待其他池。 + +type taskPool struct { + size int + once sync.Once + pool *grpool.Pool +} + +var pools sync.Map // name → *taskPool + +// Submit 提交任务到命名池。ctx 建议传 gctx.New()(请求结束后任务不中断)。 +func Submit(ctx context.Context, name string, defaultSize int, fn func(ctx context.Context)) error { + v, _ := pools.LoadOrStore(name, &taskPool{size: defaultSize}) + tp := v.(*taskPool) + tp.once.Do(func() { + if n := g.Cfg().MustGet(ctx, "pool."+name, defaultSize).Int(); n > 0 { + tp.size = n + } + tp.pool = grpool.New(tp.size) + }) + return tp.pool.Add(ctx, fn) +} diff --git a/server/common/util.go b/server/common/util.go index 0c659fb..ce415a9 100644 --- a/server/common/util.go +++ b/server/common/util.go @@ -1,13 +1,27 @@ package common import ( + "database/sql" "encoding/base64" "encoding/json" + "errors" "fmt" + "math" "os" "strings" ) +// RoundInt 浮点数量(克)× 单价(分)等金额计算的四舍五入到整数分 +func RoundInt(f float64) int64 { + return int64(math.Round(f)) +} + +// IsNotFound GoFrame Scan/One 无匹配行时返回 sql.ErrNoRows, +// dao 层统一归一为「无记录」(返回 nil 实体),不作为系统错误向上抛 +func IsNotFound(err error) bool { + return err != nil && errors.Is(err, sql.ErrNoRows) +} + // ImageFileToBase64 reads an image file and returns a data:image/...;base64 string. func ImageFileToBase64(path string) (string, error) { data, err := os.ReadFile(path) diff --git a/server/common/web.go b/server/common/web.go index dff9d3c..c7abd01 100644 --- a/server/common/web.go +++ b/server/common/web.go @@ -6,10 +6,10 @@ import ( ) // webStaticDirs 候选前端静态目录(相对 server 工作目录,按序取第一个存在者): -// 1. 本地开发:直接服务 Flutter 构建产物 app/build/web(scripts/build_web.sh 或 dev.sh 构建) +// 1. 本地开发:uni-app H5 构建产物 app-uni/dist/build/h5(npm run build:h5) // 2. Docker 镜像:Dockerfile web-builder 阶段产物(/app/web) var webStaticDirs = []string{ - "../app/build/web", + "../app-uni/dist/build/h5", "web", } diff --git a/server/common/with_lock.go b/server/common/with_lock.go new file mode 100644 index 0000000..d1224c3 --- /dev/null +++ b/server/common/with_lock.go @@ -0,0 +1,125 @@ +package common + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/gogf/gf/v2/database/gredis" + "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gcache" +) + +// ErrLockHeld 锁被他人持有(重试耗尽仍拿不到时返回) +var ErrLockHeld = errors.New("lock held") + +// WithLock 互斥临界区唯一入口(泛型):业务返回值经 T 原样透出。 +// 内部按 config.yml 自动选择锁实现:配置了 redis 节点 → redis 锁(跨实例互斥, +// SET NX EX + token 对比删除防误删他人锁);未配置 → gcache 内存锁(单实例互斥)。 +// 拿不到锁最多重试 retries 次、每次间隔 retryInterval(retries=0 立即失败; +// ctx 取消/超时同样终止);中间件故障不重试直接返回。defer 自动释放:无论 fn +// 成功、失败还是 panic。expire 必须 > 0(进程崩溃兜底不死锁),fn 耗时须在 expire 前完成, +// fn 内禁止长耗时 IO(LLM/DB 调用);锁粒度按业务唯一键尽量小。 +func WithLock[T any](ctx context.Context, key string, expire time.Duration, retries int, retryInterval time.Duration, fn func() (T, error)) (T, error) { + var zero T + if expire <= 0 { + return zero, errors.New("lock expire must be positive") + } + lock, err := newLock(ctx, key, expire) + if err != nil { + return zero, err + } + var ok bool + for attempt := 0; ; attempt++ { + ok, err = lock.TryAcquire(ctx) + if err != nil { + return zero, err + } + if ok { + break + } + if attempt >= retries { + return zero, ErrLockHeld + } + select { + case <-ctx.Done(): + return zero, ctx.Err() + case <-time.After(retryInterval): + } + } + defer lock.Release(ctx) + return fn() +} + +type lock interface { + TryAcquire(ctx context.Context) (bool, error) + Release(ctx context.Context) +} + +func newLock(ctx context.Context, key string, expire time.Duration) (lock, error) { + token := fmt.Sprintf("%d-%d", time.Now().UnixNano(), time.Now().UnixMilli()%1e9) + if g.Cfg().MustGet(ctx, "redis.default.address", "").String() != "" { + return &redisLock{key: "lock:" + key, token: token, expire: expire, client: g.Redis()}, nil + } + return &memoryLock{key: "lock:" + key, token: token, expire: expire}, nil +} + +type redisLock struct { + key string + token string + expire time.Duration + client *gredis.Redis +} + +func (l *redisLock) TryAcquire(ctx context.Context) (bool, error) { + // SetNX 无 TTL 参数,SET NX 与 TTL 分两步;当前项目未配置 redis 节点此路径不可达, + // 若崩溃于两步之间仅残留无 TTL 锁(token 归属明确,可手工清除),可接受 + ok, err := l.client.SetNX(ctx, l.key, l.token) + if err != nil || !ok { + return ok, err + } + if _, err := l.client.PExpire(ctx, l.key, l.expire.Milliseconds()); err != nil { + if _, derr := l.client.Del(ctx, l.key); derr != nil { + g.Log().Warningf(ctx, "清理未设 TTL 的锁失败: %v", derr) + } + return false, err + } + return true, nil +} + +func (l *redisLock) Release(ctx context.Context) { + v, err := l.client.Get(ctx, l.key) + if err != nil { + g.Log().Warningf(ctx, "释放锁失败(读取): %v", err) + return + } + if v.String() == l.token { + if _, err := l.client.Del(ctx, l.key); err != nil { + g.Log().Warningf(ctx, "释放锁失败(删除): %v", err) + } + } +} + +type memoryLock struct { + key string + token string + expire time.Duration +} + +func (l *memoryLock) TryAcquire(ctx context.Context) (bool, error) { + return gcache.SetIfNotExist(ctx, l.key, l.token, l.expire) +} + +func (l *memoryLock) Release(ctx context.Context) { + v, err := gcache.Get(ctx, l.key) + if err != nil { + g.Log().Warningf(ctx, "释放锁失败(读取): %v", err) + return + } + if v.String() == l.token { + if _, err := gcache.Remove(ctx, l.key); err != nil { + g.Log().Warningf(ctx, "释放锁失败(删除): %v", err) + } + } +} diff --git a/server/config.yml b/server/config.yml index 31bfe8d..09e0fd1 100644 --- a/server/config.yml +++ b/server/config.yml @@ -20,11 +20,15 @@ database: cache: ttl: 60 server: - address: :3007 + address: :8080 name: slogan - workerId: 1 clientMaxBodySize: 209715200 - requestTimeout: 3000 + +# 异步任务协程池并发度(缺失或非法回退 styleagent/consts 业务默认值) +pool: + generate: 8 + effect: 4 + avatar: 4 chat: timeout: 300 max_retries: 3 @@ -42,7 +46,7 @@ geo: # 图像生成供应商配置(真实调用,不支持 mock) imagegen: supplier: "wanx" # wanx - wanx_api_key: "sk-ws-H.RPMDIPI.Ba0s.MEUCIDmDkIKUzhk_TCC6hckvIZCP6LNVCnE-VDCdYH5yDTUjAiEAqfG-wUWGcVJyNuyxWsUFdKD_oHkPn3TxdxDcYcmlU24" + wanx_api_key: "sk-ws-H.RPMDIPI.Ba0s.MEUCIDmDkIKUzhk_TCC6hckvIZCP6LNVCnE-VDCdYH5yDTUjAiEAqfG-wUWGcVJyNuyxWsUFdKD_oHkPn3TxdxDcYcmlU24" # 真实 Key,提交前勿带入新仓库 wanx_model: "wan2.7-image-pro" wanx_base: "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation" wanx_task_base: "https://dashscope.aliyuncs.com/api/v1/tasks" @@ -50,7 +54,7 @@ imagegen: # 大模型配置(OpenAI 兼容,如通义/DeepSeek/Kimi) llm: base_url: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" - api_key: "sk-ws-H.RPMDIPI.Ba0s.MEUCIDmDkIKUzhk_TCC6hckvIZCP6LNVCnE-VDCdYH5yDTUjAiEAqfG-wUWGcVJyNuyxWsUFdKD_oHkPn3TxdxDcYcmlU24" + api_key: "sk-ws-H.RPMDIPI.Ba0s.MEUCIDmDkIKUzhk_TCC6hckvIZCP6LNVCnE-VDCdYH5yDTUjAiEAqfG-wUWGcVJyNuyxWsUFdKD_oHkPn3TxdxDcYcmlU24" # 真实 Key,提交前勿带入新仓库 model_name: "qwen3.7-plus" max_tokens: 4096 temperature: 0.8 @@ -59,7 +63,7 @@ llm: payment: xunhu_appid: "" xunhu_appsecret: "" - notify_url: "http://localhost:3007/member/order/notify" # 生产需公网可达 + notify_url: "http://localhost:8080/member/order/notify" # 生产需公网可达 channel: "alipay,wechat" api_base: "https://api.xunhupay.com" @@ -75,12 +79,6 @@ avatar: tripo_model_version: "v2.5-20250123" poll_interval: 5 # 秒 poll_timeout: 900 # 秒(15 分钟上限) - render_frames: true # 是否用 Tripo GLB 本地渲染旋转帧预览(frames_url) - -# 3D 化身帧序列预渲染(Node + headless-gl,node_bin 需指向 gl 有预编译二进制的 Node 版本) -render: - enabled: true - node_bin: "/Users/zhangbin/.nvm/versions/node/v18.20.4/bin/node" # CPS 联盟(key 全空则联盟入口优雅降级隐藏) cps: diff --git a/server/data/slogan.db b/server/data/slogan.db deleted file mode 100644 index ff9687d..0000000 Binary files a/server/data/slogan.db and /dev/null differ diff --git a/server/data/slogan_cps.db b/server/data/slogan_cps.db deleted file mode 100644 index e00beec..0000000 Binary files a/server/data/slogan_cps.db and /dev/null differ diff --git a/server/data/slogan_pay.db b/server/data/slogan_pay.db deleted file mode 100644 index 6148cac..0000000 Binary files a/server/data/slogan_pay.db and /dev/null differ diff --git a/server/data/slogan_plan.db b/server/data/slogan_plan.db deleted file mode 100644 index 92895bb..0000000 Binary files a/server/data/slogan_plan.db and /dev/null differ diff --git a/server/docker-compose.yml b/server/docker-compose.yml index f7d37b4..e4af6c6 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -1,18 +1,17 @@ services: slogan-agent: - # 统一镜像包含 Flutter web 前端(Dockerfile web-builder stage 构建) + # 统一镜像包含 uni-app H5 前端(Dockerfile web-builder stage 构建) build: context: .. dockerfile: server/Dockerfile container_name: slogan-agent restart: unless-stopped ports: - - "3007:3007" + - "8080:8080" volumes: # SQLite 数据库(config.yml 已指向 data/ 子目录,容器内 /app/data 与宿主机 ./data 互通) - ./data:/app/data # 生成的图片 / GLB 等运行时产物 - ./workspace:/app/workspace # 容器内运行前需在 config.yml 调整: - # render.node_bin → "/usr/bin/node"(镜像内置 alpine node,非 macOS nvm 路径) # payment.notify_url → 公网可达地址(支付回调容器内 localhost 不可达) diff --git a/server/docs/api.json b/server/docs/api.json deleted file mode 100644 index 9b9787d..0000000 --- a/server/docs/api.json +++ /dev/null @@ -1 +0,0 @@ -{"openapi":"3.0.0","components":{"schemas":{"slogan-agent.styleagent.model.dto.AvatarBuildReq":{"properties":{},"type":"object"},"slogan-agent.styleagent.model.dto.AvatarBuildRes":{"properties":{"avatar_id":{"format":"int64","type":"integer"},"status":{"format":"string","type":"string"}},"type":"object"},"struct":{"properties":{},"type":"object"},"slogan-agent.styleagent.model.dto.AvatarGetRes":{"properties":{"face_template_id":{"format":"int","type":"integer"},"body_template_id":{"format":"int","type":"integer"},"skin_tone_index":{"format":"int","type":"integer"},"glb_url":{"format":"string","type":"string"},"build_status":{"format":"string","type":"string"},"error":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.BodyMeasurementGetRes":{"properties":{"height":{"format":"int","type":"integer"},"weight":{"format":"int","type":"integer"},"skin_tone":{"format":"int","type":"integer"},"fit_params":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.BodyMeasurementSaveReq":{"properties":{"height":{"format":"int","type":"integer"},"weight":{"format":"int","type":"integer"},"skin_tone":{"enum":[1,2,3,4,5],"format":"int","type":"integer"},"fit_params":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.HairstyleListRes":{"properties":{"list":{"format":"[]*entity.HairstyleAsset","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.HairstyleAsset","description":""},"type":"array"}},"type":"object"},"slogan-agent.styleagent.model.entity.HairstyleAsset":{"properties":{"id":{"format":"int64","type":"integer"},"name":{"format":"string","type":"string"},"style_tag":{"format":"string","type":"string"},"glb_url":{"format":"string","type":"string"},"thumb_url":{"format":"string","type":"string"},"applicable_face":{"format":"string","type":"string"},"sort":{"format":"int","type":"integer"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.OutfitGenerateReq":{"properties":{"start_date":{"format":"string","type":"string"},"end_date":{"format":"string","type":"string"},"location":{"format":"string","type":"string"}},"required":["start_date","end_date","location"],"type":"object"},"slogan-agent.styleagent.model.dto.OutfitGenerateRes":{"properties":{"task_id":{"format":"int64","type":"integer"}},"type":"object"},"slogan-agent.styleagent.model.dto.OutfitPlanDetailReq":{"properties":{"plan_id":{"format":"int64","type":"integer"}},"required":["plan_id"],"type":"object"},"slogan-agent.styleagent.model.dto.OutfitPlanDetailRes":{"properties":{"plan":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.OutfitPlan","description":""},"items":{"format":"[]*entity.PlanOutfitItem","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.PlanOutfitItem","description":""},"type":"array"},"images":{"format":"[]*entity.PlanEffectImage","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.PlanEffectImage","description":""},"type":"array"},"hairstyle":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.HairstyleAsset","description":""}},"type":"object"},"slogan-agent.styleagent.model.entity.OutfitPlan":{"properties":{"id":{"format":"int64","type":"integer"},"task_id":{"format":"int64","type":"integer"},"user_id":{"format":"int64","type":"integer"},"date_range":{"format":"string","type":"string"},"location":{"format":"string","type":"string"},"title":{"format":"string","type":"string"},"source":{"format":"string","type":"string"},"score":{"format":"int","type":"integer"},"main_flag":{"format":"int","type":"integer"},"hairstyle_id":{"format":"int64","type":"integer"},"hair_color":{"format":"string","type":"string"},"weather_ref":{"format":"string","type":"string"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.entity.PlanOutfitItem":{"properties":{"id":{"format":"int64","type":"integer"},"plan_id":{"format":"int64","type":"integer"},"slot":{"format":"string","type":"string"},"source":{"format":"string","type":"string"},"wardrobe_item_id":{"format":"int64","type":"integer"},"product_name":{"format":"string","type":"string"},"name":{"format":"string","type":"string"},"desc":{"format":"string","type":"string"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.entity.PlanEffectImage":{"properties":{"id":{"format":"int64","type":"integer"},"plan_id":{"format":"int64","type":"integer"},"angle":{"format":"string","type":"string"},"url":{"format":"string","type":"string"},"status":{"format":"string","type":"string"},"prompt_snapshot":{"format":"string","type":"string"},"created_at":{"format":"*gtime.Time","type":"string"},"updated_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.OutfitPlanListReq":{"properties":{},"type":"object"},"slogan-agent.styleagent.model.dto.OutfitPlanListRes":{"properties":{"list":{"format":"[]*entity.OutfitPlan","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.OutfitPlan","description":""},"type":"array"}},"type":"object"},"slogan-agent.styleagent.model.dto.OutfitReviewReq":{"properties":{"plan_id":{"format":"int64","type":"integer"},"action":{"enum":["fav","unfav"],"format":"string","type":"string"},"note":{"format":"string","type":"string"}},"required":["plan_id","action"],"type":"object"},"slogan-agent.styleagent.model.dto.OutfitSelectMainReq":{"properties":{"plan_id":{"format":"int64","type":"integer"}},"required":["plan_id"],"type":"object"},"slogan-agent.styleagent.model.dto.OutfitTaskStatusReq":{"properties":{"task_id":{"format":"int64","type":"integer"}},"required":["task_id"],"type":"object"},"slogan-agent.styleagent.model.dto.OutfitTaskStatusRes":{"properties":{"status":{"format":"string","type":"string"},"error":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.StoreListReq":{"properties":{"type":{"format":"int","type":"integer"}},"type":"object"},"slogan-agent.styleagent.model.dto.StoreListRes":{"properties":{"list":{"format":"[]*entity.PartnerStore","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.PartnerStore","description":""},"type":"array"}},"type":"object"},"slogan-agent.styleagent.model.entity.PartnerStore":{"properties":{"id":{"format":"int64","type":"integer"},"name":{"format":"string","type":"string"},"type":{"format":"int","type":"integer"},"lat":{"format":"float64","type":"number"},"lng":{"format":"float64","type":"number"},"address":{"format":"string","type":"string"},"commission_policy":{"format":"string","type":"string"},"status":{"format":"int","type":"integer"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.UserPhotoDeleteReq":{"properties":{"id":{"format":"int64","type":"integer"}},"required":["id"],"type":"object"},"slogan-agent.styleagent.model.dto.UserPhotoListReq":{"properties":{"type":{"format":"int","type":"integer"}},"type":"object"},"slogan-agent.styleagent.model.dto.UserPhotoListRes":{"properties":{"list":{"format":"[]*entity.UserPhoto","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.UserPhoto","description":""},"type":"array"}},"type":"object"},"slogan-agent.styleagent.model.entity.UserPhoto":{"properties":{"id":{"format":"int64","type":"integer"},"user_id":{"format":"int64","type":"integer"},"type":{"format":"int","type":"integer"},"url":{"format":"string","type":"string"},"status":{"format":"int","type":"integer"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.UserPhotoUploadReq":{"properties":{"type":{"enum":[1,2,3,4],"format":"int","type":"integer"}},"required":["type"],"type":"object"},"slogan-agent.styleagent.model.dto.UserPhotoUploadRes":{"properties":{"id":{"format":"int64","type":"integer"}},"type":"object"},"slogan-agent.styleagent.model.dto.ChangePasswordReq":{"properties":{"old_password":{"format":"string","type":"string"},"new_password":{"format":"string","minLength":6,"type":"string"}},"required":["old_password","new_password"],"type":"object"},"slogan-agent.styleagent.model.dto.LoginReq":{"properties":{"account":{"format":"string","type":"string"},"password":{"format":"string","type":"string"}},"required":["account","password"],"type":"object"},"slogan-agent.styleagent.model.dto.LoginRes":{"properties":{"token":{"format":"string","type":"string"},"user":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.LoginUser","description":""}},"type":"object"},"slogan-agent.styleagent.model.dto.LoginUser":{"properties":{"id":{"format":"int64","type":"integer"},"role":{"format":"string","type":"string"},"name":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.ProfileRes":{"properties":{"id":{"format":"int64","type":"integer"},"role":{"format":"string","type":"string"},"name":{"format":"string","type":"string"},"username":{"format":"string","type":"string"},"phone":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.RegisterReq":{"properties":{"account":{"format":"string","type":"string"},"password":{"format":"string","minLength":6,"type":"string"},"name":{"format":"string","type":"string"}},"required":["account","password"],"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeDeleteReq":{"properties":{"id":{"format":"int64","type":"integer"}},"required":["id"],"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeListReq":{"properties":{"category":{"format":"string","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeListRes":{"properties":{"list":{"format":"[]*entity.WardrobeItem","items":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.entity.WardrobeItem","description":""},"type":"array"}},"type":"object"},"slogan-agent.styleagent.model.entity.WardrobeItem":{"properties":{"id":{"format":"int64","type":"integer"},"user_id":{"format":"int64","type":"integer"},"photo_url":{"format":"string","type":"string"},"category":{"format":"string","type":"string"},"season":{"format":"string","type":"string"},"style_tags":{"format":"string","type":"string"},"color_info":{"format":"string","type":"string"},"status":{"format":"int","type":"integer"},"created_at":{"format":"*gtime.Time","type":"string"}},"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeUpdateReq":{"properties":{"id":{"format":"int64","type":"integer"},"category":{"format":"string","type":"string"},"season":{"format":"string","type":"string"},"style_tags":{"format":"string","type":"string"}},"required":["id"],"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeUploadReq":{"properties":{"category":{"enum":["上衣","下装","鞋","配饰"],"format":"string","type":"string"},"season":{"format":"string","type":"string"},"style_tags":{"format":"string","type":"string"},"color_info":{"format":"string","type":"string"}},"required":["category"],"type":"object"},"slogan-agent.styleagent.model.dto.WardrobeUploadRes":{"properties":{"id":{"format":"int64","type":"integer"}},"type":"object"}}},"info":{"title":"","version":""},"paths":{"/avatar/build":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarBuildReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarBuildRes","description":""}}},"description":""}},"summary":"构建化身","tags":["化身"]}},"/avatar/get":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"head":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"options":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"patch":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"post":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"put":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}},"trace":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.AvatarGetRes","description":""}}},"description":""}}}},"/body-measurement/get":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"head":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"options":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"patch":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"post":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"put":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}},"trace":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementGetRes","description":""}}},"description":""}}}},"/body-measurement/save":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.BodyMeasurementSaveReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"保存身形参数","tags":["身形"]}},"/hairstyle/list":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"head":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"options":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"patch":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"post":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"put":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}},"trace":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.HairstyleListRes","description":""}}},"description":""}}}},"/outfit/generate":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitGenerateReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitGenerateRes","description":""}}},"description":""}},"summary":"生成穿搭方案","tags":["穿搭"]}},"/outfit/plan/detail":{"get":{"parameters":[{"in":"query","name":"plan_id","required":true,"schema":{"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitPlanDetailRes","description":""}}},"description":""}},"summary":"方案详情","tags":["穿搭"]}},"/outfit/plan/list":{"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitPlanListRes","description":""}}},"description":""}},"summary":"方案列表","tags":["穿搭"]}},"/outfit/plan/review":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitReviewReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"方案反馈","tags":["穿搭"]}},"/outfit/plan/select-main":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitSelectMainReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"选定主方案","tags":["穿搭"]}},"/outfit/task/status":{"get":{"parameters":[{"in":"query","name":"task_id","required":true,"schema":{"format":"int64","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.OutfitTaskStatusRes","description":""}}},"description":""}},"summary":"任务状态","tags":["穿搭"]}},"/partner-store/list":{"get":{"parameters":[{"in":"query","name":"type","schema":{"format":"int","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.StoreListRes","description":""}}},"description":""}},"summary":"合作门店列表","tags":["门店"]}},"/user-photo/delete":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.UserPhotoDeleteReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"删除照片","tags":["照片"]}},"/user-photo/list":{"get":{"parameters":[{"in":"query","name":"type","schema":{"format":"int","type":"integer"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.UserPhotoListRes","description":""}}},"description":""}},"summary":"照片列表","tags":["照片"]}},"/user-photo/upload":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.UserPhotoUploadReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.UserPhotoUploadRes","description":""}}},"description":""}},"summary":"上传照片","tags":["照片"]}},"/user/change-password":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ChangePasswordReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"修改密码","tags":["用户"]}},"/user/login":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.LoginReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.LoginRes","description":""}}},"description":""}},"summary":"登录","tags":["用户"]}},"/user/profile":{"delete":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"get":{"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"head":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"options":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"patch":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"post":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"put":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}},"trace":{"requestBody":{"content":{"application/json":{}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.ProfileRes","description":""}}},"description":""}}}},"/user/register":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.RegisterReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"注册","tags":["用户"]}},"/wardrobe/delete":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.WardrobeDeleteReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"删除服装","tags":["衣橱"]}},"/wardrobe/list":{"get":{"parameters":[{"in":"query","name":"category","schema":{"format":"string","type":"string"}}],"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.WardrobeListRes","description":""}}},"description":""}},"summary":"衣橱列表","tags":["衣橱"]}},"/wardrobe/update":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.WardrobeUpdateReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/struct","description":""}}},"description":""}},"summary":"更新服装","tags":["衣橱"]}},"/wardrobe/upload":{"post":{"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.WardrobeUploadReq","description":""}}}},"responses":{"200":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/slogan-agent.styleagent.model.dto.WardrobeUploadRes","description":""}}},"description":""}},"summary":"上传服装","tags":["衣橱"]}}}} \ No newline at end of file diff --git a/server/docs/superpowers/plans/2026-07-31-commerce-p0-backend.md b/server/docs/superpowers/plans/2026-07-31-commerce-p0-backend.md deleted file mode 100644 index 9821f7c..0000000 --- a/server/docs/superpowers/plans/2026-07-31-commerce-p0-backend.md +++ /dev/null @@ -1,1485 +0,0 @@ -# 商业化 P0 实现计划(后端)· slogan-agent - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 落地商业化四支柱中的「VIP 会员充值(虎皮椒聚合支付)」与「广告激励(效果图加次 / 体验会员)」,key 未配置时优雅降级。 - -**Architecture:** 沿用 Controller→Service→DAO 三层 + 包级单例。新增 `payment/` 适配器包隔离虎皮椒签名/HTTP;`dao/` 五张新表(slogan_ 前缀沿用仓库规范);效果图限额改造为「基础额度 3 + 广告额外次数」,VIP 用户不限。回调 `/member/order/notify` 因需返回裸文本 `"success"`,用标准 handler 手动绑定并加入 publicPaths。 - -**Tech Stack:** Go 1.22 / GoFrame v2 / SQLite / crypto/md5 / GoFrame http client - -**关联 spec:** `docs/superpowers/specs/2026-07-31-commerce-monetization-design.md` 支柱 A/B + 第 6 节配置。 - -**测试方式(沿用仓库惯例):** 纯逻辑用 `go test`(签名算法、续期计算、限频数学);DAO/接口用启动服务 + sqlite3 + curl 冒烟;支付全链路用本地 mock 虎皮椒服务(`payment.api_base` 可覆盖)。 - ---- - -## 任务总览与文件映射 - -| 任务 | 文件 | -|---|---| -| T1 | `consts/table_name.go`、`consts/status.go`、5 个 entity | -| T2 | `dao/member_plan_dao.go`(含 seed) | -| T3 | `dao/payment_order_dao.go`、`dao/user_member_dao.go`、`dao/pay_notify_log_dao.go` | -| T4 | `payment/gateway.go` + `payment/gateway_test.go` | -| T5 | `service/member_service.go` + `service/member_service_test.go` | -| T6 | `model/dto/dto.go` 追加、`controller/member_controller.go`、`common/auth_middleware.go`、`main.go`、`config.yml` | -| T7 | `dao/ad_reward_log_dao.go`、`service/ad_service.go` + 测试、`controller/ad_controller.go` | -| T8 | `service/effect_image_service.go` 限额改造 | -| T9 | 构建 + 全链路冒烟 | - ---- - -### Task 1: 表名常量、状态常量、5 个 Entity - -**Files:** -- Modify: `styleagent/consts/table_name.go` -- Modify: `styleagent/consts/status.go` -- Create: `styleagent/model/entity/member_plan.go` -- Create: `styleagent/model/entity/payment_order.go` -- Create: `styleagent/model/entity/user_member.go` -- Create: `styleagent/model/entity/pay_notify_log.go` -- Create: `styleagent/model/entity/ad_reward_log.go` - -- [ ] **Step 1: 追加表名常量**(`table_name.go` 末尾) - -```go - TableNameMemberPlan = "slogan_member_plan" - TableNamePaymentOrder = "slogan_payment_order" - TableNameUserMember = "slogan_user_member" - TableNamePayNotifyLog = "slogan_pay_notify_log" - TableNameAdRewardLog = "slogan_ad_reward_log" -``` - -- [ ] **Step 2: 追加状态/类型常量**(`status.go` 末尾) - -```go -// 支付订单状态 -const ( - PayStatusPending = "pending" - PayStatusPaid = "paid" - PayStatusClosed = "closed" -) - -// 广告激励类型 -const ( - AdTypeEffectExtra = "effect_extra" - AdTypeVipTrial = "vip_trial" -) - -// 会员开通来源 -const ( - MemberSourceVipPay = "vip_pay" - MemberSourceAdTrial = "ad_trial" -) -``` - -- [ ] **Step 3: 创建 5 个 Entity**(或m/json 标签,gtime 时间字段,与 spec SQL 列一一对应) - -`model/entity/member_plan.go`: -```go -package entity - -import "github.com/gogf/gf/v2/os/gtime" - -type MemberPlan struct { - Id int64 `orm:"id" json:"id"` - Name string `orm:"name" json:"name"` - PriceFen int `orm:"price_fen" json:"price_fen"` - DurationDays int `orm:"duration_days" json:"duration_days"` - Features string `orm:"features" json:"features"` // 权益 JSON 数组字符串 - Sort int `orm:"sort" json:"sort"` - Status int `orm:"status" json:"status"` - CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` -} -``` - -`model/entity/payment_order.go`: -```go -package entity - -import "github.com/gogf/gf/v2/os/gtime" - -type PaymentOrder struct { - Id int64 `orm:"id" json:"id"` - OrderNo string `orm:"order_no" json:"order_no"` - UserId int64 `orm:"user_id" json:"user_id"` - PlanId int64 `orm:"plan_id" json:"plan_id"` - AmountFen int `orm:"amount_fen" json:"amount_fen"` - Channel string `orm:"channel" json:"channel"` - Status string `orm:"status" json:"status"` - TradeNo string `orm:"trade_no" json:"trade_no"` - NotifyRaw string `orm:"notify_raw" json:"-"` - PaidAt *gtime.Time `orm:"paid_at" json:"paid_at"` - CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` -} -``` - -`model/entity/user_member.go`: -```go -package entity - -import "github.com/gogf/gf/v2/os/gtime" - -type UserMember struct { - Id int64 `orm:"id" json:"id"` - UserId int64 `orm:"user_id" json:"user_id"` - PlanId int64 `orm:"plan_id" json:"plan_id"` - ExpireAt *gtime.Time `orm:"expire_at" json:"expire_at"` - Source string `orm:"source" json:"source"` - CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` - UpdatedAt *gtime.Time `orm:"updated_at" json:"updated_at"` -} -``` - -`model/entity/pay_notify_log.go`: -```go -package entity - -import "github.com/gogf/gf/v2/os/gtime" - -type PayNotifyLog struct { - Id int64 `orm:"id" json:"id"` - OrderNo string `orm:"order_no" json:"order_no"` - Body string `orm:"body" json:"body"` - Sign string `orm:"sign" json:"sign"` - RemoteIp string `orm:"remote_ip" json:"remote_ip"` - Status string `orm:"status" json:"status"` - CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` -} -``` - -`model/entity/ad_reward_log.go`: -```go -package entity - -import "github.com/gogf/gf/v2/os/gtime" - -type AdRewardLog struct { - Id int64 `orm:"id" json:"id"` - UserId int64 `orm:"user_id" json:"user_id"` - AdType string `orm:"ad_type" json:"ad_type"` - RewardKey string `orm:"reward_key" json:"reward_key"` - Status string `orm:"status" json:"status"` - CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` -} -``` - -- [ ] **Step 4: 编译验证** - -Run: `go build ./...` Expected: 无输出(成功) - -- [ ] **Step 5: 提交** - -```bash -git add styleagent/consts styleagent/model/entity -git commit -m "feat: 商业化 P0 表名/状态常量与实体定义" -``` - ---- - -### Task 2: member_plan DAO(建表 + seed 套餐) - -**Files:** -- Create: `styleagent/dao/member_plan_dao.go` - -- [ ] **Step 1: 创建 DAO**(沿用 partner_store_dao 模式:init 建表 + seed,包级单例) - -```go -package dao - -import ( - "context" - - "slogan-agent/styleagent/consts" - "slogan-agent/styleagent/model/entity" - - "github.com/gogf/gf/v2/frame/g" -) - -var MemberPlan = &memberPlanDao{} - -type memberPlanDao struct{} - -func init() { - ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameMemberPlan+` ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL DEFAULT '', - price_fen INTEGER NOT NULL DEFAULT 0, - duration_days INTEGER NOT NULL DEFAULT 30, - features TEXT NOT NULL DEFAULT '[]', - sort INTEGER NOT NULL DEFAULT 0, - status INTEGER NOT NULL DEFAULT 1, - created_at DATETIME DEFAULT (datetime('now','localtime')) - )`) - if err != nil { - g.Log().Warningf(ctx, "create member_plan table failed: %v", err) - } - seedMemberPlans(ctx) -} - -func seedMemberPlans(ctx context.Context) { - r, err := g.DB().Model(consts.TableNameMemberPlan).Ctx(ctx).Count() - if err != nil || r > 0 { - return - } - plans := []struct { - name string - price int - days int - features string - sort int - }{ - {"月卡 ¥29.9", 2990, 30, `["effect_unlimited","cps_commission_x15"]`, 1}, - {"年卡 ¥199", 19900, 365, `["effect_unlimited","ai_priority","cps_commission_x15","store_discount"]`, 2}, - } - for _, p := range plans { - _, _ = g.DB().Exec(ctx, - "INSERT INTO "+consts.TableNameMemberPlan+" (name, price_fen, duration_days, features, sort, status, created_at) VALUES (?, ?, ?, ?, ?, 1, datetime('now','localtime'))", - p.name, p.price, p.days, p.features, p.sort) - } -} - -func (d *memberPlanDao) ListEnabled(ctx context.Context) ([]*entity.MemberPlan, error) { - var list []*entity.MemberPlan - err := g.DB().Model(consts.TableNameMemberPlan).Ctx(ctx). - Where("status", 1).OrderAsc("sort").OrderAsc("id").Scan(&list) - return list, err -} - -func (d *memberPlanDao) GetOne(ctx context.Context, id int64) (*entity.MemberPlan, error) { - var p *entity.MemberPlan - err := g.DB().Model(consts.TableNameMemberPlan).Ctx(ctx). - Where("id", id).Where("status", 1).Scan(&p) - return p, err -} -``` - -- [ ] **Step 2: 编译** - -Run: `go build ./...` Expected: 成功 - -- [ ] **Step 3: 提交** - -```bash -git add styleagent/dao/member_plan_dao.go -git commit -m "feat: 会员套餐 DAO(建表 + 月卡/年卡 seed)" -``` - ---- - -### Task 3: payment_order / user_member / pay_notify_log 三个 DAO - -**Files:** -- Create: `styleagent/dao/payment_order_dao.go` -- Create: `styleagent/dao/user_member_dao.go` -- Create: `styleagent/dao/pay_notify_log_dao.go` - -- [ ] **Step 1: payment_order_dao** - -```go -package dao - -import ( - "context" - - "slogan-agent/styleagent/consts" - "slogan-agent/styleagent/model/entity" - - "github.com/gogf/gf/v2/frame/g" -) - -var PaymentOrder = &paymentOrderDao{} - -type paymentOrderDao struct{} - -func init() { - ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePaymentOrder+` ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_no TEXT NOT NULL UNIQUE, - user_id INTEGER NOT NULL DEFAULT 0, - plan_id INTEGER NOT NULL DEFAULT 0, - amount_fen INTEGER NOT NULL DEFAULT 0, - channel TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'pending', - trade_no TEXT NOT NULL DEFAULT '', - notify_raw TEXT NOT NULL DEFAULT '', - paid_at DATETIME, - created_at DATETIME DEFAULT (datetime('now','localtime')) - )`) - if err != nil { - g.Log().Warningf(ctx, "create payment_order table failed: %v", err) - } - _, _ = g.DB().Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_payment_order_user ON `+consts.TableNamePaymentOrder+`(user_id, created_at)`) -} - -func (d *paymentOrderDao) Insert(ctx context.Context, order *entity.PaymentOrder) (int64, error) { - r, err := g.DB().Model(consts.TableNamePaymentOrder).Ctx(ctx).Data(g.Map{ - "order_no": order.OrderNo, "user_id": order.UserId, "plan_id": order.PlanId, - "amount_fen": order.AmountFen, "channel": order.Channel, "status": order.Status, - }).Insert() - if err != nil { - return 0, err - } - return r.LastInsertId() -} - -func (d *paymentOrderDao) GetByOrderNo(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) { - var o *entity.PaymentOrder - err := g.DB().Model(consts.TableNamePaymentOrder).Ctx(ctx). - Where("order_no", orderNo).Scan(&o) - return o, err -} - -// MarkPaid 状态机 pending→paid(只更新 pending 行,返回是否成功,回调并发安全) -func (d *paymentOrderDao) MarkPaid(ctx context.Context, orderNo, tradeNo, notifyRaw string) (bool, error) { - r, err := g.DB().Exec(ctx, - "UPDATE "+consts.TableNamePaymentOrder+" SET status=?, trade_no=?, notify_raw=?, paid_at=datetime('now','localtime') WHERE order_no=? AND status=?", - consts.PayStatusPaid, tradeNo, notifyRaw, orderNo, consts.PayStatusPending) - if err != nil { - return false, err - } - n, _ := r.RowsAffected() - return n > 0, nil -} - -func (d *paymentOrderDao) GetByUser(ctx context.Context, userId int64) ([]*entity.PaymentOrder, error) { - var list []*entity.PaymentOrder - err := g.DB().Model(consts.TableNamePaymentOrder).Ctx(ctx). - Where("user_id", userId).OrderDesc("id").Limit(20).Scan(&list) - return list, err -} -``` - -- [ ] **Step 2: user_member_dao**(续期计算放 Service,DAO 只做读写;upsert 用 ON CONFLICT) - -```go -package dao - -import ( - "context" - - "slogan-agent/styleagent/consts" - "slogan-agent/styleagent/model/entity" - - "github.com/gogf/gf/v2/frame/g" -) - -var UserMember = &userMemberDao{} - -type userMemberDao struct{} - -func init() { - ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUserMember+` ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL UNIQUE, - plan_id INTEGER NOT NULL DEFAULT 0, - expire_at DATETIME, - source TEXT NOT NULL DEFAULT 'vip_pay', - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME - )`) - if err != nil { - g.Log().Warningf(ctx, "create user_member table failed: %v", err) - } -} - -func (d *userMemberDao) GetByUser(ctx context.Context, userId int64) (*entity.UserMember, error) { - var m *entity.UserMember - err := g.DB().Model(consts.TableNameUserMember).Ctx(ctx). - Where("user_id", userId).Scan(&m) - return m, err -} - -// Upsert 无则插入有则整体覆盖(expire_at 由 Service 算好传入) -func (d *userMemberDao) Upsert(ctx context.Context, userId, planId int64, expireAt, source string) error { - _, err := g.DB().Exec(ctx, - "INSERT INTO "+consts.TableNameUserMember+" (user_id, plan_id, expire_at, source, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime')) "+ - "ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id, expire_at=excluded.expire_at, source=excluded.source, updated_at=datetime('now','localtime')", - userId, planId, expireAt, source) - return err -} - -// IsVip 当前是否会员(未过期) -func (d *userMemberDao) IsVip(ctx context.Context, userId int64) bool { - n, err := g.DB().Model(consts.TableNameUserMember).Ctx(ctx). - Where("user_id", userId).Where("expire_at > datetime('now','localtime')").Count() - return err == nil && n > 0 -} -``` - -- [ ] **Step 3: pay_notify_log_dao** - -```go -package dao - -import ( - "context" - - "slogan-agent/styleagent/consts" - "slogan-agent/styleagent/model/entity" - - "github.com/gogf/gf/v2/frame/g" -) - -var PayNotifyLog = &payNotifyLogDao{} - -type payNotifyLogDao struct{} - -func init() { - ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePayNotifyLog+` ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_no TEXT NOT NULL DEFAULT '', - body TEXT NOT NULL DEFAULT '', - sign TEXT NOT NULL DEFAULT '', - remote_ip TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'ok', - created_at DATETIME DEFAULT (datetime('now','localtime')) - )`) - if err != nil { - g.Log().Warningf(ctx, "create pay_notify_log table failed: %v", err) - } -} - -func (d *payNotifyLogDao) Insert(ctx context.Context, log *entity.PayNotifyLog) error { - _, err := g.DB().Model(consts.TableNamePayNotifyLog).Ctx(ctx).Data(g.Map{ - "order_no": log.OrderNo, "body": log.Body, "sign": log.Sign, - "remote_ip": log.RemoteIp, "status": log.Status, - }).Insert() - return err -} -``` - -- [ ] **Step 4: 编译 + 建表冒烟** - -Run: `go build ./... && go run main.go &` → 等服务启动后: -Run: `sqlite3 slogan.db ".tables" | tr ' ' '\n' | grep -E "member|payment|notify"` -Expected: 输出 `slogan_member_plan` `slogan_payment_order` `slogan_user_member` `slogan_pay_notify_log` `slogan_ad_reward_log`(ad_reward 表 T7 才建,此处 4 张)+ 杀掉后台进程 - -- [ ] **Step 5: 提交** - -```bash -git add styleagent/dao -git commit -m "feat: 支付订单/会员/回调日志 DAO" -``` - ---- - -### Task 4: payment 适配器(虎皮棋签名算法,TDD) - -**Files:** -- Create: `styleagent/payment/gateway.go` -- Test: `styleagent/payment/gateway_test.go` - -> 签名算法(参数名升序拼接 `key=value&...` + secret → md5)为虎皮棋经典签名约定;**真实签名规则以官方最新文档为准**,全部收敛在本包内。`api_base` 可配置以便冒烟指向本地 mock。 - -- [ ] **Step 1: 先写失败测试**(`payment/gateway_test.go`) - -```go -package payment - -import "testing" - -func TestSignDeterministic(t *testing.T) { - params := map[string]string{ - "appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90", - } - s1 := Sign(params, "secret123") - s2 := Sign(params, "secret123") - if s1 != s2 { - t.Fatalf("相同参数签名应一致: %s != %s", s1, s2) - } - if s1 == "" { - t.Fatal("签名不应为空") - } -} - -func TestSignChangesWithSecret(t *testing.T) { - params := map[string]string{"appid": "1000", "trade_order_id": "ORDER001"} - if Sign(params, "a") == Sign(params, "b") { - t.Fatal("不同 secret 签名应不同") - } -} - -func TestVerifyNotify(t *testing.T) { - params := map[string]string{ - "appid": "1000", "trade_order_id": "ORDER001", "total_fee": "29.90", - "status": "OD", "hash": "", - } - hash := Sign(params, "secret123") - if !VerifyNotify(params, hash, "secret123") { - t.Fatal("正确签名应通过验签") - } - params["total_fee"] = "0.01" - if VerifyNotify(params, hash, "secret123") { - t.Fatal("篡改参数后应验签失败") - } - if VerifyNotify(params, hash, "wrong-secret") { - t.Fatal("错误 secret 应验签失败") - } -} - -func TestGetConfigDisabledWhenEmpty(t *testing.T) { - cfg := GetConfig(t.Context()) - if cfg.Enabled { - t.Fatal("默认配置(key 为空)应 disabled") - } -} -``` - -- [ ] **Step 2: 运行确认失败** - -Run: `go test ./styleagent/payment/...` Expected: FAIL(Sign/VerifyNotify/GetConfig 未定义) - -- [ ] **Step 3: 实现 gateway.go** - -```go -package payment - -import ( - "context" - "crypto/md5" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "sort" - "strings" - "time" - - "github.com/gogf/gf/v2/frame/g" -) - -// 虎皮棋聚合支付适配器:签名/HTTP 细节全部收敛在本包,业务层不感知。 -// 注意:签名规则与字段名以官方最新文档为准(当前实现为经典 md5 约定)。 - -type Config struct { - AppId string - AppSecret string - NotifyUrl string - Channel string // 逗号分隔,如 "alipay,wechat" - ApiBase string - Enabled bool -} - -func GetConfig(ctx context.Context) Config { - cfg := Config{ - AppId: g.Cfg().MustGet(ctx, "payment.xunhu_appid", "").String(), - AppSecret: g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String(), - NotifyUrl: g.Cfg().MustGet(ctx, "payment.notify_url", "").String(), - Channel: g.Cfg().MustGet(ctx, "payment.channel", "alipay").String(), - ApiBase: g.Cfg().MustGet(ctx, "payment.api_base", "https://api.xunhupay.com").String(), - } - cfg.Enabled = cfg.AppId != "" && cfg.AppSecret != "" - return cfg -} - -// CreateOrder 创建支付单,返回收银台/支付 URL(金额单位:分) -func CreateOrder(ctx context.Context, orderNo string, amountFen int) (payURL string, err error) { - cfg := GetConfig(ctx) - if !cfg.Enabled { - return "", errors.New("支付未开通,请在 config.yml 配置 payment") - } - channel := "alipay" - if first := strings.Split(cfg.Channel, ",")[0]; first != "" { - channel = first - } - params := map[string]string{ - "appid": cfg.AppId, - "trade_order_id": orderNo, - "total_fee": fmt.Sprintf("%.2f", float64(amountFen)/100), - "title": "形象会员", - "notify_url": cfg.NotifyUrl, - "type": channel, - "version": "1.1", - "nonce_str": nonce(), - } - params["hash"] = Sign(params, cfg.AppSecret) - - var resp struct { - Errcode int `json:"errcode"` - Errmsg string `json:"errmsg"` - Url string `json:"url"` - } - if _, err := g.Client().SetTimeout(10 * time.Second).Post(context.Background(), cfg.ApiBase+"/payment/do.html", params, &resp); err != nil { - return "", fmt.Errorf("虎皮棋下单失败: %w", err) - } - if resp.Errcode != 0 { - return "", fmt.Errorf("虎皮棋下单失败: %s", resp.Errmsg) - } - if resp.Url == "" { - return "", errors.New("虎皮棋下单失败: 返回为空") - } - return resp.Url, nil -} - -// Sign 参数名升序拼接 key=value,追加 secret 后 md5 hex -func Sign(params map[string]string, secret string) string { - keys := make([]string, 0, len(params)) - for k := range params { - if params[k] == "" { - continue - } - keys = append(keys, k) - } - sort.Strings(keys) - var sb strings.Builder - for i, k := range keys { - if i > 0 { - sb.WriteString("&") - } - sb.WriteString(k) - sb.WriteString("=") - sb.WriteString(params[k]) - } - sb.WriteString(secret) - sum := md5.Sum([]byte(sb.String())) - return hex.EncodeToString(sum[:]) -} - -// VerifyNotify 验签:复制参数去掉 hash 后重算签名比较 -func VerifyNotify(params map[string]string, hash, secret string) bool { - if hash == "" || secret == "" { - return false - } - cp := make(map[string]string, len(params)) - for k, v := range params { - if k != "hash" { - cp[k] = v - } - } - return Sign(cp, secret) == strings.ToLower(hash) -} - -func nonce() string { - b := make([]byte, 8) - _, _ = rand.Read(b) - return hex.EncodeToString(b) -} -``` - -> `g.Client().SetTimeout(10 * time.Second)` 为 10 秒超时,需 import "time"。 - -- [ ] **Step 4: 运行测试确认通过** - -Run: `go test ./styleagent/payment/... -v` Expected: PASS(4 个用例) - -- [ ] **Step 5: 提交** - -```bash -git add styleagent/payment -git commit -m "feat: 虎皮棋支付适配器(签名/下单/验签,未配置降级)" -``` - ---- - -### Task 5: member service(套餐/状态/下单/回调处理,TDD 续期计算) - -**Files:** -- Create: `styleagent/service/member_service.go` -- Test: `styleagent/service/member_service_test.go` - -- [ ] **Step 1: 先写失败测试**(续期纯函数) - -```go -package service - -import ( - "testing" - "time" - - "github.com/gogf/gf/v2/os/gtime" -) - -func TestNextExpireFromNow(t *testing.T) { - got := NextExpire(nil, 30) - want := time.Now().Add(30 * 24 * time.Hour).Format("2006-01-02 15:04:05") - gotT, _ := time.Parse("2006-01-02 15:04:05", got) - wantT, _ := time.Parse("2006-01-02 15:04:05", want) - if !gotT.Equal(wantT) { - t.Fatalf("过期会员应从现在起算: got=%s want~%s", got, want) - } -} - -func TestNextExpireStackOnFuture(t *testing.T) { - base := gtime.NewFromTime(time.Now().Add(10 * 24 * time.Hour)) - got := NextExpire(base, 30) - gotT, _ := time.Parse("2006-01-02 15:04:05", got) - if gotT.Before(base.Time) { - t.Fatalf("未过期会员应叠加: got=%s base=%s", got, base.Format("2006-01-02 15:04:05")) - } -} -``` - -- [ ] **Step 2: 运行确认失败** - -Run: `go test ./styleagent/service/... -run "TestNextExpire"` Expected: FAIL(NextExpire 未定义) - -- [ ] **Step 3: 实现 member_service.go** - -```go -package service - -import ( - "context" - "errors" - "fmt" - "time" - - "slogan-agent/styleagent/consts" - "slogan-agent/styleagent/dao" - "slogan-agent/styleagent/model/entity" - "slogan-agent/styleagent/payment" - - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gtime" -) - -type memberService struct{} - -var MemberService = new(memberService) - -func (s *memberService) PlanList(ctx context.Context) ([]*entity.MemberPlan, error) { - return dao.MemberPlan.ListEnabled(ctx) -} - -type MemberStatus struct { - IsVip bool `json:"is_vip"` - ExpireAt string `json:"expire_at"` - PlanName string `json:"plan_name"` - Benefits []string `json:"benefits"` -} - -func (s *memberService) Status(ctx context.Context, userId int64) (*MemberStatus, error) { - st := &MemberStatus{Benefits: make([]string, 0)} - um, err := dao.UserMember.GetByUser(ctx, userId) - if err != nil { - return nil, err - } - if um == nil || um.ExpireAt == nil || um.ExpireAt.Time.Before(time.Now()) { - return st, nil - } - st.IsVip = true - st.ExpireAt = um.ExpireAt.Format("2006-01-02 15:04:05") - if plan, _ := dao.MemberPlan.GetOne(ctx, um.PlanId); plan != nil { - st.PlanName = plan.Name - st.Benefits = parseBenefits(plan.Features) - } - return st, nil -} - -// CreateOrder 下单:生成业务订单号 → 虎皮棋下单 → 返回支付 URL -func (s *memberService) CreateOrder(ctx context.Context, userId, planId int64) (*entity.PaymentOrder, string, error) { - plan, err := dao.MemberPlan.GetOne(ctx, planId) - if err != nil { - return nil, "", err - } - if plan == nil { - return nil, "", errors.New("套餐不存在") - } - order := &entity.PaymentOrder{ - OrderNo: fmt.Sprintf("M%d%d", time.Now().UnixNano()/1e6, userId%1000), - UserId: userId, - PlanId: planId, - AmountFen: plan.PriceFen, - Channel: "alipay", - Status: consts.PayStatusPending, - } - if _, err := dao.PaymentOrder.Insert(ctx, order); err != nil { - return nil, "", err - } - payURL, err := payment.CreateOrder(ctx, order.OrderNo, plan.PriceFen) - if err != nil { - return nil, "", err - } - return order, payURL, nil -} - -func (s *memberService) OrderStatus(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) { - return dao.PaymentOrder.GetByOrderNo(ctx, orderNo) -} - -// HandlePaidNotify 验签已在 handler 完成;状态机 pending→paid 幂等,成功开通/续期 -func (s *memberService) HandlePaidNotify(ctx context.Context, orderNo, tradeNo, notifyRaw string) (string, error) { - order, err := dao.PaymentOrder.GetByOrderNo(ctx, orderNo) - if err != nil { - return "no_order", err - } - if order == nil { - return "no_order", nil - } - ok, err := dao.PaymentOrder.MarkPaid(ctx, orderNo, tradeNo, notifyRaw) - if err != nil { - return "no_order", err - } - if !ok { - return "duplicate", nil // 已是 paid 或已关闭 - } - days := 30 - if plan, _ := dao.MemberPlan.GetOne(ctx, order.PlanId); plan != nil { - days = plan.DurationDays - } - um, _ := dao.UserMember.GetByUser(ctx, order.UserId) - expireAt := NextExpire(um.ExpireAt, days) - if err := dao.UserMember.Upsert(ctx, order.UserId, order.PlanId, expireAt, consts.MemberSourceVipPay); err != nil { - return "no_order", err - } - g.Log().Infof(ctx, "会员开通成功 user=%d order=%s expire=%s", order.UserId, orderNo, expireAt) - return "ok", nil -} - -// NextExpire 续期计算:未过期在原有效期上叠加,过期/无记录从现在起算 -func NextExpire(old *gtime.Time, days int) string { - base := time.Now() - if old != nil && old.Time.After(base) { - base = old.Time - } - return base.Add(time.Duration(days) * 24 * time.Hour).Format("2006-01-02 15:04:05") -} - -func parseBenefits(features string) []string { - var list []string - _ = json.Unmarshal([]byte(features), &list) - if list == nil { - list = make([]string, 0) - } - return list -} -``` - -> 需要 import `"encoding/json"`。 - -- [ ] **Step 4: 运行测试确认通过** - -Run: `go test ./styleagent/service/... -run "TestNextExpire" -v` Expected: PASS - -- [ ] **Step 5: 提交** - -```bash -git add styleagent/service/member_service.go styleagent/service/member_service_test.go -git commit -m "feat: 会员服务(套餐/状态/下单/回调幂等续期)" -``` - ---- - -### Task 6: member controller + DTO + 回调路由 + 配置 - -**Files:** -- Modify: `styleagent/model/dto/dto.go`(追加) -- Create: `styleagent/controller/member_controller.go` -- Modify: `common/auth_middleware.go`(publicPaths) -- Modify: `main.go`(注册控制器 + 回调裸文本路由) -- Modify: `config.yml`(payment 段) - -- [ ] **Step 1: dto.go 追加会员 DTO** - -```go -type MemberPlanListReq struct { - g.Meta `path:"/plan/list" method:"get" tags:"会员" summary:"会员套餐列表"` -} - -type MemberPlanListRes struct { - List []*entity.MemberPlan `json:"list"` -} - -type MemberStatusReq struct { - g.Meta `path:"/status" method:"get" tags:"会员" summary:"我的会员状态"` -} - -type MemberStatusRes struct { - IsVip bool `json:"is_vip"` - ExpireAt string `json:"expire_at"` - PlanName string `json:"plan_name"` - Benefits []string `json:"benefits"` -} - -type MemberOrderCreateReq struct { - g.Meta `path:"/order/create" method:"post" tags:"会员" summary:"创建支付订单"` - PlanId int64 `v:"required" json:"plan_id"` -} - -type MemberOrderCreateRes struct { - OrderNo string `json:"order_no"` - PayUrl string `json:"pay_url"` -} - -type MemberOrderStatusReq struct { - g.Meta `path:"/order/status" method:"get" tags:"会员" summary:"订单状态"` - OrderNo string `v:"required" json:"order_no"` -} - -type MemberOrderStatusRes struct { - Status string `json:"status"` - TradeNo string `json:"trade_no"` - PaidAt string `json:"paid_at"` -} -``` - -- [ ] **Step 2: member_controller.go**(struct 名 `member` → 路由前缀 `/member`) - -```go -package controller - -import ( - "context" - - commonHttp "slogan-agent/common" - "slogan-agent/styleagent/model/dto" - "slogan-agent/styleagent/service" -) - -type member struct{} - -var Member = new(member) - -// PlanList 会员套餐列表 -func (c *member) PlanList(ctx context.Context, req *dto.MemberPlanListReq) (res *dto.MemberPlanListRes, err error) { - list, err := service.MemberService.PlanList(ctx) - if err != nil { - return nil, err - } - return &dto.MemberPlanListRes{List: list}, nil -} - -// Status 我的会员状态 -func (c *member) Status(ctx context.Context, req *dto.MemberStatusReq) (res *dto.MemberStatusRes, err error) { - st, err := service.MemberService.Status(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx))) - if err != nil { - return nil, err - } - return &dto.MemberStatusRes{ - IsVip: st.IsVip, ExpireAt: st.ExpireAt, PlanName: st.PlanName, Benefits: st.Benefits, - }, nil -} - -// OrderCreate 下单 → 返回支付 URL -func (c *member) OrderCreate(ctx context.Context, req *dto.MemberOrderCreateReq) (res *dto.MemberOrderCreateRes, err error) { - order, payURL, err := service.MemberService.CreateOrder(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.PlanId) - if err != nil { - return nil, err - } - return &dto.MemberOrderCreateRes{OrderNo: order.OrderNo, PayUrl: payURL}, nil -} - -// OrderStatus 订单状态(App 轮询) -func (c *member) OrderStatus(ctx context.Context, req *dto.MemberOrderStatusReq) (res *dto.MemberOrderStatusRes, err error) { - order, err := service.MemberService.OrderStatus(ctx, req.OrderNo) - if err != nil || order == nil { - return nil, errors.New("订单不存在") - } - paidAt := "" - if order.PaidAt != nil { - paidAt = order.PaidAt.Format("2006-01-02 15:04:05") - } - return &dto.MemberOrderStatusRes{Status: order.Status, TradeNo: order.TradeNo, PaidAt: paidAt}, nil -} -``` - -> 需要 import `"errors"` 与 `"github.com/gogf/gf/v2/frame/g"`(`g.RequestFromCtx`)。 - -- [ ] **Step 3: 回调裸文本 handler(member_controller.go 追加,不用 2 参签名)** - -```go -// MemberNotify 虎皮棋支付回调:验签 → 幂等开通 → 返回裸文本 "success" -// 虎皮棋要求回调响应体为字面 "success",故不走统一 JSON 包装 -func MemberNotify(r *ghttp.Request) { - ctx := r.Context() - body := r.GetRawString() - hash := r.Get("hash").String() - orderNo := r.Get("trade_order_id").String() - remoteIP := r.GetClientIp() - - params := make(map[string]string) - for k, v := range r.GetRequestMap() { - params[k] = fmt.Sprint(v) - } - ok := payment.VerifyNotify(params, hash, g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String()) - - if !ok { - _ = dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{ - OrderNo: orderNo, Body: body, Sign: hash, RemoteIp: remoteIP, Status: "bad_sign", - }) - r.Response.Write("fail") - r.ExitAll() - return - } - - state, err := service.MemberService.HandlePaidNotify(ctx, orderNo, r.Get("transaction_id").String(), body) - _ = dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{ - OrderNo: orderNo, Body: body, Sign: hash, RemoteIp: remoteIP, Status: state, - }) - if err != nil || state != "ok" { - r.Response.Write("fail") - r.ExitAll() - return - } - r.Response.Write("success") - r.ExitAll() -} -``` - -> import 追加:`"fmt"`、`"github.com/gogf/gf/v2/net/ghttp"`、`"slogan-agent/styleagent/dao"`、`"slogan-agent/styleagent/model/entity"`、`"slogan-agent/styleagent/payment"`。 - -- [ ] **Step 4: publicPaths 放行回调**(`common/auth_middleware.go` 的 publicPaths 数组加一项) - -```go - "/member/order/notify", -``` - -- [ ] **Step 5: main.go 注册**(RouteRegister 数组加 `controller.Member`,并手动绑定回调路由) - -```go - commonHttp.RouteRegister([]interface{}{ - controller.User, - controller.UserPhoto, - controller.Wardrobe, - controller.BodyMeasurement, - controller.Avatar, - controller.Hairstyle, - controller.Outfit, - controller.PartnerStore, - controller.Member, - }) - - // 虎皮棋支付回调(裸文本 "success",不走统一 JSON 包装) - commonHttp.Httpserver.Group("/member/order", func(group *ghttp.RouterGroup) { - group.POST("/notify", controller.MemberNotify) - }) -``` - -- [ ] **Step 6: config.yml 追加 payment 段** - -```yaml -# 支付(虎皮棋聚合支付,key 为空则支付功能降级关闭) -payment: - xunhu_appid: "" - xunhu_appsecret: "" - notify_url: "http://localhost:3007/member/order/notify" # 生产需公网可达 - channel: "alipay,wechat" - api_base: "https://api.xunhupay.com" -``` - -- [ ] **Step 7: 编译 + 冒烟(降级路径)** - -Run: `go build ./...` Expected: 成功 -Run: 启动服务 → `curl -s http://127.0.0.1:3007/member/plan/list -H "Authorization: Bearer $TOKEN"` -Expected: `{"code":0,...,"data":{"list":[{"name":"月卡 ¥29.9","price_fen":2990,...},...]}}`(2 个套餐) -Run: `curl -s -X POST http://127.0.0.1:3007/member/order/create -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"plan_id":1}'` -Expected: code=0 但 data 为 null?—— 不对:CreateOrder 返回 error → 统一响应 `{"code":-1,"message":"支付未开通,请在 config.yml 配置 payment",...}`(降级路径正确) -Run: `curl -s http://127.0.0.1:3007/member/order/notify` (无鉴权) -Expected: 返回 `fail`(key 为空验签失败),且 `pay_notify_log` 多一条 bad_sign 记录 -Run: `sqlite3 slogan.db "SELECT status FROM slogan_pay_notify_log ORDER BY id DESC LIMIT 1"` -Expected: `bad_sign` - -- [ ] **Step 8: 提交** - -```bash -git add styleagent/model/dto/dto.go styleagent/controller/member_controller.go common/auth_middleware.go main.go config.yml -git commit -m "feat: 会员接口(套餐/状态/下单/回调)+ 支付配置" -``` - ---- - -### Task 7: 广告激励(ad_reward DAO + service + controller,TDD 限频) - -**Files:** -- Create: `styleagent/dao/ad_reward_log_dao.go` -- Create: `styleagent/service/ad_service.go` -- Test: `styleagent/service/ad_service_test.go` -- Create: `styleagent/controller/ad_controller.go` -- Modify: `styleagent/model/dto/dto.go` -- Modify: `main.go`、`config.yml` - -- [ ] **Step 1: ad_reward_log DAO**(唯一索引 `user_id+reward_key` 防并发重复) - -```go -package dao - -import ( - "context" - "fmt" - "time" - - "slogan-agent/styleagent/consts" - "slogan-agent/styleagent/model/entity" - - "github.com/gogf/gf/v2/frame/g" -) - -var AdRewardLog = &adRewardLogDao{} - -type adRewardLogDao struct{} - -func init() { - ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAdRewardLog+` ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 0, - ad_type TEXT NOT NULL DEFAULT '', - reward_key TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'ok', - created_at DATETIME DEFAULT (datetime('now','localtime')) - )`) - if err != nil { - g.Log().Warningf(ctx, "create ad_reward_log table failed: %v", err) - } - _, _ = g.DB().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON `+consts.TableNameAdRewardLog+`(user_id, reward_key)`) -} - -// rewardKey 自然日去重粒度:"2026-07-31:effect_extra" -func rewardKey(adType string) string { - return fmt.Sprintf("%s:%s", time.Now().Format("2006-01-02"), adType) -} - -func (d *adRewardLogDao) CountTodayByType(ctx context.Context, userId int64, adType string) (int, error) { - n, err := g.DB().Model(consts.TableNameAdRewardLog).Ctx(ctx). - Where("user_id", userId).Where("reward_key", rewardKey(adType)).Count() - return int(n), err -} - -// Insert 领取记录(唯一索引冲突即返回错误 → 视为限频) -func (d *adRewardLogDao) Insert(ctx context.Context, userId int64, adType string) (int64, error) { - r, err := g.DB().Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{ - "user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "status": "ok", - }).Insert() - if err != nil { - return 0, err - } - return r.LastInsertId() -} -``` - -- [ ] **Step 2: 先写限频数学失败测试**(`service/ad_service_test.go`) - -```go -package service - -import "testing" - -func TestRewardRemaining(t *testing.T) { - if got := rewardRemaining(2, 0); got != 2 { - t.Fatalf("未领取时剩余应为 2, got %d", got) - } - if got := rewardRemaining(2, 2); got != 0 { - t.Fatalf("已用满时剩余应为 0, got %d", got) - } - if got := rewardRemaining(1, 1); got != 0 { - t.Fatalf("vip_trial 已用完剩余应为 0, got %d", got) - } -} - -func TestRewardQuota(t *testing.T) { - if got := rewardQuota(t.Context(), "effect_extra"); got != 2 { - t.Fatalf("默认每日 2 次, got %d", got) - } -} -``` - -- [ ] **Step 3: 运行确认失败** - -Run: `go test ./styleagent/service/... -run "TestReward"` Expected: FAIL(函数未定义) - -- [ ] **Step 4: 实现 ad_service.go** - -```go -package service - -import ( - "context" - "errors" - - "slogan-agent/styleagent/consts" - "slogan-agent/styleagent/dao" - - "github.com/gogf/gf/v2/frame/g" -) - -type adService struct{} - -var AdService = new(adService) - -type AdRewardResult struct { - AdType string `json:"ad_type"` - RemainingToday int `json:"remaining_today"` -} - -// Claim 领取广告激励:服务端限频计数,不信任客户端 -func (s *adService) Claim(ctx context.Context, userId int64, adType string) (*AdRewardResult, error) { - if adType != consts.AdTypeEffectExtra && adType != consts.AdTypeVipTrial { - return nil, errors.New("无效的广告类型") - } - limit := rewardQuota(ctx, adType) - used, err := dao.AdRewardLog.CountTodayByType(ctx, userId, adType) - if err != nil { - return nil, err - } - if used >= limit { - return nil, errors.New("今日次数已用完") - } - if _, err := dao.AdRewardLog.Insert(ctx, userId, adType); err != nil { - return nil, errors.New("今日次数已用完") // 唯一索引兜底并发 - } - if adType == consts.AdTypeVipTrial { - _ = dao.UserMember.Upsert(ctx, userId, 0, NextExpire(nil, 1), consts.MemberSourceAdTrial) - } - return &AdRewardResult{AdType: adType, RemainingToday: limit - used - 1}, nil -} - -func rewardQuota(ctx context.Context, adType string) int { - if adType == consts.AdTypeVipTrial { - return g.Cfg().MustGet(ctx, "ad.limit_vip_trial", 1).Int() - } - return g.Cfg().MustGet(ctx, "ad.limit_effect_extra", 2).Int() -} - -func rewardRemaining(limit, used int) int { - if r := limit - used; r > 0 { - return r - } - return 0 -} -``` - -- [ ] **Step 5: DTO 追加**(AdRewardInfo 定义在 dto 包内,controller 从 service 结果转换) - -```go -type AdRewardClaimReq struct { - g.Meta `path:"/reward/claim" method:"post" tags:"广告" summary:"领取广告激励"` - AdType string `v:"required|in:effect_extra,vip_trial" json:"ad_type"` -} - -type AdRewardInfo struct { - AdType string `json:"ad_type"` - RemainingToday int `json:"remaining_today"` -} - -type AdRewardClaimRes struct { - Reward *AdRewardInfo `json:"reward"` -} -``` - -- [ ] **Step 6: ad_controller.go**(struct 名 `ad` → 前缀 `/ad`) - -```go -package controller - -import ( - "context" - - commonHttp "slogan-agent/common" - "slogan-agent/styleagent/model/dto" - "slogan-agent/styleagent/service" - - "github.com/gogf/gf/v2/frame/g" -) - -type ad struct{} - -var Ad = new(ad) - -// RewardClaim 领取广告激励(限频:effect_extra 每日 2 次 / vip_trial 每日 1 次) -func (c *ad) RewardClaim(ctx context.Context, req *dto.AdRewardClaimReq) (res *dto.AdRewardClaimRes, err error) { - result, err := service.AdService.Claim(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.AdType) - if err != nil { - return nil, err - } - return &dto.AdRewardClaimRes{Reward: &dto.AdRewardInfo{ - AdType: result.AdType, RemainingToday: result.RemainingToday, - }}, nil -} -``` - -- [ ] **Step 7: 注册 + 配置**(main.go RouteRegister 加 `controller.Ad`;config.yml 加 ad 段) - -```yaml -# 广告激励限频(自然日) -ad: - limit_effect_extra: 2 - limit_vip_trial: 1 -``` - -- [ ] **Step 8: 测试 + 编译 + 冒烟** - -Run: `go test ./styleagent/service/... -run "TestReward" -v` Expected: PASS -Run: `go build ./...` Expected: 成功 -Run: 启动服务 → 依次 `curl -X POST .../ad/reward/claim -d '{"ad_type":"effect_extra"}'` 三次(带 TOKEN) -Expected: 前两次 `{"code":0,...,"data":{"reward":{"ad_type":"effect_extra","remaining_today":1}}} / remaining_today:0`,第三次 `{"code":-1,"message":"今日次数已用完"}` -Run: `sqlite3 slogan.db "SELECT reward_key, COUNT(*) FROM slogan_ad_reward_log GROUP BY reward_key"` -Expected: 当天 key 计数 2 - -- [ ] **Step 9: 提交** - -```bash -git add styleagent/dao/ad_reward_log_dao.go styleagent/service/ad_service.go styleagent/service/ad_service_test.go styleagent/controller/ad_controller.go styleagent/model/dto/dto.go main.go config.yml -git commit -m "feat: 广告激励(效果图加次/体验会员,服务端限频防刷)" -``` - ---- - -### Task 8: 效果图限额改造(VIP 不限 + 广告加次) - -**Files:** -- Modify: `styleagent/service/effect_image_service.go`(`run` 方法限额判定) - -- [ ] **Step 1: 修改限额判定**(原 40-44 行) - -```go - // 每日限额:VIP 不限;普通用户 = 基础额度 + 广告激励额外次数 - if !dao.UserMember.IsVip(ctx, userId) { - limit := dailyEffectLimit(ctx) - if limit > 0 { - used, _ := dao.PlanEffectImage.CountByUserToday(ctx, userId) - extra, _ := dao.AdRewardLog.CountTodayByType(ctx, userId, consts.AdTypeEffectExtra) - if used >= limit+extra { - g.Log().Warningf(ctx, "效果图任务: 用户 %d 当日次数已用尽(%d/%d)", userId, used, limit+extra) - return - } - } - } -``` - -- [ ] **Step 2: 编译** - -Run: `go build ./...` Expected: 成功 - -- [ ] **Step 3: 提交** - -```bash -git add styleagent/service/effect_image_service.go -git commit -m "feat: 效果图限额支持 VIP 不限与广告加次" -``` - ---- - -### Task 9: 全链路冒烟(mock 虎皮棋 + 回调验签 + 会员开通) - -**Files:** 无(临时文件 `/tmp/mock_xunhu.py`) - -- [ ] **Step 1: 写本地 mock 虎皮棋服务**(`/tmp/mock_xunhu.py`,签名算法与服务端一致) - -```python -import hashlib, json -from http.server import HTTPServer, BaseHTTPRequestHandler -from urllib.parse import parse_qs - -SECRET = "test-secret" -APPID = "test-appid" - -def sign(params: dict) -> str: - parts = [] - for k in sorted(params): - if params[k] == "": - continue - parts.append(f"{k}={params[k]}") - return hashlib.md5(("&".join(parts) + SECRET).encode()).hexdigest() - -class H(BaseHTTPRequestHandler): - def do_POST(self): - length = int(self.headers.get("Content-Length", 0)) - body = self.rfile.read(length).decode() - p = {k: v[0] for k, v in parse_qs(body).items()} - if self.path == "/payment/do.html": - resp = {"errcode": 0, "errmsg": "ok", "url": "http://localhost:3998/pay?order=" + p["trade_order_id"]} - self.send_response(200); self.send_header("Content-Type", "application/json") - self.end_headers(); self.wfile.write(json.dumps(resp).encode()) - else: - self.send_response(404); self.end_headers() - def do_GET(self): - self.send_response(200); self.end_headers() - self.wfile.write(b"fake cashier page") - -HTTPServer(("127.0.0.1", 3998), H).serve_forever() -``` - -- [ ] **Step 2: 临时配置指向 mock**(`config.yml` payment 段替换;先备份) - -```yaml -payment: - xunhu_appid: "test-appid" - xunhu_appsecret: "test-secret" - notify_url: "http://localhost:3007/member/order/notify" - channel: "alipay,wechat" - api_base: "http://127.0.0.1:3998" -``` - -- [ ] **Step 3: 重启服务,执行全链路** - -```bash -cp config.yml /tmp/config.yml.bak -python3 /tmp/mock_xunhu.py & # mock 先起 -go run main.go & # 后端(先改好 config.yml) -# 1) 下单 -curl -s -X POST http://127.0.0.1:3007/member/order/create -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"plan_id":1}' -# → {"order_no":"M...","pay_url":"http://localhost:3998/pay?order=M..."} -# 2) 模拟回调(用 mock 的签名算法对参数签名,python 现场算) -ORDER=上面返回的order_no -PAYLOAD=$(python3 -c " -import sys -p = {'appid':'test-appid','trade_order_id':'$ORDER','total_fee':'29.90','status':'OD','transaction_id':'TX001','notify_url':'http://localhost:3007/member/order/notify'} -def sign(d): - parts=[] - for k in sorted(d): - if d[k]=='': continue - parts.append(f'{k}={d[k]}') - import hashlib - return hashlib.md5(('&'.join(parts)+'test-secret').encode()).hexdigest() -h = sign(p) -p['hash']=h -import urllib.parse -print(urllib.parse.urlencode(p)) -") -curl -s -X POST http://127.0.0.1:3007/member/order/notify -d "$PAYLOAD" -# → success -# 3) 订单状态 -curl -s "http://127.0.0.1:3007/member/order/status?order_no=$ORDER" -H "Authorization: Bearer $TOKEN" -# → {"status":"paid","trade_no":"TX001",...} -# 4) 会员状态 -curl -s http://127.0.0.1:3007/member/status -H "Authorization: Bearer $TOKEN" -# → {"is_vip":true,"expire_at":"...","plan_name":"月卡 ¥29.9","benefits":["effect_unlimited","cps_commission_x15"]} -# 5) 幂等:重复回调 -curl -s -X POST http://127.0.0.1:3007/member/order/notify -d "$PAYLOAD" -# → success(幂等);pay_notify_log 出现 duplicate 记录 -sqlite3 slogan.db "SELECT status FROM slogan_pay_notify_log ORDER BY id DESC LIMIT 2" -# → duplicate / ok -``` - -- [ ] **Step 4: 验证 VIP 效果图限额 + 广告加次** - -```bash -# vip 用户选定主方案 → 效果图生成不再受 3 次限制(日志无"当日次数已用尽") -# 非 vip:先领 2 次 effect_extra,再生成 → 当日额度 3+2=5(多生成 2 张验证计数生效) -``` - -- [ ] **Step 5: 恢复配置并清理** - -```bash -cp /tmp/config.yml.bak config.yml -kill %1 %2 2>/dev/null -git diff --stat # 期望无 config.yml 变更 -``` - -- [ ] **Step 6: 提交最终状态** - -```bash -git status # 确认无残留 -git log --oneline -5 -``` - ---- - -## 自检清单 - -- [ ] 5 张表名、5 个 entity 与 spec SQL 列一致(表名前缀 slogan_ 为仓库规范差异,已注明) -- [ ] `/member/order/notify` 在 publicPaths + 手动裸文本绑定,验签失败记 bad_sign -- [ ] 支付/广告 key 未配置 → 明确错误信息,不 panic -- [ ] 回调幂等:MarkPaid 状态机 + pay_notify_log 审计 -- [ ] 效果图限额 = 基础 3 + 广告额外次数;VIP 跳过限额 -- [ ] `go build`、`go test ./...`、全链路冒烟全部通过 - -## 后续计划(P1,不在本计划内) - -CPS 统一引擎(4 表 + Provider 抽象 + 美团联盟适配器 + 方案驱动推荐 + 6 接口)、客户端会员中心与 CPS 入口、P2 京东/淘宝适配器与广告 SDK。 diff --git a/server/docs/superpowers/plans/2026-07-31-slogan-agent-mvp.md b/server/docs/superpowers/plans/2026-07-31-slogan-agent-mvp.md deleted file mode 100644 index f2df8a7..0000000 --- a/server/docs/superpowers/plans/2026-07-31-slogan-agent-mvp.md +++ /dev/null @@ -1,933 +0,0 @@ -# slogan-agent MVP 实现计划 - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** 实现 slogan-agent 服务端 MVP:登录 → 照片/衣橱/身形管理 → 化身构建(v1 模板匹配)→ 穿搭生成(规则评分 + Agent 规划 + 兜底)→ 效果图按需生成,全链路可运行。 - -**Architecture:** Go 单体 + GoFrame v2 + SQLite,严格遵循 video-factory 分层规范(controller → service → dao),包级单例,RouteRegister 反射注册路由,JWT 鉴权,OpenAI 兼容 LLM。规则评分零 LLM 成本,效果图按需生成 + 缓存。 - -**Tech Stack:** Go 1.22+ / GoFrame v2.10 / SQLite / JWT / bcrypt / OpenAI 兼容 API / 和风天气 API - -**参考代码(必须阅读后复制模式):** -- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/common/`(http.go / auth.go / base_dao.go / cache.go / auth_middleware.go / util.go) -- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/shortdrama/agent/chat_model.go`(直接复用整个文件,改包名) -- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/shortdrama/`(controller/service/dao/model 全部模式) -- `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/main.go`(入口模式) - -**数据库:** `slogan.db`(config.yml 配置),表名前缀 `slogan_`。所有 init() 自动建表 + ALTER 迁移兼容。 - -**模块路径:** `slogan-agent`(go.mod module name),业务包 `styleagent`。 - ---- - -## 数据库表总览(Task 2-3 建全) - -| 表 | 关键字段 | -|----|---------| -| `slogan_user` | id, role(default 'user'), username, phone, password, name, created_at, updated_at | -| `slogan_user_photo` | id, user_id, type(1大头照 2全身正面 3全身侧面 4全身背面), url, status, created_at | -| `slogan_wardrobe_item` | id, user_id, photo_url, category(上衣/下装/鞋/配饰), season(春/夏/秋/冬/四季), style_tags, color_info, status, created_at | -| `slogan_body_measurement` | id, user_id, height, weight, skin_tone(1-5), fit_params(JSON), updated_at | -| `slogan_avatar_model` | id, user_id, face_template_id, body_template_id, skin_tone_index, face_texture_url, glb_url, build_status(pending/processing/done/failed), error, params_snapshot(JSON), created_at | -| `slogan_hairstyle_asset` | id, name, style_tag, glb_url, thumb_url, applicable_face, sort | -| `slogan_outfit_generation_task` | id, user_id, start_date, end_date, location, weather_snapshot(JSON), status(pending/planning/scoring/rendering/done/failed), error, model_name, created_at | -| `slogan_outfit_plan` | id, task_id, user_id, date_range, location, source(wardrobe/recommend), score, main_flag(0/1), hairstyle_id, hair_color, weather_ref(JSON), created_at | -| `slogan_plan_outfit_item` | id, plan_id, slot(发型/上衣/下装/鞋/配饰), source, wardrobe_item_id, product_name, name, desc | -| `slogan_plan_effect_image` | id, plan_id, angle(正面/侧面/背面), url, status, prompt_snapshot, created_at | -| `slogan_plan_review` | id, plan_id, user_id, action(fav/unfav), note, created_at | -| `slogan_scoring_rule` | id, dimension, rule_type, rules_json, enabled, version | - ---- - -### Task 1: 项目骨架(go.mod / config / main.go / common 复制) - -**Files:** -- Create: `go.mod` -- Create: `config.yml` -- Create: `main.go` -- Copy: `common/http.go`, `common/auth.go`, `common/base_dao.go`, `common/cache.go`, `common/util.go`, `common/auth_middleware.go`(从 video-factory 复制,改 package 注释即可,无需改逻辑) - -- [ ] **Step 1: 创建 go.mod** - -```bash -cd slogan-agent -go mod init slogan-agent -``` - -- [ ] **Step 2: 创建 config.yml** - -```yaml -database: - default: - name: slogan.db - type: sqlite - debug: false - cache: - ttl: 60 -server: - address: :3007 - name: slogan - workerId: 1 - clientMaxBodySize: 209715200 - requestTimeout: 3000 -chat: - timeout: 300 - max_retries: 3 -``` - -- [ ] **Step 3: 复制 common 包** - -```bash -cp /Users/zhangbin/Desktop/d盘/work/video-factory/video-factory/common/{http.go,auth.go,base_dao.go,cache.go,util.go,auth_middleware.go} common/ -``` - -注意:auth_middleware.go 和 cache.go / util.go 需检查是否有对 video-factory 特定包的 import,如有则调整。auth.go 中 jwtSecret 改为 slogan 自己的密钥。 - -- [ ] **Step 4: 创建 main.go**(模式同 video-factory main.go,路由表注册 controller,workspace 鉴权静态服务,端口 3007) - -- [ ] **Step 5: 添加依赖并编译** - -```bash -go mod tidy -go build ./... -``` - -Expected: 编译通过(common 包复制可能依赖 gtime/gcache,tidy 解决)。 - -- [ ] **Step 6: Commit** - -```bash -git add -A && git commit -m "feat: slogan-agent skeleton with common package" -``` - ---- - -### Task 2: consts 与全部 entity - -**Files:** -- Create: `styleagent/consts/table_name.go`(全部表名常量) -- Create: `styleagent/consts/status.go`(任务状态/照片类型/方案来源常量) -- Create: `styleagent/model/entity/`(12 个文件:user.go, user_photo.go, wardrobe_item.go, body_measurement.go, avatar_model.go, hairstyle_asset.go, outfit_generation_task.go, outfit_plan.go, plan_outfit_item.go, plan_effect_image.go, plan_review.go, scoring_rule.go) - -- [ ] **Step 1: consts/table_name.go** - -```go -package consts - -const ( - TableNameUser = "slogan_user" - TableNameUserPhoto = "slogan_user_photo" - TableNameWardrobeItem = "slogan_wardrobe_item" - TableNameBodyMeasurement = "slogan_body_measurement" - TableNameAvatarModel = "slogan_avatar_model" - TableNameHairstyleAsset = "slogan_hairstyle_asset" - TableNameOutfitGenTask = "slogan_outfit_generation_task" - TableNameOutfitPlan = "slogan_outfit_plan" - TableNamePlanOutfitItem = "slogan_plan_outfit_item" - TableNamePlanEffectImage = "slogan_plan_effect_image" - TableNamePlanReview = "slogan_plan_review" - TableNameScoringRule = "slogan_scoring_rule" -) -``` - -- [ ] **Step 2: consts/status.go** - -```go -package consts - -// 照片类型 -const ( - PhotoTypeHeadshot = 1 // 大头照 - PhotoTypeFullFront = 2 // 全身正面 - PhotoTypeFullSide = 3 // 全身侧面 - PhotoTypeFullBack = 4 // 全身背面 -) - -// 生成任务状态 -const ( - TaskStatusPending = "pending" - TaskStatusPlanning = "planning" - TaskStatusScoring = "scoring" - TaskStatusRendering = "rendering" - TaskStatusDone = "done" - TaskStatusFailed = "failed" -) - -// 方案来源 -const ( - PlanSourceWardrobe = "wardrobe" - PlanSourceRecommend = "recommend" -) - -// 化身构建状态 -const ( - AvatarBuildPending = "pending" - AvatarBuildProcessing = "processing" - AvatarBuildDone = "done" - AvatarBuildFailed = "failed" -) - -// 评分阈值(可被 scoring_rule 配置覆盖) -const DefaultScoreThreshold = 75 -``` - -- [ ] **Step 3: entity 文件**(orm tag 模式同 video-factory entity/user.go;全部含 CreatedAt/UpdatedAt `*gtime.Time`;字段完全对齐 Task 表格总览) - -- [ ] **Step 4: 编译检查** `go build ./...` - -- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: consts and entities"` - ---- - -### Task 3: 全部 DAO(init 自动建表) - -**Files:** -- Create: `styleagent/dao/user_dao.go`(完整示例,含建表 + CRUD + 缓存) -- Create: 其余 11 个 dao 文件(user_photo / wardrobe_item / body_measurement / avatar_model / hairstyle_asset / outfit_generation_task / outfit_plan / plan_outfit_item / plan_effect_image / plan_review / scoring_rule) - -- [ ] **Step 1: user_dao.go**(模式:video-factory dao/user_dao.go) - -```go -package dao - -import ( - "context" - "slogan-agent/common" - "slogan-agent/styleagent/consts" - "slogan-agent/styleagent/model/entity" - - "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gcache" - "github.com/gogf/gf/v2/util/gconv" -) - -var User = &userDao{} - -type userDao struct{} - -func init() { - ctx := context.Background() - _, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUser+` ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - role TEXT NOT NULL DEFAULT 'user', - username TEXT NOT NULL DEFAULT '', - phone TEXT NOT NULL DEFAULT '', - password TEXT NOT NULL DEFAULT '', - name TEXT NOT NULL DEFAULT '', - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME DEFAULT (datetime('now','localtime')) - )`) - if err != nil { - g.Log().Warningf(ctx, "create user table failed: %v", err) - } - _, _ = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_username ON "+consts.TableNameUser+"(username) WHERE username != ''") - _, _ = g.DB().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_slogan_user_phone ON "+consts.TableNameUser+"(phone) WHERE phone != ''") -} - -// 方法:Insert / GetOne / GetByAccount / Update / UpdateFields(复制 video-factory user_dao 对应方法,表名换 consts.TableNameUser) -``` - -- [ ] **Step 2: 其余 11 个 dao**:每个含 init() 建表 + 核心查询方法(按字段):ListByUser(user_photo/wardrobe_item 按 user_id 分页)、GetByUserAndType、GetByUser(avatar/body 单行)、ListByPlan(plan_outfit_item/plan_effect_image)、GetByTask(outfit_plan 列表)、ListAll(hairstyle_asset 按 sort)、GetEnabled(scoring_rule)、UpdateStatus(task 状态流转) - -- [ ] **Step 3: 建表自检**(先写 dao 测试或直接启动临时 main 验证) - -```bash -go build ./... && go run main.go 2>&1 | head -5 -# 验证 slogan.db 生成且无建表错误日志 -``` - -- [ ] **Step 4: Commit** `git add -A && git commit -m "feat: dao layer with auto table creation"` - ---- - -### Task 4: 全部 DTO(请求/响应 + g.Meta 路由) - -**Files:** -- Create: `styleagent/model/dto/user_dto.go`(LoginReq/LoginRes/ProfileRes) -- Create: `styleagent/model/dto/user_photo_dto.go` -- Create: `styleagent/model/dto/wardrobe_dto.go` -- Create: `styleagent/model/dto/body_measurement_dto.go` -- Create: `styleagent/model/dto/avatar_dto.go` -- Create: `styleagent/model/dto/hairstyle_dto.go` -- Create: `styleagent/model/dto/outfit_dto.go` - -- [ ] **Step 1: 关键 dto 内容** - -```go -// user_dto.go -type LoginReq struct { - g.Meta `path:"/login" method:"post" tags:"用户" summary:"登录"` - Account string `v:"required" json:"account"` - Password string `v:"required" json:"password"` -} -type LoginRes struct { - Token string `json:"token"` - User *LoginUser `json:"user"` -} -type LoginUser struct { - Id int64 `json:"id"` - Role string `json:"role"` - Name string `json:"name"` -} - -// user_photo_dto.go -type UserPhotoUploadReq struct { - g.Meta `path:"/upload" method:"post" tags:"照片" summary:"上传照片"` - Type int `v:"required|in:1,2,3,4" json:"type"` - // 文件字段:GoFrame 自动绑定 upload 文件(r.GetUploadFile) -} -type UserPhotoUploadRes struct { Id int64 `json:"id"` } -type UserPhotoListReq struct { - g.Meta `path:"/list" method:"get" tags:"照片" summary:"照片列表"` - Type int `json:"type"` // 可空 -} -type UserPhotoListRes struct { - List []*entity.UserPhoto `json:"list"` -} -type UserPhotoDeleteReq struct { - g.Meta `path:"/delete" method:"post" tags:"照片" summary:"删除照片"` - Id int64 `v:"required" json:"id"` -} - -// wardrobe_dto.go -type WardrobeUploadReq struct { - g.Meta `path:"/upload" method:"post" tags:"衣橱" summary:"上传服装"` - Category string `v:"required" json:"category"` - Season string `json:"season"` - StyleTags string `json:"style_tags"` - ColorInfo string `json:"color_info"` - // 文件字段同上 -} -type WardrobeListReq struct { - g.Meta `path:"/list" method:"get" tags:"衣橱" summary:"衣橱列表"` - Category string `json:"category"` -} -type WardrobeListRes struct { List []*entity.WardrobeItem `json:"list"` } -type WardrobeUpdateReq struct { - g.Meta `path:"/update" method:"post" tags:"衣橱" summary:"更新服装"` - Id int64 `v:"required" json:"id"` - Category string `json:"category"` - Season string `json:"season"` - StyleTags string `json:"style_tags"` -} -type WardrobeDeleteReq struct { - g.Meta `path:"/delete" method:"post" tags:"衣橱" summary:"删除服装"` - Id int64 `v:"required" json:"id"` -} - -// body_measurement_dto.go -type BodyMeasurementSaveReq struct { - g.Meta `path:"/save" method:"post" tags:"身形" summary:"保存身形参数"` - Height int `json:"height"` - Weight int `json:"weight"` - SkinTone int `v:"in:1,2,3,4,5" json:"skin_tone"` - FitParams string `json:"fit_params"` -} -type BodyMeasurementGetRes struct { - Height int `json:"height"` - Weight int `json:"weight"` - SkinTone int `json:"skin_tone"` - FitParams string `json:"fit_params"` -} - -// avatar_dto.go -type AvatarBuildReq struct { - g.Meta `path:"/build" method:"post" tags:"化身" summary:"构建化身"` -} -type AvatarBuildRes struct { TaskId int64 `json:"task_id"` } -type AvatarGetRes struct { - FaceTemplateId int `json:"face_template_id"` - BodyTemplateId int `json:"body_template_id"` - SkinToneIndex int `json:"skin_tone_index"` - GlbUrl string `json:"glb_url"` - BuildStatus string `json:"build_status"` -} - -// hairstyle_dto.go -type HairstyleListRes struct { List []*entity.HairstyleAsset `json:"list"` } - -// outfit_dto.go -type OutfitGenerateReq struct { - g.Meta `path:"/generate" method:"post" tags:"穿搭" summary:"生成穿搭方案"` - StartDate string `v:"required|date" json:"start_date"` - EndDate string `v:"required|date" json:"end_date"` - Location string `v:"required" json:"location"` -} -type OutfitGenerateRes struct { TaskId int64 `json:"task_id"` } -type OutfitTaskStatusReq struct { - g.Meta `path:"/task/status" method:"get" tags:"穿搭" summary:"任务状态"` - TaskId int64 `v:"required" json:"task_id"` -} -type OutfitTaskStatusRes struct { - Status string `json:"status"` - Error string `json:"error"` -} -type OutfitPlanListReq struct { - g.Meta `path:"/plan/list" method:"get" tags:"穿搭" summary:"方案列表"` -} -type OutfitPlanListRes struct { List []*entity.OutfitPlan `json:"list"` } -type OutfitPlanDetailReq struct { - g.Meta `path:"/plan/detail" method:"get" tags:"穿搭" summary:"方案详情"` - PlanId int64 `v:"required" json:"plan_id"` -} -type OutfitPlanDetailRes struct { - Plan *entity.OutfitPlan `json:"plan"` - Items []*entity.PlanOutfitItem `json:"items"` - Images []*entity.PlanEffectImage `json:"images"` - Hairstyle *entity.HairstyleAsset `json:"hairstyle,omitempty"` -} -type OutfitSelectMainReq struct { - g.Meta `path:"/plan/select-main" method:"post" tags:"穿搭" summary:"选定主方案"` - PlanId int64 `v:"required" json:"plan_id"` -} -type OutfitReviewReq struct { - g.Meta `path:"/plan/review" method:"post" tags:"穿搭" summary:"方案反馈"` - PlanId int64 `v:"required" json:"plan_id"` - Action string `v:"required|in:fav,unfav" json:"action"` - Note string `json:"note"` -} -``` - -- [ ] **Step 2: 编译检查** `go build ./...`(entity 引入路径) - -- [ ] **Step 3: Commit** `git add -A && git commit -m "feat: dto layer with route metadata"` - ---- - -### Task 5: 用户域(user controller + service) - -**Files:** -- Create: `styleagent/controller/user_controller.go` -- Create: `styleagent/service/user_service.go` -- Test: `styleagent/service/user_service_test.go` - -- [ ] **Step 1: 写失败测试** - -```go -// user_service_test.go -package service - -import ( - "context" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestLoginSuccess(t *testing.T) { - ctx := context.Background() - // 注册新用户 - userId, err := UserService.Register(ctx, "test_user_1", "password123") - assert.NoError(t, err) - assert.True(t, userId > 0) - - _, token, err := UserService.Login(ctx, "test_user_1", "password123") - assert.NoError(t, err) - assert.NotEmpty(t, token) -} - -func TestLoginWrongPassword(t *testing.T) { - ctx := context.Background() - _, _, err := UserService.Login(ctx, "test_user_1", "wrong") - assert.Error(t, err) -} -``` - -- [ ] **Step 2: 运行确认失败** `go test ./styleagent/service/ -run TestLogin -v` -Expected: FAIL(编译失败/未定义 UserService) - -- [ ] **Step 3: 实现 user_service.go**(复制 video-factory user_service.go 模式 + Register 方法,bcrypt 哈希密码,JWT 7 天;测试需要独立 DB —— 测试用 `test_slogan.db`,在 TestMain 中切换 g.DB 配置) - -- [ ] **Step 4: 实现 user_controller.go**(Login / ChangePassword / Profile 三个方法绑定 dto) - -- [ ] **Step 5: 运行确认通过** `go test ./styleagent/service/ -run TestLogin -v` → PASS - -- [ ] **Step 6: Commit** `git add -A && git commit -m "feat: user domain login/register"` - ---- - -### Task 6: 照片/衣橱/身形域(上传 + 列表 + 删除) - -**Files:** -- Create: `styleagent/controller/user_photo_controller.go`, `wardrobe_controller.go`, `body_measurement_controller.go` -- Create: `styleagent/service/user_photo_service.go`, `wardrobe_service.go`, `body_measurement_service.go` -- Create: `styleagent/service/file_storage.go`(文件保存封装:`SaveUploadedFile(file, subDir)` → `workspace/user_{id}/photos/xxx.jpg`,返回相对路径) - -- [ ] **Step 1: file_storage.go**(模式:video-factory character_service 的文件保存逻辑) - -```go -package service - -import ( - "fmt" - "os" - "path/filepath" - "time" - - "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/net/ghttp" -) - -// SaveUploadedFile 保存上传文件到 workspace/{subDir},返回 "workspace/{subDir}/{filename}" -func SaveUploadedFile(file *ghttp.UploadFile, subDir string) (string, error) { - dir := filepath.Join("workspace", subDir) - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - filename := fmt.Sprintf("%d_%s", time.Now().UnixNano(), file.Filename) - path := filepath.Join(dir, filename) - if err := file.Save(path); err != nil { - return "", err - } - return "/" + filepath.ToSlash(filepath.Join("workspace", subDir, filename)), nil -} -``` - -- [ ] **Step 2: 写失败测试**(user_photo:上传→列表→删除;wardrobe 同理;body:save→get 往返) - -- [ ] **Step 3: 实现三个 service**:upload 校验(单张 ≤10MB、jpg/png/webp 扩展名校验)→ SaveUploadedFile → dao.Insert;list 按 user_id;delete 校验归属(id + user_id 双条件)后删除文件 + 记录 - -- [ ] **Step 4: 实现三个 controller**:Upload 方法用 `r.GetUploadFile("file")` 获取文件(controller 直接拿 request 时用 `*ghttp.Request` 参数) - -```go -func (c *userPhoto) Upload(ctx context.Context, req *dto.UserPhotoUploadReq, r *ghttp.Request) (res *dto.UserPhotoUploadRes, err error) { - file := r.GetUploadFile("file") - userId := common.GetUserId(ctx) - url, err := service.UserPhotoService.Upload(ctx, userId, req.Type, file) - if err != nil { - return nil, err - } - return &dto.UserPhotoUploadRes{Id: url.Id}, nil -} -``` - -注意:GetUserId(ctx) 从 auth 中间件注入的 ctx 读取(auth_middleware.go 已有实现,按 video-factory 方式调用)。 - -- [ ] **Step 5: 测试通过** `go test ./styleagent/service/ -v` - -- [ ] **Step 6: Commit** `git add -A && git commit -m "feat: photo/wardrobe/body domains"` - ---- - -### Task 7: 化身域(v1 模板匹配 + build 任务) - -**Files:** -- Create: `styleagent/avatar/template_matcher.go` -- Create: `styleagent/avatar/glb_packer.go` -- Create: `styleagent/service/avatar_service.go` -- Create: `styleagent/controller/avatar_controller.go` -- Test: `styleagent/avatar/template_matcher_test.go` - -- [ ] **Step 1: 写失败测试**(template_matcher:给定模拟特征(肤色 1-5 + 身高 cm + 胖瘦 1-5)→ 返回 face_template_id/body_template_id 索引) - -```go -package avatar - -import "testing" - -func TestMatchTemplates(t *testing.T) { - f := &FaceFeature{SkinTone: 3, HeightCm: 175, Build: 3} - faceId, bodyId, skinIdx := MatchTemplates(f) - if faceId < 1 || faceId > 20 || bodyId < 1 || bodyId > 6 || skinIdx < 1 || skinIdx > 5 { - t.Fatalf("out of range: face=%d body=%d skin=%d", faceId, bodyId, skinIdx) - } -} -``` - -- [ ] **Step 2: 确认失败** `go test ./styleagent/avatar/ -v` - -- [ ] **Step 3: 实现 template_matcher.go** - -```go -package avatar - -// FaceFeature 从照片+用户填写提取的化身特征(v1 简化:照片仅做肤色采样,其余用户填写/默认) -type FaceFeature struct { - SkinTone int // 1-5 - HeightCm int - Build int // 1-5 瘦~胖 -} - -// 预烘焙模板库索引(构建期产物,运行时只读常量) -const ( - FaceTemplateCount = 20 - BodyTemplateCount = 6 - SkinToneLevels = 5 - DefaultFaceTemplate = 5 - DefaultBodyTemplate = 3 -) - -// MatchTemplates 特征 → 模板索引(v1 规则映射:肤色→皮肤档,身高+体型→身体模板,脸型由照片后续 AI 提取后替换) -func MatchTemplates(f *FaceFeature) (faceId, bodyId, skinIdx int) { - if f == nil { - return DefaultFaceTemplate, DefaultBodyTemplate, 3 - } - skinIdx = f.SkinTone - if skinIdx < 1 { skinIdx = 1 } - if skinIdx > SkinToneLevels { skinIdx = SkinToneLevels } - // 身体模板:身高 150-190 → 6 档 - bodyId = (f.HeightCm - 145) / 8 - if bodyId < 1 { bodyId = 1 } - if bodyId > BodyTemplateCount { bodyId = BodyTemplateCount } - // v1 脸型固定默认模板(AI 人脸特征提取后替换,见 spec v2) - faceId = DefaultFaceTemplate - return -} -``` - -- [ ] **Step 4: glb_packer.go**(v1:拼 URL —— `/workspace/templates/face_{id}.glb`、`body_{id}.glb`,组合 avatar GLB 记录;真实打包后续) - -- [ ] **Step 5: avatar_service.go**:Build(ctx, userId):检查照片齐备(大头照+至少1张全身)→ 读 body_measurement → MatchTemplates → 插入 avatar_model(build_status=pending)→ 异步 goroutine 执行 processing → done(v1 同步简化:直接 done + glb_url 用 packer 生成的路径);Get(ctx, userId) 返回最新 avatar_model - -- [ ] **Step 6: avatar_controller.go**:Build/Get 绑定 dto;Build 返回 task 语义(v1 直接返回 avatar 记录 id) - -- [ ] **Step 7: 测试通过** + `go build ./...` + **Commit** `git commit -m "feat: avatar domain with template matching"` - ---- - -### Task 8: 发型资产列表(静态 seed) - -**Files:** -- Create: `styleagent/controller/hairstyle_controller.go` -- Create: `styleagent/service/hairstyle_service.go` -- Modify: `styleagent/dao/hairstyle_asset_dao.go`(init 时 seed 8 个默认发型) - -- [ ] **Step 1: dao init seed**(插入 8 条:短发/中发/长发/卷发/寸头/马尾/丸子头/波浪卷,style_tag、glb_url=`/workspace/templates/hairstyle_{id}.glb`、sort) - -- [ ] **Step 2: 测试**:List 返回按 sort 排序的 8 条(dao 测试) - -- [ ] **Step 3: service + controller 绑定**,`go build ./...`,**Commit** - ---- - -### Task 9: 规则引擎评分(5 维度,零 LLM) - -**Files:** -- Create: `styleagent/scoring/rules.go`(ScoreContext + CandidateOutfit) -- Create: `styleagent/scoring/weather_rule.go` -- Create: `styleagent/scoring/occasion_rule.go` -- Create: `styleagent/scoring/color_rule.go` -- Create: `styleagent/scoring/completeness_rule.go` -- Create: `styleagent/scoring/style_rule.go` -- Create: `styleagent/scoring/engine.go`(总分聚合 + 阈值判定) -- Test: `styleagent/scoring/engine_test.go` - -- [ ] **Step 1: 写失败测试**(关键边界:冬季温度带外套得分高于无外套;色调和谐组合得分高于冲突组合;缺鞋减分;总分 ≥ 阈值判定通过) - -```go -package scoring - -import "testing" - -func TestWinterOuterwearBonus(t *testing.T) { - ctx := ScoreContext{ - TempAvg: 5, // 冬季 - Occasion: "通勤", - Weekday: "workday", - Wardrobe: []WardrobeItem{{Category: "上衣", ColorInfo: "#333333"}, {Category: "下装", ColorInfo: "#1a1a1a"}}, - } - withJacket := CandidateOutfit{Items: ctx.Wardrobe, HasOuterwear: true} - noJacket := CandidateOutfit{Items: ctx.Wardrobe, HasOuterwear: false} - if weatherScore(withJacket) <= weatherScore(noJacket) { - t.Fatal("winter should favor outerwear") - } -} -``` - -- [ ] **Step 2: 确认失败** `go test ./styleagent/scoring/ -v` - -- [ ] **Step 3: 实现 5 个规则文件**(均为纯函数,输入输出确定): - -```go -// rules.go 公共类型 -type WardrobeItem struct { - Category string // 上衣/下装/鞋/配饰 - Season string // 春/夏/秋/冬/四季 - ColorInfo string // 如 #RRGGBB 或 "黑/白/红" - StyleTags string -} -type CandidateOutfit struct { - Items []WardrobeItem - HasOuterwear bool -} -type ScoreContext struct { - TempAvg int // 平均温度℃ - Season string - Occasion string // 通勤/约会/聚会/运动 - Weekday string // workday/weekend/holiday - StyleTags []string // 用户偏好 -} - -// weather_rule.go 温度档位表 -func weatherScore(o CandidateOutfit, ctx ScoreContext) int { - // 25 分制:温度匹配每件服装 season 加 5 分;<10℃ 无外套扣 10 分;>30℃ 有外套扣 8 分 -} - -// occasion_rule.go 场合规则表 -func occasionScore(o CandidateOutfit, ctx ScoreContext) int { - // 25 分制:场合→类别规则(约会加分:正装/裙装;运动加分:运动服)基础分 15 + 匹配项各 5 -} - -// color_rule.go 色相环相似度 -func colorScore(o CandidateOutfit) int { - // 20 分制:同色系 20;邻近色 15;对比色 8;随机冲突 3 -} - -// completeness_rule.go -func completenessScore(o CandidateOutfit) int { - // 20 分制:上衣+5 下装+5 鞋+5 配饰+5 -} - -// style_rule.go 用户偏好 -func styleScore(o CandidateOutfit, ctx ScoreContext) int { - // 10 分制:命中用户 styleTags 每项 +2 -} - -// engine.go -func Score(c *CandidateOutfit, ctx *ScoreContext) int { - return weatherScore(*c, *ctx) + occasionScore(*c, *ctx) + colorScore(*c) + - completenessScore(*c) + styleScore(*c, *ctx) -} -func IsPass(score int, threshold int) bool { return score >= threshold } -``` - -- [ ] **Step 4: 测试通过**(含色相解析测试:`#ff0000` 与 `#ff6666` 同色系;`#ff0000` 与 `#00ff00` 对比色) - -- [ ] **Step 5: Commit** `git add -A && git commit -m "feat: rule-based scoring engine"` - ---- - -### Task 10: 天气适配(和风 + 高德 + 缓存) - -**Files:** -- Create: `styleagent/weather/qweather.go` -- Create: `styleagent/weather/geo.go` -- Create: `styleagent/weather/cache.go` -- Create: `styleagent/service/weather_service.go`(供 outfit service 调用) -- Test: `styleagent/weather/cache_test.go` - -- [ ] **Step 1: 写失败测试**(cache:get→miss→set→hit;TTL 过期) - -- [ ] **Step 2: 实现 cache.go**(内存 map + mutex,key=`{city}:{date}`,TTL 6h) - -- [ ] **Step 3: 实现 qweather.go**:`GetDaily(ctx, cityCode, startDate, endDate) ([]DayWeather, error)`,和风 `v7/weather/7d` 接口,Key 从 `config.yml` 的 `weather.qweather_key` 读取(空则返回 error 提示配置缺失) - -```go -type DayWeather struct { - Date string `json:"date"` - TempMax int `json:"temp_max"` - TempMin int `json:"temp_min"` - TextDay string `json:"text_day"` -} - -// 返回该日期范围内平均温度(用于评分)+ 每日天气 -func GetDaily(ctx context.Context, cityCode string, startDate, endDate string) (*WeatherResult, error) -``` - -- [ ] **Step 4: 实现 geo.go**:`GetCityCode(ctx, location) (string, error)` —— 高德地理编码 API,Key 从配置读;失败时降级:直接以 location 为 cityCode 缓存并返回默认天气(config 开启 mock 时) - -- [ ] **Step 5: weather_service.go**:封装 `GetWeather(ctx, location, startDate, endDate)` → 先查缓存 → 未命中调 API → 存缓存;测试用 mock API 响应(httptest server 或注入接口) - -- [ ] **Step 6: 测试通过** + **Commit** `git commit -m "feat: weather adapter with cache"` - ---- - -### Task 11: Agent(chat_model 复用 + 方案规划/兜底) - -**Files:** -- Copy: `styleagent/agent/chat_model.go`(从 video-factory 复制,改 import 路径) -- Copy: `styleagent/agent/types.go`(ChatRequest/ChatMessage/ChatResponse/ToolCall) -- Create: `styleagent/agent/outfit_agent.go`(规划 + 兜底两函数) -- Create: `styleagent/agent/output.go`(JSON Schema 校验) -- Create: `styleagent/agent/agent_config.go`(从 model_config 表读取 LLM 配置,未配置时返回错误) -- Test: `styleagent/agent/output_test.go` - -- [ ] **Step 1: 复制 chat_model.go + types.go**,改包路径,`go build ./...` 通过 - -- [ ] **Step 2: 写失败测试**(output 解析:合法 JSON 解析为 PlanOutput;缺字段报错;非法 JSON 报错) - -```go -// output.go 规划输出结构 -type PlanOutput struct { - Plans []PlanCandidate `json:"plans"` -} -type PlanCandidate struct { - Title string `json:"title"` - Hairstyle string `json:"hairstyle"` // 发型名称(匹配资产库) - HairColor string `json:"hair_color"` // 如 #A0522D - Items []PlanItemOut `json:"items"` -} -type PlanItemOut struct { - Slot string `json:"slot"` // 上衣/下装/鞋/配饰 - ItemId int64 `json:"item_id,omitempty"` // 衣橱条目(wardrobe 来源) - Name string `json:"name"` - Desc string `json:"desc"` - NewItem bool `json:"new_item"` // 是否为推荐新服装 -} -``` - -- [ ] **Step 3: 确认失败** `go test ./styleagent/agent/ -v` - -- [ ] **Step 4: 实现 output.go 校验**(json.Unmarshal + 必填字段检查:plans 非空、每套 items 至少 1 件) - -- [ ] **Step 5: 实现 outfit_agent.go**: - -```go -// PlanOutfits 规则预筛候选 → LLM 润色规划(1 次调用) -func PlanOutfits(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string, candidates []CandidateData) (*PlanOutput, error) - -// CreateRecommendPlan 兜底创作(全低分时调用,1 次调用) -func CreateRecommendPlan(ctx context.Context, cfg *ModelConfig, sysPrompt, userInput string) (*PlanOutput, error) -``` - -system prompt 要点(写入 agent/prompt.go 常量):角色是穿搭顾问;输出严格 JSON;天气/场合约束注入;仅输出 JSON 无额外文字。 - -- [ ] **Step 6: agent_config.go**:从 `model_config` 表(复用 video-factory 结构:system 配置 + 可覆盖)读取 base_url/api_key/model_name,用 gcache 缓存 60s;未配置返回明确错误。 - -- [ ] **Step 7: 测试通过**(output 校验单测;agent 调用用 httptest mock OpenAI 端点)+ **Commit** - ---- - -### Task 12: 穿搭生成编排(outfit service 核心) - -**Files:** -- Create: `styleagent/service/outfit_service.go`(Generate 编排 + 评分 + 兜底 + 落库) -- Test: `styleagent/service/outfit_service_test.go`(核心逻辑 mock:weather/agent 注入接口) - -- [ ] **Step 1: 写失败测试**(核心流程:衣橱 3 件 → 规则预筛 3 套 → 评分 → 全低分时触发兜底 → 落库 plan + items;高分时直接落库) - -- [ ] **Step 2: 确认失败** - -- [ ] **Step 3: 实现 outfit_service.go** - -```go -type outfitService struct{} -var OutfitService = new(outfitService) - -// Generate 创建生成任务并同步执行核心流程(v1 同步;异步任务表见 Task 13) -func (s *outfitService) Generate(ctx context.Context, userId int64, req *dto.OutfitGenerateReq) (int64, error) { - // 1. 校验衣橱非空(<3 件返回 "衣橱服装不足,请先添加至少 3 件服装") - // 2. 天气获取(weather_service) - // 3. 规则预筛:衣橱 × 季节温度 × 场合 → 3 套候选(组合算法:按 category 分组随机/轮询组合) - // 4. 创建任务记录(planning)→ Agent.PlanOutfits(1 次 LLM) - // 5. 规则评分每套 → 任务状态 scoring - // 6. 3 套全 < 阈值 → Agent.CreateRecommendPlan(1 次 LLM)→ 新套装标 recommend - // 7. 落库 outfit_plan(hairstyle_id 匹配资产库)+ plan_outfit_item(来源标注) - // 8. 任务 → done;返回 task_id -} -``` - -- [ ] **Step 4: 预筛组合算法**(outfit_combiner.go):按 category 将衣橱分组,按温度过滤 season,生成最多 3 个互不相同组合(确定性:按 id 排序轮询),每个组合带 HasOuterwear 标记 - -- [ ] **Step 5: 测试通过** + `go build ./...` + **Commit** - ---- - -### Task 13: 穿搭 controller + 异步任务表 - -**Files:** -- Create: `styleagent/controller/outfit_controller.go` -- Modify: `styleagent/service/outfit_service.go`(异步化:Generate 只建任务返回 task_id,worker goroutine 执行;GetTaskStatus / ListPlans / GetPlanDetail / SelectMain / Review) - -- [ ] **Step 1: 异步化改造**:Generate 插入任务(pending)→ 启动 goroutine 执行核心流程(含任务状态流转 pending→planning→scoring→done/failed + error 记录);`startWorker(ctx)` 守护恢复未完成任务(main.go 启动时调用,模式同 video-factory StartVideoPoller) - -- [ ] **Step 2: controller 绑定 6 个 dto 方法**(Generate/TaskStatus/PlanList/PlanDetail/SelectMain/Review) - -- [ ] **Step 3: GetPlanDetail**:查 plan + items + images + hairstyle 资产,组装 OutfitPlanDetailRes - -- [ ] **Step 4: SelectMain**:置 main_flag(事务:同 task 其他 plan 清零)+ 触发效果图任务(Task 14 后接通) - -- [ ] **Step 5: 编译 + 冒烟测试**(TestMain 起 gtest server:登录 → 上传 → generate → 轮询 → detail)**Commit** - ---- - -### Task 14: 效果图生成(ImageGenClient 接口 + wanx + mock + 缓存) - -**Files:** -- Create: `styleagent/imagegen/client.go`(接口 + Factory) -- Create: `styleagent/imagegen/wanx_client.go` -- Create: `styleagent/imagegen/mock_client.go` -- Create: `styleagent/imagegen/cache.go` -- Create: `styleagent/service/effect_image_service.go`(异步任务执行:选主方案后生成 3 视角) -- Test: `styleagent/imagegen/cache_test.go` + `mock_client_test.go` - -- [ ] **Step 1: 写失败测试**(cache:plan 内容 hash → 命中/未命中;mock client:调用返回固定 URL) - -- [ ] **Step 2: 实现 client.go** - -```go -type ImageGenClient interface { - // Generate 生成单张效果图,返回图片 URL - Generate(ctx context.Context, req *GenerateReq) (string, error) -} -type GenerateReq struct { - BaseImageURL string // 用户全身照 - Prompt string // 方案描述 - Angle string // 正面/侧面/背面 - Seed int64 -} -func NewClient(supplier string) ImageGenClient // wanx | mock(config 无 key 时强制 mock) -``` - -- [ ] **Step 3: mock_client.go**:返回 `/workspace/mock/effect_{angle}.png` 占位路径(不真实调用,开发联调用) - -- [ ] **Step 4: wanx_client.go**:通义万相人像写真类 API(`image-sync` 或异步轮询接口),Key/模型从 `imagegen_config` 表读;v1 实现为"调用 + 轮询结果"封装;错误降级 mock - -- [ ] **Step 5: effect_image_service.go**:SelectMain 后 goroutine:按 plan 内容 hash 查缓存 → 未命中调用 ImageGenClient 逐角度生成(3 张)→ 存 plan_effect_image + 任务 rendering→done;每日免费次数校验(user 维度,默认 3 次/天,scoring_rule 表配置) - -- [ ] **Step 6: 测试通过** + **Commit** - ---- - -### Task 14.5: 商业化基础(partner_store 列表 + seed) - -**Files:** -- Create: `styleagent/model/entity/partner_store.go`(id, name, type(1形象设计 2服装门店), lat, lng, address, commission_policy, status, created_at) -- Modify: `styleagent/consts/table_name.go`(+`TableNamePartnerStore = "slogan_partner_store"`) -- Create: `styleagent/dao/partner_store_dao.go`(建表 + init seed 4 条示例门店 + ListByType) -- Create: `styleagent/model/dto/partner_store_dto.go`(`StoreListReq` path `/list` + `StoreListRes{List []*entity.PartnerStore}`) -- Create: `styleagent/service/partner_store_service.go` -- Create: `styleagent/controller/partner_store_controller.go` -- Modify: `main.go`(注册 `controller.PartnerStore`) - -- [ ] **Step 1: entity + dao**(模式同 Task 3;seed:2 条形象设计 + 2 条服装门店,坐标覆盖城市) - -- [ ] **Step 2: dto + service + controller**(List 支持 `type` 筛选,0 返回全部) - -- [ ] **Step 3: `go build ./...` + 冒烟**(GET /partner-store/list 返回 seed 数据)+ **Commit** `git commit -m "feat: partner store domain"` - ---- - -### Task 15: 集成冒烟 + Dockerfile - -**Files:** -- Create: `Dockerfile`(复用 video-factory 多阶段构建模式) -- Create: `docs/项目文档.md`(服务端文档,模式同 video-factory 项目文档) -- Create: `docs/api.json` 导出(启动后 GoFrame OpenAPI) - -- [ ] **Step 1: Dockerfile**(golang:1.22 builder + alpine runtime,复制 video-factory Dockerfile 改端口) - -- [ ] **Step 2: 全链路冒烟**:`go run main.go` → curl 全流程: - 1. `POST /user/login`(注册后)→ token - 2. `POST /user-photo/upload`(-F file=@headshot.jpg -F type=1) - 3. `POST /wardrobe/upload` × 3 - 4. `POST /body-measurement/save` - 5. `POST /avatar/build` → get - 6. `POST /outfit/generate` → task status 轮询 → done - 7. `GET /outfit/plan/list` → detail - 8. `POST /outfit/plan/select-main` → effect images(mock 路径) - 9. `GET /hairstyle/list` - -- [ ] **Step 3: 验证响应格式统一** `{"code":0,"message":"OK","data":...}` - -- [ ] **Step 4: Commit** `git commit -m "feat: mvp complete with dockerfile and docs"` - ---- - -## Self-Review 备注(执行前已知项) - -- 测试 DB:`styleagent/service` 单测使用独立 sqlite 文件 `test_slogan.db`(TestMain 设置),避免污染开发库 -- GetUserId(ctx):确认 auth_middleware.go 注入的 key(复制 video-factory 后保持一致) -- 上传文件字段名统一 `file` -- 天气/LLM/图像 Key 全部从 config.yml / 配置表读取,代码不入 Key diff --git a/server/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md b/server/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md deleted file mode 100644 index e7528ee..0000000 --- a/server/docs/superpowers/specs/2026-07-31-commerce-monetization-design.md +++ /dev/null @@ -1,293 +0,0 @@ -# 商业化四支柱设计(后端)· slogan-agent - -> **目标:** 以「个人形象设计」为主题业务,落地四支柱收入:VIP 会员充值、穿山甲广告、线下门店引流(OTA 联盟)、线上商品(电商联盟 CPS)。 -> **核心原则:** 商业化从「方案/单品」长出,不做泛化场景广场。所有推荐由方案已有字段驱动,**零新增 LLM 调用**。 - -## 1. 总体架构 - -``` -App(slogan-app) - │ 会员中心/方案详情商业化入口/衣橱升级款/广告位 - ▼ -slogan-agent 新增模块 - ├─ 会员模块 member_plan / payment_order / user_member / pay_notify_log - ├─ 广告激励 ad_reward_log + 发放权益 - ├─ CPS 统一引擎 cps_category / cps_product / cps_click_log / scene_category_map - │ └─ 适配器:美团联盟(OTA 到店) / 京东联盟(电商) / 淘宝客(美妆配饰) - └─ 配置 config.yml(cps.payment.ad 配置段,Key 默认空 → 模块自动降级) - │ - ├─▶ 虎皮椒聚合支付(微信/支付宝收银台,iOS WebView) - ├─▶ 美团联盟 API(选品 + 转链,pid 归因) - ├─▶ 京东联盟 API(选品 + 转链) - └─▶ 淘宝客 API(选品 + 淘口令) -``` - -**模块降级原则**:与现有 `llm/weather/geo` 配置段同模式 —— 支付/CPS 相关 key 未配置时,接口返回明确错误信息(如"支付未开通,请在 config.yml 配置"),App 端隐藏对应入口,不影响主功能闭环。 - -## 2. 支柱 A:VIP 会员与聚合支付 - -### 2.1 支付服务商:虎皮棋(xunhupay) - -- 个人可开通、无营业执照门槛、微信+支付宝双通道、收银台 URL 模式(App WebView 打开) -- 下单:`POST /v1/payment`(RSA 签名请求);回调:`POST notify_url`(验签后解析) -- **签名/验签细节以官方最新文档为准**,实现时封装在 `payment/gateway.go` 适配器内,与业务解耦 -- 金额一律以「分」为单位存库,避免浮点误差 - -### 2.2 数据模型(dao init 自动建表,沿用 SQLite 规范) - -```sql -CREATE TABLE IF NOT EXISTS member_plan ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL DEFAULT '', - price_fen INTEGER NOT NULL DEFAULT 0, -- 金额(分) - duration_days INTEGER NOT NULL DEFAULT 30, -- 时长(天) - features TEXT NOT NULL DEFAULT '[]', -- 权益 JSON:["effect_unlimited","ai_priority","cps_commission_x15","store_discount"] - sort INTEGER NOT NULL DEFAULT 0, - status INTEGER NOT NULL DEFAULT 1, - created_at DATETIME DEFAULT (datetime('now','localtime')) -); - -CREATE TABLE IF NOT EXISTS payment_order ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_no TEXT NOT NULL UNIQUE, -- 业务订单号 - user_id INTEGER NOT NULL DEFAULT 0, - plan_id INTEGER NOT NULL DEFAULT 0, - amount_fen INTEGER NOT NULL DEFAULT 0, - channel TEXT NOT NULL DEFAULT '', -- alipay | wechat - status TEXT NOT NULL DEFAULT 'pending', -- pending | paid | closed - trade_no TEXT NOT NULL DEFAULT '', -- 第三方交易号 - notify_raw TEXT NOT NULL DEFAULT '', -- 回调原文(审计) - paid_at DATETIME, - created_at DATETIME DEFAULT (datetime('now','localtime')) -); -CREATE INDEX IF NOT EXISTS idx_payment_order_user ON payment_order(user_id, created_at); - -CREATE TABLE IF NOT EXISTS user_member ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL UNIQUE, - plan_id INTEGER NOT NULL DEFAULT 0, - expire_at DATETIME, - source TEXT NOT NULL DEFAULT 'vip_pay', -- vip_pay | ad_trial | gift - created_at DATETIME DEFAULT (datetime('now','localtime')), - updated_at DATETIME -); - -CREATE TABLE IF NOT EXISTS pay_notify_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_no TEXT NOT NULL DEFAULT '', - body TEXT NOT NULL DEFAULT '', - sign TEXT NOT NULL DEFAULT '', - remote_ip TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'ok', -- ok | bad_sign | duplicate | no_order - created_at DATETIME DEFAULT (datetime('now','localtime')) -); -``` - -### 2.3 接口(RouteRegister 2 参 handler,`common.GetUserId(g.RequestFromCtx(ctx))` 取用户) - -| 路径 | 方法 | 请求 | 响应 | 说明 | -|---|---|---|---|---| -| `/member/plan/list` | GET | - | `{list: [member_plan]}` | 上架套餐 | -| `/member/status` | GET | - | `{member: {...}, is_vip, expire_at}` | 我的会员状态 | -| `/member/order/create` | POST | `{plan_id}` | `{order_no, pay_url}` | 下单 → 虎皮棋收银台 URL | -| `/member/order/notify` | POST | 表单回调 | `"success"` | **publicPaths 放行**;验签 → 幂等 → 订单 paid → 开通/续期会员 | -| `/member/order/status` | GET | `{order_no}` | `{status}` | App 轮询 | - -**支付时序**: -``` -App → POST /member/order/create → 后端生成订单 + 调虎皮棋下单 → 返回 pay_url -App → WebView 打开 pay_url(用户完成支付) -虎皮棋 → POST /member/order/notify(RSA 验签) -后端 → 幂等校验(order_no 状态机 pending→paid,重复回调忽略并记 pay_notify_log) -后端 → 更新 user_member(续费:expire_at 在原有效期上叠加,min 逻辑;过期则从现在起算) -App → GET /member/order/status 轮询(间隔 2s,超时 60s)→ 展示开通成功 -``` - -**幂等与安全**:回调必须验签(失败记 `bad_sign` 并返回非 success);`order_no` 唯一 + 状态机保证只开通一次;回调日志全量入库审计;退款 MVP 阶段客服手动处理(标记 order closed + 人工延退会员)。 - -## 3. 支柱 B:广告激励 - -### 3.1 数据模型 - -```sql -CREATE TABLE IF NOT EXISTS ad_reward_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 0, - ad_type TEXT NOT NULL DEFAULT '', -- effect_extra(效果图+1) | vip_trial(体验会员1天) - reward_key TEXT NOT NULL DEFAULT '', -- "2026-07-31:effect_extra" 自然日去重粒度 - status TEXT NOT NULL DEFAULT 'ok', - created_at DATETIME DEFAULT (datetime('now','localtime')) -); -CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON ad_reward_log(user_id, reward_key); -``` - -### 3.2 接口 - -| 路径 | 方法 | 请求 | 响应 | 说明 | -|---|---|---|---|---| -| `/ad/reward/claim` | POST | `{ad_type}` | `{reward: {...}}` | 发放权益(限频见下) | - -**风控**(防刷,纯服务端计数,不信任客户端): -- `ad_type=effect_extra`:每日每用户限 **2 次**(`reward_key` 唯一索引 + 计数),发放后效果图当日额外 +1 次 -- `ad_type=vip_trial`:每日每用户限 **1 次**,发放 1 天体验会员(写 user_member,source=ad_trial,到期自动失效) -- 效果图限额判定逻辑改造:`EffectImageService.GenerateForPlan` 的 `CountByUserToday` 判断改为 `当日已用 ≤ 基础额度(3) + 额外次数(ad_reward_log 当日 count)`;额外次数次日归零(不落独立表,按日查询即可) - -## 4. 支柱 C/D:统一 CPS 引擎 - -### 4.1 核心抽象 - -```go -// cps/provider.go —— 数据源适配器接口(包级单例:cps.Providers 注册表) -type Provider interface { - Source() string // meituan_ota | jd_ecom | tb_ecom - SyncProducts(ctx, city string, catCode string) ([]CpsProduct, error) // 定时选品池同步 - Search(ctx, keyword string, catCode string, page int) ([]CpsProduct, error) // 实时搜索兜底 - GetLink(ctx, outerId string) (string, error) // 转链(带 pid),结果按 outerId 缓存 24h -} -``` - -- 统一 `cps_product` 选品池:联盟商品定时同步入库,列表读库(不实时调联盟);搜索接口实时兜底 -- 转链结果缓存(与 imagegen cache 同模式),点击时写 `cps_click_log` -- 未配置某联盟 key → 该 source 降级(列表为空 + App 隐藏入口) - -### 4.2 数据模型 - -```sql -CREATE TABLE IF NOT EXISTS cps_category ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - code TEXT NOT NULL UNIQUE, -- haircut / clothing / beauty / food / hotel / ticket / transport / digital ... - name TEXT NOT NULL DEFAULT '', - parent_code TEXT NOT NULL DEFAULT '', - source TEXT NOT NULL DEFAULT '', -- meituan_ota / jd_ecom / tb_ecom - source_cat_id TEXT NOT NULL DEFAULT '', -- 联盟侧类目 ID - sort INTEGER NOT NULL DEFAULT 0 -); - -CREATE TABLE IF NOT EXISTS cps_product ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - source TEXT NOT NULL DEFAULT '', - outer_id TEXT NOT NULL DEFAULT '', -- 联盟商品 ID - category_code TEXT NOT NULL DEFAULT '', - name TEXT NOT NULL DEFAULT '', - cover_url TEXT NOT NULL DEFAULT '', - price_fen INTEGER NOT NULL DEFAULT 0, - shop_name TEXT NOT NULL DEFAULT '', - commission_rate INTEGER NOT NULL DEFAULT 0, -- 万分比 - city TEXT NOT NULL DEFAULT '', -- OTA 到店类目按城市 - scene_tags TEXT NOT NULL DEFAULT '[]', -- 场合标签 ["通勤","约会","旅行"] - raw TEXT NOT NULL DEFAULT '', -- 联盟原始数据 JSON - status INTEGER NOT NULL DEFAULT 1, - sync_at DATETIME, - created_at DATETIME DEFAULT (datetime('now','localtime')) -); -CREATE INDEX IF NOT EXISTS idx_cps_product_cat ON cps_product(source, category_code, status); - -CREATE TABLE IF NOT EXISTS cps_click_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL DEFAULT 0, - source TEXT NOT NULL DEFAULT '', - outer_id TEXT NOT NULL DEFAULT '', - scene TEXT NOT NULL DEFAULT '', -- plan_haircut / plan_item / plan_occasion / wardrobe_upgrade / member_benefit - plan_id INTEGER NOT NULL DEFAULT 0, - category_code TEXT NOT NULL DEFAULT '', - deeplink TEXT NOT NULL DEFAULT '', - ip TEXT NOT NULL DEFAULT '', - created_at DATETIME DEFAULT (datetime('now','localtime')) -); -CREATE INDEX IF NOT EXISTS idx_cps_click_user ON cps_click_log(user_id, created_at); - -CREATE TABLE IF NOT EXISTS scene_category_map ( -- 方案字段 → 联盟类目映射(零 LLM 推荐核心) - id INTEGER PRIMARY KEY AUTOINCREMENT, - scene_type TEXT NOT NULL DEFAULT '', -- haircut / item_buy / item_upgrade / occasion - occasion TEXT NOT NULL DEFAULT '', -- 通勤/约会/旅行/运动/商务(occasion 场景) - source TEXT NOT NULL DEFAULT '', - category_code TEXT NOT NULL DEFAULT '', - priority INTEGER NOT NULL DEFAULT 0 -); -``` - -### 4.3 方案驱动推荐(核心:用已有方案字段,零新增 LLM 调用) - -| 入口 | 方案字段 | 映射 | 推荐内容 | -|---|---|---|---| -| 发型卡「做同款发型」 | 发型名 + 城市 | scene_type=haircut → 丽人类目 | 理发/造型店联盟券(美团) | -| 穿衣清单「买同款」 | 单品 name/Desc | 京东联盟搜索关键词 | 电商同款卡片 | -| 穿衣清单「到店试穿」 | 单品风格 tags + 城市 | scene_type=item_upgrade → 服装类目 | 服装店联盟券(美团) | -| 场合卡「延伸优惠」 | occasion + 地点 | scene_type=occasion 映射表 | 约会→餐厅+丽人;旅行→酒店/车票/当地丽人 | -| 衣橱「找升级款」 | 旧款 category + style_tags | 京东搜索相似款 | 电商升级款 | - -### 4.4 接口 - -| 路径 | 方法 | 请求 | 响应 | 说明 | -|---|---|---|---|---| -| `/cps/category/list` | GET | - | `{list: [cps_category]}` | 统一分类树 | -| `/cps/product/list` | GET | `{source, category_code, city, page}` | `{list, has_more}` | 选品池分页 | -| `/cps/product/link` | POST | `{product_id, scene, plan_id}` | `{deeplink}` | 转链(缓存 24h)+ 记点击日志 | -| `/cps/plan/recommend` | GET | `{plan_id, scene}` | `{list: [推荐项]}` | 方案驱动推荐(发型卡/单品/场合) | -| `/cps/wardrobe/upgrade` | GET | `{item_id}` | `{list}` | 衣橱旧款升级款 | -| `/cps/my/recent` | GET | - | `{list: [点击记录]}` | 我的优惠记录(含返现状态占位) | - -**归因**:转链 URL 内嵌联盟 pid(下单时由适配器生成),联盟侧自动归因;`cps_click_log` 用于转化分析,结算数据以联盟后台为准。 - -## 5. 会员权益实现 - -- `effect_unlimited`:效果图限额判定跳过(`EffectImageService` 加 `IsVip(userId)` 查询) -- `ai_priority`:`outfit_service.Generate` 任务插入优先级字段(MVP 可用简单 FIFO + vip 优先标记,或仅权益展示占位) -- `cps_commission_x15`:VIP 购买 CPS 佣金 ×1.5 —— 结算在联盟后台,**MVP 仅权益展示**(文案"返现加成 1.5x"),真实返现二期(需联盟侧对账) -- `store_discount`:品牌合作门店(partner_store)展示"会员价"标识,到店出示会员状态(App 会员码页),自营核销二期 - -## 6. 配置(config.yml 新增段,Key 默认空) - -```yaml -payment: - xunhu_appid: "" - xunhu_appsecret: "" - notify_url: "http://<公网>/member/order/notify" # 回调需公网可达 - channel: "alipay,wechat" - -ad: - limit_effect_extra: 2 # 每日激励视频次数(效果图) - limit_vip_trial: 1 - -cps: - meituan_appkey: "" - meituan_pid: "" - meituan_shop_id: "" - jd_appkey: "" - jd_secret: "" - jd_pid: "" - tb_appkey: "" - tb_secret: "" - tb_pid: "" - sync_cron: "0 4 * * *" # 选品池定时同步 -``` - -## 7. 合规与风控 - -- **支付**:金额单位分;回调幂等 + 验签;`pay_notify_log` 全量审计;退款人工处理(记录到订单) -- **iOS 合规**:iOS 端 WebView 支付为国内惯例做法,需在 App Store 审核时注意(虚拟商品 IAP 政策风险,上线策略:iOS 端主推激励广告+门店引流,充值入口弱化或按要求接 IAP) -- **广告**:隐私政策披露第三方 SDK 收集信息;提供个性化广告关闭入口(穿山甲 SDK 提供) -- **CPS**:各联盟 API 需个人/企业账号申请(美团联盟、京东联盟、淘宝客均可个人申请);跳转链接遵守联盟推广规范(不得截流/改链接);禁用敏感类目(医疗、成人等) -- **激励防刷**:`ad_reward_log` 唯一索引 + 自然日限频;异常用户(同设备多账号)风控日志记录 - -## 8. 分期实施与成本 - -| 分期 | 内容 | 后端工作量 | 依赖 | -|---|---|---|---| -| **P0** | 会员全链路(4 表 + 5 接口 + 虎皮棋适配器 + 回调验签)+ 广告激励(1 表 + 1 接口 + 效果图限额改造) | ~2 人日 | 虎皮棋账号 | -| **P1** | CPS 引擎(4 表 + 6 接口 + 美团适配器 + 方案驱动推荐)+ 转链缓存 + 点击日志 | ~2.5 人日 | 美团联盟账号 | -| **P2** | 京东/淘宝适配器 + 会员返现加成 + 收益看板 + 风控报表 | ~2 人日 | 京东/淘宝联盟账号 | - -- **服务器成本**:零新增基础设施(SQLite 表均小体量,选品池定时同步 + 转链缓存) -- **模型成本**:零新增 LLM 调用(类目映射 + 关键词匹配) -- **维护成本**:联盟 API 变更由适配器隔离;第三方故障 → 接口降级返回错误,App 隐藏入口 - -## 9. 开发规范约束(沿用 video-factory 规范) - -- Controller→Service→DAO 三层,包级单例(`var MemberService = new(memberService)`) -- RouteRegister 反射路由,**handler 必须 2 参** `func(ctx context.Context, req *BizReq) (*BizRes, error)`;struct 名 kebab-case(`member_plan` → `/member/plan`) -- 用户 ID 一律 `common.GetUserId(g.RequestFromCtx(ctx))` -- 每表一 DAO(`dao/member_plan_dao.go` 等),`init()` 内 `CREATE TABLE IF NOT EXISTS` + seed -- 统一响应 `{"code":0,"message":"OK","data":...}`;`/member/order/notify` 加入 publicPaths -- 外部服务(支付/联盟)全部走包内适配器(`payment/`、`cps/`),业务层不直接感知 -- 配置默认空 → 降级不 panic(与 llm/weather 同模式) diff --git a/server/docs/superpowers/specs/2026-07-31-slogan-agent-design.md b/server/docs/superpowers/specs/2026-07-31-slogan-agent-design.md deleted file mode 100644 index 92031dc..0000000 --- a/server/docs/superpowers/specs/2026-07-31-slogan-agent-design.md +++ /dev/null @@ -1,301 +0,0 @@ -# slogan-agent 服务端设计方案 - -> 日期:2026-07-31 -> 关联:slogan-app 设计方案(App 端)见 slogan-app 仓库对应文档 - -## 1. 项目概述 - -slogan 是一个"人形象设计"应用:用户上传大头照和全身多角度照片、维护个人服装资产(衣橱),指定日期范围和地点后一键生成最适合的穿搭方案(含发型、发色、服装穿搭),方案以 3D 化身 + 2D 效果图双形态呈现。 - -本仓库为服务端(slogan-agent),提供:用户/照片/衣橱/身形管理、3D 化身构建、穿搭方案生成(规则评分 + Agent)、效果图生成、天气服务、商业化渠道(CPS 电商/门店导流/订阅)。 - -## 2. 开发规范约束(严格遵守 video-factory) - -本服务端**架构与代码规范严格遵守** `/Users/zhangbin/Desktop/d盘/work/video-factory/video-factory` 的既有规范: - -| 规范点 | 约束 | -|--------|------| -| 技术栈 | Go 1.22+ / GoFrame v2 (github.com/gogf/gf/v2) / SQLite(GoFrame ORM 驱动) | -| 认证 | JWT (golang-jwt/jwt/v5),`/user/login` 公开,其余全部经 auth 中间件,7 天过期,bcrypt 密码 | -| 分层 | Controller → Service → DAO → SQLite;每一层独立包,包级变量单例(`var XxxService = new(xxxService)`) | -| 路由 | `RouteRegister`(common/http/http.go)反射注册,kebab-case 前缀,如 `/outfit/generate` | -| 响应 | 统一 JSON `{"code":0,"message":"OK","data":...}` | -| DAO | 每张表一个 DAO,`init()` 自动建表 + ALTER TABLE 兼容迁移 | -| 模型 | `model/entity/`(表实体)+ `model/dto/`(请求响应,含 g.Meta 路由)+ `model/domain/` | -| Agent | 复用 video-factory ReAct 引擎模式:chat_model.go(OpenAI 兼容 API,指数退避重试)+ react_agent.go + tools.go + context.go | -| 模型配置 | 系统配置 + 用户配置 → MergedModelConfig(复用 model_config / user_model_config 表模式) | -| 异步任务 | 生成任务表 + 后台轮询(复用 GenerationService.StartPoller 模式,15s 间隔) | -| 文件存储 | `workspace/` 目录 + JWT 鉴权静态文件服务(BindHandler 方式,防路径穿越) | -| 参数校验 | gvalid(main.go 注册自定义规则) | -| 部署 | 单体服务,Docker(复用 video-factory Dockerfile 模式),端口 3006 规则下自定 | - -**新增加固规则**(本项目的领域约束): -- 所有涉及 LLM / 图像生成的调用必须经过"供应商适配层"(chat_model / imagegen),禁止业务代码直连第三方 SDK -- 所有外部 API(人脸/天气/地理编码)必须封装为 service 层适配器,Key 存配置表不入代码 -- 费用敏感:LLM/图像调用全部走任务表异步化 + 缓存,禁止同步阻塞式出图 - -## 3. 总体架构 - -``` -Flutter App (slogan-app) - │ HTTPS + JWT - ▼ -slogan-agent (Go 单体) -├── controller → service → dao → SQLite -├── avatar/ 3D 化身管线(预烘焙模板匹配 + 贴图合成 + GLB 输出) -├── scoring/ 规则引擎评分(零 LLM 成本) -├── agent/ 轻量 Agent(方案规划 / 兜底创作) -├── imagegen/ 效果图客户端(多供应商适配 + 缓存) -├── weather/ 天气适配(和风天气 + 缓存) -├── commercial/ CPS 商品 / 门店 / 导流 / 订阅 -├── assets/avatar-templates/ 预烘焙模板库(构建期产物,运行时只读) -└── workspace/ 用户照片 / GLB / 效果图 -``` - -## 4. 项目结构 - -``` -slogan-agent/ -├── main.go # 入口:RouteRegister + workspace 鉴权文件服务 + 后台轮询 -├── common/ # 复用 video-factory(auth / cache / http / base_dao) -├── styleagent/ # 业务模块(对应 shortdrama) -│ ├── controller/ # user / user-photo / wardrobe / body-measurement / -│ │ # avatar / outfit / hairstyle / product-recommend / -│ │ # partner-store / store-lead / subscription -│ ├── service/ # 对应业务逻辑(每域一个) -│ ├── dao/ # 每表一个 -│ ├── model/ -│ │ ├── entity/ # 表实体 -│ │ ├── dto/ # 请求/响应 + g.Meta 路由 -│ │ └── domain/ -│ │ ├── outfit_plan.go # 方案领域模型 + JSON 解析校验 -│ │ └── avatar_profile.go # 化身参数配置 -│ ├── avatar/ # 3D 化身管线 -│ │ ├── template_matcher.go # 特征 → 模板匹配 -│ │ ├── texture_composer.go # 面部照片贴图合成 -│ │ ├── glb_packer.go # 头部/身体/发型 GLB 组合打包 -│ │ └── template_builder/ # 构建期烘焙脚本(MakeHuman/MPFB+Blender,CI 运行,不入运行时) -│ ├── scoring/ # 规则引擎评分 -│ │ ├── rules.go # 规则定义与配置加载 -│ │ ├── weather_rule.go # 天气适宜度 -│ │ ├── occasion_rule.go # 场合匹配 -│ │ ├── color_rule.go # 色彩和谐 -│ │ └── completeness_rule.go # 层次完整度 -│ ├── agent/ # 轻量 Agent -│ │ ├── chat_model.go # OpenAI 兼容调用(含重试/限流,复用模式) -│ │ ├── outfit_agent.go # 方案规划 / 兜底创作 -│ │ ├── tools.go # get_weather / list_wardrobe / score_outfit / create_plan -│ │ └── output.go # 输出 JSON Schema 校验 -│ ├── imagegen/ -│ │ ├── client.go # ImageGenClient 接口 -│ │ ├── wanx_client.go # 通义万相 -│ │ ├── jimeng_client.go # 即梦 -│ │ └── cache.go # 按快照 hash 缓存 -│ ├── weather/ -│ │ ├── qweather.go # 和风天气适配 -│ │ └── geo.go # 地点 → 城市编码(高德) -│ ├── commercial/ -│ │ ├── cps.go # CPS 商品检索 -│ │ ├── store.go # 合作门店 LBS -│ │ └── subscription.go # 订阅权益 -│ └── consts/ -│ ├── public/table_name.go # 表名常量 -│ ├── public/content_type.go # 照片类型/方案来源/任务状态 -│ └── status.go # 任务状态常量 -├── assets/avatar-templates/ # 预烘焙模板(20 头部 GLB + 6 身体 GLB + 5 档皮肤贴图 + 发型 GLB) -└── workspace/ # 用户数据(照片/GLB/效果图) -``` - -## 5. 数据库设计(每表一个 DAO/Service/Controller) - -### 用户域 - -| 表 | 字段要点 | 说明 | -|----|---------|------| -| `user` | 复用 video-factory 用户模型(role 扩展:user) | 账号密码登录 v1,手机号绑定留扩展 | -| `user_photo` | id / user_id / type(1大头照 2全身正面 3全身侧面 4全身背面) / url / status | 3D 构建用原图 | -| `wardrobe_item` | id / user_id / photo_url / category(上衣/下装/鞋/配饰) / season / style_tags / color_info / status | 服装资产 | -| `body_measurement` | id / user_id / height / weight / skin_tone / fit_params(JSON) | 用户填写 + 照片估算合并 | - -### 化身域 - -| 表 | 字段要点 | 说明 | -|----|---------|------| -| `avatar_model` | id / user_id / face_template_id / body_template_id / skin_tone_index / face_texture_url / glb_url / build_status / params_snapshot(JSON) | 3D 化身 | -| `hairstyle_asset` | id / name / style_tag / glb_url / thumb_url / applicable_face / sort | 发型资产库(静态维护) | -| `outfit_asset` | id / name / style_tag / season / glb_url / cc0_source | 服装简模资产库(少量 CC0) | - -### 生成域 - -| 表 | 字段要点 | 说明 | -|----|---------|------| -| `outfit_generation_task` | id / user_id / start_date / end_date / location / weather_snapshot(JSON) / status(planning→scored→rendering→done/failed) / model_name / error | 生成任务(轮询) | -| `outfit_plan` | id / task_id / user_id / date_range / location / source(wardrobe/recommend) / score / main_flag / hairstyle_id / hair_color / weather_ref(JSON) | 穿搭方案 | -| `plan_outfit_item` | id / plan_id / slot(发型/上衣/下装/鞋/配饰) / source(wardrobe/recommend) / wardrobe_item_id(可空) / product_recommend_id(可空) / name / desc | 方案条目 | -| `plan_effect_image` | id / plan_id / angle(正面/侧面/背面) / url / status / prompt_snapshot | 2D 效果图 | -| `plan_review` | id / plan_id / user_id / action(fav/unfav) / note | 用户反馈 → 回流 Agent | - -### 商业域 - -| 表 | 字段要点 | 说明 | -|----|---------|------| -| `product_recommend` | id / plan_id(可空,全局备选) / product_name / channel(淘宝/京东/抖音/拼多多) / cps_url / price / commission_rate / image_url / status | CPS 商品 | -| `partner_store` | id / name / type(1形象设计 2服装门店) / lat / lng / address / commission_policy(JSON) / status | 合作门店 | -| `store_lead` | id / user_id / plan_id / store_id / status(created→visited→settled/cancelled) / create_time | 导流订单 | -| `subscription` | id / user_id / plan_type(standard/pro) / start_time / end_time / status | 会员订阅 | -| `model_config` / `user_model_config` | 复用 video-factory 表结构 | 模型配置 | -| `imagegen_config` | id / supplier / api_key / model_name / price_tier / enabled | 图像生成供应商配置 | -| `scoring_rule` | id / dimension / rule_type / rules_json / enabled / version | 评分规则配置(第 7 节),内置默认值 + 可配置 | - -## 6. 3D 化身管线(预烘焙模板 + 运行时匹配) - -### 核心理念 - -所有"昂贵且不稳定"的环节在**构建期**完成;运行时只做轻量匹配与合成,服务器成本趋近于零。 - -### 构建期(CI 或发布流水线,一次性执行) - -1. MakeHuman(CC0 资产,官方导出可商用)生成参数化角色基底 -2. MPFB + Blender headless 脚本烘焙: - - 20 个头部 GLB(脸型差异,PBR 材质) - - 6 个身体 GLB(体型差异:身高×胖瘦组合) - - 5 档皮肤贴图(肤色深浅) - - 10-15 个发型 GLB(CC0/自建,含发色可调材质) -3. glTF-Transform 压缩优化,产物提交 `assets/avatar-templates/` - -### 运行时(用户触发 build) - -``` -用户照片(大头照+全身) + 身形参数 - → ① 特征提取:国内人脸 API(腾讯/阿里,免费额度)→ 脸型/五官特征向量 - → ② 模板匹配:特征向量 → 最近脸型模板(余弦距离,阈值外降级到用户滑杆微调) - → ③ 贴图合成:大头照人脸区域 → 面部贴图(对齐模板 UV,肤色按色阶匹配 5 档) - → ④ 打包:组合 头部模板 + 身体模板 + 皮肤贴图 → avatar GLB(头部/身体/发型分离存储,App 端组合换装) - → ⑤ 保存 avatar_model 记录(build 任务异步,状态机 pending→processing→done/failed) -``` - -### v1 边界声明 - -- 化身定位"高相似度虚拟形象"(脸型/肤色/身形贴近),非照片级真人重建 -- 发型为资产库切换,不做 AI 重建用户真实发型 -- 用户可在 App 端用滑杆微调身形/肤色(参数化信息与照片估算合并),滑杆调整即时反映在 GLB 缩放参数上(运行时零渲染成本) - -## 7. 规则引擎评分(零 LLM 成本) - -每个候选方案多维度打分,总分 100: - -| 维度 | 权重 | 规则来源 | -|------|------|---------| -| 天气适宜度 | 25 | 温度区间 × 服装厚度匹配表(如 <10°C 需外套;25-32°C 短袖) | -| 场合匹配 | 25 | 日期类型(工作日/周末/节假日)→ 场合(通勤/约会/聚会)→ 服装类别规则表 | -| 色彩和谐 | 20 | 色相环配色表(同类色/邻近色/对比色得分) | -| 层次完整度 | 20 | 上衣/下装/鞋/配饰齐全度 + 可穿性(衣橱库存覆盖) | -| 风格一致性 | 10 | 服装 style_tags 与用户画像(历史收藏偏好)匹配度 | - -- 规则表配置存库(`scoring_rule` 可配置,后台可调,v1 内置默认值常量 + 配置表扩展) -- 阈值 75 分可配置 -- 全部低于阈值 → 判定"无合格衣橱方案",触发 Agent 兜底创作 -- 免费用户效果图次数:每日 N 次(默认 3 次,配置可调);pro 订阅不限 - -## 8. 穿搭生成流程(Agent + 评分 + 兜底) - -``` -POST /outfit/generate {start_date, end_date, location} - → ① 天气获取(和风 API,按 城市+日期 缓存 6h;地点经高德地理编码) - → ② 规则引擎预筛:衣橱 × 天气 × 场合 → 3 套候选组合(零 LLM) - → ③ Agent 规划(1 次 LLM 调用): - │ 工具:get_weather / list_wardrobe / score_outfit(规则引擎) / create_plan - │ 输出:3 套方案结构化 JSON(每套含发型建议/发色/服装条目) - → ④ 规则评分:≥75 → source=wardrobe;3 套全 <75 → LLM 兜底创作(1 次调用): - │ 输入:用户画像 + 天气 + 场合 + 衣橱摘要 - │ 输出:高分方案 JSON(含 1-3 件新服装推荐,带品类/风格/价格带) - │ 方案标记 source=recommend,新服装关联 CPS 商品检索 - → ⑤ 保存方案(outfit_plan + plan_outfit_item),任务状态 → done - → ⑥ App 端 3D 即时呈现 3 套方案(无额外成本);用户选定主方案后: - → ⑦ 效果图按需生成(见下节),缓存命中则免费 -``` - -**成本控制**: -- 每次生成 LLM 调用 ≤ 2 次(规划 + 兜底,兜底仅全低分时触发) -- 评分 100% 规则引擎 -- 工具调用控制在 3-5 次内(ReAct 最大步数 8) - -## 9. 效果图生成(按需 + 缓存 + 多供应商) - -- 供应商适配器:`ImageGenClient` 接口,实现 通义万相(人像写真类 API)/ 即梦,配置表切换 -- 输入:用户全身照 + 方案条目描述 + 人像一致性参数 + 视角(正面/侧面/背面) -- 触发:用户选定主方案后自动生成 3 视角;其余方案需用户主动请求(免费次数内/订阅权益检查) -- 缓存:key = md5(user_id + wardrobe_snapshot + plan_content),命中直接返回已生成图 -- 异步:任务表 + 轮询(复用 StartPoller 模式) -- 失败重试 1 次,仍失败则标记 failed 并降级提示(3D 方案仍可用) - -## 10. 商业化模块 - -| 渠道 | 实现 | -|------|------| -| 服装电商 CPS | `product_recommend` 表;兜底方案新服装检索 CPS 商品(淘宝联盟/京东联盟/抖音电商),App 端展示跳转,按成交佣金分成 | -| 形象设计门店 | `partner_store` type=1;发型/造型方案 LBS 推荐附近合作店(理发/造型师),`store_lead` 导流 + 到店核销 | -| 服装门店渠道 | `partner_store` type=2;本地服装门店展示 + 方案一键到店 | -| 会员订阅 | `subscription`:standard(免费基础)/ pro(无限生成/高清效果图/方案全量效果图解锁) | - -## 11. API 路由表(所有请求 JWT 鉴权,除 /user/login) - -| 方法 | 路径 | 说明 | -|------|------|------| -| POST | `/user/login` | 登录(公开) | -| POST | `/user/change-password` | 修改密码 | -| GET | `/user/profile` | 个人资料 | -| POST | `/user-photo/upload` | 上传照片(type:大头照/全身正面/侧面/背面) | -| GET | `/user-photo/list` | 照片列表 | -| POST | `/user-photo/delete` | 删除照片 | -| POST | `/wardrobe/upload` | 上传服装(分类/季节/风格标签) | -| GET | `/wardrobe/list` | 衣橱列表 | -| POST | `/wardrobe/update` | 更新服装信息 | -| POST | `/wardrobe/delete` | 删除服装 | -| POST | `/body-measurement/save` | 保存身形参数 | -| GET | `/body-measurement/get` | 获取身形参数 | -| POST | `/avatar/build` | 触发化身构建任务 | -| GET | `/avatar/get` | 化身信息(GLB 地址/状态) | -| POST | `/avatar/rebuild` | 重新构建化身 | -| GET | `/hairstyle/list` | 发型资产列表 | -| POST | `/outfit/generate` | 生成穿搭方案(日期范围+地点) | -| GET | `/outfit/task/status` | 生成任务状态轮询 | -| GET | `/outfit/plan/list` | 方案列表(历史) | -| GET | `/outfit/plan/detail` | 方案详情(3D 配置 + 条目 + 商品/门店) | -| POST | `/outfit/plan/select-main` | 选定主方案(触发效果图生成) | -| POST | `/outfit/plan/effect-image/generate` | 补生成某方案效果图(权益检查) | -| POST | `/outfit/plan/review` | 方案反馈(收藏/点赞/备注) | -| GET | `/product-recommend/list` | 方案关联 CPS 商品 | -| GET | `/partner-store/list` | 附近合作门店(lat/lng) | -| POST | `/store-lead/create` | 创建导流订单 | -| POST | `/store-lead/confirm` | 到店核销 | -| POST | `/subscription/create` | 创建订阅 | -| GET | `/subscription/status` | 订阅状态 | - -## 12. 错误处理与异步任务 - -- 任务状态机:`pending → processing → done / failed`,失败写 `error` 字段,App 轮询展示 -- 外部 API(人脸/天气/LLM/图像)统一超时与指数退避重试;Key 失效/欠费返回明确错误码 -- 图片上传限制:单张 ≤ 10MB,格式 jpg/png/webp,服务端校验 + 压缩(宽边 ≤ 2048) -- 路径安全:workspace 文件服务防 `..` 穿越(复用 video-factory BindHandler 实现) - -## 13. 测试策略 - -- DAO/Service:表驱动单测(SQLite 内存库),覆盖评分规则各维度边界(温度档位/色彩组合/阈值判定) -- Agent:输出 JSON Schema 校验测试 + 工具 mock(chat_model 接口化) -- 化身管线:模板匹配单元测试(特征向量 → 模板索引)+ 贴图合成冒烟 -- Controller:路由注册冒烟 + 鉴权中间件测试 -- 关键流程集成测试:generate → 评分 → 兜底 → 出图(全 mock 外部 API) - -## 14. 成本估算与部署(初期 1 万次生成/月) - -| 项目 | 月成本 | 说明 | -|------|--------|------| -| 服务器 | ~¥150 | 2C4G 轻量云,Docker 部署单体 | -| LLM | ~¥1000 | DeepSeek/Qwen,~¥0.1/次(≤2 次调用 + 工具) | -| 图像生成 | ~¥7000 | 主方案 3 视角 ≈ ¥0.7/次;pro 订阅用户分摊成本 | -| 人脸 API / 天气 | 免费额度内 | 缓存 + 免费版 | -| **单次生成总成本** | **~¥0.8** | 其中图像生成占大头,已按最优策略控制 | - -- 存储 v1 本地 workspace(可迁 OSS/COS,存储接口抽象预留) -- 规模化信号:存储 > 50GB 或单机 CPU 持续 >70% → 迁对象存储 + 拆分轮询 worker diff --git a/server/docs/项目文档.md b/server/docs/项目文档.md deleted file mode 100644 index 996a109..0000000 --- a/server/docs/项目文档.md +++ /dev/null @@ -1,123 +0,0 @@ -# slogan-agent 服务端 - -人形象设计应用(slogan)的服务端。用户上传个人照片与服装照片,指定日期地点后由大模型生成穿搭方案(含发型),支持 3D 化身与效果图查看。 - -技术栈:Go 1.22+ / GoFrame v2 / SQLite / JWT / OpenAI 兼容大模型 / 和风天气 + 高德地理编码。 - -## 快速开始 - -```bash -go mod tidy -go build -o slogan-agent . -./slogan-agent -``` - -服务默认监听 `:3007`,首次启动自动建库建表(`slogan.db`)。 - -### 必要配置(config.yml) - -| 配置项 | 说明 | -|--------|------| -| `llm.base_url / api_key / model_name` | 大模型(OpenAI 兼容,如通义/DeepSeek/Kimi),未配置时生成任务失败并返回明确错误 | -| `weather.qweather_key` | 和风天气 v7 Key(免费版即可),用于 7 天预报 | -| `geo.amap_key` | 高德地理编码 Key,地点 → adcode | -| `imagegen.supplier` | 效果图供应商:`mock`(占位图,开发用)或 `wanx`(通义万相,需配 `wanx_api_key`) | - -未配置天气/LLM Key 时接口返回明确错误提示,服务本身可正常启动。 - -## 接口总览 - -统一响应格式:`{"code":0,"message":"OK","data":...}`;`code != 0` 为业务错误。除公开接口外需 `Authorization: Bearer `(JWT,7 天有效)。 - -| 模块 | 路径 | 说明 | 公开 | -|------|------|------|------| -| 用户 | `POST /user/register` | 注册 | 是 | -| 用户 | `POST /user/login` | 登录,返回 token | 是 | -| 用户 | `POST /user/change-password` | 修改密码 | | -| 用户 | `GET /user/profile` | 个人信息 | | -| 照片 | `POST /user-photo/upload` | 上传照片(type: 1 大头 2 全身正面 3 侧面 4 背面) | | -| 照片 | `GET /user-photo/list` | 照片列表(type 可筛选) | | -| 照片 | `POST /user-photo/delete` | 删除照片 | | -| 衣橱 | `POST /wardrobe/upload` | 上传服装(category: 上衣/下装/鞋/配饰,season, style_tags, color_info) | | -| 衣橱 | `GET /wardrobe/list` | 衣橱列表 | | -| 衣橱 | `POST /wardrobe/update` | 更新服装信息 | | -| 衣橱 | `POST /wardrobe/delete` | 删除服装 | | -| 身形 | `POST /body-measurement/save` | 保存身形(height/weight/skin_tone) | | -| 身形 | `GET /body-measurement/get` | 查询身形 | | -| 化身 | `POST /avatar/build` | 构建 3D 化身(模板匹配,v1 同步) | | -| 化身 | `GET /avatar/get` | 化身信息(glb_url) | | -| 发型 | `GET /hairstyle/list` | 发型资产库 | 是 | -| 穿搭 | `POST /outfit/generate` | 生成穿搭方案(异步任务,body: start_date/end_date/location) | | -| 穿搭 | `GET /outfit/task/status` | 任务状态(pending→planning→scoring→done/failed) | | -| 穿搭 | `GET /outfit/plan/list` | 方案列表 | | -| 穿搭 | `GET /outfit/plan/detail` | 方案详情(items + hairstyle + effect images) | | -| 穿搭 | `POST /outfit/plan/select-main` | 选定主方案(触发 3 视角效果图生成) | | -| 穿搭 | `POST /outfit/plan/review` | 方案反馈(fav/unfav) | | -| 门店 | `GET /partner-store/list` | 合作门店(type: 1 形象设计 2 服装门店,0 全部) | | -| 静态 | `GET /workspace/*` | 上传文件与模板资产(鉴权放行) | | - -OpenAPI 文档:`http://127.0.0.1:3007/api.json` - -## 生成流程(outfit/generate) - -``` -pending → planning(天气获取 → 规则预筛 3 套候选 → LLM 规划 1 次调用) - → scoring(规则引擎 5 维评分:天气 25/场合 25/色彩 20/完整度 20/风格 10,阈值 75) - → 全低分 → LLM 兜底创作(1 次调用,recommend 方案) - → 落库 outfit_plan + plan_outfit_item - → done -``` - -- 衣橱不足 3 件、日期倒挂、Key 未配置等均在任务结果中返回明确错误 -- 服务重启时未完成任务标记 failed(避免重复消耗模型费用) -- 效果图按需生成:选主方案后异步生成 正面/侧面/背面 3 张,内容 hash 缓存 24h,每日限 3 次(可配 `scoring_rule` 表 `effect_limit` 维度) - -## 数据模型 - -13 张表:`slogan_user`、`slogan_user_photo`、`slogan_wardrobe_item`、`slogan_body_measurement`、`slogan_avatar_model`、`slogan_hairstyle_asset`(seed 8 发型)、`slogan_outfit_generation_task`、`slogan_outfit_plan`、`slogan_plan_outfit_item`、`slogan_plan_effect_image`、`slogan_plan_review`、`slogan_scoring_rule`、`slogan_partner_store`(seed 4 门店)。 - -## 目录结构 - -``` -main.go 入口:路由注册 + workspace 静态服务 + 任务恢复 -common/ 统一响应/RouteRegister/JWT 鉴权/工具 -styleagent/ - controller/ Controller 层(反射路由,struct 名 → kebab-case URL) - service/ 业务层(生成编排/化身/衣橱/效果图) - dao/ 每表一 DAO(init 自动建表 + seed) - model/entity|dto/ 实体与请求响应结构 - agent/ LLM 调用(OpenAI 兼容,重试/工具调用)+ 方案规划/兜底 - scoring/ 规则评分引擎(零 LLM 成本) - weather/ 和风天气 + 高德地理编码 + 缓存 - imagegen/ 效果图客户端(mock/wanx)+ 缓存 - avatar/ 3D 化身模板匹配 - consts/ 常量 -``` - -## 部署 - -```bash -docker build -t slogan-agent . -docker run -d -p 3007:3007 -v /data/slogan:/app/workspace -v /data/slogan/slogan.db:/app/slogan.db slogan-agent -``` - -生产部署前在 config.yml 填写 llm/weather/geo/imagegen 的真实 Key。 - -## 联调与测试规范 - -**测试必须使用真实用户数据**,禁止用临时注册的新账号验证业务链路(临时账号没有衣橱/照片/会员等真实数据,无法完整联调): - -| 场景 | 账号 | 说明 | -|------|------|------| -| 前端登录页 | `wenwu901` / `123456` | 登录页自带「测试账号一键登录」按钮(`AppConfig.testAccount/testPassword`) | -| 后端联调脚本 | `wenwu901` | `scripts/gen_outfit_plan/`、`scripts/gen_user_photos/` 默认用户 | - -前端测试账号由环境变量注入,构建时覆盖默认值(默认 `wenwu901`/`123456`): - -```bash -TEST_ACCOUNT=xxx TEST_PASSWORD=xxx bash scripts/dev.sh # dev.sh 内部透传给 build_web.sh -``` - -实现:`build_web.sh` 把 `TEST_ACCOUNT`/`TEST_PASSWORD` 环境变量透传为 `--dart-define`,编译期注入 `AppConfig.testAccount/testPassword`;账号置空时登录页不显示测试入口。 - -后端冒烟 `scripts/smoke.sh` 仍使用临时注册账号(仅验证接口可用性,不依赖业务数据)。 diff --git a/server/main.go b/server/main.go index d3816e0..30d5f2c 100644 --- a/server/main.go +++ b/server/main.go @@ -22,7 +22,7 @@ import ( ) func main() { - // ==================== Web 静态资源(Flutter web 构建产物) ==================== + // ==================== Web 静态资源(uni-app H5 构建产物) ==================== // 静态目录不存在时优雅跳过(仅 API 模式运行);注册顺序在 Auth 之后,history 回退先经鉴权放行 if dir := commonHttp.WebStaticDir(); dir != "" { commonHttp.Httpserver.SetServerRoot(dir) @@ -33,21 +33,6 @@ func main() { r.Response.ServeFile(filepath.Join(dir, "index.html")) } }) - - // Chrome DevTools 探测请求:.well-known 协议处理器探测返回 204; - // flutter.js 尾部保留 sourceMappingURL 注释但 release 构建不生成 map,返回合法空 sourcemap, - // 避免每次打开 DevTools 都产生 404(覆盖本地直服与 Docker 两种部署) - commonHttp.Httpserver.BindHandler("/.well-known/appspecific/com.chrome.devtools.json", func(r *ghttp.Request) { - r.Response.WriteStatus(http.StatusNoContent) - }) - commonHttp.Httpserver.BindHandler("/flutter.js.map", func(r *ghttp.Request) { - r.Response.WriteJson(map[string]interface{}{ - "version": 3, - "sources": []string{}, - "names": []string{}, - "mappings": "", - }) - }) } // ==================== API 路由(RouteRegister 反射注册,kebab-case 前缀) ==================== @@ -65,10 +50,8 @@ func main() { controller.Cps, }) - // 虎皮棋支付回调(裸文本 "success",不走统一 JSON 包装) - commonHttp.Httpserver.Group("/member/order", func(group *ghttp.RouterGroup) { - group.POST("/notify", controller.MemberNotify) - }) + // 虎皮棋支付回调:经 RouteRegister 由 dto g.Meta 注册(/member/order/notify, + // Auth 白名单放行;裸文本 "success" 由 controller 直接写响应体,不走统一 JSON 包装) // ==================== Workspace 文件服务(鉴权保护) ==================== commonHttp.Httpserver.BindHandler("/workspace/*", func(r *ghttp.Request) { @@ -94,8 +77,6 @@ func main() { // CPS 联盟商品定时同步(未配置 key 时空转) service.CpsProductService.StartSyncLoop(ctx) - g.Log().Info(ctx, "slogan-agent started on :3007") - <-ctx.Done() g.Log().Info(ctx, "shutting down...") time.Sleep(1 * time.Second) diff --git a/server/scripts/avatar-render/package-lock.json b/server/scripts/avatar-render/package-lock.json deleted file mode 100644 index 1f63996..0000000 --- a/server/scripts/avatar-render/package-lock.json +++ /dev/null @@ -1,713 +0,0 @@ -{ - "name": "avatar-render", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "avatar-render", - "version": "1.0.0", - "dependencies": { - "gl": "^9.0.0-rc.10", - "pngjs": "^7.0.0", - "three": "0.162.0" - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, - "node_modules/bit-twiddle": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bit-twiddle/-/bit-twiddle-1.0.2.tgz", - "integrity": "sha512-B9UhK0DKFZhoTFcfvAzhqsjStvGJp9vYWf3+6SNTtdSQnvIgfkHbgHrg/e4+TH71N2GDu8tpmCVoyfrL1d7ntA==" - }, - "node_modules/bl": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", - "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dependencies": { - "buffer": "^5.5.0", - "inherits": "^2.0.4", - "readable-stream": "^3.4.0" - } - }, - "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.1.13" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "engines": { - "node": ">=18" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" - }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "engines": { - "node": ">=8" - } - }, - "node_modules/end-of-stream": { - "version": "1.4.5", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", - "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dependencies": { - "once": "^1.4.0" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "engines": { - "node": ">=6" - } - }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==" - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" - }, - "node_modules/fs-constants": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", - "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" - }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==" - }, - "node_modules/gl": { - "version": "9.0.0-rc.10", - "resolved": "https://registry.npmjs.org/gl/-/gl-9.0.0-rc.10.tgz", - "integrity": "sha512-G6lYaWoan0d2d8UO0UmaSS8zqyyZwYt6q2dFVJ2hD62sRWUwkMIH+Jp7gEoQesLlo28Nzelh+GIYz1qOUa5WmQ==", - "hasInstallScript": true, - "dependencies": { - "bindings": "^1.5.0", - "bit-twiddle": "^1.0.2", - "glsl-tokenizer": "^2.1.5", - "nan": "^2.26.2", - "node-gyp": "^12.2.0", - "prebuild-install": "^7.1.3" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/glsl-tokenizer": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/glsl-tokenizer/-/glsl-tokenizer-2.1.5.tgz", - "integrity": "sha512-XSZEJ/i4dmz3Pmbnpsy3cKh7cotvFlBiZnDOwnj/05EwNp2XrhQ4XKJxT7/pDt4kp4YcpRSKz8eTV7S+mwV6MA==", - "dependencies": { - "through2": "^0.6.3" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" - }, - "node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==" - }, - "node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "engines": { - "node": ">=20" - } - }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" - }, - "node_modules/nan": { - "version": "2.28.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", - "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==" - }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==" - }, - "node_modules/node-abi": { - "version": "3.94.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz", - "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-gyp": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", - "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "tar": "^7.5.4", - "tinyglobby": "^0.2.12", - "undici": "^6.25.0", - "which": "^6.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/nopt": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", - "engines": { - "node": ">=14.19.0" - } - }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/tar": { - "version": "7.5.22", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", - "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar-fs": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", - "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" - }, - "node_modules/tar-stream": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", - "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", - "dependencies": { - "bl": "^4.0.3", - "end-of-stream": "^1.4.1", - "fs-constants": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.1.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/three": { - "version": "0.162.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.162.0.tgz", - "integrity": "sha512-xfCYj4RnlozReCmUd+XQzj6/5OjDNHBy5nT6rVwrOKGENAvpXe2z1jL+DZYaMu4/9pNsjH/4Os/VvS9IrH7IOQ==" - }, - "node_modules/through2": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", - "integrity": "sha512-RkK/CCESdTKQZHdmKICijdKKsCRVHs5KsLZ6pACAmF/1GPUQhonHSXWNERctxEp7RmvjdNbZTL5z9V7nSCXKcg==", - "dependencies": { - "readable-stream": ">=1.0.33-1 <1.1.0-0", - "xtend": ">=4.0.0 <4.1.0-0" - } - }, - "node_modules/through2/node_modules/readable-stream": { - "version": "1.0.34", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", - "integrity": "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg==", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.1", - "isarray": "0.0.1", - "string_decoder": "~0.10.x" - } - }, - "node_modules/through2/node_modules/string_decoder": { - "version": "0.10.31", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", - "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==" - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" - }, - "node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "engines": { - "node": ">=18" - } - } - } -} diff --git a/server/scripts/avatar-render/package.json b/server/scripts/avatar-render/package.json deleted file mode 100644 index b6a7337..0000000 --- a/server/scripts/avatar-render/package.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "name": "avatar-render", - "version": "1.0.0", - "private": true, - "description": "服务端 3D 化身预渲染:GLB -> 36 帧旋转 PNG(three.js + headless-gl + pngjs)", - "type": "module", - "dependencies": { - "gl": "^9.0.0-rc.10", - "pngjs": "^7.0.0", - "three": "0.162.0" - } -} diff --git a/server/scripts/avatar-render/render.js b/server/scripts/avatar-render/render.js deleted file mode 100644 index ceac7c3..0000000 --- a/server/scripts/avatar-render/render.js +++ /dev/null @@ -1,115 +0,0 @@ -// 化身 GLB -> 36 帧旋转 PNG(绕 Y 轴 10° 步进),服务端预渲染。 -// 用法: node render.js --glb --out [--frames 36] [--size 256x512] -import { argv } from 'node:process'; -import fs from 'node:fs'; -import path from 'node:path'; -import createGL from 'gl'; -import { PNG } from 'pngjs'; -import * as THREE from 'three'; -import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; - -function parseArgs() { - const a = {}; - for (let i = 2; i < argv.length; i++) { - if (argv[i].startsWith('--')) { - const key = argv[i].slice(2); - const val = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : true; - a[key] = val; - } - } - if (!a.glb || !a.out) { - console.error('用法: node render.js --glb --out [--frames 36] [--size 256x512]'); - process.exit(1); - } - a.frames = a.frames === true ? 36 : parseInt(a.frames, 10) || 36; - const [w, h] = (a.size === true ? '256x512' : String(a.size)).split('x').map(Number); - a.width = w || 256; - a.height = h || 512; - return a; -} - -const args = parseArgs(); -const { width, height, frames } = args; - -const gl = createGL(width, height, { preserveDrawingBuffer: true }); -if (!gl) { - console.error('headless-gl 初始化失败(容器内需 mesa/libglvnd)'); - process.exit(2); -} - -const canvas = { - width, - height, - style: {}, - addEventListener: () => {}, - removeEventListener: () => {}, - clientWidth: width, - clientHeight: height, - getContext: () => gl, -}; - -const renderer = new THREE.WebGLRenderer({ canvas, context: gl, antialias: false }); -renderer.setClearColor(0xffffff, 1); -renderer.setSize(width, height, false); - -const scene = new THREE.Scene(); -scene.add(new THREE.AmbientLight(0xffffff, 1.1)); -const dirLight = new THREE.DirectionalLight(0xffffff, 1.4); -dirLight.position.set(3, 6, 4); -scene.add(dirLight); -scene.add(new THREE.DirectionalLight(0xffffff, 0.5).translateY(-4).translateX(-3)); - -const camera = new THREE.PerspectiveCamera(35, width / height, 0.1, 100); - -const loader = new GLTFLoader(); - -const loadGlb = () => - new Promise((resolve, reject) => { - // Buffer 需转成 ArrayBuffer 才能触发 GLB 头解析 - const bin = fs.readFileSync(args.glb); - const ab = bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength); - loader.parse(ab, '', (gltf) => resolve(gltf.scene), (err) => reject(err)); - }); - -loadGlb() - .then((object) => { - scene.add(object); - render(object); - }) - .catch((err) => { - console.error('GLB 解析失败:', err && err.message ? err.message : err); - process.exit(3); - }); - -function render(object) { - // 包围盒 -> 相机半径与观测高度 - const box = new THREE.Box3().setFromObject(object); - const center = box.getCenter(new THREE.Vector3()); - const size = box.getSize(new THREE.Vector3()); - const radius = Math.max(size.x, size.z) * 1.6 + 0.6; - const lookY = center.y + size.y * 0.35; - const cameraY = center.y + size.y * 0.35; - - fs.mkdirSync(args.out, { recursive: true }); - const pixels = new Uint8Array(width * height * 4); - const rowSize = width * 4; - const png = new PNG({ width, height }); - - for (let i = 0; i < frames; i++) { - const angle = (i / frames) * Math.PI * 2; - camera.position.set(Math.sin(angle) * radius, cameraY, Math.cos(angle) * radius); - camera.lookAt(0, lookY, 0); - renderer.render(scene, camera); - - gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels); - const buf = Buffer.from(pixels.buffer); - for (let y = 0; y < height; y++) { - buf.copy(png.data, y * rowSize, (height - 1 - y) * rowSize, (height - y) * rowSize); - } - const out = path.join(args.out, `frame_${String(i).padStart(3, '0')}.png`); - fs.writeFileSync(out, PNG.sync.write(png)); - } - - console.log(`rendered ${frames} frames -> ${args.out}`); - process.exit(0); -} diff --git a/server/scripts/build_web.sh b/server/scripts/build_web.sh deleted file mode 100644 index 7bdc15e..0000000 --- a/server/scripts/build_web.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/bin/bash -# 构建 Flutter web 前端(产物 app/build/web,后端 WebStaticDir 直接托管) -# 缓存判断:产物不存在或源码(lib/ pubspec web/)比产物新时重建,否则跳过 -# 用法:bash scripts/build_web.sh (在 server/ 下执行) -set -e - -cd "$(dirname "${BASH_SOURCE[0]}")/.." -APP_DIR="$(cd ../app && pwd)" -OUT="$APP_DIR/build/web" - -if ! command -v flutter >/dev/null 2>&1; then - echo "[build_web] ERROR: 未找到 flutter,请先安装并加入 PATH(或手动构建)" - exit 1 -fi - -needs_build=0 -if [ ! -d "$OUT" ]; then - needs_build=1 -elif find "$APP_DIR/lib" "$APP_DIR/pubspec.yaml" "$APP_DIR/pubspec.lock" "$APP_DIR/web" \ - -newer "$OUT" -print 2>/dev/null | grep -q .; then - needs_build=1 -fi - -if [ "$needs_build" -eq 0 ]; then - echo "[build_web] 产物已是最新,跳过构建:$OUT" - exit 0 -fi - -# 透传测试账号环境变量到前端(AppConfig.testAccount/testPassword,--dart-define 覆盖默认 wenwu901/123456) -DEFINES="" -if [ -n "$TEST_ACCOUNT" ]; then - DEFINES="$DEFINES --dart-define=TEST_ACCOUNT=$TEST_ACCOUNT" -fi -if [ -n "$TEST_PASSWORD" ]; then - DEFINES="$DEFINES --dart-define=TEST_PASSWORD=$TEST_PASSWORD" -fi - -echo "[build_web] 源码有更新,开始构建 Flutter web(首次约 1-3 分钟)..." -cd "$APP_DIR" -flutter build web --release $DEFINES -echo "[build_web] 完成:$OUT" diff --git a/server/scripts/dev.sh b/server/scripts/dev.sh deleted file mode 100644 index 172c970..0000000 --- a/server/scripts/dev.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# 本地一键启动:自动构建/刷新 web 前端,再启动后端(http://localhost:3007 完整网页版) -# 想仅 API 模式(不构建前端)可跳过本脚本直接 go run ./main.go -# 用法:bash scripts/dev.sh (在 server/ 下执行) -set -e - -cd "$(dirname "${BASH_SOURCE[0]}")/.." - -bash scripts/build_web.sh - -echo "[dev] 启动后端 :3007 ..." -exec go run ./main.go diff --git a/server/scripts/gen_outfit_plan/main.go b/server/scripts/gen_outfit_plan/main.go deleted file mode 100644 index 42f5efb..0000000 --- a/server/scripts/gen_outfit_plan/main.go +++ /dev/null @@ -1,232 +0,0 @@ -package main - -// 为 wenwu901 真实生成一套 AI 穿搭方案(真实调用 imagegen 与 LLM,非 mock): -// go run scripts/gen_outfit_plan/main.go -// 步骤:补衣橱(8 件单品,imagegen 生成服装图)→ 调 OutfitService.Generate → -// 轮询任务到 done → 选主方案(触发效果图异步生成)→ 等 3 张效果图完成。 -// 前置:config.yml 已配置 geo.amap_key + weather.qweather_key(天气硬依赖)。 - -import ( - "context" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - "time" - - _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" - "github.com/gogf/gf/v2/frame/g" - - "slogan-agent/styleagent/agent" - "slogan-agent/styleagent/consts" - "slogan-agent/styleagent/dao" - "slogan-agent/styleagent/model/dto" - "slogan-agent/styleagent/model/entity" - "slogan-agent/styleagent/service" -) - -const username = "wenwu901" - -type garment struct { - name string - category string - style string - color string - prompt string -} - -var garments = []garment{ - {"白色长袖衬衫", "上衣", "休闲", "白色", "纯白背景的白色长袖衬衫商品图,正面展示,高清,电商风格"}, - {"灰色圆领T恤", "上衣", "休闲", "灰色", "纯白背景的灰色圆领T恤商品图,正面展示,高清,电商风格"}, - {"深蓝夹克外套", "上衣", "外套", "深蓝", "纯白背景的深蓝色夹克外套商品图,正面展示,高清,电商风格"}, - {"深灰休闲长裤", "下装", "休闲", "深灰", "纯白背景的深灰色休闲长裤商品图,正面展示,高清,电商风格"}, - {"蓝色牛仔裤", "下装", "休闲", "蓝色", "纯白背景的蓝色牛仔裤商品图,正面展示,高清,电商风格"}, - {"白色运动鞋", "鞋", "休闲", "白色", "纯白背景的白色运动鞋商品图,侧面展示,高清,电商风格"}, - {"棕色皮鞋", "鞋", "商务", "棕色", "纯白背景的棕色皮鞋商品图,侧面展示,高清,电商风格"}, - {"黑色双肩背包", "配饰", "休闲", "黑色", "纯白背景的黑色双肩背包商品图,正面展示,高清,电商风格"}, -} - -func main() { - ctx := context.Background() - - var user entity.User - if err := g.DB().Model(consts.TableNameUser).Ctx(ctx). - Where("username", username).Scan(&user); err != nil || user.Id == 0 { - panic(fmt.Sprintf("用户 %s 不存在: %v", username, err)) - } - fmt.Printf("用户: %s (id=%d)\n", username, user.Id) - - if err := ensureWardrobe(ctx, user.Id); err != nil { - panic(err) - } - - // 幂等:已有方案则只重试效果图(主方案 → select-main → 等 3 张 done) - var mainPlan *entity.OutfitPlan - existing, err := dao.OutfitPlan.ListByUser(ctx, user.Id) - if err != nil { - panic(fmt.Sprintf("读取方案列表失败: %v", err)) - } - for _, p := range existing { - if p.MainFlag == 1 { - mainPlan = p - } - } - if len(existing) == 0 { - taskId, err := service.OutfitService.Generate(ctx, user.Id, &dto.OutfitGenerateReq{ - StartDate: "2026-08-01", EndDate: "2026-08-07", Location: "上海", Occasion: "通勤", - }) - if err != nil { - if strings.Contains(err.Error(), "未配置") { - panic(fmt.Sprintf("%v\n请先在 config.yml 配置 geo.amap_key / weather.qweather_key 后重跑", err)) - } - panic(fmt.Sprintf("发起方案生成失败: %v", err)) - } - fmt.Printf("生成任务已提交: task_id=%d,轮询中...\n", taskId) - - waitTaskDone(ctx, taskId, user.Id) - plans, err := dao.OutfitPlan.ListByTask(ctx, taskId) - if err != nil || len(plans) == 0 { - panic(fmt.Sprintf("任务完成但无方案: %v", err)) - } - mainPlan = plans[0] - for _, p := range plans { - if p.MainFlag == 1 { - mainPlan = p - } - } - } else if mainPlan == nil { - panic("已有方案但无主方案,请先选主方案") - } - fmt.Printf("主方案: id=%d %s 评分=%d\n", mainPlan.Id, mainPlan.Title, mainPlan.Score) - if err := service.OutfitPlanService.SelectMain(ctx, user.Id, mainPlan.Id); err != nil { - panic(fmt.Sprintf("选主方案失败: %v", err)) - } - - waitEffects(ctx, mainPlan.Id) - - all, err := dao.OutfitPlan.ListByUser(ctx, user.Id) - if err != nil { - panic(fmt.Sprintf("读取方案列表失败: %v", err)) - } - fmt.Printf("完成!wenwu901 现有 %d 套方案:\n", len(all)) - for _, p := range all { - fmt.Printf(" - plan %d: %s(评分 %d,%s)\n", p.Id, p.Title, p.Score, p.Source) - } -} - -// ensureWardrobe 为指定用户补齐 8 件衣橱单品(同 Category 已有则跳过该分类),服装图用 imagegen 生成 -func ensureWardrobe(ctx context.Context, userId int64) error { - existing, err := dao.WardrobeItem.ListAllByUser(ctx, userId) - if err != nil { - return err - } - have := map[string]bool{} - for _, it := range existing { - have[it.Category] = true - } - need := make([]garment, 0, len(garments)) - for _, ga := range garments { - if !have[ga.category] { - need = append(need, ga) - } - } - if len(need) == 0 { - fmt.Println("衣橱 4 类已齐,跳过补衣橱") - return nil - } - - client, err := agent.NewClient(g.Cfg().MustGet(ctx, "imagegen.supplier", "wanx").String()) - if err != nil { - return err - } - dir := filepath.Join("workspace", fmt.Sprintf("user_%d", userId), "wardrobe") - if err := os.MkdirAll(dir, 0o755); err != nil { - return err - } - for _, ga := range need { - fmt.Printf("生成服装图: %s...\n", ga.name) - url, err := client.Generate(ctx, &agent.GenerateReq{ - Prompt: ga.prompt, Seed: time.Now().UnixNano() % 1_000_000, - }) - if err != nil { - return fmt.Errorf("生成 %s 服装图失败: %w", ga.name, err) - } - path := filepath.Join(dir, fmt.Sprintf("%d_%s.png", time.Now().UnixNano(), ga.name)) - if err := download(url, path); err != nil { - return fmt.Errorf("保存 %s 失败: %w", ga.name, err) - } - if _, err := dao.WardrobeItem.Insert(ctx, &entity.WardrobeItem{ - UserId: userId, PhotoUrl: "/" + filepath.ToSlash(path), - Name: ga.name, Category: ga.category, Season: "四季", StyleTags: ga.style, - ColorInfo: ga.color, Status: 1, - }); err != nil { - return fmt.Errorf("入库 %s 失败: %w", ga.name, err) - } - fmt.Printf("%s 完成: %s\n", ga.name, path) - } - fmt.Println("衣橱补齐完毕") - return nil -} - -func waitTaskDone(ctx context.Context, taskId, userId int64) { - for i := 0; i < 30; i++ { - task, err := dao.OutfitGenTask.GetOne(ctx, taskId, userId) - if err != nil || task == nil { - panic(fmt.Sprintf("读取任务失败: %v", err)) - } - switch task.Status { - case consts.TaskStatusDone: - fmt.Println("方案生成完成") - return - case consts.TaskStatusFailed: - panic(fmt.Sprintf("方案生成失败: %s", task.Error)) - } - time.Sleep(10 * time.Second) - } - panic("方案生成超时(5 分钟)") -} - -// waitEffects 等主方案的 3 张效果图(正面/侧面/背面)全部 done -func waitEffects(ctx context.Context, planId int64) { - for i := 0; i < 20; i++ { - images, err := dao.PlanEffectImage.ListByPlan(ctx, planId) - if err != nil { - panic(fmt.Sprintf("读取效果图列表失败: %v", err)) - } - done := 0 - for _, im := range images { - if im.Status == consts.EffectStatusDone { - done++ - } - } - if done >= 3 { - fmt.Printf("效果图 3 张完成\n") - return - } - if i == 19 { - fmt.Printf("警告: 效果图超时(完成 %d/3),可稍后查看\n", done) - return - } - time.Sleep(15 * time.Second) - } -} - -func download(url, dest string) error { - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("下载失败: http %d", resp.StatusCode) - } - out, err := os.Create(dest) - if err != nil { - return err - } - defer out.Close() - _, err = io.Copy(out, resp.Body) - return err -} diff --git a/server/scripts/gen_user_photos/main.go b/server/scripts/gen_user_photos/main.go deleted file mode 100644 index 5c9b290..0000000 --- a/server/scripts/gen_user_photos/main.go +++ /dev/null @@ -1,114 +0,0 @@ -package main - -// 为指定用户生成一套三视角全身照(真实调用 imagegen,非 mock): -// go run scripts/gen_user_photos/main.go [username] -// 默认用户 wenwu901。已存在同视角照片时跳过;图片存 workspace/user_{id}/photos/,记录写入 slogan_user_photo。 - -import ( - "context" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "time" - - _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" - "github.com/gogf/gf/v2/frame/g" - - "slogan-agent/styleagent/agent" - "slogan-agent/styleagent/consts" - "slogan-agent/styleagent/dao" - "slogan-agent/styleagent/model/entity" -) - -const personDesc = "一位穿浅蓝色衬衫与深灰色西裤的亚洲年轻女性,干净利落的黑色短发,身材匀称" - -var views = []struct { - angle string - photoT int - prompt string -}{ - {angle: "front", photoT: consts.PhotoTypeFullFront, prompt: personDesc + ",全身正面照,站直面对镜头,双手自然下垂,纯白背景,高清写实,全身入镜"}, - {angle: "side", photoT: consts.PhotoTypeFullSide, prompt: personDesc + ",全身侧面照,侧身站立目视前方,纯白背景,高清写实,全身入镜"}, - {angle: "back", photoT: consts.PhotoTypeFullBack, prompt: personDesc + ",全身背面照,背对镜头站立,纯白背景,高清写实,全身入镜"}, -} - -func main() { - username := "wenwu901" - if len(os.Args) > 1 { - username = os.Args[1] - } - ctx := context.Background() - - var user entity.User - if err := g.DB().Model(consts.TableNameUser).Ctx(ctx). - Where("username", username).Scan(&user); err != nil || user.Id == 0 { - panic(fmt.Sprintf("用户 %s 不存在: %v", username, err)) - } - fmt.Printf("用户: %s (id=%d)\n", username, user.Id) - - existing, err := dao.UserPhoto.ListByUser(ctx, user.Id, 0) - if err != nil { - panic(err) - } - have := map[int]bool{} - for _, p := range existing { - have[p.Type] = true - } - - client, err := agent.NewClient(g.Cfg().MustGet(ctx, "imagegen.supplier", "wanx").String()) - if err != nil { - panic(err) - } - - dir := filepath.Join("workspace", fmt.Sprintf("user_%d", user.Id), "photos") - if err := os.MkdirAll(dir, 0o755); err != nil { - panic(err) - } - - // 三视角用同一 seed,保证人物一致 - seed := time.Now().UnixNano() % 1_000_000 - for _, v := range views { - if have[v.photoT] { - fmt.Printf("视角 %s 已有照片,跳过\n", v.angle) - continue - } - fmt.Printf("生成 %s 视角...\n", v.angle) - url, err := client.Generate(ctx, &agent.GenerateReq{ - Prompt: v.prompt, Angle: v.angle, Seed: seed, - }) - if err != nil { - panic(fmt.Sprintf("生成 %s 失败: %v", v.angle, err)) - } - path := filepath.Join(dir, fmt.Sprintf("%d_%s.png", time.Now().UnixNano(), v.angle)) - if err := download(url, path); err != nil { - panic(fmt.Sprintf("保存 %s 失败: %v", v.angle, err)) - } - if _, err := dao.UserPhoto.Insert(ctx, &entity.UserPhoto{ - UserId: user.Id, Type: v.photoT, Url: "/" + filepath.ToSlash(path), Status: 1, - }); err != nil { - panic(fmt.Sprintf("入库 %s 失败: %v", v.angle, err)) - } - fmt.Printf("%s 完成: %s\n", v.angle, path) - } - fmt.Println("照片套生成完毕") -} - -func download(url, dest string) error { - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("下载失败: http %d", resp.StatusCode) - } - out, err := os.Create(dest) - if err != nil { - return err - } - defer out.Close() - _, err = io.Copy(out, resp.Body) - return err -} diff --git a/server/scripts/smoke.sh b/server/scripts/smoke.sh deleted file mode 100644 index 69768f9..0000000 --- a/server/scripts/smoke.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/bash -# 全路径冒烟:register/login → token → 依次打全部接口,断言 HTTP 200 + code 符合预期 -# 用法:bash scripts/smoke.sh -# 约定:check_code 第三参 = 允许的降级 code 列表(逗号分隔,默认只许 0) -set -e -BASE="${BASE:-http://localhost:3007}" -FAIL=0 - -say() { echo "[smoke] $*"; } -fail() { echo "[smoke] FAIL: $*"; FAIL=1; } - -check_code() { - local name="$1" body="$2" allow="$3" - local code - code=$(echo "$body" | python3 -c "import json,sys; print(json.load(sys.stdin).get('code','?'))" 2>/dev/null || echo "?") - if [ "$code" = "0" ] || echo ",$allow," | grep -q ",$code,"; then - say "OK: $name" - else - fail "$name: unexpected code=$code (allow: $allow) body=$(echo "$body" | head -c 200)" - fi -} - -# 1. 注册 + 登录拿 token -USER="smoke_$(date +%s)" -REG=$(curl -s -X POST "$BASE/user/register" -H 'Content-Type: application/json' -d "{\"account\":\"$USER\",\"password\":\"smoketest123\"}") -say "register: $(echo "$REG" | head -c 120)" -LOGIN=$(curl -s -X POST "$BASE/user/login" -H 'Content-Type: application/json' -d "{\"account\":\"$USER\",\"password\":\"smoketest123\"}") -TOKEN=$(echo "$LOGIN" | python3 -c "import json,sys; print(json.load(sys.stdin).get('data',{}).get('token',''))" 2>/dev/null) -if [ -z "$TOKEN" ]; then - fail "login: no token in $(echo "$LOGIN" | head -c 200)" - exit 1 -fi -say "login OK, token len=${#TOKEN}" -AUTH="Authorization: Bearer $TOKEN" - -# 2. GET 接口(第三参允许的 code:50=未开通/降级/无数据) -for item in \ - "GET /user/profile 0" \ - "GET /user-photo/list 0" \ - "GET /wardrobe/list 0" \ - "GET /body-measurement/get 0" \ - "GET /avatar/get 0" \ - "GET /hairstyle/list 0" \ - "GET /outfit/task/status?task_id=0 50" \ - "GET /outfit/plan/list 0" \ - "GET /partner-store/list 0" \ - "GET /member/plan/list 0" \ - "GET /member/status 0" \ - "GET /cps/category/list 0" \ - "GET /cps/product/list?source=meituan_ota&category_code=beauty 0" \ - "GET /cps/plan/recommend?plan_id=0&scene=haircut 50" \ - "GET /cps/wardrobe/upgrade?item_id=0 50" \ - "GET /cps/my/recent 0" ; do - set -- $item - METHOD="$1"; PATH_="$2"; ALLOW="${3:-0}" - RESP=$(curl -s -X "$METHOD" "$BASE$PATH_" -H "$AUTH") - check_code "$PATH_" "$RESP" "$ALLOW" -done - -# 3. POST 接口 -post_check() { - local name="$1" json="$2" allow="${3:-0}" - local resp - resp=$(curl -s -X POST "$BASE$name" -H "$AUTH" -H 'Content-Type: application/json' -d "$json") - check_code "$name" "$resp" "$allow" -} -post_check "/body-measurement/save" '{"height_cm":175,"weight_kg":65}' -post_check "/outfit/plan/review" '{"plan_id":0,"action":"fav"}' "50" -post_check "/member/order/create" '{"plan_id":1}' "50" -post_check "/ad/reward/claim" '{"ad_type":"effect_extra"}' "50" -post_check "/outfit/generate" '{"start_date":"2026-08-01","end_date":"2026-08-07","location":"上海"}' "50" -post_check "/cps/product/link" '{"product_id":0,"scene":"item_buy"}' "50,51" -post_check "/user/change-password" '{"old_password":"smoketest123","new_password":"smoketest456"}' - -# 4. 裸回调(无鉴权;no_order 预期返回 fail) -NOTIFY=$(curl -s -X POST "$BASE/member/order/notify" -H 'Content-Type: application/x-www-form-urlencoded' -d 'out_trade_no=nonexist&trade_no=x&amount=0&status=paid') -say "notify(no_order)=$NOTIFY" - -# 5. workspace 静态文件 -WS=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/workspace/nonexist.png" -H "$AUTH") -say "workspace/404: $WS" - -if [ "$FAIL" = "0" ]; then - say "ALL SMOKE PASS" -else - say "SMOKE HAS FAILURES" - exit 1 -fi diff --git a/server/styleagent/agent/agent_config.go b/server/styleagent/agent/agent_config.go index 6bf4659..359bb1e 100644 --- a/server/styleagent/agent/agent_config.go +++ b/server/styleagent/agent/agent_config.go @@ -32,6 +32,8 @@ func GetModelConfig(ctx context.Context) (*ModelConfig, error) { if cfg.APIKey == "" || cfg.ModelName == "" || cfg.BaseURL == "" { return nil, fmt.Errorf("LLM 未配置:请在 config.yml 设置 llm.base_url / llm.api_key / llm.model_name") } - _ = modelCfgCache.Set(ctx, cacheKey, cfg, 60*time.Second) + if err := modelCfgCache.Set(ctx, cacheKey, cfg, 60*time.Second); err != nil { + g.Log().Warningf(ctx, "写入 LLM 配置缓存失败: %v", err) + } return cfg, nil } diff --git a/server/styleagent/agent/avatar_tripo_client.go b/server/styleagent/agent/avatar_tripo_client.go index c07d42a..95e6766 100644 --- a/server/styleagent/agent/avatar_tripo_client.go +++ b/server/styleagent/agent/avatar_tripo_client.go @@ -45,7 +45,7 @@ func (c *TripoClient) UploadImage(ctx context.Context, filePath string) (string, if err != nil { return "", fmt.Errorf("打开图片失败: %w", err) } - defer f.Close() + defer func() { _ = f.Close() }() fw, err := w.CreateFormFile("file", filepath.Base(filePath)) if err != nil { return "", err @@ -166,7 +166,7 @@ func (c *TripoClient) DownloadGlb(ctx context.Context, url, destPath string) err if err != nil { return fmt.Errorf("下载 GLB 失败: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return fmt.Errorf("下载 GLB 失败: http %d", resp.StatusCode) } @@ -177,7 +177,7 @@ func (c *TripoClient) DownloadGlb(ctx context.Context, url, destPath string) err if err != nil { return err } - defer out.Close() + defer func() { _ = out.Close() }() if _, err := io.Copy(out, resp.Body); err != nil { return err } @@ -190,7 +190,7 @@ func (c *TripoClient) do(req *http.Request) (map[string]any, error) { if err != nil { return nil, fmt.Errorf("Tripo 请求失败: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() raw, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取 Tripo 响应失败: %w", err) diff --git a/server/styleagent/agent/chat_model.go b/server/styleagent/agent/chat_model.go index 41ead60..d9596ee 100644 --- a/server/styleagent/agent/chat_model.go +++ b/server/styleagent/agent/chat_model.go @@ -108,7 +108,7 @@ func doChatRequest(ctx context.Context, url, apiKey string, body []byte, timeout if err != nil { return nil, fmt.Errorf("request failed (elapsed %v): %w", elapsed, err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { diff --git a/server/styleagent/agent/cps_jd.go b/server/styleagent/agent/cps_jd.go index a5c7714..248eea3 100644 --- a/server/styleagent/agent/cps_jd.go +++ b/server/styleagent/agent/cps_jd.go @@ -14,6 +14,8 @@ import ( "strings" "time" + "slogan-agent/common" + "github.com/gogf/gf/v2/frame/g" ) @@ -38,13 +40,13 @@ func (jdProvider) apiBase(ctx context.Context) string { func (p jdProvider) SyncProducts(ctx context.Context, city, catCode string) ([]CpsProduct, error) { biz := map[string]any{ "goodsReqDTO": map[string]any{ - "cid1": catCode, - "pageIndex": 1, - "pageSize": 20, - "eliteId": 1, - "sortName": "inOrderCount30Days", - "sort": "desc", - "fields": "skuId,skuName,imageUrl,priceInfo,shopName,commissionInfo,categoryInfo", + "cid1": catCode, + "pageIndex": 1, + "pageSize": 20, + "eliteId": 1, + "sortName": "inOrderCount30Days", + "sort": "desc", + "fields": "skuId,skuName,imageUrl,priceInfo,shopName,commissionInfo,categoryInfo", }, } resp, err := p.doRequest(ctx, "jd.union.open.goods.query", biz) @@ -58,12 +60,12 @@ func (p jdProvider) SyncProducts(ctx context.Context, city, catCode string) ([]C func (p jdProvider) Search(ctx context.Context, keyword, catCode string, page int) ([]CpsProduct, error) { biz := map[string]any{ "goodsReqDTO": map[string]any{ - "keyword": keyword, - "pageIndex": page, - "pageSize": 20, - "sortName": "inOrderCount30Days", - "sort": "desc", - "fields": "skuId,skuName,imageUrl,priceInfo,shopName,commissionInfo,categoryInfo", + "keyword": keyword, + "pageIndex": page, + "pageSize": 20, + "sortName": "inOrderCount30Days", + "sort": "desc", + "fields": "skuId,skuName,imageUrl,priceInfo,shopName,commissionInfo,categoryInfo", }, } resp, err := p.doRequest(ctx, "jd.union.open.goods.query", biz) @@ -134,12 +136,12 @@ func (p jdProvider) doRequest(ctx context.Context, method string, biz map[string } params := map[string]string{ - "method": method, - "app_key": appKey, - "timestamp": time.Now().Format("2006-01-02 15:04:05"), - "format": "json", - "v": "1.0", - "sign_method": "md5", + "method": method, + "app_key": appKey, + "timestamp": time.Now().Format("2006-01-02 15:04:05"), + "format": "json", + "v": "1.0", + "sign_method": "md5", "360buy_param_json": string(payload), } keys := make([]string, 0, len(params)) @@ -174,7 +176,7 @@ func (p jdProvider) doRequest(ctx context.Context, method string, biz map[string if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err @@ -194,11 +196,11 @@ func (p jdProvider) parseProducts(body []byte, catCode string) ([]CpsProduct, er } var inner struct { Result []struct { - SkuID int64 `json:"skuId"` - SkuName string `json:"skuName"` - ImageURL string `json:"imageUrl"` - ShopName string `json:"shopName"` - PriceInfo struct { + SkuID int64 `json:"skuId"` + SkuName string `json:"skuName"` + ImageURL string `json:"imageUrl"` + ShopName string `json:"shopName"` + PriceInfo struct { Price float64 `json:"price"` } `json:"priceInfo"` CommissionInfo struct { @@ -224,7 +226,7 @@ func (p jdProvider) parseProducts(body []byte, catCode string) ([]CpsProduct, er CategoryCode: catCode, Name: it.SkuName, CoverUrl: it.ImageURL, - PriceFen: int64(it.PriceInfo.Price * 100), + PriceFen: common.RoundInt(it.PriceInfo.Price * 100), ShopName: it.ShopName, CommissionRate: rate, }) diff --git a/server/styleagent/agent/cps_meituan.go b/server/styleagent/agent/cps_meituan.go index e4c325f..c0a72cf 100644 --- a/server/styleagent/agent/cps_meituan.go +++ b/server/styleagent/agent/cps_meituan.go @@ -13,6 +13,8 @@ import ( "strings" "time" + "slogan-agent/common" + "github.com/gogf/gf/v2/frame/g" ) @@ -36,11 +38,11 @@ func (meituanProvider) apiBase(ctx context.Context) string { // SyncProducts 到店 POI/商品选品(按类目 + 城市) func (p meituanProvider) SyncProducts(ctx context.Context, city, catCode string) ([]CpsProduct, error) { biz := map[string]any{ - "cityName": city, - "categoryId": catCode, - "pageNo": 1, - "pageSize": 50, - "isActivity": 0, + "cityName": city, + "categoryId": catCode, + "pageNo": 1, + "pageSize": 50, + "isActivity": 0, "promotionPid": g.Cfg().MustGet(ctx, "cps.meituan_pid", "").String(), } resp, err := p.doRequest(ctx, "union/search", biz) @@ -53,10 +55,10 @@ func (p meituanProvider) SyncProducts(ctx context.Context, city, catCode string) // Search 实时搜索兜底 func (p meituanProvider) Search(ctx context.Context, keyword, catCode string, page int) ([]CpsProduct, error) { biz := map[string]any{ - "keyword": keyword, - "categoryId": catCode, - "pageNo": page, - "pageSize": 20, + "keyword": keyword, + "categoryId": catCode, + "pageNo": page, + "pageSize": 20, "promotionPid": g.Cfg().MustGet(ctx, "cps.meituan_pid", "").String(), } resp, err := p.doRequest(ctx, "union/search", biz) @@ -69,7 +71,7 @@ func (p meituanProvider) Search(ctx context.Context, keyword, catCode string, pa // GetLink 转链(pid 归因) func (p meituanProvider) GetLink(ctx context.Context, outerId string) (string, error) { biz := map[string]any{ - "poiId": outerId, + "poiId": outerId, "promotionPid": g.Cfg().MustGet(ctx, "cps.meituan_pid", "").String(), } resp, err := p.doRequest(ctx, "union/link", biz) @@ -121,7 +123,7 @@ func (p meituanProvider) doRequest(ctx context.Context, path string, biz map[str if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err @@ -167,11 +169,11 @@ func (p meituanProvider) parseProducts(body []byte, catCode, city string) ([]Cps return out, nil } -// parseFen 金额字符串(元)→ 分 +// parseFen 金额字符串(元)→ 分(四舍五入,禁止浮点截断) func parseFen(amount string) int64 { f, err := strconv.ParseFloat(amount, 64) if err != nil { return 0 } - return int64(f * 100) + return common.RoundInt(f * 100) } diff --git a/server/styleagent/agent/cps_tb.go b/server/styleagent/agent/cps_tb.go index 683dbf0..c2dd0da 100644 --- a/server/styleagent/agent/cps_tb.go +++ b/server/styleagent/agent/cps_tb.go @@ -141,7 +141,7 @@ func (p tbProvider) doRequest(ctx context.Context, method string, biz map[string if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err diff --git a/server/styleagent/agent/imagegen_wanx_client.go b/server/styleagent/agent/imagegen_wanx_client.go index d9e455f..6cf1299 100644 --- a/server/styleagent/agent/imagegen_wanx_client.go +++ b/server/styleagent/agent/imagegen_wanx_client.go @@ -126,7 +126,7 @@ func (c *wanxClient) submit(ctx context.Context, body []byte) (string, error) { if err != nil { return "", err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() data, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("读取万相提交响应失败: %w", err) diff --git a/server/styleagent/agent/render.go b/server/styleagent/agent/render.go deleted file mode 100644 index f6864a1..0000000 --- a/server/styleagent/agent/render.go +++ /dev/null @@ -1,85 +0,0 @@ -package agent - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "github.com/gogf/gf/v2/frame/g" -) - -const ( - renderFramesCount = 36 - renderFrameSize = "256x512" - renderTimeout = 15 * time.Minute -) - -// RenderAvatarFrames 将化身 GLB 预渲染为绕 Y 轴旋转帧序列,返回帧目录访问 URL。 -// 帧目录已就绪直接复用;render.enabled=false 或渲染失败时返回 error,由调用方降级。 -func RenderAvatarFrames(ctx context.Context, glb string, outKey string) (framesURL string, err error) { - if !g.Cfg().MustGet(ctx, "render.enabled", true).Bool() { - return "", errors.New("3D 渲染服务未启用") - } - dir := filepath.Join("workspace", "avatar_frames", outKey) - if framesReady(dir) { - return "/workspace/avatar_frames/" + outKey, nil - } - if _, err := os.Stat(glb); err != nil { - return "", fmt.Errorf("化身 GLB 不存在: %w", err) - } - if err := runRender(ctx, glb, dir); err != nil { - return "", err - } - return "/workspace/avatar_frames/" + outKey, nil -} - -func framesReady(dir string) bool { - entries, err := os.ReadDir(dir) - if err != nil { - return false - } - count := 0 - for _, e := range entries { - if strings.HasPrefix(e.Name(), "frame_") && strings.HasSuffix(e.Name(), ".png") { - count++ - } - } - return count >= renderFramesCount -} - -func nodeBin(ctx context.Context) string { - bin := g.Cfg().MustGet(ctx, "render.node_bin", "node").String() - if bin == "" { - return "node" - } - // 配置路径不存在(如容器环境)→ 回退 PATH 中的 node - if _, err := os.Stat(bin); err != nil { - return "node" - } - return bin -} - -func runRender(ctx context.Context, glb, out string) error { - if err := os.MkdirAll(out, 0o755); err != nil { - return err - } - return execNode(ctx, filepath.Join("scripts", "avatar-render", "render.js"), - "--glb", glb, "--out", out, - "--frames", fmt.Sprint(renderFramesCount), "--size", renderFrameSize) -} - -func execNode(ctx context.Context, script string, args ...string) error { - cmdCtx, cancel := context.WithTimeout(ctx, renderTimeout) - defer cancel() - cmd := exec.CommandContext(cmdCtx, nodeBin(ctx), append([]string{script}, args...)...) - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("node 渲染失败: %v: %s", err, strings.TrimSpace(string(out))) - } - return nil -} diff --git a/server/styleagent/agent/weather_geo.go b/server/styleagent/agent/weather_geo.go index 0528f21..9eef769 100644 --- a/server/styleagent/agent/weather_geo.go +++ b/server/styleagent/agent/weather_geo.go @@ -38,7 +38,7 @@ func GetCityCode(ctx context.Context, location string) (string, error) { if err != nil { return "", fmt.Errorf("高德地理编码失败: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return "", err diff --git a/server/styleagent/agent/weather_qweather.go b/server/styleagent/agent/weather_qweather.go index f4f5413..15854cb 100644 --- a/server/styleagent/agent/weather_qweather.go +++ b/server/styleagent/agent/weather_qweather.go @@ -59,7 +59,7 @@ func GetDaily(ctx context.Context, cityCode, startDate, endDate string) (*Weathe if err != nil { return nil, fmt.Errorf("和风天气请求失败: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(resp.Body) if err != nil { return nil, err diff --git a/server/styleagent/consts/consts.go b/server/styleagent/consts/consts.go new file mode 100644 index 0000000..9974334 --- /dev/null +++ b/server/styleagent/consts/consts.go @@ -0,0 +1,9 @@ +package consts + +// 异步任务协程池默认大小(config.yml pool. 缺失或非法时回退; +// 实际并发度由 common.Submit 读取 config,此处为业务默认值) +const ( + DefaultGeneratePoolSize = 8 + DefaultEffectPoolSize = 4 + DefaultAvatarPoolSize = 4 +) diff --git a/server/styleagent/consts/cps.go b/server/styleagent/consts/cps.go index 674a51d..39773b7 100644 --- a/server/styleagent/consts/cps.go +++ b/server/styleagent/consts/cps.go @@ -9,19 +9,19 @@ const ( // CPS 推荐场景(scene_category_map.scene_type) const ( - CpsSceneHaircut = "haircut" // 发型卡「做同款发型」 - CpsSceneItemBuy = "item_buy" // 穿衣清单「买同款」 - CpsSceneItemUpgrade = "item_upgrade" // 穿衣清单「到店试穿」 - CpsSceneOccasion = "occasion" // 场合卡「延伸优惠」 + CpsSceneHaircut = "haircut" // 发型卡「做同款发型」 + CpsSceneItemBuy = "item_buy" // 穿衣清单「买同款」 + CpsSceneItemUpgrade = "item_upgrade" // 穿衣清单「到店试穿」 + CpsSceneOccasion = "occasion" // 场合卡「延伸优惠」 CpsSceneWardrobeUpgrade = "wardrobe_upgrade" // 衣橱「找升级款」 - CpsSceneMemberBenefit = "member_benefit" // 会员中心最近优惠 + CpsSceneMemberBenefit = "member_benefit" // 会员中心最近优惠 ) // 点击日志场景(cps_click_log.scene) const ( - CpsClickScenePlanHaircut = "plan_haircut" - CpsClickScenePlanItem = "plan_item" - CpsClickScenePlanOccasion = "plan_occasion" + CpsClickScenePlanHaircut = "plan_haircut" + CpsClickScenePlanItem = "plan_item" + CpsClickScenePlanOccasion = "plan_occasion" CpsClickSceneWardrobeUpgrade = "wardrobe_upgrade" - CpsClickSceneMemberBenefit = "member_benefit" + CpsClickSceneMemberBenefit = "member_benefit" ) diff --git a/server/styleagent/consts/table_name.go b/server/styleagent/consts/table_name.go index 3cf14b2..6ae0f1d 100644 --- a/server/styleagent/consts/table_name.go +++ b/server/styleagent/consts/table_name.go @@ -1,23 +1,23 @@ package consts const ( - TableNameUser = "slogan_user" - TableNameUserPhoto = "slogan_user_photo" - TableNameWardrobeItem = "slogan_wardrobe_item" - TableNameBodyMeasurement = "slogan_body_measurement" - TableNameAvatarModel = "slogan_avatar_model" - TableNameHairstyleAsset = "slogan_hairstyle_asset" - TableNameOutfitGenTask = "slogan_outfit_generation_task" - TableNameOutfitPlan = "slogan_outfit_plan" - TableNamePlanOutfitItem = "slogan_plan_outfit_item" - TableNamePlanEffectImage = "slogan_plan_effect_image" - TableNamePlanReview = "slogan_plan_review" - TableNameScoringRule = "slogan_scoring_rule" - TableNamePartnerStore = "slogan_partner_store" - TableNameMemberPlan = "slogan_member_plan" - TableNamePaymentOrder = "slogan_payment_order" - TableNameUserMember = "slogan_user_member" - TableNamePayNotifyLog = "slogan_pay_notify_log" + TableNameUser = "slogan_user" + TableNameUserPhoto = "slogan_user_photo" + TableNameWardrobeItem = "slogan_wardrobe_item" + TableNameBodyMeasurement = "slogan_body_measurement" + TableNameAvatarModel = "slogan_avatar_model" + TableNameHairstyleAsset = "slogan_hairstyle_asset" + TableNameOutfitGenTask = "slogan_outfit_generation_task" + TableNameOutfitPlan = "slogan_outfit_plan" + TableNamePlanOutfitItem = "slogan_plan_outfit_item" + TableNamePlanEffectImage = "slogan_plan_effect_image" + TableNamePlanReview = "slogan_plan_review" + TableNameScoringRule = "slogan_scoring_rule" + TableNamePartnerStore = "slogan_partner_store" + TableNameMemberPlan = "slogan_member_plan" + TableNamePaymentOrder = "slogan_payment_order" + TableNameUserMember = "slogan_user_member" + TableNamePayNotifyLog = "slogan_pay_notify_log" TableNameAdRewardLog = "slogan_ad_reward_log" TableNameCpsCategory = "slogan_cps_category" TableNameCpsProduct = "slogan_cps_product" diff --git a/server/styleagent/controller/ad_reward_log_controller.go b/server/styleagent/controller/ad_reward_log_controller.go index b5387c4..9f511cf 100644 --- a/server/styleagent/controller/ad_reward_log_controller.go +++ b/server/styleagent/controller/ad_reward_log_controller.go @@ -16,11 +16,5 @@ var Ad = new(ad) // RewardClaim 领取广告激励(限频:effect_extra 每日 2 次 / vip_trial 每日 1 次) func (c *ad) RewardClaim(ctx context.Context, req *dto.AdRewardClaimReq) (res *dto.AdRewardClaimRes, err error) { - result, err := service.AdService.Claim(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.AdType) - if err != nil { - return nil, err - } - return &dto.AdRewardClaimRes{Reward: &dto.AdRewardInfo{ - AdType: result.AdType, RemainingToday: result.RemainingToday, - }}, nil + return service.AdService.Claim(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req) } diff --git a/server/styleagent/controller/avatar_model_controller.go b/server/styleagent/controller/avatar_model_controller.go index 025b5f8..c6e32d1 100644 --- a/server/styleagent/controller/avatar_model_controller.go +++ b/server/styleagent/controller/avatar_model_controller.go @@ -15,25 +15,9 @@ type avatar struct{} var Avatar = new(avatar) func (c *avatar) Build(ctx context.Context, req *dto.AvatarBuildReq) (res *dto.AvatarBuildRes, err error) { - a, err := service.AvatarService.Build(ctx, common.GetUserId(g.RequestFromCtx(ctx))) - if err != nil { - return nil, err - } - return &dto.AvatarBuildRes{AvatarId: a.Id, Status: a.BuildStatus}, nil + return service.AvatarService.Build(ctx, common.GetUserId(g.RequestFromCtx(ctx))) } func (c *avatar) Get(ctx context.Context, req *dto.AvatarGetReq) (res *dto.AvatarGetRes, err error) { - a, err := service.AvatarService.Get(ctx, common.GetUserId(g.RequestFromCtx(ctx))) - if err != nil || a == nil { - return &dto.AvatarGetRes{}, nil - } - return &dto.AvatarGetRes{ - FaceTemplateId: a.FaceTemplateId, - BodyTemplateId: a.BodyTemplateId, - SkinToneIndex: a.SkinToneIndex, - GlbUrl: a.GlbUrl, - FramesUrl: a.FramesUrl, - BuildStatus: a.BuildStatus, - Error: a.Error, - }, nil + return service.AvatarService.Get(ctx, common.GetUserId(g.RequestFromCtx(ctx))) } diff --git a/server/styleagent/controller/body_measurement_controller.go b/server/styleagent/controller/body_measurement_controller.go index 5779ca6..bc33caf 100644 --- a/server/styleagent/controller/body_measurement_controller.go +++ b/server/styleagent/controller/body_measurement_controller.go @@ -5,7 +5,6 @@ import ( "slogan-agent/common" "slogan-agent/styleagent/model/dto" - "slogan-agent/styleagent/model/entity" "slogan-agent/styleagent/service" "github.com/gogf/gf/v2/frame/g" @@ -15,35 +14,10 @@ type body_measurement struct{} var BodyMeasurement = new(body_measurement) -func (c *body_measurement) Save(ctx context.Context, req *dto.BodyMeasurementSaveReq) (res *struct{}, err error) { - if err := service.BodyMeasurementService.Save(ctx, common.GetUserId(g.RequestFromCtx(ctx)), &entity.BodyMeasurement{ - Height: req.Height, - Weight: req.Weight, - SkinTone: req.SkinTone, - Bust: req.Bust, - Waist: req.Waist, - Hip: req.Hip, - Shoulder: req.Shoulder, - FitParams: req.FitParams, - }); err != nil { - return nil, err - } - return &struct{}{}, nil +func (c *body_measurement) Save(ctx context.Context, req *dto.BodyMeasurementSaveReq) (res *dto.BodyMeasurementSaveRes, err error) { + return service.BodyMeasurementService.Save(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req) } func (c *body_measurement) Get(ctx context.Context, req *dto.BodyMeasurementGetReq) (res *dto.BodyMeasurementGetRes, err error) { - b, err := service.BodyMeasurementService.Get(ctx, common.GetUserId(g.RequestFromCtx(ctx))) - if err != nil || b == nil { - return &dto.BodyMeasurementGetRes{}, nil - } - return &dto.BodyMeasurementGetRes{ - Height: b.Height, - Weight: b.Weight, - SkinTone: b.SkinTone, - Bust: b.Bust, - Waist: b.Waist, - Hip: b.Hip, - Shoulder: b.Shoulder, - FitParams: b.FitParams, - }, nil + return service.BodyMeasurementService.Get(ctx, common.GetUserId(g.RequestFromCtx(ctx))) } diff --git a/server/styleagent/controller/cps_category_controller.go b/server/styleagent/controller/cps_category_controller.go index 12edf4f..60c4eca 100644 --- a/server/styleagent/controller/cps_category_controller.go +++ b/server/styleagent/controller/cps_category_controller.go @@ -9,9 +9,5 @@ import ( // CategoryList 联盟分类列表(客户端 chips) func (c *cps) CategoryList(ctx context.Context, req *dto.CpsCategoryListReq) (res *dto.CpsCategoryListRes, err error) { - list, err := service.CpsCategoryService.List(ctx) - if err != nil { - return nil, err - } - return &dto.CpsCategoryListRes{List: list}, nil + return service.CpsCategoryService.List(ctx) } diff --git a/server/styleagent/controller/cps_click_log_controller.go b/server/styleagent/controller/cps_click_log_controller.go index 0bcc1db..5b266f2 100644 --- a/server/styleagent/controller/cps_click_log_controller.go +++ b/server/styleagent/controller/cps_click_log_controller.go @@ -12,9 +12,5 @@ import ( // MyRecent 最近优惠(点击日志 → 商品) func (c *cps) MyRecent(ctx context.Context, req *dto.CpsMyRecentReq) (res *dto.CpsMyRecentRes, err error) { - list, err := service.CpsClickLogService.MyRecent(ctx, common.GetUserId(g.RequestFromCtx(ctx))) - if err != nil { - return nil, err - } - return &dto.CpsMyRecentRes{List: list}, nil + return service.CpsClickLogService.MyRecent(ctx, common.GetUserId(g.RequestFromCtx(ctx))) } diff --git a/server/styleagent/controller/cps_product_controller.go b/server/styleagent/controller/cps_product_controller.go index f391da3..ac41548 100644 --- a/server/styleagent/controller/cps_product_controller.go +++ b/server/styleagent/controller/cps_product_controller.go @@ -17,19 +17,11 @@ var Cps = new(cps) // ProductList 选品池分页列表 func (c *cps) ProductList(ctx context.Context, req *dto.CpsProductListReq) (res *dto.CpsProductListRes, err error) { - list, hasMore, err := service.CpsProductService.ListByCategory(ctx, req.Source, req.CategoryCode, req.City, req.Page, 0) - if err != nil { - return nil, err - } - return &dto.CpsProductListRes{List: list, HasMore: hasMore}, nil + return service.CpsProductService.ListByCategory(ctx, req.Source, req.CategoryCode, req.City, req.Page, 0) } // ProductLink 商品转链(记录点击日志) func (c *cps) ProductLink(ctx context.Context, req *dto.CpsProductLinkReq) (res *dto.CpsProductLinkRes, err error) { r := g.RequestFromCtx(ctx) - link, err := service.CpsProductService.ClickLink(ctx, common.GetUserId(r), req.ProductId, req.Scene, req.PlanId, r.GetClientIp()) - if err != nil { - return nil, err - } - return &dto.CpsProductLinkRes{Deeplink: link}, nil + return service.CpsProductService.ClickLink(ctx, common.GetUserId(r), req.ProductId, req.Scene, req.PlanId, r.GetClientIp()) } diff --git a/server/styleagent/controller/hairstyle_asset_controller.go b/server/styleagent/controller/hairstyle_asset_controller.go index d0f3dfe..a7d0e0f 100644 --- a/server/styleagent/controller/hairstyle_asset_controller.go +++ b/server/styleagent/controller/hairstyle_asset_controller.go @@ -12,9 +12,5 @@ type hairstyle struct{} var Hairstyle = new(hairstyle) func (c *hairstyle) List(ctx context.Context, req *dto.HairstyleListReq) (res *dto.HairstyleListRes, err error) { - list, err := service.HairstyleService.List(ctx) - if err != nil { - return nil, err - } - return &dto.HairstyleListRes{List: list}, nil + return service.HairstyleService.List(ctx) } diff --git a/server/styleagent/controller/member_plan_controller.go b/server/styleagent/controller/member_plan_controller.go index c114091..4e5aab6 100644 --- a/server/styleagent/controller/member_plan_controller.go +++ b/server/styleagent/controller/member_plan_controller.go @@ -18,20 +18,10 @@ var Member = new(member) // PlanList 会员套餐列表 func (c *member) PlanList(ctx context.Context, req *dto.MemberPlanListReq) (res *dto.MemberPlanListRes, err error) { - list, err := service.MemberPlanService.PlanList(ctx) - if err != nil { - return nil, err - } - return &dto.MemberPlanListRes{List: list}, nil + return service.MemberPlanService.PlanList(ctx) } // Status 我的会员状态 func (c *member) Status(ctx context.Context, req *dto.MemberStatusReq) (res *dto.MemberStatusRes, err error) { - st, err := service.MemberPlanService.Status(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx))) - if err != nil { - return nil, err - } - return &dto.MemberStatusRes{ - IsVip: st.IsVip, ExpireAt: st.ExpireAt, PlanName: st.PlanName, Benefits: st.Benefits, - }, nil + return service.MemberPlanService.Status(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx))) } diff --git a/server/styleagent/controller/outfit_generation_task_controller.go b/server/styleagent/controller/outfit_generation_task_controller.go index 63fe6e9..a5c81c1 100644 --- a/server/styleagent/controller/outfit_generation_task_controller.go +++ b/server/styleagent/controller/outfit_generation_task_controller.go @@ -18,18 +18,10 @@ var Outfit = new(outfit) // Generate 生成穿搭方案(异步任务) func (c *outfit) Generate(ctx context.Context, req *dto.OutfitGenerateReq) (res *dto.OutfitGenerateRes, err error) { - taskId, err := service.OutfitService.Generate(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req) - if err != nil { - return nil, err - } - return &dto.OutfitGenerateRes{TaskId: taskId}, nil + return service.OutfitService.Generate(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req) } // TaskStatus 查询生成任务状态 func (c *outfit) TaskStatus(ctx context.Context, req *dto.OutfitTaskStatusReq) (res *dto.OutfitTaskStatusRes, err error) { - status, msg, err := service.OutfitService.GetTaskStatus(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.TaskId) - if err != nil { - return nil, err - } - return &dto.OutfitTaskStatusRes{Status: status, Error: msg}, nil + return service.OutfitService.GetTaskStatus(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.TaskId) } diff --git a/server/styleagent/controller/outfit_plan_controller.go b/server/styleagent/controller/outfit_plan_controller.go index 4efba71..f7f1c33 100644 --- a/server/styleagent/controller/outfit_plan_controller.go +++ b/server/styleagent/controller/outfit_plan_controller.go @@ -12,11 +12,7 @@ import ( // PlanList 方案列表 func (c *outfit) PlanList(ctx context.Context, req *dto.OutfitPlanListReq) (res *dto.OutfitPlanListRes, err error) { - list, err := service.OutfitPlanService.ListPlans(ctx, common.GetUserId(g.RequestFromCtx(ctx))) - if err != nil { - return nil, err - } - return &dto.OutfitPlanListRes{List: list}, nil + return service.OutfitPlanService.ListPlans(ctx, common.GetUserId(g.RequestFromCtx(ctx))) } // PlanDetail 方案详情(items + images + hairstyle) @@ -25,9 +21,6 @@ func (c *outfit) PlanDetail(ctx context.Context, req *dto.OutfitPlanDetailReq) ( } // SelectMain 选定主方案(触发效果图生成) -func (c *outfit) SelectMain(ctx context.Context, req *dto.OutfitSelectMainReq) (res *struct{}, err error) { - if err = service.OutfitPlanService.SelectMain(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.PlanId); err != nil { - return nil, err - } - return &struct{}{}, nil +func (c *outfit) SelectMain(ctx context.Context, req *dto.OutfitSelectMainReq) (res *dto.OutfitSelectMainRes, err error) { + return service.OutfitPlanService.SelectMain(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.PlanId) } diff --git a/server/styleagent/controller/partner_store_controller.go b/server/styleagent/controller/partner_store_controller.go index 1d9f34d..77122bf 100644 --- a/server/styleagent/controller/partner_store_controller.go +++ b/server/styleagent/controller/partner_store_controller.go @@ -13,9 +13,5 @@ var PartnerStore = new(partner_store) // List 合作门店列表 func (c *partner_store) List(ctx context.Context, req *dto.StoreListReq) (res *dto.StoreListRes, err error) { - list, err := service.PartnerStoreService.List(ctx, req.Type) - if err != nil { - return nil, err - } - return &dto.StoreListRes{List: list}, nil + return service.PartnerStoreService.List(ctx, req.Type) } diff --git a/server/styleagent/controller/pay_notify_log_controller.go b/server/styleagent/controller/pay_notify_log_controller.go deleted file mode 100644 index 8bc6444..0000000 --- a/server/styleagent/controller/pay_notify_log_controller.go +++ /dev/null @@ -1,3 +0,0 @@ -package controller - -// pay_notify_log 表控制器:无独立路由 handler(回调日志由 /member/order/notify 审计写入) diff --git a/server/styleagent/controller/payment_order_controller.go b/server/styleagent/controller/payment_order_controller.go index e30358f..dcf06f1 100644 --- a/server/styleagent/controller/payment_order_controller.go +++ b/server/styleagent/controller/payment_order_controller.go @@ -2,7 +2,6 @@ package controller import ( "context" - "errors" "fmt" commonHttp "slogan-agent/common" @@ -10,65 +9,32 @@ import ( "slogan-agent/styleagent/service" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/net/ghttp" ) // OrderCreate 下单 → 返回支付 URL func (c *member) OrderCreate(ctx context.Context, req *dto.MemberOrderCreateReq) (res *dto.MemberOrderCreateRes, err error) { - order, payURL, err := service.PaymentOrderService.CreateMemberOrder(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.PlanId) - if err != nil { - return nil, err - } - return &dto.MemberOrderCreateRes{OrderNo: order.OrderNo, PayUrl: payURL}, nil + return service.PaymentOrderService.CreateMemberOrder(ctx, commonHttp.GetUserId(g.RequestFromCtx(ctx)), req.PlanId) } // OrderStatus 订单状态(App 轮询) func (c *member) OrderStatus(ctx context.Context, req *dto.MemberOrderStatusReq) (res *dto.MemberOrderStatusRes, err error) { - order, err := service.PaymentOrderService.OrderStatus(ctx, req.OrderNo) - if err != nil || order == nil { - return nil, errors.New("订单不存在") - } - paidAt := "" - if order.PaidAt != nil { - paidAt = order.PaidAt.Format("Y-m-d H:i:s") - } - return &dto.MemberOrderStatusRes{Status: order.Status, TradeNo: order.TradeNo, PaidAt: paidAt}, nil + return service.PaymentOrderService.OrderStatus(ctx, req.OrderNo) } -// MemberNotify 虎皮棋支付回调:验签 → 幂等开通 → 返回裸文本 "success" -// 虎皮棋要求回调响应体为字面 "success",故不走统一 JSON 包装(main.go 手动绑定) -func MemberNotify(r *ghttp.Request) { - ctx := r.Context() +// Notify 虎皮棋支付回调:验签/幂等开通在 service,此处仅做 HTTP 协议职责——读取回调参数、 +// 直接写裸文本响应体(虎皮棋要求字面 "success",不走统一 JSON 包装,属"直接写响应体"例外) +func (c *member) Notify(ctx context.Context, req *dto.MemberNotifyReq) (res *dto.MemberNotifyRes, err error) { + r := g.RequestFromCtx(ctx) body := r.GetBodyString() - hash := r.Get("hash").String() - orderNo := r.Get("trade_order_id").String() remoteIP := r.GetClientIp() params := make(map[string]string) for k, v := range r.GetRequestMap() { params[k] = fmt.Sprint(v) } - ok := service.PaymentOrderService.VerifyNotify(params, hash, g.Cfg().MustGet(ctx, "payment.xunhu_appsecret", "").String()) + text := service.PaymentOrderService.HandleNotify(ctx, params, body, remoteIP) - if !ok { - if err := service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, "bad_sign"); err != nil { - g.Log().Warningf(ctx, "写入支付回调日志失败(bad_sign): %v", err) - } - r.Response.Write("fail") - r.ExitAll() - return - } - - state, err := service.PaymentOrderService.HandlePaidNotify(ctx, orderNo, r.Get("transaction_id").String(), body) - if logErr := service.PayNotifyLogService.Insert(ctx, orderNo, body, hash, remoteIP, state); logErr != nil { - g.Log().Warningf(ctx, "写入支付回调日志失败: %v", logErr) - } - // duplicate(幂等重复回调)同样返回 success,避免支付渠道无限重试 - if err != nil || state == "no_order" { - r.Response.Write("fail") - r.ExitAll() - return - } - r.Response.Write("success") + r.Response.Write(text) r.ExitAll() + return nil, nil } diff --git a/server/styleagent/controller/plan_effect_image_controller.go b/server/styleagent/controller/plan_effect_image_controller.go deleted file mode 100644 index 343e2fe..0000000 --- a/server/styleagent/controller/plan_effect_image_controller.go +++ /dev/null @@ -1,4 +0,0 @@ -package controller - -// plan_effect_image 表控制器:无独立路由 handler,逻辑归属 outfit/* 簇 -// (效果图由选定主方案后异步生成,经 /outfit/plan/detail 返回) diff --git a/server/styleagent/controller/plan_outfit_item_controller.go b/server/styleagent/controller/plan_outfit_item_controller.go deleted file mode 100644 index 5de0273..0000000 --- a/server/styleagent/controller/plan_outfit_item_controller.go +++ /dev/null @@ -1,4 +0,0 @@ -package controller - -// plan_outfit_item 表控制器:无独立路由 handler,逻辑归属 outfit/* 簇 -// (方案条目数据由 /outfit/plan/detail 承载) diff --git a/server/styleagent/controller/plan_review_controller.go b/server/styleagent/controller/plan_review_controller.go index bac9985..a2cfadb 100644 --- a/server/styleagent/controller/plan_review_controller.go +++ b/server/styleagent/controller/plan_review_controller.go @@ -11,9 +11,6 @@ import ( ) // Review 方案反馈 -func (c *outfit) Review(ctx context.Context, req *dto.OutfitReviewReq) (res *struct{}, err error) { - if err = service.PlanReviewService.Review(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.PlanId, req.Action, req.Note); err != nil { - return nil, err - } - return &struct{}{}, nil +func (c *outfit) Review(ctx context.Context, req *dto.OutfitReviewReq) (res *dto.OutfitReviewRes, err error) { + return service.PlanReviewService.Review(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req) } diff --git a/server/styleagent/controller/scene_category_map_controller.go b/server/styleagent/controller/scene_category_map_controller.go index 960a140..53f5406 100644 --- a/server/styleagent/controller/scene_category_map_controller.go +++ b/server/styleagent/controller/scene_category_map_controller.go @@ -2,7 +2,6 @@ package controller import ( "context" - "errors" "slogan-agent/common" "slogan-agent/styleagent/model/dto" @@ -13,23 +12,10 @@ import ( // PlanRecommend 方案驱动推荐(发型/买同款/到店试穿/场合) func (c *cps) PlanRecommend(ctx context.Context, req *dto.CpsPlanRecommendReq) (res *dto.CpsPlanRecommendRes, err error) { - userId := common.GetUserId(g.RequestFromCtx(ctx)) - plan, err := service.OutfitPlanService.GetPlan(ctx, userId, req.PlanId) - if err != nil || plan == nil { - return nil, errors.New("方案不存在") - } - list, err := service.SceneCategoryMapService.Recommend(ctx, plan, req.Scene) - if err != nil { - return nil, err - } - return &dto.CpsPlanRecommendRes{List: list}, nil + return service.SceneCategoryMapService.PlanRecommend(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req) } // WardrobeUpgrade 衣橱升级款 func (c *cps) WardrobeUpgrade(ctx context.Context, req *dto.CpsWardrobeUpgradeReq) (res *dto.CpsWardrobeUpgradeRes, err error) { - list, err := service.SceneCategoryMapService.WardrobeUpgrade(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.ItemId) - if err != nil { - return nil, err - } - return &dto.CpsWardrobeUpgradeRes{List: list}, nil + return service.SceneCategoryMapService.WardrobeUpgrade(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req) } diff --git a/server/styleagent/controller/scoring_rule_controller.go b/server/styleagent/controller/scoring_rule_controller.go deleted file mode 100644 index 07219b9..0000000 --- a/server/styleagent/controller/scoring_rule_controller.go +++ /dev/null @@ -1,3 +0,0 @@ -package controller - -// scoring_rule 表控制器:无独立路由 handler,规则在服务端读取(评分阈值/效果图额度) diff --git a/server/styleagent/controller/user_controller.go b/server/styleagent/controller/user_controller.go index 4875868..13e64b2 100644 --- a/server/styleagent/controller/user_controller.go +++ b/server/styleagent/controller/user_controller.go @@ -4,7 +4,6 @@ import ( "context" "slogan-agent/common" - "slogan-agent/styleagent/dao" "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/service" @@ -15,33 +14,18 @@ type user struct{} var User = new(user) -func (c *user) Register(ctx context.Context, req *dto.RegisterReq) (res *struct{}, err error) { - _, err = service.UserService.Register(ctx, req.Account, req.Password, req.Name) - if err != nil { - return nil, err - } - return &struct{}{}, nil +func (c *user) Register(ctx context.Context, req *dto.RegisterReq) (res *dto.RegisterRes, err error) { + return service.UserService.Register(ctx, req) } func (c *user) Login(ctx context.Context, req *dto.LoginReq) (res *dto.LoginRes, err error) { - user, token, err := service.UserService.Login(ctx, req.Account, req.Password) - if err != nil { - return nil, err - } - return &dto.LoginRes{ - Token: token, - User: &dto.LoginUser{Id: user.Id, Role: user.Role, Name: user.Name}, - }, nil + return service.UserService.Login(ctx, req) } -func (c *user) ChangePassword(ctx context.Context, req *dto.ChangePasswordReq) (res *struct{}, err error) { - return nil, service.UserService.ChangePassword(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.OldPassword, req.NewPassword) +func (c *user) ChangePassword(ctx context.Context, req *dto.ChangePasswordReq) (res *dto.ChangePasswordRes, err error) { + return service.UserService.ChangePassword(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req) } func (c *user) Profile(ctx context.Context, req *dto.ProfileReq) (res *dto.ProfileRes, err error) { - user, err := dao.User.GetOne(ctx, common.GetUserId(g.RequestFromCtx(ctx))) - if err != nil || user == nil { - return nil, err - } - return &dto.ProfileRes{Id: user.Id, Role: user.Role, Name: user.Name, Username: user.Username, Phone: user.Phone}, nil + return service.UserService.Profile(ctx, common.GetUserId(g.RequestFromCtx(ctx))) } diff --git a/server/styleagent/controller/user_member_controller.go b/server/styleagent/controller/user_member_controller.go deleted file mode 100644 index 43bf417..0000000 --- a/server/styleagent/controller/user_member_controller.go +++ /dev/null @@ -1,4 +0,0 @@ -package controller - -// user_member 表控制器:无独立路由 handler(会员状态经 /member/status 返回, -// 开通由支付回调与广告激励写入) diff --git a/server/styleagent/controller/user_photo_controller.go b/server/styleagent/controller/user_photo_controller.go index 1d47502..fbed6b6 100644 --- a/server/styleagent/controller/user_photo_controller.go +++ b/server/styleagent/controller/user_photo_controller.go @@ -15,24 +15,13 @@ type user_photo struct{} var UserPhoto = new(user_photo) func (c *user_photo) Upload(ctx context.Context, req *dto.UserPhotoUploadReq) (res *dto.UserPhotoUploadRes, err error) { - id, err := service.UserPhotoService.Upload(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Type, g.RequestFromCtx(ctx).GetUploadFile("file")) - if err != nil { - return nil, err - } - return &dto.UserPhotoUploadRes{Id: id}, nil + return service.UserPhotoService.Upload(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req, g.RequestFromCtx(ctx).GetUploadFile("file")) } func (c *user_photo) List(ctx context.Context, req *dto.UserPhotoListReq) (res *dto.UserPhotoListRes, err error) { - list, err := service.UserPhotoService.List(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Type) - if err != nil { - return nil, err - } - return &dto.UserPhotoListRes{List: list}, nil + return service.UserPhotoService.List(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Type) } -func (c *user_photo) Delete(ctx context.Context, req *dto.UserPhotoDeleteReq) (res *struct{}, err error) { - if err := service.UserPhotoService.Delete(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Id); err != nil { - return nil, err - } - return &struct{}{}, nil +func (c *user_photo) Delete(ctx context.Context, req *dto.UserPhotoDeleteReq) (res *dto.UserPhotoDeleteRes, err error) { + return service.UserPhotoService.Delete(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Id) } diff --git a/server/styleagent/controller/wardrobe_item_controller.go b/server/styleagent/controller/wardrobe_item_controller.go index b8d3ec7..af33fdc 100644 --- a/server/styleagent/controller/wardrobe_item_controller.go +++ b/server/styleagent/controller/wardrobe_item_controller.go @@ -5,7 +5,6 @@ import ( "slogan-agent/common" "slogan-agent/styleagent/model/dto" - "slogan-agent/styleagent/model/entity" "slogan-agent/styleagent/service" "github.com/gogf/gf/v2/frame/g" @@ -16,46 +15,17 @@ type wardrobe struct{} var Wardrobe = new(wardrobe) func (c *wardrobe) Upload(ctx context.Context, req *dto.WardrobeUploadReq) (res *dto.WardrobeUploadRes, err error) { - id, err := service.WardrobeService.Upload(ctx, common.GetUserId(g.RequestFromCtx(ctx)), entity.WardrobeItem{ - Category: req.Category, - Season: req.Season, - StyleTags: req.StyleTags, - ColorInfo: req.ColorInfo, - }, g.RequestFromCtx(ctx).GetUploadFile("file")) - if err != nil { - return nil, err - } - return &dto.WardrobeUploadRes{Id: id}, nil + return service.WardrobeService.Upload(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req, g.RequestFromCtx(ctx).GetUploadFile("file")) } func (c *wardrobe) List(ctx context.Context, req *dto.WardrobeListReq) (res *dto.WardrobeListRes, err error) { - list, err := service.WardrobeService.List(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Category) - if err != nil { - return nil, err - } - return &dto.WardrobeListRes{List: list}, nil + return service.WardrobeService.List(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Category) } -func (c *wardrobe) Update(ctx context.Context, req *dto.WardrobeUpdateReq) (res *struct{}, err error) { - data := map[string]any{} - if req.Category != "" { - data["category"] = req.Category - } - if req.Season != "" { - data["season"] = req.Season - } - if req.StyleTags != "" { - data["style_tags"] = req.StyleTags - } - if err := service.WardrobeService.Update(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Id, data); err != nil { - return nil, err - } - return &struct{}{}, nil +func (c *wardrobe) Update(ctx context.Context, req *dto.WardrobeUpdateReq) (res *dto.WardrobeUpdateRes, err error) { + return service.WardrobeService.Update(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req) } -func (c *wardrobe) Delete(ctx context.Context, req *dto.WardrobeDeleteReq) (res *struct{}, err error) { - if err := service.WardrobeService.Delete(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Id); err != nil { - return nil, err - } - return &struct{}{}, nil +func (c *wardrobe) Delete(ctx context.Context, req *dto.WardrobeDeleteReq) (res *dto.WardrobeDeleteRes, err error) { + return service.WardrobeService.Delete(ctx, common.GetUserId(g.RequestFromCtx(ctx)), req.Id) } diff --git a/server/styleagent/dao/ad_reward_log_dao.go b/server/styleagent/dao/ad_reward_log_dao.go index 1b935be..53cc280 100644 --- a/server/styleagent/dao/ad_reward_log_dao.go +++ b/server/styleagent/dao/ad_reward_log_dao.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slogan-agent/common" "time" "slogan-agent/styleagent/consts" @@ -18,7 +19,7 @@ type adRewardLogDao struct{} func init() { ctx := context.Background() - _, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAdRewardLog+` ( + _, err := common.DbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameAdRewardLog+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL DEFAULT 0, ad_type TEXT NOT NULL DEFAULT '', @@ -31,7 +32,7 @@ func init() { g.Log().Warningf(ctx, "create ad_reward_log table failed: %v", err) } // 唯一索引兜底并发:同一 (user, day, type) 最多 limit 个 slot(如 effect_extra 2 / vip_trial 1) - if _, err := dbPay().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON `+consts.TableNameAdRewardLog+`(user_id, reward_key, slot)`); err != nil { + if _, err := common.DbPay().Exec(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_ad_reward_unique ON `+consts.TableNameAdRewardLog+`(user_id, reward_key, slot)`); err != nil { g.Log().Warningf(ctx, "create index idx_ad_reward_unique failed: %v", err) } } @@ -48,6 +49,7 @@ func (d *adRewardLogDao) InsertTx(ctx context.Context, tx gdb.TX, userId int64, "user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "slot": slot, "status": "ok", }).Insert() if err == nil { + common.CacheClear(ctx, common.DbPay(), consts.TableNameAdRewardLog) return r.LastInsertId() } } @@ -55,7 +57,8 @@ func (d *adRewardLogDao) InsertTx(ctx context.Context, tx gdb.TX, userId int64, } func (d *adRewardLogDao) CountTodayByType(ctx context.Context, userId int64, adType string) (int, error) { - n, err := dbPay().Model(consts.TableNameAdRewardLog).Ctx(ctx). + n, err := common.DbPay().Model(consts.TableNameAdRewardLog).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameAdRewardLog, "CountTodayByType", userId, adType)}). Where("user_id", userId).Where("reward_key", rewardKey(adType)).Count() return int(n), err } @@ -63,10 +66,11 @@ func (d *adRewardLogDao) CountTodayByType(ctx context.Context, userId int64, adT // Insert 领取记录:在 1..limit 的 slot 中找一个空闲位写入;全满(唯一索引冲突)返回错误 → 视为限频 func (d *adRewardLogDao) Insert(ctx context.Context, userId int64, adType string, limit int) (int64, error) { for slot := 1; slot <= limit; slot++ { - r, err := dbPay().Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{ + r, err := common.DbPay().Model(consts.TableNameAdRewardLog).Ctx(ctx).Data(g.Map{ "user_id": userId, "ad_type": adType, "reward_key": rewardKey(adType), "slot": slot, "status": "ok", }).Insert() if err == nil { + common.CacheClear(ctx, common.DbPay(), consts.TableNameAdRewardLog) return r.LastInsertId() } } diff --git a/server/styleagent/dao/avatar_model_dao.go b/server/styleagent/dao/avatar_model_dao.go index 9304135..a98fe69 100644 --- a/server/styleagent/dao/avatar_model_dao.go +++ b/server/styleagent/dao/avatar_model_dao.go @@ -2,10 +2,12 @@ package dao import ( "context" - "strings" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "strings" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -48,20 +50,29 @@ func (d *avatarModelDao) Insert(ctx context.Context, data *entity.AvatarModel) ( if err != nil { return 0, err } + common.CacheClear(ctx, g.DB(), consts.TableNameAvatarModel) return r.LastInsertId() } func (d *avatarModelDao) GetByUser(ctx context.Context, userId int64) (*entity.AvatarModel, error) { var a entity.AvatarModel err := g.DB().Model(consts.TableNameAvatarModel).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameAvatarModel, "GetByUser", userId)}). Where("user_id", userId).OrderDesc("id").Scan(&a) - if err != nil || a.Id == 0 { + if err != nil && !common.IsNotFound(err) { return nil, err } + if a.Id == 0 { + return nil, nil + } return &a, nil } func (d *avatarModelDao) Update(ctx context.Context, id int64, data map[string]any) error { _, err := g.DB().Model(consts.TableNameAvatarModel).Ctx(ctx).Data(data).Where("id", id).Update() - return err + if err != nil { + return err + } + common.CacheClear(ctx, g.DB(), consts.TableNameAvatarModel) + return nil } diff --git a/server/styleagent/dao/body_measurement_dao.go b/server/styleagent/dao/body_measurement_dao.go index 5edb859..875e2e2 100644 --- a/server/styleagent/dao/body_measurement_dao.go +++ b/server/styleagent/dao/body_measurement_dao.go @@ -2,11 +2,13 @@ package dao import ( "context" + "slogan-agent/common" "strings" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -45,6 +47,7 @@ func init() { func (d *bodyMeasurementDao) Save(ctx context.Context, data *entity.BodyMeasurement) error { r, err := g.DB().Model(consts.TableNameBodyMeasurement).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameBodyMeasurement, "Save", data.UserId)}). Where("user_id", data.UserId).One() if err != nil { return err @@ -54,22 +57,34 @@ func (d *bodyMeasurementDao) Save(ctx context.Context, data *entity.BodyMeasurem "INSERT INTO "+consts.TableNameBodyMeasurement+" (user_id, height, weight, skin_tone, bust, waist, hip, shoulder, fit_params, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))", data.UserId, data.Height, data.Weight, data.SkinTone, data.Bust, data.Waist, data.Hip, data.Shoulder, data.FitParams) - return err + if err != nil { + return err + } + common.CacheClear(ctx, g.DB(), consts.TableNameBodyMeasurement) + return nil } _, err = g.DB().Model(consts.TableNameBodyMeasurement).Ctx(ctx).Data(g.Map{ "height": data.Height, "weight": data.Weight, "skin_tone": data.SkinTone, "bust": data.Bust, "waist": data.Waist, "hip": data.Hip, "shoulder": data.Shoulder, "fit_params": data.FitParams, "updated_at": "datetime('now','localtime')", }).Where("user_id", data.UserId).Update() - return err + if err != nil { + return err + } + common.CacheClear(ctx, g.DB(), consts.TableNameBodyMeasurement) + return nil } func (d *bodyMeasurementDao) GetByUser(ctx context.Context, userId int64) (*entity.BodyMeasurement, error) { var b entity.BodyMeasurement err := g.DB().Model(consts.TableNameBodyMeasurement).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameBodyMeasurement, "GetByUser", userId)}). Where("user_id", userId).Scan(&b) - if err != nil || b.Id == 0 { + if err != nil && !common.IsNotFound(err) { return nil, err } + if b.Id == 0 { + return nil, nil + } return &b, nil } diff --git a/server/styleagent/dao/cps_category_dao.go b/server/styleagent/dao/cps_category_dao.go index a06f391..6215ce3 100644 --- a/server/styleagent/dao/cps_category_dao.go +++ b/server/styleagent/dao/cps_category_dao.go @@ -2,9 +2,11 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -14,7 +16,7 @@ type cpsCategoryDao struct{} func init() { ctx := context.Background() - _, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsCategory+` ( + _, err := common.DbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsCategory+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL DEFAULT '', @@ -40,7 +42,7 @@ func seedCpsCategories(ctx context.Context) { {"digital", "数码", consts.CpsSourceJdEcom, ""}, } for i, c := range base { - if _, err := dbCps().Exec(ctx, + if _, err := common.DbCps().Exec(ctx, "INSERT OR IGNORE INTO "+consts.TableNameCpsCategory+ " (code, name, parent_code, source, source_cat_id, sort) VALUES (?, ?, '', ?, ?, ?)", c.code, c.name, c.source, c.sourceCatId, i); err != nil { @@ -51,7 +53,8 @@ func seedCpsCategories(ctx context.Context) { func (d *cpsCategoryDao) List(ctx context.Context) ([]*entity.CpsCategory, error) { var list []*entity.CpsCategory - err := dbCps().Model(consts.TableNameCpsCategory).Ctx(ctx). + err := common.DbCps().Model(consts.TableNameCpsCategory).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameCpsCategory, "List")}). OrderAsc("sort").OrderAsc("id").Scan(&list) return list, err } diff --git a/server/styleagent/dao/cps_click_log_dao.go b/server/styleagent/dao/cps_click_log_dao.go index d7d61ee..320ef23 100644 --- a/server/styleagent/dao/cps_click_log_dao.go +++ b/server/styleagent/dao/cps_click_log_dao.go @@ -2,9 +2,11 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -14,7 +16,7 @@ type cpsClickLogDao struct{} func init() { ctx := context.Background() - _, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsClickLog+` ( + _, err := common.DbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsClickLog+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL DEFAULT 0, source TEXT NOT NULL DEFAULT '', @@ -29,7 +31,7 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create cps_click_log table failed: %v", err) } - _, err = dbCps().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_click_user ON "+ + _, err = common.DbCps().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_click_user ON "+ consts.TableNameCpsClickLog+"(user_id, created_at)") if err != nil { g.Log().Warningf(ctx, "create cps_click_log index failed: %v", err) @@ -37,19 +39,21 @@ func init() { } func (d *cpsClickLogDao) Insert(ctx context.Context, log *entity.CpsClickLog) (int64, error) { - r, err := dbCps().Exec(ctx, "INSERT INTO "+consts.TableNameCpsClickLog+ + r, err := common.DbCps().Exec(ctx, "INSERT INTO "+consts.TableNameCpsClickLog+ " (user_id, source, outer_id, scene, plan_id, category_code, deeplink, ip, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))", log.UserId, log.Source, log.OuterId, log.Scene, log.PlanId, log.CategoryCode, log.Deeplink, log.Ip) if err != nil { return 0, err } + common.CacheClear(ctx, common.DbCps(), consts.TableNameCpsClickLog) return r.LastInsertId() } func (d *cpsClickLogDao) ListByUser(ctx context.Context, userId int64, limit int) ([]*entity.CpsClickLog, error) { var list []*entity.CpsClickLog - err := dbCps().Model(consts.TableNameCpsClickLog).Ctx(ctx). + err := common.DbCps().Model(consts.TableNameCpsClickLog).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameCpsClickLog, "ListByUser", userId, limit)}). Where("user_id", userId).OrderDesc("id").Limit(limit).Scan(&list) return list, err } diff --git a/server/styleagent/dao/cps_product_dao.go b/server/styleagent/dao/cps_product_dao.go index e5d5eac..691391d 100644 --- a/server/styleagent/dao/cps_product_dao.go +++ b/server/styleagent/dao/cps_product_dao.go @@ -2,6 +2,7 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" "strings" @@ -16,7 +17,7 @@ type cpsProductDao struct{} func init() { ctx := context.Background() - _, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsProduct+` ( + _, err := common.DbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameCpsProduct+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT NOT NULL DEFAULT '', outer_id TEXT NOT NULL DEFAULT '', @@ -36,13 +37,13 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create cps_product table failed: %v", err) } - _, err = dbCps().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_product_cat ON "+ + _, err = common.DbCps().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_cps_product_cat ON "+ consts.TableNameCpsProduct+"(source, category_code, status)") if err != nil { g.Log().Warningf(ctx, "create cps_product index failed: %v", err) } // Upsert 的 ON CONFLICT 依赖唯一索引 - _, err = dbCps().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_cps_product_outer ON "+ + _, err = common.DbCps().Exec(ctx, "CREATE UNIQUE INDEX IF NOT EXISTS idx_cps_product_outer ON "+ consts.TableNameCpsProduct+"(source, outer_id)") if err != nil { g.Log().Warningf(ctx, "create cps_product unique index failed: %v", err) @@ -50,7 +51,7 @@ func init() { } func (d *cpsProductDao) Upsert(ctx context.Context, p *entity.CpsProduct) error { - _, err := dbCps().Exec(ctx, `INSERT INTO `+consts.TableNameCpsProduct+ + _, err := common.DbCps().Exec(ctx, `INSERT INTO `+consts.TableNameCpsProduct+ ` (source, outer_id, category_code, name, cover_url, price_fen, shop_name, commission_rate, city, scene_tags, raw, status, sync_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, datetime('now','localtime'), datetime('now','localtime')) ON CONFLICT(source, outer_id) DO UPDATE SET @@ -60,12 +61,17 @@ func (d *cpsProductDao) Upsert(ctx context.Context, p *entity.CpsProduct) error status=1, sync_at=datetime('now','localtime')`, p.Source, p.OuterId, p.CategoryCode, p.Name, p.CoverUrl, p.PriceFen, p.ShopName, p.CommissionRate, p.City, p.SceneTags, p.Raw) - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbCps(), consts.TableNameCpsProduct) + return nil } func (d *cpsProductDao) ListByCategory(ctx context.Context, source, categoryCode, city string, page, pageSize int) ([]*entity.CpsProduct, error) { var list []*entity.CpsProduct - m := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx). + m := common.DbCps().Model(consts.TableNameCpsProduct).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameCpsProduct, "ListByCategory", source, categoryCode, city, page, pageSize)}). Where("status", 1).Where("source", source).Where("category_code", categoryCode) if city != "" { m = m.Where("city", city) @@ -75,7 +81,8 @@ func (d *cpsProductDao) ListByCategory(ctx context.Context, source, categoryCode } func (d *cpsProductDao) CountByCategory(ctx context.Context, source, categoryCode, city string) (int, error) { - m := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx). + m := common.DbCps().Model(consts.TableNameCpsProduct).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameCpsProduct, "CountByCategory", source, categoryCode, city)}). Where("status", 1).Where("source", source).Where("category_code", categoryCode) if city != "" { m = m.Where("city", city) @@ -85,32 +92,35 @@ func (d *cpsProductDao) CountByCategory(ctx context.Context, source, categoryCod func (d *cpsProductDao) Get(ctx context.Context, id int64) (*entity.CpsProduct, error) { var p entity.CpsProduct - err := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx).Where("id", id).Scan(&p) - if err != nil || p.Id == 0 { + err := common.DbCps().Model(consts.TableNameCpsProduct).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameCpsProduct, "Get", id)}). + Where("id", id).Scan(&p) + if err != nil && !common.IsNotFound(err) { return nil, err } + if p.Id == 0 { + return nil, nil + } return &p, nil } // cpsUpsertBatchSize 每批条数:11 参数/条 × 80 = 880 < SQLite 变量上限 999 const cpsUpsertBatchSize = 80 -// UpsertBatch 批量 Upsert(单条 multi-row SQL + ON CONFLICT),每批独立事务,批间失败互不影响 +// UpsertBatch 批量 Upsert(按 cpsUpsertBatchSize 分批 multi-row SQL + ON CONFLICT; +// 单条 INSERT 语句在 SQLite 中本身原子,无需事务包装,dao 层不持事务) func (d *cpsProductDao) UpsertBatch(ctx context.Context, list []*entity.CpsProduct) error { for start := 0; start < len(list); start += cpsUpsertBatchSize { end := start + cpsUpsertBatchSize if end > len(list) { end = len(list) } - batch := list[start:end] - sqlText, args := buildCpsUpsertSQL(batch) - if err := dbCps().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { - _, err := tx.Ctx(ctx).Exec(sqlText, args...) - return err - }); err != nil { + sqlText, args := buildCpsUpsertSQL(list[start:end]) + if _, err := common.DbCps().Exec(ctx, sqlText, args...); err != nil { return err } } + common.CacheClear(ctx, common.DbCps(), consts.TableNameCpsProduct) return nil } @@ -134,10 +144,33 @@ func buildCpsUpsertSQL(batch []*entity.CpsProduct) (string, []any) { // GetByOuter 按联盟来源 + 外部 ID 取商品(点击日志回填商品信息用) func (d *cpsProductDao) GetByOuter(ctx context.Context, source, outerId string) (*entity.CpsProduct, error) { var p entity.CpsProduct - err := dbCps().Model(consts.TableNameCpsProduct).Ctx(ctx). + err := common.DbCps().Model(consts.TableNameCpsProduct).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameCpsProduct, "GetByOuter", source, outerId)}). Where("source", source).Where("outer_id", outerId).Scan(&p) - if err != nil || p.Id == 0 { + if err != nil && !common.IsNotFound(err) { return nil, err } + if p.Id == 0 { + return nil, nil + } return &p, nil } + +// ListByOuters 按来源 + 外键 ID 批量取商品(IN 参数 ≤100 分批,防 SQLite 变量数超限) +func (d *cpsProductDao) ListByOuters(ctx context.Context, source string, outerIds []string) ([]*entity.CpsProduct, error) { + var out []*entity.CpsProduct + for start := 0; start < len(outerIds); start += 100 { + end := start + 100 + if end > len(outerIds) { + end = len(outerIds) + } + var list []*entity.CpsProduct + if err := common.DbCps().Model(consts.TableNameCpsProduct).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameCpsProduct, "ListByOuters", source, outerIds)}). + Where("source", source).WhereIn("outer_id", outerIds[start:end]).Scan(&list); err != nil { + return nil, err + } + out = append(out, list...) + } + return out, nil +} diff --git a/server/styleagent/dao/db.go b/server/styleagent/dao/db.go deleted file mode 100644 index ef3e761..0000000 --- a/server/styleagent/dao/db.go +++ /dev/null @@ -1,11 +0,0 @@ -package dao - -import ( - "github.com/gogf/gf/v2/database/gdb" - "github.com/gogf/gf/v2/frame/g" -) - -// 数据库组归属:DAO 按业务域拆分到独立 SQLite 文件,经所属组访问 -func dbPlan() gdb.DB { return g.DB("plan") } -func dbPay() gdb.DB { return g.DB("pay") } -func dbCps() gdb.DB { return g.DB("cps") } diff --git a/server/styleagent/dao/hairstyle_asset_dao.go b/server/styleagent/dao/hairstyle_asset_dao.go index f9b3c43..3f4327a 100644 --- a/server/styleagent/dao/hairstyle_asset_dao.go +++ b/server/styleagent/dao/hairstyle_asset_dao.go @@ -2,10 +2,12 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" "strings" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -15,7 +17,7 @@ type hairstyleAssetDao struct{} func init() { ctx := context.Background() - _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameHairstyleAsset+` ( + _, err := common.DbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameHairstyleAsset+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL DEFAULT '', style_tag TEXT NOT NULL DEFAULT '', @@ -32,7 +34,7 @@ func init() { } func seedHairstyles(ctx context.Context) { - r, err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Count() + r, err := common.DbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Count() if err != nil || r > 0 { return } @@ -62,7 +64,7 @@ func seedHairstyles(ctx context.Context) { args = append(args, it.name, it.tag, "/workspace/templates/hairstyle_"+itoa(i+1)+".glb", "/workspace/templates/hairstyle_thumb_"+itoa(i+1)+".png", it.face, it.sort) } - if _, err := dbPlan().Exec(ctx, sb.String(), args...); err != nil { + if _, err := common.DbPlan().Exec(ctx, sb.String(), args...); err != nil { g.Log().Warningf(ctx, "seed hairstyle_asset failed: %v", err) } } @@ -83,15 +85,22 @@ func itoa(n int) string { func (d *hairstyleAssetDao) ListAll(ctx context.Context) ([]*entity.HairstyleAsset, error) { var list []*entity.HairstyleAsset - err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).OrderAsc("sort").Scan(&list) + err := common.DbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameHairstyleAsset, "ListAll")}). + OrderAsc("sort").Scan(&list) return list, err } func (d *hairstyleAssetDao) GetOne(ctx context.Context, id int64) (*entity.HairstyleAsset, error) { var h entity.HairstyleAsset - err := dbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx).Where("id", id).Scan(&h) - if err != nil || h.Id == 0 { + err := common.DbPlan().Model(consts.TableNameHairstyleAsset).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameHairstyleAsset, "GetOne", id)}). + Where("id", id).Scan(&h) + if err != nil && !common.IsNotFound(err) { return nil, err } + if h.Id == 0 { + return nil, nil + } return &h, nil } diff --git a/server/styleagent/dao/member_plan_dao.go b/server/styleagent/dao/member_plan_dao.go index 2276b28..a714b66 100644 --- a/server/styleagent/dao/member_plan_dao.go +++ b/server/styleagent/dao/member_plan_dao.go @@ -2,6 +2,7 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" @@ -16,7 +17,7 @@ type memberPlanDao struct{} func init() { ctx := context.Background() - _, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameMemberPlan+` ( + _, err := common.DbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameMemberPlan+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL DEFAULT '', price_fen INTEGER NOT NULL DEFAULT 0, @@ -33,7 +34,7 @@ func init() { } func seedMemberPlans(ctx context.Context) { - r, err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx).Count() + r, err := common.DbPay().Model(consts.TableNameMemberPlan).Ctx(ctx).Count() if err != nil || r > 0 { return } @@ -48,7 +49,7 @@ func seedMemberPlans(ctx context.Context) { {"年卡 ¥199", 19900, 365, `["effect_unlimited","ai_priority","cps_commission_x15","store_discount"]`, 2}, } for _, p := range plans { - if _, err := dbPay().Exec(ctx, + if _, err := common.DbPay().Exec(ctx, "INSERT INTO "+consts.TableNameMemberPlan+" (name, price_fen, duration_days, features, sort, status, created_at) VALUES (?, ?, ?, ?, ?, 1, datetime('now','localtime'))", p.name, p.price, p.days, p.features, p.sort); err != nil { g.Log().Warningf(ctx, "seed member_plan %s failed: %v", p.name, err) @@ -58,22 +59,40 @@ func seedMemberPlans(ctx context.Context) { func (d *memberPlanDao) ListEnabled(ctx context.Context) ([]*entity.MemberPlan, error) { var list []*entity.MemberPlan - err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx). + err := common.DbPay().Model(consts.TableNameMemberPlan).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameMemberPlan, "ListEnabled")}). Where("status", 1).OrderAsc("sort").OrderAsc("id").Scan(&list) - return list, err + if err != nil && !common.IsNotFound(err) { + return nil, err + } + return list, nil } // GetOneTx 事务版本:支付回调事务内读取套餐配置 func (d *memberPlanDao) GetOneTx(ctx context.Context, tx gdb.TX, id int64) (*entity.MemberPlan, error) { var p *entity.MemberPlan err := tx.Model(consts.TableNameMemberPlan).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameMemberPlan, "GetOneTx", id)}). Where("id", id).Where("status", 1).Scan(&p) - return p, err + if err != nil && !common.IsNotFound(err) { + return nil, err + } + if p == nil || p.Id == 0 { + return nil, nil + } + return p, nil } func (d *memberPlanDao) GetOne(ctx context.Context, id int64) (*entity.MemberPlan, error) { var p *entity.MemberPlan - err := dbPay().Model(consts.TableNameMemberPlan).Ctx(ctx). + err := common.DbPay().Model(consts.TableNameMemberPlan).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameMemberPlan, "GetOne", id)}). Where("id", id).Where("status", 1).Scan(&p) - return p, err + if err != nil && !common.IsNotFound(err) { + return nil, err + } + if p == nil || p.Id == 0 { + return nil, nil + } + return p, nil } diff --git a/server/styleagent/dao/outfit_generation_task_dao.go b/server/styleagent/dao/outfit_generation_task_dao.go index 02bb3b6..6566f76 100644 --- a/server/styleagent/dao/outfit_generation_task_dao.go +++ b/server/styleagent/dao/outfit_generation_task_dao.go @@ -2,6 +2,7 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" @@ -15,7 +16,7 @@ type outfitGenTaskDao struct{} func init() { ctx := context.Background() - _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitGenTask+` ( + _, err := common.DbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitGenTask+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, start_date TEXT NOT NULL DEFAULT '', @@ -31,42 +32,55 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create outfit_generation_task table failed: %v", err) } - if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_gen_task_user ON "+consts.TableNameOutfitGenTask+"(user_id, created_at)"); err != nil { + if _, err := common.DbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_gen_task_user ON "+consts.TableNameOutfitGenTask+"(user_id, created_at)"); err != nil { g.Log().Warningf(ctx, "create index idx_slogan_gen_task_user failed: %v", err) } } func (d *outfitGenTaskDao) Insert(ctx context.Context, data *entity.OutfitGenerationTask) (int64, error) { - r, err := dbPlan().Exec(ctx, + r, err := common.DbPlan().Exec(ctx, "INSERT INTO "+consts.TableNameOutfitGenTask+" (user_id, start_date, end_date, location, weather_snapshot, status, error, model_name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))", data.UserId, data.StartDate, data.EndDate, data.Location, data.WeatherSnapshot, data.Status, data.Error, data.ModelName) if err != nil { return 0, err } + common.CacheClear(ctx, common.DbPlan(), consts.TableNameOutfitGenTask) return r.LastInsertId() } func (d *outfitGenTaskDao) GetOne(ctx context.Context, id, userId int64) (*entity.OutfitGenerationTask, error) { var t entity.OutfitGenerationTask - err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx). + err := common.DbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameOutfitGenTask, "GetOne", id, userId)}). Where("id", id).Where("user_id", userId).Scan(&t) - if err != nil || t.Id == 0 { + if err != nil && !common.IsNotFound(err) { return nil, err } + if t.Id == 0 { + return nil, nil + } return &t, nil } func (d *outfitGenTaskDao) Update(ctx context.Context, id int64, data g.Map) error { - _, err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx). + _, err := common.DbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx). Data(data).Where("id", id).Update() - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNameOutfitGenTask) + return nil } func (d *outfitGenTaskDao) UpdateStatus(ctx context.Context, id int64, status, errMsg string) error { - _, err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx).Data(g.Map{ + _, err := common.DbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx).Data(g.Map{ "status": status, "error": errMsg, "updated_at": "datetime('now','localtime')", }).Where("id", id).Update() - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNameOutfitGenTask) + return nil } // UpdateStatusTx 事务版本:方案落库事务内同步任务状态 @@ -74,13 +88,18 @@ func (d *outfitGenTaskDao) UpdateStatusTx(ctx context.Context, tx gdb.TX, id int _, err := tx.Model(consts.TableNameOutfitGenTask).Ctx(ctx).Data(g.Map{ "status": status, "error": errMsg, "updated_at": "datetime('now','localtime')", }).Where("id", id).Update() - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNameOutfitGenTask) + return nil } // ListUnfinished 返回未完成的任务(重启恢复用) func (d *outfitGenTaskDao) ListUnfinished(ctx context.Context) ([]*entity.OutfitGenerationTask, error) { var list []*entity.OutfitGenerationTask - err := dbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx). + err := common.DbPlan().Model(consts.TableNameOutfitGenTask).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameOutfitGenTask, "ListUnfinished")}). Where("status NOT IN (?)", g.Slice{consts.TaskStatusDone, consts.TaskStatusFailed}). OrderAsc("id").Limit(50).Scan(&list) return list, err diff --git a/server/styleagent/dao/outfit_plan_dao.go b/server/styleagent/dao/outfit_plan_dao.go index 1e24c8c..be232d1 100644 --- a/server/styleagent/dao/outfit_plan_dao.go +++ b/server/styleagent/dao/outfit_plan_dao.go @@ -2,6 +2,7 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" @@ -15,7 +16,7 @@ type outfitPlanDao struct{} func init() { ctx := context.Background() - _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitPlan+` ( + _, err := common.DbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameOutfitPlan+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id INTEGER NOT NULL, user_id INTEGER NOT NULL, @@ -34,26 +35,27 @@ func init() { g.Log().Warningf(ctx, "create outfit_plan table failed: %v", err) } // 容错迁移:CREATE TABLE IF NOT EXISTS 不给旧库加列,duplicate column 错误可忽略 - if _, err := dbPlan().Exec(ctx, "ALTER TABLE "+consts.TableNameOutfitPlan+ + if _, err := common.DbPlan().Exec(ctx, "ALTER TABLE "+consts.TableNameOutfitPlan+ " ADD COLUMN occasion TEXT NOT NULL DEFAULT ''"); err != nil { g.Log().Warningf(ctx, "migrate outfit_plan.occasion skipped: %v", err) } - if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_user ON "+consts.TableNameOutfitPlan+"(user_id, created_at)"); err != nil { + if _, err := common.DbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_user ON "+consts.TableNameOutfitPlan+"(user_id, created_at)"); err != nil { g.Log().Warningf(ctx, "create index idx_slogan_plan_user failed: %v", err) } - if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_task ON "+consts.TableNameOutfitPlan+"(task_id)"); err != nil { + if _, err := common.DbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_task ON "+consts.TableNameOutfitPlan+"(task_id)"); err != nil { g.Log().Warningf(ctx, "create index idx_slogan_plan_task failed: %v", err) } } func (d *outfitPlanDao) Insert(ctx context.Context, data *entity.OutfitPlan) (int64, error) { - r, err := dbPlan().Exec(ctx, + r, err := common.DbPlan().Exec(ctx, "INSERT INTO "+consts.TableNameOutfitPlan+" (task_id, user_id, date_range, location, title, source, score, main_flag, hairstyle_id, hair_color, weather_ref, occasion, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))", data.TaskId, data.UserId, data.DateRange, data.Location, data.Title, data.Source, data.Score, data.MainFlag, data.HairstyleId, data.HairColor, data.WeatherRef, data.Occasion) if err != nil { return 0, err } + common.CacheClear(ctx, common.DbPlan(), consts.TableNameOutfitPlan) return r.LastInsertId() } @@ -67,53 +69,76 @@ func (d *outfitPlanDao) InsertTx(ctx context.Context, tx gdb.TX, data *entity.Ou if err != nil { return 0, err } + common.CacheClear(ctx, common.DbPlan(), consts.TableNameOutfitPlan) return r.LastInsertId() } func (d *outfitPlanDao) ClearMainFlagTx(ctx context.Context, tx gdb.TX, taskId int64) error { _, err := tx.Model(consts.TableNameOutfitPlan).Ctx(ctx). Data(g.Map{"main_flag": 0}).Where("task_id", taskId).Update() - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNameOutfitPlan) + return nil } func (d *outfitPlanDao) SetMainFlagTx(ctx context.Context, tx gdb.TX, id int64) error { _, err := tx.Model(consts.TableNameOutfitPlan).Ctx(ctx). Data(g.Map{"main_flag": 1}).Where("id", id).Update() - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNameOutfitPlan) + return nil } func (d *outfitPlanDao) ListByUser(ctx context.Context, userId int64) ([]*entity.OutfitPlan, error) { var list []*entity.OutfitPlan - err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). + err := common.DbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameOutfitPlan, "ListByUser", userId)}). Where("user_id", userId).OrderDesc("id").Limit(50).Scan(&list) return list, err } func (d *outfitPlanDao) ListByTask(ctx context.Context, taskId int64) ([]*entity.OutfitPlan, error) { var list []*entity.OutfitPlan - err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). + err := common.DbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameOutfitPlan, "ListByTask", taskId)}). Where("task_id", taskId).OrderAsc("id").Scan(&list) return list, err } func (d *outfitPlanDao) GetOne(ctx context.Context, id, userId int64) (*entity.OutfitPlan, error) { var p entity.OutfitPlan - err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). + err := common.DbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameOutfitPlan, "GetOne", id, userId)}). Where("id", id).Where("user_id", userId).Scan(&p) - if err != nil || p.Id == 0 { + if err != nil && !common.IsNotFound(err) { return nil, err } + if p.Id == 0 { + return nil, nil + } return &p, nil } func (d *outfitPlanDao) ClearMainFlag(ctx context.Context, taskId int64) error { - _, err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). + _, err := common.DbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). Data(g.Map{"main_flag": 0}).Where("task_id", taskId).Update() - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNameOutfitPlan) + return nil } func (d *outfitPlanDao) SetMainFlag(ctx context.Context, id int64) error { - _, err := dbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). + _, err := common.DbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). Data(g.Map{"main_flag": 1}).Where("id", id).Update() - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNameOutfitPlan) + return nil } diff --git a/server/styleagent/dao/partner_store_dao.go b/server/styleagent/dao/partner_store_dao.go index 083c398..2f85498 100644 --- a/server/styleagent/dao/partner_store_dao.go +++ b/server/styleagent/dao/partner_store_dao.go @@ -2,9 +2,11 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -56,7 +58,9 @@ func seedStores(ctx context.Context) { } func (d *partnerStoreDao) List(ctx context.Context, storeType int) ([]*entity.PartnerStore, error) { - m := g.DB().Model(consts.TableNamePartnerStore).Ctx(ctx).Where("status", 1) + m := g.DB().Model(consts.TableNamePartnerStore).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNamePartnerStore, "List", storeType)}). + Where("status", 1) if storeType > 0 { m = m.Where("type", storeType) } diff --git a/server/styleagent/dao/pay_notify_log_dao.go b/server/styleagent/dao/pay_notify_log_dao.go deleted file mode 100644 index c495652..0000000 --- a/server/styleagent/dao/pay_notify_log_dao.go +++ /dev/null @@ -1,38 +0,0 @@ -package dao - -import ( - "context" - - "slogan-agent/styleagent/consts" - "slogan-agent/styleagent/model/entity" - - "github.com/gogf/gf/v2/frame/g" -) - -var PayNotifyLog = &payNotifyLogDao{} - -type payNotifyLogDao struct{} - -func init() { - ctx := context.Background() - _, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePayNotifyLog+` ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_no TEXT NOT NULL DEFAULT '', - body TEXT NOT NULL DEFAULT '', - sign TEXT NOT NULL DEFAULT '', - remote_ip TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'ok', - created_at DATETIME DEFAULT (datetime('now','localtime')) - )`) - if err != nil { - g.Log().Warningf(ctx, "create pay_notify_log table failed: %v", err) - } -} - -func (d *payNotifyLogDao) Insert(ctx context.Context, log *entity.PayNotifyLog) error { - _, err := dbPay().Model(consts.TableNamePayNotifyLog).Ctx(ctx).Data(g.Map{ - "order_no": log.OrderNo, "body": log.Body, "sign": log.Sign, - "remote_ip": log.RemoteIp, "status": log.Status, - }).Insert() - return err -} diff --git a/server/styleagent/dao/payment_order_dao.go b/server/styleagent/dao/payment_order_dao.go index ab77f14..8267be9 100644 --- a/server/styleagent/dao/payment_order_dao.go +++ b/server/styleagent/dao/payment_order_dao.go @@ -2,6 +2,7 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" @@ -16,7 +17,7 @@ type paymentOrderDao struct{} func init() { ctx := context.Background() - _, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePaymentOrder+` ( + _, err := common.DbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePaymentOrder+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, order_no TEXT NOT NULL UNIQUE, user_id INTEGER NOT NULL DEFAULT 0, @@ -32,38 +33,62 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create payment_order table failed: %v", err) } - if _, err := dbPay().Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_payment_order_user ON `+consts.TableNamePaymentOrder+`(user_id, created_at)`); err != nil { + if _, err := common.DbPay().Exec(ctx, `CREATE INDEX IF NOT EXISTS idx_payment_order_user ON `+consts.TableNamePaymentOrder+`(user_id, created_at)`); err != nil { g.Log().Warningf(ctx, "create index idx_payment_order_user failed: %v", err) } + // pay_notify_log 为记录类豁免表(不建独立分层),建表由主表 dao 统一管理 + if _, err := common.DbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePayNotifyLog+` ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_no TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL DEFAULT '', + sign TEXT NOT NULL DEFAULT '', + remote_ip TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'ok', + created_at DATETIME DEFAULT (datetime('now','localtime')) + )`); err != nil { + g.Log().Warningf(ctx, "create pay_notify_log table failed: %v", err) + } } func (d *paymentOrderDao) Insert(ctx context.Context, order *entity.PaymentOrder) (int64, error) { - r, err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx).Data(g.Map{ + r, err := common.DbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx).Data(g.Map{ "order_no": order.OrderNo, "user_id": order.UserId, "plan_id": order.PlanId, "amount_fen": order.AmountFen, "channel": order.Channel, "status": order.Status, }).Insert() if err != nil { return 0, err } + common.CacheClear(ctx, common.DbPay(), consts.TableNamePaymentOrder) return r.LastInsertId() } func (d *paymentOrderDao) GetByOrderNo(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) { var o *entity.PaymentOrder - err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx). + err := common.DbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNamePaymentOrder, "GetByOrderNo", orderNo)}). Where("order_no", orderNo).Scan(&o) - return o, err + if err != nil && !common.IsNotFound(err) { + return nil, err + } + if o == nil || o.Id == 0 { + return nil, nil + } + return o, nil } // MarkPaid 状态机 pending→paid(只更新 pending 行,返回是否成功,回调并发安全) func (d *paymentOrderDao) MarkPaid(ctx context.Context, orderNo, tradeNo, notifyRaw string) (bool, error) { - r, err := dbPay().Exec(ctx, + r, err := common.DbPay().Exec(ctx, "UPDATE "+consts.TableNamePaymentOrder+" SET status=?, trade_no=?, notify_raw=?, paid_at=datetime('now','localtime') WHERE order_no=? AND status=?", consts.PayStatusPaid, tradeNo, notifyRaw, orderNo, consts.PayStatusPending) if err != nil { return false, err } - n, _ := r.RowsAffected() + n, err := r.RowsAffected() + if err != nil { + return false, err + } + common.CacheClear(ctx, common.DbPay(), consts.TableNamePaymentOrder) return n > 0, nil } @@ -71,8 +96,16 @@ func (d *paymentOrderDao) MarkPaid(ctx context.Context, orderNo, tradeNo, notify func (d *paymentOrderDao) GetByOrderNoTx(ctx context.Context, tx gdb.TX, orderNo string) (*entity.PaymentOrder, error) { var o *entity.PaymentOrder - err := tx.Model(consts.TableNamePaymentOrder).Ctx(ctx).Where("order_no", orderNo).Scan(&o) - return o, err + err := tx.Model(consts.TableNamePaymentOrder).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNamePaymentOrder, "GetByOrderNoTx", orderNo)}). + Where("order_no", orderNo).Scan(&o) + if err != nil && !common.IsNotFound(err) { + return nil, err + } + if o == nil || o.Id == 0 { + return nil, nil + } + return o, nil } func (d *paymentOrderDao) MarkPaidTx(ctx context.Context, tx gdb.TX, orderNo, tradeNo, notifyRaw string) (bool, error) { @@ -82,13 +115,21 @@ func (d *paymentOrderDao) MarkPaidTx(ctx context.Context, tx gdb.TX, orderNo, tr if err != nil { return false, err } - n, _ := r.RowsAffected() + n, err := r.RowsAffected() + if err != nil { + return false, err + } + common.CacheClear(ctx, common.DbPay(), consts.TableNamePaymentOrder) return n > 0, nil } func (d *paymentOrderDao) GetByUser(ctx context.Context, userId int64) ([]*entity.PaymentOrder, error) { var list []*entity.PaymentOrder - err := dbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx). + err := common.DbPay().Model(consts.TableNamePaymentOrder).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNamePaymentOrder, "GetByUser", userId)}). Where("user_id", userId).OrderDesc("id").Limit(20).Scan(&list) - return list, err + if err != nil && !common.IsNotFound(err) { + return nil, err + } + return list, nil } diff --git a/server/styleagent/dao/plan_effect_image_dao.go b/server/styleagent/dao/plan_effect_image_dao.go index 0c0e934..5dd88c9 100644 --- a/server/styleagent/dao/plan_effect_image_dao.go +++ b/server/styleagent/dao/plan_effect_image_dao.go @@ -2,10 +2,12 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" "strings" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -15,7 +17,7 @@ type planEffectImageDao struct{} func init() { ctx := context.Background() - _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanEffectImage+` ( + _, err := common.DbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanEffectImage+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id INTEGER NOT NULL, angle TEXT NOT NULL DEFAULT '', @@ -28,18 +30,19 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create plan_effect_image table failed: %v", err) } - if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_effect_plan ON "+consts.TableNamePlanEffectImage+"(plan_id)"); err != nil { + if _, err := common.DbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_effect_plan ON "+consts.TableNamePlanEffectImage+"(plan_id)"); err != nil { g.Log().Warningf(ctx, "create index idx_slogan_effect_plan failed: %v", err) } } func (d *planEffectImageDao) Insert(ctx context.Context, data *entity.PlanEffectImage) (int64, error) { - r, err := dbPlan().Exec(ctx, + r, err := common.DbPlan().Exec(ctx, "INSERT INTO "+consts.TableNamePlanEffectImage+" (plan_id, angle, url, status, prompt_snapshot, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))", data.PlanId, data.Angle, data.Url, data.Status, data.PromptSnapshot) if err != nil { return 0, err } + common.CacheClear(ctx, common.DbPlan(), consts.TableNamePlanEffectImage) return r.LastInsertId() } @@ -59,38 +62,71 @@ func (d *planEffectImageDao) InsertBatch(ctx context.Context, list []*entity.Pla sb.WriteString("(?,?,?,?,?,datetime('now','localtime'),datetime('now','localtime'))") args = append(args, it.PlanId, it.Angle, it.Url, it.Status, it.PromptSnapshot) } - _, err := dbPlan().Exec(ctx, sb.String(), args...) - return err + _, err := common.DbPlan().Exec(ctx, sb.String(), args...) + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNamePlanEffectImage) + return nil } func (d *planEffectImageDao) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanEffectImage, error) { var list []*entity.PlanEffectImage - err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx). + err := common.DbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNamePlanEffectImage, "ListByPlan", planId)}). Where("plan_id", planId).OrderAsc("id").Scan(&list) return list, err } -// CountByUserToday 统计用户当日已生成的效果图数量(join outfit_plan 拿 user_id) +// CountByUserToday 统计用户当日已生成的效果图数量 +// (先取用户方案 id 列表,再对效果图单表 IN 统计,拆两条单表 SQL,禁 JOIN) func (d *planEffectImageDao) CountByUserToday(ctx context.Context, userId int64) (int, error) { - n, err := dbPlan().Model(consts.TableNamePlanEffectImage+" p"). - InnerJoin(consts.TableNameOutfitPlan+" o", "p.plan_id = o.id"). - Ctx(ctx). - Where("o.user_id", userId). - Where("date(p.created_at) = date('now','localtime')"). - Where("p.status IN (?)", g.Slice{consts.EffectStatusDone, consts.EffectStatusRendering}). - Count() - return n, err + var planIds []int64 + if err := common.DbPlan().Model(consts.TableNameOutfitPlan).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameOutfitPlan, "CountByUserToday", userId)}). + Where("user_id", userId).Fields("id").Scan(&planIds); err != nil { + return 0, err + } + if len(planIds) == 0 { + return 0, nil + } + total := 0 + for start := 0; start < len(planIds); start += 100 { + end := start + 100 + if end > len(planIds) { + end = len(planIds) + } + n, err := common.DbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNamePlanEffectImage, "CountByUserToday", userId, planIds[start:end])}). + WhereIn("plan_id", planIds[start:end]). + Where("date(created_at) = date('now','localtime')"). + WhereIn("status", g.Slice{consts.EffectStatusDone, consts.EffectStatusRendering}). + Count() + if err != nil { + return 0, err + } + total += n + } + return total, nil } func (d *planEffectImageDao) UpdateStatus(ctx context.Context, id int64, status, url string) error { - _, err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx).Data(g.Map{ + _, err := common.DbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx).Data(g.Map{ "status": status, "url": url, "updated_at": "datetime('now','localtime')", }).Where("id", id).Update() - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNamePlanEffectImage) + return nil } func (d *planEffectImageDao) DeleteByPlan(ctx context.Context, planId int64) error { - _, err := dbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx). + _, err := common.DbPlan().Model(consts.TableNamePlanEffectImage).Ctx(ctx). Unscoped().Where("plan_id", planId).Delete() - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNamePlanEffectImage) + return nil } diff --git a/server/styleagent/dao/plan_outfit_item_dao.go b/server/styleagent/dao/plan_outfit_item_dao.go index 2b737db..d387666 100644 --- a/server/styleagent/dao/plan_outfit_item_dao.go +++ b/server/styleagent/dao/plan_outfit_item_dao.go @@ -2,6 +2,7 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" "strings" @@ -16,7 +17,7 @@ type planOutfitItemDao struct{} func init() { ctx := context.Background() - _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanOutfitItem+` ( + _, err := common.DbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanOutfitItem+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id INTEGER NOT NULL, slot TEXT NOT NULL DEFAULT '', @@ -30,18 +31,19 @@ func init() { if err != nil { g.Log().Warningf(ctx, "create plan_outfit_item table failed: %v", err) } - if _, err := dbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_item ON "+consts.TableNamePlanOutfitItem+"(plan_id)"); err != nil { + if _, err := common.DbPlan().Exec(ctx, "CREATE INDEX IF NOT EXISTS idx_slogan_plan_item ON "+consts.TableNamePlanOutfitItem+"(plan_id)"); err != nil { g.Log().Warningf(ctx, "create index idx_slogan_plan_item failed: %v", err) } } func (d *planOutfitItemDao) Insert(ctx context.Context, data *entity.PlanOutfitItem) (int64, error) { - r, err := dbPlan().Exec(ctx, + r, err := common.DbPlan().Exec(ctx, "INSERT INTO "+consts.TableNamePlanOutfitItem+" (plan_id, slot, source, wardrobe_item_id, product_name, name, desc, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now','localtime'))", data.PlanId, data.Slot, data.Source, data.WardrobeItemId, data.ProductName, data.Name, data.Desc) if err != nil { return 0, err } + common.CacheClear(ctx, common.DbPlan(), consts.TableNamePlanOutfitItem) return r.LastInsertId() } @@ -62,18 +64,27 @@ func (d *planOutfitItemDao) InsertBatchTx(ctx context.Context, tx gdb.TX, items args = append(args, it.PlanId, it.Slot, it.Source, it.WardrobeItemId, it.ProductName, it.Name, it.Desc) } _, err := tx.Ctx(ctx).Exec(sb.String(), args...) - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNamePlanOutfitItem) + return nil } func (d *planOutfitItemDao) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanOutfitItem, error) { var list []*entity.PlanOutfitItem - err := dbPlan().Model(consts.TableNamePlanOutfitItem).Ctx(ctx). + err := common.DbPlan().Model(consts.TableNamePlanOutfitItem).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNamePlanOutfitItem, "ListByPlan", planId)}). Where("plan_id", planId).OrderAsc("id").Scan(&list) return list, err } func (d *planOutfitItemDao) DeleteByPlan(ctx context.Context, planId int64) error { - _, err := dbPlan().Model(consts.TableNamePlanOutfitItem).Ctx(ctx). + _, err := common.DbPlan().Model(consts.TableNamePlanOutfitItem).Ctx(ctx). Unscoped().Where("plan_id", planId).Delete() - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPlan(), consts.TableNamePlanOutfitItem) + return nil } diff --git a/server/styleagent/dao/plan_review_dao.go b/server/styleagent/dao/plan_review_dao.go index bab431f..3cf8235 100644 --- a/server/styleagent/dao/plan_review_dao.go +++ b/server/styleagent/dao/plan_review_dao.go @@ -2,9 +2,11 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -14,7 +16,7 @@ type planReviewDao struct{} func init() { ctx := context.Background() - _, err := dbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanReview+` ( + _, err := common.DbPlan().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNamePlanReview+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, plan_id INTEGER NOT NULL, user_id INTEGER NOT NULL, @@ -28,18 +30,20 @@ func init() { } func (d *planReviewDao) Insert(ctx context.Context, data *entity.PlanReview) (int64, error) { - r, err := dbPlan().Exec(ctx, + r, err := common.DbPlan().Exec(ctx, "INSERT INTO "+consts.TableNamePlanReview+" (plan_id, user_id, action, note, created_at) VALUES (?, ?, ?, ?, datetime('now','localtime'))", data.PlanId, data.UserId, data.Action, data.Note) if err != nil { return 0, err } + common.CacheClear(ctx, common.DbPlan(), consts.TableNamePlanReview) return r.LastInsertId() } func (d *planReviewDao) ListByUserAndPlan(ctx context.Context, userId, planId int64) ([]*entity.PlanReview, error) { var list []*entity.PlanReview - err := dbPlan().Model(consts.TableNamePlanReview).Ctx(ctx). + err := common.DbPlan().Model(consts.TableNamePlanReview).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNamePlanReview, "ListByUserAndPlan", userId, planId)}). Where("user_id", userId).Where("plan_id", planId).OrderDesc("id").Limit(20).Scan(&list) return list, err } diff --git a/server/styleagent/dao/scene_category_map_dao.go b/server/styleagent/dao/scene_category_map_dao.go index 5d871f8..66f4408 100644 --- a/server/styleagent/dao/scene_category_map_dao.go +++ b/server/styleagent/dao/scene_category_map_dao.go @@ -2,9 +2,11 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -14,7 +16,7 @@ type sceneCategoryMapDao struct{} func init() { ctx := context.Background() - _, err := dbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameSceneCategoryMap+` ( + _, err := common.DbCps().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameSceneCategoryMap+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, scene_type TEXT NOT NULL DEFAULT '', occasion TEXT NOT NULL DEFAULT '', @@ -40,7 +42,7 @@ func seedSceneCategoryMap(ctx context.Context) { {SceneType: consts.CpsSceneOccasion, Occasion: "运动", Source: consts.CpsSourceMeituanOta, CategoryCode: "ticket", Priority: 1}, } for _, s := range seeds { - if _, err := dbCps().Exec(ctx, + if _, err := common.DbCps().Exec(ctx, "INSERT OR IGNORE INTO "+consts.TableNameSceneCategoryMap+ " (scene_type, occasion, source, category_code, priority) VALUES (?, ?, ?, ?, ?)", s.SceneType, s.Occasion, s.Source, s.CategoryCode, s.Priority); err != nil { @@ -52,7 +54,9 @@ func seedSceneCategoryMap(ctx context.Context) { // QueryByScene 场景 → 映射列表(occasion 精确匹配优先,通用匹配兜底) func (d *sceneCategoryMapDao) QueryByScene(ctx context.Context, sceneType, occasion string) ([]*entity.SceneCategoryMap, error) { var list []*entity.SceneCategoryMap - m := dbCps().Model(consts.TableNameSceneCategoryMap).Ctx(ctx).Where("scene_type", sceneType) + m := common.DbCps().Model(consts.TableNameSceneCategoryMap).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameSceneCategoryMap, "QueryByScene", sceneType, occasion)}). + Where("scene_type", sceneType) if occasion != "" { m = m.Where("occasion", occasion).OrderAsc("priority") } else { diff --git a/server/styleagent/dao/scoring_rule_dao.go b/server/styleagent/dao/scoring_rule_dao.go index b29b939..555d51c 100644 --- a/server/styleagent/dao/scoring_rule_dao.go +++ b/server/styleagent/dao/scoring_rule_dao.go @@ -2,9 +2,11 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -31,6 +33,7 @@ func init() { func (d *scoringRuleDao) ListEnabled(ctx context.Context) ([]*entity.ScoringRule, error) { var list []*entity.ScoringRule err := g.DB().Model(consts.TableNameScoringRule).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameScoringRule, "ListEnabled")}). Where("enabled", 1).OrderAsc("id").Scan(&list) return list, err } diff --git a/server/styleagent/dao/user_dao.go b/server/styleagent/dao/user_dao.go index 87a1f21..8c0ecd3 100644 --- a/server/styleagent/dao/user_dao.go +++ b/server/styleagent/dao/user_dao.go @@ -8,8 +8,6 @@ import ( "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" - "github.com/gogf/gf/v2/os/gcache" - "github.com/gogf/gf/v2/util/gconv" ) var User = &userDao{} @@ -39,11 +37,6 @@ func init() { } } -func clearUserCache(ctx context.Context, id int64) { - _, _ = gcache.Remove(ctx, "user_GetOne_"+gconv.String(id)) - _, _ = gcache.Remove(ctx, "user_GetByAccount_") -} - func (d *userDao) Insert(ctx context.Context, data *entity.User) (int64, error) { r, err := g.DB().Exec(ctx, "INSERT INTO "+consts.TableNameUser+" (role, username, phone, password, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime'))", @@ -51,15 +44,16 @@ func (d *userDao) Insert(ctx context.Context, data *entity.User) (int64, error) if err != nil { return 0, err } + common.CacheClear(ctx, g.DB(), consts.TableNameUser) return r.LastInsertId() } func (d *userDao) GetOne(ctx context.Context, id int64) (*entity.User, error) { var u entity.User err := g.DB().Model(consts.TableNameUser).Ctx(ctx). - Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: "user_GetOne_" + gconv.String(id)}). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameUser, "GetOne", id)}). Where("id", id).Scan(&u) - if err != nil { + if err != nil && !common.IsNotFound(err) { return nil, err } if u.Id == 0 { @@ -71,9 +65,9 @@ func (d *userDao) GetOne(ctx context.Context, id int64) (*entity.User, error) { func (d *userDao) GetByAccount(ctx context.Context, account string) (*entity.User, error) { var u entity.User err := g.DB().Model(consts.TableNameUser).Ctx(ctx). - Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: "user_GetByAccount_" + account}). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameUser, "GetByAccount", account)}). Where("username = ? OR phone = ?", account, account).Scan(&u) - if err != nil { + if err != nil && !common.IsNotFound(err) { return nil, err } if u.Id == 0 { @@ -84,12 +78,18 @@ func (d *userDao) GetByAccount(ctx context.Context, account string) (*entity.Use func (d *userDao) Update(ctx context.Context, data *entity.User) error { _, err := g.DB().Model(consts.TableNameUser).Ctx(ctx).Data(data).Where("id", data.Id).Update() - clearUserCache(ctx, data.Id) - return err + if err != nil { + return err + } + common.CacheClear(ctx, g.DB(), consts.TableNameUser) + return nil } func (d *userDao) UpdateFields(ctx context.Context, id int64, data g.Map) error { _, err := g.DB().Model(consts.TableNameUser).Ctx(ctx).Data(data).Where("id", id).Update() - clearUserCache(ctx, id) - return err + if err != nil { + return err + } + common.CacheClear(ctx, g.DB(), consts.TableNameUser) + return nil } diff --git a/server/styleagent/dao/user_member_dao.go b/server/styleagent/dao/user_member_dao.go index 0a718f8..d03152b 100644 --- a/server/styleagent/dao/user_member_dao.go +++ b/server/styleagent/dao/user_member_dao.go @@ -2,6 +2,7 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" @@ -16,7 +17,7 @@ type userMemberDao struct{} func init() { ctx := context.Background() - _, err := dbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUserMember+` ( + _, err := common.DbPay().Exec(ctx, `CREATE TABLE IF NOT EXISTS `+consts.TableNameUserMember+` ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL UNIQUE, plan_id INTEGER NOT NULL DEFAULT 0, @@ -32,26 +33,44 @@ func init() { func (d *userMemberDao) GetByUser(ctx context.Context, userId int64) (*entity.UserMember, error) { var m *entity.UserMember - err := dbPay().Model(consts.TableNameUserMember).Ctx(ctx). + err := common.DbPay().Model(consts.TableNameUserMember).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameUserMember, "GetByUser", userId)}). Where("user_id", userId).Scan(&m) - return m, err + if err != nil && !common.IsNotFound(err) { + return nil, err + } + if m == nil || m.Id == 0 { + return nil, nil + } + return m, nil } // Upsert 无则插入有则整体覆盖(expire_at 由 Service 算好传入) func (d *userMemberDao) Upsert(ctx context.Context, userId, planId int64, expireAt, source string) error { - _, err := dbPay().Exec(ctx, + _, err := common.DbPay().Exec(ctx, "INSERT INTO "+consts.TableNameUserMember+" (user_id, plan_id, expire_at, source, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime')) "+ "ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id, expire_at=excluded.expire_at, source=excluded.source, updated_at=datetime('now','localtime')", userId, planId, expireAt, source) - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPay(), consts.TableNameUserMember) + return nil } // GetByUserTx 事务版本:事务内读取当前会员状态,避免跨连接读到并发中间态 func (d *userMemberDao) GetByUserTx(ctx context.Context, tx gdb.TX, userId int64) (*entity.UserMember, error) { var m *entity.UserMember err := tx.Model(consts.TableNameUserMember).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameUserMember, "GetByUserTx", userId)}). Where("user_id", userId).Scan(&m) - return m, err + if err != nil && !common.IsNotFound(err) { + return nil, err + } + if m == nil || m.Id == 0 { + return nil, nil + } + return m, nil } // UpsertTx 事务版本:支付回调/广告奖励流程使用,保证与订单状态原子 @@ -60,12 +79,17 @@ func (d *userMemberDao) UpsertTx(ctx context.Context, tx gdb.TX, userId, planId "INSERT INTO "+consts.TableNameUserMember+" (user_id, plan_id, expire_at, source, created_at, updated_at) VALUES (?, ?, ?, ?, datetime('now','localtime'), datetime('now','localtime')) "+ "ON CONFLICT(user_id) DO UPDATE SET plan_id=excluded.plan_id, expire_at=excluded.expire_at, source=excluded.source, updated_at=datetime('now','localtime')", userId, planId, expireAt, source) - return err + if err != nil { + return err + } + common.CacheClear(ctx, common.DbPay(), consts.TableNameUserMember) + return nil } // IsVip 当前是否会员(未过期) func (d *userMemberDao) IsVip(ctx context.Context, userId int64) bool { - n, err := dbPay().Model(consts.TableNameUserMember).Ctx(ctx). + n, err := common.DbPay().Model(consts.TableNameUserMember).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameUserMember, "IsVip", userId)}). Where("user_id", userId).Where("expire_at > datetime('now','localtime')").Count() return err == nil && n > 0 } diff --git a/server/styleagent/dao/user_photo_dao.go b/server/styleagent/dao/user_photo_dao.go index 962394d..2a79958 100644 --- a/server/styleagent/dao/user_photo_dao.go +++ b/server/styleagent/dao/user_photo_dao.go @@ -2,9 +2,11 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -37,11 +39,14 @@ func (d *userPhotoDao) Insert(ctx context.Context, data *entity.UserPhoto) (int6 if err != nil { return 0, err } + common.CacheClear(ctx, g.DB(), consts.TableNameUserPhoto) return r.LastInsertId() } func (d *userPhotoDao) ListByUser(ctx context.Context, userId int64, photoType int) ([]*entity.UserPhoto, error) { - m := g.DB().Model(consts.TableNameUserPhoto).Ctx(ctx).Where("user_id", userId).Where("status", 1) + m := g.DB().Model(consts.TableNameUserPhoto).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameUserPhoto, "ListByUser", userId, photoType)}). + Where("user_id", userId).Where("status", 1) if photoType > 0 { m = m.Where("type", photoType) } @@ -53,14 +58,22 @@ func (d *userPhotoDao) ListByUser(ctx context.Context, userId int64, photoType i func (d *userPhotoDao) GetOne(ctx context.Context, id, userId int64) (*entity.UserPhoto, error) { var p entity.UserPhoto err := g.DB().Model(consts.TableNameUserPhoto).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameUserPhoto, "GetOne", id, userId)}). Where("id", id).Where("user_id", userId).Scan(&p) - if err != nil || p.Id == 0 { + if err != nil && !common.IsNotFound(err) { return nil, err } + if p.Id == 0 { + return nil, nil + } return &p, nil } func (d *userPhotoDao) Delete(ctx context.Context, id int64) error { _, err := g.DB().Model(consts.TableNameUserPhoto).Ctx(ctx).Unscoped().Where("id", id).Delete() - return err + if err != nil { + return err + } + common.CacheClear(ctx, g.DB(), consts.TableNameUserPhoto) + return nil } diff --git a/server/styleagent/dao/wardrobe_item_dao.go b/server/styleagent/dao/wardrobe_item_dao.go index 682d2b1..36f8d62 100644 --- a/server/styleagent/dao/wardrobe_item_dao.go +++ b/server/styleagent/dao/wardrobe_item_dao.go @@ -2,9 +2,11 @@ package dao import ( "context" + "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/model/entity" + "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" ) @@ -45,11 +47,14 @@ func (d *wardrobeItemDao) Insert(ctx context.Context, data *entity.WardrobeItem) if err != nil { return 0, err } + common.CacheClear(ctx, g.DB(), consts.TableNameWardrobeItem) return r.LastInsertId() } func (d *wardrobeItemDao) ListByUser(ctx context.Context, userId int64, category string) ([]*entity.WardrobeItem, error) { - m := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx).Where("user_id", userId).Where("status", 1) + m := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameWardrobeItem, "ListByUser", userId, category)}). + Where("user_id", userId).Where("status", 1) if category != "" { m = m.Where("category", category) } @@ -61,6 +66,7 @@ func (d *wardrobeItemDao) ListByUser(ctx context.Context, userId int64, category func (d *wardrobeItemDao) ListAllByUser(ctx context.Context, userId int64) ([]*entity.WardrobeItem, error) { var list []*entity.WardrobeItem err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameWardrobeItem, "ListAllByUser", userId)}). Where("user_id", userId).Where("status", 1).OrderAsc("id").Scan(&list) return list, err } @@ -68,19 +74,31 @@ func (d *wardrobeItemDao) ListAllByUser(ctx context.Context, userId int64) ([]*e func (d *wardrobeItemDao) GetOne(ctx context.Context, id, userId int64) (*entity.WardrobeItem, error) { var w entity.WardrobeItem err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx). + Cache(gdb.CacheOption{Duration: common.CacheTTL(), Name: common.CacheName(consts.TableNameWardrobeItem, "GetOne", id, userId)}). Where("id", id).Where("user_id", userId).Scan(&w) - if err != nil || w.Id == 0 { + if err != nil && !common.IsNotFound(err) { return nil, err } + if w.Id == 0 { + return nil, nil + } return &w, nil } func (d *wardrobeItemDao) Update(ctx context.Context, id int64, data map[string]any) error { _, err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx).Data(data).Where("id", id).Update() - return err + if err != nil { + return err + } + common.CacheClear(ctx, g.DB(), consts.TableNameWardrobeItem) + return nil } func (d *wardrobeItemDao) Delete(ctx context.Context, id int64) error { _, err := g.DB().Model(consts.TableNameWardrobeItem).Ctx(ctx).Unscoped().Where("id", id).Delete() - return err + if err != nil { + return err + } + common.CacheClear(ctx, g.DB(), consts.TableNameWardrobeItem) + return nil } diff --git a/server/styleagent/model/dto/body_measurement_dto.go b/server/styleagent/model/dto/body_measurement_dto.go index 4473efc..add9f5f 100644 --- a/server/styleagent/model/dto/body_measurement_dto.go +++ b/server/styleagent/model/dto/body_measurement_dto.go @@ -4,6 +4,8 @@ import ( "github.com/gogf/gf/v2/frame/g" ) +type BodyMeasurementSaveRes struct{} + type BodyMeasurementSaveReq struct { g.Meta `path:"/save" method:"post" tags:"身形" summary:"保存身形参数"` Height int `json:"height"` diff --git a/server/styleagent/model/dto/cps_category_dto.go b/server/styleagent/model/dto/cps_category_dto.go index b54855a..0d7cc1e 100644 --- a/server/styleagent/model/dto/cps_category_dto.go +++ b/server/styleagent/model/dto/cps_category_dto.go @@ -1,8 +1,6 @@ package dto import ( - "slogan-agent/styleagent/model/entity" - "github.com/gogf/gf/v2/frame/g" ) @@ -11,5 +9,5 @@ type CpsCategoryListReq struct { } type CpsCategoryListRes struct { - List []*entity.CpsCategory `json:"list"` + List []*CpsCategoryItem `json:"list"` } diff --git a/server/styleagent/model/dto/cps_click_log_dto.go b/server/styleagent/model/dto/cps_click_log_dto.go index b1f901f..364bfc8 100644 --- a/server/styleagent/model/dto/cps_click_log_dto.go +++ b/server/styleagent/model/dto/cps_click_log_dto.go @@ -1,8 +1,6 @@ package dto import ( - "slogan-agent/styleagent/model/entity" - "github.com/gogf/gf/v2/frame/g" ) @@ -11,5 +9,5 @@ type CpsMyRecentReq struct { } type CpsMyRecentRes struct { - List []*entity.CpsProduct `json:"list"` + List []*CpsProductItem `json:"list"` } diff --git a/server/styleagent/model/dto/cps_product_dto.go b/server/styleagent/model/dto/cps_product_dto.go index 0ee7838..9c521ee 100644 --- a/server/styleagent/model/dto/cps_product_dto.go +++ b/server/styleagent/model/dto/cps_product_dto.go @@ -1,8 +1,6 @@ package dto import ( - "slogan-agent/styleagent/model/entity" - "github.com/gogf/gf/v2/frame/g" ) @@ -15,8 +13,8 @@ type CpsProductListReq struct { } type CpsProductListRes struct { - List []*entity.CpsProduct `json:"list"` - HasMore bool `json:"has_more"` + List []*CpsProductItem `json:"list"` + HasMore bool `json:"has_more"` } type CpsProductLinkReq struct { diff --git a/server/styleagent/model/dto/hairstyle_asset_dto.go b/server/styleagent/model/dto/hairstyle_asset_dto.go index c611f4b..0a5358e 100644 --- a/server/styleagent/model/dto/hairstyle_asset_dto.go +++ b/server/styleagent/model/dto/hairstyle_asset_dto.go @@ -1,8 +1,6 @@ package dto import ( - "slogan-agent/styleagent/model/entity" - "github.com/gogf/gf/v2/frame/g" ) @@ -11,5 +9,5 @@ type HairstyleListReq struct { } type HairstyleListRes struct { - List []*entity.HairstyleAsset `json:"list"` + List []*HairstyleAssetItem `json:"list"` } diff --git a/server/styleagent/model/dto/items.go b/server/styleagent/model/dto/items.go new file mode 100644 index 0000000..352ebc5 --- /dev/null +++ b/server/styleagent/model/dto/items.go @@ -0,0 +1,130 @@ +package dto + +import "github.com/gogf/gf/v2/os/gtime" + +// 接口响应 item:与 entity 字段一一镜像(wire 格式一致), +// 保证接口出入参一律由 dto 描述、service 组装,entity 不外泄到 HTTP 层 + +type UserPhotoItem struct { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + Type int `json:"type"` + Url string `json:"url"` + Status int `json:"status"` + CreatedAt *gtime.Time `json:"created_at"` +} + +type MemberPlanItem struct { + Id int64 `json:"id"` + Name string `json:"name"` + PriceFen int64 `json:"price_fen"` + DurationDays int `json:"duration_days"` + Features string `json:"features"` + Sort int `json:"sort"` + Status int `json:"status"` + CreatedAt *gtime.Time `json:"created_at"` +} + +type WardrobeItem struct { + Id int64 `json:"id"` + UserId int64 `json:"user_id"` + PhotoUrl string `json:"photo_url"` + Name string `json:"name"` + Category string `json:"category"` + Season string `json:"season"` + StyleTags string `json:"style_tags"` + ColorInfo string `json:"color_info"` + Status int `json:"status"` + CreatedAt *gtime.Time `json:"created_at"` +} + +type CpsCategoryItem struct { + Id int64 `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + ParentCode string `json:"parent_code"` + Source string `json:"source"` + SourceCatId string `json:"source_cat_id"` + Sort int `json:"sort"` + CreatedAt *gtime.Time `json:"created_at"` +} + +type CpsProductItem struct { + Id int64 `json:"id"` + Source string `json:"source"` + OuterId string `json:"outer_id"` + CategoryCode string `json:"category_code"` + Name string `json:"name"` + CoverUrl string `json:"cover_url"` + PriceFen int64 `json:"price_fen"` + ShopName string `json:"shop_name"` + CommissionRate int `json:"commission_rate"` + City string `json:"city"` + SceneTags string `json:"scene_tags"` + Status int `json:"status"` + SyncAt *gtime.Time `json:"sync_at"` + CreatedAt *gtime.Time `json:"created_at"` +} + +type HairstyleAssetItem struct { + Id int64 `json:"id"` + Name string `json:"name"` + StyleTag string `json:"style_tag"` + GlbUrl string `json:"glb_url"` + ThumbUrl string `json:"thumb_url"` + ApplicableFace string `json:"applicable_face"` + Sort int `json:"sort"` + CreatedAt *gtime.Time `json:"created_at"` +} + +type PartnerStoreItem struct { + Id int64 `json:"id"` + Name string `json:"name"` + Type int `json:"type"` + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` + Address string `json:"address"` + CommissionPolicy string `json:"commission_policy"` + Status int `json:"status"` + CreatedAt *gtime.Time `json:"created_at"` +} + +type OutfitPlanItem struct { + Id int64 `json:"id"` + TaskId int64 `json:"task_id"` + UserId int64 `json:"user_id"` + DateRange string `json:"date_range"` + Location string `json:"location"` + Title string `json:"title"` + Source string `json:"source"` + Score int `json:"score"` + MainFlag int `json:"main_flag"` + HairstyleId int64 `json:"hairstyle_id"` + HairColor string `json:"hair_color"` + WeatherRef string `json:"weather_ref"` + Occasion string `json:"occasion"` + CreatedAt *gtime.Time `json:"created_at"` +} + +type PlanOutfitItem struct { + Id int64 `json:"id"` + PlanId int64 `json:"plan_id"` + Slot string `json:"slot"` + Source string `json:"source"` + WardrobeItemId int64 `json:"wardrobe_item_id"` + ProductName string `json:"product_name"` + Name string `json:"name"` + Desc string `json:"desc"` + CreatedAt *gtime.Time `json:"created_at"` +} + +type PlanEffectImageItem struct { + Id int64 `json:"id"` + PlanId int64 `json:"plan_id"` + Angle string `json:"angle"` + Url string `json:"url"` + Status string `json:"status"` + PromptSnapshot string `json:"prompt_snapshot"` + CreatedAt *gtime.Time `json:"created_at"` + UpdatedAt *gtime.Time `json:"updated_at"` +} diff --git a/server/styleagent/model/dto/member_plan_dto.go b/server/styleagent/model/dto/member_plan_dto.go index 28496df..527e1fc 100644 --- a/server/styleagent/model/dto/member_plan_dto.go +++ b/server/styleagent/model/dto/member_plan_dto.go @@ -1,8 +1,6 @@ package dto import ( - "slogan-agent/styleagent/model/entity" - "github.com/gogf/gf/v2/frame/g" ) @@ -11,7 +9,7 @@ type MemberPlanListReq struct { } type MemberPlanListRes struct { - List []*entity.MemberPlan `json:"list"` + List []*MemberPlanItem `json:"list"` } type MemberStatusReq struct { diff --git a/server/styleagent/model/dto/outfit_plan_dto.go b/server/styleagent/model/dto/outfit_plan_dto.go index 4c4af89..74c4247 100644 --- a/server/styleagent/model/dto/outfit_plan_dto.go +++ b/server/styleagent/model/dto/outfit_plan_dto.go @@ -1,8 +1,6 @@ package dto import ( - "slogan-agent/styleagent/model/entity" - "github.com/gogf/gf/v2/frame/g" ) @@ -11,7 +9,7 @@ type OutfitPlanListReq struct { } type OutfitPlanListRes struct { - List []*entity.OutfitPlan `json:"list"` + List []*OutfitPlanItem `json:"list"` } type OutfitPlanDetailReq struct { @@ -20,13 +18,15 @@ type OutfitPlanDetailReq struct { } type OutfitPlanDetailRes struct { - Plan *entity.OutfitPlan `json:"plan"` - Items []*entity.PlanOutfitItem `json:"items"` - Images []*entity.PlanEffectImage `json:"images"` - Hairstyle *entity.HairstyleAsset `json:"hairstyle,omitempty"` + Plan *OutfitPlanItem `json:"plan"` + Items []*PlanOutfitItem `json:"items"` + Images []*PlanEffectImageItem `json:"images"` + Hairstyle *HairstyleAssetItem `json:"hairstyle,omitempty"` } type OutfitSelectMainReq struct { g.Meta `path:"/plan/select-main" method:"post" tags:"穿搭" summary:"选定主方案"` PlanId int64 `v:"required" json:"plan_id"` } + +type OutfitSelectMainRes struct{} diff --git a/server/styleagent/model/dto/partner_store_dto.go b/server/styleagent/model/dto/partner_store_dto.go index 967de48..a86bc26 100644 --- a/server/styleagent/model/dto/partner_store_dto.go +++ b/server/styleagent/model/dto/partner_store_dto.go @@ -1,8 +1,6 @@ package dto import ( - "slogan-agent/styleagent/model/entity" - "github.com/gogf/gf/v2/frame/g" ) @@ -12,5 +10,5 @@ type StoreListReq struct { } type StoreListRes struct { - List []*entity.PartnerStore `json:"list"` + List []*PartnerStoreItem `json:"list"` } diff --git a/server/styleagent/model/dto/pay_notify_log_dto.go b/server/styleagent/model/dto/pay_notify_log_dto.go deleted file mode 100644 index 3868774..0000000 --- a/server/styleagent/model/dto/pay_notify_log_dto.go +++ /dev/null @@ -1,3 +0,0 @@ -package dto - -// 支付回调日志(pay_notify_log)为服务端记录,无 HTTP 接口。 diff --git a/server/styleagent/model/dto/payment_order_dto.go b/server/styleagent/model/dto/payment_order_dto.go index 7303d04..cad600a 100644 --- a/server/styleagent/model/dto/payment_order_dto.go +++ b/server/styleagent/model/dto/payment_order_dto.go @@ -24,3 +24,10 @@ type MemberOrderStatusRes struct { TradeNo string `json:"trade_no"` PaidAt string `json:"paid_at"` } + +// MemberNotifyReq 支付回调(裸文本 "success" 响应,不走统一 JSON 包装) +type MemberNotifyReq struct { + g.Meta `path:"/order/notify" method:"post" tags:"会员" summary:"支付回调"` +} + +type MemberNotifyRes struct{} diff --git a/server/styleagent/model/dto/plan_effect_image_dto.go b/server/styleagent/model/dto/plan_effect_image_dto.go deleted file mode 100644 index e4f8d1e..0000000 --- a/server/styleagent/model/dto/plan_effect_image_dto.go +++ /dev/null @@ -1,3 +0,0 @@ -package dto - -// 方案效果图(plan_effect_image)无独立 HTTP 接口,由方案选定流程在服务端生成,客户端经方案详情获取。 diff --git a/server/styleagent/model/dto/plan_outfit_item_dto.go b/server/styleagent/model/dto/plan_outfit_item_dto.go deleted file mode 100644 index fba399e..0000000 --- a/server/styleagent/model/dto/plan_outfit_item_dto.go +++ /dev/null @@ -1,3 +0,0 @@ -package dto - -// 方案穿搭项(plan_outfit_item)无独立 HTTP 接口,请求/响应经 outfit_plan_dto.go 透传。 diff --git a/server/styleagent/model/dto/plan_review_dto.go b/server/styleagent/model/dto/plan_review_dto.go index 7493c00..c2b5fe8 100644 --- a/server/styleagent/model/dto/plan_review_dto.go +++ b/server/styleagent/model/dto/plan_review_dto.go @@ -10,3 +10,5 @@ type OutfitReviewReq struct { Action string `v:"required|in:fav,unfav" json:"action"` Note string `json:"note"` } + +type OutfitReviewRes struct{} diff --git a/server/styleagent/model/dto/scene_category_map_dto.go b/server/styleagent/model/dto/scene_category_map_dto.go index 839eaf7..beab5bf 100644 --- a/server/styleagent/model/dto/scene_category_map_dto.go +++ b/server/styleagent/model/dto/scene_category_map_dto.go @@ -1,8 +1,6 @@ package dto import ( - "slogan-agent/styleagent/model/entity" - "github.com/gogf/gf/v2/frame/g" ) @@ -13,7 +11,7 @@ type CpsPlanRecommendReq struct { } type CpsPlanRecommendRes struct { - List []*entity.CpsProduct `json:"list"` + List []*CpsProductItem `json:"list"` } type CpsWardrobeUpgradeReq struct { @@ -22,5 +20,5 @@ type CpsWardrobeUpgradeReq struct { } type CpsWardrobeUpgradeRes struct { - List []*entity.CpsProduct `json:"list"` + List []*CpsProductItem `json:"list"` } diff --git a/server/styleagent/model/dto/scoring_rule_dto.go b/server/styleagent/model/dto/scoring_rule_dto.go deleted file mode 100644 index 598681b..0000000 --- a/server/styleagent/model/dto/scoring_rule_dto.go +++ /dev/null @@ -1,3 +0,0 @@ -package dto - -// 评分规则(scoring_rule)为服务端内部配置,无 HTTP 接口。 diff --git a/server/styleagent/model/dto/user_dto.go b/server/styleagent/model/dto/user_dto.go index f23f863..ded0f05 100644 --- a/server/styleagent/model/dto/user_dto.go +++ b/server/styleagent/model/dto/user_dto.go @@ -15,6 +15,10 @@ type LoginRes struct { User *LoginUser `json:"user"` } +type RegisterRes struct{} + +type ChangePasswordRes struct{} + type LoginUser struct { Id int64 `json:"id"` Role string `json:"role"` diff --git a/server/styleagent/model/dto/user_member_dto.go b/server/styleagent/model/dto/user_member_dto.go deleted file mode 100644 index a59c18b..0000000 --- a/server/styleagent/model/dto/user_member_dto.go +++ /dev/null @@ -1,3 +0,0 @@ -package dto - -// 用户会员关系(user_member)由支付回调/下单流程维护,状态经 member_plan_dto.go 返回。 diff --git a/server/styleagent/model/dto/user_photo_dto.go b/server/styleagent/model/dto/user_photo_dto.go index 20a61fb..43eecd5 100644 --- a/server/styleagent/model/dto/user_photo_dto.go +++ b/server/styleagent/model/dto/user_photo_dto.go @@ -1,8 +1,6 @@ package dto import ( - "slogan-agent/styleagent/model/entity" - "github.com/gogf/gf/v2/frame/g" ) @@ -21,10 +19,12 @@ type UserPhotoListReq struct { } type UserPhotoListRes struct { - List []*entity.UserPhoto `json:"list"` + List []*UserPhotoItem `json:"list"` } type UserPhotoDeleteReq struct { g.Meta `path:"/delete" method:"post" tags:"照片" summary:"删除照片"` Id int64 `v:"required" json:"id"` } + +type UserPhotoDeleteRes struct{} diff --git a/server/styleagent/model/dto/wardrobe_item_dto.go b/server/styleagent/model/dto/wardrobe_item_dto.go index e3c70bd..6174f56 100644 --- a/server/styleagent/model/dto/wardrobe_item_dto.go +++ b/server/styleagent/model/dto/wardrobe_item_dto.go @@ -1,8 +1,6 @@ package dto import ( - "slogan-agent/styleagent/model/entity" - "github.com/gogf/gf/v2/frame/g" ) @@ -24,7 +22,7 @@ type WardrobeListReq struct { } type WardrobeListRes struct { - List []*entity.WardrobeItem `json:"list"` + List []*WardrobeItem `json:"list"` } type WardrobeUpdateReq struct { @@ -39,3 +37,7 @@ type WardrobeDeleteReq struct { g.Meta `path:"/delete" method:"post" tags:"衣橱" summary:"删除服装"` Id int64 `v:"required" json:"id"` } + +type WardrobeUpdateRes struct{} + +type WardrobeDeleteRes struct{} diff --git a/server/styleagent/model/entity/cps_category.go b/server/styleagent/model/entity/cps_category.go index c5a023e..f72c28b 100644 --- a/server/styleagent/model/entity/cps_category.go +++ b/server/styleagent/model/entity/cps_category.go @@ -3,12 +3,12 @@ package entity import "github.com/gogf/gf/v2/os/gtime" type CpsCategory struct { - Id int64 `orm:"id" json:"id"` - Code string `orm:"code" json:"code"` - Name string `orm:"name" json:"name"` - ParentCode string `orm:"parent_code" json:"parent_code"` - Source string `orm:"source" json:"source"` - SourceCatId string `orm:"source_cat_id" json:"source_cat_id"` - Sort int `orm:"sort" json:"sort"` - CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` + Id int64 `orm:"id" json:"id"` + Code string `orm:"code" json:"code"` + Name string `orm:"name" json:"name"` + ParentCode string `orm:"parent_code" json:"parent_code"` + Source string `orm:"source" json:"source"` + SourceCatId string `orm:"source_cat_id" json:"source_cat_id"` + Sort int `orm:"sort" json:"sort"` + CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` } diff --git a/server/styleagent/model/entity/member_plan.go b/server/styleagent/model/entity/member_plan.go index 9263f50..84bfc3a 100644 --- a/server/styleagent/model/entity/member_plan.go +++ b/server/styleagent/model/entity/member_plan.go @@ -5,7 +5,7 @@ import "github.com/gogf/gf/v2/os/gtime" type MemberPlan struct { Id int64 `orm:"id" json:"id"` Name string `orm:"name" json:"name"` - PriceFen int `orm:"price_fen" json:"price_fen"` + PriceFen int64 `orm:"price_fen" json:"price_fen"` DurationDays int `orm:"duration_days" json:"duration_days"` Features string `orm:"features" json:"features"` // 权益 JSON 数组字符串 Sort int `orm:"sort" json:"sort"` diff --git a/server/styleagent/model/entity/pay_notify_log.go b/server/styleagent/model/entity/pay_notify_log.go deleted file mode 100644 index 6c8622f..0000000 --- a/server/styleagent/model/entity/pay_notify_log.go +++ /dev/null @@ -1,13 +0,0 @@ -package entity - -import "github.com/gogf/gf/v2/os/gtime" - -type PayNotifyLog struct { - Id int64 `orm:"id" json:"id"` - OrderNo string `orm:"order_no" json:"order_no"` - Body string `orm:"body" json:"body"` - Sign string `orm:"sign" json:"sign"` - RemoteIp string `orm:"remote_ip" json:"remote_ip"` - Status string `orm:"status" json:"status"` - CreatedAt *gtime.Time `orm:"created_at" json:"created_at"` -} diff --git a/server/styleagent/model/entity/payment_order.go b/server/styleagent/model/entity/payment_order.go index 9ac72bd..9630e0c 100644 --- a/server/styleagent/model/entity/payment_order.go +++ b/server/styleagent/model/entity/payment_order.go @@ -7,7 +7,7 @@ type PaymentOrder struct { OrderNo string `orm:"order_no" json:"order_no"` UserId int64 `orm:"user_id" json:"user_id"` PlanId int64 `orm:"plan_id" json:"plan_id"` - AmountFen int `orm:"amount_fen" json:"amount_fen"` + AmountFen int64 `orm:"amount_fen" json:"amount_fen"` Channel string `orm:"channel" json:"channel"` Status string `orm:"status" json:"status"` TradeNo string `orm:"trade_no" json:"trade_no"` diff --git a/server/styleagent/service/ad_reward_log_service.go b/server/styleagent/service/ad_reward_log_service.go index 8be976e..9df5870 100644 --- a/server/styleagent/service/ad_reward_log_service.go +++ b/server/styleagent/service/ad_reward_log_service.go @@ -6,6 +6,7 @@ import ( "slogan-agent/styleagent/consts" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/frame/g" @@ -15,13 +16,9 @@ type adService struct{} var AdService = new(adService) -type AdRewardResult struct { - AdType string `json:"ad_type"` - RemainingToday int `json:"remaining_today"` -} - // Claim 领取广告激励:服务端限频计数,不信任客户端 -func (s *adService) Claim(ctx context.Context, userId int64, adType string) (*AdRewardResult, error) { +func (s *adService) Claim(ctx context.Context, userId int64, req *dto.AdRewardClaimReq) (*dto.AdRewardClaimRes, error) { + adType := req.AdType if adType != consts.AdTypeEffectExtra && adType != consts.AdTypeVipTrial { return nil, errors.New("无效的广告类型") } @@ -46,7 +43,9 @@ func (s *adService) Claim(ctx context.Context, userId int64, adType string) (*Ad if err != nil { return nil, errors.New("今日次数已用完") // 唯一索引兜底并发 } - return &AdRewardResult{AdType: adType, RemainingToday: limit - used - 1}, nil + return &dto.AdRewardClaimRes{Reward: &dto.AdRewardInfo{ + AdType: adType, RemainingToday: limit - used - 1, + }}, nil } func rewardQuota(ctx context.Context, adType string) int { diff --git a/server/styleagent/service/avatar_model_service.go b/server/styleagent/service/avatar_model_service.go index 89a42c8..d91b1f5 100644 --- a/server/styleagent/service/avatar_model_service.go +++ b/server/styleagent/service/avatar_model_service.go @@ -7,10 +7,13 @@ import ( "fmt" "path/filepath" "strings" + "time" + "slogan-agent/common" "slogan-agent/styleagent/agent" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" "github.com/gogf/gf/v2/frame/g" @@ -22,7 +25,7 @@ type avatarService struct{} var AvatarService = new(avatarService) // Build 构建化身:校验三视角全身照 → 写库(processing)→ 异步 Tripo 图像转 3D -func (s *avatarService) Build(ctx context.Context, userId int64) (*entity.AvatarModel, error) { +func (s *avatarService) Build(ctx context.Context, userId int64) (*dto.AvatarBuildRes, error) { photos, err := dao.UserPhoto.ListByUser(ctx, userId, 0) if err != nil { return nil, err @@ -52,36 +55,58 @@ func (s *avatarService) Build(ctx context.Context, userId int64) (*entity.Avatar "body": bodyMap, }) - var record *entity.AvatarModel - existing, err := dao.AvatarModel.GetByUser(ctx, userId) - if err != nil { - return nil, err - } - if existing != nil { - if err := dao.AvatarModel.Update(ctx, existing.Id, map[string]any{ - "face_template_id": 0, "body_template_id": 0, "skin_tone_index": 0, - "glb_url": "", "frames_url": "", - "build_status": consts.AvatarBuildProcessing, "error": "", - "params_snapshot": snapshot, - }); err != nil { - return nil, err + // 防重复构建:同用户并发 Build 只允许一个提交 Tripo 任务(用户维度锁,仅覆盖快速 DB 段) + outcome, err := common.WithLock(ctx, fmt.Sprintf("avatar_build_%d", userId), 30*time.Second, 3, 200*time.Millisecond, func() (avatarBuildOutcome, error) { + existing, err := dao.AvatarModel.GetByUser(ctx, userId) + if err != nil { + return avatarBuildOutcome{}, err + } + if existing != nil { + if existing.BuildStatus == consts.AvatarBuildProcessing { + return avatarBuildOutcome{id: existing.Id}, nil + } + if err := dao.AvatarModel.Update(ctx, existing.Id, map[string]any{ + "face_template_id": 0, "body_template_id": 0, "skin_tone_index": 0, + "glb_url": "", "frames_url": "", + "build_status": consts.AvatarBuildProcessing, "error": "", + "params_snapshot": snapshot, + }); err != nil { + return avatarBuildOutcome{}, err + } + return avatarBuildOutcome{id: existing.Id, fresh: true}, nil } - record = existing - } else { id, err := dao.AvatarModel.Insert(ctx, &entity.AvatarModel{ UserId: userId, BuildStatus: consts.AvatarBuildProcessing, ParamsSnapshot: snapshot, }) if err != nil { + return avatarBuildOutcome{}, err + } + return avatarBuildOutcome{id: id, fresh: true}, nil + }) + if err != nil { + return nil, err + } + if outcome.fresh { + if err := common.Submit(gctx.New(), "avatar", consts.DefaultAvatarPoolSize, func(ctx context.Context) { + s.buildJob(ctx, outcome.id, userId) + }); err != nil { + if uerr := dao.AvatarModel.Update(ctx, outcome.id, map[string]any{ + "build_status": consts.AvatarBuildFailed, "error": "任务提交失败,请重试", + }); uerr != nil { + g.Log().Warningf(ctx, "标记化身构建失败状态失败: %v", uerr) + } return nil, err } - record = &entity.AvatarModel{Id: id, UserId: userId} } + return &dto.AvatarBuildRes{AvatarId: outcome.id, Status: consts.AvatarBuildProcessing}, nil +} - go s.buildJob(gctx.New(), record.Id, userId) - record.BuildStatus = consts.AvatarBuildProcessing - return record, nil +// avatarBuildOutcome WithLock 回调产出:id 为化身记录主键,fresh 表示是否真正发起新构建 +type avatarBuildOutcome struct { + id int64 + fresh bool } // buildJob 异步构建:Tripo 上传三视角照片 → 提交任务 → 轮询 → 下载 GLB →(可选)渲染旋转帧 @@ -157,16 +182,8 @@ func (s *avatarService) buildJob(ctx context.Context, id, userId int64) { return } - framesURL := "" - if g.Cfg().MustGet(ctx, "avatar.render_frames", true).Bool() { - framesURL, err = agent.RenderAvatarFrames(ctx, glbPath, fmt.Sprintf("user_%d", userId)) - if err != nil { - g.Log().Warningf(ctx, "avatar frames render skipped: %v", err) - } - } - if dbErr := dao.AvatarModel.Update(ctx, id, map[string]any{ - "glb_url": "/" + filepath.ToSlash(glbPath), "frames_url": framesURL, + "glb_url": "/" + filepath.ToSlash(glbPath), "build_status": consts.AvatarBuildDone, "error": "", "updated_at": "datetime('now','localtime')", }); dbErr != nil { @@ -175,8 +192,19 @@ func (s *avatarService) buildJob(ctx context.Context, id, userId int64) { } // Get 我的化身 -func (s *avatarService) Get(ctx context.Context, userId int64) (*entity.AvatarModel, error) { - return dao.AvatarModel.GetByUser(ctx, userId) +func (s *avatarService) Get(ctx context.Context, userId int64) (*dto.AvatarGetRes, error) { + a, err := dao.AvatarModel.GetByUser(ctx, userId) + if err != nil { + return nil, err + } + if a == nil { + return &dto.AvatarGetRes{}, nil + } + return &dto.AvatarGetRes{ + FaceTemplateId: a.FaceTemplateId, BodyTemplateId: a.BodyTemplateId, + SkinToneIndex: a.SkinToneIndex, GlbUrl: a.GlbUrl, FramesUrl: a.FramesUrl, + BuildStatus: a.BuildStatus, Error: a.Error, + }, nil } func mustJSON(v any) string { diff --git a/server/styleagent/service/body_measurement_service.go b/server/styleagent/service/body_measurement_service.go index bc645ce..189d4af 100644 --- a/server/styleagent/service/body_measurement_service.go +++ b/server/styleagent/service/body_measurement_service.go @@ -4,6 +4,7 @@ import ( "context" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" ) @@ -11,32 +12,56 @@ type bodyMeasurementService struct{} var BodyMeasurementService = new(bodyMeasurementService) -func (s *bodyMeasurementService) Save(ctx context.Context, userId int64, req *entity.BodyMeasurement) error { - if req.Height == 0 { - req.Height = 170 +func (s *bodyMeasurementService) Save(ctx context.Context, userId int64, req *dto.BodyMeasurementSaveReq) (*dto.BodyMeasurementSaveRes, error) { + b := &entity.BodyMeasurement{ + UserId: userId, + Height: req.Height, + Weight: req.Weight, + SkinTone: req.SkinTone, + Bust: req.Bust, + Waist: req.Waist, + Hip: req.Hip, + Shoulder: req.Shoulder, + FitParams: req.FitParams, } - if req.Weight == 0 { - req.Weight = 60 + if b.Height == 0 { + b.Height = 170 } - if req.SkinTone == 0 { - req.SkinTone = 3 + if b.Weight == 0 { + b.Weight = 60 } - if req.Bust == 0 { - req.Bust = 88 + if b.SkinTone == 0 { + b.SkinTone = 3 } - if req.Waist == 0 { - req.Waist = 70 + if b.Bust == 0 { + b.Bust = 88 } - if req.Hip == 0 { - req.Hip = 92 + if b.Waist == 0 { + b.Waist = 70 } - if req.Shoulder == 0 { - req.Shoulder = 42 + if b.Hip == 0 { + b.Hip = 92 } - req.UserId = userId - return dao.BodyMeasurement.Save(ctx, req) + if b.Shoulder == 0 { + b.Shoulder = 42 + } + if err := dao.BodyMeasurement.Save(ctx, b); err != nil { + return nil, err + } + return &dto.BodyMeasurementSaveRes{}, nil } -func (s *bodyMeasurementService) Get(ctx context.Context, userId int64) (*entity.BodyMeasurement, error) { - return dao.BodyMeasurement.GetByUser(ctx, userId) +func (s *bodyMeasurementService) Get(ctx context.Context, userId int64) (*dto.BodyMeasurementGetRes, error) { + b, err := dao.BodyMeasurement.GetByUser(ctx, userId) + if err != nil { + return nil, err + } + if b == nil { + return &dto.BodyMeasurementGetRes{}, nil + } + return &dto.BodyMeasurementGetRes{ + Height: b.Height, Weight: b.Weight, SkinTone: b.SkinTone, + Bust: b.Bust, Waist: b.Waist, Hip: b.Hip, Shoulder: b.Shoulder, + FitParams: b.FitParams, + }, nil } diff --git a/server/styleagent/service/cps_category_service.go b/server/styleagent/service/cps_category_service.go index 1d6342a..b739547 100644 --- a/server/styleagent/service/cps_category_service.go +++ b/server/styleagent/service/cps_category_service.go @@ -4,6 +4,7 @@ import ( "context" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" ) @@ -12,6 +13,21 @@ type cpsCategoryService struct{} var CpsCategoryService = new(cpsCategoryService) // List 联盟分类(客户端 chips;未配置任何 key 时也无分类,前端隐藏入口) -func (s *cpsCategoryService) List(ctx context.Context) ([]*entity.CpsCategory, error) { - return dao.CpsCategory.List(ctx) +func (s *cpsCategoryService) List(ctx context.Context) (*dto.CpsCategoryListRes, error) { + list, err := dao.CpsCategory.List(ctx) + if err != nil { + return nil, err + } + out := make([]*dto.CpsCategoryItem, 0, len(list)) + for _, c := range list { + out = append(out, toCpsCategoryItem(c)) + } + return &dto.CpsCategoryListRes{List: out}, nil +} + +func toCpsCategoryItem(c *entity.CpsCategory) *dto.CpsCategoryItem { + return &dto.CpsCategoryItem{ + Id: c.Id, Code: c.Code, Name: c.Name, ParentCode: c.ParentCode, + Source: c.Source, SourceCatId: c.SourceCatId, Sort: c.Sort, CreatedAt: c.CreatedAt, + } } diff --git a/server/styleagent/service/cps_click_log_service.go b/server/styleagent/service/cps_click_log_service.go index eca5bad..26be0d8 100644 --- a/server/styleagent/service/cps_click_log_service.go +++ b/server/styleagent/service/cps_click_log_service.go @@ -3,6 +3,7 @@ package service import ( "context" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" ) @@ -17,24 +18,39 @@ func (s *cpsClickLogService) Click(ctx context.Context, log *entity.CpsClickLog) } // MyRecent 最近优惠(点击日志 → 商品信息,去重倒序) -func (s *cpsClickLogService) MyRecent(ctx context.Context, userId int64) ([]*entity.CpsProduct, error) { +func (s *cpsClickLogService) MyRecent(ctx context.Context, userId int64) (*dto.CpsMyRecentRes, error) { logs, err := dao.CpsClickLog.ListByUser(ctx, userId, 20) if err != nil { return nil, err } + // 先按来源聚拢去重外键,再批量 IN 取商品,避免循环单条查询的 N+1 + bySource := make(map[string][]string) + order := make([]string, 0, len(logs)) seen := make(map[string]bool, len(logs)) - out := make([]*entity.CpsProduct, 0, len(logs)) for _, log := range logs { key := log.Source + ":" + log.OuterId if seen[key] { continue } seen[key] = true - prod, err := dao.CpsProduct.GetByOuter(ctx, log.Source, log.OuterId) - if err != nil || prod == nil { - continue - } - out = append(out, prod) + bySource[log.Source] = append(bySource[log.Source], log.OuterId) + order = append(order, key) } - return out, nil + byKey := make(map[string]*entity.CpsProduct, len(order)) + for source, outerIds := range bySource { + list, err := dao.CpsProduct.ListByOuters(ctx, source, outerIds) + if err != nil { + return nil, err + } + for _, p := range list { + byKey[source+":"+p.OuterId] = p + } + } + out := make([]*dto.CpsProductItem, 0, len(order)) + for _, key := range order { + if p := byKey[key]; p != nil { + out = append(out, toCpsProductItem(p)) + } + } + return &dto.CpsMyRecentRes{List: out}, nil } diff --git a/server/styleagent/service/cps_product_service.go b/server/styleagent/service/cps_product_service.go index 910143b..36249b5 100644 --- a/server/styleagent/service/cps_product_service.go +++ b/server/styleagent/service/cps_product_service.go @@ -8,6 +8,7 @@ import ( "slogan-agent/styleagent/agent" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" "github.com/gogf/gf/v2/frame/g" @@ -134,7 +135,7 @@ func toCpsProductEntity(p *agent.CpsProduct) *entity.CpsProduct { } // ListByCategory 分页商品列表(hasMore 供客户端上滑分页) -func (s *cpsProductService) ListByCategory(ctx context.Context, source, categoryCode, city string, page, pageSize int) ([]*entity.CpsProduct, bool, error) { +func (s *cpsProductService) ListByCategory(ctx context.Context, source, categoryCode, city string, page, pageSize int) (*dto.CpsProductListRes, error) { if page < 1 { page = 1 } @@ -143,24 +144,31 @@ func (s *cpsProductService) ListByCategory(ctx context.Context, source, category } total, err := dao.CpsProduct.CountByCategory(ctx, source, categoryCode, city) if err != nil { - return nil, false, err + return nil, err } list, err := dao.CpsProduct.ListByCategory(ctx, source, categoryCode, city, page, pageSize) if err != nil { - return nil, false, err + return nil, err } - return list, page*pageSize < total, nil + out := make([]*dto.CpsProductItem, 0, len(list)) + for _, p := range list { + out = append(out, toCpsProductItem(p)) + } + return &dto.CpsProductListRes{List: out, HasMore: page*pageSize < total}, nil } // ClickLink 取转链并记录点击日志(/cps/product/link 调用) -func (s *cpsProductService) ClickLink(ctx context.Context, userId, productId int64, scene string, planId int64, ip string) (string, error) { +func (s *cpsProductService) ClickLink(ctx context.Context, userId, productId int64, scene string, planId int64, ip string) (*dto.CpsProductLinkRes, error) { prod, err := dao.CpsProduct.Get(ctx, productId) - if err != nil || prod == nil { - return "", errors.New("商品不存在") + if err != nil { + return nil, err + } + if prod == nil { + return nil, errors.New("商品不存在") } link, err := s.GetLink(ctx, prod.OuterId) if err != nil { - return "", err + return nil, err } if err := CpsClickLogService.Click(ctx, &entity.CpsClickLog{ UserId: userId, @@ -174,7 +182,17 @@ func (s *cpsProductService) ClickLink(ctx context.Context, userId, productId int }); err != nil { g.Log().Warningf(ctx, "记录 CPS 点击日志失败: %v", err) } - return link, nil + return &dto.CpsProductLinkRes{Deeplink: link}, nil +} + +// toCpsProductItem 商品出参镜像(Raw 内部 payload 不外泄) +func toCpsProductItem(p *entity.CpsProduct) *dto.CpsProductItem { + return &dto.CpsProductItem{ + Id: p.Id, Source: p.Source, OuterId: p.OuterId, CategoryCode: p.CategoryCode, + Name: p.Name, CoverUrl: p.CoverUrl, PriceFen: p.PriceFen, ShopName: p.ShopName, + CommissionRate: p.CommissionRate, City: p.City, SceneTags: p.SceneTags, + Status: p.Status, SyncAt: p.SyncAt, CreatedAt: p.CreatedAt, + } } // StartSyncLoop 定时同步联盟商品(main 启动;未配置任何 key 时空转) diff --git a/server/styleagent/service/hairstyle_asset_service.go b/server/styleagent/service/hairstyle_asset_service.go index 4a04012..b8195d2 100644 --- a/server/styleagent/service/hairstyle_asset_service.go +++ b/server/styleagent/service/hairstyle_asset_service.go @@ -4,6 +4,7 @@ import ( "context" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" ) @@ -11,6 +12,22 @@ type hairstyleService struct{} var HairstyleService = new(hairstyleService) -func (s *hairstyleService) List(ctx context.Context) ([]*entity.HairstyleAsset, error) { - return dao.HairstyleAsset.ListAll(ctx) +func (s *hairstyleService) List(ctx context.Context) (*dto.HairstyleListRes, error) { + list, err := dao.HairstyleAsset.ListAll(ctx) + if err != nil { + return nil, err + } + out := make([]*dto.HairstyleAssetItem, 0, len(list)) + for _, h := range list { + out = append(out, toHairstyleItem(h)) + } + return &dto.HairstyleListRes{List: out}, nil +} + +func toHairstyleItem(h *entity.HairstyleAsset) *dto.HairstyleAssetItem { + return &dto.HairstyleAssetItem{ + Id: h.Id, Name: h.Name, StyleTag: h.StyleTag, GlbUrl: h.GlbUrl, + ThumbUrl: h.ThumbUrl, ApplicableFace: h.ApplicableFace, + Sort: h.Sort, CreatedAt: h.CreatedAt, + } } diff --git a/server/styleagent/service/member_plan_service.go b/server/styleagent/service/member_plan_service.go index 3847b36..f8df191 100644 --- a/server/styleagent/service/member_plan_service.go +++ b/server/styleagent/service/member_plan_service.go @@ -6,6 +6,7 @@ import ( "time" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" "github.com/gogf/gf/v2/frame/g" @@ -15,19 +16,20 @@ type memberPlanService struct{} var MemberPlanService = new(memberPlanService) -func (s *memberPlanService) PlanList(ctx context.Context) ([]*entity.MemberPlan, error) { - return dao.MemberPlan.ListEnabled(ctx) +func (s *memberPlanService) PlanList(ctx context.Context) (*dto.MemberPlanListRes, error) { + list, err := dao.MemberPlan.ListEnabled(ctx) + if err != nil { + return nil, err + } + out := make([]*dto.MemberPlanItem, 0, len(list)) + for _, p := range list { + out = append(out, toMemberPlanItem(p)) + } + return &dto.MemberPlanListRes{List: out}, nil } -type MemberStatus struct { - IsVip bool `json:"is_vip"` - ExpireAt string `json:"expire_at"` - PlanName string `json:"plan_name"` - Benefits []string `json:"benefits"` -} - -func (s *memberPlanService) Status(ctx context.Context, userId int64) (*MemberStatus, error) { - st := &MemberStatus{Benefits: make([]string, 0)} +func (s *memberPlanService) Status(ctx context.Context, userId int64) (*dto.MemberStatusRes, error) { + st := &dto.MemberStatusRes{Benefits: make([]string, 0)} um, err := dao.UserMember.GetByUser(ctx, userId) if err != nil { return nil, err @@ -46,6 +48,13 @@ func (s *memberPlanService) Status(ctx context.Context, userId int64) (*MemberSt return st, nil } +func toMemberPlanItem(p *entity.MemberPlan) *dto.MemberPlanItem { + return &dto.MemberPlanItem{ + Id: p.Id, Name: p.Name, PriceFen: p.PriceFen, DurationDays: p.DurationDays, + Features: p.Features, Sort: p.Sort, Status: p.Status, CreatedAt: p.CreatedAt, + } +} + func parseBenefits(ctx context.Context, features string) []string { var list []string if err := json.Unmarshal([]byte(features), &list); err != nil { diff --git a/server/styleagent/service/outfit_generation_task_service.go b/server/styleagent/service/outfit_generation_task_service.go index 9b280b2..f4ca507 100644 --- a/server/styleagent/service/outfit_generation_task_service.go +++ b/server/styleagent/service/outfit_generation_task_service.go @@ -7,6 +7,7 @@ import ( "fmt" "time" + "slogan-agent/common" "slogan-agent/styleagent/agent" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/dao" @@ -23,27 +24,34 @@ type outfitService struct{} var OutfitService = new(outfitService) // Generate 创建生成任务(pending)并异步执行核心流程 -func (s *outfitService) Generate(ctx context.Context, userId int64, req *dto.OutfitGenerateReq) (int64, error) { +func (s *outfitService) Generate(ctx context.Context, userId int64, req *dto.OutfitGenerateReq) (*dto.OutfitGenerateRes, error) { if req.StartDate > req.EndDate { - return 0, errors.New("开始日期不能晚于结束日期") + return nil, errors.New("开始日期不能晚于结束日期") } items, err := dao.WardrobeItem.ListAllByUser(ctx, userId) if err != nil { - return 0, err + return nil, err } if len(items) < 3 { - return 0, errors.New("衣橱服装不足,请先添加至少 3 件服装") + return nil, errors.New("衣橱服装不足,请先添加至少 3 件服装") } taskId, err := dao.OutfitGenTask.Insert(ctx, &entity.OutfitGenerationTask{ UserId: userId, StartDate: req.StartDate, EndDate: req.EndDate, Location: req.Location, Status: consts.TaskStatusPending, }) if err != nil { - return 0, err + return nil, err } - // 异步执行:传入独立 ctx(请求结束不中断任务) - go runGenerateTask(gctx.New(), taskId, userId, normalizeOccasion(req.Occasion)) - return taskId, nil + // 异步执行:提交协程池,传入独立 ctx(请求结束不中断任务) + if err := common.Submit(gctx.New(), "generate", consts.DefaultGeneratePoolSize, func(ctx context.Context) { + runGenerateTask(ctx, taskId, userId, normalizeOccasion(req.Occasion)) + }); err != nil { + if uerr := dao.OutfitGenTask.UpdateStatus(ctx, taskId, consts.TaskStatusFailed, "任务提交失败,请重试"); uerr != nil { + g.Log().Warningf(ctx, "标记任务 %d 失败失败: %v", taskId, uerr) + } + return nil, err + } + return &dto.OutfitGenerateRes{TaskId: taskId}, nil } // normalizeOccasion 空场景默认通勤(评分引擎按 通勤/约会/聚会/运动 匹配) @@ -243,12 +251,15 @@ func scorePlan(p agent.PlanCandidate, items []*entity.WardrobeItem, ctxScore age // ==================== 查询 ==================== -func (s *outfitService) GetTaskStatus(ctx context.Context, userId, taskId int64) (string, string, error) { +func (s *outfitService) GetTaskStatus(ctx context.Context, userId, taskId int64) (*dto.OutfitTaskStatusRes, error) { t, err := dao.OutfitGenTask.GetOne(ctx, taskId, userId) - if err != nil || t == nil { - return "", "", errors.New("任务不存在") + if err != nil { + return nil, err } - return t.Status, t.Error, nil + if t == nil { + return nil, errors.New("任务不存在") + } + return &dto.OutfitTaskStatusRes{Status: t.Status, Error: t.Error}, nil } // ==================== 天气(原 weather_service 内联) ==================== diff --git a/server/styleagent/service/outfit_plan_service.go b/server/styleagent/service/outfit_plan_service.go index b61f4be..665359b 100644 --- a/server/styleagent/service/outfit_plan_service.go +++ b/server/styleagent/service/outfit_plan_service.go @@ -19,43 +19,63 @@ type outfitPlanService struct{} var OutfitPlanService = new(outfitPlanService) -func (s *outfitPlanService) ListPlans(ctx context.Context, userId int64) ([]*entity.OutfitPlan, error) { - return dao.OutfitPlan.ListByUser(ctx, userId) +func (s *outfitPlanService) ListPlans(ctx context.Context, userId int64) (*dto.OutfitPlanListRes, error) { + list, err := dao.OutfitPlan.ListByUser(ctx, userId) + if err != nil { + return nil, err + } + out := make([]*dto.OutfitPlanItem, 0, len(list)) + for _, p := range list { + out = append(out, toOutfitPlanItem(p)) + } + return &dto.OutfitPlanListRes{List: out}, nil } // GetPlan 按用户取方案(CPS 方案驱动推荐入口用) -func (s *outfitPlanService) GetPlan(ctx context.Context, userId, planId int64) (*entity.OutfitPlan, error) { - return dao.OutfitPlan.GetOne(ctx, planId, userId) +func (s *outfitPlanService) GetPlan(ctx context.Context, userId, planId int64) (*dto.OutfitPlanItem, error) { + plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId) + if err != nil { + return nil, err + } + if plan == nil { + return nil, nil + } + return toOutfitPlanItem(plan), nil } func (s *outfitPlanService) GetPlanDetail(ctx context.Context, userId, planId int64) (*dto.OutfitPlanDetailRes, error) { plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId) - if err != nil || plan == nil { - return nil, errors.New("方案不存在") - } - res := &dto.OutfitPlanDetailRes{Plan: plan} - res.Items, err = PlanOutfitItemService.ListByPlan(ctx, planId) if err != nil { return nil, err } - res.Images, err = dao.PlanEffectImage.ListByPlan(ctx, planId) - if err != nil { + if plan == nil { + return nil, errors.New("方案不存在") + } + res := &dto.OutfitPlanDetailRes{Plan: toOutfitPlanItem(plan)} + if res.Items, err = PlanOutfitItemService.ListByPlan(ctx, planId); err != nil { + return nil, err + } + if res.Images, err = EffectImageService.ListByPlan(ctx, planId); err != nil { return nil, err } if plan.HairstyleId > 0 { - res.Hairstyle, err = dao.HairstyleAsset.GetOne(ctx, plan.HairstyleId) - if err != nil { + if h, err := dao.HairstyleAsset.GetOne(ctx, plan.HairstyleId); err != nil { g.Log().Warningf(ctx, "读取发型 %d 失败: %v", plan.HairstyleId, err) + } else if h != nil { + res.Hairstyle = toHairstyleItem(h) } } return res, nil } // SelectMain 选定主方案(同任务其他方案清零,同一事务保证不出现无主方案)+ 异步生成效果图 -func (s *outfitPlanService) SelectMain(ctx context.Context, userId, planId int64) error { +func (s *outfitPlanService) SelectMain(ctx context.Context, userId, planId int64) (*dto.OutfitSelectMainRes, error) { plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId) - if err != nil || plan == nil { - return errors.New("方案不存在") + if err != nil { + return nil, err + } + if plan == nil { + return nil, errors.New("方案不存在") } err = g.DB(consts.DBGroupPlan).Transaction(ctx, func(ctx context.Context, tx gdb.TX) error { if err := dao.OutfitPlan.ClearMainFlagTx(ctx, tx, plan.TaskId); err != nil { @@ -64,11 +84,20 @@ func (s *outfitPlanService) SelectMain(ctx context.Context, userId, planId int64 return dao.OutfitPlan.SetMainFlagTx(ctx, tx, planId) }) if err != nil { - return err + return nil, err } // 异步生成 3 视角效果图 EffectImageService.GenerateForPlan(gctx.New(), planId, userId) - return nil + return &dto.OutfitSelectMainRes{}, nil +} + +func toOutfitPlanItem(p *entity.OutfitPlan) *dto.OutfitPlanItem { + return &dto.OutfitPlanItem{ + Id: p.Id, TaskId: p.TaskId, UserId: p.UserId, DateRange: p.DateRange, + Location: p.Location, Title: p.Title, Source: p.Source, Score: p.Score, + MainFlag: p.MainFlag, HairstyleId: p.HairstyleId, HairColor: p.HairColor, + WeatherRef: p.WeatherRef, Occasion: p.Occasion, CreatedAt: p.CreatedAt, + } } func planSource(p agent.PlanCandidate) string { diff --git a/server/styleagent/service/partner_store_service.go b/server/styleagent/service/partner_store_service.go index c7f5cde..3010bba 100644 --- a/server/styleagent/service/partner_store_service.go +++ b/server/styleagent/service/partner_store_service.go @@ -4,6 +4,7 @@ import ( "context" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" ) @@ -12,6 +13,22 @@ type partnerStoreService struct{} var PartnerStoreService = new(partnerStoreService) // List 合作门店列表(type 为 0 返回全部) -func (s *partnerStoreService) List(ctx context.Context, storeType int) ([]*entity.PartnerStore, error) { - return dao.PartnerStore.List(ctx, storeType) +func (s *partnerStoreService) List(ctx context.Context, storeType int) (*dto.StoreListRes, error) { + list, err := dao.PartnerStore.List(ctx, storeType) + if err != nil { + return nil, err + } + out := make([]*dto.PartnerStoreItem, 0, len(list)) + for _, st := range list { + out = append(out, toPartnerStoreItem(st)) + } + return &dto.StoreListRes{List: out}, nil +} + +func toPartnerStoreItem(st *entity.PartnerStore) *dto.PartnerStoreItem { + return &dto.PartnerStoreItem{ + Id: st.Id, Name: st.Name, Type: st.Type, Lat: st.Lat, Lng: st.Lng, + Address: st.Address, CommissionPolicy: st.CommissionPolicy, + Status: st.Status, CreatedAt: st.CreatedAt, + } } diff --git a/server/styleagent/service/pay_notify_log_service.go b/server/styleagent/service/pay_notify_log_service.go deleted file mode 100644 index 3901770..0000000 --- a/server/styleagent/service/pay_notify_log_service.go +++ /dev/null @@ -1,19 +0,0 @@ -package service - -import ( - "context" - - "slogan-agent/styleagent/dao" - "slogan-agent/styleagent/model/entity" -) - -type payNotifyLogService struct{} - -var PayNotifyLogService = new(payNotifyLogService) - -// Insert 回调日志全量入库(审计) -func (s *payNotifyLogService) Insert(ctx context.Context, orderNo, body, sign, remoteIP, status string) error { - return dao.PayNotifyLog.Insert(ctx, &entity.PayNotifyLog{ - OrderNo: orderNo, Body: body, Sign: sign, RemoteIp: remoteIP, Status: status, - }) -} diff --git a/server/styleagent/service/payment_order_service.go b/server/styleagent/service/payment_order_service.go index b1adcec..0f04f9b 100644 --- a/server/styleagent/service/payment_order_service.go +++ b/server/styleagent/service/payment_order_service.go @@ -12,8 +12,10 @@ import ( "strings" "time" + commonHttp "slogan-agent/common" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" "github.com/gogf/gf/v2/database/gdb" @@ -50,7 +52,7 @@ func (s *paymentOrderService) getPaymentConfig(ctx context.Context) paymentConfi } // CreateOrder 创建支付单,返回收银台/支付 URL(金额单位:分) -func (s *paymentOrderService) CreateOrder(ctx context.Context, orderNo string, amountFen int) (payURL string, err error) { +func (s *paymentOrderService) CreateOrder(ctx context.Context, orderNo string, amountFen int64) (payURL string, err error) { cfg := s.getPaymentConfig(ctx) if !cfg.Enabled { return "", errors.New("支付未开通,请在 config.yml 配置 payment") @@ -62,12 +64,14 @@ func (s *paymentOrderService) CreateOrder(ctx context.Context, orderNo string, a params := map[string]string{ "appid": cfg.AppId, "trade_order_id": orderNo, - "total_fee": fmt.Sprintf("%.2f", float64(amountFen)/100), - "title": "形象会员", - "notify_url": cfg.NotifyUrl, - "type": channel, - "version": "1.1", - "nonce_str": paymentNonce(), + // 边界例外:虎皮棋协议要求金额以元为单位,此处为对接第三方协议的分→元转换, + // 仅此一处(展示转换在规范上仍在前端,后端业务金额始终为整数分) + "total_fee": fmt.Sprintf("%.2f", float64(amountFen)/100), + "title": "形象会员", + "notify_url": cfg.NotifyUrl, + "type": channel, + "version": "1.1", + "nonce_str": paymentNonce(), } params["hash"] = paymentSign(params, cfg.AppSecret) @@ -81,7 +85,7 @@ func (s *paymentOrderService) CreateOrder(ctx context.Context, orderNo string, a if err != nil { return "", fmt.Errorf("虎皮棋下单失败: %w", err) } - defer respRaw.Close() + defer func() { _ = respRaw.Close() }() if err := json.Unmarshal(respRaw.ReadAll(), &resp); err != nil { return "", fmt.Errorf("虎皮棋下单失败: %w", err) } @@ -134,20 +138,23 @@ func paymentSign(params map[string]string, secret string) string { func paymentNonce() string { b := make([]byte, 8) - _, _ = rand.Read(b) + if _, err := rand.Read(b); err != nil { + // crypto/rand 失败兜底:时间戳熵;签名不匹配时下单接口会返回错误,不会静默成交 + return fmt.Sprintf("%x", time.Now().UnixNano()) + } return hex.EncodeToString(b) } // ==================== 订单业务 ==================== // CreateMemberOrder 下单:生成业务订单号 → 虎皮棋下单 → 返回支付 URL -func (s *paymentOrderService) CreateMemberOrder(ctx context.Context, userId, planId int64) (*entity.PaymentOrder, string, error) { +func (s *paymentOrderService) CreateMemberOrder(ctx context.Context, userId, planId int64) (*dto.MemberOrderCreateRes, error) { plan, err := dao.MemberPlan.GetOne(ctx, planId) if err != nil { - return nil, "", err + return nil, err } if plan == nil { - return nil, "", errors.New("套餐不存在") + return nil, errors.New("套餐不存在") } order := &entity.PaymentOrder{ OrderNo: fmt.Sprintf("M%d%d", time.Now().UnixNano()/1e6, userId%1000), @@ -158,17 +165,28 @@ func (s *paymentOrderService) CreateMemberOrder(ctx context.Context, userId, pla Status: consts.PayStatusPending, } if _, err := dao.PaymentOrder.Insert(ctx, order); err != nil { - return nil, "", err + return nil, err } payURL, err := s.CreateOrder(ctx, order.OrderNo, plan.PriceFen) if err != nil { - return nil, "", err + return nil, err } - return order, payURL, nil + return &dto.MemberOrderCreateRes{OrderNo: order.OrderNo, PayUrl: payURL}, nil } -func (s *paymentOrderService) OrderStatus(ctx context.Context, orderNo string) (*entity.PaymentOrder, error) { - return dao.PaymentOrder.GetByOrderNo(ctx, orderNo) +func (s *paymentOrderService) OrderStatus(ctx context.Context, orderNo string) (*dto.MemberOrderStatusRes, error) { + order, err := dao.PaymentOrder.GetByOrderNo(ctx, orderNo) + if err != nil { + return nil, err + } + if order == nil { + return nil, errors.New("订单不存在") + } + paidAt := "" + if order.PaidAt != nil { + paidAt = order.PaidAt.Format("Y-m-d H:i:s") + } + return &dto.MemberOrderStatusRes{Status: order.Status, TradeNo: order.TradeNo, PaidAt: paidAt}, nil } // HandlePaidNotify 验签已在 handler 完成;状态机 pending→paid 幂等,订单标记与会员开通同一事务,避免"扣款成功会员未开通" @@ -234,3 +252,34 @@ func NextExpire(old *gtime.Time, days int) string { } return base.Add(time.Duration(days) * 24 * time.Hour).Format("2006-01-02 15:04:05") } + +// LogNotify 支付回调日志直写(pay_notify_log 为记录类豁免表:不建独立分层, +// 建表由 payment_order_dao init 统一管理,写入归本 service 事务内直写) +func (s *paymentOrderService) LogNotify(ctx context.Context, orderNo, body, sign, remoteIP, status string) error { + _, err := commonHttp.DbPay().Model(consts.TableNamePayNotifyLog).Ctx(ctx).Data(g.Map{ + "order_no": orderNo, "body": body, "sign": sign, "remote_ip": remoteIP, "status": status, + }).Insert() + return err +} + +// HandleNotify 支付回调统一处理:验签 → 幂等开通 → 返回响应文本("success"/"fail")。 +// 验签失败/无订单返回 "fail",重复回调返回 "success"(幂等,避免支付渠道无限重试)。 +func (s *paymentOrderService) HandleNotify(ctx context.Context, params map[string]string, body, remoteIP string) string { + cfg := s.getPaymentConfig(ctx) + hash := params["hash"] + orderNo := params["trade_order_id"] + if !s.VerifyNotify(params, hash, cfg.AppSecret) { + if err := s.LogNotify(ctx, orderNo, body, hash, remoteIP, "bad_sign"); err != nil { + g.Log().Warningf(ctx, "写入支付回调日志失败(bad_sign): %v", err) + } + return "fail" + } + state, err := s.HandlePaidNotify(ctx, orderNo, params["transaction_id"], body) + if logErr := s.LogNotify(ctx, orderNo, body, hash, remoteIP, state); logErr != nil { + g.Log().Warningf(ctx, "写入支付回调日志失败: %v", logErr) + } + if err != nil || state == "no_order" { + return "fail" + } + return "success" +} diff --git a/server/styleagent/service/plan_effect_image_service.go b/server/styleagent/service/plan_effect_image_service.go index 53b8789..678f32b 100644 --- a/server/styleagent/service/plan_effect_image_service.go +++ b/server/styleagent/service/plan_effect_image_service.go @@ -7,12 +7,15 @@ import ( "fmt" "strings" + "slogan-agent/common" "slogan-agent/styleagent/agent" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" "github.com/gogf/gf/v2/frame/g" + "github.com/gogf/gf/v2/os/gctx" ) type effectImageService struct{} @@ -23,7 +26,24 @@ var effectAngles = []string{"正面", "侧面", "背面"} // GenerateForPlan 选定主方案后异步生成 3 视角效果图 func (s *effectImageService) GenerateForPlan(ctx context.Context, planId, userId int64) { - go s.run(ctx, planId, userId) + if err := common.Submit(gctx.New(), "effect", consts.DefaultEffectPoolSize, func(ctx context.Context) { + s.run(ctx, planId, userId) + }); err != nil { + g.Log().Warningf(ctx, "提交效果图任务到协程池失败: %v", err) + } +} + +// ListByPlan 方案效果图列表(方案详情组装用) +func (s *effectImageService) ListByPlan(ctx context.Context, planId int64) ([]*dto.PlanEffectImageItem, error) { + list, err := dao.PlanEffectImage.ListByPlan(ctx, planId) + if err != nil { + return nil, err + } + out := make([]*dto.PlanEffectImageItem, 0, len(list)) + for _, img := range list { + out = append(out, toPlanEffectImageItem(img)) + } + return out, nil } func (s *effectImageService) run(ctx context.Context, planId, userId int64) { @@ -62,7 +82,7 @@ func (s *effectImageService) run(ctx context.Context, planId, userId int64) { } }() - items, err := dao.PlanOutfitItem.ListByPlan(ctx, planId) + items, err := PlanOutfitItemService.ListByPlan(ctx, planId) if err != nil { g.Log().Warningf(ctx, "读取方案单品失败(效果图描述将缺单品): %v", err) } @@ -126,7 +146,7 @@ func (s *effectImageService) run(ctx context.Context, planId, userId int64) { g.Log().Infof(ctx, "方案 %d 效果图生成完成", planId) } -func planTitleDesc(title string, items []*entity.PlanOutfitItem) string { +func planTitleDesc(title string, items []*dto.PlanOutfitItem) string { var sb strings.Builder sb.WriteString("方案:") sb.WriteString(title) @@ -144,3 +164,11 @@ func effectCacheKey(plan *entity.OutfitPlan, angle string) string { sum := md5.Sum([]byte(fmt.Sprintf("%d:%s:%s", plan.Id, plan.Title, angle))) return "plan:" + hex.EncodeToString(sum[:]) } + +func toPlanEffectImageItem(img *entity.PlanEffectImage) *dto.PlanEffectImageItem { + return &dto.PlanEffectImageItem{ + Id: img.Id, PlanId: img.PlanId, Angle: img.Angle, Url: img.Url, + Status: img.Status, PromptSnapshot: img.PromptSnapshot, + CreatedAt: img.CreatedAt, UpdatedAt: img.UpdatedAt, + } +} diff --git a/server/styleagent/service/plan_outfit_item_service.go b/server/styleagent/service/plan_outfit_item_service.go index 254352a..c765239 100644 --- a/server/styleagent/service/plan_outfit_item_service.go +++ b/server/styleagent/service/plan_outfit_item_service.go @@ -4,6 +4,7 @@ import ( "context" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" ) @@ -11,6 +12,22 @@ type planOutfitItemService struct{} var PlanOutfitItemService = new(planOutfitItemService) -func (s *planOutfitItemService) ListByPlan(ctx context.Context, planId int64) ([]*entity.PlanOutfitItem, error) { - return dao.PlanOutfitItem.ListByPlan(ctx, planId) +func (s *planOutfitItemService) ListByPlan(ctx context.Context, planId int64) ([]*dto.PlanOutfitItem, error) { + list, err := dao.PlanOutfitItem.ListByPlan(ctx, planId) + if err != nil { + return nil, err + } + out := make([]*dto.PlanOutfitItem, 0, len(list)) + for _, it := range list { + out = append(out, toPlanOutfitItem(it)) + } + return out, nil +} + +func toPlanOutfitItem(it *entity.PlanOutfitItem) *dto.PlanOutfitItem { + return &dto.PlanOutfitItem{ + Id: it.Id, PlanId: it.PlanId, Slot: it.Slot, Source: it.Source, + WardrobeItemId: it.WardrobeItemId, ProductName: it.ProductName, + Name: it.Name, Desc: it.Desc, CreatedAt: it.CreatedAt, + } } diff --git a/server/styleagent/service/plan_review_service.go b/server/styleagent/service/plan_review_service.go index b5c9fab..040f518 100644 --- a/server/styleagent/service/plan_review_service.go +++ b/server/styleagent/service/plan_review_service.go @@ -5,6 +5,7 @@ import ( "errors" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" ) @@ -12,11 +13,16 @@ type planReviewService struct{} var PlanReviewService = new(planReviewService) -func (s *planReviewService) Review(ctx context.Context, userId, planId int64, action, note string) error { - plan, err := dao.OutfitPlan.GetOne(ctx, planId, userId) - if err != nil || plan == nil { - return errors.New("方案不存在") +func (s *planReviewService) Review(ctx context.Context, userId int64, req *dto.OutfitReviewReq) (*dto.OutfitReviewRes, error) { + plan, err := dao.OutfitPlan.GetOne(ctx, req.PlanId, userId) + if err != nil { + return nil, err } - _, err = dao.PlanReview.Insert(ctx, &entity.PlanReview{PlanId: planId, UserId: userId, Action: action, Note: note}) - return err + if plan == nil { + return nil, errors.New("方案不存在") + } + if _, err := dao.PlanReview.Insert(ctx, &entity.PlanReview{PlanId: req.PlanId, UserId: userId, Action: req.Action, Note: req.Note}); err != nil { + return nil, err + } + return &dto.OutfitReviewRes{}, nil } diff --git a/server/styleagent/service/scene_category_map_service.go b/server/styleagent/service/scene_category_map_service.go index 8eb5cd6..b5fdbec 100644 --- a/server/styleagent/service/scene_category_map_service.go +++ b/server/styleagent/service/scene_category_map_service.go @@ -6,7 +6,7 @@ import ( "slogan-agent/styleagent/consts" "slogan-agent/styleagent/dao" - "slogan-agent/styleagent/model/entity" + "slogan-agent/styleagent/model/dto" ) // sceneCategoryMapService 方案驱动推荐(零新增 LLM:场景映射表 + 联盟选品/搜索) @@ -16,8 +16,24 @@ type sceneCategoryMapService struct { var SceneCategoryMapService = new(sceneCategoryMapService) +// PlanRecommend 方案驱动推荐(发型/买同款/到店试穿/场合) +func (s *sceneCategoryMapService) PlanRecommend(ctx context.Context, userId int64, req *dto.CpsPlanRecommendReq) (*dto.CpsPlanRecommendRes, error) { + plan, err := OutfitPlanService.GetPlan(ctx, userId, req.PlanId) + if err != nil { + return nil, err + } + if plan == nil { + return nil, errors.New("方案不存在") + } + list, err := s.Recommend(ctx, plan, req.Scene) + if err != nil { + return nil, err + } + return &dto.CpsPlanRecommendRes{List: list}, nil +} + // Recommend 按场景给方案推荐联盟商品;查不到返回空列表(客户端隐藏入口) -func (s *sceneCategoryMapService) Recommend(ctx context.Context, plan *entity.OutfitPlan, scene string) ([]*entity.CpsProduct, error) { +func (s *sceneCategoryMapService) Recommend(ctx context.Context, plan *dto.OutfitPlanItem, scene string) ([]*dto.CpsProductItem, error) { svc := s.productSvc if svc == nil { svc = CpsProductService @@ -36,7 +52,7 @@ func (s *sceneCategoryMapService) Recommend(ctx context.Context, plan *entity.Ou } // recommendHaircut 发型 → 映射表丽人(美团到店),城市过滤 -func (s *sceneCategoryMapService) recommendHaircut(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) { +func (s *sceneCategoryMapService) recommendHaircut(ctx context.Context, svc *cpsProductService, plan *dto.OutfitPlanItem) ([]*dto.CpsProductItem, error) { if plan.HairstyleId <= 0 { return nil, nil } @@ -44,12 +60,12 @@ func (s *sceneCategoryMapService) recommendHaircut(ctx context.Context, svc *cps } // recommendOccasion 场合 → 映射表(通勤/约会/旅行/运动),城市过滤 -func (s *sceneCategoryMapService) recommendOccasion(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) { +func (s *sceneCategoryMapService) recommendOccasion(ctx context.Context, svc *cpsProductService, plan *dto.OutfitPlanItem) ([]*dto.CpsProductItem, error) { return s.byScene(ctx, svc, consts.CpsSceneOccasion, plan.Occasion, plan.Location) } // byScene 场景映射表(priority 最高者)→ 分页商品 -func (s *sceneCategoryMapService) byScene(ctx context.Context, svc *cpsProductService, sceneType, occasion, city string) ([]*entity.CpsProduct, error) { +func (s *sceneCategoryMapService) byScene(ctx context.Context, svc *cpsProductService, sceneType, occasion, city string) ([]*dto.CpsProductItem, error) { maps, err := dao.SceneCategoryMap.QueryByScene(ctx, sceneType, occasion) if err != nil { return nil, err @@ -57,12 +73,15 @@ func (s *sceneCategoryMapService) byScene(ctx context.Context, svc *cpsProductSe if len(maps) == 0 { return nil, nil } - list, _, err := svc.ListByCategory(ctx, maps[0].Source, maps[0].CategoryCode, city, 1, 10) - return list, err + res, err := svc.ListByCategory(ctx, maps[0].Source, maps[0].CategoryCode, city, 1, 10) + if err != nil { + return nil, err + } + return res.List, nil } // recommendItemBuy 买同款:推荐条目名作关键词 → 电商搜索 -func (s *sceneCategoryMapService) recommendItemBuy(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) { +func (s *sceneCategoryMapService) recommendItemBuy(ctx context.Context, svc *cpsProductService, plan *dto.OutfitPlanItem) ([]*dto.CpsProductItem, error) { items, err := dao.PlanOutfitItem.ListByPlan(ctx, plan.Id) if err != nil { return nil, err @@ -81,7 +100,7 @@ func (s *sceneCategoryMapService) recommendItemBuy(ctx context.Context, svc *cps } // recommendItemUpgrade 到店试穿:首条条目名 → 服装类目(美团) -func (s *sceneCategoryMapService) recommendItemUpgrade(ctx context.Context, svc *cpsProductService, plan *entity.OutfitPlan) ([]*entity.CpsProduct, error) { +func (s *sceneCategoryMapService) recommendItemUpgrade(ctx context.Context, svc *cpsProductService, plan *dto.OutfitPlanItem) ([]*dto.CpsProductItem, error) { items, err := dao.PlanOutfitItem.ListByPlan(ctx, plan.Id) if err != nil { return nil, err @@ -93,27 +112,34 @@ func (s *sceneCategoryMapService) recommendItemUpgrade(ctx context.Context, svc } // WardrobeUpgrade 衣橱升级款:物品品类作关键词 → 服装类目搜索 -func (s *sceneCategoryMapService) WardrobeUpgrade(ctx context.Context, userId, itemId int64) ([]*entity.CpsProduct, error) { - item, err := dao.WardrobeItem.GetOne(ctx, itemId, userId) - if err != nil || item == nil { +func (s *sceneCategoryMapService) WardrobeUpgrade(ctx context.Context, userId int64, req *dto.CpsWardrobeUpgradeReq) (*dto.CpsWardrobeUpgradeRes, error) { + item, err := dao.WardrobeItem.GetOne(ctx, req.ItemId, userId) + if err != nil { + return nil, err + } + if item == nil { return nil, errors.New("衣橱物品不存在") } svc := s.productSvc if svc == nil { svc = CpsProductService } - return s.search(ctx, svc, item.Category, "clothing") + list, err := s.search(ctx, svc, item.Category, "clothing") + if err != nil { + return nil, err + } + return &dto.CpsWardrobeUpgradeRes{List: list}, nil } // search 联盟实时搜索,失败降级为空列表而非错误 -func (s *sceneCategoryMapService) search(ctx context.Context, svc *cpsProductService, keyword, catCode string) ([]*entity.CpsProduct, error) { +func (s *sceneCategoryMapService) search(ctx context.Context, svc *cpsProductService, keyword, catCode string) ([]*dto.CpsProductItem, error) { raw, err := svc.Search(ctx, keyword, catCode, 1) if err != nil { return nil, nil } - out := make([]*entity.CpsProduct, 0, len(raw)) + out := make([]*dto.CpsProductItem, 0, len(raw)) for i := range raw { - out = append(out, toCpsProductEntity(&raw[i])) + out = append(out, toCpsProductItem(toCpsProductEntity(&raw[i]))) } return out, nil } diff --git a/server/styleagent/service/scene_category_map_service_test.go b/server/styleagent/service/scene_category_map_service_test.go index ec6a985..0ebd949 100644 --- a/server/styleagent/service/scene_category_map_service_test.go +++ b/server/styleagent/service/scene_category_map_service_test.go @@ -8,6 +8,7 @@ import ( "slogan-agent/styleagent/agent" "slogan-agent/styleagent/consts" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" ) @@ -32,7 +33,7 @@ func upsertTestProduct(t *testing.T, source, outerId, catCode, city, name string func TestRecommendHaircut(t *testing.T) { upsertTestProduct(t, consts.CpsSourceMeituanOta, "mt-beauty-1", "beauty", "北京", "明星剪发", 8800) svc := newTestSceneSvc(nil) - list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{HairstyleId: 5, Location: "北京"}, consts.CpsSceneHaircut) + list, err := svc.Recommend(context.Background(), &dto.OutfitPlanItem{HairstyleId: 5, Location: "北京"}, consts.CpsSceneHaircut) if err != nil { t.Fatalf("发型场景推荐失败: %v", err) } @@ -43,7 +44,7 @@ func TestRecommendHaircut(t *testing.T) { func TestRecommendHaircutNoHairstyle(t *testing.T) { svc := newTestSceneSvc(nil) - list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{HairstyleId: 0}, consts.CpsSceneHaircut) + list, err := svc.Recommend(context.Background(), &dto.OutfitPlanItem{HairstyleId: 0}, consts.CpsSceneHaircut) if err != nil { t.Fatalf("无发型时应返回空而非错误: %v", err) } @@ -66,7 +67,7 @@ func TestRecommendItemBuy(t *testing.T) { {Source: consts.CpsSourceJdEcom, OuterId: "jd2", Name: "黑色西装裤 男", PriceFen: 9900}, }} svc := newTestSceneSvc([]agent.Provider{p}) - list, err := svc.Recommend(ctx, &entity.OutfitPlan{Id: planId}, consts.CpsSceneItemBuy) + list, err := svc.Recommend(ctx, &dto.OutfitPlanItem{Id: planId}, consts.CpsSceneItemBuy) if err != nil { t.Fatalf("买同款推荐失败: %v", err) } @@ -91,7 +92,7 @@ func TestRecommendItemUpgrade(t *testing.T) { {Source: consts.CpsSourceMeituanOta, OuterId: "mt-up", Name: "西装定制店", PriceFen: 29900}, }} svc := newTestSceneSvc([]agent.Provider{p}) - list, err := svc.Recommend(ctx, &entity.OutfitPlan{Id: planId}, consts.CpsSceneItemUpgrade) + list, err := svc.Recommend(ctx, &dto.OutfitPlanItem{Id: planId}, consts.CpsSceneItemUpgrade) if err != nil { t.Fatalf("到店试穿推荐失败: %v", err) } @@ -106,7 +107,7 @@ func TestRecommendItemUpgrade(t *testing.T) { func TestRecommendOccasion(t *testing.T) { upsertTestProduct(t, consts.CpsSourceMeituanOta, "mt-food-1", "food", "上海", "烛光晚餐双人套餐", 68800) svc := newTestSceneSvc(nil) - list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{Occasion: "约会", Location: "上海"}, consts.CpsSceneOccasion) + list, err := svc.Recommend(context.Background(), &dto.OutfitPlanItem{Occasion: "约会", Location: "上海"}, consts.CpsSceneOccasion) if err != nil { t.Fatalf("场合推荐失败: %v", err) } @@ -117,7 +118,7 @@ func TestRecommendOccasion(t *testing.T) { func TestRecommendEmptyDegrade(t *testing.T) { svc := newTestSceneSvc(nil) - list, err := svc.Recommend(context.Background(), &entity.OutfitPlan{Occasion: "旅行", Location: "北京"}, consts.CpsSceneOccasion) + list, err := svc.Recommend(context.Background(), &dto.OutfitPlanItem{Occasion: "旅行", Location: "北京"}, consts.CpsSceneOccasion) if err != nil { t.Fatalf("无商品时应返回空而非错误: %v", err) } diff --git a/server/styleagent/service/user_photo_service.go b/server/styleagent/service/user_photo_service.go index 5a17f8f..81d20bd 100644 --- a/server/styleagent/service/user_photo_service.go +++ b/server/styleagent/service/user_photo_service.go @@ -7,6 +7,7 @@ import ( commonHttp "slogan-agent/common" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" "github.com/gogf/gf/v2/net/ghttp" @@ -16,30 +17,55 @@ type userPhotoService struct{} var UserPhotoService = new(userPhotoService) -func (s *userPhotoService) Upload(ctx context.Context, userId int64, photoType int, file *ghttp.UploadFile) (int64, error) { +func (s *userPhotoService) Upload(ctx context.Context, userId int64, req *dto.UserPhotoUploadReq, file *ghttp.UploadFile) (*dto.UserPhotoUploadRes, error) { url, err := commonHttp.SaveUploadedFile(file, fmt.Sprintf("user_%d/photos", userId)) if err != nil { - return 0, err + return nil, err } - return dao.UserPhoto.Insert(ctx, &entity.UserPhoto{ + id, err := dao.UserPhoto.Insert(ctx, &entity.UserPhoto{ UserId: userId, - Type: photoType, + Type: req.Type, Url: url, Status: 1, }) + if err != nil { + return nil, err + } + return &dto.UserPhotoUploadRes{Id: id}, nil } -func (s *userPhotoService) List(ctx context.Context, userId int64, photoType int) ([]*entity.UserPhoto, error) { - return dao.UserPhoto.ListByUser(ctx, userId, photoType) +func (s *userPhotoService) List(ctx context.Context, userId int64, photoType int) (*dto.UserPhotoListRes, error) { + list, err := dao.UserPhoto.ListByUser(ctx, userId, photoType) + if err != nil { + return nil, err + } + out := make([]*dto.UserPhotoItem, 0, len(list)) + for _, p := range list { + out = append(out, toUserPhotoItem(p)) + } + return &dto.UserPhotoListRes{List: out}, nil } -func (s *userPhotoService) Delete(ctx context.Context, userId, id int64) error { +func (s *userPhotoService) Delete(ctx context.Context, userId, id int64) (*dto.UserPhotoDeleteRes, error) { p, err := dao.UserPhoto.GetOne(ctx, id, userId) - if err != nil || p == nil { - return errors.New("照片不存在") + if err != nil { + return nil, err + } + if p == nil { + return nil, errors.New("照片不存在") } if err := commonHttp.RemoveWorkspaceFile(p.Url); err != nil { - return err + return nil, err + } + if err := dao.UserPhoto.Delete(ctx, id); err != nil { + return nil, err + } + return &dto.UserPhotoDeleteRes{}, nil +} + +func toUserPhotoItem(p *entity.UserPhoto) *dto.UserPhotoItem { + return &dto.UserPhotoItem{ + Id: p.Id, UserId: p.UserId, Type: p.Type, + Url: p.Url, Status: p.Status, CreatedAt: p.CreatedAt, } - return dao.UserPhoto.Delete(ctx, id) } diff --git a/server/styleagent/service/user_service.go b/server/styleagent/service/user_service.go index 428546f..16ba522 100644 --- a/server/styleagent/service/user_service.go +++ b/server/styleagent/service/user_service.go @@ -7,6 +7,7 @@ import ( "slogan-agent/common" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" "github.com/golang-jwt/jwt/v5" @@ -17,45 +18,49 @@ type userService struct{} var UserService = new(userService) -func (s *userService) Register(ctx context.Context, account, password, name string) (int64, error) { - if account == "" || password == "" { - return 0, errors.New("账号和密码不能为空") +func (s *userService) Register(ctx context.Context, req *dto.RegisterReq) (*dto.RegisterRes, error) { + if req.Account == "" || req.Password == "" { + return nil, errors.New("账号和密码不能为空") } - existing, err := dao.User.GetByAccount(ctx, account) + existing, err := dao.User.GetByAccount(ctx, req.Account) if err != nil { - return 0, err + return nil, err } if existing != nil { - return 0, errors.New("账号已存在") + return nil, errors.New("账号已存在") } - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) if err != nil { - return 0, err + return nil, err } + name := req.Name if name == "" { - name = account + name = req.Account } - return dao.User.Insert(ctx, &entity.User{ + if _, err := dao.User.Insert(ctx, &entity.User{ Role: "user", - Username: account, + Username: req.Account, Password: string(hash), Name: name, - }) + }); err != nil { + return nil, err + } + return &dto.RegisterRes{}, nil } -func (s *userService) Login(ctx context.Context, account, password string) (*entity.User, string, error) { - if account == "" { - return nil, "", errors.New("请输入账号") +func (s *userService) Login(ctx context.Context, req *dto.LoginReq) (*dto.LoginRes, error) { + if req.Account == "" { + return nil, errors.New("请输入账号") } - user, err := dao.User.GetByAccount(ctx, account) + user, err := dao.User.GetByAccount(ctx, req.Account) if err != nil { - return nil, "", err + return nil, err } if user == nil { - return nil, "", errors.New("账号不存在") + return nil, errors.New("账号不存在") } - if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil { - return nil, "", errors.New("密码错误") + if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) != nil { + return nil, errors.New("密码错误") } now := time.Now() claims := common.JwtClaims{ @@ -68,25 +73,46 @@ func (s *userService) Login(ctx context.Context, account, password string) (*ent } token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(common.GetJwtSecret())) if err != nil { - return nil, "", err + return nil, err } - return user, token, nil + return &dto.LoginRes{ + Token: token, + User: &dto.LoginUser{Id: user.Id, Role: user.Role, Name: user.Name}, + }, nil } -func (s *userService) ChangePassword(ctx context.Context, userId int64, oldPwd, newPwd string) error { +func (s *userService) ChangePassword(ctx context.Context, userId int64, req *dto.ChangePasswordReq) (*dto.ChangePasswordRes, error) { user, err := dao.User.GetOne(ctx, userId) if err != nil { - return err + return nil, err } if user == nil { - return errors.New("用户不存在") + return nil, errors.New("用户不存在") } - if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(oldPwd)) != nil { - return errors.New("原密码错误") + if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.OldPassword)) != nil { + return nil, errors.New("原密码错误") } - hash, err := bcrypt.GenerateFromPassword([]byte(newPwd), bcrypt.DefaultCost) + hash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost) if err != nil { - return err + return nil, err } - return dao.User.UpdateFields(ctx, userId, map[string]any{"password": string(hash)}) + if err := dao.User.UpdateFields(ctx, userId, map[string]any{"password": string(hash)}); err != nil { + return nil, err + } + return &dto.ChangePasswordRes{}, nil +} + +// Profile 我的资料(用户不存在返回空资料,兼容已过期 token 清理场景) +func (s *userService) Profile(ctx context.Context, userId int64) (*dto.ProfileRes, error) { + user, err := dao.User.GetOne(ctx, userId) + if err != nil { + return nil, err + } + if user == nil { + return &dto.ProfileRes{}, nil + } + return &dto.ProfileRes{ + Id: user.Id, Role: user.Role, Name: user.Name, + Username: user.Username, Phone: user.Phone, + }, nil } diff --git a/server/styleagent/service/wardrobe_item_service.go b/server/styleagent/service/wardrobe_item_service.go index a8ffa43..e287a05 100644 --- a/server/styleagent/service/wardrobe_item_service.go +++ b/server/styleagent/service/wardrobe_item_service.go @@ -7,9 +7,9 @@ import ( commonHttp "slogan-agent/common" "slogan-agent/styleagent/dao" + "slogan-agent/styleagent/model/dto" "slogan-agent/styleagent/model/entity" - "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" ) @@ -17,39 +17,82 @@ type wardrobeService struct{} var WardrobeService = new(wardrobeService) -func (s *wardrobeService) Upload(ctx context.Context, userId int64, req entity.WardrobeItem, file *ghttp.UploadFile) (int64, error) { +func (s *wardrobeService) Upload(ctx context.Context, userId int64, req *dto.WardrobeUploadReq, file *ghttp.UploadFile) (*dto.WardrobeUploadRes, error) { url, err := commonHttp.SaveUploadedFile(file, fmt.Sprintf("user_%d/wardrobe", userId)) if err != nil { - return 0, err + return nil, err } - req.UserId = userId - req.PhotoUrl = url - req.Status = 1 - if req.Season == "" { - req.Season = "四季" + season := req.Season + if season == "" { + season = "四季" } - return dao.WardrobeItem.Insert(ctx, &req) + id, err := dao.WardrobeItem.Insert(ctx, &entity.WardrobeItem{ + UserId: userId, PhotoUrl: url, Category: req.Category, Season: season, + StyleTags: req.StyleTags, ColorInfo: req.ColorInfo, Status: 1, + }) + if err != nil { + return nil, err + } + return &dto.WardrobeUploadRes{Id: id}, nil } -func (s *wardrobeService) List(ctx context.Context, userId int64, category string) ([]*entity.WardrobeItem, error) { - return dao.WardrobeItem.ListByUser(ctx, userId, category) +func (s *wardrobeService) List(ctx context.Context, userId int64, category string) (*dto.WardrobeListRes, error) { + list, err := dao.WardrobeItem.ListByUser(ctx, userId, category) + if err != nil { + return nil, err + } + out := make([]*dto.WardrobeItem, 0, len(list)) + for _, it := range list { + out = append(out, toWardrobeItem(it)) + } + return &dto.WardrobeListRes{List: out}, nil } -func (s *wardrobeService) Update(ctx context.Context, userId, id int64, data g.Map) error { +func (s *wardrobeService) Update(ctx context.Context, userId int64, req *dto.WardrobeUpdateReq) (*dto.WardrobeUpdateRes, error) { + item, err := dao.WardrobeItem.GetOne(ctx, req.Id, userId) + if err != nil { + return nil, err + } + if item == nil { + return nil, errors.New("服装不存在") + } + data := map[string]any{} + if req.Category != "" { + data["category"] = req.Category + } + if req.Season != "" { + data["season"] = req.Season + } + if req.StyleTags != "" { + data["style_tags"] = req.StyleTags + } + if err := dao.WardrobeItem.Update(ctx, req.Id, data); err != nil { + return nil, err + } + return &dto.WardrobeUpdateRes{}, nil +} + +func (s *wardrobeService) Delete(ctx context.Context, userId, id int64) (*dto.WardrobeDeleteRes, error) { item, err := dao.WardrobeItem.GetOne(ctx, id, userId) - if err != nil || item == nil { - return errors.New("服装不存在") + if err != nil { + return nil, err } - return dao.WardrobeItem.Update(ctx, id, data) -} - -func (s *wardrobeService) Delete(ctx context.Context, userId, id int64) error { - item, err := dao.WardrobeItem.GetOne(ctx, id, userId) - if err != nil || item == nil { - return errors.New("服装不存在") + if item == nil { + return nil, errors.New("服装不存在") } if err := commonHttp.RemoveWorkspaceFile(item.PhotoUrl); err != nil { - return err + return nil, err + } + if err := dao.WardrobeItem.Delete(ctx, id); err != nil { + return nil, err + } + return &dto.WardrobeDeleteRes{}, nil +} + +func toWardrobeItem(it *entity.WardrobeItem) *dto.WardrobeItem { + return &dto.WardrobeItem{ + Id: it.Id, UserId: it.UserId, PhotoUrl: it.PhotoUrl, Name: it.Name, + Category: it.Category, Season: it.Season, StyleTags: it.StyleTags, + ColorInfo: it.ColorInfo, Status: it.Status, CreatedAt: it.CreatedAt, } - return dao.WardrobeItem.Delete(ctx, id) } diff --git a/server/workspace/user_1/photos/1785723771952782000_front.png b/server/workspace/user_1/photos/1785723771952782000_front.png deleted file mode 100644 index ae7985d..0000000 Binary files a/server/workspace/user_1/photos/1785723771952782000_front.png and /dev/null differ diff --git a/server/workspace/user_1/photos/1785723799465074000_side.png b/server/workspace/user_1/photos/1785723799465074000_side.png deleted file mode 100644 index a360d45..0000000 Binary files a/server/workspace/user_1/photos/1785723799465074000_side.png and /dev/null differ diff --git a/server/workspace/user_1/photos/1785723825045739000_back.png b/server/workspace/user_1/photos/1785723825045739000_back.png deleted file mode 100644 index 75275db..0000000 Binary files a/server/workspace/user_1/photos/1785723825045739000_back.png and /dev/null differ diff --git a/server/workspace/user_1/wardrobe/1785726274735460000_白色长袖衬衫.png b/server/workspace/user_1/wardrobe/1785726274735460000_白色长袖衬衫.png deleted file mode 100644 index 5525061..0000000 Binary files a/server/workspace/user_1/wardrobe/1785726274735460000_白色长袖衬衫.png and /dev/null differ diff --git a/server/workspace/user_1/wardrobe/1785726301212577000_灰色圆领T恤.png b/server/workspace/user_1/wardrobe/1785726301212577000_灰色圆领T恤.png deleted file mode 100644 index b924433..0000000 Binary files a/server/workspace/user_1/wardrobe/1785726301212577000_灰色圆领T恤.png and /dev/null differ diff --git a/server/workspace/user_1/wardrobe/1785726326891729000_深蓝夹克外套.png b/server/workspace/user_1/wardrobe/1785726326891729000_深蓝夹克外套.png deleted file mode 100644 index ae25927..0000000 Binary files a/server/workspace/user_1/wardrobe/1785726326891729000_深蓝夹克外套.png and /dev/null differ diff --git a/server/workspace/user_1/wardrobe/1785726352507021000_深灰休闲长裤.png b/server/workspace/user_1/wardrobe/1785726352507021000_深灰休闲长裤.png deleted file mode 100644 index 2c2c9c5..0000000 Binary files a/server/workspace/user_1/wardrobe/1785726352507021000_深灰休闲长裤.png and /dev/null differ diff --git a/server/workspace/user_1/wardrobe/1785726378078015000_蓝色牛仔裤.png b/server/workspace/user_1/wardrobe/1785726378078015000_蓝色牛仔裤.png deleted file mode 100644 index 1467673..0000000 Binary files a/server/workspace/user_1/wardrobe/1785726378078015000_蓝色牛仔裤.png and /dev/null differ diff --git a/server/workspace/user_1/wardrobe/1785726403709914000_白色运动鞋.png b/server/workspace/user_1/wardrobe/1785726403709914000_白色运动鞋.png deleted file mode 100644 index 4d03cb0..0000000 Binary files a/server/workspace/user_1/wardrobe/1785726403709914000_白色运动鞋.png and /dev/null differ diff --git a/server/workspace/user_1/wardrobe/1785726429309819000_棕色皮鞋.png b/server/workspace/user_1/wardrobe/1785726429309819000_棕色皮鞋.png deleted file mode 100644 index 5513e13..0000000 Binary files a/server/workspace/user_1/wardrobe/1785726429309819000_棕色皮鞋.png and /dev/null differ diff --git a/server/workspace/user_1/wardrobe/1785726455098469000_黑色双肩背包.png b/server/workspace/user_1/wardrobe/1785726455098469000_黑色双肩背包.png deleted file mode 100644 index d110d3a..0000000 Binary files a/server/workspace/user_1/wardrobe/1785726455098469000_黑色双肩背包.png and /dev/null differ diff --git a/技术设计.md b/技术设计.md new file mode 100644 index 0000000..63055d2 --- /dev/null +++ b/技术设计.md @@ -0,0 +1,168 @@ +# 技术设计 + +「我的形象穿搭」后端实现细节与技术决策。功能与接口清单见 README.md,开发规范见 CLAUDE.md。 + +## 1. 总体架构 + +前后端一体单端口部署(默认 :8080):GoFrame 托管 API + H5 静态产物 + workspace 文件服务。 + +``` +┌─────────────┐ H5 静态托管 ┌───────────────────────────┐ +│ 前端 app-uni │ ───────────────▶│ server (GoFrame :8080) │ +└─────────────┘ │ RouteRegister 反射路由 │ + │ REST/JSON │ Auth 中间件(JWT) │ + └───────────────────────▶│ workspace/* 文件服务 │ + └───────────┬───────────────┘ + │ 4 组 SQLite(data/) + ┌──────────────────┼──────────────────┐ + ▼ ▼ ▼ + default(slogan.db) plan(slogan_plan.db) pay(slogan_pay.db) / cps(slogan_cps.db) +``` + +外部依赖(均可配置,未配置时对应功能降级):大模型(LLM)、通义万相(出图)、Tripo(3D)、和风天气 + 高德地理、虎皮棋聚合支付、美团/京东/淘宝联盟。 + +## 2. 分层契约 + +| 层 | 输入 | 输出 | 约束 | +|---|---|---|---| +| controller | `*dto.XxxReq`(`v` tag 自动校验) | `*dto.XxxRes, error` | 只透传,禁止调 dao、禁止字段搬运 | +| service | 整 `*dto.XxxReq` 直传 | `*dto.XxxRes, error` | 组装/事务/校验/跨表;事务唯一入口 | +| dao | entity/单值 | entity/单值 | 单表 SQL;Record→entity 转换在 dao 内 | + +路由由 dto `g.Meta` 声明,`common.RouteRegister` 按 controller 结构体名反射为 kebab-case 组前缀统一注册;新增接口 = 写 dto + controller 方法,不手工注册。例外:支付回调 `/member/order/notify` 需裸文本响应 "success",由 controller 直接写响应体(HTTP 协议职责),经 Auth 白名单放行。 + +## 3. 数据库设计(4 库 22 表) + +SQLite 无 WAL 并发写,写一律回主 goroutine;跨表数据拆多条单表 SQL + 应用层内存组装(禁 JOIN/子查询,`IN` 按 ≤100 分批)。金额字段一律整数分 `int64`。查询走 `gdb.CacheOption` 缓存(TTL 60s,CacheName 用表名前缀),写操作后按表前缀 `ClearCache`。 + +### default(用户域) +| 表 | 关键字段 | 说明 | +|---|---|---| +| slogan_user | username/phone/password/role | 账号;username 唯一索引 | +| slogan_user_photo | user_id/type/url/status | type: 1 大头照 2 正面 3 侧面 4 背面;(user_id,type) 唯一,重复上传覆盖 | +| slogan_wardrobe_item | user_id/photo_url/name/category/season/style_tags/color_info | category: 上衣/下装/鞋/配饰 | +| slogan_body_measurement | user_id/height/weight/bust/waist/hip/shoulder/skin_tone/fit_params | 每用户一行 | +| slogan_avatar_model | user_id/glb_url/frames_url/build_status/params_snapshot | 每用户一行;params_snapshot 存构建参数 JSON | +| slogan_scoring_rule | dimension/rule_type/rules_json/enabled | 评分规则(5 维权重、效果图限次),seed 数据 | +| slogan_partner_store | name/type/lat/lng/address/commission_policy | 合作门店,seed 4 家 | + +### plan(穿搭域) +| 表 | 关键字段 | 说明 | +|---|---|---| +| slogan_hairstyle_asset | name/style_tag/glb_url/thumb_url/applicable_face | 发型资产,seed 8 款,公开接口 | +| slogan_outfit_generation_task | user_id/start_date/end_date/location/weather_snapshot/status/error/model_name | 状态机:pending→planning→scoring→rendering→done/failed | +| slogan_outfit_plan | task_id/user_id/date_range/title/source/score/main_flag/hairstyle_id/occasion | source: wardrobe/recommend;每任务多方案 | +| slogan_plan_outfit_item | plan_id/slot/source/wardrobe_item_id/product_name/name/desc | slot: top/bottom/shoes/accessory 等 | +| slogan_plan_effect_image | plan_id/angle/url/status/prompt_snapshot | angle: front/side/back | +| slogan_plan_review | plan_id/user_id/action/note | action: fav/unfav | + +### pay(支付域) +| 表 | 关键字段 | 说明 | +|---|---|---| +| slogan_member_plan | name/price_fen/duration_days/features/status | 套餐配置;金额分 int64 | +| slogan_user_member | user_id/plan_id/expire_at/source | 每用户一行会员态 | +| slogan_payment_order | order_no/user_id/plan_id/amount_fen/channel/status/trade_no/notify_raw | status: pending/paid/failed;order_no 唯一 | +| slogan_pay_notify_log | order_no/body/sign/status | 回调日志(记录类豁免分层,由支付 service 事务内直写) | +| slogan_ad_reward_log | user_id/ad_type/reward_key/status | 广告激励领取流水,限频依据 | + +### cps(联盟域) +| 表 | 关键字段 | 说明 | +|---|---|---| +| slogan_cps_category | code/name/parent_code/source/source_cat_id/sort | 三源归一分类树 | +| slogan_cps_product | source/outer_id/category_code/name/cover_url/price_fen/shop_name/commission_rate/city/scene_tags/raw/status | (source,outer_id) 唯一;raw 存原始报文 | +| slogan_cps_click_log | user_id/source/outer_id/scene/plan_id/category_code/deeplink/ip | 点击流水 | +| slogan_scene_category_map | scene_type/occasion/source/category_code/priority | 业务场景→联盟分类映射 | + +豁免分层规则(与 CLAUDE.md 一致): +- **完全豁免(无任何分层文件)**:`pay_notify_log`(记录类,建表归 payment_order_dao init(),读写由支付 service 事务内直写) +- **豁免 controller/dto(保留 entity/dao/service,无独立 HTTP 出入口)**:`plan_effect_image`、`plan_outfit_item`(方案详情聚合透出)、`scoring_rule`(纯内部配置)、`user_member`(member/status 状态聚合透出)——禁止造无路由的空壳 controller/dto 门面 +- 其余 17 表五层齐全,每层目录文件数 = 分层表数(21),非表文件不进业务分层目录 + +## 4. 核心流程设计 + +### 4.1 穿搭生成(slogan_outfit_generation_task 状态机) + +``` +POST /outfit/generate + → 校验(日期不倒挂、衣橱 ≥3 件、task 唯一性)→ 落任务(pending) → 提交协程池 + → planning:和风天气(7天预报) + 高德地理编码(location→adcode) → 规则预筛 3 套候选(衣橱季节/风格匹配) + → LLM 规划 1 次调用(OpenAI 兼容,temperature 0.8,超时 300s,重试 3 次) + → scoring:规则引擎 5 维评分(天气 25 / 场合 25 / 色彩 20 / 完整度 20 / 风格 10,阈值 75) + → 全低分 → LLM 兜底创作 1 次(recommend 来源方案) + → 落库 outfit_plan + plan_outfit_item → done +``` + +- 任务状态经 `GET /outfit/task/status` 轮询(前端 2s × 90);任何失败置 failed + 明确错误文案 +- 服务重启时未完成任务标记 failed(`main.go` StartWorker 恢复,避免重复消耗 LLM 费用) +- 并行调度:任务提交走 common 协程池(大小来自 config `pool` 节点,consts 提供默认值),禁止裸 go + +### 4.2 效果图(万相异步任务) + +``` +POST /outfit/plan/select-main → 置 main_flag → 触发 front/side/back 三张效果图任务(提交协程池) + → 万相 wan2.7-image-pro 提交异步任务 → 轮询任务状态(5s × 60)→ 下载落盘 workspace/plan_effect/{user}/{plan}/{angle}.png → done +``` + +- 内容 hash 缓存 24h(同方案不重复出图) +- 每日限 3 次(`scoring_rule` 表 `effect_limit` 维度),超限返回明确错误;广告激励可补充次数(见 4.5) +- prompt 由方案 item 描述组装(含发型/肤色/场景),prompt_snapshot 落库便于回溯 + +### 4.3 3D 化身(Tripo 图像转 3D) + +``` +POST /avatar/build(用户维度 WithLock 防重复构建) + → 校验三视角照片齐全(type 2/3/4)+ config 已配 tripo_api_key + → 落库 build_status=pending → 提交协程池 + → Tripo 图像转 3D(v2.5-20250123,多视角图 → GLB)→ 轮询任务(5s,上限 900s) + → 成功后本地渲染 36 帧旋转预览(Node + headless-gl,render.node_bin)→ frames_url → done +``` + +- 构建结果经 `GET /avatar/get` 轮询(前端 2s × 30);重启未完成任务标记 failed +- 帧序列目录:`workspace/avatar_frames/{user}/frame_001.png...`;GLB:`workspace/avatar/{user}.glb` + +### 4.4 会员支付(虎皮棋聚合支付) + +``` +POST /member/order/create → 生成 order_no + 调 xunhu 下单(amount 分→元仅此对接处,协议要求)→ 返回 pay_url +前端打开 pay_url → 轮询 GET /member/order/status(2s × 30) +POST /member/order/notify(公开,裸文本 "success")→ 验签 → 按 order_no 幂等(已 paid 直接 success) + → 事务:payment_order 置 paid + user_member upsert 会员态 + pay_notify_log 落日志 +``` + +- `xunhu_appid/appsecret` 未配置 → 支付接口返回"支付功能未配置",前端降级隐藏充值入口 +- 回调重复投递幂等:以 order_no 为唯一键查重,重复直接返回 success + +### 4.5 广告激励(限频) + +``` +POST /ad/reward/claim(ad_type: effect_extra / vip_trial) + → 自然日限频:effect_extra 2 次 / vip_trial 1 次(ad_reward_log 当日计数) + → 超限返回明确错误;effect_extra 增加当日效果图额度,vip_trial 发放体验会员 +``` + +### 4.6 CPS 联盟 + +- 定时同步:`cps.sync_cron`(默认 `0 4 * * *`)拉取美团/京东/淘宝选品池,按 (source,outer_id) upsert,分类归一到 slogan_cps_category +- 推荐链路:场景(发型卡/买同款/到店试穿/延伸优惠/找升级款/会员权益)→ scene_category_map → 分类 → 商品池筛选(city/scene_tags) +- 转链:`POST /cps/product/link` 调联盟转链接口 + cps_click_log 落点击流水(按 (user,outer_id,scene) 去重) +- key 全空 → 联盟入口优雅降级隐藏(前端不渲染) + +## 5. 关键技术决策 + +| 决策 | 方案 | 理由 | +|---|---|---| +| 存储 | SQLite 4 库分域 | 单机部署零运维;分域隔离写锁争用;文件即备份 | +| 金额 | 整数分 int64,`common.RoundInt` 四舍五入 | 杜绝浮点误差;前端 ÷100 展示 | +| 查询缓存 | `gdb.CacheOption`(TTL 60s,CacheName=表名前缀+参数),写后 `ClearCache(表前缀)` | 命中率高、清理精确;防"库里已改、查询旧值" | +| 并发 | 并行点三处落地:common 池封装 → consts 默认大小 → config `pool` 节点;用户维度互斥用 `common.WithLock` | 统一入口、可配置、防裸 go 失控 | +| 锁 | `common.WithLock[T]` 泛型:配置 redis → redis 锁(SET NX EX + token 删除),否则 gcache 内存锁;ErrLockHeld 重试,defer 释放 | 单机/多实例部署形态自适应 | +| 任务恢复 | 启动时未完成任务置 failed | 防重启后重复消耗外部服务费用 | +| 回调幂等 | 业务唯一键先查重再落库 | 支付回调/CPS 同步都可能重复投递 | +| 图片 URL | 相对路径 `/workspace/...`,前端 `resolveUrl()` 拼 BASE_URL | 部署地址变化前端一处配置 | + +## 6. 风险与备选 + +- **LLM 输出不稳定**:评分引擎兜底 + 全低分走 recommend 创作;prompt 模板在 agent/ 集中维护 +- **万相/Tripo 任务长耗时**:异步任务 + 轮询;超时置 failed 可重试 +- **SQLite 并发写锁**:写回主 goroutine 串行;分库降低跨域锁争用;必要时可切 WAL 或迁移 PG +- **缓存脏读**:写后按表前缀清缓存全覆盖(含 Insert);冒烟回归验证改后读新值