- schema v8:lexgo_book_attachments(按 book_id+kind 唯一,bytes MEDIUMBLOB)与 lexgo_playback_positions(owner_id+book_id 主键),只用可重放 DDL,外键级联删书 - 后端:按文件头 magic bytes 判定类型(MP3 / JPG / PNG / WebP,含 WebP 维度解析), 音频 ≤20 MiB、封面 ≤2 MiB 且 ≤4096²;上传/替换/移除;binaryResponse 交给 http.ServeContent 提供 Range 206、416、ETag 与 304;播放位置 upsert,替换音频时重置 - 学习端:带凭据的 fetch + 对象 URL(令牌不进 URL),书库封面、书籍页「音频与封面」 区块、阅读页常驻播放器(播放/暂停、进度、0.75–1.5 倍速、错误重试、位置上报) - 测试:Go 86 项(新增附件单测/集成与 v7→v8 迁移)、学习端 157 单测与 26 项 E2E - 顺带修掉 e2e/phrase.spec.ts 的偶发失败:重试条件改为断言期望词数、重读坐标、关面板后重试 - 文档:Architecture / Business-Rules / Local-Development / Requirements / Home / Deployment-and-Operations(备份范围含附件与体积提示)
132 lines
4.6 KiB
TypeScript
132 lines
4.6 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { ref } from 'vue'
|
|
|
|
export const TOKEN_KEY = 'lexgo-learner-token'
|
|
// Carries the real HTTP status alongside the server message so callers can tell
|
|
// "this id does not exist for me" (404) from a transient failure without
|
|
// re-parsing the envelope. The message itself is unchanged.
|
|
export class ApiError extends Error {
|
|
readonly status: number
|
|
constructor(message: string, status: number) {
|
|
super(message)
|
|
this.name = 'ApiError'
|
|
this.status = status
|
|
}
|
|
}
|
|
interface User { id: number; username: string; role: 'admin' | 'learner' }
|
|
interface Space { ownerId: number; language: 'en' }
|
|
interface Login { token: string; expiresAt: string; user: User }
|
|
|
|
export const useSessionStore = defineStore('session', () => {
|
|
const user = ref<User | null>(null)
|
|
const space = ref<Space | null>(null)
|
|
const notice = ref('')
|
|
let token = sessionStorage.getItem(TOKEN_KEY) ?? ''
|
|
// Invalidate all in-flight work on any session boundary, including failed switches.
|
|
let generation = 0
|
|
let initialized = false
|
|
let restoring: Promise<void> | undefined
|
|
|
|
function clear() {
|
|
generation++
|
|
token = ''
|
|
user.value = null
|
|
space.value = null
|
|
sessionStorage.removeItem(TOKEN_KEY)
|
|
}
|
|
|
|
async function request<T>(path: string, method = 'GET', body?: unknown, auth = token, version = generation): Promise<T> {
|
|
// A multipart body carries its own content type with the boundary, so it is sent as is.
|
|
const multipart = body instanceof FormData
|
|
const response = await fetch(`/api/v1/${path}`, {
|
|
method,
|
|
headers: { ...(auth ? { Authorization: `Bearer ${auth}` } : {}), ...(body && !multipart ? { 'Content-Type': 'application/json' } : {}) },
|
|
body: body ? (multipart ? body : JSON.stringify(body)) : undefined,
|
|
cache: 'no-store',
|
|
})
|
|
if (response.status === 401 && version === generation) {
|
|
clear()
|
|
notice.value = '登录已失效,请重新登录。'
|
|
}
|
|
const result = await response.json()
|
|
if (!response.ok || result.code !== 200) throw new ApiError(result.msg || '请求失败,请稍后重试。', response.status)
|
|
return result.data as T
|
|
}
|
|
|
|
/**
|
|
* Fetches binary content with the same session as every other request. A media element cannot
|
|
* carry an Authorization header, so the bytes are fetched here and handed to the page as a blob
|
|
* URL instead of putting the token into a URL where it could be logged or shared.
|
|
*/
|
|
async function requestBlob(path: string): Promise<Blob> {
|
|
const response = await fetch(`/api/v1/${path}`, {
|
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
|
cache: 'no-store',
|
|
})
|
|
if (!response.ok) {
|
|
let message = '附件暂时无法加载。'
|
|
try {
|
|
const result = await response.json()
|
|
if (result?.msg) message = result.msg
|
|
} catch {
|
|
// A non-JSON error body keeps the default message.
|
|
}
|
|
throw new ApiError(message, response.status)
|
|
}
|
|
return response.blob()
|
|
}
|
|
|
|
async function login(username: string, password: string) {
|
|
clear()
|
|
initialized = true
|
|
notice.value = ''
|
|
const version = generation
|
|
const result = await request<Login>('login', 'POST', { username, password }, '', version)
|
|
if (version !== generation) return
|
|
sessionStorage.setItem(TOKEN_KEY, result.token)
|
|
token = result.token
|
|
user.value = result.user
|
|
}
|
|
|
|
async function restore() {
|
|
if (initialized) return
|
|
if (restoring) return restoring
|
|
const version = generation
|
|
restoring = (async () => {
|
|
try {
|
|
if (!token) return
|
|
const account = await request<User>('me')
|
|
if (version === generation) user.value = account
|
|
} catch {
|
|
if (version === generation) {
|
|
clear()
|
|
notice.value = '暂时无法恢复登录,请重新登录。'
|
|
}
|
|
} finally {
|
|
initialized = true
|
|
restoring = undefined
|
|
}
|
|
})()
|
|
return restoring
|
|
}
|
|
|
|
async function loadSpace() {
|
|
if (!user.value) throw new Error('请先登录。')
|
|
const version = generation
|
|
const ownerId = user.value.id
|
|
const result = await request<Space>('space')
|
|
if (version !== generation) return
|
|
if (result.ownerId !== ownerId || result.language !== 'en') throw new Error('学习空间暂时无法加载,请稍后重试。')
|
|
space.value = result
|
|
}
|
|
|
|
async function logout() {
|
|
const previousToken = token
|
|
clear()
|
|
notice.value = ''
|
|
if (previousToken) await request<null>('logout', 'POST', undefined, previousToken, -1)
|
|
}
|
|
|
|
return { user, space, notice, login, restore, logout, loadSpace, request, requestBlob }
|
|
})
|