CI / backend (push) Canceled after 0s
用户要求:内测的固定验证码 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。
84 lines
3.1 KiB
Python
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})
|