feat: add sqlserver admin connection settings
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-24 17:01:50 +08:00
parent cfb4a0e329
commit 817309ade9
7 changed files with 1144 additions and 1 deletions

View File

@ -29,6 +29,10 @@ import {
DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY,
normalizeDetailAgentLocalMoonshotConfig,
} from "@/lib/ai/detail-agent-model-config";
import {
defaultThirdPartySqlServerConfig,
THIRD_PARTY_SQLSERVER_CONFIG_KEY,
} from "@/lib/sqlserver/config";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import {
@ -65,6 +69,7 @@ import {
Loader2,
Check,
Paintbrush,
Database,
} from "lucide-react";
import {
Dialog,
@ -1031,6 +1036,10 @@ export function AdminSettingsClient() {
displayName: "官方 Kimi K2.5",
isEnabled: true,
});
const [thirdPartySqlServerConfig, setThirdPartySqlServerConfig] = useState({
...defaultThirdPartySqlServerConfig,
});
const [isTestingThirdPartySqlServer, setIsTestingThirdPartySqlServer] = useState(false);
// 加载抠图API配置
const { data: rmbgConfigData } = useSWR("/api/admin/settings?key=rmbg_api_config", fetcher);
@ -1184,6 +1193,39 @@ export function AdminSettingsClient() {
}
}, [layerSplitConfigData]);
const { data: thirdPartySqlServerConfigData } = useSWR(
`/api/admin/settings?key=${THIRD_PARTY_SQLSERVER_CONFIG_KEY}`,
fetcher
);
useEffect(() => {
if (thirdPartySqlServerConfigData?.setting?.value) {
const config = thirdPartySqlServerConfigData.setting.value as {
enabled?: boolean;
host?: string;
port?: number;
database?: string;
username?: string;
password?: string;
encrypt?: boolean;
trustServerCertificate?: boolean;
connectionTimeoutMs?: number;
requestTimeoutMs?: number;
};
setThirdPartySqlServerConfig({
enabled: config.enabled !== false,
host: config.host || "",
port: Math.max(1, Math.min(65535, Number(config.port) || 1433)),
database: config.database || "",
username: config.username || "",
password: config.password || "",
encrypt: config.encrypt === true,
trustServerCertificate: config.trustServerCertificate !== false,
connectionTimeoutMs: Math.max(1000, Math.min(120000, Number(config.connectionTimeoutMs) || 15000)),
requestTimeoutMs: Math.max(1000, Math.min(120000, Number(config.requestTimeoutMs) || 30000)),
});
}
}, [thirdPartySqlServerConfigData]);
// 加载实时画布API配置
const { data: realtimeCanvasConfigData } = useSWR(
"/api/admin/settings?key=realtime_canvas_config",
@ -2337,6 +2379,108 @@ export function AdminSettingsClient() {
}
};
const handleSaveThirdPartySqlServerConfig = async () => {
try {
if (thirdPartySqlServerConfig.enabled) {
if (!thirdPartySqlServerConfig.host.trim()) {
toast.error("请填写 SQL Server 主机地址");
return;
}
if (!thirdPartySqlServerConfig.database.trim()) {
toast.error("请填写数据库名称");
return;
}
if (!thirdPartySqlServerConfig.username.trim()) {
toast.error("请填写数据库账号");
return;
}
if (!thirdPartySqlServerConfig.password.trim()) {
toast.error("请填写数据库密码");
return;
}
}
const response = await fetch("/api/admin/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
key: THIRD_PARTY_SQLSERVER_CONFIG_KEY,
value: {
enabled: thirdPartySqlServerConfig.enabled,
host: thirdPartySqlServerConfig.host.trim(),
port: Math.max(1, Math.min(65535, Number(thirdPartySqlServerConfig.port) || 1433)),
database: thirdPartySqlServerConfig.database.trim(),
username: thirdPartySqlServerConfig.username.trim(),
password: thirdPartySqlServerConfig.password.trim(),
encrypt: thirdPartySqlServerConfig.encrypt,
trustServerCertificate: thirdPartySqlServerConfig.trustServerCertificate,
connectionTimeoutMs: Math.max(
1000,
Math.min(120000, Number(thirdPartySqlServerConfig.connectionTimeoutMs) || 15000)
),
requestTimeoutMs: Math.max(
1000,
Math.min(120000, Number(thirdPartySqlServerConfig.requestTimeoutMs) || 30000)
),
},
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(payload?.cause || payload?.message || payload?.error || "保存失败");
}
toast.success("SQL Server 配置保存成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存 SQL Server 配置失败");
}
};
const handleTestThirdPartySqlServerConfig = async () => {
try {
setIsTestingThirdPartySqlServer(true);
const response = await fetch("/api/admin/sqlserver/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
config: {
enabled: thirdPartySqlServerConfig.enabled,
host: thirdPartySqlServerConfig.host.trim(),
port: Math.max(1, Math.min(65535, Number(thirdPartySqlServerConfig.port) || 1433)),
database: thirdPartySqlServerConfig.database.trim(),
username: thirdPartySqlServerConfig.username.trim(),
password: thirdPartySqlServerConfig.password.trim(),
encrypt: thirdPartySqlServerConfig.encrypt,
trustServerCertificate: thirdPartySqlServerConfig.trustServerCertificate,
connectionTimeoutMs: Math.max(
1000,
Math.min(120000, Number(thirdPartySqlServerConfig.connectionTimeoutMs) || 15000)
),
requestTimeoutMs: Math.max(
1000,
Math.min(120000, Number(thirdPartySqlServerConfig.requestTimeoutMs) || 30000)
),
},
}),
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(payload?.cause || payload?.message || payload?.error || "连接测试失败");
}
toast.success(
`连接成功:${payload?.serverName || thirdPartySqlServerConfig.host} / ${payload?.databaseName || thirdPartySqlServerConfig.database}(${payload?.latencyMs || 0}ms)`
);
} catch (error) {
toast.error(error instanceof Error ? error.message : "SQL Server 连接测试失败");
} finally {
setIsTestingThirdPartySqlServer(false);
}
};
// --- 实时画布API配置逻辑 ---
const handleSaveRealtimeCanvasConfig = async () => {
try {
@ -9450,6 +9594,205 @@ export function AdminSettingsClient() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-xl">
<Database className="h-5 w-5" />
第三方 SQL Server 数据库连接
</CardTitle>
<CardDescription>
配置后可供后续业务功能直接访问外部 SQL Server 数据库。当前仅提供配置保存与连通性测试。
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 pt-6">
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label className="text-base">启用第三方数据库连接</Label>
<div className="text-sm text-muted-foreground">
关闭后保留配置但不作为后续功能默认外部数据源。
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={thirdPartySqlServerConfig.enabled}
onCheckedChange={(checked) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, enabled: checked }))
}
/>
<span className="text-sm font-medium text-muted-foreground min-w-[3rem]">
{thirdPartySqlServerConfig.enabled ? "已开启" : "已关闭"}
</span>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="sqlserverHost">主机地址</Label>
<Input
id="sqlserverHost"
value={thirdPartySqlServerConfig.host}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, host: e.target.value }))
}
placeholder="218.93.191.16"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sqlserverPort">端口</Label>
<Input
id="sqlserverPort"
type="number"
min={1}
max={65535}
value={thirdPartySqlServerConfig.port}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({
...prev,
port: Math.max(1, Math.min(65535, Number(e.target.value) || 1433)),
}))
}
placeholder="1433"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="sqlserverDatabase">数据库名称</Label>
<Input
id="sqlserverDatabase"
value={thirdPartySqlServerConfig.database}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, database: e.target.value }))
}
placeholder="JSDataMiddlePlatform"
/>
</div>
<div className="space-y-2">
<Label htmlFor="sqlserverUsername">账号</Label>
<Input
id="sqlserverUsername"
value={thirdPartySqlServerConfig.username}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, username: e.target.value }))
}
placeholder="nanting"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="sqlserverPassword">密码</Label>
<Input
id="sqlserverPassword"
type="password"
value={thirdPartySqlServerConfig.password}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, password: e.target.value }))
}
placeholder="请输入 SQL Server 密码"
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="sqlserverConnectionTimeout">连接超时(毫秒)</Label>
<Input
id="sqlserverConnectionTimeout"
type="number"
min={1000}
max={120000}
step={1000}
value={thirdPartySqlServerConfig.connectionTimeoutMs}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({
...prev,
connectionTimeoutMs: Math.max(
1000,
Math.min(120000, Number(e.target.value) || 15000)
),
}))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="sqlserverRequestTimeout">请求超时(毫秒)</Label>
<Input
id="sqlserverRequestTimeout"
type="number"
min={1000}
max={120000}
step={1000}
value={thirdPartySqlServerConfig.requestTimeoutMs}
onChange={(e) =>
setThirdPartySqlServerConfig((prev) => ({
...prev,
requestTimeoutMs: Math.max(
1000,
Math.min(120000, Number(e.target.value) || 30000)
),
}))
}
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">启用加密</Label>
<div className="text-xs text-muted-foreground">
现代云数据库通常需要开启;传统内网 SQL Server 可关闭。
</div>
</div>
<Switch
checked={thirdPartySqlServerConfig.encrypt}
onCheckedChange={(checked) =>
setThirdPartySqlServerConfig((prev) => ({ ...prev, encrypt: checked }))
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border p-3">
<div className="space-y-0.5">
<Label className="text-sm">信任服务器证书</Label>
<div className="text-xs text-muted-foreground">
对自签名证书或传统 SQL Server 更友好。
</div>
</div>
<Switch
checked={thirdPartySqlServerConfig.trustServerCertificate}
onCheckedChange={(checked) =>
setThirdPartySqlServerConfig((prev) => ({
...prev,
trustServerCertificate: checked,
}))
}
/>
</div>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 space-y-1 text-xs text-blue-800">
<p>• 当前支持 SQL Server 标准 TCP 连接。</p>
<p>• 配置会保存到系统设置,供后续外部数据查询/同步功能复用。</p>
<p>• 测试连接会尝试建立连接并执行基础查询,不会修改外部数据库数据。</p>
</div>
<div className="flex flex-wrap justify-end gap-3">
<Button variant="outline" onClick={handleTestThirdPartySqlServerConfig} disabled={isTestingThirdPartySqlServer}>
{isTestingThirdPartySqlServer ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Database className="mr-2 h-4 w-4" />
)}
测试连接
</Button>
<Button onClick={handleSaveThirdPartySqlServerConfig}>
<Save className="mr-2 h-4 w-4" /> 保存 SQL Server 配置
</Button>
</div>
</CardContent>
</Card>
{/* 实时画布API配置 */}
<Card>
<CardHeader>

View File

@ -13,6 +13,10 @@ import {
resolveRealtimeProviders,
} from "@/lib/realtime/config";
import { DETAIL_AGENT_LOCAL_MOONSHOT_SETTING_KEY } from "@/lib/ai/detail-agent-model-config";
import {
normalizeThirdPartySqlServerConfig,
THIRD_PARTY_SQLSERVER_CONFIG_KEY,
} from "@/lib/sqlserver/config";
const settingSchema = z.object({
key: z.string(),
@ -166,6 +170,37 @@ export async function POST(request: Request) {
nextValue = normalizedValue;
}
if (key === THIRD_PARTY_SQLSERVER_CONFIG_KEY) {
const existingSetting = await getSystemSetting({
key: THIRD_PARTY_SQLSERVER_CONFIG_KEY,
}).catch(() => null);
const existingValue =
existingSetting?.value && typeof existingSetting.value === "object"
? (existingSetting.value as Record<string, unknown>)
: {};
const normalizedValue = normalizeThirdPartySqlServerConfig(
value,
String(existingValue.password || "").trim(),
);
if (normalizedValue.enabled) {
if (!normalizedValue.host) {
return new ChatSDKError("bad_request:api", "请填写 SQL Server 主机地址").toResponse();
}
if (!normalizedValue.database) {
return new ChatSDKError("bad_request:api", "请填写数据库名称").toResponse();
}
if (!normalizedValue.username) {
return new ChatSDKError("bad_request:api", "请填写数据库账号").toResponse();
}
if (!normalizedValue.password) {
return new ChatSDKError("bad_request:api", "请填写数据库密码").toResponse();
}
}
nextValue = normalizedValue;
}
const setting = await upsertSystemSetting({
key,
value: nextValue,

View File

@ -0,0 +1,50 @@
import { z } from "zod";
import { requireAdmin } from "@/lib/middleware/admin";
import { getSystemSetting } from "@/lib/db/queries";
import { ChatSDKError } from "@/lib/errors";
import {
THIRD_PARTY_SQLSERVER_CONFIG_KEY,
thirdPartySqlServerConfigSchema,
} from "@/lib/sqlserver/config";
import { testSqlServerConnection } from "@/lib/sqlserver/client";
export const runtime = "nodejs";
const requestSchema = z.object({
config: thirdPartySqlServerConfigSchema.optional(),
});
export async function POST(request: Request) {
try {
await requireAdmin();
const body = requestSchema.parse(await request.json().catch(() => ({})));
const savedSetting = !body.config
? await getSystemSetting({ key: THIRD_PARTY_SQLSERVER_CONFIG_KEY }).catch(() => null)
: null;
const config = body.config ?? savedSetting?.value ?? {};
const result = await testSqlServerConnection(config);
return Response.json({
success: true,
...result,
});
} catch (error) {
if (error instanceof ChatSDKError) {
return error.toResponse();
}
if (error instanceof z.ZodError) {
return new ChatSDKError("bad_request:api", error.message).toResponse();
}
return Response.json(
{
code: "bad_request:api",
message: error instanceof Error ? error.message : "SQL Server 连接测试失败",
},
{ status: 400 },
);
}
}

66
lib/sqlserver/client.ts Normal file
View File

@ -0,0 +1,66 @@
import "server-only";
import sql from "mssql";
import {
normalizeThirdPartySqlServerConfig,
type NormalizedThirdPartySqlServerConfig,
} from "@/lib/sqlserver/config";
function toSqlServerConnectionConfig(config: NormalizedThirdPartySqlServerConfig): sql.config {
return {
server: config.host,
port: config.port,
user: config.username,
password: config.password,
database: config.database,
options: {
encrypt: config.encrypt,
trustServerCertificate: config.trustServerCertificate,
},
connectionTimeout: config.connectionTimeoutMs,
requestTimeout: config.requestTimeoutMs,
pool: {
max: 1,
min: 0,
idleTimeoutMillis: 5000,
},
};
}
export async function testSqlServerConnection(configValue: unknown) {
const config = normalizeThirdPartySqlServerConfig(configValue);
if (!config.host) {
throw new Error("请填写 SQL Server 主机地址");
}
if (!config.database) {
throw new Error("请填写数据库名称");
}
if (!config.username) {
throw new Error("请填写数据库账号");
}
if (!config.password) {
throw new Error("请填写数据库密码");
}
const startedAt = Date.now();
const pool = new sql.ConnectionPool(toSqlServerConnectionConfig(config));
try {
await pool.connect();
const result = await pool
.request()
.query("SELECT @@SERVERNAME AS serverName, DB_NAME() AS databaseName, @@VERSION AS version");
const row = result.recordset?.[0] || {};
return {
latencyMs: Date.now() - startedAt,
serverName: String(row.serverName || config.host),
databaseName: String(row.databaseName || config.database),
version: String(row.version || "").trim(),
};
} finally {
await pool.close().catch(() => undefined);
}
}

66
lib/sqlserver/config.ts Normal file
View File

@ -0,0 +1,66 @@
import { z } from "zod";
export const THIRD_PARTY_SQLSERVER_CONFIG_KEY = "third_party_sqlserver_config";
export const thirdPartySqlServerConfigSchema = z.object({
enabled: z.boolean().optional(),
host: z.string().trim().optional(),
port: z.coerce.number().int().min(1).max(65535).optional(),
database: z.string().trim().optional(),
username: z.string().trim().optional(),
password: z.string().optional(),
encrypt: z.boolean().optional(),
trustServerCertificate: z.boolean().optional(),
connectionTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional(),
requestTimeoutMs: z.coerce.number().int().min(1000).max(120000).optional(),
});
export type ThirdPartySqlServerConfig = z.infer<typeof thirdPartySqlServerConfigSchema>;
export type NormalizedThirdPartySqlServerConfig = {
enabled: boolean;
host: string;
port: number;
database: string;
username: string;
password: string;
encrypt: boolean;
trustServerCertificate: boolean;
connectionTimeoutMs: number;
requestTimeoutMs: number;
};
export const defaultThirdPartySqlServerConfig: NormalizedThirdPartySqlServerConfig = {
enabled: true,
host: "",
port: 1433,
database: "",
username: "",
password: "",
encrypt: false,
trustServerCertificate: true,
connectionTimeoutMs: 15000,
requestTimeoutMs: 30000,
};
export function normalizeThirdPartySqlServerConfig(
value: unknown,
existingPassword = "",
): NormalizedThirdPartySqlServerConfig {
const parsed = thirdPartySqlServerConfigSchema.parse(value || {});
return {
enabled: parsed.enabled !== false,
host: String(parsed.host || "").trim(),
port: Number(parsed.port) || defaultThirdPartySqlServerConfig.port,
database: String(parsed.database || "").trim(),
username: String(parsed.username || "").trim(),
password: String(parsed.password || "").trim() || String(existingPassword || "").trim(),
encrypt: parsed.encrypt === true,
trustServerCertificate: parsed.trustServerCertificate !== false,
connectionTimeoutMs:
Number(parsed.connectionTimeoutMs) || defaultThirdPartySqlServerConfig.connectionTimeoutMs,
requestTimeoutMs:
Number(parsed.requestTimeoutMs) || defaultThirdPartySqlServerConfig.requestTimeoutMs,
};
}

View File

@ -75,6 +75,7 @@
"katex": "^0.16.25",
"lucide-react": "^0.446.0",
"matter-js": "^0.20.0",
"mssql": "^12.2.1",
"nanoid": "^5.0.8",
"next": "^16.0.9",
"next-auth": "^5.0.0-beta.30",
@ -121,6 +122,7 @@
"@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/typography": "^0.5.15",
"@types/d3-scale": "^4.0.8",
"@types/mssql": "^9.1.9",
"@types/node": "^22.8.6",
"@types/papaparse": "^5.3.15",
"@types/pdf-parse": "^1.1.4",

583
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff