feat: 完成数字分身多分身管理与生产 H5 接入 #3
@@ -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. 回滚
|
||||
|
||||
保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。
|
||||
@@ -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')
|
||||
|
||||
@@ -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,9 +27,40 @@ if (userStore.isLogin && userStore.user) {
|
||||
})
|
||||
}
|
||||
|
||||
if (params.token) {
|
||||
setAuthToken(params.token)
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
if (params.userId || params.nickname || params.avatar) {
|
||||
avatarStore.setNativeProfile({
|
||||
userId: params.userId || '',
|
||||
@@ -42,18 +69,11 @@ if (params.userId || params.nickname || params.avatar) {
|
||||
})
|
||||
}
|
||||
|
||||
// 原生 → H5:注册消息处理(壳通过 web-view.evalJS 调用)
|
||||
onNativeMessage((msg) => {
|
||||
if (!msg || !msg.type) return
|
||||
if (msg.type === 'tokenRefresh' && msg.token) {
|
||||
setAuthToken(msg.token)
|
||||
}
|
||||
if (msg.type === 'userUpdate' && msg.user) {
|
||||
avatarStore.setNativeProfile(msg.user)
|
||||
}
|
||||
})
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
// 通知原生壳:H5 已就绪
|
||||
UniEvents.ready()
|
||||
}
|
||||
|
||||
void bootstrap()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user