diff --git a/backend/.env.example b/backend/.env.example index 172314e6..dc201f9e 100644 --- a/backend/.env.example +++ b/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 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 72153387..6a379b6f 100644 --- a/backend/app/api/__init__.py +++ b/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", ] diff --git a/backend/app/api/mini_auth.py b/backend/app/api/mini_auth.py new file mode 100644 index 00000000..b81053c2 --- /dev/null +++ b/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": "绑定成功"}) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 7966299b..eedb0f97 100644 --- a/backend/app/core/config.py +++ b/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," diff --git a/backend/app/crud/user.py b/backend/app/crud/user.py index 991f3b29..751117cc 100644 --- a/backend/app/crud/user.py +++ b/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) diff --git a/backend/app/main.py b/backend/app/main.py index 80db824e..7e9f0dc5 100644 --- a/backend/app/main.py +++ b/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: diff --git a/backend/app/models/models.py b/backend/app/models/models.py index 62cac070..0b2c0545 100644 --- a/backend/app/models/models.py +++ b/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="最后工作日") diff --git a/backend/app/services/issue_user_tokens.py b/backend/app/services/issue_user_tokens.py new file mode 100644 index 00000000..1252c01c --- /dev/null +++ b/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), + } diff --git a/backend/app/services/wechat_mini_auth.py b/backend/app/services/wechat_mini_auth.py new file mode 100644 index 00000000..39f55325 --- /dev/null +++ b/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 diff --git a/backend/tests/test_wechat_mini_auth_phone.py b/backend/tests/test_wechat_mini_auth_phone.py new file mode 100644 index 00000000..d831da0c --- /dev/null +++ b/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) == "" diff --git a/docs/sql/alter-sys-user-wx-openid.sql b/docs/sql/alter-sys-user-wx-openid.sql new file mode 100644 index 00000000..976a87be --- /dev/null +++ b/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`); diff --git a/mobile-h5/manifest.json b/mobile-h5/manifest.json index c01bfcf9..b6e59c76 100644 --- a/mobile-h5/manifest.json +++ b/mobile-h5/manifest.json @@ -50,7 +50,7 @@ "quickapp" : {}, /* 小程序特有相关 */ "mp-weixin" : { - "appid" : "", + "appid" : "wxa261ad85f2c99b26", "setting" : { "urlCheck" : false }, diff --git a/mobile-h5/pages/login/index.vue b/mobile-h5/pages/login/index.vue index 51af3a50..6ac7fdc5 100644 --- a/mobile-h5/pages/login/index.vue +++ b/mobile-h5/pages/login/index.vue @@ -74,7 +74,23 @@ 或 - + + 微信授权登录 @@ -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 + } } } } diff --git a/mobile-h5/styles/components/login.scss b/mobile-h5/styles/components/login.scss index 4d13a29e..5db96f9a 100644 --- a/mobile-h5/styles/components/login.scss +++ b/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 { diff --git a/mobile-h5/utils/auth.js b/mobile-h5/utils/auth.js index 502b4c55..b864116f 100644 --- a/mobile-h5/utils/auth.js +++ b/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) + } +}