后端 FastAPI 六层结构(api/services/repositories/models/schemas/core), 统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存 db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、 登出黑名单(TTL=令牌剩余有效期)、登录日志。 前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。 工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。 测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
161 lines
5.8 KiB
Python
161 lines
5.8 KiB
Python
"""图形验证码渲染。
|
||
|
||
**为什么自绘而不用 captcha 库的默认渲染**:
|
||
- 该库默认输出随机彩色字符 + 重噪点 + 弧线,与界面视觉体系严重冲突
|
||
- 它最后更新于 2025-03,内部还引用了 Pillow 的旧别名(`PIL.ImageDraw.ImageDraw`)
|
||
|
||
自绘后配色与界面统一,而识别难度由**逐字旋转 + 垂直抖动 + 干扰曲线 + 噪点**
|
||
共同保证,强度与原实现相当且可调(见 `_draw_characters` 的注释)。
|
||
|
||
仅沿用该库内置的 DroidSansMono 字体文件:自带字体可避开
|
||
在 Linux 容器里找不到系统字体的经典问题。
|
||
"""
|
||
|
||
import base64
|
||
import io
|
||
import math
|
||
import random
|
||
import secrets
|
||
from pathlib import Path
|
||
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
|
||
from app.core.constants import CAPTCHA_CHARSET, CAPTCHA_LENGTH
|
||
|
||
# 画布尺寸与前端展示槽位对齐(登录页为 116×40)
|
||
_WIDTH, _HEIGHT = 120, 40
|
||
_FONT_SIZE = 25
|
||
_PADDING = 8
|
||
|
||
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
|
||
_INK = (15, 54, 70)
|
||
_INTERFERENCE = (126, 156, 170)
|
||
_SPECKLE = (168, 183, 192)
|
||
_PAPER = (247, 246, 242)
|
||
|
||
_SPECKLE_COUNT = 46
|
||
_INTERFERENCE_LINES = 2
|
||
|
||
# 备选字体路径:优先用 captcha 包内置字体(跨平台一致),其余为回退
|
||
_FONT_CANDIDATES = (
|
||
"C:/Windows/Fonts/consola.ttf",
|
||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||
)
|
||
|
||
|
||
def _font_path() -> Path | None:
|
||
"""定位可用的等宽字体文件。"""
|
||
import captcha
|
||
|
||
package_file = getattr(captcha, "__file__", None)
|
||
if package_file:
|
||
bundled = Path(package_file).parent / "data" / "DroidSansMono.ttf"
|
||
if bundled.is_file():
|
||
return bundled
|
||
|
||
for candidate in _FONT_CANDIDATES:
|
||
path = Path(candidate)
|
||
if path.is_file():
|
||
return path
|
||
return None
|
||
|
||
|
||
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||
"""加载字体。找不到任何字体文件时退回 PIL 内置位图字体(仍可用,只是较粗糙)。"""
|
||
path = _font_path()
|
||
if path is None:
|
||
return ImageFont.load_default(size)
|
||
return ImageFont.truetype(str(path), size)
|
||
|
||
|
||
def generate_text() -> str:
|
||
"""生成验证码文本。
|
||
|
||
用 `secrets` 而非 `random`:验证码是安全凭据,
|
||
`random` 的 Mersenne Twister 状态可被观测样本反推。
|
||
"""
|
||
return "".join(secrets.choice(CAPTCHA_CHARSET) for _ in range(CAPTCHA_LENGTH))
|
||
|
||
|
||
def _draw_speckles(draw: ImageDraw.ImageDraw, count: int) -> None:
|
||
"""撒噪点。
|
||
|
||
用单像素点与 2×2 方块,**不用 `ellipse`** —— 3×3 包围盒画出的椭圆
|
||
恰好填充中心与四个边中点,形状就是「+」,会形成一种规则的十字纹理,
|
||
既显眼又容易被整体滤波抹掉,起不到干扰作用。
|
||
"""
|
||
for _ in range(count):
|
||
x = random.randint(0, _WIDTH - 2)
|
||
y = random.randint(0, _HEIGHT - 2)
|
||
if random.random() < 0.75:
|
||
draw.point((x, y), fill=_SPECKLE)
|
||
else:
|
||
draw.rectangle((x, y, x + 1, y + 1), fill=_SPECKLE)
|
||
|
||
|
||
def _draw_interference(draw: ImageDraw.ImageDraw) -> None:
|
||
"""绘制干扰曲线。
|
||
|
||
用低对比度的正弦折线而非粗直线:粗直线会把字符主干割断、
|
||
显著提高正常用户的识错率,而低对比细线对分割算法的干扰并不更差。
|
||
"""
|
||
for _ in range(_INTERFERENCE_LINES):
|
||
amplitude = random.uniform(5.0, 11.0)
|
||
phase = random.uniform(0, math.tau)
|
||
base_y = random.uniform(_HEIGHT * 0.25, _HEIGHT * 0.75)
|
||
points = [
|
||
(x, base_y + amplitude * math.sin(phase + x / 34.0)) for x in range(0, _WIDTH + 1, 4)
|
||
]
|
||
draw.line(points, fill=_INTERFERENCE, width=1)
|
||
|
||
|
||
def _draw_characters(image: Image.Image, text: str) -> None:
|
||
"""逐字绘制。
|
||
|
||
**每个字符单独渲染后旋转再贴回**,而不是整行一起旋转:
|
||
整行旋转只是把同一个倾斜做一次仿射变换,对齐后即可还原;
|
||
逐字随机旋转会破坏字符间的版式一致性,模板匹配难以复用。
|
||
再叠加 ±2px 的垂直抖动,进一步打散基线。
|
||
"""
|
||
font = _load_font(_FONT_SIZE)
|
||
cell = (_WIDTH - 2 * _PADDING) / len(text)
|
||
|
||
for index, char in enumerate(text):
|
||
# 每个字形画在独立画布上以便单独旋转;留出余量避免旋转后切角
|
||
glyph = Image.new("RGBA", (int(cell) + 16, _HEIGHT), (0, 0, 0, 0))
|
||
ImageDraw.Draw(glyph).text(
|
||
(8, 3),
|
||
char,
|
||
font=font,
|
||
fill=(*_INK, 255),
|
||
# 加 1 像素描边:等宽细体在小字号下笔画过细,
|
||
# 而本系统的用户是营销人员、屏幕与年龄跨度都大,
|
||
# 验证码过难会直接变成支持负担。加粗的代价是连通像素略多,
|
||
# 但相对于"用户反复输错"的代价是划算的。
|
||
stroke_width=1,
|
||
stroke_fill=(*_INK, 255),
|
||
)
|
||
glyph = glyph.rotate(random.uniform(-14, 14), resample=Image.BICUBIC, expand=False)
|
||
|
||
image.paste(glyph, (int(_PADDING + index * cell) - 8, random.randint(-2, 2)), glyph)
|
||
|
||
|
||
def render_png_base64(text: str) -> str:
|
||
"""把验证码文本渲染为 PNG 并返回 base64(**不含** `data:` 前缀)。
|
||
|
||
不含前缀是为了让前端自行拼接;服务端返回纯数据,
|
||
浏览器 / 小程序 / 桌面端可各自决定呈现方式。
|
||
"""
|
||
image = Image.new("RGB", (_WIDTH, _HEIGHT), _PAPER)
|
||
draw = ImageDraw.Draw(image)
|
||
|
||
_draw_speckles(draw, _SPECKLE_COUNT // 2)
|
||
_draw_interference(draw)
|
||
_draw_characters(image, text)
|
||
# 字符之上再撒一层,干扰基于连通域的分割
|
||
_draw_speckles(draw, _SPECKLE_COUNT // 2)
|
||
|
||
buffer = io.BytesIO()
|
||
image.save(buffer, format="PNG")
|
||
return base64.b64encode(buffer.getvalue()).decode("ascii")
|