feat: restore source parity and harden agent runtime

This commit is contained in:
2026-06-22 11:17:26 +08:00
parent e33f08277b
commit 0793eb82d6
596 changed files with 168879 additions and 290 deletions
File diff suppressed because it is too large Load Diff
+612
View File
@@ -0,0 +1,612 @@
"""
Safe formula engine for smart table.
Supports field references {FieldName}, arithmetic, comparisons, and built-in functions.
No eval/exec - all evaluation done via AST traversal.
"""
import math
from datetime import date, datetime, timedelta
from enum import Enum
from typing import Any, Dict, List, Optional
# ==================== Tokenizer ====================
class TokenType(Enum):
NUMBER = "NUMBER"
STRING = "STRING"
FIELD_REF = "FIELD_REF"
FUNCTION = "FUNCTION"
OPERATOR = "OPERATOR"
LPAREN = "LPAREN"
RPAREN = "RPAREN"
COMMA = "COMMA"
BOOLEAN = "BOOLEAN"
EOF = "EOF"
class Token:
__slots__ = ("type", "value")
def __init__(self, type_: TokenType, value: Any):
self.type = type_
self.value = value
def __repr__(self):
return f"Token({self.type.name}, {self.value!r})"
_FUNC_NAMES = {
"IF", "AND", "OR", "NOT",
"CONCATENATE", "CONCAT",
"ABS", "ROUND", "CEIL", "FLOOR", "INT", "MOD", "POWER", "SQRT",
"UPPER", "LOWER", "LEN", "LEFT", "RIGHT", "MID", "TRIM", "SUBSTITUTE",
"NOW", "TODAY", "DATEDIFF", "DATEADD", "YEAR", "MONTH", "DAY",
"MIN", "MAX", "SUM", "AVERAGE",
"ISNULL", "VALUE", "TEXT", "FIXED",
}
_TWO_CHAR_OPS = {"!=", ">=", "<=", "<>", "&&", "||"}
_ONE_CHAR_OPS = {"+", "-", "*", "/", "%", "=", ">", "<", "&"}
def tokenize(formula: str) -> List[Token]:
tokens: List[Token] = []
i = 0
n = len(formula)
while i < n:
ch = formula[i]
if ch in (" ", "\t", "\n", "\r"):
i += 1
continue
if ch == "{":
end = formula.find("}", i + 1)
if end == -1:
raise FormulaError(f"未闭合的字段引用 '{{' 在位置 {i}")
tokens.append(Token(TokenType.FIELD_REF, formula[i + 1:end]))
i = end + 1
continue
if ch == '"' or ch == "'":
quote = ch
j = i + 1
parts = []
while j < n:
if formula[j] == "\\" and j + 1 < n:
parts.append(formula[j + 1])
j += 2
elif formula[j] == quote:
break
else:
parts.append(formula[j])
j += 1
if j >= n:
raise FormulaError(f"未闭合的字符串在位置 {i}")
tokens.append(Token(TokenType.STRING, "".join(parts)))
i = j + 1
continue
if ch.isdigit() or (ch == "." and i + 1 < n and formula[i + 1].isdigit()):
j = i
has_dot = False
while j < n and (formula[j].isdigit() or (formula[j] == "." and not has_dot)):
if formula[j] == ".":
has_dot = True
j += 1
tokens.append(Token(TokenType.NUMBER, float(formula[i:j])))
i = j
continue
if ch == "(":
tokens.append(Token(TokenType.LPAREN, "("))
i += 1
continue
if ch == ")":
tokens.append(Token(TokenType.RPAREN, ")"))
i += 1
continue
if ch == ",":
tokens.append(Token(TokenType.COMMA, ","))
i += 1
continue
two = formula[i:i + 2] if i + 1 < n else ""
if two in _TWO_CHAR_OPS:
tokens.append(Token(TokenType.OPERATOR, two))
i += 2
continue
if ch in _ONE_CHAR_OPS:
tokens.append(Token(TokenType.OPERATOR, ch))
i += 1
continue
if ch.isalpha() or ch == "_":
j = i
while j < n and (formula[j].isalnum() or formula[j] == "_"):
j += 1
word = formula[i:j]
upper = word.upper()
if upper in ("TRUE", "FALSE"):
tokens.append(Token(TokenType.BOOLEAN, upper == "TRUE"))
elif upper in _FUNC_NAMES:
tokens.append(Token(TokenType.FUNCTION, upper))
else:
tokens.append(Token(TokenType.FIELD_REF, word))
i = j
continue
raise FormulaError(f"无法识别的字符 '{ch}' 在位置 {i}")
tokens.append(Token(TokenType.EOF, None))
return tokens
# ==================== AST Nodes ====================
class ASTNode:
pass
class NumberLiteral(ASTNode):
__slots__ = ("value",)
def __init__(self, value: float):
self.value = value
class StringLiteral(ASTNode):
__slots__ = ("value",)
def __init__(self, value: str):
self.value = value
class BooleanLiteral(ASTNode):
__slots__ = ("value",)
def __init__(self, value: bool):
self.value = value
class FieldReference(ASTNode):
__slots__ = ("name",)
def __init__(self, name: str):
self.name = name
class BinaryOp(ASTNode):
__slots__ = ("op", "left", "right")
def __init__(self, op: str, left: ASTNode, right: ASTNode):
self.op = op
self.left = left
self.right = right
class UnaryOp(ASTNode):
__slots__ = ("op", "operand")
def __init__(self, op: str, operand: ASTNode):
self.op = op
self.operand = operand
class FunctionCall(ASTNode):
__slots__ = ("name", "args")
def __init__(self, name: str, args: List[ASTNode]):
self.name = name
self.args = args
# ==================== Parser ====================
class FormulaError(Exception):
pass
class Parser:
def __init__(self, tokens: List[Token]):
self.tokens = tokens
self.pos = 0
def _current(self) -> Token:
return self.tokens[self.pos]
def _eat(self, expected_type: Optional[TokenType] = None) -> Token:
tok = self._current()
if expected_type and tok.type != expected_type:
raise FormulaError(f"期望 {expected_type.name},实际 {tok.type.name}({tok.value!r})")
self.pos += 1
return tok
def parse(self) -> ASTNode:
node = self._expr()
if self._current().type != TokenType.EOF:
raise FormulaError(f"意外的 token: {self._current()}")
return node
def _expr(self) -> ASTNode:
return self._logic_or()
def _logic_or(self) -> ASTNode:
node = self._logic_and()
while self._current().type == TokenType.OPERATOR and self._current().value in ("||",):
op = self._eat().value
right = self._logic_and()
node = BinaryOp(op, node, right)
return node
def _logic_and(self) -> ASTNode:
node = self._comparison()
while self._current().type == TokenType.OPERATOR and self._current().value in ("&&",):
op = self._eat().value
right = self._comparison()
node = BinaryOp(op, node, right)
return node
def _comparison(self) -> ASTNode:
node = self._concat()
while self._current().type == TokenType.OPERATOR and self._current().value in ("=", "!=", "<>", ">", "<", ">=", "<="):
op = self._eat().value
right = self._concat()
node = BinaryOp(op, node, right)
return node
def _concat(self) -> ASTNode:
node = self._addition()
while self._current().type == TokenType.OPERATOR and self._current().value == "&":
self._eat()
right = self._addition()
node = BinaryOp("&", node, right)
return node
def _addition(self) -> ASTNode:
node = self._multiplication()
while self._current().type == TokenType.OPERATOR and self._current().value in ("+", "-"):
op = self._eat().value
right = self._multiplication()
node = BinaryOp(op, node, right)
return node
def _multiplication(self) -> ASTNode:
node = self._unary()
while self._current().type == TokenType.OPERATOR and self._current().value in ("*", "/", "%"):
op = self._eat().value
right = self._unary()
node = BinaryOp(op, node, right)
return node
def _unary(self) -> ASTNode:
if self._current().type == TokenType.OPERATOR and self._current().value == "-":
self._eat()
operand = self._unary()
return UnaryOp("-", operand)
return self._primary()
def _primary(self) -> ASTNode:
tok = self._current()
if tok.type == TokenType.NUMBER:
self._eat()
return NumberLiteral(tok.value)
if tok.type == TokenType.STRING:
self._eat()
return StringLiteral(tok.value)
if tok.type == TokenType.BOOLEAN:
self._eat()
return BooleanLiteral(tok.value)
if tok.type == TokenType.FIELD_REF:
self._eat()
return FieldReference(tok.value)
if tok.type == TokenType.FUNCTION:
return self._function_call()
if tok.type == TokenType.LPAREN:
self._eat()
node = self._expr()
self._eat(TokenType.RPAREN)
return node
raise FormulaError(f"意外的 token: {tok}")
def _function_call(self) -> ASTNode:
name = self._eat(TokenType.FUNCTION).value
self._eat(TokenType.LPAREN)
args: List[ASTNode] = []
if self._current().type != TokenType.RPAREN:
args.append(self._expr())
while self._current().type == TokenType.COMMA:
self._eat()
args.append(self._expr())
self._eat(TokenType.RPAREN)
return FunctionCall(name, args)
# ==================== Evaluator ====================
def _to_number(v: Any) -> float:
if v is None or v == "":
return 0.0
try:
return float(v)
except (ValueError, TypeError):
return 0.0
def _to_string(v: Any) -> str:
if v is None:
return ""
if isinstance(v, bool):
return "TRUE" if v else "FALSE"
if isinstance(v, float) and v == int(v):
return str(int(v))
return str(v)
def _to_bool(v: Any) -> bool:
if isinstance(v, bool):
return v
if isinstance(v, (int, float)):
return v != 0
if isinstance(v, str):
return v.upper() not in ("", "FALSE", "0")
return bool(v)
def _parse_date(v: Any) -> Optional[datetime]:
if isinstance(v, datetime):
return v
if isinstance(v, date):
return datetime.combine(v, datetime.min.time())
if isinstance(v, str):
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d", "%Y/%m/%d"):
try:
return datetime.strptime(v.strip()[:19], fmt)
except ValueError:
continue
return None
def _evaluate_function(name: str, args: List[Any]) -> Any:
n = len(args)
if name == "IF":
if n < 2:
raise FormulaError("IF 需要至少 2 个参数")
cond = _to_bool(args[0])
return args[1] if cond else (args[2] if n > 2 else "")
if name == "AND":
return all(_to_bool(a) for a in args)
if name == "OR":
return any(_to_bool(a) for a in args)
if name == "NOT":
return not _to_bool(args[0]) if n > 0 else True
if name in ("CONCATENATE", "CONCAT"):
return "".join(_to_string(a) for a in args)
if name == "ABS":
return abs(_to_number(args[0])) if n > 0 else 0
if name == "ROUND":
digits = int(_to_number(args[1])) if n > 1 else 0
return round(_to_number(args[0]), digits) if n > 0 else 0
if name == "CEIL":
return math.ceil(_to_number(args[0])) if n > 0 else 0
if name == "FLOOR":
return math.floor(_to_number(args[0])) if n > 0 else 0
if name == "INT":
return int(_to_number(args[0])) if n > 0 else 0
if name == "MOD":
if n < 2:
return 0
divisor = _to_number(args[1])
return _to_number(args[0]) % divisor if divisor != 0 else 0
if name == "POWER":
return _to_number(args[0]) ** _to_number(args[1]) if n >= 2 else 0
if name == "SQRT":
val = _to_number(args[0]) if n > 0 else 0
return math.sqrt(val) if val >= 0 else None
if name == "UPPER":
return _to_string(args[0]).upper() if n > 0 else ""
if name == "LOWER":
return _to_string(args[0]).lower() if n > 0 else ""
if name == "LEN":
return len(_to_string(args[0])) if n > 0 else 0
if name == "LEFT":
s = _to_string(args[0]) if n > 0 else ""
count = int(_to_number(args[1])) if n > 1 else 1
return s[:count]
if name == "RIGHT":
s = _to_string(args[0]) if n > 0 else ""
count = int(_to_number(args[1])) if n > 1 else 1
return s[-count:] if count > 0 else ""
if name == "MID":
s = _to_string(args[0]) if n > 0 else ""
start = max(1, int(_to_number(args[1]))) if n > 1 else 1
length = int(_to_number(args[2])) if n > 2 else 1
return s[start - 1:start - 1 + length]
if name == "TRIM":
return _to_string(args[0]).strip() if n > 0 else ""
if name == "SUBSTITUTE":
if n < 3:
return _to_string(args[0]) if n > 0 else ""
s = _to_string(args[0])
old = _to_string(args[1])
new = _to_string(args[2])
return s.replace(old, new)
if name == "NOW":
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if name == "TODAY":
return date.today().isoformat()
if name == "YEAR":
d = _parse_date(args[0]) if n > 0 else None
return d.year if d else None
if name == "MONTH":
d = _parse_date(args[0]) if n > 0 else None
return d.month if d else None
if name == "DAY":
d = _parse_date(args[0]) if n > 0 else None
return d.day if d else None
if name == "DATEDIFF":
if n < 2:
return None
d1 = _parse_date(args[0])
d2 = _parse_date(args[1])
if d1 and d2:
unit = _to_string(args[2]).upper() if n > 2 else "DAYS"
diff = d2 - d1
if unit in ("DAYS", "D"):
return diff.days
if unit in ("HOURS", "H"):
return diff.total_seconds() / 3600
if unit in ("MONTHS", "M"):
return (d2.year - d1.year) * 12 + (d2.month - d1.month)
if unit in ("YEARS", "Y"):
return d2.year - d1.year
return diff.days
return None
if name == "DATEADD":
if n < 2:
return None
d = _parse_date(args[0])
amount = int(_to_number(args[1]))
unit = _to_string(args[2]).upper() if n > 2 else "DAYS"
if d:
if unit in ("DAYS", "D"):
return (d + timedelta(days=amount)).strftime("%Y-%m-%d")
if unit in ("HOURS", "H"):
return (d + timedelta(hours=amount)).strftime("%Y-%m-%d %H:%M:%S")
if unit in ("MONTHS", "M"):
month = d.month + amount
year = d.year + (month - 1) // 12
month = (month - 1) % 12 + 1
day = min(d.day, 28)
return date(year, month, day).isoformat()
return None
if name == "MIN":
nums = [_to_number(a) for a in args if a is not None and a != ""]
return min(nums) if nums else None
if name == "MAX":
nums = [_to_number(a) for a in args if a is not None and a != ""]
return max(nums) if nums else None
if name == "SUM":
return sum(_to_number(a) for a in args if a is not None and a != "")
if name == "AVERAGE":
nums = [_to_number(a) for a in args if a is not None and a != ""]
return sum(nums) / len(nums) if nums else None
if name == "ISNULL":
return args[0] is None or args[0] == "" if n > 0 else True
if name == "VALUE":
return _to_number(args[0]) if n > 0 else 0
if name == "TEXT":
return _to_string(args[0]) if n > 0 else ""
if name == "FIXED":
val = _to_number(args[0]) if n > 0 else 0
digits = int(_to_number(args[1])) if n > 1 else 2
return f"{val:.{digits}f}"
raise FormulaError(f"未知函数: {name}")
def evaluate(node: ASTNode, context: Dict[str, Any], field_name_map: Dict[str, str]) -> Any:
if isinstance(node, NumberLiteral):
return node.value
if isinstance(node, StringLiteral):
return node.value
if isinstance(node, BooleanLiteral):
return node.value
if isinstance(node, FieldReference):
field_id = field_name_map.get(node.name)
if field_id is None:
field_id = node.name
return context.get(field_id)
if isinstance(node, UnaryOp):
val = evaluate(node.operand, context, field_name_map)
if node.op == "-":
return -_to_number(val)
return val
if isinstance(node, BinaryOp):
left = evaluate(node.left, context, field_name_map)
right = evaluate(node.right, context, field_name_map)
op = node.op
if op == "+":
return _to_number(left) + _to_number(right)
if op == "-":
return _to_number(left) - _to_number(right)
if op == "*":
return _to_number(left) * _to_number(right)
if op == "/":
r = _to_number(right)
return _to_number(left) / r if r != 0 else None
if op == "%":
r = _to_number(right)
return _to_number(left) % r if r != 0 else None
if op == "&":
return _to_string(left) + _to_string(right)
if op in ("=", "=="):
return left == right
if op in ("!=", "<>"):
return left != right
if op == ">":
return _to_number(left) > _to_number(right)
if op == "<":
return _to_number(left) < _to_number(right)
if op == ">=":
return _to_number(left) >= _to_number(right)
if op == "<=":
return _to_number(left) <= _to_number(right)
if op == "&&":
return _to_bool(left) and _to_bool(right)
if op == "||":
return _to_bool(left) or _to_bool(right)
raise FormulaError(f"未知运算符: {op}")
if isinstance(node, FunctionCall):
evaluated_args = [evaluate(a, context, field_name_map) for a in node.args]
return _evaluate_function(node.name, evaluated_args)
raise FormulaError(f"未知 AST 节点: {type(node)}")
# ==================== Public API ====================
def compute_formula(
formula: str,
record_values: Dict[str, Any],
field_name_map: Dict[str, str],
) -> Any:
"""
计算公式。
formula: 公式字符串,如 "IF({状态}=\"完成\", {金额} * 1.1, {金额})"
record_values: {fieldId: value}
field_name_map: {fieldName: fieldId}
返回计算结果;出错时返回 '#ERROR'
"""
if not formula or not formula.strip():
return ""
try:
tokens = tokenize(formula)
parser = Parser(tokens)
ast = parser.parse()
result = evaluate(ast, record_values, field_name_map)
if isinstance(result, float):
if result == int(result) and abs(result) < 1e15:
return int(result)
return round(result, 10)
return result
except Exception:
return "#ERROR"
+138
View File
@@ -0,0 +1,138 @@
from sqlalchemy import Column, String, Text, Boolean, Integer, JSON, Index
from sqlalchemy.dialects.postgresql import JSONB
from app.base_model import BaseModel
SmartJSON = JSON().with_variant(JSONB(), "postgresql")
class SmartTable(BaseModel):
"""多维表格 / 文档"""
__tablename__ = "smart_table"
name = Column(String(200), nullable=False, comment="表名")
icon = Column(String(50), default="Grid", comment="图标")
description = Column(Text, nullable=True, comment="描述")
active_view_id = Column(String(21), nullable=True, comment="当前激活视图ID")
type = Column(String(20), default="table", nullable=False, comment="类型: table / document")
content = Column(SmartJSON, nullable=True, comment="文档内容(Tiptap JSON), 仅 type=document 时使用")
parent_id = Column(String(21), nullable=True, index=True, comment="父页面ID(自引用,用于子页面层级嵌套)")
wiki_space_id = Column(String(21), nullable=True, index=True, comment="所属文档库ID")
class SmartField(BaseModel):
"""多维表格字段"""
__tablename__ = "smart_field"
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
name = Column(String(200), nullable=False, comment="字段名")
type = Column(String(30), nullable=False, comment="字段类型")
width = Column(Integer, default=150, comment="列宽")
visible = Column(Boolean, default=True, comment="是否可见")
required = Column(Boolean, default=False, comment="是否必填")
description = Column(Text, nullable=True, comment="描述")
config = Column(SmartJSON, default=dict, comment="扩展配置(options/format/precision等)")
__table_args__ = (
Index("ix_smart_field_table_sort", "table_id", "sort"),
)
class SmartRecord(BaseModel):
"""多维表格记录"""
__tablename__ = "smart_record"
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
values = Column(SmartJSON, default=dict, comment="字段值映射 {fieldId: cellValue}")
__table_args__ = (
Index("ix_smart_record_table_sort", "table_id", "sort"),
)
class SmartTableLink(BaseModel):
"""记录关联关系(多对多)"""
__tablename__ = "smart_table_link"
field_id = Column(String(21), nullable=False, index=True, comment="Link字段ID")
source_record_id = Column(String(21), nullable=False, index=True, comment="源记录ID")
target_record_id = Column(String(21), nullable=False, index=True, comment="目标记录ID")
__table_args__ = (
Index("ix_link_field_source", "field_id", "source_record_id"),
Index("ix_link_field_target", "field_id", "target_record_id"),
Index("uq_link_pair", "field_id", "source_record_id", "target_record_id", unique=True),
)
class SmartView(BaseModel):
"""多维表格视图"""
__tablename__ = "smart_view"
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
name = Column(String(200), nullable=False, comment="视图名")
type = Column(String(30), default="grid", comment="视图类型")
config = Column(SmartJSON, default=dict, comment="视图配置(filters/sorts/groups/visibleFieldIds等)")
__table_args__ = (
Index("ix_smart_view_table_sort", "table_id", "sort"),
)
class SmartTableComment(BaseModel):
"""记录评论"""
__tablename__ = "smart_table_comment"
record_id = Column(String(21), nullable=False, index=True, comment="所属记录ID")
user_id = Column(String(21), nullable=False, index=True, comment="评论者用户ID")
content = Column(Text, nullable=False, comment="评论内容(纯文本,含@提及标记)")
mentions = Column(SmartJSON, default=list, comment="被@提及的用户ID列表")
parent_id = Column(String(21), nullable=True, index=True, comment="父评论ID(用于回复)")
__table_args__ = (
Index("ix_comment_record_created", "record_id", "sys_create_datetime"),
)
class SmartDocumentVersion(BaseModel):
"""文档版本历史"""
__tablename__ = "smart_document_version"
document_id = Column(String(21), nullable=False, index=True, comment="文档ID(逻辑外键关联smart_table")
version = Column(Integer, nullable=False, comment="版本号")
content = Column(SmartJSON, nullable=False, comment="文档内容快照(Tiptap JSON)")
title = Column(String(200), nullable=True, comment="版本标题/文档名称快照")
change_summary = Column(String(500), nullable=True, comment="变更摘要")
content_size = Column(Integer, default=0, comment="内容大小(字节)")
__table_args__ = (
Index("ix_doc_version_doc_ver", "document_id", "version"),
Index("ix_doc_version_doc_created", "document_id", "sys_create_datetime"),
)
class SmartDocumentTemplate(BaseModel):
"""文档模板"""
__tablename__ = "smart_document_template"
name = Column(String(200), nullable=False, comment="模板名称")
description = Column(Text, nullable=True, comment="模板描述")
icon = Column(String(50), default="FileText", comment="模板图标")
category = Column(String(50), default="custom", comment="分类: system / custom")
content = Column(SmartJSON, nullable=False, comment="模板内容(Tiptap JSON)")
preview_image = Column(String(500), nullable=True, comment="预览图URL")
is_system = Column(Boolean, default=False, comment="是否系统预设模板")
use_count = Column(Integer, default=0, comment="使用次数")
class SmartWikiSpace(BaseModel):
"""文档库/知识空间"""
__tablename__ = "smart_wiki_space"
name = Column(String(200), nullable=False, comment="文档库名称")
icon = Column(String(50), default="BookOpen", comment="图标")
avatar = Column(String(500), nullable=True, comment="头像文件ID")
description = Column(Text, nullable=True, comment="描述")
cover = Column(String(500), nullable=True, comment="封面图URL")
category = Column(String(50), default="default", comment="分类标签")
visibility = Column(String(20), default="private", comment="可见性: private/team/public")
@@ -0,0 +1,299 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.base_schema import ResponseModel
from zq_smart_table.permission.schema import (
SmartTableRoleCreate, SmartTableRoleUpdate, SmartTableRoleResponse,
CollaboratorCreate, CollaboratorUpdate, CollaboratorResponse,
FieldPermBatchUpdate, FieldPermMatrix, FieldPermItem,
RowRuleUpdate, RowRuleResponse,
MyPermissionResponse,
)
from zq_smart_table.permission.service import PermissionService
from zq_smart_table.permission.model import SmartTableRole
router = APIRouter(tags=["多维表格-权限"])
# ==================== My Permission ====================
@router.get(
"/tables/{table_id}/my-permission",
response_model=MyPermissionResponse,
summary="获取当前用户对该表的有效权限",
)
async def get_my_permission(table_id: str, db: AsyncSession = Depends(get_db)):
perm = await PermissionService.get_my_permission(db, table_id)
return MyPermissionResponse(**perm)
# ==================== Role ====================
@router.get(
"/tables/{table_id}/roles",
response_model=List[SmartTableRoleResponse],
summary="获取表角色列表",
)
async def get_roles(table_id: str, db: AsyncSession = Depends(get_db)):
await PermissionService.ensure_system_roles(db, table_id)
roles = await PermissionService.get_roles(db, table_id)
return roles
@router.post(
"/tables/{table_id}/roles",
response_model=SmartTableRoleResponse,
summary="创建自定义角色",
)
async def create_role(
table_id: str,
data: SmartTableRoleCreate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "manage_permission")
role = await PermissionService.create_custom_role(
db, table_id, data.name, data.capabilities
)
return role
@router.put(
"/tables/{table_id}/roles/{role_id}",
response_model=SmartTableRoleResponse,
summary="更新角色",
)
async def update_role(
table_id: str, role_id: str,
data: SmartTableRoleUpdate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "manage_permission")
role = await PermissionService.update_role(
db, role_id, name=data.name, capabilities=data.capabilities
)
if not role:
raise HTTPException(status_code=404, detail="角色不存在")
return role
@router.delete(
"/tables/{table_id}/roles/{role_id}",
response_model=ResponseModel,
summary="删除自定义角色",
)
async def delete_role(
table_id: str, role_id: str,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "manage_permission")
success = await PermissionService.delete_role(db, role_id)
if not success:
raise HTTPException(status_code=404, detail="角色不存在或为系统预置角色")
return ResponseModel(message="删除成功")
# ==================== Collaborator ====================
@router.get(
"/tables/{table_id}/collaborators",
response_model=List[CollaboratorResponse],
summary="获取协作者列表",
)
async def get_collaborators(table_id: str, db: AsyncSession = Depends(get_db)):
await PermissionService.require_table_access(db, table_id)
collabs = await PermissionService.get_collaborators(db, table_id)
role_cache: dict = {}
results = []
for c in collabs:
if c.role_id not in role_cache:
from sqlalchemy import select
r = await db.execute(
select(SmartTableRole).where(SmartTableRole.id == c.role_id)
)
role_cache[c.role_id] = r.scalar_one_or_none()
role = role_cache.get(c.role_id)
subject_name = None
subject_avatar = None
if c.subject_type == "user":
from core.user.model import User
from sqlalchemy import select as sel
u = await db.execute(sel(User).where(User.id == c.subject_id))
user = u.scalar_one_or_none()
if user:
subject_name = user.name or user.username
subject_avatar = user.avatar
elif c.subject_type == "dept":
from core.dept.model import Dept
from sqlalchemy import select as sel2
d = await db.execute(sel2(Dept).where(Dept.id == c.subject_id))
dept = d.scalar_one_or_none()
if dept:
subject_name = dept.name
results.append(CollaboratorResponse(
id=c.id,
table_id=c.table_id,
subject_type=c.subject_type,
subject_id=c.subject_id,
role_id=c.role_id,
role_name=role.name if role else None,
role_type=role.role_type if role else None,
subject_name=subject_name,
subject_avatar=subject_avatar,
sys_create_datetime=c.sys_create_datetime,
))
return results
@router.post(
"/tables/{table_id}/collaborators",
response_model=CollaboratorResponse,
summary="添加协作者",
)
async def add_collaborator(
table_id: str,
data: CollaboratorCreate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "manage_permission")
collab = await PermissionService.add_collaborator(
db, table_id, data.subject_type, data.subject_id, data.role_id,
)
return CollaboratorResponse(
id=collab.id,
table_id=collab.table_id,
subject_type=collab.subject_type,
subject_id=collab.subject_id,
role_id=collab.role_id,
sys_create_datetime=collab.sys_create_datetime,
)
@router.put(
"/tables/{table_id}/collaborators/{collab_id}",
response_model=CollaboratorResponse,
summary="更新协作者角色",
)
async def update_collaborator(
table_id: str, collab_id: str,
data: CollaboratorUpdate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "manage_permission")
collab = await PermissionService.update_collaborator_role(db, collab_id, data.role_id)
if not collab:
raise HTTPException(status_code=404, detail="协作者不存在")
return CollaboratorResponse(
id=collab.id,
table_id=collab.table_id,
subject_type=collab.subject_type,
subject_id=collab.subject_id,
role_id=collab.role_id,
sys_create_datetime=collab.sys_create_datetime,
)
@router.delete(
"/tables/{table_id}/collaborators/{collab_id}",
response_model=ResponseModel,
summary="移除协作者",
)
async def remove_collaborator(
table_id: str, collab_id: str,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "manage_permission")
success = await PermissionService.remove_collaborator(db, collab_id)
if not success:
raise HTTPException(status_code=404, detail="协作者不存在")
return ResponseModel(message="移除成功")
# ==================== Field Permission ====================
@router.get(
"/tables/{table_id}/field-permissions",
response_model=List[FieldPermMatrix],
summary="获取列权限矩阵",
)
async def get_field_permissions(table_id: str, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_permission")
await PermissionService.require_multidimensional_table(db, table_id)
roles = await PermissionService.get_roles(db, table_id)
result = []
for role in roles:
perms = await PermissionService.get_field_permissions(db, table_id, role.id)
fields = [FieldPermItem(field_id=fid, access=acc) for fid, acc in perms.items()]
result.append(FieldPermMatrix(
role_id=role.id,
role_name=role.name,
role_type=role.role_type,
fields=fields,
))
return result
@router.put(
"/tables/{table_id}/field-permissions",
response_model=ResponseModel,
summary="批量更新列权限",
)
async def update_field_permissions(
table_id: str,
data: FieldPermBatchUpdate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "manage_permission")
await PermissionService.require_multidimensional_table(db, table_id)
permissions = [{"field_id": p.field_id, "access": p.access} for p in data.permissions]
await PermissionService.batch_set_field_permissions(db, table_id, data.role_id, permissions)
return ResponseModel(message="更新成功")
# ==================== Row Rule ====================
@router.get(
"/tables/{table_id}/row-rules",
response_model=List[RowRuleResponse],
summary="获取行权限规则",
)
async def get_row_rules(table_id: str, db: AsyncSession = Depends(get_db)):
await PermissionService.require_capability(db, table_id, "manage_permission")
await PermissionService.require_multidimensional_table(db, table_id)
from sqlalchemy import select
from zq_smart_table.permission.model import SmartTableRowRule
result = await db.execute(
select(SmartTableRowRule).where(
SmartTableRowRule.table_id == table_id,
SmartTableRowRule.is_deleted == False, # noqa: E712
)
)
return list(result.scalars().all())
@router.put(
"/tables/{table_id}/row-rules",
response_model=RowRuleResponse,
summary="更新行权限规则",
)
async def update_row_rule(
table_id: str,
data: RowRuleUpdate,
db: AsyncSession = Depends(get_db),
):
await PermissionService.require_capability(db, table_id, "manage_permission")
await PermissionService.require_multidimensional_table(db, table_id)
rule = await PermissionService.upsert_row_rule(
db, table_id, data.role_id, data.rule_type, data.mode, data.conditions,
)
return rule
@@ -0,0 +1,115 @@
from sqlalchemy import Column, String, Boolean, Index
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import JSON
from app.base_model import BaseModel
PermJSON = JSON().with_variant(JSONB(), "postgresql")
SYSTEM_CAPABILITIES = {
"owner": {
"manage_table": True,
"manage_permission": True,
"manage_field": True,
"manage_view": True,
"add_record": True,
"edit_record": True,
"delete_record": True,
"export_data": True,
"import_data": True,
},
"manager": {
"manage_table": False,
"manage_permission": True,
"manage_field": True,
"manage_view": True,
"add_record": True,
"edit_record": True,
"delete_record": True,
"export_data": True,
"import_data": True,
},
"editor": {
"manage_table": False,
"manage_permission": False,
"manage_field": False,
"manage_view": False,
"add_record": True,
"edit_record": True,
"delete_record": False,
"export_data": True,
"import_data": False,
},
"viewer": {
"manage_table": False,
"manage_permission": False,
"manage_field": False,
"manage_view": False,
"add_record": False,
"edit_record": False,
"delete_record": False,
"export_data": False,
"import_data": False,
},
}
ROLE_PRIORITY = {"owner": 100, "manager": 80, "editor": 60, "viewer": 40, "custom": 50}
class SmartTableRole(BaseModel):
"""多维表格 - 表角色定义"""
__tablename__ = "smart_table_role"
table_id = Column(String(21), nullable=True, index=True, comment="所属表ID(null=系统预置)")
name = Column(String(64), nullable=False, comment="角色名称")
role_type = Column(String(20), nullable=False, default="custom", comment="owner/manager/editor/viewer/custom")
capabilities = Column(PermJSON, default=dict, comment="能力配置JSON")
is_system = Column(Boolean, default=False, comment="是否系统预置(不可删除)")
__table_args__ = (
Index("ix_smart_table_role_table", "table_id", "role_type"),
)
class SmartTableCollaborator(BaseModel):
"""多维表格 - 表协作者"""
__tablename__ = "smart_table_collaborator"
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
subject_type = Column(String(20), nullable=False, comment="授权对象类型: user/dept/role")
subject_id = Column(String(21), nullable=False, index=True, comment="授权对象ID(用户/部门/角色)")
role_id = Column(String(21), nullable=False, index=True, comment="表角色ID")
__table_args__ = (
Index("ix_smart_collab_table_subject", "table_id", "subject_type", "subject_id", unique=True),
)
class SmartTableFieldPerm(BaseModel):
"""多维表格 - 列权限"""
__tablename__ = "smart_table_field_perm"
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
role_id = Column(String(21), nullable=False, index=True, comment="表角色ID")
field_id = Column(String(21), nullable=False, index=True, comment="字段ID")
access = Column(String(10), default="write", comment="访问级别: write/read/hidden")
__table_args__ = (
Index("ix_smart_fperm_role_field", "table_id", "role_id", "field_id", unique=True),
)
class SmartTableRowRule(BaseModel):
"""多维表格 - 行权限规则"""
__tablename__ = "smart_table_row_rule"
table_id = Column(String(21), nullable=False, index=True, comment="所属表ID")
role_id = Column(String(21), nullable=False, index=True, comment="表角色ID")
rule_type = Column(String(10), nullable=False, comment="规则类型: view/edit")
mode = Column(String(20), default="all", comment="模式: all/conditions/creator_only")
conditions = Column(PermJSON, default=list, comment="过滤条件(复用视图filter结构)")
__table_args__ = (
Index("ix_smart_row_rule_role_type", "table_id", "role_id", "rule_type", unique=True),
)
@@ -0,0 +1,122 @@
from typing import Optional, List, Any, Dict
from pydantic import BaseModel, ConfigDict, Field
from app.base_schema import CSTDatetime
# ==================== Role ====================
class SmartTableRoleCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64, description="角色名称")
role_type: str = Field(default="custom", description="角色类型")
capabilities: Dict[str, bool] = Field(default_factory=dict, description="能力配置")
class SmartTableRoleUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64, description="角色名称")
capabilities: Optional[Dict[str, bool]] = Field(None, description="能力配置")
class SmartTableRoleResponse(BaseModel):
id: str
table_id: Optional[str] = None
name: str
role_type: str
capabilities: Dict[str, bool] = {}
is_system: bool = False
sys_create_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
# ==================== Collaborator ====================
class CollaboratorCreate(BaseModel):
subject_type: str = Field(..., description="授权对象类型: user/dept/role")
subject_id: str = Field(..., description="授权对象ID")
role_id: str = Field(..., description="表角色ID")
class CollaboratorUpdate(BaseModel):
role_id: str = Field(..., description="表角色ID")
class CollaboratorResponse(BaseModel):
id: str
table_id: str
subject_type: str
subject_id: str
role_id: str
role_name: Optional[str] = None
role_type: Optional[str] = None
subject_name: Optional[str] = None
subject_avatar: Optional[str] = None
sys_create_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
# ==================== Field Permission ====================
class FieldPermItem(BaseModel):
field_id: str = Field(..., description="字段ID")
access: str = Field(default="write", description="访问级别: write/read/hidden")
class FieldPermBatchUpdate(BaseModel):
role_id: str = Field(..., description="表角色ID")
permissions: List[FieldPermItem] = Field(..., description="列权限列表")
class FieldPermResponse(BaseModel):
id: str
table_id: str
role_id: str
field_id: str
access: str = "write"
model_config = ConfigDict(from_attributes=True)
class FieldPermMatrix(BaseModel):
"""列权限矩阵:按角色分组"""
role_id: str
role_name: str
role_type: str
fields: List[FieldPermItem] = []
# ==================== Row Rule ====================
class RowRuleUpdate(BaseModel):
role_id: str = Field(..., description="表角色ID")
rule_type: str = Field(..., description="规则类型: view/edit")
mode: str = Field(default="all", description="模式: all/conditions/creator_only")
conditions: List[Dict[str, Any]] = Field(default_factory=list, description="过滤条件")
class RowRuleResponse(BaseModel):
id: str
table_id: str
role_id: str
rule_type: str
mode: str = "all"
conditions: List[Dict[str, Any]] = []
model_config = ConfigDict(from_attributes=True)
# ==================== My Permission ====================
class MyPermissionResponse(BaseModel):
"""当前用户对某表的有效权限"""
role_type: str = Field(description="角色类型: owner/manager/editor/viewer/custom/superadmin")
role_name: str = Field(description="角色名称")
capabilities: Dict[str, bool] = Field(default_factory=dict, description="能力配置")
field_permissions: Dict[str, str] = Field(
default_factory=dict,
description="列权限映射: fieldId -> write/read/hidden"
)
row_view_mode: str = Field(default="all", description="行查看模式")
row_edit_mode: str = Field(default="all", description="行编辑模式")
@@ -0,0 +1,709 @@
from typing import Optional, List, Dict, Any, Tuple
from sqlalchemy import select, delete as sa_delete
from sqlalchemy.ext.asyncio import AsyncSession
from zq_smart_table.permission.model import (
SmartTableRole, SmartTableCollaborator, SmartTableFieldPerm, SmartTableRowRule,
SYSTEM_CAPABILITIES, ROLE_PRIORITY,
)
from zq_smart_table.model import SmartTable
from utils.context import get_current_user_info_from_context
SUPERADMIN_CAPABILITIES = {k: True for k in SYSTEM_CAPABILITIES["owner"]}
class PermissionService:
"""多维表格权限判定服务"""
# ─── 系统预置角色 ───
@classmethod
async def ensure_system_roles(cls, db: AsyncSession, table_id: str) -> Dict[str, str]:
"""确保表拥有 4 个系统预置角色,返回 {role_type: role_id}"""
result = await db.execute(
select(SmartTableRole).where(
SmartTableRole.table_id == table_id,
SmartTableRole.is_system == True, # noqa: E712
SmartTableRole.is_deleted == False, # noqa: E712
)
)
existing = {r.role_type: r for r in result.scalars().all()}
role_map: Dict[str, str] = {}
names = {"owner": "所有者", "manager": "管理者", "editor": "编辑者", "viewer": "只读者"}
for rt, caps in SYSTEM_CAPABILITIES.items():
if rt in existing:
role_map[rt] = existing[rt].id
else:
role = SmartTableRole(
table_id=table_id,
name=names[rt],
role_type=rt,
capabilities=caps,
is_system=True,
)
db.add(role)
await db.flush()
role_map[rt] = role.id
await db.commit()
return role_map
# ─── 初始化所有者 ───
@classmethod
async def init_table_owner(cls, db: AsyncSession, table_id: str, user_id: str) -> None:
"""创建表时自动将创建者设为所有者"""
role_map = await cls.ensure_system_roles(db, table_id)
owner_role_id = role_map["owner"]
existing = await db.execute(
select(SmartTableCollaborator).where(
SmartTableCollaborator.table_id == table_id,
SmartTableCollaborator.subject_type == "user",
SmartTableCollaborator.subject_id == user_id,
SmartTableCollaborator.is_deleted == False, # noqa: E712
)
)
if existing.scalar_one_or_none():
return
collab = SmartTableCollaborator(
table_id=table_id,
subject_type="user",
subject_id=user_id,
role_id=owner_role_id,
)
db.add(collab)
await db.commit()
# ─── 获取有效角色 ───
@classmethod
async def get_effective_role(
cls, db: AsyncSession, table_id: str,
user_id: str, dept_id: Optional[str] = None,
role_ids: Optional[List[str]] = None,
is_superuser: bool = False,
) -> Optional[Dict[str, Any]]:
"""
获取用户对某表的有效角色。
优先级: superadmin > user直接 > dept > system_role
多个匹配时取权限最高的角色。
"""
if is_superuser:
return {
"role_type": "superadmin",
"role_name": "超级管理员",
"capabilities": SUPERADMIN_CAPABILITIES,
"role_id": None,
}
# 查同表创建者(兜底:如果 collaborator 记录缺失但用户是表的创建者)
table = await db.execute(
select(SmartTable).where(SmartTable.id == table_id, SmartTable.is_deleted == False) # noqa: E712
)
table_obj = table.scalar_one_or_none()
if table_obj and table_obj.sys_creator_id == user_id:
role_map = await cls.ensure_system_roles(db, table_id)
owner_role_id = role_map["owner"]
return {
"role_type": "owner",
"role_name": "所有者",
"capabilities": dict(SYSTEM_CAPABILITIES["owner"]),
"role_id": owner_role_id,
}
# 查询所有匹配的协作者记录
conditions = [
SmartTableCollaborator.table_id == table_id,
SmartTableCollaborator.is_deleted == False, # noqa: E712
]
subject_conditions = [
(SmartTableCollaborator.subject_type == "user") & (SmartTableCollaborator.subject_id == user_id)
]
if dept_id:
subject_conditions.append(
(SmartTableCollaborator.subject_type == "dept") & (SmartTableCollaborator.subject_id == dept_id)
)
if role_ids:
for rid in role_ids:
subject_conditions.append(
(SmartTableCollaborator.subject_type == "role") & (SmartTableCollaborator.subject_id == rid)
)
from sqlalchemy import or_
conditions.append(or_(*subject_conditions))
result = await db.execute(
select(SmartTableCollaborator).where(*conditions)
)
collabs = list(result.scalars().all())
if not collabs:
return None
# 加载对应角色,取优先级最高的
best_collab = None
best_role = None
best_priority = -1
for c in collabs:
role_result = await db.execute(
select(SmartTableRole).where(
SmartTableRole.id == c.role_id,
SmartTableRole.is_deleted == False, # noqa: E712
)
)
role = role_result.scalar_one_or_none()
if not role:
continue
# user 直接授权优先级额外 +10
priority = ROLE_PRIORITY.get(role.role_type, 50)
if c.subject_type == "user":
priority += 10
if priority > best_priority:
best_priority = priority
best_collab = c
best_role = role
if not best_role:
return None
return {
"role_type": best_role.role_type,
"role_name": best_role.name,
"capabilities": dict(best_role.capabilities) if best_role.capabilities else {},
"role_id": best_role.id,
}
# ─── 能力检查 ───
@classmethod
async def check_capability(
cls, db: AsyncSession, table_id: str, capability: str,
) -> bool:
"""检查当前用户是否具有某项能力"""
user_info = get_current_user_info_from_context()
if not user_info:
return False
role_info = await cls.get_effective_role(
db, table_id,
user_id=user_info.get("user_id", ""),
dept_id=user_info.get("dept_id"),
role_ids=user_info.get("role_ids", []),
is_superuser=user_info.get("is_superuser", False),
)
if not role_info:
return False
return role_info["capabilities"].get(capability, False)
@classmethod
async def require_capability(
cls, db: AsyncSession, table_id: str, capability: str,
) -> Dict[str, Any]:
"""检查能力,无权限则抛 403"""
from fastapi import HTTPException
user_info = get_current_user_info_from_context()
if not user_info:
raise HTTPException(status_code=401, detail="未登录")
role_info = await cls.get_effective_role(
db, table_id,
user_id=user_info.get("user_id", ""),
dept_id=user_info.get("dept_id"),
role_ids=user_info.get("role_ids", []),
is_superuser=user_info.get("is_superuser", False),
)
if not role_info:
raise HTTPException(status_code=403, detail="无权访问此表")
if not role_info["capabilities"].get(capability, False):
raise HTTPException(status_code=403, detail=f"无权执行此操作({capability})")
return role_info
@classmethod
async def require_table_access(
cls, db: AsyncSession, table_id: str,
) -> Dict[str, Any]:
"""检查表访问权限(至少 viewer),无权限则抛 403"""
from fastapi import HTTPException
user_info = get_current_user_info_from_context()
if not user_info:
raise HTTPException(status_code=401, detail="未登录")
role_info = await cls.get_effective_role(
db, table_id,
user_id=user_info.get("user_id", ""),
dept_id=user_info.get("dept_id"),
role_ids=user_info.get("role_ids", []),
is_superuser=user_info.get("is_superuser", False),
)
if not role_info:
raise HTTPException(status_code=403, detail="无权访问此表")
return role_info
# ─── 列权限 ───
@classmethod
async def get_field_permissions(
cls, db: AsyncSession, table_id: str, role_id: str,
) -> Dict[str, str]:
"""获取某角色的列权限映射: {field_id: access}"""
result = await db.execute(
select(SmartTableFieldPerm).where(
SmartTableFieldPerm.table_id == table_id,
SmartTableFieldPerm.role_id == role_id,
SmartTableFieldPerm.is_deleted == False, # noqa: E712
)
)
return {fp.field_id: fp.access for fp in result.scalars().all()}
@classmethod
async def get_accessible_field_ids(
cls, db: AsyncSession, table_id: str, role_id: Optional[str],
mode: str = "read",
) -> Optional[List[str]]:
"""
获取角色可访问的字段ID列表。
mode="read" 返回 access != "hidden" 的字段
mode="write" 返回 access == "write" 的字段
如果没有配置列权限,返回 None 表示不限制。
"""
if not role_id:
return None
perms = await cls.get_field_permissions(db, table_id, role_id)
if not perms:
return None
if mode == "write":
return [fid for fid, acc in perms.items() if acc == "write"]
else:
return [fid for fid, acc in perms.items() if acc != "hidden"]
@classmethod
async def batch_set_field_permissions(
cls, db: AsyncSession, table_id: str, role_id: str,
permissions: List[Dict[str, str]],
) -> None:
"""批量设置列权限"""
await db.execute(
sa_delete(SmartTableFieldPerm).where(
SmartTableFieldPerm.table_id == table_id,
SmartTableFieldPerm.role_id == role_id,
)
)
for p in permissions:
fp = SmartTableFieldPerm(
table_id=table_id,
role_id=role_id,
field_id=p["field_id"],
access=p.get("access", "write"),
)
db.add(fp)
await db.commit()
# ─── 行权限 ───
@classmethod
async def get_row_rules(
cls, db: AsyncSession, table_id: str, role_id: str,
) -> List[SmartTableRowRule]:
result = await db.execute(
select(SmartTableRowRule).where(
SmartTableRowRule.table_id == table_id,
SmartTableRowRule.role_id == role_id,
SmartTableRowRule.is_deleted == False, # noqa: E712
)
)
return list(result.scalars().all())
@classmethod
async def upsert_row_rule(
cls, db: AsyncSession, table_id: str, role_id: str,
rule_type: str, mode: str, conditions: List[Dict[str, Any]],
) -> SmartTableRowRule:
"""创建或更新行权限规则"""
result = await db.execute(
select(SmartTableRowRule).where(
SmartTableRowRule.table_id == table_id,
SmartTableRowRule.role_id == role_id,
SmartTableRowRule.rule_type == rule_type,
SmartTableRowRule.is_deleted == False, # noqa: E712
)
)
rule = result.scalar_one_or_none()
if rule:
rule.mode = mode
rule.conditions = conditions
else:
rule = SmartTableRowRule(
table_id=table_id,
role_id=role_id,
rule_type=rule_type,
mode=mode,
conditions=conditions,
)
db.add(rule)
await db.commit()
await db.refresh(rule)
return rule
# ─── 协作者管理 ───
@classmethod
async def get_collaborators(
cls, db: AsyncSession, table_id: str,
) -> List[SmartTableCollaborator]:
result = await db.execute(
select(SmartTableCollaborator).where(
SmartTableCollaborator.table_id == table_id,
SmartTableCollaborator.is_deleted == False, # noqa: E712
).order_by(SmartTableCollaborator.sys_create_datetime)
)
return list(result.scalars().all())
@classmethod
async def add_collaborator(
cls, db: AsyncSession, table_id: str,
subject_type: str, subject_id: str, role_id: str,
) -> SmartTableCollaborator:
existing = await db.execute(
select(SmartTableCollaborator).where(
SmartTableCollaborator.table_id == table_id,
SmartTableCollaborator.subject_type == subject_type,
SmartTableCollaborator.subject_id == subject_id,
SmartTableCollaborator.is_deleted == False, # noqa: E712
)
)
collab = existing.scalar_one_or_none()
if collab:
collab.role_id = role_id
else:
collab = SmartTableCollaborator(
table_id=table_id,
subject_type=subject_type,
subject_id=subject_id,
role_id=role_id,
)
db.add(collab)
await db.commit()
await db.refresh(collab)
return collab
@classmethod
async def update_collaborator_role(
cls, db: AsyncSession, collab_id: str, role_id: str,
) -> Optional[SmartTableCollaborator]:
result = await db.execute(
select(SmartTableCollaborator).where(
SmartTableCollaborator.id == collab_id,
SmartTableCollaborator.is_deleted == False, # noqa: E712
)
)
collab = result.scalar_one_or_none()
if not collab:
return None
collab.role_id = role_id
await db.commit()
await db.refresh(collab)
return collab
@classmethod
async def remove_collaborator(
cls, db: AsyncSession, collab_id: str,
) -> bool:
result = await db.execute(
select(SmartTableCollaborator).where(
SmartTableCollaborator.id == collab_id,
SmartTableCollaborator.is_deleted == False, # noqa: E712
)
)
collab = result.scalar_one_or_none()
if not collab:
return False
collab.is_deleted = True
await db.commit()
return True
# ─── 角色管理 ───
@classmethod
async def get_roles(
cls, db: AsyncSession, table_id: str,
) -> List[SmartTableRole]:
result = await db.execute(
select(SmartTableRole).where(
SmartTableRole.table_id == table_id,
SmartTableRole.is_deleted == False, # noqa: E712
).order_by(SmartTableRole.sys_create_datetime)
)
return list(result.scalars().all())
@classmethod
async def create_custom_role(
cls, db: AsyncSession, table_id: str,
name: str, capabilities: Dict[str, bool],
) -> SmartTableRole:
role = SmartTableRole(
table_id=table_id,
name=name,
role_type="custom",
capabilities=capabilities,
is_system=False,
)
db.add(role)
await db.commit()
await db.refresh(role)
return role
@classmethod
async def update_role(
cls, db: AsyncSession, role_id: str,
name: Optional[str] = None, capabilities: Optional[Dict[str, bool]] = None,
) -> Optional[SmartTableRole]:
result = await db.execute(
select(SmartTableRole).where(
SmartTableRole.id == role_id,
SmartTableRole.is_deleted == False, # noqa: E712
)
)
role = result.scalar_one_or_none()
if not role:
return None
if name is not None:
role.name = name
if capabilities is not None:
role.capabilities = capabilities
await db.commit()
await db.refresh(role)
return role
@classmethod
async def delete_role(
cls, db: AsyncSession, role_id: str,
) -> bool:
result = await db.execute(
select(SmartTableRole).where(
SmartTableRole.id == role_id,
SmartTableRole.is_system == False, # noqa: E712
SmartTableRole.is_deleted == False, # noqa: E712
)
)
role = result.scalar_one_or_none()
if not role:
return False
role.is_deleted = True
await db.commit()
return True
# ─── 获取 my-permission ───
@classmethod
async def get_my_permission(
cls, db: AsyncSession, table_id: str,
) -> Dict[str, Any]:
"""获取当前用户对某表的完整权限信息"""
user_info = get_current_user_info_from_context()
if not user_info:
return {
"role_type": "none",
"role_name": "无权限",
"capabilities": {},
"field_permissions": {},
"row_view_mode": "none",
"row_edit_mode": "none",
}
role_info = await cls.get_effective_role(
db, table_id,
user_id=user_info.get("user_id", ""),
dept_id=user_info.get("dept_id"),
role_ids=user_info.get("role_ids", []),
is_superuser=user_info.get("is_superuser", False),
)
if not role_info:
return {
"role_type": "none",
"role_name": "无权限",
"capabilities": {},
"field_permissions": {},
"row_view_mode": "none",
"row_edit_mode": "none",
}
role_id = role_info.get("role_id")
field_perms: Dict[str, str] = {}
row_view_mode = "all"
row_edit_mode = "all"
if role_id:
field_perms = await cls.get_field_permissions(db, table_id, role_id)
row_rules = await cls.get_row_rules(db, table_id, role_id)
for rr in row_rules:
if rr.rule_type == "view":
row_view_mode = rr.mode
elif rr.rule_type == "edit":
row_edit_mode = rr.mode
out: Dict[str, Any] = {
"role_type": role_info["role_type"],
"role_name": role_info["role_name"],
"capabilities": role_info["capabilities"],
"field_permissions": field_perms,
"row_view_mode": row_view_mode,
"row_edit_mode": row_edit_mode,
}
# 文档无列/行数据维度,列权限与行规则不对文档内容生效;归一化返回值以免与多维表格混淆
tbl_row = await db.execute(
select(SmartTable).where(SmartTable.id == table_id, SmartTable.is_deleted == False) # noqa: E712
)
tbl_obj = tbl_row.scalar_one_or_none()
if tbl_obj and getattr(tbl_obj, "type", "table") == "document":
out["field_permissions"] = {}
out["row_view_mode"] = "all"
out["row_edit_mode"] = "all"
return out
@classmethod
async def require_multidimensional_table(cls, db: AsyncSession, table_id: str) -> None:
"""列权限、行权限仅适用于多维表格(type=table),文档页应使用协作者角色与能力位。"""
from fastapi import HTTPException
result = await db.execute(
select(SmartTable).where(SmartTable.id == table_id, SmartTable.is_deleted == False) # noqa: E712
)
tbl = result.scalar_one_or_none()
if not tbl:
raise HTTPException(status_code=404, detail="表不存在")
if getattr(tbl, "type", "table") == "document":
raise HTTPException(
status_code=400,
detail="文档不支持列权限与行权限配置,请通过协作者角色控制访问",
)
# ─── 行权限过滤 ───
@classmethod
async def build_row_filter_conditions(
cls, db: AsyncSession, table_id: str, role_id: Optional[str],
user_id: str, rule_type: str = "view",
) -> list:
"""
根据行权限规则构建 SQLAlchemy 过滤条件列表。
rule_type: "view""edit"
返回空列表表示不限制。
"""
from zq_smart_table.model import SmartRecord
from app.db_compat import json_extract
if not role_id:
return []
row_rules = await cls.get_row_rules(db, table_id, role_id)
rule = next((r for r in row_rules if r.rule_type == rule_type), None)
if not rule or rule.mode == "all":
return []
if rule.mode == "creator_only":
return [SmartRecord.sys_creator_id == user_id]
if rule.mode == "conditions" and rule.conditions:
from sqlalchemy import or_, and_
conds = []
for c in rule.conditions:
fid = c.get("field_id")
op = c.get("operator", "equals")
val = c.get("value")
if not fid:
continue
col = json_extract(SmartRecord.values, fid)
if op == "equals":
conds.append(col == str(val) if val is not None else col == None) # noqa: E711
elif op == "contains":
conds.append(col.ilike(f"%{val}%") if val else col == col)
elif op == "isEmpty":
conds.append(or_(col == None, col == "")) # noqa: E711
elif op == "isNotEmpty":
conds.append(and_(col != None, col != "")) # noqa: E711
elif op == "greaterThan":
from sqlalchemy import cast, String
conds.append(cast(col, String) > str(val))
elif op == "lessThan":
from sqlalchemy import cast, String
conds.append(cast(col, String) < str(val))
return conds
return []
@classmethod
async def check_row_edit_permission(
cls, db: AsyncSession, table_id: str, record, role_id: Optional[str], user_id: str,
) -> bool:
"""检查当前用户是否有权编辑指定记录(基于行编辑权限规则)"""
if not role_id:
return True
row_rules = await cls.get_row_rules(db, table_id, role_id)
rule = next((r for r in row_rules if r.rule_type == "edit"), None)
if not rule or rule.mode == "all":
return True
if rule.mode == "creator_only":
return getattr(record, "sys_creator_id", None) == user_id
if rule.mode == "conditions" and rule.conditions:
from app.db_compat import json_extract
values = record.values or {}
for c in rule.conditions:
fid = c.get("field_id")
op = c.get("operator", "equals")
val = c.get("value")
if not fid:
continue
cell_val = str(values.get(fid, ""))
if op == "equals" and cell_val != str(val):
return False
if op == "contains" and str(val or "") not in cell_val:
return False
if op == "isEmpty" and cell_val != "":
return False
if op == "isNotEmpty" and cell_val == "":
return False
return True
return True
# ─── 过滤记录值(去掉不可见字段) ───
@classmethod
def filter_record_values(
cls, values: Dict[str, Any], accessible_fields: Optional[List[str]],
) -> Dict[str, Any]:
"""过滤记录中不可见的字段值"""
if accessible_fields is None:
return values
return {k: v for k, v in values.items() if k in accessible_fields}
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from zq_smart_table.api import router as smart_table_api_router
from zq_smart_table.permission.api import router as permission_api_router
router = APIRouter()
router.include_router(smart_table_api_router)
router.include_router(permission_api_router)
+495
View File
@@ -0,0 +1,495 @@
from typing import Optional, List, Any, Dict
from pydantic import BaseModel, ConfigDict, Field
from app.base_schema import CSTDatetime
# ==================== SmartTable ====================
class SmartTableCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200, description="表名")
icon: str = Field(default="Grid", max_length=50, description="图标")
description: Optional[str] = Field(None, description="描述")
type: str = Field(default="table", description="类型: table / document")
content: Optional[Dict[str, Any]] = Field(None, description="文档内容(Tiptap JSON), 仅 type=document 时使用")
parent_id: Optional[str] = Field(None, description="父页面ID")
wiki_space_id: Optional[str] = Field(None, description="所属文档库ID")
class SmartTableUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200, description="表名")
icon: Optional[str] = Field(None, max_length=50, description="图标")
description: Optional[str] = Field(None, description="描述")
active_view_id: Optional[str] = Field(None, description="当前激活视图ID")
content: Optional[Dict[str, Any]] = Field(None, description="文档内容(Tiptap JSON)")
parent_id: Optional[str] = Field(None, description="父页面ID")
class SmartTableMove(BaseModel):
parent_id: Optional[str] = Field(None, description="目标父页面IDnull 表示移到根级")
after_id: Optional[str] = Field(None, description="排在此项之后,null 表示排在同级首位")
class SmartTableResponse(BaseModel):
id: str
name: str
icon: str = "Grid"
description: Optional[str] = None
active_view_id: Optional[str] = None
type: str = "table"
parent_id: Optional[str] = None
wiki_space_id: Optional[str] = None
sort: int = 0
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
class SmartTableSimple(BaseModel):
"""列表简要信息(含 parent_id 以便前端构建树)"""
id: str
name: str
icon: str = "Grid"
type: str = "table"
parent_id: Optional[str] = None
wiki_space_id: Optional[str] = None
sort: int = 0
model_config = ConfigDict(from_attributes=True)
# ==================== SmartField ====================
class SmartFieldCreate(BaseModel):
table_id: Optional[str] = Field(None, description="所属表ID(从URL路径注入)")
name: str = Field(..., min_length=1, max_length=200, description="字段名")
type: str = Field(..., description="字段类型")
width: int = Field(default=150, description="列宽")
visible: bool = Field(default=True, description="是否可见")
required: bool = Field(default=False, description="是否必填")
description: Optional[str] = Field(None, description="描述")
config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="扩展配置")
sort: int = Field(default=0, description="排序")
class SmartFieldUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200, description="字段名")
type: Optional[str] = Field(None, description="字段类型")
width: Optional[int] = Field(None, description="列宽")
visible: Optional[bool] = Field(None, description="是否可见")
required: Optional[bool] = Field(None, description="是否必填")
description: Optional[str] = Field(None, description="描述")
config: Optional[Dict[str, Any]] = Field(None, description="扩展配置")
sort: Optional[int] = Field(None, description="排序")
class SmartFieldResponse(BaseModel):
id: str
table_id: str
name: str
type: str
width: int = 150
visible: bool = True
required: bool = False
description: Optional[str] = None
config: Optional[Dict[str, Any]] = None
sort: int = 0
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
class SmartFieldReorder(BaseModel):
field_ids: List[str] = Field(..., description="按顺序排列的字段ID列表")
# ==================== SmartRecord ====================
class SmartRecordCreate(BaseModel):
table_id: Optional[str] = Field(None, description="所属表ID(从URL路径注入)")
values: Dict[str, Any] = Field(default_factory=dict, description="字段值映射")
class SmartRecordUpdate(BaseModel):
values: Optional[Dict[str, Any]] = Field(None, description="字段值映射")
class SmartRecordCellUpdate(BaseModel):
"""更新单个单元格"""
field_id: str = Field(..., description="字段ID")
value: Any = Field(None, description="单元格值")
class SmartRecordResponse(BaseModel):
id: str
table_id: str
values: Dict[str, Any] = {}
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
sys_creator_id: Optional[str] = None
sys_modifier_id: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class SmartRecordBatchDelete(BaseModel):
ids: List[str] = Field(..., description="要删除的记录ID列表")
class SmartRecordBatchRestore(BaseModel):
ids: List[str] = Field(..., description="要恢复的记录ID列表")
class TrashRecordResponse(BaseModel):
id: str
table_id: str
values: Dict[str, Any] = {}
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
sys_creator_id: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class TrashListResponse(BaseModel):
items: List[TrashRecordResponse]
total: int
# ==================== SmartView ====================
class SmartViewCreate(BaseModel):
table_id: Optional[str] = Field(None, description="所属表ID(从URL路径注入)")
name: str = Field(..., min_length=1, max_length=200, description="视图名")
type: str = Field(default="grid", description="视图类型")
config: Optional[Dict[str, Any]] = Field(default_factory=dict, description="视图配置")
sort: int = Field(default=0, description="排序")
class SmartViewUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200, description="视图名")
type: Optional[str] = Field(None, description="视图类型")
config: Optional[Dict[str, Any]] = Field(None, description="视图配置")
sort: Optional[int] = Field(None, description="排序")
class SmartViewResponse(BaseModel):
id: str
table_id: str
name: str
type: str = "grid"
config: Optional[Dict[str, Any]] = None
sort: int = 0
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
model_config = ConfigDict(from_attributes=True)
# ==================== Record Query (Server-side filter/sort/search) ====================
class RecordFilterRule(BaseModel):
field_id: str = Field(..., description="字段ID")
operator: str = Field(..., description="操作符: equals, notEquals, contains, notContains, isEmpty, isNotEmpty, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual")
value: Any = Field(None, description="筛选值")
class RecordSortRule(BaseModel):
field_id: str = Field(..., description="字段ID")
direction: str = Field(default="asc", description="排序方向: asc / desc")
class RecordQuery(BaseModel):
filters: List[RecordFilterRule] = Field(default_factory=list, description="筛选条件")
filter_logic: str = Field(default="and", description="筛选逻辑: and / or")
sorts: List[RecordSortRule] = Field(default_factory=list, description="排序规则")
search: Optional[str] = Field(None, description="全文搜索关键词")
search_field_ids: Optional[List[str]] = Field(None, description="搜索范围字段ID列表,为空搜索所有文本字段")
group_field_id: Optional[str] = Field(None, description="分组字段ID")
cursor: Optional[str] = Field(None, description="游标(上一页最后一条记录的ID")
limit: int = Field(default=200, ge=1, le=5000, description="每页数量")
class RecordGroupItem(BaseModel):
key: str
label: str
records: List[SmartRecordResponse] = []
class GroupedRecordsResponse(BaseModel):
groups: List[RecordGroupItem]
total: int
class SmartRecordReorder(BaseModel):
record_ids: List[str] = Field(..., description="按顺序排列的记录ID列表")
# ==================== Batch Cell Update ====================
class SmartRecordBatchCellUpdate(BaseModel):
"""批量更新多个单元格"""
cells: Dict[str, Any] = Field(..., description="fieldId->value 映射")
class MultiRecordCellUpdate(BaseModel):
"""单条记录的更新项"""
record_id: str = Field(..., description="记录ID")
cells: Dict[str, Any] = Field(..., description="fieldId->value 映射")
class MultiRecordBatchUpdate(BaseModel):
"""批量更新多条记录的单元格 - 合并为单次事务"""
updates: List[MultiRecordCellUpdate] = Field(..., description="更新列表", max_length=200)
# ==================== Cursor Pagination ====================
class CursorPaginatedRecords(BaseModel):
items: List[SmartRecordResponse]
total: int
next_cursor: Optional[str] = None
has_more: bool = False
# ==================== Full Table Load ====================
# ==================== Link / Lookup / Rollup ====================
class LinkCellUpdate(BaseModel):
"""更新 Link 字段的关联记录"""
record_ids: List[str] = Field(..., description="目标记录ID列表")
class LinkedRecordItem(BaseModel):
"""关联记录摘要"""
id: str
title: str = ""
class RecordSearchResult(BaseModel):
"""搜索目标表记录结果"""
id: str
title: str = ""
class RecordSearchQuery(BaseModel):
"""搜索请求"""
keyword: str = Field(default="", description="搜索关键词")
limit: int = Field(default=20, ge=1, le=100, description="返回数量")
# ==================== Full Table Load ====================
class SmartTableFull(BaseModel):
"""完整表数据(含字段、首页记录、视图)"""
id: str
name: str
icon: str = "Grid"
description: Optional[str] = None
active_view_id: Optional[str] = None
type: str = "table"
parent_id: Optional[str] = None
wiki_space_id: Optional[str] = None
content: Optional[Dict[str, Any]] = None
fields: List[SmartFieldResponse] = []
records: List[SmartRecordResponse] = []
views: List[SmartViewResponse] = []
record_total: int = 0
next_cursor: Optional[str] = None
has_more: bool = False
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
sys_creator_id: Optional[str] = None
creator_name: Optional[str] = None
creator_avatar: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
# ==================== Comment ====================
class CommentCreate(BaseModel):
content: str = Field(..., min_length=1, max_length=5000, description="评论内容")
mentions: List[str] = Field(default_factory=list, description="被@提及的用户ID列表")
parent_id: Optional[str] = Field(None, description="父评论ID(回复)")
class CommentUpdate(BaseModel):
content: str = Field(..., min_length=1, max_length=5000, description="评论内容")
mentions: List[str] = Field(default_factory=list, description="被@提及的用户ID列表")
class CommentResponse(BaseModel):
id: str
record_id: str
user_id: str
content: str
mentions: List[str] = []
parent_id: Optional[str] = None
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
user_name: Optional[str] = None
user_avatar: Optional[str] = None
replies: List["CommentResponse"] = []
model_config = ConfigDict(from_attributes=True)
CommentResponse.model_rebuild()
# ==================== Summary / Aggregation ====================
class SummaryRequest(BaseModel):
"""汇总请求:指定字段及聚合方式"""
aggregations: Dict[str, str] = Field(
..., description="fieldId -> aggregation_type 映射,如 {'fld_abc': 'SUM'}"
)
filters: List[RecordFilterRule] = Field(default_factory=list, description="筛选条件")
filter_logic: str = Field(default="and", description="筛选逻辑: and / or")
search: Optional[str] = Field(None, description="搜索关键词")
class SummaryResponse(BaseModel):
"""汇总响应"""
summaries: Dict[str, Any] = Field(
default_factory=dict,
description="fieldId -> 聚合结果,如 {'fld_abc': 12345.67}"
)
total_count: int = 0
# ==================== Document ====================
class DocumentContentUpdate(BaseModel):
"""更新文档内容"""
content: Dict[str, Any] = Field(..., description="Tiptap JSON 文档内容")
# ==================== Document Version ====================
class DocumentVersionCreate(BaseModel):
"""手动创建版本"""
change_summary: Optional[str] = Field(None, max_length=500, description="变更摘要")
class DocumentVersionResponse(BaseModel):
id: str
document_id: str
version: int
title: Optional[str] = None
change_summary: Optional[str] = None
content_size: int = 0
sys_create_datetime: Optional[CSTDatetime] = None
sys_creator_id: Optional[str] = None
creator_name: Optional[str] = None
creator_avatar: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class DocumentVersionDetail(DocumentVersionResponse):
"""版本详情(含完整内容)"""
content: Dict[str, Any] = {}
class DocumentVersionCompare(BaseModel):
"""版本对比结果"""
version_from: DocumentVersionDetail
version_to: DocumentVersionDetail
# ==================== Document Template ====================
class DocumentTemplateCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200, description="模板名称")
description: Optional[str] = Field(None, description="模板描述")
icon: str = Field(default="FileText", max_length=50, description="模板图标")
category: str = Field(default="custom", max_length=50, description="分类")
content: Dict[str, Any] = Field(..., description="模板内容(Tiptap JSON)")
preview_image: Optional[str] = Field(None, description="预览图URL")
class DocumentTemplateUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200, description="模板名称")
description: Optional[str] = Field(None, description="模板描述")
icon: Optional[str] = Field(None, max_length=50, description="模板图标")
category: Optional[str] = Field(None, max_length=50, description="分类")
content: Optional[Dict[str, Any]] = Field(None, description="模板内容(Tiptap JSON)")
preview_image: Optional[str] = Field(None, description="预览图URL")
class DocumentTemplateResponse(BaseModel):
id: str
name: str
description: Optional[str] = None
icon: str = "FileText"
category: str = "custom"
preview_image: Optional[str] = None
is_system: bool = False
use_count: int = 0
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
sys_creator_id: Optional[str] = None
creator_name: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class DocumentTemplateDetail(DocumentTemplateResponse):
"""模板详情(含完整内容)"""
content: Dict[str, Any] = {}
# ==================== Wiki Space ====================
class WikiSpaceCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200, description="文档库名称")
icon: str = Field(default="BookOpen", max_length=50, description="图标")
avatar: Optional[str] = Field(None, description="头像文件ID")
description: Optional[str] = Field(None, description="描述")
cover: Optional[str] = Field(None, description="封面图URL")
category: str = Field(default="default", max_length=50, description="分类标签")
visibility: str = Field(default="private", description="可见性: private/team/public")
class WikiSpaceUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=200, description="文档库名称")
icon: Optional[str] = Field(None, max_length=50, description="图标")
avatar: Optional[str] = Field(None, description="头像文件ID")
description: Optional[str] = Field(None, description="描述")
cover: Optional[str] = Field(None, description="封面图URL")
category: Optional[str] = Field(None, max_length=50, description="分类标签")
visibility: Optional[str] = Field(None, description="可见性: private/team/public")
class WikiSpaceResponse(BaseModel):
id: str
name: str
icon: str = "BookOpen"
avatar: Optional[str] = None
description: Optional[str] = None
cover: Optional[str] = None
category: str = "default"
visibility: str = "private"
sort: int = 0
sys_create_datetime: Optional[CSTDatetime] = None
sys_update_datetime: Optional[CSTDatetime] = None
sys_creator_id: Optional[str] = None
creator_name: Optional[str] = None
model_config = ConfigDict(from_attributes=True)
class WikiSpaceListItem(WikiSpaceResponse):
"""列表项,含文档数量"""
document_count: int = 0
class WikiSpaceDetail(WikiSpaceResponse):
"""详情,含文档列表"""
documents: List[SmartTableSimple] = []
File diff suppressed because it is too large Load Diff