# 查询执行与验证规则

## 调用示例（保底转盘）

```python
import sys
sys.path.insert(0, "references")
from lottery_validator import LotteryValidator
from datetime import datetime, timedelta

# 从用户粘贴的抽奖报告中直接构建参数
account_id      = "wechat_12274"   # {market}_{uid}，见 报告解析规则.md
activity_id     = "10439"          # 活动入口id，见 events/guaranteed_lottery.md
coin_item_id    = "140210"         # 代币道具 item_id
module          = "guaranteed_turntable_30"
big_reward_name = "滑稽咸鱼王"

exchange_rate = 10  # 报告头部「苹果兑换比例」，如 1代币=10苹果

draws = [
    # {"draw_num": N, "time": "yyyy-MM-dd HH:mm:ss", "cost_amount": N,
    #  "reward_type": N, "reward_item_id": "N", "reward_name": "...", "reward_num": N},
]

# 奖励事件分流规则（见 events/guaranteed_lottery.md）
CURRENCY_GAIN_TYPES     = {1, 7, 18}       # → CurrencyGain
ACTIVITY_CURRENCY_TYPES = {15, 17, 19, 28} # → ActivityCurrencyGain（奖励）
# 其他 type → GoodsGain

t_start = (datetime.strptime(draws[0]["time"], "%Y-%m-%d %H:%M:%S") - timedelta(seconds=10)).strftime("%Y-%m-%d %H:%M:%S")
t_end   = (datetime.strptime(draws[-1]["time"], "%Y-%m-%d %H:%M:%S") + timedelta(seconds=10)).strftime("%Y-%m-%d %H:%M:%S")
time_range = (t_start, t_end)

validator = LotteryValidator(region="domestic", project_id=13)
results = []

# ActivityLottery：每次抽奖1条
results.append(validator.validate_event(
    "query_activity_lottery",
    account_id=account_id,
    expected_row_count=len(draws),
    expected={"activity_id": activity_id, "item_id": coin_item_id},
    per_row_expected=[
        {"cost_amount": d["cost_amount"], "reward_id": d["reward_item_id"], "reward_num": d["reward_num"]}
        for d in draws
    ],
    time_range=time_range,
))

# CurrencyConsume：每次抽奖1条，amount = cost_amount × exchange_rate
results.append(validator.validate_event(
    "query_currency_consume",
    account_id=account_id,
    expected_row_count=len(draws),
    expected={
        "activity_id": activity_id,
        "sub_source": "lottery",
        "currency_type": "18",
        "source": "guaranteed_turntable",
    },
    per_row_expected=[{"amount": d["cost_amount"] * exchange_rate} for d in draws],
    time_range=time_range,
))

# ActivityCurrencyGain（代币变化）：每次抽奖2条
# 兑换苹果→代币：amount = +cost_amount（source=apple）
# 代币消耗：amount = -cost_amount（source=baodizhuanpan）
# 注意：必须加 filters={"item_id": coin_item_id} 过滤奖励行，否则奖励行会干扰时间匹配
results.append(validator.validate_event(
    "query_activity_currency_gain",
    account_id=account_id,
    filters={"item_id": coin_item_id},
    expected={"activity_id": activity_id, "item_id": coin_item_id},
    per_draw_pairs_expected=[
        {coin_item_id: [d["cost_amount"], -d["cost_amount"]]}
        for d in draws
    ],
    time_range=time_range,
))

# ActivityCurrencyGain（奖励，type 15/17/19/28）：amount = reward_num
# 注意：必须按全部抽奖顺序传，不触发的抽奖用 {} 占位，否则报告索引错位
results.append(validator.validate_event(
    "query_activity_currency_gain",
    account_id=account_id,
    expected={"activity_id": activity_id},
    per_draw_pairs_expected=[
        # reward_item_id="0" 时用 None 作 key，按 amount 匹配（实际 item_id 为 doll_coin/wish_bottle 等字符串）
        {(d["reward_item_id"] if d["reward_item_id"] != "0" else None): d["reward_num"]}
        if d["reward_type"] in ACTIVITY_CURRENCY_TYPES else {}
        for d in draws
    ],
    time_range=time_range,
))

# CurrencyGain（type 1/7/18）：amount = reward_num，currency_type = reward_type
# 注意：必须按全部抽奖顺序传，不触发的抽奖用 {} 占位，否则报告索引错位
currency_gain_draws = [d for d in draws if d["reward_type"] in CURRENCY_GAIN_TYPES]
if currency_gain_draws:
    results.append(validator.validate_event(
        "query_currency_gain",
        account_id=account_id,
        expected_row_count=len(currency_gain_draws),
        expected={"activity_id": activity_id},
        per_row_expected=[
            {"amount": d["reward_num"], "currency_type": str(d["reward_type"])}
            if d["reward_type"] in CURRENCY_GAIN_TYPES else {}
            for d in draws
        ],
        time_range=time_range,
    ))

# GoodsGain（其他 type）：item_id / item_type 逐条校验；item_name 非强制字段，不校验
goods_draws = [d for d in draws if d["reward_type"] not in CURRENCY_GAIN_TYPES | ACTIVITY_CURRENCY_TYPES]
if goods_draws:
    results.append(validator.validate_event(
        "query_goods_gain",
        account_id=account_id,
        expected_row_count=len(goods_draws),
        expected={
            "activity_id": activity_id,
            "source": "guaranteed_turntable",
            "sub_source": "lottery",
            "is_permanent": "true",
            "amount": 1,
        },
        per_row_expected=[
            {"item_id": d["reward_item_id"], "item_type": str(d["reward_type"])}
            if d["reward_type"] not in CURRENCY_GAIN_TYPES | ACTIVITY_CURRENCY_TYPES else {}
            for d in draws
        ],
        time_range=time_range,
    ))

LotteryValidator.print_batch_summary(results)

import os
os.makedirs("test_reports", exist_ok=True)
fname = f"{big_reward_name}_{module}_{datetime.now().strftime('%Y%m%d%H%M')}.html"
validator.save_draw_report_html(results, filename=fname, draws=draws, big_reward_name=big_reward_name)
```

**时间窗口**：`[第一次抽奖时间 − 10s, 最后一次抽奖时间 + 10s]`

---

## 验证规则

| 状态 | 含义 |
|------|------|
| `PASS` | 记录数与预期一致，所有确定性字段精确匹配 |
| `FAIL` | 有确定性字段不匹配 |
| `NO_DATA` | 时间窗口内无任何记录 |
| `EXTRA_ROWS` | 实际记录数 > 预期条数 |
| `MISSING_ROWS` | 实际记录数 < 预期条数 |

- **自动校验字段**：在 `expected` / `per_row_expected` 中指定的字段，精确比较（数值兼容 `5828.0 == 5828`）
- **非确定性字段**：未在 `expected` 中的字段，仅展示实际值，标记「需人工确认」
- **时间误差**：±5s，每次抽奖取最近且未被占用的1条匹配

---

## 报告输出

报告保存到 `test_reports/` 目录。

**文件命名**：`{大奖名称}_{活动配置id}_{yyyyMMddHHmm}.html`

| 方法 | 格式 | 用途 |
|------|------|------|
| `save_draw_report_html()` | HTML | 主要报告，以每次抽奖为维度，可折叠卡片 |
| `save_report_html()` | HTML | 以事件为维度的传统报告 |
| `save_report_md()` | Markdown | 轻量文本报告 |
