Compare commits
5 Commits
ad4a755998
...
a0ee5962df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0ee5962df | ||
|
|
144cd3d781 | ||
|
|
b496a87b84 | ||
|
|
ecef1df1c6 | ||
|
|
0b428fc9c7 |
@ -10705,6 +10705,7 @@ type LuckyGiftDrawResult struct {
|
||||
HighMultiplier bool `protobuf:"varint,18,opt,name=high_multiplier,json=highMultiplier,proto3" json:"high_multiplier,omitempty"`
|
||||
CreatedAtMs int64 `protobuf:"varint,19,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
||||
PoolId string `protobuf:"bytes,20,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
||||
MultiplierPpm int64 `protobuf:"varint,21,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -10879,6 +10880,13 @@ func (x *LuckyGiftDrawResult) GetPoolId() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawResult) GetMultiplierPpm() int64 {
|
||||
if x != nil {
|
||||
return x.MultiplierPpm
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ExecuteLuckyGiftDrawRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
LuckyGift *LuckyGiftMeta `protobuf:"bytes,1,opt,name=lucky_gift,json=luckyGift,proto3" json:"lucky_gift,omitempty"`
|
||||
@ -12673,7 +12681,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" +
|
||||
"\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\x12'\n" +
|
||||
"\x0fexperience_pool\x18\a \x01(\tR\x0eexperiencePool\x12\x17\n" +
|
||||
"\apool_id\x18\b \x01(\tR\x06poolId\"\xce\x06\n" +
|
||||
"\apool_id\x18\b \x01(\tR\x06poolId\"\xf5\x06\n" +
|
||||
"\x13LuckyGiftDrawResult\x12\x17\n" +
|
||||
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
|
||||
"\n" +
|
||||
@ -12696,7 +12704,8 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\x0estage_feedback\x18\x11 \x01(\bR\rstageFeedback\x12'\n" +
|
||||
"\x0fhigh_multiplier\x18\x12 \x01(\bR\x0ehighMultiplier\x12\"\n" +
|
||||
"\rcreated_at_ms\x18\x13 \x01(\x03R\vcreatedAtMs\x12\x17\n" +
|
||||
"\apool_id\x18\x14 \x01(\tR\x06poolId\"^\n" +
|
||||
"\apool_id\x18\x14 \x01(\tR\x06poolId\x12%\n" +
|
||||
"\x0emultiplier_ppm\x18\x15 \x01(\x03R\rmultiplierPpm\"^\n" +
|
||||
"\x1bExecuteLuckyGiftDrawRequest\x12?\n" +
|
||||
"\n" +
|
||||
"lucky_gift\x18\x01 \x01(\v2 .hyapp.activity.v1.LuckyGiftMetaR\tluckyGift\"^\n" +
|
||||
|
||||
@ -1218,6 +1218,7 @@ message LuckyGiftDrawResult {
|
||||
bool high_multiplier = 18;
|
||||
int64 created_at_ms = 19;
|
||||
string pool_id = 20;
|
||||
int64 multiplier_ppm = 21;
|
||||
}
|
||||
|
||||
message ExecuteLuckyGiftDrawRequest {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -63,6 +63,27 @@ message RankItem {
|
||||
int64 updated_at_ms = 4;
|
||||
}
|
||||
|
||||
// LuckyGiftDrawResult 是 SendGift 同步返回给当前送礼用户的幸运礼物抽奖表现。
|
||||
message LuckyGiftDrawResult {
|
||||
bool enabled = 1;
|
||||
string draw_id = 2;
|
||||
string command_id = 3;
|
||||
string pool_id = 4;
|
||||
string gift_id = 5;
|
||||
int64 rule_version = 6;
|
||||
string experience_pool = 7;
|
||||
string selected_tier_id = 8;
|
||||
int64 multiplier_ppm = 9;
|
||||
int64 base_reward_coins = 10;
|
||||
int64 room_atmosphere_reward_coins = 11;
|
||||
int64 activity_subsidy_coins = 12;
|
||||
int64 effective_reward_coins = 13;
|
||||
string reward_status = 14;
|
||||
bool stage_feedback = 15;
|
||||
bool high_multiplier = 16;
|
||||
int64 created_at_ms = 17;
|
||||
}
|
||||
|
||||
// RoomTreasureRewardItem 是后台配置给客户端展示的宝箱奖励候选项。
|
||||
message RoomTreasureRewardItem {
|
||||
string reward_item_id = 1;
|
||||
@ -695,6 +716,7 @@ message SendGiftResponse {
|
||||
repeated RankItem gift_rank = 4;
|
||||
RoomSnapshot room = 5;
|
||||
RoomTreasureState treasure = 6;
|
||||
LuckyGiftDrawResult lucky_gift = 7;
|
||||
}
|
||||
|
||||
// CheckSpeakPermissionRequest 让腾讯云 IM 发言回调或 gateway 在公屏前同步问房间业务态。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -745,6 +745,32 @@ message ListRechargeProductsResponse {
|
||||
repeated string channels = 2;
|
||||
}
|
||||
|
||||
message ConfirmGooglePaymentRequest {
|
||||
string request_id = 1;
|
||||
string app_code = 2;
|
||||
string command_id = 3;
|
||||
int64 user_id = 4;
|
||||
int64 region_id = 5;
|
||||
int64 product_id = 6;
|
||||
string product_code = 7;
|
||||
string package_name = 8;
|
||||
string purchase_token = 9;
|
||||
string order_id = 10;
|
||||
int64 purchase_time_ms = 11;
|
||||
}
|
||||
|
||||
message ConfirmGooglePaymentResponse {
|
||||
string payment_order_id = 1;
|
||||
string transaction_id = 2;
|
||||
string status = 3;
|
||||
int64 product_id = 4;
|
||||
string product_code = 5;
|
||||
int64 coin_amount = 6;
|
||||
AssetBalance balance = 7;
|
||||
bool idempotent_replay = 8;
|
||||
string consume_state = 9;
|
||||
}
|
||||
|
||||
// ListAdminRechargeProductsRequest 是后台内购配置列表查询,不参与用户充值主链路。
|
||||
message ListAdminRechargeProductsRequest {
|
||||
string request_id = 1;
|
||||
@ -1233,6 +1259,7 @@ service WalletService {
|
||||
rpc GetWalletValueSummary(GetWalletValueSummaryRequest) returns (GetWalletValueSummaryResponse);
|
||||
rpc GetUserGiftWall(GetUserGiftWallRequest) returns (GetUserGiftWallResponse);
|
||||
rpc ListRechargeProducts(ListRechargeProductsRequest) returns (ListRechargeProductsResponse);
|
||||
rpc ConfirmGooglePayment(ConfirmGooglePaymentRequest) returns (ConfirmGooglePaymentResponse);
|
||||
rpc ListAdminRechargeProducts(ListAdminRechargeProductsRequest) returns (ListAdminRechargeProductsResponse);
|
||||
rpc CreateRechargeProduct(CreateRechargeProductRequest) returns (RechargeProductResponse);
|
||||
rpc UpdateRechargeProduct(UpdateRechargeProductRequest) returns (RechargeProductResponse);
|
||||
|
||||
@ -50,6 +50,7 @@ const (
|
||||
WalletService_GetWalletValueSummary_FullMethodName = "/hyapp.wallet.v1.WalletService/GetWalletValueSummary"
|
||||
WalletService_GetUserGiftWall_FullMethodName = "/hyapp.wallet.v1.WalletService/GetUserGiftWall"
|
||||
WalletService_ListRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeProducts"
|
||||
WalletService_ConfirmGooglePayment_FullMethodName = "/hyapp.wallet.v1.WalletService/ConfirmGooglePayment"
|
||||
WalletService_ListAdminRechargeProducts_FullMethodName = "/hyapp.wallet.v1.WalletService/ListAdminRechargeProducts"
|
||||
WalletService_CreateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateRechargeProduct"
|
||||
WalletService_UpdateRechargeProduct_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateRechargeProduct"
|
||||
@ -111,6 +112,7 @@ type WalletServiceClient interface {
|
||||
GetWalletValueSummary(ctx context.Context, in *GetWalletValueSummaryRequest, opts ...grpc.CallOption) (*GetWalletValueSummaryResponse, error)
|
||||
GetUserGiftWall(ctx context.Context, in *GetUserGiftWallRequest, opts ...grpc.CallOption) (*GetUserGiftWallResponse, error)
|
||||
ListRechargeProducts(ctx context.Context, in *ListRechargeProductsRequest, opts ...grpc.CallOption) (*ListRechargeProductsResponse, error)
|
||||
ConfirmGooglePayment(ctx context.Context, in *ConfirmGooglePaymentRequest, opts ...grpc.CallOption) (*ConfirmGooglePaymentResponse, error)
|
||||
ListAdminRechargeProducts(ctx context.Context, in *ListAdminRechargeProductsRequest, opts ...grpc.CallOption) (*ListAdminRechargeProductsResponse, error)
|
||||
CreateRechargeProduct(ctx context.Context, in *CreateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
|
||||
UpdateRechargeProduct(ctx context.Context, in *UpdateRechargeProductRequest, opts ...grpc.CallOption) (*RechargeProductResponse, error)
|
||||
@ -453,6 +455,16 @@ func (c *walletServiceClient) ListRechargeProducts(ctx context.Context, in *List
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ConfirmGooglePayment(ctx context.Context, in *ConfirmGooglePaymentRequest, opts ...grpc.CallOption) (*ConfirmGooglePaymentResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ConfirmGooglePaymentResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_ConfirmGooglePayment_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) ListAdminRechargeProducts(ctx context.Context, in *ListAdminRechargeProductsRequest, opts ...grpc.CallOption) (*ListAdminRechargeProductsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListAdminRechargeProductsResponse)
|
||||
@ -710,6 +722,7 @@ type WalletServiceServer interface {
|
||||
GetWalletValueSummary(context.Context, *GetWalletValueSummaryRequest) (*GetWalletValueSummaryResponse, error)
|
||||
GetUserGiftWall(context.Context, *GetUserGiftWallRequest) (*GetUserGiftWallResponse, error)
|
||||
ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error)
|
||||
ConfirmGooglePayment(context.Context, *ConfirmGooglePaymentRequest) (*ConfirmGooglePaymentResponse, error)
|
||||
ListAdminRechargeProducts(context.Context, *ListAdminRechargeProductsRequest) (*ListAdminRechargeProductsResponse, error)
|
||||
CreateRechargeProduct(context.Context, *CreateRechargeProductRequest) (*RechargeProductResponse, error)
|
||||
UpdateRechargeProduct(context.Context, *UpdateRechargeProductRequest) (*RechargeProductResponse, error)
|
||||
@ -835,6 +848,9 @@ func (UnimplementedWalletServiceServer) GetUserGiftWall(context.Context, *GetUse
|
||||
func (UnimplementedWalletServiceServer) ListRechargeProducts(context.Context, *ListRechargeProductsRequest) (*ListRechargeProductsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListRechargeProducts not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ConfirmGooglePayment(context.Context, *ConfirmGooglePaymentRequest) (*ConfirmGooglePaymentResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ConfirmGooglePayment not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) ListAdminRechargeProducts(context.Context, *ListAdminRechargeProductsRequest) (*ListAdminRechargeProductsResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListAdminRechargeProducts not implemented")
|
||||
}
|
||||
@ -1480,6 +1496,24 @@ func _WalletService_ListRechargeProducts_Handler(srv interface{}, ctx context.Co
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ConfirmGooglePayment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ConfirmGooglePaymentRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).ConfirmGooglePayment(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_ConfirmGooglePayment_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).ConfirmGooglePayment(ctx, req.(*ConfirmGooglePaymentRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_ListAdminRechargeProducts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListAdminRechargeProductsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -2007,6 +2041,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ListRechargeProducts",
|
||||
Handler: _WalletService_ListRechargeProducts_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ConfirmGooglePayment",
|
||||
Handler: _WalletService_ConfirmGooglePayment_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListAdminRechargeProducts",
|
||||
Handler: _WalletService_ListAdminRechargeProducts_Handler,
|
||||
|
||||
4
deploy/standalone/docker/statistics-service.env.example
Normal file
4
deploy/standalone/docker/statistics-service.env.example
Normal file
@ -0,0 +1,4 @@
|
||||
IMAGE=10.2.1.3:18082/hyapp/statistics-service:RELEASE_TAG
|
||||
CONTAINER_NAME=hyapp-statistics-service
|
||||
CONFIG_PATH=/etc/hyapp/statistics-service/config.yaml
|
||||
STOP_TIMEOUT_SEC=60
|
||||
17
deploy/standalone/systemd/hyapp-statistics-service.service
Normal file
17
deploy/standalone/systemd/hyapp-statistics-service.service
Normal file
@ -0,0 +1,17 @@
|
||||
[Unit]
|
||||
Description=HYApp statistics-service
|
||||
After=docker.service network-online.target
|
||||
Wants=network-online.target
|
||||
Requires=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/statistics-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop -t ${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=always
|
||||
RestartSec=3
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@ -10,7 +10,7 @@ StartLimitBurst=3
|
||||
Type=simple
|
||||
EnvironmentFile=/etc/hyapp/wallet-service/docker.env
|
||||
ExecStartPre=-/usr/bin/env docker rm -f ${CONTAINER_NAME}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13004 -service=wallet-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStart=/usr/bin/env docker run --name=${CONTAINER_NAME} --rm --network=host --init --pull=never --stop-signal=SIGTERM --stop-timeout=${STOP_TIMEOUT_SEC} --env=TZ=UTC --log-driver=json-file --log-opt=max-size=100m --log-opt=max-file=5 --mount=type=bind,src=${CONFIG_PATH},dst=/app/config.yaml,readonly --mount=type=bind,src=/etc/hyapp/secrets,dst=/etc/hyapp/secrets,readonly --health-cmd="/app/grpc-health-probe -addr=127.0.0.1:13004 -service=wallet-service" --health-interval=5s --health-timeout=3s --health-retries=6 --health-start-period=10s ${IMAGE}
|
||||
ExecStop=/usr/bin/env docker stop --time=${STOP_TIMEOUT_SEC} ${CONTAINER_NAME}
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
@ -21,4 +21,3 @@ LimitNOFILE=1048576
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
|
||||
@ -523,7 +523,7 @@ fi
|
||||
echo "== hyapp containers =="
|
||||
docker ps --filter 'name=hyapp-' --format '{{.Names}} {{.Image}} {{.Status}}' || true
|
||||
echo "== listening ports =="
|
||||
ss -lntp 2>/dev/null | awk 'NR==1 || /:13000|:13001|:13004|:13005|:13006|:13008|:13009|:13100|:13101|:13104|:13105|:13106|:13108|:13109/' || true
|
||||
ss -lntp 2>/dev/null | awk 'NR==1 || /:13000|:13001|:13004|:13005|:13006|:13008|:13009|:13010|:13100|:13101|:13104|:13105|:13106|:13108|:13109|:13110/' || true
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@ -194,6 +194,25 @@
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
},
|
||||
"statistics-service": {
|
||||
"unit": "hyapp-statistics-service",
|
||||
"container": "hyapp-statistics-service",
|
||||
"env_file": "/etc/hyapp/statistics-service/docker.env",
|
||||
"image_template": "${REGISTRY}/statistics-service:${TAG}",
|
||||
"target_port": 13010,
|
||||
"hosts": [
|
||||
"new-app-1",
|
||||
"new-app-2"
|
||||
],
|
||||
"clb": {
|
||||
"enabled": true,
|
||||
"load_balancer_id": "lb-epvnr4o0",
|
||||
"listener_ids": [
|
||||
"lbl-a0myhr6q"
|
||||
],
|
||||
"drain_seconds": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
268
docs/ExploreTab-Flutter-App对接文档.md
Normal file
268
docs/ExploreTab-Flutter-App对接文档.md
Normal file
@ -0,0 +1,268 @@
|
||||
# Explore tab Flutter App 对接文档
|
||||
|
||||
本文档定义 App 端 Explore 页 H5 tab 的读取接口。后台在 `HYApp 管理平台 / APP配置 / Explore配置` 维护 tab 名称和 H5 链接;App 只读取已启用配置并按服务端返回顺序展示。
|
||||
|
||||
## 基础约定
|
||||
|
||||
本地网关地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:13000
|
||||
```
|
||||
|
||||
所有 `/api/v1` 业务接口使用统一响应外壳:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
客户端成功判断:
|
||||
|
||||
```text
|
||||
HTTP 200 && code == "OK"
|
||||
```
|
||||
|
||||
失败时保留 `request_id`,用于后端排查日志。
|
||||
|
||||
## App 标识 Header
|
||||
|
||||
Explore tab 按 App 隔离,接口没有 query/body 形式的 `app_code` 参数。gateway 通过 Header 解析 App:
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `X-App-Code` | 推荐 | 内部 App code,例如 `lalu`。 |
|
||||
| `X-HY-App-Code` | 否 | `X-App-Code` 的兼容别名。 |
|
||||
| `X-App-Package` | 推荐二选一 | App 包名,例如 `com.org.laluparty`。传包名时 gateway 可解析到内部 `app_code`。 |
|
||||
| `X-App-Platform` | 包名解析时推荐 | 平台,例如 `android`、`ios`。 |
|
||||
|
||||
当前开发默认 `app_code` 是 `lalu`。Flutter 仍建议显式传 `X-App-Code` 或 `X-App-Package`,避免多 App 环境串配置。
|
||||
|
||||
## 获取 Explore tab 列表
|
||||
|
||||
接口地址:
|
||||
|
||||
```http
|
||||
GET /api/v1/app/explore-tabs
|
||||
```
|
||||
|
||||
完整本地地址:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:13000/api/v1/app/explore-tabs
|
||||
```
|
||||
|
||||
鉴权:
|
||||
|
||||
```text
|
||||
不需要 Bearer token
|
||||
```
|
||||
|
||||
服务端行为:
|
||||
|
||||
- 只返回当前 App 已启用的 Explore tabs。
|
||||
- 后台关闭的 tab 不下发给 App。
|
||||
- 返回顺序为后台 `sort_order` 升序,同排序时按较新的记录优先。
|
||||
|
||||
### 请求 Header
|
||||
|
||||
```http
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```http
|
||||
X-App-Package: com.org.laluparty
|
||||
X-App-Platform: android
|
||||
```
|
||||
|
||||
### Query 参数
|
||||
|
||||
无。
|
||||
|
||||
### Request Body
|
||||
|
||||
无。
|
||||
|
||||
### curl 示例
|
||||
|
||||
```bash
|
||||
curl -X GET 'http://127.0.0.1:13000/api/v1/app/explore-tabs' \
|
||||
-H 'X-App-Code: lalu'
|
||||
```
|
||||
|
||||
包名解析方式:
|
||||
|
||||
```bash
|
||||
curl -X GET 'http://127.0.0.1:13000/api/v1/app/explore-tabs' \
|
||||
-H 'X-App-Package: com.org.laluparty' \
|
||||
-H 'X-App-Platform: android'
|
||||
```
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"id": 7,
|
||||
"app_code": "lalu",
|
||||
"tab": "Farm",
|
||||
"h5_url": "https://www.baidu.com",
|
||||
"enabled": true,
|
||||
"sort_order": 1,
|
||||
"updated_at_ms": 1700000000000
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `data` 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `items` | array | Explore tab 列表。 |
|
||||
| `total` | int | 本次返回数量。 |
|
||||
|
||||
### `items[]` 字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | int | 后台配置记录 ID,只用于排查或本地缓存 key,不作为业务路由。 |
|
||||
| `app_code` | string | tab 所属 App,例如 `lalu`。 |
|
||||
| `tab` | string | App 展示的 tab 文案。 |
|
||||
| `h5_url` | string | 点击 tab 后加载的 H5 地址。 |
|
||||
| `enabled` | bool | 是否启用。App 接口只返回 `true` 的记录。 |
|
||||
| `sort_order` | int | 展示排序,数值越小越靠前。 |
|
||||
| `updated_at_ms` | int | 更新时间,Unix epoch milliseconds。 |
|
||||
|
||||
### 空列表响应
|
||||
|
||||
当后台没有启用的 Explore tab 时,返回空数组,不是错误:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"items": [],
|
||||
"total": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 错误响应
|
||||
|
||||
配置读取异常时:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "UPSTREAM_ERROR",
|
||||
"message": "upstream service error",
|
||||
"request_id": "req_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
客户端处理建议:
|
||||
|
||||
- `UPSTREAM_ERROR`:展示空态或保留上一次本地缓存。
|
||||
- 非 `OK`:不要使用 `data`。
|
||||
- 记录 `request_id` 到客户端日志,便于问题排查。
|
||||
|
||||
## Flutter 数据模型
|
||||
|
||||
```dart
|
||||
class ExploreTabList {
|
||||
final List<ExploreTabItem> items;
|
||||
final int total;
|
||||
|
||||
ExploreTabList({
|
||||
required this.items,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
factory ExploreTabList.fromJson(Map<String, dynamic> json) {
|
||||
final rawItems = json['items'] as List<dynamic>? ?? const [];
|
||||
return ExploreTabList(
|
||||
items: rawItems
|
||||
.map((e) => ExploreTabItem.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: json['total'] as int? ?? rawItems.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ExploreTabItem {
|
||||
final int id;
|
||||
final String appCode;
|
||||
final String tab;
|
||||
final String h5Url;
|
||||
final bool enabled;
|
||||
final int sortOrder;
|
||||
final int updatedAtMs;
|
||||
|
||||
ExploreTabItem({
|
||||
required this.id,
|
||||
required this.appCode,
|
||||
required this.tab,
|
||||
required this.h5Url,
|
||||
required this.enabled,
|
||||
required this.sortOrder,
|
||||
required this.updatedAtMs,
|
||||
});
|
||||
|
||||
factory ExploreTabItem.fromJson(Map<String, dynamic> json) {
|
||||
return ExploreTabItem(
|
||||
id: json['id'] as int? ?? 0,
|
||||
appCode: json['app_code'] as String? ?? '',
|
||||
tab: json['tab'] as String? ?? '',
|
||||
h5Url: json['h5_url'] as String? ?? '',
|
||||
enabled: json['enabled'] as bool? ?? false,
|
||||
sortOrder: json['sort_order'] as int? ?? 0,
|
||||
updatedAtMs: json['updated_at_ms'] as int? ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Flutter Dio 示例
|
||||
|
||||
```dart
|
||||
Future<ExploreTabList> fetchExploreTabs(Dio dio) async {
|
||||
final response = await dio.get(
|
||||
'/api/v1/app/explore-tabs',
|
||||
options: Options(
|
||||
headers: {
|
||||
'X-App-Code': 'lalu',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final body = response.data as Map<String, dynamic>;
|
||||
if (body['code'] != 'OK') {
|
||||
throw Exception('${body['code']}: ${body['message']} request_id=${body['request_id']}');
|
||||
}
|
||||
|
||||
return ExploreTabList.fromJson(body['data'] as Map<String, dynamic>);
|
||||
}
|
||||
```
|
||||
|
||||
## 展示和跳转规则
|
||||
|
||||
- App 按 `items` 返回顺序展示,不需要二次排序。
|
||||
- `tab` 为空或 `h5_url` 为空时,建议 App 跳过该项,避免展示不可点击入口。
|
||||
- 点击 tab 后打开 `h5_url`,由 H5 页面自身处理内部导航。
|
||||
- 如果接口失败,App 可以使用本地缓存的上一版 tabs;没有缓存时展示空态。
|
||||
|
||||
@ -20,16 +20,16 @@ Flutter 只有在 `code == "OK"` 时读取 `data`。其他错误必须记录 `re
|
||||
已实现:
|
||||
|
||||
- `GET /api/v1/wallet/recharge/products`:App 端充值商品列表。
|
||||
- `POST /api/v1/wallet/payments/google/confirm`:提交 Google purchase token,后端调用 Google Play Developer API 校验、入账、写支付订单审计并 consume。
|
||||
- `GET /api/v1/wallet/me/balances`:刷新金币余额。
|
||||
- 钱包余额 IM 通知:`WalletBalanceChanged`。
|
||||
- `payment_orders`:保存 Google provider 订单审计,purchase token 只落 SHA256,不落明文。
|
||||
|
||||
后端待补齐:
|
||||
|
||||
- `POST /api/v1/wallet/payments/google/confirm`:提交 Google purchase token,后端校验并入账。
|
||||
- Google Play RTDN / Pub/Sub 回调:只做补偿通知,不替代客户端确认接口。
|
||||
- `payment_orders` 和 Google provider 订单审计表。
|
||||
|
||||
Flutter 可以先按本文契约接商品列表和本地 Google Billing 流程;确认支付接口需要等后端补齐后联调。
|
||||
Flutter 可以按本文契约联调商品列表、本地 Google Billing 流程和确认支付接口。
|
||||
|
||||
## 2. Flutter 依赖
|
||||
|
||||
@ -200,7 +200,7 @@ void startPurchaseListener() {
|
||||
|
||||
## 6. 确认 Google 支付
|
||||
|
||||
后端待实现接口:
|
||||
后端已实现接口:
|
||||
|
||||
`POST /api/v1/wallet/payments/google/confirm`
|
||||
|
||||
@ -214,7 +214,7 @@ void startPurchaseListener() {
|
||||
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
|
||||
| `X-App-Platform` | 是 | 固定传 `android`。 |
|
||||
|
||||
请求体建议契约:
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -232,7 +232,7 @@ void startPurchaseListener() {
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `command_id` | 是 | 客户端幂等 ID。同一个 purchase token 重试必须使用同一个值。 |
|
||||
| `command_id` | 是 | 客户端幂等 ID。同一个 purchase token 重试建议使用同一个值。后端最终还会按 purchase token 去重。 |
|
||||
| `product_id` | 是 | 后端商品列表返回的本地商品 ID。 |
|
||||
| `product_code` | 是 | 后端商品列表返回的 Google productId。 |
|
||||
| `package_name` | 是 | 当前 Android 包名。 |
|
||||
@ -246,7 +246,7 @@ void startPurchaseListener() {
|
||||
final String commandId = 'google-pay-${sha256Of(purchaseToken).substring(0, 24)}';
|
||||
```
|
||||
|
||||
成功响应建议契约:
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
@ -254,7 +254,7 @@ final String commandId = 'google-pay-${sha256Of(purchaseToken).substring(0, 24)}
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"payment_order_id": "pay_google_xxx",
|
||||
"payment_order_id": "gpay_xxx",
|
||||
"transaction_id": "wtx_xxx",
|
||||
"status": "credited",
|
||||
"product_id": 11,
|
||||
@ -276,16 +276,26 @@ Flutter 成功处理:
|
||||
|
||||
- `status=credited`:展示充值成功,刷新 `/wallet/me/balances`。
|
||||
- `idempotent_replay=true`:按成功处理,不重复提示异常。
|
||||
- `consume_state=consume_pending`:仍按到账成功处理;后端会补偿 consume。
|
||||
- `consume_state=consumed`:后端已完成 Google consume。
|
||||
- `consume_state=consume_pending`:仍按到账成功处理;说明入账已成功但后端 consume 调用短时失败,下一次提交同一 token 会继续尝试 consume。
|
||||
- 后端成功后调用 `InAppPurchase.instance.completePurchase(purchaseDetails)`,结束客户端 pending 状态。
|
||||
|
||||
失败处理:
|
||||
|
||||
- `UNAUTHORIZED`:登录失效,走登录态恢复。
|
||||
- `INVALID_ARGUMENT`:商品 ID、token、包名等参数错误,记录 `request_id`。
|
||||
- `INVALID_ARGUMENT`:商品 ID、token、包名等参数错误,或 Google 判定 token 无效,记录 `request_id`。
|
||||
- `CONFLICT`:商品未上架、商品区域不匹配、Google purchase state 不是 `PURCHASED`、Google productId/orderId 与后端商品不一致;不入账。
|
||||
- `UPSTREAM_ERROR` / `INTERNAL_ERROR`:保留订单为待确认,稍后重试。
|
||||
- provider 校验失败:不入账,不 complete purchase;记录 `request_id` 并触发补单或客服入口。
|
||||
|
||||
后端校验和审计规则:
|
||||
|
||||
- 后端使用 Google Play Developer API `purchases.productsv2.getproductpurchasev2` 查询 purchase token,只接受 `purchaseState=PURCHASED`。
|
||||
- Google 返回的 `productLineItem[0].productId` 必须等于后端商品 `product_code`。
|
||||
- 后端以 `payment_orders(app_code, provider, purchase_token_hash)` 做 provider 幂等,同一个 token 不会重复发币。
|
||||
- 后端入账成功后写 `wallet_transactions`、`wallet_entries`、`wallet_recharge_records`、`payment_orders` 和 `wallet_outbox`。
|
||||
- 后端不会记录明文 `purchase_token`,只保存 SHA256 hash 和 Google 返回快照。
|
||||
|
||||
## 7. 补单规则
|
||||
|
||||
Flutter 必须做补单,因为用户可能在支付成功后断网、杀进程或后端短时失败。
|
||||
|
||||
@ -2156,6 +2156,38 @@ paths:
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/wallet/payments/google/confirm:
|
||||
post:
|
||||
tags:
|
||||
- wallet
|
||||
summary: 确认 Google Play 充值支付
|
||||
operationId: confirmGooglePayment
|
||||
description: App 支付成功后提交 Google purchase token;后端调用 Google Play Developer API 校验,成功后写入钱包账本、充值记录、payment_orders 审计并尝试 consume。接口支持按 purchase token 幂等重试。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/GooglePaymentConfirmRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 校验成功并已入账;`consume_state=consume_pending` 也表示已入账成功,只是 Google consume 待后续重试。
|
||||
schema:
|
||||
$ref: "#/definitions/GooglePaymentConfirmEnvelope"
|
||||
"400":
|
||||
$ref: "#/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/responses/Forbidden"
|
||||
"409":
|
||||
$ref: "#/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/wallet/coin-seller/transfer:
|
||||
post:
|
||||
tags:
|
||||
@ -3800,6 +3832,75 @@ definitions:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/definitions/RechargeProductData"
|
||||
GooglePaymentConfirmRequest:
|
||||
type: object
|
||||
required:
|
||||
- command_id
|
||||
- product_id
|
||||
- package_name
|
||||
- purchase_token
|
||||
properties:
|
||||
command_id:
|
||||
type: string
|
||||
description: 客户端确认支付幂等键;同一 purchase token 重试建议复用。
|
||||
product_id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 后端充值商品 ID。
|
||||
product_code:
|
||||
type: string
|
||||
description: 后端返回的 Google productId,必须与 Play Console 商品 ID 一致。
|
||||
package_name:
|
||||
type: string
|
||||
description: Android 包名。
|
||||
purchase_token:
|
||||
type: string
|
||||
description: Google Play Billing 返回的 serverVerificationData,服务端只保存 SHA256。
|
||||
order_id:
|
||||
type: string
|
||||
description: Google orderId;客户端拿不到时可不传。
|
||||
purchase_time_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 客户端购买时间,UTC epoch ms,仅用于排障。
|
||||
GooglePaymentConfirmData:
|
||||
type: object
|
||||
properties:
|
||||
payment_order_id:
|
||||
type: string
|
||||
transaction_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
enum: [credited]
|
||||
product_id:
|
||||
type: integer
|
||||
format: int64
|
||||
product_code:
|
||||
type: string
|
||||
coin_amount:
|
||||
type: integer
|
||||
format: int64
|
||||
balance:
|
||||
type: object
|
||||
properties:
|
||||
asset_type:
|
||||
type: string
|
||||
example: COIN
|
||||
available_amount:
|
||||
type: integer
|
||||
format: int64
|
||||
frozen_amount:
|
||||
type: integer
|
||||
format: int64
|
||||
version:
|
||||
type: integer
|
||||
format: int64
|
||||
idempotent_replay:
|
||||
type: boolean
|
||||
consume_state:
|
||||
type: string
|
||||
enum: [consume_pending, consumed]
|
||||
CoinSellerTransferRequest:
|
||||
type: object
|
||||
required:
|
||||
@ -5095,6 +5196,13 @@ definitions:
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/RechargeProductListData"
|
||||
GooglePaymentConfirmEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/GooglePaymentConfirmData"
|
||||
CoinSellerTransferEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
|
||||
@ -40,7 +40,7 @@ wallet_service:
|
||||
addr: "10.2.1.15:13004"
|
||||
request_timeout: "3s"
|
||||
room_service:
|
||||
addr: "10.2.1.15:13001"
|
||||
addr: "10.2.1.16:13001"
|
||||
request_timeout: "3s"
|
||||
activity_service:
|
||||
addr: "10.2.1.16:13006"
|
||||
|
||||
@ -88,6 +88,7 @@ type drawDTO struct {
|
||||
RuleVersion int64 `json:"rule_version"`
|
||||
ExperiencePool string `json:"experience_pool"`
|
||||
SelectedTierID string `json:"selected_tier_id"`
|
||||
MultiplierPPM int64 `json:"multiplier_ppm"`
|
||||
BaseRewardCoins int64 `json:"base_reward_coins"`
|
||||
RoomAtmosphereRewardCoins int64 `json:"room_atmosphere_reward_coins"`
|
||||
ActivitySubsidyCoins int64 `json:"activity_subsidy_coins"`
|
||||
@ -346,6 +347,7 @@ func drawFromProto(draw *activityv1.LuckyGiftDrawResult) drawDTO {
|
||||
RuleVersion: draw.GetRuleVersion(),
|
||||
ExperiencePool: draw.GetExperiencePool(),
|
||||
SelectedTierID: draw.GetSelectedTierId(),
|
||||
MultiplierPPM: draw.GetMultiplierPpm(),
|
||||
BaseRewardCoins: draw.GetBaseRewardCoins(),
|
||||
RoomAtmosphereRewardCoins: draw.GetRoomAtmosphereRewardCoins(),
|
||||
ActivitySubsidyCoins: draw.GetActivitySubsidyCoins(),
|
||||
|
||||
@ -351,6 +351,37 @@ func (h *Handler) UpdateGiftType(c *gin.Context) {
|
||||
response.OK(c, item)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateGiftTypes(c *gin.Context) {
|
||||
var req giftTypesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "礼物类型参数不正确")
|
||||
return
|
||||
}
|
||||
if len(req.Items) == 0 {
|
||||
response.BadRequest(c, "礼物类型不能为空")
|
||||
return
|
||||
}
|
||||
items := make([]giftTypeDTO, 0, len(req.Items))
|
||||
tabKeys := make([]string, 0, len(req.Items))
|
||||
for _, requestItem := range req.Items {
|
||||
tabKey := strings.TrimSpace(requestItem.TabKey)
|
||||
if tabKey == "" {
|
||||
response.BadRequest(c, "类型编码不正确")
|
||||
return
|
||||
}
|
||||
resp, err := h.wallet.UpsertGiftTypeConfig(c.Request.Context(), requestItem.upsertProto(c))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
item := giftTypeFromProto(resp.GetGiftType())
|
||||
items = append(items, item)
|
||||
tabKeys = append(tabKeys, item.TabKey)
|
||||
}
|
||||
h.auditLog(c, "update-gift-types", "gift_type_configs", "batch", "success", strings.Join(tabKeys, ","))
|
||||
response.OK(c, items)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateGift(c *gin.Context) {
|
||||
var req giftRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@ -122,6 +122,18 @@ type giftTypeRequest struct {
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
}
|
||||
|
||||
type giftTypesRequest struct {
|
||||
Items []giftTypeItemRequest `json:"items"`
|
||||
}
|
||||
|
||||
type giftTypeItemRequest struct {
|
||||
TabKey string `json:"tabKey"`
|
||||
DisplayName string `json:"displayName"`
|
||||
TabName string `json:"tabName"`
|
||||
Status string `json:"status"`
|
||||
SortOrder int32 `json:"sortOrder"`
|
||||
}
|
||||
|
||||
type grantResourceRequest struct {
|
||||
CommandID string `json:"commandId"`
|
||||
TargetUserID int64 `json:"targetUserId"`
|
||||
@ -326,6 +338,15 @@ func (r giftTypeRequest) upsertProto(c *gin.Context, typeCode string) *walletv1.
|
||||
}
|
||||
}
|
||||
|
||||
func (r giftTypeItemRequest) upsertProto(c *gin.Context) *walletv1.UpsertGiftTypeConfigRequest {
|
||||
return giftTypeRequest{
|
||||
DisplayName: r.DisplayName,
|
||||
TabName: r.TabName,
|
||||
Status: r.Status,
|
||||
SortOrder: r.SortOrder,
|
||||
}.upsertProto(c, r.TabKey)
|
||||
}
|
||||
|
||||
func managerGrantEnabledOrDefault(value *bool) *bool {
|
||||
enabled := true
|
||||
if value != nil {
|
||||
|
||||
@ -32,6 +32,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
|
||||
protected.GET("/admin/gifts", middleware.RequirePermission("gift:view"), h.ListGifts)
|
||||
protected.GET("/admin/gift-types", middleware.RequirePermission("gift:view"), h.ListGiftTypes)
|
||||
protected.PUT("/admin/gift-types", middleware.RequirePermission("gift:update"), h.UpdateGiftTypes)
|
||||
protected.PUT("/admin/gift-types/:type_code", middleware.RequirePermission("gift:update"), h.UpdateGiftType)
|
||||
protected.POST("/admin/gifts", middleware.RequirePermission("gift:create"), h.CreateGift)
|
||||
protected.PUT("/admin/gifts/:gift_id", middleware.RequirePermission("gift:update"), h.UpdateGift)
|
||||
|
||||
63
server/admin/migrations/023_activity_extra_navigation.sql
Normal file
63
server/admin/migrations/023_activity_extra_navigation.sql
Normal file
@ -0,0 +1,63 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 补齐活动管理下由既有 bootstrap seed 提供、但历史 migration 未覆盖的生产菜单。
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||
('用户榜单查看', 'user-leaderboard:view', 'menu', '', @now_ms, @now_ms),
|
||||
('红包配置查看', 'red-packet:view', 'menu', '', @now_ms, @now_ms),
|
||||
('红包配置更新', 'red-packet:update', 'button', '', @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
kind = VALUES(kind),
|
||||
description = VALUES(description),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '用户榜单', 'user-leaderboard', '/activities/user-leaderboards', 'leaderboard', 'user-leaderboard:view', 74, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'activities'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '红包配置', 'red-packet', '/activities/red-packets', 'redeem', 'red-packet:view', 76, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'activities'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code = 'platform-admin'
|
||||
AND admin_permission.code IN ('user-leaderboard:view', 'red-packet:view', 'red-packet:update');
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code = 'ops-admin'
|
||||
AND admin_permission.code IN ('user-leaderboard:view', 'red-packet:view', 'red-packet:update');
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code IN ('auditor', 'readonly')
|
||||
AND admin_permission.code IN ('user-leaderboard:view', 'red-packet:view');
|
||||
@ -0,0 +1,38 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- 老生产库曾由早期 AutoMigrate 建表,缺少当前模型依赖的描述和创建时间列。
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_app_configs' AND COLUMN_NAME = 'description') = 0,
|
||||
'ALTER TABLE admin_app_configs ADD COLUMN description VARCHAR(255) NOT NULL DEFAULT '''' COMMENT ''描述信息'' AFTER value',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_app_configs' AND COLUMN_NAME = 'created_at_ms') = 0,
|
||||
'ALTER TABLE admin_app_configs ADD COLUMN created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''创建时间,UTC epoch ms'' AFTER description',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_app_banners' AND COLUMN_NAME = 'created_at_ms') = 0,
|
||||
'ALTER TABLE admin_app_banners ADD COLUMN created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''创建时间,UTC epoch ms'' AFTER ends_at_ms',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_app_versions' AND COLUMN_NAME = 'created_at_ms') = 0,
|
||||
'ALTER TABLE admin_app_versions ADD COLUMN created_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''创建时间,UTC epoch ms'' AFTER description',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
@ -860,3 +860,30 @@ CREATE TABLE IF NOT EXISTS message_fanout_jobs (
|
||||
UNIQUE KEY uk_message_fanout_command (app_code, command_id),
|
||||
KEY idx_message_fanout_status (app_code, status, next_run_at_ms, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息分发任务表';
|
||||
|
||||
-- 本地开发必须开箱即可验证幸运礼物链路;INSERT IGNORE 只补缺省奖池,不覆盖后台已发布规则。
|
||||
SET @lucky_seed_now_ms := 1779259000000;
|
||||
SET @lucky_seed_tiers := JSON_ARRAY(
|
||||
JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'novice', 'tier_id', 'novice_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'intermediate', 'tier_id', 'intermediate_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_none', 'reward_coins', 0, 'multiplier_ppm', 0, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_1x', 'reward_coins', 100, 'multiplier_ppm', 1000000, 'weight', 0, 'high_water_only', false, 'enabled', true),
|
||||
JSON_OBJECT('pool', 'advanced', 'tier_id', 'advanced_2x', 'reward_coins', 200, 'multiplier_ppm', 2000000, 'weight', 0, 'high_water_only', false, 'enabled', true)
|
||||
);
|
||||
INSERT IGNORE INTO lucky_gift_rules (
|
||||
app_code, gift_id, enabled, rule_version, gift_price, target_rtp_ppm, pool_rate_ppm,
|
||||
global_window_draws, gift_window_draws, novice_draw_limit, intermediate_draw_limit,
|
||||
high_multiplier, high_water_pool_multiple, platform_pool_weight_ppm, room_pool_weight_ppm,
|
||||
gift_pool_weight_ppm, initial_platform_pool, initial_gift_pool, initial_room_pool,
|
||||
platform_reserve, gift_reserve, room_reserve, max_single_payout, user_hourly_payout_cap,
|
||||
user_daily_payout_cap, device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap,
|
||||
room_atmosphere_rate_ppm, room_atmosphere_initial, room_atmosphere_reserve,
|
||||
activity_budget, activity_daily_limit, large_tier_enabled, tiers_json,
|
||||
updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', 'lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, @lucky_seed_tiers, 0, @lucky_seed_now_ms, @lucky_seed_now_ms),
|
||||
('lalu', 'super_lucky', true, 1, 100, 950000, 950000, 100000, 100000, 2000, 20000, 100, 2, 200000, 300000, 500000, 9500000, 23750000, 5000000, 1000000, 1000000, 100000, 50000, 3420000, 61560000, 102600000, 68400000, 1231200000, 10000, 100000, 10000, 0, 0, true, @lucky_seed_tiers, 0, @lucky_seed_now_ms, @lucky_seed_now_ms);
|
||||
|
||||
@ -112,6 +112,7 @@ type DrawResult struct {
|
||||
RuleVersion int64
|
||||
ExperiencePool string
|
||||
SelectedTierID string
|
||||
MultiplierPPM int64
|
||||
BaseRewardCoins int64
|
||||
RoomAtmosphereRewardCoins int64
|
||||
ActivitySubsidyCoins int64
|
||||
|
||||
@ -415,6 +415,7 @@ func (r *Repository) ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCo
|
||||
RuleVersion: config.RuleVersion,
|
||||
ExperiencePool: experiencePool,
|
||||
SelectedTierID: candidate.TierID,
|
||||
MultiplierPPM: candidate.MultiplierPPM,
|
||||
BaseRewardCoins: candidate.BaseReward,
|
||||
RoomAtmosphereRewardCoins: candidate.RoomReward,
|
||||
ActivitySubsidyCoins: candidate.ActivityReward,
|
||||
@ -604,6 +605,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
|
||||
args = append(args, limit, offset)
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT draw_id, command_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(candidate_tiers_json, '$.multiplier_ppm')), '0'),
|
||||
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status,
|
||||
rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
|
||||
@ -619,6 +621,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
|
||||
for rows.Next() {
|
||||
var item domain.DrawResult
|
||||
if err := rows.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID,
|
||||
&item.MultiplierPPM,
|
||||
&item.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
|
||||
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
||||
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
|
||||
@ -1256,6 +1259,7 @@ func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appC
|
||||
}
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT draw_id, command_id, pool_id, gift_id, rule_version, experience_pool, selected_tier_id,
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(candidate_tiers_json, '$.multiplier_ppm')), '0'),
|
||||
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status,
|
||||
rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
|
||||
@ -1265,6 +1269,7 @@ func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appC
|
||||
)
|
||||
var item domain.DrawResult
|
||||
if err := row.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID,
|
||||
&item.MultiplierPPM,
|
||||
&item.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
|
||||
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
||||
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
|
||||
|
||||
@ -270,6 +270,7 @@ func luckyDrawResultToProto(result domain.DrawResult) *activityv1.LuckyGiftDrawR
|
||||
RuleVersion: result.RuleVersion,
|
||||
ExperiencePool: result.ExperiencePool,
|
||||
SelectedTierId: result.SelectedTierID,
|
||||
MultiplierPpm: result.MultiplierPPM,
|
||||
BaseRewardCoins: result.BaseRewardCoins,
|
||||
RoomAtmosphereRewardCoins: result.RoomAtmosphereRewardCoins,
|
||||
ActivitySubsidyCoins: result.ActivitySubsidyCoins,
|
||||
|
||||
@ -66,6 +66,8 @@ type UserCountryQueryClient interface {
|
||||
|
||||
// UserHostClient 抽象 gateway 对 user-service host domain 身份查询能力的依赖。
|
||||
type UserHostClient interface {
|
||||
SearchAgencies(ctx context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error)
|
||||
ApplyToAgency(ctx context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error)
|
||||
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
|
||||
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
|
||||
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
|
||||
@ -294,6 +296,14 @@ func (c *grpcUserCountryQueryClient) ListLoginRiskBlockedCountries(ctx context.C
|
||||
return c.client.ListLoginRiskBlockedCountries(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) SearchAgencies(ctx context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error) {
|
||||
return c.client.SearchAgencies(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) ApplyToAgency(ctx context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error) {
|
||||
return c.client.ApplyToAgency(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
|
||||
return c.client.GetHostProfile(ctx, req)
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ type WalletClient interface {
|
||||
GetWalletValueSummary(ctx context.Context, req *walletv1.GetWalletValueSummaryRequest) (*walletv1.GetWalletValueSummaryResponse, error)
|
||||
GetUserGiftWall(ctx context.Context, req *walletv1.GetUserGiftWallRequest) (*walletv1.GetUserGiftWallResponse, error)
|
||||
ListRechargeProducts(ctx context.Context, req *walletv1.ListRechargeProductsRequest) (*walletv1.ListRechargeProductsResponse, error)
|
||||
ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error)
|
||||
GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error)
|
||||
ListWalletTransactions(ctx context.Context, req *walletv1.ListWalletTransactionsRequest) (*walletv1.ListWalletTransactionsResponse, error)
|
||||
ApplyWithdrawal(ctx context.Context, req *walletv1.ApplyWithdrawalRequest) (*walletv1.ApplyWithdrawalResponse, error)
|
||||
@ -66,6 +67,10 @@ func (c *grpcWalletClient) ListRechargeProducts(ctx context.Context, req *wallet
|
||||
return c.client.ListRechargeProducts(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
|
||||
return c.client.ConfirmGooglePayment(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcWalletClient) GetDiamondExchangeConfig(ctx context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
|
||||
return c.client.GetDiamondExchangeConfig(ctx, req)
|
||||
}
|
||||
|
||||
@ -221,6 +221,10 @@ func RequireCompletedProfile(next http.HandlerFunc) http.HandlerFunc {
|
||||
// RoomMeta 把外部 HTTP 请求上下文转换成 room-service 命令元信息。
|
||||
// request_id 只做链路追踪;command_id 必须来自客户端用户动作,gateway 只裁剪空白。
|
||||
func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.RequestMeta {
|
||||
sessionID := auth.SessionIDFromContext(request.Context())
|
||||
if sessionID == "" {
|
||||
sessionID = idgen.New("sess")
|
||||
}
|
||||
return &roomv1.RequestMeta{
|
||||
RequestId: RequestIDFromContext(request.Context()),
|
||||
CommandId: strings.TrimSpace(commandID),
|
||||
@ -228,7 +232,7 @@ func RoomMeta(request *http.Request, roomID string, commandID string) *roomv1.Re
|
||||
RoomId: roomID,
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
GatewayNodeId: "gateway-local",
|
||||
SessionId: idgen.New("sess"),
|
||||
SessionId: sessionID,
|
||||
SentAtMs: time.Now().UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -67,6 +67,8 @@ type UserHandlers struct {
|
||||
GetMyIdentity http.HandlerFunc
|
||||
GetMyHostIdentity http.HandlerFunc
|
||||
GetMyRoleSummary http.HandlerFunc
|
||||
SearchHostAgencies http.HandlerFunc
|
||||
ApplyToHostAgency http.HandlerFunc
|
||||
CompleteMyOnboarding http.HandlerFunc
|
||||
UpdateMyProfile http.HandlerFunc
|
||||
ChangeMyCountry http.HandlerFunc
|
||||
@ -154,6 +156,7 @@ type WalletHandlers struct {
|
||||
GetWalletOverview http.HandlerFunc
|
||||
GetMyBalances http.HandlerFunc
|
||||
ListRechargeProducts http.HandlerFunc
|
||||
ConfirmGooglePayment http.HandlerFunc
|
||||
GetDiamondExchangeConfig http.HandlerFunc
|
||||
ApplyWithdrawal http.HandlerFunc
|
||||
ListCoinTransactions http.HandlerFunc
|
||||
@ -267,6 +270,8 @@ func (r routes) registerUserRoutes() {
|
||||
r.auth("/users/me/identity", "", h.GetMyIdentity)
|
||||
r.profile("/users/me/host-identity", "", h.GetMyHostIdentity)
|
||||
r.profile("/users/me/role-summary", "", h.GetMyRoleSummary)
|
||||
r.profile("/host/agencies/search", http.MethodGet, h.SearchHostAgencies)
|
||||
r.profile("/host/agency-applications", http.MethodPost, h.ApplyToHostAgency)
|
||||
r.auth("/users/me/onboarding/complete", "", h.CompleteMyOnboarding)
|
||||
r.profile("/users/me/profile/update", "", h.UpdateMyProfile)
|
||||
r.profile("/users/me/country/change", "", h.ChangeMyCountry)
|
||||
@ -361,6 +366,7 @@ func (r routes) registerWalletRoutes() {
|
||||
r.profile("/wallet/me/overview", http.MethodGet, h.GetWalletOverview)
|
||||
r.profile("/wallet/me/balances", "", h.GetMyBalances)
|
||||
r.profile("/wallet/recharge/products", http.MethodGet, h.ListRechargeProducts)
|
||||
r.profile("/wallet/payments/google/confirm", http.MethodPost, h.ConfirmGooglePayment)
|
||||
r.profile("/wallet/diamond-exchange/config", http.MethodGet, h.GetDiamondExchangeConfig)
|
||||
r.profile("/wallet/withdrawals/apply", http.MethodPost, h.ApplyWithdrawal)
|
||||
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)
|
||||
|
||||
@ -52,6 +52,7 @@ type fakeRoomClient struct {
|
||||
lastKick *roomv1.KickUserRequest
|
||||
lastUnban *roomv1.UnbanUserRequest
|
||||
lastGift *roomv1.SendGiftRequest
|
||||
sendGiftResp *roomv1.SendGiftResponse
|
||||
lastFollowRoom *roomv1.FollowRoomRequest
|
||||
lastUnfollowRoom *roomv1.UnfollowRoomRequest
|
||||
createErr error
|
||||
@ -219,6 +220,9 @@ func (f *fakeRoomClient) UnbanUser(_ context.Context, req *roomv1.UnbanUserReque
|
||||
|
||||
func (f *fakeRoomClient) SendGift(_ context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
|
||||
f.lastGift = req
|
||||
if f.sendGiftResp != nil {
|
||||
return f.sendGiftResp, nil
|
||||
}
|
||||
return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14}}, nil
|
||||
}
|
||||
|
||||
@ -315,21 +319,27 @@ type fakeUserCountryQueryClient struct {
|
||||
}
|
||||
|
||||
type fakeUserHostClient struct {
|
||||
last *userv1.GetCoinSellerProfileRequest
|
||||
profile *userv1.CoinSellerProfile
|
||||
err error
|
||||
lastHost *userv1.GetHostProfileRequest
|
||||
hostProfile *userv1.HostProfile
|
||||
hostErr error
|
||||
lastBD *userv1.GetBDProfileRequest
|
||||
bdProfile *userv1.BDProfile
|
||||
bdErr error
|
||||
lastRoleSummary *userv1.GetUserRoleSummaryRequest
|
||||
roleSummary *userv1.UserRoleSummary
|
||||
roleSummaryErr error
|
||||
lastCapability *userv1.CheckBusinessCapabilityRequest
|
||||
capabilityResp *userv1.CheckBusinessCapabilityResponse
|
||||
capabilityErr error
|
||||
lastSearchAgencies *userv1.SearchAgenciesRequest
|
||||
searchAgenciesResp *userv1.SearchAgenciesResponse
|
||||
searchAgenciesErr error
|
||||
lastApplyAgency *userv1.ApplyToAgencyRequest
|
||||
applyAgencyResp *userv1.ApplyToAgencyResponse
|
||||
applyAgencyErr error
|
||||
last *userv1.GetCoinSellerProfileRequest
|
||||
profile *userv1.CoinSellerProfile
|
||||
err error
|
||||
lastHost *userv1.GetHostProfileRequest
|
||||
hostProfile *userv1.HostProfile
|
||||
hostErr error
|
||||
lastBD *userv1.GetBDProfileRequest
|
||||
bdProfile *userv1.BDProfile
|
||||
bdErr error
|
||||
lastRoleSummary *userv1.GetUserRoleSummaryRequest
|
||||
roleSummary *userv1.UserRoleSummary
|
||||
roleSummaryErr error
|
||||
lastCapability *userv1.CheckBusinessCapabilityRequest
|
||||
capabilityResp *userv1.CheckBusinessCapabilityResponse
|
||||
capabilityErr error
|
||||
}
|
||||
|
||||
type fakeWalletClient struct {
|
||||
@ -346,6 +356,8 @@ type fakeWalletClient struct {
|
||||
giftWallResp *walletv1.GetUserGiftWallResponse
|
||||
lastRechargeProducts *walletv1.ListRechargeProductsRequest
|
||||
rechargeProductsResp *walletv1.ListRechargeProductsResponse
|
||||
lastGoogleConfirm *walletv1.ConfirmGooglePaymentRequest
|
||||
googleConfirmResp *walletv1.ConfirmGooglePaymentResponse
|
||||
lastDiamondExchange *walletv1.GetDiamondExchangeConfigRequest
|
||||
diamondExchangeResp *walletv1.GetDiamondExchangeConfigResponse
|
||||
lastTransactions *walletv1.ListWalletTransactionsRequest
|
||||
@ -901,6 +913,28 @@ func (f *fakeBroadcastClient) RemoveRegionBroadcastMember(_ context.Context, req
|
||||
return &activityv1.RemoveRegionBroadcastMemberResponse{GroupId: "hy_lalu_bc_r_1001", Removed: true}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHostClient) SearchAgencies(_ context.Context, req *userv1.SearchAgenciesRequest) (*userv1.SearchAgenciesResponse, error) {
|
||||
f.lastSearchAgencies = req
|
||||
if f.searchAgenciesErr != nil {
|
||||
return nil, f.searchAgenciesErr
|
||||
}
|
||||
if f.searchAgenciesResp != nil {
|
||||
return f.searchAgenciesResp, nil
|
||||
}
|
||||
return &userv1.SearchAgenciesResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHostClient) ApplyToAgency(_ context.Context, req *userv1.ApplyToAgencyRequest) (*userv1.ApplyToAgencyResponse, error) {
|
||||
f.lastApplyAgency = req
|
||||
if f.applyAgencyErr != nil {
|
||||
return nil, f.applyAgencyErr
|
||||
}
|
||||
if f.applyAgencyResp != nil {
|
||||
return f.applyAgencyResp, nil
|
||||
}
|
||||
return &userv1.ApplyToAgencyResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHostClient) GetHostProfile(_ context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
|
||||
f.lastHost = req
|
||||
if f.hostErr != nil {
|
||||
@ -1041,6 +1075,17 @@ func (f *fakeWalletClient) ListRechargeProducts(_ context.Context, req *walletv1
|
||||
return &walletv1.ListRechargeProductsResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) ConfirmGooglePayment(_ context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
|
||||
f.lastGoogleConfirm = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.googleConfirmResp != nil {
|
||||
return f.googleConfirmResp, nil
|
||||
}
|
||||
return &walletv1.ConfirmGooglePaymentResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) GetDiamondExchangeConfig(_ context.Context, req *walletv1.GetDiamondExchangeConfigRequest) (*walletv1.GetDiamondExchangeConfigResponse, error) {
|
||||
f.lastDiamondExchange = req
|
||||
if f.err != nil {
|
||||
@ -1515,6 +1560,55 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{sendGiftResp: &roomv1.SendGiftResponse{
|
||||
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14, ServerTimeMs: 1_779_258_000_000},
|
||||
BillingReceiptId: "receipt-lucky",
|
||||
RoomHeat: 100,
|
||||
LuckyGift: &roomv1.LuckyGiftDrawResult{
|
||||
Enabled: true,
|
||||
DrawId: "lucky_draw_test",
|
||||
CommandId: "cmd-gift-lucky",
|
||||
PoolId: "super_lucky",
|
||||
GiftId: "rose",
|
||||
RuleVersion: 12,
|
||||
ExperiencePool: "novice",
|
||||
SelectedTierId: "novice_2x",
|
||||
MultiplierPpm: 2_000_000,
|
||||
EffectiveRewardCoins: 200,
|
||||
RewardStatus: "pending",
|
||||
CreatedAtMs: 1_779_258_000_000,
|
||||
},
|
||||
}}
|
||||
router := NewHandlerWithClients(roomClient, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/gift/send", bytes.NewReader([]byte(`{"room_id":"room-1","command_id":"cmd-gift-lucky","target_user_id":43,"gift_id":"rose","gift_count":1,"pool_id":"super_lucky"}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
LuckyGift struct {
|
||||
DrawID string `json:"draw_id"`
|
||||
PoolID string `json:"pool_id"`
|
||||
MultiplierPPM int64 `json:"multiplier_ppm"`
|
||||
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
|
||||
} `json:"lucky_gift"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 2_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 200 {
|
||||
t.Fatalf("lucky_gift response mismatch: %+v", envelope.Data.LuckyGift)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinRoomReturnsInitialDataWithIMGroupRTCTokenAndProfiles(t *testing.T) {
|
||||
previousNow := roomapi.TimeNow
|
||||
roomapi.TimeNow = func() time.Time { return time.Unix(1_700_000_000, 0) }
|
||||
@ -2500,6 +2594,77 @@ func TestGetMyHostIdentityTreatsMissingProfilesAsFalse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{searchAgenciesResp: &userv1.SearchAgenciesResponse{Agencies: []*userv1.Agency{
|
||||
{
|
||||
AgencyId: 7001,
|
||||
OwnerUserId: 901,
|
||||
RegionId: 30,
|
||||
Name: "Admin Seed Agency",
|
||||
Status: "active",
|
||||
JoinEnabled: true,
|
||||
MaxHosts: 100,
|
||||
CreatedAtMs: 1700000000000,
|
||||
UpdatedAtMs: 1700000001000,
|
||||
},
|
||||
}}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/host/agencies/search?short_id=900901&page_size=99", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-host-agency-search")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if hostClient.lastSearchAgencies == nil || hostClient.lastSearchAgencies.GetUserId() != 42 || hostClient.lastSearchAgencies.GetKeyword() != "900901" || hostClient.lastSearchAgencies.GetPageSize() != 20 {
|
||||
t.Fatalf("host agency search request mismatch: %+v", hostClient.lastSearchAgencies)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
data := response.Data.(map[string]any)
|
||||
items := data["items"].([]any)
|
||||
first := items[0].(map[string]any)
|
||||
if first["agency_id"] != "7001" || first["owner_user_id"] != "901" || first["name"] != "Admin Seed Agency" || data["page_size"] != float64(20) {
|
||||
t.Fatalf("host agency search response mismatch: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyToHostAgencyPropagatesClientCommandID(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{applyAgencyResp: &userv1.ApplyToAgencyResponse{Application: &userv1.AgencyApplication{
|
||||
ApplicationId: 8001,
|
||||
CommandId: "cmd-apply-agency",
|
||||
ApplicantUserId: 42,
|
||||
AgencyId: 7001,
|
||||
RegionId: 30,
|
||||
Status: "pending",
|
||||
CreatedAtMs: 1700000000000,
|
||||
UpdatedAtMs: 1700000001000,
|
||||
}}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/host/agency-applications", bytes.NewReader([]byte(`{"command_id":"cmd-apply-agency","agency_id":"7001"}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-host-agency-apply")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if hostClient.lastApplyAgency == nil || hostClient.lastApplyAgency.GetUserId() != 42 || hostClient.lastApplyAgency.GetCommandId() != "cmd-apply-agency" || hostClient.lastApplyAgency.GetAgencyId() != 7001 {
|
||||
t.Fatalf("host agency apply request mismatch: %+v", hostClient.lastApplyAgency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) {
|
||||
client := &fakeRoomClient{}
|
||||
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
|
||||
@ -3624,6 +3789,60 @@ func TestListRechargeProductsUsesProfileRegionAndPlatform(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmGooglePaymentUsesAuthenticatedUserAndProfileRegion(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{googleConfirmResp: &walletv1.ConfirmGooglePaymentResponse{
|
||||
PaymentOrderId: "gpay_abc",
|
||||
TransactionId: "wtx_abc",
|
||||
Status: "credited",
|
||||
ProductId: 11,
|
||||
ProductCode: "iap_android_11",
|
||||
CoinAmount: 1500,
|
||||
Balance: &walletv1.AssetBalance{
|
||||
AssetType: "COIN",
|
||||
AvailableAmount: 2500,
|
||||
},
|
||||
ConsumeState: "consumed",
|
||||
}}
|
||||
profileClient := &fakeUserProfileClient{regionID: 2002}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
body := `{"command_id":"pay-cmd-1","product_id":11,"product_code":"iap_android_11","package_name":"com.hyapp.lalu","purchase_token":"purchase-token","order_id":"GPA.1","purchase_time_ms":1710000000000}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/wallet/payments/google/confirm", bytes.NewBufferString(body))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Request-ID", "req-google-confirm")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
|
||||
t.Fatalf("profile lookup mismatch: %+v", profileClient.lastGet)
|
||||
}
|
||||
if walletClient.lastGoogleConfirm == nil ||
|
||||
walletClient.lastGoogleConfirm.GetUserId() != 42 ||
|
||||
walletClient.lastGoogleConfirm.GetRegionId() != 2002 ||
|
||||
walletClient.lastGoogleConfirm.GetProductId() != 11 ||
|
||||
walletClient.lastGoogleConfirm.GetProductCode() != "iap_android_11" ||
|
||||
walletClient.lastGoogleConfirm.GetPackageName() != "com.hyapp.lalu" ||
|
||||
walletClient.lastGoogleConfirm.GetPurchaseToken() != "purchase-token" ||
|
||||
walletClient.lastGoogleConfirm.GetCommandId() != "pay-cmd-1" ||
|
||||
walletClient.lastGoogleConfirm.GetRequestId() != assertGeneratedRequestID(t, recorder, "req-google-confirm") {
|
||||
t.Fatalf("google confirm request mismatch: %+v", walletClient.lastGoogleConfirm)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
data, ok := response.Data.(map[string]any)
|
||||
if response.Code != httpkit.CodeOK || !ok || data["payment_order_id"] != "gpay_abc" || data["consume_state"] != "consumed" || data["coin_amount"] != float64(1500) {
|
||||
t.Fatalf("google confirm response mismatch: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoinSellerTransferChecksIdentityAndPropagatesWalletCommand(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{transferResp: &walletv1.TransferCoinFromSellerResponse{
|
||||
TransactionId: "wtx-coin-seller",
|
||||
|
||||
@ -48,6 +48,8 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
|
||||
GetMyIdentity: h.getMyIdentity,
|
||||
GetMyHostIdentity: h.getMyHostIdentity,
|
||||
GetMyRoleSummary: h.getMyRoleSummary,
|
||||
SearchHostAgencies: h.searchHostAgencies,
|
||||
ApplyToHostAgency: h.applyToHostAgency,
|
||||
CompleteMyOnboarding: h.completeMyOnboarding,
|
||||
UpdateMyProfile: h.updateMyProfile,
|
||||
ChangeMyCountry: h.changeMyCountry,
|
||||
|
||||
@ -0,0 +1,185 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
type hostAgencyData struct {
|
||||
AgencyID string `json:"agency_id"`
|
||||
OwnerUserID string `json:"owner_user_id"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
ParentBDUserID string `json:"parent_bd_user_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
JoinEnabled bool `json:"join_enabled"`
|
||||
MaxHosts int32 `json:"max_hosts"`
|
||||
CreatedByUserID string `json:"created_by_user_id,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type hostAgencyApplicationData struct {
|
||||
ApplicationID string `json:"application_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
ApplicantUserID string `json:"applicant_user_id"`
|
||||
AgencyID string `json:"agency_id"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
Status string `json:"status"`
|
||||
ReviewedByUserID string `json:"reviewed_by_user_id,omitempty"`
|
||||
ReviewReason string `json:"review_reason,omitempty"`
|
||||
ReviewedAtMS int64 `json:"reviewed_at_ms,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
func (h *Handler) searchHostAgencies(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userHostClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
keyword := strings.TrimSpace(request.URL.Query().Get("short_id"))
|
||||
if keyword == "" {
|
||||
keyword = strings.TrimSpace(request.URL.Query().Get("keyword"))
|
||||
}
|
||||
if keyword == "" {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
pageSize, ok := httpkit.PositiveInt32Query(request, "page_size", 20)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
if pageSize > 20 {
|
||||
pageSize = 20
|
||||
}
|
||||
resp, err := h.userHostClient.SearchAgencies(request.Context(), &userv1.SearchAgenciesRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
Keyword: keyword,
|
||||
PageSize: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
items := make([]hostAgencyData, 0, len(resp.GetAgencies()))
|
||||
for _, agency := range resp.GetAgencies() {
|
||||
items = append(items, hostAgencyFromProto(agency))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items), "page_size": pageSize})
|
||||
}
|
||||
|
||||
func (h *Handler) applyToHostAgency(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userHostClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
CommandID string `json:"command_id"`
|
||||
CommandIDAlt string `json:"commandId"`
|
||||
AgencyID json.RawMessage `json:"agency_id"`
|
||||
AgencyIDAlt json.RawMessage `json:"agencyId"`
|
||||
}
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
commandID := strings.TrimSpace(body.CommandID)
|
||||
if commandID == "" {
|
||||
commandID = strings.TrimSpace(body.CommandIDAlt)
|
||||
}
|
||||
agencyID, ok := firstRawInt64(body.AgencyID, body.AgencyIDAlt)
|
||||
if commandID == "" || !ok || agencyID <= 0 {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
resp, err := h.userHostClient.ApplyToAgency(request.Context(), &userv1.ApplyToAgencyRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
CommandId: commandID,
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
AgencyId: agencyID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{"application": hostAgencyApplicationFromProto(resp.GetApplication())})
|
||||
}
|
||||
|
||||
func hostAgencyFromProto(agency *userv1.Agency) hostAgencyData {
|
||||
if agency == nil {
|
||||
return hostAgencyData{}
|
||||
}
|
||||
return hostAgencyData{
|
||||
AgencyID: int64String(agency.GetAgencyId()),
|
||||
OwnerUserID: int64String(agency.GetOwnerUserId()),
|
||||
RegionID: agency.GetRegionId(),
|
||||
ParentBDUserID: int64String(agency.GetParentBdUserId()),
|
||||
Name: agency.GetName(),
|
||||
Status: agency.GetStatus(),
|
||||
JoinEnabled: agency.GetJoinEnabled(),
|
||||
MaxHosts: agency.GetMaxHosts(),
|
||||
CreatedByUserID: int64String(agency.GetCreatedByUserId()),
|
||||
CreatedAtMS: agency.GetCreatedAtMs(),
|
||||
UpdatedAtMS: agency.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func hostAgencyApplicationFromProto(application *userv1.AgencyApplication) hostAgencyApplicationData {
|
||||
if application == nil {
|
||||
return hostAgencyApplicationData{}
|
||||
}
|
||||
return hostAgencyApplicationData{
|
||||
ApplicationID: int64String(application.GetApplicationId()),
|
||||
CommandID: application.GetCommandId(),
|
||||
ApplicantUserID: int64String(application.GetApplicantUserId()),
|
||||
AgencyID: int64String(application.GetAgencyId()),
|
||||
RegionID: application.GetRegionId(),
|
||||
Status: application.GetStatus(),
|
||||
ReviewedByUserID: int64String(application.GetReviewedByUserId()),
|
||||
ReviewReason: application.GetReviewReason(),
|
||||
ReviewedAtMS: application.GetReviewedAtMs(),
|
||||
CreatedAtMS: application.GetCreatedAtMs(),
|
||||
UpdatedAtMS: application.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func int64String(value int64) string {
|
||||
if value <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
|
||||
func firstRawInt64(values ...json.RawMessage) (int64, bool) {
|
||||
for _, raw := range values {
|
||||
value, ok := parseRawInt64(raw)
|
||||
if ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func parseRawInt64(raw json.RawMessage) (int64, bool) {
|
||||
text := strings.TrimSpace(string(raw))
|
||||
if text == "" || text == "null" {
|
||||
return 0, false
|
||||
}
|
||||
if strings.HasPrefix(text, `"`) {
|
||||
var value string
|
||||
if err := json.Unmarshal(raw, &value); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
text = strings.TrimSpace(value)
|
||||
}
|
||||
value, err := strconv.ParseInt(text, 10, 64)
|
||||
return value, err == nil
|
||||
}
|
||||
@ -105,6 +105,35 @@ type withdrawalApplyRequestBody struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type googlePaymentConfirmRequestBody struct {
|
||||
CommandID string `json:"command_id"`
|
||||
CommandIDAlt string `json:"commandId"`
|
||||
ProductID int64 `json:"product_id"`
|
||||
ProductIDAlt int64 `json:"productId"`
|
||||
ProductCode string `json:"product_code"`
|
||||
ProductCodeAlt string `json:"productCode"`
|
||||
PackageName string `json:"package_name"`
|
||||
PackageNameAlt string `json:"packageName"`
|
||||
PurchaseToken string `json:"purchase_token"`
|
||||
PurchaseAlt string `json:"purchaseToken"`
|
||||
OrderID string `json:"order_id"`
|
||||
OrderIDAlt string `json:"orderId"`
|
||||
PurchaseTimeMS int64 `json:"purchase_time_ms"`
|
||||
PurchaseTimeAlt int64 `json:"purchaseTimeMs"`
|
||||
}
|
||||
|
||||
type googlePaymentConfirmData struct {
|
||||
PaymentOrderID string `json:"payment_order_id"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
Status string `json:"status"`
|
||||
ProductID int64 `json:"product_id"`
|
||||
ProductCode string `json:"product_code"`
|
||||
CoinAmount int64 `json:"coin_amount"`
|
||||
Balance assetBalanceData `json:"balance"`
|
||||
IdempotentReplay bool `json:"idempotent_replay"`
|
||||
ConsumeState string `json:"consume_state"`
|
||||
}
|
||||
|
||||
type withdrawalData struct {
|
||||
WithdrawalID string `json:"withdrawal_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
@ -199,6 +228,77 @@ func (h *Handler) listRechargeProducts(writer http.ResponseWriter, request *http
|
||||
httpkit.WriteOK(writer, request, map[string]any{"channels": resp.GetChannels(), "products": products})
|
||||
}
|
||||
|
||||
// confirmGooglePayment 接收 App 支付成功后的 Google purchase_token,转交 wallet-service 做服务端校验和入账。
|
||||
func (h *Handler) confirmGooglePayment(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil || h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
var body googlePaymentConfirmRequestBody
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
commandID := strings.TrimSpace(body.CommandID)
|
||||
if commandID == "" {
|
||||
commandID = strings.TrimSpace(body.CommandIDAlt)
|
||||
}
|
||||
productID := body.ProductID
|
||||
if productID <= 0 {
|
||||
productID = body.ProductIDAlt
|
||||
}
|
||||
productCode := strings.TrimSpace(body.ProductCode)
|
||||
if productCode == "" {
|
||||
productCode = strings.TrimSpace(body.ProductCodeAlt)
|
||||
}
|
||||
packageName := strings.TrimSpace(body.PackageName)
|
||||
if packageName == "" {
|
||||
packageName = strings.TrimSpace(body.PackageNameAlt)
|
||||
}
|
||||
purchaseToken := strings.TrimSpace(body.PurchaseToken)
|
||||
if purchaseToken == "" {
|
||||
purchaseToken = strings.TrimSpace(body.PurchaseAlt)
|
||||
}
|
||||
orderID := strings.TrimSpace(body.OrderID)
|
||||
if orderID == "" {
|
||||
orderID = strings.TrimSpace(body.OrderIDAlt)
|
||||
}
|
||||
purchaseTimeMS := body.PurchaseTimeMS
|
||||
if purchaseTimeMS <= 0 {
|
||||
purchaseTimeMS = body.PurchaseTimeAlt
|
||||
}
|
||||
if commandID == "" || productID <= 0 || packageName == "" || purchaseToken == "" {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
userID := auth.UserIDFromContext(request.Context())
|
||||
profileResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
resp, err := h.walletClient.ConfirmGooglePayment(request.Context(), &walletv1.ConfirmGooglePaymentRequest{
|
||||
RequestId: httpkit.RequestIDFromContext(request.Context()),
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
CommandId: commandID,
|
||||
UserId: userID,
|
||||
RegionId: profileResp.GetUser().GetRegionId(),
|
||||
ProductId: productID,
|
||||
ProductCode: productCode,
|
||||
PackageName: packageName,
|
||||
PurchaseToken: purchaseToken,
|
||||
OrderId: orderID,
|
||||
PurchaseTimeMs: purchaseTimeMS,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, googlePaymentConfirmFromProto(resp))
|
||||
}
|
||||
|
||||
// getDiamondExchangeConfig 返回钻石兑换配置。
|
||||
func (h *Handler) getDiamondExchangeConfig(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
@ -347,6 +447,23 @@ func rechargeProductFromProto(product *walletv1.RechargeProduct) rechargeProduct
|
||||
}
|
||||
}
|
||||
|
||||
func googlePaymentConfirmFromProto(resp *walletv1.ConfirmGooglePaymentResponse) googlePaymentConfirmData {
|
||||
if resp == nil {
|
||||
return googlePaymentConfirmData{}
|
||||
}
|
||||
return googlePaymentConfirmData{
|
||||
PaymentOrderID: resp.GetPaymentOrderId(),
|
||||
TransactionID: resp.GetTransactionId(),
|
||||
Status: resp.GetStatus(),
|
||||
ProductID: resp.GetProductId(),
|
||||
ProductCode: resp.GetProductCode(),
|
||||
CoinAmount: resp.GetCoinAmount(),
|
||||
Balance: balanceFromProto(resp.GetBalance()),
|
||||
IdempotentReplay: resp.GetIdempotentReplay(),
|
||||
ConsumeState: resp.GetConsumeState(),
|
||||
}
|
||||
}
|
||||
|
||||
func diamondExchangeRuleFromProto(rule *walletv1.DiamondExchangeRule) diamondExchangeRuleData {
|
||||
if rule == nil {
|
||||
return diamondExchangeRuleData{}
|
||||
|
||||
@ -40,6 +40,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
|
||||
GetWalletOverview: h.getWalletOverview,
|
||||
GetMyBalances: h.getMyBalances,
|
||||
ListRechargeProducts: h.listRechargeProducts,
|
||||
ConfirmGooglePayment: h.confirmGooglePayment,
|
||||
GetDiamondExchangeConfig: h.getDiamondExchangeConfig,
|
||||
ApplyWithdrawal: h.applyWithdrawal,
|
||||
ListCoinTransactions: h.listCoinTransactions,
|
||||
|
||||
@ -84,7 +84,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
var activityConn *grpc.ClientConn
|
||||
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDirect || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
|
||||
if cfg.ActivityServiceAddr != "" {
|
||||
activityConn, err = grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
_ = walletConn.Close()
|
||||
@ -189,6 +189,10 @@ func New(cfg config.Config) (*App, error) {
|
||||
treasureOpenScheduler = integration.NewRocketMQTreasureOpenScheduler(treasureProducer, cfg.RocketMQ.TreasureOpen.Topic)
|
||||
}
|
||||
outboxPublisher := integration.NewCompositeOutboxPublisher(outboxPublishers...)
|
||||
var luckyGiftClient integration.LuckyGiftClient
|
||||
if activityConn != nil {
|
||||
luckyGiftClient = integration.NewGRPCLuckyGiftClient(activityConn)
|
||||
}
|
||||
|
||||
// 领域服务只依赖接口,App 层负责选择 MySQL/Redis/gRPC 具体实现。
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
@ -200,7 +204,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
MicPublishTimeout: cfg.MicPublishTimeout,
|
||||
RTCUserRemover: rtcUserRemover,
|
||||
RoomTreasureOpenScheduler: treasureOpenScheduler,
|
||||
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher)
|
||||
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher, luckyGiftClient)
|
||||
if cfg.RocketMQ.TreasureOpen.Enabled {
|
||||
treasureConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.TreasureOpen.ConsumerGroup, cfg.RocketMQ.TreasureOpen.ConsumerMaxReconsumeTimes))
|
||||
if err != nil {
|
||||
|
||||
@ -4,6 +4,7 @@ package integration
|
||||
import (
|
||||
"context"
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/tencentim"
|
||||
@ -17,6 +18,14 @@ type WalletClient interface {
|
||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
}
|
||||
|
||||
// LuckyGiftClient 抽象 room-service 对 activity-service 幸运礼物抽奖边界的同步依赖。
|
||||
type LuckyGiftClient interface {
|
||||
// CheckLuckyGift 在扣费前确认奖池规则可用;失败时不能扣费。
|
||||
CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error)
|
||||
// ExecuteLuckyGiftDraw 在钱包扣费成功后按 command_id 幂等落抽奖事实。
|
||||
ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error)
|
||||
}
|
||||
|
||||
// RoomEventPublisher 保留腾讯云 IM 直接桥接能力;房间命令主链路不再调用它。
|
||||
type RoomEventPublisher interface {
|
||||
// EnsureRoomGroup 确保房间在腾讯云 IM 中有对应群组。
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
@ -32,3 +33,20 @@ func (c *grpcWalletClient) GrantResourceGroup(ctx context.Context, req *walletv1
|
||||
// 宝箱奖励以 resource group 为后台配置单位,wallet-service 负责展开资产和权益。
|
||||
return c.client.GrantResourceGroup(ctx, req)
|
||||
}
|
||||
|
||||
type grpcLuckyGiftClient struct {
|
||||
client activityv1.LuckyGiftServiceClient
|
||||
}
|
||||
|
||||
// NewGRPCLuckyGiftClient 用 gRPC 连接创建 activity-service 幸运礼物客户端。
|
||||
func NewGRPCLuckyGiftClient(conn *grpc.ClientConn) LuckyGiftClient {
|
||||
return &grpcLuckyGiftClient{client: activityv1.NewLuckyGiftServiceClient(conn)}
|
||||
}
|
||||
|
||||
func (c *grpcLuckyGiftClient) CheckLuckyGift(ctx context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
|
||||
return c.client.CheckLuckyGift(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcLuckyGiftClient) ExecuteLuckyGiftDraw(ctx context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
|
||||
return c.client.ExecuteLuckyGiftDraw(ctx, req)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
@ -66,6 +67,26 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
if !exists {
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
|
||||
}
|
||||
luckyEnabled := false
|
||||
if cmd.PoolID != "" {
|
||||
if s.luckyGift == nil {
|
||||
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift service is not configured")
|
||||
}
|
||||
checkResp, err := s.luckyGift.CheckLuckyGift(ctx, &activityv1.CheckLuckyGiftRequest{
|
||||
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
|
||||
UserId: cmd.ActorUserID(),
|
||||
RoomId: cmd.RoomID(),
|
||||
GiftId: cmd.GiftID,
|
||||
PoolId: cmd.PoolID,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if checkResp == nil || !checkResp.GetEnabled() {
|
||||
return mutationResult{}, nil, xerr.New(xerr.RuleNotActive, "lucky gift rule is not active")
|
||||
}
|
||||
luckyEnabled = true
|
||||
}
|
||||
treasureConfig, err := s.roomTreasureConfig(ctx)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
@ -98,6 +119,33 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
settledCommand.HeatValue = heatValue
|
||||
settledCommand.PriceVersion = billing.GetPriceVersion()
|
||||
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
|
||||
if !luckyEnabled {
|
||||
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
|
||||
}
|
||||
var luckyGift *roomv1.LuckyGiftDrawResult
|
||||
if luckyEnabled {
|
||||
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{
|
||||
LuckyGift: &activityv1.LuckyGiftMeta{
|
||||
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
|
||||
CommandId: cmd.ID(),
|
||||
UserId: cmd.ActorUserID(),
|
||||
DeviceId: luckyGiftDeviceID(cmd),
|
||||
RoomId: cmd.RoomID(),
|
||||
AnchorId: luckyGiftAnchorID(roomMeta),
|
||||
GiftId: cmd.GiftID,
|
||||
CoinSpent: billing.GetCoinSpent(),
|
||||
PaidAtMs: now.UTC().UnixMilli(),
|
||||
PoolId: cmd.PoolID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if drawResp == nil {
|
||||
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift draw response is empty")
|
||||
}
|
||||
luckyGift = luckyGiftResultFromProto(drawResp.GetResult())
|
||||
}
|
||||
|
||||
// 扣费成功后,Room Cell 同步更新热度和本地礼物榜。
|
||||
current.Heat += heatValue
|
||||
@ -167,6 +215,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
billingReceiptID: billing.GetBillingReceiptId(),
|
||||
roomHeat: current.Heat,
|
||||
giftRank: cloneProtoRank(current.GiftRank),
|
||||
luckyGift: luckyGift,
|
||||
commandPayload: commandPayload,
|
||||
walletDebitMS: walletDebitMS,
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
@ -202,9 +251,71 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
GiftRank: result.giftRank,
|
||||
Room: result.snapshot,
|
||||
Treasure: result.snapshot.GetTreasure(),
|
||||
LuckyGift: result.luckyGift,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool {
|
||||
if s.luckyGift == nil {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(giftTypeCode) {
|
||||
case "lucky", "super_lucky":
|
||||
return true
|
||||
default:
|
||||
return strings.TrimSpace(poolID) != ""
|
||||
}
|
||||
}
|
||||
|
||||
func activityMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *activityv1.RequestMeta {
|
||||
return &activityv1.RequestMeta{
|
||||
RequestId: meta.GetRequestId(),
|
||||
Caller: "room-service",
|
||||
GatewayNodeId: meta.GetGatewayNodeId(),
|
||||
SentAtMs: time.Now().UTC().UnixMilli(),
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
}
|
||||
}
|
||||
|
||||
func luckyGiftDeviceID(cmd command.SendGift) string {
|
||||
if value := strings.TrimSpace(cmd.SessionID); value != "" {
|
||||
return value
|
||||
}
|
||||
return cmd.ID()
|
||||
}
|
||||
|
||||
func luckyGiftAnchorID(roomMeta RoomMeta) string {
|
||||
if roomMeta.OwnerUserID <= 0 {
|
||||
return roomMeta.RoomID
|
||||
}
|
||||
return fmt.Sprintf("%d", roomMeta.OwnerUserID)
|
||||
}
|
||||
|
||||
func luckyGiftResultFromProto(result *activityv1.LuckyGiftDrawResult) *roomv1.LuckyGiftDrawResult {
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
return &roomv1.LuckyGiftDrawResult{
|
||||
Enabled: true,
|
||||
DrawId: result.GetDrawId(),
|
||||
CommandId: result.GetCommandId(),
|
||||
PoolId: result.GetPoolId(),
|
||||
GiftId: result.GetGiftId(),
|
||||
RuleVersion: result.GetRuleVersion(),
|
||||
ExperiencePool: result.GetExperiencePool(),
|
||||
SelectedTierId: result.GetSelectedTierId(),
|
||||
MultiplierPpm: result.GetMultiplierPpm(),
|
||||
BaseRewardCoins: result.GetBaseRewardCoins(),
|
||||
RoomAtmosphereRewardCoins: result.GetRoomAtmosphereRewardCoins(),
|
||||
ActivitySubsidyCoins: result.GetActivitySubsidyCoins(),
|
||||
EffectiveRewardCoins: result.GetEffectiveRewardCoins(),
|
||||
RewardStatus: result.GetRewardStatus(),
|
||||
StageFeedback: result.GetStageFeedback(),
|
||||
HighMultiplier: result.GetHighMultiplier(),
|
||||
CreatedAtMs: result.GetCreatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeGiftTargetType(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
switch raw {
|
||||
|
||||
110
services/room-service/internal/room/service/lucky_gift_test.go
Normal file
110
services/room-service/internal/room/service/lucky_gift_test.go
Normal file
@ -0,0 +1,110 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/room-service/internal/integration"
|
||||
roomservice "hyapp/services/room-service/internal/room/service"
|
||||
"hyapp/services/room-service/internal/router"
|
||||
"hyapp/services/room-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
type luckyGiftTestClient struct {
|
||||
checks []*activityv1.CheckLuckyGiftRequest
|
||||
draws []*activityv1.ExecuteLuckyGiftDrawRequest
|
||||
}
|
||||
|
||||
func (c *luckyGiftTestClient) CheckLuckyGift(_ context.Context, req *activityv1.CheckLuckyGiftRequest) (*activityv1.CheckLuckyGiftResponse, error) {
|
||||
c.checks = append(c.checks, req)
|
||||
return &activityv1.CheckLuckyGiftResponse{
|
||||
Enabled: true,
|
||||
Reason: "enabled",
|
||||
GiftId: req.GetGiftId(),
|
||||
PoolId: req.GetPoolId(),
|
||||
RuleVersion: 12,
|
||||
ExperiencePool: "novice",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *luckyGiftTestClient) ExecuteLuckyGiftDraw(_ context.Context, req *activityv1.ExecuteLuckyGiftDrawRequest) (*activityv1.ExecuteLuckyGiftDrawResponse, error) {
|
||||
c.draws = append(c.draws, req)
|
||||
meta := req.GetLuckyGift()
|
||||
return &activityv1.ExecuteLuckyGiftDrawResponse{Result: &activityv1.LuckyGiftDrawResult{
|
||||
DrawId: "lucky_draw_test",
|
||||
CommandId: meta.GetCommandId(),
|
||||
PoolId: meta.GetPoolId(),
|
||||
GiftId: meta.GetGiftId(),
|
||||
RuleVersion: 12,
|
||||
ExperiencePool: "novice",
|
||||
SelectedTierId: "novice_2x",
|
||||
MultiplierPpm: 2_000_000,
|
||||
BaseRewardCoins: 200,
|
||||
EffectiveRewardCoins: 200,
|
||||
RewardStatus: "pending",
|
||||
CreatedAtMs: 1_779_258_000_000,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
wallet := &treasureTestWallet{debits: []*walletv1.DebitGiftResponse{{
|
||||
BillingReceiptId: "receipt-lucky",
|
||||
CoinSpent: 100,
|
||||
GiftPointAdded: 100,
|
||||
HeatValue: 100,
|
||||
GiftTypeCode: "super_lucky",
|
||||
}}}
|
||||
luckyGift := &luckyGiftTestClient{}
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-lucky-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), luckyGift)
|
||||
|
||||
roomID := "room-lucky-gift"
|
||||
ownerID := int64(101)
|
||||
viewerID := int64(202)
|
||||
createTreasureRoom(t, ctx, svc, roomID, ownerID, 9001)
|
||||
joinTreasureRoom(t, ctx, svc, roomID, viewerID)
|
||||
|
||||
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: &roomv1.RequestMeta{
|
||||
RequestId: "req-lucky",
|
||||
CommandId: "cmd-lucky",
|
||||
ActorUserId: ownerID,
|
||||
RoomId: roomID,
|
||||
SessionId: "device-session-1",
|
||||
AppCode: appcode.Default,
|
||||
SentAtMs: time.Date(2026, 5, 20, 12, 0, 0, 0, time.UTC).UnixMilli(),
|
||||
},
|
||||
TargetType: "user",
|
||||
TargetUserId: viewerID,
|
||||
GiftId: "rose",
|
||||
GiftCount: 1,
|
||||
PoolId: "super_lucky",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("send lucky gift failed: %v", err)
|
||||
}
|
||||
if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 {
|
||||
t.Fatalf("lucky gift result mismatch: %+v", resp.GetLuckyGift())
|
||||
}
|
||||
if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" || luckyGift.checks[0].GetGiftId() != "rose" {
|
||||
t.Fatalf("lucky check request mismatch: %+v", luckyGift.checks)
|
||||
}
|
||||
if len(luckyGift.draws) != 1 {
|
||||
t.Fatalf("expected one lucky draw, got %d", len(luckyGift.draws))
|
||||
}
|
||||
drawMeta := luckyGift.draws[0].GetLuckyGift()
|
||||
if drawMeta.GetCommandId() != "cmd-lucky" || drawMeta.GetCoinSpent() != 100 || drawMeta.GetDeviceId() != "device-session-1" || drawMeta.GetAnchorId() != "101" {
|
||||
t.Fatalf("lucky draw meta mismatch: %+v", drawMeta)
|
||||
}
|
||||
}
|
||||
@ -60,6 +60,8 @@ type Service struct {
|
||||
repository Repository
|
||||
// wallet 是 SendGift 的同步扣费依赖,扣费失败不能进入 Room Cell 状态变更。
|
||||
wallet integration.WalletClient
|
||||
// luckyGift 是幸运礼物同步检查和抽奖依赖;未配置时幸运礼物按未启用处理。
|
||||
luckyGift integration.LuckyGiftClient
|
||||
// syncPublisher 是 room-service 到腾讯云 IM 的低时延房间系统事件桥。
|
||||
syncPublisher integration.RoomEventPublisher
|
||||
// outboxPublisher 是补偿 worker 对外部消费者的异步投递抽象。
|
||||
@ -97,6 +99,8 @@ type mutationResult struct {
|
||||
roomHeat int64
|
||||
// giftRank 是 SendGift 后的本地礼物榜投影。
|
||||
giftRank []*roomv1.RankItem
|
||||
// luckyGift 是扣费成功后同步执行的幸运礼物抽奖结果;普通礼物保持 nil。
|
||||
luckyGift *roomv1.LuckyGiftDrawResult
|
||||
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log,用于恢复。
|
||||
commandPayload []byte
|
||||
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;实际投递只由 outbox worker 异步执行。
|
||||
@ -117,7 +121,7 @@ type mutationResult struct {
|
||||
}
|
||||
|
||||
// New 初始化 room-service 领域服务。
|
||||
func New(cfg Config, directory router.Directory, repository Repository, wallet integration.WalletClient, syncPublisher integration.RoomEventPublisher, outboxPublisher integration.OutboxPublisher) *Service {
|
||||
func New(cfg Config, directory router.Directory, repository Repository, wallet integration.WalletClient, syncPublisher integration.RoomEventPublisher, outboxPublisher integration.OutboxPublisher, luckyGift ...integration.LuckyGiftClient) *Service {
|
||||
usedClock := cfg.Clock
|
||||
if usedClock == nil {
|
||||
// 生产默认系统时钟,测试可注入固定时钟保证断言稳定。
|
||||
@ -148,6 +152,11 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
|
||||
micPublishTimeout = 15 * time.Second
|
||||
}
|
||||
|
||||
var luckyGiftClient integration.LuckyGiftClient
|
||||
if len(luckyGift) > 0 {
|
||||
luckyGiftClient = luckyGift[0]
|
||||
}
|
||||
|
||||
return &Service{
|
||||
nodeID: cfg.NodeID,
|
||||
leaseTTL: cfg.LeaseTTL,
|
||||
@ -159,6 +168,7 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
|
||||
directory: directory,
|
||||
repository: repository,
|
||||
wallet: wallet,
|
||||
luckyGift: luckyGiftClient,
|
||||
syncPublisher: syncPublisher,
|
||||
outboxPublisher: outboxPublisher,
|
||||
roomTreasureOpenScheduler: cfg.RoomTreasureOpenScheduler,
|
||||
|
||||
@ -400,6 +400,13 @@ func TestAdminCreateAgencyControlsAppSearch(t *testing.T) {
|
||||
if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
|
||||
t.Fatalf("created agency should be searchable: %+v", agencies)
|
||||
}
|
||||
agencies, err = svc.SearchAgencies(ctx, 902, displayID(901), 20)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchAgencies by owner display id failed: %v", err)
|
||||
}
|
||||
if len(agencies) != 1 || agencies[0].AgencyID != created.Agency.AgencyID {
|
||||
t.Fatalf("created agency should be searchable by owner display id: %+v", agencies)
|
||||
}
|
||||
|
||||
disabledJoin, err := svc.SetAgencyJoinEnabled(ctx, hostservice.SetAgencyJoinEnabledInput{
|
||||
CommandID: "admin-disable-agency-join-901",
|
||||
|
||||
@ -30,8 +30,13 @@ func (r *Repository) SearchAgencies(ctx context.Context, command hostservice.Sea
|
||||
`, agencyColumns)
|
||||
args := []any{appcode.FromContext(ctx), regionID, hostdomain.AgencyStatusActive}
|
||||
if strings.TrimSpace(command.Keyword) != "" {
|
||||
query += " AND name LIKE ?"
|
||||
args = append(args, "%"+strings.TrimSpace(command.Keyword)+"%")
|
||||
keyword := strings.TrimSpace(command.Keyword)
|
||||
query += ` AND (name LIKE ? OR owner_user_id IN (
|
||||
SELECT user_id
|
||||
FROM users
|
||||
WHERE app_code = ? AND current_display_user_id = ?
|
||||
))`
|
||||
args = append(args, "%"+keyword+"%", appcode.FromContext(ctx), keyword)
|
||||
}
|
||||
query += " ORDER BY created_at_ms DESC, agency_id DESC LIMIT ?"
|
||||
args = append(args, command.PageSize)
|
||||
|
||||
@ -31,6 +31,15 @@ rocketmq:
|
||||
enabled: true
|
||||
topic: "hyapp_wallet_outbox"
|
||||
producer_group: "hyapp-wallet-outbox-producer"
|
||||
google_play:
|
||||
enabled: false
|
||||
package_name: ""
|
||||
service_account_file: ""
|
||||
service_account_json: ""
|
||||
api_base_url: "https://androidpublisher.googleapis.com"
|
||||
token_url: "https://oauth2.googleapis.com/token"
|
||||
http_timeout: "10s"
|
||||
consume_enabled: true
|
||||
outbox_worker:
|
||||
enabled: true
|
||||
poll_interval: "1s"
|
||||
|
||||
@ -31,6 +31,15 @@ rocketmq:
|
||||
enabled: true
|
||||
topic: "hyapp_wallet_outbox"
|
||||
producer_group: "hyapp-wallet-outbox-producer"
|
||||
google_play:
|
||||
enabled: false
|
||||
package_name: "com.example.hyapp"
|
||||
service_account_file: "/etc/hyapp/secrets/google-play-service-account.json"
|
||||
service_account_json: ""
|
||||
api_base_url: "https://androidpublisher.googleapis.com"
|
||||
token_url: "https://oauth2.googleapis.com/token"
|
||||
http_timeout: "10s"
|
||||
consume_enabled: true
|
||||
outbox_worker:
|
||||
enabled: true
|
||||
poll_interval: "1s"
|
||||
|
||||
@ -31,6 +31,15 @@ rocketmq:
|
||||
enabled: false
|
||||
topic: "hyapp_wallet_outbox"
|
||||
producer_group: "hyapp-wallet-outbox-producer"
|
||||
google_play:
|
||||
enabled: false
|
||||
package_name: ""
|
||||
service_account_file: ""
|
||||
service_account_json: ""
|
||||
api_base_url: "https://androidpublisher.googleapis.com"
|
||||
token_url: "https://oauth2.googleapis.com/token"
|
||||
http_timeout: "10s"
|
||||
consume_enabled: true
|
||||
outbox_worker:
|
||||
enabled: false
|
||||
poll_interval: "1s"
|
||||
|
||||
@ -243,6 +243,35 @@ CREATE TABLE IF NOT EXISTS wallet_recharge_product_regions (
|
||||
KEY idx_wallet_recharge_product_regions_region (app_code, region_id, product_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包充值商品区域表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS payment_orders (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
payment_order_id VARCHAR(96) NOT NULL COMMENT '平台支付订单 ID',
|
||||
provider VARCHAR(32) NOT NULL COMMENT '支付提供方',
|
||||
channel VARCHAR(32) NOT NULL COMMENT '充值渠道',
|
||||
status VARCHAR(32) NOT NULL COMMENT '订单状态',
|
||||
command_id VARCHAR(128) NOT NULL COMMENT '客户端确认命令幂等 ID',
|
||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||
product_id BIGINT NOT NULL COMMENT '后台充值商品 ID',
|
||||
product_code VARCHAR(128) NOT NULL COMMENT 'Google Play 商品 ID',
|
||||
package_name VARCHAR(256) NOT NULL DEFAULT '' COMMENT 'Google Play 包名',
|
||||
provider_order_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Google Play orderId',
|
||||
purchase_token_hash VARCHAR(128) NOT NULL COMMENT 'Google purchase token SHA256,不落明文 token',
|
||||
purchase_state VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Google purchase state',
|
||||
consume_state VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'Google consume 状态',
|
||||
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
|
||||
coin_amount BIGINT NOT NULL COMMENT '到账金币数量',
|
||||
currency_code VARCHAR(8) NOT NULL DEFAULT '' COMMENT '商品币种编码',
|
||||
amount_micro BIGINT NOT NULL DEFAULT 0 COMMENT '商品金额微单位',
|
||||
provider_payload JSON NULL COMMENT 'Google Play 校验返回快照',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, payment_order_id),
|
||||
UNIQUE KEY uk_payment_orders_token (app_code, provider, purchase_token_hash),
|
||||
UNIQUE KEY uk_payment_orders_command (app_code, command_id),
|
||||
KEY idx_payment_orders_user_time (app_code, user_id, created_at_ms),
|
||||
KEY idx_payment_orders_provider_order (app_code, provider, provider_order_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='支付订单与 provider 审计表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coin_seller_stock_records (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
stock_id VARCHAR(96) NOT NULL COMMENT '库存 ID',
|
||||
@ -804,95 +833,13 @@ CREATE TABLE IF NOT EXISTS user_vip_history (
|
||||
KEY idx_user_vip_history_user_time (app_code, user_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 VIP 历史表';
|
||||
|
||||
INSERT IGNORE INTO wallet_gift_prices (
|
||||
app_code, gift_id, price_version, status, charge_asset_type, coin_price, gift_point_amount, heat_value,
|
||||
effective_at_ms, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', 'rose', 'v1', 'active', 'COIN', 10, 10, 10, 0, 0, 0),
|
||||
('lalu', 'rocket', 'v1', 'active', 'COIN', 100, 100, 100, 0, 0, 0);
|
||||
|
||||
INSERT IGNORE INTO resources (
|
||||
app_code, resource_code, resource_type, name, status, grantable, grant_strategy,
|
||||
wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url,
|
||||
animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', 'gift_rose', 'gift', 'Rose', 'active', TRUE, 'increase_quantity', '', 0, 'coin', 10, 10, JSON_ARRAY('gift'), '', '', '', JSON_OBJECT(), 10, 0, 0, 0, 0),
|
||||
('lalu', 'gift_rocket', 'gift', 'Rocket', 'active', TRUE, 'increase_quantity', '', 0, 'coin', 100, 100, JSON_ARRAY('gift'), '', '', '', JSON_OBJECT(), 20, 0, 0, 0, 0);
|
||||
|
||||
INSERT IGNORE INTO resources (
|
||||
app_code, resource_code, resource_type, name, status, grantable, grant_strategy,
|
||||
wallet_asset_type, wallet_asset_amount, price_type, coin_price, gift_point_amount, usage_scope_json, asset_url, preview_url,
|
||||
animation_url, metadata_json, sort_order, created_by_user_id, updated_by_user_id,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', 'vip1_avatar_frame', 'avatar_frame', 'VIP1 Avatar Frame', 'active', TRUE, 'set_active_flag', '', 0, 'free', 0, 0, JSON_ARRAY('profile', 'room'), '', '', '', JSON_OBJECT(), 101, 0, 0, 0, 0),
|
||||
('lalu', 'vip2_avatar_frame', 'avatar_frame', 'VIP2 Avatar Frame', 'active', TRUE, 'set_active_flag', '', 0, 'free', 0, 0, JSON_ARRAY('profile', 'room'), '', '', '', JSON_OBJECT(), 102, 0, 0, 0, 0),
|
||||
('lalu', 'vip3_vehicle', 'vehicle', 'VIP3 Vehicle', 'active', TRUE, 'set_active_flag', '', 0, 'free', 0, 0, JSON_ARRAY('room_entry'), '', '', '', JSON_OBJECT(), 103, 0, 0, 0, 0);
|
||||
|
||||
INSERT IGNORE INTO resource_groups (
|
||||
app_code, group_code, name, status, description, sort_order,
|
||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', 'vip1_rewards', 'VIP1 Rewards', 'active', 'VIP1 purchase rewards', 101, 0, 0, 0, 0),
|
||||
('lalu', 'vip2_rewards', 'VIP2 Rewards', 'active', 'VIP2 purchase rewards', 102, 0, 0, 0, 0),
|
||||
('lalu', 'vip3_rewards', 'VIP3 Rewards', 'active', 'VIP3 purchase rewards', 103, 0, 0, 0, 0);
|
||||
|
||||
INSERT IGNORE INTO resource_group_items (
|
||||
app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
|
||||
quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT 'lalu', g.group_id, 'resource', r.resource_id, '', 0, 1, 604800000, 10, 0, 0
|
||||
FROM resource_groups g
|
||||
JOIN resources r ON r.app_code = g.app_code AND r.resource_code = 'vip1_avatar_frame'
|
||||
WHERE g.app_code = 'lalu' AND g.group_code = 'vip1_rewards';
|
||||
|
||||
INSERT IGNORE INTO resource_group_items (
|
||||
app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
|
||||
quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT 'lalu', g.group_id, 'resource', r.resource_id, '', 0, 1, 604800000, 10, 0, 0
|
||||
FROM resource_groups g
|
||||
JOIN resources r ON r.app_code = g.app_code AND r.resource_code = 'vip2_avatar_frame'
|
||||
WHERE g.app_code = 'lalu' AND g.group_code = 'vip2_rewards';
|
||||
|
||||
INSERT IGNORE INTO resource_group_items (
|
||||
app_code, group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
|
||||
quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT 'lalu', g.group_id, 'resource', r.resource_id, '', 0, 1, 604800000, 10, 0, 0
|
||||
FROM resource_groups g
|
||||
JOIN resources r ON r.app_code = g.app_code AND r.resource_code = 'vip3_vehicle'
|
||||
WHERE g.app_code = 'lalu' AND g.group_code = 'vip3_rewards';
|
||||
|
||||
INSERT IGNORE INTO vip_levels (
|
||||
app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id, required_recharge_coin_amount,
|
||||
sort_order, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT 'lalu', 1, 'VIP1', 'active', 1000, 604800000, group_id, 0, 10, 0, 0
|
||||
FROM resource_groups
|
||||
WHERE app_code = 'lalu' AND group_code = 'vip1_rewards';
|
||||
|
||||
INSERT IGNORE INTO vip_levels (
|
||||
app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id, required_recharge_coin_amount,
|
||||
sort_order, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT 'lalu', 2, 'VIP2', 'active', 3000, 604800000, group_id, 0, 20, 0, 0
|
||||
FROM resource_groups
|
||||
WHERE app_code = 'lalu' AND group_code = 'vip2_rewards';
|
||||
|
||||
INSERT IGNORE INTO vip_levels (
|
||||
app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id, required_recharge_coin_amount,
|
||||
sort_order, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT 'lalu', 3, 'VIP3', 'active', 8000, 604800000, group_id, 0, 30, 0, 0
|
||||
FROM resource_groups
|
||||
WHERE app_code = 'lalu' AND group_code = 'vip3_rewards';
|
||||
|
||||
INSERT IGNORE INTO vip_levels (
|
||||
app_code, level, name, status, price_coin, duration_ms, reward_resource_group_id, required_recharge_coin_amount,
|
||||
sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', 1, 'VIP1', 'active', 1000, 604800000, 0, 0, 10, 0, 0),
|
||||
('lalu', 2, 'VIP2', 'active', 3000, 604800000, 0, 0, 20, 0, 0),
|
||||
('lalu', 3, 'VIP3', 'active', 8000, 604800000, 0, 0, 30, 0, 0),
|
||||
('lalu', 4, 'VIP4', 'disabled', 0, 604800000, 0, 0, 40, 0, 0),
|
||||
('lalu', 5, 'VIP5', 'disabled', 0, 604800000, 0, 0, 50, 0, 0),
|
||||
('lalu', 6, 'VIP6', 'disabled', 0, 604800000, 0, 0, 60, 0, 0),
|
||||
@ -922,38 +869,3 @@ INSERT IGNORE INTO gift_type_configs (
|
||||
('lalu', 'activity', '活动礼物', 'Activity', 'active', 80, 0, 0, 0, 0),
|
||||
('lalu', 'magic', '魔法礼物', 'Magic', 'active', 90, 0, 0, 0, 0),
|
||||
('lalu', 'custom', '定制礼物', 'Custom', 'active', 100, 0, 0, 0, 0);
|
||||
|
||||
INSERT IGNORE INTO gift_configs (
|
||||
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
|
||||
gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
|
||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT 'lalu', 'rose', resource_id, 'active', 'Rose', 10, JSON_OBJECT(),
|
||||
'normal', 0, 0, JSON_ARRAY(),
|
||||
0, 0, 0, 0
|
||||
FROM resources
|
||||
WHERE app_code = 'lalu' AND resource_code = 'gift_rose'
|
||||
LIMIT 1;
|
||||
|
||||
INSERT IGNORE INTO gift_configs (
|
||||
app_code, gift_id, resource_id, status, name, sort_order, presentation_json,
|
||||
gift_type_code, effective_from_ms, effective_to_ms, effect_types_json,
|
||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT 'lalu', 'rocket', resource_id, 'active', 'Rocket', 20, JSON_OBJECT(),
|
||||
'normal', 0, 0, JSON_ARRAY('animation', 'global_broadcast'),
|
||||
0, 0, 0, 0
|
||||
FROM resources
|
||||
WHERE app_code = 'lalu' AND resource_code = 'gift_rocket'
|
||||
LIMIT 1;
|
||||
|
||||
INSERT IGNORE INTO gift_config_regions (
|
||||
app_code, gift_id, region_id, created_at_ms, updated_at_ms
|
||||
)
|
||||
SELECT gc.app_code, gc.gift_id, 0, 0, 0
|
||||
FROM gift_configs gc
|
||||
LEFT JOIN gift_config_regions existing
|
||||
ON existing.app_code = gc.app_code AND existing.gift_id = gc.gift_id
|
||||
WHERE existing.gift_id IS NULL
|
||||
AND gc.app_code = 'lalu'
|
||||
AND gc.gift_id IN ('rose', 'rocket');
|
||||
|
||||
@ -22,6 +22,7 @@ import (
|
||||
"hyapp/pkg/rocketmqx"
|
||||
"hyapp/pkg/walletmq"
|
||||
"hyapp/services/wallet-service/internal/client"
|
||||
"hyapp/services/wallet-service/internal/client/googleplay"
|
||||
"hyapp/services/wallet-service/internal/config"
|
||||
walletservice "hyapp/services/wallet-service/internal/service/wallet"
|
||||
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
|
||||
@ -82,6 +83,19 @@ func New(cfg config.Config) (*App, error) {
|
||||
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("wallet-service")))
|
||||
svc := walletservice.New(repository, client.NewActivityAchievementClient(activityConn))
|
||||
if cfg.GooglePlay.Enabled {
|
||||
googleClient, err := googleplay.New(cfg.GooglePlay)
|
||||
if err != nil {
|
||||
if outboxProducer != nil {
|
||||
_ = outboxProducer.Shutdown()
|
||||
}
|
||||
_ = activityConn.Close()
|
||||
_ = listener.Close()
|
||||
_ = repository.Close()
|
||||
return nil, err
|
||||
}
|
||||
svc.SetGooglePlayClient(googleClient)
|
||||
}
|
||||
walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc))
|
||||
health := grpchealth.NewServingChecker("wallet-service", grpchealth.Dependency{
|
||||
Name: "mysql",
|
||||
|
||||
281
services/wallet-service/internal/client/googleplay/client.go
Normal file
281
services/wallet-service/internal/client/googleplay/client.go
Normal file
@ -0,0 +1,281 @@
|
||||
package googleplay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/config"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
const androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher"
|
||||
|
||||
// Client implements the minimal Google Play Developer API calls needed by wallet confirm.
|
||||
type Client struct {
|
||||
httpClient *http.Client
|
||||
apiBaseURL string
|
||||
tokenURL string
|
||||
packageName string
|
||||
consumeEnabled bool
|
||||
email string
|
||||
privateKey *rsa.PrivateKey
|
||||
|
||||
mu sync.Mutex
|
||||
accessToken string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type serviceAccount struct {
|
||||
ClientEmail string `json:"client_email"`
|
||||
PrivateKey string `json:"private_key"`
|
||||
TokenURI string `json:"token_uri"`
|
||||
}
|
||||
|
||||
// New creates a Google Play Developer API client from wallet-service config.
|
||||
func New(cfg config.GooglePlayConfig) (*Client, error) {
|
||||
body := strings.TrimSpace(cfg.ServiceAccountJSON)
|
||||
if body == "" && strings.TrimSpace(cfg.ServiceAccountFile) != "" {
|
||||
fileBody, err := os.ReadFile(strings.TrimSpace(cfg.ServiceAccountFile))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = string(fileBody)
|
||||
}
|
||||
var account serviceAccount
|
||||
if err := json.Unmarshal([]byte(body), &account); err != nil {
|
||||
return nil, fmt.Errorf("parse google play service account: %w", err)
|
||||
}
|
||||
account.ClientEmail = strings.TrimSpace(account.ClientEmail)
|
||||
account.PrivateKey = strings.TrimSpace(account.PrivateKey)
|
||||
account.TokenURI = strings.TrimSpace(account.TokenURI)
|
||||
if account.ClientEmail == "" || account.PrivateKey == "" {
|
||||
return nil, errors.New("google play service account is incomplete")
|
||||
}
|
||||
privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(account.PrivateKey))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse google play private key: %w", err)
|
||||
}
|
||||
tokenURL := strings.TrimSpace(cfg.TokenURL)
|
||||
if tokenURL == "" {
|
||||
tokenURL = account.TokenURI
|
||||
}
|
||||
if tokenURL == "" {
|
||||
tokenURL = "https://oauth2.googleapis.com/token"
|
||||
}
|
||||
apiBaseURL := strings.TrimRight(strings.TrimSpace(cfg.APIBaseURL), "/")
|
||||
if apiBaseURL == "" {
|
||||
apiBaseURL = "https://androidpublisher.googleapis.com"
|
||||
}
|
||||
timeout := cfg.HTTPTimeout
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return &Client{
|
||||
httpClient: &http.Client{Timeout: timeout},
|
||||
apiBaseURL: apiBaseURL,
|
||||
tokenURL: tokenURL,
|
||||
packageName: strings.TrimSpace(cfg.PackageName),
|
||||
consumeEnabled: cfg.ConsumeEnabled,
|
||||
email: account.ClientEmail,
|
||||
privateKey: privateKey,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetProductPurchase(ctx context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error) {
|
||||
if c == nil {
|
||||
return ledger.GooglePlayPurchase{}, xerr.New(xerr.Unavailable, "google play client is not configured")
|
||||
}
|
||||
packageName = c.normalizePackageName(packageName)
|
||||
purchaseToken = strings.TrimSpace(purchaseToken)
|
||||
if packageName == "" || purchaseToken == "" {
|
||||
return ledger.GooglePlayPurchase{}, xerr.New(xerr.InvalidArgument, "google play purchase request is incomplete")
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/purchases/productsv2/tokens/%s",
|
||||
c.apiBaseURL,
|
||||
url.PathEscape(packageName),
|
||||
url.PathEscape(purchaseToken),
|
||||
)
|
||||
body, err := c.doJSON(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return ledger.GooglePlayPurchase{}, err
|
||||
}
|
||||
var parsed productPurchaseV2
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return ledger.GooglePlayPurchase{}, err
|
||||
}
|
||||
purchase := ledger.GooglePlayPurchase{
|
||||
PackageName: packageName,
|
||||
OrderID: parsed.OrderID,
|
||||
PurchaseState: parsed.PurchaseStateContext.PurchaseState,
|
||||
AcknowledgementState: parsed.AcknowledgementState,
|
||||
PurchaseCompletionTime: parsed.PurchaseCompletionTime,
|
||||
RawJSON: string(body),
|
||||
}
|
||||
if len(parsed.ProductLineItem) > 0 {
|
||||
purchase.ProductID = parsed.ProductLineItem[0].ProductID
|
||||
purchase.ConsumptionState = parsed.ProductLineItem[0].ProductOfferDetails.ConsumptionState
|
||||
}
|
||||
return purchase, nil
|
||||
}
|
||||
|
||||
func (c *Client) ConsumeProduct(ctx context.Context, packageName string, productID string, purchaseToken string) error {
|
||||
if c == nil {
|
||||
return xerr.New(xerr.Unavailable, "google play client is not configured")
|
||||
}
|
||||
if !c.consumeEnabled {
|
||||
return xerr.New(xerr.Unavailable, "google play consume is disabled")
|
||||
}
|
||||
packageName = c.normalizePackageName(packageName)
|
||||
productID = strings.TrimSpace(productID)
|
||||
purchaseToken = strings.TrimSpace(purchaseToken)
|
||||
if packageName == "" || productID == "" || purchaseToken == "" {
|
||||
return xerr.New(xerr.InvalidArgument, "google play consume request is incomplete")
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/androidpublisher/v3/applications/%s/purchases/products/%s/tokens/%s:consume",
|
||||
c.apiBaseURL,
|
||||
url.PathEscape(packageName),
|
||||
url.PathEscape(productID),
|
||||
url.PathEscape(purchaseToken),
|
||||
)
|
||||
_, err := c.doJSON(ctx, http.MethodPost, endpoint, bytes.NewReader(nil))
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) normalizePackageName(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
return c.packageName
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(ctx context.Context, method string, endpoint string, body io.Reader) ([]byte, error) {
|
||||
token, err := c.token(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if method != http.MethodGet {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "google play api request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return nil, readErr
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusBadRequest {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "google play purchase token is invalid")
|
||||
}
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
return nil, xerr.New(xerr.Unavailable, "google play api is not authorized")
|
||||
}
|
||||
return nil, xerr.New(xerr.Unavailable, "google play api returned non-success")
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
|
||||
func (c *Client) token(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
if c.accessToken != "" && time.Now().Before(c.expiresAt.Add(-60*time.Second)) {
|
||||
token := c.accessToken
|
||||
c.mu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
claims := jwt.MapClaims{
|
||||
"iss": c.email,
|
||||
"scope": androidPublisherScope,
|
||||
"aud": c.tokenURL,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
}
|
||||
assertion, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(c.privateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||
form.Set("assertion", assertion)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", xerr.New(xerr.Unavailable, "google oauth token request failed")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return "", readErr
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", xerr.New(xerr.Unavailable, "google oauth token request failed")
|
||||
}
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
if err := json.Unmarshal(respBody, &tokenResp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if tokenResp.AccessToken == "" {
|
||||
return "", xerr.New(xerr.Unavailable, "google oauth token response is incomplete")
|
||||
}
|
||||
expiresIn := time.Duration(tokenResp.ExpiresIn) * time.Second
|
||||
if expiresIn <= 0 {
|
||||
expiresIn = time.Hour
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.accessToken = tokenResp.AccessToken
|
||||
c.expiresAt = now.Add(expiresIn)
|
||||
c.mu.Unlock()
|
||||
return tokenResp.AccessToken, nil
|
||||
}
|
||||
|
||||
type productPurchaseV2 struct {
|
||||
ProductLineItem []productLineItem `json:"productLineItem"`
|
||||
OrderID string `json:"orderId"`
|
||||
PurchaseStateContext purchaseState `json:"purchaseStateContext"`
|
||||
PurchaseCompletionTime string `json:"purchaseCompletionTime"`
|
||||
AcknowledgementState string `json:"acknowledgementState"`
|
||||
}
|
||||
|
||||
type purchaseState struct {
|
||||
PurchaseState string `json:"purchaseState"`
|
||||
}
|
||||
|
||||
type productLineItem struct {
|
||||
ProductID string `json:"productId"`
|
||||
ProductOfferDetails productOfferDetails `json:"productOfferDetails"`
|
||||
}
|
||||
|
||||
type productOfferDetails struct {
|
||||
ConsumptionState string `json:"consumptionState"`
|
||||
}
|
||||
@ -25,6 +25,8 @@ type Config struct {
|
||||
RedPacketExpiryWorker RedPacketExpiryWorkerConfig `yaml:"red_packet_expiry_worker"`
|
||||
// RocketMQ 控制 wallet_outbox 事实发布。
|
||||
RocketMQ RocketMQConfig `yaml:"rocketmq"`
|
||||
// GooglePlay 控制 Google Play Developer API 购买校验。
|
||||
GooglePlay GooglePlayConfig `yaml:"google_play"`
|
||||
// OutboxWorker 控制 wallet_outbox 到 MQ 的补偿投递。
|
||||
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
||||
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
|
||||
@ -73,6 +75,18 @@ type OutboxWorkerConfig struct {
|
||||
MaxBackoff time.Duration `yaml:"max_backoff"`
|
||||
}
|
||||
|
||||
// GooglePlayConfig 保存 Google Play Developer API 服务账号和网络配置。
|
||||
type GooglePlayConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
PackageName string `yaml:"package_name"`
|
||||
ServiceAccountFile string `yaml:"service_account_file"`
|
||||
ServiceAccountJSON string `yaml:"service_account_json"`
|
||||
APIBaseURL string `yaml:"api_base_url"`
|
||||
TokenURL string `yaml:"token_url"`
|
||||
HTTPTimeout time.Duration `yaml:"http_timeout"`
|
||||
ConsumeEnabled bool `yaml:"consume_enabled"`
|
||||
}
|
||||
|
||||
// Default 返回本地开发默认配置。
|
||||
func Default() Config {
|
||||
return Config{
|
||||
@ -90,6 +104,13 @@ func Default() Config {
|
||||
BatchSize: 50,
|
||||
},
|
||||
RocketMQ: defaultRocketMQConfig(),
|
||||
GooglePlay: GooglePlayConfig{
|
||||
Enabled: false,
|
||||
APIBaseURL: "https://androidpublisher.googleapis.com",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
HTTPTimeout: 10 * time.Second,
|
||||
ConsumeEnabled: true,
|
||||
},
|
||||
OutboxWorker: OutboxWorkerConfig{
|
||||
Enabled: false,
|
||||
PollInterval: time.Second,
|
||||
@ -167,6 +188,28 @@ func Load(path string) (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
cfg.RocketMQ = rocketMQ
|
||||
cfg.GooglePlay.PackageName = strings.TrimSpace(cfg.GooglePlay.PackageName)
|
||||
cfg.GooglePlay.ServiceAccountFile = strings.TrimSpace(cfg.GooglePlay.ServiceAccountFile)
|
||||
cfg.GooglePlay.ServiceAccountJSON = strings.TrimSpace(cfg.GooglePlay.ServiceAccountJSON)
|
||||
cfg.GooglePlay.APIBaseURL = strings.TrimRight(strings.TrimSpace(cfg.GooglePlay.APIBaseURL), "/")
|
||||
if cfg.GooglePlay.APIBaseURL == "" {
|
||||
cfg.GooglePlay.APIBaseURL = Default().GooglePlay.APIBaseURL
|
||||
}
|
||||
cfg.GooglePlay.TokenURL = strings.TrimSpace(cfg.GooglePlay.TokenURL)
|
||||
if cfg.GooglePlay.TokenURL == "" {
|
||||
cfg.GooglePlay.TokenURL = Default().GooglePlay.TokenURL
|
||||
}
|
||||
if cfg.GooglePlay.HTTPTimeout <= 0 {
|
||||
cfg.GooglePlay.HTTPTimeout = Default().GooglePlay.HTTPTimeout
|
||||
}
|
||||
if cfg.GooglePlay.Enabled {
|
||||
if cfg.GooglePlay.PackageName == "" {
|
||||
return Config{}, errors.New("google_play package_name is required")
|
||||
}
|
||||
if cfg.GooglePlay.ServiceAccountFile == "" && cfg.GooglePlay.ServiceAccountJSON == "" {
|
||||
return Config{}, errors.New("google_play service account is required")
|
||||
}
|
||||
}
|
||||
if cfg.OutboxWorker.PollInterval <= 0 {
|
||||
cfg.OutboxWorker.PollInterval = Default().OutboxWorker.PollInterval
|
||||
}
|
||||
|
||||
@ -32,6 +32,16 @@ const (
|
||||
RechargeChannelGoogle = "google"
|
||||
// RechargeChannelApple 是 iOS 平台内购渠道标识。
|
||||
RechargeChannelApple = "apple"
|
||||
// PaymentProviderGooglePlay 是 Google Play 一次性内购支付渠道。
|
||||
PaymentProviderGooglePlay = "google_play"
|
||||
// PaymentStatusCredited 表示支付已完成校验并入账。
|
||||
PaymentStatusCredited = "credited"
|
||||
// PaymentConsumeStatePending 表示已入账但 Google consume 尚未确认完成。
|
||||
PaymentConsumeStatePending = "consume_pending"
|
||||
// PaymentConsumeStateConsumed 表示 Google consume 已确认完成。
|
||||
PaymentConsumeStateConsumed = "consumed"
|
||||
// GooglePurchaseStatePurchased 是 Google Play 已支付完成状态。
|
||||
GooglePurchaseStatePurchased = "PURCHASED"
|
||||
|
||||
// WithdrawalStatusPending 表示主播美元余额提现已提交,等待后台人工审核和线下转账。
|
||||
WithdrawalStatusPending = "pending"
|
||||
@ -316,6 +326,45 @@ type RechargeProductCommand struct {
|
||||
OperatorUserID int64
|
||||
}
|
||||
|
||||
// GooglePaymentCommand 是 App 完成 Google Play 支付后提交给后端确认和入账的命令。
|
||||
type GooglePaymentCommand struct {
|
||||
AppCode string
|
||||
CommandID string
|
||||
UserID int64
|
||||
RegionID int64
|
||||
ProductID int64
|
||||
ProductCode string
|
||||
PackageName string
|
||||
PurchaseToken string
|
||||
OrderID string
|
||||
PurchaseTimeMS int64
|
||||
}
|
||||
|
||||
// GooglePlayPurchase 是 Google Play Developer API 返回的一次性商品购买状态快照。
|
||||
type GooglePlayPurchase struct {
|
||||
PackageName string
|
||||
ProductID string
|
||||
OrderID string
|
||||
PurchaseState string
|
||||
ConsumptionState string
|
||||
AcknowledgementState string
|
||||
PurchaseCompletionTime string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
// GooglePaymentReceipt 是 Google 支付确认接口返回给 App 的稳定入账回执。
|
||||
type GooglePaymentReceipt struct {
|
||||
PaymentOrderID string
|
||||
TransactionID string
|
||||
Status string
|
||||
ProductID int64
|
||||
ProductCode string
|
||||
CoinAmount int64
|
||||
Balance AssetBalance
|
||||
IdempotentReplay bool
|
||||
ConsumeState string
|
||||
}
|
||||
|
||||
// DiamondExchangeRule 是钻石兑换金币或余额的固定规则投影。
|
||||
type DiamondExchangeRule struct {
|
||||
ExchangeType string
|
||||
|
||||
@ -37,6 +37,9 @@ type Repository interface {
|
||||
GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error)
|
||||
ListRechargeProducts(ctx context.Context, userID int64, regionID int64, platform string) ([]ledger.RechargeProduct, []string, error)
|
||||
ListAdminRechargeProducts(ctx context.Context, query ledger.ListRechargeProductsQuery) ([]ledger.RechargeProduct, int64, error)
|
||||
GetRechargeProduct(ctx context.Context, appCode string, productID int64) (ledger.RechargeProduct, error)
|
||||
ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase) (ledger.GooglePaymentReceipt, error)
|
||||
MarkGooglePaymentConsumed(ctx context.Context, appCode string, paymentOrderID string) error
|
||||
CreateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error)
|
||||
UpdateRechargeProduct(ctx context.Context, command ledger.RechargeProductCommand) (ledger.RechargeProduct, error)
|
||||
DeleteRechargeProduct(ctx context.Context, appCode string, productID int64) error
|
||||
@ -81,10 +84,17 @@ type ActivityBadgeClient interface {
|
||||
ConsumeAchievementEvent(ctx context.Context, req *activityv1.ConsumeAchievementEventRequest, opts ...grpc.CallOption) (*activityv1.ConsumeAchievementEventResponse, error)
|
||||
}
|
||||
|
||||
// GooglePlayClient 隔离 Google Play Developer API,service 只依赖购买校验和消耗能力。
|
||||
type GooglePlayClient interface {
|
||||
GetProductPurchase(ctx context.Context, packageName string, purchaseToken string) (ledger.GooglePlayPurchase, error)
|
||||
ConsumeProduct(ctx context.Context, packageName string, productID string, purchaseToken string) error
|
||||
}
|
||||
|
||||
// Service 承载钱包账务用例。
|
||||
type Service struct {
|
||||
repository Repository
|
||||
activity ActivityBadgeClient
|
||||
googlePlay GooglePlayClient
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
@ -97,6 +107,11 @@ func New(repository Repository, activity ...ActivityBadgeClient) *Service {
|
||||
return &Service{repository: repository, activity: activityClient, now: time.Now}
|
||||
}
|
||||
|
||||
// SetGooglePlayClient 注入 Google Play Developer API 客户端;未配置时 confirm 返回依赖不可用。
|
||||
func (s *Service) SetGooglePlayClient(client GooglePlayClient) {
|
||||
s.googlePlay = client
|
||||
}
|
||||
|
||||
// DebitGift 校验送礼扣费命令,并交给 repository 做原子落账和幂等。
|
||||
func (s *Service) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) {
|
||||
if command.CommandID == "" || command.RoomID == "" || command.SenderUserID <= 0 || command.TargetUserID <= 0 || command.GiftID == "" {
|
||||
@ -491,6 +506,86 @@ func (s *Service) DeleteRechargeProduct(ctx context.Context, appCode string, pro
|
||||
return s.repository.DeleteRechargeProduct(ctx, appcode.FromContext(ctx), productID)
|
||||
}
|
||||
|
||||
// ConfirmGooglePayment 校验 Google Play purchase token,原子入账并记录支付审计。
|
||||
func (s *Service) ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand) (ledger.GooglePaymentReceipt, error) {
|
||||
command.AppCode = appcode.Normalize(command.AppCode)
|
||||
command.CommandID = strings.TrimSpace(command.CommandID)
|
||||
command.ProductCode = strings.TrimSpace(command.ProductCode)
|
||||
command.PackageName = strings.TrimSpace(command.PackageName)
|
||||
command.PurchaseToken = strings.TrimSpace(command.PurchaseToken)
|
||||
command.OrderID = strings.TrimSpace(command.OrderID)
|
||||
if command.CommandID == "" || command.UserID <= 0 || command.RegionID <= 0 || command.ProductID <= 0 || command.PackageName == "" || command.PurchaseToken == "" {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is incomplete")
|
||||
}
|
||||
if len(command.CommandID) > 128 || len(command.PackageName) > 256 || len(command.PurchaseToken) > 4096 || len(command.OrderID) > 128 {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "google payment command is too long")
|
||||
}
|
||||
if s.repository == nil {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
if s.googlePlay == nil {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "google play client is not configured")
|
||||
}
|
||||
ctx = appcode.WithContext(ctx, command.AppCode)
|
||||
|
||||
product, err := s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID)
|
||||
if err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if product.Status != ledger.RechargeProductStatusActive || !product.Enabled {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not active")
|
||||
}
|
||||
if product.Platform != ledger.RechargeProductPlatformAndroid || product.Channel != ledger.RechargeChannelGoogle {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.InvalidArgument, "recharge product is not google play product")
|
||||
}
|
||||
if command.ProductCode != "" && command.ProductCode != product.ProductCode {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "product_code does not match")
|
||||
}
|
||||
if !rechargeProductSupportsRegion(product, command.RegionID) {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "recharge product is not available in user region")
|
||||
}
|
||||
|
||||
purchase, err := s.googlePlay.GetProductPurchase(ctx, command.PackageName, command.PurchaseToken)
|
||||
if err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if purchase.PackageName == "" {
|
||||
purchase.PackageName = command.PackageName
|
||||
}
|
||||
if purchase.PackageName != command.PackageName {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "package_name does not match")
|
||||
}
|
||||
if purchase.PurchaseState != ledger.GooglePurchaseStatePurchased {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google purchase is not purchased")
|
||||
}
|
||||
if purchase.ProductID != "" && purchase.ProductID != product.ProductCode {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google product_id does not match recharge product")
|
||||
}
|
||||
if command.OrderID != "" && purchase.OrderID != "" && command.OrderID != purchase.OrderID {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google order_id does not match")
|
||||
}
|
||||
|
||||
receipt, err := s.repository.ConfirmGooglePayment(ctx, command, product, purchase)
|
||||
if err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if receipt.ConsumeState == ledger.PaymentConsumeStateConsumed {
|
||||
return receipt, nil
|
||||
}
|
||||
consumeProductID := product.ProductCode
|
||||
if purchase.ProductID != "" {
|
||||
consumeProductID = purchase.ProductID
|
||||
}
|
||||
if err := s.googlePlay.ConsumeProduct(ctx, command.PackageName, consumeProductID, command.PurchaseToken); err != nil {
|
||||
return receipt, nil
|
||||
}
|
||||
if err := s.repository.MarkGooglePaymentConsumed(ctx, command.AppCode, receipt.PaymentOrderID); err != nil {
|
||||
return receipt, nil
|
||||
}
|
||||
receipt.ConsumeState = ledger.PaymentConsumeStateConsumed
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
// GetDiamondExchangeConfig 返回当前 App 支持的钻石兑换规则。
|
||||
func (s *Service) GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error) {
|
||||
if userID <= 0 {
|
||||
@ -669,3 +764,12 @@ func validateRechargeProductCommand(command ledger.RechargeProductCommand, requi
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rechargeProductSupportsRegion(product ledger.RechargeProduct, regionID int64) bool {
|
||||
for _, productRegionID := range product.RegionIDs {
|
||||
if productRegionID == regionID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@ -1417,6 +1417,31 @@ func TestManagerCenterResourceGrantFiltersAndRechecksSwitch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateResourceRejectsDuplicateResourceCodeAsConflict(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
ctx := context.Background()
|
||||
|
||||
command := resourcedomain.ResourceCommand{
|
||||
ResourceCode: "duplicate_badge",
|
||||
ResourceType: resourcedomain.TypeBadge,
|
||||
Name: "Duplicate Badge",
|
||||
Status: resourcedomain.StatusActive,
|
||||
Grantable: true,
|
||||
ManagerGrantEnabled: true,
|
||||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||||
UsageScopes: []string{"profile"},
|
||||
OperatorUserID: 90001,
|
||||
}
|
||||
if _, err := svc.CreateResource(ctx, command); err != nil {
|
||||
t.Fatalf("first CreateResource failed: %v", err)
|
||||
}
|
||||
command.Name = "Duplicate Badge Again"
|
||||
if _, err := svc.CreateResource(ctx, command); !xerr.IsCode(err, xerr.Conflict) {
|
||||
t.Fatalf("duplicate resource_code should be conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestManagerCenterCannotGrantResourceGroup 固定首版经理中心只允许单资源赠送。
|
||||
func TestManagerCenterCannotGrantResourceGroup(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -0,0 +1,358 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
// ConfirmGooglePayment 将 Google Play 已购买 token 落为平台支付订单、钱包交易和充值审计记录。
|
||||
func (r *Repository) ConfirmGooglePayment(ctx context.Context, command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase) (ledger.GooglePaymentReceipt, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, command.AppCode)
|
||||
command.AppCode = appcode.FromContext(ctx)
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
tokenHash := googlePurchaseTokenHash(command.PurchaseToken)
|
||||
if existing, exists, err := r.lookupGooglePaymentOrderByToken(ctx, tx, tokenHash); err != nil || exists {
|
||||
if err != nil || !exists {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if existing.UserID != command.UserID || existing.ProductID != product.ProductID {
|
||||
return ledger.GooglePaymentReceipt{}, xerr.New(xerr.Conflict, "google purchase token belongs to another payment order")
|
||||
}
|
||||
return r.receiptForGooglePaymentOrder(ctx, tx, existing.PaymentOrderID, true)
|
||||
}
|
||||
|
||||
requestHash := googlePaymentRequestHash(command, product, purchase, tokenHash)
|
||||
if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeGooglePlayRecharge); err != nil || exists {
|
||||
if err != nil || !exists {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
return r.receiptForGooglePaymentTransaction(ctx, tx, txRow.TransactionID, true)
|
||||
}
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
account, err := r.lockAccount(ctx, tx, command.UserID, ledger.AssetCoin, true, nowMs)
|
||||
if err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
transactionID := transactionID(command.AppCode, command.CommandID)
|
||||
paymentOrderID := googlePaymentOrderID(command.AppCode, tokenHash)
|
||||
balanceAfter := account.AvailableAmount + product.CoinAmount
|
||||
rechargeSequence, err := r.reserveUserRechargeSequence(ctx, tx, command.AppCode, command.UserID, transactionID, product.CoinAmount, product.AmountMicro, nowMs)
|
||||
if err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
metadata := googlePaymentMetadata{
|
||||
AppCode: command.AppCode,
|
||||
PaymentOrderID: paymentOrderID,
|
||||
UserID: command.UserID,
|
||||
RegionID: command.RegionID,
|
||||
ProductID: product.ProductID,
|
||||
ProductCode: product.ProductCode,
|
||||
ProductName: product.ProductName,
|
||||
PackageName: command.PackageName,
|
||||
Provider: ledger.PaymentProviderGooglePlay,
|
||||
Channel: ledger.RechargeChannelGoogle,
|
||||
ProviderOrderID: purchase.OrderID,
|
||||
ClientOrderID: command.OrderID,
|
||||
PurchaseTokenHash: tokenHash,
|
||||
PurchaseState: purchase.PurchaseState,
|
||||
ConsumptionState: purchase.ConsumptionState,
|
||||
AcknowledgementState: purchase.AcknowledgementState,
|
||||
PurchaseCompletionTime: purchase.PurchaseCompletionTime,
|
||||
CoinAmount: product.CoinAmount,
|
||||
CurrencyCode: product.CurrencyCode,
|
||||
AmountMicro: product.AmountMicro,
|
||||
BalanceAfter: balanceAfter,
|
||||
RechargeSequence: rechargeSequence,
|
||||
CreatedAtMS: nowMs,
|
||||
}
|
||||
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGooglePlayRecharge, requestHash, externalGooglePaymentRef(purchase, tokenHash), metadata, nowMs); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if err := r.applyAccountDelta(ctx, tx, account, product.CoinAmount, 0, nowMs); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: command.UserID,
|
||||
AssetType: ledger.AssetCoin,
|
||||
AvailableDelta: product.CoinAmount,
|
||||
FrozenDelta: 0,
|
||||
AvailableAfter: balanceAfter,
|
||||
FrozenAfter: account.FrozenAmount,
|
||||
CreatedAtMS: nowMs,
|
||||
}); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if err := r.insertRechargeRecord(ctx, tx, transactionID, coinSellerTransferMetadata{
|
||||
AppCode: command.AppCode,
|
||||
TargetUserID: command.UserID,
|
||||
TargetRegionID: command.RegionID,
|
||||
Amount: product.CoinAmount,
|
||||
TargetAssetType: ledger.AssetCoin,
|
||||
TargetBalanceAfter: balanceAfter,
|
||||
RechargeSequence: rechargeSequence,
|
||||
RechargeUSDMinor: product.AmountMicro,
|
||||
RechargeCurrencyCode: product.CurrencyCode,
|
||||
RechargePolicyVersion: product.PolicyVersion,
|
||||
RechargePolicyCoinAmount: product.CoinAmount,
|
||||
RechargePolicyUSDMinorAmount: product.AmountMicro,
|
||||
}, nowMs); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if err := r.insertGooglePaymentOrder(ctx, tx, paymentOrderID, command, product, purchase, tokenHash, transactionID, nowMs); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||
balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, product.CoinAmount, 0, balanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs),
|
||||
googlePaymentCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
|
||||
rechargeRecordedEvent(transactionID, command.CommandID, command.UserID, product.CoinAmount, metadata, nowMs),
|
||||
}); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
|
||||
return ledger.GooglePaymentReceipt{
|
||||
PaymentOrderID: paymentOrderID,
|
||||
TransactionID: transactionID,
|
||||
Status: ledger.PaymentStatusCredited,
|
||||
ProductID: product.ProductID,
|
||||
ProductCode: product.ProductCode,
|
||||
CoinAmount: product.CoinAmount,
|
||||
Balance: ledger.AssetBalance{
|
||||
AppCode: command.AppCode,
|
||||
UserID: command.UserID,
|
||||
AssetType: ledger.AssetCoin,
|
||||
AvailableAmount: balanceAfter,
|
||||
FrozenAmount: account.FrozenAmount,
|
||||
},
|
||||
ConsumeState: ledger.PaymentConsumeStatePending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) MarkGooglePaymentConsumed(ctx context.Context, appCode string, paymentOrderID string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
ctx = contextWithCommandApp(ctx, appCode)
|
||||
paymentOrderID = strings.TrimSpace(paymentOrderID)
|
||||
if paymentOrderID == "" {
|
||||
return xerr.New(xerr.InvalidArgument, "payment_order_id is required")
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE payment_orders
|
||||
SET consume_state = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND payment_order_id = ? AND provider = ?`,
|
||||
ledger.PaymentConsumeStateConsumed,
|
||||
time.Now().UnixMilli(),
|
||||
appcode.FromContext(ctx),
|
||||
paymentOrderID,
|
||||
ledger.PaymentProviderGooglePlay,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
type googlePaymentOrderRow struct {
|
||||
PaymentOrderID string
|
||||
UserID int64
|
||||
ProductID int64
|
||||
TransactionID string
|
||||
}
|
||||
|
||||
func (r *Repository) lookupGooglePaymentOrderByToken(ctx context.Context, tx *sql.Tx, tokenHash string) (googlePaymentOrderRow, bool, error) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT payment_order_id, user_id, product_id, wallet_transaction_id
|
||||
FROM payment_orders
|
||||
WHERE app_code = ? AND provider = ? AND purchase_token_hash = ?
|
||||
FOR UPDATE`,
|
||||
appcode.FromContext(ctx),
|
||||
ledger.PaymentProviderGooglePlay,
|
||||
tokenHash,
|
||||
)
|
||||
var payment googlePaymentOrderRow
|
||||
if err := row.Scan(&payment.PaymentOrderID, &payment.UserID, &payment.ProductID, &payment.TransactionID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return googlePaymentOrderRow{}, false, nil
|
||||
}
|
||||
return googlePaymentOrderRow{}, false, err
|
||||
}
|
||||
return payment, true, nil
|
||||
}
|
||||
|
||||
func (r *Repository) insertGooglePaymentOrder(ctx context.Context, tx *sql.Tx, paymentOrderID string, command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase, tokenHash string, transactionID string, nowMs int64) error {
|
||||
payloadJSON := purchase.RawJSON
|
||||
if strings.TrimSpace(payloadJSON) == "" {
|
||||
payloadJSON = mustJSON(purchase)
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO payment_orders (
|
||||
app_code, payment_order_id, provider, channel, status, command_id, user_id,
|
||||
product_id, product_code, package_name, provider_order_id, purchase_token_hash,
|
||||
purchase_state, consume_state, wallet_transaction_id, coin_amount, currency_code,
|
||||
amount_micro, provider_payload, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
command.AppCode,
|
||||
paymentOrderID,
|
||||
ledger.PaymentProviderGooglePlay,
|
||||
ledger.RechargeChannelGoogle,
|
||||
ledger.PaymentStatusCredited,
|
||||
command.CommandID,
|
||||
command.UserID,
|
||||
product.ProductID,
|
||||
product.ProductCode,
|
||||
command.PackageName,
|
||||
purchase.OrderID,
|
||||
tokenHash,
|
||||
purchase.PurchaseState,
|
||||
ledger.PaymentConsumeStatePending,
|
||||
transactionID,
|
||||
product.CoinAmount,
|
||||
product.CurrencyCode,
|
||||
product.AmountMicro,
|
||||
payloadJSON,
|
||||
nowMs,
|
||||
nowMs,
|
||||
)
|
||||
if err != nil && isMySQLDuplicateError(err) {
|
||||
return xerr.New(xerr.Conflict, "google payment order already exists")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) receiptForGooglePaymentTransaction(ctx context.Context, tx *sql.Tx, transactionID string, idempotentReplay bool) (ledger.GooglePaymentReceipt, error) {
|
||||
var paymentOrderID string
|
||||
if err := tx.QueryRowContext(ctx, `
|
||||
SELECT payment_order_id
|
||||
FROM payment_orders
|
||||
WHERE app_code = ? AND wallet_transaction_id = ?
|
||||
LIMIT 1`,
|
||||
appcode.FromContext(ctx),
|
||||
transactionID,
|
||||
).Scan(&paymentOrderID); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
return r.receiptForGooglePaymentOrder(ctx, tx, paymentOrderID, idempotentReplay)
|
||||
}
|
||||
|
||||
func (r *Repository) receiptForGooglePaymentOrder(ctx context.Context, tx *sql.Tx, paymentOrderID string, idempotentReplay bool) (ledger.GooglePaymentReceipt, error) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT payment_order_id, wallet_transaction_id, status, user_id, product_id, product_code,
|
||||
coin_amount, consume_state
|
||||
FROM payment_orders
|
||||
WHERE app_code = ? AND payment_order_id = ?`,
|
||||
appcode.FromContext(ctx),
|
||||
paymentOrderID,
|
||||
)
|
||||
var receipt ledger.GooglePaymentReceipt
|
||||
var userID int64
|
||||
if err := row.Scan(
|
||||
&receipt.PaymentOrderID,
|
||||
&receipt.TransactionID,
|
||||
&receipt.Status,
|
||||
&userID,
|
||||
&receipt.ProductID,
|
||||
&receipt.ProductCode,
|
||||
&receipt.CoinAmount,
|
||||
&receipt.ConsumeState,
|
||||
); err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
balance, err := r.balanceAfterTransaction(ctx, tx, receipt.TransactionID, userID, ledger.AssetCoin)
|
||||
if err != nil {
|
||||
return ledger.GooglePaymentReceipt{}, err
|
||||
}
|
||||
receipt.Balance = balance
|
||||
receipt.IdempotentReplay = idempotentReplay
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
type googlePaymentMetadata struct {
|
||||
AppCode string `json:"app_code"`
|
||||
PaymentOrderID string `json:"payment_order_id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
ProductID int64 `json:"product_id"`
|
||||
ProductCode string `json:"product_code"`
|
||||
ProductName string `json:"product_name"`
|
||||
PackageName string `json:"package_name"`
|
||||
Provider string `json:"provider"`
|
||||
Channel string `json:"channel"`
|
||||
ProviderOrderID string `json:"provider_order_id"`
|
||||
ClientOrderID string `json:"client_order_id"`
|
||||
PurchaseTokenHash string `json:"purchase_token_hash"`
|
||||
PurchaseState string `json:"purchase_state"`
|
||||
ConsumptionState string `json:"consumption_state"`
|
||||
AcknowledgementState string `json:"acknowledgement_state"`
|
||||
PurchaseCompletionTime string `json:"purchase_completion_time"`
|
||||
CoinAmount int64 `json:"coin_amount"`
|
||||
CurrencyCode string `json:"currency_code"`
|
||||
AmountMicro int64 `json:"amount_micro"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
RechargeSequence int64 `json:"recharge_sequence"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
}
|
||||
|
||||
func googlePaymentCreditedEvent(transactionID string, commandID string, metadata googlePaymentMetadata, nowMs int64) walletOutboxEvent {
|
||||
return walletOutboxEvent{
|
||||
EventID: eventID(transactionID, "WalletGooglePaymentCredited", metadata.UserID, ledger.AssetCoin),
|
||||
EventType: "WalletGooglePaymentCredited",
|
||||
TransactionID: transactionID,
|
||||
CommandID: commandID,
|
||||
UserID: metadata.UserID,
|
||||
AssetType: ledger.AssetCoin,
|
||||
AvailableDelta: metadata.CoinAmount,
|
||||
FrozenDelta: 0,
|
||||
Payload: metadata,
|
||||
CreatedAtMS: nowMs,
|
||||
}
|
||||
}
|
||||
|
||||
func googlePaymentRequestHash(command ledger.GooglePaymentCommand, product ledger.RechargeProduct, purchase ledger.GooglePlayPurchase, tokenHash string) string {
|
||||
return stableHash(fmt.Sprintf("google_play|%s|%d|%d|%d|%s|%s|%s|%s|%s|%d|%d",
|
||||
appcode.Normalize(command.AppCode),
|
||||
command.UserID,
|
||||
command.RegionID,
|
||||
product.ProductID,
|
||||
product.ProductCode,
|
||||
command.PackageName,
|
||||
tokenHash,
|
||||
purchase.OrderID,
|
||||
purchase.PurchaseState,
|
||||
product.CoinAmount,
|
||||
product.AmountMicro,
|
||||
))
|
||||
}
|
||||
|
||||
func googlePurchaseTokenHash(token string) string {
|
||||
return stableHash(strings.TrimSpace(token))
|
||||
}
|
||||
|
||||
func googlePaymentOrderID(appCode string, tokenHash string) string {
|
||||
return "gpay_" + stableHash(appcode.Normalize(appCode) + "|" + tokenHash)[:48]
|
||||
}
|
||||
|
||||
func externalGooglePaymentRef(purchase ledger.GooglePlayPurchase, tokenHash string) string {
|
||||
if strings.TrimSpace(purchase.OrderID) != "" {
|
||||
return purchase.OrderID
|
||||
}
|
||||
return "token:" + tokenHash[:32]
|
||||
}
|
||||
@ -28,6 +28,7 @@ const (
|
||||
bizTypeCoinSellerTransfer = "coin_seller_transfer"
|
||||
bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
|
||||
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
|
||||
bizTypeGooglePlayRecharge = "google_play_recharge"
|
||||
bizTypeGameDebit = "game_debit"
|
||||
bizTypeGameCredit = "game_credit"
|
||||
bizTypeGameRefund = "game_refund"
|
||||
|
||||
@ -146,7 +146,7 @@ func (r *Repository) CreateResource(ctx context.Context, command resourcedomain.
|
||||
nowMs,
|
||||
)
|
||||
if err != nil {
|
||||
return resourcedomain.Resource{}, err
|
||||
return resourcedomain.Resource{}, mapResourceWriteError(err)
|
||||
}
|
||||
resourceID, err := result.LastInsertId()
|
||||
if err != nil {
|
||||
@ -225,7 +225,7 @@ func (r *Repository) UpdateResource(ctx context.Context, command resourcedomain.
|
||||
command.ResourceID,
|
||||
)
|
||||
if err != nil {
|
||||
return resourcedomain.Resource{}, err
|
||||
return resourcedomain.Resource{}, mapResourceWriteError(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil {
|
||||
return resourcedomain.Resource{}, err
|
||||
@ -2215,6 +2215,13 @@ func resourceOffset(page int32, pageSize int32) int32 {
|
||||
return (page - 1) * pageSize
|
||||
}
|
||||
|
||||
func mapResourceWriteError(err error) error {
|
||||
if isMySQLDuplicateError(err) {
|
||||
return xerr.New(xerr.Conflict, "resource_code already exists")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func normalizeResourceCommand(command resourcedomain.ResourceCommand) resourcedomain.ResourceCommand {
|
||||
command.ResourceCode = strings.TrimSpace(command.ResourceCode)
|
||||
command.ResourceType = resourcedomain.NormalizeResourceType(command.ResourceType)
|
||||
|
||||
@ -140,6 +140,37 @@ func (s *Server) ListRechargeProducts(ctx context.Context, req *walletv1.ListRec
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// ConfirmGooglePayment 校验 Google Play purchase token 并完成钱包入账。
|
||||
func (s *Server) ConfirmGooglePayment(ctx context.Context, req *walletv1.ConfirmGooglePaymentRequest) (*walletv1.ConfirmGooglePaymentResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
receipt, err := s.svc.ConfirmGooglePayment(ctx, ledger.GooglePaymentCommand{
|
||||
AppCode: req.GetAppCode(),
|
||||
CommandID: req.GetCommandId(),
|
||||
UserID: req.GetUserId(),
|
||||
RegionID: req.GetRegionId(),
|
||||
ProductID: req.GetProductId(),
|
||||
ProductCode: req.GetProductCode(),
|
||||
PackageName: req.GetPackageName(),
|
||||
PurchaseToken: req.GetPurchaseToken(),
|
||||
OrderID: req.GetOrderId(),
|
||||
PurchaseTimeMS: req.GetPurchaseTimeMs(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &walletv1.ConfirmGooglePaymentResponse{
|
||||
PaymentOrderId: receipt.PaymentOrderID,
|
||||
TransactionId: receipt.TransactionID,
|
||||
Status: receipt.Status,
|
||||
ProductId: receipt.ProductID,
|
||||
ProductCode: receipt.ProductCode,
|
||||
CoinAmount: receipt.CoinAmount,
|
||||
Balance: balanceToProto(receipt.Balance),
|
||||
IdempotentReplay: receipt.IdempotentReplay,
|
||||
ConsumeState: receipt.ConsumeState,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListAdminRechargeProducts 返回后台内购配置列表。
|
||||
func (s *Server) ListAdminRechargeProducts(ctx context.Context, req *walletv1.ListAdminRechargeProductsRequest) (*walletv1.ListAdminRechargeProductsResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user