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:
2026-05-26 01:19:01 +08:00
parent 5f6e9f11da
commit 7d8e82c69f
37 changed files with 5111 additions and 66 deletions
+7
View File
@@ -53,6 +53,7 @@ import {
CloudDownloadOutlined, CloudDownloadOutlined,
ControlOutlined, ControlOutlined,
CrownOutlined, CrownOutlined,
DatabaseOutlined,
FileSearchOutlined, FileSearchOutlined,
GlobalOutlined, GlobalOutlined,
LineChartOutlined, LineChartOutlined,
@@ -84,6 +85,7 @@ const menuLeaves: MenuLeaf[] = [
{ key: '/admin-users', label: '用户管理', path: '/admin-users' }, { key: '/admin-users', label: '用户管理', path: '/admin-users' },
{ key: '/kol-subscriptions', label: '订阅包审批', path: '/kol-subscriptions' }, { key: '/kol-subscriptions', label: '订阅包审批', path: '/kol-subscriptions' },
{ key: '/site-domain-mappings', label: '站点映射', path: '/site-domain-mappings' }, { 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: '/desktop-client/releases', label: '客户端版本', path: '/desktop-client/releases' },
{ key: '/compliance/policy', label: '全局策略', path: '/compliance/policy' }, { key: '/compliance/policy', label: '全局策略', path: '/compliance/policy' },
{ key: '/compliance/dictionaries', label: '合规词库', path: '/compliance/dictionaries' }, { key: '/compliance/dictionaries', label: '合规词库', path: '/compliance/dictionaries' },
@@ -112,6 +114,11 @@ const menuItems = computed<ItemType[]>(() => [
label: '站点映射', label: '站点映射',
icon: () => h(GlobalOutlined), icon: () => h(GlobalOutlined),
}, },
{
key: '/object-storage',
label: '对象存储',
icon: () => h(DatabaseOutlined),
},
{ {
key: '/desktop-client/releases', key: '/desktop-client/releases',
label: '客户端版本', label: '客户端版本',
+107
View File
@@ -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()}`,
)
},
}
+2
View File
@@ -25,6 +25,7 @@ import {
Result, Result,
Segmented, Segmented,
Select, Select,
Skeleton,
Space, Space,
Spin, Spin,
Statistic, Statistic,
@@ -98,6 +99,7 @@ bindUnauthorizedHandler(() => {
Result, Result,
Select, Select,
Segmented, Segmented,
Skeleton,
Space, Space,
Spin, Spin,
Statistic, Statistic,
+6
View File
@@ -61,6 +61,12 @@ export const router = createRouter({
component: () => import('@/views/SiteDomainMappingsView.vue'), component: () => import('@/views/SiteDomainMappingsView.vue'),
meta: { title: '站点映射' }, meta: { title: '站点映射' },
}, },
{
path: 'object-storage',
name: 'object-storage',
component: () => import('@/views/ObjectStorageView.vue'),
meta: { title: '对象存储' },
},
{ {
path: 'desktop-client/releases', path: 'desktop-client/releases',
name: 'desktop-client-releases', name: 'desktop-client-releases',
File diff suppressed because it is too large Load Diff
@@ -9,11 +9,28 @@
<span>本页停用 {{ inactiveRows }}</span> <span>本页停用 {{ inactiveRows }}</span>
</div> </div>
</div> </div>
<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"> <a-button type="primary" @click="openCreate">
<template #icon><PlusOutlined /></template> <template #icon><PlusOutlined /></template>
新增映射 新增映射
</a-button> </a-button>
</div> </div>
</div>
<div class="ops-card site-domain-card"> <div class="ops-card site-domain-card">
<div class="ops-toolbar site-domain-toolbar"> <div class="ops-toolbar site-domain-toolbar">
@@ -37,6 +54,10 @@
<template #icon><ReloadOutlined /></template> <template #icon><ReloadOutlined /></template>
刷新 刷新
</a-button> </a-button>
<a-button @click="downloadTemplate">
<template #icon><FileTextOutlined /></template>
下载模板
</a-button>
</div> </div>
<a-table <a-table
@@ -133,11 +154,73 @@
</div> </div>
</template> </template>
</a-drawer> </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> </div>
</template> </template>
<script setup lang="ts"> <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 type { TablePaginationConfig } from 'ant-design-vue'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
@@ -163,6 +246,21 @@ interface ListResult {
size: number 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({ const filter = reactive({
keyword: '', keyword: '',
status: undefined as string | undefined, status: undefined as string | undefined,
@@ -183,10 +281,14 @@ const columns = [
const rows = ref<SiteDomainMappingRow[]>([]) const rows = ref<SiteDomainMappingRow[]>([])
const loading = ref(false) const loading = ref(false)
const importing = ref(false)
const exporting = ref(false)
const page = ref(1) const page = ref(1)
const size = ref(20) const size = ref(20)
const total = ref(0) const total = ref(0)
const statusLoadingId = ref<number | null>(null) 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 activeRows = computed(() => rows.value.filter((row) => row.is_active).length)
const inactiveRows = computed(() => rows.value.length - activeRows.value) 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 { function formatDate(value: string | null | undefined): string {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—' return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : '—'
} }
@@ -384,6 +576,14 @@ onMounted(() => {
padding: 4px 8px; padding: 4px 8px;
} }
.site-domain-header-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.site-domain-card { .site-domain-card {
padding-top: 20px; padding-top: 20px;
} }
@@ -430,15 +630,87 @@ onMounted(() => {
gap: 8px; 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) { @media (max-width: 768px) {
.site-domain-header { .site-domain-header {
align-items: stretch; align-items: stretch;
flex-direction: column; flex-direction: column;
} }
.site-domain-header-actions {
justify-content: flex-start;
}
.site-domain-search, .site-domain-search,
.site-domain-status { .site-domain-status {
width: 100%; width: 100%;
} }
.import-result-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
</style> </style>
+3 -1
View File
@@ -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 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: 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. 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.
+1
View File
@@ -157,6 +157,7 @@ object_storage:
use_ssl: false use_ssl: false
public_base_url: "" public_base_url: ""
region: "" region: ""
force_path_style: false
cache: cache:
driver: redis driver: redis
+1
View File
@@ -11,6 +11,7 @@
- `provider: minio``provider: mino`:部署 MinIO 和 `minio-init` - `provider: minio``provider: mino`:部署 MinIO 和 `minio-init`
- `provider: aliyun` / `aliyun_oss` / `aliyun-oss` / `oss`:使用 `deploy/k3s-aliyun-oss` overlay,不部署 MinIO 和 `minio-init` - `provider: aliyun` / `aliyun_oss` / `aliyun-oss` / `oss`:使用 `deploy/k3s-aliyun-oss` overlay,不部署 MinIO 和 `minio-init`
- `provider: r2` / `s3` / `custom_s3`:复用 S3 兼容客户端,不部署自建 MinIOCloudflare R2 建议配置 `region: auto``force_path_style: true`
## 1. 准备镜像 ## 1. 准备镜像
+1 -1
View File
@@ -115,7 +115,7 @@ use_minio_for_object_storage() {
""|minio|mino) ""|minio|mino)
return 0 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 return 1
;; ;;
*) *)
+2
View File
@@ -122,6 +122,7 @@ func main() {
siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc) siteDomainMappingSvc := app.NewSiteDomainMappingService(siteDomainMappingsRepo, auditSvc)
objectStorageClient := objectstorage.New(cfg.ObjectStorage, logger) objectStorageClient := objectstorage.New(cfg.ObjectStorage, logger)
desktopClientReleaseSvc := app.NewDesktopClientReleaseService(desktopClientReleasesRepo, objectStorageClient, auditSvc) desktopClientReleaseSvc := app.NewDesktopClientReleaseService(desktopClientReleasesRepo, objectStorageClient, auditSvc)
objectStorageSvc := app.NewObjectStorageService(objectStorageClient, auditSvc).WithLogger(logger)
complianceSvc := app.NewComplianceService(pool, auditSvc, logger) complianceSvc := app.NewComplianceService(pool, auditSvc, logger)
schedulerSvc := app.NewSchedulerService(schedulerRepo, auditSvc) schedulerSvc := app.NewSchedulerService(schedulerRepo, auditSvc)
@@ -171,6 +172,7 @@ func main() {
Audits: auditSvc, Audits: auditSvc,
SiteDomains: siteDomainMappingSvc, SiteDomains: siteDomainMappingSvc,
Releases: desktopClientReleaseSvc, Releases: desktopClientReleaseSvc,
ObjectStorage: objectStorageSvc,
Compliance: complianceSvc, Compliance: complianceSvc,
Scheduler: schedulerSvc, Scheduler: schedulerSvc,
}) })
+10
View File
@@ -143,6 +143,7 @@ object_storage:
use_ssl: false use_ssl: false
public_base_url: "" public_base_url: ""
region: "" region: ""
force_path_style: false
# Prefer environment override for secrets: # Prefer environment override for secrets:
# export OBJECT_STORAGE_ACCESS_KEY=minioadmin # export OBJECT_STORAGE_ACCESS_KEY=minioadmin
# export OBJECT_STORAGE_SECRET_KEY=minioadmin # export OBJECT_STORAGE_SECRET_KEY=minioadmin
@@ -151,6 +152,15 @@ object_storage:
# endpoint: localhost:9000 # endpoint: localhost:9000
# bucket: geo-private # bucket: geo-private
# use_ssl: false # 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: # Aliyun OSS example:
# provider: aliyun # provider: aliyun
+1
View File
@@ -167,6 +167,7 @@ object_storage:
object_acl: default object_acl: default
signed_url_ttl: 30m signed_url_ttl: 30m
region: cn-shanghai region: cn-shanghai
force_path_style: false
cache: cache:
driver: redis driver: redis
+1
View File
@@ -52,6 +52,7 @@ object_storage:
object_acl: default object_acl: default
signed_url_ttl: 30m signed_url_ttl: 30m
region: cn-shanghai region: cn-shanghai
force_path_style: false
log: log:
level: info level: info
+708
View File
@@ -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 package app
import ( import (
"bytes"
"context" "context"
"encoding/csv"
"errors" "errors"
"fmt"
"net" "net"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time" "time"
@@ -21,6 +25,12 @@ const (
ActionSiteDomainMappingEnable = "site_domain_mapping.enable" ActionSiteDomainMappingEnable = "site_domain_mapping.enable"
ActionSiteDomainMappingDisable = "site_domain_mapping.disable" ActionSiteDomainMappingDisable = "site_domain_mapping.disable"
ActionSiteDomainMappingDelete = "site_domain_mapping.delete" ActionSiteDomainMappingDelete = "site_domain_mapping.delete"
ActionSiteDomainMappingImport = "site_domain_mapping.import"
ActionSiteDomainMappingExport = "site_domain_mapping.export"
siteDomainMappingImportMaxRows = 5000
siteDomainMappingExportMaxRows = 10000
siteDomainMappingImportMaxErrors = 50
) )
type SiteDomainMappingService struct { type SiteDomainMappingService struct {
@@ -63,6 +73,32 @@ type SiteDomainMappingInput struct {
IsActive bool 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 { func toSiteDomainMappingView(item *domain.SiteDomainMapping) SiteDomainMappingView {
return SiteDomainMappingView{ return SiteDomainMappingView{
ID: item.ID, 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 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) { func (s *SiteDomainMappingService) Create(ctx context.Context, actor *Actor, in SiteDomainMappingInput) (*SiteDomainMappingView, error) {
normalized, err := normalizeSiteDomainMappingInput(in) normalized, err := normalizeSiteDomainMappingInput(in)
if err != nil { if err != nil {
@@ -126,6 +212,85 @@ func (s *SiteDomainMappingService) Create(ctx context.Context, actor *Actor, in
return &view, nil 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) { func (s *SiteDomainMappingService) Update(ctx context.Context, actor *Actor, id int64, in SiteDomainMappingInput) (*SiteDomainMappingView, error) {
normalized, err := normalizeSiteDomainMappingInput(in) normalized, err := normalizeSiteDomainMappingInput(in)
if err != nil { if err != nil {
@@ -240,6 +405,130 @@ func normalizeDomainName(raw string) string {
return strings.Trim(strings.TrimSuffix(value, "."), ".") 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 { func isDuplicateSiteDomainMapping(err error) bool {
var pgErr *pgconn.PgError var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505" 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")
}
}
+5
View File
@@ -504,6 +504,11 @@ func applyOpsObjectStorageEnvOverrides(cfg *Config) {
if region := firstNonEmptyEnv("OPS_OBJECT_STORAGE_REGION", "OBJECT_STORAGE_REGION"); region != "" { if region := firstNonEmptyEnv("OPS_OBJECT_STORAGE_REGION", "OBJECT_STORAGE_REGION"); region != "" {
cfg.ObjectStorage.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 v := firstNonEmptyEnv("OPS_OBJECT_STORAGE_USE_SSL", "OBJECT_STORAGE_USE_SSL"); v != "" {
if useSSL, err := parseBoolEnv("OPS_OBJECT_STORAGE_USE_SSL", v); err == nil { if useSSL, err := parseBoolEnv("OPS_OBJECT_STORAGE_USE_SSL", v); err == nil {
cfg.ObjectStorage.UseSSL = useSSL cfg.ObjectStorage.UseSSL = useSSL
@@ -44,6 +44,18 @@ type UpdateSiteDomainMappingInput struct {
IsActive bool 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) { func scanSiteDomainMapping(row pgx.Row) (*domain.SiteDomainMapping, error) {
var item domain.SiteDomainMapping var item domain.SiteDomainMapping
err := row.Scan( err := row.Scan(
@@ -64,7 +76,29 @@ func scanSiteDomainMapping(row pgx.Row) (*domain.SiteDomainMapping, error) {
return &item, nil 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{} args := []any{}
where := "1=1" 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)) 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 var total int64
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM site_domain_mappings WHERE "+where, args...).Scan(&total); err != nil { if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM site_domain_mappings WHERE "+where, args...).Scan(&total); err != nil {
return nil, 0, err return nil, 0, err
@@ -117,6 +157,37 @@ func (r *SiteDomainMappingRepository) List(ctx context.Context, f SiteDomainMapp
return items, total, rows.Err() 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) { func (r *SiteDomainMappingRepository) GetByID(ctx context.Context, id int64) (*domain.SiteDomainMapping, error) {
return scanSiteDomainMapping(r.pool.QueryRow(ctx, ` return scanSiteDomainMapping(r.pool.QueryRow(ctx, `
SELECT `+siteDomainMappingColumns+` 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)) 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) { func (r *SiteDomainMappingRepository) SetActive(ctx context.Context, id int64, active bool) (*domain.SiteDomainMapping, error) {
return scanSiteDomainMapping(r.pool.QueryRow(ctx, ` return scanSiteDomainMapping(r.pool.QueryRow(ctx, `
UPDATE site_domain_mappings 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"
}
+23
View File
@@ -9,6 +9,7 @@ import (
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config" 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/middleware"
"github.com/geo-platform/tenant-api/internal/shared/response" "github.com/geo-platform/tenant-api/internal/shared/response"
"github.com/geo-platform/tenant-api/internal/shared/swagger"
) )
type Deps struct { type Deps struct {
@@ -26,6 +27,7 @@ type Deps struct {
Audits *app.AuditService Audits *app.AuditService
SiteDomains *app.SiteDomainMappingService SiteDomains *app.SiteDomainMappingService
Releases *app.DesktopClientReleaseService Releases *app.DesktopClientReleaseService
ObjectStorage *app.ObjectStorageService
Compliance *app.ComplianceService Compliance *app.ComplianceService
Scheduler *app.SchedulerService Scheduler *app.SchedulerService
} }
@@ -132,11 +134,25 @@ func RegisterRoutes(d Deps) {
authed.GET("/scheduler/instances", listSchedulerInstancesHandler(d.Scheduler)) authed.GET("/scheduler/instances", listSchedulerInstancesHandler(d.Scheduler))
authed.GET("/site-domain-mappings", listSiteDomainMappingsHandler(d.SiteDomains)) 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.POST("/site-domain-mappings", createSiteDomainMappingHandler(d.SiteDomains))
authed.PATCH("/site-domain-mappings/:id", updateSiteDomainMappingHandler(d.SiteDomains)) authed.PATCH("/site-domain-mappings/:id", updateSiteDomainMappingHandler(d.SiteDomains))
authed.POST("/site-domain-mappings/:id/active", setSiteDomainMappingActiveHandler(d.SiteDomains)) authed.POST("/site-domain-mappings/:id/active", setSiteDomainMappingActiveHandler(d.SiteDomains))
authed.DELETE("/site-domain-mappings/:id", deleteSiteDomainMappingHandler(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.GET("/desktop-client/releases", listDesktopClientReleasesHandler(d.Releases))
authed.POST("/desktop-client/releases/upload", uploadDesktopClientReleaseHandler(d.Releases)) authed.POST("/desktop-client/releases/upload", uploadDesktopClientReleaseHandler(d.Releases))
authed.POST("/desktop-client/releases", createDesktopClientReleaseHandler(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("/stats", complianceStatsHandler(d.Compliance))
compliance.GET("/records", listComplianceRecordsHandler(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 package transport
import ( import (
"fmt"
"io"
"net/http"
"strconv" "strconv"
"time"
"github.com/gin-gonic/gin" "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 { func createSiteDomainMappingHandler(svc *app.SiteDomainMappingService) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
var body siteDomainMappingRequest var body siteDomainMappingRequest
+3
View File
@@ -8,12 +8,15 @@ import (
"github.com/geo-platform/tenant-api/internal/shared/config" "github.com/geo-platform/tenant-api/internal/shared/config"
) )
const MaxMultipartMemory = 8 << 20
func NewEngine(server config.ServerConfig) (*gin.Engine, error) { func NewEngine(server config.ServerConfig) (*gin.Engine, error) {
config.NormalizeServerConfig(&server) config.NormalizeServerConfig(&server)
if server.Mode == "release" { if server.Mode == "release" {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
} }
engine := gin.New() engine := gin.New()
engine.MaxMultipartMemory = MaxMultipartMemory
engine.ForwardedByClientIP = true engine.ForwardedByClientIP = true
engine.RemoteIPHeaders = []string{"X-Forwarded-For", "X-Real-IP"} engine.RemoteIPHeaders = []string{"X-Forwarded-For", "X-Real-IP"}
if err := engine.SetTrustedProxies(server.TrustedProxies); err != nil { if err := engine.SetTrustedProxies(server.TrustedProxies); err != nil {
@@ -20,6 +20,7 @@ func TestNewEngineUsesForwardedClientIPFromTrustedProxy(t *testing.T) {
TrustedProxies: []string{"10.42.0.0/16"}, TrustedProxies: []string{"10.42.0.0/16"},
}) })
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, int64(MaxMultipartMemory), engine.MaxMultipartMemory)
engine.GET("/ip", func(c *gin.Context) { engine.GET("/ip", func(c *gin.Context) {
c.String(http.StatusOK, c.ClientIP()) c.String(http.StatusOK, c.ClientIP())
+4
View File
@@ -296,6 +296,7 @@ type ObjectStorageConfig struct {
ObjectACL string `mapstructure:"object_acl"` ObjectACL string `mapstructure:"object_acl"`
SignedURLTTL time.Duration `mapstructure:"signed_url_ttl"` SignedURLTTL time.Duration `mapstructure:"signed_url_ttl"`
Region string `mapstructure:"region"` Region string `mapstructure:"region"`
ForcePathStyle bool `mapstructure:"force_path_style"`
} }
type JWTConfig struct { type JWTConfig struct {
@@ -971,6 +972,9 @@ func applyEnvOverrides(cfg *Config) {
if region, ok := lookupNonEmptyEnv("OBJECT_STORAGE_REGION"); ok { if region, ok := lookupNonEmptyEnv("OBJECT_STORAGE_REGION"); ok {
cfg.ObjectStorage.Region = region 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 { if useSSL, ok := lookupBoolEnv("OBJECT_STORAGE_USE_SSL"); ok {
cfg.ObjectStorage.UseSSL = useSSL cfg.ObjectStorage.UseSSL = useSSL
} }
@@ -112,12 +112,14 @@ func TestLoadAppliesObjectStorageEnvOverrides(t *testing.T) {
t.Setenv("OBJECT_STORAGE_BUCKET_ACL", "private") t.Setenv("OBJECT_STORAGE_BUCKET_ACL", "private")
t.Setenv("OBJECT_STORAGE_OBJECT_ACL", "public-read") t.Setenv("OBJECT_STORAGE_OBJECT_ACL", "public-read")
t.Setenv("OBJECT_STORAGE_SIGNED_URL_TTL", "45m") t.Setenv("OBJECT_STORAGE_SIGNED_URL_TTL", "45m")
t.Setenv("OBJECT_STORAGE_FORCE_PATH_STYLE", "true")
configPath := writeTestConfig(t, ` configPath := writeTestConfig(t, `
object_storage: object_storage:
bucket_acl: public-read bucket_acl: public-read
object_acl: private object_acl: private
signed_url_ttl: 10m signed_url_ttl: 10m
force_path_style: false
`) `)
cfg, err := Load(configPath) cfg, err := Load(configPath)
@@ -134,6 +136,9 @@ object_storage:
if cfg.ObjectStorage.SignedURLTTL != 45*time.Minute { if cfg.ObjectStorage.SignedURLTTL != 45*time.Minute {
t.Fatalf("expected env signed url ttl, got %s", cfg.ObjectStorage.SignedURLTTL) 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) { func TestLoadAppliesBrandLibraryDefaults(t *testing.T) {
+178 -2
View File
@@ -5,6 +5,8 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"net/http"
"strconv"
"strings" "strings"
"time" "time"
@@ -296,6 +298,180 @@ func (c *aliyunClient) PublicURL(objectKey string) (string, error) {
return buildPublicURL(c.cfg, objectKey, true) 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) { func (c *aliyunClient) isPublicReadable() (bool, error) {
objectACL := strings.ToLower(strings.TrimSpace(c.cfg.ObjectACL)) objectACL := strings.ToLower(strings.TrimSpace(c.cfg.ObjectACL))
if isPublicReadACL(objectACL) { if isPublicReadACL(objectACL) {
@@ -317,7 +493,7 @@ func (c *aliyunClient) isPublicReadable() (bool, error) {
return isPublicReadACL(result.ACL), nil 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) objectKey = normalizeObjectKeyPath(objectKey)
if objectKey == "" { if objectKey == "" {
return "", fmt.Errorf("object key is required") return "", fmt.Errorf("object key is required")
@@ -330,7 +506,7 @@ func (c *aliyunClient) signedURL(objectKey string) (string, error) {
if ttl <= 0 { if ttl <= 0 {
ttl = 30 * time.Minute 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 { func isPublicReadACL(acl string) bool {
+75 -1
View File
@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"time"
"go.uber.org/zap" "go.uber.org/zap"
@@ -13,6 +14,8 @@ import (
var ErrNotConfigured = errors.New("object storage is not configured") var ErrNotConfigured = errors.New("object storage is not configured")
var ErrObjectNotFound = errors.New("object storage object not found") 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 { type Client interface {
Validate() error Validate() error
@@ -21,6 +24,57 @@ type Client interface {
Exists(ctx context.Context, objectKey string) (bool, error) Exists(ctx context.Context, objectKey string) (bool, error)
Delete(ctx context.Context, objectKey string) error Delete(ctx context.Context, objectKey string) error
PublicURL(objectKey string) (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 { func New(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
@@ -28,7 +82,7 @@ func New(cfg config.ObjectStorageConfig, logger *zap.Logger) Client {
switch provider { switch provider {
case "", "disabled": case "", "disabled":
return disabledClient{reason: "object storage is 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) return NewMinIOClient(cfg, logger)
case "aliyun", "aliyun_oss", "aliyun-oss", "oss": case "aliyun", "aliyun_oss", "aliyun-oss", "oss":
return NewAliyunClient(cfg, logger) return NewAliyunClient(cfg, logger)
@@ -67,3 +121,23 @@ func (c disabledClient) Delete(context.Context, string) error {
func (c disabledClient) PublicURL(string) (string, error) { func (c disabledClient) PublicURL(string) (string, error) {
return "", c.Validate() 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()
}
+198 -2
View File
@@ -5,7 +5,9 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"net/url"
"strings" "strings"
"time"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials" "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"} 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{ client, err := minio.New(endpoint, &minio.Options{
Creds: credentials.NewStaticV4(accessKey, secretKey, ""), Creds: credentials.NewStaticV4(accessKey, secretKey, ""),
Secure: cfg.UseSSL, Secure: cfg.UseSSL,
Region: strings.TrimSpace(cfg.Region), Region: region,
BucketLookup: bucketLookup,
}) })
if err != nil { if err != nil {
return disabledClient{reason: fmt.Sprintf("init minio client failed: %v", err)} 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{ return &minioClient{
client: client, client: client,
bucket: bucket, bucket: bucket,
region: strings.TrimSpace(cfg.Region), region: region,
logger: logger, logger: logger,
cfg: cfg, 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 { func (c *minioClient) Validate() error {
if c == nil || c.client == nil { if c == nil || c.client == nil {
return fmt.Errorf("%w: minio client is not initialized", ErrNotConfigured) 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 { if err := c.Validate(); err != nil {
return "", err return "", err
} }
if !c.isPublicReadable() {
return c.DownloadURL(objectKey)
}
return buildPublicURL(c.cfg, objectKey, false) 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) { func (c *minioClient) logError(ctx context.Context, msg string, fields ...zap.Field) {
if c == nil || c.logger == nil { if c == nil || c.logger == nil {
return return
@@ -47,6 +47,55 @@ func (c *ReloadableClient) PublicURL(objectKey string) (string, error) {
return c.snapshot().PublicURL(objectKey) 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 { func (c *ReloadableClient) snapshot() Client {
if c == nil { if c == nil {
return disabledClient{reason: "object storage client is nil"} return disabledClient{reason: "object storage client is nil"}
@@ -3,6 +3,7 @@ package objectstorage
import ( import (
"fmt" "fmt"
"net/url" "net/url"
"path/filepath"
"strings" "strings"
"github.com/geo-platform/tenant-api/internal/shared/config" "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 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) { func parseURLWithScheme(raw string, defaultScheme string) (*url.URL, error) {
raw = strings.TrimSpace(raw) raw = strings.TrimSpace(raw)
if raw == "" { if raw == "" {
@@ -51,3 +51,23 @@ func TestNormalizeObjectKeyPathDecodesEncodedSegments(t *testing.T) {
t.Fatalf("object key = %q, want %q", got, want) 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/live": {"存活探针", "Kubernetes liveness 探针,永远返回 200。"},
"GET /api/health/ready": {"就绪探针", "Kubernetes readiness 探针,依赖(数据库等)就绪后返回 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 --- // --- Auth ---
"GET /api/auth/password-key": {"登录密码加密公钥", "返回登录密码前端加密使用的临时公钥与 key_id。"}, "GET /api/auth/password-key": {"登录密码加密公钥", "返回登录密码前端加密使用的临时公钥与 key_id。"},
"POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"}, "POST /api/auth/login": {"账号密码登录", "客户后台账号密码登录,返回 access/refresh token。"},
@@ -13,14 +13,17 @@ import (
// TestRouteDocs_CoversEveryRouterEntry parses the tenant-api router source and // TestRouteDocs_CoversEveryRouterEntry parses the tenant-api router source and
// asserts that every registered route has a corresponding entry in routeDocs. // 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 // 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. // summary is added.
func TestRouteDocs_CoversEveryRouterEntry(t *testing.T) { 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 { 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) missing := make([]string, 0)
@@ -133,7 +136,7 @@ func parseEngineRoutes(t *testing.T, path string) []parsedRoute {
return routes return routes
} }
func findRepoSources(t *testing.T) (router, bootstrap string) { func findRepoSources(t *testing.T) (routers []string, bootstrap string) {
t.Helper() t.Helper()
_, file, _, ok := runtime.Caller(0) _, file, _, ok := runtime.Caller(0)
if !ok { if !ok {
@@ -142,6 +145,9 @@ func findRepoSources(t *testing.T) (router, bootstrap string) {
// .../server/internal/shared/swagger/descriptions_parity_test.go -> // .../server/internal/shared/swagger/descriptions_parity_test.go ->
// .../server is three levels up from this file's parent dir. // .../server is three levels up from this file's parent dir.
serverRoot := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "..")) 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") filepath.Join(serverRoot, "internal", "bootstrap", "bootstrap.go")
} }
+16 -2
View File
@@ -303,6 +303,17 @@ func queryParameterNames(route gin.RouteInfo) []string {
} }
switch { 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"): case strings.Contains(path, "/workspace/ai-point-usage"):
add("page", "page_size") add("page", "page_size")
case strings.Contains(path, "/tenant/articles"): case strings.Contains(path, "/tenant/articles"):
@@ -357,13 +368,13 @@ func queryParameter(name string) map[string]any {
func schemaForQueryParameter(name string) map[string]any { func schemaForQueryParameter(name string) map[string]any {
switch name { 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"} 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": 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"} return map[string]any{"type": "integer", "format": "int64"}
case "force": case "force":
return map[string]any{"type": "string", "enum": []string{"1"}} 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"}} return map[string]any{"type": "string", "enum": []string{"true", "false"}}
case "created_from", "created_to", "date_from", "date_to", "business_date": case "created_from", "created_to", "date_from", "date_to", "business_date":
return map[string]any{"type": "string", "format": "date"} return map[string]any{"type": "string", "format": "date"}
@@ -403,6 +414,9 @@ func isMultipartRoute(route gin.RouteInfo) bool {
} }
path := route.Path path := route.Path
return path == "/api/tenant/images" || 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/knowledge/items/file" ||
path == "/api/tenant/kol/manage/profile/avatar" || path == "/api/tenant/kol/manage/profile/avatar" ||
path == "/api/tenant/articles/:id/images" path == "/api/tenant/articles/:id/images"
@@ -45,6 +45,10 @@ func (s *fakeAssetObjectStorage) PublicURL(string) (string, error) {
panic("unexpected call") panic("unexpected call")
} }
func (s *fakeAssetObjectStorage) DownloadURL(string) (string, error) {
panic("unexpected call")
}
func TestAssetHandlerServeDesktop_AllowsStaleSignedTenantAsset(t *testing.T) { func TestAssetHandlerServeDesktop_AllowsStaleSignedTenantAsset(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)