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);
+}