写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。 这次把「正文」这条路补全,思路是**存 Markdown 而不是 HTML**—— 能导出、能全文检索、能直接喂给 LLM,也不必每次渲染都重新清洗一遍 HTML。 后端 - attachment 表 + RLS + 迁移 0005;本地磁盘存储,key 由内容 sha256 决定, 换对象存储只改 services/storage.py 一个文件 - 上传在**读流时**卡 20MB,不先落盘再判断;磁盘名由服务端生成,用户文件名只进库和响应头 - 列表带 attachment_count、详情带 attachments,避免一屏 N 次请求 - raw 直链给 <img src> 用(图片带不了 Authorization 头),只允许本工作区成员, 非成员一律 8001 不区分「不存在」与「没权限」 - 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 鉴权 前端 - 自研零依赖 Markdown 渲染器:先转义再上标签,链接协议白名单 - RichTextEditor:工具栏 + Ctrl+B/I + 预览,粘贴或拖入图片自动上传并插入引用 - AttachmentPanel:上传/下载/删除,下载走带 Authorization 的 fetch 转 blob - 任务备注、工单说明与验收结论、知识正文、评论全部接入 顺手修的 - TaskDetail.hydrate 漏了 setAttachments,导致附件面板永远是空的(e2e 抓到的) 71 passed;web tsc 0 错、构建 255.07 kB │ gzip 80.85 kB; e2e:richtext 9 项 + smoke 7 + tickets 8 + knowledge 7,全绿。
133 lines
6.4 KiB
Python
133 lines
6.4 KiB
Python
"""分层配置:dev / prod 全部走环境变量,启动即校验。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from functools import lru_cache
|
||
from typing import Literal
|
||
|
||
from pydantic import Field, field_validator
|
||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||
|
||
DEV_JWT_SECRET = "dev-only-change-me" # noqa: S105 - 仅开发默认值,生产启动时会被校验拦下
|
||
|
||
|
||
class Settings(BaseSettings):
|
||
model_config = SettingsConfigDict(
|
||
env_file=".env", env_file_encoding="utf-8", extra="ignore", case_sensitive=False
|
||
)
|
||
|
||
# ── 基础 ──────────────────────────────────────────────
|
||
app_name: str = "达道Task"
|
||
env: Literal["dev", "test", "prod"] = "dev"
|
||
api_prefix: str = "/api/v1"
|
||
cors_origins: list[str] = Field(
|
||
default_factory=lambda: ["http://localhost:5180", "http://127.0.0.1:5180"]
|
||
)
|
||
|
||
# ── 数据库 ────────────────────────────────────────────
|
||
# 应用角色:受 RLS 约束,非 owner、非 superuser
|
||
database_url: str = "postgresql+asyncpg://dadao_app:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
||
# 迁移角色:owner,可绕过 RLS,仅 alembic / 运维脚本使用
|
||
migration_database_url: str = (
|
||
"postgresql+asyncpg://dadao_owner:dadao_dev_pw@127.0.0.1:5433/dadaotask"
|
||
)
|
||
# 测试专用库:测试会 TRUNCATE 所有表,绝不能落到开发库上。
|
||
# 留空则退回 database_url(CI 里直接给 DATABASE_URL 即可)。
|
||
test_database_url: str | None = None
|
||
test_migration_database_url: str | None = None
|
||
db_echo: bool = False
|
||
db_pool_size: int = 10
|
||
|
||
# ── Redis ─────────────────────────────────────────────
|
||
redis_url: str = "redis://127.0.0.1:6380/0"
|
||
|
||
# ── 业务 ──────────────────────────────────────────────
|
||
# "今天/本周"这类视图按用户时区切日界,不能按 UTC —— 中国用户 23:30 建的任务
|
||
# 在 UTC 下已经是"明天"了。M7 多地域时改成按工作区配置。
|
||
default_timezone: str = "Asia/Shanghai"
|
||
|
||
# ── 认证 ──────────────────────────────────────────────
|
||
jwt_secret: str = DEV_JWT_SECRET
|
||
jwt_algorithm: str = "HS256"
|
||
access_token_minutes: int = 30
|
||
refresh_token_days: int = 14
|
||
sms_code_ttl_seconds: int = 300
|
||
# 同一手机号两次发码的最小间隔;测试里设为 0 可关闭节流
|
||
sms_throttle_seconds: int = 60
|
||
sms_dev_code: str = "123456"
|
||
sms_provider: Literal["dev", "aliyun", "tencent"] = "dev"
|
||
|
||
# ── 幂等 ──────────────────────────────────────────────
|
||
idempotency_enabled: bool = True
|
||
idempotency_ttl_seconds: int = 86_400
|
||
|
||
# ── 附件 ──────────────────────────────────────────────
|
||
# 本地磁盘目录(相对 backend/ 或绝对路径)。换对象存储时只改 services/storage.py。
|
||
upload_dir: str = "var/uploads"
|
||
# 单文件上限。20MB 够放文档与截图;再大就该走对象存储 + 分片了。
|
||
max_upload_mb: int = 20
|
||
|
||
# ── 知识库向量库(独立实例,M3 起用)───────────────────
|
||
# 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它;
|
||
# 向量检索吃内存,和任务库挤在同一实例会互相拖累。
|
||
# 未配置时所有向量相关能力(M3 才启用)直接不可用,不会静默降级成假结果。
|
||
vector_database_url: str | None = None
|
||
embedding_dim: int = 1024
|
||
embedding_model: str = "BAAI/bge-m3"
|
||
|
||
# ── 构建标记(部署校验用)──────────────────────────────
|
||
build_commit: str = "unknown"
|
||
build_time: str = "unknown"
|
||
|
||
@field_validator("cors_origins", mode="before")
|
||
@classmethod
|
||
def _split_origins(cls, value: object) -> object:
|
||
"""支持 CORS_ORIGINS="a,b" 这种写法。"""
|
||
if isinstance(value, str) and not value.startswith("["):
|
||
return [item.strip() for item in value.split(",") if item.strip()]
|
||
return value
|
||
|
||
def validate_runtime(self) -> None:
|
||
"""生产环境的致命配置错误,必须在启动时炸掉,而不是等待请求。"""
|
||
if self.env != "prod":
|
||
return
|
||
problems: list[str] = []
|
||
if self.jwt_secret == DEV_JWT_SECRET or len(self.jwt_secret) < 32:
|
||
problems.append("JWT_SECRET 仍是开发默认值或长度不足 32")
|
||
if "dadao_dev_pw" in self.database_url:
|
||
problems.append("DATABASE_URL 仍在用开发默认口令")
|
||
if self.sms_provider == "dev":
|
||
problems.append("生产环境不允许 SMS_PROVIDER=dev(任意验证码可登录)")
|
||
if problems:
|
||
raise RuntimeError("配置校验失败:" + ";".join(problems))
|
||
|
||
def resolved_database_url(self) -> str:
|
||
"""应用实际连的库:测试环境优先用独立测试库(测试会 TRUNCATE)。"""
|
||
if self.env == "test" and self.test_database_url:
|
||
return self.test_database_url
|
||
return self.database_url
|
||
|
||
def resolved_migration_database_url(self) -> str:
|
||
"""迁移/清表实际连的库:必须和应用同一个库,否则清表清到别处去了。"""
|
||
if self.env == "test" and self.test_migration_database_url:
|
||
return self.test_migration_database_url
|
||
return self.migration_database_url
|
||
|
||
def resolved_vector_database_url(self) -> str | None:
|
||
"""向量库连接串;没配就是 None,调用方必须显式处理"没有向量能力"。"""
|
||
return self.vector_database_url
|
||
|
||
|
||
@lru_cache
|
||
def get_settings() -> Settings:
|
||
return Settings()
|
||
|
||
|
||
def build_commit() -> str:
|
||
"""优先读环境变量(CI/镜像注入),退回 git。"""
|
||
env_value = os.getenv("BUILD_COMMIT")
|
||
if env_value:
|
||
return env_value
|
||
return get_settings().build_commit
|