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:
@@ -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}")
|
||||
Reference in New Issue
Block a user