身分 cookie 僅保存 token / user_id / has_login / id;暱稱、頭像、餘額等資料存入加密 user-profile localStorage。客戶端 info 合併兩部分,SSR 的 info 僅含鑑權欄位。setInfo 完整替換資料,所有登入與自動重新登入均在寫入前拆分;setToken 保留其他資料。expireLogin 與 logout 同時清理資料。客戶端首次讀取舊完整 cookie 時遷移;若 nginx 已拒絕請求,需要先清理該站身分 cookie,頁面腳本無法繞過入口 400。

userStore

framework 內建的使用者身份 Pinia store,composable 名是 useUserStore(不是 useUserInfoStore,原始碼位於 src/runtime/store/user.ts)。

登入憑證以原子物件 tokenInfo 形式持久到 cookie:重新整理 / SSR 都能自動恢復,「token 在、user_id 就在」絕不分離。

基本用法

<script setup lang="ts">
const userStore = useUserStore()

// 狀態都是 computed,模板裡直接用
console.log(userStore.isLoggedIn, userStore.info?.nickname)

// 業務操作
async function onLogin() {
  await userStore.loginByUsername(form.username, form.password)
  await navigateTo(route.query.redirect as string || '/')
}

async function onLogout() {
  await userStore.logout() // 先撤銷伺服器端會話再清本地;不 await 時本地清理仍同步生效
  navigateTo('/login')
}
</script>

framework 不在 SSR 期主動呼叫 userStore.autoLogin()。已登入使用者的 cookie 由 useCookie 自然透傳,request 工具自動注入 authToken;新訪客的裝置登入在 client hydrate 後由 auth.client.ts plugin 完成。詳見 算繪模式快速指南。

State(computed 派生,唯讀)

鑑權 state 由 tokenInfoCookie 派生,客戶端 info 合併加密 localStorage 資料。

欄位類型說明
infoRef<UserInfo | null>完整使用者資訊(user_id / nickname / vip / has_login / …)。null = 未登入
tokenRef<string>純 token 字串(不含 user_id)。未登入時為 ''
authTokenRef<string>請求用憑證:已登入 = ${token}_${user_id};裝置登入 = token;未登入 = ''。framework request util 自動用此欄位
isLoggedInRef<boolean>是否為真實使用者登入(has_login === 'y')。裝置登入不算

裝置登入使用者(has_login = 'n')能呼叫匿名介面、做行為埋點,但 isLoggedIn 仍為 false。

Actions

方法簽名說明
getDeviceId()() => string取裝置 ID。首次呼叫時用 framework randomUuid() 產生 + 寫 cookie(10 年);非 secure context(LAN HTTP)自動降級,不裸依賴 crypto.randomUUID
getSessionId()() => string取埋點工作階段 ID(framework#415)。距上次呼叫不足 30 分鐘沿用,否則產生新 ID;每次呼叫刷新活動時間。存 localStorage(跨分頁共享),供自建埋點通道 context.session_id 使用
setInfo(data)(data: UserInfo) => void完整替換使用者資訊,鑑權欄位寫 cookie,其他資料寫加密 localStorage。目前已有 token 而傳入資料缺 token 時 throw;只補充或修改資料時合併寫入:setInfo({ ...userStore.info, ...data })
setToken(value)(value: string) => void僅更新 tokenInfo.token 欄位,其它欄位保留
setAccount(data)(data: AccountInfo) => void儲存登入憑證用於自動登入。data.remember === false 時清空 accountLocal
getAccountInfo()() => AccountInfo | null讀取儲存的登入憑證
clearAccount()() => void單獨清空 AccountInfo(不動 tokenInfo)
autoLogin()() => Promise<boolean>自動登入編排器:根據 saved AccountInfo.remember 決定路徑;無憑證 → 降級 loginByDevice()
loginByDevice()() => Promise<Record>裝置登入:無憑證使用者的匿名身份(靠 deviceId 識別,不寫 AccountInfo),供 autoLogin 降級 / 業務顯式呼叫
loginByUsername(username, password, options?)(string, string, LoginOptions?) => Promise<Record>帳號密碼登入。options.remember(預設 true)決定是否持久化 AccountInfo。options.extra 透傳後端
loginByQrcode(code, options?)(string, LoginOptions?) => Promise<Record>二維碼登入。options.remember / options.extra 同上
register(loginName, password, options?)(string, string, LoginOptions?) => Promise<Record>自助註冊(POST /login/register,後端回傳與 /login/username 同形狀回應)。成功即進登入態(複用 setInfo/setAccount 鏈),options.remember / options.extra 同上;失敗(重名等)拋 Error 且登入態不變
expireLogin()() => void登入窮盡最小清理(fw#302):2002 靜默重登重試仍失敗時由 request 插件呼叫,清 tokenInfo 与用户资料(isLoggedIn 歸 false);不動 deviceId / accountLocal / 渠道碼 / IDB。業務一般不直接呼叫——監聽 swift-web:auth-expired hook 做引導即可
logout()() => Promise<void>先撤銷伺服器端會話(POST /login/logout,失敗靜默、不阻斷本地清理),再清空 tokenInfo / channel_code / share_code cookie + accountLocal / user-profile(localStorage)+ IDB 快取。保留 deviceId。本地清理在同步段完成,不 await 也立即生效

UserInfo 類型

interface UserInfo {
  user_id?: number | string
  username?: string
  nickname?: string
  headico?: string        // 頭像 URL
  token?: string
  has_login?: 'y' | 'n'   // 'y' 真實使用者登入;'n' 裝置登入
  vip?: number            // VIP 等級,0 = 普通
  vip_expire?: string
  balance?: number
  websocket?: string      // WebSocket 連線位址
  [key: string]: unknown  // 後端可擴展任意欄位
}

持久化儲存

按「SSR 是否需讀」+「是否需隨每個請求發」+「敏感度」分配位置:

Key媒介壽命內容
${tokenKey}Cookie(AES 加密 + secure)30 天token / user_id / has_login / id
${deviceKey}Cookie(AES 加密 + secure)10 年deviceId UUID
${accountKey}localStorage(AES 加密)無 TTLAccountInfo 物件(存 localStorage 不進 cookie)
channel_code / share_codeCookie(AES 加密 + secure)30 天渠道碼 / 邀請碼
  • 所有 cookie 預設帶 secure: true + sameSite: lax。
  • AES 加密透過 framework useCookie / useLocal wrapper 透明完成。
  • ⚠️ 加密 ≠ 安全屏障。詳見 客戶端加密的安全邊界。
  • AccountInfo 在 localStorage 中無 TTL,「記住我」=記到 logout 或使用者取消勾選。

跨標籤頁同步

cookie 是天然跨標籤頁(同源同 path)。一個標籤頁 logout() 清 cookie → 其他標籤頁的 useCookie ref 在下次存取時拿到空值 → state 同步為未登入。

但已載入的頁面不會自動 re-render —— 業務側如需「即時」同步可監聽 storage 事件或用 BroadcastChannel。framework 不內建這層邏輯(避免冗餘訂閱)。

身分建立與安全模型

device + auth 身分完全 client-only 建立(設計決策見 framework#44,安全加固見 framework#45):

  • SSR 期 framework 永不呼叫寫狀態方法(autoLogin / loginByDevice / getDeviceId / setAccount / setInfo 等)——任何會寫 useCookie ref 的 mutation 都不在 server 期觸發,保證 Pinia payload 不被烤進 ISR 快取;這些方法頂部有 assertClientOnly(),server 呼叫直接 throw
  • 裝置登入由 client 端 auth.client.ts plugin 在 hydrate 後建立;已登入使用者的 token 透過 cookie 自然透傳到 SSR;業務呼叫 request() 自動等 auth-ready
  • 密碼類憑證不進 cookie:AccountInfo 存加密 localStorage,DevTools Cookies 面板不可見、不隨請求飛
  • remember 語義:loginByUsername / loginByQrcode 的 options.remember(預設 true)決定是否持久化 AccountInfo;remember: false 不保存;使用者中途取消勾選用 clearAccount() 立即清
  • 核心 cookie 全部 AES 加密 + 預設 secure: true(localhost 自動豁免)+ sameSite: lax

實戰配方

<!-- 配方 1:LoginForm 透傳「記住我」勾選(STORE-002,必須顯式透傳) -->
<script setup lang="ts">
const isRemember = ref(true)
async function onSubmit() {
  await useUserStore().loginByUsername(form.username, form.password, {
    remember: isRemember.value,
  })
}
</script>
// 配方 2:等登入鏈路就緒再判斷登入態(UAR-001:auth-ready ≠ 登入成功)
const userStore = useUserStore()
onMounted(async () => {
  await useAuthReady()
  if (!userStore.isLoggedIn) return useLoginCheck()
  console.log(userStore.info?.nickname)
})
// 配方 3:業務擴充 store —— 包裝而非改寫(升級 framework 零衝突)
export const useAppUserStore = defineStore('app-user', () => {
  const base = useUserStore()
  const { info, isLoggedIn } = storeToRefs(base)
  const vipLevel = computed(() => (info.value?.vip as number) ?? 0)
  return { info, isLoggedIn, vipLevel, logout: base.logout }
})
// 配方 4:SSR 讀寫邊界(STORE-001)
const userStore = useUserStore()
const logged = userStore.isLoggedIn   // ✅ 唯讀成員 SSR 可用
// userStore.getDeviceId()            // ❌ setup 同步路徑呼叫寫方法 → server throw
onMounted(() => userStore.getDeviceId()) // ✅ 寫方法挪 client 生命週期

相關

注意事項

  • 寫狀態方法一律 client-only,SSR 期呼叫直接 throw(Rule STORE-001)—— getDeviceId / getSessionId / setInfo / setToken / setAccount / clearAccount / loginByUsername / register / loginByQrcode / loginByDevice / autoLogin / expireLogin / logout 頂部都有 assertClientOnly() 守衛。根因是 device id + token 完全在用戶端建立,SSR 期呼叫會寫 Set-Cookie 並烤進 Pinia payload,被 ISR 快取廣播給所有訪客 = 不同訪客拿到同一身分(安全級事故)。挪到 onMounted / event handler / <ClientOnly> 內。
  • 唯讀成員 SSR 可用 —— info / token / authToken / isLoggedIn / getAccountInfo 不受上條限制。
  • 「記住我」必須顯式透傳(Rule STORE-002)—— loginByUsername / loginByQrcode 的 remember 預設 true;LoginForm 不傳使用者的勾選狀態,則使用者取消勾選後仍會被持久化(帳號 / 加密密碼進 localStorage)。寫法:loginByUsername(u, p, { remember: isRemember.value })。
  • 別繞開 store 直讀核心 cookie(已有 eslint 規則 swift-web/no-direct-cookie-in-app 攔截)—— 注意兩條直讀路徑壞在不同地方:document.cookie.match(...) 拿到的是 AES 密文;而 useCookie(...) 拿到的其實是明文(framework 已同名覆蓋 Nuxt 內建的 useCookie,自動拼 cachePrefix + 透明解密),它壞在別處 —— 真實 key 是 runtimeConfig.public.tokenKey(如 tt),寫 useCookie('token') 讀的是另一個不存在的 cookie、靜默返回 null;就算 key 用對,也繞開了 store 的寫守衛、auth-ready 時序與欄位歸一,並把業務焊死在內部儲存布局上。