mirror of
https://github.com/SECTL/SecScore.git
synced 2026-07-21 20:29:03 +08:00
chore: 完成项目多维度优化与功能迭代
本次提交包含多项改进: 1. 新增Vitest测试配置与相关测试用例 2. 完善Deep Link监听与OAuth回调支持 3. 修复并优化多个React组件的依赖与性能 4. 更新后端API域名与端点地址 5. 优化KV存储服务实现与类型定义 6. 调整用户信息展示与权限相关逻辑 7. 重构部分业务逻辑,使用useCallback优化性能 8. 更新依赖包与Cargo.lock依赖版本
This commit is contained in:
@@ -4,21 +4,20 @@
|
||||
|
||||
export { sectlAuth, SECTL_CONFIG } from "./sectlAuth"
|
||||
|
||||
export type { TokenData, UserInfo } from "./sectlAuth"
|
||||
export type { TokenData, TokenIntrospection, UserInfo } from "./sectlAuth"
|
||||
|
||||
export { sectlCloudStorage } from "./sectlCloudStorage"
|
||||
|
||||
export type { CloudFile, ShareLink, StorageUsage } from "./sectlCloudStorage"
|
||||
export type { CloudFile, ShareLink, KVData, StorageUsage } from "./sectlCloudStorage"
|
||||
|
||||
export { sectlKVStorage } from "./sectlKVStorage"
|
||||
|
||||
export type { KVData } from "./sectlCloudStorage"
|
||||
export type { KVItem, ListKVOptions } from "./sectlKVStorage"
|
||||
|
||||
export { sectlNotification } from "./sectlNotification"
|
||||
|
||||
export type { Notification, SendNotificationParams } from "./sectlNotification"
|
||||
|
||||
// 积分数据同步服务
|
||||
export { scoreSyncService } from "./scoreSyncService"
|
||||
|
||||
export type { ScoreData, ScoreEvent, SyncedData } from "./scoreSyncService"
|
||||
|
||||
+303
-192
@@ -1,181 +1,203 @@
|
||||
/**
|
||||
* SECTL OAuth 2.0 认证服务
|
||||
* 基于 SECTL-One-Stop 项目的 OAuth API 实现
|
||||
* 基于 SECTL-One-Stop SDK 的 OAuth API 实现
|
||||
* 支持 PKCE (Proof Key for Code Exchange) 流程
|
||||
*/
|
||||
|
||||
import { Client, Account } from "appwrite"
|
||||
import CryptoJS from "crypto-js"
|
||||
|
||||
// SECTL API 配置
|
||||
export const SECTL_CONFIG = {
|
||||
baseUrl: "https://appwrite.sectl.top",
|
||||
authUrl: "https://sectl.top",
|
||||
platformId: "", // 需要在设置中配置
|
||||
callbackUrl: "http://localhost:5173/auth/callback",
|
||||
baseUrl: "https://appwrite.sectl.cn",
|
||||
authUrl: "https://sectl.cn",
|
||||
platformId: "",
|
||||
callbackUrl: "secscore://oauth",
|
||||
callbackPort: 5173,
|
||||
}
|
||||
|
||||
// Token 数据类型
|
||||
// Token 数据类型 (与 SDK TokenData 对齐)
|
||||
export interface TokenData {
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user_id: string
|
||||
refresh_token?: string
|
||||
token_type?: string
|
||||
expires_in?: number
|
||||
scope?: string
|
||||
user_id?: string
|
||||
}
|
||||
|
||||
// 用户信息类型
|
||||
// Token 验证结果 (与 SDK TokenIntrospection 对齐)
|
||||
export interface TokenIntrospection {
|
||||
active: boolean
|
||||
scope?: string
|
||||
user_id?: string
|
||||
client_id?: string
|
||||
exp?: number
|
||||
iat?: number
|
||||
}
|
||||
|
||||
// 用户信息类型 (与 SDK UserInfoData 对齐)
|
||||
export interface UserInfo {
|
||||
user_id: string
|
||||
email: string
|
||||
name: string
|
||||
github_username?: string
|
||||
permission: string
|
||||
role: string
|
||||
user_id?: string
|
||||
id?: string
|
||||
email?: string
|
||||
name?: string
|
||||
avatar?: string
|
||||
avatar_url?: string
|
||||
background_url?: string
|
||||
bio: string
|
||||
tags: string[]
|
||||
platform_id?: string
|
||||
login_time?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
email_verified?: boolean
|
||||
status?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// PKCE 相关工具函数
|
||||
function generateCodeVerifier(): string {
|
||||
async function generateCodeVerifier(): Promise<string> {
|
||||
const array = new Uint8Array(32)
|
||||
crypto.getRandomValues(array)
|
||||
return base64URLEncode(array)
|
||||
}
|
||||
|
||||
function generateCodeChallenge(verifier: string): string {
|
||||
const sha256 = CryptoJS.SHA256(verifier)
|
||||
return base64URLEncode(CryptoJS.enc.Base64.parse(sha256.toString(CryptoJS.enc.Base64)))
|
||||
async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const encoder = new TextEncoder()
|
||||
const data = encoder.encode(verifier)
|
||||
const digest = await crypto.subtle.digest("SHA-256", data)
|
||||
return base64URLEncode(new Uint8Array(digest))
|
||||
}
|
||||
|
||||
function base64URLEncode(buffer: Uint8Array | CryptoJS.lib.WordArray): string {
|
||||
let base64: string
|
||||
if (buffer instanceof Uint8Array) {
|
||||
base64 = btoa(String.fromCharCode(...buffer))
|
||||
} else {
|
||||
base64 = buffer.toString(CryptoJS.enc.Base64)
|
||||
function base64URLEncode(buffer: Uint8Array): string {
|
||||
let binary = ""
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
binary += String.fromCharCode(buffer[i])
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
function extractUserIdFromJwt(accessToken: string): string | null {
|
||||
try {
|
||||
const parts = accessToken.split(".")
|
||||
if (parts.length !== 3) return null
|
||||
let payload = parts[1]
|
||||
const padding = 4 - (payload.length % 4)
|
||||
if (padding !== 4) payload += "=".repeat(padding)
|
||||
const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"))
|
||||
const claims = JSON.parse(decoded)
|
||||
return claims.user_id || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function extractPlatformIdFromJwt(accessToken: string): string | null {
|
||||
try {
|
||||
const parts = accessToken.split(".")
|
||||
if (parts.length !== 3) return null
|
||||
let payload = parts[1]
|
||||
const padding = 4 - (payload.length % 4)
|
||||
if (padding !== 4) payload += "=".repeat(padding)
|
||||
const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"))
|
||||
const claims = JSON.parse(decoded)
|
||||
return claims.platform_id || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
|
||||
}
|
||||
|
||||
class SectlAuthService {
|
||||
private client: Client
|
||||
private tokenData: TokenData | null = null
|
||||
private codeVerifier: string = ""
|
||||
private accessToken: string | null = null
|
||||
private refreshToken: string | null = null
|
||||
private tokenExpiresAt: number | null = null
|
||||
private userId: string | null = null
|
||||
private codeVerifier: string | null = null
|
||||
|
||||
constructor() {
|
||||
this.client = new Client()
|
||||
new Account(this.client)
|
||||
this.loadToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化客户端配置
|
||||
*/
|
||||
initialize(platformId: string, callbackUrl?: string) {
|
||||
SECTL_CONFIG.platformId = platformId
|
||||
if (callbackUrl) {
|
||||
SECTL_CONFIG.callbackUrl = callbackUrl
|
||||
}
|
||||
|
||||
this.client.setEndpoint(SECTL_CONFIG.baseUrl).setProject("sectl-cloud") // 项目 ID
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成授权 URL
|
||||
*/
|
||||
getAuthorizationUrl(scope: string[] = ["user:read"]): string {
|
||||
this.codeVerifier = generateCodeVerifier()
|
||||
const codeChallenge = generateCodeChallenge(this.codeVerifier)
|
||||
async getAuthorizationUrl(scope?: string[]): Promise<string> {
|
||||
const state = this.generateRandomState()
|
||||
this.codeVerifier = await generateCodeVerifier()
|
||||
const codeChallenge = await generateCodeChallenge(this.codeVerifier)
|
||||
|
||||
// 保存 code_verifier 到 localStorage,以便 deep link 回调时使用
|
||||
localStorage.setItem("sectl_code_verifier", this.codeVerifier)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
redirect_uri: SECTL_CONFIG.callbackUrl,
|
||||
response_type: "code",
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
scope: scope.join(" "),
|
||||
})
|
||||
|
||||
if (scope && scope.length > 0) {
|
||||
params.set("scope", scope.join(" "))
|
||||
}
|
||||
|
||||
return `${SECTL_CONFIG.authUrl}/oauth/authorize?${params.toString()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行完整的 OAuth 授权流程
|
||||
*/
|
||||
async authorize(scope: string[] = ["user:read"]): Promise<TokenData> {
|
||||
// 生成 PKCE 参数
|
||||
this.codeVerifier = generateCodeVerifier()
|
||||
// codeChallenge 在 getAuthorizationUrl 中生成
|
||||
async authorize(scope?: string[]): Promise<TokenData> {
|
||||
this.codeVerifier = await generateCodeVerifier()
|
||||
|
||||
// 构建授权 URL
|
||||
const authUrl = this.getAuthorizationUrl(scope)
|
||||
|
||||
// 在新窗口中打开授权页面
|
||||
const authUrl = await this.getAuthorizationUrl(scope)
|
||||
const authWindow = window.open(authUrl, "_blank", "width=600,height=700")
|
||||
|
||||
// 等待回调
|
||||
return new Promise((resolve, reject) => {
|
||||
// 监听授权回调
|
||||
const checkCallback = async () => {
|
||||
try {
|
||||
// 检查是否有授权码(通过 postMessage 或 URL 参数)
|
||||
window.addEventListener("message", async (event) => {
|
||||
if (event.data.type === "oauth-callback" && event.data.code) {
|
||||
try {
|
||||
const token = await this.exchangeCodeForToken(event.data.code, scope)
|
||||
authWindow?.close()
|
||||
resolve(token)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 轮询检查窗口关闭
|
||||
const checkInterval = setInterval(() => {
|
||||
if (authWindow?.closed) {
|
||||
clearInterval(checkInterval)
|
||||
reject(new Error("授权窗口已关闭"))
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
window.addEventListener("message", async (event) => {
|
||||
if (event.data.type === "oauth-callback" && event.data.code) {
|
||||
try {
|
||||
const token = await this.exchangeCodeForToken(event.data.code, scope)
|
||||
authWindow?.close()
|
||||
resolve(token)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
checkCallback()
|
||||
const checkInterval = setInterval(() => {
|
||||
if (authWindow?.closed) {
|
||||
clearInterval(checkInterval)
|
||||
reject(new Error("授权窗口已关闭"))
|
||||
}
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用授权码换取 Token
|
||||
*/
|
||||
async exchangeCodeForToken(code: string, scope: string[] = ["user:read"]): Promise<TokenData> {
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
|
||||
async exchangeCodeForToken(code: string, scope?: string[]): Promise<TokenData> {
|
||||
if (!this.codeVerifier) {
|
||||
throw new Error("缺少 code_verifier,请先调用 getAuthorizationUrl()")
|
||||
}
|
||||
|
||||
const payload = {
|
||||
const deviceUuid = this.generateDeviceUuid()
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
redirect_uri: SECTL_CONFIG.callbackUrl,
|
||||
code_verifier: this.codeVerifier,
|
||||
scope: scope.join(" "),
|
||||
device_uuid: deviceUuid,
|
||||
ip_address: "127.0.0.1",
|
||||
}
|
||||
|
||||
if (scope && scope.length > 0) {
|
||||
payload.scope = scope.join(" ")
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
@@ -185,9 +207,55 @@ class SectlAuthService {
|
||||
}
|
||||
|
||||
const data: TokenData = await response.json()
|
||||
this.tokenData = data
|
||||
this.saveToken()
|
||||
|
||||
this.saveToken(data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Token 交换失败:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// 用于 Deep Link 回调的 code 交换
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async exchangeCode(code: string, _state: string): Promise<TokenData> {
|
||||
// 从 localStorage 恢复 code_verifier
|
||||
const savedVerifier = localStorage.getItem("sectl_code_verifier")
|
||||
if (savedVerifier) {
|
||||
this.codeVerifier = savedVerifier
|
||||
}
|
||||
|
||||
if (!this.codeVerifier) {
|
||||
throw new Error("缺少 code_verifier,请重新发起登录")
|
||||
}
|
||||
|
||||
const deviceUuid = this.generateDeviceUuid()
|
||||
|
||||
const payload = {
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
redirect_uri: SECTL_CONFIG.callbackUrl,
|
||||
code_verifier: this.codeVerifier,
|
||||
device_uuid: deviceUuid,
|
||||
ip_address: "127.0.0.1",
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "Token 交换失败")
|
||||
}
|
||||
|
||||
const data: TokenData = await response.json()
|
||||
this.saveToken(data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Token 交换失败:", error)
|
||||
@@ -195,11 +263,8 @@ class SectlAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
async getUserInfo(): Promise<UserInfo> {
|
||||
if (!this.tokenData?.access_token) {
|
||||
if (!this.accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
@@ -207,9 +272,7 @@ class SectlAuthService {
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.tokenData.access_token}`,
|
||||
},
|
||||
headers: { Authorization: `Bearer ${this.accessToken}` },
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -224,12 +287,8 @@ class SectlAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Token
|
||||
*/
|
||||
async introspectToken(token?: string): Promise<{ active: boolean; user_id?: string }> {
|
||||
const tokenToCheck = token || this.tokenData?.access_token
|
||||
|
||||
async introspectToken(token?: string): Promise<TokenIntrospection> {
|
||||
const tokenToCheck = token || this.accessToken
|
||||
if (!tokenToCheck) {
|
||||
return { active: false }
|
||||
}
|
||||
@@ -239,9 +298,7 @@ class SectlAuthService {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: tokenToCheck,
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
@@ -253,34 +310,32 @@ class SectlAuthService {
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("Token 验证失败:", error)
|
||||
} catch {
|
||||
return { active: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*/
|
||||
async refreshAccessToken(): Promise<TokenData> {
|
||||
if (!this.tokenData?.refresh_token) {
|
||||
throw new Error("没有刷新令牌")
|
||||
if (!this.refreshToken) {
|
||||
throw new Error("没有 refresh_token,无法刷新")
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/token`
|
||||
const deviceUuid = this.generateDeviceUuid()
|
||||
|
||||
const payload = {
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: this.tokenData.refresh_token,
|
||||
refresh_token: this.refreshToken,
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
device_uuid: deviceUuid,
|
||||
ip_address: "127.0.0.1",
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/refresh`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
@@ -290,9 +345,7 @@ class SectlAuthService {
|
||||
}
|
||||
|
||||
const data: TokenData = await response.json()
|
||||
this.tokenData = data
|
||||
this.saveToken()
|
||||
|
||||
this.saveToken(data)
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error("Token 刷新失败:", error)
|
||||
@@ -300,89 +353,147 @@ class SectlAuthService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出(撤销 Token)
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
if (!this.tokenData?.access_token) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/oauth/logout`
|
||||
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.tokenData.access_token}`,
|
||||
},
|
||||
})
|
||||
if (this.accessToken) {
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ client_id: SECTL_CONFIG.platformId }),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("登出失败:", error)
|
||||
} finally {
|
||||
this.tokenData = null
|
||||
this.accessToken = null
|
||||
this.refreshToken = null
|
||||
this.tokenExpiresAt = null
|
||||
this.userId = null
|
||||
this.clearToken()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前 Token
|
||||
*/
|
||||
getToken(): TokenData | null {
|
||||
return this.tokenData
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Access Token
|
||||
*/
|
||||
getAccessToken(): string | null {
|
||||
return this.tokenData?.access_token || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否已授权
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.tokenData?.access_token
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Token 到本地存储
|
||||
*/
|
||||
private saveToken(): void {
|
||||
if (this.tokenData) {
|
||||
localStorage.setItem("sectl_token", JSON.stringify(this.tokenData))
|
||||
localStorage.setItem("sectl_code_verifier", this.codeVerifier)
|
||||
private saveToken(tokenData: TokenData): void {
|
||||
const rawAccessToken = tokenData.access_token
|
||||
if (rawAccessToken.includes("|")) {
|
||||
const parts = rawAccessToken.split("|")
|
||||
this.accessToken = parts[0]
|
||||
this.refreshToken = tokenData.refresh_token || parts[1] || null
|
||||
} else {
|
||||
this.accessToken = rawAccessToken
|
||||
this.refreshToken = tokenData.refresh_token || null
|
||||
}
|
||||
|
||||
this.userId = tokenData.user_id || extractUserIdFromJwt(this.accessToken)
|
||||
|
||||
const jwtPlatformId = extractPlatformIdFromJwt(this.accessToken)
|
||||
if (jwtPlatformId && !SECTL_CONFIG.platformId) {
|
||||
SECTL_CONFIG.platformId = jwtPlatformId
|
||||
}
|
||||
|
||||
if (tokenData.expires_in) {
|
||||
this.tokenExpiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in
|
||||
}
|
||||
|
||||
const storableData: TokenData = {
|
||||
access_token: rawAccessToken,
|
||||
refresh_token: tokenData.refresh_token,
|
||||
token_type: tokenData.token_type,
|
||||
expires_in: tokenData.expires_in,
|
||||
scope: tokenData.scope,
|
||||
user_id: this.userId || undefined,
|
||||
}
|
||||
|
||||
localStorage.setItem("sectl_token", JSON.stringify(storableData))
|
||||
// 登录成功后清除 code_verifier
|
||||
localStorage.removeItem("sectl_code_verifier")
|
||||
}
|
||||
|
||||
/**
|
||||
* 从本地存储加载 Token
|
||||
*/
|
||||
private loadToken(): void {
|
||||
try {
|
||||
const tokenStr = localStorage.getItem("sectl_token")
|
||||
const codeVerifier = localStorage.getItem("sectl_code_verifier")
|
||||
|
||||
if (tokenStr) {
|
||||
this.tokenData = JSON.parse(tokenStr)
|
||||
this.codeVerifier = codeVerifier || ""
|
||||
const tokenData: TokenData = JSON.parse(tokenStr)
|
||||
|
||||
const rawAccessToken = tokenData.access_token
|
||||
if (rawAccessToken.includes("|")) {
|
||||
const parts = rawAccessToken.split("|")
|
||||
this.accessToken = parts[0]
|
||||
this.refreshToken = tokenData.refresh_token || parts[1] || null
|
||||
} else {
|
||||
this.accessToken = rawAccessToken
|
||||
this.refreshToken = tokenData.refresh_token || null
|
||||
}
|
||||
|
||||
this.userId = tokenData.user_id || extractUserIdFromJwt(this.accessToken)
|
||||
|
||||
if (tokenData.expires_in) {
|
||||
this.tokenExpiresAt = Math.floor(Date.now() / 1000) + tokenData.expires_in
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载 Token 失败:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 Token
|
||||
*/
|
||||
private clearToken(): void {
|
||||
localStorage.removeItem("sectl_token")
|
||||
localStorage.removeItem("sectl_code_verifier")
|
||||
this.tokenData = null
|
||||
this.codeVerifier = ""
|
||||
this.accessToken = null
|
||||
this.refreshToken = null
|
||||
this.tokenExpiresAt = null
|
||||
this.userId = null
|
||||
this.codeVerifier = null
|
||||
}
|
||||
|
||||
getToken(): TokenData | null {
|
||||
if (!this.accessToken) return null
|
||||
return {
|
||||
access_token: this.accessToken,
|
||||
refresh_token: this.refreshToken || undefined,
|
||||
user_id: this.userId || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
getAccessToken(): string | null {
|
||||
return this.accessToken
|
||||
}
|
||||
|
||||
getUserId(): string | null {
|
||||
return this.userId
|
||||
}
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return !!this.accessToken
|
||||
}
|
||||
|
||||
isTokenExpired(): boolean {
|
||||
if (!this.tokenExpiresAt) return false
|
||||
return Date.now() / 1000 >= this.tokenExpiresAt
|
||||
}
|
||||
|
||||
private generateRandomState(): string {
|
||||
const array = new Uint8Array(32)
|
||||
crypto.getRandomValues(array)
|
||||
return base64URLEncode(array)
|
||||
}
|
||||
|
||||
private generateDeviceUuid(): string {
|
||||
if (typeof crypto.randomUUID === "function") {
|
||||
return crypto.randomUUID()
|
||||
}
|
||||
const array = new Uint8Array(16)
|
||||
crypto.getRandomValues(array)
|
||||
array[6] = (array[6] & 0x0f) | 0x40
|
||||
array[8] = (array[8] & 0x3f) | 0x80
|
||||
const hex = Array.from(array, (b) => b.toString(16).padStart(2, "0")).join("")
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const sectlAuth = new SectlAuthService()
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { sectlCloudStorage } from "./sectlCloudStorage"
|
||||
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
|
||||
|
||||
// Mock fetch and sectlAuth
|
||||
global.fetch = vi.fn()
|
||||
vi.mock("./sectlAuth", () => ({
|
||||
SECTL_CONFIG: {
|
||||
baseUrl: "https://api.sectl.cn",
|
||||
platformId: "test-platform-id",
|
||||
},
|
||||
sectlAuth: {
|
||||
getAccessToken: vi.fn(),
|
||||
getUserId: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("sectlCloudStorage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(sectlAuth.getAccessToken).mockReturnValue("test-access-token")
|
||||
vi.mocked(sectlAuth.getUserId).mockReturnValue("test-user-id")
|
||||
})
|
||||
|
||||
describe("uploadFile", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockResponse = { success: true, file_id: "file-123" }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
const file = new File(["test"], "test.txt", { type: "text/plain" })
|
||||
await sectlCloudStorage.uploadFile(file, { filename: "test.txt" })
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/upload`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("listFiles", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockResponse = { files: [], total: 0 }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlCloudStorage.listFiles({ limit: 10, offset: 0 })
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`${SECTL_CONFIG.baseUrl}/api/cloud/files`),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getFileInfo", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockResponse = { file_id: "file-123", name: "test.txt" }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlCloudStorage.getFileInfo("file-123")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/files/file-123`,
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("downloadFile", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockBlob = new Blob(["test"])
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
blob: async () => mockBlob,
|
||||
} as Response)
|
||||
|
||||
await sectlCloudStorage.downloadFile("file-123")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`${SECTL_CONFIG.baseUrl}/api/cloud/files/file-123/download`),
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("renameFile", () => {
|
||||
it("should call correct API endpoint with PUT method", async () => {
|
||||
const mockResponse = { file_id: "file-123", filename: "new.txt" }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlCloudStorage.renameFile("file-123", "new.txt")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/files/file-123`,
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createShare", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockResponse = { share_id: "share-123", share_url: "https://example.com" }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlCloudStorage.createShare("file-123")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/share`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("disableShare", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
} as Response)
|
||||
|
||||
await sectlCloudStorage.disableShare("share-123")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/shares/share-123/disable`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("enableShare", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
} as Response)
|
||||
|
||||
await sectlCloudStorage.enableShare("share-123")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/shares/share-123/enable`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
+295
-488
@@ -1,11 +1,10 @@
|
||||
/**
|
||||
* SECTL 云存储服务
|
||||
* 提供文件上传、下载、管理、分享等功能
|
||||
* 基于 SECTL-One-Stop SDK 的云存储 API 实现
|
||||
*/
|
||||
|
||||
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
|
||||
|
||||
// 文件信息类型
|
||||
export interface CloudFile {
|
||||
file_id: string
|
||||
filename: string
|
||||
@@ -18,7 +17,6 @@ export interface CloudFile {
|
||||
thumbnail_url?: string
|
||||
}
|
||||
|
||||
// 分享信息类型
|
||||
export interface ShareLink {
|
||||
share_id: string
|
||||
share_url: string
|
||||
@@ -31,18 +29,16 @@ export interface ShareLink {
|
||||
status: "active" | "disabled" | "expired"
|
||||
}
|
||||
|
||||
// KV 存储类型
|
||||
export interface KVData {
|
||||
kv_id: string
|
||||
key: string
|
||||
value: any
|
||||
value: unknown
|
||||
is_json: boolean
|
||||
size: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 存储使用情况
|
||||
export interface StorageUsage {
|
||||
used_storage: number
|
||||
used_storage_formatted: string
|
||||
@@ -55,542 +51,353 @@ export interface StorageUsage {
|
||||
}
|
||||
|
||||
class SectlCloudStorageService {
|
||||
/**
|
||||
* 获取文件列表
|
||||
*/
|
||||
async listFiles(options?: {
|
||||
folder_id?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{ files: CloudFile[]; total: number; has_more: boolean }> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
limit: String(options?.limit || 100),
|
||||
offset: String(options?.offset || 0),
|
||||
})
|
||||
|
||||
if (options?.folder_id) {
|
||||
params.append("folder_id", options.folder_id)
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files?${params.toString()}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "获取文件列表失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("获取文件列表失败:", error)
|
||||
throw error
|
||||
}
|
||||
private getAccessToken(): string {
|
||||
const token = sectlAuth.getAccessToken()
|
||||
if (!token) throw new Error("未授权,请先登录")
|
||||
return token
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
return { Authorization: `Bearer ${this.getAccessToken()}` }
|
||||
}
|
||||
|
||||
private buildParams(extra?: Record<string, string>): URLSearchParams {
|
||||
const params = new URLSearchParams({ client_id: SECTL_CONFIG.platformId })
|
||||
const userId = sectlAuth.getUserId()
|
||||
if (userId) params.append("user_id", userId)
|
||||
if (extra) {
|
||||
for (const [key, value] of Object.entries(extra)) {
|
||||
params.append(key, value)
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*/
|
||||
async uploadFile(
|
||||
file: File | Blob,
|
||||
options?: {
|
||||
folder_id?: string
|
||||
filename?: string
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean
|
||||
file_id: string
|
||||
filename: string
|
||||
size: number
|
||||
size_formatted: string
|
||||
}> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
options?: { filename?: string; description?: string; tags?: string[] }
|
||||
): Promise<CloudFile> {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file, options?.filename)
|
||||
formData.append("client_id", SECTL_CONFIG.platformId)
|
||||
formData.append("user_id", sectlAuth.getToken()?.user_id || "")
|
||||
if (options?.folder_id) {
|
||||
formData.append("folder_id", options.folder_id)
|
||||
}
|
||||
const userId = sectlAuth.getUserId()
|
||||
if (userId) formData.append("user_id", userId)
|
||||
if (options?.description) formData.append("description", options.description)
|
||||
if (options?.tags) formData.append("tags", JSON.stringify(options.tags))
|
||||
|
||||
// 如果是 File 对象,直接使用;否则需要指定文件名
|
||||
if (file instanceof File) {
|
||||
formData.append("file", file)
|
||||
} else {
|
||||
const filename = options?.filename || "upload_" + Date.now()
|
||||
formData.append("file", file, filename)
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/upload`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "文件上传失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("文件上传失败:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件(Base64 格式)
|
||||
*/
|
||||
async uploadFileBase64(
|
||||
base64Data: string,
|
||||
filename: string,
|
||||
mimeType: string = "application/octet-stream",
|
||||
options?: {
|
||||
folder_id?: string
|
||||
}
|
||||
): Promise<{
|
||||
success: boolean
|
||||
file_id: string
|
||||
filename: string
|
||||
size: number
|
||||
}> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/upload`
|
||||
|
||||
const payload = {
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
filename,
|
||||
mime_type: mimeType,
|
||||
file: base64Data,
|
||||
folder_id: options?.folder_id,
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "文件上传失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("文件上传失败:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件下载链接
|
||||
*/
|
||||
async downloadFile(fileId: string): Promise<{ download_url: string; expires_at: string }> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/upload`, {
|
||||
method: "POST",
|
||||
headers: this.getAuthHeaders(),
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/download?${params.toString()}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "获取下载链接失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("获取下载链接失败:", error)
|
||||
throw error
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "文件上传失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件预览链接
|
||||
*/
|
||||
async previewFile(fileId: string): Promise<{ view_url: string; expires_at: string }> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
async listFiles(options?: {
|
||||
folder?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{ files: CloudFile[]; total: number }> {
|
||||
const params = this.buildParams()
|
||||
if (options?.folder) params.append("folder", options.folder)
|
||||
if (options?.limit) params.append("limit", String(options.limit))
|
||||
if (options?.offset) params.append("offset", String(options.offset))
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files?${params}`, {
|
||||
headers: this.getAuthHeaders(),
|
||||
})
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/preview?${params.toString()}`
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "获取文件列表失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
async getFileInfo(fileId: string): Promise<CloudFile> {
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`, {
|
||||
headers: this.getAuthHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "获取预览链接失败")
|
||||
}
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "获取文件信息失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("获取预览链接失败:", error)
|
||||
throw error
|
||||
async downloadFile(fileId: string): Promise<Blob> {
|
||||
const params = this.buildParams()
|
||||
const response = await fetch(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/download?${params}`,
|
||||
{ headers: this.getAuthHeaders() }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "下载文件失败")
|
||||
}
|
||||
return response.blob()
|
||||
}
|
||||
|
||||
async previewFile(fileId: string): Promise<CloudFile> {
|
||||
const params = this.buildParams()
|
||||
const response = await fetch(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}/preview?${params}`,
|
||||
{ headers: this.getAuthHeaders() }
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "预览文件失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async deleteFile(fileId: string, userId?: string): Promise<void> {
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
...(userId ? { user_id: userId } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "删除文件失败")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
*/
|
||||
async deleteFile(fileId: string): Promise<{ success: boolean; message: string }> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "删除文件失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("删除文件失败:", error)
|
||||
throw error
|
||||
async renameFile(fileId: string, newFilename: string): Promise<CloudFile> {
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
filename: newFilename,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "重命名文件失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 重命名文件
|
||||
*/
|
||||
async renameFile(
|
||||
fileId: string,
|
||||
newFilename: string
|
||||
): Promise<{
|
||||
file_id: string
|
||||
filename: string
|
||||
updated_at: string
|
||||
}> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/files/${fileId}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
filename: newFilename,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "重命名文件失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("重命名文件失败:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建分享链接
|
||||
*/
|
||||
async createShare(
|
||||
fileId: string,
|
||||
options?: {
|
||||
expires_in?: number // 秒,0 表示永久
|
||||
password?: string
|
||||
}
|
||||
): Promise<{
|
||||
share_id: string
|
||||
share_url: string
|
||||
file_id: string
|
||||
filename: string
|
||||
expires_at?: string
|
||||
has_password: boolean
|
||||
}> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/share`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
file_id: fileId,
|
||||
expires_in: options?.expires_in || 86400, // 默认 1 天
|
||||
password: options?.password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "创建分享失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("创建分享失败:", error)
|
||||
throw error
|
||||
options?: { expiresIn?: number; password?: string; userId?: string }
|
||||
): Promise<ShareLink> {
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/share`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
file_id: fileId,
|
||||
...(options?.expiresIn ? { expires_in: options.expiresIn } : {}),
|
||||
...(options?.password ? { password: options.password } : {}),
|
||||
...(options?.userId ? { user_id: options.userId } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "创建分享链接失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取分享列表
|
||||
*/
|
||||
async listShares(): Promise<{ shares: ShareLink[]; total: number }> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
const params = this.buildParams()
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares?${params}`, {
|
||||
headers: this.getAuthHeaders(),
|
||||
})
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares?${params.toString()}`
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "获取分享列表失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
async disableShare(shareId: string, userId?: string): Promise<void> {
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/disable`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
...(userId ? { user_id: userId } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "获取分享列表失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("获取分享列表失败:", error)
|
||||
throw error
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "禁用分享失败")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用分享
|
||||
*/
|
||||
async disableShare(shareId: string): Promise<{ success: boolean; message: string }> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
async enableShare(shareId: string, userId?: string): Promise<void> {
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/enable`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
...(userId ? { user_id: userId } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/disable`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "禁用分享失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("禁用分享失败:", error)
|
||||
throw error
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "启用分享失败")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用分享
|
||||
*/
|
||||
async enableShare(shareId: string): Promise<{ success: boolean; message: string }> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
async deleteShare(shareId: string, userId?: string): Promise<void> {
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
...(userId ? { user_id: userId } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}/enable`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "启用分享失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("启用分享失败:", error)
|
||||
throw error
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "删除分享失败")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除分享
|
||||
*/
|
||||
async deleteShare(shareId: string): Promise<{ success: boolean; message: string }> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/shares/${shareId}`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "删除分享失败")
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("删除分享失败:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取存储使用情况
|
||||
*/
|
||||
async getStorageUsage(): Promise<StorageUsage> {
|
||||
const accessToken = sectlAuth.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("未授权,请先登录")
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
user_id: sectlAuth.getToken()?.user_id || "",
|
||||
const params = this.buildParams()
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/storage/usage?${params}`, {
|
||||
headers: this.getAuthHeaders(),
|
||||
})
|
||||
|
||||
const url = `${SECTL_CONFIG.baseUrl}/api/cloud/storage/usage?${params.toString()}`
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "获取存储使用情况失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
async setKV(
|
||||
key: string,
|
||||
value: string | Record<string, unknown>,
|
||||
options?: { expiresIn?: number; userId?: string; description?: string }
|
||||
): Promise<KVData> {
|
||||
const isJson = typeof value === "object"
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
key,
|
||||
value: isJson ? JSON.stringify(value) : value,
|
||||
is_json: isJson,
|
||||
...(options?.expiresIn ? { expires_in: options.expiresIn } : {}),
|
||||
...(options?.userId ? { user_id: options.userId } : {}),
|
||||
...(options?.description ? { description: options.description } : {}),
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.error_description || "获取存储使用情况失败")
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "设置 KV 失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async getKV(key: string): Promise<KVData> {
|
||||
const response = await fetch(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: this.getAuthHeaders(),
|
||||
}
|
||||
)
|
||||
|
||||
return await response.json()
|
||||
} catch (error) {
|
||||
console.error("获取存储使用情况失败:", error)
|
||||
throw error
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "获取 KV 失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async updateKVField(
|
||||
key: string,
|
||||
field: string,
|
||||
value: unknown,
|
||||
userId?: string
|
||||
): Promise<KVData> {
|
||||
const response = await fetch(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
field,
|
||||
value: typeof value === "object" ? JSON.stringify(value) : value,
|
||||
...(userId ? { user_id: userId } : {}),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "更新 KV 字段失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async listKV(options?: {
|
||||
prefix?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}): Promise<{ data: KVData[]; total: number }> {
|
||||
const params = this.buildParams()
|
||||
if (options?.prefix) params.append("prefix", options.prefix)
|
||||
if (options?.limit) params.append("limit", String(options.limit))
|
||||
if (options?.offset) params.append("offset", String(options.offset))
|
||||
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv?${params}`, {
|
||||
headers: this.getAuthHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "获取 KV 列表失败")
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async deleteKV(key: string, userId?: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify({
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
...(userId ? { user_id: userId } : {}),
|
||||
}),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "删除 KV 失败")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const sectlCloudStorage = new SectlCloudStorageService()
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { sectlKVStorage } from "./sectlKVStorage"
|
||||
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
|
||||
|
||||
// Mock fetch and sectlAuth
|
||||
global.fetch = vi.fn()
|
||||
vi.mock("./sectlAuth", () => ({
|
||||
SECTL_CONFIG: {
|
||||
baseUrl: "https://api.sectl.cn",
|
||||
platformId: "test-platform-id",
|
||||
},
|
||||
sectlAuth: {
|
||||
getAccessToken: vi.fn(),
|
||||
getUserId: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("sectlKVStorage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(sectlAuth.getAccessToken).mockReturnValue("test-access-token")
|
||||
vi.mocked(sectlAuth.getUserId).mockReturnValue("test-user-id")
|
||||
})
|
||||
|
||||
describe("setKV", () => {
|
||||
it("should call correct API endpoint for string value", async () => {
|
||||
const mockResponse = { success: true }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlKVStorage.setKV("test-key", "test-value")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/kv`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.stringContaining('"key":"test-key"'),
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("should call correct API endpoint for object value", async () => {
|
||||
const mockResponse = { success: true }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlKVStorage.setKV("test-key", { foo: "bar" })
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/kv`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: expect.stringContaining('"key":"test-key"'),
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getKV", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockResponse = { value: "test-value" }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlKVStorage.getKV("test-key")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/test-key?client_id=test-platform-id&user_id=test-user-id`,
|
||||
expect.any(Object)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteKV", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockResponse = { success: true }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlKVStorage.deleteKV("test-key")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/test-key`,
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateKVField", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockResponse = { success: true }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlKVStorage.updateKVField("test-key", "field", "value")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/test-key`,
|
||||
expect.objectContaining({
|
||||
method: "PATCH",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
+131
-83
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* SECTL KV 存储服务
|
||||
* 基于 SECTL-One-Stop SDK 的云存储 KV API 实现
|
||||
*/
|
||||
|
||||
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
|
||||
|
||||
export interface KVItem {
|
||||
@@ -16,58 +21,26 @@ export interface ListKVOptions {
|
||||
}
|
||||
|
||||
class SectlKVStorageService {
|
||||
private get platformId(): string {
|
||||
return SECTL_CONFIG.platformId
|
||||
private getAccessToken(): string {
|
||||
const token = sectlAuth.getAccessToken()
|
||||
if (!token) throw new Error("未授权,请先登录")
|
||||
return token
|
||||
}
|
||||
|
||||
private getAuthHeaders(): Record<string, string> {
|
||||
const token = sectlAuth.getAccessToken()
|
||||
if (!token) throw new Error("未授权,请先登录")
|
||||
return { Authorization: `Bearer ${token}` }
|
||||
return { Authorization: `Bearer ${this.getAccessToken()}` }
|
||||
}
|
||||
|
||||
private async makeRequest<T>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
options: { jsonData?: Record<string, unknown>; params?: Record<string, unknown> } = {}
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
...this.getAuthHeaders(),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
let url = `${SECTL_CONFIG.baseUrl}${endpoint}`
|
||||
if (options.params) {
|
||||
const searchParams = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(options.params)) {
|
||||
if (v !== undefined && v !== null) searchParams.set(k, String(v))
|
||||
private buildParams(extra?: Record<string, string>): URLSearchParams {
|
||||
const params = new URLSearchParams({ client_id: SECTL_CONFIG.platformId })
|
||||
const userId = sectlAuth.getUserId()
|
||||
if (userId) params.append("user_id", userId)
|
||||
if (extra) {
|
||||
for (const [key, value] of Object.entries(extra)) {
|
||||
params.append(key, value)
|
||||
}
|
||||
const qs = searchParams.toString()
|
||||
if (qs) url += `?${qs}`
|
||||
}
|
||||
|
||||
const init: RequestInit = { method, headers }
|
||||
if (options.jsonData && method !== "GET") {
|
||||
init.body = JSON.stringify(options.jsonData)
|
||||
}
|
||||
|
||||
const response = await fetch(url, init)
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData: { error?: string; error_description?: string; message?: string } = {}
|
||||
try {
|
||||
errorData = await response.json()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const desc = errorData.error_description || errorData.message || `HTTP ${response.status}`
|
||||
const err = new Error(desc) as Error & { status?: number; error?: string }
|
||||
err.status = response.status
|
||||
err.error = errorData.error
|
||||
throw err
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
return params
|
||||
}
|
||||
|
||||
async setKV(
|
||||
@@ -83,67 +56,142 @@ class SectlKVStorageService {
|
||||
updated_at?: string
|
||||
message: string
|
||||
}> {
|
||||
const jsonData: Record<string, unknown> = {
|
||||
client_id: this.platformId,
|
||||
const isJson = typeof value === "object" && value !== null
|
||||
const body: Record<string, unknown> = {
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
key,
|
||||
value,
|
||||
}
|
||||
if (ttl !== undefined) jsonData.ttl = ttl
|
||||
const userId = sectlAuth.getUserId()
|
||||
if (userId) body.user_id = userId
|
||||
if (ttl) body.ttl = ttl
|
||||
|
||||
return this.makeRequest("POST", "/api/cloud/kv", { jsonData })
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "设置 KV 失败")
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
success: result.success ?? true,
|
||||
key: result.key ?? key,
|
||||
size: result.size ?? 0,
|
||||
is_json: result.is_json ?? isJson,
|
||||
created_at: result.created_at,
|
||||
updated_at: result.updated_at,
|
||||
message: result.message ?? "OK",
|
||||
}
|
||||
}
|
||||
|
||||
async getKV<T = unknown>(
|
||||
key: string,
|
||||
field?: string
|
||||
): Promise<
|
||||
| (KVItem & { key: string; value: T })
|
||||
| { key: string; field: string; value: T; is_json: boolean }
|
||||
> {
|
||||
const params: Record<string, string> = { client_id: this.platformId }
|
||||
if (field) params.field = field
|
||||
async getKV<T = unknown>(key: string): Promise<KVItem & { key: string; value: T }> {
|
||||
const params = this.buildParams()
|
||||
const response = await fetch(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}?${params}`,
|
||||
{
|
||||
headers: this.getAuthHeaders(),
|
||||
}
|
||||
)
|
||||
|
||||
return this.makeRequest("GET", `/api/cloud/kv/${encodeURIComponent(key)}`, { params })
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "获取 KV 失败")
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
key: result.key ?? key,
|
||||
value: result.value as T,
|
||||
size: result.size ?? 0,
|
||||
is_json: result.is_json ?? false,
|
||||
created_at: result.created_at ?? "",
|
||||
updated_at: result.updated_at ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
async listKV(
|
||||
options: ListKVOptions = {}
|
||||
): Promise<{ kv_list: KVItem[]; total: number; has_more: boolean }> {
|
||||
const params: Record<string, unknown> = {
|
||||
client_id: this.platformId,
|
||||
limit: Math.min(options.limit || 100, 1000),
|
||||
offset: options.offset || 0,
|
||||
}
|
||||
if (options.prefix) params.prefix = options.prefix
|
||||
const params = this.buildParams({
|
||||
limit: String(options.limit ?? 100),
|
||||
offset: String(options.offset ?? 0),
|
||||
})
|
||||
if (options.prefix) params.append("prefix", options.prefix)
|
||||
|
||||
return this.makeRequest("GET", "/api/cloud/kv", { params })
|
||||
const response = await fetch(`${SECTL_CONFIG.baseUrl}/api/cloud/kv?${params}`, {
|
||||
headers: this.getAuthHeaders(),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "获取 KV 列表失败")
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
return {
|
||||
kv_list: result.kv_list ?? [],
|
||||
total: result.total ?? 0,
|
||||
has_more: result.has_more ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
async updateKVField(
|
||||
key: string,
|
||||
field: string,
|
||||
value: unknown
|
||||
): Promise<{
|
||||
success: boolean
|
||||
key: string
|
||||
field: string
|
||||
size: number
|
||||
updated_at: string
|
||||
message: string
|
||||
}> {
|
||||
return this.makeRequest("PATCH", `/api/cloud/kv/${encodeURIComponent(key)}`, {
|
||||
jsonData: { client_id: this.platformId, field, value },
|
||||
})
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const body: Record<string, unknown> = {
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
field,
|
||||
value,
|
||||
}
|
||||
const userId = sectlAuth.getUserId()
|
||||
if (userId) body.user_id = userId
|
||||
|
||||
const response = await fetch(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "更新 KV 字段失败")
|
||||
}
|
||||
|
||||
return { success: true, message: "OK" }
|
||||
}
|
||||
|
||||
async deleteKV(key: string): Promise<{ success: boolean; message: string }> {
|
||||
return this.makeRequest("DELETE", `/api/cloud/kv/${encodeURIComponent(key)}`, {
|
||||
jsonData: { client_id: this.platformId },
|
||||
})
|
||||
}
|
||||
const body = {
|
||||
client_id: SECTL_CONFIG.platformId,
|
||||
}
|
||||
const userId = sectlAuth.getUserId()
|
||||
if (userId) (body as Record<string, unknown>).user_id = userId
|
||||
|
||||
getPlatformId(): string {
|
||||
return this.platformId
|
||||
const response = await fetch(
|
||||
`${SECTL_CONFIG.baseUrl}/api/cloud/kv/${encodeURIComponent(key)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json", ...this.getAuthHeaders() },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}))
|
||||
throw new Error(err.error_description || "删除 KV 失败")
|
||||
}
|
||||
|
||||
return { success: true, message: "OK" }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import { sectlNotification } from "./sectlNotification"
|
||||
import { SECTL_CONFIG, sectlAuth } from "./sectlAuth"
|
||||
|
||||
// Mock fetch and sectlAuth
|
||||
global.fetch = vi.fn()
|
||||
vi.mock("./sectlAuth", () => ({
|
||||
SECTL_CONFIG: {
|
||||
baseUrl: "https://api.sectl.cn",
|
||||
platformId: "test-platform-id",
|
||||
},
|
||||
sectlAuth: {
|
||||
getAccessToken: vi.fn(),
|
||||
getUserId: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("sectlNotification", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(sectlAuth.getAccessToken).mockReturnValue("test-access-token")
|
||||
vi.mocked(sectlAuth.getUserId).mockReturnValue("test-user-id")
|
||||
})
|
||||
|
||||
describe("getNotifications", () => {
|
||||
it("should call correct API endpoint without client_id", async () => {
|
||||
const mockResponse = { notifications: [], total: 0 }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlNotification.getNotifications({ limit: 10, offset: 0 })
|
||||
|
||||
const calledUrl = vi.mocked(global.fetch).mock.calls[0][0] as string
|
||||
expect(calledUrl).toContain(`${SECTL_CONFIG.baseUrl}/api/notifications?`)
|
||||
expect(calledUrl).toContain("limit=10")
|
||||
expect(calledUrl).toContain("offset=0")
|
||||
expect(calledUrl).not.toContain("client_id")
|
||||
})
|
||||
})
|
||||
|
||||
describe("markNotificationRead", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockResponse = { success: true, notification: { $id: "notif-123", is_read: true } }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlNotification.markNotificationRead("notif-123")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/notifications/notif-123/read`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("markAllNotificationsRead", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockResponse = { success: true, marked_count: 5 }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlNotification.markAllNotificationsRead()
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/notifications/read-all`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("deleteNotification", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockResponse = { success: true, message: "deleted" }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlNotification.deleteNotification("notif-123")
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/notifications/notif-123`,
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("sendNotification", () => {
|
||||
it("should call correct API endpoint", async () => {
|
||||
const mockResponse = { success: true, notification_id: "notif-123" }
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockResponse,
|
||||
} as Response)
|
||||
|
||||
await sectlNotification.sendNotification({
|
||||
user_id: "user-123",
|
||||
title: "Test",
|
||||
content: "Test content",
|
||||
})
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
`${SECTL_CONFIG.baseUrl}/api/notifications/send`,
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user