feat: redesign metadata as KV store, add custom properties and WOPI Discovery
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:
2026-02-24 17:21:22 +08:00
parent 743a2c9d65
commit bcb0a9b322
17 changed files with 1943 additions and 471 deletions

View 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;

View File

@@ -1,16 +1,18 @@
""" """
管理员文件应用管理端点 管理员文件应用管理端点
提供文件查看器应用的 CRUD、扩展名管理用户组权限管理。 提供文件查看器应用的 CRUD、扩展名管理用户组权限管理和 WOPI Discovery
""" """
from uuid import UUID from uuid import UUID
import aiohttp
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, status
from loguru import logger as l from loguru import logger as l
from sqlalchemy import select from sqlalchemy import select
from middleware.auth import admin_required from middleware.auth import admin_required
from middleware.dependencies import SessionDep, TableViewRequestDep from middleware.dependencies import SessionDep, TableViewRequestDep
from service.wopi import parse_wopi_discovery_xml
from sqlmodels import ( from sqlmodels import (
FileApp, FileApp,
FileAppCreateRequest, FileAppCreateRequest,
@@ -21,7 +23,10 @@ from sqlmodels import (
FileAppUpdateRequest, FileAppUpdateRequest,
ExtensionUpdateRequest, ExtensionUpdateRequest,
GroupAccessUpdateRequest, GroupAccessUpdateRequest,
WopiDiscoveredExtension,
WopiDiscoveryResponse,
) )
from sqlmodels.file_app import FileAppType
from utils import http_exceptions from utils import http_exceptions
admin_file_app_router = APIRouter( admin_file_app_router = APIRouter(
@@ -123,6 +128,7 @@ async def create_file_app(
group_links.append(link) group_links.append(link)
if group_links: if group_links:
await session.commit() await session.commit()
await session.refresh(app)
l.info(f"创建文件应用: {app.name} ({app.app_key})") l.info(f"创建文件应用: {app.name} ({app.app_key})")
@@ -261,16 +267,22 @@ async def update_extensions(
if not app: if not app:
http_exceptions.raise_not_found("应用不存在") http_exceptions.raise_not_found("应用不存在")
# 删除旧的扩展名 # 保留旧扩展名的 wopi_action_urlDiscovery 填充的值)
old_extensions: list[FileAppExtension] = await FileAppExtension.get( old_extensions: list[FileAppExtension] = await FileAppExtension.get(
session, session,
FileAppExtension.app_id == app_id, FileAppExtension.app_id == app_id,
fetch_mode="all", 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: for old_ext in old_extensions:
await FileAppExtension.delete(session, old_ext, commit=False) await FileAppExtension.delete(session, old_ext, commit=False)
await session.flush()
# 创建新的扩展名 # 创建新的扩展名(保留已有的 wopi_action_url
new_extensions: list[FileAppExtension] = [] new_extensions: list[FileAppExtension] = []
for i, ext in enumerate(request.extensions): for i, ext in enumerate(request.extensions):
normalized = ext.lower().strip().lstrip('.') normalized = ext.lower().strip().lstrip('.')
@@ -278,12 +290,14 @@ async def update_extensions(
app_id=app_id, app_id=app_id,
extension=normalized, extension=normalized,
priority=i, priority=i,
wopi_action_url=old_url_map.get(normalized),
) )
session.add(ext_record) session.add(ext_record)
new_extensions.append(ext_record) new_extensions.append(ext_record)
await session.commit() await session.commit()
# refresh 新创建的记录 # refresh commit 后过期的对象
await session.refresh(app)
for ext_record in new_extensions: for ext_record in new_extensions:
await session.refresh(ext_record) await session.refresh(ext_record)
@@ -336,6 +350,7 @@ async def update_group_access(
new_links.append(link) new_links.append(link)
await session.commit() await session.commit()
await session.refresh(app)
extensions = await FileAppExtension.get( extensions = await FileAppExtension.get(
session, session,
@@ -346,3 +361,102 @@ async def update_group_access(
l.info(f"更新文件应用 {app.app_key} 的用户组权限: {request.group_ids}") l.info(f"更新文件应用 {app.app_key} 的用户组权限: {request.group_ids}")
return FileAppResponse.from_app(app, extensions, new_links) 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),
)

View File

@@ -812,6 +812,7 @@ async def create_wopi_session(
) )
wopi_app: FileApp | None = None wopi_app: FileApp | None = None
matched_ext_record: FileAppExtension | None = None
for ext_record in ext_records: for ext_record in ext_records:
app = ext_record.app app = ext_record.app
if app.type == FileAppType.WOPI and app.is_enabled: if app.type == FileAppType.WOPI and app.is_enabled:
@@ -827,13 +828,20 @@ async def create_wopi_session(
if not result.first(): if not result.first():
continue continue
wopi_app = app wopi_app = app
matched_ext_record = ext_record
break break
if not wopi_app: if not wopi_app:
http_exceptions.raise_not_found("无可用的 WOPI 查看器") http_exceptions.raise_not_found("无可用的 WOPI 查看器")
if not wopi_app.wopi_editor_url_template: # 优先使用 per-extension URLDiscovery 自动填充),回退到全局模板
http_exceptions.raise_bad_request("WOPI 应用未配置编辑器 URL 模板") 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 # 获取站点 URL
site_url_setting: Setting | None = await Setting.get( site_url_setting: Setting | None = await Setting.get(
@@ -849,12 +857,8 @@ async def create_wopi_session(
# 构建 wopi_src # 构建 wopi_src
wopi_src = f"{site_url}/wopi/files/{file_id}" wopi_src = f"{site_url}/wopi/files/{file_id}"
# 构建 editor URL # 构建 editor URL(只替换 wopi_srctoken 通过 POST 表单传递)
editor_url = wopi_app.wopi_editor_url_template.format( editor_url = editor_url_template.format(wopi_src=wopi_src)
wopi_src=wopi_src,
access_token=token,
access_token_ttl=access_token_ttl,
)
return WopiSessionResponse( return WopiSessionResponse(
wopi_src=wopi_src, wopi_src=wopi_src,

View File

@@ -34,6 +34,12 @@ from sqlmodels import (
TaskSummaryBase, TaskSummaryBase,
TaskType, TaskType,
User, User,
# 元数据相关
ObjectMetadata,
MetadataResponse,
MetadataPatchRequest,
INTERNAL_NAMESPACES,
USER_WRITABLE_NAMESPACES,
) )
from service.storage import ( from service.storage import (
LocalStorageService, LocalStorageService,
@@ -46,10 +52,13 @@ from service.storage.object import soft_delete_objects
from sqlmodels.database_connection import DatabaseManager from sqlmodels.database_connection import DatabaseManager
from utils import http_exceptions from utils import http_exceptions
from .custom_property import router as custom_property_router
object_router = APIRouter( object_router = APIRouter(
prefix="/object", prefix="/object",
tags=["object"] tags=["object"]
) )
object_router.include_router(custom_property_router)
@object_router.post( @object_router.post(
path='/', path='/',
@@ -503,6 +512,7 @@ async def router_object_property(
name=obj.name, name=obj.name,
type=obj.type, type=obj.type,
size=obj.size, size=obj.size,
mime_type=obj.mime_type,
created_at=obj.created_at, created_at=obj.created_at,
updated_at=obj.updated_at, updated_at=obj.updated_at,
parent_id=obj.parent_id, parent_id=obj.parent_id,
@@ -530,7 +540,7 @@ async def router_object_property_detail(
obj = await Object.get( obj = await Object.get(
session, session,
(Object.id == id) & (Object.deleted_at == None), (Object.id == id) & (Object.deleted_at == None),
load=Object.file_metadata, load=Object.metadata_entries,
) )
if not obj: if not obj:
raise HTTPException(status_code=404, detail="对象不存在") 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_views = sum(s.views for s in shares)
total_downloads = sum(s.downloads for s in shares) total_downloads = sum(s.downloads for s in shares)
# 获取物理文件引用计数 # 获取物理文件信息(引用计数、校验和)
reference_count = 1 reference_count = 1
checksum_md5: str | None = None
checksum_sha256: str | None = None
if obj.physical_file_id: if obj.physical_file_id:
physical_file = await PhysicalFile.get(session, PhysicalFile.id == obj.physical_file_id) physical_file = await PhysicalFile.get(session, PhysicalFile.id == obj.physical_file_id)
if physical_file: if physical_file:
reference_count = physical_file.reference_count 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, id=obj.id,
name=obj.name, name=obj.name,
type=obj.type, type=obj.type,
size=obj.size, size=obj.size,
mime_type=obj.mime_type,
created_at=obj.created_at, created_at=obj.created_at,
updated_at=obj.updated_at, updated_at=obj.updated_at,
parent_id=obj.parent_id, parent_id=obj.parent_id,
checksum_md5=checksum_md5,
checksum_sha256=checksum_sha256,
policy_name=policy_name, policy_name=policy_name,
share_count=share_count, share_count=share_count,
total_views=total_views, total_views=total_views,
total_downloads=total_downloads, total_downloads=total_downloads,
reference_count=reference_count, 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( @object_router.patch(
path='/{object_id}/policy', path='/{object_id}/policy',
@@ -718,3 +732,132 @@ async def router_object_switch_policy(
created_at=task.created_at, created_at=task.created_at,
updated_at=task.updated_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)} 条元数据")

View 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
View 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

View File

@@ -69,8 +69,6 @@ from .object import (
CreateUploadSessionRequest, CreateUploadSessionRequest,
DirectoryCreateRequest, DirectoryCreateRequest,
DirectoryResponse, DirectoryResponse,
FileMetadata,
FileMetadataBase,
Object, Object,
ObjectBase, ObjectBase,
ObjectCopyRequest, ObjectCopyRequest,
@@ -98,6 +96,24 @@ from .object import (
TrashRestoreRequest, TrashRestoreRequest,
TrashDeleteRequest, 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 .physical_file import PhysicalFile, PhysicalFileBase
from .uri import DiskNextURI, FileSystemNamespace from .uri import DiskNextURI, FileSystemNamespace
from .order import Order, OrderStatus, OrderType from .order import Order, OrderStatus, OrderType
@@ -131,6 +147,7 @@ from .file_app import (
FileAppSummary, FileViewersResponse, SetDefaultViewerRequest, UserFileAppDefaultResponse, FileAppSummary, FileViewersResponse, SetDefaultViewerRequest, UserFileAppDefaultResponse,
FileAppCreateRequest, FileAppUpdateRequest, FileAppResponse, FileAppListResponse, FileAppCreateRequest, FileAppUpdateRequest, FileAppResponse, FileAppListResponse,
ExtensionUpdateRequest, GroupAccessUpdateRequest, WopiSessionResponse, ExtensionUpdateRequest, GroupAccessUpdateRequest, WopiSessionResponse,
WopiDiscoveredExtension, WopiDiscoveryResponse,
) )
from .wopi import WopiFileInfo, WopiAccessTokenPayload from .wopi import WopiFileInfo, WopiAccessTokenPayload

View 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
"""排序顺序"""

View File

@@ -297,6 +297,29 @@ class WopiSessionResponse(SQLModelBase):
"""完整的编辑器 URL""" """完整的编辑器 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): class FileApp(SQLModelBase, UUIDTableBaseMixin):
@@ -377,6 +400,9 @@ class FileAppExtension(SQLModelBase, TableBaseMixin):
priority: int = Field(default=0, ge=0) 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") app: FileApp = Relationship(back_populates="extensions")

View File

@@ -18,6 +18,7 @@ if TYPE_CHECKING:
from .share import Share from .share import Share
from .physical_file import PhysicalFile from .physical_file import PhysicalFile
from .uri import DiskNextURI from .uri import DiskNextURI
from .object_metadata import ObjectMetadata
class ObjectType(StrEnum): class ObjectType(StrEnum):
@@ -26,31 +27,6 @@ class ObjectType(StrEnum):
FOLDER = "folder" 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 模型 ==================== # ==================== Base 模型 ====================
class ObjectBase(SQLModelBase): class ObjectBase(SQLModelBase):
@@ -65,6 +41,9 @@ class ObjectBase(SQLModelBase):
size: int | None = None size: int | None = None
"""文件大小(字节),目录为 None""" """文件大小(字节),目录为 None"""
mime_type: str | None = Field(default=None, max_length=127)
"""MIME类型仅文件有效"""
# ==================== DTO 模型 ==================== # ==================== 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): 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", back_populates="object",
sa_relationship_kwargs={"uselist": False, "cascade": "all, delete-orphan"}, sa_relationship_kwargs={"cascade": "all, delete-orphan"},
) )
"""文件元数据(仅文件有效)""" """元数据键值对列表"""
source_links: list["SourceLink"] = Relationship( source_links: list["SourceLink"] = Relationship(
back_populates="object", back_populates="object",
@@ -775,6 +738,9 @@ class ObjectPropertyResponse(SQLModelBase):
size: int size: int
"""文件大小(字节)""" """文件大小(字节)"""
mime_type: str | None = None
"""MIME类型"""
created_at: datetime created_at: datetime
"""创建时间""" """创建时间"""
@@ -788,22 +754,13 @@ class ObjectPropertyResponse(SQLModelBase):
class ObjectPropertyDetailResponse(ObjectPropertyResponse): class ObjectPropertyDetailResponse(ObjectPropertyResponse):
"""对象详细属性响应 DTO继承基本属性""" """对象详细属性响应 DTO继承基本属性"""
# 元数据信息 # 校验和(从 PhysicalFile 读取)
mime_type: str | None = None
"""MIME类型"""
width: int | None = None
"""图片宽度(像素)"""
height: int | None = None
"""图片高度(像素)"""
duration: float | None = None
"""音视频时长(秒)"""
checksum_md5: str | None = None checksum_md5: str | None = None
"""MD5校验和""" """MD5校验和"""
checksum_sha256: str | None = None
"""SHA256校验和"""
# 分享统计 # 分享统计
share_count: int = 0 share_count: int = 0
"""分享次数""" """分享次数"""
@@ -821,6 +778,10 @@ class ObjectPropertyDetailResponse(ObjectPropertyResponse):
reference_count: int = 1 reference_count: int = 1
"""物理文件引用计数(仅文件有效)""" """物理文件引用计数(仅文件有效)"""
# 元数据KV 格式)
metadatas: dict[str, str] = {}
"""所有元数据条目(键名 → 值)"""
# ==================== 管理员文件管理 DTO ==================== # ==================== 管理员文件管理 DTO ====================

View 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]
"""补丁列表"""

View File

@@ -34,6 +34,9 @@ class PhysicalFileBase(SQLModelBase):
checksum_md5: str | None = Field(default=None, max_length=32) checksum_md5: str | None = Field(default=None, max_length=32)
"""MD5校验和用于文件去重和完整性校验""" """MD5校验和用于文件去重和完整性校验"""
checksum_sha256: str | None = Field(default=None, max_length=64)
"""SHA256校验和"""
class PhysicalFile(PhysicalFileBase, UUIDTableBaseMixin): class PhysicalFile(PhysicalFileBase, UUIDTableBaseMixin):
""" """

View File

@@ -92,9 +92,9 @@ class ObjectFactory:
owner_id=owner_id, owner_id=owner_id,
policy_id=policy_id, policy_id=policy_id,
size=size, size=size,
mime_type=kwargs.get("mime_type"),
source_name=kwargs.get("source_name", name), source_name=kwargs.get("source_name", name),
upload_session_id=kwargs.get("upload_session_id"), upload_session_id=kwargs.get("upload_session_id"),
file_metadata=kwargs.get("file_metadata"),
password=kwargs.get("password"), password=kwargs.get("password"),
) )

View 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

View 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

View File

@@ -62,6 +62,10 @@ def raise_not_implemented(detail: str = "尚未支持这种方法") -> NoReturn:
"""Raises an HTTP 501 Not Implemented exception.""" """Raises an HTTP 501 Not Implemented exception."""
raise HTTPException(status_code=status.HTTP_501_NOT_IMPLEMENTED, detail=detail) 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: def raise_service_unavailable(detail: str | None = None) -> NoReturn:
"""Raises an HTTP 503 Service Unavailable exception.""" """Raises an HTTP 503 Service Unavailable exception."""
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail) raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)

799
uv.lock generated

File diff suppressed because it is too large Load Diff