diff --git a/server/.dockerignore b/server/.dockerignore deleted file mode 100644 index b184c4a..0000000 --- a/server/.dockerignore +++ /dev/null @@ -1,14 +0,0 @@ -# 构建与运行时产物(避免把生产数据库与图片塞进构建上下文) -data/ -workspace/ -*.db - -# 渲染器依赖:由 Dockerfile renderer stage 安装,宿主 node_modules 平台不匹配,排除以防覆盖 -scripts/avatar-render/node_modules/ - -# VCS 与 IDE -.git -.gitignore -.idea/ -.vscode/ -.DS_Store diff --git a/server/.gitignore b/server/.gitignore index 380ec5e..bba7c64 100644 --- a/server/.gitignore +++ b/server/.gitignore @@ -4,6 +4,7 @@ slogan.db data/ slogan-agent workspace/ +web/ # IDE .idea/ diff --git a/server/Dockerfile b/server/Dockerfile index a67908d..926b948 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,3 +1,19 @@ +# ============================================================ +# 统一构建:Flutter web 前端 + Go 后端 + Node 渲染器 +# 构建上下文必须为仓库根目录(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 +WORKDIR /web +COPY app/pubspec.yaml app/pubspec.lock ./ +RUN flutter pub get +COPY app/ . +RUN flutter build web --release + +# ---------- 后端:Go 编译 ---------- FROM golang:alpine AS builder RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ && apk add --no-cache git ca-certificates tzdata @@ -7,28 +23,31 @@ ENV GOPROXY=https://goproxy.cn,direct ENV CGO_ENABLED=0 ENV GOTOOLCHAIN=auto WORKDIR /build -COPY . . +COPY server/go.mod server/go.sum ./ RUN go mod download +COPY server/ . RUN go build -ldflags="-s -w" -o main ./main.go -# 3D 化身渲染器(Node + headless-gl,需原生编译) +# ---------- 渲染器: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 scripts/avatar-render/package*.json ./ +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 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=builder /build/config.yml . COPY --from=builder /build/main . COPY --from=renderer /render/node_modules ./scripts/avatar-render/node_modules -COPY scripts/avatar-render/ ./scripts/avatar-render/ +COPY server/scripts/avatar-render/ ./scripts/avatar-render/ RUN mkdir -p /app/workspace /app/data EXPOSE 3007 ENTRYPOINT ["./main"] diff --git a/server/common/auth_middleware.go b/server/common/auth_middleware.go index 289c1dd..f9a72ff 100644 --- a/server/common/auth_middleware.go +++ b/server/common/auth_middleware.go @@ -32,6 +32,12 @@ func Auth(r *ghttp.Request) { return } + // web 静态资源与前端 history 路由放行(浏览器加载页面不带 Authorization) + if IsWebAsset(path) || IsWebHistoryRoute(path) { + r.Middleware.Next() + return + } + auth := r.Header.Get("Authorization") if auth == "" || !strings.HasPrefix(auth, "Bearer ") { r.Response.WriteJson(ghttp.DefaultHandlerResponse{ diff --git a/server/common/web.go b/server/common/web.go new file mode 100644 index 0000000..dff9d3c --- /dev/null +++ b/server/common/web.go @@ -0,0 +1,57 @@ +package common + +import ( + "os" + "strings" +) + +// webStaticDirs 候选前端静态目录(相对 server 工作目录,按序取第一个存在者): +// 1. 本地开发:直接服务 Flutter 构建产物 app/build/web(scripts/build_web.sh 或 dev.sh 构建) +// 2. Docker 镜像:Dockerfile web-builder 阶段产物(/app/web) +var webStaticDirs = []string{ + "../app/build/web", + "web", +} + +// WebStaticDir 返回可用的前端静态目录;全部不存在时返回 ""(仅 API 模式) +func WebStaticDir() string { + for _, dir := range webStaticDirs { + if _, err := os.Stat(dir); err == nil { + return dir + } + } + return "" +} + +// apiPathPrefixes 后端 API 路径前缀,需与 RouteRegister 控制器注册的 kebab-case 保持一致。 +// web 静态资源与 history 路由的识别都依赖它:带扩展名的路径视为静态资源, +// 无扩展名且命中 API 前缀的视为后端接口,其余视为前端 history 路由。 +var apiPathPrefixes = []string{ + "/user", "/user-photo", "/wardrobe", "/body-measurement", "/avatar", + "/hairstyle", "/outfit", "/partner-store", "/member", "/ad", "/cps", + "/workspace", "/api.json", +} + +// IsAPIRequest 是否后端 API/受保护路径(不参与 web 静态与 history 回退) +func IsAPIRequest(path string) bool { + for _, p := range apiPathPrefixes { + if path == p || strings.HasPrefix(path, p+"/") { + return true + } + } + return false +} + +// IsWebAsset web 静态资源:根路径或末段带扩展名(main.dart.js / assets/* / favicon.png),免鉴权放行 +func IsWebAsset(path string) bool { + if path == "/" { + return true + } + base := path[strings.LastIndex(path, "/")+1:] + return strings.Contains(base, ".") +} + +// IsWebHistoryRoute 前端 history 路由(无扩展名且非 API 路径),回退服务 index.html +func IsWebHistoryRoute(path string) bool { + return !IsWebAsset(path) && !IsAPIRequest(path) +} diff --git a/server/config.yml b/server/config.yml index 910f93c..31bfe8d 100644 --- a/server/config.yml +++ b/server/config.yml @@ -1,21 +1,22 @@ # SQLite 落盘到 data/ 子目录:本地开发与容器都便于挂载持久化(data/ 已入 .gitignore) +# debug: true 时 gdb 在控制台打印每条 SQL("[ms] [group] [schema] [rows] ..."),生产可关 database: default: name: data/slogan.db type: sqlite - debug: false + debug: true plan: name: data/slogan_plan.db type: sqlite - debug: false + debug: true pay: name: data/slogan_pay.db type: sqlite - debug: false + debug: true cps: name: data/slogan_cps.db type: sqlite - debug: false + debug: true cache: ttl: 60 server: diff --git a/server/docker-compose.yml b/server/docker-compose.yml index d523f72..f7d37b4 100644 --- a/server/docker-compose.yml +++ b/server/docker-compose.yml @@ -1,6 +1,9 @@ services: slogan-agent: - build: . + # 统一镜像包含 Flutter web 前端(Dockerfile web-builder stage 构建) + build: + context: .. + dockerfile: server/Dockerfile container_name: slogan-agent restart: unless-stopped ports: diff --git a/server/docs/项目文档.md b/server/docs/项目文档.md index 3dabfd2..996a109 100644 --- a/server/docs/项目文档.md +++ b/server/docs/项目文档.md @@ -102,3 +102,22 @@ docker run -d -p 3007:3007 -v /data/slogan:/app/workspace -v /data/slogan/slogan ``` 生产部署前在 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 c26e571..d3816e0 100644 --- a/server/main.go +++ b/server/main.go @@ -22,6 +22,34 @@ import ( ) func main() { + // ==================== Web 静态资源(Flutter web 构建产物) ==================== + // 静态目录不存在时优雅跳过(仅 API 模式运行);注册顺序在 Auth 之后,history 回退先经鉴权放行 + if dir := commonHttp.WebStaticDir(); dir != "" { + commonHttp.Httpserver.SetServerRoot(dir) + commonHttp.Httpserver.BindMiddlewareDefault(func(r *ghttp.Request) { + r.Middleware.Next() + if r.Response.Status == http.StatusNotFound && commonHttp.IsWebHistoryRoute(r.URL.Path) { + r.Response.ClearBuffer() + 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 前缀) ==================== commonHttp.RouteRegister([]interface{}{ controller.User, diff --git a/server/scripts/build_web.sh b/server/scripts/build_web.sh new file mode 100644 index 0000000..7bdc15e --- /dev/null +++ b/server/scripts/build_web.sh @@ -0,0 +1,41 @@ +#!/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 new file mode 100644 index 0000000..172c970 --- /dev/null +++ b/server/scripts/dev.sh @@ -0,0 +1,12 @@ +#!/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/routes.sh b/server/scripts/routes.sh deleted file mode 100644 index 35f55c5..0000000 --- a/server/scripts/routes.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/bash -# 路由快照:输出 /api.json 的全部路径(排序去重) -# 用法:bash scripts/routes.sh > /tmp/routes-before.txt -set -e -BASE="${BASE:-http://localhost:3007}" -curl -s "$BASE/api.json" | python3 -c " -import json, sys -d = json.load(sys.stdin) -for p in sorted(d.get('paths', {}).keys()): - print(p) -" diff --git a/server/scripts/split_db/main.go b/server/scripts/split_db/main.go deleted file mode 100644 index f789493..0000000 --- a/server/scripts/split_db/main.go +++ /dev/null @@ -1,238 +0,0 @@ -// 一次性迁移工具:把单文件 slogan.db 拆分为 4 个 SQLite 库 -// -// slogan.db 主库(用户域 + 低频配置) -// slogan_plan.db 穿搭方案域 -// slogan_pay.db 会员/支付域 -// slogan_cps.db CPS 联盟域 -// -// 用法:在 slogan-agent 目录执行 `go run ./scripts/split_db` -package main - -import ( - "database/sql" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - _ "github.com/gogf/gf/contrib/drivers/sqlite/v2" -) - -const mainDB = "slogan.db" - -var groups = []struct { - file string - tables []string -}{ - {"slogan_plan.db", []string{ - "slogan_outfit_generation_task", "slogan_outfit_plan", "slogan_plan_outfit_item", - "slogan_plan_effect_image", "slogan_plan_review", "slogan_hairstyle_asset", - }}, - {"slogan_pay.db", []string{ - "slogan_member_plan", "slogan_user_member", "slogan_payment_order", - "slogan_pay_notify_log", "slogan_ad_reward_log", - }}, - {"slogan_cps.db", []string{ - "slogan_cps_category", "slogan_cps_product", "slogan_cps_click_log", - "slogan_scene_category_map", - }}, -} - -func main() { - dir, err := os.Getwd() - if err != nil { - fatal("getwd: %v", err) - } - mainPath := filepath.Join(dir, mainDB) - if _, err := os.Stat(mainPath); err != nil { - fatal("slogan.db 不存在(请在 slogan-agent 目录执行): %v", err) - } - backup := filepath.Join(dir, "slogan_backup_"+time.Now().Format("20060102_150405")+".db") - if err := copyFile(mainPath, backup); err != nil { - fatal("备份失败: %v", err) - } - fmt.Printf("已备份 -> %s\n", backup) - - src, err := sql.Open("sqlite", mainPath) - if err != nil { - fatal("open %s: %v", mainDB, err) - } - src.SetMaxOpenConns(1) - defer src.Close() - - ddl, err := loadDDL(src) - if err != nil { - fatal("读取 DDL: %v", err) - } - srcAbs, err := filepath.Abs(mainPath) - if err != nil { - fatal("abs: %v", err) - } - - failed := false - for _, g := range groups { - if err := migrateGroup(dir, src, srcAbs, g.file, g.tables, ddl); err != nil { - fmt.Printf("❌ %s 迁移失败: %v\n", g.file, err) - failed = true - } - } - if failed { - os.Exit(1) - } - - // 主库删除已迁出的表(连带索引),清理自增序列残留 - for _, g := range groups { - for _, t := range g.tables { - if _, err := src.Exec("DROP TABLE IF EXISTS " + t); err != nil { - fatal("DROP %s: %v", t, err) - } - } - names := quoteList(g.tables) - if _, err := src.Exec("DELETE FROM sqlite_sequence WHERE name IN (" + names + ")"); err != nil { - fmt.Printf("⚠ 清理 sqlite_sequence 失败(可忽略): %v\n", err) - } - } - fmt.Printf("✅ %s 主库已清理,剩余表:\n", mainDB) - if err := listTables(src, mainDB); err != nil { - fatal("list: %v", err) - } - fmt.Println("✅ 拆分完成") -} - -// migrateGroup 新建目标库文件并拷贝表 + 索引 + 校验行数 -func migrateGroup(dir string, src *sql.DB, srcAbs, file string, tables []string, ddl map[string][]string) error { - dstPath := filepath.Join(dir, file) - _ = os.Remove(dstPath) // 覆盖上次失败残留 - dst, err := sql.Open("sqlite", dstPath) - if err != nil { - return err - } - dst.SetMaxOpenConns(1) - defer dst.Close() - - if _, err := dst.Exec(fmt.Sprintf("ATTACH DATABASE %q AS src", srcAbs)); err != nil { - return fmt.Errorf("attach: %w", err) - } - defer dst.Exec("DETACH DATABASE src") - - for _, t := range tables { - createDDL, ok := firstByType(ddl[t], "table") - if !ok { - return fmt.Errorf("表 %s 未找到建表 DDL", t) - } - if _, err := dst.Exec(createDDL); err != nil { - return fmt.Errorf("create %s: %w", t, err) - } - if _, err := dst.Exec(fmt.Sprintf("INSERT INTO %s SELECT * FROM src.%s", t, t)); err != nil { - return fmt.Errorf("copy %s: %w", t, err) - } - } - for _, t := range tables { - for _, idx := range ddl[t] { - if !strings.HasPrefix(idx, "CREATE INDEX") && !strings.HasPrefix(idx, "CREATE UNIQUE INDEX") { - continue - } - if _, err := dst.Exec(idx); err != nil { - return fmt.Errorf("index %s: %w", idx, err) - } - } - } - // 校验行数 - for _, t := range tables { - srcN, dstN, err := countPair(src, dst, t) - if err != nil { - return err - } - if srcN != dstN { - return fmt.Errorf("%s 行数不一致: src=%d dst=%d", t, srcN, dstN) - } - fmt.Printf("✅ %-24s %8d 行\n", t, dstN) - } - return nil -} - -func loadDDL(db *sql.DB) (map[string][]string, error) { - rows, err := db.Query("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' AND type IN ('table','index') ORDER BY type DESC") - if err != nil { - return nil, err - } - defer rows.Close() - out := map[string][]string{} - for rows.Next() { - var typ, name, sqlText string - if err := rows.Scan(&typ, &name, &sqlText); err != nil { - return nil, err - } - if strings.HasPrefix(sqlText, "CREATE TABLE") { - out[name] = append([]string{sqlText}, out[name]...) // table 放最前 - } else { - out[name] = append(out[name], sqlText) - } - } - return out, rows.Err() -} - -func firstByType(ddls []string, prefix string) (string, bool) { - for _, d := range ddls { - if strings.HasPrefix(d, "CREATE TABLE") { - return d, true - } - } - return "", false -} - -func countPair(src, dst *sql.DB, table string) (int, int, error) { - srcN, err := count(src, table) - if err != nil { - return 0, 0, err - } - dstN, err := count(dst, table) - if err != nil { - return 0, 0, err - } - return srcN, dstN, nil -} - -func count(db *sql.DB, table string) (int, error) { - var n int - err := db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n) - return n, err -} - -func listTables(db *sql.DB, file string) error { - rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name") - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var name string - if err := rows.Scan(&name); err != nil { - return err - } - fmt.Printf(" %s\n", name) - } - return rows.Err() -} - -func quoteList(items []string) string { - q := make([]string, len(items)) - for i, s := range items { - q[i] = "'" + s + "'" - } - return strings.Join(q, ",") -} - -func copyFile(src, dst string) error { - data, err := os.ReadFile(src) - if err != nil { - return err - } - return os.WriteFile(dst, data, 0o644) -} - -func fatal(format string, args ...any) { - fmt.Printf("❌ "+format+"\n", args...) - os.Exit(1) -}