fix: use verified WeChat login and recover sessions after cache clearing
Build and push backend image / backend-image (push) Successful in 45s
Build and push backend image / backend-image (push) Successful in 45s
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
## 数据兼容
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+3
-25
@@ -1,7 +1,5 @@
|
||||
<script lang="uts">
|
||||
import { post } from './utils/api.uts'
|
||||
import { currentUser, demoUser, phoneFromUser, setUser } from './utils/user.uts'
|
||||
import { User } from './utils/types.uts'
|
||||
import { loginUser } from './utils/user.uts'
|
||||
|
||||
// #ifdef APP-ANDROID || APP-HARMONY
|
||||
let firstBackTime = 0
|
||||
@@ -14,28 +12,8 @@
|
||||
onHide() {},
|
||||
methods: {
|
||||
login() {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (loginRes) => {
|
||||
const loginData = loginRes as any
|
||||
const code = loginData.code != null ? loginData.code as string : 'demo'
|
||||
post('/api/auth/wechat/session', { code }).then((session) => {
|
||||
const sessionUser = (session as any).user as any
|
||||
const storedUser = currentUser()
|
||||
const storedPhone = phoneFromUser(storedUser)
|
||||
if (storedPhone.length > 0 && phoneFromUser(sessionUser).length == 0) {
|
||||
sessionUser.phone = storedPhone
|
||||
}
|
||||
setUser(sessionUser as User)
|
||||
}).catch(() => {
|
||||
const stored = currentUser()
|
||||
setUser(stored == null ? demoUser() : stored)
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
const stored = currentUser()
|
||||
if (stored == null) setUser(demoUser())
|
||||
}
|
||||
loginUser().catch(() => {
|
||||
uni.showToast({ title: '微信登录失败,请重试', icon: 'none' })
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -110,7 +110,7 @@
|
||||
|
||||
<script setup lang="uts">
|
||||
import { post } from '../../utils/api.uts'
|
||||
import { bindPhoneFromEvent, currentUser, openidFromUser, phoneFromUser } from '../../utils/user.uts'
|
||||
import { ensureLogin, bindPhoneFromEvent, currentUser, openidFromUser, phoneFromUser } from '../../utils/user.uts'
|
||||
|
||||
const PRICE_INQUIRY_PROJECTS = [
|
||||
'半月板损伤',
|
||||
@@ -156,6 +156,9 @@
|
||||
|
||||
onShow(() => {
|
||||
refreshPhone()
|
||||
ensureLogin().then(() => { refreshPhone() }).catch((err) => {
|
||||
console.error('[price-inquiry] login unavailable', err)
|
||||
})
|
||||
})
|
||||
|
||||
onShareAppMessage(() => {
|
||||
@@ -261,20 +264,27 @@
|
||||
}
|
||||
|
||||
const submitInquiry = (): Promise<any> => {
|
||||
const user = currentUser()
|
||||
return post('/api/price-inquiries', {
|
||||
return ensureLogin().then((user) => post('/api/price-inquiries', {
|
||||
openid: openidFromUser(user),
|
||||
province: state.region[0],
|
||||
city: state.region[1],
|
||||
district: state.region[2],
|
||||
project_name: state.projectNames[state.projectIndex]
|
||||
}).then(() => {
|
||||
})).then(() => {
|
||||
showContactNotice()
|
||||
})
|
||||
}
|
||||
|
||||
const showSubmitError = (err: any) => {
|
||||
const message = err != null && err.message != null ? err.message as string : ''
|
||||
console.error('[price-inquiry] submit failed', { message })
|
||||
if (message.indexOf('phone authorization is required') >= 0) {
|
||||
state.phone = ''
|
||||
state.hasPhone = false
|
||||
updateCanAuthorize()
|
||||
uni.showToast({ title: '请重新授权手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (message.indexOf('手机号授权未完成') >= 0) {
|
||||
uni.showModal({
|
||||
title: '未完成手机号授权',
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { registerHooks, stripTypeScriptTypes } from 'node:module'
|
||||
import { test } from 'node:test'
|
||||
|
||||
// Execute the request module with mocked uni APIs; no production requests are sent.
|
||||
registerHooks({
|
||||
load(url, context, nextLoad) {
|
||||
if (url.endsWith('/utils/api.uts')) {
|
||||
return {
|
||||
format: 'module',
|
||||
source: stripTypeScriptTypes(readFileSync(new URL(url), 'utf8')),
|
||||
shortCircuit: true,
|
||||
}
|
||||
}
|
||||
return nextLoad(url, context)
|
||||
},
|
||||
})
|
||||
const { apiBase, request, post } = await import('../utils/api.uts')
|
||||
const production = 'https://app01.min.wooo.host'
|
||||
|
||||
for (const envVersion of ['develop', 'trial', 'release']) {
|
||||
test(`${envVersion} requests use the production domain`, async () => {
|
||||
globalThis.uni = {
|
||||
getAccountInfoSync: () => ({ miniProgram: { envVersion } }),
|
||||
request(options) {
|
||||
assert.equal(options.url, `${production}/api/example`)
|
||||
assert.equal(options.method, 'POST')
|
||||
assert.deepEqual(options.data, { value: 1 })
|
||||
assert.equal(options.header['content-type'], 'application/json')
|
||||
options.success({ statusCode: 200, data: { ok: true } })
|
||||
},
|
||||
}
|
||||
assert.equal(apiBase(), production)
|
||||
assert.deepEqual(await post('/api/example', { value: 1 }), { ok: true })
|
||||
})
|
||||
}
|
||||
|
||||
test('HTTP failures reject with the backend error', async () => {
|
||||
globalThis.uni = {
|
||||
request: (options) => options.success({ statusCode: 500, data: { error: 'backend unavailable' } }),
|
||||
}
|
||||
await assert.rejects(request('GET', '/api/example'), /backend unavailable/)
|
||||
})
|
||||
|
||||
for (const data of [null, {}]) {
|
||||
test(`HTTP failures without an error message reject (${JSON.stringify(data)})`, async () => {
|
||||
globalThis.uni = {
|
||||
request: (options) => options.success({ statusCode: 400, data }),
|
||||
}
|
||||
await assert.rejects(request('GET', '/api/example'), /request failed/)
|
||||
})
|
||||
}
|
||||
|
||||
test('network failures retain their diagnostic message', async () => {
|
||||
globalThis.uni = {
|
||||
request: (options) => options.fail({ errMsg: 'request:fail timeout' }),
|
||||
}
|
||||
await assert.rejects(request('GET', '/api/example'), /request:fail timeout/)
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { registerHooks, stripTypeScriptTypes } from 'node:module'
|
||||
import { test } from 'node:test'
|
||||
registerHooks({load(url,context,next){
|
||||
if(url.endsWith('.uts')) return {format:'module',source:stripTypeScriptTypes(readFileSync(new URL(url),'utf8').replace("import { User }", "import type { User }")),shortCircuit:true}
|
||||
return next(url,context)
|
||||
}})
|
||||
const user = await import('../utils/user.uts')
|
||||
test('login replaces cached phone with server state and rejects fake identities', async()=>{
|
||||
let stored={openid:'demo_openid_old',phone:'13900000000'}
|
||||
globalThis.uni={getStorageSync:()=>stored,setStorageSync:(_,v)=>stored=v,removeStorageSync:()=>stored=null,
|
||||
login:opts=>opts.success({code:'fresh-code'}),request:opts=>opts.success({statusCode:200,data:{user:{openid:'real-openid',phone:null}}})}
|
||||
await user.loginUser()
|
||||
assert.equal(user.phoneFromUser(user.currentUser()),'')
|
||||
assert.equal(user.openidFromUser(user.currentUser()),'real-openid')
|
||||
assert.throws(()=>user.openidFromUser(null),/登录/)
|
||||
assert.throws(()=>user.openidFromUser({openid:'fake_user_old'}),/登录/)
|
||||
uni.login=opts=>opts.fail({errMsg:'login failed'})
|
||||
await assert.rejects(user.loginUser(),/login failed/)
|
||||
assert.equal(user.currentUser(),null)
|
||||
})
|
||||
|
||||
test('authorization waits for login and binds only the verified identity', async()=>{
|
||||
let stored=null
|
||||
const requests=[]
|
||||
globalThis.uni={getStorageSync:()=>stored,setStorageSync:(_,v)=>stored=v,removeStorageSync:()=>stored=null,
|
||||
getAccountInfoSync:()=>({miniProgram:{envVersion:'develop'}}),
|
||||
login:opts=>setTimeout(()=>opts.success({code:'new-code'}),5),request:opts=>{
|
||||
requests.push(opts.url)
|
||||
if(opts.url.endsWith('/session')) opts.success({statusCode:200,data:{user:{openid:'verified-user',phone:null}}})
|
||||
else {assert.equal(opts.data.openid,'verified-user');opts.success({statusCode:200,data:{user:{openid:'verified-user',phone:{Valid:true,String:'13900001234'}}}})}
|
||||
}}
|
||||
const pending=user.loginUser()
|
||||
assert.equal(user.currentUser(),null)
|
||||
const bound=await user.bindPhoneFromEvent({detail:{errMsg:'getPhoneNumber:ok',code:'phone-code'}})
|
||||
await pending
|
||||
assert.equal(bound.phone,'13900001234')
|
||||
assert.equal(requests.length,2)
|
||||
await assert.rejects(user.bindPhoneFromEvent({detail:{errMsg:'getPhoneNumber:fail user deny'}}),/授权未完成/)
|
||||
await assert.rejects(user.bindPhoneFromEvent({detail:{errMsg:'getPhoneNumber:ok'}}),/code/)
|
||||
uni.request=opts=>opts.success({statusCode:200,data:{user:{openid:'verified-user',phone:null}}})
|
||||
await assert.rejects(user.bindPhoneFromEvent({detail:{errMsg:'getPhoneNumber:ok',code:'other'}}),/未返回已绑定手机号/)
|
||||
})
|
||||
|
||||
test('clearing storage triggers one automatic login for concurrent callers', async()=>{
|
||||
let stored=null
|
||||
let calls=0
|
||||
globalThis.uni={getStorageSync:()=>stored,setStorageSync:(_,v)=>stored=v,removeStorageSync:()=>stored=null,
|
||||
login:opts=>{calls++;setTimeout(()=>opts.success({code:'fresh-code'}),5)},
|
||||
request:opts=>opts.success({statusCode:200,data:{user:{openid:'stable-user',phone:null}}})}
|
||||
await user.loginUser()
|
||||
stored=null
|
||||
const [first,second]=await Promise.all([user.ensureLogin(),user.ensureLogin()])
|
||||
assert.equal(first?.openid,'stable-user')
|
||||
assert.equal(second?.openid,'stable-user')
|
||||
assert.equal(calls,2)
|
||||
await user.ensureLogin()
|
||||
assert.equal(calls,2)
|
||||
stored=null
|
||||
uni.request=opts=>opts.success({statusCode:502,data:{error:'WeChat unavailable'}})
|
||||
await assert.rejects(user.ensureLogin(),/WeChat unavailable/)
|
||||
assert.equal(user.currentUser(),null)
|
||||
uni.request=opts=>opts.success({statusCode:200,data:{user:{openid:'stable-user',phone:null}}})
|
||||
assert.equal((await user.ensureLogin()).openid,'stable-user')
|
||||
})
|
||||
+1
-19
@@ -1,28 +1,10 @@
|
||||
const LOCAL_API_BASE = 'http://localhost:9800'
|
||||
// 本地开发、体验版和正式版统一请求生产后端,便于复现生产问题。
|
||||
const PROD_API_BASE = 'https://app01.min.wooo.host'
|
||||
|
||||
export function apiBase(): string {
|
||||
if (isLocalMiniProgramDev()) return LOCAL_API_BASE
|
||||
return PROD_API_BASE
|
||||
}
|
||||
|
||||
function isLocalMiniProgramDev(): boolean {
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
const accountInfo = uni.getAccountInfoSync()
|
||||
const info = accountInfo as any
|
||||
if (info.miniProgram == null) return false
|
||||
const miniProgram = info.miniProgram as any
|
||||
if (miniProgram.envVersion == null) return false
|
||||
const envVersion = miniProgram.envVersion as string
|
||||
return envVersion == 'develop'
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
// #endif
|
||||
return false
|
||||
}
|
||||
|
||||
function errorMessage(data: any): string {
|
||||
if (data == null) return 'request failed'
|
||||
const obj = data as any
|
||||
|
||||
+81
-94
@@ -3,16 +3,48 @@ import { User } from './types.uts'
|
||||
|
||||
const USER_KEY = 'sf_user'
|
||||
|
||||
export function demoUser(): User {
|
||||
const openid = generateFakeOpenID()
|
||||
return {
|
||||
id: openid,
|
||||
openid,
|
||||
phone: null
|
||||
} as User
|
||||
let loginPending: Promise<any> | null = null
|
||||
let sessionReady = false
|
||||
|
||||
export function loginUser(): Promise<any> {
|
||||
if (loginPending != null) return loginPending as Promise<any>
|
||||
sessionReady = false
|
||||
uni.removeStorageSync(USER_KEY)
|
||||
loginPending = new Promise<any>((resolve, reject) => {
|
||||
uni.login({
|
||||
provider: 'weixin',
|
||||
success: (res) => {
|
||||
const code = (res as any).code as string
|
||||
if (code == null || code.length == 0) { reject(new Error('微信登录未返回 code')); return }
|
||||
post('/api/auth/wechat/session', { code }).then((session) => {
|
||||
const user = (session as any).user as User
|
||||
openidFromUser(user)
|
||||
setUser(user)
|
||||
sessionReady = true
|
||||
resolve(user)
|
||||
}).catch(reject)
|
||||
},
|
||||
fail: (err) => reject(new Error(err.errMsg))
|
||||
})
|
||||
}).catch((err) => {
|
||||
console.error('[login] failed', err)
|
||||
return Promise.reject(err)
|
||||
}).finally(() => { loginPending = null })
|
||||
return loginPending as Promise<any>
|
||||
}
|
||||
|
||||
export function ensureLogin(): Promise<any> {
|
||||
const user = currentUser()
|
||||
if (user != null) return Promise.resolve(user)
|
||||
if (sessionReady) {
|
||||
console.warn('[login] cached user missing; reauthenticating')
|
||||
sessionReady = false
|
||||
}
|
||||
return loginUser()
|
||||
}
|
||||
|
||||
export function currentUser(): User | null {
|
||||
if (!sessionReady) return null
|
||||
const raw = uni.getStorageSync(USER_KEY)
|
||||
if (raw == null || raw == '') return null
|
||||
return raw as User
|
||||
@@ -32,17 +64,14 @@ export function phoneFromUser(user: any | null): string {
|
||||
}
|
||||
|
||||
export function openidFromUser(user: any | null): string {
|
||||
if (user == null) return fallbackOpenID()
|
||||
if (user == null) throw new Error('请先完成微信登录')
|
||||
const obj = user as any
|
||||
if (obj.openid != null) {
|
||||
const openid = obj.openid as string
|
||||
if (openid.length > 0) return openid
|
||||
const value = obj.openid != null ? obj.openid : obj.Openid
|
||||
const openid = value == null ? '' : value as string
|
||||
if (openid.length == 0 || openid.indexOf('demo_openid_') == 0 || openid.indexOf('fake_user_') == 0) {
|
||||
throw new Error('登录身份已失效,请重新登录')
|
||||
}
|
||||
if (obj.Openid != null) {
|
||||
const openid = obj.Openid as string
|
||||
if (openid.length > 0) return openid
|
||||
}
|
||||
return fallbackOpenID()
|
||||
return openid
|
||||
}
|
||||
|
||||
export function setUser(user: User): void {
|
||||
@@ -57,48 +86,49 @@ export function bindPhoneFromEvent(event: UniEvent): Promise<any> {
|
||||
console.log('[phone-auth] getPhoneNumber rejected', { errMsg })
|
||||
return Promise.reject(new Error('手机号授权未完成'))
|
||||
}
|
||||
const user = currentUser()
|
||||
const phoneCode = detail != null && detail.code != null ? detail.code as string : ''
|
||||
if (phoneCode.length == 0) {
|
||||
console.log('[phone-auth] getPhoneNumber missing code', phoneAuthDetailLog(detail))
|
||||
return Promise.reject(new Error('没有拿到手机号授权 code'))
|
||||
}
|
||||
const openid = openidFromUser(user)
|
||||
console.log('[phone-auth] backend bind start', {
|
||||
openid: maskIdentifier(openid),
|
||||
hasStoredPhone: phoneFromUser(user).length > 0,
|
||||
phoneCodeLength: phoneCode.length,
|
||||
envVersion: miniProgramEnvVersion(),
|
||||
apiBase: apiBase()
|
||||
})
|
||||
return post('/api/auth/wechat/phone', {
|
||||
openid,
|
||||
phoneCode
|
||||
}).then((res) => {
|
||||
const nextUser = (res as any).user as User
|
||||
const phone = phoneFromUser(nextUser)
|
||||
if (phone.length == 0) {
|
||||
console.log('[phone-auth] backend bind empty phone', {
|
||||
openid: maskIdentifier(openidFromUser(nextUser))
|
||||
})
|
||||
return Promise.reject(new Error('后端未返回已绑定手机号'))
|
||||
}
|
||||
setUser(nextUser)
|
||||
console.log('[phone-auth] backend bind success', {
|
||||
openid: maskIdentifier(openidFromUser(nextUser)),
|
||||
phone: maskPhoneForLog(phone)
|
||||
})
|
||||
return {
|
||||
user: nextUser,
|
||||
phone
|
||||
}
|
||||
}).catch((err) => {
|
||||
const message = err != null && (err as any).message != null ? (err as any).message as string : '手机号绑定失败'
|
||||
console.log('[phone-auth] backend bind failed', {
|
||||
return ensureLogin().then((user) => {
|
||||
const openid = openidFromUser(user)
|
||||
console.log('[phone-auth] backend bind start', {
|
||||
openid: maskIdentifier(openid),
|
||||
message
|
||||
hasStoredPhone: phoneFromUser(user).length > 0,
|
||||
phoneCodeLength: phoneCode.length,
|
||||
envVersion: miniProgramEnvVersion(),
|
||||
apiBase: apiBase()
|
||||
})
|
||||
return post('/api/auth/wechat/phone', {
|
||||
openid,
|
||||
phoneCode
|
||||
}).then((res) => {
|
||||
const nextUser = (res as any).user as User
|
||||
const phone = phoneFromUser(nextUser)
|
||||
if (phone.length == 0) {
|
||||
console.log('[phone-auth] backend bind empty phone', {
|
||||
openid: maskIdentifier(openidFromUser(nextUser))
|
||||
})
|
||||
return Promise.reject(new Error('后端未返回已绑定手机号'))
|
||||
}
|
||||
setUser(nextUser)
|
||||
console.log('[phone-auth] backend bind success', {
|
||||
openid: maskIdentifier(openidFromUser(nextUser)),
|
||||
phone: maskPhoneForLog(phone)
|
||||
})
|
||||
return {
|
||||
user: nextUser,
|
||||
phone
|
||||
}
|
||||
}).catch((err) => {
|
||||
const message = err != null && (err as any).message != null ? (err as any).message as string : '手机号绑定失败'
|
||||
console.log('[phone-auth] backend bind failed', {
|
||||
openid: maskIdentifier(openid),
|
||||
message
|
||||
})
|
||||
return Promise.reject(err)
|
||||
})
|
||||
return Promise.reject(err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -137,49 +167,6 @@ function maskPhoneForLog(phone: string): string {
|
||||
return phone.slice(0, 3) + '****' + phone.slice(phone.length - 4)
|
||||
}
|
||||
|
||||
function fallbackOpenID(): string {
|
||||
const stored = currentUser()
|
||||
if (stored != null) {
|
||||
const obj = stored as any
|
||||
if (obj.openid != null) {
|
||||
const openid = obj.openid as string
|
||||
if (openid.length > 0) return openid
|
||||
}
|
||||
if (obj.Openid != null) {
|
||||
const openid = obj.Openid as string
|
||||
if (openid.length > 0) return openid
|
||||
}
|
||||
}
|
||||
const user = demoUser()
|
||||
setUser(user)
|
||||
return user.openid
|
||||
}
|
||||
|
||||
function generateFakeOpenID(): string {
|
||||
return 'fake_user_' + compactDateTime(new Date()) + '_' + randomFourDigits()
|
||||
}
|
||||
|
||||
function compactDateTime(date: Date): string {
|
||||
return date.getFullYear().toString()
|
||||
+ padNumber(date.getMonth() + 1, 2)
|
||||
+ padNumber(date.getDate(), 2)
|
||||
+ padNumber(date.getHours(), 2)
|
||||
+ padNumber(date.getMinutes(), 2)
|
||||
+ padNumber(date.getSeconds(), 2)
|
||||
}
|
||||
|
||||
function randomFourDigits(): string {
|
||||
return padNumber(Math.floor(Math.random() * 10000), 4)
|
||||
}
|
||||
|
||||
function padNumber(value: number, size: number): string {
|
||||
let text = value.toString()
|
||||
while (text.length < size) {
|
||||
text = '0' + text
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function miniProgramEnvVersion(): string {
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user