Compare commits

..

3 Commits

Author SHA1 Message Date
shijing 4a7eb906d1 chore: 移除 config.py 硬编码凭据,.env 纳入忽略
- config.py 中的 tencent/wx_mini 密钥默认值改回空串,改由 .env 提供
- .gitignore 打开 .env 与 **/.env 规则,避免再次误提交

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:19:09 +08:00
shijing a759268d88 style: 精简顶栏,仅保留 logo,并微调首页卡片内边距
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:06:36 +08:00
shijing 79508ef85c feat: 新增微信授权登录(wx.login + getPhoneNumber)
- 后端加 /api/auth/wechat-login,用 phone_code 换手机号后匹配用户表登录
- 引入 WechatMiniClient 封装 code2session / getPhoneNumber,并做 mock 兜底
- 小程序登录页加"微信授权登录"按钮,apiBase 指向 finai.ctc-zc.com:8775
- 引入 WechatSI 插件占位

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-15 09:06:31 +08:00
13 changed files with 262 additions and 23 deletions

3
.gitignore vendored
View File

@ -16,7 +16,8 @@ Thumbs.db
*.sublime-* *.sublime-*
# 环境变量 / 密钥 # 环境变量 / 密钥
# .env .env
**/.env
.env.* .env.*
!.env.example !.env.example
*.pem *.pem

View File

@ -2,12 +2,22 @@ from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime
from app.core.security import create_access_token from app.core.security import create_access_token
from app.config import get_settings
from app.db import get_db from app.db import get_db
from app.deps import get_current_user from app.deps import get_current_user
from app.integrations.sms_client import SmsClient from app.integrations.sms_client import SmsClient
from app.integrations.wechat_client import WechatMiniClient, WechatMiniClientError
from app.models import Permission, User 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.audit_service import write_audit
from app.services.user_service import get_or_create_login_user 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") @router.post("/logout")
def logout(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: 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)) write_audit(db, "auth.logout", actor_id=current_user.id, target_type="user", target_id=str(current_user.id))

View File

@ -28,6 +28,10 @@ class Settings(BaseSettings):
tencent_secret_key: str = "" tencent_secret_key: str = ""
tencent_region: str = "ap-guangzhou" tencent_region: str = "ap-guangzhou"
wx_mini_appid: str = ""
wx_mini_secret: str = ""
wx_mini_mock_phone: str = "13800000000"
@property @property
def cors_origins(self) -> list[str]: def cors_origins(self) -> list[str]:
return [origin.strip() for origin in self.allowed_origins.split(",") if origin.strip()] return [origin.strip() for origin in self.allowed_origins.split(",") if origin.strip()]

View File

@ -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

View File

@ -10,6 +10,15 @@ class LoginRequest(BaseModel):
code: str = Field(min_length=4, max_length=8) 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): class CurrentUserResponse(BaseModel):
id: int id: int
phone: str phone: str

View File

@ -1,27 +1,9 @@
<script setup> <script setup>
import { computed } from 'vue'
import { useAppStore } from '@/stores/app'
import logoUrl from '@/assets/logo.png' import logoUrl from '@/assets/logo.png'
const store = useAppStore()
const displayName = computed(() => store.currentUser?.name || '未登录')
const avatarInitial = computed(() => (displayName.value || '?')[0].toUpperCase())
</script> </script>
<template> <template>
<header class="topbar"> <header class="topbar">
<img class="topbar-logo" :src="logoUrl" alt="中国建筑材料科学研究总院有限公司" /> <img class="topbar-logo" :src="logoUrl" alt="中国建筑材料科学研究总院有限公司" />
<div class="top-actions">
<span class="status-pill" :class="{ muted: !store.healthy }">
<span class="status-dot"></span>
{{ store.healthy ? '已连接' : '未连接' }}
</span>
<button class="topbar-icon" type="button" title="通知">🔔</button>
<div class="topbar-user">
<div class="topbar-user-avatar">{{ avatarInitial }}</div>
<span class="topbar-user-name">{{ displayName }}</span>
<span class="topbar-user-caret"></span>
</div>
</div>
</header> </header>
</template> </template>

View File

@ -312,7 +312,7 @@ button {
.topbar { .topbar {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: flex-end;
gap: 16px; gap: 16px;
padding: 0 24px; padding: 0 24px;
background: transparent; background: transparent;
@ -478,7 +478,7 @@ button {
grid-template-columns: 1fr auto; grid-template-columns: 1fr auto;
gap: 24px; gap: 24px;
align-items: center; align-items: center;
padding: 24px 28px; padding: 10px;
border-radius: 14px; border-radius: 14px;
background: background:
radial-gradient(circle at 90% 50%, #ffd1d4 0%, transparent 60%), radial-gradient(circle at 90% 50%, #ffd1d4 0%, transparent 60%),

View File

@ -10,7 +10,7 @@ App({
globalData: { globalData: {
// 后端 API 基址:开发环境需要在开发者工具「详情 - 本地设置」中勾选 // 后端 API 基址:开发环境需要在开发者工具「详情 - 本地设置」中勾选
// 「不校验合法域名、web-view (业务域名)、TLS 版本以及 HTTPS 证书」 // 「不校验合法域名、web-view (业务域名)、TLS 版本以及 HTTPS 证书」
apiBase: 'http://localhost:8000', apiBase: 'https://finai.ctc-zc.com:8775',
token: '', token: '',
user: null user: null
} }

View File

@ -4,6 +4,12 @@
"pages/home/home", "pages/home/home",
"pages/chat/chat" "pages/chat/chat"
], ],
"plugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
},
"window": { "window": {
"backgroundTextStyle": "light", "backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#ffffff", "navigationBarBackgroundColor": "#ffffff",

BIN
miniapp/images/wchat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -8,6 +8,7 @@ Page({
code: '', code: '',
sending: false, sending: false,
submitting: false, submitting: false,
wxSubmitting: false,
countdown: 0 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() { async login() {
if (this.data.submitting) return if (this.data.submitting) return
const phone = (this.data.phone || '').trim() const phone = (this.data.phone || '').trim()

View File

@ -49,6 +49,20 @@
{{submitting ? '登录中…' : '登录'}} {{submitting ? '登录中…' : '登录'}}
</button> </button>
<view class="divider"><text>或</text></view>
<view class="wechat-entry">
<button
class="wechat-icon-btn {{wxSubmitting ? 'disabled' : ''}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumber"
disabled="{{wxSubmitting || submitting}}"
>
<image class="wechat-icon-img" src="/images/wchat.png" mode="aspectFit" />
</button>
<text class="wechat-entry-tip">{{wxSubmitting ? '授权中…' : '微信授权登录'}}</text>
</view>
<view class="tip">测试账号13800000000 / 验证码 123456</view> <view class="tip">测试账号13800000000 / 验证码 123456</view>
</view> </view>
</view> </view>

View File

@ -144,3 +144,52 @@
font-size: 22rpx; font-size: 22rpx;
color: var(--muted); 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);
}