CI / backend (push) Canceled after 0s
短信登录关掉后,生产没有任何注册入口——这一版补上最普通的那条路:
填邮箱、收验证码、设密码,注册完直接进首页。
先发码再注册,不是"注册时顺带发信"
POST /auth/email/code 只做三件事:查重、限流、发码。
填完密码才被告知"这个邮箱注册过了"最气人,所以占用(2019)在发码前就回。
邮箱既是账号名也是唯一联系方式,所以必须真验一次码
验证通过时间落 app_user.email_verified_at,/auth/me 回 email_verified。
"随手填一个"会让账号变成没人能找回的孤岛。
app_user.phone 改为可空(迁移 0011)
邮箱注册的账号本来就没有手机号。user.phone 对这类账号回空串,
成员列表同理,不再伪装成一串星号。
防刷
同邮箱 60 秒一次(2022)、验证码 10 分钟有效、错 5 次即作废(2021)。
6 位数可以被刮,不设上限等于把注册口子交给脚本。
本机联调开关 EMAIL_CODE_DEV_ECHO
为 true 时把码回给调用方且不发真信——否则每跑一次 e2e 就往真实收件箱塞一封。
生产打开会让 validate_runtime() 拒绝启动。
前端
新增 /register(邮箱、验证码、密码、确认、昵称;60 秒倒计时;
服务端关着注册时整页只说"联系管理员开通"),登录页按 /auth/methods
决定要不要显示「还没有账号?用邮箱注册」。
验证
pytest 139 passed、ruff check / format 全绿
tsc --noEmit 0、vite build 通过
e2e register.mjs 7 项:登录页入口 → 拿码 → 注册落进首页 → 退出用邮箱+密码登回来
→ 同一邮箱再发码回 2019 → 控制台零报错
e2e login-email-smtp.mjs 15 项回归通过(含真发一封测试信)
e2e account.mjs 14 项、knowledge.mjs 全绿
顺带修的两处测试
test_account 的字段白名单补 email_verified(接口确实多了这个字段);
test_config_prod 用 Settings(_env_file=None, ...) 构造,别被本机 .env 里的
EMAIL_CODE_DEV_ECHO=true 带偏——那个开关在生产是硬错误,本机却是跑 e2e 必需的。
216 lines
12 KiB
Python
216 lines
12 KiB
Python
"""分层配置:dev / prod 全部走环境变量,启动即校验。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from functools import lru_cache
|
||
from typing import Annotated, Literal
|
||
|
||
from pydantic import Field, field_validator
|
||
from pydantic_settings import BaseSettings, NoDecode, 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"
|
||
# 内测开关:生产默认禁止固定验证码。确实要在内网试用时显式打开,
|
||
# 启动会打 WARNING(见 main.py),其余生产校验照旧——绝不能配合公网暴露。
|
||
internal_test_login: bool = False
|
||
# 登录方式开关:手机验证码这条**默认关掉**。内测期的固定验证码等于"知道手机号就能进任意账号",
|
||
# 关掉后 /auth/sms/send 与 /auth/login 统一回 2017,登录走「手机号 / 邮箱 + 密码」。
|
||
# 将来接上真短信通道把它打开即可,登录页会按 /auth/methods 自动列出可用的方式。
|
||
sms_login_enabled: bool = False
|
||
|
||
# 平台管理员:能改系统设置(SMTP 等)。按手机号白名单,不挂工作区角色——
|
||
# 系统设置是**跨租户**的,某个工作区的 owner 也不该看到平台的发信配置。
|
||
# `NoDecode` + 下面的 before-validator:这个值环境变量里是逗号分隔的一串,
|
||
# 不让 pydantic-settings 先按 JSON 解析(那样 `1360...,1390...` 会直接启动失败)。
|
||
platform_admin_phones: Annotated[list[str], NoDecode] = Field(
|
||
default_factory=lambda: ["13605513683"]
|
||
)
|
||
|
||
# ── 邮件(SMTP)────────────────────────────────────────
|
||
# 以「系统设置」页里存的值为准(DB),这里只是首次启动前的兜底。
|
||
# 改 SMTP 不该要人登服务器改 env 再重启——所以真配置在 DB 里(见 services/mail.py)。
|
||
smtp_host: str | None = None
|
||
smtp_port: int = 465
|
||
smtp_username: str | None = None
|
||
smtp_password: str | None = None
|
||
smtp_from_name: str = "达道Task"
|
||
# 465 走 SSL,587 走 STARTTLS;126/163 这类用 465 + SSL
|
||
smtp_use_ssl: bool = True
|
||
smtp_timeout_seconds: int = 10
|
||
|
||
# ── 邮箱注册 ──────────────────────────────────────────
|
||
# 手机验证码关掉以后,新用户靠「邮箱 + 验证码」自己注册进来。
|
||
# 注册必须真的发出验证码(发信配置在「系统设置」里),所以这个开关跟 SMTP 是否配好绑定:
|
||
# 没配 SMTP 时发码接口会回 2020,而不是假装发出去。
|
||
email_register_enabled: bool = True
|
||
email_code_ttl_seconds: int = 600
|
||
# 同一邮箱两次发码的最小间隔;测试里设为 0 可关闭节流
|
||
email_code_throttle_seconds: int = 60
|
||
# 一个邮箱最多试错几次验证码,超了就作废(防止把 6 位数当彩票刮)
|
||
email_code_max_attempts: int = 5
|
||
# 开发/联调用:把验证码直接回给调用方,**不发真信**。
|
||
# 生产打开等于任何人都能注册任意邮箱,所以 validate_runtime() 会直接拒绝启动。
|
||
email_code_dev_echo: bool = False
|
||
|
||
# ── 幂等 ──────────────────────────────────────────────
|
||
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
|
||
# 图片直链的签名有效期。短一点更安全,太长等于把凭据长期贴在页面上;
|
||
# 30 分钟够一次阅读/编辑,过期后刷新页面会重新签发。
|
||
attachment_url_ttl_seconds: int = 1800
|
||
|
||
# ── 知识检索(M3)──────────────────────────────────────
|
||
# 切片大小与重叠:中文一段 600 字大约 400~600 token,够表达一个完整片段;
|
||
# 重叠 80 字是为了不把跨段的话切断(检索命中时前后文还读得懂)。
|
||
chunk_max_chars: int = 600
|
||
chunk_overlap_chars: int = 80
|
||
# 嵌入向量提供方:
|
||
# hash —— 内置的字符 n-gram 哈希向量,离线可用、零依赖、可复现;
|
||
# 质量不如真模型,但能把"检索链路"跑通,且换模型时接口不变。
|
||
# openai —— 任何 OpenAI 兼容的 /embeddings 端点(BYOK)。
|
||
embedding_provider: Literal["hash", "openai"] = "hash"
|
||
embedding_base_url: str = "https://api.deepseek.com/v1"
|
||
embedding_api_key: str | None = None
|
||
embedding_model: str = "text-embedding-3-small"
|
||
# 混合检索里向量与词法的权重。纯词法搜不到同义改写,纯向量搜不准专有名词,
|
||
# 所以默认各占一半,可用环境变量调。
|
||
search_vector_weight: float = 0.6
|
||
search_lexical_weight: float = 0.4
|
||
|
||
# 低于这个分数就不返回:宁可"没找到",也不要塞一条不相干的让人误信。
|
||
search_min_score: float = 0.06
|
||
|
||
# ── 知识库向量库(独立实例,M3 起用)───────────────────
|
||
# 刻意与业务库分开放:fnsvr-01 上的 PG 没有 pgvector,也不该为了装扩展去动它;
|
||
# 向量检索吃内存,和任务库挤在同一实例会互相拖累。
|
||
# 未配置时所有向量相关能力(M3 才启用)直接不可用,不会静默降级成假结果。
|
||
vector_database_url: str | None = None
|
||
embedding_dim: int = 1024
|
||
embedding_model: str = "BAAI/bge-m3"
|
||
|
||
# ── 前端产物 ──────────────────────────────────────────
|
||
# 生产没有 nginx 时由后端直接托管前端构建产物(同源,/api 无跨域)。
|
||
# 指向 web/dist 的绝对路径;留空则只提供 API。
|
||
web_dist_dir: str | None = None
|
||
|
||
# ── 安卓安装包(OTA)──────────────────────────────────
|
||
# 放 latest.json + 对应 apk 的目录;留空则用 backend/static/apk。
|
||
# 发布目录是构建产物,不入库,部署脚本也不清理(git reset 不动未跟踪文件)。
|
||
apk_dir: str | None = None
|
||
|
||
# ── 构建标记(部署校验用)──────────────────────────────
|
||
build_commit: str = "unknown"
|
||
build_time: str = "unknown"
|
||
|
||
@field_validator("cors_origins", "platform_admin_phones", 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_login_enabled and self.sms_provider == "dev":
|
||
problems.append(
|
||
"短信验证码登录开着(SMS_LOGIN_ENABLED=true),但 SMS_PROVIDER=dev 只会发固定验证码"
|
||
"(等于知道手机号就能进);接真短信通道之前请把 SMS_LOGIN_ENABLED 关掉"
|
||
)
|
||
if self.email_code_dev_echo:
|
||
problems.append(
|
||
"EMAIL_CODE_DEV_ECHO=true 会把注册验证码直接回给调用方,"
|
||
"等于任何人都能注册任意邮箱;生产必须关掉(只在本地联调时打开)"
|
||
)
|
||
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
|