diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index e6a70ba..a788a66 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -2,12 +2,22 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session +from datetime import datetime + from app.core.security import create_access_token +from app.config import get_settings from app.db import get_db from app.deps import get_current_user from app.integrations.sms_client import SmsClient +from app.integrations.wechat_client import WechatMiniClient, WechatMiniClientError from app.models import Permission, User -from app.schemas.auth import CurrentUserResponse, LoginRequest, SmsCodeRequest, TokenResponse +from app.schemas.auth import ( + CurrentUserResponse, + LoginRequest, + SmsCodeRequest, + TokenResponse, + WechatLoginRequest, +) from app.services.audit_service import write_audit from app.services.user_service import get_or_create_login_user @@ -60,6 +70,51 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse ) +@router.post("/wechat-login", response_model=TokenResponse) +def wechat_login(payload: WechatLoginRequest, db: Session = Depends(get_db)) -> TokenResponse: + """微信授权登录:拿手机号在用户表中匹配,找到则登录,找不到提示联系管理员。""" + settings = get_settings() + client = WechatMiniClient() + phone: str | None = None + + if client.configured and payload.phone_code: + try: + phone = client.get_phone_number(payload.phone_code) + except WechatMiniClientError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + elif payload.phone: + # 未配置微信凭据或前端未拿到 phone_code 时的兜底(开发/联调) + phone = payload.phone.strip() + elif not client.configured: + # mock 模式且未传 phone:用配置里的测试手机号,方便本地联调 + phone = settings.wx_mini_mock_phone + + if not phone: + raise HTTPException(status_code=400, detail="未获取到微信手机号") + + # 可选:换 openid 做审计追踪,失败不阻断登录 + if client.configured and payload.js_code: + try: + client.code2session(payload.js_code) + except WechatMiniClientError: + pass + + user = db.scalar(select(User).where(User.phone == phone)) + if user is None: + raise HTTPException(status_code=403, detail="未找到匹配账号,请联系管理员添加账号后使用") + if user.status != "active": + raise HTTPException(status_code=403, detail="账号已禁用") + + user.last_login_at = datetime.utcnow() + write_audit(db, "auth.wechat_login", actor_id=user.id, target_type="user", target_id=str(user.id)) + db.commit() + db.refresh(user) + return TokenResponse( + access_token=create_access_token(str(user.id)), + user=_build_current_user(db, user), + ) + + @router.post("/logout") def logout(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: write_audit(db, "auth.logout", actor_id=current_user.id, target_type="user", target_id=str(current_user.id)) diff --git a/backend/app/integrations/wechat_client.py b/backend/app/integrations/wechat_client.py new file mode 100644 index 0000000..1c6837f --- /dev/null +++ b/backend/app/integrations/wechat_client.py @@ -0,0 +1,70 @@ +import httpx + +from app.config import get_settings + + +class WechatMiniClientError(Exception): + pass + + +class WechatMiniClient: + """微信小程序 code2session / getPhoneNumber 客户端。 + + 未配置 appid/secret 时视为 mock 模式,由上层跳过真实调用。 + """ + + CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session" + ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token" + GET_PHONE_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber" + + def __init__(self) -> None: + settings = get_settings() + self.appid = settings.wx_mini_appid + self.secret = settings.wx_mini_secret + self.timeout = 10.0 + + @property + def configured(self) -> bool: + return bool(self.appid and self.secret) + + def code2session(self, js_code: str) -> dict: + params = { + "appid": self.appid, + "secret": self.secret, + "js_code": js_code, + "grant_type": "authorization_code", + } + with httpx.Client(timeout=self.timeout, trust_env=False) as client: + resp = client.get(self.CODE2SESSION_URL, params=params) + data = resp.json() + if data.get("errcode"): + raise WechatMiniClientError(f"code2session 失败: {data.get('errmsg')}") + return data + + def _access_token(self) -> str: + params = { + "grant_type": "client_credential", + "appid": self.appid, + "secret": self.secret, + } + with httpx.Client(timeout=self.timeout, trust_env=False) as client: + resp = client.get(self.ACCESS_TOKEN_URL, params=params) + data = resp.json() + token = data.get("access_token") + if not token: + raise WechatMiniClientError(f"获取 access_token 失败: {data.get('errmsg')}") + return token + + def get_phone_number(self, phone_code: str) -> str: + token = self._access_token() + url = f"{self.GET_PHONE_URL}?access_token={token}" + with httpx.Client(timeout=self.timeout, trust_env=False) as client: + resp = client.post(url, json={"code": phone_code}) + data = resp.json() + if data.get("errcode"): + raise WechatMiniClientError(f"getPhoneNumber 失败: {data.get('errmsg')}") + phone_info = data.get("phone_info") or {} + phone = phone_info.get("purePhoneNumber") or phone_info.get("phoneNumber") + if not phone: + raise WechatMiniClientError("getPhoneNumber 未返回手机号") + return phone diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index f222385..5a9183b 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -10,6 +10,15 @@ class LoginRequest(BaseModel): code: str = Field(min_length=4, max_length=8) +class WechatLoginRequest(BaseModel): + # wx.login() 返回的 jscode,用于换 openid(可选,仅记录用途) + js_code: str | None = None + # button open-type="getPhoneNumber" 事件返回的 code,用于换手机号 + phone_code: str | None = None + # mock 模式或跳过手机号服务时,前端可直接传手机号(生产可禁用) + phone: str | None = None + + class CurrentUserResponse(BaseModel): id: int phone: str diff --git a/miniapp/app.js b/miniapp/app.js index 8900a15..e5b4fda 100644 --- a/miniapp/app.js +++ b/miniapp/app.js @@ -10,7 +10,7 @@ App({ globalData: { // 后端 API 基址:开发环境需要在开发者工具「详情 - 本地设置」中勾选 // 「不校验合法域名、web-view (业务域名)、TLS 版本以及 HTTPS 证书」 - apiBase: 'http://localhost:8000', + apiBase: 'https://finai.ctc-zc.com:8775', token: '', user: null } diff --git a/miniapp/app.json b/miniapp/app.json index b04fd5e..14d95d8 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -4,6 +4,12 @@ "pages/home/home", "pages/chat/chat" ], + "plugins": { + "WechatSI": { + "version": "0.3.5", + "provider": "wx069ba97219f66d99" + } + }, "window": { "backgroundTextStyle": "light", "navigationBarBackgroundColor": "#ffffff", diff --git a/miniapp/images/wchat.png b/miniapp/images/wchat.png new file mode 100644 index 0000000..4d07666 Binary files /dev/null and b/miniapp/images/wchat.png differ diff --git a/miniapp/pages/login/login.js b/miniapp/pages/login/login.js index 875f5ba..c964f06 100644 --- a/miniapp/pages/login/login.js +++ b/miniapp/pages/login/login.js @@ -8,6 +8,7 @@ Page({ code: '', sending: false, submitting: false, + wxSubmitting: false, countdown: 0 }, @@ -69,6 +70,54 @@ Page({ } }, + wxLoginCode() { + return new Promise((resolve) => { + wx.login({ + success: (res) => resolve(res && res.code ? res.code : ''), + fail: () => resolve('') + }) + }) + }, + + async onGetPhoneNumber(e) { + if (this.data.wxSubmitting) return + const detail = e.detail || {} + // 用户拒绝授权 + if (!detail.code && !detail.encryptedData) { + wx.showToast({ title: '已取消微信授权', icon: 'none' }) + return + } + this.setData({ wxSubmitting: true }) + try { + const jsCode = await this.wxLoginCode() + const payload = { js_code: jsCode, phone_code: detail.code || '' } + const result = await request('/api/auth/wechat-login', { + method: 'POST', + data: payload + }) + app.globalData.token = result.access_token + app.globalData.user = result.user + wx.setStorageSync('token', result.access_token) + wx.setStorageSync('user', result.user) + wx.showToast({ title: '登录成功', icon: 'success' }) + wx.redirectTo({ url: '/pages/home/home' }) + } catch (err) { + const msg = (err && err.message) || '微信登录失败' + if (msg.indexOf('联系管理员') !== -1) { + wx.showModal({ + title: '无法登录', + content: '未找到匹配账号,请联系管理员添加账号后使用', + showCancel: false, + confirmText: '我知道了' + }) + } else { + wx.showToast({ title: msg, icon: 'none' }) + } + } finally { + this.setData({ wxSubmitting: false }) + } + }, + async login() { if (this.data.submitting) return const phone = (this.data.phone || '').trim() diff --git a/miniapp/pages/login/login.wxml b/miniapp/pages/login/login.wxml index 1a214fb..bdb8dc7 100644 --- a/miniapp/pages/login/login.wxml +++ b/miniapp/pages/login/login.wxml @@ -49,6 +49,20 @@ {{submitting ? '登录中…' : '登录'}} + + + + + {{wxSubmitting ? '授权中…' : '微信授权登录'}} + + 测试账号:13800000000 / 验证码 123456 diff --git a/miniapp/pages/login/login.wxss b/miniapp/pages/login/login.wxss index b4daa3b..5c1a7c6 100644 --- a/miniapp/pages/login/login.wxss +++ b/miniapp/pages/login/login.wxss @@ -144,3 +144,52 @@ font-size: 22rpx; color: var(--muted); } + +.divider { + display: flex; + align-items: center; + gap: 16rpx; + margin: 24rpx 0 8rpx; + color: var(--muted); + font-size: 22rpx; +} +.divider::before, +.divider::after { + content: ''; + flex: 1; + height: 1rpx; + background: var(--line); +} + +.wechat-entry { + display: flex; + flex-direction: column; + align-items: center; + gap: 12rpx; + margin-top: 8rpx; +} +.wechat-icon-btn { + width: 96rpx; + height: 96rpx; + padding: 0; + border-radius: 50%; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + line-height: normal; +} +.wechat-icon-btn::after { + border: none; +} +.wechat-icon-btn.disabled { + opacity: 0.5; +} +.wechat-icon-img { + width: 96rpx; + height: 96rpx; +} +.wechat-entry-tip { + font-size: 24rpx; + color: var(--muted); +}