PayrollManagementSystem/backend/app/core/captcha_image.py
liuwenxin 273ebf4657 chore: 初始化项目结构与登录鉴权模块
后端 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>
2026-10-09 18:46:36 +08:00

161 lines
5.8 KiB
Python
Raw Permalink 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.

"""图形验证码渲染。
**为什么自绘而不用 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")