Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.superun.ai/llms.txt

Use this file to discover all available pages before exploring further.

Skill: 微信小程序手机号一键登录(H5 WebView 架构)

Meta

字段
idlogin
tiercore
sourcemp_native
patternnative_page
external_configSUPERUN_WECHAT_APP_ID, SUPERUN_WECHAT_APP_SECRET
detection_keywordsmpRequestLogin, mpNavigate(‘/app/login’), signInWithOtp, parseMpTokenFromUrl

Affected Components

  • 小程序原生页面 (miniprogram/pages/login/)
  • WebView 层 (miniprogram/utils/webview-handler.jsbuildH5Url 回传 token;登录跳转仅 H5 wx.miniProgram.navigateTo,不使用 postMessage)
  • 页面注册 (miniprogram/app.json)
  • H5 bridge 函数 (src/lib/mp-bridge.tsmpNavigatempRequestLogin)
  • H5 基础工具函数 (src/lib/miniprogram-utils.tsisWechatMiniprogramloadMpJsSdkparseMpTokenFromUrl)
  • H5 工具文件 (src/services/auth-service.ts, src/hooks/useAuth.tsx)
  • Edge Function (supabase/functions/wechat-miniapp-auth/)
  • 外部配置(微信公众平台 + Edge Function Secrets)
  • 小程序通用工具层 (miniprogram/utils/auth.js, api.js, config.js)

概述

微信登录的核心问题:H5 WebView 无法直接调用 wx.getPhoneNumber,必须跳转到小程序原生页获取授权,再将认证结果回传给 H5。 整个流程分为三个阶段:
Phase 1: 触发              Phase 2: 换取                   Phase 3: 回传
(H5 → Native)             (Native → Server → 微信)         (Native → H5)
                                                          
H5 检测小程序环境           用户点击授权按钮                  session 存入 wx.Storage
↓                          ↓                               ↓
mpNavigate('/app/login')   getPhoneNumber → phoneCode       navigateBack
↓                          wx.login()     → wxCode          ↓
wx.miniProgram.navigateTo  ↓                               WebView onShow
('/pages/login/login')     POST wechat-miniapp-auth          token 变化检测
                           {wxCode, phoneCode}              ↓
                           ↓                               buildH5Url 附带 mp_token
                           jscode2session → openid          ↓
                           cgi-bin/token  → access_token    H5 parseMpTokenFromUrl
                           getPhoneNumber → phone           → setSession
                           Supabase signIn/create           → 登录态恢复 ✅

                           返回 {session, openid}
小程序原生页获取 phoneCode + wxCode → Edge Function 换取 openid 和手机号 → 创建/登录 Supabase 用户 → session 存入小程序 Storage → WebView 重载时通过 URL 参数传递 token → H5 调用 setSession 恢复登录态。

Prerequisites

微信公众平台

  1. 小程序已完成企业认证(个人主体不支持获取手机号)
  2. 开发管理 → 开发设置 → 服务器域名 → request 合法域名中添加 https://{{SUPABASE_PROJECT_ID}}.supabase.co(必须带协议头)

Edge Function Secrets

Secret来源
SUPERUN_WECHAT_APP_ID微信公众平台 → 开发管理 → AppID
SUPERUN_WECHAT_APP_SECRET微信公众平台 → 开发管理 → AppSecret
内置 Secrets(无需手动配置):SUPABASE_URLSUPABASE_ANON_KEYSUPABASE_SERVICE_ROLE_KEY

凭证验证

curl -X POST https://{{SUPABASE_PROJECT_ID}}.supabase.co/functions/v1/wechat-miniapp-auth \
  -H "Content-Type: application/json" \
  -d '{"wxCode":"fake","phoneCode":"fake"}'
  • invalid code (40029) → 凭证正确(fake code 无效是正常的)
  • invalid appid (40013) → AppID 错误
  • invalid appsecret (40125) → AppSecret 错误
  • Server configuration error → Secrets 未配置

Code Reference

Phase 1: 触发 — H5 → 小程序原生登录页

环境检测与登录态参数解析 (src/lib/miniprogram-utils.ts)

LOCKED:isWechatMiniprogram 检测逻辑、loadMpJsSdk 的 SDK URL、parseMpTokenFromUrl 参数名 mp_token / mp_refresh_token 均不可更改。
export function isWechatMiniprogram(): boolean {
  if (sessionStorage.getItem('_in_mp') === '1') return true;
  const byUA = navigator.userAgent.toLowerCase().includes('miniprogram');
  const byEnv = (window as any).__wxjs_environment === 'miniprogram';
  const params = new URLSearchParams(location.search);
  const byToken = params.has('mp_token');
  const byFrom = params.get('_from') === 'mp';
  const detected = byUA || byEnv || byToken || byFrom;
  if (detected) sessionStorage.setItem('_in_mp', '1');
  return detected;
}

export function loadMpJsSdk(): Promise<boolean> {
  if ((window as any).wx?.miniProgram) return Promise.resolve(true);
  return new Promise((resolve) => {
    const script = document.createElement('script');
    script.src = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
    script.onload = () => resolve(true);
    script.onerror = () => resolve(false);
    document.head.appendChild(script);
  });
}

export function parseMpTokenFromUrl(): { token: string; refreshToken: string } | null {
  const params = new URLSearchParams(location.search);
  const token = params.get('mp_token');
  const refreshToken = params.get('mp_refresh_token');
  if (token && refreshToken) return { token, refreshToken };
  return null;
}

登录跳转桥接 (src/lib/mp-bridge.ts)

LOCKED:mpNavigate/app/login/pages/login/login 映射、mpRequestLogin 路径不可更改。
import { isWechatMiniprogram } from '@/lib/miniprogram-utils';

export function mpNavigate(
  path: string,
  browserNavigate: (path: string) => void,
  title?: string
): void {
  if (isWechatMiniprogram()) {
    const bridge = (window as any).wx;
    if (bridge?.miniProgram) {
      if (path === '/app/login') {
        bridge.miniProgram.navigateTo({ url: '/pages/login/login' });
        return;
      }
      // ...其他路径走 webview 页面...
    }
  }
  browserNavigate(path);
}

export function mpRequestLogin(): void {
  if (isWechatMiniprogram()) {
    const bridge = (window as any).wx;
    if (bridge?.miniProgram) {
      bridge.miniProgram.navigateTo({ url: '/pages/login/login' });
      return;
    }
  }
  console.warn('WeChat login is only available in Mini Program');
}

H5 微信登录入口注入(场景分支)

根据 H5 项目是否已有登录模块,选择不同的注入方式。场景判定规则见 Scenario Detection
场景 A:现有登录页追加微信登录按钮(HAS_LOGIN_MODULE=true)
H5 已有登录模块(如 OTP、邮箱密码等)。不替换原有登录流程,仅在现有登录页中追加一个「微信手机号一键登录」按钮。该按钮仅在小程序环境中展示,H5 浏览器环境不可见。
import { isWechatMiniprogram } from '@/lib/miniprogram-utils';
import { mpNavigate } from '@/lib/mp-bridge';
import { useNavigate } from 'react-router-dom';

function LoginPage() {
  const navigate = useNavigate();

  return (
    <div className="login-page">
      {/* 原有登录表单保持不变 */}
      <ExistingLoginForm />

      {/* 仅小程序环境展示,点击跳转微信原生登录页 */}
      {isWechatMiniprogram() && (
        <button onClick={() => mpNavigate('/app/login', navigate)}>
          微信手机号一键登录
        </button>
      )}
    </div>
  );
}
关键约束:
  • 按钮使用 isWechatMiniprogram() 条件渲染,H5 浏览器环境下不展示
  • 点击调用 mpNavigate('/app/login', navigate) 跳转小程序原生登录页
  • 原有登录表单(OTP / 邮箱密码等)保持不变
  • 路由守卫等未登录拦截逻辑保持不变,仍指向原有登录页
场景 B:功能入口追加微信登录条件入口(HAS_LOGIN_MODULE=false)
H5 无登录模块,不新建 H5 登录页。在用户可触达的功能入口(如个人中心、侧边栏、需要登录的功能触发点)追加一个条件按钮,仅在小程序环境中展示,点击直接跳转微信原生登录页。
import { isWechatMiniprogram } from '@/lib/miniprogram-utils';
import { mpNavigate } from '@/lib/mp-bridge';
import { useNavigate } from 'react-router-dom';

function ProfilePage() {
  const navigate = useNavigate();

  return (
    <div>
      {/* 页面其他内容 */}

      {/* 仅小程序环境展示,点击直接跳转微信原生登录页 */}
      {isWechatMiniprogram() && (
        <button onClick={() => mpNavigate('/app/login', navigate)}>
          微信手机号一键登录
        </button>
      )}
    </div>
  );
}
关键约束:
  • 不新建 H5 登录页,不注册新路由
  • 按钮放在用户可触达的功能入口,使用 isWechatMiniprogram() 条件渲染
  • H5 浏览器环境下此按钮不可见,不影响 H5 原有体验
  • 点击直接通过 mpNavigate('/app/login', navigate) 跳转到小程序原生登录页

Phase 2: 换取 — 原生授权 → 服务端认证

登录页逻辑 (miniprogram/pages/login/login.js)

LOCKED:onGetPhoneNumber 调用链路不可更改:wx.loginapi.invokeFunction('wechat-miniapp-auth', {wxCode, phoneCode})auth.saveSession({access_token, refresh_token, openid})wx.navigateBackinvokeFunction 第一个参数必须为 'wechat-miniapp-auth'。 OPEN:toast 文案、navigateBack 延迟时长(800ms)、协议路由路径、agreed 初始值。
var auth = require('../../utils/auth');
var api = require('../../utils/api');
Page({
  data: { loading: false, agreed: true },
  onLoad: function () { console.log('[Login] Page loaded'); },
  onAgreeChange: function (e) {
    this.setData({ agreed: !!e.detail.value.length });
  },
  onGetPhoneNumber: function (e) {
    if (e.detail.errMsg !== 'getPhoneNumber:ok') {
      console.log('[Login] User cancelled phone auth');
      return;
    }
    if (!this.data.agreed) {
      wx.showToast({ title: '请先同意用户协议', icon: 'none' });
      return;
    }
    var phoneCode = e.detail.code;
    var that = this;
    that.setData({ loading: true });
    wx.login({
      success: function (loginRes) {
        if (!loginRes.code) {
          that.setData({ loading: false });
          wx.showToast({ title: '微信登录失败', icon: 'none' });
          return;
        }
        api.invokeFunction('wechat-miniapp-auth', {
          wxCode: loginRes.code,
          phoneCode: phoneCode,
        }, false).then(function (result) {
          that.setData({ loading: false });
          if (result.session) {
            auth.saveSession({
              access_token: result.session.access_token,
              refresh_token: result.session.refresh_token,
              openid: result.openid,
            });
            wx.showToast({ title: '登录成功', icon: 'success' });
            setTimeout(function () {
              wx.navigateBack({ delta: 1 });
            }, 800);
          } else {
            wx.showToast({ title: result.error || '登录失败', icon: 'none' });
          }
        }).catch(function (err) {
          that.setData({ loading: false });
          console.error('[Login] Auth error:', err);
          wx.showToast({ title: '登录失败,请重试', icon: 'none' });
        });
      },
      fail: function () {
        that.setData({ loading: false });
        wx.showToast({ title: '微信登录失败', icon: 'none' });
      },
    });
  },
  onOpenLegal: function (e) {
    var type = e.currentTarget.dataset.type;
    var titles = { terms: '用户协议', privacy: '隐私政策' };
    var paths = { terms: '/legal/terms', privacy: '/legal/privacy' };
    wx.navigateTo({
      url: '/pages/webview/webview?path=' +
        encodeURIComponent(paths[type] || '/') +
        '&title=' + encodeURIComponent(titles[type] || ''),
    });
  },
});

登录页模板 (miniprogram/pages/login/login.wxml)

LOCKED:必须保留 open-type="getPhoneNumber"bindgetphonenumber="onGetPhoneNumber"。OPEN:其余 UI 结构和文案可适配。
<view class="login-page">
  <view class="login-header">
    <view class="logo-wrap">
      <text class="logo-icon">{{LOGO_EMOJI}}</text>
    </view>
    <text class="app-name">{{APP_NAME}}</text>
    <text class="app-desc">登录后开启你的健身之旅</text>
  </view>
  <view class="login-actions">
    <button
      class="phone-btn {{loading ? 'loading' : ''}}"
      open-type="getPhoneNumber"
      bindgetphonenumber="onGetPhoneNumber"
      disabled="{{loading}}"
    >
      <text wx:if="{{!loading}}">微信手机号一键登录</text>
      <text wx:else>登录中...</text>
    </button>
    <view class="agreement">
      <checkbox-group bindchange="onAgreeChange">
        <label class="agreement-row">
          <checkbox value="agreed" checked="{{agreed}}" color="#FF4D1A" />
          <text class="agreement-text">我已阅读并同意
            <text class="link" data-type="terms" bindtap="onOpenLegal">用户协议</text>

            <text class="link" data-type="privacy" bindtap="onOpenLegal">隐私政策</text>
          </text>
        </label>
      </checkbox-group>
    </view>
  </view>
  <view class="login-footer">
    <text class="footer-text">登录即表示你同意我们使用你的手机号进行账户关联</text>
  </view>
</view>

登录页样式 (miniprogram/pages/login/login.wxss)

OPEN:全部样式可根据品牌适配。
page { background-color: #08080C; }
.login-page {
  min-height: 100vh; display: flex; flex-direction: column;
  align-items: center; padding: 0 48rpx; color: #f5f5f5;
}
.login-header { display: flex; flex-direction: column; align-items: center; margin-top: 200rpx; }
.logo-wrap {
  width: 140rpx; height: 140rpx; border-radius: 32rpx;
  background: linear-gradient(135deg, #FF4D1A, #FF7A45);
  display: flex; align-items: center; justify-content: center;
  box-shadow: 0 12rpx 40rpx -8rpx rgba(255, 77, 26, 0.5);
}
.logo-icon { font-size: 56rpx; }
.app-name { font-size: 44rpx; font-weight: 800; margin-top: 48rpx; letter-spacing: 4rpx; }
.app-desc { font-size: 26rpx; color: rgba(255,255,255,0.4); margin-top: 12rpx; }
.login-actions { width: 100%; margin-top: 100rpx; }
.phone-btn {
  width: 100% !important; height: 100rpx; border-radius: 50rpx;
  background: linear-gradient(135deg, #FF4D1A, #FF6B3D);
  color: #fff; font-size: 32rpx; font-weight: 700; letter-spacing: 4rpx;
  border: none; display: flex; align-items: center; justify-content: center;
  box-shadow: 0 8rpx 32rpx -8rpx rgba(255, 77, 26, 0.5);
}
.phone-btn.loading { opacity: 0.6; }
.phone-btn::after { border: none; }
.agreement { margin-top: 40rpx; display: flex; justify-content: center; }
.agreement-row { display: flex; align-items: center; gap: 12rpx; }
.agreement-text { font-size: 22rpx; color: rgba(255,255,255,0.4); line-height: 1.6; }
.link { color: #FF4D1A; }
.login-footer { position: fixed; bottom: 80rpx; left: 48rpx; right: 48rpx; text-align: center; }
.footer-text { font-size: 20rpx; color: rgba(255,255,255,0.2); line-height: 1.5; }

登录页配置 (miniprogram/pages/login/login.json)

LOCKED:导航栏配色须与模板一致。OPEN:navigationBarTitleText 可适配。
{
  "navigationBarTitleText": "登录",
  "navigationBarBackgroundColor": "#0D1017",
  "navigationBarTextStyle": "white"
}

Edge Function (supabase/functions/wechat-miniapp-auth/index.ts)

LOCKED:整个 API 调用链(jscode2session → cgi-bin/token → getuserphonenumber → Supabase Auth)、HMAC-SHA256 密码生成算法、合成邮箱格式 wx_{openid}@miniapp.local 均不可更改。仅允许替换附录中的占位符。
config.toml 注册:
[functions.wechat-miniapp-auth]
verify_jwt = false
认证策略:
策略说明
合成邮箱wx_{openid}@miniapp.local — 用 openid 构造唯一邮箱
密码生成HMAC-SHA256(openid, SERVICE_ROLE_KEY) — 确定性密码,相同 openid 始终生成同一密码
不传 phone 字段phone 只存 user_metadata,避免与 H5 OTP 登录冲突(见 Troubleshooting)
先登录后创建signInWithPassword 失败 → createUser + 再次 signIn
完整代码:
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers":
    "authorization, x-client-info, apikey, content-type, x-supabase-client-platform",
};

const log = (step: string, details?: unknown) => {
  const str = details ? ` - ${JSON.stringify(details)}` : "";
  console.log(`[wechat-miniapp-auth] ${step}${str}`);
};

async function generatePassword(openid: string): Promise<string> {
  const secret = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
  const key = await crypto.subtle.importKey(
    "raw", new TextEncoder().encode(secret),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"]
  );
  const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(openid));
  return Array.from(new Uint8Array(sig))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

serve(async (req) => {
  if (req.method === "OPTIONS") {
    return new Response("ok", { headers: corsHeaders });
  }
  try {
    const { wxCode, phoneCode } = await req.json();
    if (!wxCode || !phoneCode) {
      return new Response(
        JSON.stringify({ error: "wxCode and phoneCode are required" }),
        { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400 }
      );
    }
    const appId = Deno.env.get("SUPERUN_WECHAT_APP_ID");
    const appSecret = Deno.env.get("SUPERUN_WECHAT_APP_SECRET");
    if (!appId || !appSecret) {
      log("ERROR", "Missing WECHAT credentials");
      return new Response(
        JSON.stringify({ error: "Server configuration error" }),
        { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500 }
      );
    }

    log("Step 1: Exchange wx code for openid");
    const sessionRes = await fetch(
      `https://api.weixin.qq.com/sns/jscode2session?appid=${appId}&secret=${appSecret}&js_code=${wxCode}&grant_type=authorization_code`
    );
    const sessionData = await sessionRes.json();
    if (sessionData.errcode) {
      log("ERROR jscode2session", { errcode: sessionData.errcode, errmsg: sessionData.errmsg });
      return new Response(
        JSON.stringify({ error: sessionData.errmsg || "WeChat session error" }),
        { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400 }
      );
    }
    const openid: string = sessionData.openid;
    log("Got openid", { openid: openid.substring(0, 8) + "..." });

    log("Step 2: Get access_token");
    const tokenRes = await fetch(
      `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appId}&secret=${appSecret}`
    );
    const tokenData = await tokenRes.json();
    if (tokenData.errcode) {
      log("ERROR get_token", { errcode: tokenData.errcode, errmsg: tokenData.errmsg });
      return new Response(
        JSON.stringify({ error: tokenData.errmsg || "WeChat token error" }),
        { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400 }
      );
    }

    log("Step 3: Get phone number");
    const phoneRes = await fetch(
      `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${tokenData.access_token}`,
      {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ code: phoneCode }),
      }
    );
    const phoneData = await phoneRes.json();
    if (phoneData.errcode !== 0) {
      log("ERROR getPhoneNumber", { errcode: phoneData.errcode, errmsg: phoneData.errmsg });
      return new Response(
        JSON.stringify({ error: phoneData.errmsg || "Phone number error" }),
        { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 400 }
      );
    }
    const purePhone = phoneData.phone_info.purePhoneNumber;
    const countryCode = phoneData.phone_info.countryCode;
    const fullPhone = `+${countryCode}${purePhone}`;
    log("Got phone", { phone: purePhone.substring(0, 3) + "****" });

    log("Step 4: Supabase auth");
    const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
    const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
    const anonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
    const adminClient = createClient(supabaseUrl, serviceRoleKey);
    const anonClient = createClient(supabaseUrl, anonKey);
    const syntheticEmail = `wx_${openid}@miniapp.local`;
    const password = await generatePassword(openid);

    let signInResult = await anonClient.auth.signInWithPassword({ email: syntheticEmail, password });
    if (signInResult.error) {
      log("User not found, creating");
      const { error: createError } = await adminClient.auth.admin.createUser({
        email: syntheticEmail, email_confirm: true, password,
        user_metadata: { openid, phone: fullPhone, nickname: "{{DEFAULT_NICKNAME}}" },
      });
      if (createError) {
        log("ERROR createUser", { message: createError.message });
        return new Response(
          JSON.stringify({ error: createError.message }),
          { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500 }
        );
      }
      signInResult = await anonClient.auth.signInWithPassword({ email: syntheticEmail, password });
      if (signInResult.error) {
        log("ERROR signIn after create", { message: signInResult.error.message });
        return new Response(
          JSON.stringify({ error: "登录失败,请重试" }),
          { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500 }
        );
      }
    }

    log("Login success", { userId: signInResult.data.user?.id?.substring(0, 8) + "..." });
    return new Response(
      JSON.stringify({
        session: {
          access_token: signInResult.data.session!.access_token,
          refresh_token: signInResult.data.session!.refresh_token,
        },
        openid,
      }),
      { headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 200 }
    );
  } catch (error) {
    const msg = error instanceof Error ? error.message : String(error);
    log("ERROR", { message: msg });
    return new Response(JSON.stringify({ error: msg }), {
      headers: { ...corsHeaders, "Content-Type": "application/json" }, status: 500,
    });
  }
});

小程序通用工具层

miniprogram/utils/auth.js
LOCKED:saveSession 参数须含 access_token、refresh_token、openid;Storage 键名不可更改;所有 getter 函数签名不可更改。
function getToken() { return wx.getStorageSync('access_token') || ''; }
function getRefreshToken() { return wx.getStorageSync('refresh_token') || ''; }
function getOpenid() { return wx.getStorageSync('openid') || ''; }
function saveSession(session) {
  if (session.access_token) wx.setStorageSync('access_token', session.access_token);
  if (session.refresh_token) wx.setStorageSync('refresh_token', session.refresh_token);
  if (session.openid) wx.setStorageSync('openid', session.openid);
}
function clearSession() {
  wx.removeStorageSync('access_token');
  wx.removeStorageSync('refresh_token');
  wx.removeStorageSync('openid');
}
function isLoggedIn() { return !!getToken(); }
module.exports = { getToken, getRefreshToken, getOpenid, saveSession, clearSession, isLoggedIn };
miniprogram/utils/api.js
LOCKED:invokeFunction 函数签名、请求头结构、URL 拼接方式不可更改。
var config = require('./config');
var auth = require('./auth');
function invokeFunction(functionName, data, withAuth) {
  return new Promise(function (resolve, reject) {
    var headers = { 'Content-Type': 'application/json', 'apikey': config.API_PUBLIC_KEY };
    if (withAuth) {
      var token = auth.getToken();
      if (token) headers['Authorization'] = 'Bearer ' + token;
    }
    wx.request({
      url: config.API_BASE_URL + '/functions/v1/' + functionName,
      method: 'POST', data: data, header: headers,
      success: function (res) {
        if (res.statusCode >= 200 && res.statusCode < 300) resolve(res.data);
        else reject(new Error((res.data && res.data.error) || '请求失败 (' + res.statusCode + ')'));
      },
      fail: function (err) { reject(new Error(err.errMsg || '网络异常')); },
    });
  });
}
module.exports = { invokeFunction };
miniprogram/utils/config.js
OPEN:所有值均为占位符,需按项目替换(见附录)。
module.exports = {
  BASE_H5_URL: '{{BASE_H5_URL}}',
  API_BASE_URL: '{{API_BASE_URL}}',
  API_PUBLIC_KEY: '{{API_PUBLIC_KEY}}',
  BRAND: { NAME: '{{APP_NAME}}', LOGO_URL: '' },
  THEME: { PRIMARY: '#FF4D1A', BACKGROUND: '#08080C', NAV_BG: '#0D1017', NAV_TEXT: 'white' },
};

Phase 3: 回传 — session 从 Native 传递到 H5

WebView Token 回传 (miniprogram/pages/index/index.js)

LOCKED:buildH5Url 中参数名 mp_token / mp_refresh_token 不可更改。onShow 中 token 变化检测并重载 WebView 的逻辑不可更改。
buildH5Url(位于 webview-handler.js):
function buildH5Url(path, extraParams) {
  var url = config.BASE_H5_URL + (path || '/app');
  var params = ['_from=mp'];
  var token = auth.getToken();
  var refreshToken = auth.getRefreshToken();
  if (token) params.push('mp_token=' + encodeURIComponent(token));
  if (refreshToken) params.push('mp_refresh_token=' + encodeURIComponent(refreshToken));
  if (extraParams) {
    for (var key in extraParams) {
      if (extraParams.hasOwnProperty(key) && extraParams[key])
        params.push(key + '=' + encodeURIComponent(extraParams[key]));
    }
  }
  return url + (params.length > 0 ? '?' + params.join('&') : '');
}
WebView 容器页 index.js
Page({
  data: { webviewUrl: '' },
  _lastToken: '',
  onLoad: function (options) {
    this._lastToken = auth.getToken() || '';
    this.setData({ webviewUrl: handler.buildH5Url('/app') });
  },
  onShow: function () {
    if (!this.data.webviewUrl) return;
    var token = auth.getToken();
    if (token === this._lastToken) return;
    this._lastToken = token;
    this.setData({ webviewUrl: handler.buildH5Url('/app') });
  },
  onMessage: function (e) { handler.handleMessage(e, this); },
});

H5 Session 恢复 (src/services/auth-service.ts)

LOCKED:setSession 函数签名不可更改。
import { supabase } from '@/integrations/supabase/client';

export const authService = {
  // ...其他方法省略...
  async setSession(accessToken: string, refreshToken: string) {
    return supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken });
  },
};

H5 Auth 初始化 (src/hooks/useAuth.tsx)

LOCKED:initSession 中须按顺序执行 parseMpTokenFromUrl → setSession → replaceState 清除 URL。onAuthStateChange 回调内须用 setTimeout 包裹异步调用以避免死锁(见 Troubleshooting)。
useEffect(() => {
  const initSession = async () => {
    const mpTokens = parseMpTokenFromUrl();
    if (mpTokens) {
      const { data, error } = await authService.setSession(mpTokens.token, mpTokens.refreshToken);
      if (!error && data.session) {
        window.history.replaceState({}, '', window.location.pathname);
      }
    }
    const s = await authService.getSession();
    setSession(s);
    if (s?.user?.id) loadProfile(s.user.id);
    setLoading(false);
  };
  initSession();

  const { data: { subscription } } = authService.onAuthStateChange((event, s) => {
    setSession(s);
    if (s?.user?.id) {
      setTimeout(() => { loadProfile(s.user.id); }, 0);
    } else {
      setProfile(null);
    }
    setLoading(false);
  });
  return () => subscription.unsubscribe();
}, [loadProfile]);

Contracts

跨边界契约汇总(对应上述各 LOCKED 注释):
边界契约不可变项
H5 → NativempNavigate('/app/login')mpRequestLogin()wx.miniProgram.navigateTo({ url: '/pages/login/login' })/app/login/pages/login/login 映射;不使用 postMessage
Native → ServerinvokeFunction('wechat-miniapp-auth', {wxCode, phoneCode})函数名、参数名
Server → Native响应 {session: {access_token, refresh_token}, openid}返回结构
Native → wx.StoragesaveSession({access_token, refresh_token, openid})参数结构、Storage 键名
wx.Storage → URLbuildH5Url 输出 mp_token / mp_refresh_token / _from=mpURL 参数名
URL → H5parseMpTokenFromUrl()setSession(token, refreshToken)函数签名、参数解析

Scenario Detection

在执行 Generation Steps 前,必须先判定 H5 项目是否已有登录模块,以决定 Step 14 的注入方式。 判定规则:
检测 H5 src/ 目录

├─ 存在登录页面组件(LoginPage, SignIn, AuthForm 等)
│  或存在 /login 路由定义
│  或存在 signInWithPassword / signInWithOtp 调用
│  → HAS_LOGIN_MODULE = true → 场景 A

└─ 不存在以上特征
   → HAS_LOGIN_MODULE = false → 场景 B
场景前提Step 14 行为
AH5 已有登录模块在现有登录页组件中追加条件按钮
BH5 无登录模块在功能入口(个人中心等)追加条件按钮,不新建登录页
两个场景共享同一模式:使用 isWechatMiniprogram() 条件渲染按钮 + mpNavigate('/app/login', navigate) 跳转。区别仅在于按钮放置的位置。

Generation Steps

全新生成(miniprogram/ 不存在)执行所有步骤。增量追加跳过标注 [SKIP] 的步骤。
  1. [SKIP] 创建 miniprogram/utils/config.js
  2. [SKIP] 创建 miniprogram/utils/auth.js
  3. [CONDITIONAL] 确保 miniprogram/utils/api.js 存在(全新生成时创建;增量模式下若因模板清理被删除则先补齐)
  4. [SKIP] 创建 miniprogram/utils/webview-handler.js(含 buildH5Url + handleMessage
  5. 创建 miniprogram/pages/login/ 下 4 个文件(login.js / login.wxml / login.wxss / login.json)
  6. [SKIP] 创建 miniprogram/pages/index/index.js(WebView 容器页)
  7. [SKIP] 创建 miniprogram/app.json
  8. app.json 的 pages 数组中追加 "pages/login/login"
  9. 创建 supabase/functions/wechat-miniapp-auth/index.ts
  10. supabase/config.toml 注册 [functions.wechat-miniapp-auth]verify_jwt = false
  11. src/lib/mp-bridge.ts 中添加 mpNavigate(含 /app/login 拦截)与 mpRequestLogin,并在 src/lib/miniprogram-utils.ts 中添加 parseMpTokenFromUrl(如不存在)
  12. src/services/auth-service.ts 中添加 setSession()
  13. src/hooks/useAuth.tsxinitSession 中添加 token 解析恢复逻辑
  14. 根据 Scenario Detection 结果注入 H5 微信登录入口:
    • [场景 A] 在现有 H5 登录页组件中追加微信登录条件按钮(isWechatMiniprogram() && <button>),添加 import { isWechatMiniprogram } from '@/lib/miniprogram-utils'import { mpNavigate } from '@/lib/mp-bridge'。原有登录表单和路由守卫保持不变。
    • [场景 B] 在合适的 H5 功能入口(个人中心、侧边栏等)追加微信登录条件按钮(isWechatMiniprogram() && <button>),添加相同的 import。不新建 H5 登录页,不注册新路由。
CRITICAL:不修改小程序原生层(miniprogram/ 下的 pages / handlers / bridge functions),仅追加;登录不在 webview-handler.js 中增加 request_login 等 postMessage 分支。 增量补齐规则:
  • 如果 miniprogram/utils/api.js 不存在,必须先从模板补齐 invokeFunction 实现,再执行 pages/login/login.js 中的 api.invokeFunction('wechat-miniapp-auth', ...) 调用链注入。

Post-Generation Checklist

  • app.json pages 数组包含 "pages/login/login"
  • config.toml 包含 [functions.wechat-miniapp-auth]verify_jwt = false
  • mp-bridge.tsmpNavigate / mpRequestLogin 使用 navigateTo 打开 /pages/login/login(登录不依赖 postMessage)
  • useAuth.tsx 初始化第一步调用 parseMpTokenFromUrl()
  • useAuth.tsxonAuthStateChange 回调内异步调用用 setTimeout 包裹
  • Edge Function 响应格式 {session: {access_token, refresh_token}, openid}
  • 提示用户确认企业认证 + 域名白名单 + Edge Function Secrets
  • [场景 A] 现有登录页中存在 isWechatMiniprogram() && <微信登录按钮> 条件渲染
  • [场景 A] 原有登录表单未被修改,路由守卫仍指向原有登录页
  • [场景 B] 功能入口处存在 isWechatMiniprogram() && <微信登录按钮> 条件渲染
  • [场景 B] 未新建 H5 登录页,未注册新路由
  • [通用] 微信登录按钮的 onClick 使用 mpNavigate('/app/login', navigate)
  • [通用] 使用条件按钮的组件已添加 import { isWechatMiniprogram }import { mpNavigate }

Troubleshooting

createUser 时不能传 phone 字段

现象:Phone number already registered by another user 原因:H5 端 OTP 登录可能已用同一手机号注册过用户,传 phone 字段会冲突。 解决:createUser 只传 email + password,手机号放 user_metadata

listUsers() 分页陷阱

现象:用户数 > 50 时找不到目标用户,导致”账户状态异常”。 原因:listUsers() 默认只返回前 50 条。 解决:不用 listUsers() 查找用户。直接用合成邮箱 signInWithPassword,失败则 createUser

域名必须带 https://

现象:微信后台配置域名后请求仍报错”不在 request 合法域名列表中”。 原因:域名白名单要求完整 URL(含协议头),填裸域名无效。 解决:填写 https://xxx.supabase.co

开发者工具域名缓存

现象:后台已配好域名,开发者工具仍报域名不合法。 解决:详情 → 本地设置 → 勾选”不校验合法域名”(开发阶段),或点击刷新按钮刷新域名配置。

parseMpTokenFromUrl() 必须在 useAuth 初始化时调用

现象:登录成功 → navigateBack → WebView 重载 → H5 仍显示未登录。 原因:parseMpTokenFromUrl() 未在初始化流程中被调用,URL 中的 mp_token 未被读取。 解决:useAuthuseEffect 中第一步调用 parseMpTokenFromUrl(),有 token 则 setSession() → 清除 URL。

微信登录按钮在 H5 浏览器中不应展示

现象:H5 浏览器中出现”微信手机号一键登录”按钮。 原因:未使用 isWechatMiniprogram() 条件渲染。 解决:确保按钮被 {isWechatMiniprogram() && ...} 包裹,仅在小程序环境中展示。

微信登录按钮点击后未跳转原生页

现象:点击微信登录按钮后显示 H5 页面(无法获取微信手机号),而非原生登录页。 原因:按钮 onClick 使用了 navigate('/app/login') 在 WebView 内跳转,没有走小程序原生导航。 解决:使用 mpNavigate('/app/login', navigate)

Capability Relations

能力关系
H5 OTP 登录同一用户体系,共享 Supabase Auth。createUser 不传 phone 以避免冲突
支付login 是 payment 的前置依赖
头像昵称登录获取手机号,头像昵称需单独获取(chooseAvatar + nickname 输入框)
分享分享时可附带登录用户信息

附录

端到端验证 SOP

  1. 凭证验证 — 执行 Prerequisites 中的 curl 命令,期望返回 invalid code
  2. 开发者工具 — 勾选”不校验合法域名”
  3. 原生登录 — 打开小程序 → H5 → 登录 → 应跳转原生登录页 → 授权 → “登录成功” → 自动返回
  4. H5 登录态 — 返回后个人中心应显示用户信息
  5. 持久化 — 关闭/重开小程序,登录态应保持

变量替换清单

占位符替换为所在文件
{{BASE_H5_URL}}H5 部署地址config.js
{{API_BASE_URL}}Supabase 项目 URLconfig.js
{{API_PUBLIC_KEY}}Supabase anon keyconfig.js
{{APP_NAME}}应用名称config.js, login.wxml
{{LOGO_EMOJI}}Logo emoji 或替换为 image 组件login.wxml
{{DEFAULT_NICKNAME}}新用户默认昵称Edge Function
{{SUPABASE_PROJECT_ID}}Supabase 项目 IDconfig.toml, 域名白名单
SUPERUN_WECHAT_APP_ID小程序 AppIDEdge Function Secrets
SUPERUN_WECHAT_APP_SECRET小程序 AppSecretEdge Function Secrets