feat(rich): 安卓能编辑富文本了,图片直链换成短期签名,表格不再撑破抽屉
CI / backend (push) Canceled after 0s

三件欠账一次清:

1. 图片直链签名化
   - 新增 `backend/app/services/signing.py`:HMAC-SHA256,密钥由 `JWT_SECRET` 域分离派生
     (`attachment-raw-v1`),载荷 `workspace_id:attachment_id:exp`,默认 30 分钟
   - `/raw` 支持 `?exp=&sig=`,不需要登录态;签名里绑了工作区,跨租户链接直接拒
   - 附件对象多一个 `raw_url`,前端每次渲染现签,正文里只存不带凭据的 `/raw`
   - 保留旧 `?token=` 通道,给未升级的客户端过渡

2. 安卓富文本编辑器
   - `ui/RichTextEditorScreen.kt`:Markdown 工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片),
     选区包裹与行首前缀都按光标位置来;贴图走附件接口,插入签名直链
   - 预览用 Markwon,图片用 Coil;正文落在一张白纸卡片里,不再贴屏幕边
   - 从任务详情与知识点都能进,保存按 `body_md` 回写(PATCH 带 version)

3. 表格
   - 只读渲染走 markdown-it 的 GFM 表,`display:block + overflow-x:auto`:
     宽表横向滚动、窄表铺满,都不撑破抽屉

验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过(主包 299.42 kB / gzip 74.96 kB);
安卓 assembleDebug 成功并在 API 35 模拟器走通编辑与预览。

仍欠:保存后回列表而非回详情抽屉、图片压缩与缩略图、HTML 片段按文本显示。
This commit is contained in:
dadao
2026-10-29 03:32:53 +08:00
parent fbdf54cb4c
commit 17be9d1d42
24 changed files with 923 additions and 99 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)
+3
View File
@@ -67,6 +67,9 @@ class Settings(BaseSettings):
upload_dir: str = "var/uploads"
# 单文件上限。20MB 够放文档与截图;再大就该走对象存储 + 分片了。
max_upload_mb: int = 20
# 图片直链的签名有效期。短一点更安全,太长等于把凭据长期贴在页面上;
# 30 分钟够一次阅读/编辑,过期后刷新页面会重新签发。
attachment_url_ttl_seconds: int = 1800
# ── 知识库向量库(独立实例,M3 起用)───────────────────
# 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它;
+45
View File
@@ -0,0 +1,45 @@
"""附件直链的短期签名。
为什么需要它:Markdown 里的图片是 `<img src="…">`,浏览器不会给它加 Authorization 头,
所以图片地址必须自带凭据。把**登录令牌**塞进 query 是最省事的做法,但代价明确:
令牌会进 Nginx access log、浏览器历史、以及任何复制粘贴出去的链接里,有效期还长达 30 分钟。
这里换成一个只为"这一张图"服务、只活 30 分钟的签名:
`?exp=<unix 秒>&sig=<hmac 前 32 位>`。它不能用来调任何别的接口,泄漏了也只影响一张图的一小段时间。
"""
import hashlib
import hmac
import time
import uuid
from app.config import get_settings
# 域分离:即使有人拿 JWT 密钥来试,也拿不到同样的签名(反之亦然)
_DOMAIN = b"attachment-raw-v1"
def _key() -> bytes:
base = get_settings().jwt_secret.encode("utf-8")
return hmac.new(base, _DOMAIN, hashlib.sha256).digest()
def _digest(workspace_id: uuid.UUID, attachment_id: uuid.UUID, exp: int) -> str:
# 工作区也签进去,避免拿 A 工作区的签名去读 B 工作区的文件
payload = f"{workspace_id}:{attachment_id}:{exp}".encode()
return hmac.new(_key(), payload, hashlib.sha256).hexdigest()[:32]
def sign(workspace_id: uuid.UUID, attachment_id: uuid.UUID, ttl_seconds: int | None = None) -> str:
"""返回可直接拼在 `/raw` 后面的查询串。"""
ttl = ttl_seconds if ttl_seconds is not None else get_settings().attachment_url_ttl_seconds
exp = int(time.time()) + ttl
return f"exp={exp}&sig={_digest(workspace_id, attachment_id, exp)}"
def verify(workspace_id: uuid.UUID, attachment_id: uuid.UUID, exp: int | None, sig: str | None) -> bool:
if not exp or not sig:
return False
if exp < int(time.time()): # 过期就是过期,不给宽限
return False
return hmac.compare_digest(_digest(workspace_id, attachment_id, exp), sig)