feat(storage): 完善对象存储配置与过期策略

补齐 OSS/S3 的 Endpoint、Region、Bucket、CDN、对象前缀和签名有效期配置,并为生成结果与请求素材自动维护分级生命周期规则。普通上传继续保持永久,私有资源按配置生成限时签名 URL,管理端连接测试覆盖生命周期、上传、读取和删除。\n\n新增可重复的真实 OSS 验收脚本,凭据仅从本地环境读取,接口响应继续保持脱敏。\n\n验证:Go 全量测试、迁移安全检查、pnpm lint、pnpm test、pnpm build、本地阿里云 OSS 真实上传下载删除验收。
This commit is contained in:
2026-08-04 12:44:24 +08:00
parent fe56aa46b9
commit 6a64d3936a
16 changed files with 1274 additions and 47 deletions
+4 -1
View File
@@ -1191,7 +1191,10 @@ export function App() {
const refreshed = await listFileStorageChannels(token);
setFileStorageChannels(refreshed.items);
setCoreState('ready');
setCoreMessage(`对象存储连接测试通过,Put / Head / Delete 均成功,耗时 ${result.durationMs}ms。`);
const lifecycleMessage = result.expirationPolicy !== 'never'
? `生命周期 ${result.expirationPolicy} 已就绪,`
: '';
setCoreMessage(`对象存储连接测试通过,${lifecycleMessage}Put / Head / Delete 均成功,耗时 ${result.durationMs}ms。`);
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '对象存储连接测试失败');
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import type { FileStorageChannel } from '@easyai-ai-gateway/contracts';
import { channelToForm, formToPayload } from './SystemSettingsPanel';
describe('SystemSettingsPanel object storage form', () => {
it('promotes legacy CDN and expiration config while preserving unknown extensions', () => {
const channel: FileStorageChannel = {
id: 'channel-1',
channelKey: 'oss-primary',
name: 'OSS primary',
provider: 'aliyun_oss',
credentialsPreview: {},
scenes: ['upload', 'image_result', 'request_asset'],
config: {
endpoint: 'https://oss-cn-shanghai.aliyuncs.com',
region: 'cn-shanghai',
bucket: 'media',
cdnDomain: 'https://cdn.example.com/',
apiReturnExpirePolicy: '3m',
signedUrlExpiresSeconds: 3600,
customHeader: 'retained',
},
retryPolicy: {},
priority: 100,
status: 'enabled',
createdAt: '2026-08-04T00:00:00Z',
updatedAt: '2026-08-04T00:00:00Z',
};
const form = channelToForm(channel);
expect(form.publicBaseUrl).toBe('https://cdn.example.com/');
expect(form.temporaryFileExpirePolicy).toBe('3m');
expect(form.signedUrlExpiresMinutes).toBe('60');
expect(JSON.parse(form.configJson)).toEqual({ customHeader: 'retained' });
const payload = formToPayload({
...form,
objectPrefix: '/gateway/',
temporaryFileExpirePolicy: '1m',
});
expect(payload.config).toEqual({
endpoint: 'https://oss-cn-shanghai.aliyuncs.com',
region: 'cn-shanghai',
bucket: 'media',
publicBaseUrl: 'https://cdn.example.com',
objectPrefix: 'gateway',
temporaryFileExpirePolicy: '1m',
signedUrlExpiresSeconds: 3600,
customHeader: 'retained',
});
});
});
+180 -16
View File
@@ -21,16 +21,24 @@ type FileStorageChannelForm = {
accessKeySecretPreview: string;
apiKey: string;
apiKeyPreview: string;
bucket: string;
channelKey: string;
configJson: string;
endpoint: string;
forcePathStyle: boolean;
name: string;
objectPrefix: string;
priority: string;
provider: string;
publicBaseUrl: string;
region: string;
retryPolicyJson: string;
sessionToken: string;
sessionTokenPreview: string;
signedUrlExpiresMinutes: string;
scenes: string[];
status: string;
temporaryFileExpirePolicy: string;
uploadUrl: string;
};
@@ -55,6 +63,14 @@ const providerOptions = [
{ value: 's3', label: 'S3 / S3 兼容存储' },
];
const expirationPolicyOptions = [
{ value: 'never', label: '永久保留' },
{ value: '1d', label: '1 天' },
{ value: '1m', label: '1 个月(30 天)' },
{ value: '3m', label: '3 个月(90 天)' },
{ value: '6m', label: '6 个月(180 天)' },
];
const defaultScenes = ['upload', 'image_result', 'request_asset'];
const sceneOptions = [
{ value: 'upload', label: '上传', description: 'OpenAPI / 管理端主动上传文件' },
@@ -126,6 +142,15 @@ export function SystemSettingsPanel(props: {
setLocalError('请至少选择一个适用场景。');
return;
}
if (form.provider !== 'server_main_openapi' && (!form.endpoint.trim() || !form.region.trim() || !form.bucket.trim())) {
setLocalError('对象存储渠道必须填写 Endpoint、Region 和 Bucket。');
return;
}
const signedUrlExpiresMinutes = Number(form.signedUrlExpiresMinutes);
if (form.provider !== 'server_main_openapi' && (!Number.isInteger(signedUrlExpiresMinutes) || signedUrlExpiresMinutes < 1 || signedUrlExpiresMinutes > 10080)) {
setLocalError('私有链接有效期必须是 1 至 10080 分钟之间的整数。');
return;
}
try {
await props.onSaveFileStorageChannel(formToPayload(form), editingChannel?.id);
closeDialog();
@@ -245,6 +270,10 @@ export function SystemSettingsPanel(props: {
<span>: {sceneSummary(channel.scenes)}</span>
<span>: {channel.priority}</span>
<span>: {retryPolicySummary(channel.retryPolicy)}</span>
{channel.provider !== 'server_main_openapi' && configString(channel.config, 'region') && <span>Region: {configString(channel.config, 'region')}</span>}
{channel.provider !== 'server_main_openapi' && configString(channel.config, 'bucket') && <span>Bucket: {configString(channel.config, 'bucket')}</span>}
{channel.provider !== 'server_main_openapi' && publicBaseUrlFromConfig(channel.config) && <span>CDN: {publicBaseUrlFromConfig(channel.config)}</span>}
{channel.provider !== 'server_main_openapi' && <span>: {expirationPolicyLabel(expirationPolicyFromConfig(channel.config))}</span>}
{channel.uploadUrl && <span>: {channel.uploadUrl}</span>}
{apiKeyPreview(channel) && <span>API Key: {apiKeyPreview(channel)}</span>}
{channel.lastError && <span>: {channel.lastError}</span>}
@@ -348,6 +377,7 @@ export function SystemSettingsPanel(props: {
const provider = event.target.value;
setForm({
...form,
name: editingChannel ? form.name : providerLabel(provider),
provider,
retryPolicyJson: editingChannel
? form.retryPolicyJson
@@ -388,6 +418,35 @@ export function SystemSettingsPanel(props: {
<small></small>
</Label>}
{form.provider !== 'server_main_openapi' && <>
<Label className="spanTwo">
Endpoint
<Input value={form.endpoint} onChange={(event) => setForm({ ...form, endpoint: event.target.value })} placeholder={form.provider === 'aliyun_oss' ? 'https://oss-cn-shanghai.aliyuncs.com' : 'https://s3.example.com'} />
<small>使 Endpoint CDN </small>
</Label>
<Label>
Region
<Input value={form.region} onChange={(event) => setForm({ ...form, region: event.target.value })} placeholder={form.provider === 'aliyun_oss' ? 'cn-shanghai' : 'us-east-1'} />
</Label>
<Label>
Bucket
<Input value={form.bucket} onChange={(event) => setForm({ ...form, bucket: event.target.value })} placeholder="easyai-media" />
</Label>
<Label className="spanTwo">
CDN / 访
<Input value={form.publicBaseUrl} onChange={(event) => setForm({ ...form, publicBaseUrl: event.target.value })} placeholder="https://cdn.example.com" />
<small>使 URL</small>
</Label>
<Label>
<Input value={form.objectPrefix} onChange={(event) => setForm({ ...form, objectPrefix: event.target.value })} placeholder="gateway" />
</Label>
<Label>
<Select value={form.temporaryFileExpirePolicy} onChange={(event) => setForm({ ...form, temporaryFileExpirePolicy: event.target.value })}>
{expirationPolicyOptions.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}
</Select>
<small></small>
</Label>
<Label>
Access Key ID
<Input value={form.accessKeyId} onChange={(event) => setForm({ ...form, accessKeyId: event.target.value })} placeholder={credentialInputPlaceholder(form.accessKeyIdPreview)} />
@@ -396,23 +455,43 @@ export function SystemSettingsPanel(props: {
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 })} />
</Label>
<Label className="spanTwo">
JSON
<Textarea value={form.retryPolicyJson} onChange={(event) => setForm({ ...form, retryPolicyJson: event.target.value })} />
</Label>
<Label className="spanTwo">
JSON
<Textarea value={form.configJson} onChange={(event) => setForm({ ...form, configJson: event.target.value })} />
</Label>
<details className="fileStorageAdvanced spanTwo">
<summary></summary>
<div className="fileStorageAdvancedGrid">
{form.provider !== 'server_main_openapi' && <>
<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} max={10080} value={form.signedUrlExpiresMinutes} onChange={(event) => setForm({ ...form, signedUrlExpiresMinutes: event.target.value })} />
<small> URL</small>
</Label>
{form.provider === 's3' && <Label>
Bucket
<Select value={form.forcePathStyle ? 'path' : 'virtual'} onChange={(event) => setForm({ ...form, forcePathStyle: event.target.value === 'path' })}>
<option value="virtual">Virtual Host</option>
<option value="path">Path Style</option>
</Select>
</Label>}
</>}
<Label className="spanTwo">
JSON
<Textarea value={form.retryPolicyJson} onChange={(event) => setForm({ ...form, retryPolicyJson: event.target.value })} />
</Label>
<Label className="spanTwo">
JSON
<Textarea value={form.configJson} onChange={(event) => setForm({ ...form, configJson: event.target.value })} />
<small></small>
</Label>
</div>
</details>
</FormDialog>
<ConfirmDialog
@@ -464,22 +543,31 @@ function defaultChannelForm(channelKey = ''): FileStorageChannelForm {
accessKeySecretPreview: '',
apiKey: '',
apiKeyPreview: '',
bucket: '',
channelKey,
configJson: '{}',
endpoint: '',
forcePathStyle: false,
name: 'server-main OpenAPI',
objectPrefix: '',
priority: '100',
provider: 'server_main_openapi',
publicBaseUrl: '',
region: '',
retryPolicyJson: stringifyJson(defaultServerMainRetryPolicy),
sessionToken: '',
sessionTokenPreview: '',
signedUrlExpiresMinutes: '15',
scenes: defaultScenes,
status: 'disabled',
temporaryFileExpirePolicy: 'never',
uploadUrl: defaultUploadUrl,
};
}
function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
export function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
const preview = apiKeyPreview(channel);
const config = channel.config ?? {};
return {
accessKeyId: credentialPreview(channel, 'accessKeyId'),
accessKeyIdPreview: credentialPreview(channel, 'accessKeyId'),
@@ -487,27 +575,36 @@ function channelToForm(channel: FileStorageChannel): FileStorageChannelForm {
accessKeySecretPreview: credentialPreview(channel, 'accessKeySecret'),
apiKey: preview,
apiKeyPreview: preview,
bucket: configString(config, 'bucket'),
channelKey: channel.channelKey,
configJson: stringifyJson(channel.config ?? {}),
configJson: stringifyJson(channel.provider === 'server_main_openapi' ? config : unmanagedObjectStorageConfig(config)),
endpoint: configString(config, 'endpoint'),
forcePathStyle: configBoolean(config, 'forcePathStyle'),
name: channel.name,
objectPrefix: configString(config, 'objectPrefix'),
priority: String(channel.priority || 100),
provider: channel.provider || 'server_main_openapi',
publicBaseUrl: publicBaseUrlFromConfig(config),
region: configString(config, 'region'),
retryPolicyJson: stringifyJson(channel.retryPolicy ?? defaultRetryPolicyForProvider(channel.provider)),
sessionToken: credentialPreview(channel, 'sessionToken'),
sessionTokenPreview: credentialPreview(channel, 'sessionToken'),
signedUrlExpiresMinutes: String(Math.max(1, Math.round(configNumber(config, 'signedUrlExpiresSeconds', 900) / 60))),
scenes: normalizeScenes(channel.scenes),
status: channel.status || 'disabled',
temporaryFileExpirePolicy: expirationPolicyFromConfig(config),
uploadUrl: channel.uploadUrl || defaultUploadUrl,
};
}
function formToPayload(form: FileStorageChannelForm): FileStorageChannelUpsertRequest {
export function formToPayload(form: FileStorageChannelForm): FileStorageChannelUpsertRequest {
const config = parseJsonObject(form.configJson, '其他扩展配置 JSON');
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'),
config: form.provider === 'server_main_openapi' ? config : objectStorageConfigPayload(form, config),
name: form.name.trim(),
priority: Number(form.priority) || 100,
provider: form.provider,
@@ -519,6 +616,73 @@ function formToPayload(form: FileStorageChannelForm): FileStorageChannelUpsertRe
};
}
const managedObjectStorageConfigKeys = new Set([
'apiReturnExpirePolicy',
'bucket',
'cdnDomain',
'endpoint',
'forcePathStyle',
'objectPrefix',
'publicBaseURL',
'publicBaseUrl',
'publicDomain',
'region',
'signedUrlExpiresSeconds',
'temporaryFileExpirePolicy',
]);
function unmanagedObjectStorageConfig(config: Record<string, unknown>) {
return Object.fromEntries(Object.entries(config).filter(([key]) => !managedObjectStorageConfigKeys.has(key)));
}
function objectStorageConfigPayload(form: FileStorageChannelForm, extras: Record<string, unknown>) {
const config: Record<string, unknown> = { ...unmanagedObjectStorageConfig(extras) };
config.endpoint = form.endpoint.trim();
config.region = form.region.trim();
config.bucket = form.bucket.trim();
config.temporaryFileExpirePolicy = normalizeExpirationPolicy(form.temporaryFileExpirePolicy);
config.signedUrlExpiresSeconds = Math.max(1, Math.round(Number(form.signedUrlExpiresMinutes) || 15)) * 60;
if (form.publicBaseUrl.trim()) config.publicBaseUrl = form.publicBaseUrl.trim().replace(/\/+$/, '');
if (form.objectPrefix.trim()) config.objectPrefix = form.objectPrefix.trim().replace(/^\/+|\/+$/g, '');
if (form.provider === 's3') config.forcePathStyle = form.forcePathStyle;
return config;
}
function configString(config: Record<string, unknown> | undefined, key: string) {
const value = config?.[key];
return typeof value === 'string' ? value.trim() : '';
}
function configBoolean(config: Record<string, unknown> | undefined, key: string) {
return config?.[key] === true;
}
function configNumber(config: Record<string, unknown> | undefined, key: string, fallback: number) {
const value = Number(config?.[key]);
return Number.isFinite(value) && value > 0 ? value : fallback;
}
function publicBaseUrlFromConfig(config: Record<string, unknown> | undefined) {
return configString(config, 'publicBaseUrl')
|| configString(config, 'publicBaseURL')
|| configString(config, 'cdnDomain')
|| configString(config, 'publicDomain');
}
function expirationPolicyFromConfig(config: Record<string, unknown> | undefined) {
return normalizeExpirationPolicy(configString(config, 'temporaryFileExpirePolicy') || configString(config, 'apiReturnExpirePolicy'));
}
function normalizeExpirationPolicy(value: string) {
const normalized = value.trim().toLowerCase();
return expirationPolicyOptions.some((item) => item.value === normalized) ? normalized : 'never';
}
function expirationPolicyLabel(value: string) {
const normalized = normalizeExpirationPolicy(value);
return expirationPolicyOptions.find((item) => item.value === normalized)?.label ?? '永久保留';
}
function parseJsonObject(value: string, label: string) {
try {
const parsed = JSON.parse(value || '{}') as unknown;
+22
View File
@@ -2441,6 +2441,27 @@
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.fileStorageAdvanced {
padding: 0.75rem;
border: 1px solid var(--border-subtle);
border-radius: 0.625rem;
background: var(--surface-subtle);
}
.fileStorageAdvanced summary {
cursor: pointer;
color: var(--text-strong);
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
}
.fileStorageAdvancedGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.875rem;
padding-top: 0.875rem;
}
.fileStorageSceneGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -2822,6 +2843,7 @@
.fileStorageGrid,
.fileStorageSettingsCard,
.fileStorageSceneGrid,
.fileStorageAdvancedGrid,
.runtimePolicyFormBody,
.fileStorageDialogBody,
.runtimePolicyRows,