fix: use verified WeChat login and recover sessions after cache clearing
Build and push backend image / backend-image (push) Successful in 45s

This commit is contained in:
Rogee
2026-09-26 18:47:38 +08:00
parent a8c28c1d17
commit ea6811b295
12 changed files with 446 additions and 170 deletions
+9 -1
View File
@@ -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
```
## 数据兼容
+17 -18
View File
@@ -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"
+26 -4
View File
@@ -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
}
}
+55
View File
@@ -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())
}
}
+4 -5
View File
@@ -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
View File
@@ -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' })
})
}
},
+14 -4
View File
@@ -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: '未完成手机号授权',
+60
View File
@@ -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/)
})
+66
View File
@@ -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
View File
@@ -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
View File
@@ -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 {