diff --git a/.gitignore b/.gitignore
index ed6cd5d..77834e8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -151,3 +151,13 @@ temp/
*.tmp
*.bak
*.orig
+
+# ===== Web 模块 =====
+# Go embed 必须看到 web/backend/dist 目录,保留占位文件;真正的产物由 Docker 构建生成
+!web/backend/dist/
+web/backend/dist/*
+!web/backend/dist/.gitkeep
+!web/backend/dist/index.html
+# 前端构建产物完全忽略
+web/frontend/dist/
+web/frontend/node_modules/
diff --git a/CLAUDE.md b/CLAUDE.md
index 06028a5..21b4b83 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -44,3 +44,33 @@ sqlite3 data/futures.db "SELECT ts_code, trade_date, composite, signal FROM scor
## 配置/密钥规则
`.gitignore` 排除范围广(见文件):`data/`、`*.db*`、`.env*`、CTP 流文件(`*.con`/`*.dat`/`ResultInfo.xml` 等)、`.claude/`、所有日志。新增任何账户、token、行情流文件务必先确认匹配 ignore 规则。
+
+注意 `web/backend/dist/` 在 `.gitignore` 中有显式例外:目录本身入库,但内部文件除 `.gitkeep`/`index.html` 外都被忽略——这是为了 `go:embed all:dist` 在本地能编译,真正的 SPA 产物在 Docker 构建期生成。
+
+## Web 模块(报告浏览端)
+
+`./web/` 是独立的报告浏览端,与 `tushare` 流水线解耦:tushare 写 `data/futures.db`,web 只读访问。docker-compose 上是新增的 `web` 服务,与 `tushare` 共存不互相依赖。
+
+**架构与边界**:
+- 后端 Go(`golang:1.25.8-alpine3.23` 构建,`alpine:3.23` 运行),路由 `chi`,SQLite 驱动 `modernc.org/sqlite`(纯 Go 无 CGO,二进制更小、不需要 gcc)。前端 Vue 3 + Vite + Element Plus + ECharts。
+- 单进程同源服务:Vue 产物在 Docker 构建期由 `node` 阶段产出 `dist/`,被 `go:embed all:dist` 嵌入二进制,运行时由 Go 同时服务 `/api/*` 与 SPA 静态文件——不引入 nginx 旁车。
+- 双 DB 分离:`futures.db` 以 `mode=ro&query_only(true)` 打开,容器挂 `:ro` 双重保险;`auth.db` 由 web 自己 init/写入,落在 `./data/auth.db`(已被 `.gitignore` 覆盖)。
+- 鉴权 JWT(HS256, Bearer header),12h 过期,无 sessions 表。前端把 token 持久化到 `localStorage`,axios 拦截器统一注入 + 401 自动跳登录。
+
+**禁止公开注册**:登录后管理员才能在 `/admin/users` 维护账号。首次启动时 `auth.Bootstrap` 检查 `users` 表,若没有 admin 行 *且* env 同时存在 `ADMIN_USER`/`ADMIN_PASS`,则用 bcrypt(cost=12) 写一行 admin。一旦 admin 存在,这两个 env 被静默忽略——避免轮换 env 时静默改密。忘记管理员密码的恢复方式:停服 → `sqlite3 data/auth.db "DELETE FROM users WHERE role='admin'"` → 重置 env → 重启。
+
+**修改即重建**:沿用 `tushare` 服务的约定,`web/backend/` 与 `web/frontend/` 都通过镜像 COPY 进容器,**没有源码挂载**。改完 Go/Vue 代码不重建镜像就跑等于跑旧代码。重建命令 `docker-compose build web`。
+
+**常用命令**:
+```bash
+# 首启需先在 web/backend/.env 写 ADMIN_USER/ADMIN_PASS/JWT_SECRET (gitignored)
+docker-compose up -d web
+docker-compose logs -f web # 看 [bootstrap] 日志确认 admin 是否被创建
+
+# 仅重建 web,不影响 tushare
+docker-compose build web && docker-compose up -d web
+
+# 本地开发 (后端 + 前端分别起,api 走代理)
+cd web/backend && go run ./ # 需要本地 Go 1.25.8;dist/ 目录的占位会被 embed
+cd web/frontend && npm install && npm run dev # 默认 5173 端口,/api 代理到 8080
+```
diff --git a/docker-compose.yml b/docker-compose.yml
index 1e33d59..6249e61 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -7,3 +7,22 @@ services:
volumes:
- ./data:/app/data
command: ["python", "-m", "src.main"]
+
+ web:
+ build:
+ context: ./web
+ dockerfile: backend/Dockerfile
+ env_file: ./web/backend/.env
+ environment:
+ - LISTEN_ADDR=:8080
+ - FUTURES_DB_PATH=/app/data/futures.db
+ - AUTH_DB_PATH=/app/auth/auth.db
+ volumes:
+ # futures.db 由 tushare 写入,web 端通过 DSN mode=ro&query_only 只读访问;
+ # 不在容器层加 :ro,因为 WAL 模式下读访问也需要写 -shm 同步文件
+ - ./data:/app/data
+ # auth.db 由 web 自己写,落在 ./data/auth.db (已被 .gitignore)
+ - ./data:/app/auth
+ ports:
+ - "8080:8080"
+ restart: unless-stopped
diff --git a/web/.dockerignore b/web/.dockerignore
new file mode 100644
index 0000000..8d26be8
--- /dev/null
+++ b/web/.dockerignore
@@ -0,0 +1,8 @@
+**/node_modules
+**/dist
+**/.env
+**/.env.*
+!**/.env.example
+**/.DS_Store
+**/.git
+backend/tmp
diff --git a/web/backend/.env.example b/web/backend/.env.example
new file mode 100644
index 0000000..5bd622b
--- /dev/null
+++ b/web/backend/.env.example
@@ -0,0 +1,8 @@
+# 拷贝为 web/backend/.env 后填入真实值。.env 已被 .gitignore 排除。
+# 首次启动时,若 auth.db 中没有任何 admin 用户,会用下面这一对凭据创建管理员;
+# 一旦 admin 已存在,这两个变量会被忽略,改它们不会改密码。
+ADMIN_USER=admin
+ADMIN_PASS=changeme
+
+# JWT 签名密钥;生成方式:openssl rand -hex 32
+JWT_SECRET=replace-with-32-bytes-hex
diff --git a/web/backend/Dockerfile b/web/backend/Dockerfile
new file mode 100644
index 0000000..0866937
--- /dev/null
+++ b/web/backend/Dockerfile
@@ -0,0 +1,54 @@
+# ==================== Stage 1: 前端构建 ====================
+FROM node:20-alpine AS ui
+
+WORKDIR /ui
+
+# 优先拷贝 package.json 命中 layer cache;无 lock 时退回 npm install
+COPY frontend/package*.json ./
+RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi
+
+COPY frontend ./
+RUN npm run build
+
+
+# ==================== Stage 2: Go 构建 ====================
+FROM golang:1.25.8-alpine3.23 AS api
+
+WORKDIR /src
+
+# 国内可选:RUN go env -w GOPROXY=https://goproxy.cn,direct
+
+COPY backend ./
+COPY --from=ui /ui/dist ./dist
+
+# 用 modernc.org/sqlite 纯 Go 驱动,无 CGO,无需 gcc/musl-dev
+ENV CGO_ENABLED=0 GOOS=linux
+
+RUN go mod tidy && \
+ go build -trimpath -ldflags="-s -w" -o /out/web ./
+
+
+# ==================== Stage 3: 运行时 ====================
+FROM alpine:3.23
+
+RUN apk add --no-cache tzdata ca-certificates && \
+ cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
+ echo "Asia/Shanghai" > /etc/timezone && \
+ apk del tzdata && \
+ adduser -D -u 1000 app && \
+ mkdir -p /app/data /app/auth && \
+ chown -R app:app /app
+
+WORKDIR /app
+USER app
+
+COPY --from=api --chown=app:app /out/web /app/web
+
+ENV TZ=Asia/Shanghai \
+ LISTEN_ADDR=:8080 \
+ FUTURES_DB_PATH=/app/data/futures.db \
+ AUTH_DB_PATH=/app/auth/auth.db
+
+EXPOSE 8080
+
+CMD ["/app/web"]
diff --git a/web/backend/dist/.gitkeep b/web/backend/dist/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/web/backend/dist/index.html b/web/backend/dist/index.html
new file mode 100644
index 0000000..3232c95
--- /dev/null
+++ b/web/backend/dist/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+ Trade Web
+
+
+ 请通过 docker-compose build web 构建生产镜像后访问。
+ 本地开发请运行 npm run dev (web/frontend/) 与 go run ./ (web/backend/)。
+
+
diff --git a/web/backend/embed.go b/web/backend/embed.go
new file mode 100644
index 0000000..b3a56a8
--- /dev/null
+++ b/web/backend/embed.go
@@ -0,0 +1,6 @@
+package main
+
+import "embed"
+
+//go:embed all:dist
+var distFS embed.FS
diff --git a/web/backend/go.mod b/web/backend/go.mod
new file mode 100644
index 0000000..edd0dc6
--- /dev/null
+++ b/web/backend/go.mod
@@ -0,0 +1,10 @@
+module trade/web
+
+go 1.25.8
+
+require (
+ github.com/go-chi/chi/v5 v5.1.0
+ github.com/golang-jwt/jwt/v5 v5.2.1
+ golang.org/x/crypto v0.27.0
+ modernc.org/sqlite v1.32.0
+)
diff --git a/web/backend/internal/auth/bcrypt.go b/web/backend/internal/auth/bcrypt.go
new file mode 100644
index 0000000..045e077
--- /dev/null
+++ b/web/backend/internal/auth/bcrypt.go
@@ -0,0 +1,17 @@
+package auth
+
+import "golang.org/x/crypto/bcrypt"
+
+const bcryptCost = 12
+
+func HashPassword(plain string) (string, error) {
+ b, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost)
+ if err != nil {
+ return "", err
+ }
+ return string(b), nil
+}
+
+func CheckPassword(hash, plain string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) == nil
+}
diff --git a/web/backend/internal/auth/bootstrap.go b/web/backend/internal/auth/bootstrap.go
new file mode 100644
index 0000000..ec2bcfe
--- /dev/null
+++ b/web/backend/internal/auth/bootstrap.go
@@ -0,0 +1,32 @@
+package auth
+
+import (
+ "log"
+
+ "trade/web/internal/store"
+)
+
+// Bootstrap 在 auth.db 没有任何 admin 时,从 ADMIN_USER/ADMIN_PASS 写入一条管理员;
+// 已存在 admin 时静默跳过,避免轮换 env 时静默改密。
+func Bootstrap(s *store.AuthStore, adminUser, adminPass string) error {
+ n, err := s.CountAdmins()
+ if err != nil {
+ return err
+ }
+ if n > 0 {
+ return nil
+ }
+ if adminUser == "" || adminPass == "" {
+ log.Printf("[bootstrap] auth.db 无 admin,但 ADMIN_USER/ADMIN_PASS 未设置,跳过引导")
+ return nil
+ }
+ hash, err := HashPassword(adminPass)
+ if err != nil {
+ return err
+ }
+ if _, err := s.CreateUser(adminUser, hash, store.RoleAdmin); err != nil {
+ return err
+ }
+ log.Printf("[bootstrap] admin %q created", adminUser)
+ return nil
+}
diff --git a/web/backend/internal/auth/jwt.go b/web/backend/internal/auth/jwt.go
new file mode 100644
index 0000000..a53ee22
--- /dev/null
+++ b/web/backend/internal/auth/jwt.go
@@ -0,0 +1,55 @@
+package auth
+
+import (
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+const tokenTTL = 12 * time.Hour
+
+type Claims struct {
+ UserID int64 `json:"uid"`
+ Username string `json:"usr"`
+ Role string `json:"role"`
+ jwt.RegisteredClaims
+}
+
+type Manager struct{ secret []byte }
+
+func NewManager(secret []byte) *Manager { return &Manager{secret: secret} }
+
+func (m *Manager) Issue(userID int64, username, role string) (string, time.Time, error) {
+ exp := time.Now().Add(tokenTTL)
+ claims := Claims{
+ UserID: userID,
+ Username: username,
+ Role: role,
+ RegisteredClaims: jwt.RegisteredClaims{
+ ExpiresAt: jwt.NewNumericDate(exp),
+ IssuedAt: jwt.NewNumericDate(time.Now()),
+ },
+ }
+ tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ s, err := tok.SignedString(m.secret)
+ return s, exp, err
+}
+
+func (m *Manager) Parse(tokenStr string) (*Claims, error) {
+ tok, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (any, error) {
+ if t.Method.Alg() != jwt.SigningMethodHS256.Alg() {
+ return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
+ }
+ return m.secret, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ claims, ok := tok.Claims.(*Claims)
+ if !ok || !tok.Valid {
+ return nil, errors.New("invalid token")
+ }
+ return claims, nil
+}
diff --git a/web/backend/internal/config/config.go b/web/backend/internal/config/config.go
new file mode 100644
index 0000000..0693f85
--- /dev/null
+++ b/web/backend/internal/config/config.go
@@ -0,0 +1,39 @@
+package config
+
+import (
+ "fmt"
+ "os"
+ "strings"
+)
+
+type Config struct {
+ ListenAddr string
+ FuturesDBPath string
+ AuthDBPath string
+ JWTSecret []byte
+ AdminUser string
+ AdminPass string
+}
+
+func Load() (*Config, error) {
+ cfg := &Config{
+ ListenAddr: getenv("LISTEN_ADDR", ":8080"),
+ FuturesDBPath: getenv("FUTURES_DB_PATH", "/app/data/futures.db"),
+ AuthDBPath: getenv("AUTH_DB_PATH", "/app/auth/auth.db"),
+ AdminUser: strings.TrimSpace(os.Getenv("ADMIN_USER")),
+ AdminPass: os.Getenv("ADMIN_PASS"),
+ }
+ secret := strings.TrimSpace(os.Getenv("JWT_SECRET"))
+ if len(secret) < 16 {
+ return nil, fmt.Errorf("JWT_SECRET 必须至少 16 个字符 (建议 openssl rand -hex 32)")
+ }
+ cfg.JWTSecret = []byte(secret)
+ return cfg, nil
+}
+
+func getenv(key, fallback string) string {
+ if v, ok := os.LookupEnv(key); ok && v != "" {
+ return v
+ }
+ return fallback
+}
diff --git a/web/backend/internal/handlers/auth.go b/web/backend/internal/handlers/auth.go
new file mode 100644
index 0000000..f74ea17
--- /dev/null
+++ b/web/backend/internal/handlers/auth.go
@@ -0,0 +1,90 @@
+package handlers
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+
+ "trade/web/internal/auth"
+ "trade/web/internal/middleware"
+ "trade/web/internal/store"
+)
+
+type loginReq struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+}
+
+type loginResp struct {
+ Token string `json:"token"`
+ User publicUserView `json:"user"`
+}
+
+type publicUserView struct {
+ ID int64 `json:"id"`
+ Username string `json:"username"`
+ Role string `json:"role"`
+}
+
+func (d *Deps) Login(w http.ResponseWriter, r *http.Request) {
+ var req loginReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeErr(w, http.StatusBadRequest, "invalid json")
+ return
+ }
+ req.Username = strings.TrimSpace(req.Username)
+ if req.Username == "" || req.Password == "" {
+ writeErr(w, http.StatusBadRequest, "用户名和密码不能为空")
+ return
+ }
+ u, err := d.Auth.GetByUsername(req.Username)
+ if err != nil || u.Disabled {
+ // 禁用账户与不存在账户返回同样的错误,避免账户枚举
+ writeErr(w, http.StatusUnauthorized, "用户名或密码错误")
+ return
+ }
+ if !auth.CheckPassword(u.PasswordHash, req.Password) {
+ writeErr(w, http.StatusUnauthorized, "用户名或密码错误")
+ return
+ }
+ token, _, err := d.JWT.Issue(u.ID, u.Username, u.Role)
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, "issue token failed")
+ return
+ }
+ writeJSON(w, http.StatusOK, loginResp{
+ Token: token,
+ User: publicUserView{ID: u.ID, Username: u.Username, Role: u.Role},
+ })
+}
+
+func (d *Deps) Logout(w http.ResponseWriter, r *http.Request) {
+ // JWT 是无状态的,服务端 logout 仅形式化;前端丢弃 token 即可。
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+func (d *Deps) Me(w http.ResponseWriter, r *http.Request) {
+ u, ok := middleware.FromContext(r.Context())
+ if !ok {
+ writeErr(w, http.StatusUnauthorized, "no user in context")
+ return
+ }
+ full, err := d.Auth.GetByID(u.ID)
+ if err != nil {
+ writeErr(w, http.StatusUnauthorized, "user not found")
+ return
+ }
+ writeJSON(w, http.StatusOK, sanitize(full))
+}
+
+// sanitize 把内部 User 转成对外视图,剥掉 password_hash。
+func sanitize(u *store.User) map[string]any {
+ return map[string]any{
+ "id": u.ID,
+ "username": u.Username,
+ "role": u.Role,
+ "disabled": u.Disabled,
+ "created_at": u.CreatedAt,
+ "updated_at": u.UpdatedAt,
+ }
+}
diff --git a/web/backend/internal/handlers/deps.go b/web/backend/internal/handlers/deps.go
new file mode 100644
index 0000000..eb9af13
--- /dev/null
+++ b/web/backend/internal/handlers/deps.go
@@ -0,0 +1,29 @@
+package handlers
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+
+ "trade/web/internal/auth"
+ "trade/web/internal/store"
+)
+
+// Deps 是所有 handler 需要的运行时依赖,在 router 装配时一次性注入。
+type Deps struct {
+ Auth *store.AuthStore
+ Futures *store.FuturesStore
+ JWT *auth.Manager
+}
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ if err := json.NewEncoder(w).Encode(body); err != nil {
+ log.Printf("[handler] encode response: %v", err)
+ }
+}
+
+func writeErr(w http.ResponseWriter, status int, msg string) {
+ writeJSON(w, status, map[string]string{"error": msg})
+}
diff --git a/web/backend/internal/handlers/scores.go b/web/backend/internal/handlers/scores.go
new file mode 100644
index 0000000..60a0933
--- /dev/null
+++ b/web/backend/internal/handlers/scores.go
@@ -0,0 +1,70 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "github.com/go-chi/chi/v5"
+
+ "trade/web/internal/store"
+)
+
+func (d *Deps) ListScores(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ limit := 0
+ if s := q.Get("limit"); s != "" {
+ if n, err := strconv.Atoi(s); err == nil {
+ limit = n
+ }
+ }
+ rows, err := d.Futures.ListScores(store.ScoreFilter{
+ TsCode: q.Get("ts_code"),
+ Start: q.Get("start"),
+ End: q.Get("end"),
+ Limit: limit,
+ })
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, rows)
+}
+
+func (d *Deps) GetScore(w http.ResponseWriter, r *http.Request) {
+ idStr := chi.URLParam(r, "id")
+ id, err := strconv.ParseInt(idStr, 10, 64)
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ row, err := d.Futures.GetScore(id)
+ if err != nil {
+ writeErr(w, http.StatusNotFound, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, row)
+}
+
+func (d *Deps) ListContracts(w http.ResponseWriter, r *http.Request) {
+ out, err := d.Futures.ListContracts()
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+func (d *Deps) ListCandles(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ rows, err := d.Futures.ListCandles(q.Get("ts_code"), q.Get("start"), q.Get("end"))
+ if err != nil {
+ if errors.Is(err, store.ErrMissingTsCode) {
+ writeErr(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, rows)
+}
diff --git a/web/backend/internal/handlers/users.go b/web/backend/internal/handlers/users.go
new file mode 100644
index 0000000..0bbc76e
--- /dev/null
+++ b/web/backend/internal/handlers/users.go
@@ -0,0 +1,147 @@
+package handlers
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "trade/web/internal/auth"
+ "trade/web/internal/middleware"
+ "trade/web/internal/store"
+)
+
+type createUserReq struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Role string `json:"role"`
+}
+
+type patchUserReq struct {
+ Password *string `json:"password,omitempty"`
+ Disabled *bool `json:"disabled,omitempty"`
+}
+
+func (d *Deps) AdminListUsers(w http.ResponseWriter, r *http.Request) {
+ users, err := d.Auth.ListUsers()
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ out := make([]map[string]any, 0, len(users))
+ for i := range users {
+ out = append(out, sanitize(&users[i]))
+ }
+ writeJSON(w, http.StatusOK, out)
+}
+
+func (d *Deps) AdminCreateUser(w http.ResponseWriter, r *http.Request) {
+ var req createUserReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeErr(w, http.StatusBadRequest, "invalid json")
+ return
+ }
+ req.Username = strings.TrimSpace(req.Username)
+ if req.Username == "" || len(req.Password) < 6 {
+ writeErr(w, http.StatusBadRequest, "用户名必填,密码至少 6 位")
+ return
+ }
+ role := strings.TrimSpace(req.Role)
+ if role == "" {
+ role = store.RoleUser
+ }
+ if role != store.RoleAdmin && role != store.RoleUser {
+ writeErr(w, http.StatusBadRequest, "role 取值必须是 admin 或 user")
+ return
+ }
+ hash, err := auth.HashPassword(req.Password)
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, "hash failed")
+ return
+ }
+ u, err := d.Auth.CreateUser(req.Username, hash, role)
+ if err != nil {
+ // UNIQUE 冲突等
+ writeErr(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, sanitize(u))
+}
+
+func (d *Deps) AdminPatchUser(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ var req patchUserReq
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeErr(w, http.StatusBadRequest, "invalid json")
+ return
+ }
+ if req.Password == nil && req.Disabled == nil {
+ writeErr(w, http.StatusBadRequest, "无可更新字段")
+ return
+ }
+ if req.Password != nil {
+ if len(*req.Password) < 6 {
+ writeErr(w, http.StatusBadRequest, "新密码至少 6 位")
+ return
+ }
+ hash, err := auth.HashPassword(*req.Password)
+ if err != nil {
+ writeErr(w, http.StatusInternalServerError, "hash failed")
+ return
+ }
+ if err := d.Auth.UpdatePassword(id, hash); err != nil {
+ writeErr(w, statusForErr(err), err.Error())
+ return
+ }
+ }
+ if req.Disabled != nil {
+ // 禁止禁用自己,避免管理员锁死自己
+ me, _ := middleware.FromContext(r.Context())
+ if *req.Disabled && me.ID == id {
+ writeErr(w, http.StatusBadRequest, "不能禁用自己")
+ return
+ }
+ if err := d.Auth.SetDisabled(id, *req.Disabled); err != nil {
+ writeErr(w, statusForErr(err), err.Error())
+ return
+ }
+ }
+ u, err := d.Auth.GetByID(id)
+ if err != nil {
+ writeErr(w, statusForErr(err), err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, sanitize(u))
+}
+
+func (d *Deps) AdminDeleteUser(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ writeErr(w, http.StatusBadRequest, "invalid id")
+ return
+ }
+ me, _ := middleware.FromContext(r.Context())
+ if me.ID == id {
+ writeErr(w, http.StatusBadRequest, "不能删除自己")
+ return
+ }
+ if err := d.Auth.DeleteUser(id); err != nil {
+ writeErr(w, statusForErr(err), err.Error())
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
+}
+
+func statusForErr(err error) int {
+ if errors.Is(err, store.ErrNotFound) {
+ return http.StatusNotFound
+ }
+ return http.StatusInternalServerError
+}
diff --git a/web/backend/internal/middleware/auth.go b/web/backend/internal/middleware/auth.go
new file mode 100644
index 0000000..9598848
--- /dev/null
+++ b/web/backend/internal/middleware/auth.go
@@ -0,0 +1,73 @@
+package middleware
+
+import (
+ "context"
+ "net/http"
+ "strings"
+
+ "trade/web/internal/auth"
+ "trade/web/internal/store"
+)
+
+type ctxKey string
+
+const userKey ctxKey = "user"
+
+type CtxUser struct {
+ ID int64
+ Username string
+ Role string
+}
+
+func FromContext(ctx context.Context) (CtxUser, bool) {
+ u, ok := ctx.Value(userKey).(CtxUser)
+ return u, ok
+}
+
+// RequireUser 校验 Authorization Bearer JWT,通过后把 CtxUser 写入 context。
+// 同时校验数据库里的 disabled 状态,被禁用的账户即使持有 token 也会被拒。
+func RequireUser(mgr *auth.Manager, s *store.AuthStore) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ tok := bearer(r)
+ if tok == "" {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing token"})
+ return
+ }
+ claims, err := mgr.Parse(tok)
+ if err != nil {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid token"})
+ return
+ }
+ u, err := s.GetByID(claims.UserID)
+ if err != nil || u.Disabled {
+ writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "account disabled or removed"})
+ return
+ }
+ ctx := context.WithValue(r.Context(), userKey, CtxUser{
+ ID: u.ID, Username: u.Username, Role: u.Role,
+ })
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+func RequireAdmin(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ u, ok := FromContext(r.Context())
+ if !ok || u.Role != store.RoleAdmin {
+ writeJSON(w, http.StatusForbidden, map[string]string{"error": "admin only"})
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+func bearer(r *http.Request) string {
+ h := r.Header.Get("Authorization")
+ const p = "Bearer "
+ if strings.HasPrefix(h, p) {
+ return strings.TrimSpace(h[len(p):])
+ }
+ return ""
+}
diff --git a/web/backend/internal/middleware/logger.go b/web/backend/internal/middleware/logger.go
new file mode 100644
index 0000000..790c10e
--- /dev/null
+++ b/web/backend/internal/middleware/logger.go
@@ -0,0 +1,46 @@
+package middleware
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "runtime/debug"
+ "time"
+)
+
+func Logger(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
+ next.ServeHTTP(rw, r)
+ log.Printf("%s %s %d %s", r.Method, r.URL.Path, rw.status, time.Since(start))
+ })
+}
+
+func Recover(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ if rec := recover(); rec != nil {
+ log.Printf("[panic] %v\n%s", rec, debug.Stack())
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
+ }
+ }()
+ next.ServeHTTP(w, r)
+ })
+}
+
+type statusRecorder struct {
+ http.ResponseWriter
+ status int
+}
+
+func (r *statusRecorder) WriteHeader(code int) {
+ r.status = code
+ r.ResponseWriter.WriteHeader(code)
+}
+
+func writeJSON(w http.ResponseWriter, status int, body any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(body)
+}
diff --git a/web/backend/internal/router/router.go b/web/backend/internal/router/router.go
new file mode 100644
index 0000000..03ff5e7
--- /dev/null
+++ b/web/backend/internal/router/router.go
@@ -0,0 +1,65 @@
+package router
+
+import (
+ "io/fs"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "trade/web/internal/auth"
+ "trade/web/internal/handlers"
+ mw "trade/web/internal/middleware"
+ "trade/web/internal/store"
+)
+
+func New(d *handlers.Deps, mgr *auth.Manager, authStore *store.AuthStore, dist fs.FS) http.Handler {
+ r := chi.NewRouter()
+ r.Use(mw.Recover)
+ r.Use(mw.Logger)
+
+ r.Route("/api", func(r chi.Router) {
+ r.Post("/login", d.Login)
+
+ r.Group(func(r chi.Router) {
+ r.Use(mw.RequireUser(mgr, authStore))
+
+ r.Post("/logout", d.Logout)
+ r.Get("/me", d.Me)
+ r.Get("/scores", d.ListScores)
+ r.Get("/scores/{id}", d.GetScore)
+ r.Get("/contracts", d.ListContracts)
+ r.Get("/candles", d.ListCandles)
+
+ r.Group(func(r chi.Router) {
+ r.Use(mw.RequireAdmin)
+ r.Get("/admin/users", d.AdminListUsers)
+ r.Post("/admin/users", d.AdminCreateUser)
+ r.Patch("/admin/users/{id}", d.AdminPatchUser)
+ r.Delete("/admin/users/{id}", d.AdminDeleteUser)
+ })
+ })
+ })
+
+ r.Handle("/*", spa(dist))
+ return r
+}
+
+// spa 返回单文件 SPA handler:文件存在则发文件,否则发 index.html。
+func spa(root fs.FS) http.Handler {
+ fileServer := http.FileServer(http.FS(root))
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path := strings.TrimPrefix(r.URL.Path, "/")
+ if path == "" {
+ path = "index.html"
+ }
+ if _, err := fs.Stat(root, path); err != nil {
+ // 找不到文件 → SPA 路由,回 index.html 让前端 router 处理
+ r2 := r.Clone(r.Context())
+ r2.URL.Path = "/"
+ fileServer.ServeHTTP(w, r2)
+ return
+ }
+ fileServer.ServeHTTP(w, r)
+ })
+}
diff --git a/web/backend/internal/store/auth.go b/web/backend/internal/store/auth.go
new file mode 100644
index 0000000..1d129db
--- /dev/null
+++ b/web/backend/internal/store/auth.go
@@ -0,0 +1,180 @@
+package store
+
+import (
+ "database/sql"
+ "errors"
+ "fmt"
+ "path/filepath"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+type AuthStore struct{ db *sql.DB }
+
+type User struct {
+ ID int64 `json:"id"`
+ Username string `json:"username"`
+ PasswordHash string `json:"-"`
+ Role string `json:"role"`
+ Disabled bool `json:"disabled"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+const (
+ RoleAdmin = "admin"
+ RoleUser = "user"
+)
+
+var ErrNotFound = errors.New("user not found")
+
+func OpenAuth(path string) (*AuthStore, error) {
+ if dir := filepath.Dir(path); dir != "" {
+ _ = ensureDir(dir)
+ }
+ dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)", path)
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("open auth.db: %w", err)
+ }
+ db.SetMaxOpenConns(1) // sqlite write 单连接更稳
+ if err := db.Ping(); err != nil {
+ return nil, fmt.Errorf("ping auth.db: %w", err)
+ }
+ s := &AuthStore{db: db}
+ if err := s.init(); err != nil {
+ return nil, err
+ }
+ return s, nil
+}
+
+func (s *AuthStore) Close() error { return s.db.Close() }
+
+func (s *AuthStore) init() error {
+ _, err := s.db.Exec(`
+ CREATE TABLE IF NOT EXISTS users (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ username TEXT NOT NULL UNIQUE,
+ password_hash TEXT NOT NULL,
+ role TEXT NOT NULL CHECK(role IN ('admin','user')),
+ disabled INTEGER NOT NULL DEFAULT 0,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+ CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
+ `)
+ return err
+}
+
+func (s *AuthStore) CountAdmins() (int, error) {
+ var n int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE role = 'admin'`).Scan(&n)
+ return n, err
+}
+
+func (s *AuthStore) CreateUser(username, passwordHash, role string) (*User, error) {
+ now := time.Now().Format("2006-01-02 15:04:05")
+ res, err := s.db.Exec(
+ `INSERT INTO users(username, password_hash, role, disabled, created_at, updated_at)
+ VALUES (?, ?, ?, 0, ?, ?)`,
+ username, passwordHash, role, now, now,
+ )
+ if err != nil {
+ return nil, err
+ }
+ id, _ := res.LastInsertId()
+ return &User{ID: id, Username: username, PasswordHash: passwordHash, Role: role,
+ CreatedAt: now, UpdatedAt: now}, nil
+}
+
+func (s *AuthStore) GetByUsername(username string) (*User, error) {
+ row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, created_at, updated_at
+ FROM users WHERE username = ?`, username)
+ return scanUser(row)
+}
+
+func (s *AuthStore) GetByID(id int64) (*User, error) {
+ row := s.db.QueryRow(`SELECT id, username, password_hash, role, disabled, created_at, updated_at
+ FROM users WHERE id = ?`, id)
+ return scanUser(row)
+}
+
+func (s *AuthStore) ListUsers() ([]User, error) {
+ rows, err := s.db.Query(`SELECT id, username, password_hash, role, disabled, created_at, updated_at
+ FROM users ORDER BY id ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []User{}
+ for rows.Next() {
+ u, err := scanUserRows(rows)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *u)
+ }
+ return out, rows.Err()
+}
+
+func (s *AuthStore) UpdatePassword(id int64, hash string) error {
+ now := time.Now().Format("2006-01-02 15:04:05")
+ res, err := s.db.Exec(`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`, hash, now, id)
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+func (s *AuthStore) SetDisabled(id int64, disabled bool) error {
+ now := time.Now().Format("2006-01-02 15:04:05")
+ v := 0
+ if disabled {
+ v = 1
+ }
+ res, err := s.db.Exec(`UPDATE users SET disabled = ?, updated_at = ? WHERE id = ?`, v, now, id)
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+func (s *AuthStore) DeleteUser(id int64) error {
+ res, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
+ if err != nil {
+ return err
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+type rowScanner interface {
+ Scan(dest ...any) error
+}
+
+func scanUser(r rowScanner) (*User, error) {
+ var u User
+ var disabled int
+ if err := r.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &disabled, &u.CreatedAt, &u.UpdatedAt); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, err
+ }
+ u.Disabled = disabled != 0
+ return &u, nil
+}
+
+func scanUserRows(rows *sql.Rows) (*User, error) { return scanUser(rows) }
diff --git a/web/backend/internal/store/futures.go b/web/backend/internal/store/futures.go
new file mode 100644
index 0000000..5e0d976
--- /dev/null
+++ b/web/backend/internal/store/futures.go
@@ -0,0 +1,173 @@
+package store
+
+import (
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+)
+
+var ErrMissingTsCode = errors.New("ts_code 必填")
+
+type FuturesStore struct{ db *sql.DB }
+
+func OpenFutures(path string) (*FuturesStore, error) {
+ dsn := fmt.Sprintf("file:%s?mode=ro&_pragma=query_only(true)", path)
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("open futures.db: %w", err)
+ }
+ db.SetMaxOpenConns(4)
+ if err := db.Ping(); err != nil {
+ return nil, fmt.Errorf("ping futures.db: %w", err)
+ }
+ return &FuturesStore{db: db}, nil
+}
+
+func (s *FuturesStore) Close() error { return s.db.Close() }
+
+type Score struct {
+ ID int64 `json:"id"`
+ TsCode string `json:"ts_code"`
+ TradeDate string `json:"trade_date"`
+ Close float64 `json:"close"`
+ OI float64 `json:"oi"`
+ OIChg float64 `json:"oi_chg"`
+ ShortTerm float64 `json:"short_term"`
+ MediumTerm float64 `json:"medium_term"`
+ LongTerm float64 `json:"long_term"`
+ Composite float64 `json:"composite"`
+ Signal string `json:"signal"`
+ Detail json.RawMessage `json:"detail,omitempty"`
+ CreatedAt string `json:"created_at"`
+}
+
+type ScoreFilter struct {
+ TsCode string
+ Start string
+ End string
+ Limit int
+}
+
+func (s *FuturesStore) ListScores(f ScoreFilter) ([]Score, error) {
+ q := `SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term, long_term,
+ composite, signal, created_at FROM scores WHERE 1=1`
+ args := []any{}
+ if f.TsCode != "" {
+ q += " AND ts_code = ?"
+ args = append(args, f.TsCode)
+ }
+ if f.Start != "" {
+ q += " AND trade_date >= ?"
+ args = append(args, f.Start)
+ }
+ if f.End != "" {
+ q += " AND trade_date <= ?"
+ args = append(args, f.End)
+ }
+ q += " ORDER BY trade_date DESC, id DESC"
+ if f.Limit <= 0 || f.Limit > 500 {
+ f.Limit = 200
+ }
+ q += " LIMIT ?"
+ args = append(args, f.Limit)
+
+ rows, err := s.db.Query(q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []Score{}
+ for rows.Next() {
+ var x Score
+ if err := rows.Scan(&x.ID, &x.TsCode, &x.TradeDate, &x.Close, &x.OI, &x.OIChg,
+ &x.ShortTerm, &x.MediumTerm, &x.LongTerm, &x.Composite, &x.Signal, &x.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, x)
+ }
+ return out, rows.Err()
+}
+
+func (s *FuturesStore) GetScore(id int64) (*Score, error) {
+ row := s.db.QueryRow(`SELECT id, ts_code, trade_date, close, oi, oi_chg, short_term, medium_term,
+ long_term, composite, signal, detail_json, created_at FROM scores WHERE id = ?`, id)
+ var x Score
+ var detail sql.NullString
+ if err := row.Scan(&x.ID, &x.TsCode, &x.TradeDate, &x.Close, &x.OI, &x.OIChg,
+ &x.ShortTerm, &x.MediumTerm, &x.LongTerm, &x.Composite, &x.Signal, &detail, &x.CreatedAt); err != nil {
+ return nil, err
+ }
+ if detail.Valid && strings.TrimSpace(detail.String) != "" {
+ x.Detail = json.RawMessage(detail.String)
+ }
+ return &x, nil
+}
+
+func (s *FuturesStore) ListContracts() ([]string, error) {
+ rows, err := s.db.Query(`SELECT DISTINCT ts_code FROM scores ORDER BY ts_code ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []string{}
+ for rows.Next() {
+ var c string
+ if err := rows.Scan(&c); err != nil {
+ return nil, err
+ }
+ out = append(out, c)
+ }
+ return out, rows.Err()
+}
+
+type Candle struct {
+ TsCode string `json:"ts_code"`
+ TradeDate string `json:"trade_date"`
+ Open float64 `json:"open"`
+ High float64 `json:"high"`
+ Low float64 `json:"low"`
+ Close float64 `json:"close"`
+ Vol float64 `json:"vol"`
+ Amount float64 `json:"amount"`
+ OI float64 `json:"oi"`
+ OIChg float64 `json:"oi_chg"`
+ PreClose float64 `json:"pre_close"`
+}
+
+func (s *FuturesStore) ListCandles(tsCode, start, end string) ([]Candle, error) {
+ if tsCode == "" {
+ return nil, ErrMissingTsCode
+ }
+ q := `SELECT ts_code, trade_date,
+ COALESCE(open, 0), COALESCE(high, 0), COALESCE(low, 0), COALESCE(close, 0),
+ COALESCE(vol, 0), COALESCE(amount, 0),
+ COALESCE(oi, 0), COALESCE(oi_chg, 0), COALESCE(pre_close, 0)
+ FROM candles WHERE ts_code = ?`
+ args := []any{tsCode}
+ if start != "" {
+ q += " AND trade_date >= ?"
+ args = append(args, start)
+ }
+ if end != "" {
+ q += " AND trade_date <= ?"
+ args = append(args, end)
+ }
+ q += " ORDER BY trade_date ASC LIMIT 1000"
+ rows, err := s.db.Query(q, args...)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ out := []Candle{}
+ for rows.Next() {
+ var c Candle
+ if err := rows.Scan(&c.TsCode, &c.TradeDate, &c.Open, &c.High, &c.Low, &c.Close,
+ &c.Vol, &c.Amount, &c.OI, &c.OIChg, &c.PreClose); err != nil {
+ return nil, err
+ }
+ out = append(out, c)
+ }
+ return out, rows.Err()
+}
diff --git a/web/backend/internal/store/util.go b/web/backend/internal/store/util.go
new file mode 100644
index 0000000..2857eac
--- /dev/null
+++ b/web/backend/internal/store/util.go
@@ -0,0 +1,10 @@
+package store
+
+import "os"
+
+func ensureDir(dir string) error {
+ if _, err := os.Stat(dir); err == nil {
+ return nil
+ }
+ return os.MkdirAll(dir, 0o755)
+}
diff --git a/web/backend/main.go b/web/backend/main.go
new file mode 100644
index 0000000..88b6dc2
--- /dev/null
+++ b/web/backend/main.go
@@ -0,0 +1,74 @@
+package main
+
+import (
+ "context"
+ "errors"
+ "io/fs"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "trade/web/internal/auth"
+ "trade/web/internal/config"
+ "trade/web/internal/handlers"
+ "trade/web/internal/router"
+ "trade/web/internal/store"
+)
+
+func main() {
+ cfg, err := config.Load()
+ if err != nil {
+ log.Fatalf("config: %v", err)
+ }
+
+ futures, err := store.OpenFutures(cfg.FuturesDBPath)
+ if err != nil {
+ log.Fatalf("open futures: %v", err)
+ }
+ defer futures.Close()
+
+ authDB, err := store.OpenAuth(cfg.AuthDBPath)
+ if err != nil {
+ log.Fatalf("open auth: %v", err)
+ }
+ defer authDB.Close()
+
+ if err := auth.Bootstrap(authDB, cfg.AdminUser, cfg.AdminPass); err != nil {
+ log.Fatalf("bootstrap: %v", err)
+ }
+
+ mgr := auth.NewManager(cfg.JWTSecret)
+ deps := &handlers.Deps{Auth: authDB, Futures: futures, JWT: mgr}
+
+ dist, err := fs.Sub(distFS, "dist")
+ if err != nil {
+ log.Fatalf("embed dist: %v", err)
+ }
+
+ srv := &http.Server{
+ Addr: cfg.ListenAddr,
+ Handler: router.New(deps, mgr, authDB, dist),
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+
+ idle := make(chan struct{})
+ go func() {
+ stop := make(chan os.Signal, 1)
+ signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
+ <-stop
+ log.Println("shutting down ...")
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ _ = srv.Shutdown(ctx)
+ close(idle)
+ }()
+
+ log.Printf("web 监听 %s", cfg.ListenAddr)
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatalf("listen: %v", err)
+ }
+ <-idle
+}
diff --git a/web/frontend/index.html b/web/frontend/index.html
new file mode 100644
index 0000000..1090e97
--- /dev/null
+++ b/web/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ 期货报告 · Trade
+
+
+
+
+
+
diff --git a/web/frontend/package.json b/web/frontend/package.json
new file mode 100644
index 0000000..5fb49f5
--- /dev/null
+++ b/web/frontend/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "trade-web-frontend",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc --noEmit && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "axios": "^1.7.7",
+ "echarts": "^5.5.1",
+ "element-plus": "^2.8.4",
+ "pinia": "^2.2.4",
+ "vue": "^3.5.10",
+ "vue-router": "^4.4.5"
+ },
+ "devDependencies": {
+ "@types/node": "^22.7.4",
+ "@vitejs/plugin-vue": "^5.1.4",
+ "typescript": "^5.6.2",
+ "vite": "^5.4.8",
+ "vue-tsc": "^2.1.6"
+ }
+}
diff --git a/web/frontend/src/App.vue b/web/frontend/src/App.vue
new file mode 100644
index 0000000..852ab45
--- /dev/null
+++ b/web/frontend/src/App.vue
@@ -0,0 +1,91 @@
+
+
+
+
+
+ 期货报告
+
+ 打分列表
+ K 线 / 持仓
+ 用户管理
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/frontend/src/api/auth.ts b/web/frontend/src/api/auth.ts
new file mode 100644
index 0000000..15f79d0
--- /dev/null
+++ b/web/frontend/src/api/auth.ts
@@ -0,0 +1,19 @@
+import client from './client'
+import type { AuthUser } from '@/stores/auth'
+
+export interface LoginResp {
+ token: string
+ user: AuthUser
+}
+
+export function login(username: string, password: string) {
+ return client.post('/login', { username, password }).then((r) => r.data)
+}
+
+export function logout() {
+ return client.post('/logout').then((r) => r.data)
+}
+
+export function me() {
+ return client.get('/me').then((r) => r.data)
+}
diff --git a/web/frontend/src/api/candles.ts b/web/frontend/src/api/candles.ts
new file mode 100644
index 0000000..2791175
--- /dev/null
+++ b/web/frontend/src/api/candles.ts
@@ -0,0 +1,19 @@
+import client from './client'
+
+export interface Candle {
+ ts_code: string
+ trade_date: string
+ open: number
+ high: number
+ low: number
+ close: number
+ vol: number
+ amount: number
+ oi: number
+ oi_chg: number
+ pre_close: number
+}
+
+export function listCandles(ts_code: string, start?: string, end?: string) {
+ return client.get('/candles', { params: { ts_code, start, end } }).then((r) => r.data)
+}
diff --git a/web/frontend/src/api/client.ts b/web/frontend/src/api/client.ts
new file mode 100644
index 0000000..923ee0e
--- /dev/null
+++ b/web/frontend/src/api/client.ts
@@ -0,0 +1,34 @@
+import axios, { type AxiosInstance } from 'axios'
+import { ElMessage } from 'element-plus'
+import { useAuthStore } from '@/stores/auth'
+import router from '@/router'
+
+const baseURL = import.meta.env.VITE_API_BASE || '/api'
+
+const client: AxiosInstance = axios.create({ baseURL, timeout: 15_000 })
+
+client.interceptors.request.use((cfg) => {
+ const auth = useAuthStore()
+ if (auth.token) {
+ cfg.headers = cfg.headers ?? {}
+ cfg.headers.Authorization = `Bearer ${auth.token}`
+ }
+ return cfg
+})
+
+client.interceptors.response.use(
+ (resp) => resp,
+ (err) => {
+ const status = err?.response?.status
+ if (status === 401) {
+ const auth = useAuthStore()
+ auth.logout()
+ router.replace({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } })
+ }
+ const msg = err?.response?.data?.error || err.message || '请求失败'
+ if (status !== 401) ElMessage.error(msg)
+ return Promise.reject(err)
+ },
+)
+
+export default client
diff --git a/web/frontend/src/api/scores.ts b/web/frontend/src/api/scores.ts
new file mode 100644
index 0000000..360ca25
--- /dev/null
+++ b/web/frontend/src/api/scores.ts
@@ -0,0 +1,65 @@
+import client from './client'
+
+export interface ShortDetail {
+ trade_date: string
+ close: number
+ pre_close: number
+ oi: number
+ oi_chg: number
+ score: number
+}
+
+export interface MediumDetail {
+ price_return_pct: number
+ price_signal: number
+ long_up_days: number
+ long_down_days: number
+ fund_signal: number
+}
+
+export interface LongDetail {
+ avg_oi: number
+ oi_before: number
+ change_pct: number
+}
+
+export interface ScoreDetail {
+ short_details?: ShortDetail[]
+ medium_detail?: MediumDetail
+ long_detail?: LongDetail
+}
+
+export interface Score {
+ id: number
+ ts_code: string
+ trade_date: string
+ close: number
+ oi: number
+ oi_chg: number
+ short_term: number
+ medium_term: number
+ long_term: number
+ composite: number
+ signal: string
+ detail?: ScoreDetail
+ created_at: string
+}
+
+export interface ScoreListParams {
+ ts_code?: string
+ start?: string
+ end?: string
+ limit?: number
+}
+
+export function listScores(params: ScoreListParams = {}) {
+ return client.get('/scores', { params }).then((r) => r.data)
+}
+
+export function getScore(id: number) {
+ return client.get(`/scores/${id}`).then((r) => r.data)
+}
+
+export function listContracts() {
+ return client.get('/contracts').then((r) => r.data)
+}
diff --git a/web/frontend/src/api/users.ts b/web/frontend/src/api/users.ts
new file mode 100644
index 0000000..1c07208
--- /dev/null
+++ b/web/frontend/src/api/users.ts
@@ -0,0 +1,26 @@
+import client from './client'
+
+export interface AdminUser {
+ id: number
+ username: string
+ role: 'admin' | 'user'
+ disabled: boolean
+ created_at: string
+ updated_at: string
+}
+
+export function listUsers() {
+ return client.get('/admin/users').then((r) => r.data)
+}
+
+export function createUser(username: string, password: string, role: 'admin' | 'user' = 'user') {
+ return client.post('/admin/users', { username, password, role }).then((r) => r.data)
+}
+
+export function updateUser(id: number, patch: { password?: string; disabled?: boolean }) {
+ return client.patch(`/admin/users/${id}`, patch).then((r) => r.data)
+}
+
+export function deleteUser(id: number) {
+ return client.delete(`/admin/users/${id}`).then((r) => r.data)
+}
diff --git a/web/frontend/src/components/KLineChart.vue b/web/frontend/src/components/KLineChart.vue
new file mode 100644
index 0000000..2613fec
--- /dev/null
+++ b/web/frontend/src/components/KLineChart.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
diff --git a/web/frontend/src/components/ScoreDetailDrawer.vue b/web/frontend/src/components/ScoreDetailDrawer.vue
new file mode 100644
index 0000000..7fe3667
--- /dev/null
+++ b/web/frontend/src/components/ScoreDetailDrawer.vue
@@ -0,0 +1,105 @@
+
+
+
+
+
+
+ {{ score.ts_code }}
+ {{ score.trade_date }}
+ {{ score.close }}
+ {{ score.oi }}
+
+ {{ score.composite.toFixed(2) }}
+
+
+ {{ score.signal }}
+
+ {{ score.short_term.toFixed(2) }}
+ {{ score.medium_term.toFixed(2) }}
+
+ {{ score.long_term.toFixed(2) }}
+
+
+
+
短期 7 日逐日打分
+
+
+
+
+
+
+
+
+
+ 中期(15d)细节
+
+
+ {{ (score.detail.medium_detail.price_return_pct * 100).toFixed(2) }}%
+
+
+ {{ score.detail.medium_detail.price_signal.toFixed(2) }}
+
+
+ {{ score.detail.medium_detail.long_up_days }}
+
+
+ {{ score.detail.medium_detail.long_down_days }}
+
+
+ {{ score.detail.medium_detail.fund_signal }}
+
+
+
+ 长期(30d)细节
+
+
+ {{ score.detail.long_detail.avg_oi.toFixed(0) }}
+
+
+ {{ score.detail.long_detail.oi_before.toFixed(0) }}
+
+
+ {{ (score.detail.long_detail.change_pct * 100).toFixed(2) }}%
+
+
+
+
+
+
+
diff --git a/web/frontend/src/env.d.ts b/web/frontend/src/env.d.ts
new file mode 100644
index 0000000..3b84c5b
--- /dev/null
+++ b/web/frontend/src/env.d.ts
@@ -0,0 +1,15 @@
+///
+
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+ const component: DefineComponent<{}, {}, any>
+ export default component
+}
+
+interface ImportMetaEnv {
+ readonly VITE_API_BASE?: string
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv
+}
diff --git a/web/frontend/src/main.ts b/web/frontend/src/main.ts
new file mode 100644
index 0000000..4f57c49
--- /dev/null
+++ b/web/frontend/src/main.ts
@@ -0,0 +1,14 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import ElementPlus from 'element-plus'
+import zhCn from 'element-plus/es/locale/lang/zh-cn'
+import 'element-plus/dist/index.css'
+
+import App from './App.vue'
+import router from './router'
+
+const app = createApp(App)
+app.use(createPinia())
+app.use(router)
+app.use(ElementPlus, { locale: zhCn })
+app.mount('#app')
diff --git a/web/frontend/src/router/index.ts b/web/frontend/src/router/index.ts
new file mode 100644
index 0000000..24a0268
--- /dev/null
+++ b/web/frontend/src/router/index.ts
@@ -0,0 +1,48 @@
+import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
+import { useAuthStore } from '@/stores/auth'
+
+const routes: RouteRecordRaw[] = [
+ {
+ path: '/login',
+ name: 'login',
+ component: () => import('@/views/LoginView.vue'),
+ meta: { layout: 'blank', public: true },
+ },
+ { path: '/', redirect: '/scores' },
+ {
+ path: '/scores',
+ name: 'scores',
+ component: () => import('@/views/ScoresView.vue'),
+ },
+ {
+ path: '/chart',
+ name: 'chart',
+ component: () => import('@/views/ChartView.vue'),
+ },
+ {
+ path: '/admin/users',
+ name: 'admin-users',
+ component: () => import('@/views/AdminUsersView.vue'),
+ meta: { adminOnly: true },
+ },
+ { path: '/:pathMatch(.*)*', redirect: '/scores' },
+]
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes,
+})
+
+router.beforeEach((to) => {
+ const auth = useAuthStore()
+ if (to.meta.public) return true
+ if (!auth.token) {
+ return { path: '/login', query: { redirect: to.fullPath } }
+ }
+ if (to.meta.adminOnly && !auth.isAdmin) {
+ return { path: '/scores' }
+ }
+ return true
+})
+
+export default router
diff --git a/web/frontend/src/stores/auth.ts b/web/frontend/src/stores/auth.ts
new file mode 100644
index 0000000..a718c83
--- /dev/null
+++ b/web/frontend/src/stores/auth.ts
@@ -0,0 +1,43 @@
+import { defineStore } from 'pinia'
+
+export interface AuthUser {
+ id: number
+ username: string
+ role: 'admin' | 'user'
+}
+
+interface State {
+ token: string
+ user: AuthUser | null
+}
+
+const STORAGE_KEY = 'trade.auth'
+
+function load(): State {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY)
+ if (!raw) return { token: '', user: null }
+ return JSON.parse(raw) as State
+ } catch {
+ return { token: '', user: null }
+ }
+}
+
+export const useAuthStore = defineStore('auth', {
+ state: (): State => load(),
+ getters: {
+ isAdmin: (s) => s.user?.role === 'admin',
+ },
+ actions: {
+ setSession(token: string, user: AuthUser) {
+ this.token = token
+ this.user = user
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ token, user }))
+ },
+ logout() {
+ this.token = ''
+ this.user = null
+ localStorage.removeItem(STORAGE_KEY)
+ },
+ },
+})
diff --git a/web/frontend/src/views/AdminUsersView.vue b/web/frontend/src/views/AdminUsersView.vue
new file mode 100644
index 0000000..ad58df4
--- /dev/null
+++ b/web/frontend/src/views/AdminUsersView.vue
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+ 用户管理 — 仅管理员可访问,本系统不开放注册
+ 新建账号
+
+
+
+
+
+
+
+
+ {{ row.role }}
+
+
+
+
+
+ {{ row.disabled ? '已禁用' : '正常' }}
+
+
+
+
+
+
+
+ 重置密码
+
+ {{ row.disabled ? '启用' : '禁用' }}
+
+
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 普通用户
+ 管理员
+
+
+
+
+ 取消
+ 创建
+
+
+
+
+ 用户:{{ resetDialog.user?.username }}
+
+
+ 取消
+ 提交
+
+
+
+
+
+
diff --git a/web/frontend/src/views/ChartView.vue b/web/frontend/src/views/ChartView.vue
new file mode 100644
index 0000000..4f9b05c
--- /dev/null
+++ b/web/frontend/src/views/ChartView.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 刷新
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/frontend/src/views/LoginView.vue b/web/frontend/src/views/LoginView.vue
new file mode 100644
index 0000000..5a0a6d2
--- /dev/null
+++ b/web/frontend/src/views/LoginView.vue
@@ -0,0 +1,86 @@
+
+
+
+
+
+
期货报告系统
+
仅授权账号可登录,联系管理员申请。
+
+
+
+
+
+
+
+
+ 登录
+
+
+
+
+
+
+
diff --git a/web/frontend/src/views/ScoresView.vue b/web/frontend/src/views/ScoresView.vue
new file mode 100644
index 0000000..8333451
--- /dev/null
+++ b/web/frontend/src/views/ScoresView.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ row.composite.toFixed(2) }}
+
+
+
+
+ {{ row.signal }}
+
+
+
+
+ 明细
+
+
+
+
+
+
+
+
+
diff --git a/web/frontend/tsconfig.json b/web/frontend/tsconfig.json
new file mode 100644
index 0000000..6570cd4
--- /dev/null
+++ b/web/frontend/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "jsx": "preserve",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "esModuleInterop": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "skipLibCheck": true,
+ "noEmit": true,
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ },
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
+ "references": [{ "path": "./tsconfig.node.json" }]
+}
diff --git a/web/frontend/tsconfig.node.json b/web/frontend/tsconfig.node.json
new file mode 100644
index 0000000..13e2d0a
--- /dev/null
+++ b/web/frontend/tsconfig.node.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "skipLibCheck": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web/frontend/vite.config.ts b/web/frontend/vite.config.ts
new file mode 100644
index 0000000..0047f27
--- /dev/null
+++ b/web/frontend/vite.config.ts
@@ -0,0 +1,26 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import path from 'node:path'
+
+export default defineConfig({
+ plugins: [vue()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'),
+ },
+ },
+ server: {
+ host: '0.0.0.0',
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: 'http://localhost:8080',
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ },
+})
diff --git a/使用说明.md b/使用说明.md
index 69489b9..7c364f8 100644
--- a/使用说明.md
+++ b/使用说明.md
@@ -129,31 +129,60 @@ sqlite3 data/futures.db ".schema"
```
trade/
-├── docker-compose.yml # Docker Compose 编排
+├── docker-compose.yml # Docker Compose 编排(tushare + web 两个服务)
├── 使用说明.md # 本文件
├── data/ # SQLite 数据库目录(gitignored)
-│ └── futures.db
+│ ├── futures.db # tushare 写入,web 只读
+│ └── auth.db # web 自己维护的用户表
├── .gitignore # Git 忽略配置
-└── tushare/ # Python 数据服务
- ├── Dockerfile # 镜像构建
- ├── requirements.txt # Python 依赖
- ├── .env # TUSHARE_TOKEN(本地,不入库)
- └── src/ # Python 包
- ├── models.py # 数据模型
- ├── fetcher.py # tushare 数据拉取
- ├── scorer.py # 打分模型核心
- ├── storage.py # SQLite 持久化
- ├── contracts.py # 主力合约轮换规则
- ├── notifier.py # Bark 推送
- └── main.py # CLI 入口
+├── tushare/ # Python 数据服务
+│ ├── Dockerfile
+│ ├── requirements.txt
+│ ├── .env # TUSHARE_TOKEN(本地,不入库)
+│ └── src/ # 数据采集 + 打分 + Bark 推送
+│ ├── models.py
+│ ├── fetcher.py
+│ ├── scorer.py
+│ ├── storage.py
+│ ├── contracts.py
+│ ├── notifier.py
+│ └── main.py
+└── web/ # Web 浏览端
+ ├── .dockerignore
+ ├── backend/ # Go 1.25 后端 (chi + modernc.org/sqlite + JWT)
+ │ ├── Dockerfile # 多阶段:node 构 UI → go 构二进制 → alpine 运行
+ │ ├── go.mod
+ │ ├── main.go
+ │ ├── embed.go # //go:embed all:dist
+ │ ├── .env.example # ADMIN_USER/ADMIN_PASS/JWT_SECRET 示例
+ │ ├── dist/ # 占位,Docker 构建期被 vite 输出覆盖
+ │ └── internal/
+ │ ├── config/ # 环境变量加载
+ │ ├── store/ # futures.db 只读 + auth.db 用户表
+ │ ├── auth/ # JWT + bcrypt + 首启 admin 引导
+ │ ├── middleware/ # RequireUser / RequireAdmin / 日志
+ │ ├── handlers/ # 登录 / 打分 / K线 / 用户管理
+ │ └── router/ # chi 路由装配
+ └── frontend/ # Vue 3 + Vite + Element Plus + ECharts
+ ├── package.json
+ ├── vite.config.ts
+ ├── tsconfig.json
+ ├── index.html
+ └── src/
+ ├── main.ts / App.vue
+ ├── router/ # 守卫(未登录/管理员路由)
+ ├── stores/auth.ts # Pinia,持久化 token
+ ├── api/ # axios 封装 + 各端点
+ ├── views/ # 登录 / 打分列表 / 图表 / 用户管理
+ └── components/ # 抽屉 + ECharts K 线
```
## 技术栈
-- **Python 3.13** (alpine 基础镜像)
-- **tushare** — 中国金融数据接口
-- **pandas** — 数据处理
-- **SQLite** — 本地数据存储
+- **Python 3.13** (alpine) + **tushare** + **pandas** — 数据采集与打分
+- **Go 1.25.8** (alpine 3.23) + **chi** + **modernc.org/sqlite** + **JWT** — Web 后端
+- **Vue 3** + **Vite** + **Element Plus** + **ECharts** — Web 前端
+- **SQLite** — 本地数据存储(双库:`futures.db` 业务 + `auth.db` 鉴权)
- **Docker / Docker Compose** — 容器化部署
## 常见问题
@@ -169,3 +198,85 @@ A: 郑商所用 `.ZCE` 后缀(如 `FG2609.ZCE`),上期所用 `.SHF`,大
**Q: 如何定时自动跑?**
A: 通过宿主机 cron / launchd 等定时器调用 `docker-compose run --rm tushare ...`。打分结束会通过 Bark 推送结果(见 `tushare/src/notifier.py`)。
+
+## Web 报表(浏览端)
+
+`./web/` 提供一个图形化的浏览端,展示 tushare 流水线写入 `data/futures.db` 的打分与行情数据。后端 Go(`golang:1.25.8-alpine3.23`)读取数据库,前端 Vue 3 + Element Plus + ECharts,通过 docker-compose 一起部署。
+
+### 1. 配置首启凭据
+
+在 `web/backend/.env` 写入(`.env` 已 gitignored,可参考 `web/backend/.env.example`):
+
+```bash
+ADMIN_USER=admin
+ADMIN_PASS=请改成强密码
+JWT_SECRET=$(openssl rand -hex 32)
+```
+
+`ADMIN_USER`/`ADMIN_PASS` 仅在 `auth.db` 中没有任何 admin 时生效,首次启动会以这一对凭据建立管理员;之后即使改这两个变量也不会改密。`JWT_SECRET` 必须 ≥16 字符。
+
+### 2. 启动
+
+```bash
+# 构建并启动 web 服务,不影响现有 tushare
+docker-compose up -d --build web
+
+# 查看启动日志:首启会出现 [bootstrap] admin 'xxx' created
+docker-compose logs -f web
+```
+
+浏览器访问 `http://localhost:8080`,用上一步的管理员账号登录。
+
+### 3. 页面说明
+
+- **打分列表** `/scores`:按合约、日期、条数筛选,展示综合分/信号/三层得分;点击「明细」弹抽屉,显示短期 7 日逐日打分、中期(15d)价格收益与资金意愿、长期(30d)持仓变化。
+- **K 线 / 持仓** `/chart`:选合约 + 日期区间,主图蜡烛(开高低收),副图持仓量曲线;鼠标拖选缩放。
+- **用户管理** `/admin/users`:仅管理员可见。可创建子账号(`user` 默认,亦可建 `admin`)、重置密码、禁用/启用、删除;不允许对自己执行禁用或删除。
+
+普通用户登录后 `/admin/*` 路径会被前端守卫拦截并跳回 `/scores`,后端也会以 403 拒绝。
+
+### 4. 子账号维护流程
+
+1. 用 admin 登录 → 进入 `/admin/users` → 「新建账号」,填写用户名 / 密码(≥6 位) / 角色。
+2. 把账号发给同事即可登录;无注册入口。
+3. 离职 / 风险事件:用「禁用」临时停用(token 立即失效,前端不能再请求),或「删除」彻底清除。
+
+### 5. 数据流向与数据库分离
+
+```
+tushare(写) → data/futures.db ──(只读挂载 :ro)──> web 服务 ←(读写)→ data/auth.db
+```
+
+`futures.db` 的 schema 与 Python 端一致(`candles` + `scores`)。`auth.db` 表为:
+
+```sql
+users(id, username UNIQUE, password_hash, role IN ('admin','user'),
+ disabled, created_at, updated_at)
+```
+
+两个 DB 都在 `./data/` 目录,均被 `.gitignore` 覆盖。
+
+### 6. 常见问题
+
+**Q: 忘记管理员密码怎么办?**
+
+```bash
+docker-compose stop web
+sqlite3 data/auth.db "DELETE FROM users WHERE role='admin';"
+# 修改 web/backend/.env 里的 ADMIN_USER/ADMIN_PASS
+docker-compose up -d web
+```
+
+启动时会重新触发 bootstrap 写入新的 admin。
+
+**Q: 改了 Go / Vue 代码但页面没变?**
+
+源码不挂载,镜像内是 COPY 进去的。重建:`docker-compose build web && docker-compose up -d web`。
+
+**Q: 登录提示 "JWT_SECRET 必须至少 16 个字符"?**
+
+`web/backend/.env` 没设或太短,用 `openssl rand -hex 32` 生成一个 64 字符的十六进制字符串即可。
+
+**Q: 容器内能不能误写 futures.db?**
+
+不能。容器以 `./data:/app/data:ro` 挂载,Go 又用 `mode=ro&query_only(true)` 打开数据库,双层保险。auth.db 走另一个挂载点 `./data:/app/auth`(同物理目录但路径不同,无 `:ro`)。