Files
ai-agent-admin/backend-fastapi/zq_smart_table/api.py
T

1646 lines
68 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json as _json
from typing import List, Optional, Union
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.config import settings
from app.base_schema import PaginatedResponse, ResponseModel
from zq_smart_table.schema import (
SmartTableCreate, SmartTableUpdate, SmartTableResponse, SmartTableSimple, SmartTableFull,
SmartTableMove,
SmartFieldCreate, SmartFieldUpdate, SmartFieldResponse, SmartFieldReorder,
SmartRecordCreate, SmartRecordUpdate, SmartRecordResponse,
SmartRecordCellUpdate, SmartRecordBatchCellUpdate, SmartRecordBatchDelete,
SmartRecordBatchRestore, TrashListResponse,
MultiRecordBatchUpdate,
CursorPaginatedRecords,
SmartViewCreate, SmartViewUpdate, SmartViewResponse,
RecordQuery, RecordFilterRule, RecordSortRule,
SmartRecordReorder, GroupedRecordsResponse, RecordGroupItem,
RecordSearchResult, RecordSearchQuery,
SummaryRequest, SummaryResponse,
CommentCreate, CommentUpdate, CommentResponse,
DocumentContentUpdate,
DocumentVersionCreate, DocumentVersionResponse, DocumentVersionDetail, DocumentVersionCompare,
DocumentTemplateCreate, DocumentTemplateUpdate, DocumentTemplateResponse, DocumentTemplateDetail,
WikiSpaceCreate, WikiSpaceUpdate, WikiSpaceResponse, WikiSpaceListItem, WikiSpaceDetail,
)
from zq_smart_table.service import (
SmartTableService, SmartFieldService, SmartRecordService, SmartViewService,
SmartExportService, SmartImportService, SmartLinkService, SmartSummaryService,
SmartFormulaService, SmartValidationService, SmartCommentService,
SmartDocumentVersionService, SmartDocumentTemplateService,
WikiSpaceService,
)
from zq_smart_table.permission.service import PermissionService
from utils.context import get_current_user_id_from_context, get_current_user_info_from_context
from core.user.service import UserService
router = APIRouter(tags=["多维表格"])
INITIAL_RECORD_LIMIT = 500
# ==================== Table ====================
@router.post("/tables", response_model=SmartTableResponse, summary="创建表")
async def create_table(data: SmartTableCreate, db: AsyncSession = Depends(get_db)):
table = await SmartTableService.create(db, data)
user_id = get_current_user_id_from_context()
if user_id:
await PermissionService.init_table_owner(db, table.id, user_id)
return table
@router.get("/tables", response_model=List[SmartTableSimple], summary="获取表列表")
async def get_table_list(
wiki_space_id: Optional[str] = Query(default=None, description="按文档库ID过滤"),
db: AsyncSession = Depends(get_db),
):
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
dept_id = user_info.get("dept_id") if user_info else None
role_ids = user_info.get("role_ids", []) if user_info else []
is_superuser = user_info.get("is_superuser", False) if user_info else False
items, _ = await SmartTableService.get_user_tables(
db, wiki_space_id=wiki_space_id,
user_id=user_id, dept_id=dept_id,
role_ids=role_ids, is_superuser=is_superuser,
)
return items
@router.get("/tables/{table_id}", response_model=SmartTableResponse, summary="获取表详情")
async def get_table(table_id: str, db: AsyncSession = Depends(get_db)):
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
await PermissionService.require_table_access(db, table_id)
return table
@router.get("/tables/{table_id}/full", response_model=SmartTableFull, summary="获取完整表数据")
async def get_table_full(
table_id: str,
limit: int = Query(default=INITIAL_RECORD_LIMIT, ge=1, le=10000, description="首次加载记录数"),
filters: Optional[str] = Query(default=None, description="筛选条件 JSON 数组"),
filter_logic: str = Query(default="and", description="筛选逻辑: and / or"),
sorts: Optional[str] = Query(default=None, description="排序规则 JSON 数组"),
search: Optional[str] = Query(default=None, description="搜索关键词"),
db: AsyncSession = Depends(get_db),
):
"""返回表的 fields + views + 首批 records(游标分页),前端按需加载更多"""
role_info = await PermissionService.require_table_access(db, table_id)
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
creator_name = None
creator_avatar = None
if table.sys_creator_id:
creator = await UserService.get_by_id(db, table.sys_creator_id)
if creator:
creator_name = getattr(creator, "name", None) or getattr(creator, "username", None)
creator_avatar = getattr(creator, "avatar", None)
if getattr(table, "type", "table") == "document":
return SmartTableFull(
id=table.id,
name=table.name,
icon=table.icon,
description=table.description,
active_view_id=None,
type="document",
parent_id=getattr(table, "parent_id", None),
content=table.content,
fields=[],
records=[],
views=[],
record_total=0,
next_cursor=None,
has_more=False,
sys_create_datetime=table.sys_create_datetime,
sys_update_datetime=table.sys_update_datetime,
sys_creator_id=table.sys_creator_id,
creator_name=creator_name,
creator_avatar=creator_avatar,
)
all_fields = await SmartFieldService.get_by_table(db, table_id)
accessible = await PermissionService.get_accessible_field_ids(
db, table_id, role_info.get("role_id"), mode="read"
)
fields = [f for f in all_fields if f.id in accessible] if accessible is not None else all_fields
parsed_filters = None
if filters:
try:
parsed_filters = [RecordFilterRule(**f) for f in _json.loads(filters)]
except Exception:
raise HTTPException(status_code=400, detail="筛选条件格式错误")
parsed_sorts = None
if sorts:
try:
parsed_sorts = [RecordSortRule(**s) for s in _json.loads(sorts)]
except Exception:
raise HTTPException(status_code=400, detail="排序规则格式错误")
user_info = get_current_user_info_from_context() or {}
row_conditions = await PermissionService.build_row_filter_conditions(
db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view"
)
records_list, next_cursor, total = await SmartRecordService.get_by_table_cursor_filtered(
db, table_id,
filters=parsed_filters,
filter_logic=filter_logic,
sorts=parsed_sorts,
search=search,
extra_conditions=row_conditions or None,
cursor=None,
limit=limit,
)
await SmartLinkService.resolve_link_values(db, all_fields, records_list)
await SmartLinkService.resolve_lookup_values(db, all_fields, records_list)
await SmartLinkService.resolve_rollup_values(db, all_fields, records_list)
SmartFormulaService.resolve_formula_values(all_fields, records_list)
if accessible is not None:
for r in records_list:
r.values = PermissionService.filter_record_values(r.values or {}, accessible)
views = await SmartViewService.get_by_table(db, table_id)
return SmartTableFull(
id=table.id,
name=table.name,
icon=table.icon,
description=table.description,
active_view_id=table.active_view_id,
type=getattr(table, "type", "table"),
parent_id=getattr(table, "parent_id", None),
fields=fields,
records=records_list,
views=views,
record_total=total,
next_cursor=next_cursor,
has_more=next_cursor is not None,
sys_create_datetime=table.sys_create_datetime,
sys_update_datetime=table.sys_update_datetime,
sys_creator_id=table.sys_creator_id,
creator_name=creator_name,
creator_avatar=creator_avatar,
)
@router.put("/tables/{table_id}", response_model=SmartTableResponse, summary="更新表")
async def update_table(table_id: str, data: SmartTableUpdate, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_table")
table = await SmartTableService.update(db, record_id=table_id, data=data)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
return table
@router.delete("/tables/{table_id}", response_model=ResponseModel, summary="删除表")
async def delete_table(table_id: str, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_table")
success = await SmartTableService.delete(db, record_id=table_id)
if not success:
raise HTTPException(status_code=404, detail="表不存在")
return ResponseModel(message="删除成功")
@router.put("/tables/{table_id}/move", response_model=SmartTableResponse, summary="移动表/文档到新的父级")
async def move_table(table_id: str, data: SmartTableMove, db: AsyncSession = Depends(get_db)):
from sqlalchemy import select as sa_select
await PermissionService.require_capability(db, table_id, "manage_table")
if data.parent_id and data.parent_id == table_id:
raise HTTPException(status_code=400, detail="不能将页面移动到自身下")
if data.parent_id:
parent = await SmartTableService.get_by_id(db, data.parent_id)
if not parent:
raise HTTPException(status_code=404, detail="目标父页面不存在")
ancestor_id = getattr(parent, "parent_id", None)
while ancestor_id:
if ancestor_id == table_id:
raise HTTPException(status_code=400, detail="不能将页面移动到其子页面下")
ancestor = await SmartTableService.get_by_id(db, ancestor_id)
ancestor_id = getattr(ancestor, "parent_id", None) if ancestor else None
table = await SmartTableService.update(
db, record_id=table_id, data=SmartTableUpdate(parent_id=data.parent_id)
)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
# Reorder siblings at the target parent level
from zq_smart_table.model import SmartTable
if data.parent_id:
q = sa_select(SmartTable).where(
SmartTable.parent_id == data.parent_id,
SmartTable.is_deleted == False, # noqa: E712
).order_by(SmartTable.sort)
else:
q = sa_select(SmartTable).where(
SmartTable.parent_id.is_(None),
SmartTable.is_deleted == False, # noqa: E712
).order_by(SmartTable.sort)
result = await db.execute(q)
siblings = list(result.scalars().all())
ordered_ids = [s.id for s in siblings if s.id != table_id]
if data.after_id and data.after_id in ordered_ids:
idx = ordered_ids.index(data.after_id) + 1
ordered_ids.insert(idx, table_id)
else:
ordered_ids.insert(0, table_id)
id_to_obj = {s.id: s for s in siblings}
for i, sid in enumerate(ordered_ids):
obj = id_to_obj.get(sid)
if obj and obj.sort != i:
obj.sort = i
await db.commit()
await db.refresh(table)
return table
# ==================== Document Content ====================
@router.patch("/tables/{table_id}/content", response_model=ResponseModel, summary="更新文档内容")
async def update_document_content(
table_id: str,
data: DocumentContentUpdate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "edit_record")
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="文档不存在")
if getattr(table, "type", "table") != "document":
raise HTTPException(status_code=400, detail="该项目不是文档类型")
user_id = get_current_user_id_from_context()
if table.content:
try:
await SmartDocumentVersionService.create_version(
db, table_id, table.content,
title=table.name,
change_summary="自动保存",
user_id=user_id,
)
except Exception:
pass
await SmartTableService.update(db, record_id=table_id, data=SmartTableUpdate(content=data.content))
return ResponseModel(message="保存成功")
@router.post("/tables/{table_id}/export-pdf", summary="导出文档为 PDF")
async def export_document_pdf(
table_id: str,
data: dict,
db: AsyncSession = Depends(get_db),
):
"""接收编辑器 HTML 内容,使用 WeasyPrint 生成 PDF 并返回下载流"""
import io
from online_dev.document_generator.generator import pdf_generator, WEASYPRINT_AVAILABLE, HTML
await PermissionService.require_table_access(db, table_id)
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="文档不存在")
html_content = data.get("html", "")
title = data.get("title", table.name or "文档")
if not html_content:
raise HTTPException(status_code=400, detail="导出内容不能为空")
if not WEASYPRINT_AVAILABLE:
raise HTTPException(status_code=500, detail="WeasyPrint 不可用,无法生成 PDF")
doc_css = """
h1 { font-size: 22pt; font-weight: 700; margin: 0 0 14pt; padding-bottom: 8pt; border-bottom: 1px solid #e5e5e5; }
h2 { font-size: 17pt; font-weight: 600; margin: 18pt 0 8pt; }
h3 { font-size: 13pt; font-weight: 600; margin: 14pt 0 6pt; }
p { margin: 5pt 0; }
ul, ol { padding-left: 20pt; margin: 5pt 0; }
li { margin: 2pt 0; }
blockquote {
border-left: 3pt solid #d0d0d0; padding-left: 10pt;
margin: 6pt 0; color: #555; font-style: italic;
}
pre {
background: #f6f8fa; padding: 8pt; border-radius: 4pt;
border: 1px solid #e1e4e8; font-size: 9pt; line-height: 1.5;
}
code { background: #f0f0f0; padding: 1pt 3pt; border-radius: 3pt; font-size: 9.5pt; }
pre code { background: none; padding: 0; font-size: inherit; }
table { border-collapse: collapse; width: 100%; margin: 8pt 0; }
th, td { border: 1px solid #d0d0d0; padding: 5pt 8pt; text-align: left; font-size: 10pt; }
th { background: #f5f5f5; font-weight: 600; }
img { max-width: 100%; }
a { color: #1a73e8; text-decoration: none; }
hr { border: none; border-top: 1px solid #e5e5e5; margin: 10pt 0; }
"""
body_html = f"<h1>{title}</h1>\n{html_content}"
full_html = pdf_generator._wrap_html(
body_html,
css=doc_css,
page_config={"size": "A4", "orientation": "portrait", "margin": {"top": 20, "right": 15, "bottom": 20, "left": 15}},
)
try:
html_doc = HTML(string=full_html)
pdf_bytes = html_doc.write_pdf(font_config=pdf_generator.font_config)
except Exception as e:
raise HTTPException(status_code=500, detail=f"PDF 生成失败: {str(e)}")
from urllib.parse import quote
safe_name = table.name.replace("/", "_").replace("\\", "_")
encoded_name = quote(f"{safe_name}.pdf")
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_name}",
},
)
# ==================== Field ====================
@router.get("/tables/{table_id}/fields", response_model=List[SmartFieldResponse], summary="获取字段列表")
async def get_field_list(table_id: str, db: AsyncSession = Depends(get_db)):
await PermissionService.require_table_access(db, table_id)
return await SmartFieldService.get_by_table(db, table_id)
@router.post("/tables/{table_id}/fields", response_model=SmartFieldResponse, summary="添加字段")
async def create_field(table_id: str, data: SmartFieldCreate, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_field")
data.table_id = table_id
if data.type == "link":
linked_table_id = (data.config or {}).get("linkedTableId")
if not linked_table_id:
raise HTTPException(status_code=400, detail="Link 字段必须指定 linkedTableId")
source_field, _ = await SmartLinkService.create_link_field(
db, table_id, data.name, linked_table_id, sort=data.sort,
)
return source_field
if data.sort == 0:
data.sort = await SmartFieldService.get_next_sort(db, table_id)
field = await SmartFieldService.create(db, data)
return field
@router.put("/fields/{field_id}", response_model=SmartFieldResponse, summary="更新字段")
async def update_field(field_id: str, data: SmartFieldUpdate, db: AsyncSession = Depends(get_db)):
field = await SmartFieldService.get_by_id(db, field_id)
if not field:
raise HTTPException(status_code=404, detail="字段不存在")
await PermissionService.require_capability(db, field.table_id, "manage_field")
field = await SmartFieldService.update(db, record_id=field_id, data=data)
return field
@router.delete("/fields/{field_id}", response_model=ResponseModel, summary="删除字段")
async def delete_field(field_id: str, db: AsyncSession = Depends(get_db)):
field = await SmartFieldService.get_by_id(db, field_id)
if not field:
raise HTTPException(status_code=404, detail="字段不存在")
await PermissionService.require_capability(db, field.table_id, "manage_field")
if field.type == "link":
success = await SmartLinkService.delete_link_field(db, field_id)
else:
success = await SmartFieldService.delete_and_clean(db, field_id)
if not success:
raise HTTPException(status_code=404, detail="字段不存在")
return ResponseModel(message="删除成功")
@router.put("/tables/{table_id}/fields/reorder", response_model=ResponseModel, summary="字段排序")
async def reorder_fields(table_id: str, data: SmartFieldReorder, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_field")
await SmartFieldService.reorder(db, table_id, data.field_ids)
return ResponseModel(message="排序成功")
# ==================== Record ====================
@router.get(
"/tables/{table_id}/records",
response_model=CursorPaginatedRecords,
summary="获取记录列表(游标分页,支持筛选/排序/搜索)",
)
async def get_record_list(
table_id: str,
cursor: Optional[str] = Query(default=None, description="游标(上一页最后一条记录的 ID"),
limit: int = Query(default=200, ge=1, le=5000, description="每页数量"),
filters: Optional[str] = Query(default=None, description="筛选条件 JSON 数组"),
filter_logic: str = Query(default="and", description="筛选逻辑: and / or"),
sorts: Optional[str] = Query(default=None, description="排序规则 JSON 数组"),
search: Optional[str] = Query(default=None, description="搜索关键词"),
db: AsyncSession = Depends(get_db),
):
role_info = await PermissionService.require_table_access(db, table_id)
parsed_filters = None
if filters:
try:
parsed_filters = [RecordFilterRule(**f) for f in _json.loads(filters)]
except Exception:
raise HTTPException(status_code=400, detail="筛选条件格式错误")
parsed_sorts = None
if sorts:
try:
parsed_sorts = [RecordSortRule(**s) for s in _json.loads(sorts)]
except Exception:
raise HTTPException(status_code=400, detail="排序规则格式错误")
user_info = get_current_user_info_from_context() or {}
row_conditions = await PermissionService.build_row_filter_conditions(
db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view"
)
skip_count = cursor is not None
items, next_cursor, total = await SmartRecordService.get_by_table_cursor_filtered(
db, table_id,
filters=parsed_filters,
filter_logic=filter_logic,
sorts=parsed_sorts,
search=search,
extra_conditions=row_conditions or None,
cursor=cursor,
limit=limit,
skip_count=skip_count,
)
all_fields = await SmartFieldService.get_by_table(db, table_id)
await SmartLinkService.resolve_link_values(db, all_fields, items)
await SmartLinkService.resolve_lookup_values(db, all_fields, items)
await SmartLinkService.resolve_rollup_values(db, all_fields, items)
SmartFormulaService.resolve_formula_values(all_fields, items)
accessible = await PermissionService.get_accessible_field_ids(
db, table_id, role_info.get("role_id"), mode="read"
)
if accessible is not None:
for r in items:
r.values = PermissionService.filter_record_values(r.values or {}, accessible)
return CursorPaginatedRecords(
items=items,
total=total,
next_cursor=next_cursor,
has_more=next_cursor is not None,
)
@router.post(
"/tables/{table_id}/records/query",
response_model=Union[GroupedRecordsResponse, CursorPaginatedRecords],
summary="复杂查询记录(POST Body 传递筛选/排序/搜索条件)",
)
async def query_records(
table_id: str,
query: RecordQuery,
db: AsyncSession = Depends(get_db),
):
role_info = await PermissionService.require_table_access(db, table_id)
user_info = get_current_user_info_from_context() or {}
row_conditions = await PermissionService.build_row_filter_conditions(
db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view"
)
skip_count = query.cursor is not None
items, next_cursor, total = await SmartRecordService.get_by_table_cursor_filtered(
db, table_id,
filters=query.filters or None,
filter_logic=query.filter_logic,
sorts=query.sorts or None,
search=query.search,
search_field_ids=query.search_field_ids,
extra_conditions=row_conditions or None,
cursor=query.cursor,
limit=query.limit,
skip_count=skip_count,
)
all_fields = await SmartFieldService.get_by_table(db, table_id)
await SmartLinkService.resolve_link_values(db, all_fields, items)
await SmartLinkService.resolve_lookup_values(db, all_fields, items)
await SmartLinkService.resolve_rollup_values(db, all_fields, items)
SmartFormulaService.resolve_formula_values(all_fields, items)
accessible = await PermissionService.get_accessible_field_ids(
db, table_id, role_info.get("role_id"), mode="read"
)
if accessible is not None:
for r in items:
r.values = PermissionService.filter_record_values(r.values or {}, accessible)
if query.group_field_id:
field = next((f for f in all_fields if f.id == query.group_field_id), None)
groups_map: dict[str, list] = {}
for r in items:
val = str((r.values or {}).get(query.group_field_id, "") or "")
groups_map.setdefault(val, []).append(r)
group_items = []
for key, recs in groups_map.items():
label = key or "(空)"
if field and field.config and field.config.get("options"):
opt = next((o for o in field.config["options"] if o.get("id") == key), None)
if opt:
label = opt.get("label", key)
group_items.append(RecordGroupItem(key=key, label=label, records=recs))
return GroupedRecordsResponse(groups=group_items, total=total)
return CursorPaginatedRecords(
items=items,
total=total,
next_cursor=next_cursor,
has_more=next_cursor is not None,
)
@router.post("/tables/{table_id}/records", response_model=SmartRecordResponse, summary="添加记录")
async def create_record(table_id: str, data: SmartRecordCreate, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "add_record")
data.table_id = table_id
all_fields = await SmartFieldService.get_by_table(db, table_id)
auto_number_fields = [f for f in all_fields if f.type == "autoNumber"]
if auto_number_fields:
if not data.values:
data.values = {}
for af in auto_number_fields:
if af.id not in data.values:
next_val = await SmartRecordService.get_next_auto_number(db, table_id, af.id)
data.values[af.id] = next_val
record = await SmartRecordService.create(db, data)
return record
@router.put("/records/{record_id}", response_model=SmartRecordResponse, summary="更新记录")
async def update_record(record_id: str, data: SmartRecordUpdate, db: AsyncSession = Depends(get_db)):
record = await SmartRecordService.get_by_id(db, record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
role_info = await PermissionService.require_capability(db, record.table_id, "edit_record")
user_info = get_current_user_info_from_context() or {}
can_edit = await PermissionService.check_row_edit_permission(
db, record.table_id, record, role_info.get("role_id"), user_info.get("user_id", "")
)
if not can_edit:
raise HTTPException(status_code=403, detail="无权编辑此记录")
record = await SmartRecordService.update(db, record_id=record_id, data=data)
return record
@router.patch("/records/{record_id}/cells", response_model=SmartRecordResponse, summary="更新单元格")
async def update_cell(record_id: str, data: SmartRecordCellUpdate, db: AsyncSession = Depends(get_db)):
record = await SmartRecordService.get_by_id(db, record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
role_info = await PermissionService.require_capability(db, record.table_id, "edit_record")
user_info = get_current_user_info_from_context() or {}
can_edit = await PermissionService.check_row_edit_permission(
db, record.table_id, record, role_info.get("role_id"), user_info.get("user_id", "")
)
if not can_edit:
raise HTTPException(status_code=403, detail="无权编辑此记录")
writable = await PermissionService.get_accessible_field_ids(
db, record.table_id, role_info.get("role_id"), mode="write"
)
if writable is not None and data.field_id not in writable:
raise HTTPException(status_code=403, detail="无权编辑此字段")
field = await SmartFieldService.get_by_id(db, data.field_id)
if not field:
raise HTTPException(status_code=404, detail="字段不存在")
validation_error = await SmartValidationService.validate_cell(db, field, data.value, record_id)
if validation_error:
raise HTTPException(status_code=422, detail=validation_error)
if field.type == "link":
target_ids = data.value if isinstance(data.value, list) else []
await SmartLinkService.set_linked_records(db, data.field_id, record_id, target_ids)
record = await SmartRecordService.get_by_id(db, record_id)
all_fields = await SmartFieldService.get_by_table(db, record.table_id)
await SmartLinkService.resolve_link_values(db, all_fields, [record])
await SmartLinkService.resolve_lookup_values(db, all_fields, [record])
await SmartLinkService.resolve_rollup_values(db, all_fields, [record])
return record
record = await SmartRecordService.update_cell(db, record_id, data.field_id, data.value)
return record
@router.patch("/records/{record_id}/cells/batch", response_model=SmartRecordResponse, summary="批量更新单元格")
async def batch_update_cells(record_id: str, data: SmartRecordBatchCellUpdate, db: AsyncSession = Depends(get_db)):
record = await SmartRecordService.get_by_id(db, record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
role_info = await PermissionService.require_capability(db, record.table_id, "edit_record")
user_info = get_current_user_info_from_context() or {}
can_edit = await PermissionService.check_row_edit_permission(
db, record.table_id, record, role_info.get("role_id"), user_info.get("user_id", "")
)
if not can_edit:
raise HTTPException(status_code=403, detail="无权编辑此记录")
writable = await PermissionService.get_accessible_field_ids(
db, record.table_id, role_info.get("role_id"), mode="write"
)
filtered_cells = data.cells
if writable is not None:
filtered_cells = {k: v for k, v in data.cells.items() if k in writable}
if not filtered_cells:
raise HTTPException(status_code=403, detail="无权编辑任何提交的字段")
link_cells = {}
normal_cells = {}
fields_cache = {}
for field_id, val in filtered_cells.items():
if field_id not in fields_cache:
f = await SmartFieldService.get_by_id(db, field_id)
fields_cache[field_id] = f
f = fields_cache[field_id]
if f and f.type == "link":
link_cells[field_id] = val
else:
normal_cells[field_id] = val
validation_errors = await SmartValidationService.validate_cells_batch(
db, record.table_id, normal_cells, record_id
)
if validation_errors:
first_err = next(iter(validation_errors.values()))
raise HTTPException(status_code=422, detail=first_err)
if normal_cells:
record = await SmartRecordService.batch_update_cells(db, record_id, normal_cells)
for fid, val in link_cells.items():
target_ids = val if isinstance(val, list) else []
await SmartLinkService.set_linked_records(db, fid, record_id, target_ids)
record = await SmartRecordService.get_by_id(db, record_id)
if link_cells:
all_fields = await SmartFieldService.get_by_table(db, record.table_id)
await SmartLinkService.resolve_link_values(db, all_fields, [record])
await SmartLinkService.resolve_lookup_values(db, all_fields, [record])
await SmartLinkService.resolve_rollup_values(db, all_fields, [record])
return record
@router.delete("/records/{record_id}", response_model=ResponseModel, summary="删除记录")
async def delete_record(record_id: str, db: AsyncSession = Depends(get_db)):
record = await SmartRecordService.get_by_id(db, record_id)
if not record:
raise HTTPException(status_code=404, detail="记录不存在")
await PermissionService.require_capability(db, record.table_id, "delete_record")
await SmartLinkService.cleanup_record_links(db, record_id)
success = await SmartRecordService.delete(db, record_id=record_id)
if not success:
raise HTTPException(status_code=404, detail="记录不存在")
return ResponseModel(message="删除成功")
@router.post("/tables/{table_id}/records/batch-delete", response_model=ResponseModel, summary="批量删除记录")
async def batch_delete_records(table_id: str, data: SmartRecordBatchDelete, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "delete_record")
for rid in data.ids:
await SmartLinkService.cleanup_record_links(db, rid)
success, fail = await SmartRecordService.batch_delete(db, data.ids)
return ResponseModel(message=f"成功删除{success}", data={"success": success, "fail": fail})
# ==================== Trash / Recycle Bin ====================
@router.get("/tables/{table_id}/trash", response_model=TrashListResponse, summary="获取回收站记录列表")
async def get_trash_records(
table_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=200),
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "delete_record")
items, total = await SmartRecordService.get_deleted_records(db, table_id, page, page_size)
return TrashListResponse(items=items, total=total)
@router.post("/tables/{table_id}/trash/restore", response_model=ResponseModel, summary="批量恢复回收站记录")
async def restore_trash_records(
table_id: str,
data: SmartRecordBatchRestore,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "delete_record")
count = await SmartRecordService.batch_restore_records(db, table_id, data.ids)
return ResponseModel(message=f"成功恢复{count}条记录", data={"restored": count})
@router.delete("/tables/{table_id}/trash/{record_id}", response_model=ResponseModel, summary="永久删除回收站记录")
async def permanent_delete_record(
table_id: str,
record_id: str,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "delete_record")
success = await SmartRecordService.permanent_delete(db, table_id, record_id)
if not success:
raise HTTPException(status_code=404, detail="记录不存在或未被删除")
return ResponseModel(message="永久删除成功")
@router.delete("/tables/{table_id}/trash", response_model=ResponseModel, summary="清空回收站")
async def empty_trash(
table_id: str,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "delete_record")
count = await SmartRecordService.empty_trash(db, table_id)
return ResponseModel(message=f"已清空{count}条记录", data={"deleted": count})
@router.patch(
"/tables/{table_id}/records/batch-cells",
response_model=ResponseModel,
summary="批量更新多条记录的单元格(合并为单次事务)",
)
async def batch_update_multi_record_cells(
table_id: str,
data: MultiRecordBatchUpdate,
db: AsyncSession = Depends(get_db),
):
role_info = await PermissionService.require_capability(db, table_id, "edit_record")
user_info = get_current_user_info_from_context() or {}
user_id = user_info.get("user_id", "")
writable_fields = await PermissionService.get_accessible_field_ids(
db, table_id, role_info.get("role_id"), mode="write"
)
fields_cache: dict = {}
valid_updates = []
for u in data.updates:
record = await SmartRecordService.get_by_id(db, u.record_id)
if not record or record.table_id != table_id:
continue
can_edit = await PermissionService.check_row_edit_permission(
db, table_id, record, role_info.get("role_id"), user_id
)
if not can_edit:
continue
filtered_cells = {}
for field_id, val in u.cells.items():
if writable_fields is not None and field_id not in writable_fields:
continue
if field_id not in fields_cache:
fields_cache[field_id] = await SmartFieldService.get_by_id(db, field_id)
field = fields_cache[field_id]
if not field:
continue
err = await SmartValidationService.validate_cell(db, field, val, u.record_id)
if err:
continue
filtered_cells[field_id] = val
if filtered_cells:
valid_updates.append({"record_id": u.record_id, "cells": filtered_cells})
count = await SmartRecordService.batch_update_multi_records(db, valid_updates)
return ResponseModel(message=f"成功更新{count}条记录", data={"updated": count})
@router.put("/tables/{table_id}/records/reorder", response_model=ResponseModel, summary="记录排序")
async def reorder_records(table_id: str, data: SmartRecordReorder, db: AsyncSession = Depends(get_db)):
await SmartRecordService.reorder(db, table_id, data.record_ids)
return ResponseModel(message="排序成功")
@router.post(
"/tables/{table_id}/records/search",
response_model=List[RecordSearchResult],
summary="搜索表记录(供 Link 字段选择器使用)",
)
async def search_records(
table_id: str,
query: RecordSearchQuery,
db: AsyncSession = Depends(get_db),
):
items = await SmartLinkService.search_records(
db, table_id, keyword=query.keyword, limit=query.limit,
)
return [RecordSearchResult(**i) for i in items]
# ==================== Summary ====================
@router.post(
"/tables/{table_id}/summary",
response_model=SummaryResponse,
summary="获取字段汇总统计",
)
async def get_table_summary(
table_id: str,
data: SummaryRequest,
db: AsyncSession = Depends(get_db),
):
role_info = await PermissionService.require_table_access(db, table_id)
parsed_filters = data.filters or None
user_info = get_current_user_info_from_context() or {}
row_conditions = await PermissionService.build_row_filter_conditions(
db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view"
)
summaries, total = await SmartSummaryService.compute(
db, table_id,
field_agg_map=data.aggregations,
filters=parsed_filters,
filter_logic=data.filter_logic,
search=data.search,
extra_conditions=row_conditions or None,
)
return SummaryResponse(summaries=summaries, total_count=total)
# ==================== View ====================
@router.get("/tables/{table_id}/views", response_model=List[SmartViewResponse], summary="获取视图列表")
async def get_view_list(table_id: str, db: AsyncSession = Depends(get_db)):
await PermissionService.require_table_access(db, table_id)
return await SmartViewService.get_by_table(db, table_id)
@router.post("/tables/{table_id}/views", response_model=SmartViewResponse, summary="创建视图")
async def create_view(table_id: str, data: SmartViewCreate, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_view")
data.table_id = table_id
view = await SmartViewService.create(db, data)
return view
@router.put("/views/{view_id}", response_model=SmartViewResponse, summary="更新视图")
async def update_view(view_id: str, data: SmartViewUpdate, db: AsyncSession = Depends(get_db)):
existing_view = await SmartViewService.get_by_id(db, view_id)
if not existing_view:
raise HTTPException(status_code=404, detail="视图不存在")
await PermissionService.require_capability(db, existing_view.table_id, "manage_view")
view = await SmartViewService.update(db, record_id=view_id, data=data)
return view
@router.delete("/views/{view_id}", response_model=ResponseModel, summary="删除视图")
async def delete_view(view_id: str, db: AsyncSession = Depends(get_db)):
existing_view = await SmartViewService.get_by_id(db, view_id)
if not existing_view:
raise HTTPException(status_code=404, detail="视图不存在")
await PermissionService.require_capability(db, existing_view.table_id, "manage_view")
success = await SmartViewService.delete(db, record_id=view_id)
if not success:
raise HTTPException(status_code=404, detail="视图不存在")
return ResponseModel(message="删除成功")
# ==================== Export / Import ====================
@router.get("/tables/{table_id}/export", summary="导出表数据")
async def export_table(
table_id: str,
format: str = Query(default="csv", description="导出格式: csv / xlsx"),
db: AsyncSession = Depends(get_db),
):
role_info = await PermissionService.require_capability(db, table_id, "export_data")
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
fields = await SmartFieldService.get_by_table(db, table_id)
accessible = await PermissionService.get_accessible_field_ids(
db, table_id, role_info.get("role_id"), mode="read"
)
if accessible is not None:
fields = [f for f in fields if f.id in accessible]
user_info = get_current_user_info_from_context() or {}
row_conditions = await PermissionService.build_row_filter_conditions(
db, table_id, role_info.get("role_id"), user_info.get("user_id", ""), rule_type="view"
)
records, _, _ = await SmartRecordService.get_by_table_cursor_filtered(
db, table_id, extra_conditions=row_conditions or None, limit=50000
)
all_fields = await SmartFieldService.get_by_table(db, table_id)
await SmartLinkService.resolve_link_values(db, all_fields, records)
await SmartLinkService.resolve_lookup_values(db, all_fields, records)
await SmartLinkService.resolve_rollup_values(db, all_fields, records)
import io
from urllib.parse import quote
safe_name = table.name.replace("/", "_").replace("\\", "_")
def _content_disposition(filename: str) -> str:
"""Build Content-Disposition with RFC 5987 filename* for non-ASCII names."""
ascii_name = filename.encode("ascii", "ignore").decode("ascii") or "export"
encoded_name = quote(filename)
return (
f'attachment; filename="{ascii_name}"; '
f"filename*=UTF-8''{encoded_name}"
)
if format == "xlsx":
content = await SmartExportService.export_xlsx(db, table_id, fields, records)
return StreamingResponse(
io.BytesIO(content),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": _content_disposition(f"{safe_name}.xlsx")},
)
else:
content = await SmartExportService.export_csv(db, table_id, fields, records)
return StreamingResponse(
io.BytesIO(content.encode("utf-8-sig")),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": _content_disposition(f"{safe_name}.csv")},
)
@router.post("/tables/{table_id}/import", response_model=ResponseModel, summary="导入数据")
async def import_table(
table_id: str,
file: UploadFile = File(..., description="CSV 或 Excel 文件"),
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "import_data")
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="表不存在")
fields = await SmartFieldService.get_by_table(db, table_id)
file_content = await file.read()
filename = file.filename or ""
if filename.endswith(".xlsx") or filename.endswith(".xls"):
result = await SmartImportService.import_xlsx(db, table_id, file_content, fields)
elif filename.endswith(".csv"):
result = await SmartImportService.import_csv(db, table_id, file_content, fields)
else:
raise HTTPException(status_code=400, detail="仅支持 CSV 和 Excel (.xlsx) 文件")
return ResponseModel(
message=f"导入完成:成功 {result['success']} 条,失败 {result['fail']}",
data=result,
)
# ==================== Comment ====================
@router.get("/records/{record_id}/comments", response_model=List[CommentResponse], summary="获取记录评论")
async def get_comments(record_id: str, db: AsyncSession = Depends(get_db)):
from sqlalchemy import select as sa_select
comments = await SmartCommentService.get_by_record(db, record_id)
user_ids = list({c.user_id for c in comments})
user_map: dict = {}
if user_ids:
try:
from core.model import CoreUser
result = await db.execute(
sa_select(CoreUser.id, CoreUser.name, CoreUser.avatar)
.where(CoreUser.id.in_(user_ids))
)
for row in result.all():
user_map[row[0]] = {"name": row[1], "avatar": row[2]}
except Exception:
pass
top_level: list[CommentResponse] = []
reply_map: dict[str, list[CommentResponse]] = {}
for c in comments:
u = user_map.get(c.user_id, {})
item = CommentResponse(
id=c.id,
record_id=c.record_id,
user_id=c.user_id,
content=c.content,
mentions=c.mentions or [],
parent_id=c.parent_id,
sys_create_datetime=c.sys_create_datetime,
sys_update_datetime=c.sys_update_datetime,
user_name=u.get("name"),
user_avatar=u.get("avatar"),
)
if c.parent_id:
reply_map.setdefault(c.parent_id, []).append(item)
else:
top_level.append(item)
for item in top_level:
item.replies = reply_map.get(item.id, [])
return top_level
@router.post("/records/{record_id}/comments", response_model=CommentResponse, summary="创建评论")
async def create_comment(record_id: str, data: CommentCreate, db: AsyncSession = Depends(get_db)):
user_id = get_current_user_id_from_context()
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
comment = await SmartCommentService.create(
db, record_id, user_id, data.content, data.mentions, data.parent_id,
)
user_map: dict = {}
try:
from sqlalchemy import select as sa_select
from core.model import CoreUser
result = await db.execute(
sa_select(CoreUser.id, CoreUser.name, CoreUser.avatar).where(CoreUser.id == user_id)
)
row = result.first()
if row:
user_map[row[0]] = {"name": row[1], "avatar": row[2]}
except Exception:
pass
u = user_map.get(user_id, {})
return CommentResponse(
id=comment.id,
record_id=comment.record_id,
user_id=comment.user_id,
content=comment.content,
mentions=comment.mentions or [],
parent_id=comment.parent_id,
sys_create_datetime=comment.sys_create_datetime,
sys_update_datetime=comment.sys_update_datetime,
user_name=u.get("name"),
user_avatar=u.get("avatar"),
)
@router.put("/comments/{comment_id}", response_model=CommentResponse, summary="更新评论")
async def update_comment(comment_id: str, data: CommentUpdate, db: AsyncSession = Depends(get_db)):
user_id = get_current_user_id_from_context()
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
comment = await SmartCommentService.update(db, comment_id, user_id, data.content, data.mentions)
if not comment:
raise HTTPException(status_code=404, detail="评论不存在或无权编辑")
return CommentResponse(
id=comment.id,
record_id=comment.record_id,
user_id=comment.user_id,
content=comment.content,
mentions=comment.mentions or [],
parent_id=comment.parent_id,
sys_create_datetime=comment.sys_create_datetime,
sys_update_datetime=comment.sys_update_datetime,
)
@router.delete("/comments/{comment_id}", response_model=ResponseModel, summary="删除评论")
async def delete_comment(comment_id: str, db: AsyncSession = Depends(get_db)):
user_id = get_current_user_id_from_context()
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
ok = await SmartCommentService.delete(db, comment_id, user_id)
if not ok:
raise HTTPException(status_code=404, detail="评论不存在或无权删除")
return ResponseModel(message="删除成功")
# ==================== Document Version ====================
@router.get("/tables/{table_id}/versions", summary="获取版本列表")
async def get_document_versions(
table_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100, alias="pageSize"),
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_table_access(db, table_id)
items, total = await SmartDocumentVersionService.get_versions(db, table_id, page, page_size)
user_ids = list({v.sys_creator_id for v in items if v.sys_creator_id})
user_map: dict = {}
if user_ids:
users = await UserService.get_by_ids(db, user_ids)
for u in users:
user_map[u.id] = u
result = []
for v in items:
resp = DocumentVersionResponse(
id=v.id, document_id=v.document_id, version=v.version,
title=v.title, change_summary=v.change_summary,
content_size=v.content_size,
sys_create_datetime=v.sys_create_datetime,
sys_creator_id=v.sys_creator_id,
)
u = user_map.get(v.sys_creator_id) if v.sys_creator_id else None
if u:
resp.creator_name = getattr(u, "name", None) or getattr(u, "username", None)
resp.creator_avatar = getattr(u, "avatar", None)
result.append(resp)
return {"items": result, "total": total}
@router.get("/versions/{version_id}", response_model=DocumentVersionDetail, summary="获取版本详情")
async def get_document_version_detail(
version_id: str,
db: AsyncSession = Depends(get_db),
):
version = await SmartDocumentVersionService.get_version_detail(db, version_id)
if not version:
raise HTTPException(status_code=404, detail="版本不存在")
await PermissionService.require_table_access(db, version.document_id)
u = None
if version.sys_creator_id:
u = await UserService.get_by_id(db, version.sys_creator_id)
return DocumentVersionDetail(
id=version.id, document_id=version.document_id, version=version.version,
title=version.title, change_summary=version.change_summary,
content_size=version.content_size, content=version.content or {},
sys_create_datetime=version.sys_create_datetime,
sys_creator_id=version.sys_creator_id,
creator_name=(getattr(u, "name", None) or getattr(u, "username", None)) if u else None,
creator_avatar=getattr(u, "avatar", None) if u else None,
)
@router.post("/tables/{table_id}/versions", response_model=DocumentVersionResponse, summary="手动创建版本快照")
async def create_document_version(
table_id: str,
data: DocumentVersionCreate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "edit_record")
table = await SmartTableService.get_by_id(db, table_id)
if not table:
raise HTTPException(status_code=404, detail="文档不存在")
if getattr(table, "type", "table") != "document":
raise HTTPException(status_code=400, detail="该项目不是文档类型")
if not table.content:
raise HTTPException(status_code=400, detail="文档内容为空,无法创建版本")
user_id = get_current_user_id_from_context()
version = await SmartDocumentVersionService.create_version(
db, table_id, table.content,
title=table.name,
change_summary=data.change_summary,
user_id=user_id,
)
return DocumentVersionResponse(
id=version.id, document_id=version.document_id, version=version.version,
title=version.title, change_summary=version.change_summary,
content_size=version.content_size,
sys_create_datetime=version.sys_create_datetime,
sys_creator_id=version.sys_creator_id,
)
@router.post("/tables/{table_id}/versions/{version_id}/restore", response_model=ResponseModel, summary="恢复到指定版本")
async def restore_document_version(
table_id: str,
version_id: str,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "edit_record")
user_id = get_current_user_id_from_context()
result = await SmartDocumentVersionService.restore_version(db, table_id, version_id, user_id)
if not result:
raise HTTPException(status_code=404, detail="版本不存在或文档不存在")
return ResponseModel(message="版本恢复成功")
@router.get("/tables/{table_id}/versions/compare", response_model=DocumentVersionCompare, summary="版本对比")
async def compare_document_versions(
table_id: str,
version_from: str = Query(..., alias="from", description="源版本ID"),
version_to: str = Query(..., alias="to", description="目标版本ID"),
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_table_access(db, table_id)
v_from = await SmartDocumentVersionService.get_version_detail(db, version_from)
v_to = await SmartDocumentVersionService.get_version_detail(db, version_to)
if not v_from or not v_to:
raise HTTPException(status_code=404, detail="版本不存在")
if v_from.document_id != table_id or v_to.document_id != table_id:
raise HTTPException(status_code=400, detail="版本不属于该文档")
return DocumentVersionCompare(
version_from=DocumentVersionDetail(
id=v_from.id, document_id=v_from.document_id, version=v_from.version,
title=v_from.title, change_summary=v_from.change_summary,
content_size=v_from.content_size, content=v_from.content or {},
sys_create_datetime=v_from.sys_create_datetime, sys_creator_id=v_from.sys_creator_id,
),
version_to=DocumentVersionDetail(
id=v_to.id, document_id=v_to.document_id, version=v_to.version,
title=v_to.title, change_summary=v_to.change_summary,
content_size=v_to.content_size, content=v_to.content or {},
sys_create_datetime=v_to.sys_create_datetime, sys_creator_id=v_to.sys_creator_id,
),
)
@router.delete("/versions/{version_id}", response_model=ResponseModel, summary="删除版本")
async def delete_document_version(
version_id: str,
db: AsyncSession = Depends(get_db),
):
version = await SmartDocumentVersionService.get_version_detail(db, version_id)
if not version:
raise HTTPException(status_code=404, detail="版本不存在")
await PermissionService.require_capability(db, version.document_id, "edit_record")
ok = await SmartDocumentVersionService.delete_version(db, version_id)
if not ok:
raise HTTPException(status_code=404, detail="版本不存在")
return ResponseModel(message="删除成功")
# ==================== Document Template ====================
@router.get("/document-templates", summary="获取模板列表")
async def get_document_templates(
category: Optional[str] = Query(default=None, description="分类筛选"),
keyword: Optional[str] = Query(default=None, description="关键词搜索"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=100, alias="pageSize"),
db: AsyncSession = Depends(get_db),
):
items, total = await SmartDocumentTemplateService.get_list(
db, category=category, keyword=keyword, page=page, page_size=page_size,
)
user_ids = list({t.sys_creator_id for t in items if t.sys_creator_id})
user_map: dict = {}
if user_ids:
users = await UserService.get_by_ids(db, user_ids)
for u in users:
user_map[u.id] = u
result = []
for t in items:
resp = DocumentTemplateResponse(
id=t.id, name=t.name, description=t.description,
icon=t.icon, category=t.category,
preview_image=t.preview_image,
is_system=t.is_system, use_count=t.use_count,
sys_create_datetime=t.sys_create_datetime,
sys_update_datetime=t.sys_update_datetime,
sys_creator_id=t.sys_creator_id,
)
u = user_map.get(t.sys_creator_id) if t.sys_creator_id else None
if u:
resp.creator_name = getattr(u, "name", None) or getattr(u, "username", None)
result.append(resp)
return {"items": result, "total": total}
@router.get("/document-templates/categories", summary="获取模板分类")
async def get_template_categories(db: AsyncSession = Depends(get_db)):
return await SmartDocumentTemplateService.get_categories(db)
@router.get("/document-templates/{template_id}", response_model=DocumentTemplateDetail, summary="获取模板详情")
async def get_document_template_detail(
template_id: str,
db: AsyncSession = Depends(get_db),
):
template = await SmartDocumentTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
u = None
if template.sys_creator_id:
u = await UserService.get_by_id(db, template.sys_creator_id)
return DocumentTemplateDetail(
id=template.id, name=template.name, description=template.description,
icon=template.icon, category=template.category,
preview_image=template.preview_image,
is_system=template.is_system, use_count=template.use_count,
content=template.content or {},
sys_create_datetime=template.sys_create_datetime,
sys_update_datetime=template.sys_update_datetime,
sys_creator_id=template.sys_creator_id,
creator_name=(getattr(u, "name", None) or getattr(u, "username", None)) if u else None,
)
@router.post("/document-templates", response_model=DocumentTemplateResponse, summary="创建模板")
async def create_document_template(
data: DocumentTemplateCreate,
db: AsyncSession = Depends(get_db),
):
user_id = get_current_user_id_from_context()
template = await SmartDocumentTemplateService.create(
db, name=data.name, content=data.content,
description=data.description, icon=data.icon,
category=data.category, preview_image=data.preview_image,
user_id=user_id,
)
return DocumentTemplateResponse(
id=template.id, name=template.name, description=template.description,
icon=template.icon, category=template.category,
preview_image=template.preview_image,
is_system=template.is_system, use_count=template.use_count,
sys_create_datetime=template.sys_create_datetime,
sys_update_datetime=template.sys_update_datetime,
sys_creator_id=template.sys_creator_id,
)
@router.post("/document-templates/from-document/{document_id}", response_model=DocumentTemplateResponse, summary="从文档创建模板")
async def create_template_from_document(
document_id: str,
data: DocumentTemplateCreate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_table_access(db, document_id)
user_id = get_current_user_id_from_context()
template = await SmartDocumentTemplateService.create_from_document(
db, document_id=document_id, name=data.name,
description=data.description, category=data.category,
user_id=user_id,
)
if not template:
raise HTTPException(status_code=400, detail="文档不存在或内容为空")
return DocumentTemplateResponse(
id=template.id, name=template.name, description=template.description,
icon=template.icon, category=template.category,
preview_image=template.preview_image,
is_system=template.is_system, use_count=template.use_count,
sys_create_datetime=template.sys_create_datetime,
sys_update_datetime=template.sys_update_datetime,
sys_creator_id=template.sys_creator_id,
)
@router.put("/document-templates/{template_id}", response_model=DocumentTemplateResponse, summary="更新模板")
async def update_document_template(
template_id: str,
data: DocumentTemplateUpdate,
db: AsyncSession = Depends(get_db),
):
user_id = get_current_user_id_from_context()
update_data = data.model_dump(exclude_unset=True)
template = await SmartDocumentTemplateService.update(db, template_id, update_data, user_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
return DocumentTemplateResponse(
id=template.id, name=template.name, description=template.description,
icon=template.icon, category=template.category,
preview_image=template.preview_image,
is_system=template.is_system, use_count=template.use_count,
sys_create_datetime=template.sys_create_datetime,
sys_update_datetime=template.sys_update_datetime,
sys_creator_id=template.sys_creator_id,
)
@router.delete("/document-templates/{template_id}", response_model=ResponseModel, summary="删除模板")
async def delete_document_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
ok = await SmartDocumentTemplateService.delete(db, template_id)
if not ok:
raise HTTPException(status_code=404, detail="模板不存在或为系统模板不可删除")
return ResponseModel(message="删除成功")
@router.post("/document-templates/{template_id}/use", response_model=ResponseModel, summary="使用模板(增加使用计数)")
async def use_document_template(
template_id: str,
db: AsyncSession = Depends(get_db),
):
template = await SmartDocumentTemplateService.get_by_id(db, template_id)
if not template:
raise HTTPException(status_code=404, detail="模板不存在")
await SmartDocumentTemplateService.increment_use_count(db, template_id)
return ResponseModel(message="OK")
# ==================== Wiki Space ====================
@router.post("/wiki-spaces", response_model=WikiSpaceResponse, summary="创建文档库")
async def create_wiki_space(data: WikiSpaceCreate, db: AsyncSession = Depends(get_db)):
user_id = get_current_user_id_from_context()
space = await WikiSpaceService.create(db, data, current_user_id=user_id)
creator_name = None
if user_id:
user = await UserService.get_by_id(db, user_id)
if user:
creator_name = getattr(user, "real_name", None) or getattr(user, "username", None)
resp = WikiSpaceResponse.model_validate(space)
resp.creator_name = creator_name
return resp
@router.get("/wiki-spaces", response_model=List[WikiSpaceListItem], summary="获取文档库列表")
async def get_wiki_spaces(db: AsyncSession = Depends(get_db)):
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
is_superuser = user_info.get("is_superuser", False) if user_info else False
items, _ = await WikiSpaceService.get_list_with_count(
db, user_id=user_id, is_superuser=is_superuser,
)
result = []
for item in items:
space = item["space"]
creator_name = None
if space.sys_creator_id:
user = await UserService.get_by_id(db, space.sys_creator_id)
if user:
creator_name = getattr(user, "real_name", None) or getattr(user, "username", None)
resp = WikiSpaceListItem(
**WikiSpaceResponse.model_validate(space).model_dump(),
document_count=item["document_count"],
)
resp.creator_name = creator_name
result.append(resp)
return result
@router.get("/wiki-spaces/{space_id}", response_model=WikiSpaceDetail, summary="获取文档库详情")
async def get_wiki_space_detail(space_id: str, db: AsyncSession = Depends(get_db)):
space = await WikiSpaceService.get_by_id(db, space_id)
if not space:
raise HTTPException(status_code=404, detail="文档库不存在")
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
dept_id = user_info.get("dept_id") if user_info else None
role_ids = user_info.get("role_ids", []) if user_info else []
is_superuser = user_info.get("is_superuser", False) if user_info else False
has_access = await WikiSpaceService.check_space_access(
space, user_id=user_id, is_superuser=is_superuser,
)
if not has_access:
raise HTTPException(status_code=403, detail="无权访问此文档库")
documents = await WikiSpaceService.get_documents(
db, space_id,
user_id=user_id, dept_id=dept_id,
role_ids=role_ids, is_superuser=is_superuser,
)
from zq_smart_table.schema import SmartTableSimple
doc_list = [SmartTableSimple.model_validate(d) for d in documents]
creator_name = None
if space.sys_creator_id:
user = await UserService.get_by_id(db, space.sys_creator_id)
if user:
creator_name = getattr(user, "real_name", None) or getattr(user, "username", None)
resp = WikiSpaceDetail(
**WikiSpaceResponse.model_validate(space).model_dump(),
documents=doc_list,
)
resp.creator_name = creator_name
return resp
@router.put("/wiki-spaces/{space_id}", response_model=WikiSpaceResponse, summary="更新文档库")
async def update_wiki_space(
space_id: str,
data: WikiSpaceUpdate,
db: AsyncSession = Depends(get_db),
):
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
is_superuser = user_info.get("is_superuser", False) if user_info else False
space = await WikiSpaceService.get_by_id(db, space_id)
if not space:
raise HTTPException(status_code=404, detail="文档库不存在")
if not is_superuser and space.sys_creator_id != user_id:
raise HTTPException(status_code=403, detail="仅创建者可修改文档库")
space = await WikiSpaceService.update(db, space_id, data, current_user_id=user_id)
return WikiSpaceResponse.model_validate(space)
@router.delete("/wiki-spaces/{space_id}", response_model=ResponseModel, summary="删除文档库")
async def delete_wiki_space(space_id: str, db: AsyncSession = Depends(get_db)):
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
is_superuser = user_info.get("is_superuser", False) if user_info else False
space = await WikiSpaceService.get_by_id(db, space_id)
if not space:
raise HTTPException(status_code=404, detail="文档库不存在")
if not is_superuser and space.sys_creator_id != user_id:
raise HTTPException(status_code=403, detail="仅创建者可删除文档库")
ok = await WikiSpaceService.delete_with_documents(db, space_id)
if not ok:
raise HTTPException(status_code=404, detail="文档库不存在")
return ResponseModel(message="删除成功")
@router.get("/wiki-spaces/{space_id}/documents", response_model=List[SmartTableSimple], summary="获取文档库文档列表")
async def get_wiki_space_documents(space_id: str, db: AsyncSession = Depends(get_db)):
space = await WikiSpaceService.get_by_id(db, space_id)
if not space:
raise HTTPException(status_code=404, detail="文档库不存在")
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
dept_id = user_info.get("dept_id") if user_info else None
role_ids = user_info.get("role_ids", []) if user_info else []
is_superuser = user_info.get("is_superuser", False) if user_info else False
has_access = await WikiSpaceService.check_space_access(
space, user_id=user_id, is_superuser=is_superuser,
)
if not has_access:
raise HTTPException(status_code=403, detail="无权访问此文档库")
documents = await WikiSpaceService.get_documents(
db, space_id,
user_id=user_id, dept_id=dept_id,
role_ids=role_ids, is_superuser=is_superuser,
)
return [SmartTableSimple.model_validate(d) for d in documents]
@router.post("/wiki-spaces/{space_id}/documents", response_model=SmartTableResponse, summary="在文档库中创建文档")
async def create_wiki_document(
space_id: str,
data: SmartTableCreate,
db: AsyncSession = Depends(get_db),
):
space = await WikiSpaceService.get_by_id(db, space_id)
if not space:
raise HTTPException(status_code=404, detail="文档库不存在")
user_info = get_current_user_info_from_context()
user_id = user_info.get("user_id", "") if user_info else None
is_superuser = user_info.get("is_superuser", False) if user_info else False
has_access = await WikiSpaceService.check_space_access(
space, user_id=user_id, is_superuser=is_superuser,
)
if not has_access:
raise HTTPException(status_code=403, detail="无权在此文档库中创建文档")
doc = await WikiSpaceService.add_document(
db, space_id,
name=data.name,
parent_id=data.parent_id,
content=data.content,
user_id=user_id,
)
if user_id:
await PermissionService.init_table_owner(db, doc.id, user_id)
return SmartTableResponse.model_validate(doc)