新增 Web 浏览端(Go+Vue 报表系统)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
8
web/backend/.env.example
Normal file
8
web/backend/.env.example
Normal file
@@ -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
|
||||
54
web/backend/Dockerfile
Normal file
54
web/backend/Dockerfile
Normal file
@@ -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"]
|
||||
0
web/backend/dist/.gitkeep
vendored
Normal file
0
web/backend/dist/.gitkeep
vendored
Normal file
11
web/backend/dist/index.html
vendored
Normal file
11
web/backend/dist/index.html
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Trade Web</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>请通过 <code>docker-compose build web</code> 构建生产镜像后访问。</p>
|
||||
<p>本地开发请运行 <code>npm run dev</code> (web/frontend/) 与 <code>go run ./</code> (web/backend/)。</p>
|
||||
</body>
|
||||
</html>
|
||||
6
web/backend/embed.go
Normal file
6
web/backend/embed.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package main
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
10
web/backend/go.mod
Normal file
10
web/backend/go.mod
Normal file
@@ -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
|
||||
)
|
||||
17
web/backend/internal/auth/bcrypt.go
Normal file
17
web/backend/internal/auth/bcrypt.go
Normal file
@@ -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
|
||||
}
|
||||
32
web/backend/internal/auth/bootstrap.go
Normal file
32
web/backend/internal/auth/bootstrap.go
Normal file
@@ -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
|
||||
}
|
||||
55
web/backend/internal/auth/jwt.go
Normal file
55
web/backend/internal/auth/jwt.go
Normal file
@@ -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
|
||||
}
|
||||
39
web/backend/internal/config/config.go
Normal file
39
web/backend/internal/config/config.go
Normal file
@@ -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
|
||||
}
|
||||
90
web/backend/internal/handlers/auth.go
Normal file
90
web/backend/internal/handlers/auth.go
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
29
web/backend/internal/handlers/deps.go
Normal file
29
web/backend/internal/handlers/deps.go
Normal file
@@ -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})
|
||||
}
|
||||
70
web/backend/internal/handlers/scores.go
Normal file
70
web/backend/internal/handlers/scores.go
Normal file
@@ -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)
|
||||
}
|
||||
147
web/backend/internal/handlers/users.go
Normal file
147
web/backend/internal/handlers/users.go
Normal file
@@ -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
|
||||
}
|
||||
73
web/backend/internal/middleware/auth.go
Normal file
73
web/backend/internal/middleware/auth.go
Normal file
@@ -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 ""
|
||||
}
|
||||
46
web/backend/internal/middleware/logger.go
Normal file
46
web/backend/internal/middleware/logger.go
Normal file
@@ -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)
|
||||
}
|
||||
65
web/backend/internal/router/router.go
Normal file
65
web/backend/internal/router/router.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
180
web/backend/internal/store/auth.go
Normal file
180
web/backend/internal/store/auth.go
Normal file
@@ -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) }
|
||||
173
web/backend/internal/store/futures.go
Normal file
173
web/backend/internal/store/futures.go
Normal file
@@ -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()
|
||||
}
|
||||
10
web/backend/internal/store/util.go
Normal file
10
web/backend/internal/store/util.go
Normal file
@@ -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)
|
||||
}
|
||||
74
web/backend/main.go
Normal file
74
web/backend/main.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user