feat(ops): add object storage management and site-mapping CSV import/export
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<ItemType[]>(() => [
|
||||
label: '站点映射',
|
||||
icon: () => h(GlobalOutlined),
|
||||
},
|
||||
{
|
||||
key: '/object-storage',
|
||||
label: '对象存储',
|
||||
icon: () => h(DatabaseOutlined),
|
||||
},
|
||||
{
|
||||
key: '/desktop-client/releases',
|
||||
label: '客户端版本',
|
||||
|
||||
@@ -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<ObjectStorageList>('/object-storage/objects', params)
|
||||
},
|
||||
usage() {
|
||||
return http.get<ObjectStorageUsage>('/object-storage/usage')
|
||||
},
|
||||
detail(key: string) {
|
||||
return http.get<ObjectStorageDetail>('/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<ObjectStorageDetail, typeof payload>('/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<ObjectStorageFolderCreateResult, { prefix: string }>('/object-storage/folders', {
|
||||
prefix,
|
||||
})
|
||||
},
|
||||
deleteFolder(prefix: string) {
|
||||
return http.delete<ObjectStorageFolderDeleteResult>(
|
||||
`/object-storage/folders?${new URLSearchParams({ prefix }).toString()}`,
|
||||
)
|
||||
},
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Result,
|
||||
Segmented,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
@@ -98,6 +99,7 @@ bindUnauthorizedHandler(() => {
|
||||
Result,
|
||||
Select,
|
||||
Segmented,
|
||||
Skeleton,
|
||||
Space,
|
||||
Spin,
|
||||
Statistic,
|
||||
|
||||
@@ -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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,10 +9,27 @@
|
||||
<span>本页停用 {{ inactiveRows }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<a-button type="primary" @click="openCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增映射
|
||||
</a-button>
|
||||
<div class="site-domain-header-actions">
|
||||
<a-upload
|
||||
accept=".csv,text/csv"
|
||||
:before-upload="beforeImportUpload"
|
||||
:show-upload-list="false"
|
||||
:disabled="importing"
|
||||
>
|
||||
<a-button :loading="importing">
|
||||
<template #icon><UploadOutlined /></template>
|
||||
导入
|
||||
</a-button>
|
||||
</a-upload>
|
||||
<a-button :loading="exporting" @click="exportMappings">
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
导出
|
||||
</a-button>
|
||||
<a-button type="primary" @click="openCreate">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增映射
|
||||
</a-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ops-card site-domain-card">
|
||||
@@ -37,6 +54,10 @@
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button @click="downloadTemplate">
|
||||
<template #icon><FileTextOutlined /></template>
|
||||
下载模板
|
||||
</a-button>
|
||||
</div>
|
||||
|
||||
<a-table
|
||||
@@ -133,11 +154,73 @@
|
||||
</div>
|
||||
</template>
|
||||
</a-drawer>
|
||||
|
||||
<a-modal
|
||||
v-model:open="importResultOpen"
|
||||
title="导入结果"
|
||||
width="560px"
|
||||
:footer="null"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<div v-if="importResult" class="import-result">
|
||||
<div class="import-result-grid">
|
||||
<div>
|
||||
<span>读取</span>
|
||||
<strong>{{ importResult.total }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>新增</span>
|
||||
<strong>{{ importResult.created }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>更新</span>
|
||||
<strong>{{ importResult.updated }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>跳过</span>
|
||||
<strong>{{ importResult.skipped }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>失败</span>
|
||||
<strong>{{ importResult.failed }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>重复</span>
|
||||
<strong>{{ importResult.duplicate ?? 0 }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>未导入</span>
|
||||
<strong>{{ importResult.skipped + importResult.failed }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<a-alert
|
||||
v-if="importResult.errors.length"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="以下行未导入"
|
||||
class="import-result-alert"
|
||||
/>
|
||||
<div v-if="importResult.errors.length" class="import-error-list">
|
||||
<div v-for="item in importResult.errors" :key="`${item.row}-${item.message}`">
|
||||
<span>第 {{ item.row }} 行</span>
|
||||
<p>{{ item.message }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
FileTextOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -163,6 +246,21 @@ interface ListResult {
|
||||
size: number
|
||||
}
|
||||
|
||||
interface SiteDomainMappingImportError {
|
||||
row: number
|
||||
message: string
|
||||
}
|
||||
|
||||
interface SiteDomainMappingImportResult {
|
||||
total: number
|
||||
created: number
|
||||
updated: number
|
||||
skipped: number
|
||||
failed: number
|
||||
duplicate: number
|
||||
errors: SiteDomainMappingImportError[]
|
||||
}
|
||||
|
||||
const filter = reactive({
|
||||
keyword: '',
|
||||
status: undefined as string | undefined,
|
||||
@@ -183,10 +281,14 @@ const columns = [
|
||||
|
||||
const rows = ref<SiteDomainMappingRow[]>([])
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const exporting = ref(false)
|
||||
const page = ref(1)
|
||||
const size = ref(20)
|
||||
const total = ref(0)
|
||||
const statusLoadingId = ref<number | null>(null)
|
||||
const importResultOpen = ref(false)
|
||||
const importResult = ref<SiteDomainMappingImportResult | null>(null)
|
||||
|
||||
const activeRows = computed(() => rows.value.filter((row) => row.is_active).length)
|
||||
const inactiveRows = computed(() => rows.value.length - activeRows.value)
|
||||
@@ -338,6 +440,96 @@ async function deleteMapping(row: SiteDomainMappingRow) {
|
||||
}
|
||||
}
|
||||
|
||||
function beforeImportUpload(file: File) {
|
||||
void importMappings(file)
|
||||
return false
|
||||
}
|
||||
|
||||
async function importMappings(file: File) {
|
||||
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||
message.warning('请选择 CSV 文件')
|
||||
return
|
||||
}
|
||||
importing.value = true
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
importResult.value = await http.post<SiteDomainMappingImportResult, FormData>(
|
||||
'/site-domain-mappings/import',
|
||||
formData,
|
||||
)
|
||||
importResultOpen.value = true
|
||||
message.success(
|
||||
`导入完成:新增 ${importResult.value?.created ?? 0},更新 ${importResult.value?.updated ?? 0}`,
|
||||
)
|
||||
page.value = 1
|
||||
void reload()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function exportMappings() {
|
||||
exporting.value = true
|
||||
try {
|
||||
const response = await http.raw.get('/site-domain-mappings/export', {
|
||||
params: {
|
||||
keyword: filter.keyword || undefined,
|
||||
is_active: filter.status || undefined,
|
||||
},
|
||||
responseType: 'blob',
|
||||
})
|
||||
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8' })
|
||||
const fileName = resolveDownloadFileName(response.headers['content-disposition'])
|
||||
downloadBlob(blob, fileName)
|
||||
message.success('已导出 CSV')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTemplate() {
|
||||
const csv = [
|
||||
['registrable_domain', 'site_key', 'site_name', 'is_active'],
|
||||
['zhihu.com', 'zhihu.com', '知乎', 'true'],
|
||||
]
|
||||
downloadBlob(
|
||||
new Blob([toCsv(csv)], { type: 'text/csv;charset=utf-8' }),
|
||||
'site-domain-mappings-template.csv',
|
||||
)
|
||||
}
|
||||
|
||||
function toCsv(rowsForCsv: string[][]) {
|
||||
return `\uFEFF${rowsForCsv.map((row) => row.map(escapeCsvCell).join(',')).join('\n')}\n`
|
||||
}
|
||||
|
||||
function escapeCsvCell(value: string) {
|
||||
if (/[",\n\r]/.test(value)) {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function resolveDownloadFileName(contentDisposition: string | undefined) {
|
||||
const matched = contentDisposition?.match(/filename="?([^"]+)"?/i)
|
||||
return matched?.[1] || `site-domain-mappings-${dayjs().format('YYYYMMDDHHmmss')}.csv`
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function formatDate(value: string | null | undefined): string {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
|
||||
}
|
||||
@@ -384,6 +576,14 @@ onMounted(() => {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.site-domain-header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.site-domain-card {
|
||||
padding-top: 20px;
|
||||
}
|
||||
@@ -430,15 +630,87 @@ onMounted(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.import-result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.import-result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.import-result-grid div {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.import-result-grid span {
|
||||
display: block;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.import-result-grid strong {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #0f172a;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.import-result-alert {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.import-error-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.import-error-list div {
|
||||
border: 1px solid #fee2e2;
|
||||
border-radius: 6px;
|
||||
background: #fff7f7;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.import-error-list span {
|
||||
color: #b91c1c;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.import-error-list p {
|
||||
margin: 3px 0 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.site-domain-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.site-domain-header-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.site-domain-search,
|
||||
.site-domain-status {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.import-result-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -23,7 +23,9 @@ docker compose -f docker-compose.yaml -f docker-compose.offline.yaml --env-file
|
||||
Object storage is selected from `config.yaml` / `config.yml`, with `config.local.yaml` / `config.local.yml` as an override:
|
||||
|
||||
- `object_storage.provider: minio` or `mino` deploys MinIO and `minio-init`
|
||||
- `object_storage.provider: aliyun`, `aliyun_oss`, `aliyun-oss`, or `oss` skips MinIO and uses the Aliyun OSS config from the same config files
|
||||
- `object_storage.provider: aliyun`, `aliyun_oss`, `aliyun-oss`, `oss`, `r2`, `cloudflare_r2`, `s3`, `aws_s3`, or `custom_s3` skips MinIO and uses the external object storage config from the same config files
|
||||
|
||||
For Cloudflare R2, set `object_storage.region: auto` and `object_storage.force_path_style: true`.
|
||||
|
||||
Runtime services connect to Postgres through PgBouncer in `session` pooling mode. The one-shot `migrate` job still connects directly to `postgres` and `monitoring-postgres` so DDL and seed work do not go through the pooler.
|
||||
|
||||
|
||||
@@ -157,6 +157,7 @@ object_storage:
|
||||
use_ssl: false
|
||||
public_base_url: ""
|
||||
region: ""
|
||||
force_path_style: false
|
||||
|
||||
cache:
|
||||
driver: redis
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
- `provider: minio` 或 `provider: mino`:部署 MinIO 和 `minio-init`
|
||||
- `provider: aliyun` / `aliyun_oss` / `aliyun-oss` / `oss`:使用 `deploy/k3s-aliyun-oss` overlay,不部署 MinIO 和 `minio-init`
|
||||
- `provider: r2` / `s3` / `custom_s3`:复用 S3 兼容客户端,不部署自建 MinIO;Cloudflare R2 建议配置 `region: auto` 和 `force_path_style: true`
|
||||
|
||||
## 1. 准备镜像
|
||||
|
||||
|
||||
@@ -115,7 +115,7 @@ use_minio_for_object_storage() {
|
||||
""|minio|mino)
|
||||
return 0
|
||||
;;
|
||||
aliyun|aliyun_oss|aliyun-oss|oss)
|
||||
aliyun|aliyun_oss|aliyun-oss|oss|r2|cloudflare_r2|cloudflare-r2|s3|aws_s3|aws-s3|custom|custom_s3|custom-s3)
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
|
||||
+18
-16
@@ -122,6 +122,7 @@ func main() {
|
||||
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
|
||||
objectStorageClient := objectstorage.New(cfg.ObjectStorage, logger)
|
||||
desktopClientReleaseSvc := app.NewDesktopClientReleaseService(desktopClientReleasesRepo, objectStorageClient, auditSvc)
|
||||
objectStorageSvc := app.NewObjectStorageService(objectStorageClient, auditSvc).WithLogger(logger)
|
||||
complianceSvc := app.NewComplianceService(pool, auditSvc, logger)
|
||||
schedulerSvc := app.NewSchedulerService(schedulerRepo, auditSvc)
|
||||
|
||||
@@ -157,22 +158,23 @@ func main() {
|
||||
}
|
||||
|
||||
transport.RegisterRoutes(transport.Deps{
|
||||
Engine: engine,
|
||||
Server: cfg.Server,
|
||||
Logger: logger,
|
||||
DB: pool,
|
||||
MonitoringDB: monitoringPool,
|
||||
Issuer: issuer,
|
||||
Auth: authSvc,
|
||||
Accounts: accountSvc,
|
||||
AdminUsers: adminUserSvc,
|
||||
Jobs: jobSvc,
|
||||
KolSubs: kolSubscriptionSvc,
|
||||
Audits: auditSvc,
|
||||
SiteDomains: siteDomainMappingSvc,
|
||||
Releases: desktopClientReleaseSvc,
|
||||
Compliance: complianceSvc,
|
||||
Scheduler: schedulerSvc,
|
||||
Engine: engine,
|
||||
Server: cfg.Server,
|
||||
Logger: logger,
|
||||
DB: pool,
|
||||
MonitoringDB: monitoringPool,
|
||||
Issuer: issuer,
|
||||
Auth: authSvc,
|
||||
Accounts: accountSvc,
|
||||
AdminUsers: adminUserSvc,
|
||||
Jobs: jobSvc,
|
||||
KolSubs: kolSubscriptionSvc,
|
||||
Audits: auditSvc,
|
||||
SiteDomains: siteDomainMappingSvc,
|
||||
Releases: desktopClientReleaseSvc,
|
||||
ObjectStorage: objectStorageSvc,
|
||||
Compliance: complianceSvc,
|
||||
Scheduler: schedulerSvc,
|
||||
})
|
||||
|
||||
addr := fmt.Sprintf(":%d", cfg.Server.Port)
|
||||
|
||||
@@ -143,6 +143,7 @@ object_storage:
|
||||
use_ssl: false
|
||||
public_base_url: ""
|
||||
region: ""
|
||||
force_path_style: false
|
||||
# Prefer environment override for secrets:
|
||||
# export OBJECT_STORAGE_ACCESS_KEY=minioadmin
|
||||
# export OBJECT_STORAGE_SECRET_KEY=minioadmin
|
||||
@@ -151,6 +152,15 @@ object_storage:
|
||||
# endpoint: localhost:9000
|
||||
# bucket: geo-private
|
||||
# use_ssl: false
|
||||
# force_path_style: true
|
||||
#
|
||||
# Cloudflare R2 / S3-compatible example:
|
||||
# provider: r2
|
||||
# endpoint: <account-id>.r2.cloudflarestorage.com
|
||||
# bucket: your-bucket
|
||||
# region: auto
|
||||
# use_ssl: true
|
||||
# force_path_style: true
|
||||
#
|
||||
# Aliyun OSS example:
|
||||
# provider: aliyun
|
||||
|
||||
@@ -167,6 +167,7 @@ object_storage:
|
||||
object_acl: default
|
||||
signed_url_ttl: 30m
|
||||
region: cn-shanghai
|
||||
force_path_style: false
|
||||
|
||||
cache:
|
||||
driver: redis
|
||||
|
||||
@@ -52,6 +52,7 @@ object_storage:
|
||||
object_acl: default
|
||||
signed_url_ttl: 30m
|
||||
region: cn-shanghai
|
||||
force_path_style: false
|
||||
|
||||
log:
|
||||
level: info
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionObjectStorageUpload = "object_storage.upload"
|
||||
ActionObjectStorageMove = "object_storage.move"
|
||||
ActionObjectStorageDelete = "object_storage.delete"
|
||||
ActionObjectStorageFolderCreate = "object_storage.folder.create"
|
||||
ActionObjectStorageFolderDelete = "object_storage.folder.delete"
|
||||
)
|
||||
|
||||
type ObjectStorageService struct {
|
||||
storage objectstorage.ManagementClient
|
||||
audits *AuditService
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewObjectStorageService(storage objectstorage.Client, audits *AuditService) *ObjectStorageService {
|
||||
management, _ := storage.(objectstorage.ManagementClient)
|
||||
return &ObjectStorageService{storage: management, audits: audits}
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) WithLogger(logger *zap.Logger) *ObjectStorageService {
|
||||
if s == nil {
|
||||
return s
|
||||
}
|
||||
s.logger = logger
|
||||
if logger != nil && s.storage == nil {
|
||||
logger.Warn("object storage backend does not implement ManagementClient, OSS console disabled")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type ObjectStorageListInput struct {
|
||||
Prefix string
|
||||
Keyword string
|
||||
ContinuationToken string
|
||||
StartAfter string
|
||||
Recursive bool
|
||||
Size int
|
||||
}
|
||||
|
||||
type ObjectStorageListResult struct {
|
||||
Objects []objectstorage.ObjectInfo `json:"objects"`
|
||||
CommonPrefixes []string `json:"common_prefixes"`
|
||||
ContinuationToken string `json:"continuation_token,omitempty"`
|
||||
NextContinuationToken string `json:"next_continuation_token,omitempty"`
|
||||
IsTruncated bool `json:"is_truncated"`
|
||||
Prefix string `json:"prefix"`
|
||||
Keyword string `json:"keyword,omitempty"`
|
||||
Recursive bool `json:"recursive"`
|
||||
}
|
||||
|
||||
type ObjectStorageDetailResult struct {
|
||||
objectstorage.ObjectInfo
|
||||
PublicURL *string `json:"public_url,omitempty"`
|
||||
PreviewURL *string `json:"preview_url,omitempty"`
|
||||
}
|
||||
|
||||
type ObjectStorageUploadInput struct {
|
||||
ObjectKey string
|
||||
FileName string
|
||||
Content []byte
|
||||
ContentType string
|
||||
Overwrite bool
|
||||
}
|
||||
|
||||
type ObjectStorageUploadResult struct {
|
||||
Object objectstorage.ObjectInfo `json:"object"`
|
||||
}
|
||||
|
||||
type ObjectStorageMoveInput struct {
|
||||
SourceKey string
|
||||
DestinationKey string
|
||||
Overwrite bool
|
||||
DeleteSource bool
|
||||
}
|
||||
|
||||
type ObjectStorageFolderCreateInput struct {
|
||||
Prefix string
|
||||
}
|
||||
|
||||
type ObjectStorageFolderCreateResult struct {
|
||||
Prefix string `json:"prefix"`
|
||||
}
|
||||
|
||||
type ObjectStorageFolderDeleteResult struct {
|
||||
Prefix string `json:"prefix"`
|
||||
DeletedCount int `json:"deleted_count"`
|
||||
}
|
||||
|
||||
type ObjectStorageUsageResult struct {
|
||||
Available bool `json:"available"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
StorageSize int64 `json:"storage_size"`
|
||||
ObjectCount int64 `json:"object_count"`
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) List(ctx context.Context, in ObjectStorageListInput) (*ObjectStorageListResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size := in.Size
|
||||
if size <= 0 || size > 1000 {
|
||||
size = 100
|
||||
}
|
||||
prefix, err := normalizeOSSOptionalPrefix(in.Prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
keyword := strings.TrimSpace(in.Keyword)
|
||||
if keyword != "" && len([]rune(keyword)) > 200 {
|
||||
return nil, response.ErrBadRequest(40093, "invalid_object_storage_keyword", "搜索关键字不能超过 200 个字")
|
||||
}
|
||||
|
||||
listInput := objectstorage.ListInput{
|
||||
Prefix: prefix,
|
||||
ContinuationToken: strings.TrimSpace(in.ContinuationToken),
|
||||
StartAfter: normalizeOSSOptionalToken(in.StartAfter),
|
||||
Recursive: in.Recursive || keyword != "",
|
||||
Limit: size,
|
||||
}
|
||||
result, err := s.storage.List(ctx, listInput)
|
||||
if err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_list_failed", "读取 OSS 对象列表失败")
|
||||
}
|
||||
objects := normalizeOSSListedObjects(prefix, result.Objects, in.Recursive || keyword != "")
|
||||
commonPrefixes := normalizeOSSCommonPrefixes(prefix, result.CommonPrefixes, result.Objects, in.Recursive || keyword != "")
|
||||
if shouldFallbackOSSDirectoryList(in, keyword, objects, commonPrefixes) {
|
||||
listInput.Recursive = true
|
||||
result, err = s.storage.List(ctx, listInput)
|
||||
if err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_list_failed", "读取 OSS 对象列表失败")
|
||||
}
|
||||
objects = normalizeOSSListedObjects(prefix, result.Objects, in.Recursive || keyword != "")
|
||||
commonPrefixes = normalizeOSSCommonPrefixes(prefix, result.CommonPrefixes, result.Objects, in.Recursive || keyword != "")
|
||||
}
|
||||
|
||||
if keyword != "" {
|
||||
objects = filterOSSObjectsByKeyword(objects, keyword)
|
||||
}
|
||||
return &ObjectStorageListResult{
|
||||
Objects: objects,
|
||||
CommonPrefixes: commonPrefixes,
|
||||
ContinuationToken: result.ContinuationToken,
|
||||
NextContinuationToken: result.NextContinuationToken,
|
||||
IsTruncated: result.IsTruncated,
|
||||
Prefix: prefix,
|
||||
Keyword: keyword,
|
||||
Recursive: in.Recursive || keyword != "",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Usage(ctx context.Context) (*ObjectStorageUsageResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
usageClient, ok := s.storage.(objectstorage.UsageClient)
|
||||
if !ok {
|
||||
return &ObjectStorageUsageResult{Available: false}, nil
|
||||
}
|
||||
info, err := usageClient.Usage(ctx)
|
||||
if errors.Is(err, objectstorage.ErrUsageUnsupported) {
|
||||
return &ObjectStorageUsageResult{Available: false}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_usage_failed", "读取 OSS 存储用量失败")
|
||||
}
|
||||
if info == nil || !info.Available {
|
||||
return &ObjectStorageUsageResult{Available: false}, nil
|
||||
}
|
||||
return &ObjectStorageUsageResult{
|
||||
Available: true,
|
||||
Provider: info.Provider,
|
||||
StorageSize: info.StorageSize,
|
||||
ObjectCount: info.ObjectCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Get(ctx context.Context, key string) (*ObjectStorageDetailResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objectKey, err := normalizeOSSObjectKey(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := s.storage.Stat(ctx, objectKey)
|
||||
if err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_stat_failed", "读取 OSS 对象详情失败")
|
||||
}
|
||||
publicURL := s.publicReadableURL(objectKey)
|
||||
previewURL, err := s.storage.DownloadURL(objectKey)
|
||||
if err != nil || strings.TrimSpace(previewURL) == "" {
|
||||
previewURL = ""
|
||||
}
|
||||
return &ObjectStorageDetailResult{ObjectInfo: *info, PublicURL: publicURL, PreviewURL: optionalString(previewURL)}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) publicReadableURL(objectKey string) *string {
|
||||
publicClient, ok := s.storage.(objectstorage.PublicReadableClient)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
urlValue, err := publicClient.PublicReadableURL(objectKey)
|
||||
if err != nil || strings.TrimSpace(urlValue) == "" {
|
||||
return nil
|
||||
}
|
||||
return &urlValue
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Download(ctx context.Context, key string) ([]byte, *objectstorage.ObjectInfo, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
objectKey, err := normalizeOSSObjectKey(key)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info, statErr := s.storage.Stat(ctx, objectKey)
|
||||
if statErr != nil && !errors.Is(statErr, objectstorage.ErrObjectNotFound) {
|
||||
return nil, nil, mapObjectStorageError(statErr, "object_storage_stat_failed", "读取 OSS 对象详情失败")
|
||||
}
|
||||
content, err := s.storage.GetBytes(ctx, objectKey)
|
||||
if err != nil {
|
||||
return nil, nil, mapObjectStorageError(err, "object_storage_download_failed", "下载 OSS 对象失败")
|
||||
}
|
||||
return content, info, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) ResolvePublicURL(ctx context.Context, key string) (string, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
objectKey, err := normalizeOSSObjectKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists, err := s.storage.Exists(ctx, objectKey); err != nil {
|
||||
return "", mapObjectStorageError(err, "object_storage_stat_failed", "读取 OSS 对象详情失败")
|
||||
} else if !exists {
|
||||
return "", response.ErrNotFound(40441, "object_storage_object_not_found", "OSS 对象不存在")
|
||||
}
|
||||
urlValue, err := s.storage.PublicURL(objectKey)
|
||||
if errors.Is(err, objectstorage.ErrObjectNotPublic) {
|
||||
return s.ResolveDownloadURL(ctx, objectKey)
|
||||
}
|
||||
if err != nil {
|
||||
return "", mapObjectStorageError(err, "object_storage_url_failed", "生成 OSS 访问地址失败")
|
||||
}
|
||||
return urlValue, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) ResolveDownloadURL(ctx context.Context, key string) (string, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
objectKey, err := normalizeOSSObjectKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if exists, err := s.storage.Exists(ctx, objectKey); err != nil {
|
||||
return "", mapObjectStorageError(err, "object_storage_stat_failed", "读取 OSS 对象详情失败")
|
||||
} else if !exists {
|
||||
return "", response.ErrNotFound(40441, "object_storage_object_not_found", "OSS 对象不存在")
|
||||
}
|
||||
urlValue, err := s.storage.DownloadURL(objectKey)
|
||||
if err != nil {
|
||||
return "", mapObjectStorageError(err, "object_storage_url_failed", "生成 OSS 下载地址失败")
|
||||
}
|
||||
return urlValue, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Upload(ctx context.Context, actor *Actor, in ObjectStorageUploadInput) (*ObjectStorageUploadResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(in.Content) == 0 {
|
||||
return nil, response.ErrBadRequest(40094, "empty_object_storage_file", "上传文件不能为空")
|
||||
}
|
||||
if len(in.Content) > 500*1024*1024 {
|
||||
return nil, response.ErrBadRequest(40095, "object_storage_file_too_large", "单个对象不能超过 500MB")
|
||||
}
|
||||
objectKey, err := normalizeUploadObjectKey(in.ObjectKey, in.FileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !in.Overwrite {
|
||||
if exists, err := s.storage.Exists(ctx, objectKey); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_stat_failed", "检查 OSS 对象失败")
|
||||
} else if exists {
|
||||
return nil, response.ErrConflict(40941, "object_storage_object_exists", "OSS 对象已存在")
|
||||
}
|
||||
}
|
||||
contentType := strings.TrimSpace(in.ContentType)
|
||||
if contentType == "" {
|
||||
contentType = http.DetectContentType(in.Content)
|
||||
}
|
||||
if err := s.storage.PutBytes(ctx, objectKey, in.Content, contentType); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_upload_failed", "上传 OSS 对象失败")
|
||||
}
|
||||
info, err := s.storage.Stat(ctx, objectKey)
|
||||
if err != nil {
|
||||
if s.logger != nil {
|
||||
s.logger.Warn("object storage stat failed after upload; returning synthesized metadata",
|
||||
zap.String("object_key", objectKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
info = &objectstorage.ObjectInfo{
|
||||
Key: objectKey,
|
||||
Size: int64(len(in.Content)),
|
||||
ContentType: contentType,
|
||||
LastModified: time.Now(),
|
||||
}
|
||||
}
|
||||
s.audit(ctx, actor, ActionObjectStorageUpload, objectKey, map[string]any{
|
||||
"object_key": objectKey,
|
||||
"size": len(in.Content),
|
||||
"overwrite": in.Overwrite,
|
||||
})
|
||||
return &ObjectStorageUploadResult{Object: *info}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Move(ctx context.Context, actor *Actor, in ObjectStorageMoveInput) (*ObjectStorageDetailResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sourceKey, err := normalizeOSSObjectKey(in.SourceKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
destinationKey, err := normalizeOSSObjectKey(in.DestinationKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sourceKey == destinationKey {
|
||||
return s.Get(ctx, destinationKey)
|
||||
}
|
||||
if exists, err := s.storage.Exists(ctx, sourceKey); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_stat_failed", "检查源对象失败")
|
||||
} else if !exists {
|
||||
return nil, response.ErrNotFound(40441, "object_storage_object_not_found", "源 OSS 对象不存在")
|
||||
}
|
||||
if !in.Overwrite {
|
||||
if exists, err := s.storage.Exists(ctx, destinationKey); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_stat_failed", "检查目标对象失败")
|
||||
} else if exists {
|
||||
return nil, response.ErrConflict(40941, "object_storage_object_exists", "目标 OSS 对象已存在")
|
||||
}
|
||||
}
|
||||
if err := s.storage.Copy(ctx, sourceKey, destinationKey); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_move_failed", "移动 OSS 对象失败")
|
||||
}
|
||||
if in.DeleteSource {
|
||||
if err := s.storage.Delete(ctx, sourceKey); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_delete_failed", "删除源 OSS 对象失败")
|
||||
}
|
||||
}
|
||||
s.audit(ctx, actor, ActionObjectStorageMove, destinationKey, map[string]any{
|
||||
"source_key": sourceKey,
|
||||
"destination_key": destinationKey,
|
||||
"overwrite": in.Overwrite,
|
||||
"delete_source": in.DeleteSource,
|
||||
})
|
||||
return s.Get(ctx, destinationKey)
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) Delete(ctx context.Context, actor *Actor, key string) error {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
objectKey, err := normalizeOSSObjectKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists, err := s.storage.Exists(ctx, objectKey); err != nil {
|
||||
return mapObjectStorageError(err, "object_storage_stat_failed", "检查 OSS 对象失败")
|
||||
} else if !exists {
|
||||
return response.ErrNotFound(40441, "object_storage_object_not_found", "OSS 对象不存在")
|
||||
}
|
||||
if err := s.storage.Delete(ctx, objectKey); err != nil {
|
||||
return mapObjectStorageError(err, "object_storage_delete_failed", "删除 OSS 对象失败")
|
||||
}
|
||||
s.audit(ctx, actor, ActionObjectStorageDelete, objectKey, map[string]any{"object_key": objectKey})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) CreateFolder(
|
||||
ctx context.Context,
|
||||
actor *Actor,
|
||||
in ObjectStorageFolderCreateInput,
|
||||
) (*ObjectStorageFolderCreateResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix, err := normalizeOSSFolderPrefix(in.Prefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists, err := s.folderExists(ctx, prefix); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_folder_check_failed", "检查 OSS 目录失败")
|
||||
} else if exists {
|
||||
return nil, response.ErrConflict(40942, "object_storage_folder_exists", "OSS 目录已存在")
|
||||
}
|
||||
if err := s.storage.PutBytes(ctx, prefix, nil, "application/octet-stream"); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_folder_create_failed", "创建 OSS 目录失败")
|
||||
}
|
||||
s.audit(ctx, actor, ActionObjectStorageFolderCreate, prefix, map[string]any{"prefix": prefix})
|
||||
return &ObjectStorageFolderCreateResult{Prefix: prefix}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) DeleteFolder(
|
||||
ctx context.Context,
|
||||
actor *Actor,
|
||||
prefixValue string,
|
||||
) (*ObjectStorageFolderDeleteResult, error) {
|
||||
if err := s.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix, err := normalizeOSSFolderPrefix(prefixValue)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys := make([]string, 0)
|
||||
continuationToken := ""
|
||||
for {
|
||||
result, err := s.storage.List(ctx, objectstorage.ListInput{
|
||||
Prefix: prefix,
|
||||
ContinuationToken: continuationToken,
|
||||
Recursive: true,
|
||||
Limit: 1000,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_folder_list_failed", "读取 OSS 目录失败")
|
||||
}
|
||||
for _, item := range result.Objects {
|
||||
if strings.TrimSpace(item.Key) == "" || !strings.HasPrefix(item.Key, prefix) {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, item.Key)
|
||||
}
|
||||
if !result.IsTruncated || strings.TrimSpace(result.NextContinuationToken) == "" {
|
||||
break
|
||||
}
|
||||
continuationToken = strings.TrimSpace(result.NextContinuationToken)
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, response.ErrNotFound(40442, "object_storage_folder_not_found", "OSS 目录不存在或目录为空")
|
||||
}
|
||||
|
||||
deleted := 0
|
||||
for _, key := range keys {
|
||||
if strings.TrimSpace(key) == "" || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
if err := s.storage.Delete(ctx, key); err != nil {
|
||||
return nil, mapObjectStorageError(err, "object_storage_folder_delete_failed", "删除 OSS 目录失败")
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
|
||||
s.audit(ctx, actor, ActionObjectStorageFolderDelete, prefix, map[string]any{
|
||||
"prefix": prefix,
|
||||
"deleted_count": deleted,
|
||||
})
|
||||
return &ObjectStorageFolderDeleteResult{Prefix: prefix, DeletedCount: deleted}, nil
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) ensureReady() error {
|
||||
if s == nil || s.storage == nil {
|
||||
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
if err := s.storage.Validate(); err != nil {
|
||||
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeUploadObjectKey(objectKey string, fileName string) (string, error) {
|
||||
objectKey = strings.TrimSpace(objectKey)
|
||||
if objectKey == "" || strings.HasSuffix(objectKey, "/") {
|
||||
name := sanitizeOSSFileName(fileName)
|
||||
if name == "" {
|
||||
return "", response.ErrBadRequest(40096, "invalid_object_storage_key", "Object Key 不能为空")
|
||||
}
|
||||
objectKey = strings.TrimRight(objectKey, "/")
|
||||
if objectKey != "" {
|
||||
objectKey += "/"
|
||||
}
|
||||
objectKey += name
|
||||
}
|
||||
return normalizeOSSObjectKey(objectKey)
|
||||
}
|
||||
|
||||
func normalizeOSSObjectKey(value string) (string, error) {
|
||||
key, err := normalizeOSSKey(value, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if key == "" || strings.HasSuffix(key, "/") {
|
||||
return "", response.ErrBadRequest(40096, "invalid_object_storage_key", "Object Key 不能为空且不能是目录")
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func normalizeOSSFolderPrefix(value string) (string, error) {
|
||||
prefix, err := normalizeOSSKey(value, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prefix = strings.TrimRight(prefix, "/")
|
||||
if prefix == "" {
|
||||
return "", response.ErrBadRequest(40097, "invalid_object_storage_folder", "目录路径不能为空")
|
||||
}
|
||||
return prefix + "/", nil
|
||||
}
|
||||
|
||||
func normalizeOSSOptionalPrefix(value string) (string, error) {
|
||||
return normalizeOSSKey(value, true)
|
||||
}
|
||||
|
||||
func normalizeOSSOptionalToken(value string) string {
|
||||
key, err := normalizeOSSKey(value, true)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func normalizeOSSKey(value string, allowEmpty bool) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.TrimLeft(value, "/")
|
||||
if value == "" {
|
||||
if allowEmpty {
|
||||
return "", nil
|
||||
}
|
||||
return "", response.ErrBadRequest(40096, "invalid_object_storage_key", "Object Key 不能为空")
|
||||
}
|
||||
if len(value) > 1024 || strings.Contains(value, "\\") {
|
||||
return "", response.ErrBadRequest(40096, "invalid_object_storage_key", "Object Key 长度或格式无效")
|
||||
}
|
||||
for _, part := range strings.Split(value, "/") {
|
||||
if part == "." || part == ".." {
|
||||
return "", response.ErrBadRequest(40096, "invalid_object_storage_key", "Object Key 不能包含 . 或 .. 路径段")
|
||||
}
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func sanitizeOSSFileName(value string) string {
|
||||
name := strings.TrimSpace(filepath.Base(value))
|
||||
if name == "." || name == "/" || name == "\\" {
|
||||
return ""
|
||||
}
|
||||
return strings.ReplaceAll(name, "\\", "")
|
||||
}
|
||||
|
||||
func filterOSSObjectsByKeyword(items []objectstorage.ObjectInfo, keyword string) []objectstorage.ObjectInfo {
|
||||
if keyword == "" {
|
||||
return items
|
||||
}
|
||||
normalized := strings.ToLower(keyword)
|
||||
filtered := make([]objectstorage.ObjectInfo, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.Contains(strings.ToLower(item.Key), normalized) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func shouldFallbackOSSDirectoryList(
|
||||
in ObjectStorageListInput,
|
||||
keyword string,
|
||||
objects []objectstorage.ObjectInfo,
|
||||
commonPrefixes []string,
|
||||
) bool {
|
||||
if in.Recursive || keyword != "" {
|
||||
return false
|
||||
}
|
||||
return len(objects) == 0 && len(commonPrefixes) == 0
|
||||
}
|
||||
|
||||
func normalizeOSSListedObjects(prefix string, items []objectstorage.ObjectInfo, recursive bool) []objectstorage.ObjectInfo {
|
||||
if recursive {
|
||||
filtered := make([]objectstorage.ObjectInfo, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.HasSuffix(item.Key, "/") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
filtered := make([]objectstorage.ObjectInfo, 0, len(items))
|
||||
for _, item := range items {
|
||||
if strings.HasSuffix(item.Key, "/") {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(item.Key, prefix) {
|
||||
continue
|
||||
}
|
||||
remainder := strings.TrimPrefix(item.Key, prefix)
|
||||
if remainder == "" || strings.Contains(remainder, "/") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func normalizeOSSCommonPrefixes(
|
||||
prefix string,
|
||||
listedPrefixes []string,
|
||||
objects []objectstorage.ObjectInfo,
|
||||
recursive bool,
|
||||
) []string {
|
||||
if recursive {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(listedPrefixes)+len(objects))
|
||||
out := make([]string, 0, len(listedPrefixes))
|
||||
add := func(value string, requireNested bool) {
|
||||
value = strings.TrimLeft(strings.TrimSpace(value), "/")
|
||||
if value == "" || value == prefix || !strings.HasPrefix(value, prefix) {
|
||||
return
|
||||
}
|
||||
remainder := strings.TrimPrefix(value, prefix)
|
||||
first, _, hasSlash := strings.Cut(remainder, "/")
|
||||
if first == "" || (requireNested && !hasSlash) {
|
||||
return
|
||||
}
|
||||
next := prefix + first + "/"
|
||||
if _, ok := seen[next]; ok {
|
||||
return
|
||||
}
|
||||
seen[next] = struct{}{}
|
||||
out = append(out, next)
|
||||
}
|
||||
|
||||
for _, value := range listedPrefixes {
|
||||
add(value, false)
|
||||
}
|
||||
for _, item := range objects {
|
||||
add(item.Key, true)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) folderExists(ctx context.Context, prefix string) (bool, error) {
|
||||
if exists, err := s.storage.Exists(ctx, prefix); err != nil {
|
||||
return false, err
|
||||
} else if exists {
|
||||
return true, nil
|
||||
}
|
||||
result, err := s.storage.List(ctx, objectstorage.ListInput{
|
||||
Prefix: prefix,
|
||||
Recursive: true,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(result.Objects) > 0, nil
|
||||
}
|
||||
|
||||
func mapObjectStorageError(err error, code string, detail string) error {
|
||||
if errors.Is(err, objectstorage.ErrNotConfigured) {
|
||||
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
if errors.Is(err, objectstorage.ErrObjectNotFound) {
|
||||
return response.ErrNotFound(40441, "object_storage_object_not_found", "OSS 对象不存在")
|
||||
}
|
||||
appErr := response.ErrInternal(50097, code, detail)
|
||||
appErr.Cause = err
|
||||
return appErr
|
||||
}
|
||||
|
||||
func (s *ObjectStorageService) audit(ctx context.Context, actor *Actor, action string, targetKey string, metadata map[string]any) {
|
||||
if actor == nil || s.audits == nil {
|
||||
return
|
||||
}
|
||||
event := actor.audit(action, "object_storage_object", 0, metadata)
|
||||
event.TargetID = targetKey
|
||||
_ = s.audits.Append(ctx, event)
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNormalizeOSSCommonPrefixesDerivesNextLevelFolders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
objects := []objectstorage.ObjectInfo{
|
||||
{Key: "reports/2026/report.txt"},
|
||||
{Key: "reports/2026/monthly/january.txt"},
|
||||
{Key: "reports/2025/summary.txt"},
|
||||
{Key: "reports/readme.txt"},
|
||||
{Key: "images/logo.png"},
|
||||
}
|
||||
|
||||
require.Equal(t,
|
||||
[]string{"reports/2026/", "reports/2025/"},
|
||||
normalizeOSSCommonPrefixes("reports/", nil, objects, false),
|
||||
)
|
||||
require.Equal(t,
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}},
|
||||
normalizeOSSListedObjects("reports/", objects, false),
|
||||
)
|
||||
}
|
||||
|
||||
func TestNormalizeOSSListedObjectsFiltersFolderPlaceholders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
objects := []objectstorage.ObjectInfo{
|
||||
{Key: "reports/"},
|
||||
{Key: "reports/readme.txt"},
|
||||
{Key: "reports/2026/"},
|
||||
{Key: "reports/2026/report.txt"},
|
||||
}
|
||||
|
||||
require.Equal(t,
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}},
|
||||
normalizeOSSListedObjects("reports/", objects, false),
|
||||
)
|
||||
require.Equal(t,
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/readme.txt"}, {Key: "reports/2026/report.txt"}},
|
||||
normalizeOSSListedObjects("reports/", objects, true),
|
||||
)
|
||||
}
|
||||
|
||||
func TestNormalizeOSSCommonPrefixesKeepsProviderPrefixes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := normalizeOSSCommonPrefixes(
|
||||
"reports/",
|
||||
[]string{"reports/2026/", "reports/2025/"},
|
||||
[]objectstorage.ObjectInfo{
|
||||
{Key: "reports/2026/report.txt"},
|
||||
{Key: "reports/2025/summary.txt"},
|
||||
},
|
||||
false,
|
||||
)
|
||||
|
||||
require.Equal(t, []string{"reports/2026/", "reports/2025/"}, got)
|
||||
}
|
||||
|
||||
func TestNormalizeOSSCommonPrefixesOmitsFoldersInRecursiveMode(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := normalizeOSSCommonPrefixes(
|
||||
"reports/",
|
||||
[]string{"reports/2026/"},
|
||||
[]objectstorage.ObjectInfo{{Key: "reports/2026/report.txt"}},
|
||||
true,
|
||||
)
|
||||
|
||||
require.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestShouldFallbackOSSDirectoryList(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
require.True(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/"},
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
require.False(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/", Recursive: true},
|
||||
"",
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
require.False(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/"},
|
||||
"release",
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
require.False(t, shouldFallbackOSSDirectoryList(
|
||||
ObjectStorageListInput{Prefix: "desktop-client/"},
|
||||
"",
|
||||
nil,
|
||||
[]string{"desktop-client/releases/"},
|
||||
))
|
||||
}
|
||||
|
||||
func TestCreateFolderWritesPlaceholder(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
result, err := svc.CreateFolder(context.Background(), nil, ObjectStorageFolderCreateInput{Prefix: "reports/2026"})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "reports/2026/", result.Prefix)
|
||||
require.Contains(t, storage.objects, "reports/2026/")
|
||||
require.Equal(t, []byte(nil), storage.objects["reports/2026/"])
|
||||
require.Equal(t, "application/octet-stream", storage.contentTypes["reports/2026/"])
|
||||
}
|
||||
|
||||
func TestCreateFolderRejectsExistingPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
storage.objects["reports/2026/report.txt"] = []byte("content")
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
_, err := svc.CreateFolder(context.Background(), nil, ObjectStorageFolderCreateInput{Prefix: "reports/2026/"})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "object_storage_folder_exists")
|
||||
}
|
||||
|
||||
func TestDeleteFolderDeletesAllObjectsUnderPrefix(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
storage.objects["reports/2026/"] = nil
|
||||
storage.objects["reports/2026/a.txt"] = []byte("a")
|
||||
storage.objects["reports/2026/nested/b.txt"] = []byte("b")
|
||||
storage.objects["reports/2025/a.txt"] = []byte("old")
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
result, err := svc.DeleteFolder(context.Background(), nil, "reports/2026/")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "reports/2026/", result.Prefix)
|
||||
require.Equal(t, 3, result.DeletedCount)
|
||||
require.NotContains(t, storage.objects, "reports/2026/")
|
||||
require.NotContains(t, storage.objects, "reports/2026/a.txt")
|
||||
require.NotContains(t, storage.objects, "reports/2026/nested/b.txt")
|
||||
require.Contains(t, storage.objects, "reports/2025/a.txt")
|
||||
}
|
||||
|
||||
func TestGetReturnsPreviewURLAndOmitsNonPublicURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
storage := newFakeObjectStorage()
|
||||
storage.objects["reports/secret.png"] = []byte("image")
|
||||
storage.publicReadable = false
|
||||
svc := &ObjectStorageService{storage: storage}
|
||||
|
||||
result, err := svc.Get(context.Background(), "reports/secret.png")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, result.PublicURL)
|
||||
require.NotNil(t, result.PreviewURL)
|
||||
require.Equal(t, "https://example.test/download/reports/secret.png", *result.PreviewURL)
|
||||
}
|
||||
|
||||
type fakeObjectStorage struct {
|
||||
objects map[string][]byte
|
||||
contentTypes map[string]string
|
||||
publicReadable bool
|
||||
}
|
||||
|
||||
func newFakeObjectStorage() *fakeObjectStorage {
|
||||
return &fakeObjectStorage{
|
||||
objects: make(map[string][]byte),
|
||||
contentTypes: make(map[string]string),
|
||||
publicReadable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) PutBytes(_ context.Context, objectKey string, content []byte, contentType string) error {
|
||||
s.objects[objectKey] = content
|
||||
s.contentTypes[objectKey] = contentType
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) GetBytes(_ context.Context, objectKey string) ([]byte, error) {
|
||||
content, ok := s.objects[objectKey]
|
||||
if !ok {
|
||||
return nil, objectstorage.ErrObjectNotFound
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Exists(_ context.Context, objectKey string) (bool, error) {
|
||||
_, ok := s.objects[objectKey]
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Delete(_ context.Context, objectKey string) error {
|
||||
delete(s.objects, objectKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) PublicURL(objectKey string) (string, error) {
|
||||
return "https://example.test/" + objectKey, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) PublicReadableURL(objectKey string) (string, error) {
|
||||
if !s.publicReadable {
|
||||
return "", objectstorage.ErrObjectNotPublic
|
||||
}
|
||||
return "https://example.test/" + objectKey, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) DownloadURL(objectKey string) (string, error) {
|
||||
return "https://example.test/download/" + objectKey, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) List(_ context.Context, in objectstorage.ListInput) (*objectstorage.ListResult, error) {
|
||||
if in.Limit == 0 {
|
||||
return nil, errors.New("test list limit must be set")
|
||||
}
|
||||
result := &objectstorage.ListResult{}
|
||||
for key, content := range s.objects {
|
||||
if strings.HasPrefix(key, in.Prefix) {
|
||||
result.Objects = append(result.Objects, objectstorage.ObjectInfo{
|
||||
Key: key,
|
||||
Size: int64(len(content)),
|
||||
LastModified: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Stat(_ context.Context, objectKey string) (*objectstorage.ObjectInfo, error) {
|
||||
content, ok := s.objects[objectKey]
|
||||
if !ok {
|
||||
return nil, objectstorage.ErrObjectNotFound
|
||||
}
|
||||
return &objectstorage.ObjectInfo{Key: objectKey, Size: int64(len(content)), LastModified: time.Now()}, nil
|
||||
}
|
||||
|
||||
func (s *fakeObjectStorage) Copy(_ context.Context, sourceKey, destinationKey string) error {
|
||||
content, ok := s.objects[sourceKey]
|
||||
if !ok {
|
||||
return objectstorage.ErrObjectNotFound
|
||||
}
|
||||
s.objects[destinationKey] = append([]byte(nil), content...)
|
||||
return nil
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -21,6 +25,12 @@ const (
|
||||
ActionSiteDomainMappingEnable = "site_domain_mapping.enable"
|
||||
ActionSiteDomainMappingDisable = "site_domain_mapping.disable"
|
||||
ActionSiteDomainMappingDelete = "site_domain_mapping.delete"
|
||||
ActionSiteDomainMappingImport = "site_domain_mapping.import"
|
||||
ActionSiteDomainMappingExport = "site_domain_mapping.export"
|
||||
|
||||
siteDomainMappingImportMaxRows = 5000
|
||||
siteDomainMappingExportMaxRows = 10000
|
||||
siteDomainMappingImportMaxErrors = 50
|
||||
)
|
||||
|
||||
type SiteDomainMappingService struct {
|
||||
@@ -63,6 +73,32 @@ type SiteDomainMappingInput struct {
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
type SiteDomainMappingImportInput struct {
|
||||
Content []byte
|
||||
}
|
||||
|
||||
type SiteDomainMappingImportError struct {
|
||||
Row int `json:"row"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type SiteDomainMappingImportResult struct {
|
||||
Total int `json:"total"`
|
||||
Created int `json:"created"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Duplicate int `json:"duplicate"`
|
||||
Errors []SiteDomainMappingImportError `json:"errors"`
|
||||
}
|
||||
|
||||
type siteDomainMappingImportColumns struct {
|
||||
Domain int
|
||||
Key int
|
||||
Name int
|
||||
Active int
|
||||
}
|
||||
|
||||
func toSiteDomainMappingView(item *domain.SiteDomainMapping) SiteDomainMappingView {
|
||||
return SiteDomainMappingView{
|
||||
ID: item.ID,
|
||||
@@ -102,6 +138,56 @@ func (s *SiteDomainMappingService) List(ctx context.Context, in SiteDomainMappin
|
||||
return &SiteDomainMappingListResult{Items: views, Total: total, Page: page, Size: size}, nil
|
||||
}
|
||||
|
||||
func (s *SiteDomainMappingService) ExportCSV(ctx context.Context, actor *Actor, in SiteDomainMappingListInput) ([]byte, error) {
|
||||
items, err := s.mappings.Export(ctx, repository.SiteDomainMappingFilter{
|
||||
Keyword: strings.TrimSpace(in.Keyword),
|
||||
IsActive: in.IsActive,
|
||||
Limit: siteDomainMappingExportMaxRows + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(items) > siteDomainMappingExportMaxRows {
|
||||
return nil, response.ErrBadRequest(
|
||||
40059,
|
||||
"site_domain_mapping_export_too_large",
|
||||
fmt.Sprintf("单次最多导出 %d 行,请缩小筛选范围后重试", siteDomainMappingExportMaxRows),
|
||||
)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString("\xEF\xBB\xBF")
|
||||
writer := csv.NewWriter(&buf)
|
||||
if err := writer.Write([]string{"registrable_domain", "site_key", "site_name", "is_active", "created_at", "updated_at"}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range items {
|
||||
if err := writer.Write([]string{
|
||||
item.RegistrableDomain,
|
||||
item.SiteKey,
|
||||
item.SiteName,
|
||||
strconv.FormatBool(item.IsActive),
|
||||
item.CreatedAt.Format(time.RFC3339),
|
||||
item.UpdatedAt.Format(time.RFC3339),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if actor != nil && s.audits != nil {
|
||||
_ = s.audits.Append(ctx, actor.audit(ActionSiteDomainMappingExport, "site_domain_mapping", 0, map[string]any{
|
||||
"count": len(items),
|
||||
"keyword": strings.TrimSpace(in.Keyword),
|
||||
"is_active": in.IsActive,
|
||||
}))
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func (s *SiteDomainMappingService) Create(ctx context.Context, actor *Actor, in SiteDomainMappingInput) (*SiteDomainMappingView, error) {
|
||||
normalized, err := normalizeSiteDomainMappingInput(in)
|
||||
if err != nil {
|
||||
@@ -126,6 +212,85 @@ func (s *SiteDomainMappingService) Create(ctx context.Context, actor *Actor, in
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *SiteDomainMappingService) ImportCSV(ctx context.Context, actor *Actor, in SiteDomainMappingImportInput) (*SiteDomainMappingImportResult, error) {
|
||||
records, err := readSiteDomainMappingCSV(in.Content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(records) < 2 {
|
||||
return nil, response.ErrBadRequest(40053, "empty_site_domain_mapping_import", "CSV 至少需要表头和一行数据")
|
||||
}
|
||||
if len(records)-1 > siteDomainMappingImportMaxRows {
|
||||
return nil, response.ErrBadRequest(
|
||||
40054,
|
||||
"site_domain_mapping_import_too_large",
|
||||
fmt.Sprintf("单次最多导入 %d 行", siteDomainMappingImportMaxRows),
|
||||
)
|
||||
}
|
||||
|
||||
columns, err := detectSiteDomainMappingImportColumns(records[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := &SiteDomainMappingImportResult{
|
||||
Total: len(records) - 1,
|
||||
Errors: make([]SiteDomainMappingImportError, 0),
|
||||
}
|
||||
seen := map[string]int{}
|
||||
for rowIndex, record := range records[1:] {
|
||||
rowNumber := rowIndex + 2
|
||||
if isBlankCSVRecord(record) {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
input, rowErr := siteDomainMappingInputFromCSVRecord(record, columns)
|
||||
if rowErr != nil {
|
||||
appendSiteDomainMappingImportError(result, rowNumber, rowErr.Error())
|
||||
continue
|
||||
}
|
||||
normalized, rowErr := normalizeSiteDomainMappingInput(input)
|
||||
if rowErr != nil {
|
||||
appendSiteDomainMappingImportError(result, rowNumber, response.Normalize(rowErr).Detail)
|
||||
continue
|
||||
}
|
||||
if firstRow, ok := seen[normalized.RegistrableDomain]; ok {
|
||||
result.Duplicate++
|
||||
result.Skipped++
|
||||
appendSiteDomainMappingImportWarning(result, rowNumber, fmt.Sprintf("域名与第 %d 行重复,已跳过", firstRow))
|
||||
continue
|
||||
}
|
||||
seen[normalized.RegistrableDomain] = rowNumber
|
||||
|
||||
upserted, rowErr := s.mappings.Upsert(ctx, repository.UpsertSiteDomainMappingInput{
|
||||
RegistrableDomain: normalized.RegistrableDomain,
|
||||
SiteKey: normalized.SiteKey,
|
||||
SiteName: normalized.SiteName,
|
||||
IsActive: normalized.IsActive,
|
||||
})
|
||||
if rowErr != nil {
|
||||
appendSiteDomainMappingImportError(result, rowNumber, rowErr.Error())
|
||||
continue
|
||||
}
|
||||
if upserted.Created {
|
||||
result.Created++
|
||||
} else {
|
||||
result.Updated++
|
||||
}
|
||||
}
|
||||
if actor != nil && s.audits != nil {
|
||||
_ = s.audits.Append(ctx, actor.audit(ActionSiteDomainMappingImport, "site_domain_mapping", 0, map[string]any{
|
||||
"total": result.Total,
|
||||
"created": result.Created,
|
||||
"updated": result.Updated,
|
||||
"skipped": result.Skipped,
|
||||
"failed": result.Failed,
|
||||
"duplicate": result.Duplicate,
|
||||
}))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *SiteDomainMappingService) Update(ctx context.Context, actor *Actor, id int64, in SiteDomainMappingInput) (*SiteDomainMappingView, error) {
|
||||
normalized, err := normalizeSiteDomainMappingInput(in)
|
||||
if err != nil {
|
||||
@@ -240,6 +405,130 @@ func normalizeDomainName(raw string) string {
|
||||
return strings.Trim(strings.TrimSuffix(value, "."), ".")
|
||||
}
|
||||
|
||||
func readSiteDomainMappingCSV(content []byte) ([][]string, error) {
|
||||
content = bytes.TrimPrefix(content, []byte("\xEF\xBB\xBF"))
|
||||
reader := csv.NewReader(bytes.NewReader(content))
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.TrimLeadingSpace = true
|
||||
records, err := reader.ReadAll()
|
||||
if err != nil {
|
||||
return nil, response.ErrBadRequest(40055, "invalid_site_domain_mapping_csv", "CSV 文件格式无效:"+err.Error())
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func detectSiteDomainMappingImportColumns(header []string) (siteDomainMappingImportColumns, error) {
|
||||
columns := siteDomainMappingImportColumns{
|
||||
Domain: -1,
|
||||
Key: -1,
|
||||
Name: -1,
|
||||
Active: -1,
|
||||
}
|
||||
for index, raw := range header {
|
||||
switch normalizeSiteDomainMappingCSVHeader(raw) {
|
||||
case "registrable_domain":
|
||||
columns.Domain = index
|
||||
case "site_key":
|
||||
columns.Key = index
|
||||
case "site_name":
|
||||
columns.Name = index
|
||||
case "is_active":
|
||||
columns.Active = index
|
||||
}
|
||||
}
|
||||
if columns.Domain < 0 || columns.Name < 0 {
|
||||
return columns, response.ErrBadRequest(
|
||||
40056,
|
||||
"invalid_site_domain_mapping_csv_header",
|
||||
"CSV 表头至少需要 registrable_domain/site_name,或中文表头:匹配域名/中文名",
|
||||
)
|
||||
}
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
func normalizeSiteDomainMappingCSVHeader(header string) string {
|
||||
key := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(header, "\ufeff")))
|
||||
key = strings.ReplaceAll(key, " ", "_")
|
||||
key = strings.ReplaceAll(key, "-", "_")
|
||||
switch key {
|
||||
case "registrable_domain", "domain", "matched_domain", "match_domain", "host", "域名", "匹配域名", "站点域名":
|
||||
return "registrable_domain"
|
||||
case "site_key", "key", "sitekey", "站点key", "站点_key":
|
||||
return "site_key"
|
||||
case "site_name", "sitename", "name", "中文名", "站点名", "站点名称", "网站名称":
|
||||
return "site_name"
|
||||
case "is_active", "active", "enabled", "status", "状态", "启用", "是否启用":
|
||||
return "is_active"
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func siteDomainMappingInputFromCSVRecord(record []string, columns siteDomainMappingImportColumns) (SiteDomainMappingInput, error) {
|
||||
active := true
|
||||
if columns.Active >= 0 {
|
||||
parsed, err := parseSiteDomainMappingActive(csvCell(record, columns.Active))
|
||||
if err != nil {
|
||||
return SiteDomainMappingInput{}, err
|
||||
}
|
||||
active = parsed
|
||||
}
|
||||
return SiteDomainMappingInput{
|
||||
RegistrableDomain: csvCell(record, columns.Domain),
|
||||
SiteKey: csvCell(record, columns.Key),
|
||||
SiteName: csvCell(record, columns.Name),
|
||||
IsActive: active,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSiteDomainMappingActive(value string) (bool, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
return true, nil
|
||||
}
|
||||
switch normalized {
|
||||
case "true", "1", "yes", "y", "enabled", "enable", "active", "启用", "是":
|
||||
return true, nil
|
||||
case "false", "0", "no", "n", "disabled", "disable", "inactive", "停用", "否":
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("状态只能填写启用/停用、true/false 或 1/0")
|
||||
}
|
||||
}
|
||||
|
||||
func csvCell(record []string, index int) string {
|
||||
if index < 0 || index >= len(record) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(record[index])
|
||||
}
|
||||
|
||||
func isBlankCSVRecord(record []string) bool {
|
||||
for _, value := range record {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func appendSiteDomainMappingImportError(result *SiteDomainMappingImportResult, row int, message string) {
|
||||
if message == "" {
|
||||
message = "导入失败"
|
||||
}
|
||||
result.Failed++
|
||||
appendSiteDomainMappingImportWarning(result, row, message)
|
||||
}
|
||||
|
||||
func appendSiteDomainMappingImportWarning(result *SiteDomainMappingImportResult, row int, message string) {
|
||||
if message == "" {
|
||||
message = "导入失败"
|
||||
}
|
||||
if len(result.Errors) < siteDomainMappingImportMaxErrors {
|
||||
result.Errors = append(result.Errors, SiteDomainMappingImportError{Row: row, Message: message})
|
||||
}
|
||||
}
|
||||
|
||||
func isDuplicateSiteDomainMapping(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
return errors.As(err, &pgErr) && pgErr.Code == "23505"
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSiteDomainMappingCSVColumnsAcceptChineseHeaders(t *testing.T) {
|
||||
columns, err := detectSiteDomainMappingImportColumns([]string{"匹配域名", "站点 Key", "中文名", "状态"})
|
||||
if err != nil {
|
||||
t.Fatalf("detectSiteDomainMappingImportColumns() error = %v", err)
|
||||
}
|
||||
if columns.Domain != 0 || columns.Key != 1 || columns.Name != 2 || columns.Active != 3 {
|
||||
t.Fatalf("columns = %+v, want domain/key/name/active indexes 0/1/2/3", columns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteDomainMappingCSVColumnsRequireDomainAndName(t *testing.T) {
|
||||
if _, err := detectSiteDomainMappingImportColumns([]string{"site_key", "is_active"}); err == nil {
|
||||
t.Fatal("detectSiteDomainMappingImportColumns() error = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteDomainMappingInputFromCSVRecordDefaultsAndParsesActive(t *testing.T) {
|
||||
columns := siteDomainMappingImportColumns{
|
||||
Domain: 0,
|
||||
Key: 1,
|
||||
Name: 2,
|
||||
Active: 3,
|
||||
}
|
||||
input, err := siteDomainMappingInputFromCSVRecord([]string{"https://www.example.com/path", "", "示例", "停用"}, columns)
|
||||
if err != nil {
|
||||
t.Fatalf("siteDomainMappingInputFromCSVRecord() error = %v", err)
|
||||
}
|
||||
normalized, err := normalizeSiteDomainMappingInput(input)
|
||||
if err != nil {
|
||||
t.Fatalf("normalizeSiteDomainMappingInput() error = %v", err)
|
||||
}
|
||||
if normalized.RegistrableDomain != "www.example.com" {
|
||||
t.Fatalf("RegistrableDomain = %q, want www.example.com", normalized.RegistrableDomain)
|
||||
}
|
||||
if normalized.SiteKey != "www.example.com" {
|
||||
t.Fatalf("SiteKey = %q, want default domain", normalized.SiteKey)
|
||||
}
|
||||
if normalized.SiteName != "示例" {
|
||||
t.Fatalf("SiteName = %q, want 示例", normalized.SiteName)
|
||||
}
|
||||
if normalized.IsActive {
|
||||
t.Fatal("IsActive = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSiteDomainMappingActiveRejectsUnknownStatus(t *testing.T) {
|
||||
if _, err := parseSiteDomainMappingActive("待定"); err == nil {
|
||||
t.Fatal("parseSiteDomainMappingActive() error = nil, want error")
|
||||
}
|
||||
}
|
||||
@@ -504,6 +504,11 @@ func applyOpsObjectStorageEnvOverrides(cfg *Config) {
|
||||
if region := firstNonEmptyEnv("OPS_OBJECT_STORAGE_REGION", "OBJECT_STORAGE_REGION"); region != "" {
|
||||
cfg.ObjectStorage.Region = region
|
||||
}
|
||||
if v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_FORCE_PATH_STYLE", "OBJECT_STORAGE_FORCE_PATH_STYLE"); v != "" {
|
||||
if forcePathStyle, err := parseBoolEnv("OPS_OBJECT_STORAGE_FORCE_PATH_STYLE", v); err == nil {
|
||||
cfg.ObjectStorage.ForcePathStyle = forcePathStyle
|
||||
}
|
||||
}
|
||||
if v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_USE_SSL", "OBJECT_STORAGE_USE_SSL"); v != "" {
|
||||
if useSSL, err := parseBoolEnv("OPS_OBJECT_STORAGE_USE_SSL", v); err == nil {
|
||||
cfg.ObjectStorage.UseSSL = useSSL
|
||||
|
||||
@@ -44,6 +44,18 @@ type UpdateSiteDomainMappingInput struct {
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
type UpsertSiteDomainMappingInput struct {
|
||||
RegistrableDomain string
|
||||
SiteKey string
|
||||
SiteName string
|
||||
IsActive bool
|
||||
}
|
||||
|
||||
type UpsertSiteDomainMappingResult struct {
|
||||
Item *domain.SiteDomainMapping
|
||||
Created bool
|
||||
}
|
||||
|
||||
func scanSiteDomainMapping(row pgx.Row) (*domain.SiteDomainMapping, error) {
|
||||
var item domain.SiteDomainMapping
|
||||
err := row.Scan(
|
||||
@@ -64,7 +76,29 @@ func scanSiteDomainMapping(row pgx.Row) (*domain.SiteDomainMapping, error) {
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
func (r *SiteDomainMappingRepository) List(ctx context.Context, f SiteDomainMappingFilter) ([]domain.SiteDomainMapping, int64, error) {
|
||||
func scanSiteDomainMappingUpsert(row pgx.Row) (*UpsertSiteDomainMappingResult, error) {
|
||||
var item domain.SiteDomainMapping
|
||||
var created bool
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.RegistrableDomain,
|
||||
&item.SiteKey,
|
||||
&item.SiteName,
|
||||
&item.IsActive,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
&created,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrSiteDomainMappingNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &UpsertSiteDomainMappingResult{Item: &item, Created: created}, nil
|
||||
}
|
||||
|
||||
func buildSiteDomainMappingWhere(f SiteDomainMappingFilter) (string, []any) {
|
||||
args := []any{}
|
||||
where := "1=1"
|
||||
|
||||
@@ -78,6 +112,12 @@ func (r *SiteDomainMappingRepository) List(ctx context.Context, f SiteDomainMapp
|
||||
where += fmt.Sprintf(" AND is_active = $%d", len(args))
|
||||
}
|
||||
|
||||
return where, args
|
||||
}
|
||||
|
||||
func (r *SiteDomainMappingRepository) List(ctx context.Context, f SiteDomainMappingFilter) ([]domain.SiteDomainMapping, int64, error) {
|
||||
where, args := buildSiteDomainMappingWhere(f)
|
||||
|
||||
var total int64
|
||||
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM site_domain_mappings WHERE "+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
@@ -117,6 +157,37 @@ func (r *SiteDomainMappingRepository) List(ctx context.Context, f SiteDomainMapp
|
||||
return items, total, rows.Err()
|
||||
}
|
||||
|
||||
func (r *SiteDomainMappingRepository) Export(ctx context.Context, f SiteDomainMappingFilter) ([]domain.SiteDomainMapping, error) {
|
||||
where, args := buildSiteDomainMappingWhere(f)
|
||||
limit := f.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10000
|
||||
}
|
||||
args = append(args, limit)
|
||||
limitArg := len(args)
|
||||
|
||||
rows, err := r.pool.Query(ctx, fmt.Sprintf(`
|
||||
SELECT %s
|
||||
FROM site_domain_mappings
|
||||
WHERE %s
|
||||
ORDER BY is_active DESC, registrable_domain ASC, id DESC
|
||||
LIMIT $%d`, siteDomainMappingColumns, where, limitArg), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]domain.SiteDomainMapping, 0, limit)
|
||||
for rows.Next() {
|
||||
item, scanErr := scanSiteDomainMapping(rows)
|
||||
if scanErr != nil {
|
||||
return nil, scanErr
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (r *SiteDomainMappingRepository) GetByID(ctx context.Context, id int64) (*domain.SiteDomainMapping, error) {
|
||||
return scanSiteDomainMapping(r.pool.QueryRow(ctx, `
|
||||
SELECT `+siteDomainMappingColumns+`
|
||||
@@ -145,6 +216,19 @@ func (r *SiteDomainMappingRepository) Update(ctx context.Context, id int64, in U
|
||||
in.RegistrableDomain, in.SiteKey, in.SiteName, in.IsActive, id))
|
||||
}
|
||||
|
||||
func (r *SiteDomainMappingRepository) Upsert(ctx context.Context, in UpsertSiteDomainMappingInput) (*UpsertSiteDomainMappingResult, error) {
|
||||
return scanSiteDomainMappingUpsert(r.pool.QueryRow(ctx, `
|
||||
INSERT INTO site_domain_mappings (registrable_domain, site_key, site_name, is_active)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (registrable_domain) DO UPDATE
|
||||
SET site_key = EXCLUDED.site_key,
|
||||
site_name = EXCLUDED.site_name,
|
||||
is_active = EXCLUDED.is_active,
|
||||
updated_at = NOW()
|
||||
RETURNING `+siteDomainMappingColumns+`, xmax = 0`,
|
||||
in.RegistrableDomain, in.SiteKey, in.SiteName, in.IsActive))
|
||||
}
|
||||
|
||||
func (r *SiteDomainMappingRepository) SetActive(ctx context.Context, id int64, active bool) (*domain.SiteDomainMapping, error) {
|
||||
return scanSiteDomainMapping(r.pool.QueryRow(ctx, `
|
||||
UPDATE site_domain_mappings
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/app"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
)
|
||||
|
||||
const (
|
||||
maxObjectStorageUploadBytes = 500 * 1024 * 1024
|
||||
maxObjectStorageMultipartBytes = maxObjectStorageUploadBytes + 1*1024*1024
|
||||
)
|
||||
|
||||
type moveObjectStorageObjectRequest struct {
|
||||
SourceKey string `json:"source_key" binding:"required"`
|
||||
DestinationKey string `json:"destination_key" binding:"required"`
|
||||
Overwrite bool `json:"overwrite"`
|
||||
DeleteSource *bool `json:"delete_source"`
|
||||
}
|
||||
|
||||
type createObjectStorageFolderRequest struct {
|
||||
Prefix string `json:"prefix" binding:"required"`
|
||||
}
|
||||
|
||||
func listObjectStorageObjectsHandler(svc *app.ObjectStorageService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
size, _ := strconv.Atoi(c.DefaultQuery("size", "100"))
|
||||
result, err := svc.List(c.Request.Context(), app.ObjectStorageListInput{
|
||||
Prefix: c.Query("prefix"),
|
||||
Keyword: c.Query("keyword"),
|
||||
ContinuationToken: c.Query("continuation_token"),
|
||||
StartAfter: c.Query("start_after"),
|
||||
Recursive: c.Query("recursive") == "true",
|
||||
Size: size,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func getObjectStorageUsageHandler(svc *app.ObjectStorageService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := svc.Usage(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func getObjectStorageObjectHandler(svc *app.ObjectStorageService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
result, err := svc.Get(c.Request.Context(), c.Query("key"))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func resolveObjectStorageObjectURLHandler(svc *app.ObjectStorageService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
urlValue, err := svc.ResolvePublicURL(c.Request.Context(), c.Query("key"))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"url": urlValue})
|
||||
}
|
||||
}
|
||||
|
||||
func resolveObjectStorageObjectDownloadURLHandler(svc *app.ObjectStorageService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
urlValue, err := svc.ResolveDownloadURL(c.Request.Context(), c.Query("key"))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"url": urlValue})
|
||||
}
|
||||
}
|
||||
|
||||
func downloadObjectStorageObjectHandler(svc *app.ObjectStorageService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
content, info, err := svc.Download(c.Request.Context(), key)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Content-Disposition", objectStorageDownloadDisposition(key))
|
||||
c.Data(http.StatusOK, objectStorageDownloadContentType(info, content), content)
|
||||
}
|
||||
}
|
||||
|
||||
func uploadObjectStorageObjectHandler(svc *app.ObjectStorageService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.ContentLength > maxObjectStorageMultipartBytes {
|
||||
response.Error(c, response.ErrBadRequest(40095, "object_storage_file_too_large", "单个对象不能超过 500MB"))
|
||||
return
|
||||
}
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxObjectStorageMultipartBytes)
|
||||
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "request body too large") {
|
||||
response.Error(c, response.ErrBadRequest(40095, "object_storage_file_too_large", "单个对象不能超过 500MB"))
|
||||
return
|
||||
}
|
||||
response.Error(c, response.ErrBadRequest(40094, "object_storage_file_required", "请选择要上传的文件"))
|
||||
return
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40094, "object_storage_file_open_failed", "文件读取失败"))
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
content, err := io.ReadAll(io.LimitReader(file, maxObjectStorageUploadBytes+1))
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40094, "object_storage_file_read_failed", "文件读取失败"))
|
||||
return
|
||||
}
|
||||
if len(content) > maxObjectStorageUploadBytes {
|
||||
response.Error(c, response.ErrBadRequest(40095, "object_storage_file_too_large", "单个对象不能超过 500MB"))
|
||||
return
|
||||
}
|
||||
result, err := svc.Upload(c.Request.Context(), actorFromGin(c), app.ObjectStorageUploadInput{
|
||||
ObjectKey: c.PostForm("key"),
|
||||
FileName: fileHeader.Filename,
|
||||
Content: content,
|
||||
ContentType: c.PostForm("content_type"),
|
||||
Overwrite: c.PostForm("overwrite") == "true",
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func moveObjectStorageObjectHandler(svc *app.ObjectStorageService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body moveObjectStorageObjectRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
deleteSource := true
|
||||
if body.DeleteSource != nil {
|
||||
deleteSource = *body.DeleteSource
|
||||
}
|
||||
result, err := svc.Move(c.Request.Context(), actorFromGin(c), app.ObjectStorageMoveInput{
|
||||
SourceKey: body.SourceKey,
|
||||
DestinationKey: body.DestinationKey,
|
||||
Overwrite: body.Overwrite,
|
||||
DeleteSource: deleteSource,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteObjectStorageObjectHandler(svc *app.ObjectStorageService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := c.Query("key")
|
||||
if err := svc.Delete(c.Request.Context(), actorFromGin(c), key); err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"key": key})
|
||||
}
|
||||
}
|
||||
|
||||
func createObjectStorageFolderHandler(svc *app.ObjectStorageService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body createObjectStorageFolderRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
|
||||
return
|
||||
}
|
||||
result, err := svc.CreateFolder(c.Request.Context(), actorFromGin(c), app.ObjectStorageFolderCreateInput{
|
||||
Prefix: body.Prefix,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func deleteObjectStorageFolderHandler(svc *app.ObjectStorageService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
prefix := c.Query("prefix")
|
||||
result, err := svc.DeleteFolder(c.Request.Context(), actorFromGin(c), prefix)
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func objectStorageDownloadDisposition(key string) string {
|
||||
return objectstorage.DownloadContentDisposition(key)
|
||||
}
|
||||
|
||||
func objectStorageDownloadContentType(info *objectstorage.ObjectInfo, content []byte) string {
|
||||
if info != nil && strings.TrimSpace(info.ContentType) != "" {
|
||||
return info.ContentType
|
||||
}
|
||||
if len(content) > 0 {
|
||||
return http.DetectContentType(content)
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
@@ -9,25 +9,27 @@ import (
|
||||
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/swagger"
|
||||
)
|
||||
|
||||
type Deps struct {
|
||||
Engine *gin.Engine
|
||||
Server sharedconfig.ServerConfig
|
||||
Logger *zap.Logger
|
||||
DB *pgxpool.Pool
|
||||
MonitoringDB *pgxpool.Pool
|
||||
Issuer *app.TokenIssuer
|
||||
Auth *app.AuthService
|
||||
Accounts *app.AccountService
|
||||
AdminUsers *app.AdminUserService
|
||||
Jobs *app.JobService
|
||||
KolSubs *app.KolSubscriptionService
|
||||
Audits *app.AuditService
|
||||
SiteDomains *app.SiteDomainMappingService
|
||||
Releases *app.DesktopClientReleaseService
|
||||
Compliance *app.ComplianceService
|
||||
Scheduler *app.SchedulerService
|
||||
Engine *gin.Engine
|
||||
Server sharedconfig.ServerConfig
|
||||
Logger *zap.Logger
|
||||
DB *pgxpool.Pool
|
||||
MonitoringDB *pgxpool.Pool
|
||||
Issuer *app.TokenIssuer
|
||||
Auth *app.AuthService
|
||||
Accounts *app.AccountService
|
||||
AdminUsers *app.AdminUserService
|
||||
Jobs *app.JobService
|
||||
KolSubs *app.KolSubscriptionService
|
||||
Audits *app.AuditService
|
||||
SiteDomains *app.SiteDomainMappingService
|
||||
Releases *app.DesktopClientReleaseService
|
||||
ObjectStorage *app.ObjectStorageService
|
||||
Compliance *app.ComplianceService
|
||||
Scheduler *app.SchedulerService
|
||||
}
|
||||
|
||||
func (d Deps) ServerAllowedOrigins() []string {
|
||||
@@ -132,11 +134,25 @@ func RegisterRoutes(d Deps) {
|
||||
authed.GET("/scheduler/instances", listSchedulerInstancesHandler(d.Scheduler))
|
||||
|
||||
authed.GET("/site-domain-mappings", listSiteDomainMappingsHandler(d.SiteDomains))
|
||||
authed.GET("/site-domain-mappings/export", exportSiteDomainMappingsHandler(d.SiteDomains))
|
||||
authed.POST("/site-domain-mappings/import", importSiteDomainMappingsHandler(d.SiteDomains))
|
||||
authed.POST("/site-domain-mappings", createSiteDomainMappingHandler(d.SiteDomains))
|
||||
authed.PATCH("/site-domain-mappings/:id", updateSiteDomainMappingHandler(d.SiteDomains))
|
||||
authed.POST("/site-domain-mappings/:id/active", setSiteDomainMappingActiveHandler(d.SiteDomains))
|
||||
authed.DELETE("/site-domain-mappings/:id", deleteSiteDomainMappingHandler(d.SiteDomains))
|
||||
|
||||
authed.GET("/object-storage/objects", listObjectStorageObjectsHandler(d.ObjectStorage))
|
||||
authed.GET("/object-storage/usage", getObjectStorageUsageHandler(d.ObjectStorage))
|
||||
authed.GET("/object-storage/objects/detail", getObjectStorageObjectHandler(d.ObjectStorage))
|
||||
authed.GET("/object-storage/objects/url", resolveObjectStorageObjectURLHandler(d.ObjectStorage))
|
||||
authed.GET("/object-storage/objects/download-url", resolveObjectStorageObjectDownloadURLHandler(d.ObjectStorage))
|
||||
authed.GET("/object-storage/objects/download", downloadObjectStorageObjectHandler(d.ObjectStorage))
|
||||
authed.POST("/object-storage/objects/upload", uploadObjectStorageObjectHandler(d.ObjectStorage))
|
||||
authed.POST("/object-storage/objects/move", moveObjectStorageObjectHandler(d.ObjectStorage))
|
||||
authed.DELETE("/object-storage/objects", deleteObjectStorageObjectHandler(d.ObjectStorage))
|
||||
authed.POST("/object-storage/folders", createObjectStorageFolderHandler(d.ObjectStorage))
|
||||
authed.DELETE("/object-storage/folders", deleteObjectStorageFolderHandler(d.ObjectStorage))
|
||||
|
||||
authed.GET("/desktop-client/releases", listDesktopClientReleasesHandler(d.Releases))
|
||||
authed.POST("/desktop-client/releases/upload", uploadDesktopClientReleaseHandler(d.Releases))
|
||||
authed.POST("/desktop-client/releases", createDesktopClientReleaseHandler(d.Releases))
|
||||
@@ -166,4 +182,11 @@ func RegisterRoutes(d Deps) {
|
||||
compliance.GET("/stats", complianceStatsHandler(d.Compliance))
|
||||
compliance.GET("/records", listComplianceRecordsHandler(d.Compliance))
|
||||
}
|
||||
|
||||
if swagger.EnabledForMode(d.Server.Mode) {
|
||||
swagger.Register(d.Engine, swagger.Config{
|
||||
Title: "省心推 Ops API",
|
||||
Version: "dev",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
package transport
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -38,6 +42,58 @@ func listSiteDomainMappingsHandler(svc *app.SiteDomainMappingService) gin.Handle
|
||||
}
|
||||
}
|
||||
|
||||
func exportSiteDomainMappingsHandler(svc *app.SiteDomainMappingService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
data, err := svc.ExportCSV(c.Request.Context(), actorFromGin(c), app.SiteDomainMappingListInput{
|
||||
Keyword: c.Query("keyword"),
|
||||
IsActive: parseOptionalBoolQuery(c.Query("is_active")),
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
fileName := fmt.Sprintf("site-domain-mappings-%s.csv", time.Now().Format("20060102150405"))
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName))
|
||||
c.Data(http.StatusOK, "text/csv; charset=utf-8", data)
|
||||
}
|
||||
}
|
||||
|
||||
func importSiteDomainMappingsHandler(svc *app.SiteDomainMappingService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.Error(c, response.ErrBadRequest(40057, "missing_site_domain_mapping_import_file", "请选择 CSV 文件"))
|
||||
return
|
||||
}
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
content, err := io.ReadAll(io.LimitReader(file, 2*1024*1024+1))
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
if len(content) > 2*1024*1024 {
|
||||
response.Error(c, response.ErrBadRequest(40058, "site_domain_mapping_import_file_too_large", "CSV 文件不能超过 2MB"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := svc.ImportCSV(c.Request.Context(), actorFromGin(c), app.SiteDomainMappingImportInput{
|
||||
Content: content,
|
||||
})
|
||||
if err != nil {
|
||||
response.Error(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, result)
|
||||
}
|
||||
}
|
||||
|
||||
func createSiteDomainMappingHandler(svc *app.SiteDomainMappingService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var body siteDomainMappingRequest
|
||||
|
||||
@@ -8,12 +8,15 @@ import (
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
)
|
||||
|
||||
const MaxMultipartMemory = 8 << 20
|
||||
|
||||
func NewEngine(server config.ServerConfig) (*gin.Engine, error) {
|
||||
config.NormalizeServerConfig(&server)
|
||||
if server.Mode == "release" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
engine := gin.New()
|
||||
engine.MaxMultipartMemory = MaxMultipartMemory
|
||||
engine.ForwardedByClientIP = true
|
||||
engine.RemoteIPHeaders = []string{"X-Forwarded-For", "X-Real-IP"}
|
||||
if err := engine.SetTrustedProxies(server.TrustedProxies); err != nil {
|
||||
|
||||
@@ -20,6 +20,7 @@ func TestNewEngineUsesForwardedClientIPFromTrustedProxy(t *testing.T) {
|
||||
TrustedProxies: []string{"10.42.0.0/16"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(MaxMultipartMemory), engine.MaxMultipartMemory)
|
||||
|
||||
engine.GET("/ip", func(c *gin.Context) {
|
||||
c.String(http.StatusOK, c.ClientIP())
|
||||
|
||||
@@ -285,17 +285,18 @@ type QdrantConfig struct {
|
||||
}
|
||||
|
||||
type ObjectStorageConfig struct {
|
||||
Provider string `mapstructure:"provider"`
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
UseSSL bool `mapstructure:"use_ssl"`
|
||||
PublicBaseURL string `mapstructure:"public_base_url"`
|
||||
BucketACL string `mapstructure:"bucket_acl"`
|
||||
ObjectACL string `mapstructure:"object_acl"`
|
||||
SignedURLTTL time.Duration `mapstructure:"signed_url_ttl"`
|
||||
Region string `mapstructure:"region"`
|
||||
Provider string `mapstructure:"provider"`
|
||||
Endpoint string `mapstructure:"endpoint"`
|
||||
AccessKey string `mapstructure:"access_key"`
|
||||
SecretKey string `mapstructure:"secret_key"`
|
||||
Bucket string `mapstructure:"bucket"`
|
||||
UseSSL bool `mapstructure:"use_ssl"`
|
||||
PublicBaseURL string `mapstructure:"public_base_url"`
|
||||
BucketACL string `mapstructure:"bucket_acl"`
|
||||
ObjectACL string `mapstructure:"object_acl"`
|
||||
SignedURLTTL time.Duration `mapstructure:"signed_url_ttl"`
|
||||
Region string `mapstructure:"region"`
|
||||
ForcePathStyle bool `mapstructure:"force_path_style"`
|
||||
}
|
||||
|
||||
type JWTConfig struct {
|
||||
@@ -971,6 +972,9 @@ func applyEnvOverrides(cfg *Config) {
|
||||
if region, ok := lookupNonEmptyEnv("OBJECT_STORAGE_REGION"); ok {
|
||||
cfg.ObjectStorage.Region = region
|
||||
}
|
||||
if forcePathStyle, ok := lookupBoolEnv("OBJECT_STORAGE_FORCE_PATH_STYLE"); ok {
|
||||
cfg.ObjectStorage.ForcePathStyle = forcePathStyle
|
||||
}
|
||||
if useSSL, ok := lookupBoolEnv("OBJECT_STORAGE_USE_SSL"); ok {
|
||||
cfg.ObjectStorage.UseSSL = useSSL
|
||||
}
|
||||
|
||||
@@ -112,12 +112,14 @@ func TestLoadAppliesObjectStorageEnvOverrides(t *testing.T) {
|
||||
t.Setenv("OBJECT_STORAGE_BUCKET_ACL", "private")
|
||||
t.Setenv("OBJECT_STORAGE_OBJECT_ACL", "public-read")
|
||||
t.Setenv("OBJECT_STORAGE_SIGNED_URL_TTL", "45m")
|
||||
t.Setenv("OBJECT_STORAGE_FORCE_PATH_STYLE", "true")
|
||||
|
||||
configPath := writeTestConfig(t, `
|
||||
object_storage:
|
||||
bucket_acl: public-read
|
||||
object_acl: private
|
||||
signed_url_ttl: 10m
|
||||
force_path_style: false
|
||||
`)
|
||||
|
||||
cfg, err := Load(configPath)
|
||||
@@ -134,6 +136,9 @@ object_storage:
|
||||
if cfg.ObjectStorage.SignedURLTTL != 45*time.Minute {
|
||||
t.Fatalf("expected env signed url ttl, got %s", cfg.ObjectStorage.SignedURLTTL)
|
||||
}
|
||||
if !cfg.ObjectStorage.ForcePathStyle {
|
||||
t.Fatal("expected env force path style true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAppliesBrandLibraryDefaults(t *testing.T) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -296,6 +298,180 @@ func (c *aliyunClient) PublicURL(objectKey string) (string, error) {
|
||||
return buildPublicURL(c.cfg, objectKey, true)
|
||||
}
|
||||
|
||||
func (c *aliyunClient) PublicReadableURL(objectKey string) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
publicRead, err := c.isPublicReadable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !publicRead {
|
||||
return "", ErrObjectNotPublic
|
||||
}
|
||||
return buildPublicURL(c.cfg, objectKey, true)
|
||||
}
|
||||
|
||||
func (c *aliyunClient) DownloadURL(objectKey string) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return c.signedURL(objectKey, oss.ResponseContentDisposition(downloadContentDisposition(objectKey)))
|
||||
}
|
||||
|
||||
func (c *aliyunClient) Usage(ctx context.Context) (*UsageInfo, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stat, err := c.client.GetBucketStat(c.bucket)
|
||||
if err != nil {
|
||||
c.logError(ctx, "aliyun get bucket stat failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("get aliyun bucket stat: %w", err)
|
||||
}
|
||||
return &UsageInfo{
|
||||
Available: true,
|
||||
Provider: "aliyun",
|
||||
StorageSize: stat.Storage,
|
||||
ObjectCount: stat.ObjectCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *aliyunClient) List(ctx context.Context, in ListInput) (*ListResult, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bucket, err := c.client.Bucket(c.bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get aliyun bucket: %w", err)
|
||||
}
|
||||
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
delimiter := strings.TrimSpace(in.Delimiter)
|
||||
if delimiter == "" && !in.Recursive {
|
||||
delimiter = "/"
|
||||
}
|
||||
options := []oss.Option{
|
||||
oss.Prefix(normalizeObjectKeyPath(in.Prefix)),
|
||||
oss.MaxKeys(limit),
|
||||
}
|
||||
if delimiter != "" {
|
||||
options = append(options, oss.Delimiter(delimiter))
|
||||
}
|
||||
if token := strings.TrimSpace(in.ContinuationToken); token != "" {
|
||||
options = append(options, oss.ContinuationToken(token))
|
||||
}
|
||||
if startAfter := normalizeObjectKeyPath(in.StartAfter); startAfter != "" {
|
||||
options = append(options, oss.StartAfter(startAfter))
|
||||
}
|
||||
|
||||
result, err := bucket.ListObjectsV2(options...)
|
||||
if err != nil {
|
||||
c.logError(ctx, "aliyun list objects failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("prefix", in.Prefix),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("list aliyun objects: %w", err)
|
||||
}
|
||||
|
||||
out := &ListResult{
|
||||
Objects: make([]ObjectInfo, 0, len(result.Objects)),
|
||||
CommonPrefixes: append([]string(nil), result.CommonPrefixes...),
|
||||
ContinuationToken: result.ContinuationToken,
|
||||
NextContinuationToken: result.NextContinuationToken,
|
||||
IsTruncated: result.IsTruncated,
|
||||
Prefix: result.Prefix,
|
||||
Delimiter: result.Delimiter,
|
||||
}
|
||||
for _, item := range result.Objects {
|
||||
out.Objects = append(out.Objects, ObjectInfo{
|
||||
Key: item.Key,
|
||||
Size: item.Size,
|
||||
ETag: strings.Trim(item.ETag, `"`),
|
||||
LastModified: item.LastModified,
|
||||
StorageClass: item.StorageClass,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *aliyunClient) Stat(ctx context.Context, objectKey string) (*ObjectInfo, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objectKey = normalizeObjectKeyPath(objectKey)
|
||||
if objectKey == "" {
|
||||
return nil, fmt.Errorf("object key is required")
|
||||
}
|
||||
bucket, err := c.client.Bucket(c.bucket)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get aliyun bucket: %w", err)
|
||||
}
|
||||
header, err := bucket.GetObjectDetailedMeta(objectKey)
|
||||
if err != nil {
|
||||
c.logError(ctx, "aliyun stat object failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("object_key", objectKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
if isAliyunObjectNotFound(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, objectKey)
|
||||
}
|
||||
return nil, fmt.Errorf("stat aliyun object: %w", err)
|
||||
}
|
||||
return aliyunHeaderToObjectInfo(objectKey, header), nil
|
||||
}
|
||||
|
||||
func (c *aliyunClient) Copy(ctx context.Context, sourceKey, destinationKey string) error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceKey = normalizeObjectKeyPath(sourceKey)
|
||||
destinationKey = normalizeObjectKeyPath(destinationKey)
|
||||
if sourceKey == "" || destinationKey == "" {
|
||||
return fmt.Errorf("source and destination object keys are required")
|
||||
}
|
||||
if sourceKey == destinationKey {
|
||||
return nil
|
||||
}
|
||||
bucket, err := c.client.Bucket(c.bucket)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get aliyun bucket: %w", err)
|
||||
}
|
||||
if _, err := bucket.CopyObject(sourceKey, destinationKey); err != nil {
|
||||
c.logError(ctx, "aliyun copy object failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("source_key", sourceKey),
|
||||
zap.String("destination_key", destinationKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
if isAliyunObjectNotFound(err) {
|
||||
return fmt.Errorf("%w: %s", ErrObjectNotFound, sourceKey)
|
||||
}
|
||||
return fmt.Errorf("copy aliyun object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func aliyunHeaderToObjectInfo(objectKey string, header http.Header) *ObjectInfo {
|
||||
size, _ := strconv.ParseInt(strings.TrimSpace(header.Get(oss.HTTPHeaderContentLength)), 10, 64)
|
||||
lastModified, _ := http.ParseTime(header.Get(oss.HTTPHeaderLastModified))
|
||||
return &ObjectInfo{
|
||||
Key: objectKey,
|
||||
Size: size,
|
||||
ETag: strings.Trim(header.Get(oss.HTTPHeaderEtag), `"`),
|
||||
LastModified: lastModified,
|
||||
ContentType: header.Get(oss.HTTPHeaderContentType),
|
||||
StorageClass: header.Get(oss.HTTPHeaderOssStorageClass),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *aliyunClient) isPublicReadable() (bool, error) {
|
||||
objectACL := strings.ToLower(strings.TrimSpace(c.cfg.ObjectACL))
|
||||
if isPublicReadACL(objectACL) {
|
||||
@@ -317,7 +493,7 @@ func (c *aliyunClient) isPublicReadable() (bool, error) {
|
||||
return isPublicReadACL(result.ACL), nil
|
||||
}
|
||||
|
||||
func (c *aliyunClient) signedURL(objectKey string) (string, error) {
|
||||
func (c *aliyunClient) signedURL(objectKey string, options ...oss.Option) (string, error) {
|
||||
objectKey = normalizeObjectKeyPath(objectKey)
|
||||
if objectKey == "" {
|
||||
return "", fmt.Errorf("object key is required")
|
||||
@@ -330,7 +506,7 @@ func (c *aliyunClient) signedURL(objectKey string) (string, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
return bucket.SignURL(objectKey, oss.HTTPGet, int64(ttl.Seconds()))
|
||||
return bucket.SignURL(objectKey, oss.HTTPGet, int64(ttl.Seconds()), options...)
|
||||
}
|
||||
|
||||
func isPublicReadACL(acl string) bool {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
|
||||
var ErrNotConfigured = errors.New("object storage is not configured")
|
||||
var ErrObjectNotFound = errors.New("object storage object not found")
|
||||
var ErrObjectNotPublic = errors.New("object storage object is not publicly readable")
|
||||
var ErrUsageUnsupported = errors.New("object storage usage stat is not supported")
|
||||
|
||||
type Client interface {
|
||||
Validate() error
|
||||
@@ -21,6 +24,57 @@ type Client interface {
|
||||
Exists(ctx context.Context, objectKey string) (bool, error)
|
||||
Delete(ctx context.Context, objectKey string) error
|
||||
PublicURL(objectKey string) (string, error)
|
||||
DownloadURL(objectKey string) (string, error)
|
||||
}
|
||||
|
||||
type ManagementClient interface {
|
||||
Client
|
||||
List(ctx context.Context, in ListInput) (*ListResult, error)
|
||||
Stat(ctx context.Context, objectKey string) (*ObjectInfo, error)
|
||||
Copy(ctx context.Context, sourceKey, destinationKey string) error
|
||||
}
|
||||
|
||||
type UsageClient interface {
|
||||
Usage(ctx context.Context) (*UsageInfo, error)
|
||||
}
|
||||
|
||||
type PublicReadableClient interface {
|
||||
PublicReadableURL(objectKey string) (string, error)
|
||||
}
|
||||
|
||||
type ListInput struct {
|
||||
Prefix string
|
||||
ContinuationToken string
|
||||
StartAfter string
|
||||
Delimiter string
|
||||
Limit int
|
||||
Recursive bool
|
||||
}
|
||||
|
||||
type ListResult struct {
|
||||
Objects []ObjectInfo `json:"objects"`
|
||||
CommonPrefixes []string `json:"common_prefixes"`
|
||||
ContinuationToken string `json:"continuation_token,omitempty"`
|
||||
NextContinuationToken string `json:"next_continuation_token,omitempty"`
|
||||
IsTruncated bool `json:"is_truncated"`
|
||||
Prefix string `json:"prefix"`
|
||||
Delimiter string `json:"delimiter"`
|
||||
}
|
||||
|
||||
type ObjectInfo struct {
|
||||
Key string `json:"key"`
|
||||
Size int64 `json:"size"`
|
||||
ETag string `json:"etag,omitempty"`
|
||||
LastModified time.Time `json:"last_modified"`
|
||||
ContentType string `json:"content_type,omitempty"`
|
||||
StorageClass string `json:"storage_class,omitempty"`
|
||||
}
|
||||
|
||||
type UsageInfo struct {
|
||||
Available bool `json:"available"`
|
||||
Provider string `json:"provider"`
|
||||
StorageSize int64 `json:"storage_size"`
|
||||
ObjectCount int64 `json:"object_count"`
|
||||
}
|
||||
|
||||
func New(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
||||
@@ -28,7 +82,7 @@ func New(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
||||
switch provider {
|
||||
case "", "disabled":
|
||||
return disabledClient{reason: "object storage is disabled"}
|
||||
case "minio", "mino":
|
||||
case "minio", "mino", "r2", "cloudflare_r2", "cloudflare-r2", "s3", "aws_s3", "aws-s3", "custom", "custom_s3", "custom-s3":
|
||||
return NewMinIOClient(cfg, logger)
|
||||
case "aliyun", "aliyun_oss", "aliyun-oss", "oss":
|
||||
return NewAliyunClient(cfg, logger)
|
||||
@@ -67,3 +121,23 @@ func (c disabledClient) Delete(context.Context, string) error {
|
||||
func (c disabledClient) PublicURL(string) (string, error) {
|
||||
return "", c.Validate()
|
||||
}
|
||||
|
||||
func (c disabledClient) DownloadURL(string) (string, error) {
|
||||
return "", c.Validate()
|
||||
}
|
||||
|
||||
func (c disabledClient) List(context.Context, ListInput) (*ListResult, error) {
|
||||
return nil, c.Validate()
|
||||
}
|
||||
|
||||
func (c disabledClient) Stat(context.Context, string) (*ObjectInfo, error) {
|
||||
return nil, c.Validate()
|
||||
}
|
||||
|
||||
func (c disabledClient) Copy(context.Context, string, string) error {
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
func (c disabledClient) Usage(context.Context) (*UsageInfo, error) {
|
||||
return nil, c.Validate()
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/minio/minio-go/v7"
|
||||
"github.com/minio/minio-go/v7/pkg/credentials"
|
||||
@@ -32,10 +34,17 @@ func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
||||
return disabledClient{reason: "missing object_storage minio endpoint/access_key/secret_key/bucket"}
|
||||
}
|
||||
|
||||
region := normalizeMinIORegion(cfg)
|
||||
bucketLookup := minio.BucketLookupAuto
|
||||
if cfg.ForcePathStyle || isPathStyleProvider(cfg.Provider) {
|
||||
bucketLookup = minio.BucketLookupPath
|
||||
}
|
||||
|
||||
client, err := minio.New(endpoint, &minio.Options{
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Region: strings.TrimSpace(cfg.Region),
|
||||
Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
|
||||
Secure: cfg.UseSSL,
|
||||
Region: region,
|
||||
BucketLookup: bucketLookup,
|
||||
})
|
||||
if err != nil {
|
||||
return disabledClient{reason: fmt.Sprintf("init minio client failed: %v", err)}
|
||||
@@ -44,12 +53,41 @@ func NewMinIOClient(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
|
||||
return &minioClient{
|
||||
client: client,
|
||||
bucket: bucket,
|
||||
region: strings.TrimSpace(cfg.Region),
|
||||
region: region,
|
||||
logger: logger,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeMinIORegion(cfg config.ObjectStorageConfig) string {
|
||||
region := strings.TrimSpace(cfg.Region)
|
||||
if region != "" {
|
||||
return region
|
||||
}
|
||||
if isR2Provider(cfg.Provider) {
|
||||
return "auto"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isR2Provider(provider string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "r2", "cloudflare_r2", "cloudflare-r2":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isPathStyleProvider(provider string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "r2", "cloudflare_r2", "cloudflare-r2":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (c *minioClient) Validate() error {
|
||||
if c == nil || c.client == nil {
|
||||
return fmt.Errorf("%w: minio client is not initialized", ErrNotConfigured)
|
||||
@@ -198,9 +236,167 @@ func (c *minioClient) PublicURL(objectKey string) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !c.isPublicReadable() {
|
||||
return c.DownloadURL(objectKey)
|
||||
}
|
||||
return buildPublicURL(c.cfg, objectKey, false)
|
||||
}
|
||||
|
||||
func (c *minioClient) PublicReadableURL(objectKey string) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !c.isPublicReadable() {
|
||||
return "", ErrObjectNotPublic
|
||||
}
|
||||
return buildPublicURL(c.cfg, objectKey, false)
|
||||
}
|
||||
|
||||
func (c *minioClient) isPublicReadable() bool {
|
||||
return isPublicReadACL(c.cfg.ObjectACL) || isPublicReadACL(c.cfg.BucketACL)
|
||||
}
|
||||
|
||||
func (c *minioClient) DownloadURL(objectKey string) (string, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
objectKey = normalizeObjectKeyPath(objectKey)
|
||||
if objectKey == "" {
|
||||
return "", fmt.Errorf("object key is required")
|
||||
}
|
||||
ttl := c.cfg.SignedURLTTL
|
||||
if ttl <= 0 {
|
||||
ttl = 30 * time.Minute
|
||||
}
|
||||
params := url.Values{}
|
||||
params.Set("response-content-disposition", downloadContentDisposition(objectKey))
|
||||
u, err := c.client.PresignedGetObject(context.Background(), c.bucket, objectKey, ttl, params)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("presign minio download url: %w", err)
|
||||
}
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func (c *minioClient) List(ctx context.Context, in ListInput) (*ListResult, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limit := in.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 100
|
||||
}
|
||||
delimiter := strings.TrimSpace(in.Delimiter)
|
||||
if delimiter == "" && !in.Recursive {
|
||||
delimiter = "/"
|
||||
}
|
||||
|
||||
core := minio.Core{Client: c.client}
|
||||
result, err := core.ListObjectsV2(
|
||||
c.bucket,
|
||||
normalizeObjectKeyPath(in.Prefix),
|
||||
normalizeObjectKeyPath(in.StartAfter),
|
||||
strings.TrimSpace(in.ContinuationToken),
|
||||
delimiter,
|
||||
limit,
|
||||
)
|
||||
if err != nil {
|
||||
c.logError(ctx, "minio list objects failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("prefix", in.Prefix),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, fmt.Errorf("list minio objects: %w", err)
|
||||
}
|
||||
|
||||
out := &ListResult{
|
||||
Objects: make([]ObjectInfo, 0, len(result.Contents)),
|
||||
CommonPrefixes: make([]string, 0, len(result.CommonPrefixes)),
|
||||
ContinuationToken: result.ContinuationToken,
|
||||
NextContinuationToken: result.NextContinuationToken,
|
||||
IsTruncated: result.IsTruncated,
|
||||
Prefix: result.Prefix,
|
||||
Delimiter: result.Delimiter,
|
||||
}
|
||||
for _, item := range result.Contents {
|
||||
out.Objects = append(out.Objects, ObjectInfo{
|
||||
Key: item.Key,
|
||||
Size: item.Size,
|
||||
ETag: strings.Trim(item.ETag, `"`),
|
||||
LastModified: item.LastModified,
|
||||
ContentType: item.ContentType,
|
||||
StorageClass: item.StorageClass,
|
||||
})
|
||||
}
|
||||
for _, prefix := range result.CommonPrefixes {
|
||||
if prefix.Prefix != "" {
|
||||
out.CommonPrefixes = append(out.CommonPrefixes, prefix.Prefix)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *minioClient) Stat(ctx context.Context, objectKey string) (*ObjectInfo, error) {
|
||||
if err := c.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objectKey = normalizeObjectKeyPath(objectKey)
|
||||
if objectKey == "" {
|
||||
return nil, fmt.Errorf("object key is required")
|
||||
}
|
||||
item, err := c.client.StatObject(ctx, c.bucket, objectKey, minio.StatObjectOptions{})
|
||||
if err != nil {
|
||||
c.logError(ctx, "minio stat object failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("object_key", objectKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
if isMinIOObjectNotFound(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrObjectNotFound, objectKey)
|
||||
}
|
||||
return nil, fmt.Errorf("stat minio object: %w", err)
|
||||
}
|
||||
return &ObjectInfo{
|
||||
Key: item.Key,
|
||||
Size: item.Size,
|
||||
ETag: strings.Trim(item.ETag, `"`),
|
||||
LastModified: item.LastModified,
|
||||
ContentType: item.ContentType,
|
||||
StorageClass: item.StorageClass,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *minioClient) Copy(ctx context.Context, sourceKey, destinationKey string) error {
|
||||
if err := c.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceKey = normalizeObjectKeyPath(sourceKey)
|
||||
destinationKey = normalizeObjectKeyPath(destinationKey)
|
||||
if sourceKey == "" || destinationKey == "" {
|
||||
return fmt.Errorf("source and destination object keys are required")
|
||||
}
|
||||
if sourceKey == destinationKey {
|
||||
return nil
|
||||
}
|
||||
_, err := c.client.CopyObject(ctx,
|
||||
minio.CopyDestOptions{Bucket: c.bucket, Object: destinationKey},
|
||||
minio.CopySrcOptions{Bucket: c.bucket, Object: sourceKey},
|
||||
)
|
||||
if err != nil {
|
||||
c.logError(ctx, "minio copy object failed",
|
||||
zap.String("bucket", c.bucket),
|
||||
zap.String("source_key", sourceKey),
|
||||
zap.String("destination_key", destinationKey),
|
||||
zap.Error(err),
|
||||
)
|
||||
if isMinIOObjectNotFound(err) {
|
||||
return fmt.Errorf("%w: %s", ErrObjectNotFound, sourceKey)
|
||||
}
|
||||
return fmt.Errorf("copy minio object: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *minioClient) logError(ctx context.Context, msg string, fields ...zap.Field) {
|
||||
if c == nil || c.logger == nil {
|
||||
return
|
||||
|
||||
@@ -47,6 +47,55 @@ func (c *ReloadableClient) PublicURL(objectKey string) (string, error) {
|
||||
return c.snapshot().PublicURL(objectKey)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) DownloadURL(objectKey string) (string, error) {
|
||||
return c.snapshot().DownloadURL(objectKey)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) List(ctx context.Context, in ListInput) (*ListResult, error) {
|
||||
client := c.snapshot()
|
||||
management, ok := client.(ManagementClient)
|
||||
if !ok {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return management.List(ctx, in)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) Stat(ctx context.Context, objectKey string) (*ObjectInfo, error) {
|
||||
client := c.snapshot()
|
||||
management, ok := client.(ManagementClient)
|
||||
if !ok {
|
||||
return nil, ErrNotConfigured
|
||||
}
|
||||
return management.Stat(ctx, objectKey)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) Copy(ctx context.Context, sourceKey, destinationKey string) error {
|
||||
client := c.snapshot()
|
||||
management, ok := client.(ManagementClient)
|
||||
if !ok {
|
||||
return ErrNotConfigured
|
||||
}
|
||||
return management.Copy(ctx, sourceKey, destinationKey)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) Usage(ctx context.Context) (*UsageInfo, error) {
|
||||
client := c.snapshot()
|
||||
usageClient, ok := client.(UsageClient)
|
||||
if !ok {
|
||||
return nil, ErrUsageUnsupported
|
||||
}
|
||||
return usageClient.Usage(ctx)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) PublicReadableURL(objectKey string) (string, error) {
|
||||
client := c.snapshot()
|
||||
publicClient, ok := client.(PublicReadableClient)
|
||||
if !ok {
|
||||
return "", ErrObjectNotPublic
|
||||
}
|
||||
return publicClient.PublicReadableURL(objectKey)
|
||||
}
|
||||
|
||||
func (c *ReloadableClient) snapshot() Client {
|
||||
if c == nil {
|
||||
return disabledClient{reason: "object storage client is nil"}
|
||||
|
||||
@@ -3,6 +3,7 @@ package objectstorage
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
@@ -65,6 +66,66 @@ func buildPublicURL(cfg config.ObjectStorageConfig, objectKey string, bucketInHo
|
||||
return endpointURL.String(), nil
|
||||
}
|
||||
|
||||
func DownloadContentDisposition(objectKey string) string {
|
||||
name := strings.TrimSpace(filepath.Base(strings.TrimRight(objectKey, "/")))
|
||||
if name == "" || name == "." || name == "/" || name == "\\" {
|
||||
name = "object"
|
||||
}
|
||||
name = strings.Map(func(r rune) rune {
|
||||
if r == '"' || r == '\\' || r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, name)
|
||||
if name == "" {
|
||||
name = "object"
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`attachment; filename="%s"; filename*=UTF-8''%s`,
|
||||
asciiContentDispositionFileName(name),
|
||||
url.PathEscape(name),
|
||||
)
|
||||
}
|
||||
|
||||
func downloadContentDisposition(objectKey string) string {
|
||||
return DownloadContentDisposition(objectKey)
|
||||
}
|
||||
|
||||
func asciiContentDispositionFileName(name string) string {
|
||||
var builder strings.Builder
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r >= 0x7f || r == '"' || r == '\\' {
|
||||
continue
|
||||
}
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
|
||||
fallback := strings.TrimSpace(builder.String())
|
||||
if fallback == "" || fallback == "." || fallback == ".." || strings.HasPrefix(fallback, ".") {
|
||||
return "object" + safeASCIIExtension(name)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func safeASCIIExtension(name string) string {
|
||||
ext := filepath.Ext(name)
|
||||
if ext == "" || ext == "." {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, r := range ext {
|
||||
if r < 0x20 || r >= 0x7f || r == '"' || r == '\\' {
|
||||
continue
|
||||
}
|
||||
builder.WriteRune(r)
|
||||
}
|
||||
ext = strings.TrimSpace(builder.String())
|
||||
if !strings.HasPrefix(ext, ".") || ext == "." {
|
||||
return ""
|
||||
}
|
||||
return ext
|
||||
}
|
||||
|
||||
func parseURLWithScheme(raw string, defaultScheme string) (*url.URL, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
|
||||
@@ -51,3 +51,23 @@ func TestNormalizeObjectKeyPathDecodesEncodedSegments(t *testing.T) {
|
||||
t.Fatalf("object key = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadContentDispositionUsesRFC5987FileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := DownloadContentDisposition("reports/省心推 安装包.zip")
|
||||
want := `attachment; filename="object.zip"; filename*=UTF-8''%E7%9C%81%E5%BF%83%E6%8E%A8%20%E5%AE%89%E8%A3%85%E5%8C%85.zip`
|
||||
if got != want {
|
||||
t.Fatalf("content disposition = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadContentDispositionKeepsASCIIFileNameFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := DownloadContentDisposition(`reports/report "final".csv`)
|
||||
want := `attachment; filename="report final.csv"; filename*=UTF-8''report%20final.csv`
|
||||
if got != want {
|
||||
t.Fatalf("content disposition = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,110 @@ var routeDocs = map[string]routeDoc{
|
||||
"GET /api/health/live": {"存活探针", "Kubernetes liveness 探针,永远返回 200。"},
|
||||
"GET /api/health/ready": {"就绪探针", "Kubernetes readiness 探针,依赖(数据库等)就绪后返回 200。"},
|
||||
|
||||
// --- Ops:认证与账号 ---
|
||||
"GET /api/ops/auth/password-key": {"Ops 登录密码加密公钥", "返回 Ops 登录密码前端加密使用的临时公钥与 key_id。"},
|
||||
"POST /api/ops/auth/login": {"Ops 账号密码登录", "Ops 控制台账号密码登录,返回 access token。"},
|
||||
"GET /api/ops/auth/me": {"Ops 当前用户", "返回当前 Ops JWT 对应的账号信息。"},
|
||||
"POST /api/ops/auth/password": {"Ops 修改密码", "当前 Ops 用户修改自己的密码。"},
|
||||
|
||||
"GET /api/ops/accounts": {"Ops 账号列表", "分页查询 Ops 管理员账号。"},
|
||||
"POST /api/ops/accounts": {"新建 Ops 账号", "创建新的 Ops 管理员账号。"},
|
||||
"GET /api/ops/accounts/:id": {"Ops 账号详情", "读取指定 Ops 管理员账号详情。"},
|
||||
"PATCH /api/ops/accounts/:id": {"更新 Ops 账号", "更新指定 Ops 管理员账号资料。"},
|
||||
"POST /api/ops/accounts/:id/status": {"切换 Ops 账号状态", "启用或停用指定 Ops 管理员账号。"},
|
||||
"POST /api/ops/accounts/:id/password": {"重置 Ops 账号密码", "为指定 Ops 管理员账号重置登录密码。"},
|
||||
|
||||
"GET /api/ops/plans": {"租户套餐列表", "返回可分配给租户管理员的套餐列表。"},
|
||||
"GET /api/ops/roles": {"Ops 角色列表", "返回 Ops 控制台可用角色列表。"},
|
||||
|
||||
"GET /api/ops/admin-users": {"租户管理员列表", "分页查询租户管理员账号。"},
|
||||
"POST /api/ops/admin-users": {"新建租户管理员", "创建租户管理员账号。"},
|
||||
"GET /api/ops/admin-users/:id": {"租户管理员详情", "读取租户管理员账号详情。"},
|
||||
"PATCH /api/ops/admin-users/:id": {"更新租户管理员", "更新租户管理员基础资料。"},
|
||||
"POST /api/ops/admin-users/:id/plan": {"调整租户套餐", "调整租户管理员账号绑定的套餐。"},
|
||||
"POST /api/ops/admin-users/:id/subscription-expiry": {"调整订阅到期时间", "更新租户管理员账号订阅到期时间。"},
|
||||
"POST /api/ops/admin-users/:id/reset-plan-usage": {"重置套餐用量", "重置租户管理员账号套餐用量。"},
|
||||
"POST /api/ops/admin-users/:id/role": {"调整租户角色", "调整租户管理员账号角色。"},
|
||||
"POST /api/ops/admin-users/:id/kol": {"设置 KOL 身份", "设置租户管理员账号的 KOL 状态。"},
|
||||
"POST /api/ops/admin-users/:id/status": {"切换租户管理员状态", "启用或停用租户管理员账号。"},
|
||||
"POST /api/ops/admin-users/:id/password": {"重置租户管理员密码", "重置租户管理员账号登录密码。"},
|
||||
"POST /api/ops/admin-users/:id/reset-login-lock": {"解除登录锁定", "解除租户管理员账号的登录失败锁定。"},
|
||||
|
||||
// --- Ops:业务运营 ---
|
||||
"GET /api/ops/kol/packages": {"KOL 套餐列表(Ops)", "Ops 查看 KOL 市场套餐。"},
|
||||
"GET /api/ops/kol/subscriptions": {"KOL 订阅列表(Ops)", "Ops 查看租户 KOL 订阅记录。"},
|
||||
"POST /api/ops/kol/subscriptions/manual-bind": {"手动绑定 KOL 订阅", "Ops 为租户手动绑定 KOL 订阅。"},
|
||||
"POST /api/ops/kol/subscriptions/:id/approve": {"审批 KOL 订阅", "Ops 审批指定 KOL 订阅。"},
|
||||
"POST /api/ops/kol/subscriptions/:id/revoke": {"撤销 KOL 订阅", "Ops 撤销指定 KOL 订阅。"},
|
||||
"GET /api/ops/audits": {"审计日志列表", "分页查询 Ops 审计日志。"},
|
||||
"GET /api/ops/jobs": {"任务列表(Ops)", "分页查询后台任务。"},
|
||||
"GET /api/ops/jobs/:source/:id": {"任务详情(Ops)", "读取指定来源与 ID 的任务详情。"},
|
||||
"POST /api/ops/jobs/:source/:id/retry": {"重试任务(Ops)", "对失败任务发起重试。"},
|
||||
"POST /api/ops/jobs/:source/:id/cancel": {"取消任务(Ops)", "取消指定后台任务。"},
|
||||
|
||||
// --- Ops:调度器 ---
|
||||
"GET /api/ops/scheduler/jobs": {"调度任务列表", "分页查询调度器任务。"},
|
||||
"GET /api/ops/scheduler/jobs/:key": {"调度任务详情", "读取指定调度任务详情。"},
|
||||
"PATCH /api/ops/scheduler/jobs/:key": {"更新调度任务", "更新调度任务配置。"},
|
||||
"POST /api/ops/scheduler/jobs/:key/enable": {"启用调度任务", "启用指定调度任务。"},
|
||||
"POST /api/ops/scheduler/jobs/:key/disable": {"禁用调度任务", "禁用指定调度任务。"},
|
||||
"POST /api/ops/scheduler/jobs/:key/run": {"手动触发调度任务", "立即触发指定调度任务。"},
|
||||
"POST /api/ops/scheduler/jobs/:key/dry-run": {"试运行调度任务", "以 dry-run 模式触发指定调度任务。"},
|
||||
"GET /api/ops/scheduler/jobs/:key/runs": {"调度运行历史", "查询指定调度任务运行历史。"},
|
||||
"GET /api/ops/scheduler/instances": {"调度器实例列表", "查询当前调度器实例状态。"},
|
||||
|
||||
// --- Ops:站点映射 ---
|
||||
"GET /api/ops/site-domain-mappings": {"站点域名映射列表", "分页查询站点域名映射。"},
|
||||
"GET /api/ops/site-domain-mappings/export": {"导出站点域名映射", "按筛选条件导出站点域名映射 CSV。"},
|
||||
"POST /api/ops/site-domain-mappings/import": {"导入站点域名映射", "Multipart 上传 CSV 批量导入站点域名映射。"},
|
||||
"POST /api/ops/site-domain-mappings": {"新建站点域名映射", "创建站点域名映射。"},
|
||||
"PATCH /api/ops/site-domain-mappings/:id": {"更新站点域名映射", "更新指定站点域名映射。"},
|
||||
"POST /api/ops/site-domain-mappings/:id/active": {"切换站点域名映射状态", "启用或停用指定站点域名映射。"},
|
||||
"DELETE /api/ops/site-domain-mappings/:id": {"删除站点域名映射", "删除指定站点域名映射。"},
|
||||
|
||||
// --- Ops:对象存储 ---
|
||||
"GET /api/ops/object-storage/objects": {"对象存储列表", "按前缀、关键字和递归模式查询对象存储文件。"},
|
||||
"GET /api/ops/object-storage/usage": {"对象存储用量", "读取对象存储 Bucket 原生用量统计。"},
|
||||
"GET /api/ops/object-storage/objects/detail": {"对象详情", "读取对象元数据、公开访问地址和预览地址。"},
|
||||
"GET /api/ops/object-storage/objects/url": {"解析对象访问地址", "生成对象可访问 URL;私有 Bucket 返回短期签名地址。"},
|
||||
"GET /api/ops/object-storage/objects/download-url": {"解析对象下载地址", "生成带下载文件名响应头的短期签名下载地址。"},
|
||||
"GET /api/ops/object-storage/objects/download": {"下载对象", "通过 Ops API 代理下载对象。"},
|
||||
"POST /api/ops/object-storage/objects/upload": {"上传对象", "Multipart 上传单个对象,服务端限制 500MB。"},
|
||||
"POST /api/ops/object-storage/objects/move": {"移动或重命名对象", "复制对象到目标 Key,可选择删除源对象。"},
|
||||
"DELETE /api/ops/object-storage/objects": {"删除对象", "删除指定 Object Key 的对象。"},
|
||||
"POST /api/ops/object-storage/folders": {"创建对象存储目录", "创建以 / 结尾的目录占位对象。"},
|
||||
"DELETE /api/ops/object-storage/folders": {"删除对象存储目录", "按前缀递归删除目录下对象。"},
|
||||
|
||||
// --- Ops:桌面客户端版本 ---
|
||||
"GET /api/ops/desktop-client/releases": {"桌面客户端版本列表", "分页查询桌面客户端版本配置。"},
|
||||
"POST /api/ops/desktop-client/releases/upload": {"上传桌面客户端包", "Multipart 上传桌面客户端安装包或更新包。"},
|
||||
"POST /api/ops/desktop-client/releases": {"新建桌面客户端版本", "创建桌面客户端版本配置。"},
|
||||
"PATCH /api/ops/desktop-client/releases/:id": {"更新桌面客户端版本", "更新桌面客户端版本配置。"},
|
||||
"GET /api/ops/desktop-client/releases/:id/download-url": {"解析桌面客户端下载地址", "解析指定桌面客户端版本的下载地址。"},
|
||||
"POST /api/ops/desktop-client/releases/:id/enabled": {"切换桌面客户端版本状态", "启用或禁用指定桌面客户端版本。"},
|
||||
"DELETE /api/ops/desktop-client/releases/:id": {"删除桌面客户端版本", "删除指定桌面客户端版本配置。"},
|
||||
|
||||
// --- Ops:合规 ---
|
||||
"GET /api/ops/compliance/dictionaries": {"合规词库列表", "分页查询内容合规词库。"},
|
||||
"POST /api/ops/compliance/dictionaries": {"新建合规词库", "创建内容合规词库。"},
|
||||
"PUT /api/ops/compliance/dictionaries/:id": {"更新合规词库", "更新内容合规词库。"},
|
||||
"DELETE /api/ops/compliance/dictionaries/:id": {"删除合规词库", "删除内容合规词库。"},
|
||||
"GET /api/ops/compliance/dictionaries/:id/terms": {"合规词条列表", "查询指定词库下的合规词条。"},
|
||||
"POST /api/ops/compliance/dictionaries/:id/terms": {"新增合规词条", "向指定词库新增合规词条。"},
|
||||
"POST /api/ops/compliance/dictionaries/:id/terms:batch-import": {"批量导入合规词条", "向指定词库批量导入合规词条。"},
|
||||
"DELETE /api/ops/compliance/dictionaries/:id/terms/:term_id": {"删除合规词条", "删除指定合规词条。"},
|
||||
"POST /api/ops/compliance/dictionaries/:id/publish": {"发布合规词库", "发布指定合规词库版本。"},
|
||||
"POST /api/ops/compliance/dictionaries/:id/rollback": {"回滚合规词库", "回滚指定合规词库版本。"},
|
||||
"GET /api/ops/compliance/policy/global": {"全局合规策略", "读取全局内容合规策略。"},
|
||||
"PUT /api/ops/compliance/policy/global": {"更新全局合规策略", "更新全局内容合规策略。"},
|
||||
"POST /api/ops/compliance/policy/global/master-switch": {"切换合规总开关", "启用或关闭全局内容合规总开关。"},
|
||||
"GET /api/ops/compliance/manual-reviews": {"人工审阅列表", "分页查询内容合规人工审阅。"},
|
||||
"GET /api/ops/compliance/manual-reviews/:id": {"人工审阅详情", "读取指定人工审阅详情。"},
|
||||
"POST /api/ops/compliance/manual-reviews/:id/approve": {"通过人工审阅", "通过指定内容合规人工审阅。"},
|
||||
"POST /api/ops/compliance/manual-reviews/:id/reject": {"驳回人工审阅", "驳回指定内容合规人工审阅。"},
|
||||
"GET /api/ops/compliance/stats": {"合规统计", "读取内容合规统计数据。"},
|
||||
"GET /api/ops/compliance/records": {"合规检测记录", "分页查询内容合规检测记录。"},
|
||||
|
||||
// --- Auth ---
|
||||
"GET /api/auth/password-key": {"登录密码加密公钥", "返回登录密码前端加密使用的临时公钥与 key_id。"},
|
||||
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
|
||||
|
||||
@@ -13,14 +13,17 @@ import (
|
||||
// TestRouteDocs_CoversEveryRouterEntry parses the tenant-api router source and
|
||||
// asserts that every registered route has a corresponding entry in routeDocs.
|
||||
// This is a static-analysis safety net: when a new handler is wired up in
|
||||
// internal/tenant/transport/router.go, this test fails until a Chinese
|
||||
// tenant or ops transport routers, this test fails until a Chinese
|
||||
// summary is added.
|
||||
func TestRouteDocs_CoversEveryRouterEntry(t *testing.T) {
|
||||
routerPath, bootstrapPath := findRepoSources(t)
|
||||
routerPaths, bootstrapPath := findRepoSources(t)
|
||||
|
||||
routes := append(parseRoutes(t, routerPath), parseEngineRoutes(t, bootstrapPath)...)
|
||||
routes := parseEngineRoutes(t, bootstrapPath)
|
||||
for _, routerPath := range routerPaths {
|
||||
routes = append(routes, parseRoutes(t, routerPath)...)
|
||||
}
|
||||
if len(routes) == 0 {
|
||||
t.Fatalf("could not extract any routes from %s", routerPath)
|
||||
t.Fatalf("could not extract any routes from %s", strings.Join(routerPaths, ", "))
|
||||
}
|
||||
|
||||
missing := make([]string, 0)
|
||||
@@ -133,7 +136,7 @@ func parseEngineRoutes(t *testing.T, path string) []parsedRoute {
|
||||
return routes
|
||||
}
|
||||
|
||||
func findRepoSources(t *testing.T) (router, bootstrap string) {
|
||||
func findRepoSources(t *testing.T) (routers []string, bootstrap string) {
|
||||
t.Helper()
|
||||
_, file, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
@@ -142,6 +145,9 @@ func findRepoSources(t *testing.T) (router, bootstrap string) {
|
||||
// .../server/internal/shared/swagger/descriptions_parity_test.go ->
|
||||
// .../server is three levels up from this file's parent dir.
|
||||
serverRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", ".."))
|
||||
return filepath.Join(serverRoot, "internal", "tenant", "transport", "router.go"),
|
||||
return []string{
|
||||
filepath.Join(serverRoot, "internal", "tenant", "transport", "router.go"),
|
||||
filepath.Join(serverRoot, "internal", "ops", "transport", "router.go"),
|
||||
},
|
||||
filepath.Join(serverRoot, "internal", "bootstrap", "bootstrap.go")
|
||||
}
|
||||
|
||||
@@ -303,6 +303,17 @@ func queryParameterNames(route gin.RouteInfo) []string {
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(path, "/ops/site-domain-mappings"):
|
||||
add("page", "size", "keyword", "is_active")
|
||||
case strings.Contains(path, "/ops/object-storage/objects/detail") ||
|
||||
strings.Contains(path, "/ops/object-storage/objects/url") ||
|
||||
strings.Contains(path, "/ops/object-storage/objects/download-url") ||
|
||||
strings.Contains(path, "/ops/object-storage/objects/download"):
|
||||
add("key")
|
||||
case strings.Contains(path, "/ops/object-storage/objects"):
|
||||
add("prefix", "keyword", "continuation_token", "start_after", "recursive", "size", "key")
|
||||
case strings.Contains(path, "/ops/object-storage/folders"):
|
||||
add("prefix")
|
||||
case strings.Contains(path, "/workspace/ai-point-usage"):
|
||||
add("page", "page_size")
|
||||
case strings.Contains(path, "/tenant/articles"):
|
||||
@@ -357,13 +368,13 @@ func queryParameter(name string) map[string]any {
|
||||
|
||||
func schemaForQueryParameter(name string) map[string]any {
|
||||
switch name {
|
||||
case "page", "page_size", "limit", "offset", "days", "period_days":
|
||||
case "page", "page_size", "limit", "offset", "days", "period_days", "size":
|
||||
return map[string]any{"type": "integer"}
|
||||
case "brand_id", "keyword_id", "question_id", "template_id", "kol_prompt_id", "prompt_rule_id", "group_id", "folder_id", "if_sync_version":
|
||||
return map[string]any{"type": "integer", "format": "int64"}
|
||||
case "force":
|
||||
return map[string]any{"type": "string", "enum": []string{"1"}}
|
||||
case "ungrouped", "undo":
|
||||
case "ungrouped", "undo", "recursive", "is_active":
|
||||
return map[string]any{"type": "string", "enum": []string{"true", "false"}}
|
||||
case "created_from", "created_to", "date_from", "date_to", "business_date":
|
||||
return map[string]any{"type": "string", "format": "date"}
|
||||
@@ -403,6 +414,9 @@ func isMultipartRoute(route gin.RouteInfo) bool {
|
||||
}
|
||||
path := route.Path
|
||||
return path == "/api/tenant/images" ||
|
||||
path == "/api/ops/site-domain-mappings/import" ||
|
||||
path == "/api/ops/object-storage/objects/upload" ||
|
||||
path == "/api/ops/desktop-client/releases/upload" ||
|
||||
path == "/api/tenant/knowledge/items/file" ||
|
||||
path == "/api/tenant/kol/manage/profile/avatar" ||
|
||||
path == "/api/tenant/articles/:id/images"
|
||||
|
||||
@@ -45,6 +45,10 @@ func (s *fakeAssetObjectStorage) PublicURL(string) (string, error) {
|
||||
panic("unexpected call")
|
||||
}
|
||||
|
||||
func (s *fakeAssetObjectStorage) DownloadURL(string) (string, error) {
|
||||
panic("unexpected call")
|
||||
}
|
||||
|
||||
func TestAssetHandlerServeDesktop_AllowsStaleSignedTenantAsset(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user