From 750584e6195afd25a048c024daa7275722638052 Mon Sep 17 00:00:00 2001 From: fish Date: Sun, 3 May 2026 14:34:50 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Web=20=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E7=AB=AF(Go+Vue=20=E6=8A=A5=E8=A1=A8=E7=B3=BB=E7=BB=9F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- .gitignore | 10 + CLAUDE.md | 30 +++ docker-compose.yml | 19 ++ web/.dockerignore | 8 + web/backend/.env.example | 8 + web/backend/Dockerfile | 54 +++++ web/backend/dist/.gitkeep | 0 web/backend/dist/index.html | 11 + web/backend/embed.go | 6 + web/backend/go.mod | 10 + web/backend/internal/auth/bcrypt.go | 17 ++ web/backend/internal/auth/bootstrap.go | 32 +++ web/backend/internal/auth/jwt.go | 55 +++++ web/backend/internal/config/config.go | 39 ++++ web/backend/internal/handlers/auth.go | 90 ++++++++ web/backend/internal/handlers/deps.go | 29 +++ web/backend/internal/handlers/scores.go | 70 +++++++ web/backend/internal/handlers/users.go | 147 +++++++++++++ web/backend/internal/middleware/auth.go | 73 +++++++ web/backend/internal/middleware/logger.go | 46 ++++ web/backend/internal/router/router.go | 65 ++++++ web/backend/internal/store/auth.go | 180 ++++++++++++++++ web/backend/internal/store/futures.go | 173 +++++++++++++++ web/backend/internal/store/util.go | 10 + web/backend/main.go | 74 +++++++ web/frontend/index.html | 13 ++ web/frontend/package.json | 26 +++ web/frontend/src/App.vue | 91 ++++++++ web/frontend/src/api/auth.ts | 19 ++ web/frontend/src/api/candles.ts | 19 ++ web/frontend/src/api/client.ts | 34 +++ web/frontend/src/api/scores.ts | 65 ++++++ web/frontend/src/api/users.ts | 26 +++ web/frontend/src/components/KLineChart.vue | 97 +++++++++ .../src/components/ScoreDetailDrawer.vue | 105 ++++++++++ web/frontend/src/env.d.ts | 15 ++ web/frontend/src/main.ts | 14 ++ web/frontend/src/router/index.ts | 48 +++++ web/frontend/src/stores/auth.ts | 43 ++++ web/frontend/src/views/AdminUsersView.vue | 198 ++++++++++++++++++ web/frontend/src/views/ChartView.vue | 88 ++++++++ web/frontend/src/views/LoginView.vue | 86 ++++++++ web/frontend/src/views/ScoresView.vue | 125 +++++++++++ web/frontend/tsconfig.json | 23 ++ web/frontend/tsconfig.node.json | 11 + web/frontend/vite.config.ts | 26 +++ 使用说明.md | 147 +++++++++++-- 47 files changed, 2557 insertions(+), 18 deletions(-) create mode 100644 web/.dockerignore create mode 100644 web/backend/.env.example create mode 100644 web/backend/Dockerfile create mode 100644 web/backend/dist/.gitkeep create mode 100644 web/backend/dist/index.html create mode 100644 web/backend/embed.go create mode 100644 web/backend/go.mod create mode 100644 web/backend/internal/auth/bcrypt.go create mode 100644 web/backend/internal/auth/bootstrap.go create mode 100644 web/backend/internal/auth/jwt.go create mode 100644 web/backend/internal/config/config.go create mode 100644 web/backend/internal/handlers/auth.go create mode 100644 web/backend/internal/handlers/deps.go create mode 100644 web/backend/internal/handlers/scores.go create mode 100644 web/backend/internal/handlers/users.go create mode 100644 web/backend/internal/middleware/auth.go create mode 100644 web/backend/internal/middleware/logger.go create mode 100644 web/backend/internal/router/router.go create mode 100644 web/backend/internal/store/auth.go create mode 100644 web/backend/internal/store/futures.go create mode 100644 web/backend/internal/store/util.go create mode 100644 web/backend/main.go create mode 100644 web/frontend/index.html create mode 100644 web/frontend/package.json create mode 100644 web/frontend/src/App.vue create mode 100644 web/frontend/src/api/auth.ts create mode 100644 web/frontend/src/api/candles.ts create mode 100644 web/frontend/src/api/client.ts create mode 100644 web/frontend/src/api/scores.ts create mode 100644 web/frontend/src/api/users.ts create mode 100644 web/frontend/src/components/KLineChart.vue create mode 100644 web/frontend/src/components/ScoreDetailDrawer.vue create mode 100644 web/frontend/src/env.d.ts create mode 100644 web/frontend/src/main.ts create mode 100644 web/frontend/src/router/index.ts create mode 100644 web/frontend/src/stores/auth.ts create mode 100644 web/frontend/src/views/AdminUsersView.vue create mode 100644 web/frontend/src/views/ChartView.vue create mode 100644 web/frontend/src/views/LoginView.vue create mode 100644 web/frontend/src/views/ScoresView.vue create mode 100644 web/frontend/tsconfig.json create mode 100644 web/frontend/tsconfig.node.json create mode 100644 web/frontend/vite.config.ts 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + + + 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`)。