docs(rich): 表格可读性、安卓编辑器、签名直链都验证过了
CI / backend (push) Canceled after 0s

- README:富文本行改成「Web Vditor 编辑 / 安卓 Markwon 预览编辑」,e2e 15 项
- ROADMAP 第 5 节:勾掉表格与超过一两行的旧描述(表格是只读渲染,
  HTML 片段仍按文本显示);附件改为短期签名 raw_url;安卓从"未做"改成已完成
- 记下仍欠的三件事:保存后回列表而不是回详情、图片压缩/缩略图、离线缓存

验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过;安卓 assembleDebug 与模拟器逐屏走通。
This commit is contained in:
dadao
2026-10-29 03:19:33 +08:00
parent fbdf54cb4c
commit 8d61289f90
22 changed files with 911 additions and 90 deletions
+35 -20
View File
@@ -28,7 +28,7 @@ from app.models_attachment import Attachment
from app.models_task import Item
from app.schemas import ok
from app.security import decode_token
from app.services import storage
from app.services import signing, storage
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["attachments"])
@@ -36,6 +36,7 @@ ALLOWED_KINDS = ("task", "ticket", "knowledge")
def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict:
settings = get_settings()
return {
"id": str(row.id),
"item_id": str(row.item_id),
@@ -45,6 +46,11 @@ def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict:
"sha256": row.sha256,
"uploader_id": str(row.uploader_id),
"created_at": row.created_at.isoformat(),
# 图片直链:自带短期签名,浏览器 <img> 直接能用,不需要把登录令牌塞进 URL
"raw_url": (
f"{settings.api_prefix}/workspaces/{row.workspace_id}"
f"/attachments/{row.id}/raw?{signing.sign(row.workspace_id, row.id)}"
),
"can_delete": row.uploader_id == viewer_id or role in ("owner", "admin"),
}
@@ -176,31 +182,40 @@ async def raw_attachment(
ws_id: uuid.UUID,
session: SessionDep,
attachment_id: uuid.UUID,
token: Annotated[str, Query(description="access token(<img src> 带不了请求头)")],
exp: Annotated[int | None, Query(description="签名过期时间(unix 秒)")] = None,
sig: Annotated[str | None, Query(description="短期签名,见 services/signing.py")] = None,
token: Annotated[str | None, Query(description="兼容旧链接用的 access token")] = None,
):
"""给 Markdown 里的 `![](…/raw?token=…)` 用:浏览器 <img> 无法附加 Authorization 头。
"""给 Markdown 里的图片用:浏览器 `<img>` 无法附加 Authorization 头。
token 走 query 是 Web 上的通行做法,但要接受它的代价:会进访问日志与浏览器历史。
所以这个接口**只读、只给本工作区成员**,且不返回 Content-Disposition(不当下载用)。
优先认 `?exp=&sig=` 的短期签名(由附件列表接口签发,只为这一张图、只活 30 分钟);
老链接里的 `?token=` 仍能用,但那是过渡期兼容,前端已不再生成。
"""
try:
user_id = decode_token(token, "access")
except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理
raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None
if exp is not None or sig is not None:
if not signing.verify(ws_id, attachment_id, exp, sig):
raise AppError(ErrorCode.ATTACHMENT_FORBIDDEN, "图片链接已失效,请刷新页面")
# 这个接口不走 MemberDep,RLS 不会自动绑租户,得手动绑一次
await bind_tenant(session, ws_id=ws_id, user_id=None)
else:
if not token:
raise AppError(ErrorCode.UNAUTHORIZED, "缺少访问凭据")
try:
user_id = decode_token(token, "access")
except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理
raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None
# 这个接口没有走 MemberDep(令牌在 query 里),RLS 不会自动绑租户,得手动绑一次
await bind_tenant(session, ws_id=ws_id, user_id=user_id)
await bind_tenant(session, ws_id=ws_id, user_id=user_id)
member = await session.scalar(
select(Member.id).where(
Member.workspace_id == ws_id,
Member.user_id == user_id,
Member.status == "active",
member = await session.scalar(
select(Member.id).where(
Member.workspace_id == ws_id,
Member.user_id == user_id,
Member.status == "active",
)
)
)
if member is None:
# 不是成员就当作不存在:不暴露"这里有个附件但你看不到"
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
if member is None:
# 不是成员就当作不存在:不暴露"这里有个附件但你看不到"
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
row = await session.scalar(
select(Attachment).where(Attachment.id == attachment_id, Attachment.workspace_id == ws_id)