Files
NextEdu/src/modules/settings/actions.ts
SpecialX f3c223d914 feat(settings,questions,school,textbooks): add brand config, question components, school dialogs, textbooks hooks
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
2026-07-03 10:26:00 +08:00

307 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 默认 baseUrlOpenAI 兼容端点) */
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 }
}
// 新建 ProviderOllama 允许无 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 }
}
}