From ea6811b295c62fb163d38b6ef8bfd92e06dd8c3b Mon Sep 17 00:00:00 2001 From: Rogee Date: Sat, 26 Sep 2026 18:47:38 +0800 Subject: [PATCH] fix: use verified WeChat login and recover sessions after cache clearing --- README.md | 10 +- backend/internal/httpapi/admin_test.go | 35 ++-- backend/internal/httpapi/httpapi.go | 30 ++- backend/internal/httpapi/wechat_login.go | 55 ++++++ backend/internal/httpapi/wechat_login_test.go | 110 +++++++++++ backend/internal/service/service.go | 9 +- uniapp/App.uvue | 28 +-- uniapp/pages/price-query/price-query.uvue | 18 +- uniapp/tests/api.test.mjs | 60 ++++++ uniapp/tests/login.test.mjs | 66 +++++++ uniapp/utils/api.uts | 20 +- uniapp/utils/user.uts | 175 ++++++++---------- 12 files changed, 446 insertions(+), 170 deletions(-) create mode 100644 backend/internal/httpapi/wechat_login.go create mode 100644 backend/internal/httpapi/wechat_login_test.go create mode 100644 uniapp/tests/api.test.mjs create mode 100644 uniapp/tests/login.test.mjs diff --git a/README.md b/README.md index 3c066e8..b324996 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,15 @@ go run ./cmd/smilefirst serve ## 小程序 -使用 HBuilderX 打开 `uniapp/`。当前注册页面为 `pages/price-query/price-query`。API 地址配置在 `uniapp/utils/api.uts`:本地开发使用 `http://localhost:9800`,体验版和正式版使用 `https://gh.yqbmb.com`。 +使用 HBuilderX 打开 `uniapp/`。当前注册页面为 `pages/price-query/price-query`。API 地址配置在 `uniapp/utils/api.uts`:本地开发、体验版和正式版统一请求生产后端 `https://app01.min.wooo.host`,用于定位生产服务问题。本地调试提交咨询等操作会写入生产数据。修改后需在 HBuilderX 重新运行小程序。 + +微信小程序 AppID 为 `wx0a0e45ada30f01d4`(配置于 `uniapp/manifest.json`)。手机号授权使用生产后端配置的 `GUAHAO_WECHAT_APPID` 和 `GUAHAO_WECHAT_SECRET`,AppSecret 不得写入前端或提交到代码库。 + +请求模块单元测试(Node.js 22.15+,模拟 uni API,不发送真实请求): + +```bash +node --test --experimental-test-coverage --test-coverage-include='**/utils/api.uts' --test-coverage-lines=65 --test-coverage-functions=65 --test-coverage-branches=65 uniapp/tests/api.test.mjs +``` ## 数据兼容 diff --git a/backend/internal/httpapi/admin_test.go b/backend/internal/httpapi/admin_test.go index 0d815b4..80749d0 100644 --- a/backend/internal/httpapi/admin_test.go +++ b/backend/internal/httpapi/admin_test.go @@ -552,24 +552,11 @@ func TestAdminPriceInquiryRejectsInvalidPagination(t *testing.T) { } func TestWechatPhoneCodeDoesNotBindMockPhone(t *testing.T) { - app, closeDB := newTestAdminApp(t) + app, closeDB := newTestAdminAppWithConfig(t, config.Config{WeChatAPIBase: "https://unused.invalid"}) defer closeDB() - - resp := doRequest(t, app, http.MethodPost, "/api/auth/wechat/session", `{"code":"phone_code_only"}`, map[string]string{ - "Content-Type": "application/json", - }) - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("session status = %d, want %d: %s", resp.StatusCode, http.StatusOK, readBody(t, resp)) - } - var session struct { - User struct { - Openid string `json:"openid"` - } `json:"user"` - } - if err := json.NewDecoder(resp.Body).Decode(&session); err != nil { - t.Fatalf("decode session: %v", err) - } + session := struct{ User struct{ Openid string } }{} + session.User.Openid = "test-user" + var resp *http.Response resp = doRequest(t, app, http.MethodPost, "/api/auth/wechat/phone", `{"openid":"`+session.User.Openid+`","phoneCode":"test-phone-code"}`, map[string]string{ "Content-Type": "application/json", @@ -618,6 +605,8 @@ func TestWechatPhoneCodeExchangesAndBindsRealPhone(t *testing.T) { var phoneCalls atomic.Int64 wechat := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case "/sns/jscode2session": + w.Write([]byte(`{"openid":"phone-exchange-user"}`)) case "/cgi-bin/token": tokenCalls.Add(1) if r.URL.Query().Get("grant_type") != "client_credential" { @@ -711,7 +700,17 @@ func newTestAdminAppWithConfig(t *testing.T, cfgOverride config.Config) (*fiber. cfg := cfgOverride cfg.AllowOrigins = "*" if cfg.WeChatAPIBase == "" { - cfg.WeChatAPIBase = "https://api.weixin.qq.com" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/sns/jscode2session" { + json.NewEncoder(w).Encode(map[string]string{"openid": "test-openid-" + r.URL.Query().Get("js_code")}) + return + } + http.NotFound(w, r) + })) + t.Cleanup(server.Close) + cfg.WeChatAPIBase = server.URL + cfg.WeChatAppID = "test-appid" + cfg.WeChatAppSecret = "test-secret" } if cfg.AdminUsername == "" { cfg.AdminUsername = "test-admin" diff --git a/backend/internal/httpapi/httpapi.go b/backend/internal/httpapi/httpapi.go index 944d3d9..bffbaa9 100644 --- a/backend/internal/httpapi/httpapi.go +++ b/backend/internal/httpapi/httpapi.go @@ -54,7 +54,19 @@ func New(cfg config.Config, svc *service.Service, log *logrus.Logger) *fiber.App if err := bindBody(c, &req); err != nil { return err } - session, err := svc.Login(c.Context(), req.Code) + if strings.TrimSpace(req.Code) == "" { + return badRequest("login code is required") + } + openid, err := wechatPhone.LoginOpenID(c.Context(), req.Code) + if err != nil { + log.WithError(err).Error("wechat login exchange failed") + var configErr missingWeChatConfigError + if errors.As(err, &configErr) { + return fiber.NewError(500, err.Error()) + } + return fiber.NewError(502, err.Error()) + } + session, err := svc.Login(c.Context(), openid) if err != nil { return err } @@ -186,13 +198,23 @@ func logRequests(log *logrus.Logger) fiber.Handler { return func(c fiber.Ctx) error { start := time.Now() err := c.Next() - log.WithFields(logrus.Fields{ + if err != nil { + if handlerErr := c.App().Config().ErrorHandler(c, err); handlerErr != nil { + return handlerErr + } + } + entry := log.WithFields(logrus.Fields{ "method": c.Method(), "path": c.Path(), "status": c.Response().StatusCode(), "latency_ms": time.Since(start).Milliseconds(), - }).Info("request") - return err + }) + if err != nil { + entry.WithError(err).Error("request failed") + } else { + entry.Info("request") + } + return nil } } diff --git a/backend/internal/httpapi/wechat_login.go b/backend/internal/httpapi/wechat_login.go new file mode 100644 index 0000000..1699374 --- /dev/null +++ b/backend/internal/httpapi/wechat_login.go @@ -0,0 +1,55 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" +) + +// LoginOpenID exchanges a one-use login code for a stable WeChat identity. +// The session key and credential-bearing URL must never reach logs or clients. +func (c *wechatPhoneClient) LoginOpenID(ctx context.Context, code string) (string, error) { + code = strings.TrimSpace(code) + if code == "" { + return "", errors.New("login code is required") + } + if err := c.validateConfig(); err != nil { + return "", err + } + endpoint := c.apiURL("/sns/jscode2session") + q := endpoint.Query() + q.Set("appid", c.appID) + q.Set("secret", c.appSecret) + q.Set("js_code", code) + q.Set("grant_type", "authorization_code") + endpoint.RawQuery = q.Encode() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return "", errors.New("invalid WeChat login request") + } + resp, err := c.client.Do(req) + if err != nil { + return "", errors.New("WeChat login request failed (network or timeout)") + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("WeChat login HTTP %d", resp.StatusCode) + } + var data struct { + OpenID string `json:"openid"` + ErrCode int `json:"errcode"` + } + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", errors.New("invalid WeChat login response") + } + if data.ErrCode != 0 { + return "", fmt.Errorf("WeChat login API error %d", data.ErrCode) + } + if strings.TrimSpace(data.OpenID) == "" { + return "", errors.New("WeChat login returned empty openid") + } + return data.OpenID, nil +} diff --git a/backend/internal/httpapi/wechat_login_test.go b/backend/internal/httpapi/wechat_login_test.go new file mode 100644 index 0000000..eb0f906 --- /dev/null +++ b/backend/internal/httpapi/wechat_login_test.go @@ -0,0 +1,110 @@ +package httpapi + +import ( + "bytes" + "context" + "github.com/gofiber/fiber/v3" + "github.com/rogeecn/wxapp-kouqiang-guahao/backend/internal/config" + "github.com/sirupsen/logrus" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestWechatLoginStableIdentityAndFailures(t *testing.T) { + for _, tc := range []struct { + name, body string + status int + fail bool + }{ + {"success", `{"openid":"stable-user","session_key":"secret-session"}`, 200, false}, + {"invalid code", `{"errcode":40029,"errmsg":"invalid code"}`, 200, true}, + {"empty identity", `{}`, 200, true}, + {"malformed", `oops`, 200, true}, + {"upstream failure", `{}`, 503, true}, + } { + t.Run(tc.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/sns/jscode2session" || r.URL.Query().Get("appid") != "app" || r.URL.Query().Get("secret") != "secret" || r.URL.Query().Get("grant_type") != "authorization_code" { + t.Error("invalid exchange request") + } + w.WriteHeader(tc.status) + w.Write([]byte(tc.body)) + })) + defer server.Close() + c := newWeChatPhoneClient(config.Config{WeChatAppID: "app", WeChatAppSecret: "secret", WeChatAPIBase: server.URL}) + for _, code := range []string{"first-code", "second-code"} { + id, err := c.LoginOpenID(context.Background(), code) + if (err != nil) != tc.fail { + t.Fatalf("id=%q err=%v", id, err) + } + if !tc.fail && id != "stable-user" { + t.Fatalf("identity changed: %s", id) + } + } + }) + } + c := newWeChatPhoneClient(config.Config{}) + if _, err := c.LoginOpenID(context.Background(), "code"); err == nil { + t.Fatal("missing credentials accepted") + } + if _, err := c.LoginOpenID(context.Background(), ""); err == nil { + t.Fatal("empty code accepted") + } +} + +func TestLoginKeepsPhoneAcrossCodesAndAllowsInquiry(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/sns/jscode2session": + w.Write([]byte(`{"openid":"stable-real-user","session_key":"never-return-this"}`)) + case "/cgi-bin/token": + w.Write([]byte(`{"access_token":"token","expires_in":7200}`)) + case "/wxa/business/getuserphonenumber": + w.Write([]byte(`{"phone_info":{"phoneNumber":"13900001234"}}`)) + default: + http.NotFound(w, r) + } + })) + defer server.Close() + app, closeDB := newTestAdminAppWithConfig(t, config.Config{WeChatAppID: "app", WeChatAppSecret: "secret", WeChatAPIBase: server.URL}) + defer closeDB() + headers := map[string]string{"Content-Type": "application/json"} + for _, code := range []string{"first", "second"} { + resp := doRequest(t, app, "POST", "/api/auth/wechat/session", `{"code":"`+code+`"}`, headers) + body := readBody(t, resp) + if resp.StatusCode != 200 || !strings.Contains(body, `"openid":"stable-real-user"`) || strings.Contains(body, "never-return-this") { + t.Fatalf("login: %d %s", resp.StatusCode, body) + } + if code == "first" { + resp = doRequest(t, app, "POST", "/api/auth/wechat/phone", `{"openid":"stable-real-user","phoneCode":"phone-code"}`, headers) + if resp.StatusCode != 200 { + t.Fatal(readBody(t, resp)) + } + resp.Body.Close() + } else if !strings.Contains(body, "13900001234") { + t.Fatalf("phone lost after re-login: %s", body) + } + } + resp := doRequest(t, app, "POST", "/api/price-inquiries", `{"openid":"stable-real-user","province":"上海市","city":"上海市","district":"浦东新区","project_name":"半月板损伤"}`, headers) + if resp.StatusCode != 201 { + t.Fatalf("inquiry: %d %s", resp.StatusCode, readBody(t, resp)) + } + resp.Body.Close() +} + +func TestRequestLogRecordsHandledErrorStatus(t *testing.T) { + var output bytes.Buffer + log := logrus.New() + log.SetOutput(&output) + log.SetFormatter(&logrus.JSONFormatter{}) + app := fiber.New(fiber.Config{ErrorHandler: errorHandler}) + app.Use(logRequests(log)) + app.Post("/fail", func(c fiber.Ctx) error { return badRequest("phone authorization is required") }) + resp := doRequest(t, app, "POST", "/fail", "", nil) + defer resp.Body.Close() + if resp.StatusCode != 400 || !strings.Contains(output.String(), `"status":400`) || !strings.Contains(output.String(), "phone authorization is required") { + t.Fatalf("status %d log %s", resp.StatusCode, output.String()) + } +} diff --git a/backend/internal/service/service.go b/backend/internal/service/service.go index 4268c88..c47dc00 100644 --- a/backend/internal/service/service.go +++ b/backend/internal/service/service.go @@ -271,12 +271,11 @@ func EnsureDatabaseDir(path string) error { return os.MkdirAll(dir, 0o755) } -func (s *Service) Login(ctx context.Context, code string) (SessionResult, error) { - code = strings.TrimSpace(code) - if code == "" { - code = "demo" +func (s *Service) Login(ctx context.Context, openid string) (SessionResult, error) { + openid = strings.TrimSpace(openid) + if openid == "" { + return SessionResult{}, errors.New("openid is required") } - openid := "demo_openid_" + code user, err := s.Q.GetUserByOpenID(ctx, openid) if err == nil { user, err = s.Q.TouchUserLogin(ctx, openid) diff --git a/uniapp/App.uvue b/uniapp/App.uvue index 5c1f0af..937a89b 100644 --- a/uniapp/App.uvue +++ b/uniapp/App.uvue @@ -1,7 +1,5 @@