搭建用户体系

This commit is contained in:
2026-07-03 23:40:33 +08:00
parent 37a9f865ed
commit 2df19fcd02
40 changed files with 2622 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# 数据库配置
DB_USER=stock
DB_PASSWORD=stock
DB_NAME=stock
# JWT 密钥(生产环境请务必修改)
JWT_SECRET=change-me-in-production
JWT_EXPIRATION_HOURS=168
# 日志级别
RUST_LOG=info
# 端口
BACKEND_PORT=3019
FRONTEND_PORT=3018
+8
View File
@@ -20,6 +20,14 @@ build/
# ===== uv ===== # ===== uv =====
.uv/ .uv/
# ===== Go =====
backend/stock-user-system
backend/tmp/
*.exe
*.dll
*.so
*.dylib
# ===== Node / pnpm ===== # ===== Node / pnpm =====
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
+111
View File
@@ -0,0 +1,111 @@
# A股工具 · 用户体系
基于 **Go + PostgreSQL + Docker Compose** 的用户权限管理模块,角色覆盖:系统管理员、管理员、用户、游客。前端界面复用 `refer/` 目录下的暗色主题设计语言。
## 技术栈
| 层 | 技术 |
|---|---|
| 后端 | Go 1.25 + Gin + GORM + JWT + bcrypt |
| 数据库 | PostgreSQL 18 |
| 前端 | React 18 + Vite + Tailwind CSS |
| 部署 | Docker Compose |
## 快速启动
```bash
# 1. 复制环境变量
cp .env.example .env
# 2. 启动服务(首次会编译 Go 后端,可能需要几分钟)
docker compose up -d
# 3. 等待数据库健康检查通过后,访问前端
open http://localhost:3018
```
## 默认端口
| 服务 | 端口 |
|---|---|
| 前端 | 3018 |
| 后端 API | 3019 |
| PostgreSQL | 5432 |
## 创建首个系统管理员
系统不内置默认账号。启动后,先注册一个普通用户,再通过 SQL 将其提升为系统管理员:
```bash
docker compose exec db psql -U stock -d stock -c "
UPDATE users SET role_id = (SELECT id FROM roles WHERE name = 'system_admin')
WHERE username = '你的用户名';
"
```
## 角色说明
| 角色 | 权限 |
|---|---|
| 系统管理员 | 管理管理员、用户、系统配置 |
| 管理员 | 管理普通用户 |
| 用户 | 访问业务功能、修改个人资料 |
| 游客 | 仅查看公开内容,不可操作 |
## 主要 API
| 方法 | 路径 | 说明 | 权限 |
|---|---|---|---|
| GET | /api/public | 公开信息 | 公开 |
| POST | /api/auth/register | 注册(默认 user | 公开 |
| POST | /api/auth/login | 登录 | 公开 |
| GET | /api/auth/me | 当前用户 | 需登录 |
| POST | /api/auth/logout | 登出 | 需登录 |
| GET | /api/admin/users | 用户列表 | admin / system_admin |
| POST | /api/admin/users | 创建用户 | admin / system_admin |
| PUT | /api/admin/users/:id | 更新用户/角色 | admin / system_admin |
| DELETE | /api/admin/users/:id | 删除用户 | system_admin |
| GET | /api/admin/roles | 角色列表 | admin / system_admin |
## 目录结构
```
stock/
├── backend/ # Go 后端
│ ├── cmd/api/main.go
│ ├── internal/
│ │ ├── config/
│ │ ├── db/
│ │ ├── handlers/
│ │ ├── middleware/
│ │ ├── models/
│ │ └── routes/
│ ├── go.mod
│ └── Dockerfile
├── frontend/ # React 前端
│ ├── src/
│ ├── package.json
│ └── Dockerfile
├── docker-compose.yml
├── .env.example
└── README.md
```
## 开发
```bash
# 后端本地运行(需先启动 PostgreSQL)
cd backend
go run ./cmd/api
# 前端本地运行
cd frontend
npm install
npm run dev
```
## 注意事项
- 生产环境请务必修改 `JWT_SECRET`
- 游客通过首页直接访问,无需登录。
- 首次启动时 GORM 会自动创建表并插入默认角色。
+28
View File
@@ -0,0 +1,28 @@
# 多阶段构建:先编译 Go 后端,再使用最小运行时镜像
ARG GO_IMAGE=golang:1.25.8-alpine3.23
FROM ${GO_IMAGE} AS builder
WORKDIR /app
RUN apk add --no-cache git
COPY go.mod go.sum ./
RUN go env -w GOPROXY=https://goproxy.cn,direct && go mod download
COPY . ./
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app/stock-user-system ./cmd/api
FROM alpine:3.23
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/stock-user-system /app/stock-user-system
ENV DATABASE_URL=""
ENV JWT_SECRET=""
ENV JWT_EXPIRATION_HOURS="168"
ENV PORT="3019"
ENV GIN_MODE="release"
EXPOSE 3019
CMD ["/app/stock-user-system"]
+42
View File
@@ -0,0 +1,42 @@
package main
import (
"fmt"
"log"
"os"
"stock-user-system/internal/config"
"stock-user-system/internal/db"
"stock-user-system/internal/models"
"stock-user-system/internal/routes"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
database, err := db.Init(cfg.DatabaseURL)
if err != nil {
log.Fatalf("init database: %v", err)
}
if err := models.AutoMigrate(database); err != nil {
log.Fatalf("migrate database: %v", err)
}
if err := models.SeedRoles(database); err != nil {
log.Fatalf("seed roles: %v", err)
}
r := routes.Setup(cfg, database)
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
log.Printf("backend listening on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("server error: %v", err)
os.Exit(1)
}
}
+49
View File
@@ -0,0 +1,49 @@
module stock-user-system
go 1.25
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.31.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/pgx/v5 v5.5.5 // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.15.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+119
View File
@@ -0,0 +1,119 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+64
View File
@@ -0,0 +1,64 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
type Config struct {
DatabaseURL string
JWTSecret string
JWTExpirationHours int
Port string
AllowedOrigins []string
}
func Load() (*Config, error) {
_ = godotenv.Load()
databaseURL := os.Getenv("DATABASE_URL")
if databaseURL == "" {
return nil, fmt.Errorf("DATABASE_URL must be set")
}
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
jwtSecret = "change-me-in-production"
fmt.Fprintln(os.Stderr, "WARNING: JWT_SECRET not set, using default secret")
}
expHours, _ := strconv.Atoi(os.Getenv("JWT_EXPIRATION_HOURS"))
if expHours == 0 {
expHours = 168 // 7 days
}
port := os.Getenv("PORT")
if port == "" {
port = "3019"
}
allowedOrigins := []string{"*"}
if v := os.Getenv("ALLOWED_ORIGINS"); v != "" {
allowedOrigins = strings.Split(v, ",")
for i := range allowedOrigins {
allowedOrigins[i] = strings.TrimSpace(allowedOrigins[i])
}
}
return &Config{
DatabaseURL: databaseURL,
JWTSecret: jwtSecret,
JWTExpirationHours: expHours,
Port: port,
AllowedOrigins: allowedOrigins,
}, nil
}
func (c *Config) JWTExpiration() time.Duration {
return time.Duration(c.JWTExpirationHours) * time.Hour
}
+27
View File
@@ -0,0 +1,27 @@
package db
import (
"fmt"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func Init(databaseURL string) (*gorm.DB, error) {
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, err
}
sqlDB.SetMaxIdleConns(10)
sqlDB.SetMaxOpenConns(100)
return db, nil
}
+229
View File
@@ -0,0 +1,229 @@
package handlers
import (
"net/http"
"strings"
"stock-user-system/internal/config"
"stock-user-system/internal/middleware"
"stock-user-system/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type AdminHandler struct {
DB *gorm.DB
CFG *config.Config
}
func allowedRolesForCreation(actor models.RoleName) []models.RoleName {
switch actor {
case models.RoleSystemAdmin:
return []models.RoleName{models.RoleAdmin, models.RoleUser, models.RoleGuest}
case models.RoleAdmin:
return []models.RoleName{models.RoleUser, models.RoleGuest}
default:
return nil
}
}
func containsRole(roles []models.RoleName, target models.RoleName) bool {
for _, r := range roles {
if r == target {
return true
}
}
return false
}
func (h *AdminHandler) ListUsers(c *gin.Context) {
var users []models.User
if err := h.DB.Preload("Role").Order("created_at DESC").Find(&users).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
result := make([]models.PublicUserInfo, 0, len(users))
for _, u := range users {
result = append(result, u.ToPublicInfo())
}
c.JSON(http.StatusOK, result)
}
func (h *AdminHandler) CreateUser(c *gin.Context) {
current, _ := middleware.GetCurrentUser(c)
var req struct {
Username string `json:"username" binding:"required"`
Email *string `json:"email"`
Password string `json:"password" binding:"required"`
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
return
}
targetRole := models.RoleUser
if req.Role != "" {
parsed, err := models.ParseRoleName(req.Role)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的角色"})
return
}
targetRole = parsed
}
if !containsRole(allowedRolesForCreation(current.Role), targetRole) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
return
}
if err := validateUsername(req.Username); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
return
}
if len(req.Password) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码长度至少 6 位"})
return
}
var role models.Role
if err := h.DB.Where("name = ?", targetRole.String()).First(&role).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "角色不存在"})
return
}
hash, err := hashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
username := strings.ToLower(strings.TrimSpace(req.Username))
var email *string
if req.Email != nil && *req.Email != "" {
e := strings.ToLower(strings.TrimSpace(*req.Email))
email = &e
}
user := models.User{
Username: username,
Email: email,
PasswordHash: hash,
RoleID: role.ID,
Status: "active",
}
if err := h.DB.Create(&user).Error; err != nil {
if strings.Contains(err.Error(), "duplicate key") {
c.JSON(http.StatusConflict, gin.H{"success": false, "error": "用户名或邮箱已存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
h.DB.Preload("Role").First(&user, "id = ?", user.ID)
c.JSON(http.StatusOK, user.ToPublicInfo())
}
func (h *AdminHandler) UpdateUser(c *gin.Context) {
current, _ := middleware.GetCurrentUser(c)
userID := c.Param("id")
var req struct {
Email *string `json:"email"`
Role string `json:"role"`
Status string `json:"status"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
return
}
var target models.User
if err := h.DB.Preload("Role").Where("id = ?", userID).First(&target).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "not found"})
return
}
if !current.Role.CanManage(target.Role.NameEnum()) && current.ID != userID {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
return
}
updates := map[string]interface{}{}
if req.Email != nil && *req.Email != "" {
updates["email"] = strings.ToLower(strings.TrimSpace(*req.Email))
}
if req.Status != "" {
updates["status"] = req.Status
}
if req.Role != "" {
newRole, err := models.ParseRoleName(req.Role)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "无效的角色"})
return
}
if !containsRole(allowedRolesForCreation(current.Role), newRole) || !current.Role.CanManage(newRole) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
return
}
var role models.Role
if err := h.DB.Where("name = ?", newRole.String()).First(&role).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "角色不存在"})
return
}
updates["role_id"] = role.ID
}
if err := h.DB.Model(&target).Updates(updates).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
h.DB.Preload("Role").First(&target, "id = ?", userID)
c.JSON(http.StatusOK, target.ToPublicInfo())
}
func (h *AdminHandler) DeleteUser(c *gin.Context) {
current, _ := middleware.GetCurrentUser(c)
userID := c.Param("id")
if current.ID == userID {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能删除自己"})
return
}
var target models.User
if err := h.DB.Preload("Role").Where("id = ?", userID).First(&target).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "not found"})
return
}
if !current.Role.CanManage(target.Role.NameEnum()) {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
return
}
if err := h.DB.Delete(&target).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "用户已删除"})
}
func (h *AdminHandler) ListRoles(c *gin.Context) {
var roles []models.Role
if err := h.DB.Order("id").Find(&roles).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
c.JSON(http.StatusOK, roles)
}
+178
View File
@@ -0,0 +1,178 @@
package handlers
import (
"fmt"
"net/http"
"strings"
"stock-user-system/internal/config"
"stock-user-system/internal/middleware"
"stock-user-system/internal/models"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type AuthHandler struct {
DB *gorm.DB
CFG *config.Config
}
func (h *AuthHandler) Register(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Email *string `json:"email"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
return
}
if err := validateUsername(req.Username); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
return
}
if len(req.Password) < 6 {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "密码长度至少 6 位"})
return
}
username := strings.ToLower(strings.TrimSpace(req.Username))
var role models.Role
if err := h.DB.Where("name = ?", models.RoleUser).First(&role).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
hash, err := hashPassword(req.Password)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
var email *string
if req.Email != nil && *req.Email != "" {
e := strings.ToLower(strings.TrimSpace(*req.Email))
email = &e
}
user := models.User{
Username: username,
Email: email,
PasswordHash: hash,
RoleID: role.ID,
Status: "active",
}
if err := h.DB.Create(&user).Error; err != nil {
if strings.Contains(err.Error(), "duplicate key") {
c.JSON(http.StatusConflict, gin.H{"success": false, "error": "用户名或邮箱已存在"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
if err := h.DB.Preload("Role").First(&user, "id = ?", user.ID).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
token, err := middleware.GenerateToken(user.ID, models.RoleUser, h.CFG)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": user.ToPublicInfo(),
})
}
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请求参数错误"})
return
}
username := strings.ToLower(strings.TrimSpace(req.Username))
var user models.User
if err := h.DB.Preload("Role").Where("LOWER(username) = ?", username).First(&user).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
if user.Status != "active" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "账号已被禁用"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "用户名或密码错误"})
return
}
token, err := middleware.GenerateToken(user.ID, user.Role.NameEnum(), h.CFG)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "internal server error"})
return
}
c.JSON(http.StatusOK, gin.H{
"token": token,
"user": user.ToPublicInfo(),
})
}
func (h *AuthHandler) Me(c *gin.Context) {
current, ok := middleware.GetCurrentUser(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "访问令牌无效或已过期"})
return
}
var user models.User
if err := h.DB.Preload("Role").Where("id = ?", current.ID).First(&user).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "not found"})
return
}
c.JSON(http.StatusOK, user.ToPublicInfo())
}
func (h *AuthHandler) Logout(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "登出成功"})
}
func (h *AuthHandler) PublicInfo(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "欢迎访问 A股工具公开信息",
"guest_allowed": true,
})
}
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
}
func validateUsername(username string) error {
if len(username) < 3 || len(username) > 32 {
return fmt.Errorf("用户名长度需在 3-32 位之间")
}
for _, r := range username {
if !(r >= 'a' && r <= 'z') && !(r >= 'A' && r <= 'Z') && !(r >= '0' && r <= '9') && r != '_' && r != '-' {
return fmt.Errorf("用户名只能包含字母、数字、下划线和短横线")
}
}
return nil
}
+116
View File
@@ -0,0 +1,116 @@
package middleware
import (
"net/http"
"strings"
"time"
"stock-user-system/internal/config"
"stock-user-system/internal/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
)
type CurrentUser struct {
ID string
Username string
Role models.RoleName
}
func AuthMiddleware(cfg *config.Config, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
token := ""
if strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
}
if token != "" {
claims, err := parseToken(token, cfg.JWTSecret)
if err == nil {
var user models.User
if err := db.Preload("Role").Where("id = ? AND status = ?", claims.Subject, "active").First(&user).Error; err == nil {
c.Set("currentUser", CurrentUser{
ID: user.ID,
Username: user.Username,
Role: user.Role.NameEnum(),
})
}
}
}
c.Next()
}
}
type Claims struct {
Subject string `json:"sub"`
Role string `json:"role"`
jwt.RegisteredClaims
}
func GenerateToken(userID string, role models.RoleName, cfg *config.Config) (string, error) {
claims := Claims{
Subject: userID,
Role: role.String(),
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(cfg.JWTExpiration())),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(cfg.JWTSecret))
}
func parseToken(tokenString string, secret string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil
})
if err != nil {
return nil, err
}
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
return nil, jwt.ErrSignatureInvalid
}
func RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if _, exists := c.Get("currentUser"); !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "访问令牌无效或已过期"})
c.Abort()
return
}
c.Next()
}
}
func RequireRoles(allowed ...models.RoleName) gin.HandlerFunc {
return func(c *gin.Context) {
val, exists := c.Get("currentUser")
if !exists {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "访问令牌无效或已过期"})
c.Abort()
return
}
current := val.(CurrentUser)
for _, role := range allowed {
if current.Role == role {
c.Next()
return
}
}
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "权限不足"})
c.Abort()
}
}
func GetCurrentUser(c *gin.Context) (CurrentUser, bool) {
val, exists := c.Get("currentUser")
if !exists {
return CurrentUser{}, false
}
return val.(CurrentUser), true
}
+127
View File
@@ -0,0 +1,127 @@
package models
import (
"fmt"
"time"
"gorm.io/gorm"
)
type RoleName string
const (
RoleSystemAdmin RoleName = "system_admin"
RoleAdmin RoleName = "admin"
RoleUser RoleName = "user"
RoleGuest RoleName = "guest"
)
func (r RoleName) String() string { return string(r) }
func (r RoleName) Rank() int {
switch r {
case RoleSystemAdmin:
return 3
case RoleAdmin:
return 2
case RoleUser:
return 1
default:
return 0
}
}
func (r RoleName) CanManage(target RoleName) bool {
return r.Rank() > target.Rank()
}
func ParseRoleName(s string) (RoleName, error) {
switch s {
case "system_admin":
return RoleSystemAdmin, nil
case "admin":
return RoleAdmin, nil
case "user":
return RoleUser, nil
case "guest":
return RoleGuest, nil
default:
return RoleGuest, fmt.Errorf("unknown role: %s", s)
}
}
type Role struct {
ID int32 `json:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" gorm:"uniqueIndex;size:32;not null"`
Description *string `json:"description"`
Permissions string `json:"permissions" gorm:"type:jsonb;default:'[]'"`
CreatedAt time.Time `json:"created_at"`
Users []User `json:"-" gorm:"foreignKey:RoleID"`
}
func (r *Role) NameEnum() RoleName {
role, _ := ParseRoleName(r.Name)
return role
}
type User struct {
ID string `json:"id" gorm:"type:uuid;primaryKey;default:gen_random_uuid()"`
Username string `json:"username" gorm:"uniqueIndex;size:32;not null"`
Email *string `json:"email" gorm:"uniqueIndex;size:128"`
PasswordHash string `json:"-" gorm:"size:255"`
RoleID int32 `json:"role_id" gorm:"not null"`
Role Role `json:"role,omitempty" gorm:"foreignKey:RoleID;references:ID"`
Status string `json:"status" gorm:"size:16;default:active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type PublicUserInfo struct {
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
Role RoleName `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
func (u *User) ToPublicInfo() PublicUserInfo {
return PublicUserInfo{
ID: u.ID,
Username: u.Username,
Email: u.Email,
Role: u.Role.NameEnum(),
Status: u.Status,
CreatedAt: u.CreatedAt,
}
}
func AutoMigrate(db *gorm.DB) error {
return db.AutoMigrate(&Role{}, &User{})
}
func SeedRoles(db *gorm.DB) error {
roles := []Role{
{Name: string(RoleSystemAdmin), Description: strPtr("系统管理员,可管理管理员与系统配置"), Permissions: `["*"]`},
{Name: string(RoleAdmin), Description: strPtr("管理员,可管理普通用户"), Permissions: `["users.read", "users.write", "users.create"]`},
{Name: string(RoleUser), Description: strPtr("普通用户,可访问业务功能"), Permissions: `["dashboard.read", "profile.write"]`},
{Name: string(RoleGuest), Description: strPtr("游客,仅可查看公开内容"), Permissions: `["public.read"]`},
}
for _, role := range roles {
var existing Role
if err := db.Where("name = ?", role.Name).First(&existing).Error; err != nil {
if err == gorm.ErrRecordNotFound {
if err := db.Create(&role).Error; err != nil {
return err
}
} else {
return err
}
}
}
return nil
}
func strPtr(s string) *string {
return &s
}
+58
View File
@@ -0,0 +1,58 @@
package routes
import (
"stock-user-system/internal/config"
"stock-user-system/internal/handlers"
"stock-user-system/internal/middleware"
"stock-user-system/internal/models"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func Setup(cfg *config.Config, db *gorm.DB) *gin.Engine {
authHandler := &handlers.AuthHandler{DB: db, CFG: cfg}
adminHandler := &handlers.AdminHandler{DB: db, CFG: cfg}
r := gin.Default()
// CORS
r.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Origin, Content-Type, Accept, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
r.Use(middleware.AuthMiddleware(cfg, db))
// 公开接口
r.GET("/api/public", authHandler.PublicInfo)
r.POST("/api/auth/register", authHandler.Register)
r.POST("/api/auth/login", authHandler.Login)
// 受保护接口
auth := r.Group("/api/auth")
auth.Use(middleware.RequireAuth())
{
auth.GET("/me", authHandler.Me)
auth.POST("/logout", authHandler.Logout)
}
// 管理员接口
admin := r.Group("/api/admin")
admin.Use(middleware.RequireAuth(), middleware.RequireRoles(models.RoleAdmin, models.RoleSystemAdmin))
{
admin.GET("/users", adminHandler.ListUsers)
admin.POST("/users", adminHandler.CreateUser)
admin.PUT("/users/:id", adminHandler.UpdateUser)
admin.DELETE("/users/:id", adminHandler.DeleteUser)
admin.GET("/roles", adminHandler.ListRoles)
}
return r
}
+59
View File
@@ -0,0 +1,59 @@
services:
db:
image: postgres:18.3-alpine3.23
container_name: stock_db
environment:
POSTGRES_USER: ${DB_USER:-stock}
POSTGRES_PASSWORD: ${DB_PASSWORD:-stock}
POSTGRES_DB: ${DB_NAME:-stock}
volumes:
- postgres_data:/var/lib/postgresql
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-stock} -d ${DB_NAME:-stock}"]
interval: 5s
timeout: 5s
retries: 5
networks:
- stock_network
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: stock_backend
environment:
DATABASE_URL: postgres://${DB_USER:-stock}:${DB_PASSWORD:-stock}@db:5432/${DB_NAME:-stock}
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
JWT_EXPIRATION_HOURS: ${JWT_EXPIRATION_HOURS:-168}
GIN_MODE: ${GIN_MODE:-release}
PORT: 3019
ports:
- "3019:3019"
depends_on:
db:
condition: service_healthy
networks:
- stock_network
restart: unless-stopped
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: stock_frontend
ports:
- "3018:80"
depends_on:
- backend
networks:
- stock_network
restart: unless-stopped
volumes:
postgres_data:
networks:
stock_network:
driver: bridge
+18
View File
@@ -0,0 +1,18 @@
# 前端构建
ARG NODE_IMAGE=node:20-alpine
FROM ${NODE_IMAGE} AS builder
WORKDIR /build
COPY package.json package-lock.json* ./
RUN npm install
COPY . ./
RUN npm run build
# 使用 nginx 提供静态资源
FROM nginx:1.29.0-alpine
COPY --from=builder /build/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0A0A0B" />
<title>A股工具</title>
</head>
<body class="bg-base text-foreground antialiased">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3019;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"name": "stock-user-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint ."
},
"dependencies": {
"axios": "^1.7.2",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"lucide-react": "^0.439.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0",
"tailwind-merge": "^2.5.2"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.45",
"tailwindcss": "^3.4.10",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.5.4",
"vite": "^5.4.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#8B5CF6"/>
<text x="50" y="68" font-family="system-ui, sans-serif" font-size="52" font-weight="bold" fill="white" text-anchor="middle">A</text>
</svg>

After

Width:  |  Height:  |  Size: 263 B

+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { Navigate, useLocation } from 'react-router-dom'
import { Loader2 } from 'lucide-react'
import { api, UserInfo } from '@/lib/api'
import { clearAuth, RoleName } from '@/lib/auth'
interface AuthGuardProps {
children: React.ReactNode
allowedRoles?: RoleName[]
requireAuth?: boolean
}
export function AuthGuard({ children, allowedRoles, requireAuth = true }: AuthGuardProps) {
const location = useLocation()
const [user, setUser] = useState<UserInfo | null | undefined>(undefined)
useEffect(() => {
const token = localStorage.getItem('access_token')
if (!token) {
setUser(null)
return
}
api.me()
.then(setUser)
.catch(() => {
clearAuth()
setUser(null)
})
}, [location.pathname])
if (user === undefined) {
return (
<div className="h-screen w-full flex items-center justify-center bg-base text-foreground">
<Loader2 className="h-6 w-6 animate-spin text-accent" />
</div>
)
}
if (requireAuth && !user) {
return <Navigate to="/login" state={{ from: location.pathname }} replace />
}
if (allowedRoles && user && !allowedRoles.includes(user.role)) {
return <Navigate to="/" replace />
}
return <>{children}</>
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useState } from 'react'
import { NavLink, Outlet, useNavigate } from 'react-router-dom'
import {
LayoutDashboard,
Users,
User,
LogOut,
Moon,
Sun,
Menu,
} from 'lucide-react'
import { api, UserInfo } from '@/lib/api'
import { clearAuth, canManageUsers, roleLabel } from '@/lib/auth'
import { cn } from '@/lib/cn'
import { useTheme } from './ThemeProvider'
const BRAND = '#8B5CF6'
export function Layout() {
const { resolved, setTheme, theme } = useTheme()
const isDark = resolved === 'dark'
const navigate = useNavigate()
const [user, setUser] = useState<UserInfo | null>(null)
const [mobileOpen, setMobileOpen] = useState(false)
useEffect(() => {
const token = localStorage.getItem('access_token')
if (!token) {
setUser(null)
return
}
api.me()
.then(setUser)
.catch(() => {
clearAuth()
setUser(null)
})
}, [navigate])
const handleLogout = async () => {
try {
await api.logout()
} catch {}
clearAuth()
navigate('/login', { replace: true })
}
const navItems = [
{ to: '/', label: '看板', icon: LayoutDashboard },
...(user && canManageUsers(user.role) ? [{ to: '/users', label: '用户管理', icon: Users }] : []),
{ to: '/profile', label: '个人中心', icon: User },
]
const toggleTheme = () => {
if (theme === 'system') {
setTheme(isDark ? 'light' : 'dark')
} else {
setTheme(theme === 'dark' ? 'light' : 'dark')
}
}
const sidebar = (
<aside className="border-r border-border bg-surface flex flex-col h-full min-h-0 overflow-hidden w-56 lg:w-60">
<div className="px-5 py-5 border-b border-border shrink-0">
<div className="flex items-center gap-2.5">
<div
className="h-7 w-7 rounded-lg flex items-center justify-center text-white font-bold text-sm"
style={{ background: BRAND }}
>
A
</div>
<div className="font-mono font-bold text-[13px] tracking-[0.06em] text-foreground leading-tight">
<div>A股</div>
<div></div>
</div>
</div>
<div className="mt-2.5 text-[10px] uppercase tracking-[0.22em] text-secondary">
</div>
<div
className="mt-3 h-px"
style={{ background: `linear-gradient(90deg, ${BRAND}88, transparent 80%)` }}
/>
</div>
<nav className="flex-1 min-h-0 overflow-y-auto px-2 py-3 space-y-0.5">
{navItems.map(({ to, label, icon: Icon }) => (
<NavLink
key={to}
to={to}
onClick={() => setMobileOpen(false)}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-btn text-sm transition-colors duration-150 ease-smooth',
isActive
? 'bg-elevated text-foreground font-medium'
: 'text-foreground/80 hover:bg-elevated hover:text-foreground',
)
}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="flex-1">{label}</span>
</NavLink>
))}
</nav>
<div className="border-t border-border px-3 py-3 shrink-0 space-y-2">
{user && (
<div className="px-3 py-2 rounded-btn bg-elevated/50">
<div className="text-sm font-medium text-foreground truncate">{user.username}</div>
<div className="text-[10px] text-secondary">{roleLabel(user.role)}</div>
</div>
)}
<div className="flex items-center gap-1">
<button
onClick={toggleTheme}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-btn text-xs text-secondary hover:bg-elevated hover:text-foreground transition-colors"
>
{isDark ? <Sun className="h-3.5 w-3.5" /> : <Moon className="h-3.5 w-3.5" />}
{isDark ? '浅色' : '深色'}
</button>
<button
onClick={handleLogout}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-btn text-xs text-danger hover:bg-danger/10 transition-colors"
>
<LogOut className="h-3.5 w-3.5" />
退
</button>
</div>
</div>
</aside>
)
return (
<div className="h-screen flex flex-col lg:grid lg:grid-cols-[14rem_1fr] bg-base text-foreground overflow-hidden">
<div className="lg:hidden flex items-center justify-between px-4 py-3 border-b border-border bg-surface shrink-0">
<div className="flex items-center gap-2.5">
<div className="h-6 w-6 rounded-md flex items-center justify-center text-white text-xs font-bold" style={{ background: BRAND }}>A</div>
<span className="text-sm font-semibold">A股工作台</span>
</div>
<button onClick={() => setMobileOpen(!mobileOpen)} className="p-2 rounded-btn hover:bg-elevated">
<Menu className="h-5 w-5" />
</button>
</div>
<div className={cn('fixed inset-0 z-40 lg:static lg:block', mobileOpen ? 'block' : 'hidden')}>
<div className="absolute inset-0 bg-black/50 lg:hidden" onClick={() => setMobileOpen(false)} />
<div className="relative z-10 h-full max-w-[14rem]">{sidebar}</div>
</div>
<main className="flex-1 min-h-0 overflow-auto scrollbar-gutter-stable p-6">
<Outlet />
</main>
</div>
)
}
+181
View File
@@ -0,0 +1,181 @@
import { useState } from 'react'
import { Shield, Lock, AlertCircle, Loader2, UserPlus, LogIn } from 'lucide-react'
import { api, setToken } from '@/lib/api'
import { setStoredUser } from '@/lib/auth'
interface LoginFormProps {
onSuccess: () => void
}
export function LoginForm({ onSuccess }: LoginFormProps) {
const [mode, setMode] = useState<'login' | 'register'>('login')
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!username.trim() || !password.trim()) {
setError('请输入用户名和密码')
return
}
if (mode === 'register' && password.length < 6) {
setError('密码长度至少 6 位')
return
}
setLoading(true)
try {
const res =
mode === 'login'
? await api.login({ username: username.trim(), password })
: await api.register({
username: username.trim(),
email: email.trim() || undefined,
password,
})
setToken(res.token)
setStoredUser(res.user)
onSuccess()
} catch (err: any) {
setError(err?.message || '操作失败,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-md"
>
<div className="rounded-card border border-border bg-surface/80 backdrop-blur-sm p-6">
<div className="flex flex-col items-center text-center">
<div
className="rounded-2xl p-4 border border-border"
style={{ background: 'linear-gradient(135deg, #8B5CF622, transparent)' }}
>
<Shield className="h-8 w-8" style={{ color: '#8B5CF6' }} />
</div>
<h1 className="mt-5 text-2xl font-bold text-foreground tracking-tight">
{mode === 'login' ? '登录账号' : '注册账号'}
</h1>
<p className="mt-2 text-sm text-secondary leading-relaxed">
{mode === 'login'
? '请输入用户名和密码进入系统。'
: '注册后默认获得用户权限。'}
</p>
</div>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
<Shield className="h-4 w-4" />
</div>
<input
type="text"
autoComplete="username"
placeholder="用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
/>
</div>
{mode === 'register' && (
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
<Shield className="h-4 w-4" />
</div>
<input
type="email"
autoComplete="email"
placeholder="邮箱(可选)"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
/>
</div>
)}
<div className="relative">
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-muted">
<Lock className="h-4 w-4" />
</div>
<input
type="password"
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
placeholder="密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full pl-9 pr-3 py-2.5 rounded-input bg-base border border-border text-sm focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-all"
/>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full inline-flex items-center justify-center gap-2 px-5 h-11 rounded-xl bg-accent text-white text-sm font-semibold shadow-lg shadow-accent/20 hover:bg-accent/90 hover:shadow-accent/30 disabled:opacity-60 transition-all"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : mode === 'login' ? (
<LogIn className="h-4 w-4" />
) : (
<UserPlus className="h-4 w-4" />
)}
{loading ? '处理中…' : mode === 'login' ? '登录' : '注册'}
</button>
</form>
<div className="mt-5 text-center">
<button
type="button"
onClick={() => {
setMode(mode === 'login' ? 'register' : 'login')
setError('')
}}
className="text-xs text-secondary hover:text-accent transition-colors"
>
{mode === 'login' ? '没有账号?立即注册' : '已有账号?直接登录'}
</button>
</div>
</div>
</motion.div>
)
}
// 简单内联 motion 组件,避免引入 framer-motion 依赖
const motion = {
div: ({ children, className, ...props }: any) => {
return (
<div
ref={(el) => {
if (el && props.initial) {
el.style.opacity = String(props.initial.opacity ?? 1)
el.style.transform = `translateY(${props.initial.y ?? 0}px)`
requestAnimationFrame(() => {
el.style.transition = `all ${props.transition?.duration ?? 0.3}s ${(props.transition?.ease || [0.16, 1, 0.3, 1]).join(',')}`
el.style.opacity = String(props.animate?.opacity ?? 1)
el.style.transform = `translateY(${props.animate?.y ?? 0}px)`
})
}
}}
className={className}
>
{children}
</div>
)
},
}
+81
View File
@@ -0,0 +1,81 @@
import { createContext, useContext, useEffect, useState } from 'react'
export type Theme = 'light' | 'dark' | 'system'
export type ResolvedTheme = 'light' | 'dark'
interface ThemeContextValue {
theme: Theme
resolved: ResolvedTheme
setTheme: (theme: Theme) => void
}
const ThemeContext = createContext<ThemeContextValue | null>(null)
function resolveTheme(theme: Theme): ResolvedTheme {
if (theme !== 'system') return theme
if (typeof window === 'undefined') return 'dark'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function updateMetaThemeColor(resolved: ResolvedTheme) {
if (typeof document === 'undefined') return
const meta = document.querySelector('meta[name="theme-color"]')
if (!meta) return
meta.setAttribute('content', resolved === 'dark' ? '#0A0A0B' : '#FAFAFA')
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => {
try {
const saved = localStorage.getItem('theme')
if (saved === 'light' || saved === 'dark' || saved === 'system') return saved
} catch {}
return 'system'
})
const [resolved, setResolved] = useState<ResolvedTheme>(() => resolveTheme(theme))
useEffect(() => {
const nextResolved = resolveTheme(theme)
setResolved(nextResolved)
const root = document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(nextResolved)
try {
localStorage.setItem('theme', theme)
} catch {}
updateMetaThemeColor(nextResolved)
}, [theme])
useEffect(() => {
if (theme !== 'system') return
const media = window.matchMedia('(prefers-color-scheme: dark)')
const handler = () => {
const nextResolved = resolveTheme('system')
setResolved(nextResolved)
document.documentElement.classList.remove('light', 'dark')
document.documentElement.classList.add(nextResolved)
updateMetaThemeColor(nextResolved)
}
media.addEventListener('change', handler)
return () => media.removeEventListener('change', handler)
}, [theme])
const setTheme = (next: Theme) => {
setThemeState(next)
}
return (
<ThemeContext.Provider value={{ theme, resolved, setTheme }}>
{children}
</ThemeContext.Provider>
)
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext)
if (!ctx) {
throw new Error('useTheme must be used within ThemeProvider')
}
return ctx
}
+72
View File
@@ -0,0 +1,72 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 暗色为默认(html.dark) / 亮色用 :root 反转 */
:root {
--base: 0 0% 98%;
--surface: 0 0% 100%;
--elevated: 240 5% 96%;
--border: 240 6% 90%;
--fg-primary: 240 6% 10%;
--fg-secondary: 240 4% 35%;
--fg-muted: 240 5% 65%;
--accent: 217 91% 60%;
--bull: 4 87% 60%;
--bear: 152 67% 45%;
--warning: 32 95% 50%;
--danger: 4 87% 60%;
}
html.dark {
--base: 240 5% 5%;
--surface: 240 7% 10%;
--elevated: 240 9% 14%;
--border: 240 5% 22%;
--fg-primary: 0 0% 98%;
--fg-secondary: 240 5% 78%;
--fg-muted: 240 5% 58%;
--accent: 217 91% 60%;
--bull: 4 87% 60%;
--bear: 152 67% 45%;
--warning: 32 95% 50%;
--danger: 4 87% 60%;
}
.scrollbar-gutter-stable { scrollbar-gutter: stable; }
* {
scrollbar-width: thin;
scrollbar-color: hsl(var(--border)) transparent;
}
*::-webkit-scrollbar {
width: 8px;
height: 8px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background: hsl(var(--border) / 0.72);
border: 2px solid transparent;
border-radius: 999px;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover {
background: hsl(var(--accent) / 0.75);
background-clip: content-box;
}
.tabular { font-variant-numeric: tabular-nums; }
.num { font-family: theme('fontFamily.mono'); font-variant-numeric: tabular-nums; }
* { border-color: hsl(var(--border)); }
body {
background: hsl(var(--base));
color: hsl(var(--fg-primary));
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
-webkit-font-smoothing: subpixel-antialiased;
-moz-osx-font-smoothing: auto;
text-rendering: optimizeLegibility;
}
+119
View File
@@ -0,0 +1,119 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL || ''
export interface ApiResponse<T> {
success?: boolean
data?: T
error?: string
}
export interface UserInfo {
id: string
username: string
email?: string
role: 'system_admin' | 'admin' | 'user' | 'guest'
status: string
created_at: string
}
export interface Role {
id: number
name: 'system_admin' | 'admin' | 'user' | 'guest'
description?: string
permissions: string[]
created_at: string
}
export interface AuthResponse {
token: string
user: UserInfo
}
export function getToken(): string | null {
try {
return localStorage.getItem('access_token')
} catch {
return null
}
}
export function setToken(token: string) {
localStorage.setItem('access_token', token)
}
export function removeToken() {
localStorage.removeItem('access_token')
}
async function request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const url = `${API_BASE}${path}`
const token = getToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string>),
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(url, {
...options,
headers,
})
let data: any
try {
data = await res.json()
} catch {
data = {}
}
if (!res.ok) {
const msg = data.error || data.message || `HTTP ${res.status}`
throw new Error(msg)
}
return data as T
}
export const api = {
publicInfo: () =>
request<{ message: string; guest_allowed: boolean }>('/api/public'),
register: (body: { username: string; email?: string; password: string }) =>
request<AuthResponse>('/api/auth/register', {
method: 'POST',
body: JSON.stringify(body),
}),
login: (body: { username: string; password: string }) =>
request<AuthResponse>('/api/auth/login', {
method: 'POST',
body: JSON.stringify(body),
}),
me: () => request<UserInfo>('/api/auth/me'),
logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }),
listUsers: () => request<UserInfo[]>('/api/admin/users'),
createUser: (body: { username: string; email?: string; password: string; role?: string }) =>
request<UserInfo>('/api/admin/users', {
method: 'POST',
body: JSON.stringify(body),
}),
updateUser: (id: string, body: Partial<{ email: string; role: string; status: string }>) =>
request<UserInfo>(`/api/admin/users/${id}`, {
method: 'PUT',
body: JSON.stringify(body),
}),
deleteUser: (id: string) =>
request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }),
listRoles: () => request<Role[]>('/api/admin/roles'),
}
+69
View File
@@ -0,0 +1,69 @@
import { UserInfo } from './api'
export function isAuthenticated(): boolean {
try {
return !!localStorage.getItem('access_token')
} catch {
return false
}
}
export function getStoredUser(): UserInfo | null {
try {
const raw = localStorage.getItem('user')
return raw ? (JSON.parse(raw) as UserInfo) : null
} catch {
return null
}
}
export function setStoredUser(user: UserInfo | null) {
if (user) {
localStorage.setItem('user', JSON.stringify(user))
} else {
localStorage.removeItem('user')
}
}
export function clearAuth() {
localStorage.removeItem('access_token')
localStorage.removeItem('user')
}
export type RoleName = 'system_admin' | 'admin' | 'user' | 'guest'
const ROLE_RANK: Record<RoleName, number> = {
system_admin: 3,
admin: 2,
user: 1,
guest: 0,
}
export function roleRank(role: RoleName): number {
return ROLE_RANK[role] ?? 0
}
export function roleLabel(role: RoleName): string {
switch (role) {
case 'system_admin':
return '系统管理员'
case 'admin':
return '管理员'
case 'user':
return '用户'
case 'guest':
return '游客'
}
}
export function canAccess(role: RoleName, allowed: RoleName[]): boolean {
return allowed.includes(role)
}
export function canManageUsers(role: RoleName): boolean {
return role === 'admin' || role === 'system_admin'
}
export function canDeleteUsers(role: RoleName): boolean {
return role === 'system_admin'
}
+7
View File
@@ -0,0 +1,7 @@
import type { ClassValue } from 'clsx'
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { RouterProvider } from 'react-router-dom'
import { router } from './router'
import { ThemeProvider } from './components/ThemeProvider'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ThemeProvider>
<RouterProvider router={router} />
</ThemeProvider>
</React.StrictMode>,
)
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useState } from 'react'
import { LayoutDashboard, Globe, ShieldCheck } from 'lucide-react'
import { api } from '@/lib/api'
import { getStoredUser, roleLabel } from '@/lib/auth'
export function Dashboard() {
const [publicInfo, setPublicInfo] = useState<{ message: string } | null>(null)
const user = getStoredUser()
useEffect(() => {
api.publicInfo().then(setPublicInfo).catch(() => null)
}, [])
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-btn bg-accent/10 text-accent">
<LayoutDashboard className="h-5 w-5" />
</div>
<div>
<h1 className="text-xl font-bold text-foreground"></h1>
<p className="text-sm text-secondary">{user?.username || '游客'}</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<Card
icon={<ShieldCheck className="h-5 w-5 text-accent" />}
title="当前身份"
value={user ? roleLabel(user.role) : '游客'}
desc={user ? `用户:${user.username}` : '未登录,仅可查看公开信息'}
/>
<Card
icon={<Globe className="h-5 w-5 text-bear" />}
title="公开信息"
value="已接入"
desc={publicInfo?.message || '加载中…'}
/>
</div>
<div className="rounded-card border border-border bg-surface p-5">
<h2 className="text-sm font-semibold text-foreground mb-3"></h2>
<ul className="space-y-2 text-sm text-secondary">
<li><span className="text-accent font-medium"></span></li>
<li><span className="text-accent font-medium"></span></li>
<li><span className="text-accent font-medium"></span>访</li>
<li><span className="text-accent font-medium"></span></li>
</ul>
</div>
</div>
)
}
function Card({
icon,
title,
value,
desc,
}: {
icon: React.ReactNode
title: string
value: string
desc: string
}) {
return (
<div className="rounded-card border border-border bg-surface p-4 transition hover:border-accent/30">
<div className="flex items-center gap-2 mb-3">
{icon}
<span className="text-xs font-medium text-secondary uppercase tracking-wider">{title}</span>
</div>
<div className="text-lg font-bold text-foreground">{value}</div>
<div className="mt-1 text-xs text-muted">{desc}</div>
</div>
)
}
+50
View File
@@ -0,0 +1,50 @@
import { useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { LoginForm } from '@/components/LoginForm'
import { isAuthenticated } from '@/lib/auth'
const BRAND = '#8B5CF6'
export function Login() {
const navigate = useNavigate()
useEffect(() => {
if (isAuthenticated()) {
navigate('/', { replace: true })
}
}, [navigate])
return (
<div className="relative min-h-screen bg-base overflow-hidden flex flex-col">
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div
className="absolute -top-40 -left-40 h-[28rem] w-[28rem] rounded-full blur-[120px] opacity-20"
style={{ background: `radial-gradient(circle, ${BRAND}, transparent 70%)` }}
/>
<div
className="absolute -bottom-40 -right-32 h-[26rem] w-[26rem] rounded-full blur-[120px] opacity-15"
style={{ background: 'radial-gradient(circle, hsl(var(--accent)), transparent 70%)' }}
/>
<div
className="absolute inset-0 opacity-[0.025]"
style={{
backgroundImage:
'linear-gradient(hsl(var(--fg-primary)) 1px, transparent 1px), linear-gradient(90deg, hsl(var(--fg-primary)) 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
</div>
<header className="relative z-10 flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-2.5 text-foreground">
<div className="h-6 w-6 rounded-md flex items-center justify-center text-white text-xs font-bold" style={{ background: BRAND }}>A</div>
<span className="text-sm font-semibold tracking-tight">A股工具</span>
</div>
</header>
<main className="relative z-10 flex-1 flex items-center justify-center px-6 py-10">
<LoginForm onSuccess={() => navigate('/', { replace: true })} />
</main>
</div>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { useEffect, useState } from 'react'
import { User, Loader2, AlertCircle } from 'lucide-react'
import { api, UserInfo } from '@/lib/api'
import { roleLabel } from '@/lib/auth'
export function Profile() {
const [user, setUser] = useState<UserInfo | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
api.me()
.then(setUser)
.catch((err: any) => setError(err?.message || '加载失败'))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<div className="h-full flex items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-accent" />
</div>
)
}
return (
<div className="max-w-xl space-y-6">
<div className="flex items-center gap-3">
<div className="p-2 rounded-btn bg-accent/10 text-accent">
<User className="h-5 w-5" />
</div>
<h1 className="text-xl font-bold text-foreground"></h1>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
<div className="rounded-card border border-border bg-surface p-5 space-y-4">
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
<span className="text-sm text-secondary"></span>
<span className="text-sm font-medium text-foreground">{user?.username}</span>
</div>
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
<span className="text-sm text-secondary"></span>
<span className="text-sm font-medium text-foreground">{user ? roleLabel(user.role) : '-'}</span>
</div>
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
<span className="text-sm text-secondary"></span>
<span className="text-sm font-medium text-foreground">{user?.email || '未设置'}</span>
</div>
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
<span className="text-sm text-secondary"></span>
<span className="text-sm font-medium text-foreground">{user?.status === 'active' ? '启用' : '禁用'}</span>
</div>
<div className="grid grid-cols-[6rem_1fr] gap-4 items-center">
<span className="text-sm text-secondary"></span>
<span className="text-sm font-medium text-foreground">{user ? new Date(user.created_at).toLocaleString() : '-'}</span>
</div>
</div>
<div className="rounded-card border border-border bg-surface p-5">
<h2 className="text-sm font-semibold text-foreground mb-3"></h2>
<p className="text-sm text-secondary leading-relaxed">
A
</p>
</div>
</div>
)
}
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useState } from 'react'
import { Loader2, Plus, Trash2, Users as UsersIcon, AlertCircle } from 'lucide-react'
import { api, Role, UserInfo } from '@/lib/api'
import { canDeleteUsers, roleLabel } from '@/lib/auth'
import { cn } from '@/lib/cn'
export function Users() {
const [users, setUsers] = useState<UserInfo[]>([])
const [roles, setRoles] = useState<Role[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [formOpen, setFormOpen] = useState(false)
const [form, setForm] = useState({ username: '', email: '', password: '', role: 'user' })
const fetchData = async () => {
setLoading(true)
try {
const [u, r] = await Promise.all([api.listUsers(), api.listRoles()])
setUsers(u)
setRoles(r)
setError('')
} catch (err: any) {
setError(err?.message || '加载失败')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchData()
}, [])
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
try {
await api.createUser({
username: form.username,
email: form.email || undefined,
password: form.password,
role: form.role,
})
setForm({ username: '', email: '', password: '', role: 'user' })
setFormOpen(false)
fetchData()
} catch (err: any) {
setError(err?.message || '创建失败')
}
}
const handleDelete = async (id: string) => {
if (!confirm('确定删除该用户?')) return
try {
await api.deleteUser(id)
fetchData()
} catch (err: any) {
setError(err?.message || '删除失败')
}
}
const handleStatusChange = async (user: UserInfo, status: string) => {
try {
await api.updateUser(user.id, { status })
fetchData()
} catch (err: any) {
setError(err?.message || '更新失败')
}
}
const currentUser = users.find((u) => u.username === JSON.parse(localStorage.getItem('user') || '{}')?.username)
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-btn bg-accent/10 text-accent">
<UsersIcon className="h-5 w-5" />
</div>
<h1 className="text-xl font-bold text-foreground"></h1>
</div>
<button
onClick={() => setFormOpen(!formOpen)}
className="inline-flex items-center gap-2 px-3 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors"
>
<Plus className="h-4 w-4" />
</button>
</div>
{error && (
<div className="flex items-start gap-2 rounded-btn border border-danger/30 bg-danger/10 px-3 py-2.5 text-xs text-danger">
<AlertCircle className="h-3.5 w-3.5 mt-px shrink-0" />
<span>{error}</span>
</div>
)}
{formOpen && (
<form
onSubmit={handleCreate}
className="rounded-card border border-border bg-surface p-4 grid grid-cols-1 md:grid-cols-5 gap-3"
>
<input
required
placeholder="用户名"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
<input
type="email"
placeholder="邮箱"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
<input
required
type="password"
placeholder="密码"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
/>
<select
value={form.role}
onChange={(e) => setForm({ ...form, role: e.target.value })}
className="rounded-input bg-base border border-border px-3 py-2 text-sm focus:outline-none focus:border-accent"
>
{roles.map((r) => (
<option key={r.id} value={r.name}>{roleLabel(r.name)}</option>
))}
</select>
<button
type="submit"
className="md:col-span-5 inline-flex items-center justify-center gap-2 px-4 py-2 rounded-btn bg-accent text-white text-sm font-medium hover:bg-accent/90 transition-colors"
>
</button>
</form>
)}
<div className="rounded-card border border-border bg-surface overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-elevated/50 text-secondary">
<tr>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-left font-medium"></th>
<th className="px-4 py-3 text-right font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-muted"></td>
</tr>
) : (
users.map((u) => (
<tr key={u.id} className="hover:bg-elevated/30">
<td className="px-4 py-3 text-foreground">{u.username}</td>
<td className="px-4 py-3">
<span className={cn('text-xs px-2 py-0.5 rounded', roleBadge(u.role))}>
{roleLabel(u.role)}
</span>
</td>
<td className="px-4 py-3">
<select
value={u.status}
onChange={(e) => handleStatusChange(u, e.target.value)}
disabled={u.id === currentUser?.id}
className="bg-base border border-border rounded-input px-2 py-1 text-xs disabled:opacity-50"
>
<option value="active"></option>
<option value="disabled"></option>
</select>
</td>
<td className="px-4 py-3 text-muted text-xs">
{new Date(u.created_at).toLocaleString()}
</td>
<td className="px-4 py-3 text-right">
{canDeleteUsers(currentUser?.role || 'user') && u.id !== currentUser?.id && (
<button
onClick={() => handleDelete(u.id)}
className="p-1.5 rounded-btn text-danger hover:bg-danger/10 transition-colors"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
function roleBadge(role: string) {
switch (role) {
case 'system_admin':
return 'bg-accent/10 text-accent'
case 'admin':
return 'bg-purple-500/10 text-purple-400'
case 'user':
return 'bg-bear/10 text-bear'
default:
return 'bg-muted/10 text-muted'
}
}
+35
View File
@@ -0,0 +1,35 @@
import { createBrowserRouter, Navigate } from 'react-router-dom'
import { Layout } from './components/Layout'
import { AuthGuard } from './components/AuthGuard'
import { Login } from './pages/Login'
import { Dashboard } from './pages/Dashboard'
import { Users } from './pages/Users'
import { Profile } from './pages/Profile'
export const router = createBrowserRouter([
{ path: '/login', element: <Login /> },
{
path: '/',
element: <Layout />,
children: [
{ index: true, element: <Dashboard /> },
{
path: 'users',
element: (
<AuthGuard allowedRoles={['admin', 'system_admin']} requireAuth>
<Users />
</AuthGuard>
),
},
{
path: 'profile',
element: (
<AuthGuard requireAuth>
<Profile />
</AuthGuard>
),
},
],
},
{ path: '*', element: <Navigate to="/" replace /> },
])
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+40
View File
@@ -0,0 +1,40 @@
import type { Config } from 'tailwindcss'
import animate from 'tailwindcss-animate'
export default {
darkMode: ['class'],
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
container: { center: true, padding: '1rem' },
extend: {
colors: {
base: 'hsl(var(--base) / <alpha-value>)',
surface: 'hsl(var(--surface) / <alpha-value>)',
elevated: 'hsl(var(--elevated) / <alpha-value>)',
border: 'hsl(var(--border) / <alpha-value>)',
foreground: 'hsl(var(--fg-primary) / <alpha-value>)',
secondary: 'hsl(var(--fg-secondary) / <alpha-value>)',
muted: 'hsl(var(--fg-muted) / <alpha-value>)',
accent: 'hsl(var(--accent) / <alpha-value>)',
bull: 'hsl(var(--bull) / <alpha-value>)',
bear: 'hsl(var(--bear) / <alpha-value>)',
warning: 'hsl(var(--warning) / <alpha-value>)',
danger: 'hsl(var(--danger) / <alpha-value>)',
},
fontFamily: {
sans: ['Inter', '"HarmonyOS Sans SC"', '"PingFang SC"', 'system-ui', 'sans-serif'],
mono: ['"JetBrains Mono"', '"IBM Plex Mono"', 'ui-monospace', 'monospace'],
},
borderRadius: {
card: '8px',
btn: '6px',
input: '4px',
dialog: '12px',
},
transitionTimingFunction: {
smooth: 'cubic-bezier(0.16, 1, 0.3, 1)',
},
},
},
plugins: [animate],
} satisfies Config
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
proxy: {
'/api': {
target: process.env.VITE_API_URL || 'http://localhost:3019',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
},
})