feat: redesign metadata as KV store, add custom properties and WOPI Discovery
Some checks failed
Test / test (push) Failing after 2m32s
Some checks failed
Test / test (push) Failing after 2m32s
Replace one-to-one FileMetadata table with flexible ObjectMetadata KV pairs, add custom property definitions, WOPI Discovery auto-configuration, and per-extension action URL support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
88
migrations/002_metadata_kv_redesign.sql
Normal file
88
migrations/002_metadata_kv_redesign.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- 迁移:文件元数据系统重构
|
||||
-- 将固定列 FileMetadata 表替换为灵活的 ObjectMetadata KV 表
|
||||
-- 日期:2026-02-23
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- ==================== 1. object 表新增 mime_type 列 ====================
|
||||
ALTER TABLE object ADD COLUMN IF NOT EXISTS mime_type VARCHAR(127);
|
||||
|
||||
-- ==================== 2. physicalfile 表新增 checksum_sha256 列 ====================
|
||||
ALTER TABLE physicalfile ADD COLUMN IF NOT EXISTS checksum_sha256 VARCHAR(64);
|
||||
|
||||
-- ==================== 3. 创建 objectmetadata KV 表 ====================
|
||||
CREATE TABLE IF NOT EXISTS objectmetadata (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
object_id UUID NOT NULL REFERENCES object(id) ON DELETE CASCADE,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
is_public BOOLEAN NOT NULL DEFAULT false,
|
||||
CONSTRAINT uq_object_metadata_object_name UNIQUE (object_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_object_metadata_object_id
|
||||
ON objectmetadata (object_id);
|
||||
|
||||
-- ==================== 4. 创建 custompropertydefinition 表 ====================
|
||||
CREATE TABLE IF NOT EXISTS custompropertydefinition (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
owner_id UUID NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
type VARCHAR NOT NULL,
|
||||
icon VARCHAR(100),
|
||||
options JSON,
|
||||
default_value VARCHAR(500),
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_custompropertydefinition_owner_id
|
||||
ON custompropertydefinition (owner_id);
|
||||
|
||||
-- ==================== 5. 迁移旧数据(从 filemetadata 到 objectmetadata)====================
|
||||
-- 将 filemetadata 中的数据迁移到 objectmetadata KV 格式
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'filemetadata') THEN
|
||||
-- 同时将 mime_type 上提到 object 表
|
||||
UPDATE object o
|
||||
SET mime_type = fm.mime_type
|
||||
FROM filemetadata fm
|
||||
WHERE fm.object_id = o.id AND fm.mime_type IS NOT NULL;
|
||||
|
||||
-- 将 checksum_sha256 迁移到 physicalfile 表
|
||||
UPDATE physicalfile pf
|
||||
SET checksum_sha256 = fm.checksum_sha256
|
||||
FROM filemetadata fm
|
||||
JOIN object o ON fm.object_id = o.id
|
||||
WHERE o.physical_file_id = pf.id AND fm.checksum_sha256 IS NOT NULL;
|
||||
|
||||
-- 迁移 width → exif:width
|
||||
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
|
||||
SELECT gen_random_uuid(), object_id, 'exif:width', CAST(width AS TEXT), true
|
||||
FROM filemetadata WHERE width IS NOT NULL;
|
||||
|
||||
-- 迁移 height → exif:height
|
||||
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
|
||||
SELECT gen_random_uuid(), object_id, 'exif:height', CAST(height AS TEXT), true
|
||||
FROM filemetadata WHERE height IS NOT NULL;
|
||||
|
||||
-- 迁移 duration → stream:duration
|
||||
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
|
||||
SELECT gen_random_uuid(), object_id, 'stream:duration', CAST(duration AS TEXT), true
|
||||
FROM filemetadata WHERE duration IS NOT NULL;
|
||||
|
||||
-- 迁移 bitrate → stream:bitrate
|
||||
INSERT INTO objectmetadata (id, object_id, name, value, is_public)
|
||||
SELECT gen_random_uuid(), object_id, 'stream:bitrate', CAST(bitrate AS TEXT), true
|
||||
FROM filemetadata WHERE bitrate IS NOT NULL;
|
||||
|
||||
-- 删除旧表
|
||||
DROP TABLE filemetadata;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMIT;
|
||||
@@ -1,16 +1,18 @@
|
||||
"""
|
||||
管理员文件应用管理端点
|
||||
|
||||
提供文件查看器应用的 CRUD、扩展名管理和用户组权限管理。
|
||||
提供文件查看器应用的 CRUD、扩展名管理、用户组权限管理和 WOPI Discovery。
|
||||
"""
|
||||
from uuid import UUID
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from loguru import logger as l
|
||||
from sqlalchemy import select
|
||||
|
||||
from middleware.auth import admin_required
|
||||
from middleware.dependencies import SessionDep, TableViewRequestDep
|
||||
from service.wopi import parse_wopi_discovery_xml
|
||||
from sqlmodels import (
|
||||
FileApp,
|
||||
FileAppCreateRequest,
|
||||
@@ -21,7 +23,10 @@ from sqlmodels import (
|
||||
FileAppUpdateRequest,
|
||||
ExtensionUpdateRequest,
|
||||
GroupAccessUpdateRequest,
|
||||
WopiDiscoveredExtension,
|
||||
WopiDiscoveryResponse,
|
||||
)
|
||||
from sqlmodels.file_app import FileAppType
|
||||
from utils import http_exceptions
|
||||
|
||||
admin_file_app_router = APIRouter(
|
||||
@@ -123,6 +128,7 @@ async def create_file_app(
|
||||
group_links.append(link)
|
||||
if group_links:
|
||||
await session.commit()
|
||||
await session.refresh(app)
|
||||
|
||||
l.info(f"创建文件应用: {app.name} ({app.app_key})")
|
||||
|
||||
@@ -261,16 +267,22 @@ async def update_extensions(
|
||||
if not app:
|
||||
http_exceptions.raise_not_found("应用不存在")
|
||||
|
||||
# 删除旧的扩展名
|
||||
# 保留旧扩展名的 wopi_action_url(Discovery 填充的值)
|
||||
old_extensions: list[FileAppExtension] = await FileAppExtension.get(
|
||||
session,
|
||||
FileAppExtension.app_id == app_id,
|
||||
fetch_mode="all",
|
||||
)
|
||||
old_url_map: dict[str, str] = {
|
||||
ext.extension: ext.wopi_action_url
|
||||
for ext in old_extensions
|
||||
if ext.wopi_action_url
|
||||
}
|
||||
for old_ext in old_extensions:
|
||||
await FileAppExtension.delete(session, old_ext, commit=False)
|
||||
await session.flush()
|
||||
|
||||
# 创建新的扩展名
|
||||
# 创建新的扩展名(保留已有的 wopi_action_url)
|
||||
new_extensions: list[FileAppExtension] = []
|
||||
for i, ext in enumerate(request.extensions):
|
||||
normalized = ext.lower().strip().lstrip('.')
|
||||
@@ -278,12 +290,14 @@ async def update_extensions(
|
||||
app_id=app_id,
|
||||
extension=normalized,
|
||||
priority=i,
|
||||
wopi_action_url=old_url_map.get(normalized),
|
||||
)
|
||||
session.add(ext_record)
|
||||
new_extensions.append(ext_record)
|
||||
|
||||
await session.commit()
|
||||
# refresh 新创建的记录
|
||||
# refresh commit 后过期的对象
|
||||
await session.refresh(app)
|
||||
for ext_record in new_extensions:
|
||||
await session.refresh(ext_record)
|
||||
|
||||
@@ -336,6 +350,7 @@ async def update_group_access(
|
||||
new_links.append(link)
|
||||
|
||||
await session.commit()
|
||||
await session.refresh(app)
|
||||
|
||||
extensions = await FileAppExtension.get(
|
||||
session,
|
||||
@@ -346,3 +361,102 @@ async def update_group_access(
|
||||
l.info(f"更新文件应用 {app.app_key} 的用户组权限: {request.group_ids}")
|
||||
|
||||
return FileAppResponse.from_app(app, extensions, new_links)
|
||||
|
||||
|
||||
@admin_file_app_router.post(
|
||||
path='/{app_id}/discover',
|
||||
summary='执行 WOPI Discovery',
|
||||
)
|
||||
async def discover_wopi(
|
||||
session: SessionDep,
|
||||
app_id: UUID,
|
||||
) -> WopiDiscoveryResponse:
|
||||
"""
|
||||
从 WOPI 服务端获取 Discovery XML 并自动配置扩展名和 URL 模板。
|
||||
|
||||
流程:
|
||||
1. 验证 FileApp 存在且为 WOPI 类型
|
||||
2. 使用 FileApp.wopi_discovery_url 获取 Discovery XML
|
||||
3. 解析 XML,提取扩展名和动作 URL
|
||||
4. 全量替换 FileAppExtension 记录(带 wopi_action_url)
|
||||
|
||||
认证:管理员权限
|
||||
|
||||
错误处理:
|
||||
- 404: 应用不存在
|
||||
- 400: 非 WOPI 类型 / discovery URL 未配置 / XML 解析失败
|
||||
- 502: WOPI 服务端不可达或返回无效响应
|
||||
"""
|
||||
app: FileApp | None = await FileApp.get(session, FileApp.id == app_id)
|
||||
if not app:
|
||||
http_exceptions.raise_not_found("应用不存在")
|
||||
|
||||
if app.type != FileAppType.WOPI:
|
||||
http_exceptions.raise_bad_request("仅 WOPI 类型应用支持自动发现")
|
||||
|
||||
if not app.wopi_discovery_url:
|
||||
http_exceptions.raise_bad_request("未配置 WOPI Discovery URL")
|
||||
|
||||
# commit 后对象会过期,先保存需要的值
|
||||
discovery_url = app.wopi_discovery_url
|
||||
app_key = app.app_key
|
||||
|
||||
# 获取 Discovery XML
|
||||
try:
|
||||
async with aiohttp.ClientSession() as client:
|
||||
async with client.get(
|
||||
discovery_url,
|
||||
timeout=aiohttp.ClientTimeout(total=15),
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
http_exceptions.raise_bad_gateway(
|
||||
f"WOPI 服务端返回 HTTP {resp.status}"
|
||||
)
|
||||
xml_content = await resp.text()
|
||||
except aiohttp.ClientError as e:
|
||||
http_exceptions.raise_bad_gateway(f"无法连接 WOPI 服务端: {e}")
|
||||
|
||||
# 解析 XML
|
||||
try:
|
||||
action_urls, app_names = parse_wopi_discovery_xml(xml_content)
|
||||
except ValueError as e:
|
||||
http_exceptions.raise_bad_request(str(e))
|
||||
|
||||
if not action_urls:
|
||||
return WopiDiscoveryResponse(app_names=app_names)
|
||||
|
||||
# 全量替换扩展名
|
||||
old_extensions: list[FileAppExtension] = await FileAppExtension.get(
|
||||
session,
|
||||
FileAppExtension.app_id == app_id,
|
||||
fetch_mode="all",
|
||||
)
|
||||
for old_ext in old_extensions:
|
||||
await FileAppExtension.delete(session, old_ext, commit=False)
|
||||
await session.flush()
|
||||
|
||||
new_extensions: list[FileAppExtension] = []
|
||||
discovered: list[WopiDiscoveredExtension] = []
|
||||
for i, (ext, action_url) in enumerate(sorted(action_urls.items())):
|
||||
ext_record = FileAppExtension(
|
||||
app_id=app_id,
|
||||
extension=ext,
|
||||
priority=i,
|
||||
wopi_action_url=action_url,
|
||||
)
|
||||
session.add(ext_record)
|
||||
new_extensions.append(ext_record)
|
||||
discovered.append(WopiDiscoveredExtension(extension=ext, action_url=action_url))
|
||||
|
||||
await session.commit()
|
||||
|
||||
l.info(
|
||||
f"WOPI Discovery 完成: 应用 {app_key}, "
|
||||
f"发现 {len(discovered)} 个扩展名"
|
||||
)
|
||||
|
||||
return WopiDiscoveryResponse(
|
||||
discovered_extensions=discovered,
|
||||
app_names=app_names,
|
||||
applied_count=len(discovered),
|
||||
)
|
||||
|
||||
@@ -812,6 +812,7 @@ async def create_wopi_session(
|
||||
)
|
||||
|
||||
wopi_app: FileApp | None = None
|
||||
matched_ext_record: FileAppExtension | None = None
|
||||
for ext_record in ext_records:
|
||||
app = ext_record.app
|
||||
if app.type == FileAppType.WOPI and app.is_enabled:
|
||||
@@ -827,13 +828,20 @@ async def create_wopi_session(
|
||||
if not result.first():
|
||||
continue
|
||||
wopi_app = app
|
||||
matched_ext_record = ext_record
|
||||
break
|
||||
|
||||
if not wopi_app:
|
||||
http_exceptions.raise_not_found("无可用的 WOPI 查看器")
|
||||
|
||||
if not wopi_app.wopi_editor_url_template:
|
||||
http_exceptions.raise_bad_request("WOPI 应用未配置编辑器 URL 模板")
|
||||
# 优先使用 per-extension URL(Discovery 自动填充),回退到全局模板
|
||||
editor_url_template: str | None = None
|
||||
if matched_ext_record and matched_ext_record.wopi_action_url:
|
||||
editor_url_template = matched_ext_record.wopi_action_url
|
||||
if not editor_url_template:
|
||||
editor_url_template = wopi_app.wopi_editor_url_template
|
||||
if not editor_url_template:
|
||||
http_exceptions.raise_bad_request("WOPI 应用未配置编辑器 URL 模板,请先执行 Discovery 或手动配置")
|
||||
|
||||
# 获取站点 URL
|
||||
site_url_setting: Setting | None = await Setting.get(
|
||||
@@ -849,12 +857,8 @@ async def create_wopi_session(
|
||||
# 构建 wopi_src
|
||||
wopi_src = f"{site_url}/wopi/files/{file_id}"
|
||||
|
||||
# 构建 editor URL
|
||||
editor_url = wopi_app.wopi_editor_url_template.format(
|
||||
wopi_src=wopi_src,
|
||||
access_token=token,
|
||||
access_token_ttl=access_token_ttl,
|
||||
)
|
||||
# 构建 editor URL(只替换 wopi_src,token 通过 POST 表单传递)
|
||||
editor_url = editor_url_template.format(wopi_src=wopi_src)
|
||||
|
||||
return WopiSessionResponse(
|
||||
wopi_src=wopi_src,
|
||||
|
||||
@@ -34,6 +34,12 @@ from sqlmodels import (
|
||||
TaskSummaryBase,
|
||||
TaskType,
|
||||
User,
|
||||
# 元数据相关
|
||||
ObjectMetadata,
|
||||
MetadataResponse,
|
||||
MetadataPatchRequest,
|
||||
INTERNAL_NAMESPACES,
|
||||
USER_WRITABLE_NAMESPACES,
|
||||
)
|
||||
from service.storage import (
|
||||
LocalStorageService,
|
||||
@@ -46,10 +52,13 @@ from service.storage.object import soft_delete_objects
|
||||
from sqlmodels.database_connection import DatabaseManager
|
||||
from utils import http_exceptions
|
||||
|
||||
from .custom_property import router as custom_property_router
|
||||
|
||||
object_router = APIRouter(
|
||||
prefix="/object",
|
||||
tags=["object"]
|
||||
)
|
||||
object_router.include_router(custom_property_router)
|
||||
|
||||
@object_router.post(
|
||||
path='/',
|
||||
@@ -503,6 +512,7 @@ async def router_object_property(
|
||||
name=obj.name,
|
||||
type=obj.type,
|
||||
size=obj.size,
|
||||
mime_type=obj.mime_type,
|
||||
created_at=obj.created_at,
|
||||
updated_at=obj.updated_at,
|
||||
parent_id=obj.parent_id,
|
||||
@@ -530,7 +540,7 @@ async def router_object_property_detail(
|
||||
obj = await Object.get(
|
||||
session,
|
||||
(Object.id == id) & (Object.deleted_at == None),
|
||||
load=Object.file_metadata,
|
||||
load=Object.metadata_entries,
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="对象不存在")
|
||||
@@ -553,39 +563,43 @@ async def router_object_property_detail(
|
||||
total_views = sum(s.views for s in shares)
|
||||
total_downloads = sum(s.downloads for s in shares)
|
||||
|
||||
# 获取物理文件引用计数
|
||||
# 获取物理文件信息(引用计数、校验和)
|
||||
reference_count = 1
|
||||
checksum_md5: str | None = None
|
||||
checksum_sha256: str | None = None
|
||||
if obj.physical_file_id:
|
||||
physical_file = await PhysicalFile.get(session, PhysicalFile.id == obj.physical_file_id)
|
||||
if physical_file:
|
||||
reference_count = physical_file.reference_count
|
||||
checksum_md5 = physical_file.checksum_md5
|
||||
checksum_sha256 = physical_file.checksum_sha256
|
||||
|
||||
# 构建响应
|
||||
response = ObjectPropertyDetailResponse(
|
||||
# 构建元数据字典(排除内部命名空间)
|
||||
metadata: dict[str, str] = {}
|
||||
for entry in obj.metadata_entries:
|
||||
ns = entry.name.split(":")[0] if ":" in entry.name else ""
|
||||
if ns not in INTERNAL_NAMESPACES:
|
||||
metadata[entry.name] = entry.value
|
||||
|
||||
return ObjectPropertyDetailResponse(
|
||||
id=obj.id,
|
||||
name=obj.name,
|
||||
type=obj.type,
|
||||
size=obj.size,
|
||||
mime_type=obj.mime_type,
|
||||
created_at=obj.created_at,
|
||||
updated_at=obj.updated_at,
|
||||
parent_id=obj.parent_id,
|
||||
checksum_md5=checksum_md5,
|
||||
checksum_sha256=checksum_sha256,
|
||||
policy_name=policy_name,
|
||||
share_count=share_count,
|
||||
total_views=total_views,
|
||||
total_downloads=total_downloads,
|
||||
reference_count=reference_count,
|
||||
metadatas=metadata,
|
||||
)
|
||||
|
||||
# 添加文件元数据
|
||||
if obj.file_metadata:
|
||||
response.mime_type = obj.file_metadata.mime_type
|
||||
response.width = obj.file_metadata.width
|
||||
response.height = obj.file_metadata.height
|
||||
response.duration = obj.file_metadata.duration
|
||||
response.checksum_md5 = obj.file_metadata.checksum_md5
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@object_router.patch(
|
||||
path='/{object_id}/policy',
|
||||
@@ -718,3 +732,132 @@ async def router_object_switch_policy(
|
||||
created_at=task.created_at,
|
||||
updated_at=task.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ==================== 元数据端点 ====================
|
||||
|
||||
@object_router.get(
|
||||
path='/{object_id}/metadata',
|
||||
summary='获取对象元数据',
|
||||
description='获取对象的元数据键值对,可按命名空间过滤。',
|
||||
)
|
||||
async def router_get_object_metadata(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
object_id: UUID,
|
||||
ns: str | None = None,
|
||||
) -> MetadataResponse:
|
||||
"""
|
||||
获取对象元数据端点
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
查询参数:
|
||||
- ns: 逗号分隔的命名空间列表(如 exif,stream),不传返回所有非内部命名空间
|
||||
|
||||
错误处理:
|
||||
- 404: 对象不存在
|
||||
- 403: 无权查看此对象
|
||||
"""
|
||||
obj = await Object.get(
|
||||
session,
|
||||
(Object.id == object_id) & (Object.deleted_at == None),
|
||||
load=Object.metadata_entries,
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="对象不存在")
|
||||
|
||||
if obj.owner_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="无权查看此对象")
|
||||
|
||||
# 解析命名空间过滤
|
||||
ns_filter: set[str] | None = None
|
||||
if ns:
|
||||
ns_filter = {n.strip() for n in ns.split(",") if n.strip()}
|
||||
# 不允许查看内部命名空间
|
||||
ns_filter -= INTERNAL_NAMESPACES
|
||||
|
||||
# 构建元数据字典
|
||||
metadata: dict[str, str] = {}
|
||||
for entry in obj.metadata_entries:
|
||||
entry_ns = entry.name.split(":")[0] if ":" in entry.name else ""
|
||||
if entry_ns in INTERNAL_NAMESPACES:
|
||||
continue
|
||||
if ns_filter is not None and entry_ns not in ns_filter:
|
||||
continue
|
||||
metadata[entry.name] = entry.value
|
||||
|
||||
return MetadataResponse(metadatas=metadata)
|
||||
|
||||
|
||||
@object_router.patch(
|
||||
path='/{object_id}/metadata',
|
||||
summary='批量更新对象元数据',
|
||||
description='批量设置或删除对象的元数据条目。仅允许修改 custom: 命名空间。',
|
||||
status_code=204,
|
||||
)
|
||||
async def router_patch_object_metadata(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
object_id: UUID,
|
||||
request: MetadataPatchRequest,
|
||||
) -> None:
|
||||
"""
|
||||
批量更新对象元数据端点
|
||||
|
||||
请求体中值为 None 的键将被删除,其余键将被设置/更新。
|
||||
用户只能修改 custom: 命名空间的条目。
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
错误处理:
|
||||
- 400: 尝试修改非 custom: 命名空间的条目
|
||||
- 404: 对象不存在
|
||||
- 403: 无权操作此对象
|
||||
"""
|
||||
obj = await Object.get(
|
||||
session,
|
||||
(Object.id == object_id) & (Object.deleted_at == None),
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=404, detail="对象不存在")
|
||||
|
||||
if obj.owner_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此对象")
|
||||
|
||||
for patch in request.patches:
|
||||
# 验证命名空间
|
||||
patch_ns = patch.key.split(":")[0] if ":" in patch.key else ""
|
||||
if patch_ns not in USER_WRITABLE_NAMESPACES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不允许修改命名空间 '{patch_ns}' 的元数据,仅允许 custom: 命名空间",
|
||||
)
|
||||
|
||||
if patch.value is None:
|
||||
# 删除元数据条目
|
||||
existing = await ObjectMetadata.get(
|
||||
session,
|
||||
(ObjectMetadata.object_id == object_id) & (ObjectMetadata.name == patch.key),
|
||||
)
|
||||
if existing:
|
||||
await ObjectMetadata.delete(session, instances=existing)
|
||||
else:
|
||||
# 设置/更新元数据条目
|
||||
existing = await ObjectMetadata.get(
|
||||
session,
|
||||
(ObjectMetadata.object_id == object_id) & (ObjectMetadata.name == patch.key),
|
||||
)
|
||||
if existing:
|
||||
existing.value = patch.value
|
||||
await existing.save(session)
|
||||
else:
|
||||
entry = ObjectMetadata(
|
||||
object_id=object_id,
|
||||
name=patch.key,
|
||||
value=patch.value,
|
||||
is_public=True,
|
||||
)
|
||||
await entry.save(session)
|
||||
|
||||
l.info(f"用户 {user.id} 更新了对象 {object_id} 的 {len(request.patches)} 条元数据")
|
||||
|
||||
178
routers/api/v1/object/custom_property/__init__.py
Normal file
178
routers/api/v1/object/custom_property/__init__.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
用户自定义属性定义路由
|
||||
|
||||
提供自定义属性模板的增删改查功能。
|
||||
用户可以定义类型化的属性模板(如标签、评分、分类等),
|
||||
然后通过元数据 PATCH 端点为对象设置属性值。
|
||||
|
||||
路由前缀:/custom_property
|
||||
"""
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from loguru import logger as l
|
||||
|
||||
from middleware.auth import auth_required
|
||||
from middleware.dependencies import SessionDep
|
||||
from sqlmodels import (
|
||||
CustomPropertyDefinition,
|
||||
CustomPropertyCreateRequest,
|
||||
CustomPropertyUpdateRequest,
|
||||
CustomPropertyResponse,
|
||||
User,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/custom_property",
|
||||
tags=["custom_property"],
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
path='',
|
||||
summary='获取自定义属性定义列表',
|
||||
description='获取当前用户的所有自定义属性定义,按 sort_order 排序。',
|
||||
)
|
||||
async def router_list_custom_properties(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
) -> list[CustomPropertyResponse]:
|
||||
"""
|
||||
获取自定义属性定义列表端点
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
返回当前用户定义的所有自定义属性模板。
|
||||
"""
|
||||
definitions = await CustomPropertyDefinition.get(
|
||||
session,
|
||||
CustomPropertyDefinition.owner_id == user.id,
|
||||
fetch_mode="all",
|
||||
)
|
||||
|
||||
return [
|
||||
CustomPropertyResponse(
|
||||
id=d.id,
|
||||
name=d.name,
|
||||
type=d.type,
|
||||
icon=d.icon,
|
||||
options=d.options,
|
||||
default_value=d.default_value,
|
||||
sort_order=d.sort_order,
|
||||
)
|
||||
for d in sorted(definitions, key=lambda x: x.sort_order)
|
||||
]
|
||||
|
||||
|
||||
@router.post(
|
||||
path='',
|
||||
summary='创建自定义属性定义',
|
||||
description='创建一个新的自定义属性模板。',
|
||||
status_code=204,
|
||||
)
|
||||
async def router_create_custom_property(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
request: CustomPropertyCreateRequest,
|
||||
) -> None:
|
||||
"""
|
||||
创建自定义属性定义端点
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
错误处理:
|
||||
- 400: 请求数据无效
|
||||
- 409: 同名属性已存在
|
||||
"""
|
||||
# 检查同名属性
|
||||
existing = await CustomPropertyDefinition.get(
|
||||
session,
|
||||
(CustomPropertyDefinition.owner_id == user.id) &
|
||||
(CustomPropertyDefinition.name == request.name),
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(status_code=409, detail="同名自定义属性已存在")
|
||||
|
||||
definition = CustomPropertyDefinition(
|
||||
owner_id=user.id,
|
||||
name=request.name,
|
||||
type=request.type,
|
||||
icon=request.icon,
|
||||
options=request.options,
|
||||
default_value=request.default_value,
|
||||
)
|
||||
await definition.save(session)
|
||||
|
||||
l.info(f"用户 {user.id} 创建了自定义属性: {request.name}")
|
||||
|
||||
|
||||
@router.patch(
|
||||
path='/{id}',
|
||||
summary='更新自定义属性定义',
|
||||
description='更新自定义属性模板的名称、图标、选项等。',
|
||||
status_code=204,
|
||||
)
|
||||
async def router_update_custom_property(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
id: UUID,
|
||||
request: CustomPropertyUpdateRequest,
|
||||
) -> None:
|
||||
"""
|
||||
更新自定义属性定义端点
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
错误处理:
|
||||
- 404: 属性定义不存在
|
||||
- 403: 无权操作此属性
|
||||
"""
|
||||
definition = await CustomPropertyDefinition.get(
|
||||
session,
|
||||
CustomPropertyDefinition.id == id,
|
||||
)
|
||||
if not definition:
|
||||
raise HTTPException(status_code=404, detail="自定义属性不存在")
|
||||
|
||||
if definition.owner_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此属性")
|
||||
|
||||
definition = await definition.update(session, request)
|
||||
|
||||
l.info(f"用户 {user.id} 更新了自定义属性: {id}")
|
||||
|
||||
|
||||
@router.delete(
|
||||
path='/{id}',
|
||||
summary='删除自定义属性定义',
|
||||
description='删除自定义属性模板。注意:不会自动清理已使用该属性的元数据条目。',
|
||||
status_code=204,
|
||||
)
|
||||
async def router_delete_custom_property(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
id: UUID,
|
||||
) -> None:
|
||||
"""
|
||||
删除自定义属性定义端点
|
||||
|
||||
认证:JWT token 必填
|
||||
|
||||
错误处理:
|
||||
- 404: 属性定义不存在
|
||||
- 403: 无权操作此属性
|
||||
"""
|
||||
definition = await CustomPropertyDefinition.get(
|
||||
session,
|
||||
CustomPropertyDefinition.id == id,
|
||||
)
|
||||
if not definition:
|
||||
raise HTTPException(status_code=404, detail="自定义属性不存在")
|
||||
|
||||
if definition.owner_id != user.id:
|
||||
raise HTTPException(status_code=403, detail="无权操作此属性")
|
||||
|
||||
await CustomPropertyDefinition.delete(session, instances=definition)
|
||||
|
||||
l.info(f"用户 {user.id} 删除了自定义属性: {id}")
|
||||
185
service/wopi/__init__.py
Normal file
185
service/wopi/__init__.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""
|
||||
WOPI Discovery 服务模块
|
||||
|
||||
解析 WOPI 服务端(Collabora / OnlyOffice 等)的 Discovery XML,
|
||||
提取支持的文件扩展名及对应的编辑器 URL 模板。
|
||||
|
||||
参考:Cloudreve pkg/wopi/discovery.go 和 pkg/wopi/wopi.go
|
||||
"""
|
||||
import xml.etree.ElementTree as ET
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
|
||||
from loguru import logger as l
|
||||
|
||||
# WOPI URL 模板中已知的查询参数占位符及其替换值
|
||||
# 值为 None 表示删除该参数,非 None 表示替换为该值
|
||||
# 参考 Cloudreve pkg/wopi/wopi.go queryPlaceholders
|
||||
_WOPI_QUERY_PLACEHOLDERS: dict[str, str | None] = {
|
||||
'BUSINESS_USER': None,
|
||||
'DC_LLCC': 'lng',
|
||||
'DISABLE_ASYNC': None,
|
||||
'DISABLE_CHAT': None,
|
||||
'EMBEDDED': 'true',
|
||||
'FULLSCREEN': 'true',
|
||||
'HOST_SESSION_ID': None,
|
||||
'SESSION_CONTEXT': None,
|
||||
'RECORDING': None,
|
||||
'THEME_ID': 'darkmode',
|
||||
'UI_LLCC': 'lng',
|
||||
'VALIDATOR_TEST_CATEGORY': None,
|
||||
}
|
||||
|
||||
_WOPI_SRC_PLACEHOLDER = 'WOPI_SOURCE'
|
||||
|
||||
|
||||
def process_wopi_action_url(raw_urlsrc: str) -> str:
|
||||
"""
|
||||
将 WOPI Discovery 中的原始 urlsrc 转换为 DiskNext 可用的 URL 模板。
|
||||
|
||||
处理流程(参考 Cloudreve generateActionUrl):
|
||||
1. 去除 ``<>`` 占位符标记
|
||||
2. 解析查询参数,替换/删除已知占位符
|
||||
3. ``WOPI_SOURCE`` → ``{wopi_src}``
|
||||
|
||||
注意:access_token 和 access_token_ttl 不放在 URL 中,
|
||||
根据 WOPI 规范它们通过 POST 表单字段传递给编辑器。
|
||||
|
||||
:param raw_urlsrc: WOPI Discovery XML 中的 urlsrc 原始值
|
||||
:return: 处理后的 URL 模板字符串,包含 {wopi_src} 占位符
|
||||
"""
|
||||
# 去除 <> 标记
|
||||
cleaned = raw_urlsrc.replace('<', '').replace('>', '')
|
||||
parsed = urlparse(cleaned)
|
||||
raw_params = parse_qs(parsed.query, keep_blank_values=True)
|
||||
|
||||
new_params: list[tuple[str, str]] = []
|
||||
is_src_replaced = False
|
||||
|
||||
for key, values in raw_params.items():
|
||||
value = values[0] if values else ''
|
||||
|
||||
# WOPI_SOURCE 占位符 → {wopi_src}
|
||||
if value == _WOPI_SRC_PLACEHOLDER:
|
||||
new_params.append((key, '{wopi_src}'))
|
||||
is_src_replaced = True
|
||||
continue
|
||||
|
||||
# 已知占位符
|
||||
if value in _WOPI_QUERY_PLACEHOLDERS:
|
||||
replacement = _WOPI_QUERY_PLACEHOLDERS[value]
|
||||
if replacement is not None:
|
||||
new_params.append((key, replacement))
|
||||
# replacement 为 None 时删除该参数
|
||||
continue
|
||||
|
||||
# 其他参数保留原值
|
||||
new_params.append((key, value))
|
||||
|
||||
# 如果没有找到 WOPI_SOURCE 占位符,手动添加 WOPISrc
|
||||
if not is_src_replaced:
|
||||
new_params.append(('WOPISrc', '{wopi_src}'))
|
||||
|
||||
# LibreOffice/Collabora 需要 lang 参数(避免重复添加)
|
||||
existing_keys = {k for k, _ in new_params}
|
||||
if 'lang' not in existing_keys:
|
||||
new_params.append(('lang', 'lng'))
|
||||
|
||||
# 注意:access_token 和 access_token_ttl 不放在 URL 中
|
||||
# 根据 WOPI 规范,它们通过 POST 表单字段传递给编辑器
|
||||
|
||||
# 重建 URL
|
||||
new_query = urlencode(new_params, safe='{}')
|
||||
result = urlunparse((
|
||||
parsed.scheme,
|
||||
parsed.netloc,
|
||||
parsed.path,
|
||||
parsed.params,
|
||||
new_query,
|
||||
'',
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_wopi_discovery_xml(xml_content: str) -> tuple[dict[str, str], list[str]]:
|
||||
"""
|
||||
解析 WOPI Discovery XML,提取扩展名到 URL 模板的映射。
|
||||
|
||||
XML 结构::
|
||||
|
||||
<wopi-discovery>
|
||||
<net-zone name="external-https">
|
||||
<app name="Writer" favIconUrl="...">
|
||||
<action name="edit" ext="docx" urlsrc="https://..."/>
|
||||
<action name="view" ext="docx" urlsrc="https://..."/>
|
||||
</app>
|
||||
</net-zone>
|
||||
</wopi-discovery>
|
||||
|
||||
动作优先级:edit > embedview > view(参考 Cloudreve discovery.go)
|
||||
|
||||
:param xml_content: WOPI Discovery 端点返回的 XML 字符串
|
||||
:return: (action_urls, app_names) 元组
|
||||
action_urls: {extension: processed_url_template}
|
||||
app_names: 发现的应用名称列表
|
||||
:raises ValueError: XML 解析失败或格式无效
|
||||
"""
|
||||
try:
|
||||
root = ET.fromstring(xml_content)
|
||||
except ET.ParseError as e:
|
||||
raise ValueError(f"WOPI Discovery XML 解析失败: {e}")
|
||||
|
||||
# 查找 net-zone(可能有多个,取第一个非空的)
|
||||
net_zones = root.findall('net-zone')
|
||||
if not net_zones:
|
||||
raise ValueError("WOPI Discovery XML 缺少 net-zone 节点")
|
||||
|
||||
# ext_actions: {extension: {action_name: urlsrc}}
|
||||
ext_actions: dict[str, dict[str, str]] = {}
|
||||
app_names: list[str] = []
|
||||
|
||||
for net_zone in net_zones:
|
||||
for app_elem in net_zone.findall('app'):
|
||||
app_name = app_elem.get('name', '')
|
||||
if app_name:
|
||||
app_names.append(app_name)
|
||||
|
||||
for action_elem in app_elem.findall('action'):
|
||||
action_name = action_elem.get('name', '')
|
||||
ext = action_elem.get('ext', '')
|
||||
urlsrc = action_elem.get('urlsrc', '')
|
||||
|
||||
if not ext or not urlsrc:
|
||||
continue
|
||||
|
||||
# 只关注 edit / embedview / view 三种动作
|
||||
if action_name not in ('edit', 'embedview', 'view'):
|
||||
continue
|
||||
|
||||
if ext not in ext_actions:
|
||||
ext_actions[ext] = {}
|
||||
ext_actions[ext][action_name] = urlsrc
|
||||
|
||||
# 为每个扩展名选择最佳 URL: edit > embedview > view
|
||||
action_urls: dict[str, str] = {}
|
||||
for ext, actions_map in ext_actions.items():
|
||||
selected_urlsrc: str | None = None
|
||||
for preferred in ('edit', 'embedview', 'view'):
|
||||
if preferred in actions_map:
|
||||
selected_urlsrc = actions_map[preferred]
|
||||
break
|
||||
|
||||
if selected_urlsrc:
|
||||
action_urls[ext] = process_wopi_action_url(selected_urlsrc)
|
||||
|
||||
# 去重 app_names
|
||||
seen: set[str] = set()
|
||||
unique_names: list[str] = []
|
||||
for name in app_names:
|
||||
if name not in seen:
|
||||
seen.add(name)
|
||||
unique_names.append(name)
|
||||
|
||||
l.info(f"WOPI Discovery 解析完成: {len(action_urls)} 个扩展名, 应用: {unique_names}")
|
||||
|
||||
return action_urls, unique_names
|
||||
@@ -69,8 +69,6 @@ from .object import (
|
||||
CreateUploadSessionRequest,
|
||||
DirectoryCreateRequest,
|
||||
DirectoryResponse,
|
||||
FileMetadata,
|
||||
FileMetadataBase,
|
||||
Object,
|
||||
ObjectBase,
|
||||
ObjectCopyRequest,
|
||||
@@ -98,6 +96,24 @@ from .object import (
|
||||
TrashRestoreRequest,
|
||||
TrashDeleteRequest,
|
||||
)
|
||||
from .object_metadata import (
|
||||
ObjectMetadata,
|
||||
ObjectMetadataBase,
|
||||
MetadataNamespace,
|
||||
MetadataResponse,
|
||||
MetadataPatchItem,
|
||||
MetadataPatchRequest,
|
||||
INTERNAL_NAMESPACES,
|
||||
USER_WRITABLE_NAMESPACES,
|
||||
)
|
||||
from .custom_property import (
|
||||
CustomPropertyDefinition,
|
||||
CustomPropertyDefinitionBase,
|
||||
CustomPropertyType,
|
||||
CustomPropertyCreateRequest,
|
||||
CustomPropertyUpdateRequest,
|
||||
CustomPropertyResponse,
|
||||
)
|
||||
from .physical_file import PhysicalFile, PhysicalFileBase
|
||||
from .uri import DiskNextURI, FileSystemNamespace
|
||||
from .order import Order, OrderStatus, OrderType
|
||||
@@ -131,6 +147,7 @@ from .file_app import (
|
||||
FileAppSummary, FileViewersResponse, SetDefaultViewerRequest, UserFileAppDefaultResponse,
|
||||
FileAppCreateRequest, FileAppUpdateRequest, FileAppResponse, FileAppListResponse,
|
||||
ExtensionUpdateRequest, GroupAccessUpdateRequest, WopiSessionResponse,
|
||||
WopiDiscoveredExtension, WopiDiscoveryResponse,
|
||||
)
|
||||
from .wopi import WopiFileInfo, WopiAccessTokenPayload
|
||||
|
||||
|
||||
135
sqlmodels/custom_property.py
Normal file
135
sqlmodels/custom_property.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
用户自定义属性定义模型
|
||||
|
||||
允许用户定义类型化的自定义属性模板(如标签、评分、分类等),
|
||||
实际值通过 ObjectMetadata KV 表存储,键名格式:custom:{property_definition_id}。
|
||||
|
||||
支持的属性类型:text, number, boolean, select, multi_select, rating, link
|
||||
"""
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import JSON
|
||||
from sqlmodel import Field, Relationship
|
||||
|
||||
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .user import User
|
||||
|
||||
|
||||
# ==================== 枚举 ====================
|
||||
|
||||
class CustomPropertyType(StrEnum):
|
||||
"""自定义属性值类型枚举"""
|
||||
TEXT = "text"
|
||||
"""文本"""
|
||||
NUMBER = "number"
|
||||
"""数字"""
|
||||
BOOLEAN = "boolean"
|
||||
"""布尔值"""
|
||||
SELECT = "select"
|
||||
"""单选"""
|
||||
MULTI_SELECT = "multi_select"
|
||||
"""多选"""
|
||||
RATING = "rating"
|
||||
"""评分(1-5)"""
|
||||
LINK = "link"
|
||||
"""链接"""
|
||||
|
||||
|
||||
# ==================== Base 模型 ====================
|
||||
|
||||
class CustomPropertyDefinitionBase(SQLModelBase):
|
||||
"""自定义属性定义基础模型"""
|
||||
|
||||
name: str = Field(max_length=100)
|
||||
"""属性显示名称"""
|
||||
|
||||
type: CustomPropertyType
|
||||
"""属性值类型"""
|
||||
|
||||
icon: str | None = Field(default=None, max_length=100)
|
||||
"""图标标识(iconify 名称)"""
|
||||
|
||||
options: list[str] | None = Field(default=None, sa_type=JSON)
|
||||
"""可选值列表(仅 select/multi_select 类型)"""
|
||||
|
||||
default_value: str | None = Field(default=None, max_length=500)
|
||||
"""默认值"""
|
||||
|
||||
|
||||
# ==================== 数据库模型 ====================
|
||||
|
||||
class CustomPropertyDefinition(CustomPropertyDefinitionBase, UUIDTableBaseMixin):
|
||||
"""
|
||||
用户自定义属性定义
|
||||
|
||||
每个用户独立管理自己的属性模板。
|
||||
实际属性值存储在 ObjectMetadata 表中,键名格式:custom:{id}。
|
||||
"""
|
||||
|
||||
owner_id: UUID = Field(
|
||||
foreign_key="user.id",
|
||||
ondelete="CASCADE",
|
||||
index=True,
|
||||
)
|
||||
"""所有者用户UUID"""
|
||||
|
||||
sort_order: int = 0
|
||||
"""排序顺序"""
|
||||
|
||||
# 关系
|
||||
owner: "User" = Relationship()
|
||||
"""所有者"""
|
||||
|
||||
|
||||
# ==================== DTO 模型 ====================
|
||||
|
||||
class CustomPropertyCreateRequest(SQLModelBase):
|
||||
"""创建自定义属性请求 DTO"""
|
||||
|
||||
name: str = Field(max_length=100)
|
||||
"""属性显示名称"""
|
||||
|
||||
type: CustomPropertyType
|
||||
"""属性值类型"""
|
||||
|
||||
icon: str | None = None
|
||||
"""图标标识"""
|
||||
|
||||
options: list[str] | None = None
|
||||
"""可选值列表(仅 select/multi_select 类型)"""
|
||||
|
||||
default_value: str | None = None
|
||||
"""默认值"""
|
||||
|
||||
|
||||
class CustomPropertyUpdateRequest(SQLModelBase):
|
||||
"""更新自定义属性请求 DTO"""
|
||||
|
||||
name: str | None = None
|
||||
"""属性显示名称"""
|
||||
|
||||
icon: str | None = None
|
||||
"""图标标识"""
|
||||
|
||||
options: list[str] | None = None
|
||||
"""可选值列表"""
|
||||
|
||||
default_value: str | None = None
|
||||
"""默认值"""
|
||||
|
||||
sort_order: int | None = None
|
||||
"""排序顺序"""
|
||||
|
||||
|
||||
class CustomPropertyResponse(CustomPropertyDefinitionBase):
|
||||
"""自定义属性响应 DTO"""
|
||||
|
||||
id: UUID
|
||||
"""属性定义UUID"""
|
||||
|
||||
sort_order: int
|
||||
"""排序顺序"""
|
||||
@@ -297,6 +297,29 @@ class WopiSessionResponse(SQLModelBase):
|
||||
"""完整的编辑器 URL"""
|
||||
|
||||
|
||||
class WopiDiscoveredExtension(SQLModelBase):
|
||||
"""单个 WOPI Discovery 发现的扩展名"""
|
||||
|
||||
extension: str
|
||||
"""文件扩展名"""
|
||||
|
||||
action_url: str
|
||||
"""处理后的动作 URL 模板"""
|
||||
|
||||
|
||||
class WopiDiscoveryResponse(SQLModelBase):
|
||||
"""WOPI Discovery 结果响应 DTO"""
|
||||
|
||||
discovered_extensions: list[WopiDiscoveredExtension] = []
|
||||
"""发现的扩展名及其 URL 模板"""
|
||||
|
||||
app_names: list[str] = []
|
||||
"""WOPI 服务端报告的应用名称(如 Writer、Calc、Impress)"""
|
||||
|
||||
applied_count: int = 0
|
||||
"""已应用到 FileAppExtension 的数量"""
|
||||
|
||||
|
||||
# ==================== 数据库模型 ====================
|
||||
|
||||
class FileApp(SQLModelBase, UUIDTableBaseMixin):
|
||||
@@ -377,6 +400,9 @@ class FileAppExtension(SQLModelBase, TableBaseMixin):
|
||||
priority: int = Field(default=0, ge=0)
|
||||
"""排序优先级(越小越优先)"""
|
||||
|
||||
wopi_action_url: str | None = Field(default=None, max_length=2048)
|
||||
"""WOPI 动作 URL 模板(Discovery 自动填充),支持 {wopi_src} {access_token} {access_token_ttl}"""
|
||||
|
||||
# 关系
|
||||
app: FileApp = Relationship(back_populates="extensions")
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ if TYPE_CHECKING:
|
||||
from .share import Share
|
||||
from .physical_file import PhysicalFile
|
||||
from .uri import DiskNextURI
|
||||
from .object_metadata import ObjectMetadata
|
||||
|
||||
|
||||
class ObjectType(StrEnum):
|
||||
@@ -26,31 +27,6 @@ class ObjectType(StrEnum):
|
||||
FOLDER = "folder"
|
||||
|
||||
|
||||
class FileMetadataBase(SQLModelBase):
|
||||
"""文件元数据基础模型"""
|
||||
|
||||
width: int | None = Field(default=None)
|
||||
"""图片宽度(像素)"""
|
||||
|
||||
height: int | None = Field(default=None)
|
||||
"""图片高度(像素)"""
|
||||
|
||||
duration: float | None = Field(default=None)
|
||||
"""音视频时长(秒)"""
|
||||
|
||||
bitrate: int | None = Field(default=None)
|
||||
"""比特率(kbps)"""
|
||||
|
||||
mime_type: str | None = Field(default=None, max_length=127)
|
||||
"""MIME类型"""
|
||||
|
||||
checksum_md5: str | None = Field(default=None, max_length=32)
|
||||
"""MD5校验和"""
|
||||
|
||||
checksum_sha256: str | None = Field(default=None, max_length=64)
|
||||
"""SHA256校验和"""
|
||||
|
||||
|
||||
# ==================== Base 模型 ====================
|
||||
|
||||
class ObjectBase(SQLModelBase):
|
||||
@@ -65,6 +41,9 @@ class ObjectBase(SQLModelBase):
|
||||
size: int | None = None
|
||||
"""文件大小(字节),目录为 None"""
|
||||
|
||||
mime_type: str | None = Field(default=None, max_length=127)
|
||||
"""MIME类型(仅文件有效)"""
|
||||
|
||||
|
||||
# ==================== DTO 模型 ====================
|
||||
|
||||
@@ -174,22 +153,6 @@ class DirectoryResponse(SQLModelBase):
|
||||
|
||||
# ==================== 数据库模型 ====================
|
||||
|
||||
class FileMetadata(FileMetadataBase, UUIDTableBaseMixin):
|
||||
"""文件元数据模型(与Object一对一关联)"""
|
||||
|
||||
object_id: UUID = Field(
|
||||
foreign_key="object.id",
|
||||
unique=True,
|
||||
index=True,
|
||||
ondelete="CASCADE"
|
||||
)
|
||||
"""关联的对象UUID"""
|
||||
|
||||
# 反向关系
|
||||
object: "Object" = Relationship(back_populates="file_metadata")
|
||||
"""关联的对象"""
|
||||
|
||||
|
||||
class Object(ObjectBase, UUIDTableBaseMixin):
|
||||
"""
|
||||
统一对象模型
|
||||
@@ -344,11 +307,11 @@ class Object(ObjectBase, UUIDTableBaseMixin):
|
||||
"""子对象(文件和子目录)"""
|
||||
|
||||
# 仅文件有效的关系
|
||||
file_metadata: FileMetadata | None = Relationship(
|
||||
metadata_entries: list["ObjectMetadata"] = Relationship(
|
||||
back_populates="object",
|
||||
sa_relationship_kwargs={"uselist": False, "cascade": "all, delete-orphan"},
|
||||
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
|
||||
)
|
||||
"""文件元数据(仅文件有效)"""
|
||||
"""元数据键值对列表"""
|
||||
|
||||
source_links: list["SourceLink"] = Relationship(
|
||||
back_populates="object",
|
||||
@@ -775,6 +738,9 @@ class ObjectPropertyResponse(SQLModelBase):
|
||||
size: int
|
||||
"""文件大小(字节)"""
|
||||
|
||||
mime_type: str | None = None
|
||||
"""MIME类型"""
|
||||
|
||||
created_at: datetime
|
||||
"""创建时间"""
|
||||
|
||||
@@ -788,22 +754,13 @@ class ObjectPropertyResponse(SQLModelBase):
|
||||
class ObjectPropertyDetailResponse(ObjectPropertyResponse):
|
||||
"""对象详细属性响应 DTO(继承基本属性)"""
|
||||
|
||||
# 元数据信息
|
||||
mime_type: str | None = None
|
||||
"""MIME类型"""
|
||||
|
||||
width: int | None = None
|
||||
"""图片宽度(像素)"""
|
||||
|
||||
height: int | None = None
|
||||
"""图片高度(像素)"""
|
||||
|
||||
duration: float | None = None
|
||||
"""音视频时长(秒)"""
|
||||
|
||||
# 校验和(从 PhysicalFile 读取)
|
||||
checksum_md5: str | None = None
|
||||
"""MD5校验和"""
|
||||
|
||||
checksum_sha256: str | None = None
|
||||
"""SHA256校验和"""
|
||||
|
||||
# 分享统计
|
||||
share_count: int = 0
|
||||
"""分享次数"""
|
||||
@@ -821,6 +778,10 @@ class ObjectPropertyDetailResponse(ObjectPropertyResponse):
|
||||
reference_count: int = 1
|
||||
"""物理文件引用计数(仅文件有效)"""
|
||||
|
||||
# 元数据(KV 格式)
|
||||
metadatas: dict[str, str] = {}
|
||||
"""所有元数据条目(键名 → 值)"""
|
||||
|
||||
|
||||
# ==================== 管理员文件管理 DTO ====================
|
||||
|
||||
|
||||
127
sqlmodels/object_metadata.py
Normal file
127
sqlmodels/object_metadata.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
对象元数据 KV 模型
|
||||
|
||||
以键值对形式存储文件的扩展元数据。键名使用命名空间前缀分类,
|
||||
如 exif:width, stream:duration, music:artist 等。
|
||||
|
||||
架构:
|
||||
ObjectMetadata (KV 表,与 Object 一对多关系)
|
||||
└── 每个 Object 可以有多条元数据记录
|
||||
└── (object_id, name) 组合唯一索引
|
||||
|
||||
命名空间:
|
||||
- exif: 图片 EXIF 信息(尺寸、相机参数、拍摄时间等)
|
||||
- stream: 音视频流信息(时长、比特率、视频尺寸、编解码等)
|
||||
- music: 音乐标签(标题、艺术家、专辑等)
|
||||
- geo: 地理位置(经纬度、地址)
|
||||
- apk: Android 安装包信息
|
||||
- custom: 用户自定义属性
|
||||
- sys: 系统内部元数据
|
||||
- thumb: 缩略图信息
|
||||
"""
|
||||
from enum import StrEnum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from sqlmodel import Field, UniqueConstraint, Index, Relationship
|
||||
|
||||
from sqlmodel_ext import SQLModelBase, UUIDTableBaseMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .object import Object
|
||||
|
||||
|
||||
# ==================== 枚举 ====================
|
||||
|
||||
class MetadataNamespace(StrEnum):
|
||||
"""元数据命名空间枚举"""
|
||||
EXIF = "exif"
|
||||
"""图片 EXIF 信息(含尺寸、相机参数、拍摄时间等)"""
|
||||
MUSIC = "music"
|
||||
"""音乐标签(title/artist/album/genre 等)"""
|
||||
STREAM = "stream"
|
||||
"""音视频流信息(codec/duration/bitrate/resolution 等)"""
|
||||
GEO = "geo"
|
||||
"""地理位置(latitude/longitude/address)"""
|
||||
APK = "apk"
|
||||
"""Android 安装包信息(package_name/version 等)"""
|
||||
THUMB = "thumb"
|
||||
"""缩略图信息(内部使用)"""
|
||||
SYS = "sys"
|
||||
"""系统元数据(内部使用)"""
|
||||
CUSTOM = "custom"
|
||||
"""用户自定义属性"""
|
||||
|
||||
|
||||
# 对外不可见的命名空间(API 不返回给普通用户)
|
||||
INTERNAL_NAMESPACES: set[str] = {MetadataNamespace.SYS, MetadataNamespace.THUMB}
|
||||
|
||||
# 用户可写的命名空间
|
||||
USER_WRITABLE_NAMESPACES: set[str] = {MetadataNamespace.CUSTOM}
|
||||
|
||||
|
||||
# ==================== Base 模型 ====================
|
||||
|
||||
class ObjectMetadataBase(SQLModelBase):
|
||||
"""对象元数据 KV 基础模型"""
|
||||
|
||||
name: str = Field(max_length=255)
|
||||
"""元数据键名,格式:namespace:key(如 exif:width, stream:duration)"""
|
||||
|
||||
value: str
|
||||
"""元数据值(统一为字符串存储)"""
|
||||
|
||||
|
||||
# ==================== 数据库模型 ====================
|
||||
|
||||
class ObjectMetadata(ObjectMetadataBase, UUIDTableBaseMixin):
|
||||
"""
|
||||
对象元数据 KV 模型
|
||||
|
||||
以键值对形式存储文件的扩展元数据。键名使用命名空间前缀分类,
|
||||
每个对象的每个键名唯一(通过唯一索引保证)。
|
||||
"""
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("object_id", "name", name="uq_object_metadata_object_name"),
|
||||
Index("ix_object_metadata_object_id", "object_id"),
|
||||
)
|
||||
|
||||
object_id: UUID = Field(
|
||||
foreign_key="object.id",
|
||||
ondelete="CASCADE",
|
||||
)
|
||||
"""关联的对象UUID"""
|
||||
|
||||
is_public: bool = False
|
||||
"""是否对分享页面公开"""
|
||||
|
||||
# 关系
|
||||
object: "Object" = Relationship(back_populates="metadata_entries")
|
||||
"""关联的对象"""
|
||||
|
||||
|
||||
# ==================== DTO 模型 ====================
|
||||
|
||||
class MetadataResponse(SQLModelBase):
|
||||
"""元数据查询响应 DTO"""
|
||||
|
||||
metadatas: dict[str, str]
|
||||
"""元数据字典(键名 → 值)"""
|
||||
|
||||
|
||||
class MetadataPatchItem(SQLModelBase):
|
||||
"""单条元数据补丁 DTO"""
|
||||
|
||||
key: str = Field(max_length=255)
|
||||
"""元数据键名"""
|
||||
|
||||
value: str | None = None
|
||||
"""值,None 表示删除此条目"""
|
||||
|
||||
|
||||
class MetadataPatchRequest(SQLModelBase):
|
||||
"""元数据批量更新请求 DTO"""
|
||||
|
||||
patches: list[MetadataPatchItem]
|
||||
"""补丁列表"""
|
||||
@@ -34,6 +34,9 @@ class PhysicalFileBase(SQLModelBase):
|
||||
checksum_md5: str | None = Field(default=None, max_length=32)
|
||||
"""MD5校验和(用于文件去重和完整性校验)"""
|
||||
|
||||
checksum_sha256: str | None = Field(default=None, max_length=64)
|
||||
"""SHA256校验和"""
|
||||
|
||||
|
||||
class PhysicalFile(PhysicalFileBase, UUIDTableBaseMixin):
|
||||
"""
|
||||
|
||||
2
tests/fixtures/objects.py
vendored
2
tests/fixtures/objects.py
vendored
@@ -92,9 +92,9 @@ class ObjectFactory:
|
||||
owner_id=owner_id,
|
||||
policy_id=policy_id,
|
||||
size=size,
|
||||
mime_type=kwargs.get("mime_type"),
|
||||
source_name=kwargs.get("source_name", name),
|
||||
upload_session_id=kwargs.get("upload_session_id"),
|
||||
file_metadata=kwargs.get("file_metadata"),
|
||||
password=kwargs.get("password"),
|
||||
)
|
||||
|
||||
|
||||
219
tests/integration/api/test_custom_property.py
Normal file
219
tests/integration/api/test_custom_property.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
自定义属性定义端点集成测试
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
|
||||
# ==================== 获取属性定义列表测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_custom_properties_requires_auth(async_client: AsyncClient):
|
||||
"""测试获取属性定义需要认证"""
|
||||
response = await async_client.get("/api/v1/object/custom_property")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_custom_properties_empty(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试获取空的属性定义列表"""
|
||||
response = await async_client.get(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data == []
|
||||
|
||||
|
||||
# ==================== 创建属性定义测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_custom_property(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试创建自定义属性"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "评分",
|
||||
"type": "rating",
|
||||
"icon": "mdi:star",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# 验证已创建
|
||||
list_response = await async_client.get(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
)
|
||||
data = list_response.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["name"] == "评分"
|
||||
assert data[0]["type"] == "rating"
|
||||
assert data[0]["icon"] == "mdi:star"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_custom_property_with_options(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试创建带选项的自定义属性"""
|
||||
response = await async_client.post(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"name": "分类",
|
||||
"type": "select",
|
||||
"options": ["工作", "个人", "归档"],
|
||||
"default_value": "个人",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
list_response = await async_client.get(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
)
|
||||
data = list_response.json()
|
||||
prop = next(p for p in data if p["name"] == "分类")
|
||||
assert prop["type"] == "select"
|
||||
assert prop["options"] == ["工作", "个人", "归档"]
|
||||
assert prop["default_value"] == "个人"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_custom_property_duplicate_name(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试创建同名属性返回 409"""
|
||||
# 先创建
|
||||
await async_client.post(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
json={"name": "标签", "type": "text"},
|
||||
)
|
||||
|
||||
# 再创建同名
|
||||
response = await async_client.post(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
json={"name": "标签", "type": "text"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
# ==================== 更新属性定义测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_custom_property(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试更新自定义属性"""
|
||||
# 先创建
|
||||
await async_client.post(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
json={"name": "备注", "type": "text"},
|
||||
)
|
||||
|
||||
# 获取 ID
|
||||
list_response = await async_client.get(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
)
|
||||
prop_id = next(p["id"] for p in list_response.json() if p["name"] == "备注")
|
||||
|
||||
# 更新
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/object/custom_property/{prop_id}",
|
||||
headers=auth_headers,
|
||||
json={"name": "详细备注", "icon": "mdi:note"},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# 验证已更新
|
||||
list_response = await async_client.get(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
)
|
||||
prop = next(p for p in list_response.json() if p["id"] == prop_id)
|
||||
assert prop["name"] == "详细备注"
|
||||
assert prop["icon"] == "mdi:note"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_custom_property_not_found(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试更新不存在的属性返回 404"""
|
||||
fake_id = str(uuid4())
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/object/custom_property/{fake_id}",
|
||||
headers=auth_headers,
|
||||
json={"name": "不存在"},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ==================== 删除属性定义测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_custom_property(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试删除自定义属性"""
|
||||
# 先创建
|
||||
await async_client.post(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
json={"name": "待删除", "type": "text"},
|
||||
)
|
||||
|
||||
# 获取 ID
|
||||
list_response = await async_client.get(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
)
|
||||
prop_id = next(p["id"] for p in list_response.json() if p["name"] == "待删除")
|
||||
|
||||
# 删除
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/object/custom_property/{prop_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# 验证已删除
|
||||
list_response = await async_client.get(
|
||||
"/api/v1/object/custom_property",
|
||||
headers=auth_headers,
|
||||
)
|
||||
prop_names = [p["name"] for p in list_response.json()]
|
||||
assert "待删除" not in prop_names
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_custom_property_not_found(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试删除不存在的属性返回 404"""
|
||||
fake_id = str(uuid4())
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/object/custom_property/{fake_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
239
tests/integration/api/test_object_metadata.py
Normal file
239
tests/integration/api/test_object_metadata.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
对象元数据端点集成测试
|
||||
"""
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from sqlmodels import ObjectMetadata
|
||||
|
||||
|
||||
# ==================== 获取元数据测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_metadata_requires_auth(async_client: AsyncClient):
|
||||
"""测试获取元数据需要认证"""
|
||||
fake_id = str(uuid4())
|
||||
response = await async_client.get(f"/api/v1/object/{fake_id}/metadata")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_metadata_empty(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
test_directory_structure: dict[str, UUID],
|
||||
):
|
||||
"""测试获取无元数据的对象"""
|
||||
file_id = test_directory_structure["file_id"]
|
||||
response = await async_client.get(
|
||||
f"/api/v1/object/{file_id}/metadata",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["metadatas"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_metadata_with_entries(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
test_directory_structure: dict[str, UUID],
|
||||
initialized_db,
|
||||
):
|
||||
"""测试获取有元数据的对象"""
|
||||
file_id = test_directory_structure["file_id"]
|
||||
|
||||
# 直接写入元数据
|
||||
entries = [
|
||||
ObjectMetadata(object_id=file_id, name="exif:width", value="1920", is_public=True),
|
||||
ObjectMetadata(object_id=file_id, name="exif:height", value="1080", is_public=True),
|
||||
ObjectMetadata(object_id=file_id, name="sys:extract_status", value="done", is_public=False),
|
||||
]
|
||||
for entry in entries:
|
||||
initialized_db.add(entry)
|
||||
await initialized_db.commit()
|
||||
|
||||
response = await async_client.get(
|
||||
f"/api/v1/object/{file_id}/metadata",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
# sys: 命名空间应被过滤
|
||||
assert "exif:width" in data["metadatas"]
|
||||
assert "exif:height" in data["metadatas"]
|
||||
assert "sys:extract_status" not in data["metadatas"]
|
||||
assert data["metadatas"]["exif:width"] == "1920"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_metadata_ns_filter(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
test_directory_structure: dict[str, UUID],
|
||||
initialized_db,
|
||||
):
|
||||
"""测试按命名空间过滤元数据"""
|
||||
file_id = test_directory_structure["file_id"]
|
||||
|
||||
entries = [
|
||||
ObjectMetadata(object_id=file_id, name="exif:width", value="1920", is_public=True),
|
||||
ObjectMetadata(object_id=file_id, name="music:title", value="Test Song", is_public=True),
|
||||
]
|
||||
for entry in entries:
|
||||
initialized_db.add(entry)
|
||||
await initialized_db.commit()
|
||||
|
||||
# 只获取 exif 命名空间
|
||||
response = await async_client.get(
|
||||
f"/api/v1/object/{file_id}/metadata?ns=exif",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "exif:width" in data["metadatas"]
|
||||
assert "music:title" not in data["metadatas"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_metadata_nonexistent_object(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
):
|
||||
"""测试获取不存在对象的元数据"""
|
||||
fake_id = str(uuid4())
|
||||
response = await async_client.get(
|
||||
f"/api/v1/object/{fake_id}/metadata",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# ==================== 更新元数据测试 ====================
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_metadata_requires_auth(async_client: AsyncClient):
|
||||
"""测试更新元数据需要认证"""
|
||||
fake_id = str(uuid4())
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/object/{fake_id}/metadata",
|
||||
json={"patches": [{"key": "custom:tag", "value": "test"}]},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_metadata_set_custom(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
test_directory_structure: dict[str, UUID],
|
||||
):
|
||||
"""测试设置自定义元数据"""
|
||||
file_id = test_directory_structure["file_id"]
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/object/{file_id}/metadata",
|
||||
headers=auth_headers,
|
||||
json={
|
||||
"patches": [
|
||||
{"key": "custom:tag1", "value": "旅游"},
|
||||
{"key": "custom:tag2", "value": "风景"},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# 验证已写入
|
||||
get_response = await async_client.get(
|
||||
f"/api/v1/object/{file_id}/metadata?ns=custom",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert get_response.status_code == 200
|
||||
data = get_response.json()
|
||||
assert data["metadatas"]["custom:tag1"] == "旅游"
|
||||
assert data["metadatas"]["custom:tag2"] == "风景"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_metadata_update_existing(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
test_directory_structure: dict[str, UUID],
|
||||
):
|
||||
"""测试更新已有的元数据"""
|
||||
file_id = test_directory_structure["file_id"]
|
||||
|
||||
# 先创建
|
||||
await async_client.patch(
|
||||
f"/api/v1/object/{file_id}/metadata",
|
||||
headers=auth_headers,
|
||||
json={"patches": [{"key": "custom:note", "value": "旧值"}]},
|
||||
)
|
||||
|
||||
# 再更新
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/object/{file_id}/metadata",
|
||||
headers=auth_headers,
|
||||
json={"patches": [{"key": "custom:note", "value": "新值"}]},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# 验证已更新
|
||||
get_response = await async_client.get(
|
||||
f"/api/v1/object/{file_id}/metadata?ns=custom",
|
||||
headers=auth_headers,
|
||||
)
|
||||
data = get_response.json()
|
||||
assert data["metadatas"]["custom:note"] == "新值"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_metadata_delete(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
test_directory_structure: dict[str, UUID],
|
||||
):
|
||||
"""测试删除元数据条目"""
|
||||
file_id = test_directory_structure["file_id"]
|
||||
|
||||
# 先创建
|
||||
await async_client.patch(
|
||||
f"/api/v1/object/{file_id}/metadata",
|
||||
headers=auth_headers,
|
||||
json={"patches": [{"key": "custom:to_delete", "value": "temp"}]},
|
||||
)
|
||||
|
||||
# 删除(value 为 null)
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/object/{file_id}/metadata",
|
||||
headers=auth_headers,
|
||||
json={"patches": [{"key": "custom:to_delete", "value": None}]},
|
||||
)
|
||||
assert response.status_code == 204
|
||||
|
||||
# 验证已删除
|
||||
get_response = await async_client.get(
|
||||
f"/api/v1/object/{file_id}/metadata?ns=custom",
|
||||
headers=auth_headers,
|
||||
)
|
||||
data = get_response.json()
|
||||
assert "custom:to_delete" not in data["metadatas"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_patch_metadata_reject_non_custom_namespace(
|
||||
async_client: AsyncClient,
|
||||
auth_headers: dict[str, str],
|
||||
test_directory_structure: dict[str, UUID],
|
||||
):
|
||||
"""测试拒绝修改非 custom: 命名空间"""
|
||||
file_id = test_directory_structure["file_id"]
|
||||
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/object/{file_id}/metadata",
|
||||
headers=auth_headers,
|
||||
json={"patches": [{"key": "exif:width", "value": "1920"}]},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
@@ -62,6 +62,10 @@ def raise_not_implemented(detail: str = "尚未支持这种方法") -> NoReturn:
|
||||
"""Raises an HTTP 501 Not Implemented exception."""
|
||||
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=detail)
|
||||
|
||||
def raise_bad_gateway(detail: str | None = None) -> NoReturn:
|
||||
"""Raises an HTTP 502 Bad Gateway exception."""
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=detail)
|
||||
|
||||
def raise_service_unavailable(detail: str | None = None) -> NoReturn:
|
||||
"""Raises an HTTP 503 Service Unavailable exception."""
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
|
||||
|
||||
Reference in New Issue
Block a user