diff --git a/.env.example b/.env.example index 6c249db..b5ef867 100755 --- a/.env.example +++ b/.env.example @@ -9,9 +9,5 @@ DB_USER=root DB_PASSWORD=your_password DB_NAME=wx_service -# 微信小程序配置 -WECHAT_APP_ID=your_app_id -WECHAT_APP_SECRET=your_app_secret - # JWT配置 JWT_SECRET=your-secret-key-change-in-production diff --git a/cmd/api/main.go b/cmd/api/main.go index 0c0050c..cfecf59 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -19,23 +19,15 @@ func main() { if err := database.InitDB(); err != nil { log.Fatalf("init database failed: %v", err) } - if err := database.AutoMigrate(&model.User{}); err != nil { + if err := database.AutoMigrate(&model.MiniProgram{}, &model.User{}); err != nil { log.Fatalf("auto migrate failed: %v", err) } - if config.AppConfig.WeChat.AppID == "" || config.AppConfig.WeChat.AppSecret == "" { - log.Fatal("wechat app id/secret are not configured") - } - gin.SetMode(config.AppConfig.Server.Mode) router := gin.Default() - wechatClient := service.NewWeChatClient( - config.AppConfig.WeChat.AppID, - config.AppConfig.WeChat.AppSecret, - nil, - ) - authService := service.NewAuthService(database.DB, wechatClient) + miniProgramService := service.NewMiniProgramService(database.DB) + authService := service.NewAuthService(database.DB, miniProgramService) authHandler := handler.NewAuthHandler(authService) api := router.Group("/api/v1") diff --git a/config/config.go b/config/config.go index 06d549a..ef36f17 100755 --- a/config/config.go +++ b/config/config.go @@ -10,7 +10,6 @@ import ( type Config struct { Server ServerConfig Database DatabaseConfig - WeChat WeChatConfig JWT JWTConfig } @@ -27,11 +26,6 @@ type DatabaseConfig struct { DBName string } -type WeChatConfig struct { - AppID string - AppSecret string -} - type JWTConfig struct { Secret string Expire int @@ -57,10 +51,6 @@ func LoadConfig() { Password: getEnv("DB_PASSWORD", ""), DBName: getEnv("DB_NAME", "wx_service"), }, - WeChat: WeChatConfig{ - AppID: getEnv("WECHAT_APP_ID", ""), - AppSecret: getEnv("WECHAT_APP_SECRET", ""), - }, JWT: JWTConfig{ Secret: getEnv("JWT_SECRET", "your-secret-key"), Expire: 86400, // 24小时 diff --git a/docs/README.md b/docs/README.md index 3a1ece9..ec22d5c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,8 +8,8 @@ 2. 按实际环境填写以下变量: - `SERVER_PORT`:HTTP 服务端口,例如 `8080`。 - `DB_HOST/DB_PORT/DB_USER/DB_PASSWORD/DB_NAME`:MySQL 连接信息。 - - `WECHAT_APP_ID`、`WECHAT_APP_SECRET`:小程序后台 `appId` 与 `secret`(例如用户提供的 `wx67444119b166caa0` / `eb57cd73ff48a10b14df484e1d20facf`)。 3. 如果需要,替换 `GIN_MODE`、`JWT_SECRET` 等其他变量。 +4. 通过 `docs/sql/users.sql` 初始化 `mini_programs` 与 `users` 表,并插入每个小程序的 `name/app_id/app_secret`。 ## 启动 @@ -23,12 +23,26 @@ go run ./cmd/api 3. 自动迁移 `internal/model/user.go` 中的 `users` 表。 4. 注册路由并启动 Gin HTTP 服务。 -## 数据表 `users` +## 数据表 + +### `mini_programs` + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `id` | bigint unsigned | 小程序 ID,登录接口需传 | +| `name` | varchar(100) | 业务名称或备注 | +| `app_id` | varchar(100), unique | 微信小程序 `appId` | +| `app_secret` | varchar(200) | 微信小程序 `appSecret`(明文存储,注意权限) | +| `description` | varchar(255) | 可选描述 | +| `created_at/updated_at/deleted_at` | timestamp | GORM 默认时间戳 | + +### `users` | 字段 | 类型 | 说明 | | --- | --- | --- | | `id` | bigint unsigned (auto increment) | 主键 | -| `open_id` | varchar(100), unique | 微信 `openid`,登录唯一标识 | +| `mini_program_id` | bigint unsigned | 外键,关联 `mini_programs.id` | +| `open_id` | varchar(100) | 与 `mini_program_id` 组合成唯一键 | | `union_id` | varchar(100), nullable | 微信 `unionid`(若有) | | `nick_name` | varchar(100) | 用户昵称 | | `avatar_url` | varchar(500) | 头像地址 | @@ -46,6 +60,7 @@ go run ./cmd/api ```json { + "mini_program_id": 1, "code": "wx.login返回的code", "nickname": "可选", "avatar_url": "可选", @@ -63,6 +78,7 @@ go run ./cmd/api "data": { "user": { "id": 1, + "mini_program_id": 1, "open_id": "oXXX", "union_id": "可选", "nickname": "昵称", @@ -70,16 +86,28 @@ go run ./cmd/api "gender": 1, "phone": "110" }, - "session_key": "wx-session-key" + "session_key": "wx-session-key", + "mini_program": { + "id": 1, + "name": "商城小程序", + "app_id": "wx67444119b166caa0" + } } } ``` - **错误** - - `400`:`code` 缺失或请求体非法。 + - `400`:`code` 或 `mini_program_id` 缺失、请求体非法、小程序不存在。 - `502`:微信 API 返回错误。 - `500`:数据库或其他内部异常。 ## 健康检查 `GET /healthz` 返回 `{"status": "ok"}`,用于部署探活。 + +## 多小程序共用后台设计 + +- **凭证管理表**:`mini_programs` 持久化 `name/app_id/app_secret`,可通过后台页面或 SQL 插入,避免把密钥写进环境变量。 +- **接口约定**:小程序端调用登录接口时传入 `mini_program_id`。服务端通过该 ID 读取凭证,动态拼装 `jscode2session` 请求。 +- **数据隔离**:`users` 以 `mini_program_id + open_id` 建立唯一索引,同一 `openid` 在不同小程序下互不影响。 +- **扩展性**:后续如需在用户层面区分策略(如积分、营销),可直接以 `mini_program_id` 作为维度做统计或挂载其他表。 diff --git a/docs/sql/users.sql b/docs/sql/users.sql new file mode 100644 index 0000000..1c685cf --- /dev/null +++ b/docs/sql/users.sql @@ -0,0 +1,34 @@ +-- mini_programs 表存储小程序凭证 +CREATE TABLE IF NOT EXISTS `mini_programs` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `created_at` datetime(3) NULL DEFAULT NULL, + `updated_at` datetime(3) NULL DEFAULT NULL, + `deleted_at` datetime(3) NULL DEFAULT NULL, + `name` varchar(100) NOT NULL, + `app_id` varchar(100) NOT NULL, + `app_secret` varchar(200) NOT NULL, + `description` varchar(255) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_mini_programs_app_id` (`app_id`), + KEY `idx_mini_programs_deleted_at` (`deleted_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- users 表结构,与 internal/model/user.go 对应 +CREATE TABLE IF NOT EXISTS `users` ( + `id` bigint unsigned NOT NULL AUTO_INCREMENT, + `created_at` datetime(3) NULL DEFAULT NULL, + `updated_at` datetime(3) NULL DEFAULT NULL, + `deleted_at` datetime(3) NULL DEFAULT NULL, + `mini_program_id` bigint unsigned NOT NULL, + `open_id` varchar(100) NOT NULL, + `union_id` varchar(100) DEFAULT NULL, + `nick_name` varchar(100) DEFAULT NULL, + `avatar_url` varchar(500) DEFAULT NULL, + `gender` tinyint(1) NOT NULL DEFAULT 0, + `phone` varchar(20) DEFAULT NULL, + `session_key` varchar(100) NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_mini_open` (`mini_program_id`,`open_id`), + KEY `idx_users_deleted_at` (`deleted_at`), + CONSTRAINT `fk_users_mini_program` FOREIGN KEY (`mini_program_id`) REFERENCES `mini_programs`(`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go index b98157c..487ccf0 100644 --- a/internal/handler/auth_handler.go +++ b/internal/handler/auth_handler.go @@ -21,11 +21,12 @@ func NewAuthHandler(authService *service.AuthService) *AuthHandler { } type weChatLoginRequest struct { - Code string `json:"code" binding:"required"` - NickName string `json:"nickname"` - AvatarURL string `json:"avatar_url"` - Gender *int `json:"gender"` - Phone string `json:"phone"` + MiniProgramID uint `json:"mini_program_id" binding:"required"` + Code string `json:"code" binding:"required"` + NickName string `json:"nickname"` + AvatarURL string `json:"avatar_url"` + Gender *int `json:"gender"` + Phone string `json:"phone"` } func (h *AuthHandler) LoginWithWeChat(c *gin.Context) { @@ -36,16 +37,21 @@ func (h *AuthHandler) LoginWithWeChat(c *gin.Context) { } result, err := h.authService.LoginWithCode(c.Request.Context(), service.LoginRequest{ - Code: req.Code, - NickName: req.NickName, - AvatarURL: req.AvatarURL, - Gender: req.Gender, - Phone: req.Phone, + MiniProgramID: req.MiniProgramID, + Code: req.Code, + NickName: req.NickName, + AvatarURL: req.AvatarURL, + Gender: req.Gender, + Phone: req.Phone, }) if err != nil { switch { case errors.Is(err, service.ErrCodeRequired): c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "code is required")) + case errors.Is(err, service.ErrMiniProgramRequired): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "mini_program_id is required")) + case errors.Is(err, service.ErrMiniProgramNotFound): + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "mini program not found")) default: var apiErr *service.WeChatError if errors.As(err, &apiErr) { @@ -58,19 +64,30 @@ func (h *AuthHandler) LoginWithWeChat(c *gin.Context) { } userPayload := gin.H{ - "id": result.User.ID, - "open_id": result.User.OpenID, - "nickname": result.User.NickName, - "avatar_url": result.User.AvatarURL, - "gender": result.User.Gender, - "phone": result.User.Phone, + "id": result.User.ID, + "mini_program_id": result.User.MiniProgramID, + "open_id": result.User.OpenID, + "nickname": result.User.NickName, + "avatar_url": result.User.AvatarURL, + "gender": result.User.Gender, + "phone": result.User.Phone, } if result.User.UnionID != "" { userPayload["union_id"] = result.User.UnionID } + miniProgramPayload := gin.H{ + "id": result.MiniProgram.ID, + "name": result.MiniProgram.Name, + "app_id": result.MiniProgram.AppID, + } + if result.MiniProgram.Description != "" { + miniProgramPayload["description"] = result.MiniProgram.Description + } + c.JSON(http.StatusOK, model.Success(gin.H{ - "user": userPayload, - "session_key": result.SessionKey, + "user": userPayload, + "session_key": result.SessionKey, + "mini_program": miniProgramPayload, })) } diff --git a/internal/model/mini_program.go b/internal/model/mini_program.go new file mode 100644 index 0000000..08a05f3 --- /dev/null +++ b/internal/model/mini_program.go @@ -0,0 +1,23 @@ +package model + +import ( + "time" + + "gorm.io/gorm" +) + +type MiniProgram struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + Name string `gorm:"size:100;not null" json:"name"` + AppID string `gorm:"size:100;uniqueIndex" json:"app_id"` + AppSecret string `gorm:"size:200;not null" json:"-"` + Description string `gorm:"size:255" json:"description"` +} + +func (MiniProgram) TableName() string { + return "mini_programs" +} diff --git a/internal/model/user.go b/internal/model/user.go index c3471e2..0367cc1 100755 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -12,12 +12,14 @@ type User struct { UpdatedAt time.Time `json:"updated_at"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` - OpenID string `gorm:"uniqueIndex;size:100" json:"open_id"` - UnionID string `gorm:"size:100" json:"union_id,omitempty"` - NickName string `gorm:"size:100" json:"nickname"` - AvatarURL string `gorm:"size:500" json:"avatar_url"` - Gender int `gorm:"default:0" json:"gender"` - Phone string `gorm:"size:20" json:"phone,omitempty"` + MiniProgramID uint `gorm:"index:idx_mini_open,priority:1" json:"mini_program_id"` + MiniProgram MiniProgram `gorm:"foreignKey:MiniProgramID" json:"mini_program,omitempty"` + OpenID string `gorm:"size:100;index:idx_mini_open,priority:2" json:"open_id"` + UnionID string `gorm:"size:100" json:"union_id,omitempty"` + NickName string `gorm:"size:100" json:"nickname"` + AvatarURL string `gorm:"size:500" json:"avatar_url"` + Gender int `gorm:"default:0" json:"gender"` + Phone string `gorm:"size:20" json:"phone,omitempty"` SessionKey string `gorm:"size:100" json:"-"` } diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 1403305..16e341d 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -4,46 +4,67 @@ import ( "context" "errors" "fmt" - "strings" + "sync" "wx_service/internal/model" "gorm.io/gorm" ) -var ErrCodeRequired = errors.New("code is required") +var ( + ErrCodeRequired = errors.New("code is required") + ErrMiniProgramRequired = errors.New("mini program id is required") + ErrMiniProgramNotFound = errors.New("mini program not found") +) type AuthService struct { - db *gorm.DB - wechat *WeChatClient + db *gorm.DB + miniProgramSvc *MiniProgramService + wechatClientCache map[uint]*WeChatClient + cacheMu sync.RWMutex } type LoginRequest struct { - Code string - NickName string - AvatarURL string - Gender *int - Phone string + MiniProgramID uint + Code string + NickName string + AvatarURL string + Gender *int + Phone string } type LoginResult struct { - User *model.User - SessionKey string + User *model.User + SessionKey string + MiniProgram *model.MiniProgram } -func NewAuthService(db *gorm.DB, wechat *WeChatClient) *AuthService { +func NewAuthService(db *gorm.DB, miniProgramSvc *MiniProgramService) *AuthService { return &AuthService{ - db: db, - wechat: wechat, + db: db, + miniProgramSvc: miniProgramSvc, + wechatClientCache: make(map[uint]*WeChatClient), } } func (s *AuthService) LoginWithCode(ctx context.Context, req LoginRequest) (*LoginResult, error) { - if strings.TrimSpace(req.Code) == "" { + if req.MiniProgramID == 0 { + return nil, ErrMiniProgramRequired + } + if req.Code == "" { return nil, ErrCodeRequired } - session, err := s.wechat.Code2Session(ctx, req.Code) + miniProgram, err := s.miniProgramSvc.GetByID(ctx, req.MiniProgramID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrMiniProgramNotFound + } + return nil, fmt.Errorf("load mini program: %w", err) + } + + client := s.getWeChatClient(miniProgram) + session, err := client.Code2Session(ctx, req.Code) if err != nil { return nil, err } @@ -53,15 +74,16 @@ func (s *AuthService) LoginWithCode(ctx context.Context, req LoginRequest) (*Log tx := s.db.WithContext(ctx) var user model.User - err = tx.Where("open_id = ?", session.OpenID).First(&user).Error + err = tx.Where("mini_program_id = ? AND open_id = ?", miniProgram.ID, session.OpenID).First(&user).Error if errors.Is(err, gorm.ErrRecordNotFound) { user = model.User{ - OpenID: session.OpenID, - UnionID: session.UnionID, - NickName: req.NickName, - AvatarURL: req.AvatarURL, - Phone: req.Phone, - SessionKey: session.SessionKey, + MiniProgramID: miniProgram.ID, + OpenID: session.OpenID, + UnionID: session.UnionID, + NickName: req.NickName, + AvatarURL: req.AvatarURL, + Phone: req.Phone, + SessionKey: session.SessionKey, } if req.Gender != nil { user.Gender = *req.Gender @@ -104,8 +126,24 @@ func (s *AuthService) LoginWithCode(ctx context.Context, req LoginRequest) (*Log } result := &LoginResult{ - User: &user, - SessionKey: session.SessionKey, + User: &user, + SessionKey: session.SessionKey, + MiniProgram: miniProgram, } return result, nil } + +func (s *AuthService) getWeChatClient(mp *model.MiniProgram) *WeChatClient { + s.cacheMu.RLock() + client, ok := s.wechatClientCache[mp.ID] + s.cacheMu.RUnlock() + if ok { + return client + } + + newClient := NewWeChatClient(mp.AppID, mp.AppSecret, nil) + s.cacheMu.Lock() + s.wechatClientCache[mp.ID] = newClient + s.cacheMu.Unlock() + return newClient +} diff --git a/internal/service/mini_program_service.go b/internal/service/mini_program_service.go new file mode 100644 index 0000000..4f264d8 --- /dev/null +++ b/internal/service/mini_program_service.go @@ -0,0 +1,25 @@ +package service + +import ( + "context" + + "wx_service/internal/model" + + "gorm.io/gorm" +) + +type MiniProgramService struct { + db *gorm.DB +} + +func NewMiniProgramService(db *gorm.DB) *MiniProgramService { + return &MiniProgramService{db: db} +} + +func (s *MiniProgramService) GetByID(ctx context.Context, id uint) (*model.MiniProgram, error) { + var mp model.MiniProgram + if err := s.db.WithContext(ctx).Where("id = ?", id).First(&mp).Error; err != nil { + return nil, err + } + return &mp, nil +}