feat: P0/P1/P2全部功能 — 四层泳道/视角切换/KPI看板/预警/差异反打/预算/知识面板/回顾会/情景预测/Excel导入/角色权限
This commit is contained in:
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user