package com.trafficaudit.security.captcha; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Random; import java.util.concurrent.ConcurrentHashMap; /** * 行为验证码:按提示顺序点击立体图形。 * 服务端直接生成每个图形的等轴测几何面(多边形/椭圆/路径),前端只负责渲染, * 响应中不包含任何图形类型名称,答案只保存在服务端。 */ @Service public class CaptchaService { public static final int CANVAS_W = 340; public static final int CANVAS_H = 148; private static final int SHAPE_COUNT = 6; private static final int ANSWER_COUNT = 4; private static final long TTL_MILLIS = 5 * 60 * 1000L; private static final int MAX_FAIL = 3; private static final char[] ID_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789".toCharArray(); private static final Map KIND_CN = new LinkedHashMap<>(); static { KIND_CN.put("CUBE", "正方体"); KIND_CN.put("BOX", "长方体"); KIND_CN.put("CYLINDER", "圆柱"); KIND_CN.put("CONE", "圆锥"); KIND_CN.put("SPHERE", "球"); KIND_CN.put("TRI_PRISM", "三棱柱"); KIND_CN.put("PYRAMID", "金字塔"); KIND_CN.put("HEX_PRISM", "六棱柱"); } private static final String[] KINDS = KIND_CN.keySet().toArray(new String[0]); private static final String[] PATTERNS = {"dots", "grid", "lines", "rings", "cross", "tri"}; private final Map store = new ConcurrentHashMap<>(); private final Random random = new Random(); public enum VerifyResult { OK, WRONG, NOT_FOUND, RETRY_LIMIT } public enum ConsumeResult { OK, WRONG, NOT_FOUND, NOT_VERIFIED } private static class Challenge { String id; List answerKinds; Map idToKind; boolean verified; long expireAt; int failCount; } /** 生成一组挑战,返回可直接下发前端的 JSON 结构。 */ public Map generate() { boolean darkBg = random.nextBoolean(); int bgHue = random.nextInt(360); int bgSat = 30 + random.nextInt(40); int bgLight = darkBg ? 14 + random.nextInt(16) : 74 + random.nextInt(16); String pattern = PATTERNS[random.nextInt(PATTERNS.length)]; int patHue = (bgHue + 120 + random.nextInt(200)) % 360; int shapeSat = 55 + random.nextInt(30); int shapeLight = darkBg ? 60 + random.nextInt(18) : 28 + random.nextInt(16); int shapeHue = (bgHue + 30 + random.nextInt(300)) % 360; // 答案序列:随机取 ANSWER_COUNT 个不重复类型;其余位置用剩余类型填充 List pool = new ArrayList<>(Arrays.asList(KINDS)); Collections.shuffle(pool, random); List answerKinds = new ArrayList<>(pool.subList(0, ANSWER_COUNT)); List allKinds = new ArrayList<>(); for (int i = 0; i < SHAPE_COUNT; i++) { if (i < ANSWER_COUNT) { allKinds.add(answerKinds.get(i)); } else { allKinds.add(pool.get(ANSWER_COUNT + random.nextInt(pool.size() - ANSWER_COUNT))); } } Collections.shuffle(allKinds, random); int cols = 3, rows = 2; List> shapes = new ArrayList<>(); Map idToKind = new HashMap<>(); for (int i = 0; i < SHAPE_COUNT; i++) { int col = i % cols, row = i / cols; double x = CANVAS_W * (col + 0.5) / cols + (random.nextDouble() * 24 - 12); double y = CANVAS_H * (row + 0.5) / rows + (random.nextDouble() * 20 - 10); double size = 0.6 + random.nextDouble() * 0.35; double rotate = (random.nextInt(5) - 2) * 6 + (random.nextInt(5) - 2); String kind = allKinds.get(i); String id = randomId(); idToKind.put(id, kind); shapes.add(buildShape(kind, id, x, y, size, rotate, (shapeHue + random.nextInt(40) - 20 + 360) % 360, shapeSat, shapeLight)); } Challenge ch = new Challenge(); ch.id = randomId(); ch.answerKinds = answerKinds; ch.idToKind = idToKind; ch.expireAt = System.currentTimeMillis() + TTL_MILLIS; store.put(ch.id, ch); Map data = new LinkedHashMap<>(); data.put("captchaId", ch.id); List prompt = new ArrayList<>(); for (String k : answerKinds) { prompt.add(KIND_CN.get(k)); } data.put("prompt", prompt); Map bg = new LinkedHashMap<>(); bg.put("hue", bgHue); bg.put("sat", bgSat); bg.put("light", bgLight); bg.put("pattern", pattern); bg.put("patternHue", patHue); bg.put("patternSat", 50); bg.put("patternLight", 60); bg.put("patternOpacity", 0.22); bg.put("patternSize", 9 + random.nextInt(8)); data.put("background", bg); data.put("shapes", shapes); return data; } /** 校验点击顺序;通过后标记已认证,登录时消费。 */ public VerifyResult verify(String captchaId, List clickIds) { Challenge ch = getLive(captchaId); if (ch == null) { return VerifyResult.NOT_FOUND; } if (match(ch, clickIds)) { ch.verified = true; return VerifyResult.OK; } ch.failCount++; if (ch.failCount >= MAX_FAIL) { store.remove(ch.id); return VerifyResult.RETRY_LIMIT; } return VerifyResult.WRONG; } /** 登录时消费验证码:一次性,校验答案并删除挑战。 */ public ConsumeResult consume(String captchaId, List clickIds) { Challenge ch = getLive(captchaId); if (ch == null) { return ConsumeResult.NOT_FOUND; } store.remove(ch.id); if (!ch.verified) { return ConsumeResult.NOT_VERIFIED; } if (!match(ch, clickIds)) { return ConsumeResult.WRONG; } return ConsumeResult.OK; } private Challenge getLive(String captchaId) { if (captchaId == null || captchaId.isEmpty()) { return null; } Challenge ch = store.get(captchaId); if (ch == null) { return null; } if (ch.expireAt < System.currentTimeMillis()) { store.remove(ch.id); return null; } return ch; } private boolean match(Challenge ch, List clickIds) { if (clickIds == null || clickIds.isEmpty() || clickIds.size() != ch.answerKinds.size()) { return false; } List got = new ArrayList<>(); for (String id : clickIds) { String kind = ch.idToKind.get(id); if (kind == null) { return false; } got.add(kind); } return ch.answerKinds.equals(got); } private String randomId() { StringBuilder sb = new StringBuilder(6); for (int i = 0; i < 6; i++) { sb.append(ID_CHARS[random.nextInt(ID_CHARS.length)]); } return sb.toString(); } // ==================== 等轴测几何生成 ==================== private Map buildShape(String kind, String id, double x, double y, double size, double rotate, int hue, int sat, int light) { ShapeArt art; switch (kind) { case "CUBE": art = cube(hue, sat, light); break; case "BOX": art = box(hue, sat, light); break; case "CYLINDER": art = cylinder(hue, sat, light); break; case "CONE": art = cone(hue, sat, light); break; case "SPHERE": art = sphere(hue, sat, light); break; case "TRI_PRISM": art = triPrism(hue, sat, light); break; case "PYRAMID": art = pyramid(hue, sat, light); break; case "HEX_PRISM": art = hexPrism(hue, sat, light); break; default: art = cube(hue, sat, light); } Map m = new LinkedHashMap<>(); m.put("id", id); m.put("x", x); m.put("y", y); m.put("size", size); m.put("rotate", rotate); m.put("hitW", art.bbox[0]); m.put("hitH", art.bbox[1]); m.put("faces", art.faces); return m; } private static class ShapeArt { List> faces; double[] bbox; // {w, h} 未旋转未缩放时的包围盒 ShapeArt(List> faces, double w, double h) { this.faces = faces; this.bbox = new double[]{w, h}; } } private static Map poly(String pts, String fill, String stroke) { Map f = new LinkedHashMap<>(); f.put("type", "poly"); f.put("pts", pts); f.put("fill", fill); f.put("stroke", stroke); f.put("sw", 1.2); return f; } private static Map ellipse(double cx, double cy, double rx, double ry, String fill, String stroke, double opacity) { Map f = new LinkedHashMap<>(); f.put("type", "ellipse"); f.put("cx", cx); f.put("cy", cy); f.put("rx", rx); f.put("ry", ry); f.put("fill", fill); f.put("stroke", stroke); f.put("sw", opacity > 0 ? 0 : 1.2); f.put("opacity", opacity); return f; } private static Map path(String d, String fill, String stroke) { Map f = new LinkedHashMap<>(); f.put("type", "path"); f.put("d", d); f.put("fill", fill); f.put("stroke", stroke); f.put("sw", 1.2); return f; } private static String hsl(int h, int s, int l) { return "hsl(" + h + "," + s + "%," + l + "%)"; } private static Map stop(double offset, String color) { Map m = new LinkedHashMap<>(); m.put("o", offset); m.put("c", color); return m; } private static String shade(int h, int s, int l, int delta) { int v = Math.max(6, Math.min(94, l + delta)); return hsl(h, s, v); } private static String fmt(double d) { return String.format(Locale.ROOT, "%.1f", d); } private static String pts(double... p) { StringBuilder sb = new StringBuilder(); for (int i = 0; i < p.length; i += 2) { if (sb.length() > 0) { sb.append(' '); } sb.append(fmt(p[i])).append(',').append(fmt(p[i + 1])); } return sb.toString(); } private static ShapeArt cube(int h, int s, int l) { double side = 26, d = 13, cy = d / 2; String fillTop = shade(h, s, l, 18); String fillFront = hsl(h, s, l); String fillRight = shade(h, s, l, -14); String stroke = shade(h, s, l, -30); List> faces = new ArrayList<>(); faces.add(poly(pts(-side / 2, -cy, side / 2, -cy, side / 2 + d, -cy - d, -side / 2 + d, -cy - d), fillTop, stroke)); faces.add(poly(pts(side / 2, -cy, side / 2, -cy + side, side / 2 + d, -cy + side - d, side / 2 + d, -cy - d), fillRight, stroke)); faces.add(poly(pts(-side / 2, -cy, side / 2, -cy, side / 2, -cy + side, -side / 2, -cy + side), fillFront, stroke)); return new ShapeArt(faces, side + d, side + d); } private static ShapeArt box(int h, int s, int l) { double w = 34, height = 22, d = 14, cy = d / 2; String fillTop = shade(h, s, l, 18); String fillFront = hsl(h, s, l); String fillRight = shade(h, s, l, -14); String stroke = shade(h, s, l, -30); List> faces = new ArrayList<>(); faces.add(poly(pts(-w / 2, -cy, w / 2, -cy, w / 2 + d, -cy - d, -w / 2 + d, -cy - d), fillTop, stroke)); faces.add(poly(pts(w / 2, -cy, w / 2, -cy + height, w / 2 + d, -cy + height - d, w / 2 + d, -cy - d), fillRight, stroke)); faces.add(poly(pts(-w / 2, -cy, w / 2, -cy, w / 2, -cy + height, -w / 2, -cy + height), fillFront, stroke)); return new ShapeArt(faces, w + d, height + d); } private static ShapeArt cylinder(int h, int s, int l) { double r = 18, height = 34; String fillSide = hsl(h, s, l); String fillTop = shade(h, s, l, 18); String stroke = shade(h, s, l, -30); List> faces = new ArrayList<>(); String side = "M" + fmt(-r) + "," + fmt(-height / 2) + " L" + fmt(-r) + "," + fmt(height / 2) + " A" + fmt(r) + "," + fmt(r * 0.45) + ",0,0,0," + fmt(r) + "," + fmt(height / 2) + " L" + fmt(r) + "," + fmt(-height / 2) + " A" + fmt(r) + "," + fmt(r * 0.45) + ",0,0,0," + fmt(-r) + "," + fmt(-height / 2) + " Z"; faces.add(path(side, fillSide, stroke)); faces.add(ellipse(0, -height / 2, r, r * 0.45, fillTop, stroke, 1)); return new ShapeArt(faces, r * 2, height + r * 0.9); } private static ShapeArt cone(int h, int s, int l) { double r = 20, height = 38; double apexY = -height; double cy = (-height + r * 0.45) / 2; String fillBase = shade(h, s, l, -14); String fillSide = hsl(h, s, l); String stroke = shade(h, s, l, -30); List> faces = new ArrayList<>(); faces.add(ellipse(0, -cy, r, r * 0.45, fillBase, stroke, 1)); String side = "M" + fmt(0) + "," + fmt(apexY - cy) + " L" + fmt(-r) + "," + fmt(-cy) + " A" + fmt(r) + "," + fmt(r * 0.45) + ",0,0,1," + fmt(r) + "," + fmt(-cy) + " Z"; faces.add(path(side, fillSide, stroke)); return new ShapeArt(faces, r * 2, height + r * 0.45); } private static ShapeArt sphere(int h, int s, int l) { double r = 21; String stroke = shade(h, s, l, -30); List> faces = new ArrayList<>(); Map grad = new LinkedHashMap<>(); grad.put("id", "g"); grad.put("cx", 0.35); grad.put("cy", 0.32); grad.put("r", 0.85); List> stops = new ArrayList<>(); stops.add(stop(0, shade(h, s, l, 26))); stops.add(stop(0.5, hsl(h, s, l))); stops.add(stop(1, shade(h, s, l, -20))); grad.put("stops", stops); Map f = new LinkedHashMap<>(); f.put("type", "radial"); f.put("cx", 0.0); f.put("cy", 0.0); f.put("r", r); f.put("grad", grad); f.put("stroke", stroke); f.put("sw", 1.2); faces.add(f); faces.add(ellipse(-r * 0.38, -r * 0.45, r * 0.3, r * 0.2, "#ffffff", "none", 0.65)); faces.add(ellipse(r * 0.25, r * 0.45, r * 0.55, r * 0.2, shade(h, s, l, -32), "none", 0.45)); return new ShapeArt(faces, r * 2, r * 2); } private static ShapeArt triPrism(int h, int s, int l) { double w = 34, height = 30, depth = 12; double cy = (depth * 0.5 + height) / 2; String fillTop = shade(h, s, l, 18); String fillRight = hsl(h, s, l); String fillLeft = shade(h, s, l, -14); String stroke = shade(h, s, l, -30); List> faces = new ArrayList<>(); faces.add(poly(pts(0, -cy + depth * 0.5, w / 2, -cy, -w / 2, -cy), fillTop, stroke)); faces.add(poly(pts(0, -cy + depth * 0.5, w / 2, -cy, w / 2, -cy + height, 0, -cy + depth * 0.5 + height), fillRight, stroke)); faces.add(poly(pts(0, -cy + depth * 0.5, -w / 2, -cy, -w / 2, -cy + height, 0, -cy + depth * 0.5 + height), fillLeft, stroke)); return new ShapeArt(faces, w, depth * 0.5 + height); } private static ShapeArt pyramid(int h, int s, int l) { double half = 26, height = 34; double cy = (-height + half) / 2; String fillBase = shade(h, s, l, -20); String fillRight = hsl(h, s, l); String fillLeft = shade(h, s, l, -10); String stroke = shade(h, s, l, -30); List> faces = new ArrayList<>(); faces.add(poly(pts(0, -cy, half, -cy + half * 0.5, 0, -cy + half, -half, -cy + half * 0.5), fillBase, stroke)); faces.add(poly(pts(0, -cy - height, 0, -cy, -half, -cy + half * 0.5), fillLeft, stroke)); faces.add(poly(pts(0, -cy - height, 0, -cy, half, -cy + half * 0.5), fillRight, stroke)); return new ShapeArt(faces, half * 2, height + half); } private static ShapeArt hexPrism(int h, int s, int l) { double r = 22, height = 28, ry = r * 0.433; double cy = ry; String fillTop = shade(h, s, l, 18); String fillR = hsl(h, s, l); String fillF = shade(h, s, l, -12); String fillL = shade(h, s, l, -22); String stroke = shade(h, s, l, -30); List> faces = new ArrayList<>(); faces.add(poly(pts(r, -cy, r * 0.5, -cy + ry, -r * 0.5, -cy + ry, -r, -cy, -r * 0.5, -cy - ry, r * 0.5, -cy - ry), fillTop, stroke)); faces.add(poly(pts(r, -cy, r * 0.5, -cy + ry, r * 0.5, -cy + ry + height, r, -cy + height), fillR, stroke)); faces.add(poly(pts(r * 0.5, -cy + ry, -r * 0.5, -cy + ry, -r * 0.5, -cy + ry + height, r * 0.5, -cy + ry + height), fillF, stroke)); faces.add(poly(pts(-r * 0.5, -cy + ry, -r, -cy, -r, -cy + height, -r * 0.5, -cy + ry + height), fillL, stroke)); return new ShapeArt(faces, r * 2, ry * 2 + height); } }