# callback-service 商户回调服务

## 服务定位

商户回调服务负责把扣款、派彩、冲正、注单状态变更等结果按商户配置推送到商户系统，并负责失败重试、签名、限流和回调审计。

当前第一阶段已从健康检查占位升级为可运行 Go 服务，具备标准目录、Hertz HTTP 接口、内存仓储、幂等创建、ack/fail 状态推进、重试退避和业务测试。

## 核心职责

- 消费需要推送给商户的事件。
- 按商户密钥签名。
- 按商户维度限流和重试。
- 记录每次回调请求、响应和失败原因。
- 回调失败时只重放通知，不回滚、不重复扣款、不改动订单主链路。

## 服务目录

```text
平台服务/callback-service
├── api/openapi/callback-service.openapi.yaml
├── cmd/callback-service/main.go
├── configs/config.example.yaml
└── internal
    ├── bootstrap
    ├── config
    ├── domain
    ├── handler/http
    ├── middleware
    ├── model
    ├── repository/postgres
    └── service
```

## 本地命令

```bash
cd /Users/amumu/Desktop/beifen/golang新架构/平台服务/callback-service
go test ./...
go build ./cmd/callback-service
PORT=8088 go run ./cmd/callback-service
```

## 健康检查

```bash
curl http://127.0.0.1:8088/health
curl http://127.0.0.1:8088/ready
```

## 第一阶段接口

### 创建回调任务

```bash
curl -X POST http://127.0.0.1:8088/callbacks \
  -H 'Content-Type: application/json' \
  -d '{
    "callback_id": "cb_001",
    "event_id": "wallet_debit_succeeded_001",
    "trace_id": "trace_001",
    "merchant_id": "DEMO",
    "player_id": "player_001",
    "round_id": "round_001",
    "bet_order_id": "bet_001",
    "event_type": "wallet.debit.succeeded",
    "callback_url": "https://merchant.example.com/callbacks",
    "signature": "hex-hmac-signature",
    "signature_algorithm": "HMAC-SHA256",
    "payload": {"amount": 100, "currency": "CNY"}
  }'
```

创建成功后状态为 `PENDING`，`retry_count=0`，`next_retry_at` 为首次可投递时间。重复提交相同 `callback_id`，或同一商户下相同 `event_id`，返回原任务并在任务数据中标记 `code=IDEMPOTENT_HIT`，不会生成第二条通知。

### 标记成功

```bash
curl -X POST http://127.0.0.1:8088/callbacks/cb_001/ack
```

回调收到商户 2xx 或明确成功响应后，状态变为 `SUCCEEDED`，清空 `next_retry_at`，写入 `completed_at`。

### 标记失败并重试

```bash
curl -X POST http://127.0.0.1:8088/callbacks/cb_001/fail \
  -H 'Content-Type: application/json' \
  -d '{"last_error":"merchant timeout"}'
```

每次失败递增 `retry_count`，未达到 `max_retries` 时状态为 `FAILED`，并按 `retry_delay * 2^(retry_count-1)` 计算 `next_retry_at`。达到最大重试次数后进入 `DEAD`，不再自动投递，等待人工补偿或商户侧确认。

### 查询任务

```bash
curl http://127.0.0.1:8088/callbacks/cb_001
```

返回回调任务的状态、重试次数、签名字段、最后错误、下一次重试时间和完成时间。

## 状态机

```text
PENDING -> SENDING -> SUCCEEDED
PENDING -> SENDING -> FAILED -> SENDING -> SUCCEEDED
PENDING -> SENDING -> FAILED -> ... -> DEAD
```

第一阶段 HTTP 接口已经提供 `PENDING`、`SUCCEEDED`、`FAILED`、`DEAD` 的持久状态。`SENDING` 是投递 worker 抢占任务时的中间态，后续接入真实队列/worker 后使用同一状态枚举，不改变外部契约。

## 幂等与并发设计

- `callback_id` 必须全局唯一，用于外部接口重放时返回同一任务。
- `(merchant_id, event_id)` 必须唯一，防止同一业务事件换一个 callback_id 后重复通知。
- 内存仓储使用互斥锁保护任务表和事件索引；生产落库时需要用数据库唯一索引承接同样约束。
- `ack` 和 `fail` 只更新回调任务，不触发账本扣款、派彩或订单金额变更。超时、网络失败、商户 5xx 只会重放通知 payload。
- `DEAD` 是终态，不再自动重试；人工补偿应保留原 `event_id`、`callback_id` 和签名输入，保证商户侧也能幂等处理。

## 商户回调签名

第一阶段任务模型保留：

- `signature`
- `signature_algorithm`
- `callback_url`
- `payload`

建议签名原文固定为：

```text
callback_id + "." + event_id + "." + merchant_id + "." + event_type + "." + canonical_json(payload)
```

算法使用 `HMAC-SHA256`，密钥来自商户配置服务。重试时复用同一业务 payload，并重新生成或复用签名均可，但必须把 `event_id` 作为商户侧幂等键。

## 数据库表建议

```sql
create table if not exists callback_tasks (
    callback_id varchar(64) primary key,
    event_id varchar(128) not null,
    trace_id varchar(128) not null default '',
    merchant_id varchar(64) not null,
    player_id varchar(64) not null default '',
    round_id varchar(128) not null default '',
    bet_order_id varchar(128) not null default '',
    event_type varchar(128) not null,
    callback_url text not null,
    signature text not null,
    signature_algorithm varchar(32) not null,
    payload jsonb not null,
    status varchar(16) not null,
    retry_count int not null default 0,
    max_retries int not null default 5,
    next_retry_at timestamptz,
    last_error text not null default '',
    created_at timestamptz not null,
    updated_at timestamptz not null,
    completed_at timestamptz,
    unique (merchant_id, event_id)
);

create index if not exists idx_callback_tasks_retry
    on callback_tasks (status, next_retry_at)
    where status in ('PENDING', 'FAILED');
```

## 补偿机制

1. 上游账本或订单服务只发布“结果事件”，callback-service 只负责通知商户。
2. 创建任务先落库再投递，失败后通过 `next_retry_at` 扫描补偿。
3. worker 抢占任务时应使用 `for update skip locked` 或等价机制把状态置为 `SENDING`，避免多实例重复投递。
4. 商户返回成功后调用 ack 进入 `SUCCEEDED`；超时或失败调用 fail，按退避等待下次投递。
5. 进入 `DEAD` 后由后台人工查看 `last_error`，可选择联系商户、修复 URL/密钥后重新创建补偿任务，仍以原业务 `event_id` 保障商户侧幂等。

## 禁止事项

- 不能直接修改订单主状态。
- 不能直接修改玩家余额。
- 不能吞掉失败回调，必须进入重试或人工处理。
