Prechádzať zdrojové kódy

feat: implement WeChat mini login with openid bind and phone match

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 mesiacov pred
rodič
commit
efd7efbc69

+ 1 - 0
backend/.env.example

@@ -50,6 +50,7 @@ DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
 
 # 微信支付配置示例
 # 小程序支付使用 JSAPI(trade_type=JSAPI)
+# 同时复用于小程序微信登录(code2session / 手机号):WXPAY_MINI_APPID + WXPAY_SECRET
 # WXPAY_MCHID=1900000109
 # WXPAY_MINI_APPID=wx1234567890abcdef
 # WXPAY_SECRET=your-miniapp-secret

+ 2 - 0
backend/app/api/__init__.py

@@ -34,6 +34,7 @@ from app.api.cleanroom_garment import router as cleanroom_garment_router
 from app.api.market import router as market_router
 from app.api.pre_transit_sample import router as pre_transit_sample_router
 from app.api.oa_approval import router as oa_approval_router
+from app.api.mini_auth import router as mini_auth_router
 
 __all__ = [
     "public_router",
@@ -71,4 +72,5 @@ __all__ = [
     "market_router",
     "pre_transit_sample_router",
     "oa_approval_router",
+    "mini_auth_router",
 ]

+ 130 - 0
backend/app/api/mini_auth.py

@@ -0,0 +1,130 @@
+"""小程序鉴权:微信登录 / 绑定 openid。"""
+from __future__ import annotations
+
+from datetime import datetime
+
+from fastapi import APIRouter, Depends, HTTPException, Request
+from sqlalchemy.orm import Session
+
+from app.core.auth import get_current_user_by_token
+from app.core.database import get_db
+from app.core.response import error_response, success_response
+from app.crud.user import user_crud
+from app.services.issue_user_tokens import issue_tokens_for_user
+from app.services.wechat_mini_auth import (
+    WechatMiniAuthError,
+    code2session,
+    get_phone_number,
+)
+
+router = APIRouter(prefix="/mini/auth", tags=["小程序鉴权"])
+
+
+async def _resolve_user_id(request: Request, db: Session) -> str:
+    authorization = request.headers.get("Authorization", "")
+    if not authorization.startswith("Bearer "):
+        raise HTTPException(status_code=401, detail="未授权")
+    token = authorization.replace("Bearer ", "")
+    current = await get_current_user_by_token(token=token, db=db)
+    user_id = current.get("user_id") or current.get("sub")
+    if not user_id:
+        raise HTTPException(status_code=401, detail="未授权")
+    return user_id
+
+
+@router.post("/wechat-login", response_model=dict)
+async def wechat_login(body: dict, db: Session = Depends(get_db)):
+    """微信授权登录:优先 openid;未绑定则用手机号匹配员工并绑定。"""
+    login_code = (body.get("loginCode") or "").strip()
+    phone_code = (body.get("phoneCode") or "").strip() or None
+
+    try:
+        sess = code2session(login_code)
+    except WechatMiniAuthError as exc:
+        return error_response(400, str(exc))
+
+    openid = sess["openid"]
+    user = user_crud.get_by_wx_openid(db, openid)
+    if user:
+        try:
+            data = await issue_tokens_for_user(db, user)
+        except ValueError as exc:
+            return error_response(403, str(exc))
+        return success_response(data)
+
+    if not phone_code:
+        return error_response(400, "请授权手机号登录,或使用账号密码登录")
+
+    try:
+        phone = get_phone_number(phone_code)
+    except WechatMiniAuthError as exc:
+        return error_response(400, str(exc))
+
+    users = user_crud.list_active_by_phone_no(db, phone)
+    if not users:
+        return error_response(
+            401, "未找到与该手机号匹配的员工账号,请使用账号密码登录"
+        )
+    if len(users) > 1:
+        return error_response(400, "手机号对应多个账号,请使用账号密码登录")
+
+    user = users[0]
+    other = user_crud.get_by_wx_openid(db, openid)
+    if other and other.id != user.id:
+        return error_response(409, "该微信已绑定其他账号")
+    existing_oid = (getattr(user, "wx_openid", None) or "").strip()
+    if existing_oid and existing_oid != openid:
+        return error_response(409, "该账号已绑定其他微信,请联系管理员")
+
+    user.wx_openid = openid
+    user.update_time = datetime.now()
+    db.add(user)
+    db.commit()
+    db.refresh(user)
+
+    try:
+        data = await issue_tokens_for_user(db, user)
+    except ValueError as exc:
+        return error_response(403, str(exc))
+    return success_response(data)
+
+
+@router.post("/wechat-bind", response_model=dict)
+async def wechat_bind(
+    request: Request,
+    body: dict,
+    db: Session = Depends(get_db),
+):
+    """密码登录后绑定当前微信 openid(需 Bearer)。"""
+    try:
+        user_id = await _resolve_user_id(request, db)
+    except HTTPException:
+        return error_response(401, "未授权")
+
+    login_code = (body.get("loginCode") or "").strip()
+    try:
+        sess = code2session(login_code)
+    except WechatMiniAuthError as exc:
+        return error_response(400, str(exc))
+
+    openid = sess["openid"]
+    user = user_crud.get(db, user_id)
+    if not user or user.del_flag != 0:
+        return error_response(401, "用户不存在")
+
+    other = user_crud.get_by_wx_openid(db, openid)
+    if other and other.id != user.id:
+        return error_response(409, "该微信已绑定其他账号")
+
+    existing_oid = (getattr(user, "wx_openid", None) or "").strip()
+    if existing_oid == openid:
+        return success_response({"bound": True, "message": "已绑定"})
+
+    if existing_oid and existing_oid != openid:
+        return error_response(409, "该账号已绑定其他微信,请联系管理员")
+
+    user.wx_openid = openid
+    user.update_time = datetime.now()
+    db.add(user)
+    db.commit()
+    return success_response({"bound": True, "message": "绑定成功"})

+ 1 - 0
backend/app/core/config.py

@@ -134,6 +134,7 @@ class Settings(BaseSettings):
         "/api/web/auth/login-rsa-public-key,"
         "/api/web/auth/login,"
         "/api/web/auth/refresh-token,"
+        "/api/mini/auth/wechat-login,"
         "/api/web/admin/attachment/upload,"
         "/api/web/admin/products/upload-image,"
         "/api/web/admin/categories/upload-image,"

+ 24 - 0
backend/app/crud/user.py

@@ -31,6 +31,30 @@ class _UserCRUDCompat(CRUDBase[SysUser]):
             .first()
         )
 
+    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]:
+        phone = (phone_no or "").strip()
+        if not phone:
+            return []
+        return (
+            db.query(SysUser)
+            .filter(
+                SysUser.phone_no == phone,
+                SysUser.del_flag == 0,
+                SysUser.status == "1",
+            )
+            .all()
+        )
+
 
 user_crud = _UserCRUDCompat(SysUser)
 

+ 2 - 1
backend/app/main.py

@@ -13,7 +13,7 @@ from app.core.database import SessionLocal
 from app.core.api_request_log_middleware import ApiRequestLogMiddleware
 from app.core.api_crypto_middleware import ApiCryptoMiddleware
 from app.models import models  # noqa: F401
-from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router
+from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router
 
 
 @asynccontextmanager
@@ -133,6 +133,7 @@ app.include_router(cleanroom_garment_router, prefix=settings.API_V1_PREFIX)
 app.include_router(market_router, prefix=settings.API_V1_PREFIX)
 app.include_router(pre_transit_sample_router, prefix=settings.API_V1_PREFIX)
 app.include_router(oa_approval_router, prefix=settings.API_V1_PREFIX)
+app.include_router(mini_auth_router, prefix=settings.API_V1_PREFIX)
 
 
 def _mask_secret(value: str) -> str:

+ 1 - 0
backend/app/models/models.py

@@ -31,6 +31,7 @@ class SysUser(Base):
     nick_name = Column(String(30), nullable=False, comment="用户名")
     email = Column(String(50), nullable=False, default="", comment="用户邮箱")
     phone_no = Column(String(11), nullable=False, default="", comment="手机号码")
+    wx_openid = Column(String(64), nullable=True, comment="微信小程序 openid")
     hire_date = Column(Date, nullable=True, comment="入职日期")
     probation_months = Column(Integer, nullable=True, comment="试用期月数")
     last_work_day = Column(Date, nullable=True, comment="最后工作日")

+ 102 - 0
backend/app/services/issue_user_tokens.py

@@ -0,0 +1,102 @@
+"""为已通过校验的 SysUser 签发与 web 登录一致的 JWT 会话包。"""
+from __future__ import annotations
+
+from typing import Optional
+
+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 (
+    get_user_role_names,
+    resolve_web_role_for_user,
+    user_has_active_roles,
+)
+
+
+def _avatar_url_field(user: SysUser) -> Optional[str]:
+    raw = getattr(user, "avatar", None)
+    if not raw:
+        return None
+    s = str(raw).strip()
+    return s if s else None
+
+
+def build_user_info_payload(user: SysUser, web_role: str, role_names: list = None) -> dict:
+    phone = (getattr(user, "phone_no", None) or "").strip() or None
+    return {
+        "id": user.id,
+        "username": user.user_no,
+        "realName": user.nick_name,
+        "phone": phone,
+        "email": (getattr(user, "email", None) or "").strip() or None,
+        "hireDate": user.hire_date.strftime("%Y-%m-%d") if getattr(user, "hire_date", None) else None,
+        "probationMonths": getattr(user, "probation_months", None),
+        "lastWorkDay": user.last_work_day.strftime("%Y-%m-%d")
+        if getattr(user, "last_work_day", None)
+        else None,
+        "sex": getattr(user, "sex", None) or "0",
+        "remark": (getattr(user, "remark", None) or "").strip() or None,
+        "role": web_role,
+        "roleNames": role_names or [],
+        "regionId": user.dept_id,
+        "regionName": None,
+        "avatarUrl": _avatar_url_field(user),
+    }
+
+
+def _jwt_user_payload(user: SysUser, web_role: str) -> dict:
+    return {
+        "sub": user.id,
+        "username": user.user_no,
+        "role": web_role,
+        "region_id": user.dept_id,
+        "real_name": user.nick_name,
+    }
+
+
+async def issue_tokens_for_user(db: Session, user: SysUser) -> dict:
+    """校验停用/角色后签发 token。失败抛 ValueError(中文 message)。"""
+    if user.status != "1":
+        raise ValueError("账号已被停用,请联系管理员")
+    if not user_has_active_roles(db, user.id):
+        raise ValueError("账号未分配角色,请联系管理员")
+
+    web_role = resolve_web_role_for_user(db, user.id)
+    role_names = get_user_role_names(db, user.id)
+
+    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,
+    )
+
+    await store_session(session_id=session_id, user_id=user.id)
+    await 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, role_names),
+    }

+ 122 - 0
backend/app/services/wechat_mini_auth.py

@@ -0,0 +1,122 @@
+"""微信小程序登录: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 _require_mini_credentials() -> tuple:
+    appid = (settings.WXPAY_MINI_APPID or "").strip()
+    secret = (settings.WXPAY_SECRET or "").strip()
+    if not appid or not secret:
+        raise WechatMiniAuthError("未配置小程序凭证(WXPAY_MINI_APPID / WXPAY_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

+ 15 - 0
backend/tests/test_wechat_mini_auth_phone.py

@@ -0,0 +1,15 @@
+"""手机号规范化单测(微信登录匹配 sys_user.phone_no)。"""
+from app.services.wechat_mini_auth import normalize_cn_mobile
+
+
+def test_normalize_strips_plus_86():
+    assert normalize_cn_mobile("+8613800138000") == "13800138000"
+
+
+def test_normalize_strips_spaces():
+    assert normalize_cn_mobile(" 138 0013 8000 ") == "13800138000"
+
+
+def test_normalize_empty():
+    assert normalize_cn_mobile("") == ""
+    assert normalize_cn_mobile(None) == ""

+ 8 - 0
docs/sql/alter-sys-user-wx-openid.sql

@@ -0,0 +1,8 @@
+-- 员工微信小程序 openid 绑定字段(微信授权登录)
+-- MySQL 5.7:唯一索引允许多个 NULL
+
+ALTER TABLE `sys_user`
+  ADD COLUMN `wx_openid` varchar(64) DEFAULT NULL COMMENT '微信小程序 openid' AFTER `phone_no`;
+
+ALTER TABLE `sys_user`
+  ADD UNIQUE INDEX `uk_sys_user_wx_openid` (`wx_openid`);

+ 1 - 1
mobile-h5/manifest.json

@@ -50,7 +50,7 @@
     "quickapp" : {},
     /* 小程序特有相关 */
     "mp-weixin" : {
-        "appid" : "",
+        "appid" : "wxa261ad85f2c99b26",
         "setting" : {
             "urlCheck" : false
         },

+ 62 - 1
mobile-h5/pages/login/index.vue

@@ -74,7 +74,23 @@
 
     <view class="login-divider">或</view>
 
-    <view class="login-wechat is-disabled">
+    <button
+      v-if="agreed"
+      class="login-wechat"
+      :class="{ 'is-disabled': loading }"
+      open-type="getPhoneNumber"
+      :disabled="loading"
+      @getphonenumber="onGetPhoneNumber"
+    >
+      <image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
+      <text>微信授权登录</text>
+    </button>
+    <view
+      v-else
+      class="login-wechat"
+      :class="{ 'is-disabled': loading }"
+      @click="onWechatGate"
+    >
       <image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
       <text>微信授权登录</text>
     </view>
@@ -105,6 +121,7 @@
   import {
     isLoggedIn,
     passwordLogin,
+    wechatLogin,
     clearLegacyMockSessionIfNeeded
   } from '../../utils/auth.js'
   import { validateCredentials } from '../../utils/validate-credentials.js'
@@ -199,6 +216,50 @@
         } finally {
           this.loading = false
         }
+      },
+      async onWechatGate() {
+        if (this.loading) return
+        await this.ensureAgree()
+      },
+      async onGetPhoneNumber(e) {
+        if (this.loading) return
+        if (!(await this.ensureAgree())) 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
+        }
+        this.loading = true
+        try {
+          const loginRes = await new Promise((resolve, reject) => {
+            uni.login({ provider: 'weixin', success: resolve, fail: reject })
+          })
+          await wechatLogin({
+            loginCode: loginRes.code,
+            phoneCode
+          })
+          uni.showToast({ title: '登录成功', icon: 'success' })
+          setTimeout(() => this.goHome(), 400)
+        } catch (err) {
+          uni.showToast({
+            title: (err && err.message) || '登录失败',
+            icon: 'none'
+          })
+        } finally {
+          this.loading = false
+        }
       }
     }
   }

+ 9 - 0
mobile-h5/styles/components/login.scss

@@ -159,6 +159,15 @@
   font-size: 28rpx;
   font-weight: 500;
   color: var(--text-primary);
+  width: 100%;
+  margin: 0;
+  padding: 0;
+  line-height: normal;
+  box-sizing: border-box;
+}
+
+button.login-wechat::after {
+  border: none;
 }
 
 .login-wechat.is-disabled {

+ 44 - 0
mobile-h5/utils/auth.js

@@ -87,5 +87,49 @@ export async function passwordLogin({ username, password }) {
     refreshToken: data.refreshToken,
     userInfo: data.userInfo
   })
+  // 密码登录成功后静默绑定微信(失败不影响登录)
+  bindWechatQuietly()
   return data
 }
+
+/**
+ * 微信授权登录(openid 优先;未绑定需 phoneCode)。
+ */
+export async function wechatLogin({ loginCode, phoneCode }) {
+  const payload = { loginCode }
+  if (phoneCode) {
+    payload.phoneCode = phoneCode
+  }
+  const data = await post('/mini/auth/wechat-login', payload)
+  if (!data || !data.token) {
+    throw new Error('登录失败')
+  }
+  saveSession({
+    token: data.token,
+    refreshToken: data.refreshToken,
+    userInfo: data.userInfo
+  })
+  return data
+}
+
+/**
+ * 已登录态下用 wx.login code 绑定 openid;失败静默。
+ */
+export async function bindWechatQuietly() {
+  try {
+    const loginRes = await new Promise((resolve, reject) => {
+      uni.login({ provider: 'weixin', success: resolve, fail: reject })
+    })
+    const loginCode = loginRes && loginRes.code
+    if (!loginCode) return
+    const token = getToken()
+    if (!token) return
+    await post(
+      '/mini/auth/wechat-bind',
+      { loginCode },
+      { Authorization: 'Bearer ' + token }
+    )
+  } catch (e) {
+    console.warn('wechat bind skipped', e && e.message)
+  }
+}