Files
DadaoTask/backend/app/api/settings.py
T
Steven 7558637567
CI / backend (push) Canceled after 0s
feat(auth): 短信登录默认关,换成手机号/邮箱 + 密码,发信邮箱进后台
用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉;
登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。

短信那条路(默认关):
- `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败;
- 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦;
- 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。

邮箱登录:
- `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写;
- `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟;
- 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。

系统设置(跨租户,只有平台管理员能改):
- 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关);
- `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值;
- `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同;
- Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。

验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过;
`web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。

顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析,
要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
2026-11-03 00:00:11 +08:00

84 lines
3.1 KiB
Python

"""系统设置:平台级的运维参数(目前只有 SMTP)。
为什么单独一个路由而不是塞进 auth:这些设置**不属于任何工作区**,
权限也不是租户角色,而是「平台管理员」白名单(`deps.platform_admin`)。
把它和租户接口分开,读代码的人一眼就知道这里改的是整台服务的配置。
"""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel, Field
from ..deps import PlatformAdminDep, SessionDep
from ..errors import AppError, ErrorCode
from ..models import AppUser
from ..schemas import ok
from ..services import mail
router = APIRouter(prefix="/settings", tags=["系统设置"])
class SmtpIn(BaseModel):
"""授权码留空表示"沿用原来的",不传等于没改——避免每次保存都要重新粘贴一遍。"""
host: str = Field(min_length=1, max_length=255)
port: int = Field(default=465, ge=1, le=65535)
username: str = Field(min_length=1, max_length=255)
password: str | None = Field(default=None, max_length=255)
from_name: str = Field(default="达道Task", max_length=64)
use_ssl: bool = True
class TestMailIn(BaseModel):
# 不填就发给自己(账号里绑的邮箱),方便先确认通道通不通
to: str | None = Field(default=None, max_length=255)
@router.get("/smtp", summary="读发信配置(授权码只回是否已设)")
async def get_smtp(admin: PlatformAdminDep, session: SessionDep) -> dict:
return ok(await mail.config_payload(session))
@router.put("/smtp", summary="改发信配置,存库即时生效")
async def put_smtp(body: SmtpIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
await mail.save_config(
session,
values={
"host": body.host.strip(),
"port": body.port,
"username": body.username.strip(),
"password": (body.password or "").strip() or None,
"from_name": body.from_name.strip() or "达道Task",
"use_ssl": body.use_ssl,
},
updated_by=admin.id,
)
return ok(await mail.config_payload(session))
@router.post("/smtp/test", summary="发一封测试邮件")
async def test_smtp(body: TestMailIn, admin: PlatformAdminDep, session: SessionDep) -> dict:
"""真发一封。配错授权码、端口不通这类问题,只有真连一次才知道。"""
to = (body.to or "").strip()
if not to:
row = await session.get(AppUser, admin.id)
to = (row.email if row else None) or ""
if not to:
raise AppError(ErrorCode.VALIDATION_FAILED, "填一个收件邮箱,或先在自己的账号里绑一个")
cfg = await mail.load_config(session)
if cfg is None:
raise AppError(ErrorCode.MAIL_SEND_FAILED, "还没有配置发信邮箱,请先填上面的 SMTP")
await mail.send_mail(
cfg,
to=to,
subject="达道Task 测试邮件",
text=(
"这封信是从达道Task的「系统设置」里发出来的。\n\n"
"收到它说明 SMTP 配置可用:以后的找回密码、任务提醒都会走这条通道。\n"
),
)
return ok({"sent": True, "to": to})