diff --git a/digital-avatar-app/backend/routers/huihui_auth.py b/digital-avatar-app/backend/routers/huihui_auth.py index 5c2ba96..39ee61f 100644 --- a/digital-avatar-app/backend/routers/huihui_auth.py +++ b/digital-avatar-app/backend/routers/huihui_auth.py @@ -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,7 +404,8 @@ def _issue_session(db: Session, phone: str, info: dict): if info.get("avatarUrl"): user.avatar_url = info["avatarUrl"] user.huihui_token = info.get("token", "") - user.app_token = uuid.uuid4().hex + 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) db.commit() @@ -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", ""), }, }) diff --git a/digital-avatar-app/backend/tests/test_huihui_auth.py b/digital-avatar-app/backend/tests/test_huihui_auth.py index cf0043f..252301f 100644 --- a/digital-avatar-app/backend/tests/test_huihui_auth.py +++ b/digital-avatar-app/backend/tests/test_huihui_auth.py @@ -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 diff --git a/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md new file mode 100644 index 0000000..992b1c6 --- /dev/null +++ b/digital-avatar-app/docs/H5_PRODUCTION_DEPLOYMENT.md @@ -0,0 +1,112 @@ +# 数字分身 H5 生产接入与部署 + +## 1. 接入方式 + +生产会会在用户已登录后打开以下地址: + +```text +https://<数字分身生产域名>/#/avatar/manage?token= +``` + +测试环境示例: + +```text +http://192.168.1.188:8099/#/avatar/manage?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= +HUIHUI_ACCESS_ID= +HUIHUI_ACCESS_SECRET= +HUIHUI_CLIENT_CODE= +BOXIM_TIMEOUT_SECONDS=20 + +DATABASE_URL=sqlite:////data/avatar.db +UPLOAD_DIR=/data/uploads +CHAT_MODEL_CONFIG_URL=http:///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. 回滚 + +保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。 diff --git a/digital-avatar-app/src/api/index.ts b/digital-avatar-app/src/api/index.ts index 55daf23..71e011c 100644 --- a/digital-avatar-app/src/api/index.ts +++ b/digital-avatar-app/src/api/index.ts @@ -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('/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('/huihui/token/login', { token }) + // 当前登录用户 export const getCurrentUser = () => request.get('/huihui/me') diff --git a/digital-avatar-app/src/main.ts b/digital-avatar-app/src/main.ts index da075c3..9b671b7 100644 --- a/digital-avatar-app/src/main.ts +++ b/digital-avatar-app/src/main.ts @@ -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() diff --git a/digital-avatar-app/src/store/user.ts b/digital-avatar-app/src/store/user.ts index a1786c4..6bf56d5 100644 --- a/digital-avatar-app/src/store/user.ts +++ b/digital-avatar-app/src/store/user.ts @@ -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 + } }) diff --git a/digital-avatar-app/src/utils/uniapp-bridge.ts b/digital-avatar-app/src/utils/uniapp-bridge.ts index 5ec2b56..504ca13 100644 --- a/digital-avatar-app/src/utils/uniapp-bridge.ts +++ b/digital-avatar-app/src/utils/uniapp-bridge.ts @@ -1,9 +1,7 @@ // 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具 // 协议详见 uniapp-avatar/README.md // -// 引入方式:在 index.html 中加载 uniapp web-view bridge: -// -// 引入后全局会出现 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): boolean { if (!isInUniWebView()) return false diff --git a/digital-avatar-app/src/views/SmsLogin.vue b/digital-avatar-app/src/views/SmsLogin.vue index d5f7326..f49a0d2 100644 --- a/digital-avatar-app/src/views/SmsLogin.vue +++ b/digital-avatar-app/src/views/SmsLogin.vue @@ -138,7 +138,7 @@