2026-09-18-miniprogram-phone-quick-login.md 33 KB

山峡小程序「手机号快捷登录」Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在山峡 miniprogram 登录页增加「手机号快捷登录」,后端用微信 getuserphonenumber + phone_no 匹配员工并绑定 wx_openid,签发与密码登录相同的 JWT;密码登录保持不变。

Architecture: 移植 zkxw 的自建后端链路(非云开发):前端 getPhoneNumber + uni.login → POST /api/mini/auth/wechat-login → jscode2session + getuserphonenumber → 匹配/绑号 → 复用 Web 登录发 JWT。凭证使用独立 WECHAT_MP_APPID / WECHAT_MP_SECRET,不改动 WXPAY_*。本期不做密码登录后静默绑号、不做仅 openid 静默换票。

Tech Stack: FastAPI + SQLAlchemy + httpx;uni-app Vue3 小程序;MySQL 5.7

Spec: docs/superpowers/specs/2026-09-18-miniprogram-phone-quick-login-design.md
参考: /Users/zlwu/work/00code/zkxw/backend/app/services/wechat_mini_auth.py、wechat_login_service.py、api/mini_auth.py;/Users/zlwu/work/00code/zkxw/mobile-h5/pages/login/index.vue、styles/components/login.scss


文件结构

路径 职责
docs/sql/migrations/2026-09-18-sys-user-wx-openid.sql sys_user 增列增量 DDL
docs/sql/shanxia2.0.sql 基线 CREATE TABLE sys_user 同步两列 + 索引(仅改表结构定义段,勿重导全量数据)
backend/app/models/models.py SysUser.wx_openid / wx_unionid
backend/app/core/config.py WECHAT_MP_*;加密排除路径
backend/.env.example 占位说明(无真实 Secret)
backend/app/crud/user.py get_by_wx_openid、list_active_by_phone_no
backend/app/services/wechat_mini_auth.py code2session、get_phone_number、规范化
backend/app/services/wechat_login_service.py 匹配/绑号(仅 phoneCode 路径)
backend/app/services/web_login_tokens.py 从 public.py 抽出的签发 JWT 公共函数
backend/app/api/public.py 密码登录改为调用 issue_web_login_tokens
backend/app/api/mini_auth.py POST /mini/auth/wechat-login
backend/app/api/__init__.py + backend/app/main.py 注册路由
backend/tests/test_wechat_mini_auth_phone.py 规范化 / phones_match
backend/tests/test_wechat_login_resolve.py resolve 分支单测
miniprogram/config/api.js AUTH_API.WECHAT_LOGIN
miniprogram/utils/request.js 排除路径含 wechat-login(加密开启时)
miniprogram/stores/user.js wechatLogin
miniprogram/pages/login/login.vue UI + getPhoneNumber + 协议门禁

Task 1: DDL + ORM 字段

Files:

  • Create: docs/sql/migrations/2026-09-18-sys-user-wx-openid.sql
  • Modify: docs/sql/shanxia2.0.sql(CREATE TABLE \sys_user`` 段,约 35650–35674 行)
  • Modify: backend/app/models/models.py(SysUser)

  • [ ] Step 1: 写增量迁移 SQL

docs/sql/migrations/2026-09-18-sys-user-wx-openid.sql:

-- 山峡小程序手机号快捷登录:绑定微信 openid
ALTER TABLE `sys_user`
  ADD COLUMN `wx_openid` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '微信小程序 openid' AFTER `avatar`,
  ADD COLUMN `wx_unionid` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '微信 unionid' AFTER `wx_openid`;

ALTER TABLE `sys_user`
  ADD UNIQUE KEY `uk_sys_user_wx_openid` (`wx_openid`);
  • Step 2: 同步基线 CREATE TABLE

在 docs/sql/shanxia2.0.sql 的 sys_user 建表语句中,于 `avatar` 后增加:

  `wx_openid` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '微信小程序 openid',
  `wx_unionid` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '微信 unionid',

并将表尾 KEY 段改为包含:

  UNIQUE KEY `uk_sys_user_wx_openid` (`wx_openid`),
  KEY `idx_user_dept` (`dept_id`,`del_flag`) USING BTREE

(勿改动该文件中已有 INSERT 数据行。)

  • Step 3: ORM 增列

在 SysUser 中 avatar 后增加:

    wx_openid = Column(String(64), nullable=True, comment="微信小程序 openid")
    wx_unionid = Column(String(64), nullable=True, comment="微信 unionid")
  • Step 4: Commit
git add docs/sql/migrations/2026-09-18-sys-user-wx-openid.sql docs/sql/shanxia2.0.sql backend/app/models/models.py
git commit -m "$(cat <<'EOF'
feat(db): add sys_user wx_openid columns for mini phone login.

EOF
)"

Task 2: 配置项 WECHAT_MP_* + 加密排除

Files:

  • Modify: backend/app/core/config.py
  • Modify: backend/.env.example

  • [ ] Step 1: 在 config.py 增加配置

在 # 微信小程序配置 / WXPAY_SECRET 附近(支付块之后)新增独立门户小程序配置:

    # 门户微信小程序登录(与 WXPAY_* 支付凭证分离)
    WECHAT_MP_APPID: str = ""  # 门户小程序 AppID,须与 miniprogram manifest 一致
    WECHAT_MP_SECRET: str = ""  # 门户小程序 AppSecret(仅后端)

在 API_ENCRYPT_EXCLUDE_PATHS 字符串中,紧接 /api/web/auth/login, 后加入:

/api/mini/auth/wechat-login,
  • Step 2: .env.example 占位

追加(无真实 Secret):

# 门户小程序登录(AppID 与 miniprogram/manifest.json mp-weixin.appid 一致)
# WECHAT_MP_APPID=wx7db1ddb2a90c9425
# WECHAT_MP_SECRET=
  • Step 3: Commit
git add backend/app/core/config.py backend/.env.example
git commit -m "$(cat <<'EOF'
feat(config): add WECHAT_MP credentials for mini wechat login.

EOF
)"

Task 3: CRUD 查询 + 手机号规范化单测(TDD)

Files:

  • Create: backend/tests/test_wechat_mini_auth_phone.py
  • Create: backend/app/services/wechat_mini_auth.py
  • Modify: backend/app/crud/user.py

  • [ ] Step 1: 写失败单测(规范化)

backend/tests/test_wechat_mini_auth_phone.py:

from app.services.wechat_mini_auth import normalize_cn_mobile, phones_match


def test_normalize_strips_plus86():
    assert normalize_cn_mobile("+8613800138000") == "13800138000"


def test_normalize_strips_86_prefix_13_digits():
    assert normalize_cn_mobile("8613800138000") == "13800138000"


def test_phones_match_equal():
    assert phones_match("13800138000", "13800138000") is True


def test_phones_match_normalizes():
    assert phones_match("13800138000", "+8613800138000") is True


def test_phones_match_mismatch():
    assert phones_match("13800138000", "13900139000") is False


def test_phones_match_empty_fails():
    assert phones_match("", "13800138000") is False
    assert phones_match(None, "13800138000") is False
  • Step 2: 运行确认失败
cd backend && source venv/bin/activate && pytest tests/test_wechat_mini_auth_phone.py -v

Expected: ModuleNotFoundError 或 import 失败。

  • Step 3: 实现 wechat_mini_auth.py

从 zkxw 移植,但凭证改为 WECHAT_MP_APPID / WECHAT_MP_SECRET:

"""微信小程序登录:code2session、手机号快速验证。"""
from __future__ import annotations

import logging
import re
from typing import Any, Dict, Optional

import httpx

from app.core.config import settings

logger = logging.getLogger(__name__)

_WX_CODE2SESSION = "https://api.weixin.qq.com/sns/jscode2session"
_WX_TOKEN = "https://api.weixin.qq.com/cgi-bin/token"
_WX_PHONE = "https://api.weixin.qq.com/wxa/business/getuserphonenumber"


class WechatMiniAuthError(Exception):
    def __init__(self, message: str, *, wx_errcode: Optional[int] = None):
        super().__init__(message)
        self.wx_errcode = wx_errcode


def normalize_cn_mobile(raw: Optional[str]) -> str:
    if raw is None:
        return ""
    s = re.sub(r"\s+", "", str(raw).strip())
    if s.startswith("+86"):
        s = s[3:]
    elif s.startswith("86") and len(s) == 13:
        s = s[2:]
    return s


def phones_match(account_phone: Optional[str], authorized_phone: Optional[str]) -> bool:
    a = normalize_cn_mobile(account_phone)
    b = normalize_cn_mobile(authorized_phone)
    return bool(a) and bool(b) and a == b


def _require_mini_credentials() -> tuple:
    appid = (settings.WECHAT_MP_APPID or "").strip()
    secret = (settings.WECHAT_MP_SECRET or "").strip()
    if not appid or not secret:
        raise WechatMiniAuthError("未配置小程序凭证(WECHAT_MP_APPID / WECHAT_MP_SECRET)")
    return appid, secret


def code2session(login_code: str) -> Dict[str, Any]:
    code = (login_code or "").strip()
    if not code:
        raise WechatMiniAuthError("loginCode 不能为空")
    appid, secret = _require_mini_credentials()
    with httpx.Client(timeout=15.0) as client:
        r = client.get(
            _WX_CODE2SESSION,
            params={
                "appid": appid,
                "secret": secret,
                "js_code": code,
                "grant_type": "authorization_code",
            },
        )
        r.raise_for_status()
        data = r.json()
    if data.get("errcode"):
        logger.warning("code2session failed: %s", data)
        raise WechatMiniAuthError(
            "微信登录凭证无效或已过期", wx_errcode=data.get("errcode")
        )
    openid = data.get("openid")
    if not openid:
        raise WechatMiniAuthError("微信未返回 openid")
    return {
        "openid": openid,
        "session_key": data.get("session_key"),
        "unionid": data.get("unionid"),
    }


def _get_access_token() -> str:
    appid, secret = _require_mini_credentials()
    with httpx.Client(timeout=15.0) as client:
        r = client.get(
            _WX_TOKEN,
            params={
                "grant_type": "client_credential",
                "appid": appid,
                "secret": secret,
            },
        )
        r.raise_for_status()
        data = r.json()
    token = data.get("access_token")
    if not token:
        logger.warning("get access_token failed: %s", data)
        raise WechatMiniAuthError(
            "获取微信 access_token 失败", wx_errcode=data.get("errcode")
        )
    return token


def get_phone_number(phone_code: str) -> str:
    code = (phone_code or "").strip()
    if not code:
        raise WechatMiniAuthError("phoneCode 不能为空")
    token = _get_access_token()
    with httpx.Client(timeout=15.0) as client:
        r = client.post(
            f"{_WX_PHONE}?access_token={token}",
            json={"code": code},
        )
        r.raise_for_status()
        data = r.json()
    if data.get("errcode") not in (0, None):
        logger.warning("getuserphonenumber failed: %s", data)
        raise WechatMiniAuthError(
            "获取手机号失败,请改用账号密码登录",
            wx_errcode=data.get("errcode"),
        )
    info = data.get("phone_info") or {}
    phone = normalize_cn_mobile(
        info.get("purePhoneNumber") or info.get("phoneNumber")
    )
    if not phone:
        raise WechatMiniAuthError("微信未返回手机号")
    return phone
  • Step 4: CRUD 方法

在 _UserCRUDCompat 中增加:

    def get_by_wx_openid(self, db: Session, wx_openid: str) -> Optional[SysUser]:
        oid = (wx_openid or "").strip()
        if not oid:
            return None
        return (
            db.query(SysUser)
            .filter(SysUser.wx_openid == oid, SysUser.del_flag == 0)
            .first()
        )

    def list_active_by_phone_no(self, db: Session, phone_no: str) -> List[SysUser]:
        from app.services.wechat_mini_auth import normalize_cn_mobile

        phone = normalize_cn_mobile(phone_no)
        if not phone:
            return []
        rows = (
            db.query(SysUser)
            .filter(SysUser.del_flag == 0, SysUser.status == "1")
            .all()
        )
        return [u for u in rows if normalize_cn_mobile(getattr(u, "phone_no", None)) == phone]

说明:sys_user.phone_no 为 varchar(11),多数已是 11 位;用规范化过滤可兼容库内偶发空格。若后续数据量大,可改为 SQL phone_no == phone 精确匹配并保证入库规范化。

  • Step 5: 跑通单测
cd backend && source venv/bin/activate && pytest tests/test_wechat_mini_auth_phone.py -v

Expected: PASS

  • Step 6: Commit
git add backend/app/services/wechat_mini_auth.py backend/app/crud/user.py backend/tests/test_wechat_mini_auth_phone.py
git commit -m "$(cat <<'EOF'
feat(backend): add wechat mini auth helpers and user phone lookups.

EOF
)"

Task 4: resolve_wechat_login_user + 单测(TDD)

Files:

  • Create: backend/tests/test_wechat_login_resolve.py
  • Create: backend/app/services/wechat_login_service.py

  • [ ] Step 1: 写失败单测

backend/tests/test_wechat_login_resolve.py:

from types import SimpleNamespace
from unittest.mock import MagicMock, patch

from app.services.wechat_login_service import resolve_wechat_login_user


def test_openid_bound_phone_mismatch_rejects():
    bound = SimpleNamespace(id="u1", phone_no="13800138000", wx_openid="oid-a")
    db = MagicMock()
    with patch(
        "app.services.wechat_login_service.user_crud.get_by_wx_openid",
        return_value=bound,
    ):
        user, err, code = resolve_wechat_login_user(
            db, openid="oid-a", phone="13900139000"
        )
    assert user is None
    assert code == 401
    assert "不一致" in err or "不匹配" in err


def test_openid_bound_phone_match_ok():
    bound = SimpleNamespace(id="u1", phone_no="13800138000", wx_openid="oid-a")
    db = MagicMock()
    with patch(
        "app.services.wechat_login_service.user_crud.get_by_wx_openid",
        return_value=bound,
    ):
        user, err, code = resolve_wechat_login_user(
            db, openid="oid-a", phone="+8613800138000"
        )
    assert user is bound
    assert err is None
    assert code is None


def test_unbound_no_user_rejects():
    db = MagicMock()
    with patch(
        "app.services.wechat_login_service.user_crud.get_by_wx_openid",
        return_value=None,
    ), patch(
        "app.services.wechat_login_service.user_crud.list_active_by_phone_no",
        return_value=[],
    ):
        user, err, code = resolve_wechat_login_user(
            db, openid="oid-new", phone="13800138000"
        )
    assert user is None
    assert code == 401


def test_unbound_multi_user_rejects():
    db = MagicMock()
    u1 = SimpleNamespace(id="a", phone_no="13800138000", wx_openid=None)
    u2 = SimpleNamespace(id="b", phone_no="13800138000", wx_openid=None)
    with patch(
        "app.services.wechat_login_service.user_crud.get_by_wx_openid",
        return_value=None,
    ), patch(
        "app.services.wechat_login_service.user_crud.list_active_by_phone_no",
        return_value=[u1, u2],
    ):
        user, err, code = resolve_wechat_login_user(
            db, openid="oid-new", phone="13800138000"
        )
    assert user is None
    assert code == 400
    assert "多个" in err


def test_unbound_unique_binds_openid():
    db = MagicMock()
    u = SimpleNamespace(id="a", phone_no="13800138000", wx_openid=None, update_time=None)
    with patch(
        "app.services.wechat_login_service.user_crud.get_by_wx_openid",
        side_effect=[None, None],
    ), patch(
        "app.services.wechat_login_service.user_crud.list_active_by_phone_no",
        return_value=[u],
    ):
        user, err, code = resolve_wechat_login_user(
            db, openid="oid-new", phone="13800138000"
        )
    assert err is None
    assert user is u
    assert u.wx_openid == "oid-new"
    db.commit.assert_called()
  • Step 2: 运行确认失败
cd backend && source venv/bin/activate && pytest tests/test_wechat_login_resolve.py -v

Expected: import 失败。

  • Step 3: 实现 wechat_login_service.py
"""微信小程序登录业务:openid + 手机号一致性校验(仅快捷登录绑号)。"""
from __future__ import annotations

from datetime import datetime
from typing import Optional, Tuple

from sqlalchemy.orm import Session

from app.crud.user import user_crud
from app.models.models import SysUser
from app.services.wechat_mini_auth import normalize_cn_mobile, phones_match


def resolve_wechat_login_user(
    db: Session, *, openid: str, phone: str
) -> Tuple[Optional[SysUser], Optional[str], Optional[int]]:
    oid = (openid or "").strip()
    if not oid:
        return None, "微信未返回 openid", 400
    if not normalize_cn_mobile(phone):
        return None, "请授权手机号登录,或使用账号密码登录", 400

    bound = user_crud.get_by_wx_openid(db, oid)
    if bound:
        if getattr(bound, "status", "1") != "1":
            return None, "账号已被停用,请联系管理员", 403
        if not phones_match(getattr(bound, "phone_no", None), phone):
            return (
                None,
                "微信与账号不匹配,请联系管理员",
                401,
            )
        return bound, None, None

    users = user_crud.list_active_by_phone_no(db, phone)
    if not users:
        return (
            None,
            "未找到对应员工账号(请确认手机号已在系统维护)",
            401,
        )
    if len(users) > 1:
        return None, "手机号对应多个账号,请联系管理员", 400

    user = users[0]
    other = user_crud.get_by_wx_openid(db, oid)
    if other and other.id != user.id:
        return None, "微信与账号不匹配,请联系管理员", 409
    existing_oid = (getattr(user, "wx_openid", None) or "").strip()
    if existing_oid and existing_oid != oid:
        return None, "微信与账号不匹配,请联系管理员", 409

    user.wx_openid = oid
    user.update_time = datetime.now()
    db.add(user)
    db.commit()
    db.refresh(user)
    return user, None, None

不做 resolve_wechat_login_by_openid_only(spec 明确本期不做静默换票)。

  • Step 4: 跑通单测
cd backend && source venv/bin/activate && pytest tests/test_wechat_login_resolve.py tests/test_wechat_mini_auth_phone.py -v

Expected: PASS

  • Step 5: Commit
git add backend/app/services/wechat_login_service.py backend/tests/test_wechat_login_resolve.py
git commit -m "$(cat <<'EOF'
feat(backend): resolve wechat login user by phone and bind openid.

EOF
)"

Task 5: 抽出 JWT 签发 + mini_auth 路由

Files:

  • Create: backend/app/services/web_login_tokens.py
  • Modify: backend/app/api/public.py
  • Create: backend/app/api/mini_auth.py
  • Modify: backend/app/api/__init__.py
  • Modify: backend/app/main.py

  • [ ] Step 1: 抽出 issue_web_login_tokens

backend/app/services/web_login_tokens.py:

"""Web / 小程序共用:为 sys_user 签发 access + refresh JWT。"""
from __future__ import annotations

from typing import Any, Dict

from sqlalchemy.orm import Session

from app.core.auth import (
    create_access_token,
    create_refresh_token,
    store_refresh_token,
    store_session,
)
from app.core.config import settings
from app.core.utils import generate_uuid
from app.models.models import SysUser
from app.services.web_auth_roles import resolve_web_role_for_user


def _avatar_url_field(user: SysUser):
    raw = getattr(user, "avatar", None)
    if not raw:
        return None
    s = str(raw).strip()
    return s if s else None


def _web_role_list(web_role: str) -> list:
    role = (web_role or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
    return [role]


def build_user_info_payload(user: SysUser, web_role: str) -> dict:
    phone = (getattr(user, "phone_no", None) or "").strip() or None
    role = (web_role or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
    return {
        "id": user.id,
        "username": user.user_no,
        "realName": user.nick_name,
        "phone": phone,
        "email": (getattr(user, "email", None) or "").strip() or None,
        "role": role,
        "roles": _web_role_list(role),
        "regionId": user.dept_id,
        "regionName": None,
        "avatarUrl": _avatar_url_field(user),
    }


def _jwt_user_payload(user: SysUser, web_role: str) -> dict:
    tenant_no = (getattr(user, "tenant_no", None) or "").strip() or settings.DEFAULT_TENANT_NO
    return {
        "sub": user.id,
        "username": user.user_no,
        "role": web_role,
        "region_id": user.dept_id,
        "real_name": user.nick_name,
        "tenant_no": tenant_no,
    }


def issue_web_login_tokens(db: Session, user: SysUser) -> Dict[str, Any]:
    if getattr(user, "status", "1") != "1":
        raise ValueError("账号已被停用,请联系管理员")

    web_role = resolve_web_role_for_user(db, user.id, user.tenant_no)
    session_id = generate_uuid()
    access_jti = generate_uuid()
    refresh_jti = generate_uuid()

    access_token = create_access_token(
        data=_jwt_user_payload(user, web_role),
        session_id=session_id,
        token_jti=access_jti,
    )
    refresh_token = create_refresh_token(
        data={
            "sub": user.id,
            "username": user.user_no,
            "role": web_role,
        },
        session_id=session_id,
        refresh_jti=refresh_jti,
    )

    store_session(session_id=session_id, user_id=user.id)
    store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)

    return {
        "token": access_token,
        "refreshToken": refresh_token,
        "expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
        "userInfo": build_user_info_payload(user, web_role),
    }
  • Step 2: 精简 public.py 密码登录发 token

将 _build_user_info_payload / _jwt_user_payload / _avatar_url_field / _web_role_list 改为从 web_login_tokens 导入(或让 public.py 仅调用 issue_web_login_tokens,其它引用 build_user_info_payload 的地方同步改 import)。

web_login 成功分支替换为:

    try:
        data = issue_web_login_tokens(db, user)
    except ValueError as exc:
        return error_response(403, str(exc))
    return success_response(data)

(停用检查可保留在登录前;issue_web_login_tokens 内再校验一次无妨。)

current-user / mp-webview-exchange 若仍用 _build_user_info_payload,改为:

from app.services.web_login_tokens import build_user_info_payload, issue_web_login_tokens
  • Step 3: 实现 mini_auth.py(仅 wechat-login,无 bind)
"""小程序鉴权:微信手机号快捷登录。"""
from __future__ import annotations

from datetime import datetime

from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session

from app.core.database import get_db
from app.core.response import error_response, success_response
from app.services.web_login_tokens import issue_web_login_tokens
from app.services.wechat_login_service import resolve_wechat_login_user
from app.services.wechat_mini_auth import (
    WechatMiniAuthError,
    code2session,
    get_phone_number,
)

router = APIRouter(prefix="/mini/auth", tags=["小程序鉴权"])


@router.post("/wechat-login", response_model=dict)
def wechat_login(body: dict, db: Session = Depends(get_db)):
    """手机号快捷登录:须 loginCode + phoneCode;匹配 phone_no 并绑定 wx_openid。"""
    login_code = (body.get("loginCode") or "").strip()
    phone_code = (body.get("phoneCode") or "").strip()
    if not phone_code:
        return error_response(400, "请授权手机号登录,或使用账号密码登录")

    try:
        sess = code2session(login_code)
    except WechatMiniAuthError as exc:
        return error_response(400, str(exc))

    try:
        phone = get_phone_number(phone_code)
    except WechatMiniAuthError as exc:
        return error_response(400, str(exc))

    user, err_msg, err_code = resolve_wechat_login_user(
        db, openid=sess["openid"], phone=phone
    )
    if err_msg:
        return error_response(err_code or 400, err_msg)

    unionid = (sess.get("unionid") or "").strip()
    if unionid and not (getattr(user, "wx_unionid", None) or "").strip():
        user.wx_unionid = unionid
        user.update_time = datetime.now()
        db.add(user)
        db.commit()
        db.refresh(user)

    try:
        data = issue_web_login_tokens(db, user)
    except ValueError as exc:
        return error_response(403, str(exc))
    return success_response(data)
  • Step 4: 注册路由

app/api/__init__.py:

from app.api.mini_auth import router as mini_auth_router
# __all__ 增加 "mini_auth_router"

app/main.py:

from app.api import (..., mini_auth_router)
# ...
app.include_router(mini_auth_router, prefix=settings.API_V1_PREFIX)
  • Step 5: 冒烟(无需真微信)
cd backend && source venv/bin/activate && python -c "from app.main import app; print([r.path for r in app.routes if 'wechat' in getattr(r,'path','')])"

Expected: 含 /api/mini/auth/wechat-login(或带 prefix 的等价路径)。

pytest tests/test_wechat_login_resolve.py tests/test_wechat_mini_auth_phone.py -v

Expected: PASS

  • Step 6: Commit
git add backend/app/services/web_login_tokens.py backend/app/api/public.py backend/app/api/mini_auth.py backend/app/api/__init__.py backend/app/main.py
git commit -m "$(cat <<'EOF'
feat(api): add mini wechat-login and share JWT issuance with web login.

EOF
)"

Task 6: 小程序 store + API 常量

Files:

  • Modify: miniprogram/config/api.js
  • Modify: miniprogram/utils/request.js
  • Modify: miniprogram/stores/user.js

  • [ ] Step 1: API 常量

AUTH_API 增加:

  WECHAT_LOGIN: '/mini/auth/wechat-login'
  • Step 2: request 排除路径

API_ENCRYPT_EXCLUDE_PATHS 字符串追加:,/mini/auth/wechat-login

(当前 API_ENCRYPT_ENABLED = false,仍先对齐后端排除列表,避免日后开启加密踩坑。)

  • Step 3: user.js 增加 wechatLogin

在 login 函数旁增加:

  async function wechatLogin(params) {
    isLoading.value = true
    try {
      const res = await request({
        url: AUTH_API.WECHAT_LOGIN,
        method: 'POST',
        data: {
          loginCode: params.loginCode,
          phoneCode: params.phoneCode
        }
      })
      const result = parseResponseJson(res)
      if (result && result.code === 200 && result.data) {
        setLoginInfo({
          token: result.data.token,
          refreshToken: result.data.refreshToken,
          userInfo: result.data.userInfo
        })
        await fetchMenuCodes()
        return true
      }
      uni.showToast({ title: (result && result.message) || '登录失败', icon: 'none' })
      return false
    } catch (e) {
      uni.showToast({ title: (e && e.message) || '登录失败', icon: 'none' })
      return false
    } finally {
      isLoading.value = false
    }
  }

在 return 中导出 wechatLogin。

  • Step 4: Commit
git add miniprogram/config/api.js miniprogram/utils/request.js miniprogram/stores/user.js
git commit -m "$(cat <<'EOF'
feat(miniprogram): add wechatLogin API client on user store.

EOF
)"

Task 7: 登录页 UI + getPhoneNumber

Files:

  • Modify: miniprogram/pages/login/login.vue

  • [ ] Step 1: 模板 — 协议下方增加按钮

在现有 login-footer(协议行)之后、protocol-dialog 之前插入(对齐 zkxw 结构):

      <view class="login-divider">或</view>

      <button
        v-if="agreeProtocol"
        class="login-wechat"
        :disabled="isLoading"
        open-type="getPhoneNumber"
        @getphonenumber="onGetPhoneNumber"
      >
        <text class="login-wechat__label">手机号快捷登录</text>
      </button>
      <view
        v-else
        class="login-wechat"
        :class="{ 'is-disabled': isLoading }"
        @tap="onWechatGate"
      >
        <text class="login-wechat__label">手机号快捷登录</text>
      </view>
  • Step 2: 协议弹窗支持 getPhoneNumber 确认键

将快捷登录门禁弹窗的确认按钮改为:当 protocolDialogForWechat === true 时使用:

<button
  class="protocol-dialog__btn protocol-dialog__btn--confirm"
  open-type="getPhoneNumber"
  @getphonenumber="onAgreeGetPhoneNumber"
>
  同意并登录
</button>

密码登录门禁仍用原「是,继续登录」普通按钮(protocolDialogForWechat === false)。

实现要点:

  • protocolDialogForWechat ref,默认 false
  • onWechatGate:protocolDialogForWechat = true,打开弹窗
  • 密码登录未勾选协议:保持现有 Promise 弹窗,protocolDialogForWechat = false
  • onAgreeGetPhoneNumber(e):设 agreeProtocol = true,关弹窗,调用 onGetPhoneNumber(e)

  • [ ] Step 3: 脚本 — 授权登录

const onWechatGate = () => {
  if (isLoading.value) return
  protocolDialogForWechat.value = true
  protocolDialogVisible.value = true
}

const onAgreeGetPhoneNumber = (e) => {
  agreeProtocol.value = true
  protocolDialogVisible.value = false
  protocolDialogForWechat.value = false
  onGetPhoneNumber(e)
}

const onGetPhoneNumber = async (e) => {
  if (isLoading.value) return
  if (!agreeProtocol.value) {
    onWechatGate()
    return
  }
  const detail = (e && e.detail) || {}
  const errMsg = detail.errMsg || ''
  if (errMsg && errMsg.indexOf('ok') === -1) {
    uni.showToast({ title: '需要授权手机号,或请使用账号密码登录', icon: 'none' })
    return
  }
  const phoneCode = detail.code
  if (!phoneCode) {
    uni.showToast({ title: '需要授权手机号,或请使用账号密码登录', icon: 'none' })
    return
  }
  isLoading.value = true
  try {
    const loginRes = await new Promise((resolve, reject) => {
      uni.login({ provider: 'weixin', success: resolve, fail: reject })
    })
    const ok = await userStore.wechatLogin({
      loginCode: loginRes.code,
      phoneCode
    })
    if (ok) {
      userStore.navigateToHome()
    }
  } catch (err) {
    uni.showToast({ title: (err && err.message) || '登录失败', icon: 'none' })
  } finally {
    isLoading.value = false
  }
}

注意:userStore.wechatLogin 内部已切 isLoading;页面层再设一层时避免双重 loading 打架——若 store 已管理,页面可不再置 isLoading,或让 store 的 wechatLogin 不改全局 isLoading、仅返回结果。推荐:页面控制 isLoading,store 的 wechatLogin 不要再置 isLoading(或抽内部 _applyLoginSuccess)。实现时二选一保持单一 loading 源。

  • Step 4: 样式(对齐 zkxw)

使用三墙主色,结构对齐 zkxw:

.login-divider {
  display: flex;
  align-items: center;
  gap: 24rpx;
  margin: 44rpx 0 28rpx;
  width: 100%;
  font-size: 24rpx;
  color: #94a3b8;
}

.login-divider::before,
.login-divider::after {
  content: '';
  flex: 1;
  height: 1px;
  background: #e2e8f0;
}

.login-wechat {
  height: 88rpx;
  border-radius: 24rpx;
  border: 1px solid #e2e8f0;
  background: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  margin: 0;
  padding: 0;
  line-height: normal;
  box-sizing: border-box;
  font-size: 28rpx;
  font-weight: 500;
  color: #1e293b;
}

button.login-wechat::after {
  border: none;
}

.login-wechat__label {
  font-size: 28rpx;
  font-weight: 500;
  color: #1e293b;
}
  • Step 5: Commit
git add miniprogram/pages/login/login.vue miniprogram/stores/user.js
git commit -m "$(cat <<'EOF'
feat(miniprogram): add phone quick login button on login page.

EOF
)"

Task 8: 运维清单 + 图谱更新 + 人工验收说明

Files:

  • (可选注释)backend/.env / .env.production — 仅人工填写,Agent 勿把真实 Secret 写入仓库
  • Run: ./scripts/update-graph.sh backend 与 ./scripts/update-graph.sh(或项目约定的 miniprogram 图谱脚本)

  • [ ] Step 1: 交付运维检查清单(写入 PR / 回复用户,勿提交真实 Secret)

1. 在服务器执行 docs/sql/migrations/2026-09-18-sys-user-wx-openid.sql
2. 配置 WECHAT_MP_APPID=wx7db1ddb2a90c9425
3. 配置 WECHAT_MP_SECRET=<公众平台 AppSecret>
4. 微信公众平台:企业认证 + 手机号快速验证已开通
5. request 合法域名含 API 主机
6. 真机:勾选协议 → 手机号快捷登录 → 进工作台
7. 真机:密码登录仍可用
8. 员工 sys_user.phone_no 已维护且唯一
  • Step 2: 更新知识图谱
./scripts/update-graph.sh backend
# 若有 miniprogram 目标:
./scripts/update-graph.sh  # 或按 AGENTS.md / scripts 说明
  • Step 3: Commit 图谱(若有 diff)
git add backend/graphify-out miniprogram/graphify-out graphify-out 2>/dev/null || true
git status
# 仅提交本功能相关 graphify 变更
git commit -m "$(cat <<'EOF'
chore: update graphify after mini phone quick login.

EOF
)"

Spec 覆盖自检

Spec 要求 Task
协议下方按钮 + zkxw 样式 Task 7
getPhoneNumber + uni.login + wechat-login Task 6–7
phone_no 匹配、绑 openid、不自动注册 Task 4–5
不做密码登录静默绑 / 不做 openid-only Task 5(无 bind 路由)
WECHATMP* 独立、WXPAY 不动 Task 2–3
JWT 与密码登录一致 Task 5
DDL + ORM Task 1
加密排除 Task 2 + Task 6
失败文案 Task 4–5、Task 7
真机验收清单 Task 8

执行说明

实现前请在目标库执行迁移 SQL,并在本地/生产 .env 填入 WECHAT_MP_SECRET(勿提交仓库)。前端联调优先真机。