feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限

This commit is contained in:
Hermes CI Fix
2026-07-12 17:46:08 +08:00
parent cdf00efd69
commit ee25d5fa1d
8871 changed files with 1778433 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
"""图形验证码 & 滑块拼图验证码"""
import random
import string
import io
import hashlib
import hmac
import time
import base64
from typing import Tuple
from PIL import Image, ImageDraw, ImageFont
# ── HMAC 一次性 token ──────────────────────────────────
_SECRET = hashlib.sha256(b"cma-captcha-secret-2024").digest()
def sign_token(captcha_id: str, value: str) -> str:
"""签发一次性 token"""
ts = str(int(time.time()))
msg = f"{captcha_id}:{value}:{ts}".encode()
sig = hmac.new(_SECRET, msg, "sha256").hexdigest()[:12]
return f"{captcha_id}.{value}.{ts}.{sig}"
def verify_token(token: str, expected_value: str, max_age: int = 300) -> bool:
"""验证一次性 token,防止重放"""
try:
parts = token.split(".")
if len(parts) != 4:
return False
captcha_id, value, ts_str, sig = parts
if value != expected_value:
return False
if int(time.time()) - int(ts_str) > max_age:
return False
expected_sig = hmac.new(
_SECRET, f"{captcha_id}:{value}:{ts_str}".encode(), "sha256"
).hexdigest()[:12]
if sig != expected_sig:
return False
return True
except (ValueError, IndexError):
return False
# ── 字体 ────────────────────────────────────────────────
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""优先使用中文字体,回退默认"""
for p in [
"/usr/share/fonts/opentype/noto/NotoSansCJKSC-VF.otf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
]:
try:
return ImageFont.truetype(p, size)
except (IOError, OSError):
continue
return ImageFont.load_default()
# ── 图形验证码(4位字母数字) ────────────────────────────
def generate_image_captcha() -> Tuple[str, str, str]:
"""
返回: (captcha_id, plain_text, base64_png)
仅需保持 captcha_id 与 plain_text 在 token 中绑定
"""
chars = string.ascii_uppercase + string.digits
text = "".join(random.choices(chars, k=4))
captcha_id = hashlib.md5(f"{time.time()}{random.random()}".encode()).hexdigest()[:16]
w, h = 160, 60
img = Image.new("RGB", (w, h), (255, 255, 255))
draw = ImageDraw.Draw(img)
font = _load_font(36)
# 干扰线
for _ in range(5):
x1, y1 = random.randint(0, w // 2), random.randint(0, h)
x2, y2 = random.randint(w // 2, w), random.randint(0, h)
draw.line(
[(x1, y1), (x2, y2)],
fill=(random.randint(100, 200), random.randint(100, 200), random.randint(100, 200)),
width=2,
)
# 噪点
for _ in range(80):
draw.point(
(random.randint(0, w), random.randint(0, h)),
fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)),
)
# 文字
x_offset = 12
for ch in text:
angle = random.randint(-25, 25)
ch_img = Image.new("RGBA", (36, 48), (255, 255, 255, 0))
ch_draw = ImageDraw.Draw(ch_img)
ch_draw.text((2, -2), ch, fill=(random.randint(0, 80), random.randint(0, 80), random.randint(0, 80)), font=font)
rotated = ch_img.rotate(angle, expand=True, fillcolor=(255, 255, 255, 0))
img.paste(rotated, (x_offset, random.randint(5, 15)), rotated)
x_offset += 34
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
return captcha_id, text, f"data:image/png;base64,{b64}"
# ── 滑块拼图验证码 ──────────────────────────────────────
def generate_slider_captcha() -> Tuple[str, str, dict]:
"""
返回: (captcha_id, answer_xxx, {
bg: base64 背景图,
slice: base64 滑块拼图块,
x: 缺口x坐标 (前端拼图用)
})
"""
captcha_id = hashlib.md5(f"{time.time()}{random.random()}".encode()).hexdigest()[:16]
bg_w, bg_h = 280, 160
img = Image.new("RGB", (bg_w, bg_h), (240, 240, 240))
draw = ImageDraw.Draw(img)
# 背景色块
for _ in range(3):
r = random.randint(200, 255)
g = random.randint(200, 255)
b = random.randint(200, 255)
x1, y1 = random.randint(0, bg_w - 60), random.randint(0, bg_h - 40)
draw.rectangle([x1, y1, x1 + 60, y1 + 40], fill=(r, g, b))
# 随机干扰线
for _ in range(8):
draw.line(
[
(random.randint(0, bg_w), random.randint(0, bg_h)),
(random.randint(0, bg_w), random.randint(0, bg_h)),
],
fill=(random.randint(180, 220), random.randint(180, 220), random.randint(180, 220)),
width=1,
)
# 随机绘制文字(增加OCR难度)
font_small = _load_font(14)
for _ in range(12):
x = random.randint(0, bg_w - 30)
y = random.randint(0, bg_h - 20)
c = random.choice(string.ascii_uppercase)
draw.text((x, y), c, fill=(random.randint(150, 220), random.randint(150, 220), random.randint(150, 220)), font=font_small)
# 缺口位置
gap_size = 40
gap_x = random.randint(20, bg_w - gap_size - 20)
gap_y = random.randint(15, bg_h - gap_size - 15)
# 在背景图上切出缺口(深色填充)
draw.rectangle([gap_x, gap_y, gap_x + gap_size, gap_y + gap_size], fill=(80, 80, 80))
# 创建滑块拼图块(从另一位置裁取)
slice_x = max(0, gap_x - 80)
if slice_x + gap_size > bg_w:
slice_x = bg_w - gap_size - 10
slice_img = img.crop((slice_x, gap_y, slice_x + gap_size, gap_y + gap_size))
# 给滑块块加白色边框
slice_with_border = Image.new("RGB", (gap_size + 4, gap_size + 4), (255, 255, 255))
slice_with_border.paste(slice_img, (2, 2))
buf_bg = io.BytesIO()
img.save(buf_bg, format="PNG")
bg_b64 = base64.b64encode(buf_bg.getvalue()).decode()
buf_slice = io.BytesIO()
slice_with_border.save(buf_slice, format="PNG")
slice_b64 = base64.b64encode(buf_slice.getvalue()).decode()
# answer 存 gap_x 的字符串形式
answer = hashlib.md5(str(gap_x).encode()).hexdigest()[:16]
return captcha_id, f"ans_{answer}", {
"bg": f"data:image/png;base64,{bg_b64}",
"slice": f"data:image/png;base64,{slice_b64}",
"gap_x": gap_x,
}