diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7245da7 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 2eac81b..c0fa83e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,14 @@ build/ # ===== uv ===== .uv/ +# ===== Go ===== +backend/stock-user-system +backend/tmp/ +*.exe +*.dll +*.so +*.dylib + # ===== Node / pnpm ===== node_modules/ .pnpm-store/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..545503d --- /dev/null +++ b/README.md @@ -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 会自动创建表并插入默认角色。 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..3a9ca2a --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go new file mode 100644 index 0000000..5f7b1e7 --- /dev/null +++ b/backend/cmd/api/main.go @@ -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) + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..12a04cc --- /dev/null +++ b/backend/go.mod @@ -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 +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..21e784c --- /dev/null +++ b/backend/go.sum @@ -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= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..87915aa --- /dev/null +++ b/backend/internal/config/config.go @@ -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 +} diff --git a/backend/internal/db/db.go b/backend/internal/db/db.go new file mode 100644 index 0000000..57b6402 --- /dev/null +++ b/backend/internal/db/db.go @@ -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 +} diff --git a/backend/internal/handlers/admin.go b/backend/internal/handlers/admin.go new file mode 100644 index 0000000..2c8f0b2 --- /dev/null +++ b/backend/internal/handlers/admin.go @@ -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) +} diff --git a/backend/internal/handlers/auth.go b/backend/internal/handlers/auth.go new file mode 100644 index 0000000..d79e71b --- /dev/null +++ b/backend/internal/handlers/auth.go @@ -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 +} diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go new file mode 100644 index 0000000..c29303e --- /dev/null +++ b/backend/internal/middleware/auth.go @@ -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 +} diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go new file mode 100644 index 0000000..dea9733 --- /dev/null +++ b/backend/internal/models/user.go @@ -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 +} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go new file mode 100644 index 0000000..a57925c --- /dev/null +++ b/backend/internal/routes/routes.go @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1158f63 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..df7a272 --- /dev/null +++ b/frontend/Dockerfile @@ -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;"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..d4f7568 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + A股工具 + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..9c06e55 --- /dev/null +++ b/frontend/nginx.conf @@ -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; + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d9d7bbd --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..9b6ff76 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1,4 @@ + + + A + diff --git a/frontend/src/components/AuthGuard.tsx b/frontend/src/components/AuthGuard.tsx new file mode 100644 index 0000000..c767f5b --- /dev/null +++ b/frontend/src/components/AuthGuard.tsx @@ -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(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 ( +
+ +
+ ) + } + + if (requireAuth && !user) { + return + } + + if (allowedRoles && user && !allowedRoles.includes(user.role)) { + return + } + + return <>{children} +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..9d705d5 --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -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(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 = ( + + ) + + return ( +
+
+
+
A
+ A股工作台 +
+ +
+
+
setMobileOpen(false)} /> +
{sidebar}
+
+
+ +
+
+ ) +} diff --git a/frontend/src/components/LoginForm.tsx b/frontend/src/components/LoginForm.tsx new file mode 100644 index 0000000..0259ff4 --- /dev/null +++ b/frontend/src/components/LoginForm.tsx @@ -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 ( + +
+
+
+ +
+

+ {mode === 'login' ? '登录账号' : '注册账号'} +

+

+ {mode === 'login' + ? '请输入用户名和密码进入系统。' + : '注册后默认获得用户权限。'} +

+
+ +
+
+
+ +
+ 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" + /> +
+ + {mode === 'register' && ( +
+
+ +
+ 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" + /> +
+ )} + +
+
+ +
+ 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" + /> +
+ + {error && ( +
+ + {error} +
+ )} + + +
+ +
+ +
+
+
+ ) +} + +// 简单内联 motion 组件,避免引入 framer-motion 依赖 +const motion = { + div: ({ children, className, ...props }: any) => { + return ( +
{ + 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} +
+ ) + }, +} diff --git a/frontend/src/components/ThemeProvider.tsx b/frontend/src/components/ThemeProvider.tsx new file mode 100644 index 0000000..d642c7a --- /dev/null +++ b/frontend/src/components/ThemeProvider.tsx @@ -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(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(() => { + try { + const saved = localStorage.getItem('theme') + if (saved === 'light' || saved === 'dark' || saved === 'system') return saved + } catch {} + return 'system' + }) + const [resolved, setResolved] = useState(() => 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 ( + + {children} + + ) +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext) + if (!ctx) { + throw new Error('useTheme must be used within ThemeProvider') + } + return ctx +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..dfb2c24 --- /dev/null +++ b/frontend/src/index.css @@ -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; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..ac792c8 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,119 @@ +const API_BASE = import.meta.env.VITE_API_BASE_URL || '' + +export interface ApiResponse { + 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( + path: string, + options: RequestInit = {}, +): Promise { + const url = `${API_BASE}${path}` + const token = getToken() + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + } + 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('/api/auth/register', { + method: 'POST', + body: JSON.stringify(body), + }), + + login: (body: { username: string; password: string }) => + request('/api/auth/login', { + method: 'POST', + body: JSON.stringify(body), + }), + + me: () => request('/api/auth/me'), + + logout: () => request<{ message: string }>('/api/auth/logout', { method: 'POST' }), + + listUsers: () => request('/api/admin/users'), + + createUser: (body: { username: string; email?: string; password: string; role?: string }) => + request('/api/admin/users', { + method: 'POST', + body: JSON.stringify(body), + }), + + updateUser: (id: string, body: Partial<{ email: string; role: string; status: string }>) => + request(`/api/admin/users/${id}`, { + method: 'PUT', + body: JSON.stringify(body), + }), + + deleteUser: (id: string) => + request<{ message: string }>(`/api/admin/users/${id}`, { method: 'DELETE' }), + + listRoles: () => request('/api/admin/roles'), +} diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts new file mode 100644 index 0000000..03e46da --- /dev/null +++ b/frontend/src/lib/auth.ts @@ -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 = { + 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' +} diff --git a/frontend/src/lib/cn.ts b/frontend/src/lib/cn.ts new file mode 100644 index 0000000..abba253 --- /dev/null +++ b/frontend/src/lib/cn.ts @@ -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)) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..57accb9 --- /dev/null +++ b/frontend/src/main.tsx @@ -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( + + + + + , +) diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..3782de2 --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -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 ( +
+
+
+ +
+
+

看板

+

欢迎回来,{user?.username || '游客'}

+
+
+ +
+ } + title="当前身份" + value={user ? roleLabel(user.role) : '游客'} + desc={user ? `用户:${user.username}` : '未登录,仅可查看公开信息'} + /> + } + title="公开信息" + value="已接入" + desc={publicInfo?.message || '加载中…'} + /> +
+ +
+

权限说明

+
    +
  • 系统管理员:可管理管理员、用户、系统配置
  • +
  • 管理员:可管理普通用户
  • +
  • 用户:可访问业务功能、修改个人资料
  • +
  • 游客:仅查看公开内容,不可操作
  • +
+
+
+ ) +} + +function Card({ + icon, + title, + value, + desc, +}: { + icon: React.ReactNode + title: string + value: string + desc: string +}) { + return ( +
+
+ {icon} + {title} +
+
{value}
+
{desc}
+
+ ) +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..63541af --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -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 ( +
+
+
+
+
+
+ +
+
+
A
+ A股工具 +
+
+ +
+ navigate('/', { replace: true })} /> +
+
+ ) +} diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..d15d026 --- /dev/null +++ b/frontend/src/pages/Profile.tsx @@ -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(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 ( +
+ +
+ ) + } + + return ( +
+
+
+ +
+

个人中心

+
+ + {error && ( +
+ + {error} +
+ )} + +
+
+ 用户名 + {user?.username} +
+
+ 角色 + {user ? roleLabel(user.role) : '-'} +
+
+ 邮箱 + {user?.email || '未设置'} +
+
+ 状态 + {user?.status === 'active' ? '启用' : '禁用'} +
+
+ 注册时间 + {user ? new Date(user.created_at).toLocaleString() : '-'} +
+
+ +
+

关于

+

+ 本系统为 A 股复盘工具的用户权限管理模块。不同角色拥有不同的操作权限,系统管理员可在「用户管理」中分配角色。 +

+
+
+ ) +} diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx new file mode 100644 index 0000000..b638571 --- /dev/null +++ b/frontend/src/pages/Users.tsx @@ -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([]) + const [roles, setRoles] = useState([]) + 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 ( +
+
+
+
+ +
+

用户管理

+
+ +
+ + {error && ( +
+ + {error} +
+ )} + + {formOpen && ( +
+ 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" + /> + 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" + /> + 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" + /> + + +
+ )} + +
+ + + + + + + + + + + + {loading ? ( + + + + ) : users.length === 0 ? ( + + + + ) : ( + users.map((u) => ( + + + + + + + + )) + )} + +
用户名角色状态创建时间操作
+ +
暂无用户
{u.username} + + {roleLabel(u.role)} + + + + + {new Date(u.created_at).toLocaleString()} + + {canDeleteUsers(currentUser?.role || 'user') && u.id !== currentUser?.id && ( + + )} +
+
+
+ ) +} + +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' + } +} diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx new file mode 100644 index 0000000..f87dda4 --- /dev/null +++ b/frontend/src/router.tsx @@ -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: }, + { + path: '/', + element: , + children: [ + { index: true, element: }, + { + path: 'users', + element: ( + + + + ), + }, + { + path: 'profile', + element: ( + + + + ), + }, + ], + }, + { path: '*', element: }, +]) diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tailwind.config.ts b/frontend/tailwind.config.ts new file mode 100644 index 0000000..d367068 --- /dev/null +++ b/frontend/tailwind.config.ts @@ -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) / )', + surface: 'hsl(var(--surface) / )', + elevated: 'hsl(var(--elevated) / )', + border: 'hsl(var(--border) / )', + foreground: 'hsl(var(--fg-primary) / )', + secondary: 'hsl(var(--fg-secondary) / )', + muted: 'hsl(var(--fg-muted) / )', + accent: 'hsl(var(--accent) / )', + bull: 'hsl(var(--bull) / )', + bear: 'hsl(var(--bear) / )', + warning: 'hsl(var(--warning) / )', + danger: 'hsl(var(--danger) / )', + }, + 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 diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..c20738e --- /dev/null +++ b/frontend/tsconfig.json @@ -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" }] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..97ede7e --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..56a7819 --- /dev/null +++ b/frontend/vite.config.ts @@ -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', + }, +})