785 lines
26 KiB
HTML
Raw 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.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>柯大鸭 抽奖验证工具</title>
<style>
:root {
--primary: #6366F1;
--primary-light: #818CF8;
--success: #10B981;
--error: #EF4444;
--warning: #F59E0B;
--bg: #0F172A;
--bg-card: #1E293B;
--bg-input: #334155;
--text: #F8FAFC;
--text-muted: #94A3B8;
--border: #475569;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 24px;
line-height: 1.6;
}
.container {
max-width: 800px;
margin: 0 auto;
}
header {
text-align: center;
margin-bottom: 32px;
padding: 24px;
background: linear-gradient(135deg, var(--primary) 0%, #8B5CF6 100%);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.3);
}
header h1 {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
}
header p {
opacity: 0.9;
font-size: 14px;
}
.card {
background: var(--bg-card);
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
border: 1px solid var(--border);
}
.card-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.tabs {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.tab {
flex: 1;
padding: 12px 16px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
text-align: center;
font-size: 14px;
transition: all 0.2s;
}
.tab.active {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.tab:hover:not(.active) {
background: var(--border);
}
.input-group {
margin-bottom: 16px;
}
.input-group label {
display: block;
font-size: 14px;
color: var(--text-muted);
margin-bottom: 6px;
}
.input-group input,
.input-group textarea,
.input-group select {
width: 100%;
padding: 12px 16px;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 14px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
transition: border-color 0.2s;
}
.input-group textarea {
min-height: 150px;
resize: vertical;
}
.input-group input:focus,
.input-group textarea:focus {
outline: none;
border-color: var(--primary);
}
.input-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 14px 28px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: linear-gradient(135deg, var(--primary) 0%, #8B5CF6 100%);
color: #fff;
width: 100%;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.4);
}
.btn-primary:active {
transform: translateY(0);
}
.result-card {
display: none;
}
.result-card.show {
display: block;
}
.result-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.result-header.success {
background: rgba(16, 185, 129, 0.15);
border: 1px solid var(--success);
}
.result-header.error {
background: rgba(239, 68, 68, 0.15);
border: 1px solid var(--error);
}
.result-icon {
font-size: 32px;
}
.result-title {
font-size: 18px;
font-weight: 600;
}
.result-subtitle {
font-size: 14px;
color: var(--text-muted);
}
.detail-section {
background: var(--bg);
border-radius: 8px;
padding: 16px;
margin-top: 16px;
}
.detail-section h4 {
font-size: 14px;
color: var(--text-muted);
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 14px;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-row .label {
color: var(--text-muted);
}
.detail-row .value {
font-family: 'Monaco', 'Menlo', monospace;
color: var(--text);
word-break: break-all;
text-align: right;
max-width: 60%;
}
.prize-info {
display: flex;
align-items: center;
gap: 20px;
padding: 24px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.1) 0%, rgba(139, 92, 246, 0.1) 100%);
border-radius: 12px;
margin-top: 16px;
}
.prize-image {
width: 120px;
height: 120px;
border-radius: 12px;
object-fit: cover;
background: var(--bg);
border: 2px solid var(--border);
}
.prize-details {
flex: 1;
}
.prize-name {
font-size: 20px;
font-weight: 700;
color: var(--text);
margin-bottom: 8px;
line-height: 1.3;
}
.prize-id-label {
font-size: 14px;
color: var(--text-muted);
}
.prize-id-label span {
font-family: 'Monaco', 'Menlo', monospace;
color: var(--primary-light);
font-weight: 600;
}
.help-text {
font-size: 12px;
color: var(--text-muted);
margin-top: 8px;
}
.config-status {
margin-top: 12px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
display: none;
}
.config-status.valid {
display: block;
background: rgba(16, 185, 129, 0.15);
border: 1px solid var(--success);
color: var(--success);
}
.config-status.invalid {
display: block;
background: rgba(239, 68, 68, 0.15);
border: 1px solid var(--error);
color: var(--error);
}
.step-indicator {
display: inline-block;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--primary);
color: #fff;
text-align: center;
line-height: 24px;
font-size: 12px;
font-weight: 600;
margin-right: 8px;
}
.hidden {
display: none !important;
}
footer {
text-align: center;
padding: 24px;
color: var(--text-muted);
font-size: 12px;
}
footer a {
color: var(--primary-light);
text-decoration: none;
}
@media (max-width: 600px) {
body {
padding: 16px;
}
.input-row {
grid-template-columns: 1fr;
}
header h1 {
font-size: 22px;
}
}
</style>
</head>
<body>
<div class="container">
<header>
<h1>🎰 柯大鸭 抽奖验证工具</h1>
<p>离线验证抽奖结果公平性 · 无需网络连接</p>
</header>
<!-- 步骤1: 活动配置 -->
<div class="card">
<div class="card-title">📦 步骤 1: 导入活动配置</div>
<p class="help-text" style="margin-bottom: 12px;">从后台活动管理页面复制完整配置 JSON</p>
<div class="input-group">
<label>活动配置 JSON包含奖品名称和图片</label>
<textarea id="activityConfig" placeholder='{
"weights": "1:100,2:500",
"rewards": [
{"id": 1, "name": "奖品A", "image": "...", "weight": 100}
]
}'></textarea>
</div>
<div id="configStatus" class="config-status"></div>
</div>
<!-- 步骤2: 抽奖凭证 -->
<div class="card">
<div class="card-title">🎫 步骤 2: 导入抽奖凭证</div>
<p class="help-text" style="margin-bottom: 12px;">从小程序订单详情复制验证凭据 JSON</p>
<div class="input-group">
<label>验证凭据 JSON</label>
<textarea id="jsonInput" placeholder='{
"seed": "aabbccdd...",
"issue_id": 1001,
"user_id": 12345,
"salt": "1234abcd"
}'></textarea>
</div>
<button class="btn btn-primary" onclick="verify()">
🔍 开始验证
</button>
</div>
<!-- 结果区域 -->
<div id="resultsContainer"></div>
<footer>
<p>柯大鸭 抽奖公平性验证工具 v1.1</p>
<p>采用 HMAC-SHA256 承诺机制 · 完全离线运行</p>
</footer>
</div>
<script>
// Hex 解码
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
// BigEndian Uint64
function bigEndianUint64(buffer) {
const view = new DataView(buffer);
const high = view.getUint32(0);
const low = view.getUint32(4);
return BigInt(high) * BigInt(0x100000000) + BigInt(low);
}
// HMAC-SHA256
async function hmacSha256(keyHex, message) {
const keyBytes = hexToBytes(keyHex);
const encoder = new TextEncoder();
const msgBytes = encoder.encode(message);
const cryptoKey = await crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, msgBytes);
return new Uint8Array(signature);
}
// 解析奖品配置(自动排序)
function parseRewards(str) {
if (!str) return [];
const list = str.split(',').map(part => {
const [id, value] = part.trim().split(':');
return { id: parseInt(id), weight: parseInt(value), count: parseInt(value) };
}).filter(r => r.id && r.weight);
// 关键按ID排序以匹配后端逻辑
list.sort((a, b) => a.id - b.id);
return list;
}
// 全局存储活动配置
let activityConfig = null;
// 验证并显示活动配置状态
function validateConfig() {
const input = document.getElementById('activityConfig');
const status = document.getElementById('configStatus');
const configStr = input.value.trim();
if (!configStr) {
status.className = 'config-status';
status.textContent = '';
activityConfig = null;
return;
}
try {
const config = JSON.parse(configStr);
// 检查是否有 weights 字段或 rewards 数组
if (config.weights && config.rewards && Array.isArray(config.rewards)) {
activityConfig = config;
// 如果config里没有weights字符串我们自己生成一个按ID排序
// 但通常应该有。为了保险,重新解析并排序
const rewards = parseRewards(config.weights);
// 更新weights字符串为排序后的
activityConfig.weights = rewards.map(r => `${r.id}:${r.weight}`).join(',');
const totalWeight = rewards.reduce((sum, r) => sum + r.weight, 0);
status.className = 'config-status valid';
status.textContent = `✅ 已识别 ${config.rewards.length} 个奖品,总权重: ${totalWeight}`;
} else if (config.weights) {
// 只有 weights 字符串
const rewards = parseRewards(config.weights);
if (rewards.length > 0) {
activityConfig = { weights: config.weights, rewards: rewards };
// 更新weights字符串为排序后的
activityConfig.weights = rewards.map(r => `${r.id}:${r.weight}`).join(',');
const totalWeight = rewards.reduce((sum, r) => sum + r.weight, 0);
status.className = 'config-status valid';
status.textContent = `✅ 已识别 ${rewards.length} 个奖品,总权重: ${totalWeight}`;
} else {
throw new Error('无效的权重配置');
}
} else {
throw new Error('缺少 weights 字段');
}
} catch (e) {
// 尝试作为简单的权重字符串解析
const rewards = parseRewards(configStr);
if (rewards.length > 0) {
const sortedWeightsStr = rewards.map(r => `${r.id}:${r.weight}`).join(',');
activityConfig = { weights: sortedWeightsStr, rewards: rewards };
const totalWeight = rewards.reduce((sum, r) => sum + r.weight, 0);
status.className = 'config-status valid';
status.textContent = `✅ 已识别 ${rewards.length} 个奖品,总权重: ${totalWeight}`;
} else {
status.className = 'config-status invalid';
status.textContent = '❌ 配置格式无效,请粘贴后台导出的 JSON';
activityConfig = null;
}
}
}
// 初始化事件监听
document.addEventListener('DOMContentLoaded', function () {
const configInput = document.getElementById('activityConfig');
configInput.addEventListener('input', validateConfig);
configInput.addEventListener('paste', function () {
setTimeout(validateConfig, 10);
});
});
// 无限赏验证
async function verifyUnlimited(seed, issueId, userId, salt, weightsStr, rewardsInfo) {
const rewards = parseRewards(weightsStr);
if (rewards.length === 0) {
throw new Error('奖品权重配置无效');
}
const totalWeight = rewards.reduce((sum, r) => sum + r.weight, 0);
if (totalWeight <= 0) {
throw new Error('总权重必须大于 0');
}
// 构建 payload 并计算 HMAC
const payload = `draw:issue:${issueId}|user:${userId}|salt:${salt}`;
const hmac = await hmacSha256(seed, payload);
// 提取随机数
const randValue = bigEndianUint64(hmac.buffer) % BigInt(totalWeight);
const rnd = Number(randValue);
// 选择奖品
let acc = 0;
let pickedReward = null;
let rangeInfo = '';
for (const r of rewards) {
acc += r.weight;
if (rnd < acc) {
pickedReward = r;
rangeInfo = `${acc - r.weight} - ${acc}`;
break;
}
}
// 查找奖品详细信息
let prizeName = '';
let prizeImage = '';
if (pickedReward && rewardsInfo) {
// rewardsInfo 可能有 id 字段也可能没有,尝试匹配 id
const info = rewardsInfo.find(ri => (ri.id || ri.ID) === pickedReward.id);
if (info) {
prizeName = info.name || info.product_name || '';
// 处理图片可能是JSON数组字符串
const img = info.image || info.product_image_url;
if (img) {
try {
const images = JSON.parse(img);
prizeImage = Array.isArray(images) ? images[0] : img;
} catch {
prizeImage = img;
}
}
}
}
return {
mode: 'unlimited',
success: true,
rewardId: pickedReward ? pickedReward.id : 0,
prizeName: prizeName,
prizeImage: prizeImage,
details: [
{ label: '验证模式', value: '无限赏 (Weighted Random)' },
{ label: 'Payload', value: payload },
{ label: '总权重', value: totalWeight.toString() },
{ label: '随机数', value: rnd.toString() },
{ label: '命中区间', value: rangeInfo },
{ label: '奖品数量', value: rewards.length.toString() }
]
};
}
// 主验证函数
async function verify() {
try {
const resultsContainer = document.getElementById('resultsContainer');
resultsContainer.innerHTML = ''; // 清空之前结果
// 步骤1: 获取活动配置 (全局)
const globalWeightsStr = activityConfig ? activityConfig.weights : '';
const globalRewardsInfo = activityConfig ? activityConfig.rewards : [];
if (!globalWeightsStr) {
// 如果没提供全局配置,必须确保凭证里有配置
}
// 步骤2: 获取抽奖凭证
const jsonStr = document.getElementById('jsonInput').value.trim();
if (!jsonStr) {
throw new Error('请导入抽奖凭证步骤2');
}
let paramsList;
try {
const parsed = JSON.parse(jsonStr);
if (Array.isArray(parsed)) {
paramsList = parsed;
} else {
paramsList = [parsed];
}
} catch (e) {
throw new Error('凭证 JSON 格式无效');
}
// 批量验证
let allResults = [];
for (let i = 0; i < paramsList.length; i++) {
const params = paramsList[i];
try {
// 验证必要参数
if (!params.seed) throw new Error('缺少 seed');
if (!params.issue_id && params.issue_id !== 0) throw new Error('缺少 issue_id');
// 决定使用的权重配置
let currentWeightsStr = params.weights || globalWeightsStr;
if (!currentWeightsStr) {
throw new Error('未找到奖品权重配置 (请在步骤1导入或确保凭证包含配置)');
}
// 执行验证
const result = await verifyUnlimited(
params.seed,
params.issue_id,
params.user_id || 0,
params.salt || '',
currentWeightsStr,
globalRewardsInfo // 优先用全局的图片信息
);
// 附加索引
result.index = i + 1;
result.drawIndex = params.draw_index || (i + 1);
allResults.push(result);
} catch (err) {
allResults.push({
index: i + 1,
success: false,
error: err.message
});
}
}
// 显示所有结果
showResults(allResults);
} catch (err) {
// 全局错误如JSON解析失败
const resultsContainer = document.getElementById('resultsContainer');
resultsContainer.innerHTML = `
<div class="card result-card show">
<div class="result-header error">
<span class="result-icon">❌</span>
<div>
<div class="result-title">验证无法执行</div>
<div class="result-subtitle">${err.message}</div>
</div>
</div>
</div>
`;
// 滚动到结果
resultsContainer.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}
// 显示结果列表
function showResults(results) {
const container = document.getElementById('resultsContainer');
container.innerHTML = '';
results.forEach(res => {
const card = document.createElement('div');
card.className = 'card result-card show';
card.style.marginBottom = '20px';
if (res.success) {
const detailHtml = res.details.map(d => `
<div class="detail-row">
<span class="label">${d.label}</span>
<span class="value">${d.value}</span>
</div>
`).join('');
const imgHtml = res.prizeImage
? `<img class="prize-image" src="${res.prizeImage}" alt="奖品图片" style="display:block">`
: `<img class="prize-image" style="display:none">`;
const nameHtml = res.prizeName || ('奖品 #' + res.rewardId);
card.innerHTML = `
<div class="card-title">📊 第 ${res.drawIndex} 抽验证结果</div>
<div class="result-header success">
<span class="result-icon">✅</span>
<div>
<div class="result-title">验证通过</div>
<div class="result-subtitle">ID: ${res.rewardId} - ${nameHtml}</div>
</div>
</div>
<div class="prize-info">
${imgHtml}
<div class="prize-details">
<div class="prize-name">${nameHtml}</div>
<div class="prize-id-label">奖品 ID: <span>${res.rewardId}</span></div>
</div>
</div>
<div class="detail-section">
<h4>计算详情</h4>
${detailHtml}
</div>
`;
} else {
card.innerHTML = `
<div class="card-title">📊 第 ${res.index} 抽</div>
<div class="result-header error">
<span class="result-icon">❌</span>
<div>
<div class="result-title">验证失败</div>
<div class="result-subtitle">${res.error}</div>
</div>
</div>
`;
}
container.appendChild(card);
});
// 滚动到结果
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
</script>
</body>
</html>