diff --git a/go.mod b/go.mod index 49105cc..4f31d2e 100755 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.17.2 golang.org/x/crypto v0.48.0 + golang.org/x/image v0.38.0 gorm.io/driver/mysql v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 @@ -45,7 +46,6 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/image v0.38.0 // indirect golang.org/x/mod v0.33.0 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/go.sum b/go.sum index 6e67720..3d4cfcf 100644 --- a/go.sum +++ b/go.sum @@ -92,35 +92,21 @@ go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= diff --git a/internal/common/auth/handler/auth_handler.go b/internal/common/auth/handler/auth_handler.go index 72b3a3f..6dcd077 100644 --- a/internal/common/auth/handler/auth_handler.go +++ b/internal/common/auth/handler/auth_handler.go @@ -155,6 +155,11 @@ type updateProfileRequest struct { AvatarURL string `json:"avatar_url"` } +type miniProgramCodeQuery struct { + Path string `form:"path"` + Width int `form:"width"` +} + func (h *AuthHandler) UpdateProfile(c *gin.Context) { user := middleware.MustCurrentUser(c) @@ -181,3 +186,23 @@ func (h *AuthHandler) UpdateProfile(c *gin.Context) { "avatar_url": updated.AvatarURL, })) } + +func (h *AuthHandler) GetMiniProgramTestCode(c *gin.Context) { + user := middleware.MustCurrentUser(c) + + var query miniProgramCodeQuery + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "参数错误")) + return + } + + codeBytes, err := h.authService.GetMiniProgramTestCode(c.Request.Context(), user.ID, query.Path, query.Width) + if err != nil { + log.Printf("[get_mini_program_test_code] error: %v", err) + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取小程序码失败")) + return + } + + c.Header("Cache-Control", "no-store") + c.Data(http.StatusOK, "image/png", codeBytes) +} diff --git a/internal/common/auth/service/auth_service.go b/internal/common/auth/service/auth_service.go index 4f6423b..f68dfaf 100644 --- a/internal/common/auth/service/auth_service.go +++ b/internal/common/auth/service/auth_service.go @@ -265,3 +265,32 @@ func normalizeAvatarURL(raw string) string { } return text } + +func (s *AuthService) GetMiniProgramTestCode(ctx context.Context, userID uint, path string, width int) ([]byte, error) { + var user model.User + if err := s.db.WithContext(ctx).Select("id, mini_program_id").First(&user, userID).Error; err != nil { + return nil, fmt.Errorf("find user: %w", err) + } + if user.MiniProgramID == 0 { + return nil, fmt.Errorf("user mini program id missing") + } + + miniProgram, err := s.miniProgramSvc.GetByID(ctx, user.MiniProgramID) + if err != nil { + return nil, fmt.Errorf("load mini program: %w", err) + } + + if strings.TrimSpace(path) == "" { + path = "pages/nsti/test?resume=0" + } + if width <= 0 { + width = 280 + } + + client := s.getWeChatClient(miniProgram) + codeBytes, err := client.GetWXACode(ctx, path, width) + if err != nil { + return nil, fmt.Errorf("get mini program test code: %w", err) + } + return codeBytes, nil +} diff --git a/internal/common/auth/service/auth_service_test.go b/internal/common/auth/service/auth_service_test.go new file mode 100644 index 0000000..659bca1 --- /dev/null +++ b/internal/common/auth/service/auth_service_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "context" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "wx_service/internal/model" +) + +func newAuthTestDB(t *testing.T) *gorm.DB { + t.Helper() + + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + if err := db.AutoMigrate(&model.User{}); err != nil { + t.Fatalf("auto migrate: %v", err) + } + + return db +} + +func TestAuthServiceUpdateProfilePersistsUserFields(t *testing.T) { + db := newAuthTestDB(t) + svc := NewAuthService(db, nil) + + user := model.User{ + MiniProgramID: 1, + OpenID: "openid-1", + NickName: "旧昵称", + AvatarURL: "https://example.com/old.png", + SessionKey: "session-1", + } + if err := db.Create(&user).Error; err != nil { + t.Fatalf("seed user: %v", err) + } + + updated, err := svc.UpdateProfile(context.Background(), user.ID, "新昵称", "https://example.com/new.png") + if err != nil { + t.Fatalf("update profile: %v", err) + } + + if updated.NickName != "新昵称" { + t.Fatalf("expected updated nickname, got %q", updated.NickName) + } + if updated.AvatarURL != "https://example.com/new.png" { + t.Fatalf("expected updated avatar, got %q", updated.AvatarURL) + } + + var persisted model.User + if err := db.First(&persisted, user.ID).Error; err != nil { + t.Fatalf("reload user: %v", err) + } + + if persisted.NickName != "新昵称" { + t.Fatalf("expected persisted nickname, got %q", persisted.NickName) + } + if persisted.AvatarURL != "https://example.com/new.png" { + t.Fatalf("expected persisted avatar, got %q", persisted.AvatarURL) + } +} diff --git a/internal/common/auth/service/wechat_client.go b/internal/common/auth/service/wechat_client.go index afc783e..52a7e92 100644 --- a/internal/common/auth/service/wechat_client.go +++ b/internal/common/auth/service/wechat_client.go @@ -1,15 +1,19 @@ package service import ( + "bytes" "context" "encoding/json" "fmt" + "io" "net/http" "net/url" "time" ) const weChatCode2SessionURL = "https://api.weixin.qq.com/sns/jscode2session" +const weChatAccessTokenURL = "https://api.weixin.qq.com/cgi-bin/token" +const weChatGetWXACodeURL = "https://api.weixin.qq.com/wxa/getwxacode" // WeChatClient 调用微信接口获取 session/openid。 type WeChatClient struct { @@ -30,6 +34,13 @@ type weChatSessionResponse struct { ErrMsg string `json:"errmsg"` } +type weChatAccessTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + // WeChatError 表示微信接口级错误。 type WeChatError struct { Code int @@ -87,3 +98,98 @@ func (c *WeChatClient) Code2Session(ctx context.Context, code string) (*WeChatSe return &raw.WeChatSession, nil } + +func (c *WeChatClient) GetAccessToken(ctx context.Context) (string, error) { + query := url.Values{} + query.Set("grant_type", "client_credential") + query.Set("appid", c.appID) + query.Set("secret", c.appSecret) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s?%s", weChatAccessTokenURL, query.Encode()), nil) + if err != nil { + return "", fmt.Errorf("build access token request: %w", err) + } + + resp, err := c.client.Do(req) + if err != nil { + return "", fmt.Errorf("call wechat access token api: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("wechat access token api unexpected status: %s", resp.Status) + } + + var raw weChatAccessTokenResponse + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { + return "", fmt.Errorf("decode access token response: %w", err) + } + if raw.ErrCode != 0 { + return "", &WeChatError{Code: raw.ErrCode, Msg: raw.ErrMsg} + } + if raw.AccessToken == "" { + return "", fmt.Errorf("wechat access token missing") + } + return raw.AccessToken, nil +} + +type getWXACodeRequest struct { + Path string `json:"path"` + Width int `json:"width,omitempty"` + AutoColor bool `json:"auto_color"` + IsHyaline bool `json:"is_hyaline"` +} + +type weChatAPIErrorResponse struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` +} + +func (c *WeChatClient) GetWXACode(ctx context.Context, path string, width int) ([]byte, error) { + accessToken, err := c.GetAccessToken(ctx) + if err != nil { + return nil, err + } + + body, err := json.Marshal(getWXACodeRequest{ + Path: path, + Width: width, + AutoColor: false, + IsHyaline: true, + }) + if err != nil { + return nil, fmt.Errorf("marshal getwxacode request: %w", err) + } + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + fmt.Sprintf("%s?access_token=%s", weChatGetWXACodeURL, url.QueryEscape(accessToken)), + bytes.NewReader(body), + ) + if err != nil { + return nil, fmt.Errorf("build getwxacode request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("call wechat getwxacode api: %w", err) + } + defer resp.Body.Close() + + payload, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read getwxacode response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("wechat getwxacode api unexpected status: %s", resp.Status) + } + + var apiErr weChatAPIErrorResponse + if err := json.Unmarshal(payload, &apiErr); err == nil && apiErr.ErrCode != 0 { + return nil, &WeChatError{Code: apiErr.ErrCode, Msg: apiErr.ErrMsg} + } + + return payload, nil +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 6d873ca..e0daa54 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -67,6 +67,7 @@ func Register( protected.Use(middleware.RequireUserMiddleware()) { protected.PUT("/auth/profile", authHandler.UpdateProfile) + protected.GET("/auth/mini-program-test-code", authHandler.GetMiniProgramTestCode) registerCommonRoutes(protected, uploadHandler) registerRemoveWatermarkRoutes(api, protected, videoHandler) registerMembershipRoutes(protected, redeemCodeHandler)