From 7d8e82c69f2a8eb245cb39e51a3a6943fce64a60 Mon Sep 17 00:00:00 2001 From: liangxu Date: Tue, 26 May 2026 01:19:01 +0800 Subject: [PATCH] feat(ops): add object storage management and site-mapping CSV import/export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship the Ops backstage 对象存储 page (browse / upload / move / delete / folder ops) backed by a new object-storage service + handler, with the shared storage client extended with List/Stat/Copy/Usage/DownloadURL so both MinIO and Aliyun providers cover the new surface. Add ForcePathStyle to the object-storage config and treat r2/s3/custom_s3 as external providers so Cloudflare R2 and other S3-compatibles can share the MinIO client path without spinning up the bundled MinIO. Also add CSV import/export + template download to the site-domain mappings page, raise gin MaxMultipartMemory to 8 MiB for the new multipart endpoints, register Ops swagger routes, and extend the descriptions-parity test to cover the ops router. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ops-web/src/layouts/AppShell.vue | 7 + apps/ops-web/src/lib/object-storage.ts | 107 + apps/ops-web/src/main.ts | 2 + apps/ops-web/src/router/index.ts | 6 + apps/ops-web/src/views/ObjectStorageView.vue | 2195 +++++++++++++++++ .../src/views/SiteDomainMappingsView.vue | 282 ++- deploy/OFFLINE_PACKAGE.md | 4 +- deploy/config.yaml | 1 + deploy/k3s/README.md | 1 + deploy/scripts/load-and-start.sh | 2 +- server/cmd/ops-api/main.go | 34 +- server/configs/config.local.yaml.example | 10 + server/configs/config.yaml | 1 + server/configs/ops-config.yaml | 1 + server/internal/ops/app/object_storage.go | 708 ++++++ .../internal/ops/app/object_storage_test.go | 267 ++ .../internal/ops/app/site_domain_mapping.go | 289 +++ .../ops/app/site_domain_mapping_test.go | 54 + server/internal/ops/config/config.go | 5 + .../ops/repository/site_domain_mapping.go | 86 +- .../ops/transport/object_storage_handler.go | 235 ++ server/internal/ops/transport/router.go | 55 +- .../transport/site_domain_mapping_handler.go | 56 + server/internal/shared/bootstrap/http.go | 3 + server/internal/shared/bootstrap/http_test.go | 1 + server/internal/shared/config/config.go | 26 +- server/internal/shared/config/config_test.go | 5 + .../internal/shared/objectstorage/aliyun.go | 180 +- .../internal/shared/objectstorage/client.go | 76 +- server/internal/shared/objectstorage/minio.go | 204 +- .../shared/objectstorage/reloadable.go | 49 + server/internal/shared/objectstorage/url.go | 61 + .../internal/shared/objectstorage/url_test.go | 20 + .../internal/shared/swagger/descriptions.go | 104 + .../swagger/descriptions_parity_test.go | 18 +- server/internal/shared/swagger/swagger.go | 18 +- .../tenant/transport/asset_handler_test.go | 4 + 37 files changed, 5111 insertions(+), 66 deletions(-) create mode 100644 apps/ops-web/src/lib/object-storage.ts create mode 100644 apps/ops-web/src/views/ObjectStorageView.vue create mode 100644 server/internal/ops/app/object_storage.go create mode 100644 server/internal/ops/app/object_storage_test.go create mode 100644 server/internal/ops/app/site_domain_mapping_test.go create mode 100644 server/internal/ops/transport/object_storage_handler.go diff --git a/apps/ops-web/src/layouts/AppShell.vue b/apps/ops-web/src/layouts/AppShell.vue index 1ac67c2..bd4f624 100644 --- a/apps/ops-web/src/layouts/AppShell.vue +++ b/apps/ops-web/src/layouts/AppShell.vue @@ -53,6 +53,7 @@ import { CloudDownloadOutlined, ControlOutlined, CrownOutlined, + DatabaseOutlined, FileSearchOutlined, GlobalOutlined, LineChartOutlined, @@ -84,6 +85,7 @@ const menuLeaves: MenuLeaf[] = [ { key: '/admin-users', label: '用户管理', path: '/admin-users' }, { key: '/kol-subscriptions', label: '订阅包审批', path: '/kol-subscriptions' }, { key: '/site-domain-mappings', label: '站点映射', path: '/site-domain-mappings' }, + { key: '/object-storage', label: '对象存储', path: '/object-storage' }, { key: '/desktop-client/releases', label: '客户端版本', path: '/desktop-client/releases' }, { key: '/compliance/policy', label: '全局策略', path: '/compliance/policy' }, { key: '/compliance/dictionaries', label: '合规词库', path: '/compliance/dictionaries' }, @@ -112,6 +114,11 @@ const menuItems = computed(() => [ label: '站点映射', icon: () => h(GlobalOutlined), }, + { + key: '/object-storage', + label: '对象存储', + icon: () => h(DatabaseOutlined), + }, { key: '/desktop-client/releases', label: '客户端版本', diff --git a/apps/ops-web/src/lib/object-storage.ts b/apps/ops-web/src/lib/object-storage.ts new file mode 100644 index 0000000..f11d8d9 --- /dev/null +++ b/apps/ops-web/src/lib/object-storage.ts @@ -0,0 +1,107 @@ +import { http } from '@/lib/http' + +export interface ObjectStorageObject { + key: string + size: number + etag?: string + last_modified: string + content_type?: string + storage_class?: string +} + +export interface ObjectStorageList { + objects: ObjectStorageObject[] + common_prefixes: string[] + continuation_token?: string + next_continuation_token?: string + is_truncated: boolean + prefix: string + keyword?: string + recursive: boolean +} + +export interface ObjectStorageDetail extends ObjectStorageObject { + public_url?: string | null + preview_url?: string | null +} + +export interface ObjectStorageUploadResult { + object: ObjectStorageObject +} + +export interface ObjectStorageFolderCreateResult { + prefix: string +} + +export interface ObjectStorageFolderDeleteResult { + prefix: string + deleted_count: number +} + +export interface ObjectStorageUsage { + available: boolean + provider?: string + storage_size: number + object_count: number +} + +export const objectStorageApi = { + list(params: { + prefix?: string + keyword?: string + continuation_token?: string + start_after?: string + recursive?: boolean + size?: number + }) { + return http.get('/object-storage/objects', params) + }, + usage() { + return http.get('/object-storage/usage') + }, + detail(key: string) { + return http.get('/object-storage/objects/detail', { key }) + }, + resolveURL(key: string) { + return http.get<{ url: string }>('/object-storage/objects/url', { key }) + }, + resolveDownloadURL(key: string) { + return http.get<{ url: string }>('/object-storage/objects/download-url', { key }) + }, + async upload(file: File, target: { key: string; overwrite: boolean; content_type?: string }) { + const form = new FormData() + form.append('file', file, file.name) + form.append('key', target.key) + form.append('overwrite', String(target.overwrite)) + if (target.content_type) form.append('content_type', target.content_type) + const response = await http.raw.post<{ + code: number + message: string + data: ObjectStorageUploadResult + }>('/object-storage/objects/upload', form) + return response.data.data + }, + move(payload: { + source_key: string + destination_key: string + overwrite: boolean + delete_source: boolean + }) { + return http.post('/object-storage/objects/move', payload) + }, + delete(key: string) { + return http.delete<{ key: string }>( + `/object-storage/objects?${new URLSearchParams({ key }).toString()}`, + ) + }, + createFolder(prefix: string) { + return http.post('/object-storage/folders', { + prefix, + }) + }, + deleteFolder(prefix: string) { + return http.delete( + `/object-storage/folders?${new URLSearchParams({ prefix }).toString()}`, + ) + }, +} diff --git a/apps/ops-web/src/main.ts b/apps/ops-web/src/main.ts index 2bbe2fc..ca0e14b 100644 --- a/apps/ops-web/src/main.ts +++ b/apps/ops-web/src/main.ts @@ -25,6 +25,7 @@ import { Result, Segmented, Select, + Skeleton, Space, Spin, Statistic, @@ -98,6 +99,7 @@ bindUnauthorizedHandler(() => { Result, Select, Segmented, + Skeleton, Space, Spin, Statistic, diff --git a/apps/ops-web/src/router/index.ts b/apps/ops-web/src/router/index.ts index 53a5cab..23af7b7 100644 --- a/apps/ops-web/src/router/index.ts +++ b/apps/ops-web/src/router/index.ts @@ -61,6 +61,12 @@ export const router = createRouter({ component: () => import('@/views/SiteDomainMappingsView.vue'), meta: { title: '站点映射' }, }, + { + path: 'object-storage', + name: 'object-storage', + component: () => import('@/views/ObjectStorageView.vue'), + meta: { title: '对象存储' }, + }, { path: 'desktop-client/releases', name: 'desktop-client-releases', diff --git a/apps/ops-web/src/views/ObjectStorageView.vue b/apps/ops-web/src/views/ObjectStorageView.vue new file mode 100644 index 0000000..217760a --- /dev/null +++ b/apps/ops-web/src/views/ObjectStorageView.vue @@ -0,0 +1,2195 @@ + + + + + diff --git a/apps/ops-web/src/views/SiteDomainMappingsView.vue b/apps/ops-web/src/views/SiteDomainMappingsView.vue index de947d8..2c6bd71 100644 --- a/apps/ops-web/src/views/SiteDomainMappingsView.vue +++ b/apps/ops-web/src/views/SiteDomainMappingsView.vue @@ -9,10 +9,27 @@ 本页停用 {{ inactiveRows }} - - - 新增映射 - +
+ + + + 导入 + + + + + 导出 + + + + 新增映射 + +
@@ -37,6 +54,10 @@ 刷新 + + + 下载模板 +
+ + +
+
+
+ 读取 + {{ importResult.total }} +
+
+ 新增 + {{ importResult.created }} +
+
+ 更新 + {{ importResult.updated }} +
+
+ 跳过 + {{ importResult.skipped }} +
+
+ 失败 + {{ importResult.failed }} +
+
+ 重复 + {{ importResult.duplicate ?? 0 }} +
+
+ 未导入 + {{ importResult.skipped + importResult.failed }} +
+
+ +
+
+ 第 {{ item.row }} 行 +

{{ item.message }}

+
+
+
+