feat(storage): 统一二进制对象存储与公开错误

新增 Aliyun OSS 与 S3 协议、通道内重试和按优先级跨通道切换,保留 server-main 兼容与环境 OSS 内存通道。

将请求及结果中的 Base64、Data URI、Buffer、multipart 和内联二进制统一对象化,生产路径不再写入本机静态目录,历史本地资源仅保留只读兼容。

引入 PublicErrorV1 并统一 API、异步查询、兼容协议和失败回调的安全错误输出,同时补充迁移、管理端、指标、OpenAPI 与本地模拟验收。

验证:go test ./... -count=1;go vet ./...;pnpm lint;pnpm test;pnpm build;pnpm openapi;tests/ci/migrations-test.sh。
This commit is contained in:
2026-08-04 08:14:39 +08:00
parent d129bcccbd
commit 0f0998cbcf
55 changed files with 3649 additions and 1008 deletions
+18
View File
@@ -111,6 +111,7 @@ import {
updateClientCustomizationSettings,
updateFileStorageChannel,
updateFileStorageSettings,
testFileStorageChannel,
updateGatewayUser,
updatePlatform,
updatePlatformDynamicPriority,
@@ -1182,6 +1183,22 @@ export function App() {
}
}
async function verifyFileStorageChannel(channelId: string) {
setCoreState('loading');
setCoreMessage('');
try {
const result = await testFileStorageChannel(token, channelId);
const refreshed = await listFileStorageChannels(token);
setFileStorageChannels(refreshed.items);
setCoreState('ready');
setCoreMessage(`对象存储连接测试通过,Put / Head / Delete 均成功,耗时 ${result.durationMs}ms。`);
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '对象存储连接测试失败');
throw err;
}
}
async function saveClientCustomizationSettings(input: ClientCustomizationSettingsUpdateRequest) {
setCoreState('loading');
setCoreMessage('');
@@ -1614,6 +1631,7 @@ export function App() {
onSaveAccessRule={saveAccessRule}
onSaveFileStorageChannel={saveFileStorageChannel}
onSaveFileStorageSettings={saveFileStorageSettings}
onTestFileStorageChannel={verifyFileStorageChannel}
onSaveClientCustomizationSettings={saveClientCustomizationSettings}
onConnectSecurityEvents={connectSecurityEvents}
onDisconnectSecurityEvents={disconnectSecurityEvents}
+11
View File
@@ -12,6 +12,7 @@ import type {
ClientCustomizationSettingsUpdateRequest,
CreatedGatewayApiKey,
FileStorageChannel,
FileStorageChannelTestResult,
FileStorageSettings,
FileStorageSettingsUpdateRequest,
FileStorageChannelUpsertRequest,
@@ -1276,6 +1277,16 @@ export async function deleteFileStorageChannel(token: string, channelId: string)
});
}
export async function testFileStorageChannel(
token: string,
channelId: string,
): Promise<FileStorageChannelTestResult> {
return request<FileStorageChannelTestResult>(`/api/admin/system/file-storage/channels/${channelId}/test`, {
method: 'POST',
token,
});
}
async function request<T>(
path: string,
options: { token?: string; auth?: boolean; method?: string; body?: unknown; headers?: Record<string, string>; signal?: AbortSignal; timeoutMs?: number } = {},
+2
View File
@@ -92,6 +92,7 @@ export function AdminPage(props: {
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onTestFileStorageChannel: (channelId: string) => Promise<void>;
onConnectSecurityEvents: (transmitterIssuer: string) => Promise<void>;
onDisconnectSecurityEvents: () => Promise<void>;
onRefreshSecurityEvents: () => Promise<void>;
@@ -214,6 +215,7 @@ export function AdminPage(props: {
onDeleteFileStorageChannel={props.onDeleteFileStorageChannel}
onSaveFileStorageChannel={props.onSaveFileStorageChannel}
onSaveFileStorageSettings={props.onSaveFileStorageSettings}
onTestFileStorageChannel={props.onTestFileStorageChannel}
onSaveClientCustomizationSettings={props.onSaveClientCustomizationSettings}
/>
)}
+101 -18
View File
@@ -15,6 +15,10 @@ type ClientCustomizationForm = {
};
type FileStorageChannelForm = {
accessKeyId: string;
accessKeyIdPreview: string;
accessKeySecret: string;
accessKeySecretPreview: string;
apiKey: string;
apiKeyPreview: string;
channelKey: string;
@@ -23,13 +27,22 @@ type FileStorageChannelForm = {
priority: string;
provider: string;
retryPolicyJson: string;
sessionToken: string;
sessionTokenPreview: string;
scenes: string[];
status: string;
uploadUrl: string;
};
const defaultUploadUrl = 'http://127.0.0.1:3001/v1/files/upload';
const defaultRetryPolicy = {
const defaultObjectStorageRetryPolicy = {
enabled: true,
maxRetries: 2,
backoffSeconds: [0.25, 1],
strategy: 'exponential',
};
const defaultServerMainRetryPolicy = {
enabled: true,
maxRetries: 3,
backoffSeconds: [60, 120, 180],
@@ -39,7 +52,7 @@ const defaultRetryPolicy = {
const providerOptions = [
{ value: 'server_main_openapi', label: 'server-main OpenAPI' },
{ value: 'aliyun_oss', label: '阿里云 OSS' },
{ value: 'tencent_cos', label: '腾讯云 COS' },
{ value: 's3', label: 'S3 / S3 兼容存储' },
];
const defaultScenes = ['upload', 'image_result', 'request_asset'];
@@ -52,7 +65,6 @@ const sceneOptions = [
const resultUploadPolicyOptions = [
{ value: 'default', label: '默认:仅非链接资源转存', description: 'URL 结果直接保存;base64 / buffer 等结果转存后保存 URL' },
{ value: 'upload_all', label: '全部转存', description: 'URL、base64、buffer 等生成媒体结果都会转存到当前文件渠道' },
{ value: 'upload_none', label: '不做外部转存', description: 'base64 / buffer 临时写入本地静态文件;数据库仅保存占位符,默认 24 小时内按需恢复' },
];
export function SystemSettingsPanel(props: {
@@ -66,6 +78,7 @@ export function SystemSettingsPanel(props: {
onSaveClientCustomizationSettings: (input: ClientCustomizationSettingsUpdateRequest) => Promise<void>;
onSaveFileStorageChannel: (input: FileStorageChannelUpsertRequest, channelId?: string) => Promise<void>;
onSaveFileStorageSettings: (input: FileStorageSettingsUpdateRequest) => Promise<void>;
onTestFileStorageChannel: (channelId: string) => Promise<void>;
}) {
const [activeTab, setActiveTab] = useState<SystemSettingsTab>('fileStorage');
const [dialogOpen, setDialogOpen] = useState(false);
@@ -75,6 +88,7 @@ export function SystemSettingsPanel(props: {
const [clientCustomizationForm, setClientCustomizationForm] = useState<ClientCustomizationForm>(() => clientCustomizationSettingsToForm(props.clientCustomizationSettings));
const [settingsPolicy, setSettingsPolicy] = useState(() => normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
const [localError, setLocalError] = useState('');
const [testingChannelId, setTestingChannelId] = useState('');
useEffect(() => {
setSettingsPolicy(normalizeResultUploadPolicy(props.settings?.resultUploadPolicy));
@@ -130,6 +144,18 @@ export function SystemSettingsPanel(props: {
}
}
async function testChannel(channel: FileStorageChannel) {
setLocalError('');
setTestingChannelId(channel.id);
try {
await props.onTestFileStorageChannel(channel.id);
} catch (err) {
setLocalError(err instanceof Error ? err.message : '对象存储连接测试失败');
} finally {
setTestingChannelId('');
}
}
async function saveSettings() {
setLocalError('');
try {
@@ -154,7 +180,7 @@ export function SystemSettingsPanel(props: {
<CardHeader>
<div>
<CardTitle></CardTitle>
<p className="mutedText">使 60/120/180 退</p>
<p className="mutedText">使 250ms / 1s 退</p>
</div>
<Badge variant="secondary">{props.channels.length} </Badge>
</CardHeader>
@@ -177,7 +203,7 @@ export function SystemSettingsPanel(props: {
<div className="fileStorageSettingsCard">
<div>
<strong></strong>
<span>退 24 </span>
<span></span>
</div>
<Label>
@@ -195,7 +221,7 @@ export function SystemSettingsPanel(props: {
<div className="fileStorageToolbar">
<div>
<strong></strong>
<span>server-main OpenAPI API Key</span>
<span> server-main OpenAPI OSS S3 </span>
</div>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
@@ -224,6 +250,12 @@ export function SystemSettingsPanel(props: {
{channel.lastError && <span>: {channel.lastError}</span>}
</div>
<footer>
{channel.provider !== 'server_main_openapi' && (
<Button type="button" variant="outline" size="sm" disabled={testingChannelId === channel.id} onClick={() => testChannel(channel)}>
<ShieldCheck size={14} />
{testingChannelId === channel.id ? '测试中…' : '连接测试'}
</Button>
)}
<Button type="button" variant="outline" size="sm" onClick={() => editChannel(channel)}>
<Pencil size={14} />
@@ -312,7 +344,16 @@ export function SystemSettingsPanel(props: {
</Label>
<Label>
<Select value={form.provider} onChange={(event) => setForm({ ...form, provider: event.target.value })}>
<Select value={form.provider} onChange={(event) => {
const provider = event.target.value;
setForm({
...form,
provider,
retryPolicyJson: editingChannel
? form.retryPolicyJson
: stringifyJson(defaultRetryPolicyForProvider(provider)),
});
}}>
{providerOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</Select>
</Label>
@@ -337,15 +378,29 @@ export function SystemSettingsPanel(props: {
))}
</div>
</Label>
<Label className="spanTwo">
{form.provider === 'server_main_openapi' && <Label className="spanTwo">
<Input value={form.uploadUrl} onChange={(event) => setForm({ ...form, uploadUrl: event.target.value })} placeholder={defaultUploadUrl} />
</Label>
<Label className="platformCredentialField">
</Label>}
{form.provider === 'server_main_openapi' && <Label className="platformCredentialField">
API Key
<Input value={form.apiKey} onChange={(event) => setForm({ ...form, apiKey: event.target.value })} placeholder={credentialInputPlaceholder(form.apiKeyPreview)} />
<small></small>
</Label>
</Label>}
{form.provider !== 'server_main_openapi' && <>
<Label>
Access Key ID
<Input value={form.accessKeyId} onChange={(event) => setForm({ ...form, accessKeyId: event.target.value })} placeholder={credentialInputPlaceholder(form.accessKeyIdPreview)} />
</Label>
<Label>
Access Key Secret
<Input type="password" value={form.accessKeySecret} onChange={(event) => setForm({ ...form, accessKeySecret: event.target.value })} placeholder={credentialInputPlaceholder(form.accessKeySecretPreview)} />
</Label>
<Label className="spanTwo">
Session Token
<Input type="password" value={form.sessionToken} onChange={(event) => setForm({ ...form, sessionToken: event.target.value })} placeholder={credentialInputPlaceholder(form.sessionTokenPreview)} />
</Label>
</>}
<Label>
<Input type="number" min={1} value={form.priority} onChange={(event) => setForm({ ...form, priority: event.target.value })} />
@@ -403,6 +458,10 @@ function clientCustomizationFormToPayload(form: ClientCustomizationForm): Client
function defaultChannelForm(channelKey = ''): FileStorageChannelForm {
return {
accessKeyId: '',
accessKeyIdPreview: '',
accessKeySecret: '',
accessKeySecretPreview: '',
apiKey: '',
apiKeyPreview: '',
channelKey,
@@ -410,7 +469,9 @@ function defaultChannelForm(channelKey = ''): FileStorageChannelForm {
name: 'server-main OpenAPI',
priority: '100',
provider: 'server_main_openapi',
retryPolicyJson: stringifyJson(defaultRetryPolicy),
retryPolicyJson: stringifyJson(defaultServerMainRetryPolicy),
sessionToken: '',
sessionTokenPreview: '',
scenes: defaultScenes,
status: 'disabled',
uploadUrl: defaultUploadUrl,
@@ -420,6 +481,10 @@ function defaultChannelForm(channelKey = ''): FileStorageChannelForm {
function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
const preview = apiKeyPreview(channel);
return {
accessKeyId: credentialPreview(channel, 'accessKeyId'),
accessKeyIdPreview: credentialPreview(channel, 'accessKeyId'),
accessKeySecret: credentialPreview(channel, 'accessKeySecret'),
accessKeySecretPreview: credentialPreview(channel, 'accessKeySecret'),
apiKey: preview,
apiKeyPreview: preview,
channelKey: channel.channelKey,
@@ -427,7 +492,9 @@ function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
name: channel.name,
priority: String(channel.priority || 100),
provider: channel.provider || 'server_main_openapi',
retryPolicyJson: stringifyJson(channel.retryPolicy ?? defaultRetryPolicy),
retryPolicyJson: stringifyJson(channel.retryPolicy ?? defaultRetryPolicyForProvider(channel.provider)),
sessionToken: credentialPreview(channel, 'sessionToken'),
sessionTokenPreview: credentialPreview(channel, 'sessionToken'),
scenes: normalizeScenes(channel.scenes),
status: channel.status || 'disabled',
uploadUrl: channel.uploadUrl || defaultUploadUrl,
@@ -436,6 +503,8 @@ function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
function formToPayload(form: FileStorageChannelForm): FileStorageChannelUpsertRequest {
return {
accessKeyId: credentialPayloadValue(form.accessKeyId, form.accessKeyIdPreview),
accessKeySecret: credentialPayloadValue(form.accessKeySecret, form.accessKeySecretPreview),
apiKey: apiKeyPayloadValue(form),
channelKey: form.channelKey.trim(),
config: parseJsonObject(form.configJson, '扩展配置 JSON'),
@@ -443,6 +512,7 @@ function formToPayload(form: FileStorageChannelForm): FileStorageChannelUpsertRe
priority: Number(form.priority) || 100,
provider: form.provider,
retryPolicy: parseJsonObject(form.retryPolicyJson, '重试策略 JSON'),
sessionToken: credentialPayloadValue(form.sessionToken, form.sessionTokenPreview),
scenes: normalizeScenes(form.scenes),
status: form.status,
uploadUrl: form.uploadUrl.trim(),
@@ -495,8 +565,8 @@ function nextScenes(current: string[], scene: string, checked: boolean) {
}
function retryPolicySummary(policy?: Record<string, unknown>) {
const maxRetries = numberFromUnknown(policy?.maxRetries) || 3;
const backoff = Array.isArray(policy?.backoffSeconds) ? policy?.backoffSeconds.join('/') : '60/120/180';
const maxRetries = numberFromUnknown(policy?.maxRetries) || 2;
const backoff = Array.isArray(policy?.backoffSeconds) ? policy?.backoffSeconds.join('/') : '0.25/1';
return `${maxRetries} 次 · ${backoff}s`;
}
@@ -506,9 +576,22 @@ function apiKeyPreview(channel: FileStorageChannel) {
}
function apiKeyPayloadValue(form: FileStorageChannelForm) {
const value = form.apiKey.trim();
if (form.apiKeyPreview && value === form.apiKeyPreview) return undefined;
return value || (form.apiKeyPreview ? '' : undefined);
return credentialPayloadValue(form.apiKey, form.apiKeyPreview);
}
function credentialPreview(channel: FileStorageChannel, key: string) {
const value = channel.credentialsPreview?.[key];
return typeof value === 'string' ? value : '';
}
function credentialPayloadValue(value: string, preview: string) {
const normalized = value.trim();
if (preview && normalized === preview) return undefined;
return normalized || (preview ? '' : undefined);
}
function defaultRetryPolicyForProvider(provider: string) {
return provider === 'server_main_openapi' ? defaultServerMainRetryPolicy : defaultObjectStorageRetryPolicy;
}
function credentialInputPlaceholder(preview: string) {