feat(avatar): support production H5 token SSO
This commit is contained in:
@@ -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,6 +404,7 @@ 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", "")
|
||||||
|
if not reuse_existing_session or not user.app_token:
|
||||||
user.app_token = uuid.uuid4().hex
|
user.app_token = uuid.uuid4().hex
|
||||||
user.last_login_at = datetime.now()
|
user.last_login_at = datetime.now()
|
||||||
db.add(user)
|
db.add(user)
|
||||||
@@ -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. 回滚
|
||||||
|
|
||||||
|
保留上一版前后端镜像标签和发布前数据库/上传文件备份。代码回滚优先切回上一镜像;只有新版本执行了不可逆数据变更时才恢复数据库。恢复前先停止后端写入,恢复后对比用户数、分身数、知识库文档数并完成一次免登录和聊天验收。
|
||||||
@@ -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')
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user