feat(avatar): support production H5 token SSO

This commit is contained in:
stefanfeng
2026-08-26 09:17:41 +08:00
parent 4029c31ed7
commit 81aec1c63a
8 changed files with 364 additions and 61 deletions
@@ -29,6 +29,7 @@ _CN_TZ = timezone(timedelta(hours=8))
from database import get_db from database import get_db
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User
from responses import ok, fail from responses import ok, fail
from services.boxim_client import BoxIMClient, BoxIMError
router = APIRouter(tags=["会会账号"]) 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) 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): def _call_huihui(path: str, params: dict, as_query: bool = False):
"""调用会会接口,返回 (ok: bool, payload: dict, http_status: int)""" """调用会会接口,返回 (ok: bool, payload: dict, http_status: int)"""
url = f"{AUTH_BASE_URL}{path}" 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: 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.""" """Move one user's avatar-owned data to a replacement Huihui identity."""
if not old_owner_id or old_owner_id == new_owner_id: 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 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""" """建/链本地用户并签发本系统会话 token"""
huihui_user_id = info.get("userId", "") huihui_user_id = info.get("userId", "")
user = _find_or_link_user(db, phone, huihui_user_id) 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"): if info.get("avatarUrl"):
user.avatar_url = info["avatarUrl"] user.avatar_url = info["avatarUrl"]
user.huihui_token = info.get("token", "") 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() user.last_login_at = datetime.now()
db.add(user) db.add(user)
db.commit() db.commit()
@@ -359,7 +421,6 @@ def _issue_session(db: Session, phone: str, info: dict):
"userId": huihui_user_id, "userId": huihui_user_id,
"nickname": info.get("nickname", ""), "nickname": info.get("nickname", ""),
"avatarUrl": info.get("avatarUrl", ""), "avatarUrl": info.get("avatarUrl", ""),
"token": info.get("token", ""),
}, },
}) })
@@ -1,6 +1,7 @@
"""Tests for preserving local avatar ownership when Huihui IDs change.""" """Tests for preserving local avatar ownership when Huihui IDs change."""
from datetime import datetime from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest import pytest
from sqlalchemy import create_engine from sqlalchemy import create_engine
@@ -9,7 +10,8 @@ from sqlalchemy.pool import StaticPool
from database import Base from database import Base
from models import Avatar, TakeoverCursor, TakeoverMessage, TakeoverReplyTask, User 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 @pytest.fixture
@@ -135,3 +137,55 @@ def test_ambiguous_phone_matches_do_not_move_existing_avatars(db):
assert db.query(User).count() == 3 assert db.query(User).count() == 3
_assert_avatar_data_owner(db, first_avatar.id, "fat-1") _assert_avatar_data_owner(db, first_avatar.id, "fat-1")
_assert_avatar_data_owner(db, second_avatar.id, "fat-2") _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 { export interface SmsLoginResult {
token: string token: string
user: UserProfile & { huihuiUserId: string; phone: string; createdAt?: string; lastLoginAt?: 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 标记) // 发送短信验证码(演示模式会额外返回 devCode / dev 标记)
@@ -443,6 +443,10 @@ export const loginBySms = (phone: string, code: string) =>
export const loginByPassword = (account: string, password: string) => export const loginByPassword = (account: string, password: string) =>
request.post<SmsLoginResult>('/huihui/pwd/login', { account, password }) 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 = () => export const getCurrentUser = () =>
request.get<UserProfile & { huihuiUserId: string; phone: string }>('/huihui/me') 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 App from './App.vue'
import router from './router' import router from './router'
import pinia from './store' 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 { useAvatarStore } from '@/store/avatar'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { setAuthToken } from '@/api'
// Bundle the bridge locally so the H5 does not depend on an external CDN. // Bundle the bridge locally so the H5 does not depend on an external CDN.
;(window as any).uni = (window as any).uni || uniWebView ;(window as any).uni = (window as any).uni || uniWebView
const app = createApp(App) const app = createApp(App)
app.use(router)
app.use(pinia) app.use(pinia)
// —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 —— // —— 混合架构:在挂载前注入 uniapp 壳传入的认证与会会资料 ——
@@ -20,10 +18,8 @@ const params = getLaunchParams()
const avatarStore = useAvatarStore(pinia) const avatarStore = useAvatarStore(pinia)
const userStore = useUserStore(pinia) const userStore = useUserStore(pinia)
// 恢复本地短信登录会话(会会 userId ↔ 本系统用户) function syncAvatarProfile() {
userStore.loadFromStorage() if (!userStore.isLogin || !userStore.user) return
if (userStore.isLogin && userStore.user) {
setAuthToken(userStore.token)
avatarStore.setNativeProfile({ avatarStore.setNativeProfile({
userId: (userStore.user as any).huihuiUserId || '', userId: (userStore.user as any).huihuiUserId || '',
nickname: userStore.user.nickname || '', nickname: userStore.user.nickname || '',
@@ -31,29 +27,53 @@ if (userStore.isLogin && userStore.user) {
}) })
} }
if (params.token) { async function exchangeInjectedToken(token: string) {
setAuthToken(params.token) stripLaunchToken()
} userStore.clearSession()
if (params.userId || params.nickname || params.avatar) { await userStore.loginByToken(token)
avatarStore.setNativeProfile({ syncAvatarProfile()
userId: params.userId || '',
nickname: params.nickname || '',
avatarUrl: params.avatar || ''
})
} }
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用) // 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
onNativeMessage((msg) => { onNativeMessage((msg) => {
if (!msg || !msg.type) return if (!msg || !msg.type) return
if (msg.type === 'tokenRefresh' && msg.token) { 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) { if (msg.type === 'userUpdate' && msg.user) {
avatarStore.setNativeProfile(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 已就绪 if (params.userId || params.nickname || params.avatar) {
UniEvents.ready() 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 { defineStore } from 'pinia'
import { ref } from 'vue' 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 TOKEN_KEY = 'hh_app_token'
const USER_KEY = 'hh_app_user' const USER_KEY = 'hh_app_user'
@@ -37,6 +46,23 @@ export const useUserStore = defineStore('smsuser', () => {
localStorage.removeItem(USER_KEY) 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) // 发送验证码(返回结果,演示模式含 devCode)
const sendCode = async (phone: string) => { const sendCode = async (phone: string) => {
return await sendSmsCode(phone) return await sendSmsCode(phone)
@@ -44,26 +70,17 @@ export const useUserStore = defineStore('smsuser', () => {
// 短信登录 // 短信登录
const login = async (phone: string, code: string) => { const login = async (phone: string, code: string) => {
const res: any = await loginBySms(phone, code) return acceptLogin(await loginBySms(phone, code))
token.value = res.token
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
isLogin.value = true
setAuthToken(res.token)
persist()
return res
} }
// 账号密码登录 // 账号密码登录
const loginByPwd = async (account: string, password: string) => { const loginByPwd = async (account: string, password: string) => {
const res: any = await loginByPassword(account, password) return acceptLogin(await loginByPassword(account, password))
token.value = res.token
user.value = { ...(res.user || {}), ...(res.huihui || {}) }
isLogin.value = true
setAuthToken(res.token)
persist()
return res
} }
const loginByToken = async (huihuiToken: string) =>
acceptLogin(await loginByHuihuiToken(huihuiToken))
// 退出 // 退出
const logout = async () => { const logout = async () => {
try { try {
@@ -71,12 +88,19 @@ export const useUserStore = defineStore('smsuser', () => {
} catch { } catch {
/* 忽略网络错误,本地清除即可 */ /* 忽略网络错误,本地清除即可 */
} }
token.value = '' clearSession()
user.value = null
isLogin.value = false
setAuthToken(null)
clearLocal()
} }
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 原生壳 桥接工具 // 会会数字分身 H5 ↔ uniapp 原生壳 桥接工具
// 协议详见 uniapp-avatar/README.md // 协议详见 uniapp-avatar/README.md
// //
// 引入方式:在 index.html 中加载 uniapp web-view bridge: // uni-webview bridge is bundled by main.ts; no external CDN is required.
// <script src="https://unpkg.com/@dcloudio/uni-webview-js@0.0.10/index.js"></script>
// 引入后全局会出现 window.uni.webView,H5 即可用 postMessage 与原生通信。
const BRIDGE_HANDLER = '__uniBridgeHandle__' const BRIDGE_HANDLER = '__uniBridgeHandle__'
@@ -15,6 +13,16 @@ export interface UniLaunchParams {
ts?: string 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 环境中 // 是否运行在 uniapp web-view 环境中
export function isInUniWebView(): boolean { export function isInUniWebView(): boolean {
return !!(window as any).uni?.webView return !!(window as any).uni?.webView
@@ -22,21 +30,33 @@ export function isInUniWebView(): boolean {
// 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户) // 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户)
export function getLaunchParams(): UniLaunchParams { export function getLaunchParams(): UniLaunchParams {
const sp = new URLSearchParams(window.location.search)
const params: UniLaunchParams = {} const params: UniLaunchParams = {}
const token = sp.get('token') readParams(window.location.search, params)
const userId = sp.get('userId') const hashQueryIndex = window.location.hash.indexOf('?')
const nickname = sp.get('nickname') if (hashQueryIndex >= 0) {
const avatar = sp.get('avatar') readParams(window.location.hash.slice(hashQueryIndex + 1), params)
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
return 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) // H5 → 原生:发送事件(需引入 uniapp web-view bridge)
export function postToNative(message: Record<string, any>): boolean { export function postToNative(message: Record<string, any>): boolean {
if (!isInUniWebView()) return false if (!isInUniWebView()) return false
+9 -1
View File
@@ -138,7 +138,7 @@
</template> </template>
<script setup lang="ts"> <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 { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/user' import { useUserStore } from '@/store/user'
import { useAvatarStore } from '@/store/avatar' import { useAvatarStore } from '@/store/avatar'
@@ -161,6 +161,14 @@ const password = ref('')
const loading = ref(false) const loading = ref(false)
const errorMsg = ref('') 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 counting = ref(false)
const countdown = ref(60) const countdown = ref(60)
let timer: any = null let timer: any = null