更新红包代码 文档 client-integration-guide.md backend-api.md

pull/3727/head
panda 1 month ago
parent 9661adcb65
commit 392943654b

@ -3,6 +3,7 @@
A Web3 Red Packet service supporting Ethereum and TRON, following the design documents:
- `backend-api.md` - API specifications
- `client-integration-guide.md` - Frontend / gateway integration guide
- `redpacket-web3-integration-design.md` - Architecture and flows
- `red-packet-go-backend-eth-tron.md` - Blockchain integration details
@ -14,9 +15,30 @@ A Web3 Red Packet service supporting Ethereum and TRON, following the design doc
- ✅ Claim signature issuance (`/api/redpacket/claim-sign`)
- ✅ Claim result reporting
- ✅ SQLite/MySQL support
- ✅ Blockchain signature logic ready for ETH/TRON
- ✅ EVM signature generation via `getSignMessage(...)`
- ✅ Basic EVM event indexing for claim/refund synchronization
- ✅ Idempotent claim/refund persistence by transaction hash
- ✅ Admin configuration endpoints
## Current Status
This service is runnable and suitable for continued iteration, but it is not yet fully production-complete.
Working well now:
- EVM-side claim signing uses the real `authNonce` in the digest
- Claim pre-checks cover packet existence, active status, expiry, and already-claimed cases
- EVM ABI and event parsing are aligned with the current contract events
- Claim and refund events can be persisted idempotently
Still incomplete:
- ETH admin endpoints are still mostly mock behavior
- `PacketCreated` indexing is not yet fully wired for automatic order reconciliation
- TRON `getSignMessage` flow is not complete
- TRON event decoding is still a scaffold
- Admin APIs still need authentication and audit controls
## Quick Start
```bash
@ -64,7 +86,7 @@ curl -X POST http://localhost:8080/api/redpacket/create-order \
│ ├── model/ # Database models (GORM)
│ ├── repository/ # Data access layer
│ ├── service/ # Business logic
│ └── chain/ # Blockchain integration (to be expanded)
│ └── chain/ # Blockchain integration and event indexing
├── pkg/resp/ # Response helpers
├── router/ # Route definitions
├── main.go
@ -72,27 +94,19 @@ curl -X POST http://localhost:8080/api/redpacket/create-order \
└── README.md
```
## Next Steps (from design docs)
1. **Full Blockchain Integration**
- Implement `ChainClient` for ETH and TRON
- Add event indexer for `PacketCreated`, `PacketClaimed`, `PacketRefunded`
- Implement proper signature generation using `getSignMessage`
## Recommended Next Steps
2. **Advanced Features**
- Admin configuration APIs (`setSigner`, `setToken`, etc.)
- Refund logic
- Rate limiting and authentication
- Monitoring and metrics
3. **Production**
- Add proper authentication middleware
- Configure production database
- Set up monitoring and logging
- Deploy with Docker/K8s
1. Implement real ETH admin transactions for signer/token/expiry configuration
2. Finish `PacketCreated` indexing and automatic order reconciliation
3. Complete TRON `getSignMessage` and reliable event decoding
4. Add authentication, audit, and rate limiting for sensitive endpoints
5. Extend end-to-end test coverage
See the three design documents for detailed specifications.
## API Documentation
See `backend-api.md` for complete API reference with examples.
See:
- `backend-api.md` for complete API reference with request / response examples
- `client-integration-guide.md` for frontend, wallet-binding, and claim-sign integration steps

@ -202,19 +202,59 @@ curl "http://127.0.0.1:8080/api/redpacket/detail?packet_id=10001"
先做业务鉴权,再发放 `claim(...)` 所需签名参数。
#### 鉴权说明
- 该接口不再信任请求体中的 `user_id`
- 当前领取用户从 RPC / 网关注入的登录上下文中获取
- 服务端要求请求上下文里存在 `opUserID`
- 如果缺少登录上下文,接口会直接拒绝
#### 请求头
- `token`: 用户登录 token
> 约定:上游网关或鉴权中间件需要先解析 token并把当前登录用户写入请求上下文中的 `opUserID`
#### 请求体
```json
{
"packet_id": "10001",
"claimer": "0x3333333333333333333333333333333333333333",
"user_id": "u2002",
"random_seed": "0"
}
```
> `random_seed` 可选;传 `0` 或空时后端自动生成。
#### 字段说明
- `packet_id`: 红包链上 ID
- `claimer`: 本次真正发起链上 `claim(...)` 的钱包地址
- `random_seed`: 可选随机种子;空或 `0` 时后端自动生成
#### 服务端处理逻辑
1. 从请求上下文提取当前登录用户 ID
2. 校验红包是否存在、是否过期、是否仍可领取
3. 校验当前登录用户与 `claimer` 钱包地址的绑定关系
4. 校验当前用户在该红包下是否已领取
5. 校验当前钱包在该红包下是否已领取
6. 按红包类型校验群资格 / 指定接收人资格
7. 生成 `auth_nonce`、`deadline`、`random_seed`
8. 调合约 `getSignMessage(packetId, claimer, authNonce, randomSeed, deadline)` 获取摘要
9. 使用后端 `signer` 私钥对摘要裸签名
10. 落库 `red_packet_claim_auth`
11. 返回前端发链所需参数
#### 成功后前端下一步
前端拿到响应后,直接调用链上:
```text
claim(packetId, authNonce, randomSeed, deadline, signature)
```
#### 成功响应
```json
@ -241,6 +281,33 @@ curl "http://127.0.0.1:8080/api/redpacket/detail?packet_id=10001"
}
```
同一用户已领取:
```json
{
"code": 403,
"message": "user already claimed"
}
```
钱包未绑定:
```json
{
"code": 403,
"message": "wallet is not bound to user"
}
```
缺少登录上下文:
```json
{
"code": 403,
"message": "op user id missing in context"
}
```
签名服务异常:
```json
@ -258,17 +325,44 @@ curl "http://127.0.0.1:8080/api/redpacket/detail?packet_id=10001"
前端在领取交易提交后可调用该接口预写记录。最终状态仍以链监听indexer为准。
#### 鉴权说明
- 该接口不再接收可信 `user_id`
- 当前用户从 RPC / 网关注入的登录上下文中获取
- 服务端要求请求上下文里存在 `opUserID`
#### 请求头
- `token`: 用户登录 token
#### 请求体
```json
{
"packet_id": "10001",
"claimer_wallet": "0x3333333333333333333333333333333333333333",
"tx_hash": "0xdef456...",
"auth_nonce": "328840239847239847"
"claimer": "0x3333333333333333333333333333333333333333",
"tx_hash": "0xdef456..."
}
```
#### 字段说明
- `packet_id`: 红包链上 ID
- `claimer`: 发起链上领取的钱包地址
- `tx_hash`: 领取交易哈希
#### 服务端处理逻辑
1. 从请求上下文提取当前登录用户 ID
2. 先落一条 `PENDING` 领取记录
3. 如果当前节点能立即解析该交易 receipt则补全
- `auth_nonce`
- `claimed_amount`
- `block_number`
- `status=CONFIRMED`
4. 如果当前节点暂时拿不到 receipt则保持 `PENDING`
5. 最终仍以链监听器写入结果为准
#### 成功响应
```json
@ -285,8 +379,130 @@ curl "http://127.0.0.1:8080/api/redpacket/detail?packet_id=10001"
```json
{
"code": 400,
"message": "packet_id and tx_hash are required"
"code": 403,
"message": "op user id missing in context"
}
```
---
## 6) 钱包绑定挑战
### POST `/api/redpacket/wallet-bind/challenge`
生成钱包绑定挑战消息,前端拿到消息后调用钱包签名。
#### 鉴权说明
- 该接口不再信任请求体中的 `user_id`
- 当前用户从 RPC / 网关注入的登录上下文中获取
#### 请求头
- `token`: 用户登录 token
#### 请求体
```json
{
"chain_type": "EVM",
"chain_id": 1,
"wallet_address": "0x3333333333333333333333333333333333333333",
"domain": "redpacket.example.com",
"uri": "https://redpacket.example.com/wallet-bind"
}
```
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"challenge_id": "1f7d9b0d-7b43-4d84-bb11-65f2ecf7e321",
"user_id": "u2002",
"chain_type": "EVM",
"chain_id": 1,
"wallet": "0x3333333333333333333333333333333333333333",
"protocol": "siwe-eip4361",
"sign_method": "personal_sign",
"nonce": "7b7d8d48-9db6-4e95-9daa-40e9517a2a85",
"message": "redpacket.example.com wants you to sign in with your Ethereum account:\n0x3333333333333333333333333333333333333333\n\nBind wallet 0x3333333333333333333333333333333333333333 to user u2002.\nURI: https://redpacket.example.com/wallet-bind\nVersion: 1\nChain ID: 1\nNonce: 7b7d8d48-9db6-4e95-9daa-40e9517a2a85\nIssued At: 2026-04-30T03:00:00Z\nExpiration Time: 2026-04-30T03:10:00Z\nRequest ID: 1f7d9b0d-7b43-4d84-bb11-65f2ecf7e321",
"issued_at": "2026-04-30T03:00:00Z",
"expires_at": "2026-04-30T03:10:00Z"
}
}
```
#### 前端下一步
前端收到响应后:
1. 使用 `sign_method` 指定的钱包方法对 `message` 进行签名
2. 把 `challenge_id + signature` 提交给 `/api/redpacket/wallet-bind/confirm`
---
## 7) 钱包绑定确认
### POST `/api/redpacket/wallet-bind/confirm`
提交钱包签名,服务端验签成功后建立钱包绑定关系。
#### 请求体
```json
{
"challenge_id": "1f7d9b0d-7b43-4d84-bb11-65f2ecf7e321",
"signature": "0x8f..."
}
```
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"user_id": "u2002",
"chain_type": "EVM",
"chain_id": 1,
"wallet_address": "0x3333333333333333333333333333333333333333",
"status": "ACTIVE",
"verified_at": "2026-04-30T03:01:00Z"
}
}
```
---
## 8) 查询钱包绑定
### GET `/api/redpacket/wallet-bind/detail?chain_type={chainType}&wallet_address={walletAddress}`
查询当前登录用户与指定钱包地址的绑定详情。
#### 鉴权说明
- `user_id` 从登录上下文中获取,不需要也不应该由前端传入
#### 成功响应
```json
{
"code": 0,
"message": "ok",
"data": {
"user_id": "u2002",
"chain_type": "EVM",
"chain_id": 1,
"wallet_address": "0x3333333333333333333333333333333333333333",
"status": "ACTIVE",
"challenge_id": "1f7d9b0d-7b43-4d84-bb11-65f2ecf7e321",
"verified_at": "2026-04-30T03:01:00Z"
}
}
```
@ -503,4 +719,3 @@ TRON 未配置:
6. 钱包调用合约 `claim(...)`
7. 可选:`POST /api/redpacket/claim-result`
8. 详情页查询:`GET /api/redpacket/detail?packet_id=...`

@ -0,0 +1,271 @@
# RedPacket 前端对接文档
本文档面向前端 / 网关 / App 对接方,说明红包领取和钱包绑定的真实接入方式,重点覆盖:
- 如何把当前登录用户传递给红包服务
- 如何绑定钱包
- 如何申请领取签名
- 前端何时发链、何时回写后端
## 1. 总体原则
红包服务已经切换为 RPC 上下文取当前用户 ID
- 前端不再把 `user_id` 当作可信业务参数传给红包服务
- 红包服务从请求上下文里的 `opUserID` 获取当前登录用户
- 上下文通常由网关或鉴权中间件根据 `token` 解析后注入
这意味着对接时必须满足一个前提:
- 请求进入红包服务前,网关已经完成 token 解析
- 并且把当前登录用户写入上下文中的 `opUserID`
如果没有这一层,红包服务会返回:
```json
{
"code": 403,
"message": "op user id missing in context"
}
```
## 2. 钱包绑定流程
### 2.1 流程图
```text
前端 -> 红包服务: POST /api/redpacket/wallet-bind/challenge
红包服务 -> 前端: challenge_id + message + sign_method
前端 -> 钱包: 对 message 签名
前端 -> 红包服务: POST /api/redpacket/wallet-bind/confirm
红包服务 -> 前端: 绑定成功
```
### 2.2 发起挑战
请求:
```http
POST /api/redpacket/wallet-bind/challenge
token: <user token>
Content-Type: application/json
```
```json
{
"chain_type": "EVM",
"chain_id": 1,
"wallet_address": "0x3333333333333333333333333333333333333333",
"domain": "redpacket.example.com",
"uri": "https://redpacket.example.com/wallet-bind"
}
```
返回里最关键的是:
- `challenge_id`
- `message`
- `sign_method`
前端要做的是:
- 按 `sign_method` 调钱包签名
- 当前 EVM 实现使用的是 `personal_sign`
### 2.3 确认绑定
请求:
```http
POST /api/redpacket/wallet-bind/confirm
token: <user token>
Content-Type: application/json
```
```json
{
"challenge_id": "1f7d9b0d-7b43-4d84-bb11-65f2ecf7e321",
"signature": "0x8f..."
}
```
成功后代表:
- 当前登录用户
- 当前链类型
- 当前钱包地址
已经在后端建立了有效绑定关系。
## 3. 领取签名流程
### 3.1 流程图
```text
前端 -> 红包服务: POST /api/redpacket/claim-sign
红包服务 -> 红包服务: 校验当前用户、钱包绑定、领取资格
红包服务 -> 合约: getSignMessage(packetId, claimer, authNonce, randomSeed, deadline)
红包服务 -> 前端: auth_nonce + random_seed + deadline + signature
前端 -> 钱包/链上: claim(packetId, authNonce, randomSeed, deadline, signature)
前端 -> 红包服务: POST /api/redpacket/claim-result (可选)
链监听器 -> 红包服务: 最终确认领取结果
```
### 3.2 申请领取签名
请求:
```http
POST /api/redpacket/claim-sign
token: <user token>
Content-Type: application/json
```
```json
{
"packet_id": "10001",
"claimer": "0x3333333333333333333333333333333333333333",
"random_seed": "0"
}
```
说明:
- `claimer` 必须是这次真正发链的地址
- `random_seed` 可省略或传 `0`
- 不需要传 `user_id`
后端会自动完成这些校验:
1. 当前登录用户存在
2. 红包存在且仍可领取
3. 当前登录用户与 `claimer` 已绑定
4. 当前用户在该红包下未领取过
5. 当前钱包在该红包下未领取过
6. 群红包 / 转账红包的附加业务限制通过
成功响应:
```json
{
"code": 0,
"message": "ok",
"data": {
"auth_nonce": "328840239847239847",
"deadline": 1777012345,
"signature": "0x7b1e...a2",
"random_seed": "8888812345"
}
}
```
### 3.3 前端拿到响应后要做什么
前端必须原样把这些参数传给链上:
```text
claim(packetId, authNonce, randomSeed, deadline, signature)
```
对应关系:
- `packetId` -> 前端当前红包 ID
- `authNonce` -> 响应里的 `auth_nonce`
- `randomSeed` -> 响应里的 `random_seed`
- `deadline` -> 响应里的 `deadline`
- `signature` -> 响应里的 `signature`
注意:
- 不要自己改 `auth_nonce`
- 不要重新算摘要
- 不要对摘要再次做 `signMessage`
- 后端返回的 `signature` 已经是最终可上链签名
## 4. 领取结果回写
`claim-result` 是可选的,主要作用是让业务侧尽快看到一条 `PENDING` 领取记录。
请求:
```http
POST /api/redpacket/claim-result
token: <user token>
Content-Type: application/json
```
```json
{
"packet_id": "10001",
"claimer": "0x3333333333333333333333333333333333333333",
"tx_hash": "0xdef456..."
}
```
说明:
- 不需要传 `user_id`
- 当前登录用户仍然从上下文中取
- 如果后端当前能立刻解析 receipt会把记录补成 `CONFIRMED`
- 如果不能,会先记成 `PENDING`
- 最终仍以链监听器为准
## 5. 前端推荐调用顺序
### 5.1 首次使用钱包领取
1. 用户登录业务系统
2. 前端请求 `/wallet-bind/challenge`
3. 钱包对 `message` 签名
4. 前端请求 `/wallet-bind/confirm`
5. 绑定成功后再进入领取流程
### 5.2 正常领取
1. 前端拿到红包 `packet_id`
2. 用户连接钱包,得到本次 `claimer` 地址
3. 前端请求 `/claim-sign`
4. 拿到 `auth_nonce + random_seed + deadline + signature`
5. 前端调用链上 `claim(...)`
6. 前端可选请求 `/claim-result`
7. 页面轮询详情页或等待业务侧状态同步
## 6. 常见错误和排查
### 6.1 `op user id missing in context`
原因:
- 网关没有解析 token
- 网关没有把 `opUserID` 注入上下文
- 直接绕过网关调用了红包服务
### 6.2 `wallet is not bound to user`
原因:
- 当前钱包还没绑定
- 当前钱包绑定的是别的业务用户
- 链类型不一致
### 6.3 `already claimed`
原因:
- 同一个钱包地址已经领过该红包
### 6.4 `user already claimed`
原因:
- 同一个业务用户已经领取过该红包
- 即使换钱包地址,也会被后端拦截
## 7. 后端接口与代码位置
- 接口契约文档:
[backend-api.md](/Users/panda/aiCode/red_packet/open-im-server-origin/cmd/openim-rpc/openim-rpc-redpacket/backend-api.md)
- 领取签名核心逻辑:
[redpacket.go](/Users/panda/aiCode/red_packet/open-im-server-origin/cmd/openim-rpc/openim-rpc-redpacket/internal/service/redpacket.go)
- 用户上下文提取:
[user.go](/Users/panda/aiCode/red_packet/open-im-server-origin/cmd/openim-rpc/openim-rpc-redpacket/internal/authctx/user.go)

@ -17,7 +17,7 @@ tron:
contract_base58: ""
owner_base58: ""
private_key_hex: ""
fee_limit: 100000000
fee_limit: 10000000000
indexer:
poll_interval: 5

@ -0,0 +1,49 @@
package authctx
import (
"context"
"fmt"
"strings"
"github.com/gin-gonic/gin"
)
const opUserIDKey = "opUserID"
type userIDContextKey struct{}
func WithCurrentUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, userIDContextKey{}, strings.TrimSpace(userID))
}
func CurrentUserID(ctx context.Context) (string, error) {
if ctx == nil {
return "", fmt.Errorf("request context is nil")
}
if userID, ok := ctx.Value(userIDContextKey{}).(string); ok && strings.TrimSpace(userID) != "" {
return strings.TrimSpace(userID), nil
}
if userID, ok := ctx.Value(opUserIDKey).(string); ok && strings.TrimSpace(userID) != "" {
return strings.TrimSpace(userID), nil
}
return "", fmt.Errorf("op user id missing in context")
}
func BindCurrentUserID(c *gin.Context) error {
if c == nil {
return fmt.Errorf("gin context is nil")
}
userID := strings.TrimSpace(c.GetString(opUserIDKey))
if userID == "" {
if value := c.Request.Context().Value(opUserIDKey); value != nil {
if fromCtx, ok := value.(string); ok {
userID = strings.TrimSpace(fromCtx)
}
}
}
if userID == "" {
return fmt.Errorf("op user id missing in context")
}
c.Request = c.Request.WithContext(WithCurrentUserID(c.Request.Context(), userID))
return nil
}

@ -4,7 +4,7 @@
"inputs": [
{ "indexed": true, "name": "packetId", "type": "uint256" },
{ "indexed": true, "name": "creator", "type": "address" },
{ "indexed": false, "name": "packetType", "type": "uint8" },
{ "indexed": true, "name": "packetType", "type": "uint8" },
{ "indexed": false, "name": "token", "type": "address" },
{ "indexed": false, "name": "totalAmount", "type": "uint256" },
{ "indexed": false, "name": "totalShares", "type": "uint256" },
@ -19,9 +19,9 @@
{ "indexed": true, "name": "packetId", "type": "uint256" },
{ "indexed": true, "name": "claimer", "type": "address" },
{ "indexed": false, "name": "amount", "type": "uint256" },
{ "indexed": false, "name": "authNonce", "type": "uint256" },
{ "indexed": false, "name": "randomSeed", "type": "uint256" },
{ "indexed": false, "name": "blockNumber", "type": "uint256" }
{ "indexed": false, "name": "remainingAmount", "type": "uint256" },
{ "indexed": false, "name": "remainingShares", "type": "uint256" },
{ "indexed": false, "name": "authNonce", "type": "uint256" }
],
"name": "PacketClaimed",
"type": "event"
@ -30,6 +30,7 @@
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "packetId", "type": "uint256" },
{ "indexed": true, "name": "operator", "type": "address" },
{ "indexed": true, "name": "refundTo", "type": "address" },
{ "indexed": false, "name": "amount", "type": "uint256" }
],
@ -62,4 +63,4 @@
"stateMutability": "nonpayable",
"type": "function"
}
]
]

@ -3,6 +3,7 @@ package chain
import (
"context"
"crypto/ecdsa"
_ "embed"
"fmt"
"math/big"
"strings"
@ -14,14 +15,17 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
)
//go:embed abi/RedPacket.json
var embeddedABI []byte
// ChainClient handles blockchain interactions for RedPacket
type ChainClient struct {
client *ethclient.Client
contractABI abi.ABI
contractAddr common.Address
signerKey *ecdsa.PrivateKey
client *ethclient.Client
contractABI abi.ABI
contractAddr common.Address
signerKey *ecdsa.PrivateKey
configAdminKey *ecdsa.PrivateKey
chainID *big.Int
chainID *big.Int
}
// NewClient creates a new ChainClient
@ -122,6 +126,17 @@ func (c *ChainClient) ParseTransactionReceipt(ctx context.Context, txHash common
return ParseEventsFromLogs(receipt.Logs, c.contractABI)
}
func (c *ChainClient) ContractAddress() common.Address {
return c.contractAddr
}
func (c *ChainClient) ChainID() *big.Int {
if c.chainID == nil {
return nil
}
return new(big.Int).Set(c.chainID)
}
// Close closes the client connection
func (c *ChainClient) Close() {
if c.client != nil {
@ -131,13 +146,8 @@ func (c *ChainClient) Close() {
// ExtractABIFromEmbeddedArtifact returns the embedded contract ABI
func ExtractABIFromEmbeddedArtifact() ([]byte, error) {
// In production, this would be embedded with go:embed
// For now, we return a simple version. In real implementation, use:
// var abiJSON embed.FS
// data, _ := abiJSON.ReadFile("abi/RedPacket.json")
return []byte(`[
{"anonymous":false,"inputs":[{"indexed":true,"name":"packetId","type":"uint256"},{"indexed":true,"name":"creator","type":"address"},{"indexed":false,"name":"packetType","type":"uint8"},{"indexed":false,"name":"token","type":"address"},{"indexed":false,"name":"totalAmount","type":"uint256"},{"indexed":false,"name":"totalShares","type":"uint256"},{"indexed":false,"name":"expiryAt","type":"uint256"}],"name":"PacketCreated","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"name":"packetId","type":"uint256"},{"indexed":true,"name":"claimer","type":"address"},{"indexed":false,"name":"amount","type":"uint256"},{"indexed":false,"name":"authNonce","type":"uint256"},{"indexed":false,"name":"randomSeed","type":"uint256"},{"indexed":false,"name":"blockNumber","type":"uint256"}],"name":"PacketClaimed","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"name":"packetId","type":"uint256"},{"indexed":true,"name":"refundTo","type":"address"},{"indexed":false,"name":"amount","type":"uint256"}],"name":"PacketRefunded","type":"event"}
]`), nil
if len(embeddedABI) == 0 {
return nil, fmt.Errorf("embedded ABI is empty")
}
return embeddedABI, nil
}

@ -17,10 +17,10 @@ import (
// Indexer listens to blockchain events and updates database
type Indexer struct {
client *ChainClient
repo repository.Repository
client *ChainClient
repo repository.Repository
pollInterval time.Duration
lastBlock uint64
lastBlock uint64
contractAddr common.Address
}
@ -124,7 +124,7 @@ func (i *Indexer) processEvent(ctx context.Context, event *ParsedEvent, logs []*
func (i *Indexer) handlePacketCreated(ctx context.Context, event *ParsedEvent) error {
packetID := GetPacketIDFromEvent(event)
creator := GetClaimerFromEvent(event) // creator is indexed as second topic
creator := GetAddressFromEvent(event, "creator")
log.Printf("📦 PacketCreated: packetId=%s, creator=%s", packetID.String(), creator.Hex())
@ -135,29 +135,53 @@ func (i *Indexer) handlePacketCreated(ctx context.Context, event *ParsedEvent) e
func (i *Indexer) handlePacketClaimed(ctx context.Context, event *ParsedEvent) error {
packetID := GetPacketIDFromEvent(event)
claimer := GetClaimerFromEvent(event)
claimer := GetAddressFromEvent(event, "claimer")
amount := GetAmountFromEvent(event)
authNonce := GetUintFromEvent(event, "authNonce")
log.Printf("🎁 PacketClaimed: packetId=%s, claimer=%s, amount=%s",
log.Printf("🎁 PacketClaimed: packetId=%s, claimer=%s, amount=%s",
packetID.String(), claimer.Hex(), amount.String())
// Create claim record
claim := &model.RedPacketClaim{
PacketID: packetID.String(),
ClaimerWallet: claimer.Hex(),
AuthNonce: authNonce.String(),
ClaimTxHash: event.TxHash.Hex(),
ClaimedAmount: amount.String(),
BlockNumber: event.BlockNumber,
Status: "CONFIRMED",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return i.repo.CreateClaim(ctx, claim)
if err := i.repo.SaveClaim(ctx, claim); err != nil {
return err
}
if err := i.repo.MarkClaimAuthUsed(ctx, authNonce.String()); err != nil {
return err
}
return i.repo.UpdateRedPacketClaimProgress(ctx, packetID.String(), amount.String(), "")
}
func (i *Indexer) handlePacketRefunded(ctx context.Context, event *ParsedEvent) error {
packetID := GetPacketIDFromEvent(event)
refundTo := GetClaimerFromEvent(event) // refundTo is indexed
operator := GetAddressFromEvent(event, "operator")
refundTo := GetAddressFromEvent(event, "refundTo")
amount := GetAmountFromEvent(event)
log.Printf("♻️ PacketRefunded: packetId=%s, refundTo=%s", packetID.String(), refundTo.Hex())
log.Printf("♻️ PacketRefunded: packetId=%s, operator=%s, refundTo=%s, amount=%s",
packetID.String(), operator.Hex(), refundTo.Hex(), amount.String())
// TODO: Update packet status to REFUNDED
return nil
if err := i.repo.SaveRefund(ctx, &model.RedPacketRefund{
PacketID: packetID.String(),
RefundTo: refundTo.Hex(),
TxHash: event.TxHash.Hex(),
Amount: amount.String(),
CreatedAt: time.Now(),
}); err != nil {
return err
}
return i.repo.UpdateRedPacketStatus(ctx, packetID.String(), "REFUNDED")
}

@ -11,8 +11,10 @@ import (
// ParsedEvent represents a parsed blockchain event
type ParsedEvent struct {
Name string
Data map[string]interface{}
Name string
Data map[string]interface{}
TxHash common.Hash
BlockNumber uint64
}
// ParseEventsFromLogs parses logs using the contract ABI
@ -75,8 +77,10 @@ func parseEvent(log *types.Log, contractABI abi.ABI) (*ParsedEvent, error) {
}
return &ParsedEvent{
Name: name,
Data: data,
Name: name,
Data: data,
TxHash: log.TxHash,
BlockNumber: log.BlockNumber,
}, nil
}
@ -94,21 +98,28 @@ func GetPacketIDFromEvent(event *ParsedEvent) *big.Int {
}
// GetClaimerFromEvent extracts claimer address from event
func GetClaimerFromEvent(event *ParsedEvent) common.Address {
if claimer, ok := event.Data["claimer"]; ok {
if addr, ok := claimer.(common.Address); ok {
return addr
}
func GetAddressFromEvent(event *ParsedEvent, key string) common.Address {
value, ok := event.Data[key]
if !ok {
return common.Address{}
}
return common.Address{}
addr, _ := value.(common.Address)
return addr
}
// GetAmountFromEvent extracts amount from event
func GetAmountFromEvent(event *ParsedEvent) *big.Int {
if amount, ok := event.Data["amount"]; ok {
if b, ok := amount.(*big.Int); ok {
return b
}
return GetUintFromEvent(event, "amount")
}
// GetUintFromEvent extracts a uint field from event data.
func GetUintFromEvent(event *ParsedEvent, key string) *big.Int {
value, ok := event.Data[key]
if !ok {
return big.NewInt(0)
}
if b, ok := value.(*big.Int); ok {
return b
}
return big.NewInt(0)
}

@ -0,0 +1,78 @@
package chain
import (
"math/big"
"strings"
"testing"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
func TestParseEventsFromLogs_ParsesRefundEvent(t *testing.T) {
abiJSON, err := ExtractABIFromEmbeddedArtifact()
if err != nil {
t.Fatalf("ExtractABIFromEmbeddedArtifact() error = %v", err)
}
parsedABI, err := abi.JSON(strings.NewReader(string(abiJSON)))
if err != nil {
t.Fatalf("abi.JSON() error = %v", err)
}
eventDef := parsedABI.Events["PacketRefunded"]
packetID := big.NewInt(101)
operator := common.HexToAddress("0x1111111111111111111111111111111111111111")
refundTo := common.HexToAddress("0x2222222222222222222222222222222222222222")
amount := big.NewInt(8888)
data, err := eventDef.Inputs.NonIndexed().Pack(amount)
if err != nil {
t.Fatalf("Pack() error = %v", err)
}
log := &types.Log{
Address: common.HexToAddress("0x3333333333333333333333333333333333333333"),
Topics: []common.Hash{
eventDef.ID,
common.BigToHash(packetID),
common.BytesToHash(common.LeftPadBytes(operator.Bytes(), 32)),
common.BytesToHash(common.LeftPadBytes(refundTo.Bytes(), 32)),
},
Data: data,
BlockNumber: 77,
TxHash: common.HexToHash("0xabc"),
}
events, err := ParseEventsFromLogs([]*types.Log{log}, parsedABI)
if err != nil {
t.Fatalf("ParseEventsFromLogs() error = %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
event := events[0]
if event.Name != "PacketRefunded" {
t.Fatalf("unexpected event name: %s", event.Name)
}
if got := GetPacketIDFromEvent(event).String(); got != "101" {
t.Fatalf("packet id mismatch: got %s", got)
}
if got := GetAddressFromEvent(event, "operator").Hex(); got != operator.Hex() {
t.Fatalf("operator mismatch: got %s want %s", got, operator.Hex())
}
if got := GetAddressFromEvent(event, "refundTo").Hex(); got != refundTo.Hex() {
t.Fatalf("refundTo mismatch: got %s want %s", got, refundTo.Hex())
}
if got := GetAmountFromEvent(event).String(); got != "8888" {
t.Fatalf("amount mismatch: got %s", got)
}
if event.BlockNumber != 77 {
t.Fatalf("block number mismatch: got %d", event.BlockNumber)
}
if event.TxHash != common.HexToHash("0xabc") {
t.Fatalf("tx hash mismatch: got %s", event.TxHash.Hex())
}
}

@ -13,17 +13,18 @@ import (
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
// TronClient handles TRON blockchain interactions using HTTP JSON-RPC
type TronClient struct {
fullNodeURL string
fullNodeURL string
contractBase58 string
ownerBase58 string
privateKeyHex string
feeLimit int64
abiJSON string
parsedABI abi.ABI
ownerBase58 string
privateKeyHex string
feeLimit int64
abiJSON string
parsedABI abi.ABI
}
// NewTronClient creates a new TRON client
@ -48,6 +49,24 @@ func NewTronClient(fullNodeURL, contractBase58, ownerBase58, privateKeyHex strin
}, nil
}
func (t *TronClient) ContractAddress() string {
return t.contractBase58
}
func (t *TronClient) ParseTransactionReceipt(ctx context.Context, txID string) ([]*ParsedEvent, error) {
info, err := t.getTransactionInfo(ctx, txID)
if err != nil {
return nil, err
}
logs, err := tronLogsToEVMLogs(info, txID)
if err != nil {
return nil, err
}
return ParseEventsFromLogs(logs, t.parsedABI)
}
// SendAdminTransaction sends an admin transaction on TRON (setSigner, setToken, etc.)
func (t *TronClient) SendAdminTransaction(ctx context.Context, methodName string, args ...interface{}) (string, error) {
if t.privateKeyHex == "" || t.ownerBase58 == "" {
@ -86,6 +105,16 @@ func (t *TronClient) GetSignMessageForTron(ctx context.Context, packetID *big.In
return "", fmt.Errorf("TRON getSignMessage not fully implemented yet - use ETH path for signing")
}
type tronTxInfoResp struct {
ID string `json:"id"`
BlockNumber uint64 `json:"blockNumber"`
Log []struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
} `json:"log"`
}
// Helper functions
func getParamTypes(args []interface{}) string {
@ -165,6 +194,62 @@ func SendTronAdminTx(
return txid, nil
}
func (t *TronClient) getTransactionInfo(ctx context.Context, txID string) (*tronTxInfoResp, error) {
var info tronTxInfoResp
if err := postJSON(ctx, t.fullNodeURL+"/wallet/gettransactioninfobyid", map[string]interface{}{
"value": txID,
}, &info); err != nil {
return nil, err
}
return &info, nil
}
func tronLogsToEVMLogs(info *tronTxInfoResp, txID string) ([]*types.Log, error) {
if info == nil {
return nil, fmt.Errorf("tron tx info is nil")
}
txHash := common.HexToHash(addHexPrefix(txID))
logs := make([]*types.Log, 0, len(info.Log))
for _, entry := range info.Log {
topics := make([]common.Hash, 0, len(entry.Topics))
for _, topic := range entry.Topics {
topics = append(topics, common.HexToHash(addHexPrefix(topic)))
}
data, err := hex.DecodeString(strings.TrimPrefix(entry.Data, "0x"))
if err != nil {
return nil, fmt.Errorf("decode tron log data failed: %w", err)
}
logs = append(logs, &types.Log{
Address: tronLogAddressToCommonAddress(entry.Address),
Topics: topics,
Data: data,
BlockNumber: info.BlockNumber,
TxHash: txHash,
})
}
return logs, nil
}
func tronLogAddressToCommonAddress(raw string) common.Address {
raw = strings.TrimPrefix(raw, "0x")
raw = strings.TrimPrefix(raw, "41")
if len(raw) > 40 {
raw = raw[len(raw)-40:]
}
return common.HexToAddress(addHexPrefix(raw))
}
func addHexPrefix(value string) string {
if strings.HasPrefix(value, "0x") || strings.HasPrefix(value, "0X") {
return value
}
return "0x" + value
}
func encodeTronParams(abiJSON, method string, args ...interface{}) (string, error) {
parsed, err := abi.JSON(strings.NewReader(abiJSON))
if err != nil {

@ -15,9 +15,9 @@ type TronIndexer struct {
client *TronClient
repo repository.Repository
pollInterval time.Duration
lastBlockNum int64 // TRON uses block numbers
lastBlockNum int64 // TRON uses block numbers
contractAddress string
processedTxs map[string]bool // Simple dedup for this session
processedTxs map[string]bool // Simple dedup for this session
}
// NewTronIndexer creates a new TRON event indexer
@ -223,7 +223,7 @@ func (t *TronIndexer) handleTronPacketClaimed(ctx context.Context, logData map[s
Status: "CONFIRMED",
}
if err := t.repo.CreateClaim(ctx, claim); err != nil {
if err := t.repo.SaveClaim(ctx, claim); err != nil {
log.Printf("Failed to save TRON claim: %v", err)
}
}

@ -0,0 +1,90 @@
package chain
import (
"math/big"
"strings"
"testing"
"github.com/ethereum/go-ethereum/accounts/abi"
"github.com/ethereum/go-ethereum/common"
)
func TestTronLogsToEVMLogsAndParsePacketCreated(t *testing.T) {
abiJSON, err := ExtractABIFromEmbeddedArtifact()
if err != nil {
t.Fatalf("ExtractABIFromEmbeddedArtifact() error = %v", err)
}
parsedABI, err := abi.JSON(strings.NewReader(string(abiJSON)))
if err != nil {
t.Fatalf("abi.JSON() error = %v", err)
}
eventDef := parsedABI.Events["PacketCreated"]
packetID := big.NewInt(12)
creator := common.HexToAddress("0x1111111111111111111111111111111111111111")
packetType := big.NewInt(1)
token := common.HexToAddress("0x2222222222222222222222222222222222222222")
totalAmount := big.NewInt(1000)
totalShares := big.NewInt(10)
expiryAt := big.NewInt(1234567890)
data, err := eventDef.Inputs.NonIndexed().Pack(token, totalAmount, totalShares, expiryAt)
if err != nil {
t.Fatalf("Pack() error = %v", err)
}
info := &tronTxInfoResp{
ID: "abc123",
BlockNumber: 88,
Log: []struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
}{
{
Address: "41aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
Topics: []string{
strings.TrimPrefix(eventDef.ID.Hex(), "0x"),
strings.TrimPrefix(common.BigToHash(packetID).Hex(), "0x"),
strings.TrimPrefix(common.BytesToHash(common.LeftPadBytes(creator.Bytes(), 32)).Hex(), "0x"),
strings.TrimPrefix(common.BigToHash(packetType).Hex(), "0x"),
},
Data: common.Bytes2Hex(data),
},
},
}
logs, err := tronLogsToEVMLogs(info, info.ID)
if err != nil {
t.Fatalf("tronLogsToEVMLogs() error = %v", err)
}
events, err := ParseEventsFromLogs(logs, parsedABI)
if err != nil {
t.Fatalf("ParseEventsFromLogs() error = %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
event := events[0]
if event.Name != "PacketCreated" {
t.Fatalf("unexpected event name: %s", event.Name)
}
if got := GetPacketIDFromEvent(event).String(); got != "12" {
t.Fatalf("packet id mismatch: got %s", got)
}
if got := GetAddressFromEvent(event, "creator").Hex(); got != creator.Hex() {
t.Fatalf("creator mismatch: got %s want %s", got, creator.Hex())
}
if got := GetUintFromEvent(event, "packetType").String(); got != "1" {
t.Fatalf("packetType mismatch: got %s", got)
}
if got := GetAddressFromEvent(event, "token").Hex(); got != token.Hex() {
t.Fatalf("token mismatch: got %s want %s", got, token.Hex())
}
if event.BlockNumber != 88 {
t.Fatalf("block number mismatch: got %d", event.BlockNumber)
}
}

@ -3,6 +3,7 @@ package handler
import (
"net/http"
"redpacket/internal/authctx"
"redpacket/internal/service"
"redpacket/pkg/resp"
@ -18,6 +19,11 @@ func NewRedPacketHandler(rpSvc *service.RedPacketService) *RedPacketHandler {
}
func (h *RedPacketHandler) CreateOrder(c *gin.Context) {
if err := authctx.BindCurrentUserID(c); err != nil {
resp.Forbidden(c, err.Error())
return
}
var req service.CreateOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
@ -65,10 +71,14 @@ func (h *RedPacketHandler) Detail(c *gin.Context) {
}
func (h *RedPacketHandler) ClaimSign(c *gin.Context) {
if err := authctx.BindCurrentUserID(c); err != nil {
resp.Forbidden(c, err.Error())
return
}
var req struct {
PacketID string `json:"packet_id" binding:"required"`
Claimer string `json:"claimer" binding:"required"`
UserID string `json:"user_id" binding:"required"`
RandomSeed string `json:"random_seed"`
}
@ -77,12 +87,7 @@ func (h *RedPacketHandler) ClaimSign(c *gin.Context) {
return
}
if err := h.rpSvc.CanClaim(c.Request.Context(), req.PacketID, req.Claimer, req.UserID); err != nil {
resp.Forbidden(c, err.Error())
return
}
result, err := h.rpSvc.IssueClaimSign(c.Request.Context(), req.PacketID, req.Claimer, req.UserID, req.RandomSeed)
result, err := h.rpSvc.IssueClaimSign(c.Request.Context(), req.PacketID, req.Claimer, req.RandomSeed)
if err != nil {
resp.InternalError(c, "failed to issue claim signature: "+err.Error())
return
@ -92,6 +97,11 @@ func (h *RedPacketHandler) ClaimSign(c *gin.Context) {
}
func (h *RedPacketHandler) ClaimResult(c *gin.Context) {
if err := authctx.BindCurrentUserID(c); err != nil {
resp.Forbidden(c, err.Error())
return
}
var req service.ClaimResultRequest
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
@ -105,3 +115,62 @@ func (h *RedPacketHandler) ClaimResult(c *gin.Context) {
resp.OK(c, gin.H{"ok": true})
}
func (h *RedPacketHandler) WalletBindChallenge(c *gin.Context) {
if err := authctx.BindCurrentUserID(c); err != nil {
resp.Forbidden(c, err.Error())
return
}
var req service.WalletBindChallengeRequest
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
result, err := h.rpSvc.IssueWalletBindChallenge(c.Request.Context(), &req)
if err != nil {
resp.Fail(c, http.StatusBadRequest, 400, err.Error())
return
}
resp.OK(c, result)
}
func (h *RedPacketHandler) WalletBindConfirm(c *gin.Context) {
var req service.WalletBindConfirmRequest
if err := c.ShouldBindJSON(&req); err != nil {
resp.BadRequest(c, "invalid request body: "+err.Error())
return
}
result, err := h.rpSvc.ConfirmWalletBind(c.Request.Context(), &req)
if err != nil {
resp.Fail(c, http.StatusBadRequest, 400, err.Error())
return
}
resp.OK(c, result)
}
func (h *RedPacketHandler) WalletBindDetail(c *gin.Context) {
if err := authctx.BindCurrentUserID(c); err != nil {
resp.Forbidden(c, err.Error())
return
}
chainType := c.Query("chain_type")
walletAddress := c.Query("wallet_address")
if chainType == "" || walletAddress == "" {
resp.BadRequest(c, "chain_type and wallet_address are required")
return
}
result, err := h.rpSvc.GetWalletBinding(c.Request.Context(), "", chainType, walletAddress)
if err != nil {
resp.Fail(c, http.StatusNotFound, 404, err.Error())
return
}
resp.OK(c, result)
}

@ -7,15 +7,22 @@ import (
type RedPacket struct {
ID uint `gorm:"primarykey" json:"id"`
BizID string `gorm:"uniqueIndex;size:64" json:"biz_id"`
ChainType string `gorm:"index;size:16" json:"chain_type"` // EVM, TRON
PacketID string `gorm:"index;size:32" json:"packet_id"`
ChainID int64 `json:"chain_id"`
ContractAddress string `json:"contract_address"`
CreatorUserID string `gorm:"size:64" json:"creator_user_id"`
CreatorWallet string `gorm:"size:66" json:"creator_wallet"`
GroupID string `gorm:"index;size:64" json:"group_id"`
ScopeType string `gorm:"size:20" json:"scope_type"` // GROUP, DIRECT, PUBLIC
ReceiverUserID string `gorm:"size:64" json:"receiver_user_id"`
ReceiverUserIDs string `gorm:"type:text" json:"receiver_user_ids"`
PacketType int32 `json:"packet_type"` // 0=fixed, 1=random, 2=transfer
Token string `gorm:"size:66" json:"token"`
TotalAmount string `gorm:"size:50" json:"total_amount"`
TotalShares int32 `json:"total_shares"`
ClaimedAmount string `gorm:"size:50" json:"claimed_amount"`
ClaimedShares int32 `json:"claimed_shares"`
ExpiryAt int64 `json:"expiry_at"`
TxHash string `gorm:"size:66" json:"tx_hash"`
Status string `gorm:"size:20" json:"status"` // PENDING, ACTIVE, EXPIRED, COMPLETED, REFUNDED
@ -24,35 +31,69 @@ type RedPacket struct {
}
type RedPacketClaim struct {
ID uint `gorm:"primarykey" json:"id"`
PacketID string `gorm:"index;size:32" json:"packet_id"`
ClaimerWallet string `gorm:"size:66" json:"claimer_wallet"`
AuthNonce string `gorm:"size:32" json:"auth_nonce"`
ClaimTxHash string `gorm:"size:66" json:"claim_tx_hash"`
ClaimedAmount string `gorm:"size:50" json:"claimed_amount"`
BlockNumber uint64 `json:"block_number"`
Status string `gorm:"size:20" json:"status"` // PENDING, CONFIRMED, FAILED
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RedPacketClaimAuth struct {
ID uint `gorm:"primarykey" json:"id"`
PacketID string `gorm:"index;size:32" json:"packet_id"`
Claimer string `gorm:"size:66" json:"claimer"`
AuthNonce string `gorm:"uniqueIndex;size:32" json:"auth_nonce"`
RandomSeed string `gorm:"size:32" json:"random_seed"`
Deadline int64 `json:"deadline"`
Signature string `gorm:"size:132" json:"signature"`
Used bool `json:"used"`
PacketID string `gorm:"index;index:idx_packet_user;size:32" json:"packet_id"`
UserID string `gorm:"index;index:idx_packet_user;size:64" json:"user_id"`
ClaimerWallet string `gorm:"size:66" json:"claimer_wallet"`
AuthNonce string `gorm:"size:32" json:"auth_nonce"`
ClaimTxHash string `gorm:"uniqueIndex;size:66" json:"claim_tx_hash"`
ClaimedAmount string `gorm:"size:50" json:"claimed_amount"`
BlockNumber uint64 `json:"block_number"`
Status string `gorm:"size:20" json:"status"` // PENDING, CONFIRMED, FAILED
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type RedPacketRefund struct {
type RedPacketClaimAuth struct {
ID uint `gorm:"primarykey" json:"id"`
PacketID string `gorm:"index;size:32" json:"packet_id"`
RefundTo string `gorm:"size:66" json:"refund_to"`
TxHash string `gorm:"size:66" json:"tx_hash"`
Amount string `gorm:"size:50" json:"amount"`
Claimer string `gorm:"size:66" json:"claimer"`
AuthNonce string `gorm:"uniqueIndex;size:32" json:"auth_nonce"`
RandomSeed string `gorm:"size:32" json:"random_seed"`
Deadline int64 `json:"deadline"`
Signature string `gorm:"size:132" json:"signature"`
Used bool `json:"used"`
CreatedAt time.Time `json:"created_at"`
}
type RedPacketRefund struct {
ID uint `gorm:"primarykey" json:"id"`
PacketID string `gorm:"index;size:32" json:"packet_id"`
RefundTo string `gorm:"size:66" json:"refund_to"`
TxHash string `gorm:"uniqueIndex;size:66" json:"tx_hash"`
Amount string `gorm:"size:50" json:"amount"`
CreatedAt time.Time `json:"created_at"`
}
type WalletBindingChallenge struct {
ID uint `gorm:"primarykey" json:"id"`
ChallengeID string `gorm:"uniqueIndex;size:64" json:"challenge_id"`
UserID string `gorm:"index;size:64" json:"user_id"`
ChainType string `gorm:"index;size:16" json:"chain_type"`
ChainID int64 `json:"chain_id"`
WalletAddress string `gorm:"index;size:128" json:"wallet_address"`
Nonce string `gorm:"size:64" json:"nonce"`
Message string `gorm:"type:text" json:"message"`
Protocol string `gorm:"size:32" json:"protocol"`
SignMethod string `gorm:"size:32" json:"sign_method"`
Status string `gorm:"size:20" json:"status"` // PENDING, VERIFIED, EXPIRED, FAILED
Signature string `gorm:"type:text" json:"signature"`
ExpiresAt time.Time `json:"expires_at"`
VerifiedAt *time.Time `json:"verified_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type WalletBinding struct {
ID uint `gorm:"primarykey" json:"id"`
UserID string `gorm:"index:idx_user_chain_wallet,unique;size:64" json:"user_id"`
ChainType string `gorm:"index:idx_user_chain_wallet,unique;size:16" json:"chain_type"`
ChainID int64 `json:"chain_id"`
WalletAddress string `gorm:"index:idx_user_chain_wallet,unique;size:128" json:"wallet_address"`
Status string `gorm:"size:20" json:"status"` // ACTIVE, REVOKED
ChallengeID string `gorm:"size:64" json:"challenge_id"`
VerifiedAt time.Time `json:"verified_at"`
RevokedAt *time.Time `json:"revoked_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

@ -2,22 +2,34 @@ package repository
import (
"context"
"math/big"
"redpacket/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Repository interface {
CreateRedPacket(ctx context.Context, rp *model.RedPacket) error
GetRedPacketByBizID(ctx context.Context, bizID string) (*model.RedPacket, error)
GetRedPacketByPacketID(ctx context.Context, packetID string) (*model.RedPacket, error)
UpdateRedPacketTxHash(ctx context.Context, bizID, txHash, packetID string) error
UpdateRedPacketCreated(ctx context.Context, rp *model.RedPacket) error
UpdateRedPacketStatus(ctx context.Context, packetID, status string) error
UpdateRedPacketClaimProgress(ctx context.Context, packetID, claimedAmount, status string) error
CreateClaimAuth(ctx context.Context, auth *model.RedPacketClaimAuth) error
GetClaimAuth(ctx context.Context, packetID, claimer string) (*model.RedPacketClaimAuth, error)
MarkClaimAuthUsed(ctx context.Context, authNonce string) error
CreateClaim(ctx context.Context, claim *model.RedPacketClaim) error
GetClaimByPacketIDAndClaimer(ctx context.Context, packetID, claimer string) (*model.RedPacketClaim, error)
GetClaimByPacketIDAndUserID(ctx context.Context, packetID, userID string) (*model.RedPacketClaim, error)
SaveClaim(ctx context.Context, claim *model.RedPacketClaim) error
GetClaimsByPacketID(ctx context.Context, packetID string) ([]model.RedPacketClaim, error)
SaveRefund(ctx context.Context, refund *model.RedPacketRefund) error
CreateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error
GetWalletBindingChallenge(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error)
UpdateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error
UpsertWalletBinding(ctx context.Context, binding *model.WalletBinding) error
GetActiveWalletBinding(ctx context.Context, userID, chainType, walletAddress string) (*model.WalletBinding, error)
}
type repository struct {
@ -44,16 +56,54 @@ func (r *repository) GetRedPacketByPacketID(ctx context.Context, packetID string
return &rp, err
}
func (r *repository) UpdateRedPacketTxHash(ctx context.Context, bizID, txHash, packetID string) error {
func (r *repository) UpdateRedPacketCreated(ctx context.Context, rp *model.RedPacket) error {
return r.db.WithContext(ctx).Model(&model.RedPacket{}).
Where("biz_id = ?", bizID).
Where("biz_id = ?", rp.BizID).
Updates(map[string]interface{}{
"tx_hash": txHash,
"packet_id": packetID,
"status": "ACTIVE",
"chain_type": rp.ChainType,
"packet_id": rp.PacketID,
"tx_hash": rp.TxHash,
"chain_id": rp.ChainID,
"contract_address": rp.ContractAddress,
"group_id": rp.GroupID,
"scope_type": rp.ScopeType,
"receiver_user_id": rp.ReceiverUserID,
"receiver_user_ids": rp.ReceiverUserIDs,
"status": rp.Status,
}).Error
}
func (r *repository) UpdateRedPacketStatus(ctx context.Context, packetID, status string) error {
return r.db.WithContext(ctx).Model(&model.RedPacket{}).
Where("packet_id = ?", packetID).
Update("status", status).Error
}
func (r *repository) UpdateRedPacketClaimProgress(ctx context.Context, packetID, claimedAmount, status string) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var rp model.RedPacket
if err := tx.Where("packet_id = ?", packetID).First(&rp).Error; err != nil {
return err
}
totalClaimed := addNumericStrings(rp.ClaimedAmount, claimedAmount)
nextShares := rp.ClaimedShares + 1
updates := map[string]interface{}{
"claimed_amount": totalClaimed,
"claimed_shares": nextShares,
"updated_at": gorm.Expr("CURRENT_TIMESTAMP"),
}
if status != "" {
updates["status"] = status
}
return tx.Model(&model.RedPacket{}).
Where("id = ?", rp.ID).
Updates(updates).Error
})
}
func (r *repository) CreateClaimAuth(ctx context.Context, auth *model.RedPacketClaimAuth) error {
return r.db.WithContext(ctx).Create(auth).Error
}
@ -70,8 +120,62 @@ func (r *repository) MarkClaimAuthUsed(ctx context.Context, authNonce string) er
Update("used", true).Error
}
func (r *repository) CreateClaim(ctx context.Context, claim *model.RedPacketClaim) error {
return r.db.WithContext(ctx).Create(claim).Error
func (r *repository) GetClaimByPacketIDAndClaimer(ctx context.Context, packetID, claimer string) (*model.RedPacketClaim, error) {
var claim model.RedPacketClaim
err := r.db.WithContext(ctx).
Where("packet_id = ? AND claimer_wallet = ?", packetID, claimer).
Order("created_at desc").
First(&claim).Error
return &claim, err
}
func (r *repository) GetClaimByPacketIDAndUserID(ctx context.Context, packetID, userID string) (*model.RedPacketClaim, error) {
var claim model.RedPacketClaim
err := r.db.WithContext(ctx).
Where("packet_id = ? AND user_id = ?", packetID, userID).
Order("created_at desc").
First(&claim).Error
return &claim, err
}
func (r *repository) SaveClaim(ctx context.Context, claim *model.RedPacketClaim) error {
if claim.UserID != "" {
var existing model.RedPacketClaim
err := r.db.WithContext(ctx).
Where("packet_id = ? AND user_id = ?", claim.PacketID, claim.UserID).
First(&existing).Error
if err == nil {
claim.ID = existing.ID
return r.db.WithContext(ctx).Model(&model.RedPacketClaim{}).
Where("id = ?", existing.ID).
Updates(map[string]interface{}{
"claimer_wallet": existing.ClaimerWallet,
"auth_nonce": claim.AuthNonce,
"claim_tx_hash": claim.ClaimTxHash,
"claimed_amount": claim.ClaimedAmount,
"block_number": claim.BlockNumber,
"status": claim.Status,
"updated_at": claim.UpdatedAt,
}).Error
}
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
}
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "claim_tx_hash"}},
DoUpdates: clause.AssignmentColumns([]string{
"user_id",
"packet_id",
"claimer_wallet",
"auth_nonce",
"claimed_amount",
"block_number",
"status",
"updated_at",
}),
}).Create(claim).Error
}
func (r *repository) GetClaimsByPacketID(ctx context.Context, packetID string) ([]model.RedPacketClaim, error) {
@ -79,3 +183,69 @@ func (r *repository) GetClaimsByPacketID(ctx context.Context, packetID string) (
err := r.db.WithContext(ctx).Where("packet_id = ?", packetID).Order("created_at desc").Find(&claims).Error
return claims, err
}
func (r *repository) SaveRefund(ctx context.Context, refund *model.RedPacketRefund) error {
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "tx_hash"}},
DoNothing: true,
}).Create(refund).Error
}
func (r *repository) CreateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error {
return r.db.WithContext(ctx).Create(challenge).Error
}
func (r *repository) GetWalletBindingChallenge(ctx context.Context, challengeID string) (*model.WalletBindingChallenge, error) {
var challenge model.WalletBindingChallenge
err := r.db.WithContext(ctx).Where("challenge_id = ?", challengeID).First(&challenge).Error
return &challenge, err
}
func (r *repository) UpdateWalletBindingChallenge(ctx context.Context, challenge *model.WalletBindingChallenge) error {
return r.db.WithContext(ctx).Model(&model.WalletBindingChallenge{}).
Where("challenge_id = ?", challenge.ChallengeID).
Updates(map[string]interface{}{
"status": challenge.Status,
"signature": challenge.Signature,
"verified_at": challenge.VerifiedAt,
"updated_at": challenge.UpdatedAt,
}).Error
}
func (r *repository) UpsertWalletBinding(ctx context.Context, binding *model.WalletBinding) error {
return r.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{
{Name: "user_id"},
{Name: "chain_type"},
{Name: "wallet_address"},
},
DoUpdates: clause.AssignmentColumns([]string{
"chain_id",
"status",
"challenge_id",
"verified_at",
"revoked_at",
"updated_at",
}),
}).Create(binding).Error
}
func (r *repository) GetActiveWalletBinding(ctx context.Context, userID, chainType, walletAddress string) (*model.WalletBinding, error) {
var binding model.WalletBinding
err := r.db.WithContext(ctx).
Where("user_id = ? AND chain_type = ? AND wallet_address = ? AND status = ?", userID, chainType, walletAddress, "ACTIVE").
First(&binding).Error
return &binding, err
}
func addNumericStrings(current, delta string) string {
left := new(big.Int)
if current != "" {
left.SetString(current, 10)
}
right := new(big.Int)
if delta != "" {
right.SetString(delta, 10)
}
return new(big.Int).Add(left, right).String()
}

@ -0,0 +1,386 @@
package service
import (
"context"
"encoding/json"
"testing"
"time"
"redpacket/internal/authctx"
"redpacket/internal/model"
"redpacket/internal/repository"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newTestService(t *testing.T) (*RedPacketService, repository.Repository) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("gorm.Open() error = %v", err)
}
if err := db.AutoMigrate(
&model.RedPacket{},
&model.RedPacketClaim{},
&model.RedPacketClaimAuth{},
&model.RedPacketRefund{},
&model.WalletBindingChallenge{},
&model.WalletBinding{},
); err != nil {
t.Fatalf("AutoMigrate() error = %v", err)
}
repo := repository.New(db)
svc := NewRedPacketService(repo, nil, nil, "")
return svc, repo
}
func seedWalletBinding(t *testing.T, repo repository.Repository, userID, chainType, wallet string) {
t.Helper()
err := repo.UpsertWalletBinding(context.Background(), &model.WalletBinding{
UserID: userID,
ChainType: chainType,
WalletAddress: wallet,
Status: "ACTIVE",
ChallengeID: "test-challenge",
VerifiedAt: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
if err != nil {
t.Fatalf("UpsertWalletBinding() error = %v", err)
}
}
func TestCanClaimRejectsExpiredAndAlreadyClaimed(t *testing.T) {
svc, repo := newTestService(t)
ctx := authctx.WithCurrentUserID(context.Background(), "u2")
activePacket := &model.RedPacket{
BizID: "biz-active",
ChainType: "EVM",
PacketID: "1001",
CreatorUserID: "u1",
CreatorWallet: "0xabc",
GroupID: "g-active",
Status: "ACTIVE",
ExpiryAt: time.Now().Add(10 * time.Minute).Unix(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := repo.CreateRedPacket(ctx, activePacket); err != nil {
t.Fatalf("CreateRedPacket(active) error = %v", err)
}
seedWalletBinding(t, repo, "u2", "EVM", "0xclaimer")
claim := &model.RedPacketClaim{
PacketID: "1001",
ClaimerWallet: "0xclaimer",
ClaimTxHash: "0xtx1",
Status: "CONFIRMED",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := repo.SaveClaim(ctx, claim); err != nil {
t.Fatalf("SaveClaim() error = %v", err)
}
if err := svc.CanClaim(ctx, "1001", "0xclaimer", "u2"); err == nil || err.Error() != "already claimed" {
t.Fatalf("expected already claimed error, got %v", err)
}
expiredPacket := &model.RedPacket{
BizID: "biz-expired",
ChainType: "EVM",
PacketID: "1002",
CreatorUserID: "u1",
CreatorWallet: "0xabc",
GroupID: "g-expired",
Status: "ACTIVE",
ExpiryAt: time.Now().Add(-1 * time.Minute).Unix(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := repo.CreateRedPacket(ctx, expiredPacket); err != nil {
t.Fatalf("CreateRedPacket(expired) error = %v", err)
}
seedWalletBinding(t, repo, "u3", "EVM", "0xfresh")
if err := svc.CanClaim(authctx.WithCurrentUserID(context.Background(), "u3"), "1002", "0xfresh", "u3"); err == nil || err.Error() != "packet is expired" {
t.Fatalf("expected expired error, got %v", err)
}
}
func TestCanClaimRejectsAlreadyClaimedByUserID(t *testing.T) {
svc, repo := newTestService(t)
ctx := authctx.WithCurrentUserID(context.Background(), "u2")
packet := &model.RedPacket{
BizID: "biz-user-claimed",
ChainType: "EVM",
PacketID: "1003",
CreatorUserID: "u1",
CreatorWallet: "0xabc",
GroupID: "g-user-claimed",
Status: "ACTIVE",
ExpiryAt: time.Now().Add(10 * time.Minute).Unix(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := repo.CreateRedPacket(ctx, packet); err != nil {
t.Fatalf("CreateRedPacket() error = %v", err)
}
seedWalletBinding(t, repo, "u2", "EVM", "0xanother-wallet")
claim := &model.RedPacketClaim{
PacketID: "1003",
UserID: "u2",
ClaimerWallet: "0xclaimer",
ClaimTxHash: "0xtx-user-claimed",
Status: "CONFIRMED",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := repo.SaveClaim(ctx, claim); err != nil {
t.Fatalf("SaveClaim() error = %v", err)
}
if err := svc.CanClaim(ctx, "1003", "0xanother-wallet", "u2"); err == nil || err.Error() != "user already claimed" {
t.Fatalf("expected user already claimed error, got %v", err)
}
}
func TestCanClaimUsesPacketTypeRules(t *testing.T) {
svc, repo := newTestService(t)
ctx := authctx.WithCurrentUserID(context.Background(), "u2")
groupPacket := &model.RedPacket{
BizID: "biz-group",
ChainType: "EVM",
PacketID: "1101",
CreatorUserID: "u1",
CreatorWallet: "0xabc",
PacketType: 0,
Status: "ACTIVE",
ExpiryAt: time.Now().Add(10 * time.Minute).Unix(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := repo.CreateRedPacket(ctx, groupPacket); err != nil {
t.Fatalf("CreateRedPacket(group) error = %v", err)
}
seedWalletBinding(t, repo, "u2", "EVM", "0xclaimer")
if err := svc.CanClaim(ctx, "1101", "0xclaimer", "u2"); err == nil || err.Error() != "group_id is required for fixed packet claim" {
t.Fatalf("expected missing group_id error, got %v", err)
}
transferPacket := &model.RedPacket{
BizID: "biz-transfer",
ChainType: "EVM",
PacketID: "1102",
CreatorUserID: "u1",
CreatorWallet: "0xabc",
PacketType: 2,
ReceiverUserID: "u-receiver",
Status: "ACTIVE",
ExpiryAt: time.Now().Add(10 * time.Minute).Unix(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := repo.CreateRedPacket(ctx, transferPacket); err != nil {
t.Fatalf("CreateRedPacket(transfer) error = %v", err)
}
seedWalletBinding(t, repo, "u-other", "EVM", "0xclaimer")
if err := svc.CanClaim(ctx, "1102", "0xclaimer", "u-other"); err == nil || err.Error() != "user is not the designated receiver" {
t.Fatalf("expected designated receiver error, got %v", err)
}
}
func TestCreateOrderPersistsScopeFields(t *testing.T) {
svc, repo := newTestService(t)
ctx := authctx.WithCurrentUserID(context.Background(), "u-create")
result, err := svc.CreateOrder(ctx, &CreateOrderRequest{
ChainType: "EVM",
CreatorWallet: "0x1111111111111111111111111111111111111111",
GroupID: "g-100",
ScopeType: "group",
PacketType: 1,
Token: "0x2222222222222222222222222222222222222222",
TotalAmount: "1000",
TotalShares: 10,
ReceiverUserIDs: []string{"u2", "u3"},
})
if err != nil {
t.Fatalf("CreateOrder() error = %v", err)
}
bizID, _ := result["biz_id"].(string)
record, err := repo.GetRedPacketByBizID(ctx, bizID)
if err != nil {
t.Fatalf("GetRedPacketByBizID() error = %v", err)
}
if record.ScopeType != "GROUP" {
t.Fatalf("scope type mismatch: got %s", record.ScopeType)
}
if record.ChainType != "EVM" {
t.Fatalf("chain type mismatch: got %s", record.ChainType)
}
if record.GroupID != "g-100" {
t.Fatalf("group id mismatch: got %s", record.GroupID)
}
var got []string
if err := json.Unmarshal([]byte(record.ReceiverUserIDs), &got); err != nil {
t.Fatalf("Unmarshal(receiver_user_ids) error = %v", err)
}
if len(got) != 2 || got[0] != "u2" || got[1] != "u3" {
t.Fatalf("receiver_user_ids mismatch: got %+v", got)
}
}
func TestCreatedCallbackUpdatesBindingAndScope(t *testing.T) {
svc, repo := newTestService(t)
ctx := authctx.WithCurrentUserID(context.Background(), "u-create")
result, err := svc.CreateOrder(ctx, &CreateOrderRequest{
ChainType: "TRON",
CreatorWallet: "0x1111111111111111111111111111111111111111",
PacketType: 2,
Token: "0x0000000000000000000000000000000000000000",
TotalAmount: "1000",
TotalShares: 1,
})
if err != nil {
t.Fatalf("CreateOrder() error = %v", err)
}
bizID, _ := result["biz_id"].(string)
err = svc.CreatedCallback(ctx, &CreatedCallbackRequest{
BizID: bizID,
TxHash: "0xabc123",
PacketID: "3001",
ScopeType: "DIRECT",
ReceiverUserID: "u-receiver",
})
if err != nil {
t.Fatalf("CreatedCallback() error = %v", err)
}
record, err := repo.GetRedPacketByBizID(ctx, bizID)
if err != nil {
t.Fatalf("GetRedPacketByBizID() error = %v", err)
}
if record.PacketID != "3001" {
t.Fatalf("packet id mismatch: got %s", record.PacketID)
}
if record.ChainType != "TRON" {
t.Fatalf("chain type mismatch: got %s", record.ChainType)
}
if record.TxHash != "0xabc123" {
t.Fatalf("tx hash mismatch: got %s", record.TxHash)
}
if record.Status != "ACTIVE" {
t.Fatalf("status mismatch: got %s", record.Status)
}
if record.ScopeType != "DIRECT" {
t.Fatalf("scope type mismatch: got %s", record.ScopeType)
}
if record.ReceiverUserID != "u-receiver" {
t.Fatalf("receiver user mismatch: got %s", record.ReceiverUserID)
}
}
func TestIssueClaimSignValidatesInputsAndPersistsAuth(t *testing.T) {
svc, repo := newTestService(t)
ctx := authctx.WithCurrentUserID(context.Background(), "u2")
packet := &model.RedPacket{
BizID: "biz-sign",
ChainType: "EVM",
PacketID: "2001",
CreatorUserID: "u1",
CreatorWallet: "0xabc",
GroupID: "g-sign",
Status: "ACTIVE",
ExpiryAt: time.Now().Add(10 * time.Minute).Unix(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := repo.CreateRedPacket(ctx, packet); err != nil {
t.Fatalf("CreateRedPacket() error = %v", err)
}
seedWalletBinding(t, repo, "u2", "EVM", "0x1111111111111111111111111111111111111111")
if _, err := svc.IssueClaimSign(ctx, "bad-packet-id", "0x1111111111111111111111111111111111111111", "0"); err == nil {
t.Fatalf("expected invalid packet id error")
}
result, err := svc.IssueClaimSign(ctx, "2001", "0x1111111111111111111111111111111111111111", "123")
if err != nil {
t.Fatalf("IssueClaimSign() error = %v", err)
}
auth, err := repo.GetClaimAuth(ctx, "2001", "0x1111111111111111111111111111111111111111")
if err != nil {
t.Fatalf("GetClaimAuth() error = %v", err)
}
if auth.AuthNonce == "" {
t.Fatalf("expected auth nonce to be persisted")
}
if auth.RandomSeed != "123" {
t.Fatalf("random seed mismatch: got %s", auth.RandomSeed)
}
if result["auth_nonce"] == "" {
t.Fatalf("expected auth_nonce in response")
}
}
func TestClaimResultPersistsPendingWithoutChainParser(t *testing.T) {
svc, repo := newTestService(t)
ctx := authctx.WithCurrentUserID(context.Background(), "u2")
packet := &model.RedPacket{
BizID: "biz-claim-result",
ChainType: "EVM",
PacketID: "2101",
CreatorUserID: "u1",
CreatorWallet: "0xabc",
GroupID: "g-1",
PacketType: 0,
Status: "ACTIVE",
ExpiryAt: time.Now().Add(10 * time.Minute).Unix(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := repo.CreateRedPacket(ctx, packet); err != nil {
t.Fatalf("CreateRedPacket() error = %v", err)
}
seedWalletBinding(t, repo, "u2", "EVM", "0x1111111111111111111111111111111111111111")
err := svc.ClaimResult(ctx, &ClaimResultRequest{
PacketID: "2101",
Claimer: "0x1111111111111111111111111111111111111111",
TxHash: "0xtx-claim",
})
if err != nil {
t.Fatalf("ClaimResult() error = %v", err)
}
claim, err := repo.GetClaimByPacketIDAndClaimer(ctx, "2101", "0x1111111111111111111111111111111111111111")
if err != nil {
t.Fatalf("GetClaimByPacketIDAndClaimer() error = %v", err)
}
if claim.Status != "PENDING" {
t.Fatalf("claim status mismatch: got %s", claim.Status)
}
if claim.UserID != "u2" {
t.Fatalf("user id mismatch: got %s", claim.UserID)
}
}

@ -46,6 +46,8 @@ func main() {
&model.RedPacketClaim{},
&model.RedPacketClaimAuth{},
&model.RedPacketRefund{},
&model.WalletBindingChallenge{},
&model.WalletBinding{},
); err != nil {
log.Fatalf("failed to auto-migrate: %v", err)
}
@ -63,10 +65,6 @@ func main() {
// Continue without blockchain for now - can be configured later
}
// Create repository and service
repo := repository.New(db)
rpSvc := service.NewRedPacketService(repo, chainClient, cfg.Chain.SignerPrivateKey)
// Create TRON client if configured
var tronClient *chain.TronClient
if cfg.Tron.FullNodeURL != "" {
@ -91,6 +89,10 @@ func main() {
}
}
// Create repository and service
repo := repository.New(db)
rpSvc := service.NewRedPacketService(repo, chainClient, tronClient, cfg.Chain.SignerPrivateKey)
// Create admin service and handler
adminSvc := service.NewAdminService(chainClient, tronClient)
adminHandler := handler.NewAdminHandler(adminSvc)

@ -19,6 +19,9 @@ func Setup(r *gin.Engine, rpHandler *handler.RedPacketHandler, adminHandler *han
api.GET("/detail", rpHandler.Detail)
api.POST("/claim-sign", rpHandler.ClaimSign)
api.POST("/claim-result", rpHandler.ClaimResult)
api.POST("/wallet-bind/challenge", rpHandler.WalletBindChallenge)
api.POST("/wallet-bind/confirm", rpHandler.WalletBindConfirm)
api.GET("/wallet-bind/detail", rpHandler.WalletBindDetail)
}
// Admin APIs - should be protected with authentication in production

Loading…
Cancel
Save