feat: 完成数字分身多分身管理与生产 H5 接入 #3

Merged
stefanfeng merged 10 commits from codex/avatar-multi-management-integrated-20260825 into main 2026-08-26 09:43:53 +08:00
8 changed files with 364 additions and 61 deletions
Showing only changes of commit 81aec1c63a - Show all commits
@@ -29,6 +29,7 @@ _CN_TZ = timezone(timedelta(hours=8))
from database import get_db
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from responses import ok, fail
from services.boxim_client import BoxIMClient, BoxIMError
router = APIRouter(tags=["会会账号"])
@@ -111,6 +112,19 @@ def _cfg_ready() -> bool:
return bool(AUTH_BASE_URL and APP_ID and ACCESS_ID and ACCESS_SECRET)
def _create_boxim_client() -> BoxIMClient:
return BoxIMClient({
"HUIHUI_PLATFORM_BASE_URL": os.getenv(
"HUIHUI_PLATFORM_BASE_URL", "https://open.99hui.com/api"
),
"BOXIM_API_BASE_URL": os.getenv("BOXIM_API_BASE_URL", "https://im.99hui.com/api"),
"HUIHUI_APP_ID": APP_ID,
"HUIHUI_ACCESS_ID": ACCESS_ID,
"HUIHUI_ACCESS_SECRET": ACCESS_SECRET,
"BOXIM_TIMEOUT_SECONDS": os.getenv("BOXIM_TIMEOUT_SECONDS", "20"),
})
def _call_huihui(path: str, params: dict, as_query: bool = False):
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
url = f"{AUTH_BASE_URL}{path}"
@@ -281,6 +295,47 @@ def pwd_login(body: dict = Body(...), db: Session = Depends(get_db)):
})
@router.post("/huihui/token/login")
async def token_login(body: dict = Body(...), db: Session = Depends(get_db)):
"""Validate a production Huihui token through BOXIM and issue an app session."""
huihui_token = (body.get("token") or "").strip()
if not huihui_token or len(huihui_token) > 8192:
return fail("会会登录凭证无效或已过期", 401)
if not _cfg_ready():
return fail("会会登录服务未配置", 500)
client = _create_boxim_client()
try:
token_data = await client.exchange_access_token(huihui_token)
profile = await client.get_self(token_data["accessToken"])
except BoxIMError as exc:
if exc.auth_error:
return fail("会会登录凭证无效或已过期", 401)
return fail("会会登录服务暂时不可用,请稍后重试", 502)
# BOXIM's id is its internal IM id. Account ownership must use huihuiUserId.
huihui_user_id = str(profile.get("huihuiUserId") or "").strip()
if not huihui_user_id:
return fail("会会未返回用户标识", 502)
phone = str(_pick(profile, "mobile", "phone", default="")).strip()
nickname = str(_pick(profile, "nickName", "nickname", "name", "userName", default="")).strip()
avatar_url = str(
_pick(profile, "headImage", "headImageThumb", "avatar", "avatarUrl", default="")
).strip()
return _issue_session(
db,
phone,
{
"userId": huihui_user_id,
"nickname": nickname,
"avatarUrl": avatar_url,
"token": huihui_token,
},
reuse_existing_session=True,
)
def _transfer_avatar_ownership(db: Session, old_owner_id: str, new_owner_id: str) -> int:
"""Move one user's avatar-owned data to a replacement Huihui identity."""
if not old_owner_id or old_owner_id == new_owner_id:
@@ -332,7 +387,13 @@ def _find_or_link_user(db: Session, phone: str, huihui_user_id: str) -> User:
return user
def _issue_session(db: Session, phone: str, info: dict):
def _issue_session(
db: Session,
phone: str,
info: dict,
*,
reuse_existing_session: bool = False,
):
"""建/链本地用户并签发本系统会话 token"""
huihui_user_id = info.get("userId", "")
user = _find_or_link_user(db, phone, huihui_user_id)
@@ -343,6 +404,7 @@ def _issue_session(db: Session, phone: str, info: dict):
if info.get("avatarUrl"):
user.avatar_url = info["avatarUrl"]
user.huihui_token = info.get("token", "")
if not reuse_existing_session or not user.app_token:
user.app_token = uuid.uuid4().hex
user.last_login_at = datetime.now()
db.add(user)
@@ -359,7 +421,6 @@ def _issue_session(db: Session, phone: str, info: dict):
"userId": huihui_user_id,
"nickname": info.get("nickname", ""),
"avatarUrl": info.get("avatarUrl", ""),
"token": info.get("token", ""),
},
})
@@ -1,6 +1,7 @@
"""Tests for preserving local avatar ownership when Huihui IDs change."""
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
from sqlalchemy import create_engine
@@ -9,7 +10,8 @@ from sqlalchemy.pool import StaticPool
from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from routers.huihui_auth import _issue_session
from routers.huihui_auth import _issue_session, token_login
from services.boxim_client import BoxIMError
@pytest.fixture
@@ -135,3 +137,55 @@ def test_ambiguous_phone_matches_do_not_move_existing_avatars(db):
assert db.query(User).count() == 3
_assert_avatar_data_owner(db, first_avatar.id, "fat-1")
_assert_avatar_data_owner(db, second_avatar.id, "fat-2")
@pytest.mark.asyncio
async def test_token_login_uses_huihui_user_id_and_keeps_upstream_token_server_side(db):
existing = User(
id="existing-local",
huihui_user_id="huihui-user-88",
app_token="existing-app-session",
)
db.add(existing)
db.commit()
client = AsyncMock()
client.exchange_access_token.return_value = {"accessToken": "boxim-token"}
client.get_self.return_value = {
"id": 998877,
"huihuiUserId": "huihui-user-88",
"nickName": "会会用户",
"headImage": "https://cdn.example/avatar.jpg",
}
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
"routers.huihui_auth._create_boxim_client", return_value=client
):
response = await token_login({"token": "production-huihui-token"}, db)
assert response["code"] == 200
assert response["data"]["token"] == "existing-app-session"
assert "token" not in response["data"]["huihui"]
user = db.query(User).one()
assert user.huihui_user_id == "huihui-user-88"
assert user.huihui_user_id != "998877"
assert user.huihui_token == "production-huihui-token"
assert user.nickname == "会会用户"
assert user.avatar_url == "https://cdn.example/avatar.jpg"
client.exchange_access_token.assert_awaited_once_with("production-huihui-token")
client.get_self.assert_awaited_once_with("boxim-token")
@pytest.mark.asyncio
async def test_token_login_rejects_expired_huihui_token_without_creating_user(db):
client = AsyncMock()
client.exchange_access_token.side_effect = BoxIMError(
"expired", auth_error=True
)
with patch("routers.huihui_auth._cfg_ready", return_value=True), patch(
"routers.huihui_auth._create_boxim_client", return_value=client
):
response = await token_login({"token": "expired-token"}, db)
assert response["code"] == 401
assert response["message"] == "会会登录凭证无效或已过期"
assert db.query(User).count() == 0
@@ -0,0 +1,112 @@
# 数字分身 H5 生产接入与部署
## 1. 接入方式
生产会会在用户已登录后打开以下地址:
```text
https://<数字分身生产域名>/#/avatar/manage?token=<encodeURIComponent(会会 access token)>
```
测试环境示例:
```text
http://192.168.1.188:8099/#/avatar/manage?token=<encodeURIComponent(token)>
```
兼容参数位于域名查询串的形式,但生产统一使用上面的 hash 路由形式。必须对 token 调用 `encodeURIComponent`,不能拼接用户 ID 代替 token。
免登录流程如下:
1. H5 在页面渲染前读取 `token`,立即通过 `history.replaceState` 从地址栏和浏览器历史中移除。
2. H5 调用 `POST /api/huihui/token/login`,不会把会会 token 当作数字分身接口 token 直接使用。
3. 后端通过会会生产接口 `/im/box/netease` 换取 BOXIM 凭证,再调用 BOXIM `/user/self` 校验用户身份。
4. 后端以返回的 `huihuiUserId` 绑定本地用户,保存会会凭证供 BOXIM 接管功能使用,并签发本系统 `app_token`。
5. 浏览器只保存 `app_token` 和非敏感用户资料。会会原始 token 不返回浏览器存储。
6. token 无效、过期或上游校验失败时清除旧会话并进入登录页,不会沿用上一位用户的缓存身份。
## 2. 生产配置
后端 `.env` 至少配置以下内容,密钥由部署平台注入,禁止提交 Git:
```dotenv
HUIHUI_DEV_MOCK=false
HUIHUI_AUTH_BASE_URL=https://99hui.com/api/usercenter
HUIHUI_PLATFORM_BASE_URL=https://open.99hui.com/api
BOXIM_API_BASE_URL=https://im.99hui.com/api
HUIHUI_APP_ID=<production-app-id>
HUIHUI_ACCESS_ID=<production-access-id>
HUIHUI_ACCESS_SECRET=<production-access-secret>
HUIHUI_CLIENT_CODE=<production-client-code>
BOXIM_TIMEOUT_SECONDS=20
DATABASE_URL=sqlite:////data/avatar.db
UPLOAD_DIR=/data/uploads
CHAT_MODEL_CONFIG_URL=http://<huihuisquare-api>/api/ai-models/runtime/digital-avatar
```
如生产 AI 配置中心不可用,还应提供当前项目支持的 `OPENAI_API_KEY`、`OPENAI_BASE_URL`、`CHAT_MODEL` 等兜底配置。`/data` 必须挂载持久卷,数据库与知识库文件不可存放在容器临时层。
## 3. 构建与发布
首次发布前备份数据:
```bash
BACKUP_DIR="backups/$(date +%Y%m%d-%H%M%S)"
mkdir -p "$BACKUP_DIR"
cp /srv/digital-avatar/data/avatar.db "$BACKUP_DIR/"
tar -C /srv/digital-avatar/data -czf "$BACKUP_DIR/uploads.tgz" uploads
```
在发布目录执行:
```bash
git fetch origin
git checkout <已验收的提交SHA>
cd digital-avatar-app
docker compose build --pull avatar-backend avatar-frontend
docker compose up -d avatar-backend avatar-frontend
docker compose ps
curl -fsS http://127.0.0.1:8099/api/health
```
生产编排应把示例中的测试端口改为内网暴露,由统一 HTTPS 网关接入。后端暂时使用 SQLite,必须保持单实例写入;若扩展为多后端实例,应先迁移到 PostgreSQL,并把延迟接管任务改为共享队列。
## 4. 网关要求
必须使用 HTTPS。同域部署时,H5 静态资源与 `/api/` 由同一域名提供,可避免跨域和 Cookie/来源策略问题。Nginx 关键配置示例:
```nginx
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://avatar-backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 300s;
client_max_body_size 20m;
}
```
`proxy_buffering off` 用于数字分身 SSE 流式吐字,`client_max_body_size` 用于知识库文件上传。网关和应用日志必须关闭完整 URL 查询参数记录,任何异常日志都不得输出 token、Authorization 或平台密钥。建议同时设置严格的 `Referrer-Policy: no-referrer`。
## 5. 发布验收
1. 已登录会会用户通过带 token 链接打开后直接进入 `/avatar/manage`,不出现登录页或创建新账号页。
2. 页面加载后地址栏中不再包含 `token`,刷新页面仍使用本地 `app_token` 正常访问。
3. 后端用户绑定的是 BOXIM 返回的 `huihuiUserId`,不是 BOXIM 内部 `id`;原有数字分身、独立知识库和 Token 余额均存在。
4. A、B 两个会会用户分别进入时只能看到各自的数字分身与知识库,不会继承上一用户缓存。
5. 使用过期或伪造 token 时进入登录页并显示凭证失效,不得继续访问旧用户数据。
6. 分身聊天 SSE 逐段输出正常,Markdown 正常渲染,知识库优先级和 Token 扣费正常。
7. 开启 BOXIM 主动接管后保持在线,收到消息、三秒回复、已读回执和主人发言暂停均正常。
8. 重建容器后数据库、头像、知识库文档仍存在,`/api/health` 返回成功。
## 6. 回滚
保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。
+5 -1
View File
@@ -428,7 +428,7 @@ export const getUserProfile = () =>
export interface SmsLoginResult {
token: string
user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: string }
huihui: { userId: string; nickname: string; avatarUrl: string; token: string }
huihui: { userId: string; nickname: string; avatarUrl: string }
}
// 发送短信验证码(演示模式会额外返回 devCode / dev 标记)
@@ -443,6 +443,10 @@ export const loginBySms = (phone: string, code: string) =>
export const loginByPassword = (account: string, password: string) =>
request.post<SmsLoginResult>('/huihui/pwd/login', { account, password })
// Validate a production Huihui access token and exchange it for an app session.
export const loginByHuihuiToken = (token: string) =>
request.post<SmsLoginResult>('/huihui/token/login', { token })
// 当前登录用户
export const getCurrentUser = () =>
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me')
+40 -20
View File
@@ -3,16 +3,14 @@ import uniWebView from '@dcloudio/uni-webview-js'
import App from './App.vue'
import router from './router'
import pinia from './store'
import { getLaunchParams, onNativeMessage, UniEvents } from '@/utils/uniapp-bridge'
import { getLaunchParams, onNativeMessage, stripLaunchToken, UniEvents } from '@/utils/uniapp-bridge'
import { useAvatarStore } from '@/store/avatar'
import { useUserStore } from '@/store/user'
import { setAuthToken } from '@/api'
// Bundle the bridge locally so the H5 does not depend on an external CDN.
;(window as any).uni = (window as any).uni || uniWebView
const app = createApp(App)
app.use(router)
app.use(pinia)
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
@@ -20,10 +18,8 @@ const params = getLaunchParams()
const avatarStore = useAvatarStore(pinia)
const userStore = useUserStore(pinia)
// 恢复本地短信登录会话(会会 userId ↔ 本系统用户)
userStore.loadFromStorage()
if (userStore.isLogin && userStore.user) {
setAuthToken(userStore.token)
function syncAvatarProfile() {
if (!userStore.isLogin || !userStore.user) return
avatarStore.setNativeProfile({
userId: (userStore.user as any).huihuiUserId || '',
nickname: userStore.user.nickname || '',
@@ -31,29 +27,53 @@ if (userStore.isLogin && userStore.user) {
})
}
if (params.token) {
setAuthToken(params.token)
}
if (params.userId || params.nickname || params.avatar) {
avatarStore.setNativeProfile({
userId: params.userId || '',
nickname: params.nickname || '',
avatarUrl: params.avatar || ''
})
async function exchangeInjectedToken(token: string) {
stripLaunchToken()
userStore.clearSession()
await userStore.loginByToken(token)
syncAvatarProfile()
}
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
onNativeMessage((msg) => {
if (!msg || !msg.type) return
if (msg.type === 'tokenRefresh' && msg.token) {
setAuthToken(msg.token)
void exchangeInjectedToken(msg.token).catch(() => {
sessionStorage.setItem('hh_sso_error', '会会登录凭证无效或已过期,请重新进入')
void router.replace('/login/sms')
})
}
if (msg.type === 'userUpdate' && msg.user) {
avatarStore.setNativeProfile(msg.user)
}
})
app.mount('#app')
async function bootstrap() {
// A URL token represents the current production user and must override stale storage.
if (params.token) {
try {
await exchangeInjectedToken(params.token)
} catch {
sessionStorage.setItem('hh_sso_error', '会会登录凭证无效或已过期,请重新进入')
}
} else {
userStore.loadFromStorage()
syncAvatarProfile()
}
// 通知原生壳:H5 已就绪
UniEvents.ready()
if (params.userId || params.nickname || params.avatar) {
avatarStore.setNativeProfile({
userId: params.userId || '',
nickname: params.nickname || '',
avatarUrl: params.avatar || ''
})
}
app.use(router)
app.mount('#app')
// 通知原生壳:H5 已就绪
UniEvents.ready()
}
void bootstrap()
+45 -21
View File
@@ -1,6 +1,15 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { setAuthToken, sendSmsCode, loginBySms, loginByPassword, logoutUser, type UserProfile } from '@/api'
import {
setAuthToken,
sendSmsCode,
loginBySms,
loginByPassword,
loginByHuihuiToken,
logoutUser,
type SmsLoginResult,
type UserProfile
} from '@/api'
const TOKEN_KEY = 'hh_app_token'
const USER_KEY = 'hh_app_user'
@@ -37,6 +46,23 @@ export const useUserStore = defineStore('smsuser', () => {
localStorage.removeItem(USER_KEY)
}
const clearSession = () => {
token.value = ''
user.value = null
isLogin.value = false
setAuthToken(null)
clearLocal()
}
const acceptLogin = (res: SmsLoginResult) => {
token.value = res.token
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
isLogin.value = true
setAuthToken(res.token)
persist()
return res
}
// 发送验证码(返回结果,演示模式含 devCode)
const sendCode = async (phone: string) => {
return await sendSmsCode(phone)
@@ -44,26 +70,17 @@ export const useUserStore = defineStore('smsuser', () => {
// 短信登录
const login = async (phone: string, code: string) => {
const res: any = await loginBySms(phone, code)
token.value = res.token
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
isLogin.value = true
setAuthToken(res.token)
persist()
return res
return acceptLogin(await loginBySms(phone, code))
}
// 账号密码登录
const loginByPwd = async (account: string, password: string) => {
const res: any = await loginByPassword(account, password)
token.value = res.token
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
isLogin.value = true
setAuthToken(res.token)
persist()
return res
return acceptLogin(await loginByPassword(account, password))
}
const loginByToken = async (huihuiToken: string) =>
acceptLogin(await loginByHuihuiToken(huihuiToken))
// 退出
const logout = async () => {
try {
@@ -71,12 +88,19 @@ export const useUserStore = defineStore('smsuser', () => {
} catch {
/* 忽略网络错误,本地清除即可 */
}
token.value = ''
user.value = null
isLogin.value = false
setAuthToken(null)
clearLocal()
clearSession()
}
return { token, user, isLogin, loadFromStorage, sendCode, login, loginByPwd, logout }
return {
token,
user,
isLogin,
loadFromStorage,
clearSession,
sendCode,
login,
loginByPwd,
loginByToken,
logout
}
})
+34 -14
View File
@@ -1,9 +1,7 @@
// 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
// 协议详见 uniapp-avatar/README.md
//
// 引入方式:在 index.html 中加载 uniapp web-view bridge:
// <script src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
// 引入后全局会出现 window.uni.webView,H5 即可用 postMessage 与原生通信。
// uni-webview bridge is bundled by main.ts; no external CDN is required.
const BRIDGE_HANDLER = '__uniBridgeHandle__'
@@ -15,6 +13,16 @@ export interface UniLaunchParams {
ts?: string
}
const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts']
function readParams(search: string, target: UniLaunchParams): void {
const sp = new URLSearchParams(search)
for (const key of PARAM_KEYS) {
const value = sp.get(key)
if (value) target[key] = value
}
}
// 是否运行在 uniapp web-view 环境中
export function isInUniWebView(): boolean {
return !!(window as any).uni?.webView
@@ -22,21 +30,33 @@ export function isInUniWebView(): boolean {
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
export function getLaunchParams(): UniLaunchParams {
const sp = new URLSearchParams(window.location.search)
const params: UniLaunchParams = {}
const token = sp.get('token')
const userId = sp.get('userId')
const nickname = sp.get('nickname')
const avatar = sp.get('avatar')
const ts = sp.get('ts')
if (token) params.token = token
if (userId) params.userId = userId
if (nickname) params.nickname = decodeURIComponent(nickname)
if (avatar) params.avatar = decodeURIComponent(avatar)
if (ts) params.ts = ts
readParams(window.location.search, params)
const hashQueryIndex = window.location.hash.indexOf('?')
if (hashQueryIndex >= 0) {
readParams(window.location.hash.slice(hashQueryIndex + 1), params)
}
return params
}
// Remove the one-time login credential before any route is rendered or logged.
export function stripLaunchToken(): void {
const url = new URL(window.location.href)
url.searchParams.delete('token')
const hash = url.hash.slice(1)
const queryIndex = hash.indexOf('?')
if (queryIndex >= 0) {
const path = hash.slice(0, queryIndex)
const hashParams = new URLSearchParams(hash.slice(queryIndex + 1))
hashParams.delete('token')
const query = hashParams.toString()
url.hash = `${path}${query ? `?${query}` : ''}`
}
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
}
// H5 → 原生:发送事件(需引入 uniapp web-view bridge)
export function postToNative(message: Record<string, any>): boolean {
if (!isInUniWebView()) return false
+9 -1
View File
@@ -138,7 +138,7 @@
</template>
<script setup lang="ts">
import { reactive, ref, computed, onUnmounted } from 'vue'
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/user'
import { useAvatarStore } from '@/store/avatar'
@@ -161,6 +161,14 @@ const password = ref('')
const loading = ref(false)
const errorMsg = ref('')
onMounted(() => {
const ssoError = sessionStorage.getItem('hh_sso_error')
if (ssoError) {
errorMsg.value = ssoError
sessionStorage.removeItem('hh_sso_error')
}
})
const counting = ref(false)
const countdown = ref(60)
let timer: any = null