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 + 协议门禁 |
Files:
docs/sql/migrations/2026-09-18-sys-user-wx-openid.sqldocs/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`);
在 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 数据行。)
在 SysUser 中 avatar 后增加:
wx_openid = Column(String(64), nullable=True, comment="微信小程序 openid")
wx_unionid = Column(String(64), nullable=True, comment="微信 unionid")
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
)"
WECHAT_MP_* + 加密排除Files:
backend/app/core/config.pyModify: 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,
.env.example 占位追加(无真实 Secret):
# 门户小程序登录(AppID 与 miniprogram/manifest.json mp-weixin.appid 一致)
# WECHAT_MP_APPID=wx7db1ddb2a90c9425
# WECHAT_MP_SECRET=
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
)"
Files:
backend/tests/test_wechat_mini_auth_phone.pybackend/app/services/wechat_mini_auth.pyModify: 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
cd backend && source venv/bin/activate && pytest tests/test_wechat_mini_auth_phone.py -v
Expected: ModuleNotFoundError 或 import 失败。
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
在 _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 精确匹配并保证入库规范化。
cd backend && source venv/bin/activate && pytest tests/test_wechat_mini_auth_phone.py -v
Expected: PASS
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
)"
resolve_wechat_login_user + 单测(TDD)Files:
backend/tests/test_wechat_login_resolve.pyCreate: 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()
cd backend && source venv/bin/activate && pytest tests/test_wechat_login_resolve.py -v
Expected: import 失败。
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 明确本期不做静默换票)。
cd backend && source venv/bin/activate && pytest tests/test_wechat_login_resolve.py tests/test_wechat_mini_auth_phone.py -v
Expected: PASS
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
)"
mini_auth 路由Files:
backend/app/services/web_login_tokens.pybackend/app/api/public.pybackend/app/api/mini_auth.pybackend/app/api/__init__.pyModify: 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),
}
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
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)
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)
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
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
)"
Files:
miniprogram/config/api.jsminiprogram/utils/request.jsModify: miniprogram/stores/user.js
[ ] Step 1: API 常量
AUTH_API 增加:
WECHAT_LOGIN: '/mini/auth/wechat-login'
API_ENCRYPT_EXCLUDE_PATHS 字符串追加:,/mini/auth/wechat-login
(当前 API_ENCRYPT_ENABLED = false,仍先对齐后端排除列表,避免日后开启加密踩坑。)
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。
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
)"
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>
将快捷登录门禁弹窗的确认按钮改为:当 protocolDialogForWechat === true 时使用:
<button
class="protocol-dialog__btn protocol-dialog__btn--confirm"
open-type="getPhoneNumber"
@getphonenumber="onAgreeGetPhoneNumber"
>
同意并登录
</button>
密码登录门禁仍用原「是,继续登录」普通按钮(protocolDialogForWechat === false)。
实现要点:
protocolDialogForWechat ref,默认 falseonWechatGate:protocolDialogForWechat = true,打开弹窗protocolDialogForWechat = falseonAgreeGetPhoneNumber(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 源。
使用三墙主色,结构对齐 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;
}
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
)"
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 已维护且唯一
./scripts/update-graph.sh backend
# 若有 miniprogram 目标:
./scripts/update-graph.sh # 或按 AGENTS.md / scripts 说明
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 要求 | 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(勿提交仓库)。前端联调优先真机。