settings: - Add actions-brand, brand-config, data-access-brand for brand management - Add admin-file-upload-card, admin-notification-config-card, admin-school-info-card, admin-security-policy-card - Add ai-provider-delete-dialog, ai-provider-selector, brand-config-card - Add security-recent-logins-section, security-two-factor-section - Add config/profile-overview-config, data-access-profile-overview, lib/system-settings-utils questions: - Add batch-operations, import-export-buttons, knowledge-point-selector, options-editor - Add question-bank-results-client, question-cascade-filter, question-content-renderer, utils school: - Add grade-delete-dialog, grade-form-dialog, grade-list-toolbar, grade-overview-cards - Add use-grade-data hook textbooks: - Add textbook-form-fields component - Add use-kp-create, use-kp-delete, use-kp-update hooks
307 lines
11 KiB
TypeScript
307 lines
11 KiB
TypeScript
"use server"
|
||
|
||
import { z } from "zod"
|
||
import { revalidatePath } from "next/cache"
|
||
import { createId } from "@paralleldrive/cuid2"
|
||
|
||
import type { ActionState } from "@/shared/types/action-state"
|
||
import {
|
||
requirePermission,
|
||
PermissionDeniedError,
|
||
getAuthContext,
|
||
} from "@/shared/lib/auth-guard"
|
||
import { Permissions } from "@/shared/types/permissions"
|
||
import { encryptAiApiKey, getAiErrorMessage, testAiProviderById, testAiProviderConfig } from "@/shared/lib/ai"
|
||
|
||
import {
|
||
countDefaultAiProviders,
|
||
createAiProvider,
|
||
deleteAiProvider as deleteAiProviderRecord,
|
||
getAiProviderForUpdate,
|
||
getAiProviderSummaries as fetchAiProviderSummaries,
|
||
getAiProviderSummariesForUser,
|
||
updateAiProvider,
|
||
} from "./data-access"
|
||
import type { AiProviderSummary, AiProviderVisibility } from "./types"
|
||
|
||
export type { AiProviderSummary } from "./types"
|
||
|
||
const ProviderSchema = z.enum(["zhipu", "openai", "gemini", "custom", "ollama"])
|
||
const VisibilitySchema = z.enum(["public", "private"])
|
||
|
||
const AiProviderFormSchema = z.object({
|
||
id: z.string().optional(),
|
||
provider: ProviderSchema,
|
||
baseUrl: z.string().url().optional().or(z.literal("")),
|
||
model: z.string().min(1),
|
||
apiKey: z.string().min(1).optional(),
|
||
isDefault: z.boolean().optional(),
|
||
visibility: VisibilitySchema.optional(),
|
||
})
|
||
|
||
const AiProviderTestSchema = AiProviderFormSchema.extend({
|
||
apiKey: z.string().optional(),
|
||
}).superRefine((data, ctx) => {
|
||
// Ollama 本地部署无需 API Key
|
||
if (data.provider === "ollama") return
|
||
if (!data.apiKey?.trim() && !data.id?.trim()) {
|
||
ctx.addIssue({
|
||
code: z.ZodIssueCode.custom,
|
||
path: ["apiKey"],
|
||
message: "API key is required",
|
||
})
|
||
}
|
||
})
|
||
|
||
/** Ollama 默认 baseUrl(OpenAI 兼容端点) */
|
||
const OLLAMA_DEFAULT_BASE_URL = "http://localhost:11434/v1"
|
||
|
||
/** Ollama 本地部署无需 API Key,使用占位符满足 NOT NULL 约束 */
|
||
const OLLAMA_PLACEHOLDER_API_KEY = "ollama"
|
||
|
||
/** 判断是否为不需要 API Key 的本地 Provider */
|
||
const isLocalProvider = (provider: string): boolean => provider === "ollama"
|
||
|
||
/**
|
||
* 校验当前用户身份,返回 { id, isAdmin }
|
||
*
|
||
* - 所有 AI_CHAT 用户均可访问(用于管理自己的 private provider)
|
||
* - isAdmin 标识是否拥有 AI_CONFIGURE 权限(可管理 public provider 与他人 private)
|
||
*/
|
||
const ensureUser = async (): Promise<{ id: string; isAdmin: boolean }> => {
|
||
const ctx = await requirePermission(Permissions.AI_CHAT)
|
||
return { id: ctx.userId, isAdmin: ctx.permissions.includes(Permissions.AI_CONFIGURE) }
|
||
}
|
||
|
||
const normalizeBaseUrl = (value: string | undefined): string | null => {
|
||
const raw = String(value ?? "").trim()
|
||
if (!raw.length) return null
|
||
const trimmed = raw.replace(/\/+$/, "")
|
||
return trimmed
|
||
.replace(/\/v1\/chat\/completions$/i, "")
|
||
.replace(/\/chat\/completions$/i, "")
|
||
}
|
||
|
||
/**
|
||
* 解析 Provider 的 baseUrl,应用默认值
|
||
*
|
||
* - Ollama:未提供时使用默认本地地址 http://localhost:11434/v1
|
||
* - 其他 Provider:未提供时返回 null(由调用方校验)
|
||
*/
|
||
const resolveBaseUrl = (
|
||
provider: string,
|
||
raw: string | undefined
|
||
): string | null => {
|
||
const normalized = normalizeBaseUrl(raw)
|
||
if (normalized) return normalized
|
||
if (isLocalProvider(provider)) return OLLAMA_DEFAULT_BASE_URL
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* 获取当前用户可见的 AI Provider 列表
|
||
*
|
||
* - 管理员:返回所有 public + private 记录
|
||
* - 普通用户:返回 public + 自己创建的 private 记录
|
||
*/
|
||
export async function getAiProviderSummaries(): Promise<ActionState<AiProviderSummary[]>> {
|
||
try {
|
||
const user = await ensureUser()
|
||
const data = user.isAdmin
|
||
? await fetchAiProviderSummaries()
|
||
: await getAiProviderSummariesForUser(user.id)
|
||
return { success: true, data }
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) return { success: false, message: error.message }
|
||
return { success: false, message: "Failed to load AI providers" }
|
||
}
|
||
}
|
||
|
||
export async function upsertAiProviderAction(
|
||
data: z.infer<typeof AiProviderFormSchema>
|
||
): Promise<ActionState<string>> {
|
||
try {
|
||
const user = await ensureUser()
|
||
const parsed = AiProviderFormSchema.safeParse(data)
|
||
if (!parsed.success) {
|
||
return { success: false, message: "Invalid form data" }
|
||
}
|
||
|
||
const payload = parsed.data
|
||
const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl)
|
||
if (!isLocalProvider(payload.provider) && !baseUrl) {
|
||
return { success: false, message: "Base URL is required for this provider" }
|
||
}
|
||
|
||
// 可见性规则:
|
||
// - 管理员可创建/更新 public 或 private
|
||
// - 普通用户只能创建/更新 private
|
||
const requestedVisibility: AiProviderVisibility = payload.visibility ?? "private"
|
||
const visibility: AiProviderVisibility =
|
||
user.isAdmin ? requestedVisibility : "private"
|
||
|
||
// Parallelize default-count and existing-provider queries
|
||
const [defaultCount, existing] = await Promise.all([
|
||
countDefaultAiProviders(),
|
||
payload.id ? getAiProviderForUpdate(payload.id, user.id) : Promise.resolve(null),
|
||
])
|
||
const hasDefault = defaultCount > 0
|
||
|
||
if (payload.id) {
|
||
const id = payload.id
|
||
if (!existing) return { success: false, message: "AI provider not found" }
|
||
|
||
// Ollama 无需 API Key:未提供时使用占位符(仅新建时);更新时保留原值
|
||
const nextKey = payload.apiKey?.trim()
|
||
const effectiveKey = nextKey
|
||
? nextKey
|
||
: isLocalProvider(payload.provider) && !existing.apiKeyEncrypted
|
||
? OLLAMA_PLACEHOLDER_API_KEY
|
||
: null
|
||
const encrypted = effectiveKey ? encryptAiApiKey(effectiveKey) : existing.apiKeyEncrypted
|
||
const last4 = effectiveKey ? effectiveKey.slice(-4) : existing.apiKeyLast4
|
||
|
||
const isNextDefault =
|
||
payload.isDefault === false && existing.isDefault && defaultCount <= 1
|
||
? true
|
||
: payload.isDefault ?? existing.isDefault
|
||
|
||
await updateAiProvider(
|
||
id,
|
||
{
|
||
provider: payload.provider,
|
||
baseUrl,
|
||
model: payload.model,
|
||
apiKeyEncrypted: encrypted,
|
||
apiKeyLast4: last4,
|
||
isDefault: isNextDefault,
|
||
visibility,
|
||
updatedBy: user.id,
|
||
},
|
||
payload.isDefault === true
|
||
)
|
||
|
||
revalidatePath("/admin/ai-settings")
|
||
revalidatePath("/settings")
|
||
return { success: true, message: "AI provider updated", data: id }
|
||
}
|
||
|
||
// 新建 Provider:Ollama 允许无 API Key(使用占位符)
|
||
const rawApiKey = payload.apiKey?.trim()
|
||
if (!rawApiKey && !isLocalProvider(payload.provider)) {
|
||
return { success: false, message: "API key is required" }
|
||
}
|
||
const effectiveApiKey = rawApiKey ?? OLLAMA_PLACEHOLDER_API_KEY
|
||
|
||
const id = createId()
|
||
const encrypted = encryptAiApiKey(effectiveApiKey)
|
||
const last4 = effectiveApiKey.slice(-4)
|
||
const shouldMakeDefault = payload.isDefault ?? !hasDefault
|
||
|
||
await createAiProvider(
|
||
{
|
||
id,
|
||
provider: payload.provider,
|
||
baseUrl,
|
||
model: payload.model,
|
||
apiKeyEncrypted: encrypted,
|
||
apiKeyLast4: last4,
|
||
isDefault: shouldMakeDefault,
|
||
visibility,
|
||
createdBy: user.id,
|
||
updatedBy: user.id,
|
||
},
|
||
shouldMakeDefault
|
||
)
|
||
|
||
revalidatePath("/admin/ai-settings")
|
||
revalidatePath("/settings")
|
||
return { success: true, message: "AI provider created", data: id }
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) return { success: false, message: error.message }
|
||
console.error("[upsertAiProviderAction] Failed to save AI provider:", error)
|
||
return { success: false, message: "Failed to save AI provider" }
|
||
}
|
||
}
|
||
|
||
export async function testAiProviderAction(
|
||
data: z.infer<typeof AiProviderTestSchema>
|
||
): Promise<ActionState<null>> {
|
||
try {
|
||
await ensureUser()
|
||
const parsed = AiProviderTestSchema.safeParse(data)
|
||
if (!parsed.success) {
|
||
return { success: false, message: "Invalid form data" }
|
||
}
|
||
const payload = parsed.data
|
||
const baseUrl = resolveBaseUrl(payload.provider, payload.baseUrl)
|
||
if (!isLocalProvider(payload.provider) && !baseUrl) {
|
||
return { success: false, message: "Base URL is required for this provider" }
|
||
}
|
||
const model = payload.model.trim()
|
||
const apiKey = payload.apiKey?.trim()
|
||
// Ollama 无 API Key 时使用占位符进行测试
|
||
const effectiveApiKey = apiKey ?? (isLocalProvider(payload.provider) ? OLLAMA_PLACEHOLDER_API_KEY : undefined)
|
||
if (effectiveApiKey) {
|
||
await testAiProviderConfig({ apiKey: effectiveApiKey, baseUrl: baseUrl ?? undefined, model })
|
||
} else if (payload.id) {
|
||
await testAiProviderById(payload.id, { baseUrl: baseUrl ?? undefined, model })
|
||
}
|
||
return { success: true, message: "AI connection ok", data: null }
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) return { success: false, message: error.message }
|
||
return { success: false, message: getAiErrorMessage(error) }
|
||
}
|
||
}
|
||
|
||
const DeleteAiProviderSchema = z.object({
|
||
id: z.string().min(1),
|
||
})
|
||
|
||
/**
|
||
* 删除 AI Provider
|
||
*
|
||
* 权限规则:
|
||
* - 管理员(AI_CONFIGURE):可删除任意 Provider
|
||
* - 普通用户(AI_CHAT):仅可删除自己创建的 Provider
|
||
*
|
||
* 如果删除的是默认 Provider,自动将最新的一条记录设为默认(若存在)。
|
||
*/
|
||
export async function deleteAiProviderAction(
|
||
input: z.infer<typeof DeleteAiProviderSchema>
|
||
): Promise<ActionState<null>> {
|
||
try {
|
||
const user = await ensureUser()
|
||
const parsed = DeleteAiProviderSchema.safeParse(input)
|
||
if (!parsed.success) {
|
||
return { success: false, message: "Invalid provider id" }
|
||
}
|
||
// 管理员不传 userId(可删除任意);普通用户传 userId 做所有权校验
|
||
await deleteAiProviderRecord(
|
||
parsed.data.id,
|
||
user.isAdmin ? undefined : user.id
|
||
)
|
||
revalidatePath("/admin/ai-settings")
|
||
revalidatePath("/settings")
|
||
return { success: true, message: "AI provider deleted", data: null }
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) return { success: false, message: error.message }
|
||
return { success: false, message: "Failed to delete AI provider" }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查当前用户是否拥有 AI_CONFIGURE 权限(管理员)
|
||
*
|
||
* 供 UI 层决定是否显示 public 可见性选项。
|
||
*/
|
||
export async function canConfigurePublicAiProvider(): Promise<ActionState<boolean>> {
|
||
try {
|
||
const ctx = await getAuthContext()
|
||
return { success: true, data: ctx.permissions.includes(Permissions.AI_CONFIGURE) }
|
||
} catch (error) {
|
||
if (error instanceof PermissionDeniedError) return { success: false, message: error.message, data: false }
|
||
return { success: false, message: "Failed to check permission", data: false }
|
||
}
|
||
}
|