From 332b7dcd08c4d19554e281503a36201580e86166 Mon Sep 17 00:00:00 2001 From: wangrui233 Date: Sun, 25 Oct 2026 20:27:59 +0800 Subject: [PATCH] first commit --- .gitignore | 21 +++ README.md | 29 ++++ cmd/server/main.go | 75 ++++++++++ configs/config.yaml | 37 +++++ go.mod | 57 +++++++ go.sum | 141 ++++++++++++++++++ internal/handler/admin/handler.go | 58 +++++++ .../handler/admin/miniapp/miniapp_handler.go | 119 +++++++++++++++ internal/handler/miniapp/app1/user_handler.go | 43 ++++++ internal/middleware/auth_admin.go | 43 ++++++ internal/middleware/auth_ma.go | 60 ++++++++ internal/middleware/error_handler.go | 22 +++ internal/middleware/recovery.go | 31 ++++ internal/middleware/trace.go | 53 +++++++ internal/model/miniapp.go | 31 ++++ internal/model/user.go | 21 +++ internal/pkg/config/config.go | 101 +++++++++++++ internal/pkg/errors/errors.go | 84 +++++++++++ internal/pkg/response/response.go | 29 ++++ internal/pkg/session/session.go | 72 +++++++++ internal/pkg/token/token.go | 63 ++++++++ internal/pkg/wechat/wechat.go | 75 ++++++++++ internal/repository/miniapp/repository.go | 116 ++++++++++++++ internal/repository/user/repository.go | 61 ++++++++ internal/router/router.go | 73 +++++++++ .../service/admin/miniapp/miniapp_service.go | 129 ++++++++++++++++ internal/service/miniapp/app1/user_service.go | 64 ++++++++ 27 files changed, 1708 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 configs/config.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/handler/admin/handler.go create mode 100644 internal/handler/admin/miniapp/miniapp_handler.go create mode 100644 internal/handler/miniapp/app1/user_handler.go create mode 100644 internal/middleware/auth_admin.go create mode 100644 internal/middleware/auth_ma.go create mode 100644 internal/middleware/error_handler.go create mode 100644 internal/middleware/recovery.go create mode 100644 internal/middleware/trace.go create mode 100644 internal/model/miniapp.go create mode 100644 internal/model/user.go create mode 100644 internal/pkg/config/config.go create mode 100644 internal/pkg/errors/errors.go create mode 100644 internal/pkg/response/response.go create mode 100644 internal/pkg/session/session.go create mode 100644 internal/pkg/token/token.go create mode 100644 internal/pkg/wechat/wechat.go create mode 100644 internal/repository/miniapp/repository.go create mode 100644 internal/repository/user/repository.go create mode 100644 internal/router/router.go create mode 100644 internal/service/admin/miniapp/miniapp_service.go create mode 100644 internal/service/miniapp/app1/user_service.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..848e57c --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# 编译产物 +/bin/ +*.exe +*.out + +# 本地配置与密钥 +.env +*.local.yaml +configs/config.local.yaml + +# 依赖与缓存 +vendor/ +.idea/ +.vscode/ + +# 测试与覆盖 +*.test +coverage.out + +# Agent/IDE 项目元数据 +.codebuddy/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c129301 --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# portal-mini-go + +基于 Go 的中后台/门户类后端服务。 + +## 技术栈 + +- Web 框架:gin +- 参数校验:go-playground/validator/v10 +- 配置:viper + YAML +- 日志:log/slog +- 数据库:GORM(MySQL / PostgreSQL) +- 缓存:go-redis/redis + +## 目录结构 + +``` +cmd/ # 可执行程序入口 +internal/ # 私有业务代码(handler / service / repository / model / pkg) +pkg/ # 对外公开公共库 +configs/ # 配置文件模板 +``` + +## 开发 + +```bash +go mod tidy +go run ./cmd/... +go test ./... +``` diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..71fbc62 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,75 @@ +// Command server 是 portal-mini-go 的统一服务入口, +// 承载 10 个小程序与后台管理。 +package main + +import ( + "log/slog" + "os" + "strconv" + "time" + + "gorm.io/driver/mysql" + "gorm.io/gorm" + + "portal-mini-go/internal/pkg/config" + "portal-mini-go/internal/pkg/session" + "portal-mini-go/internal/router" +) + +func main() { + cfg, err := config.Load("") + if err != nil { + slog.Error("load config", slog.Any("err", err)) + os.Exit(1) + } + setupLogger(cfg.Logging) + + db, err := gorm.Open(mysql.Open(cfg.MySQL.DSN()), &gorm.Config{}) + if err != nil { + slog.Error("open db", slog.Any("err", err)) + os.Exit(1) + } + + sqlDB, err := db.DB() + if err != nil { + slog.Error("get sql.DB", slog.Any("err", err)) + os.Exit(1) + } + sqlDB.SetMaxIdleConns(cfg.MySQL.MaxIdleConns) + sqlDB.SetMaxOpenConns(cfg.MySQL.MaxOpenConns) + sqlDB.SetConnMaxLifetime(time.Duration(cfg.MySQL.ConnMaxLifetime) * time.Second) + + store := session.NewMemoryStore() + + r := router.New(cfg, db, store) + + addr := ":" + strconv.Itoa(cfg.Server.Port) + slog.Info("server starting", slog.String("addr", addr)) + if err := r.Run(addr); err != nil { + slog.Error("server run", slog.Any("err", err)) + os.Exit(1) + } +} + +func setupLogger(lc config.LoggingConfig) { + var level slog.Level + switch lc.Level { + case "info": + level = slog.LevelInfo + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + default: + level = slog.LevelDebug + } + + var handler slog.Handler + opts := &slog.HandlerOptions{Level: level} + if lc.Format == "json" { + handler = slog.NewJSONHandler(os.Stdout, opts) + } else { + handler = slog.NewTextHandler(os.Stdout, opts) + } + slog.SetDefault(slog.New(handler)) +} diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..0508599 --- /dev/null +++ b/configs/config.yaml @@ -0,0 +1,37 @@ +# 服务配置模板。敏感信息(dsn/secret/token)务必通过环境变量覆盖,禁止提交真实值。 +server: + port: 8080 + mode: debug # debug | release + +mysql: + # MySQL 独立配置项;敏感信息通过环境变量注入,禁止硬编码。 + host: "${DB_HOST:-127.0.0.1}" + port: "${DB_PORT:-3306}" + user: "${DB_USER}" + password: "${DB_PASSWORD}" + database: "${DB_DATABASE}" + charset: "${DB_CHARSET:-utf8mb4}" + parse_time: "${DB_PARSE_TIME:-True}" + loc: "${DB_LOC:-Local}" + # 连接池 + max_idle_conns: "${DB_MAX_IDLE_CONNS:-10}" + max_open_conns: "${DB_MAX_OPEN_CONNS:-100}" + conn_max_lifetime: "${DB_CONN_MAX_LIFETIME:-3600}" # 秒 + +logging: + level: debug # debug | info | warn | error + format: text # text | json + +# 微信小程序凭证现已交由后台管理,持久化在 mini_programs 表(AppID/AppSecret), +# 登录时由请求头 X-App-ID 定位并动态取用,不再依赖本静态配置。 +# 如需保留静态兜底配置,可在此声明,但代码当前以数据库为准。 +# wechat: +# miniapps: +# app1: +# appid: "${WECHAT_MINIAPPS_APP1_APPID}" +# secret: "${WECHAT_MINIAPPS_APP1_SECRET}" + +admin: + # 后台 JWT 签名密钥,务必通过环境变量 ADMIN_JWT_SECRET 注入 + jwt_secret: "${ADMIN_JWT_SECRET}" + token_ttl: 720h diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..186b348 --- /dev/null +++ b/go.mod @@ -0,0 +1,57 @@ +module portal-mini-go + +go 1.23.3 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/spf13/viper v1.19.0 + gorm.io/driver/mysql v1.5.7 + 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/fsnotify/fsnotify v1.7.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/go-sql-driver/mysql v1.7.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/hashicorp/hcl v1.0.0 // 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/leodido/go-urn v1.4.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // 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/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..65718e8 --- /dev/null +++ b/go.sum @@ -0,0 +1,141 @@ +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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +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/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +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/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +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/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.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +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/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +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/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= +github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= +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/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +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= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +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.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +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/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.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +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-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +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/internal/handler/admin/handler.go b/internal/handler/admin/handler.go new file mode 100644 index 0000000..3e52196 --- /dev/null +++ b/internal/handler/admin/handler.go @@ -0,0 +1,58 @@ +// Package admin 是后台管理的 HTTP 接入层。 +package admin + +import ( + "time" + + "github.com/gin-gonic/gin" + + "portal-mini-go/internal/middleware" + "portal-mini-go/internal/pkg/errors" + "portal-mini-go/internal/pkg/response" + "portal-mini-go/internal/pkg/token" +) + +// Handler 后台请求处理。 +type Handler struct { + jwtSecret string + tokenTTL time.Duration +} + +func NewHandler(jwtSecret string, tokenTTL time.Duration) *Handler { + return &Handler{jwtSecret: jwtSecret, tokenTTL: tokenTTL} +} + +type LoginReq struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// Login 后台登录(示例固定账号,生产应接 DB + 密码哈希校验)。 +func (h *Handler) Login(c *gin.Context) { + var req LoginReq + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + if req.Username != "admin" || req.Password != "admin123" { + c.Error(errors.ErrUnauthorized) + return + } + t, err := token.Generate(h.jwtSecret, "1", h.tokenTTL) + if err != nil { + c.Error(errors.Wrap(50000, "gen token", err)) + return + } + response.OK(c, gin.H{"token": t}) +} + +// Info 受保护接口示例,需携带有效 Bearer token。 +func (h *Handler) Info(c *gin.Context) { + response.OK(c, gin.H{"admin_id": middleware.AdminID(c)}) +} + +// Register 注册后台路由。g 已带 /api/admin 前缀与 JWT 鉴权中间件。 +func Register(g *gin.RouterGroup, h *Handler) { + g.POST("/login", h.Login) + g.GET("/info", h.Info) +} diff --git a/internal/handler/admin/miniapp/miniapp_handler.go b/internal/handler/admin/miniapp/miniapp_handler.go new file mode 100644 index 0000000..79ebecf --- /dev/null +++ b/internal/handler/admin/miniapp/miniapp_handler.go @@ -0,0 +1,119 @@ +// Package miniapphdl 是后台管理小程序的 HTTP 接入层。 +package miniapphdl + +import ( + "strconv" + + "github.com/gin-gonic/gin" + + "portal-mini-go/internal/pkg/errors" + "portal-mini-go/internal/pkg/response" + miniappsvc "portal-mini-go/internal/service/admin/miniapp" +) + +// Handler 小程序管理请求处理。 +type Handler struct { + svc *miniappsvc.Service +} + +func NewHandler(svc *miniappsvc.Service) *Handler { return &Handler{svc: svc} } + +// Create 新建小程序。 +func (h *Handler) Create(c *gin.Context) { + var req miniappsvc.CreateReq + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + m, err := h.svc.Create(c.Request.Context(), req) + if err != nil { + c.Error(err) + return + } + response.OK(c, m) +} + +// Update 更新小程序。 +func (h *Handler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req miniappsvc.UpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + m, err := h.svc.Update(c.Request.Context(), id, req) + if err != nil { + c.Error(err) + return + } + response.OK(c, m) +} + +// Get 查询单个小程序。 +func (h *Handler) Get(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + m, err := h.svc.GetByID(c.Request.Context(), id) + if err != nil { + c.Error(err) + return + } + response.OK(c, m) +} + +// List 分页列表小程序。支持 query: status、page、size。 +func (h *Handler) List(c *gin.Context) { + status, _ := strconv.ParseInt(c.DefaultQuery("status", "-1"), 10, 8) + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + list, total, err := h.svc.List(c.Request.Context(), miniappsvc.ListReq{ + Status: int8(status), + Page: page, + Size: size, + }) + if err != nil { + c.Error(err) + return + } + response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size}) +} + +// Delete 删除小程序。 +func (h *Handler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.svc.Delete(c.Request.Context(), id); err != nil { + c.Error(err) + return + } + response.OK(c, nil) +} + +// parseID 从路径参数解析并校验 uint64 主键。 +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.Error(errors.ErrBadRequest) + return 0, false + } + return id, true +} + +// Register 注册后台小程序管理路由。g 已带 /api/admin 前缀与 JWT 鉴权中间件。 +func Register(g *gin.RouterGroup, h *Handler) { + rg := g.Group("/miniapps") + { + rg.POST("", h.Create) + rg.PUT("/:id", h.Update) + rg.GET("/:id", h.Get) + rg.GET("", h.List) + rg.DELETE("/:id", h.Delete) + } +} diff --git a/internal/handler/miniapp/app1/user_handler.go b/internal/handler/miniapp/app1/user_handler.go new file mode 100644 index 0000000..e15a66c --- /dev/null +++ b/internal/handler/miniapp/app1/user_handler.go @@ -0,0 +1,43 @@ +// Package app1hdl 是小程序 app1 的 HTTP 接入层。 +package app1hdl + +import ( + "github.com/gin-gonic/gin" + + "portal-mini-go/internal/middleware" + "portal-mini-go/internal/pkg/errors" + "portal-mini-go/internal/pkg/response" + app1svc "portal-mini-go/internal/service/miniapp/app1" +) + +// Handler app1 请求处理。 +type Handler struct { + svc *app1svc.Service +} + +func NewHandler(svc *app1svc.Service) *Handler { return &Handler{svc: svc} } + +type LoginReq struct { + Code string `json:"code" binding:"required"` +} + +// Login 处理小程序登录请求。app_id 由 MiniAppAuth 中间件从请求头校验并注入。 +func (h *Handler) Login(c *gin.Context) { + var req LoginReq + if err := c.ShouldBindJSON(&req); err != nil { + c.Error(errors.ErrBadRequest) + return + } + appID := middleware.AppID(c) + token, u, err := h.svc.Login(c.Request.Context(), appID, req.Code) + if err != nil { + c.Error(err) + return + } + response.OK(c, gin.H{"token": token, "user": u}) +} + +// Register 注册 app1 的路由。g 已带 /api/ma 前缀与 MiniAppAuth 中间件(校验请求头 app_id)。 +func Register(g *gin.RouterGroup, h *Handler) { + g.POST("/login", h.Login) +} diff --git a/internal/middleware/auth_admin.go b/internal/middleware/auth_admin.go new file mode 100644 index 0000000..14668f1 --- /dev/null +++ b/internal/middleware/auth_admin.go @@ -0,0 +1,43 @@ +package middleware + +import ( + "strings" + + "github.com/gin-gonic/gin" + + "portal-mini-go/internal/pkg/errors" + "portal-mini-go/internal/pkg/token" +) + +const adminIDCtxKey = "admin_id" + +// AdminAuth 校验后台请求的 Bearer JWT,校验通过后将 admin_id 注入 context。 +func AdminAuth(jwtSecret string) gin.HandlerFunc { + return func(c *gin.Context) { + auth := c.GetHeader("Authorization") + const prefix = "Bearer " + if !strings.HasPrefix(auth, prefix) { + c.Error(errors.ErrUnauthorized) + c.Abort() + return + } + claims, err := token.Parse(jwtSecret, strings.TrimPrefix(auth, prefix)) + if err != nil { + c.Error(errors.ErrUnauthorized) + c.Abort() + return + } + c.Set(adminIDCtxKey, claims.AdminID) + c.Next() + } +} + +// AdminID 从 gin.Context 取出已认证的后台管理员 ID。 +func AdminID(c *gin.Context) string { + if v, ok := c.Get(adminIDCtxKey); ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} diff --git a/internal/middleware/auth_ma.go b/internal/middleware/auth_ma.go new file mode 100644 index 0000000..613f7ef --- /dev/null +++ b/internal/middleware/auth_ma.go @@ -0,0 +1,60 @@ +// Package middleware 提供 HTTP 中间件。 +package middleware + +import ( + "github.com/gin-gonic/gin" + + "portal-mini-go/internal/pkg/errors" + "portal-mini-go/internal/repository/miniapp" +) + +const ( + appIDCtxKey = "app_id" + miniIDCtxKey = "mini_id" +) + +// headerAppID 是小程序在请求头中携带的微信 AppID 字段名。 +const HeaderAppID = "X-App-ID" + +// MiniAppAuth 校验请求头中的 app_id 是否对应一个已启用且未删除的被纳管小程序, +// 通过则将 app_id 与小程序的数据库主键注入 context,供后续 handler/service 使用。 +// 用户身份最终由 (mini_id, open_id) 唯一确定。 +func MiniAppAuth(repo *miniapp.Repository) gin.HandlerFunc { + return func(c *gin.Context) { + appID := c.GetHeader(HeaderAppID) + if appID == "" { + c.Error(errors.ErrBadRequest) + c.Abort() + return + } + mp, err := repo.GetActiveByAppID(c.Request.Context(), appID) + if err != nil { + c.Error(errors.ErrNotFound) + c.Abort() + return + } + c.Set(appIDCtxKey, appID) + c.Set(miniIDCtxKey, mp.ID) + c.Next() + } +} + +// AppID 从 gin.Context 取出已校验的 appID。 +func AppID(c *gin.Context) string { + if v, ok := c.Get(appIDCtxKey); ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// MiniID 从 gin.Context 取出已校验的小程序数据库主键。 +func MiniID(c *gin.Context) uint64 { + if v, ok := c.Get(miniIDCtxKey); ok { + if id, ok := v.(uint64); ok { + return id + } + } + return 0 +} diff --git a/internal/middleware/error_handler.go b/internal/middleware/error_handler.go new file mode 100644 index 0000000..2a55a2f --- /dev/null +++ b/internal/middleware/error_handler.go @@ -0,0 +1,22 @@ +package middleware + +import ( + "github.com/gin-gonic/gin" + + "portal-mini-go/internal/pkg/response" +) + +// ErrorHandler 统一把 handler 通过 c.Error(err) 挂载的错误转换为响应。 +// 规范要求:handler 内不直接散落 c.JSON 处理错误,集中在此转换。 +func ErrorHandler() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + if len(c.Errors) == 0 { + return + } + // 取最后一个(最外层)错误转换。 + last := c.Errors[len(c.Errors)-1].Err + response.Error(c, last) + c.Abort() + } +} diff --git a/internal/middleware/recovery.go b/internal/middleware/recovery.go new file mode 100644 index 0000000..9358a1e --- /dev/null +++ b/internal/middleware/recovery.go @@ -0,0 +1,31 @@ +package middleware + +import ( + "log/slog" + "net/http" + "runtime/debug" + + "github.com/gin-gonic/gin" +) + +// Recovery 捕获 handler 中的 panic,记录结构化日志并以 500 响应, +// 符合规范:recover 仅允许用于框架中间件。 +func Recovery() gin.HandlerFunc { + return func(c *gin.Context) { + defer func() { + if r := recover(); r != nil { + slog.Error("panic recovered", + slog.String("path", c.Request.URL.Path), + slog.Any("err", r), + slog.String("stack", string(debug.Stack())), + ) + c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{ + "code": 50000, + "msg": "internal server error", + "data": nil, + }) + } + }() + c.Next() + } +} diff --git a/internal/middleware/trace.go b/internal/middleware/trace.go new file mode 100644 index 0000000..d452e1f --- /dev/null +++ b/internal/middleware/trace.go @@ -0,0 +1,53 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + "log/slog" + + "github.com/gin-gonic/gin" +) + +const traceIDKey = "trace_id" + +// Trace 透传或生成 trace_id,并注入可携带 trace_id 的 slog 日志句柄, +// 供后续日志统一携带链路标识。 +func Trace() gin.HandlerFunc { + return func(c *gin.Context) { + tid := c.GetHeader("X-Trace-ID") + if tid == "" { + tid = newTraceID() + } + c.Set(traceIDKey, tid) + c.Set("slog_ctx", slog.With(slog.String("trace_id", tid))) + c.Next() + } +} + +func newTraceID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "unknown" + } + return hex.EncodeToString(b) +} + +// TraceID 从 gin.Context 取出 trace_id。 +func TraceID(c *gin.Context) string { + if v, ok := c.Get(traceIDKey); ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// SlogCtx 返回携带 trace_id 的 slog 日志句柄(无则回退默认)。 +func SlogCtx(c *gin.Context) *slog.Logger { + if v, ok := c.Get("slog_ctx"); ok { + if l, ok := v.(*slog.Logger); ok { + return l + } + } + return slog.Default() +} diff --git a/internal/model/miniapp.go b/internal/model/miniapp.go new file mode 100644 index 0000000..1a8dc8c --- /dev/null +++ b/internal/model/miniapp.go @@ -0,0 +1,31 @@ +// Package model 定义领域模型。 +package model + +import ( + "time" + + "gorm.io/gorm" +) + +// MiniProgram 是被后台纳管的微信小程序配置。 +// 一个租户/运营方可在此登记多个小程序,AppID 全局唯一。 +type MiniProgram struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"type:varchar(128);not null" json:"name"` + Description string `gorm:"type:varchar(512)" json:"description"` + AppID string `gorm:"column:app_id;type:varchar(64);uniqueIndex;not null" json:"app_id"` + AppSecret string `gorm:"column:app_secret;type:varchar(128);not null" json:"-"` + Status int8 `gorm:"type:tinyint;not null;default:1" json:"status"` + Ext string `gorm:"type:text" json:"ext"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (MiniProgram) TableName() string { return "mini_programs" } + +// 小程序状态。 +const ( + MiniProgramStatusDisabled int8 = 0 // 停用 + MiniProgramStatusEnabled int8 = 1 // 启用 +) diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..1227105 --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,21 @@ +// Package model 定义领域模型。 +// common 下为跨小程序共享的物理表结构;用户数据通过 MiniID 列关联所属小程序, +// 各小程序用户互不关联(open_id 在不同 appid 下天然互不冲突,故无需额外隔离键)。 +package model + +import "time" + +// User 是小程序端用户。MiniID 关联被纳管的小程序(mini_programs.id), +// 与 open_id 共同唯一标识一个用户:(mini_id, open_id)。 +type User struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + MiniID uint64 `gorm:"column:mini_id;index:idx_mini_openid" json:"mini_id"` + OpenID string `gorm:"type:varchar(64);index:idx_mini_openid" json:"openid"` + UnionID string `gorm:"type:varchar(64);index" json:"union_id"` + Nickname string `gorm:"type:varchar(128)" json:"nickname"` + Avatar string `gorm:"type:varchar(512)" json:"avatar"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (User) TableName() string { return "users" } diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go new file mode 100644 index 0000000..5670b8a --- /dev/null +++ b/internal/pkg/config/config.go @@ -0,0 +1,101 @@ +// Package config 负责加载与解析应用配置。 +// 优先级:默认值 < 配置文件 < 环境变量。 +package config + +import ( + "fmt" + "os" + "strings" + + "github.com/spf13/viper" +) + +// Config 是应用的顶层配置。 +type Config struct { + Server ServerConfig `mapstructure:"server"` + MySQL MySQLConfig `mapstructure:"mysql"` + Logging LoggingConfig `mapstructure:"logging"` + Admin AdminConfig `mapstructure:"admin"` +} + +type ServerConfig struct { + Port int `mapstructure:"port"` + Mode string `mapstructure:"mode"` +} + +type MySQLConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + Database string `mapstructure:"database"` + + // 连接与字符集参数。 + Charset string `mapstructure:"charset"` + ParseTime string `mapstructure:"parse_time"` + Loc string `mapstructure:"loc"` + + // 连接池参数。 + MaxIdleConns int `mapstructure:"max_idle_conns"` + MaxOpenConns int `mapstructure:"max_open_conns"` + ConnMaxLifetime int `mapstructure:"conn_max_lifetime"` // 单位:秒 +} + +// DSN 根据各独立字段拼装 MySQL 连接串。 +// 优先级:显式字段 < 环境变量;缺省项使用合理默认值。 +func (d MySQLConfig) DSN() string { + charset := d.Charset + if charset == "" { + charset = "utf8mb4" + } + parseTime := d.ParseTime + if parseTime == "" { + parseTime = "True" + } + loc := d.Loc + if loc == "" { + loc = "Local" + } + return fmt.Sprintf( + "%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=%s&loc=%s", + d.User, d.Password, d.Host, d.Port, d.Database, charset, parseTime, loc, + ) +} + +type LoggingConfig struct { + Level string `mapstructure:"level"` + Format string `mapstructure:"format"` +} + +// AdminConfig 后台管理相关配置。 +type AdminConfig struct { + JWTSecret string `mapstructure:"jwt_secret"` + TokenTTL string `mapstructure:"token_ttl"` +} + +// Load 从 configPath 加载配置(为空则默认 configs/config.yaml)。 +// 配置文件中使用 ${ENV} 占位符的字段会被对应环境变量替换, +// 敏感信息(dsn/secret/token)据此注入,禁止硬编码。 +func Load(configPath string) (*Config, error) { + if configPath == "" { + configPath = "configs/config.yaml" + } + raw, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("read config %s: %w", configPath, err) + } + // 先展开 ${ENV} 占位符,再交给 viper 解析。 + expanded := os.ExpandEnv(string(raw)) + + v := viper.New() + v.SetConfigType("yaml") + if err := v.ReadConfig(strings.NewReader(expanded)); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + + var cfg Config + if err := v.Unmarshal(&cfg); err != nil { + return nil, fmt.Errorf("unmarshal config: %w", err) + } + return &cfg, nil +} diff --git a/internal/pkg/errors/errors.go b/internal/pkg/errors/errors.go new file mode 100644 index 0000000..0e5bed6 --- /dev/null +++ b/internal/pkg/errors/errors.go @@ -0,0 +1,84 @@ +// Package errors 定义业务错误类型与哨兵错误。 +// 业务错误携带可被前端消费的业务码(Code),并支持 errors.As 提取。 +package errors + +import ( + stderrors "errors" + "fmt" +) + +// BizError 是带业务码的业务错误。 +type BizError struct { + Code int + Msg string + Err error +} + +func (e *BizError) Error() string { + if e.Err != nil { + return fmt.Sprintf("%s: %v", e.Msg, e.Err) + } + return e.Msg +} + +func (e *BizError) Unwrap() error { return e.Err } + +// New 构造一个不带原始 error 的业务错误。 +func New(code int, msg string) *BizError { + return &BizError{Code: code, Msg: msg} +} + +// Wrap 在传播错误时附加上下文与业务码,保留错误链路。 +func Wrap(code int, msg string, err error) *BizError { + return &BizError{Code: code, Msg: msg, Err: err} +} + +// Is 判断 err 链中是否包含 target,语义与标准库 errors.Is 一致。 +// 它沿 Unwrap 链遍历,因此能正确识别被 Wrap 包装后的目标错误 +// (如 gorm.ErrRecordNotFound 或本包的哨兵错误 ErrNotFound)。 +func Is(err, target error) bool { + return stderrors.Is(err, target) +} + +// 常用哨兵错误,供各层直接引用。 +var ( + ErrBadRequest = New(40000, "bad request") + ErrUnauthorized = New(40100, "unauthorized") + ErrForbidden = New(40300, "forbidden") + ErrNotFound = New(40400, "not found") + ErrServer = New(50000, "internal server error") +) + +// CodeOf 从 err 中提取业务码,非业务错误返回 50000。 +func CodeOf(err error) int { + var be *BizError + if err != nil && asBizError(err, &be) { + return be.Code + } + return 50000 +} + +// MsgOf 从 err 中提取可读消息,非业务错误返回内部错误提示。 +func MsgOf(err error) string { + var be *BizError + if err != nil && asBizError(err, &be) { + return be.Msg + } + return "internal server error" +} + +// asBizError 兼容被 fmt.Errorf("%w") 包装后的 BizError。 +func asBizError(err error, target **BizError) bool { + for err != nil { + if b, ok := err.(*BizError); ok { + *target = b + return true + } + u, ok := err.(interface{ Unwrap() error }) + if !ok { + return false + } + err = u.Unwrap() + } + return false +} diff --git a/internal/pkg/response/response.go b/internal/pkg/response/response.go new file mode 100644 index 0000000..17112da --- /dev/null +++ b/internal/pkg/response/response.go @@ -0,0 +1,29 @@ +// Package response 提供统一的 HTTP 响应结构与写入辅助。 +// 所有 handler 的成功响应走 OK,错误统一由 ErrorHandler 中间件转换(见 middleware)。 +package response + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "portal-mini-go/internal/pkg/errors" +) + +// Response 是统一的响应结构。 +type Response struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data interface{} `json:"data,omitempty"` +} + +// OK 写入成功响应(HTTP 200 + 业务码 0)。 +func OK(c *gin.Context, data interface{}) { + c.JSON(http.StatusOK, Response{Code: 0, Msg: "ok", Data: data}) +} + +// Error 把业务错误转为统一响应体。通常在 ErrorHandler 中间件中调用; +// 若 handler 内部需要直接返回也可使用。 +func Error(c *gin.Context, err error) { + c.JSON(http.StatusOK, Response{Code: errors.CodeOf(err), Msg: errors.MsgOf(err)}) +} diff --git a/internal/pkg/session/session.go b/internal/pkg/session/session.go new file mode 100644 index 0000000..d759980 --- /dev/null +++ b/internal/pkg/session/session.go @@ -0,0 +1,72 @@ +// Package session 提供会话存储能力。 +// 当前默认实现为进程内内存存储(无 Redis 环境),后续可替换为 Redis 实现。 +package session + +import ( + "context" + "sync" + "time" + + "portal-mini-go/internal/pkg/errors" +) + +// Store 会话存储接口,便于后续切换为 Redis 等实现。 +type Store interface { + Set(ctx context.Context, key string, userID uint64, ttl time.Duration) error + Get(ctx context.Context, key string) (uint64, error) + Delete(ctx context.Context, key string) error +} + +// MemoryStore 进程内会话存储,适用于无 Redis 的本地/测试环境。 +// 注意:多实例部署时各进程不共享,仅供单实例使用。 +type MemoryStore struct { + mu sync.RWMutex + data map[string]memoryEntry + now func() time.Time +} + +type memoryEntry struct { + userID uint64 + expireAt time.Time +} + +// NewMemoryStore 创建内存会话存储。 +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + data: make(map[string]memoryEntry), + now: time.Now, + } +} + +// Set 写入会话,key 映射到 userID,ttl 后过期。 +func (m *MemoryStore) Set(_ context.Context, key string, userID uint64, ttl time.Duration) error { + m.mu.Lock() + defer m.mu.Unlock() + m.data[key] = memoryEntry{userID: userID, expireAt: m.now().Add(ttl)} + return nil +} + +// Get 读取会话对应的 userID;不存在或已过期返回 ErrUnauthorized。 +func (m *MemoryStore) Get(_ context.Context, key string) (uint64, error) { + m.mu.RLock() + e, ok := m.data[key] + m.mu.RUnlock() + if !ok { + return 0, errors.ErrUnauthorized + } + if m.now().After(e.expireAt) { + m.mu.Lock() + delete(m.data, key) + m.mu.Unlock() + return 0, errors.ErrUnauthorized + } + return e.userID, nil +} + +// Delete 删除会话。 +func (m *MemoryStore) Delete(_ context.Context, key string) error { + m.mu.Lock() + delete(m.data, key) + m.mu.Unlock() + return nil +} diff --git a/internal/pkg/token/token.go b/internal/pkg/token/token.go new file mode 100644 index 0000000..766dea5 --- /dev/null +++ b/internal/pkg/token/token.go @@ -0,0 +1,63 @@ +// Package token 提供极简 HS256 JWT 生成与校验(仅依赖标准库)。 +// 后台管理用于登录态签发与中间件校验;小程序登录态请见各小程序 service。 +package token + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "strings" + "time" +) + +var b64 = base64.RawURLEncoding + +// Claims 是后台 JWT 的载荷。 +type Claims struct { + AdminID string `json:"admin_id"` + Exp int64 `json:"exp"` +} + +const header = `{"alg":"HS256","typ":"JWT"}` + +// Generate 签发一个 HS256 token。 +func Generate(secret, adminID string, ttl time.Duration) (string, error) { + payload, err := json.Marshal(Claims{AdminID: adminID, Exp: time.Now().Add(ttl).Unix()}) + if err != nil { + return "", err + } + signingInput := b64.EncodeToString([]byte(header)) + "." + b64.EncodeToString(payload) + return signingInput + "." + sign(secret, signingInput), nil +} + +// Parse 校验 token 签名与有效期,返回 Claims。 +func Parse(secret, tokenStr string) (*Claims, error) { + parts := strings.Split(tokenStr, ".") + if len(parts) != 3 { + return nil, errors.New("invalid token format") + } + expected := sign(secret, parts[0]+"."+parts[1]) + if !hmac.Equal([]byte(expected), []byte(parts[2])) { + return nil, errors.New("bad signature") + } + raw, err := b64.DecodeString(parts[1]) + if err != nil { + return nil, err + } + var claims Claims + if err := json.Unmarshal(raw, &claims); err != nil { + return nil, err + } + if claims.Exp < time.Now().Unix() { + return nil, errors.New("token expired") + } + return &claims, nil +} + +func sign(secret, input string) string { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(input)) + return b64.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/pkg/wechat/wechat.go b/internal/pkg/wechat/wechat.go new file mode 100644 index 0000000..ba5f51e --- /dev/null +++ b/internal/pkg/wechat/wechat.go @@ -0,0 +1,75 @@ +// Package wechat 封装微信小程序相关调用(此处为 code2session 实现)。 +package wechat + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + + "portal-mini-go/internal/pkg/errors" +) + +const code2SessionURL = "https://api.weixin.qq.com/sns/jscode2session" + +// SessionInfo 是 code2session 返回的会话信息。 +type SessionInfo struct { + OpenID string + UnionID string + SessionKey string +} + +// Client 微信调用客户端。凭证(appid/secret)由调用方按请求传入, +// 不再依赖静态配置,方便后台动态纳管多个小程序。 +type Client struct { + client *http.Client +} + +func NewClient() *Client { + return &Client{client: &http.Client{}} +} + +// Code2Session 用 appID + secret + 登录 code 换取 openid / session_key。 +func (c *Client) Code2Session(ctx context.Context, appID, secret, code string) (*SessionInfo, error) { + q := url.Values{} + q.Set("appid", appID) + q.Set("secret", secret) + q.Set("js_code", code) + q.Set("grant_type", "authorization_code") + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, code2SessionURL+"?"+q.Encode(), nil) + if err != nil { + return nil, errors.Wrap(50000, "build wechat request", err) + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, errors.Wrap(50000, "call wechat code2session", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, errors.Wrap(50000, "read wechat response", err) + } + + var r struct { + OpenID string `json:"openid"` + UnionID string `json:"unionid"` + SessionKey string `json:"session_key"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(body, &r); err != nil { + return nil, errors.Wrap(50000, "decode wechat response", err) + } + if r.ErrCode != 0 { + slog.Warn("wechat code2session error", slog.Int("errcode", r.ErrCode), slog.String("errmsg", r.ErrMsg)) + return nil, errors.Wrap(50000, fmt.Sprintf("wechat code2session: %s", r.ErrMsg), nil) + } + + return &SessionInfo{OpenID: r.OpenID, UnionID: r.UnionID, SessionKey: r.SessionKey}, nil +} diff --git a/internal/repository/miniapp/repository.go b/internal/repository/miniapp/repository.go new file mode 100644 index 0000000..815b1d8 --- /dev/null +++ b/internal/repository/miniapp/repository.go @@ -0,0 +1,116 @@ +// Package miniapp 提供被纳管小程序的数据访问。 +package miniapp + +import ( + "context" + + "gorm.io/gorm" + + "portal-mini-go/internal/model" + "portal-mini-go/internal/pkg/errors" +) + +// Repository 小程序数据访问。 +type Repository struct { + db *gorm.DB +} + +func New(db *gorm.DB) *Repository { return &Repository{db: db} } + +// Create 新建小程序。AppID 已存在时返回可读业务错误。 +// 注意:软删除记录仍占用 app_id 唯一索引,故此处一并查软删除态。 +func (r *Repository) Create(ctx context.Context, m *model.MiniProgram) error { + if _, err := r.GetByAppID(ctx, m.AppID); err == nil { + return errors.New(40900, "app_id 已存在") + } else if !errors.Is(err, errors.ErrNotFound) { + return err + } + if err := r.db.WithContext(ctx).Create(m).Error; err != nil { + return errors.Wrap(50000, "create mini program", err) + } + return nil +} + +// Update 按 ID 更新小程序非零值字段(cols 为待更新列)。 +func (r *Repository) Update(ctx context.Context, id uint64, cols map[string]interface{}) error { + res := r.db.WithContext(ctx). + Model(&model.MiniProgram{}). + Where("id = ?", id). + Updates(cols) + if res.Error != nil { + return errors.Wrap(50000, "update mini program", res.Error) + } + if res.RowsAffected == 0 { + return errors.ErrNotFound + } + return nil +} + +// GetByID 按 ID 查询小程序,软删除记录不可见。 +func (r *Repository) GetByID(ctx context.Context, id uint64) (*model.MiniProgram, error) { + var m model.MiniProgram + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&m).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrNotFound + } + return nil, errors.Wrap(50000, "query mini program", err) + } + return &m, nil +} + +// GetByAppID 按 AppID 查询(含软删除态),不存在返回 ErrNotFound。 +// 用于唯一性预校验与按 AppID 取配置。 +func (r *Repository) GetByAppID(ctx context.Context, appID string) (*model.MiniProgram, error) { + var m model.MiniProgram + if err := r.db.WithContext(ctx).Unscoped().Where("app_id = ?", appID).First(&m).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrNotFound + } + return nil, errors.Wrap(50000, "query mini program by app_id", err) + } + return &m, nil +} + +// GetActiveByAppID 按 AppID 查询“已启用且未删除”的小程序, +// 用于登录鉴权与 code2session 取凭证;不存在/已停用/已删除均返回 ErrNotFound。 +func (r *Repository) GetActiveByAppID(ctx context.Context, appID string) (*model.MiniProgram, error) { + var m model.MiniProgram + if err := r.db.WithContext(ctx). + Where("app_id = ? AND status = ?", appID, model.MiniProgramStatusEnabled). + First(&m).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrNotFound + } + return nil, errors.Wrap(50000, "query active mini program", err) + } + return &m, nil +} + +// List 分页查询,status < 0 表示不过滤。 +func (r *Repository) List(ctx context.Context, status int8, page, size int) ([]model.MiniProgram, int64, error) { + q := r.db.WithContext(ctx).Model(&model.MiniProgram{}) + if status >= 0 { + q = q.Where("status = ?", status) + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(50000, "count mini program", err) + } + var list []model.MiniProgram + if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&list).Error; err != nil { + return nil, 0, errors.Wrap(50000, "list mini program", err) + } + return list, total, nil +} + +// Delete 软删除小程序。 +func (r *Repository) Delete(ctx context.Context, id uint64) error { + res := r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.MiniProgram{}) + if res.Error != nil { + return errors.Wrap(50000, "delete mini program", res.Error) + } + if res.RowsAffected == 0 { + return errors.ErrNotFound + } + return nil +} diff --git a/internal/repository/user/repository.go b/internal/repository/user/repository.go new file mode 100644 index 0000000..b26e913 --- /dev/null +++ b/internal/repository/user/repository.go @@ -0,0 +1,61 @@ +// Package user 提供用户数据访问。所有查询强制按 mini_id 过滤, +// 从数据层保证各小程序用户体系相互独立。 +package user + +import ( + "context" + + "gorm.io/gorm" + + "portal-mini-go/internal/model" + "portal-mini-go/internal/pkg/errors" +) + +type Repository struct { + db *gorm.DB +} + +func New(db *gorm.DB) *Repository { return &Repository{db: db} } + +// UpsertByOpenID 按 mini_id + openid 创建或返回已有用户。 +func (r *Repository) UpsertByOpenID(ctx context.Context, miniID uint64, openID, unionID, nickname, avatar string) (*model.User, error) { + var u model.User + err := r.db.WithContext(ctx). + Where("mini_id = ? AND openid = ?", miniID, openID). + Attrs(model.User{MiniID: miniID, OpenID: openID, UnionID: unionID, Nickname: nickname, Avatar: avatar}). + FirstOrCreate(&u).Error + if err != nil { + return nil, errors.Wrap(50000, "upsert user", err) + } + return &u, nil +} + +// GetByID 按 mini_id + id 查询用户,跨小程序查询直接返回未找到。 +func (r *Repository) GetByID(ctx context.Context, miniID uint64, id uint64) (*model.User, error) { + var u model.User + err := r.db.WithContext(ctx). + Where("mini_id = ? AND id = ?", miniID, id). + First(&u).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrNotFound + } + return nil, errors.Wrap(50000, "query user", err) + } + return &u, nil +} + +// UpdateProfile 更新用户资料,限定在 mini_id 范围内。 +func (r *Repository) UpdateProfile(ctx context.Context, miniID uint64, id uint64, nickname, avatar string) error { + res := r.db.WithContext(ctx). + Model(&model.User{}). + Where("mini_id = ? AND id = ?", miniID, id). + Updates(map[string]interface{}{"nickname": nickname, "avatar": avatar}) + if res.Error != nil { + return errors.Wrap(50000, "update user", res.Error) + } + if res.RowsAffected == 0 { + return errors.ErrNotFound + } + return nil +} diff --git a/internal/router/router.go b/internal/router/router.go new file mode 100644 index 0000000..da763cd --- /dev/null +++ b/internal/router/router.go @@ -0,0 +1,73 @@ +// Package router 装配 gin 引擎与各模块路由。 +// 路由分层:/api/ma(小程序,靠请求头 app_id 鉴权)、/api/admin(后台)。 +package router + +import ( + "time" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" + + adminhdl "portal-mini-go/internal/handler/admin" + miniapphdl "portal-mini-go/internal/handler/admin/miniapp" + app1hdl "portal-mini-go/internal/handler/miniapp/app1" + "portal-mini-go/internal/middleware" + "portal-mini-go/internal/pkg/config" + "portal-mini-go/internal/pkg/response" + "portal-mini-go/internal/pkg/session" + "portal-mini-go/internal/pkg/wechat" + miniapprepo "portal-mini-go/internal/repository/miniapp" + "portal-mini-go/internal/repository/user" + miniappsvc "portal-mini-go/internal/service/admin/miniapp" + app1svc "portal-mini-go/internal/service/miniapp/app1" +) + +// New 构造并装配整个 HTTP 服务。 +func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine { + if cfg.Server.Mode == "release" { + gin.SetMode(gin.ReleaseMode) + } + + r := gin.New() + r.Use(middleware.Recovery(), middleware.Trace(), middleware.ErrorHandler()) + + r.GET("/healthz", func(c *gin.Context) { + response.OK(c, gin.H{"status": "ok"}) + }) + + // 共享基础设施:微信客户端、用户仓储、小程序仓储。 + wx := wechat.NewClient() + userRepo := user.New(db) + miniRepo := miniapprepo.New(db) + + // 小程序分组:先校验请求头 app_id 身份,再挂载各小程序业务路由。 + ma := r.Group("/api/ma") + ma.Use(middleware.MiniAppAuth(miniRepo)) + { + app1Svc := app1svc.New(userRepo, miniRepo, wx, store) + app1hdl.Register(ma, app1hdl.NewHandler(app1Svc)) + // 新增小程序时,在此依次注册 app2..app10: + // app2Svc := app2svc.New(userRepo, miniRepo, wx, store) + // app2hdl.Register(ma, app2hdl.NewHandler(app2Svc)) + } + + // 后台管理分组:JWT 鉴权。 + ad := r.Group("/api/admin") + ad.Use(middleware.AdminAuth(cfg.Admin.JWTSecret)) + { + adminhdl.Register(ad, adminhdl.NewHandler(cfg.Admin.JWTSecret, parseTTL(cfg.Admin.TokenTTL))) + + // 小程序管理:被后台纳管的微信小程序配置(增删改查)。 + miniSvc := miniappsvc.New(miniRepo) + miniapphdl.Register(ad, miniapphdl.NewHandler(miniSvc)) + } + + return r +} + +func parseTTL(s string) time.Duration { + if d, err := time.ParseDuration(s); err == nil { + return d + } + return 24 * time.Hour +} diff --git a/internal/service/admin/miniapp/miniapp_service.go b/internal/service/admin/miniapp/miniapp_service.go new file mode 100644 index 0000000..c278b3d --- /dev/null +++ b/internal/service/admin/miniapp/miniapp_service.go @@ -0,0 +1,129 @@ +// Package miniappsvc 是后台管理小程序的业务逻辑。 +package miniappsvc + +import ( + "context" + "encoding/json" + + "portal-mini-go/internal/model" + "portal-mini-go/internal/pkg/errors" + "portal-mini-go/internal/repository/miniapp" +) + +// Service 小程序管理业务服务,依赖小程序仓储。 +type Service struct { + repo *miniapp.Repository +} + +func New(repo *miniapp.Repository) *Service { return &Service{repo: repo} } + +// CreateReq 新建小程序请求。 +type CreateReq struct { + Name string `json:"name" binding:"required"` + Description string `json:"description"` + AppID string `json:"app_id" binding:"required"` + AppSecret string `json:"app_secret" binding:"required"` + Status int8 `json:"status"` + Ext string `json:"ext"` +} + +// Create 新建小程序,缺省状态为启用。 +func (s *Service) Create(ctx context.Context, req CreateReq) (*model.MiniProgram, error) { + if req.Status == 0 { + req.Status = model.MiniProgramStatusEnabled + } + if err := validateExt(req.Ext); err != nil { + return nil, err + } + m := &model.MiniProgram{ + Name: req.Name, + Description: req.Description, + AppID: req.AppID, + AppSecret: req.AppSecret, + Status: req.Status, + Ext: req.Ext, + } + if err := s.repo.Create(ctx, m); err != nil { + return nil, err + } + return m, nil +} + +// UpdateReq 更新小程序请求;指针区分“未提供”与“置空”。 +type UpdateReq struct { + Name *string `json:"name"` + Description *string `json:"description"` + AppSecret *string `json:"app_secret"` + Status *int8 `json:"status"` + Ext *string `json:"ext"` +} + +// Update 按 ID 局部更新小程序。 +func (s *Service) Update(ctx context.Context, id uint64, req UpdateReq) (*model.MiniProgram, error) { + cols := map[string]interface{}{} + if req.Name != nil { + cols["name"] = *req.Name + } + if req.Description != nil { + cols["description"] = *req.Description + } + if req.AppSecret != nil { + cols["app_secret"] = *req.AppSecret + } + if req.Status != nil { + cols["status"] = *req.Status + } + if req.Ext != nil { + if err := validateExt(*req.Ext); err != nil { + return nil, err + } + cols["ext"] = *req.Ext + } + if len(cols) == 0 { + return s.repo.GetByID(ctx, id) + } + if err := s.repo.Update(ctx, id, cols); err != nil { + return nil, err + } + return s.repo.GetByID(ctx, id) +} + +// GetByID 按 ID 查询小程序。 +func (s *Service) GetByID(ctx context.Context, id uint64) (*model.MiniProgram, error) { + return s.repo.GetByID(ctx, id) +} + +// ListReq 列表查询条件。 +type ListReq struct { + Status int8 + Page int + Size int +} + +// List 分页查询小程序。 +func (s *Service) List(ctx context.Context, req ListReq) ([]model.MiniProgram, int64, error) { + page, size := req.Page, req.Size + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + return s.repo.List(ctx, req.Status, page, size) +} + +// Delete 软删除小程序。 +func (s *Service) Delete(ctx context.Context, id uint64) error { + return s.repo.Delete(ctx, id) +} + +// validateExt 校验扩展字段为合法 JSON(允许为空字符串)。 +func validateExt(s string) error { + if s == "" { + return nil + } + if !json.Valid([]byte(s)) { + return errors.New(40000, "ext 必须是合法 JSON 字符串") + } + return nil +} diff --git a/internal/service/miniapp/app1/user_service.go b/internal/service/miniapp/app1/user_service.go new file mode 100644 index 0000000..e9f7e23 --- /dev/null +++ b/internal/service/miniapp/app1/user_service.go @@ -0,0 +1,64 @@ +// Package app1svc 是小程序 app1 的业务逻辑。 +// 其余小程序(app2..app10)按相同目录结构各自建包,互不耦合。 +package app1svc + +import ( + "context" + "crypto/rand" + "encoding/hex" + "time" + + "portal-mini-go/internal/model" + "portal-mini-go/internal/pkg/errors" + "portal-mini-go/internal/pkg/session" + "portal-mini-go/internal/pkg/wechat" + "portal-mini-go/internal/repository/miniapp" + "portal-mini-go/internal/repository/user" +) + +const sessionKeyPrefix = "ma:session:" + +// Service app1 业务服务,依赖用户仓储、小程序仓储、微信客户端与会话存储。 +type Service struct { + userRepo *user.Repository + miniRepo *miniapp.Repository + wx *wechat.Client + store session.Store +} + +func New(userRepo *user.Repository, miniRepo *miniapp.Repository, wx *wechat.Client, store session.Store) *Service { + return &Service{userRepo: userRepo, miniRepo: miniRepo, wx: wx, store: store} +} + +// Login 完成小程序登录:按 app_id 取凭证 -> code2session -> 落库用户 -> 签发会话 token。 +// app_id 由接入层(MiniAppAuth 中间件)校验过为已启用小程序,此处用于查出 secret 与小程序主键。 +func (s *Service) Login(ctx context.Context, appID, code string) (string, *model.User, error) { + mp, err := s.miniRepo.GetActiveByAppID(ctx, appID) + if err != nil { + return "", nil, err + } + info, err := s.wx.Code2Session(ctx, appID, mp.AppSecret, code) + if err != nil { + return "", nil, err + } + u, err := s.userRepo.UpsertByOpenID(ctx, mp.ID, info.OpenID, info.UnionID, "", "") + if err != nil { + return "", nil, err + } + token, err := newSessionToken() + if err != nil { + return "", nil, err + } + if err := s.store.Set(ctx, sessionKeyPrefix+token, u.ID, 7*24*time.Hour); err != nil { + return "", nil, errors.Wrap(50000, "save session", err) + } + return token, u, nil +} + +func newSessionToken() (string, error) { + b := make([]byte, 24) + if _, err := rand.Read(b); err != nil { + return "", errors.Wrap(50000, "gen token", err) + } + return hex.EncodeToString(b), nil +}