server: web 静态托管完善 + 联调脚本 + 清理过期工具
- main.go/auth_middleware: web 静态目录托管与 history 回退、Chrome DevTools 探测处理 - 新增 scripts/build_web.sh(缓存判断构建 web)与 scripts/dev.sh(一键构建+启动) - 删除已过时的 routes.sh(goframe 自动生成 api.json)与 split_db(数据库已拆分完毕) - docs: 补充联调与测试规范(测试必须用真实用户数据 wenwu901/123456)
This commit is contained in:
@@ -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
|
||||
@@ -4,6 +4,7 @@ slogan.db
|
||||
data/
|
||||
slogan-agent
|
||||
workspace/
|
||||
web/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
+23
-4
@@ -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"]
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
+5
-4
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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` 仍使用临时注册账号(仅验证接口可用性,不依赖业务数据)。
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
"
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user