搭建用户体系

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
+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
}