Files
DadaoTask/backend/app/errors.py
T
Steven a5510cdb79 feat(auth): 手机号也能用密码登录,验证码之外多一条路
短信通道挂了人就进不来,所以给账号加第二把自助的钥匙:设过密码的用密码进,
没设过的照旧只用验证码,不强制。

- app_user.password_hash(迁移 0008 加列)+ scrypt 哈希(标准库实现,
  n=2^14/r=8/p=1,存 scrypt$n$r$p$salt$hash),不引 passlib
- POST /auth/login/password:没设过密码与密码错都回 2013,不区分账号是否存在;
  15 分钟内连错 10 次 → 2016,期间正确密码也挡住
- POST /auth/password:设/改共用,改要先验旧密码,新旧相同算弱密码 2015
- PATCH /auth/me 改昵称;用户输出走白名单 _user_out,密码哈希一个字节都不出去
- 登录页顶部「验证码 / 密码」两段切换

tests/test_account.py 10 项;全量 pytest 113 passed,ruff 全绿。
2026-10-31 04:04:55 +08:00

149 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""错误码与异常。错误码一旦对外发布就**不许改号**(客户端靠它做分支)。"""
from __future__ import annotations
from enum import IntEnum
from typing import Any
class ErrorCode(IntEnum):
OK = 0
# 1xxx 通用
BAD_REQUEST = 1001
VALIDATION_FAILED = 1002
NOT_FOUND = 1004
CONFLICT = 1009
PAYLOAD_TOO_LARGE = 1013
RATE_LIMITED = 1029
INTERNAL = 1500
# 2xxx 认证 / 账号
UNAUTHORIZED = 2001
TOKEN_EXPIRED = 2002
TOKEN_INVALID = 2003
SMS_CODE_INVALID = 2010
SMS_TOO_FREQUENT = 2011
ACCOUNT_DISABLED = 2012
PASSWORD_INVALID = 2013
PASSWORD_NOT_SET = 2014
PASSWORD_WEAK = 2015
LOGIN_TOO_MANY = 2016
# 3xxx 租户 / 权限
WORKSPACE_NOT_FOUND = 3001
NOT_A_MEMBER = 3003
INSUFFICIENT_ROLE = 3004
ALREADY_A_MEMBER = 3005
INVITATION_INVALID = 3010
# 4xxx 并发
VERSION_CONFLICT = 4009
# 5xxx 任务域
ITEM_NOT_FOUND = 5001
ITEM_STATUS_INVALID = 5002
TAG_NOT_FOUND = 5003
LINK_NOT_FOUND = 5004
LINK_CONFLICT = 5005
# 6xxx 工单域
TICKET_NOT_FOUND = 6001
TICKET_ALREADY_EXISTS = 6002
TICKET_STATUS_INVALID = 6003
TICKET_FORBIDDEN = 6004
NOTIFICATION_NOT_FOUND = 6005
# 7xxx 知识域
KNOWLEDGE_NOT_FOUND = 7001
KNOWLEDGE_ALREADY_EXISTS = 7002
KNOWLEDGE_STATUS_INVALID = 7003
KNOWLEDGE_FORBIDDEN = 7004
KNOWLEDGE_SOURCE_REQUIRED = 7005
KNOWLEDGE_CATEGORY_NOT_FOUND = 7006
KNOWLEDGE_CATEGORY_DEPTH = 7007
KNOWLEDGE_CATEGORY_CONFLICT = 7008
# 8xxx 附件
ATTACHMENT_NOT_FOUND = 8001
ATTACHMENT_TOO_LARGE = 8002
ATTACHMENT_FORBIDDEN = 8003
CONFIRM_REQUIRED = 8004
# 9xxx 客户端发布(OTA)
APP_RELEASE_NOT_FOUND = 9001
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
_HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.OK: 200,
ErrorCode.BAD_REQUEST: 400,
ErrorCode.VALIDATION_FAILED: 422,
ErrorCode.NOT_FOUND: 404,
ErrorCode.CONFLICT: 409,
ErrorCode.PAYLOAD_TOO_LARGE: 413,
ErrorCode.RATE_LIMITED: 429,
ErrorCode.INTERNAL: 500,
ErrorCode.UNAUTHORIZED: 401,
ErrorCode.TOKEN_EXPIRED: 401,
ErrorCode.TOKEN_INVALID: 401,
ErrorCode.SMS_CODE_INVALID: 400,
ErrorCode.SMS_TOO_FREQUENT: 429,
ErrorCode.ACCOUNT_DISABLED: 403,
ErrorCode.PASSWORD_INVALID: 400,
ErrorCode.PASSWORD_NOT_SET: 400,
ErrorCode.PASSWORD_WEAK: 422,
ErrorCode.LOGIN_TOO_MANY: 429,
ErrorCode.WORKSPACE_NOT_FOUND: 404,
ErrorCode.NOT_A_MEMBER: 403,
ErrorCode.INSUFFICIENT_ROLE: 403,
ErrorCode.ALREADY_A_MEMBER: 409,
ErrorCode.INVITATION_INVALID: 400,
ErrorCode.VERSION_CONFLICT: 409,
ErrorCode.ITEM_NOT_FOUND: 404,
ErrorCode.ITEM_STATUS_INVALID: 409,
ErrorCode.TAG_NOT_FOUND: 404,
ErrorCode.LINK_NOT_FOUND: 404,
ErrorCode.LINK_CONFLICT: 409,
ErrorCode.TICKET_NOT_FOUND: 404,
ErrorCode.TICKET_ALREADY_EXISTS: 409,
ErrorCode.TICKET_STATUS_INVALID: 409,
ErrorCode.TICKET_FORBIDDEN: 403,
ErrorCode.NOTIFICATION_NOT_FOUND: 404,
ErrorCode.KNOWLEDGE_NOT_FOUND: 404,
ErrorCode.KNOWLEDGE_ALREADY_EXISTS: 409,
ErrorCode.KNOWLEDGE_STATUS_INVALID: 409,
ErrorCode.KNOWLEDGE_FORBIDDEN: 403,
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409,
ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND: 404,
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH: 422,
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT: 409,
ErrorCode.ATTACHMENT_NOT_FOUND: 404,
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
ErrorCode.CONFIRM_REQUIRED: 409,
ErrorCode.APP_RELEASE_NOT_FOUND: 404,
}
class AppError(Exception):
"""业务异常。__str__ 只用于日志,对外一律走 code + message。"""
def __init__(
self,
code: ErrorCode,
message: str | None = None,
*,
http_status: int | None = None,
details: Any = None,
) -> None:
self.code = code
self.message = message or code.name
self.http_status = http_status or _HTTP_STATUS.get(code, 400)
self.details = details
super().__init__(f"[{int(code)}] {self.message}")
def http_status_for(code: ErrorCode) -> int:
return _HTTP_STATUS.get(code, 400)