余额通知

This commit is contained in:
zhx 2026-05-13 16:23:05 +08:00
parent fb1789f97f
commit 20bef24a12
8 changed files with 516 additions and 9 deletions

View File

@ -127,6 +127,7 @@ services/notice-service/configs/config.tencent.example.yaml
```text
docs/语音房基础闭环实现.md
docs/notice-service架构.md
docs/flutter对接/Flutter App 通知与钱包余额对接.md
```
## Storage Model

View File

@ -0,0 +1,387 @@
# Flutter App 通知与钱包余额对接
本文只定义 App 需要对接的 HTTP 接口和 IM 私有事件契约,不约束 Flutter 端内部状态管理实现。
## 通用 HTTP 约定
### Base URL
```text
https://{api_host}
```
本地开发:
```text
http://127.0.0.1:13000
```
### Headers
| Header | 必填 | 说明 |
| -------------------------------------- | --------- | -------------------------------------------- |
| `Authorization: Bearer {access_token}` | 是 | 登录后后端返回的 App 访问令牌 |
| `Content-Type: application/json` | POST 必填 | 请求体为 JSON |
| `X-App-Code` | 否 | 多 App 包场景可传;通常由 gateway 按包名解析 |
| `X-App-Platform` | 否 | `android` / `ios`,部分钱包商品接口使用 |
### 响应 Envelope
所有 `/api/v1` 业务接口使用统一响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {}
}
```
失败时没有 `data`
```json
{
"code": "UNAUTHORIZED",
"message": "unauthorized",
"request_id": "req_xxx"
}
```
## 错误代码
| HTTP | code | message | 说明 |
| ---- | ---------------------- | ------------------------ | ------------------------------- |
| 200 | `OK` | `ok` | 成功 |
| 400 | `INVALID_JSON` | `invalid json` | JSON 格式错误 |
| 400 | `INVALID_ARGUMENT` | `invalid argument` | 参数缺失或非法 |
| 401 | `UNAUTHORIZED` | `unauthorized` | 未登录、token 过期或 token 无效 |
| 403 | `PROFILE_REQUIRED` | `profile required` | 用户资料未完成 |
| 403 | `PERMISSION_DENIED` | `permission denied` | 无权限 |
| 404 | `NOT_FOUND` | `not found` | 资源不存在 |
| 409 | `INSUFFICIENT_BALANCE` | `insufficient balance` | 金币/余额不足 |
| 409 | `LEDGER_CONFLICT` | `conflict` | 钱包幂等冲突 |
| 409 | `IDEMPOTENCY_CONFLICT` | `conflict` | `command_id` 重复但请求内容不同 |
| 409 | `ROOM_CLOSED` | `room closed` | 房间已关闭 |
| 409 | `CONFLICT` | `conflict` | 当前状态不允许操作 |
| 429 | `RATE_LIMITED` | `rate limited` | 触发限流 |
| 500 | `INTERNAL_ERROR` | `internal error` | 服务端内部错误 |
| 502 | `UPSTREAM_ERROR` | `upstream service error` | 内部依赖不可用 |
App 处理建议:`UNAUTHORIZED` 重新登录;`UPSTREAM_ERROR` 可提示重试;`INSUFFICIENT_BALANCE` 引导充值;其它错误按页面语义提示。
## 1. 获取腾讯 IM 登录票据
```text
GET /api/v1/im/usersig
```
### Headers
| Header | 必填 |
| --------------- | ---- |
| `Authorization` | 是 |
### Query 参数
无。
### 返回值 `data`
```json
{
"sdk_app_id": 1400000000,
"user_id": "10001",
"user_sig": "xxx",
"expire_at_ms": 1710000000000,
"join_groups": [
{
"group_id": "hy_lalu_bc_g",
"type": "global_broadcast",
"region_id": 0
}
]
}
```
| 字段 | 类型 | 说明 |
| -------------- | ------ | -------------------------------------- |
| `sdk_app_id` | int64 | 腾讯云 IM AppID |
| `user_id` | string | 腾讯云 IM identifier直接用于 IM 登录 |
| `user_sig` | string | 腾讯云 IM 登录票据 |
| `expire_at_ms` | int64 | 票据过期时间UTC epoch ms |
| `join_groups` | array | App 登录 IM 后需要加入的服务端群 |
### 可能错误
`UNAUTHORIZED``PROFILE_REQUIRED``PERMISSION_DENIED``INTERNAL_ERROR``UPSTREAM_ERROR`
## 2. 获取我的钱包余额
```text
GET /api/v1/wallet/me/balances
```
### Headers
| Header | 必填 |
| --------------- | ---- |
| `Authorization` | 是 |
### Query 参数
| 参数 | 类型 | 必填 | 说明 |
| ------------- | --------------- | ---- | --------------------------------------------- |
| `asset_type` | string/repeated | 否 | 可重复传,也可逗号分隔;不传默认只返回 `COIN` |
| `asset_types` | string/repeated | 否 | 同 `asset_type` |
示例:
```text
GET /api/v1/wallet/me/balances?asset_type=COIN&asset_type=DIAMOND&asset_type=USD_BALANCE
```
### 返回值 `data`
```json
{
"balances": [
{
"asset_type": "COIN",
"available_amount": 1123123,
"frozen_amount": 0,
"version": 18
}
]
}
```
| 字段 | 类型 | 说明 |
| ------------------ | ------ | ------------------------------------------------- |
| `asset_type` | string | `COIN` / `DIAMOND` / `USD_BALANCE` / `GIFT_POINT` |
| `available_amount` | int64 | 可用余额 |
| `frozen_amount` | int64 | 冻结余额 |
| `version` | int64 | 该资产余额版本,用于和 IM 通知去重/防乱序 |
### 可能错误
`UNAUTHORIZED``PROFILE_REQUIRED``INVALID_ARGUMENT``UPSTREAM_ERROR`
## 3. 获取钱包首页摘要
```text
GET /api/v1/wallet/me/overview
```
### Headers
| Header | 必填 |
| --------------- | ---- |
| `Authorization` | 是 |
### Query 参数
无。
### 返回值 `data`
```json
{
"balances": [
{
"asset_type": "COIN",
"available_amount": 1123123,
"frozen_amount": 0,
"version": 18
},
{
"asset_type": "DIAMOND",
"available_amount": 200,
"frozen_amount": 0,
"version": 4
},
{
"asset_type": "USD_BALANCE",
"available_amount": 1500,
"frozen_amount": 0,
"version": 2
}
],
"feature_flags": {
"recharge_enabled": true,
"diamond_exchange_enabled": true,
"withdraw_enabled": true
}
}
```
| 字段 | 类型 | 说明 |
| ---------------------------------------- | ----- | -------------------------- |
| `balances` | array | 钱包首页需要展示的资产余额 |
| `feature_flags.recharge_enabled` | bool | 是否允许充值 |
| `feature_flags.diamond_exchange_enabled` | bool | 是否允许钻石兑换 |
| `feature_flags.withdraw_enabled` | bool | 是否允许提现 |
### 可能错误
`UNAUTHORIZED``PROFILE_REQUIRED``UPSTREAM_ERROR`
## 4. 送礼
```text
POST /api/v1/rooms/gift/send
```
### Headers
| Header | 必填 |
| -------------------------------- | ---- |
| `Authorization` | 是 |
| `Content-Type: application/json` | 是 |
### Body 参数
```json
{
"room_id": "lalu_xxx",
"command_id": "gift_1700000000000_10001",
"target_type": "user",
"target_user_ids": [10002],
"gift_id": "car_001",
"gift_count": 1
}
```
| 参数 | 类型 | 必填 | 说明 |
| ----------------- | ------- | ---- | --------------------------------------------------------- |
| `room_id` | string | 是 | 房间 ID |
| `command_id` | string | 是 | 本次用户动作幂等键HTTP 重试必须复用同一个值 |
| `target_type` | string | 否 | 当前支持 `user`;后续扩展 `all_mic``all_room``couple` |
| `target_user_ids` | int64[] | 是 | 收礼用户 ID`target_type=user` 时必须且只能 1 个 |
| `target_user_id` | int64 | 否 | 单目标兼容字段;新代码优先用 `target_user_ids` |
| `gift_id` | string | 是 | 礼物 ID |
| `gift_count` | int32 | 是 | 礼物数量 |
### 返回值 `data`
```json
{
"result": {
"applied": true,
"room_version": 14,
"server_time_ms": 1710000000000
},
"billing_receipt_id": "br_xxx",
"room_heat": 123456,
"gift_rank": [],
"room": {}
}
```
| 字段 | 类型 | 说明 |
| ----------------------- | ------ | ------------------------------------------ |
| `result.applied` | bool | 本次命令是否实际落地;幂等重放可能为 false |
| `result.room_version` | int64 | 房间版本 |
| `result.server_time_ms` | int64 | 服务端时间 |
| `billing_receipt_id` | string | 钱包扣费回执 |
| `room_heat` | int64 | 房间热度 |
| `gift_rank` | array | 房间礼物榜快照 |
| `room` | object | 房间快照 |
送礼成功不代表 App 必须从该响应里更新钱包余额。金币余额以 `WalletBalanceChanged` IM 私有事件或 `/wallet/me/balances` 刷新结果为准。
### 可能错误
`UNAUTHORIZED``PROFILE_REQUIRED``INVALID_ARGUMENT``INSUFFICIENT_BALANCE``ROOM_CLOSED``LEDGER_CONFLICT``IDEMPOTENCY_CONFLICT``UPSTREAM_ERROR``INTERNAL_ERROR`
## IM 私有事件:钱包余额变更
### 事件名称
```text
WalletBalanceChanged
```
### IM 通道
腾讯云 IM C2C 自定义消息。
| 字段 | 值 |
| ------------------- | ------------------------------------------ |
| `MsgType` | `TIMCustomElem` |
| `Desc` | `WalletBalanceChanged` |
| `Ext` / `extension` | `wallet_notice` |
| `To_Account` | 当前登录用户的 IM identifier |
| `CloudCustomData` | 与 `TIMCustomElem.Data` 相同的 JSON 字符串 |
### Headers
无。IM 私有事件不是 HTTP 请求,没有 HTTP header。
### 参数 / Payload
`TIMCustomElem.Data` 是 JSON 字符串:
```json
{
"event_type": "WalletBalanceChanged",
"event_id": "wev_xxx",
"app_code": "lalu",
"transaction_id": "tx_xxx",
"command_id": "gift_1700000000000_10001",
"user_id": "10001",
"asset_type": "COIN",
"available_delta": -9900,
"frozen_delta": 0,
"available_after": 1123123,
"frozen_after": 0,
"balance_version": 18,
"created_at_ms": 1710000000000,
"source_created_at_ms": 1710000000000,
"metadata": {}
}
```
| 字段 | 类型 | 必填 | 说明 |
| ---------------------- | ------ | ---- | -------------------------------- |
| `event_type` | string | 是 | 固定 `WalletBalanceChanged` |
| `event_id` | string | 是 | 事件幂等键 |
| `app_code` | string | 是 | App 租户 |
| `transaction_id` | string | 是 | 钱包交易 ID |
| `command_id` | string | 是 | 触发该余额变化的业务命令 ID |
| `user_id` | string | 是 | 当前用户 ID 字符串 |
| `asset_type` | string | 是 | 资产类型 |
| `available_delta` | int64 | 是 | 可用余额变化量 |
| `frozen_delta` | int64 | 是 | 冻结余额变化量 |
| `available_after` | int64 | 是 | 变化后的可用余额 |
| `frozen_after` | int64 | 是 | 变化后的冻结余额 |
| `balance_version` | int64 | 是 | 该用户该资产的新版本 |
| `created_at_ms` | int64 | 是 | 钱包事件创建时间 |
| `source_created_at_ms` | int64 | 是 | 源 outbox 创建时间 |
| `metadata` | object | 否 | 业务附加信息,不作为余额展示依赖 |
### 返回值
无。该事件由服务端单向投递给 AppApp 不需要回调后端确认。
### 错误代码
无 HTTP 错误代码。App 本地处理规则:
| 场景 | 处理 |
| ------------------------------------------------- | -------------------------------------------- |
| `Desc` 不是 `WalletBalanceChanged` | 忽略 |
| `Ext/extension` 不是 `wallet_notice` | 忽略 |
| JSON 解析失败 | 忽略并记录客户端日志 |
| `event_id` 已处理 | 忽略 |
| 本地 `asset_type``version >= balance_version` | 忽略 |
| 长时间未收到通知 | 调 `GET /api/v1/wallet/me/balances` 兜底刷新 |
## App 最小对接顺序
1. 登录后调用 `GET /api/v1/im/usersig`
2. 用返回的 `user_id/user_sig/sdk_app_id` 登录腾讯云 IM。
3. 调 `GET /api/v1/wallet/me/balances` 拉取初始余额。
4. 监听 C2C 自定义消息 `WalletBalanceChanged`
5. 送礼调用 `POST /api/v1/rooms/gift/send`
6. 送礼后的余额以 IM 私有事件或余额刷新接口为准。

View File

@ -0,0 +1,92 @@
# 版本强更接口 App 对接
## 接口地址
`GET /api/v1/app/version`
公开接口,不需要 `Authorization`
## Header
| Header | 必填 | 说明 |
| --- | --- | --- |
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
| `X-App-Package` | 否 | App 包名;当前 `com.org.laluparty` 可解析为 `lalu`。 |
| `X-App-Platform` | 条件必填 | `android``ios`query `platform` 为空时读取该 header。 |
| `X-App-Build-Number` | 条件必填 | 当前客户端编译版本号query `build_number` 为空时读取该 header。 |
| `X-App-Version` | 否 | 当前客户端展示版本号,只用于日志排查。 |
## Query 参数
| 参数 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| `platform` | 条件必填 | string | `android``ios`;优先级高于 `X-App-Platform`。 |
| `build_number` | 条件必填 | int64 | 当前客户端编译版本号;优先级高于 `X-App-Build-Number`。 |
`platform` 必须通过 query 或 header 传入。`build_number` 对 App 端必须传;服务端缺省时按 `0` 处理,会导致已配置版本都被判定为有更新。
请求示例:
```http
GET /api/v1/app/version?platform=android&build_number=100
X-App-Code: lalu
X-App-Platform: android
X-App-Build-Number: 100
X-App-Version: 1.1.0
```
## 成功返回
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"app_code": "lalu",
"platform": "android",
"version": "1.2.0",
"build_number": 120,
"force_update": true,
"download_url": "https://example.com/lalu.apk",
"description": "修复已知问题",
"has_update": true,
"current_build_number": 100,
"updated_at_ms": 1710000000000,
"server_time_ms": 1710000001234
}
}
```
返回字段:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `app_code` | string | 服务端解析后的 App 编码。 |
| `platform` | string | `android``ios`。 |
| `version` | string | 后台配置的最新展示版本号,只用于展示。 |
| `build_number` | int64 | 后台当前最高编译版本号。 |
| `current_build_number` | int64 | 服务端收到的客户端编译版本号。 |
| `has_update` | bool | `build_number > current_build_number` 时为 `true`。 |
| `force_update` | bool | 当前客户端落后且后台最新版本标记强更时为 `true`。 |
| `download_url` | string | 更新跳转地址Android 通常是 APK 下载页iOS 通常是 App Store 链接。 |
| `description` | string | 更新说明,可为空。 |
| `updated_at_ms` | int64 | 后台版本配置更新时间Unix epoch milliseconds。 |
| `server_time_ms` | int64 | gateway 返回时间Unix epoch milliseconds。 |
## 错误码
| HTTP 状态码 | `code` | 触发条件 |
| --- | --- | --- |
| `400` | `INVALID_ARGUMENT` | `platform` 为空或不是 `android` / `ios`。 |
| `502` | `UPSTREAM_ERROR` | gateway 读取后台版本配置失败。 |
错误返回仍使用统一 envelope
```json
{
"code": "INVALID_ARGUMENT",
"message": "platform is invalid",
"request_id": "req_abc"
}
```

View File

@ -159,3 +159,27 @@ wallet_notice_worker:
3. 活动/系统私有通知:新增 `activitynotice` 模块,读取 activity outbox复用 `notice_delivery_events` 或新增同语义位点表。
4. 站内 inbox新增 inbox 模块,写 notice-service 自己的 inbox 表,再按客户端分页接口读取。
5. Push新增 push 模块,和 IM 投递使用不同 `channel`,独立重试和死信。
## 真实库验证
默认测试不会访问本地 MySQL。需要验证真实表链路时显式传入 DSN
```bash
NOTICE_REAL_MYSQL_DSN='hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_notice?parseTime=true&charset=utf8mb4&loc=UTC' \
NOTICE_REAL_WALLET_DATABASE=hyapp_wallet \
go test ./services/notice-service/internal/modules/walletnotice \
-run TestMySQLRepositoryProcessesRealWalletOutbox -count=1 -v
```
如果需要保留本次验证行用于查库,加:
```bash
NOTICE_REAL_MYSQL_KEEP_ROWS=true
```
验证内容:
- 往 `hyapp_wallet.wallet_outbox` 插入一条真实 `WalletBalanceChanged`
- `walletnotice` 从真实 MySQL 抢占该事件。
- fake publisher 代替腾讯云 IM避免误发真实消息。
- `hyapp_notice.notice_delivery_events` 写入 `delivered`,并保存最终客户端 payload。

View File

@ -38,7 +38,7 @@ func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
if err != nil {
t.Fatalf("NewMySQLRepository: %v", err)
}
nowMs := time.Now().UnixMilli()
insertedAtMS := int64(0)
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
appCode := "notice_real"
userID := int64(990000000)
@ -58,7 +58,7 @@ func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
"metadata": map[string]any{
"validation": "notice-service-real-mysql",
},
"created_at_ms": nowMs,
"created_at_ms": insertedAtMS,
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
@ -70,7 +70,7 @@ func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
available_delta, frozen_delta, payload, status, retry_count, last_error, created_at_ms, updated_at_ms
) VALUES (?, ?, 'WalletBalanceChanged', ?, ?, ?, 'COIN', 188, 0, ?, 'pending', 0, '', ?, ?)`,
quoteDB(walletDatabase),
), appCode, eventID, transactionID, commandID, userID, string(payloadJSON), nowMs, nowMs); err != nil {
), appCode, eventID, transactionID, commandID, userID, string(payloadJSON), insertedAtMS, insertedAtMS); err != nil {
t.Fatalf("insert wallet_outbox: %v", err)
}
if !keepRealRows() {
@ -84,7 +84,7 @@ func TestMySQLRepositoryProcessesRealWalletOutbox(t *testing.T) {
service := New(Config{NodeID: "notice-real-test"}, repository, publisher)
processed, err := service.ProcessWalletBalanceNotices(ctx, WalletNoticeWorkerOptions{
WorkerID: "notice-real-test-worker",
BatchSize: 10,
BatchSize: 1,
LockTTL: 30 * time.Second,
PublishTimeout: time.Second,
MaxRetryCount: 3,

View File

@ -3,6 +3,7 @@ package walletnotice
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
@ -273,13 +274,15 @@ func (r *MySQLRepository) claimDeliveryEvent(ctx context.Context, tx *sql.Tx, ev
}
// MarkWalletBalanceDelivered 标记一条私有余额通知已投递到腾讯云 IM。
func (r *MySQLRepository) MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, nowMs int64) error {
// payload_json 更新成最终客户端负载,便于后台排查和死信重放使用同一消息形态。
func (r *MySQLRepository) MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, deliveredPayload json.RawMessage, nowMs int64) error {
_, err := r.db.ExecContext(ctx, `
UPDATE notice_delivery_events
SET status = ?, locked_by = '', lock_until_ms = 0, next_retry_at_ms = 0,
last_error = '', delivered_at_ms = ?, updated_at_ms = ?
payload_json = ?, last_error = '', delivered_at_ms = ?, updated_at_ms = ?
WHERE source_name = ? AND app_code = ? AND source_event_id = ? AND channel = ?`,
deliveryStatusDelivered,
string(deliveredPayload),
nowMs,
nowMs,
sourceWalletOutbox,

View File

@ -15,7 +15,7 @@ import (
// Repository 是 notice-service 需要的持久化能力。
type Repository interface {
ClaimWalletBalanceEvents(ctx context.Context, workerID string, limit int, lockTTL time.Duration) ([]WalletBalanceEvent, error)
MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, nowMs int64) error
MarkWalletBalanceDelivered(ctx context.Context, event WalletBalanceEvent, deliveredPayload json.RawMessage, nowMs int64) error
MarkWalletBalanceRetryable(ctx context.Context, event WalletBalanceEvent, retryCount int, nextRetryAtMS int64, lastErr string, nowMs int64) error
MarkWalletBalanceFailed(ctx context.Context, event WalletBalanceEvent, retryCount int, lastErr string, nowMs int64) error
}
@ -81,7 +81,7 @@ func (s *Service) publishWalletBalanceEvent(ctx context.Context, event WalletBal
})
nowMs := time.Now().UnixMilli()
if err == nil {
if markErr := s.repository.MarkWalletBalanceDelivered(ctx, event, nowMs); markErr != nil {
if markErr := s.repository.MarkWalletBalanceDelivered(ctx, event, payload, nowMs); markErr != nil {
return markErr
}
logx.Info(ctx, "notice_wallet_balance_delivered",

View File

@ -97,7 +97,7 @@ func (r *fakeRepository) ClaimWalletBalanceEvents(context.Context, string, int,
return r.events, nil
}
func (r *fakeRepository) MarkWalletBalanceDelivered(_ context.Context, event WalletBalanceEvent, _ int64) error {
func (r *fakeRepository) MarkWalletBalanceDelivered(_ context.Context, event WalletBalanceEvent, _ json.RawMessage, _ int64) error {
r.delivered = append(r.delivered, event)
return nil
}