Add protocol submodule

pull/3727/head
hawklin2017 2 months ago
parent 34ecfd283d
commit 38d4923ec6

@ -66,9 +66,17 @@ func Start(ctx context.Context, cfg *Config, _ discovery.SvcDiscoveryRegistry, g
return err
}
resources, err := loadResources()
if err != nil {
log.ZError(ctx, "captcha load resources failed", err)
return err
}
builder := slide.NewBuilder()
builder.SetResources(resources...)
s := &server{
conf: cfg.RpcConfig,
capt: slide.NewBuilder().Make(),
capt: builder.Make(),
collection: collection,
}
if s.conf.ExpireSeconds <= 0 {
@ -116,6 +124,7 @@ func (s *server) GenerateCaptcha(ctx context.Context, _ *pbcaptcha.GenerateCaptc
CaptchaID: id,
MasterImage: masterImage,
TileImage: tileImage,
TileY: int32(block.DY),
ExpireAt: expiredAt.Unix(),
}, nil
}

@ -0,0 +1,13 @@
package captcha
import "embed"
// resourceFS embeds background images and tile images at compile time.
// Background images come from go-captcha-resources (sourcedata/images/image-{1..5}).
// Tile images come from go-captcha-resources (sourcedata/tiles/tile-{1..4}):
// overlay.png → GraphImage.OverlayImage
// shadow.png → GraphImage.ShadowImage
// mask.png → GraphImage.MaskImage
//
//go:embed resources/images/*.jpg resources/tiles/*/*.png
var resourceFS embed.FS

@ -0,0 +1,78 @@
package captcha
import (
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"github.com/wenlng/go-captcha/v2/slide"
)
// loadResources reads the embedded files and returns slide.Resource options
// ready to be passed to slide.NewBuilder().SetResources(...).
func loadResources() ([]slide.Resource, error) {
backgrounds, err := loadBackgrounds()
if err != nil {
return nil, fmt.Errorf("load captcha backgrounds: %w", err)
}
graphImages, err := loadGraphImages()
if err != nil {
return nil, fmt.Errorf("load captcha graph images: %w", err)
}
return []slide.Resource{
slide.WithBackgrounds(backgrounds),
slide.WithGraphImages(graphImages),
}, nil
}
// loadBackgrounds decodes the embedded JPEG background images.
func loadBackgrounds() ([]image.Image, error) {
const count = 5
images := make([]image.Image, 0, count)
for i := 1; i <= count; i++ {
path := fmt.Sprintf("resources/images/image-%d.jpg", i)
img, err := decodeEmbedImage(path)
if err != nil {
return nil, fmt.Errorf("decode %s: %w", path, err)
}
images = append(images, img)
}
return images, nil
}
// loadGraphImages decodes the 4 sets of tile overlay/shadow/mask PNG images.
func loadGraphImages() ([]*slide.GraphImage, error) {
const count = 4
graphs := make([]*slide.GraphImage, 0, count)
for i := 1; i <= count; i++ {
overlay, err := decodeEmbedImage(fmt.Sprintf("resources/tiles/tile-%d/overlay.png", i))
if err != nil {
return nil, fmt.Errorf("decode tile-%d overlay: %w", i, err)
}
shadow, err := decodeEmbedImage(fmt.Sprintf("resources/tiles/tile-%d/shadow.png", i))
if err != nil {
return nil, fmt.Errorf("decode tile-%d shadow: %w", i, err)
}
mask, err := decodeEmbedImage(fmt.Sprintf("resources/tiles/tile-%d/mask.png", i))
if err != nil {
return nil, fmt.Errorf("decode tile-%d mask: %w", i, err)
}
graphs = append(graphs, &slide.GraphImage{
OverlayImage: overlay,
ShadowImage: shadow,
MaskImage: mask,
})
}
return graphs, nil
}
func decodeEmbedImage(path string) (image.Image, error) {
f, err := resourceFS.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
img, _, err := image.Decode(f)
return img, err
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

@ -0,0 +1,318 @@
#!/usr/bin/env bash
# ============================================================
# Captcha API 接口测试脚本
#
# 覆盖接口:
# POST /captcha/generate —— 生成滑块验证码
# POST /captcha/verify —— 验证滑块验证码
#
# 依赖curl / jq
# 用法:
# chmod +x captcha_api_test.sh
# ./captcha_api_test.sh
# ./captcha_api_test.sh --host http://127.0.0.1:10002
# ============================================================
set -euo pipefail
# ──────────────────────────────────────────────
# 可配置参数(可通过环境变量覆盖)
# ──────────────────────────────────────────────
HOST="${HOST:-http://127.0.0.1:10002}"
ADMIN_USER_ID="${ADMIN_USER_ID:-imAdmin}"
ADMIN_SECRET="${ADMIN_SECRET:-openIM123}"
PLATFORM_ID="${PLATFORM_ID:-1}" # 1=iOS 2=Android 3=Windows ...
# 命令行参数解析
while [[ $# -gt 0 ]]; do
case "$1" in
--host) HOST="$2"; shift 2 ;;
--admin-user-id) ADMIN_USER_ID="$2"; shift 2 ;;
--admin-secret) ADMIN_SECRET="$2"; shift 2 ;;
*) echo "未知参数: $1"; exit 1 ;;
esac
done
# ──────────────────────────────────────────────
# 颜色输出
# ──────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
PASS=0; FAIL=0
pass() { echo -e "${GREEN} [PASS]${NC} $1"; PASS=$((PASS+1)); }
fail() { echo -e "${RED} [FAIL]${NC} $1"; FAIL=$((FAIL+1)); }
info() { echo -e "${CYAN} [INFO]${NC} $1"; }
section() { echo -e "\n${YELLOW}══ $1 ══${NC}"; }
# ──────────────────────────────────────────────
# 生成唯一 operationID每次调用递增
# ──────────────────────────────────────────────
_OP_SEQ=0
new_op_id() {
(( _OP_SEQ++ ))
echo "captcha-test-$$-${_OP_SEQ}"
}
# ──────────────────────────────────────────────
# 断言工具函数
# ──────────────────────────────────────────────
assert_err_code() {
local resp="$1" expected="$2" desc="$3"
local actual
actual=$(echo "$resp" | jq -r '.errCode // "null"')
if [[ "${actual}" == "${expected}" ]]; then
pass "${desc} (errCode=${actual})"
else
fail "${desc} - expected errCode=${expected}, got errCode=${actual}"
info "resp: ${resp}"
fi
}
assert_not_empty() {
local resp="$1" jq_path="$2" desc="$3"
local val
val=$(echo "$resp" | jq -r "$jq_path // empty")
if [[ -n "${val}" && "${val}" != "null" ]]; then
pass "${desc} (val=${val:0:40}...)"
else
fail "${desc} - '${jq_path}' is empty or null"
info "resp: ${resp}"
fi
}
assert_eq() {
local resp="$1" jq_path="$2" expected="$3" desc="$4"
local actual
# 不使用 // emptyjq 的 // 运算符会把布尔 false 视为 false 并走替代分支
actual=$(echo "$resp" | jq -r "$jq_path")
if [[ "${actual}" == "${expected}" ]]; then
pass "${desc} (val=${actual})"
else
fail "${desc} - expected=${expected}, got=${actual}"
info "resp: ${resp}"
fi
}
# errCode 非 0 即通过
assert_err_nonzero() {
local resp="$1" desc="$2"
local actual
actual=$(echo "$resp" | jq -r '.errCode // "null"')
if [[ "${actual}" != "0" && "${actual}" != "null" ]]; then
pass "${desc} (errCode=${actual})"
else
fail "${desc} - expected errCode!=0, got errCode=${actual}"
info "resp: ${resp}"
fi
}
# ──────────────────────────────────────────────
# 前置:获取 Admin Token
# ──────────────────────────────────────────────
section "前置:获取 Admin Token"
TOKEN_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "operationID: $(new_op_id)" \
-d "{\"secret\":\"${ADMIN_SECRET}\",\"platformID\":${PLATFORM_ID},\"userID\":\"${ADMIN_USER_ID}\"}" \
"${HOST}/auth/get_admin_token")
info "Token 响应: $TOKEN_RESP"
ERR_CODE=$(echo "$TOKEN_RESP" | jq -r '.errCode // "null"')
if [[ "$ERR_CODE" != "0" ]]; then
echo -e "${RED}[ERROR]${NC} 获取 Admin Token 失败 (errCode=$ERR_CODE),中止测试"
exit 1
fi
TOKEN=$(echo "$TOKEN_RESP" | jq -r '.data.token')
info "获取到 token: ${TOKEN:0:40}..."
# ──────────────────────────────────────────────
# 用例 1生成验证码 —— 正常流程
# ──────────────────────────────────────────────
section "用例 1 / POST /captcha/generate —— 正常生成验证码"
GEN_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d '{}' \
"${HOST}/captcha/generate")
info "响应摘要: $(echo "${GEN_RESP}" | jq -c '{errCode,errMsg,data:{captchaID:.data.captchaID,expireAt:.data.expireAt}}')"
GEN_ERR=$(echo "${GEN_RESP}" | jq -r '.errCode // "null"')
GEN_MSG=$(echo "${GEN_RESP}" | jq -r '.errMsg // ""')
# 检测服务端是否因缺少背景图资源而报 500
if [[ "${GEN_ERR}" == "500" && "${GEN_MSG}" == *"background"* ]]; then
fail "用例 1 跳过 - captcha 服务未配置背景图资源 (errMsg=${GEN_MSG})"
info "修复方式:在 captcha.Start() 中通过 slide.NewBuilder().SetBackground(...).Make() 注入背景图"
CAPTCHA_ID=""
EXPIRE_AT=""
else
assert_err_code "${GEN_RESP}" "0" "生成验证码 errCode 应为 0"
assert_not_empty "${GEN_RESP}" ".data.captchaID" "captchaID 非空"
assert_not_empty "${GEN_RESP}" ".data.masterImage" "masterImage(背景图 Base64) 非空"
assert_not_empty "${GEN_RESP}" ".data.tileImage" "tileImage(滑块图 Base64) 非空"
assert_not_empty "${GEN_RESP}" ".data.expireAt" "expireAt(过期 Unix 时间戳) 非空"
CAPTCHA_ID=$(echo "${GEN_RESP}" | jq -r '.data.captchaID')
EXPIRE_AT=$(echo "${GEN_RESP}" | jq -r '.data.expireAt')
info "captchaID = ${CAPTCHA_ID}"
info "expireAt = ${EXPIRE_AT}"
fi
# ──────────────────────────────────────────────
# 用例 2生成验证码 —— 不携带 Token
# ──────────────────────────────────────────────
section "用例 2 / POST /captcha/generate —— 无 Token 应被鉴权中间件拦截"
NO_TOKEN_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "operationID: $(new_op_id)" \
-d '{}' \
"${HOST}/captcha/generate")
info "响应: $NO_TOKEN_RESP"
assert_err_nonzero "$NO_TOKEN_RESP" "无 Token 被鉴权中间件拦截"
# ──────────────────────────────────────────────
# 用例 3验证验证码 —— 坐标错误x=999, y=999
# ──────────────────────────────────────────────
section "用例 3 / POST /captcha/verify —— 坐标错误success 应为 false"
if [[ -z "${CAPTCHA_ID}" ]]; then
fail "用例 3 跳过 - 依赖用例 1 生成的 captchaID但用例 1 未成功"
else
VERIFY_WRONG_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d "{\"captchaID\":\"${CAPTCHA_ID}\",\"x\":999,\"y\":999}" \
"${HOST}/captcha/verify")
info "响应: ${VERIFY_WRONG_RESP}"
assert_err_code "${VERIFY_WRONG_RESP}" "0" "验证请求本身成功 errCode=0"
assert_eq "${VERIFY_WRONG_RESP}" ".data.success" "false" "坐标错误时 success=false"
fi
# ──────────────────────────────────────────────
# 用例 4验证验证码 —— 重复使用同一 captchaID
# 用例 3 已消耗该 IDverify_time 已被 FindOneAndUpdate 写入),
# 再次调用服务端 filter 匹配不到记录,应返回错误
# ──────────────────────────────────────────────
section "用例 4 / POST /captcha/verify —— 重复使用同一 captchaID幂等应返回错误"
if [[ -z "${CAPTCHA_ID}" ]]; then
fail "用例 4 跳过 - 依赖用例 1 生成的 captchaID但用例 1 未成功"
else
VERIFY_REUSE_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d "{\"captchaID\":\"${CAPTCHA_ID}\",\"x\":0,\"y\":0}" \
"${HOST}/captcha/verify")
info "响应: ${VERIFY_REUSE_RESP}"
assert_err_nonzero "${VERIFY_REUSE_RESP}" "重复使用 captchaID 被拒绝"
fi
# ──────────────────────────────────────────────
# 用例 5验证验证码 —— captchaID 不存在
# ──────────────────────────────────────────────
section "用例 5 / POST /captcha/verify —— captchaID 不存在,应返回错误"
VERIFY_NOTFOUND_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d '{"captchaID":"00000000-0000-0000-0000-000000000000","x":10,"y":10}' \
"${HOST}/captcha/verify")
info "响应: $VERIFY_NOTFOUND_RESP"
assert_err_nonzero "$VERIFY_NOTFOUND_RESP" "captchaID 不存在时返回错误"
# ──────────────────────────────────────────────
# 用例 6验证验证码 —— captchaID 为空字符串
# ──────────────────────────────────────────────
section "用例 6 / POST /captcha/verify —— captchaID 为空字符串,应返回错误"
VERIFY_EMPTY_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d '{"captchaID":"","x":10,"y":10}' \
"${HOST}/captcha/verify")
info "响应: $VERIFY_EMPTY_RESP"
assert_err_nonzero "$VERIFY_EMPTY_RESP" "captchaID 为空时返回错误"
# ──────────────────────────────────────────────
# 用例 7验证验证码 —— 不携带 Token
# ──────────────────────────────────────────────
section "用例 7 / POST /captcha/verify —— 无 Token 应被鉴权中间件拦截"
VERIFY_NOTOKEN_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "operationID: $(new_op_id)" \
-d "{\"captchaID\":\"${CAPTCHA_ID:-00000000-0000-0000-0000-000000000000}\",\"x\":10,\"y\":10}" \
"${HOST}/captcha/verify")
info "响应: $VERIFY_NOTOKEN_RESP"
assert_err_nonzero "$VERIFY_NOTOKEN_RESP" "无 Token 被鉴权中间件拦截"
# ──────────────────────────────────────────────
# 用例 8完整正向链路 —— 新生成 + 用偏差坐标验证
# 服务端不回传正确坐标,用 (0,0) 验证 success=false
# 正确坐标可从 MongoDB 查询:
# db.captcha.findOne({captcha_id: "<ID>"}, {x:1,y:1})
# ──────────────────────────────────────────────
section "用例 8 / 完整正向链路 —— 新生成验证码 → 坐标偏差验证"
GEN_RESP2=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d '{}' \
"${HOST}/captcha/generate")
GEN_ERR2=$(echo "${GEN_RESP2}" | jq -r '.errCode // "null"')
GEN_MSG2=$(echo "${GEN_RESP2}" | jq -r '.errMsg // ""')
if [[ "${GEN_ERR2}" == "500" && "${GEN_MSG2}" == *"background"* ]]; then
fail "用例 8 跳过 - captcha 服务未配置背景图资源 (errMsg=${GEN_MSG2})"
else
CAPTCHA_ID2=$(echo "${GEN_RESP2}" | jq -r '.data.captchaID')
EXPIRE_AT2=$(echo "${GEN_RESP2}" | jq -r '.data.expireAt')
MASTER_LEN=$(echo "${GEN_RESP2}" | jq -r '.data.masterImage | length')
TILE_LEN=$(echo "${GEN_RESP2}" | jq -r '.data.tileImage | length')
assert_err_code "${GEN_RESP2}" "0" "新一轮生成验证码成功"
assert_not_empty "${GEN_RESP2}" ".data.captchaID" "captchaID2 非空"
info "captchaID2 = ${CAPTCHA_ID2}"
info "expireAt = ${EXPIRE_AT2}"
info "masterImage 长度 = ${MASTER_LEN} chars(Base64)"
info "tileImage 长度 = ${TILE_LEN} chars(Base64)"
info "查询真实坐标: db.captcha.findOne({captcha_id:\"${CAPTCHA_ID2}\"},{x:1,y:1})"
VERIFY_LINK_RESP=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "token: ${TOKEN}" \
-H "operationID: $(new_op_id)" \
-d "{\"captchaID\":\"${CAPTCHA_ID2}\",\"x\":0,\"y\":0}" \
"${HOST}/captcha/verify")
assert_err_code "${VERIFY_LINK_RESP}" "0" "验证接口响应正常 errCode=0"
assert_eq "${VERIFY_LINK_RESP}" ".data.success" "false" "偏差坐标(0,0) success=false"
fi
# ──────────────────────────────────────────────
# 汇总
# ──────────────────────────────────────────────
echo ""
echo -e "══════════════════════════════════════════"
echo -e " 测试汇总:${GREEN}PASS=${PASS}${NC} ${RED}FAIL=${FAIL}${NC}"
echo -e "══════════════════════════════════════════"
[[ $FAIL -eq 0 ]]

@ -0,0 +1,316 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Captcha 验证码测试</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
background:#f0f2f5;padding:20px;min-height:100vh}
/* ── 卡片 ── */
.card{background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,.08);
padding:20px;max-width:500px;margin:0 auto 16px}
.card-title{font-size:15px;font-weight:600;color:#1a1a2e;margin-bottom:14px;
display:flex;align-items:center;gap:6px}
/* ── 表单 ── */
.field{margin-bottom:10px}
.field label{display:block;font-size:11px;color:#888;margin-bottom:3px}
input[type=text]{width:100%;padding:8px 10px;border:1px solid #e0e0e0;border-radius:8px;
font-size:12px;outline:none;transition:border .15s}
input[type=text]:focus{border-color:#4f46e5}
/* ── 按钮 ── */
.btn{display:inline-flex;align-items:center;gap:5px;padding:8px 16px;border:none;
border-radius:8px;font-size:12px;font-weight:500;cursor:pointer;transition:.15s}
.btn:hover{filter:brightness(.92)}
.btn:disabled{opacity:.45;cursor:not-allowed}
.btn-indigo{background:#4f46e5;color:#fff}
.btn-gray {background:#6b7280;color:#fff}
.btn-green {background:#10b981;color:#fff}
.btn-row {display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
/* ── 状态标签 ── */
.tag{display:inline-block;padding:3px 10px;border-radius:20px;font-size:11px;font-weight:600}
.tag-ok {background:#d1fae5;color:#065f46}
.tag-err {background:#fee2e2;color:#991b1b}
.tag-info{background:#e0e7ff;color:#3730a3}
#status,#verify-status{margin-top:10px;min-height:24px}
/* ── 验证码区域 ── */
#captcha-section{display:none}
/* 背景图 + 滑块叠层 */
.slide-wrap{
position:relative;width:300px;height:220px;
border:2px solid #e5e7eb;border-radius:10px;
overflow:hidden;background:#f3f4f6;
cursor:default;user-select:none;
}
#master-img{position:absolute;top:0;left:0;width:300px;height:220px;display:block}
#tile-img {position:absolute;cursor:grab;transition:none}
#tile-img:active{cursor:grabbing}
/* 滑轨 */
.slider-row{display:flex;align-items:center;gap:6px;margin-top:8px}
.slider-row span{font-size:11px;color:#6b7280;min-width:14px}
.slider-row input[type=range]{flex:1;accent-color:#4f46e5;height:4px}
.slider-row .val{font-size:11px;color:#4f46e5;min-width:30px;text-align:right}
/* 提示箭头 */
.hint{font-size:11px;color:#9ca3af;margin-top:6px}
/* ── 调试区 ── */
#debug-area{display:none;margin-top:14px}
.debug-label{font-size:10px;color:#9ca3af;margin-bottom:4px}
.image-preview{display:flex;gap:12px;flex-wrap:wrap;align-items:flex-start}
.image-preview figure{text-align:center}
.image-preview figcaption{font-size:10px;color:#888;margin-top:4px}
pre{background:#1e1e2e;color:#cdd6f4;border-radius:8px;padding:12px;
font-size:10px;overflow:auto;max-height:160px;word-break:break-all}
</style>
</head>
<body>
<!-- ① 配置 -->
<div class="card">
<div class="card-title">⚙️ 配置</div>
<div class="field">
<label>API Host</label>
<input type="text" id="host" value="http://127.0.0.1:10002">
</div>
<div class="field">
<label>Token留空则自动获取 Admin Token</label>
<input type="text" id="token" placeholder="eyJhbGci...">
</div>
<div class="field">
<label>Admin Secret</label>
<input type="text" id="secret" value="openIM123">
</div>
<div class="btn-row">
<button class="btn btn-gray" onclick="getToken()">🔑 获取 Token</button>
<button class="btn btn-indigo" id="btn-gen" onclick="generate()">🖼 生成验证码</button>
</div>
<div id="status"></div>
</div>
<!-- ② 验证码交互 -->
<div class="card" id="captcha-section">
<div class="card-title">🧩 滑块验证码</div>
<p class="hint">拖动滑块或直接拖拽图中的小方块,使其嵌入背景缺口后点击「验证」。</p>
<div style="margin-top:12px">
<!-- 背景图 + 浮动滑块 -->
<div class="slide-wrap" id="slide-wrap">
<img id="master-img" alt="">
<img id="tile-img" alt="">
</div>
<!-- X 轴滑轨 -->
<div class="slider-row">
<span>←→</span>
<input type="range" id="sx" min="0" max="240" value="0" oninput="syncTile()">
<span class="val" id="sx-val">0 px</span>
</div>
<!-- Y 轴调试用 -->
<div class="slider-row">
<span></span>
<input type="range" id="sy" min="0" max="160" value="0" oninput="syncTile()">
<span class="val" id="sy-val">0 px</span>
</div>
</div>
<div class="btn-row">
<button class="btn btn-green" onclick="verify()">✅ 验证</button>
<button class="btn btn-indigo" onclick="generate()">🔄 刷新</button>
</div>
<div id="verify-status"></div>
<!-- 调试:直接渲染两张图 + 原始数据 -->
<div id="debug-area">
<hr style="border:none;border-top:1px solid #f0f0f0;margin:14px 0">
<div class="debug-label">图片预览(直接从 Base64 渲染)</div>
<div class="image-preview">
<figure>
<img id="dbg-master" style="border-radius:6px;border:1px solid #e5e7eb" alt="">
<figcaption>masterImageJPEG 背景)</figcaption>
</figure>
<figure>
<img id="dbg-tile" style="border-radius:4px;border:1px solid #e5e7eb;background:#ddd" alt="">
<figcaption>tileImagePNG 滑块)</figcaption>
</figure>
</div>
<div class="debug-label" style="margin-top:10px">原始响应(去除图片数据)</div>
<pre id="raw-json"></pre>
</div>
</div>
<script>
/* ── 工具 ── */
const $ = id => document.getElementById(id);
let captchaState = { id:'', tileW:68, tileH:68, tileY:0 };
let opSeq = 0;
function setStatus(elId, html, type='info'){
$(elId).innerHTML = `<span class="tag tag-${type}">${html}</span>`;
}
async function post(path, body){
const host = $('host').value.replace(/\/$/,'');
const tok = $('token').value.trim();
const headers = {
'Content-Type':'application/json',
'operationID':`demo-${Date.now()}-${++opSeq}`,
};
if(tok) headers['token'] = tok;
const r = await fetch(host+path, {method:'POST', headers, body:JSON.stringify(body)});
return r.json();
}
/* ── 1. 获取 Token ── */
async function getToken(){
setStatus('status','获取 Token 中…','info');
try{
const d = await post('/auth/get_admin_token',{
secret:$('secret').value, platformID:1, userID:'imAdmin'
});
if(d.errCode!==0){ setStatus('status',`获取失败: ${d.errMsg}`,'err'); return; }
$('token').value = d.data.token;
setStatus('status','Token 获取成功 ✓','ok');
}catch(e){ setStatus('status',`请求异常: ${e.message}`,'err'); }
}
/* ── 2. 生成验证码 ── */
async function generate(){
if(!$('token').value.trim()){ await getToken(); if(!$('token').value.trim()) return; }
setStatus('status','生成验证码中…','info');
$('btn-gen').disabled = true;
try{
const d = await post('/captcha/generate',{});
/* 展示原始响应(隐去图片数据保持可读) */
const preview = JSON.parse(JSON.stringify(d));
if(preview.data){
if(preview.data.masterImage) preview.data.masterImage = `<base64 JPEG, ${preview.data.masterImage.length} chars>`;
if(preview.data.tileImage) preview.data.tileImage = `<base64 PNG, ${preview.data.tileImage.length} chars>`;
}
$('raw-json').textContent = JSON.stringify(preview, null, 2);
if(d.errCode!==0){
setStatus('status',`生成失败 errCode=${d.errCode}: ${d.errMsg} — ${d.errDlt}`,'err');
return;
}
const {captchaID, masterImage, tileImage, tileY, expireAt} = d.data;
captchaState.id = captchaID;
captchaState.tileY = tileY || 0;
/* ── 渲染 masterImageJPEG── */
const masterSrc = `data:image/jpeg;base64,${masterImage}`;
$('master-img').src = masterSrc;
$('dbg-master').src = masterSrc;
$('dbg-master').style.width = '300px';
/* ── 渲染 tileImagePNG── */
const tileSrc = `data:image/png;base64,${tileImage}`;
$('dbg-tile').src = tileSrc;
/* 等 tile 图片加载完毕后设置滑块 */
await loadImage($('tile-img'), tileSrc);
captchaState.tileW = $('tile-img').naturalWidth || 68;
captchaState.tileH = $('tile-img').naturalHeight || 68;
/* 更新滑轨范围,初始化 Y = tileY */
$('sx').max = 300 - captchaState.tileW;
$('sy').max = 220 - captchaState.tileH;
$('sx').value = 0;
$('sy').value = captchaState.tileY;
syncTile();
const expire = new Date(expireAt * 1000).toLocaleTimeString();
setStatus('status',
`验证码已生成 | id: ${captchaID.slice(0,8)}… | tileY: ${tileY} | 过期: ${expire}`,'ok');
$('captcha-section').style.display = 'block';
$('debug-area').style.display = 'block';
$('verify-status').innerHTML = '';
}catch(e){
setStatus('status',`请求异常: ${e.message}`,'err');
}finally{
$('btn-gen').disabled = false;
}
}
/* 等待 img.onloadPromise 封装) */
function loadImage(imgEl, src){
return new Promise((resolve, reject)=>{
imgEl.onload = resolve;
imgEl.onerror = reject;
imgEl.src = src;
});
}
/* ── 3. 同步滑块位置 ── */
function syncTile(){
const x = parseInt($('sx').value);
const y = parseInt($('sy').value);
$('sx-val').textContent = x + ' px';
$('sy-val').textContent = y + ' px';
$('tile-img').style.left = x + 'px';
$('tile-img').style.top = y + 'px';
}
/* ── 4. 鼠标 / 触屏拖拽 ── */
(()=>{
let drag=false, sx0=0, sliderX0=0;
const wrap = $('slide-wrap');
const start = (cx)=>{ drag=true; sx0=cx; sliderX0=parseInt($('sx').value); };
const move = (cx)=>{
if(!drag) return;
const nx = Math.max(0, Math.min(parseInt($('sx').max), sliderX0+(cx-sx0)));
$('sx').value = nx;
syncTile();
};
const end = ()=>{ drag=false; };
wrap.addEventListener('mousedown', e=>{ if(e.target.id==='tile-img') start(e.clientX); });
document.addEventListener('mousemove', e=>move(e.clientX));
document.addEventListener('mouseup', end);
wrap.addEventListener('touchstart', e=>{
if(e.target.id==='tile-img'){ start(e.touches[0].clientX); e.preventDefault(); }
},{passive:false});
document.addEventListener('touchmove', e=>{
if(drag){ move(e.touches[0].clientX); e.preventDefault(); }
},{passive:false});
document.addEventListener('touchend', end);
})();
/* ── 5. 验证 ── */
async function verify(){
if(!captchaState.id){ alert('请先生成验证码'); return; }
const x = parseInt($('sx').value);
const y = parseInt($('sy').value);
setStatus('verify-status',`提交验证 x=${x} y=${y}…`,'info');
try{
const d = await post('/captcha/verify',{captchaID:captchaState.id, x, y});
if(d.errCode!==0){
setStatus('verify-status',`请求失败 errCode=${d.errCode}: ${d.errMsg}`,'err');
return;
}
if(d.data.success){
setStatus('verify-status',`🎉 验证通过x=${x} y=${y}`,'ok');
}else{
setStatus('verify-status',`❌ 验证失败,偏差过大,请重新拖动后再试`,'err');
}
}catch(e){
setStatus('verify-status',`请求异常: ${e.message}`,'err');
}
}
</script>
</body>
</html>
Loading…
Cancel
Save