feat: allow storage endpoint updates and deletion
This commit is contained in:
@@ -729,7 +729,9 @@ DELETE /admin/storage/endpoints/{endpoint_id}
|
|||||||
Authorization: Bearer <admin_token>
|
Authorization: Bearer <admin_token>
|
||||||
```
|
```
|
||||||
|
|
||||||
凭据加密保存且不通过 API 回传。测试接口执行 Bucket 检查、内部临时对象读写删和公网预签名下载;激活接口会再次测试并原子切换活动端点。活动端点不能直接编辑或删除。
|
凭据加密保存且不通过 API 回传。测试接口执行 Bucket 检查、内部临时对象读写删和公网预签名下载;激活接口会再次测试并原子切换活动端点。编辑端点时,后端先测试候选配置并抽查一个历史对象,全部通过后才以并发版本校验方式保存;活动端点编辑成功后保持活动,测试失败则原配置不变。
|
||||||
|
|
||||||
|
删除接口对活动或仍有关联对象的端点执行软删除:端点立即停止接收新对象并从管理列表隐藏;若删除的是活动端点,新写入自动回退本地。历史对象继续使用保留的加密配置下载和清理,关联对象清空且软删除超过 30 天后,后台任务才彻底移除端点凭据。
|
||||||
|
|
||||||
列表响应中的 `local_object_count` 与 `local_stored_bytes` 统计当前实际位于应用服务器的成品。存在活动 S3 时,新对象仍优先写入 S3;若本次 S3 写入失败,则自动回退本地并按实际后端完成下载和到期清理。
|
列表响应中的 `local_object_count` 与 `local_stored_bytes` 统计当前实际位于应用服务器的成品。存在活动 S3 时,新对象仍优先写入 S3;若本次 S3 写入失败,则自动回退本地并按实际后端完成下载和到期清理。
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ Worker 每 5 分钟按 `expires_at` 精确删除对象,删除成功后才删
|
|||||||
| Force path style | 开启 |
|
| Force path style | 开启 |
|
||||||
| 签名有效期 | `300` 秒 |
|
| 签名有效期 | `300` 秒 |
|
||||||
|
|
||||||
Access Key 和 Secret Key 使用项目现有 AES-256-GCM 机制加密入库,页面只显示掩码。保存后先执行“全链路测试”,再点“验证并启用”。后端激活前会再次执行 `HeadBucket + 内部 PutObject/GetObject + 公网预签名 GET + 内部 DeleteObject`。活动端点或仍有关联对象的端点不能直接编辑、归档,需先新增并启用替代端点,等待旧对象过期后再移除。
|
Access Key 和 Secret Key 使用项目现有 AES-256-GCM 机制加密入库,页面只显示掩码。保存后先执行“全链路测试”,再点“验证并启用”。后端激活前会再次执行 `HeadBucket + 内部 PutObject/GetObject + 公网预签名 GET + 内部 DeleteObject`。编辑现有端点时先对候选配置执行同等测试,并抽查历史对象是否仍可访问;测试通过后才保存,活动状态不变。删除采用软删除,立即停止新写入并隐藏端点,但保留历史对象所需的加密配置;历史关联清空 30 天后再彻底移除。若删除活动端点,新文件自动回退应用服务器本地,直到启用其他 S3。
|
||||||
|
|
||||||
## 7. 后续部署顺序(本次不执行)
|
## 7. 后续部署顺序(本次不执行)
|
||||||
|
|
||||||
|
|||||||
@@ -40,8 +40,16 @@ const emptyForm = () => ({
|
|||||||
})
|
})
|
||||||
const form = ref(emptyForm())
|
const form = ref(emptyForm())
|
||||||
|
|
||||||
|
const editingEndpoint = computed(() => endpoints.value.find((endpoint) => endpoint.id === editingId.value))
|
||||||
const formTitle = computed(() => (editingId.value ? '编辑存储端点' : '新增存储端点'))
|
const formTitle = computed(() => (editingId.value ? '编辑存储端点' : '新增存储端点'))
|
||||||
const submitLabel = computed(() => (editingId.value ? '保存并停用待测' : '保存端点'))
|
const submitLabel = computed(() => (editingId.value ? '测试并保存' : '保存端点'))
|
||||||
|
const formDescription = computed(() => {
|
||||||
|
if (!editingId.value) return '新增端点保存后保持停用,请执行全链路测试并手动启用。'
|
||||||
|
if (editingEndpoint.value?.is_active) {
|
||||||
|
return '保存前会测试候选配置和一个历史对象;全部通过后替换配置,并保持活动状态。'
|
||||||
|
}
|
||||||
|
return '保存前会测试候选配置和一个历史对象;全部通过后替换配置,端点保持停用。'
|
||||||
|
})
|
||||||
|
|
||||||
function errorText(err: unknown, fallback: string) {
|
function errorText(err: unknown, fallback: string) {
|
||||||
if (err instanceof ApiError) return `[${err.code}] ${err.message}`
|
if (err instanceof ApiError) return `[${err.code}] ${err.message}`
|
||||||
@@ -121,8 +129,10 @@ async function submitForm() {
|
|||||||
if (form.value.secret_key.trim()) payload.secret_key = form.value.secret_key.trim()
|
if (form.value.secret_key.trim()) payload.secret_key = form.value.secret_key.trim()
|
||||||
|
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await updateStorageEndpoint(auth.token, editingId.value, payload)
|
const updated = await updateStorageEndpoint(auth.token, editingId.value, payload)
|
||||||
message.value = '端点已保存并停用,请重新测试后启用。'
|
message.value = updated.is_active
|
||||||
|
? '配置已保存并通过全链路测试,端点保持活动。'
|
||||||
|
: '配置已保存并通过全链路测试,端点保持停用。'
|
||||||
} else {
|
} else {
|
||||||
if (!payload.access_key || !payload.secret_key) {
|
if (!payload.access_key || !payload.secret_key) {
|
||||||
error.value = '新增端点必须填写 Access Key 和 Secret Key。'
|
error.value = '新增端点必须填写 Access Key 和 Secret Key。'
|
||||||
@@ -183,7 +193,16 @@ async function activate(endpoint: AdminStorageEndpoint) {
|
|||||||
|
|
||||||
async function removeEndpoint(endpoint: AdminStorageEndpoint) {
|
async function removeEndpoint(endpoint: AdminStorageEndpoint) {
|
||||||
if (!auth.token) return
|
if (!auth.token) return
|
||||||
const confirmed = window.confirm(`确定删除“${endpoint.name}”吗?关联对象未清空时后端会拒绝删除。`)
|
const effects = [`确定删除“${endpoint.name}”吗?删除后端点将立即从管理列表移除。`]
|
||||||
|
if (endpoint.is_active) {
|
||||||
|
effects.push('这是当前活动端点;删除后新文件将回退到应用服务器本地,直到启用其他 S3。')
|
||||||
|
}
|
||||||
|
if (endpoint.object_count > 0) {
|
||||||
|
effects.push(
|
||||||
|
`现有 ${endpoint.object_count} 个历史对象仍可通过原配置下载,关联对象过期清理后凭据才会自动移除。`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const confirmed = window.confirm(effects.join('\n\n'))
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
busy.value = `delete:${endpoint.id}`
|
busy.value = `delete:${endpoint.id}`
|
||||||
@@ -305,9 +324,9 @@ onMounted(loadEndpoints)
|
|||||||
|
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="!endpoint.is_active && endpoint.object_count === 0"
|
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-white disabled:opacity-50"
|
||||||
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm text-slate-700 hover:bg-white"
|
|
||||||
type="button"
|
type="button"
|
||||||
|
:disabled="busy !== null"
|
||||||
@click="openEdit(endpoint)"
|
@click="openEdit(endpoint)"
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
@@ -330,10 +349,9 @@ onMounted(loadEndpoints)
|
|||||||
{{ busy === `activate:${endpoint.id}` ? '验证并启用...' : '验证并启用' }}
|
{{ busy === `activate:${endpoint.id}` ? '验证并启用...' : '验证并启用' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!endpoint.is_active"
|
|
||||||
class="rounded-md px-3 py-1.5 text-sm text-rose-700 hover:bg-rose-50 disabled:opacity-50"
|
class="rounded-md px-3 py-1.5 text-sm text-rose-700 hover:bg-rose-50 disabled:opacity-50"
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="busy !== null || endpoint.object_count > 0"
|
:disabled="busy !== null"
|
||||||
@click="removeEndpoint(endpoint)"
|
@click="removeEndpoint(endpoint)"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
@@ -348,7 +366,7 @@ onMounted(loadEndpoints)
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-slate-900">{{ formTitle }}</h3>
|
<h3 class="font-semibold text-slate-900">{{ formTitle }}</h3>
|
||||||
<p class="mt-1 text-sm text-slate-500">编辑连接参数后端点会自动停用,避免未经验证的配置接收新文件。</p>
|
<p class="mt-1 text-sm text-slate-500">{{ formDescription }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="text-sm text-slate-500 hover:text-slate-900" @click="closeForm">关闭</button>
|
<button type="button" class="text-sm text-slate-500 hover:text-slate-900" @click="closeForm">关闭</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -205,19 +205,6 @@ async fn update_storage_endpoint(
|
|||||||
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
|
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
|
||||||
let existing = storage::get_endpoint(&state, endpoint_id).await?;
|
let existing = storage::get_endpoint(&state, endpoint_id).await?;
|
||||||
ensure_configurable(&existing)?;
|
ensure_configurable(&existing)?;
|
||||||
if existing.is_active {
|
|
||||||
return Err(AppError::new(
|
|
||||||
ErrorCode::InvalidRequest,
|
|
||||||
"活动端点不能直接编辑,请先新增并启用替代端点",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let (object_count, _) = endpoint_usage(&state, endpoint_id).await?;
|
|
||||||
if object_count > 0 {
|
|
||||||
return Err(AppError::new(
|
|
||||||
ErrorCode::InvalidRequest,
|
|
||||||
"端点仍有关联对象,不能修改;请新增替代端点并等待旧对象过期",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let name = match req.name.as_deref() {
|
let name = match req.name.as_deref() {
|
||||||
Some(value) => validate_name(value)?,
|
Some(value) => validate_name(value)?,
|
||||||
@@ -240,30 +227,67 @@ async fn update_storage_endpoint(
|
|||||||
Some(value) => validate_region(value)?,
|
Some(value) => validate_region(value)?,
|
||||||
None => existing.region.clone(),
|
None => existing.region.clone(),
|
||||||
};
|
};
|
||||||
let access_key_encrypted = match req.access_key.as_deref() {
|
let (access_key_encrypted, access_key_hint_value) = match req.access_key.as_deref() {
|
||||||
Some(value) if !value.trim().is_empty() => Some(settings::encrypt_secret(
|
Some(value) if !value.trim().is_empty() => {
|
||||||
&state,
|
let value = validate_credential(value, "Access Key")?;
|
||||||
&validate_credential(value, "Access Key")?,
|
(
|
||||||
)?),
|
settings::encrypt_secret(&state, &value)?,
|
||||||
_ => None,
|
access_key_hint(&value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => (
|
||||||
|
existing.access_key_encrypted.clone(),
|
||||||
|
existing.access_key_hint.clone(),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
let secret_key_encrypted = match req.secret_key.as_deref() {
|
let secret_key_encrypted = match req.secret_key.as_deref() {
|
||||||
Some(value) if !value.trim().is_empty() => Some(settings::encrypt_secret(
|
Some(value) if !value.trim().is_empty() => {
|
||||||
&state,
|
settings::encrypt_secret(&state, &validate_credential(value, "Secret Key")?)?
|
||||||
&validate_credential(value, "Secret Key")?,
|
}
|
||||||
)?),
|
_ => existing.secret_key_encrypted.clone(),
|
||||||
_ => None,
|
|
||||||
};
|
};
|
||||||
let access_key_hint_value = req
|
let force_path_style = req.force_path_style.unwrap_or(existing.force_path_style);
|
||||||
.access_key
|
|
||||||
.as_deref()
|
|
||||||
.filter(|value| !value.trim().is_empty())
|
|
||||||
.map(access_key_hint);
|
|
||||||
let presign_ttl_seconds = validate_presign_ttl(
|
let presign_ttl_seconds = validate_presign_ttl(
|
||||||
req.presign_ttl_seconds
|
req.presign_ttl_seconds
|
||||||
.unwrap_or(existing.presign_ttl_seconds),
|
.unwrap_or(existing.presign_ttl_seconds),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
|
let mut candidate = existing.clone();
|
||||||
|
candidate.name = name.clone();
|
||||||
|
candidate.internal_endpoint = internal_endpoint.clone();
|
||||||
|
candidate.public_endpoint = public_endpoint.clone();
|
||||||
|
candidate.bucket = bucket.clone();
|
||||||
|
candidate.region = region.clone();
|
||||||
|
candidate.access_key_encrypted = access_key_encrypted.clone();
|
||||||
|
candidate.secret_key_encrypted = secret_key_encrypted.clone();
|
||||||
|
candidate.access_key_hint = access_key_hint_value.clone();
|
||||||
|
candidate.force_path_style = force_path_style;
|
||||||
|
candidate.presign_ttl_seconds = presign_ttl_seconds;
|
||||||
|
|
||||||
|
let test_result = match storage::test_endpoint(&state, &candidate).await {
|
||||||
|
Ok(()) => match endpoint_sample_key(&state, endpoint_id).await? {
|
||||||
|
Some(key) => storage::test_existing_object(&state, &candidate, &key).await,
|
||||||
|
None => Ok(()),
|
||||||
|
},
|
||||||
|
Err(err) => Err(err),
|
||||||
|
};
|
||||||
|
if let Err(err) = test_result {
|
||||||
|
let detail = storage_test_message(&err);
|
||||||
|
audit_storage_action(
|
||||||
|
&state,
|
||||||
|
admin_id,
|
||||||
|
ip,
|
||||||
|
"storage_endpoint.update_test_failed",
|
||||||
|
endpoint_id,
|
||||||
|
serde_json::json!({ "name": name, "message": detail }),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::StorageUnavailable,
|
||||||
|
format!("新配置测试失败,原配置未修改:{detail}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
let endpoint = sqlx::query_as::<_, storage::StorageEndpoint>(
|
let endpoint = sqlx::query_as::<_, storage::StorageEndpoint>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE storage_endpoints
|
UPDATE storage_endpoints
|
||||||
@@ -272,18 +296,17 @@ async fn update_storage_endpoint(
|
|||||||
public_endpoint = $4,
|
public_endpoint = $4,
|
||||||
bucket = $5,
|
bucket = $5,
|
||||||
region = $6,
|
region = $6,
|
||||||
access_key_encrypted = COALESCE($7, access_key_encrypted),
|
access_key_encrypted = $7,
|
||||||
secret_key_encrypted = COALESCE($8, secret_key_encrypted),
|
secret_key_encrypted = $8,
|
||||||
access_key_hint = COALESCE($9, access_key_hint),
|
access_key_hint = $9,
|
||||||
force_path_style = $10,
|
force_path_style = $10,
|
||||||
presign_ttl_seconds = $11,
|
presign_ttl_seconds = $11,
|
||||||
is_active = false,
|
last_test_at = NOW(),
|
||||||
last_test_at = NULL,
|
last_test_ok = true,
|
||||||
last_test_ok = NULL,
|
|
||||||
last_test_error = NULL,
|
last_test_error = NULL,
|
||||||
updated_at = NOW(),
|
updated_at = NOW(),
|
||||||
updated_by = $12
|
updated_by = $12
|
||||||
WHERE id = $1
|
WHERE id = $1 AND updated_at = $13 AND deleted_at IS NULL
|
||||||
RETURNING id, name, internal_endpoint, public_endpoint, bucket, region,
|
RETURNING id, name, internal_endpoint, public_endpoint, bucket, region,
|
||||||
access_key_encrypted, secret_key_encrypted, access_key_hint,
|
access_key_encrypted, secret_key_encrypted, access_key_hint,
|
||||||
force_path_style, presign_ttl_seconds, is_active,
|
force_path_style, presign_ttl_seconds, is_active,
|
||||||
@@ -300,12 +323,19 @@ async fn update_storage_endpoint(
|
|||||||
.bind(access_key_encrypted)
|
.bind(access_key_encrypted)
|
||||||
.bind(secret_key_encrypted)
|
.bind(secret_key_encrypted)
|
||||||
.bind(access_key_hint_value)
|
.bind(access_key_hint_value)
|
||||||
.bind(req.force_path_style.unwrap_or(existing.force_path_style))
|
.bind(force_path_style)
|
||||||
.bind(presign_ttl_seconds)
|
.bind(presign_ttl_seconds)
|
||||||
.bind(admin_id)
|
.bind(admin_id)
|
||||||
.fetch_one(&state.db)
|
.bind(existing.updated_at)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "更新存储端点失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "更新存储端点失败").with_source(err))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"端点配置在测试期间发生变化,请重新编辑",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
audit_storage_action(
|
audit_storage_action(
|
||||||
&state,
|
&state,
|
||||||
@@ -313,7 +343,7 @@ async fn update_storage_endpoint(
|
|||||||
ip,
|
ip,
|
||||||
"storage_endpoint.update",
|
"storage_endpoint.update",
|
||||||
endpoint.id,
|
endpoint.id,
|
||||||
serde_json::json!({ "name": endpoint.name, "requires_reactivation": true }),
|
serde_json::json!({ "name": endpoint.name, "kept_active": endpoint.is_active }),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -458,42 +488,42 @@ async fn delete_storage_endpoint(
|
|||||||
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
|
let (_jar, admin_id) = require_admin(&state, jar, &headers, ip).await?;
|
||||||
let endpoint = storage::get_endpoint(&state, endpoint_id).await?;
|
let endpoint = storage::get_endpoint(&state, endpoint_id).await?;
|
||||||
ensure_configurable(&endpoint)?;
|
ensure_configurable(&endpoint)?;
|
||||||
if endpoint.is_active {
|
|
||||||
return Err(AppError::new(
|
|
||||||
ErrorCode::InvalidRequest,
|
|
||||||
"活动端点不能删除,请先启用其他端点",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (object_count, _): (i64, i64) = endpoint_usage(&state, endpoint_id).await?;
|
let (object_count, _): (i64, i64) = endpoint_usage(&state, endpoint_id).await?;
|
||||||
if object_count > 0 {
|
|
||||||
return Err(AppError::new(
|
|
||||||
ErrorCode::InvalidRequest,
|
|
||||||
format!("端点仍关联 {object_count} 个对象,需等待对象过期清理后再删除"),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
sqlx::query(
|
let deleted = sqlx::query(
|
||||||
"UPDATE storage_endpoints SET is_active = false, deleted_at = NOW(), updated_at = NOW(), updated_by = $2 WHERE id = $1",
|
"UPDATE storage_endpoints SET is_active = false, deleted_at = NOW(), updated_at = NOW(), updated_by = $2 WHERE id = $1 AND updated_at = $3 AND deleted_at IS NULL",
|
||||||
)
|
)
|
||||||
.bind(endpoint_id)
|
.bind(endpoint_id)
|
||||||
.bind(admin_id)
|
.bind(admin_id)
|
||||||
|
.bind(endpoint.updated_at)
|
||||||
.execute(&state.db)
|
.execute(&state.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "归档存储端点失败").with_source(err))?;
|
.map_err(|err| AppError::new(ErrorCode::Internal, "归档存储端点失败").with_source(err))?;
|
||||||
|
if deleted.rows_affected() == 0 {
|
||||||
|
return Err(AppError::new(
|
||||||
|
ErrorCode::InvalidRequest,
|
||||||
|
"端点配置已发生变化,请刷新后重试",
|
||||||
|
));
|
||||||
|
}
|
||||||
audit_storage_action(
|
audit_storage_action(
|
||||||
&state,
|
&state,
|
||||||
admin_id,
|
admin_id,
|
||||||
ip,
|
ip,
|
||||||
"storage_endpoint.delete",
|
"storage_endpoint.delete",
|
||||||
endpoint_id,
|
endpoint_id,
|
||||||
serde_json::json!({ "name": endpoint.name }),
|
serde_json::json!({
|
||||||
|
"name": endpoint.name,
|
||||||
|
"object_count": object_count,
|
||||||
|
"was_active": endpoint.is_active,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
Ok(Json(Envelope {
|
Ok(Json(Envelope {
|
||||||
success: true,
|
success: true,
|
||||||
data: serde_json::json!({ "message": "存储端点已删除" }),
|
data: serde_json::json!({
|
||||||
|
"message": delete_endpoint_message(endpoint.is_active, object_count),
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,6 +571,31 @@ async fn endpoint_usage(state: &AppState, endpoint_id: Uuid) -> Result<(i64, i64
|
|||||||
.map_err(|err| AppError::new(ErrorCode::Internal, "统计存储使用量失败").with_source(err))
|
.map_err(|err| AppError::new(ErrorCode::Internal, "统计存储使用量失败").with_source(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn endpoint_sample_key(
|
||||||
|
state: &AppState,
|
||||||
|
endpoint_id: Uuid,
|
||||||
|
) -> Result<Option<String>, AppError> {
|
||||||
|
sqlx::query_scalar(
|
||||||
|
r#"
|
||||||
|
SELECT object_key
|
||||||
|
FROM (
|
||||||
|
SELECT storage_key AS object_key
|
||||||
|
FROM task_files
|
||||||
|
WHERE storage_endpoint_id = $1 AND storage_key IS NOT NULL
|
||||||
|
UNION ALL
|
||||||
|
SELECT zip_storage_key AS object_key
|
||||||
|
FROM tasks
|
||||||
|
WHERE zip_storage_endpoint_id = $1 AND zip_storage_key IS NOT NULL
|
||||||
|
) stored_objects
|
||||||
|
LIMIT 1
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(endpoint_id)
|
||||||
|
.fetch_optional(&state.db)
|
||||||
|
.await
|
||||||
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询存储历史对象失败").with_source(err))
|
||||||
|
}
|
||||||
|
|
||||||
async fn local_usage(state: &AppState) -> Result<(i64, i64), AppError> {
|
async fn local_usage(state: &AppState) -> Result<(i64, i64), AppError> {
|
||||||
sqlx::query_as::<_, (i64, i64)>(
|
sqlx::query_as::<_, (i64, i64)>(
|
||||||
r#"
|
r#"
|
||||||
@@ -724,6 +779,23 @@ fn access_key_hint(value: &str) -> String {
|
|||||||
format!("{start}...{end}")
|
format!("{start}...{end}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn delete_endpoint_message(was_active: bool, object_count: i64) -> String {
|
||||||
|
if object_count > 0 {
|
||||||
|
let fallback = if was_active {
|
||||||
|
"新文件将回退到本地存储;"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
return format!(
|
||||||
|
"存储端点已删除并停止接收新对象;{fallback}仍关联的 {object_count} 个历史对象可继续下载,关联对象清理完成后凭据将自动移除"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if was_active {
|
||||||
|
return "存储端点已删除;当前没有活动 S3,新文件将回退到本地存储".to_string();
|
||||||
|
}
|
||||||
|
"存储端点已删除".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
fn storage_test_message(error: &AppError) -> String {
|
fn storage_test_message(error: &AppError) -> String {
|
||||||
let Some(source) = error.source.as_deref() else {
|
let Some(source) = error.source.as_deref() else {
|
||||||
return error.message.clone();
|
return error.message.clone();
|
||||||
@@ -785,4 +857,16 @@ mod tests {
|
|||||||
assert_eq!(access_key_hint("ABCD12345678WXYZ"), "ABCD...WXYZ");
|
assert_eq!(access_key_hint("ABCD12345678WXYZ"), "ABCD...WXYZ");
|
||||||
assert_eq!(access_key_hint("short"), "********");
|
assert_eq!(access_key_hint("short"), "********");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_active_endpoint_explains_local_fallback() {
|
||||||
|
let message = delete_endpoint_message(true, 3);
|
||||||
|
assert!(message.contains("新文件将回退到本地存储"));
|
||||||
|
assert!(message.contains("3 个历史对象可继续下载"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deleting_inactive_unused_endpoint_has_short_message() {
|
||||||
|
assert_eq!(delete_endpoint_message(false, 0), "存储端点已删除");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -522,6 +522,22 @@ pub async fn test_endpoint(state: &AppState, endpoint: &StorageEndpoint) -> Resu
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn test_existing_object(
|
||||||
|
state: &AppState,
|
||||||
|
endpoint: &StorageEndpoint,
|
||||||
|
key: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let client = client_for(state, endpoint, EndpointKind::Internal)?;
|
||||||
|
client
|
||||||
|
.head_object()
|
||||||
|
.bucket(&endpoint.bucket)
|
||||||
|
.key(key)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| storage_error("S3 历史对象访问测试失败", err))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn client_for(
|
fn client_for(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
endpoint: &StorageEndpoint,
|
endpoint: &StorageEndpoint,
|
||||||
|
|||||||
Reference in New Issue
Block a user