日志服务相关
This commit is contained in:
parent
544aee1860
commit
241ba59f38
4
Makefile
4
Makefile
@ -34,7 +34,10 @@ build:
|
||||
go build ./server/admin/...
|
||||
|
||||
# `admin` 在本机直接启动后台管理后端;依赖默认从 server/admin/configs/config.yaml 读取。
|
||||
# 启动前自动杀掉占用 13100 端口的旧进程,避免 bind: address already in use。
|
||||
ADMIN_PORT ?= 13100
|
||||
admin:
|
||||
@pids=$$(lsof -ti tcp:$(ADMIN_PORT)) && [ -n "$$pids" ] && kill -9 $$pids 2>/dev/null || true
|
||||
cd server/admin && go run ./cmd/server -config $(ADMIN_CONFIG)
|
||||
|
||||
# `admin-up` 先拉起后台本地依赖,再在本机启动后台管理后端。
|
||||
@ -50,6 +53,7 @@ admin-deps:
|
||||
|
||||
# `admin-bootstrap` 显式执行后台初始化种子后启动服务。
|
||||
admin-bootstrap:
|
||||
@pids=$$(lsof -ti tcp:$(ADMIN_PORT)) && [ -n "$$pids" ] && kill -9 $$pids 2>/dev/null || true
|
||||
cd server/admin && go run ./cmd/server -config $(ADMIN_CONFIG) -bootstrap
|
||||
|
||||
# `admin-test` 只跑后台管理后端测试。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -71,6 +71,33 @@ message AdminCreditAssetResponse {
|
||||
AssetBalance balance = 2;
|
||||
}
|
||||
|
||||
// AdminCreditCoinSellerStockRequest 是后台给 active 币商专用库存入账的账务命令。
|
||||
message AdminCreditCoinSellerStockRequest {
|
||||
string command_id = 1;
|
||||
int64 seller_user_id = 2;
|
||||
string stock_type = 3;
|
||||
int64 coin_amount = 4;
|
||||
string paid_currency_code = 5;
|
||||
int64 paid_amount_micro = 6;
|
||||
string payment_ref = 7;
|
||||
string evidence_ref = 8;
|
||||
int64 operator_user_id = 9;
|
||||
string reason = 10;
|
||||
string app_code = 11;
|
||||
}
|
||||
|
||||
message AdminCreditCoinSellerStockResponse {
|
||||
string transaction_id = 1;
|
||||
int64 seller_user_id = 2;
|
||||
string stock_type = 3;
|
||||
int64 coin_amount = 4;
|
||||
string paid_currency_code = 5;
|
||||
int64 paid_amount_micro = 6;
|
||||
bool counts_as_seller_recharge = 7;
|
||||
int64 balance_after = 8;
|
||||
int64 created_at_ms = 9;
|
||||
}
|
||||
|
||||
// TransferCoinFromSellerRequest 是币商给玩家转普通金币的内部账务命令。
|
||||
message TransferCoinFromSellerRequest {
|
||||
string command_id = 1;
|
||||
@ -558,6 +585,7 @@ service WalletService {
|
||||
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
|
||||
rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse);
|
||||
rpc AdminCreditAsset(AdminCreditAssetRequest) returns (AdminCreditAssetResponse);
|
||||
rpc AdminCreditCoinSellerStock(AdminCreditCoinSellerStockRequest) returns (AdminCreditCoinSellerStockResponse);
|
||||
rpc TransferCoinFromSeller(TransferCoinFromSellerRequest) returns (TransferCoinFromSellerResponse);
|
||||
rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse);
|
||||
rpc GetResource(GetResourceRequest) returns (GetResourceResponse);
|
||||
|
||||
@ -19,30 +19,31 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
|
||||
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
|
||||
WalletService_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset"
|
||||
WalletService_TransferCoinFromSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferCoinFromSeller"
|
||||
WalletService_ListResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResources"
|
||||
WalletService_GetResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResource"
|
||||
WalletService_CreateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResource"
|
||||
WalletService_UpdateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResource"
|
||||
WalletService_SetResourceStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceStatus"
|
||||
WalletService_ListResourceGroups_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGroups"
|
||||
WalletService_GetResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResourceGroup"
|
||||
WalletService_CreateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResourceGroup"
|
||||
WalletService_UpdateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResourceGroup"
|
||||
WalletService_SetResourceGroupStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceGroupStatus"
|
||||
WalletService_ListGiftConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftConfigs"
|
||||
WalletService_CreateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateGiftConfig"
|
||||
WalletService_UpdateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateGiftConfig"
|
||||
WalletService_SetGiftConfigStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetGiftConfigStatus"
|
||||
WalletService_GrantResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResource"
|
||||
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
|
||||
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
|
||||
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
|
||||
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
|
||||
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
|
||||
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
|
||||
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
|
||||
WalletService_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset"
|
||||
WalletService_AdminCreditCoinSellerStock_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditCoinSellerStock"
|
||||
WalletService_TransferCoinFromSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferCoinFromSeller"
|
||||
WalletService_ListResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResources"
|
||||
WalletService_GetResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResource"
|
||||
WalletService_CreateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResource"
|
||||
WalletService_UpdateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResource"
|
||||
WalletService_SetResourceStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceStatus"
|
||||
WalletService_ListResourceGroups_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGroups"
|
||||
WalletService_GetResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResourceGroup"
|
||||
WalletService_CreateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResourceGroup"
|
||||
WalletService_UpdateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResourceGroup"
|
||||
WalletService_SetResourceGroupStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceGroupStatus"
|
||||
WalletService_ListGiftConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftConfigs"
|
||||
WalletService_CreateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateGiftConfig"
|
||||
WalletService_UpdateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateGiftConfig"
|
||||
WalletService_SetGiftConfigStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetGiftConfigStatus"
|
||||
WalletService_GrantResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResource"
|
||||
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
|
||||
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
|
||||
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
|
||||
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
|
||||
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
|
||||
)
|
||||
|
||||
// WalletServiceClient is the client API for WalletService service.
|
||||
@ -54,6 +55,7 @@ type WalletServiceClient interface {
|
||||
DebitGift(ctx context.Context, in *DebitGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
|
||||
GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error)
|
||||
AdminCreditAsset(ctx context.Context, in *AdminCreditAssetRequest, opts ...grpc.CallOption) (*AdminCreditAssetResponse, error)
|
||||
AdminCreditCoinSellerStock(ctx context.Context, in *AdminCreditCoinSellerStockRequest, opts ...grpc.CallOption) (*AdminCreditCoinSellerStockResponse, error)
|
||||
TransferCoinFromSeller(ctx context.Context, in *TransferCoinFromSellerRequest, opts ...grpc.CallOption) (*TransferCoinFromSellerResponse, error)
|
||||
ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error)
|
||||
GetResource(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*GetResourceResponse, error)
|
||||
@ -115,6 +117,16 @@ func (c *walletServiceClient) AdminCreditAsset(ctx context.Context, in *AdminCre
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) AdminCreditCoinSellerStock(ctx context.Context, in *AdminCreditCoinSellerStockRequest, opts ...grpc.CallOption) (*AdminCreditCoinSellerStockResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminCreditCoinSellerStockResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_AdminCreditCoinSellerStock_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) TransferCoinFromSeller(ctx context.Context, in *TransferCoinFromSellerRequest, opts ...grpc.CallOption) (*TransferCoinFromSellerResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(TransferCoinFromSellerResponse)
|
||||
@ -334,6 +346,7 @@ type WalletServiceServer interface {
|
||||
DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error)
|
||||
GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error)
|
||||
AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error)
|
||||
AdminCreditCoinSellerStock(context.Context, *AdminCreditCoinSellerStockRequest) (*AdminCreditCoinSellerStockResponse, error)
|
||||
TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error)
|
||||
ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error)
|
||||
GetResource(context.Context, *GetResourceRequest) (*GetResourceResponse, error)
|
||||
@ -374,6 +387,9 @@ func (UnimplementedWalletServiceServer) GetBalances(context.Context, *GetBalance
|
||||
func (UnimplementedWalletServiceServer) AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AdminCreditAsset not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) AdminCreditCoinSellerStock(context.Context, *AdminCreditCoinSellerStockRequest) (*AdminCreditCoinSellerStockResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AdminCreditCoinSellerStock not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method TransferCoinFromSeller not implemented")
|
||||
}
|
||||
@ -512,6 +528,24 @@ func _WalletService_AdminCreditAsset_Handler(srv interface{}, ctx context.Contex
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_AdminCreditCoinSellerStock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminCreditCoinSellerStockRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).AdminCreditCoinSellerStock(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_AdminCreditCoinSellerStock_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).AdminCreditCoinSellerStock(ctx, req.(*AdminCreditCoinSellerStockRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_TransferCoinFromSeller_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(TransferCoinFromSellerRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -909,6 +943,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "AdminCreditAsset",
|
||||
Handler: _WalletService_AdminCreditAsset_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminCreditCoinSellerStock",
|
||||
Handler: _WalletService_AdminCreditCoinSellerStock_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "TransferCoinFromSeller",
|
||||
Handler: _WalletService_TransferCoinFromSeller_Handler,
|
||||
|
||||
264
docs/api-endpoints.md
Normal file
264
docs/api-endpoints.md
Normal file
@ -0,0 +1,264 @@
|
||||
# API 清单
|
||||
|
||||
本清单由当前仓库的 OpenAPI/路由合约生成。浏览器测试页只直接调用 HTTP JSON 接口;内部 gRPC 路径是 protobuf 合约引用,不代表公网 HTTP endpoint。
|
||||
|
||||
## Gateway HTTP
|
||||
|
||||
`/api/v1/**` 业务接口返回 `{code,message,request_id,data}` envelope;`/healthz/**` 不套 envelope。
|
||||
|
||||
| Method | Path | Tag | Operation | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| GET | `/healthz/live` | health | `gatewayLive` | 进程存活探测 |
|
||||
| GET | `/healthz/ready` | health | `gatewayReady` | 接流量就绪探测 |
|
||||
| POST | `/api/v1/auth/account/login` | auth | `loginAccountPassword` | 账号密码登录 |
|
||||
| POST | `/api/v1/auth/third-party/login` | auth | `loginThirdParty` | 三方登录或注册 |
|
||||
| POST | `/api/v1/auth/password/set` | auth | `setPassword` | 已登录用户首次设置密码 |
|
||||
| POST | `/api/v1/auth/token/refresh` | auth | `refreshToken` | 刷新 token |
|
||||
| POST | `/api/v1/auth/logout` | auth | `logout` | 登出并失效 refresh session |
|
||||
| GET | `/api/v1/countries` | countries | `listRegistrationCountries` | 获取 App 注册页可选国家 |
|
||||
| GET | `/api/v1/app/bootstrap` | app-config | `getAppBootstrap` | 获取 App 启动配置摘要 |
|
||||
| GET | `/api/v1/app/h5-links` | app-config | `listAppH5Links` | 获取 App H5 入口地址 |
|
||||
| GET | `/api/v1/im/usersig` | tencent-im | `issueTencentIMUserSig` | 获取腾讯云 IM UserSig |
|
||||
| POST | `/api/v1/rtc/token` | tencent-rtc | `issueTencentRTCToken` | 获取腾讯 RTC 进房 UserSig |
|
||||
| POST | `/api/v1/tencent-rtc/callback` | tencent-rtc | `handleTencentRTCCallback` | 腾讯 RTC 服务端事件回调 |
|
||||
| POST | `/api/v1/files/upload` | files | `uploadFile` | 上传 App 通用文件 |
|
||||
| POST | `/api/v1/files/avatar/upload` | files | `uploadAvatar` | 上传用户头像 |
|
||||
| POST | `/api/v1/devices/push-token` | devices | `bindPushToken` | 绑定设备推送 token |
|
||||
| DELETE | `/api/v1/devices/push-token` | devices | `deletePushToken` | 删除设备推送 token |
|
||||
| POST | `/api/v1/tencent-im/callback` | tencent-im | `handleTencentIMCallback` | 腾讯云 IM 服务端回调 |
|
||||
| GET | `/api/v1/users/by-display-user-id/{display_user_id}` | users | `resolveDisplayUserID` | 通过当前展示短号解析用户 |
|
||||
| GET | `/api/v1/users/profiles:batch` | users | `batchUserProfiles` | 批量查询会话展示资料 |
|
||||
| GET | `/api/v1/users/me/identity` | users | `getMyIdentity` | 查询当前用户展示短号状态 |
|
||||
| GET | `/api/v1/users/me/host-identity` | users | `getMyHostIdentity` | 查询当前用户 Host/Agency/BD 身份 |
|
||||
| GET | `/api/v1/users/me/overview` | users | `getMyOverview` | 我的页首屏聚合摘要 |
|
||||
| POST | `/api/v1/users/me/onboarding/complete` | users | `completeMyOnboarding` | 完成注册页必填资料 |
|
||||
| POST | `/api/v1/users/me/profile/update` | users | `updateMyProfile` | 修改当前用户基础资料 |
|
||||
| POST | `/api/v1/users/me/country/change` | users | `changeMyCountry` | 修改当前用户国家 |
|
||||
| POST | `/api/v1/users/me/display-id/change` | users | `changeMyDisplayUserID` | 修改当前用户默认短号 |
|
||||
| POST | `/api/v1/users/me/display-id/pretty/apply` | users | `applyMyPrettyDisplayUserID` | 申请临时靓号 |
|
||||
| GET | `/api/v1/rooms/current` | rooms | `getCurrentRoom` | 查询当前可恢复房间 |
|
||||
| GET | `/api/v1/rooms/snapshot` | rooms | `getRoomSnapshot` | 查询房间完整快照 |
|
||||
| POST | `/api/v1/rooms/create` | rooms | `createRoom` | 创建房间 |
|
||||
| POST | `/api/v1/rooms/join` | rooms | `joinRoom` | 加入房间 |
|
||||
| POST | `/api/v1/rooms/heartbeat` | rooms | `roomHeartbeat` | 刷新房间业务 presence |
|
||||
| POST | `/api/v1/rooms/leave` | rooms | `leaveRoom` | 离开房间 |
|
||||
| POST | `/api/v1/rooms/mic/up` | rooms | `micUp` | 上麦 |
|
||||
| POST | `/api/v1/rooms/mic/down` | rooms | `micDown` | 下麦 |
|
||||
| POST | `/api/v1/rooms/mic/change` | rooms | `changeMicSeat` | 调整麦位 |
|
||||
| POST | `/api/v1/rooms/mic/publishing/confirm` | rooms | `confirmMicPublishing` | 确认 RTC 音频发布成功 |
|
||||
| POST | `/api/v1/rooms/mic/lock` | rooms | `setMicSeatLock` | 锁定或解锁麦位 |
|
||||
| POST | `/api/v1/rooms/chat/enabled` | rooms | `setChatEnabled` | 开启或关闭房间公屏 |
|
||||
| POST | `/api/v1/rooms/admin/set` | rooms | `setRoomAdmin` | 添加或移除房间管理员 |
|
||||
| POST | `/api/v1/rooms/host/transfer` | rooms | `transferRoomHost` | 转移房间主持人 |
|
||||
| POST | `/api/v1/rooms/user/mute` | rooms | `muteUser` | 设置房间内禁言 |
|
||||
| POST | `/api/v1/rooms/user/kick` | rooms | `kickUser` | 踢出房间用户 |
|
||||
| POST | `/api/v1/rooms/user/unban` | rooms | `unbanUser` | 解除房间 ban |
|
||||
| POST | `/api/v1/rooms/gift/send` | rooms | `sendGift` | 发送礼物 |
|
||||
| GET | `/api/v1/resources` | resources | `listResources` | App 资源列表 |
|
||||
| GET | `/api/v1/resource-groups/{group_id}` | resources | `getResourceGroup` | App 资源组详情 |
|
||||
| GET | `/api/v1/gifts` | resources | `listGifts` | App 礼物列表 |
|
||||
| GET | `/api/v1/users/me/resources` | resources | `listMyResources` | 我的资源权益 |
|
||||
| POST | `/api/v1/users/me/resources/{resource_id}/equip` | resources | `equipMyResource` | 佩戴我的资源 |
|
||||
| GET | `/api/v1/messages/tabs` | messages | `listMessageTabs` | 消息 tab 分区摘要 |
|
||||
| GET | `/api/v1/messages` | messages | `listInboxMessages` | system/activity 消息列表 |
|
||||
| POST | `/api/v1/messages/read-all` | messages | `markInboxSectionRead` | 标记分区全部已读 |
|
||||
| POST | `/api/v1/messages/{message_id}/read` | messages | `markInboxMessageRead` | 标记单条消息已读 |
|
||||
| DELETE | `/api/v1/messages/{message_id}` | messages | `deleteInboxMessage` | 删除单条消息 |
|
||||
| POST | `/api/v1/wallet/coin-seller/transfer` | wallet | `transferCoinFromSeller` | 币商给玩家转金币 |
|
||||
|
||||
## Admin HTTP
|
||||
|
||||
后台接口实际路径包含 `/api/v1` 前缀,鉴权使用后台登录 token。
|
||||
|
||||
| Method | Path | Tag | Operation | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| GET | `/api/v1/admin/agencies` | admin | `listAgencies` | listAgencies |
|
||||
| POST | `/api/v1/admin/agencies` | admin | `createAgency` | createAgency |
|
||||
| POST | `/api/v1/admin/agencies/{agency_id}/close` | admin | `closeAgency` | closeAgency |
|
||||
| POST | `/api/v1/admin/agencies/{agency_id}/join-enabled` | admin | `setAgencyJoinEnabled` | setAgencyJoinEnabled |
|
||||
| GET | `/api/v1/admin/app-config/banners` | admin | `listBanners` | listBanners |
|
||||
| POST | `/api/v1/admin/app-config/banners` | admin | `createBanner` | createBanner |
|
||||
| DELETE | `/api/v1/admin/app-config/banners/{banner_id}` | admin | `deleteBanner` | deleteBanner |
|
||||
| PUT | `/api/v1/admin/app-config/banners/{banner_id}` | admin | `updateBanner` | updateBanner |
|
||||
| GET | `/api/v1/admin/app-config/h5-links` | admin | `listH5Links` | listH5Links |
|
||||
| PUT | `/api/v1/admin/app-config/h5-links` | admin | `updateH5Links` | updateH5Links |
|
||||
| GET | `/api/v1/admin/apps` | admin | `listApps` | listApps |
|
||||
| GET | `/api/v1/admin/bd-leaders` | admin | `listBDLeaders` | listBDLeaders |
|
||||
| POST | `/api/v1/admin/bd-leaders` | admin | `createBDLeader` | createBDLeader |
|
||||
| PATCH | `/api/v1/admin/bd-leaders/{user_id}/status` | admin | `setBDLeaderStatus` | setBDLeaderStatus |
|
||||
| GET | `/api/v1/admin/bds` | admin | `listBDs` | listBDs |
|
||||
| POST | `/api/v1/admin/bds` | admin | `createBD` | createBD |
|
||||
| PATCH | `/api/v1/admin/bds/{user_id}/status` | admin | `setBDStatus` | setBDStatus |
|
||||
| GET | `/api/v1/admin/coin-sellers` | admin | `listCoinSellers` | listCoinSellers |
|
||||
| POST | `/api/v1/admin/coin-sellers` | admin | `createCoinSeller` | createCoinSeller |
|
||||
| PATCH | `/api/v1/admin/coin-sellers/{user_id}/status` | admin | `setCoinSellerStatus` | setCoinSellerStatus |
|
||||
| POST | `/api/v1/admin/coin-sellers/{user_id}/stock-credits` | admin | `creditCoinSellerStock` | creditCoinSellerStock |
|
||||
| GET | `/api/v1/admin/countries` | admin | `listCountries` | listCountries |
|
||||
| POST | `/api/v1/admin/countries` | admin | `createCountry` | createCountry |
|
||||
| DELETE | `/api/v1/admin/countries/{country_id}` | admin | `deleteCountry` | deleteCountry |
|
||||
| GET | `/api/v1/admin/countries/{country_id}` | admin | `getCountry` | getCountry |
|
||||
| PATCH | `/api/v1/admin/countries/{country_id}` | admin | `updateCountry` | updateCountry |
|
||||
| POST | `/api/v1/admin/countries/{country_id}/disable` | admin | `disableCountry` | disableCountry |
|
||||
| POST | `/api/v1/admin/countries/{country_id}/enable` | admin | `enableCountry` | enableCountry |
|
||||
| POST | `/api/v1/admin/files/image/upload` | admin | `uploadImage` | uploadImage |
|
||||
| POST | `/api/v1/admin/files/upload` | admin | `uploadFile` | uploadFile |
|
||||
| GET | `/api/v1/admin/gifts` | admin | `listGifts` | listGifts |
|
||||
| POST | `/api/v1/admin/gifts` | admin | `createGift` | createGift |
|
||||
| PUT | `/api/v1/admin/gifts/{gift_id}` | admin | `updateGift` | updateGift |
|
||||
| POST | `/api/v1/admin/gifts/{gift_id}/disable` | admin | `disableGift` | disableGift |
|
||||
| POST | `/api/v1/admin/gifts/{gift_id}/enable` | admin | `enableGift` | enableGift |
|
||||
| GET | `/api/v1/admin/hosts` | admin | `listHosts` | listHosts |
|
||||
| GET | `/api/v1/admin/payment/recharge-bills` | admin | `listRechargeBills` | listRechargeBills |
|
||||
| GET | `/api/v1/admin/regions` | admin | `listRegions` | listRegions |
|
||||
| POST | `/api/v1/admin/regions` | admin | `createRegion` | createRegion |
|
||||
| DELETE | `/api/v1/admin/regions/{region_id}` | admin | `disableRegion` | disableRegion |
|
||||
| GET | `/api/v1/admin/regions/{region_id}` | admin | `getRegion` | getRegion |
|
||||
| PATCH | `/api/v1/admin/regions/{region_id}` | admin | `updateRegion` | updateRegion |
|
||||
| PUT | `/api/v1/admin/regions/{region_id}/countries` | admin | `replaceRegionCountries` | replaceRegionCountries |
|
||||
| POST | `/api/v1/admin/regions/{region_id}/enable` | admin | `enableRegion` | enableRegion |
|
||||
| GET | `/api/v1/admin/resource-grants` | admin | `listResourceGrants` | listResourceGrants |
|
||||
| POST | `/api/v1/admin/resource-grants/group` | admin | `grantResourceGroup` | grantResourceGroup |
|
||||
| POST | `/api/v1/admin/resource-grants/resource` | admin | `grantResource` | grantResource |
|
||||
| GET | `/api/v1/admin/resource-groups` | admin | `listResourceGroups` | listResourceGroups |
|
||||
| POST | `/api/v1/admin/resource-groups` | admin | `createResourceGroup` | createResourceGroup |
|
||||
| GET | `/api/v1/admin/resource-groups/{group_id}` | admin | `getResourceGroup` | getResourceGroup |
|
||||
| PUT | `/api/v1/admin/resource-groups/{group_id}` | admin | `updateResourceGroup` | updateResourceGroup |
|
||||
| POST | `/api/v1/admin/resource-groups/{group_id}/disable` | admin | `disableResourceGroup` | disableResourceGroup |
|
||||
| POST | `/api/v1/admin/resource-groups/{group_id}/enable` | admin | `enableResourceGroup` | enableResourceGroup |
|
||||
| PUT | `/api/v1/admin/resource-groups/{group_id}/items` | admin | `updateResourceGroupItems` | updateResourceGroupItems |
|
||||
| GET | `/api/v1/admin/resources` | admin | `listResources` | listResources |
|
||||
| POST | `/api/v1/admin/resources` | admin | `createResource` | createResource |
|
||||
| GET | `/api/v1/admin/resources/{resource_id}` | admin | `getResource` | getResource |
|
||||
| PUT | `/api/v1/admin/resources/{resource_id}` | admin | `updateResource` | updateResource |
|
||||
| POST | `/api/v1/admin/resources/{resource_id}/disable` | admin | `disableResource` | disableResource |
|
||||
| POST | `/api/v1/admin/resources/{resource_id}/enable` | admin | `enableResource` | enableResource |
|
||||
| GET | `/api/v1/admin/rooms` | admin | `listRooms` | listRooms |
|
||||
| DELETE | `/api/v1/admin/rooms/{room_id}` | admin | `deleteRoom` | deleteRoom |
|
||||
| PATCH | `/api/v1/admin/rooms/{room_id}` | admin | `updateRoom` | updateRoom |
|
||||
| GET | `/api/v1/app/users` | admin | `appListUsers` | appListUsers |
|
||||
| GET | `/api/v1/app/users/{id}` | admin | `appGetUser` | appGetUser |
|
||||
| PATCH | `/api/v1/app/users/{id}` | admin | `appUpdateUser` | appUpdateUser |
|
||||
| POST | `/api/v1/app/users/{id}/ban` | admin | `appBanUser` | appBanUser |
|
||||
| POST | `/api/v1/app/users/{id}/password` | admin | `appSetPassword` | appSetPassword |
|
||||
| POST | `/api/v1/app/users/{id}/unban` | admin | `appUnbanUser` | appUnbanUser |
|
||||
| POST | `/api/v1/auth/change-password` | admin | `changePassword` | changePassword |
|
||||
| POST | `/api/v1/auth/login` | admin | `login` | login |
|
||||
| POST | `/api/v1/auth/logout` | admin | `logout` | logout |
|
||||
| GET | `/api/v1/auth/me` | admin | `me` | me |
|
||||
| POST | `/api/v1/auth/refresh` | admin | `refresh` | refresh |
|
||||
| GET | `/api/v1/dashboard/overview` | admin | `dashboardOverview` | dashboardOverview |
|
||||
| POST | `/api/v1/exports/users` | admin | `createUserExportJob` | createUserExportJob |
|
||||
| GET | `/api/v1/jobs` | admin | `listJobs` | listJobs |
|
||||
| GET | `/api/v1/jobs/{id}/artifact` | admin | `downloadJobArtifact` | downloadJobArtifact |
|
||||
| POST | `/api/v1/jobs/{id}/cancel` | admin | `cancelJob` | cancelJob |
|
||||
| GET | `/api/v1/logs/login` | admin | `listLoginLogs` | listLoginLogs |
|
||||
| GET | `/api/v1/logs/login/export` | admin | `exportLoginLogs` | exportLoginLogs |
|
||||
| GET | `/api/v1/logs/operations` | admin | `listOperationLogs` | listOperationLogs |
|
||||
| GET | `/api/v1/logs/operations/export` | admin | `exportOperationLogs` | exportOperationLogs |
|
||||
| GET | `/api/v1/navigation/menus` | admin | `navigationMenus` | navigationMenus |
|
||||
| GET | `/api/v1/notifications` | admin | `listNotifications` | listNotifications |
|
||||
| DELETE | `/api/v1/notifications/{id}` | admin | `deleteNotification` | deleteNotification |
|
||||
| PATCH | `/api/v1/notifications/{id}/read` | admin | `markNotificationRead` | markNotificationRead |
|
||||
| PATCH | `/api/v1/notifications/read-all` | admin | `markAllNotificationsRead` | markAllNotificationsRead |
|
||||
| GET | `/api/v1/permissions` | admin | `listPermissions` | listPermissions |
|
||||
| POST | `/api/v1/permissions` | admin | `createPermission` | createPermission |
|
||||
| DELETE | `/api/v1/permissions/{id}` | admin | `deletePermission` | deletePermission |
|
||||
| PATCH | `/api/v1/permissions/{id}` | admin | `updatePermission` | updatePermission |
|
||||
| POST | `/api/v1/permissions/sync` | admin | `syncPermissions` | syncPermissions |
|
||||
| GET | `/api/v1/roles` | admin | `listRoles` | listRoles |
|
||||
| POST | `/api/v1/roles` | admin | `createRole` | createRole |
|
||||
| DELETE | `/api/v1/roles/{id}` | admin | `deleteRole` | deleteRole |
|
||||
| PATCH | `/api/v1/roles/{id}` | admin | `updateRole` | updateRole |
|
||||
| GET | `/api/v1/roles/{id}/data-scopes` | admin | `getRoleDataScopes` | getRoleDataScopes |
|
||||
| PUT | `/api/v1/roles/{id}/data-scopes` | admin | `replaceRoleDataScopes` | replaceRoleDataScopes |
|
||||
| PUT | `/api/v1/roles/{id}/permissions` | admin | `replaceRolePermissions` | replaceRolePermissions |
|
||||
| GET | `/api/v1/search` | admin | `search` | search |
|
||||
| GET | `/api/v1/system/menus` | admin | `listSystemMenus` | listSystemMenus |
|
||||
| POST | `/api/v1/system/menus` | admin | `createMenu` | createMenu |
|
||||
| DELETE | `/api/v1/system/menus/{id}` | admin | `deleteMenu` | deleteMenu |
|
||||
| PATCH | `/api/v1/system/menus/{id}` | admin | `updateMenu` | updateMenu |
|
||||
| PATCH | `/api/v1/system/menus/{id}/visible` | admin | `updateMenuVisible` | updateMenuVisible |
|
||||
| PUT | `/api/v1/system/menus/sort` | admin | `sortMenus` | sortMenus |
|
||||
| GET | `/api/v1/users` | admin | `listUsers` | listUsers |
|
||||
| POST | `/api/v1/users` | admin | `createUser` | createUser |
|
||||
| GET | `/api/v1/users/{id}` | admin | `getUser` | getUser |
|
||||
| PATCH | `/api/v1/users/{id}` | admin | `updateUser` | updateUser |
|
||||
| POST | `/api/v1/users/{id}/reset-password` | admin | `resetUserPassword` | resetUserPassword |
|
||||
| PATCH | `/api/v1/users/{id}/status` | admin | `updateUserStatus` | updateUserStatus |
|
||||
| POST | `/api/v1/users/batch/status` | admin | `batchUpdateUserStatus` | batchUpdateUserStatus |
|
||||
| GET | `/api/v1/users/export` | admin | `exportUsers` | exportUsers |
|
||||
|
||||
## Internal gRPC/OpenAPI Reference
|
||||
|
||||
这些路径用于记录 gRPC full method,不能直接用浏览器 fetch 调用。
|
||||
|
||||
| Method | Path | Tag | Operation | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| POST | `/hyapp.user.v1.AuthService/LoginPassword` | auth | `userAuthLoginPassword` | 短号密码登录 |
|
||||
| POST | `/hyapp.user.v1.AuthService/LoginThirdParty` | auth | `userAuthLoginThirdParty` | 三方登录或注册 |
|
||||
| POST | `/hyapp.user.v1.AuthService/SetPassword` | auth | `userAuthSetPassword` | 首次设置密码 |
|
||||
| POST | `/hyapp.user.v1.AuthService/RefreshToken` | auth | `userAuthRefreshToken` | 刷新 token |
|
||||
| POST | `/hyapp.user.v1.AuthService/Logout` | auth | `userAuthLogout` | 登出并失效 refresh session |
|
||||
| POST | `/hyapp.user.v1.UserService/GetUser` | users | `userGetUser` | 查询单个用户主状态 |
|
||||
| POST | `/hyapp.user.v1.UserService/BatchGetUsers` | users | `userBatchGetUsers` | 批量查询用户主状态 |
|
||||
| POST | `/hyapp.user.v1.UserService/ListUserIDs` | users | `userListUserIDs` | 后台任务按 user_id 游标查询目标用户 |
|
||||
| POST | `/hyapp.user.v1.UserService/UpdateUserProfile` | users | `userUpdateUserProfile` | 修改用户基础资料 |
|
||||
| POST | `/hyapp.user.v1.UserService/ChangeUserCountry` | users | `userChangeUserCountry` | 修改用户国家 |
|
||||
| POST | `/hyapp.user.v1.UserService/CompleteOnboarding` | users | `userCompleteOnboarding` | 完成注册页必填资料 |
|
||||
| POST | `/hyapp.user.v1.UserDeviceService/BindPushToken` | user-devices | `userBindPushToken` | 绑定当前登录用户的系统推送 token |
|
||||
| POST | `/hyapp.user.v1.UserDeviceService/DeletePushToken` | user-devices | `userDeletePushToken` | 失效当前登录用户的系统推送 token |
|
||||
| POST | `/hyapp.user.v1.CountryAdminService/CreateCountry` | country-admin | `userCreateCountry` | 创建国家主数据 |
|
||||
| POST | `/hyapp.user.v1.CountryAdminService/ListCountries` | country-admin | `userListCountries` | 查询国家主数据 |
|
||||
| POST | `/hyapp.user.v1.CountryAdminService/UpdateCountry` | country-admin | `userUpdateCountry` | 修改国家展示字段 |
|
||||
| POST | `/hyapp.user.v1.CountryAdminService/EnableCountry` | country-admin | `userEnableCountry` | 开放国家给 App 选择 |
|
||||
| POST | `/hyapp.user.v1.CountryAdminService/DisableCountry` | country-admin | `userDisableCountry` | 关闭国家 App 可选状态 |
|
||||
| POST | `/hyapp.user.v1.CountryQueryService/ListRegistrationCountries` | country-query | `userListRegistrationCountries` | 查询 App 注册页开放国家列表 |
|
||||
| POST | `/hyapp.user.v1.RegionAdminService/CreateRegion` | region-admin | `userCreateRegion` | 创建区域并配置国家列表 |
|
||||
| POST | `/hyapp.user.v1.RegionAdminService/ListRegions` | region-admin | `userListRegions` | 查询区域列表 |
|
||||
| POST | `/hyapp.user.v1.RegionAdminService/GetRegion` | region-admin | `userGetRegion` | 查询单个区域 |
|
||||
| POST | `/hyapp.user.v1.RegionAdminService/UpdateRegion` | region-admin | `userUpdateRegion` | 修改区域展示字段 |
|
||||
| POST | `/hyapp.user.v1.RegionAdminService/ReplaceRegionCountries` | region-admin | `userReplaceRegionCountries` | 替换区域国家列表 |
|
||||
| POST | `/hyapp.user.v1.RegionAdminService/DisableRegion` | region-admin | `userDisableRegion` | 停用区域 |
|
||||
| POST | `/hyapp.user.v1.UserIdentityService/GetUserIdentity` | user-identity | `userGetUserIdentity` | 按 user_id 查询当前展示短号 |
|
||||
| POST | `/hyapp.user.v1.UserIdentityService/ResolveDisplayUserID` | user-identity | `userResolveDisplayUserID` | 通过当前展示短号解析用户 |
|
||||
| POST | `/hyapp.user.v1.UserIdentityService/ChangeDisplayUserID` | user-identity | `userChangeDisplayUserID` | 修改默认展示短号 |
|
||||
| POST | `/hyapp.user.v1.UserIdentityService/ApplyPrettyDisplayUserID` | user-identity | `userApplyPrettyDisplayUserID` | 申请临时靓号 |
|
||||
| POST | `/hyapp.user.v1.UserIdentityService/ExpirePrettyDisplayUserID` | user-identity | `userExpirePrettyDisplayUserID` | 主动触发靓号过期恢复 |
|
||||
| POST | `/grpc.health.v1.Health/Check` | health | `userHealthCheck` | 标准 gRPC health check |
|
||||
| POST | `/grpc.health.v1.Health/Watch` | health | `userHealthWatch` | 标准 gRPC health watch |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/CreateRoom` | room-command | `roomCreateRoom` | 创建房间 |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/JoinRoom` | room-command | `roomJoinRoom` | 加入房间业务 presence |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/LeaveRoom` | room-command | `roomLeaveRoom` | 离开房间业务 presence |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/MicUp` | room-command | `roomMicUp` | 上麦 |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/MicDown` | room-command | `roomMicDown` | 下麦 |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/ChangeMicSeat` | room-command | `roomChangeMicSeat` | 调整麦位 |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/ConfirmMicPublishing` | room-command | `roomConfirmMicPublishing` | 确认 RTC 音频发布成功 |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/SetMicSeatLock` | room-command | `roomSetMicSeatLock` | 锁定或解锁麦位 |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/SetChatEnabled` | room-command | `roomSetChatEnabled` | 开启或关闭公屏 |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/SetRoomAdmin` | room-command | `roomSetRoomAdmin` | 添加或移除房间管理员 |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/TransferRoomHost` | room-command | `roomTransferRoomHost` | 转移主持人 |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/MuteUser` | room-command | `roomMuteUser` | 设置房间内禁言 |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/KickUser` | room-command | `roomKickUser` | 踢出房间用户 |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/UnbanUser` | room-command | `roomUnbanUser` | 解除房间 ban |
|
||||
| POST | `/hyapp.room.v1.RoomCommandService/SendGift` | room-command | `roomSendGift` | 发送礼物 |
|
||||
| POST | `/hyapp.room.v1.RoomGuardService/CheckSpeakPermission` | room-guard | `roomCheckSpeakPermission` | 发言前守卫 |
|
||||
| POST | `/hyapp.room.v1.RoomGuardService/VerifyRoomPresence` | room-guard | `roomVerifyRoomPresence` | 入群前 presence 守卫 |
|
||||
| POST | `/hyapp.room.v1.RoomQueryService/GetCurrentRoom` | room-query | `roomGetCurrentRoom` | 查询用户当前可恢复房间 |
|
||||
| POST | `/hyapp.room.v1.RoomQueryService/GetRoomSnapshot` | room-query | `roomGetRoomSnapshot` | 查询房间完整快照 |
|
||||
| POST | `/grpc.health.v1.Health/Check` | health | `roomHealthCheck` | 标准 gRPC health check |
|
||||
| POST | `/grpc.health.v1.Health/Watch` | health | `roomHealthWatch` | 标准 gRPC health watch |
|
||||
| POST | `/hyapp.wallet.v1.WalletService/DebitGift` | wallet | `walletDebitGift` | 送礼扣费 |
|
||||
| POST | `/grpc.health.v1.Health/Check` | health | `walletHealthCheck` | 标准 gRPC health check |
|
||||
| POST | `/grpc.health.v1.Health/Watch` | health | `walletHealthWatch` | 标准 gRPC health watch |
|
||||
| POST | `/hyapp.activity.v1.ActivityService/PingActivity` | activity | `activityPingActivity` | 活动服务连通性探测 |
|
||||
| POST | `/hyapp.activity.v1.ActivityService/GetActivityStatus` | activity | `activityGetActivityStatus` | 查询活动状态 |
|
||||
| POST | `/hyapp.activity.v1.MessageInboxService/ListMessageTabs` | messages | `activityListMessageTabs` | 查询 App 消息 tab 摘要 |
|
||||
| POST | `/hyapp.activity.v1.MessageInboxService/ListInboxMessages` | messages | `activityListInboxMessages` | 分页查询 system/activity inbox |
|
||||
| POST | `/hyapp.activity.v1.MessageInboxService/MarkInboxMessageRead` | messages | `activityMarkInboxMessageRead` | 标记单条 inbox 消息已读 |
|
||||
| POST | `/hyapp.activity.v1.MessageInboxService/MarkInboxSectionRead` | messages | `activityMarkInboxSectionRead` | 标记一个消息分区全部已读 |
|
||||
| POST | `/hyapp.activity.v1.MessageInboxService/DeleteInboxMessage` | messages | `activityDeleteInboxMessage` | 删除当前用户可见 inbox 消息 |
|
||||
| POST | `/hyapp.activity.v1.MessageInboxService/CreateInboxMessage` | messages | `activityCreateInboxMessage` | 生产者创建单用户 inbox 消息 |
|
||||
| POST | `/hyapp.activity.v1.MessageInboxService/CreateFanoutJob` | messages | `activityCreateFanoutJob` | 创建后台 fanout 任务 |
|
||||
| POST | `/grpc.health.v1.Health/Check` | health | `activityHealthCheck` | 标准 gRPC health check |
|
||||
| POST | `/grpc.health.v1.Health/Watch` | health | `activityHealthWatch` | 标准 gRPC health watch |
|
||||
|
||||
88
docs/api-test-flows.md
Normal file
88
docs/api-test-flows.md
Normal file
@ -0,0 +1,88 @@
|
||||
# API 测试链路
|
||||
|
||||
本文件记录 `docs/api-test-harness.html` 内置的流程化链路。HTML 只直接执行 HTTP API;腾讯云 IM/TRTC SDK 动作作为客户端步骤列出,不伪造成后端接口。
|
||||
|
||||
## 01 三方注册/登录 -> 资料 -> 国家 -> token 生命周期
|
||||
|
||||
1. `GET /healthz/ready`:确认 gateway 可接流量。
|
||||
2. `GET /api/v1/countries`:确认注册页国家主数据可读。
|
||||
3. `POST /api/v1/auth/third-party/login`:使用真实 Firebase/Google ID token 登录或注册,写入 username/avatar/gender/birth/country/device/source 等注册字段。
|
||||
4. `GET /api/v1/users/me/identity`:确认 user_id/display_user_id 映射。
|
||||
5. `GET /api/v1/users/me/overview`:确认我的页聚合数据。
|
||||
6. `POST /api/v1/users/me/profile/update`:修改用户名、头像、生日。
|
||||
7. `POST /api/v1/users/me/country/change`:修改国家;同一用户一个月只允许一次,重复执行应返回冲突。
|
||||
8. `POST /api/v1/auth/password/set`:首次设置密码。
|
||||
9. `POST /api/v1/auth/token/refresh`:刷新 access token。
|
||||
10. `POST /api/v1/auth/logout`:失效 refresh session。
|
||||
|
||||
## 02 密码登录 -> refresh -> logout
|
||||
|
||||
1. `POST /api/v1/auth/account/login`:账号是当前展示短号。
|
||||
2. `GET /api/v1/users/me/identity`:验证登录态。
|
||||
3. `POST /api/v1/auth/token/refresh`:验证 refresh 轮换。
|
||||
4. `POST /api/v1/auth/logout`:验证 session 失效。
|
||||
|
||||
## 03 房间 -> IM -> RTC -> MicUp -> 发流确认 -> 下麦离房
|
||||
|
||||
1. `POST /api/v1/rooms/create`:创建房间,成功后回填 `room_id`。
|
||||
2. `POST /api/v1/rooms/join`:加入业务 presence。
|
||||
3. `GET /api/v1/rooms/current`:确认当前可恢复房间。
|
||||
4. `GET /api/v1/im/usersig`:获取 IM `sdk_app_id/user_id/user_sig`。
|
||||
5. 客户端 IM SDK:`TIM.create + login`,房间群 ID 使用 `room_id`。
|
||||
6. `POST /api/v1/rtc/token`:获取 RTC `sdk_app_id/rtc_user_id/user_sig/str_room_id`。
|
||||
7. 客户端 TRTC SDK:使用 `str_room_id` 进房。
|
||||
8. `POST /api/v1/rooms/mic/up`:麦位进入 `pending_publish`,回填 `mic_session_id/room_version`。
|
||||
9. 客户端 TRTC SDK:`switchRole(anchor) + startLocalAudio`。
|
||||
10. `POST /api/v1/rooms/mic/publishing/confirm`:带 `mic_session_id/room_version/event_time_ms` 确认发布。
|
||||
11. `GET /api/v1/rooms/snapshot`:确认麦位进入 `publishing`。
|
||||
12. `POST /api/v1/rooms/heartbeat`:刷新业务 presence。
|
||||
13. `POST /api/v1/rooms/mic/down`:下麦。
|
||||
14. `POST /api/v1/rooms/leave`:离房。
|
||||
|
||||
## 04 MicUp 未确认 -> publish_timeout 自动释放验证
|
||||
|
||||
1. `POST /api/v1/rooms/create`
|
||||
2. `POST /api/v1/rooms/join`
|
||||
3. `POST /api/v1/rtc/token`
|
||||
4. `POST /api/v1/rooms/mic/up`
|
||||
5. 不调用 `ConfirmMicPublishing`,等待 `publish_deadline_ms` 或服务配置 deadline。
|
||||
6. `GET /api/v1/rooms/current`
|
||||
7. `GET /api/v1/rooms/snapshot`:确认麦位释放或状态转移符合 `publish_timeout`。
|
||||
|
||||
## 05 App 消息 tab/inbox 读写链路
|
||||
|
||||
1. `GET /api/v1/messages/tabs`
|
||||
2. `GET /api/v1/messages`
|
||||
3. `POST /api/v1/messages/{message_id}/read`
|
||||
4. `POST /api/v1/messages/read-all`
|
||||
5. `DELETE /api/v1/messages/{message_id}`
|
||||
|
||||
## 06 资源/礼物/送礼/币商转账链路
|
||||
|
||||
1. `GET /api/v1/resources`
|
||||
2. `GET /api/v1/resource-groups/{group_id}`
|
||||
3. `GET /api/v1/gifts`
|
||||
4. `GET /api/v1/users/me/resources`
|
||||
5. `POST /api/v1/users/me/resources/{resource_id}/equip`
|
||||
6. `POST /api/v1/rooms/gift/send`
|
||||
7. `POST /api/v1/wallet/coin-seller/transfer`
|
||||
|
||||
## 07 后台国家/区域配置链路
|
||||
|
||||
1. `POST /api/v1/auth/login`
|
||||
2. `GET /api/v1/auth/me`
|
||||
3. `GET /api/v1/admin/countries`
|
||||
4. `POST /api/v1/admin/countries`
|
||||
5. `POST /api/v1/admin/countries/{country_id}/enable`
|
||||
6. `GET /api/v1/admin/regions`
|
||||
7. `POST /api/v1/admin/regions`
|
||||
8. `PUT /api/v1/admin/regions/{region_id}/countries`
|
||||
9. `POST /api/v1/admin/regions/{region_id}/enable`
|
||||
|
||||
## 08 后台币商库存入账 -> App 币商转账
|
||||
|
||||
1. `POST /api/v1/auth/login`
|
||||
2. `GET /api/v1/admin/coin-sellers`
|
||||
3. `POST /api/v1/admin/coin-sellers/{user_id}/stock-credits`
|
||||
4. `POST /api/v1/wallet/coin-seller/transfer`
|
||||
5. `GET /api/v1/admin/payment/recharge-bills`
|
||||
2794
docs/api-test-harness.html
Normal file
2794
docs/api-test-harness.html
Normal file
File diff suppressed because it is too large
Load Diff
422
docs/coin-seller-stock-credit-architecture.md
Normal file
422
docs/coin-seller-stock-credit-architecture.md
Normal file
@ -0,0 +1,422 @@
|
||||
# 币商进货与金币补偿架构设计
|
||||
|
||||
## 目标
|
||||
|
||||
币商管理列表增加“加金币”按钮。运营点击后打开“币商进货”弹窗,给币商的专用金币账户 `COIN_SELLER_COIN` 增加库存。
|
||||
|
||||
表单字段:
|
||||
|
||||
- `充值金币`:增加到币商库存的金币数量,必填。
|
||||
- `类型`:`USDT进货` / `金币补偿`,必填。
|
||||
- `充值金额`:币商线下支付给平台的 USDT 金额,仅 `USDT进货` 必填。
|
||||
|
||||
业务口径:
|
||||
|
||||
- `USDT进货`:平台收到币商线下 USDT 后,后台给币商发放 `COIN_SELLER_COIN` 库存;这笔记录计入币商进货/充值统计。
|
||||
- `金币补偿`:平台因为异常、活动、人工修正等原因给币商补库存;没有充值金额,不计入币商进货/充值统计。
|
||||
- 两种类型都不能给币商发放 `USD_BALANCE`,也不能直接影响玩家普通 `COIN`。
|
||||
|
||||
## 服务边界
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `server/admin` | 币商列表、按钮权限、弹窗表单、后台审计、调用 user-service 校验币商身份、调用 wallet-service 发放库存 |
|
||||
| `user-service` | 保存 `coin_seller_profiles` 身份事实,判断目标用户是否是有效币商 |
|
||||
| `wallet-service` | 拥有 `COIN_SELLER_COIN` 账户、钱包流水、币商进货记录、幂等和 outbox |
|
||||
| `gateway-service` | 不参与后台币商进货;App 币商给玩家转金币仍走现有 `/api/v1/wallet/coin-seller/transfer` |
|
||||
|
||||
边界规则:
|
||||
|
||||
- `server/admin` 不能直接改 `wallet_accounts`。
|
||||
- `user-service` 不能保存币商余额。
|
||||
- `wallet-service` 不判断后台菜单权限,但必须保存完整账务流水和进货记录。
|
||||
- 币商库存入账必须走钱包命令,不能用通用 SQL 修余额。
|
||||
|
||||
## 后台交互
|
||||
|
||||
### 币商列表
|
||||
|
||||
现有币商列表继续展示:
|
||||
|
||||
- 用户 ID / 短号
|
||||
- 昵称 / 头像
|
||||
- 区域
|
||||
- 币商状态
|
||||
- 币商库存余额 `merchantBalance`
|
||||
- 创建时间 / 更新时间
|
||||
|
||||
新增行操作:
|
||||
|
||||
| 按钮 | 权限 | 状态 |
|
||||
| --- | --- | --- |
|
||||
| `加金币` | `coin-seller:stock-credit` | 仅 active 币商可点击;disabled 币商按钮置灰 |
|
||||
|
||||
如果后续产品要求 disabled 币商也能补偿库存,必须新增单独权限或二次确认,不在首版默认放开。
|
||||
|
||||
### 弹窗表单
|
||||
|
||||
弹窗标题:`币商进货`
|
||||
|
||||
只读信息:
|
||||
|
||||
- 币商用户 ID
|
||||
- 币商短号
|
||||
- 昵称
|
||||
- 当前库存余额
|
||||
- 区域
|
||||
|
||||
可编辑字段:
|
||||
|
||||
| 字段 | 类型 | 规则 |
|
||||
| --- | --- | --- |
|
||||
| `type` | 单选 | `usdt_purchase` / `coin_compensation` |
|
||||
| `coin_amount` | 正整数 | 两种类型都必填,表示增加的 `COIN_SELLER_COIN` |
|
||||
| `recharge_amount` | 金额 | 仅 `usdt_purchase` 展示并必填;`coin_compensation` 不展示也不提交 |
|
||||
| `payment_ref` | 文本 | `usdt_purchase` 必填,建议填链上 tx hash、收款单号或财务凭证号 |
|
||||
| `evidence_ref` | 文本 | 凭证附件或后台上传后的文件 ID;首版建议两种类型都必填 |
|
||||
| `reason` | 文本 | 必填,写清楚进货或补偿原因 |
|
||||
| `command_id` | 文本 | 前端生成或 admin-server 生成,用于幂等 |
|
||||
|
||||
金额存储规则:
|
||||
|
||||
- `recharge_amount` 前端可以展示小数,例如 `120.50`。
|
||||
- 服务端统一转成 `paid_amount_micro`,USDT 按 6 位精度保存。
|
||||
- `coin_compensation` 必须满足 `paid_currency_code=''` 且 `paid_amount_micro=0`,如果客户端传了金额,服务端直接拒绝。
|
||||
|
||||
## 账务模型
|
||||
|
||||
币商库存仍然是钱包资产:
|
||||
|
||||
- 资产类型:`COIN_SELLER_COIN`
|
||||
- 账户表:`wallet_accounts`
|
||||
- 流水表:`wallet_transactions`
|
||||
- 分录表:`wallet_entries`
|
||||
- 事件表:`wallet_outbox`
|
||||
|
||||
新增专门的币商进货明细表,避免从通用钱包 metadata 里做统计。
|
||||
|
||||
```sql
|
||||
coin_seller_stock_records(
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
stock_id VARCHAR(96) NOT NULL,
|
||||
transaction_id VARCHAR(96) NOT NULL,
|
||||
command_id VARCHAR(128) NOT NULL,
|
||||
seller_user_id BIGINT NOT NULL,
|
||||
stock_type VARCHAR(32) NOT NULL,
|
||||
counts_as_seller_recharge BOOLEAN NOT NULL,
|
||||
coin_amount BIGINT NOT NULL,
|
||||
paid_currency_code VARCHAR(8) NOT NULL DEFAULT '',
|
||||
paid_amount_micro BIGINT NOT NULL DEFAULT 0,
|
||||
payment_ref VARCHAR(128) NULL,
|
||||
evidence_ref VARCHAR(256) NOT NULL DEFAULT '',
|
||||
operator_user_id BIGINT NOT NULL,
|
||||
reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||
balance_after BIGINT NOT NULL,
|
||||
metadata_json JSON NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, stock_id),
|
||||
UNIQUE KEY uk_coin_seller_stock_command (app_code, command_id),
|
||||
UNIQUE KEY uk_coin_seller_stock_payment_ref (app_code, stock_type, payment_ref),
|
||||
KEY idx_coin_seller_stock_seller_time (app_code, seller_user_id, created_at_ms),
|
||||
KEY idx_coin_seller_stock_type_time (app_code, stock_type, created_at_ms)
|
||||
);
|
||||
```
|
||||
|
||||
字段语义:
|
||||
|
||||
- `stock_type='usdt_purchase'`:`counts_as_seller_recharge=true`,`paid_currency_code='USDT'`,`paid_amount_micro>0`。
|
||||
- `stock_type='coin_compensation'`:`counts_as_seller_recharge=false`,`paid_currency_code=''`,`paid_amount_micro=0`。
|
||||
- `coin_amount` 是发给币商的库存金币,不是玩家充值金币。
|
||||
- `payment_ref` 对 USDT 进货必须唯一,防止同一笔线下收款重复入账。
|
||||
- `balance_after` 保存账后币商库存,后台详情不用再回放钱包分录。
|
||||
|
||||
注意:这张表不是 `wallet_recharge_records`。`wallet_recharge_records` 只记录币商给玩家转金币后形成的“玩家充值事实”。币商自己进货应该单独统计,不能混到玩家充值记录里。
|
||||
|
||||
## 业务类型
|
||||
|
||||
钱包交易 `biz_type` 建议拆成两个值:
|
||||
|
||||
| `biz_type` | 说明 | 是否计入币商充值统计 |
|
||||
| --- | --- | --- |
|
||||
| `coin_seller_stock_purchase` | USDT 进货,平台收到币商付款后发放库存 | 是 |
|
||||
| `coin_seller_coin_compensation` | 人工金币补偿、异常修复、活动补库存 | 否 |
|
||||
|
||||
不要复用 `manual_credit`,因为通用后台入账无法表达:
|
||||
|
||||
- 是否是币商库存
|
||||
- 是否是 USDT 进货
|
||||
- 是否计入币商充值统计
|
||||
- 线下付款凭证是否唯一
|
||||
- 金币补偿没有充值金额这个约束
|
||||
|
||||
## 主链路
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant Admin as Admin Web
|
||||
participant AdminSrv as server/admin
|
||||
participant User as user-service
|
||||
participant Wallet as wallet-service
|
||||
participant DB as wallet MySQL
|
||||
participant Audit as admin_operation_logs
|
||||
|
||||
Admin->>AdminSrv: POST /admin/coin-sellers/{user_id}/stock-credits
|
||||
AdminSrv->>AdminSrv: 校验 coin-seller:stock-credit 权限和表单
|
||||
AdminSrv->>User: GetCoinSellerProfile(user_id)
|
||||
User-->>AdminSrv: active coin_seller profile
|
||||
AdminSrv->>Wallet: AdminCreditCoinSellerStock(command)
|
||||
Wallet->>DB: BEGIN
|
||||
Wallet->>DB: 按 command_id / payment_ref 做幂等和重复收款校验
|
||||
Wallet->>DB: SELECT wallet_accounts FOR UPDATE
|
||||
Wallet->>DB: 增加 COIN_SELLER_COIN
|
||||
Wallet->>DB: 写 wallet_transactions / wallet_entries
|
||||
Wallet->>DB: 写 coin_seller_stock_records
|
||||
Wallet->>DB: 写 wallet_outbox
|
||||
Wallet->>DB: COMMIT
|
||||
AdminSrv->>Audit: 写后台操作审计
|
||||
AdminSrv-->>Admin: 返回 transaction_id 和账后余额
|
||||
```
|
||||
|
||||
失败边界:
|
||||
|
||||
- user-service 判断不是 active 币商时,不调用 wallet-service。
|
||||
- wallet-service 入账失败时,不写成功审计;可以写失败审计或错误日志。
|
||||
- 钱包事务提交成功但后台审计写失败时,必须记录告警;后续需要审计补偿任务,不能静默吞掉。
|
||||
|
||||
## Admin API
|
||||
|
||||
新增后台接口:
|
||||
|
||||
```http
|
||||
POST /admin/coin-sellers/{user_id}/stock-credits
|
||||
```
|
||||
|
||||
权限:
|
||||
|
||||
```text
|
||||
coin-seller:stock-credit
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"commandId": "admin-coin-seller-stock-20260509-001",
|
||||
"type": "usdt_purchase",
|
||||
"coinAmount": 8000000,
|
||||
"rechargeAmount": "100.000000",
|
||||
"paymentRef": "usdt-tx-hash-or-offline-receipt",
|
||||
"evidenceRef": "oss://admin-evidence/receipt.png",
|
||||
"reason": "币商 USDT 进货"
|
||||
}
|
||||
```
|
||||
|
||||
金币补偿请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"commandId": "admin-coin-seller-comp-20260509-001",
|
||||
"type": "coin_compensation",
|
||||
"coinAmount": 50000,
|
||||
"evidenceRef": "ticket-12345",
|
||||
"reason": "上次转账异常补偿"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"transactionId": "lalu_admin_coin_seller_stock_...",
|
||||
"sellerUserId": "163001",
|
||||
"stockType": "usdt_purchase",
|
||||
"coinAmount": 8000000,
|
||||
"paidCurrencyCode": "USDT",
|
||||
"paidAmountMicro": 100000000,
|
||||
"countsAsSellerRecharge": true,
|
||||
"balanceAfter": 12800000,
|
||||
"createdAtMs": 1760000000000
|
||||
}
|
||||
```
|
||||
|
||||
校验:
|
||||
|
||||
- `user_id` 必须是 active `coin_seller`。
|
||||
- `coinAmount > 0`。
|
||||
- `type=usdt_purchase` 时,`rechargeAmount > 0`、`paymentRef` 必填、`paid_currency_code=USDT`。
|
||||
- `type=coin_compensation` 时,不能提交 `rechargeAmount`,`paid_amount_micro` 固定为 0。
|
||||
- `reason` 必填。
|
||||
- `evidenceRef` 首版建议必填,方便财务和客服追踪。
|
||||
|
||||
## gRPC 契约建议
|
||||
|
||||
在 `wallet.v1.WalletService` 增加后台命令:
|
||||
|
||||
```protobuf
|
||||
message AdminCreditCoinSellerStockRequest {
|
||||
string command_id = 1;
|
||||
int64 seller_user_id = 2;
|
||||
string stock_type = 3;
|
||||
int64 coin_amount = 4;
|
||||
string paid_currency_code = 5;
|
||||
int64 paid_amount_micro = 6;
|
||||
string payment_ref = 7;
|
||||
string evidence_ref = 8;
|
||||
int64 operator_user_id = 9;
|
||||
string reason = 10;
|
||||
string app_code = 11;
|
||||
}
|
||||
|
||||
message AdminCreditCoinSellerStockResponse {
|
||||
string transaction_id = 1;
|
||||
int64 seller_user_id = 2;
|
||||
string stock_type = 3;
|
||||
int64 coin_amount = 4;
|
||||
string paid_currency_code = 5;
|
||||
int64 paid_amount_micro = 6;
|
||||
bool counts_as_seller_recharge = 7;
|
||||
int64 balance_after = 8;
|
||||
int64 created_at_ms = 9;
|
||||
}
|
||||
```
|
||||
|
||||
`server/admin` 负责在调用前通过 `user-service` 校验币商身份;`wallet-service` 负责校验金额、类型、幂等、重复 `payment_ref` 和钱包事务。
|
||||
|
||||
## 统计口径
|
||||
|
||||
币商库存相关统计从 `coin_seller_stock_records` 读取:
|
||||
|
||||
| 指标 | 查询条件 |
|
||||
| --- | --- |
|
||||
| 币商累计 USDT 进货金币 | `stock_type='usdt_purchase'` |
|
||||
| 币商累计 USDT 进货金额 | `counts_as_seller_recharge=true`,汇总 `paid_amount_micro` |
|
||||
| 币商累计金币补偿 | `stock_type='coin_compensation'` |
|
||||
| 当前库存余额 | `wallet_accounts.asset_type='COIN_SELLER_COIN'` |
|
||||
| 币商给玩家充值金币 | 现有 `wallet_recharge_records` 或 `biz_type='coin_seller_transfer'` |
|
||||
|
||||
金币补偿不计入币商充值统计的落地方式:
|
||||
|
||||
- 表单不展示 `充值金额`。
|
||||
- API 不接受 `rechargeAmount`。
|
||||
- 数据库存 `paid_amount_micro=0`。
|
||||
- `counts_as_seller_recharge=false`。
|
||||
- 统计 SQL 必须带 `counts_as_seller_recharge=true` 或 `stock_type='usdt_purchase'`。
|
||||
|
||||
## 列表性能
|
||||
|
||||
币商列表不要每行实时做大范围流水汇总。
|
||||
|
||||
首版建议:
|
||||
|
||||
- 列表只展示当前库存余额 `merchantBalance`,继续按当前实现从 `wallet_accounts` 按当页 user_id 批量查询。
|
||||
- 如果产品要展示累计进货金额、累计补偿金币、最近进货时间,只对当前页 user_id 从 `coin_seller_stock_records` 做 group by。
|
||||
- 如果后续需要按累计进货金额排序或筛选,再增加 `coin_seller_stock_stats` 投影表。
|
||||
|
||||
可选投影表:
|
||||
|
||||
```sql
|
||||
coin_seller_stock_stats(
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
seller_user_id BIGINT NOT NULL,
|
||||
purchase_coin_total BIGINT NOT NULL DEFAULT 0,
|
||||
purchase_paid_usdt_micro_total BIGINT NOT NULL DEFAULT 0,
|
||||
compensation_coin_total BIGINT NOT NULL DEFAULT 0,
|
||||
last_stock_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, seller_user_id)
|
||||
);
|
||||
```
|
||||
|
||||
投影更新可以和钱包事务同事务完成,也可以由 wallet outbox 异步更新。首版如果列表不展示累计值,不需要这张表。
|
||||
|
||||
## Outbox 事件
|
||||
|
||||
钱包事务成功后写出:
|
||||
|
||||
- `WalletCoinSellerStockPurchased`
|
||||
- `WalletCoinSellerCoinCompensated`
|
||||
- `WalletBalanceChanged`
|
||||
|
||||
事件 payload:
|
||||
|
||||
- `seller_user_id`
|
||||
- `stock_type`
|
||||
- `coin_amount`
|
||||
- `paid_currency_code`
|
||||
- `paid_amount_micro`
|
||||
- `counts_as_seller_recharge`
|
||||
- `transaction_id`
|
||||
- `operator_user_id`
|
||||
- `created_at_ms`
|
||||
|
||||
这些事件可用于后台通知、财务报表、风控告警和统计投影。
|
||||
|
||||
## 权限和审计
|
||||
|
||||
新增权限:
|
||||
|
||||
| Permission | Kind | Usage |
|
||||
| --- | --- | --- |
|
||||
| `coin-seller:stock-credit` | `button` | 币商进货、金币补偿 |
|
||||
|
||||
审计 action:
|
||||
|
||||
| Action | Resource | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `coin-seller-stock-credit` | `coin_seller_stock_records` | USDT 进货或金币补偿成功 |
|
||||
|
||||
审计 detail 必须包含:
|
||||
|
||||
- `command_id`
|
||||
- `seller_user_id`
|
||||
- `stock_type`
|
||||
- `coin_amount`
|
||||
- `paid_currency_code`
|
||||
- `paid_amount_micro`
|
||||
- `payment_ref`
|
||||
- `transaction_id`
|
||||
- `reason`
|
||||
|
||||
USDT 进货金额属于财务口径,不能只放前端日志。
|
||||
|
||||
## 错误码建议
|
||||
|
||||
纳入 `pkg/xerr` catalog:
|
||||
|
||||
| 业务错误 | gRPC code | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `COIN_SELLER_NOT_ACTIVE` | `FailedPrecondition` | 目标用户不是 active 币商 |
|
||||
| `COIN_SELLER_STOCK_TYPE_INVALID` | `InvalidArgument` | 类型不是 USDT 进货或金币补偿 |
|
||||
| `COIN_SELLER_STOCK_AMOUNT_INVALID` | `InvalidArgument` | 金币数或 USDT 金额不合法 |
|
||||
| `COIN_SELLER_PAYMENT_REF_DUPLICATED` | `AlreadyExists` | 同一 USDT 凭证已入账 |
|
||||
| `IDEMPOTENCY_CONFLICT` | `AlreadyExists` | command_id 重复但 payload 不同 |
|
||||
|
||||
客户端展示文案由 admin 前端根据业务 code 转换,不从内部错误字符串拼 UI。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 增加权限种子:`coin-seller:stock-credit`,币商列表行操作按权限展示。
|
||||
2. 扩展 `api/proto/wallet/v1/wallet.proto`:新增 `AdminCreditCoinSellerStock` 请求和响应。
|
||||
3. 增加 wallet MySQL 表 `coin_seller_stock_records`。
|
||||
4. 在 `wallet-service` 实现专用命令:校验类型、金额、payment_ref、幂等,锁定 `COIN_SELLER_COIN` 账户并入账。
|
||||
5. 在 `server/admin` 增加接口:校验 active 币商身份,调用 wallet-service,成功后写 `admin_operation_logs`。
|
||||
6. 后台页面币商列表增加“加金币”按钮和弹窗表单。
|
||||
7. 增加列表成功后的刷新策略:成功后刷新当前行余额或重新拉取当前页。
|
||||
8. 如需累计统计,再增加当前页聚合查询或 `coin_seller_stock_stats` 投影。
|
||||
|
||||
## 测试重点
|
||||
|
||||
必须覆盖:
|
||||
|
||||
- active 币商 USDT 进货成功,`COIN_SELLER_COIN` 增加,`counts_as_seller_recharge=true`。
|
||||
- USDT 进货缺少 `rechargeAmount` 或 `paymentRef` 被拒绝。
|
||||
- 同一个 `paymentRef` 不能重复进货。
|
||||
- 金币补偿成功,`COIN_SELLER_COIN` 增加,`paid_amount_micro=0`,`counts_as_seller_recharge=false`。
|
||||
- 金币补偿传入 `rechargeAmount` 被拒绝。
|
||||
- disabled 或不存在的币商不能加金币。
|
||||
- 相同 `command_id` 和相同 payload 重试返回原交易结果。
|
||||
- 相同 `command_id` 但 payload 不同返回幂等冲突。
|
||||
- wallet 事务失败时不写进货记录、不改余额。
|
||||
- admin 审计记录包含 transaction_id、类型、金币数、USDT 金额和操作人。
|
||||
|
||||
447
docs/logging-infrastructure-architecture.md
Normal file
447
docs/logging-infrastructure-architecture.md
Normal file
@ -0,0 +1,447 @@
|
||||
# 日志基础设施整理方案
|
||||
|
||||
## 目标
|
||||
|
||||
当前服务已经有基础日志能力:HTTP/gRPC 边界日志在 `pkg/logx`,服务启动和 worker 使用标准库 `log` 输出。但生产环境需要把日志整理成稳定、可检索、可告警、可控成本的基础设施。
|
||||
|
||||
目标:
|
||||
|
||||
- 所有服务统一输出 JSON 结构化日志到 stdout/stderr。
|
||||
- 业务代码不直接依赖腾讯云 CLS SDK。
|
||||
- 部署层采集容器 stdout/stderr 到腾讯云 CLS。
|
||||
- 日志字段统一,支持按 `request_id`、`command_id`、`user_id`、`room_id`、`transaction_id` 追踪。
|
||||
- 敏感字段默认脱敏,避免 token、密码、支付凭证、UserSig、SecretKey 进入日志。
|
||||
- 保留后台审计日志和业务账务流水,不用运行日志替代审计和账务事实。
|
||||
|
||||
非目标:
|
||||
|
||||
- 首版不做完整链路追踪系统。
|
||||
- 首版不引入重型日志框架。
|
||||
- 首版不在代码里直连 CLS API 写日志。
|
||||
- 首版不采集客户端 SDK 日志;客户端日志和崩溃分析后续单独设计。
|
||||
|
||||
## 当前现状
|
||||
|
||||
已有能力:
|
||||
|
||||
- `gateway-service` HTTP 入口接入 `logx.HTTPMiddleware`。
|
||||
- `room-service`、`wallet-service`、`user-service`、`activity-service` gRPC server 接入 `logx.UnaryServerInterceptor`。
|
||||
- `logx` 会记录请求、响应、状态码、错误 reason、耗时和 `request_id`。
|
||||
- `logx` 已经对 `password`、`token`、`secret`、`credential`、`user_sig`、`authorization` 等字段做脱敏。
|
||||
- 健康检查不会进入高频 access log。
|
||||
|
||||
主要问题:
|
||||
|
||||
- App 后端服务已接入 `logx.Init`,默认输出 stdout JSON;`log.Printf` 会被兜底包成 `stdlog` JSON。
|
||||
- 核心 worker、RTC callback、room outbox 已改为结构化字段日志。
|
||||
- admin-server 是独立 Go module,按项目边界在 `server/admin/internal/platform/logging` 接入同一 stdout JSON 口径,不 import `hyapp/pkg/logx`。
|
||||
- access log 默认不记录 request/response body;本地可通过配置临时打开,生产需要继续按接口和环境控制体积。
|
||||
- 没有部署层日志采集配置,也没有腾讯云 CLS logset/topic 规划。
|
||||
|
||||
## 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
S1["gateway-service"] --> STDOUT["stdout/stderr JSON logs"]
|
||||
S2["room-service"] --> STDOUT
|
||||
S3["wallet-service"] --> STDOUT
|
||||
S4["user-service"] --> STDOUT
|
||||
S5["activity-service"] --> STDOUT
|
||||
S6["hyapp-admin-server"] --> STDOUT
|
||||
|
||||
STDOUT --> Runtime["Container Runtime"]
|
||||
Runtime --> Collector["TKE Log Collector / CLS LogListener"]
|
||||
Collector --> CLS["Tencent Cloud CLS"]
|
||||
CLS --> Search["检索与排障"]
|
||||
CLS --> Dashboard["仪表盘"]
|
||||
CLS --> Alarm["告警策略"]
|
||||
```
|
||||
|
||||
核心决策:
|
||||
|
||||
- 服务只负责输出标准 JSON 日志。
|
||||
- 日志采集由部署层负责。
|
||||
- 本地开发继续用 stdout,`docker compose logs` 可查看。
|
||||
- 生产环境通过腾讯云容器日志采集或 CLS LogListener 采集 stdout。
|
||||
- 后台审计继续写 `admin_operation_logs`,钱包账务继续写 `wallet_transactions`、`wallet_entries`,不能用运行日志替代。
|
||||
|
||||
## 日志等级
|
||||
|
||||
统一使用:
|
||||
|
||||
| Level | 使用场景 |
|
||||
| --- | --- |
|
||||
| `debug` | 本地调试,生产默认关闭 |
|
||||
| `info` | 正常业务事件、access log、worker 成功摘要 |
|
||||
| `warn` | 可恢复异常、降级、重试、外部依赖短暂失败 |
|
||||
| `error` | 请求失败、事务失败、外部依赖不可用、worker 批次失败 |
|
||||
|
||||
配置项:
|
||||
|
||||
```yaml
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
```
|
||||
|
||||
本地可以打开 body:
|
||||
|
||||
```yaml
|
||||
log:
|
||||
level: debug
|
||||
format: text
|
||||
include_request_body: true
|
||||
include_response_body: true
|
||||
max_payload_bytes: 8192
|
||||
```
|
||||
|
||||
生产默认不记录完整 request/response body,只记录关键字段和错误摘要。
|
||||
|
||||
## 统一字段规范
|
||||
|
||||
每条日志都应该尽量包含基础字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `time` | 日志时间 |
|
||||
| `level` | `debug/info/warn/error` |
|
||||
| `msg` | 短事件名,例如 `http_access`、`grpc_access`、`wallet_transaction_failed` |
|
||||
| `env` | `local/dev/staging/prod` |
|
||||
| `app_code` | 多 App 租户标识 |
|
||||
| `service` | 服务名 |
|
||||
| `version` | 镜像版本或 git sha |
|
||||
| `node_id` | 当前服务节点 |
|
||||
| `request_id` | 请求追踪 ID |
|
||||
|
||||
业务字段按场景补充:
|
||||
|
||||
| 场景 | 推荐字段 |
|
||||
| --- | --- |
|
||||
| HTTP | `method`、`path`、`status`、`duration_ms`、`client_ip` |
|
||||
| gRPC | `grpc_method`、`grpc_code`、`reason`、`duration_ms` |
|
||||
| 房间 | `room_id`、`room_version`、`command_id`、`user_id` |
|
||||
| 钱包 | `transaction_id`、`command_id`、`user_id`、`asset_type`、`biz_type` |
|
||||
| 礼物 | `room_id`、`sender_user_id`、`target_user_id`、`gift_id`、`gift_count` |
|
||||
| 币商 | `seller_user_id`、`target_user_id`、`amount`、`transaction_id` |
|
||||
| worker | `worker`、`worker_id`、`batch_size`、`retry_count` |
|
||||
| 外部依赖 | `provider`、`endpoint`、`external_code`、`duration_ms` |
|
||||
|
||||
字段命名固定使用 snake_case,和 protobuf/mysql 字段风格一致,方便 CLS 查询。
|
||||
|
||||
## `pkg/logx` 改造
|
||||
|
||||
`pkg/logx` 应该从“access log middleware”升级成“统一日志入口”。
|
||||
|
||||
建议新增:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Service string
|
||||
Env string
|
||||
Version string
|
||||
NodeID string
|
||||
Level string
|
||||
Format string
|
||||
IncludeRequestBody bool
|
||||
IncludeResponseBody bool
|
||||
MaxPayloadBytes int
|
||||
}
|
||||
|
||||
func Init(cfg Config) error
|
||||
func Logger(ctx context.Context) *slog.Logger
|
||||
func With(ctx context.Context, attrs ...slog.Attr) context.Context
|
||||
func Info(ctx context.Context, msg string, attrs ...slog.Attr)
|
||||
func Warn(ctx context.Context, msg string, attrs ...slog.Attr)
|
||||
func Error(ctx context.Context, msg string, err error, attrs ...slog.Attr)
|
||||
```
|
||||
|
||||
改造原则:
|
||||
|
||||
- 服务启动时调用 `logx.Init` 设置全局 logger。
|
||||
- `log.Printf` 逐步替换成 `logx.Info/Warn/Error` 或 `slog` 结构化字段。
|
||||
- HTTP/gRPC middleware 自动把 `request_id`、`service`、`app_code` 写入日志上下文。
|
||||
- `max_payload_bytes` 从配置读取,不写死。
|
||||
- 生产默认不记录完整 body;只有错误或 debug 环境才允许记录脱敏 body。
|
||||
|
||||
## Access Log 策略
|
||||
|
||||
HTTP access log:
|
||||
|
||||
- 成功请求记录一条 `info`。
|
||||
- 4xx 记录 `warn` 或 `info`,取决于业务错误类型。
|
||||
- 5xx 记录 `error`。
|
||||
- healthcheck 不记录。
|
||||
- request/response body 默认关闭。
|
||||
|
||||
gRPC access log:
|
||||
|
||||
- `codes.OK` 记录 `info`。
|
||||
- 业务前置失败,例如 `InvalidArgument`、`FailedPrecondition`,记录 `warn` 或 `info`。
|
||||
- `Internal`、`Unavailable`、`DeadlineExceeded` 记录 `error`。
|
||||
- `grpc.health.v1.Health` 不记录。
|
||||
|
||||
建议生产日志示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"time": "2026-05-09T12:00:00.000Z",
|
||||
"level": "INFO",
|
||||
"msg": "grpc_access",
|
||||
"env": "prod",
|
||||
"service": "wallet-service",
|
||||
"request_id": "req-123",
|
||||
"app_code": "lalu",
|
||||
"grpc_method": "/hyapp.wallet.v1.WalletService/TransferCoinFromSeller",
|
||||
"grpc_code": "OK",
|
||||
"reason": "",
|
||||
"duration_ms": 18,
|
||||
"command_id": "cmd-456",
|
||||
"seller_user_id": 1001,
|
||||
"target_user_id": 2002,
|
||||
"transaction_id": "wtx-789"
|
||||
}
|
||||
```
|
||||
|
||||
## 敏感字段规则
|
||||
|
||||
禁止进入日志:
|
||||
|
||||
- 明文密码
|
||||
- Firebase ID token
|
||||
- Google/Apple provider credential
|
||||
- refresh token / access token
|
||||
- 腾讯云 SecretId / SecretKey
|
||||
- Tencent IM UserSig
|
||||
- RTC token
|
||||
- 支付 provider receipt 原文
|
||||
- USDT 完整链上凭证如包含敏感内部备注时,最多记录摘要
|
||||
- 身份证件、银行卡、提现收款账号全文
|
||||
|
||||
可以记录:
|
||||
|
||||
- `request_id`
|
||||
- `command_id`
|
||||
- `transaction_id`
|
||||
- `room_id`
|
||||
- `user_id`
|
||||
- `provider`
|
||||
- provider 错误码
|
||||
- token/receipt 的 hash 摘要,前提是确实需要排障
|
||||
|
||||
`logx` 的脱敏 key 列表需要继续集中维护,禁止业务代码自己拼接敏感原文。
|
||||
|
||||
## 腾讯云 CLS 接入方案
|
||||
|
||||
推荐方案:stdout 采集到 CLS。
|
||||
|
||||
### 方案 A:TKE 容器标准输出采集
|
||||
|
||||
如果服务部署在腾讯云 TKE:
|
||||
|
||||
1. 每个服务输出 JSON 到 stdout/stderr。
|
||||
2. TKE 开启日志采集。
|
||||
3. 采集目标选择容器标准输出。
|
||||
4. 解析方式选择 JSON。
|
||||
5. 不同环境写入不同 CLS topic。
|
||||
|
||||
推荐 topic:
|
||||
|
||||
| 环境 | Logset | Topic |
|
||||
| --- | --- | --- |
|
||||
| dev | `hyapp-dev` | `service-logs` |
|
||||
| staging | `hyapp-staging` | `service-logs` |
|
||||
| prod | `hyapp-prod` | `service-logs` |
|
||||
| prod | `hyapp-prod` | `admin-audit-runtime` |
|
||||
| prod | `hyapp-prod` | `wallet-security` |
|
||||
|
||||
首版可以所有服务进同一个 `service-logs` topic,通过 `service` 字段过滤。等日志量变大,再把 `gateway-service`、`wallet-service`、`room-service` 拆 topic。
|
||||
|
||||
### 方案 B:CVM / Docker 部署使用 CLS LogListener
|
||||
|
||||
如果服务部署在 CVM Docker:
|
||||
|
||||
1. 服务仍输出 JSON 到 stdout。
|
||||
2. Docker 日志落到宿主机默认 json log 文件。
|
||||
3. 安装 CLS LogListener。
|
||||
4. LogListener 采集容器日志文件或指定目录。
|
||||
5. CLS 端按 JSON 解析。
|
||||
|
||||
不建议在每个 Go 服务里集成 CLS SDK。直连 SDK 会把业务进程和日志平台绑定,CLS 抖动时还可能影响业务路径。
|
||||
|
||||
## CLS 索引字段
|
||||
|
||||
CLS topic 需要开启键值索引。
|
||||
|
||||
建议索引字段:
|
||||
|
||||
- `level`
|
||||
- `env`
|
||||
- `service`
|
||||
- `app_code`
|
||||
- `request_id`
|
||||
- `command_id`
|
||||
- `user_id`
|
||||
- `room_id`
|
||||
- `transaction_id`
|
||||
- `biz_type`
|
||||
- `grpc_code`
|
||||
- `reason`
|
||||
- `status`
|
||||
- `path`
|
||||
- `worker`
|
||||
- `event_type`
|
||||
|
||||
全文索引可以保留,但排障优先用字段检索,避免只靠模糊搜索。
|
||||
|
||||
## 告警策略
|
||||
|
||||
首版告警不要太多,先覆盖最关键的错误和积压。
|
||||
|
||||
建议:
|
||||
|
||||
| 告警 | 条件 |
|
||||
| --- | --- |
|
||||
| 服务 5xx 增多 | `service=gateway-service AND status>=500` 5 分钟超过阈值 |
|
||||
| gRPC Internal | `grpc_code=Internal` 5 分钟超过阈值 |
|
||||
| 钱包失败 | `service=wallet-service AND level=ERROR` 1 分钟超过阈值 |
|
||||
| room outbox 积压 | worker 日志出现 `status=batch_failed` 或 retryable 数量持续增长 |
|
||||
| 腾讯云 IM/RTC 回调异常 | `provider=tencent AND level>=WARN` 超过阈值 |
|
||||
| 用户登录失败突增 | `msg=auth_failed` 或 `reason=AUTH_FAILED` 超过阈值 |
|
||||
|
||||
告警通知:
|
||||
|
||||
- 开发阶段:企业微信/飞书群。
|
||||
- 生产阶段:按服务 owner 分组。
|
||||
- 钱包和支付相关 error 单独告警,不能淹没在普通业务 error 里。
|
||||
|
||||
## 保留周期和成本控制
|
||||
|
||||
建议:
|
||||
|
||||
| 环境 | 保留时间 |
|
||||
| --- | --- |
|
||||
| local | 不上传 CLS |
|
||||
| dev | 3-7 天 |
|
||||
| staging | 7-14 天 |
|
||||
| prod 普通服务日志 | 30 天 |
|
||||
| prod 钱包/支付安全日志 | 90-180 天,按合规要求调整 |
|
||||
|
||||
成本控制:
|
||||
|
||||
- 生产关闭 request/response body。
|
||||
- 高频成功日志只保留摘要。
|
||||
- healthcheck 不采集或不输出。
|
||||
- 大 payload 截断。
|
||||
- 对重复 worker 成功日志做批次汇总,不逐条刷屏。
|
||||
- 对外部 callback 原始 body 只在 debug 或白名单场景短期开启。
|
||||
|
||||
## 和审计日志的关系
|
||||
|
||||
运行日志和审计日志是两套东西。
|
||||
|
||||
运行日志:
|
||||
|
||||
- 用于排障、告警、性能分析。
|
||||
- 存在 CLS。
|
||||
- 可以按保留周期清理。
|
||||
|
||||
审计日志:
|
||||
|
||||
- 用于追责、后台操作记录、财务追踪。
|
||||
- 存在业务数据库,例如 `admin_operation_logs`。
|
||||
- 必须有操作者、动作、资源、结果、IP、原因。
|
||||
- 不能因为 CLS 采集失败而丢失。
|
||||
|
||||
钱包账务流水:
|
||||
|
||||
- 存在 `wallet_transactions`、`wallet_entries`、`wallet_recharge_records` 等表。
|
||||
- 是账务事实来源。
|
||||
- 运行日志只辅助定位,不能替代账务表。
|
||||
|
||||
## 阶段划分
|
||||
|
||||
第一阶段是服务内日志标准化,目标是让所有进程稳定输出结构化 JSON 到 stdout/stderr。当前第一阶段已经完成到可进入联调的状态:
|
||||
|
||||
- App 后端服务接入 `logx.Init`。
|
||||
- `admin-server` 因为是独立 Go module,使用 `server/admin/internal/platform/logging` 接入。
|
||||
- HTTP/gRPC access log 支持统一字段、脱敏、body 开关、payload 截断和错误等级。
|
||||
- `log.Printf` 类 worker 日志已改成结构化日志或被 stdout JSON 兜底。
|
||||
- `logx` 已补充 gRPC 常用追踪字段提取;即使生产关闭 request body,也能在 access log 中检索 `command_id`、`user_id`、`room_id`、`transaction_id` 等字段。
|
||||
- 启动完成后的进程级失败会按 `ERROR` 结构化输出,不再落成普通 INFO `stdlog`。
|
||||
|
||||
第二阶段是腾讯云 CLS 部署接入,目标是把第一阶段的 stdout JSON 真正采集、索引、查询和告警:
|
||||
|
||||
1. 在腾讯云 CLS 创建 logset/topic,按环境隔离 `dev`、`staging`、`prod`。
|
||||
2. 配置 TKE 容器标准输出采集,或在 CVM/Docker 部署 CLS LogListener。
|
||||
3. CLS topic 使用 JSON 解析,开启字段索引。
|
||||
4. 建立基础查询模板:按 `request_id` 查链路,按 `service` 看错误,按 `transaction_id` 查钱包,按 `room_id` 查房间。
|
||||
5. 建立第一批 dashboard:服务错误、接口耗时、gRPC 错误、wallet 失败、room outbox 重试、登录失败。
|
||||
6. 建立第一批告警:gateway 5xx、gRPC Internal/Unavailable、wallet ERROR、room outbox 重试异常、腾讯云回调异常。
|
||||
7. 验证保留周期和采集成本,生产默认不上传 request/response body。
|
||||
|
||||
第三阶段再做观测增强,包括 OpenTelemetry trace、Prometheus 指标、慢请求日志、安全日志 topic、客户端日志关联。
|
||||
|
||||
## 实施顺序
|
||||
|
||||
1. 已增加统一日志配置结构:`log.level`、`log.format`、`log.include_request_body`、`log.include_response_body`、`log.max_payload_bytes`。
|
||||
2. 已扩展 `pkg/logx.Init`,服务启动时统一设置 JSON/text handler、level、基础字段。
|
||||
3. 已给 gateway、room、user、wallet、activity 接入 `logx.Init`;admin-server 通过独立 module 内的 `platform/logging` 接入。
|
||||
4. 已改造 `logx.HTTPMiddleware` 和 `logx.UnaryServerInterceptor`,按配置控制 body、截断大小和错误等级。
|
||||
5. 已替换核心路径的 `log.Printf`:
|
||||
- room outbox worker
|
||||
- user region rebuild worker
|
||||
- user mic time worker
|
||||
- invite recharge worker
|
||||
- Tencent RTC callback
|
||||
6. 本地验证 JSON 日志格式:`docker compose logs gateway-service` 能看到结构化 JSON。
|
||||
7. 生产部署层接入腾讯云 CLS stdout 采集。
|
||||
8. 在 CLS 配置 JSON 解析和字段索引。
|
||||
9. 建立基础 dashboard:错误率、接口耗时、钱包失败、room outbox 重试、登录失败。
|
||||
10. 建立首批告警策略。
|
||||
|
||||
## 测试和验收
|
||||
|
||||
代码侧:
|
||||
|
||||
- `logx` 单测覆盖脱敏字段。
|
||||
- `logx` 单测覆盖 payload 截断。
|
||||
- HTTP middleware 单测覆盖 success、4xx、5xx 日志级别。
|
||||
- gRPC interceptor 单测覆盖 OK、业务错误、Internal。
|
||||
- 配置解析单测覆盖默认值和非法 log level。
|
||||
|
||||
本地验收:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs gateway-service
|
||||
docker compose logs wallet-service
|
||||
```
|
||||
|
||||
验收标准:
|
||||
|
||||
- 日志是 JSON 或本地配置指定的 text。
|
||||
- 每条 access log 有 `service`、`request_id`、`duration_ms`。
|
||||
- 敏感字段显示为 `***REDACTED***`。
|
||||
- healthcheck 不刷屏。
|
||||
- 生产配置下 request/response body 不输出。
|
||||
|
||||
CLS 验收:
|
||||
|
||||
- 能按 `request_id` 查到 gateway 和下游服务日志。
|
||||
- 能按 `service=wallet-service AND transaction_id=...` 查到钱包交易日志。
|
||||
- 能按 `room_id` 查房间命令相关日志。
|
||||
- 能按 `level=ERROR` 聚合最近 5 分钟错误。
|
||||
- 告警能正确触发并发送到通知渠道。
|
||||
|
||||
## 后续演进
|
||||
|
||||
日志整理完成后,再考虑:
|
||||
|
||||
- OpenTelemetry trace,把 `trace_id/span_id` 加入日志字段。
|
||||
- Prometheus 指标和日志告警分工,错误率和延迟优先走 metrics。
|
||||
- 慢请求日志,按 `duration_ms` 阈值记录额外字段。
|
||||
- 安全日志 topic,把支付、提现、币商、管理员高危操作单独留存。
|
||||
- 客户端日志和崩溃分析接入,和服务端 `request_id` 或 `session_id` 关联。
|
||||
@ -181,7 +181,7 @@ Activity 专用 reason:
|
||||
|
||||
### Error Implementation Boundary
|
||||
|
||||
建议扩展 `pkg/xerr`,但领域层仍不能 import gRPC 包。
|
||||
错误码统一由 `pkg/xerr` 的 catalog 注册,领域层仍不能 import gRPC 包。
|
||||
|
||||
目标结构:
|
||||
|
||||
@ -192,7 +192,15 @@ type Error struct {
|
||||
}
|
||||
```
|
||||
|
||||
`transport/grpc` 负责把 `xerr.Error` 转成 gRPC status,并附带 `google.rpc.ErrorInfo`。
|
||||
`transport/grpc` 负责把 `xerr.Error` 转成 gRPC status,并附带 `google.rpc.ErrorInfo`。gRPC code 保持少量 canonical code;业务码只放在 `ErrorInfo.reason` 和 HTTP envelope `code`。
|
||||
|
||||
新增业务错误码时必须同时在 `xerr` catalog 注册:
|
||||
|
||||
- gRPC canonical code。
|
||||
- HTTP status。
|
||||
- App 可见 public code。
|
||||
- App 可见 public message。
|
||||
- 是否可重试。
|
||||
|
||||
不要在 service 层直接返回 `status.Error(...)`。
|
||||
|
||||
|
||||
@ -270,22 +270,29 @@ user_mic_event_consumption(
|
||||
|
||||
## Open Session Compensation
|
||||
|
||||
需要一个补偿 worker 处理异常 open session:
|
||||
user-service 已内置 open session compensation worker,用于兜底关闭 room-service down 事件缺失导致的异常 open session。补偿只修正用户麦时长事实,不反写 Room Cell,也不向客户端发送房间控制消息。
|
||||
|
||||
| Case | Action |
|
||||
| --- | --- |
|
||||
| `pending_publish` 超过 room-service publish timeout | 等 room-service `publish_timeout` down 事件;若长期没有事件,按异常关闭 |
|
||||
| `publishing` 长时间没有 down,但用户 presence 已离开 | 使用 presence/room close 事实补偿关闭 |
|
||||
| 房间关闭但仍有 open session | 以 `RoomClosed.occurred_at_ms` 关闭 |
|
||||
| 消费者停机后恢复 | 从 `room_outbox` 或 MQ 位点继续消费;必要时用未结束 session 扫描补偿 |
|
||||
| `pending_publish` 超过 `pending_publish_max_age_sec` | 关闭 session,`end_reason=compensated_publish_timeout`,`mic_online_ms=0` |
|
||||
| `publishing` 超过 `publishing_session_max_age_sec` | 关闭 session,`end_reason=compensated_publishing_max_window`,按确认发流后到截断点累计 `mic_online_ms` |
|
||||
| 消费者停机后恢复 | room outbox worker 继续从位点扫描;补偿 worker 扫描 MySQL open session 兜底 |
|
||||
|
||||
补偿关闭必须写 `end_reason=compensated_*`,不能伪装成用户主动下麦。
|
||||
补偿扫描使用 MySQL `FOR UPDATE SKIP LOCKED` 锁定候选 session。单批 claim、关闭、daily/lifetime 聚合和 outbox 写入在同一个事务内完成,避免关闭成功但派生事件丢失。
|
||||
|
||||
补偿必须设置最大可累计窗口,避免房间事件缺失时把异常 open session 算成超长在线。首版建议按房间关闭时间、presence 离开时间或 `publish_deadline_ms` 截断;仍无法确认时只关闭 session 并告警,不增加 `mic_online_ms`。
|
||||
补偿关闭必须写 `end_reason=compensated_*`,不能伪装成用户主动下麦。补偿必须设置最大可累计窗口,避免房间事件缺失时把异常 open session 算成超长在线。当前默认配置:
|
||||
|
||||
| Config | Default | Meaning |
|
||||
| --- | ---: | --- |
|
||||
| `compensation_enabled` | `true` | 是否启动异常 session 补偿 worker |
|
||||
| `compensation_interval_sec` | `60` | 没有积压时两轮扫描间隔 |
|
||||
| `compensation_batch_size` | `100` | 单轮最多关闭 session 数 |
|
||||
| `pending_publish_max_age_sec` | `120` | pending 未确认发流的最大保留窗口 |
|
||||
| `publishing_session_max_age_sec` | `43200` | publishing session 最大可累计窗口,默认 12 小时 |
|
||||
|
||||
## Derived Events And Consumers
|
||||
|
||||
用户麦时长消费者写入 MySQL 后,可以通过 user-service outbox 产出派生事件,供 host stats、任务、活动和 BI 消费。派生事件不能替代基础事实表,消费者失败不影响 `user_mic_sessions`、daily 和 lifetime 事实。
|
||||
用户麦时长消费者关闭 session 时,在同一个 MySQL 事务内写入 user-service `user_outbox` 派生事件,供 host stats、任务、活动和 BI 消费。派生事件不能替代基础事实表,消费者失败不影响 `user_mic_sessions`、daily 和 lifetime 事实。
|
||||
|
||||
| Event | Trigger | Consumer |
|
||||
| --- | --- | --- |
|
||||
@ -293,6 +300,18 @@ user_mic_event_consumption(
|
||||
| `UserMicDailyStatsUpdated` | daily stats 有增量 | task progress、growth system |
|
||||
| `UserMicLifetimeStatsUpdated` | lifetime stats 有增量 | lifetime task cache |
|
||||
|
||||
事件幂等键:
|
||||
|
||||
| Event | `event_id` |
|
||||
| --- | --- |
|
||||
| `UserMicSessionClosed` | `UserMicSessionClosed:{mic_session_id}` |
|
||||
| `UserMicDailyStatsUpdated` | `UserMicDailyStatsUpdated:{mic_session_id}:{stat_date}` |
|
||||
| `UserMicLifetimeStatsUpdated` | `UserMicLifetimeStatsUpdated:{mic_session_id}` |
|
||||
|
||||
`UserMicSessionClosed` payload 至少包含 `app_code`、`user_id`、`room_id`、`mic_session_id`、`seat_occupied_ms`、`mic_online_ms`、`seat_started_at_ms`、`ended_at_ms`、`end_reason`、`closed_event_id`。
|
||||
|
||||
daily/lifetime update payload 使用 delta 语义,包含 `seat_occupied_delta_ms`、`mic_online_delta_ms`、`session_count_delta` 和 `updated_at_ms`;daily 事件额外包含 `stat_date`、`room_count_delta`。
|
||||
|
||||
host/Agency/BD 统计应该优先消费 `UserMicSessionClosed` 或读取 user mic facts 派生关系快照,不应该再直接消费 `RoomMicChanged` 重算同一套用户时长。
|
||||
|
||||
## Task Usage
|
||||
@ -389,8 +408,8 @@ GET /api/v1/users/me/mic-stats?from_ms=...&to_ms=...
|
||||
4. 已实现 session 状态机:`up -> publish_confirmed -> down`,支持 `change` 不切断。
|
||||
5. 已实现 daily/lifetime 聚合,跨 UTC 日切分。
|
||||
6. 已增加内部查询 RPC `GetUserMicLifetimeStats`,供任务、主播、后台读取 lifetime 聚合。
|
||||
7. 下一步增加补偿 worker,关闭异常 open session。
|
||||
8. 下一步增加 user mic outbox,产出 `UserMicSessionClosed`、`UserMicDailyStatsUpdated`、`UserMicLifetimeStatsUpdated`。
|
||||
7. 已增加补偿 worker,关闭异常 open session。
|
||||
8. 已增加 user mic outbox,产出 `UserMicSessionClosed`、`UserMicDailyStatsUpdated`、`UserMicLifetimeStatsUpdated`。
|
||||
9. 任务系统接入 `user_mic_lifetime_stats`,不要重复消费 room 事件。
|
||||
10. host stats 从 user mic facts 或 user mic outbox 派生,不直接重复处理 Room Cell 麦位事件。
|
||||
|
||||
|
||||
461
docs/vip-system-architecture.md
Normal file
461
docs/vip-system-architecture.md
Normal file
@ -0,0 +1,461 @@
|
||||
# VIP 系统架构设计
|
||||
|
||||
## 目标
|
||||
|
||||
VIP 是用户可直接用金币购买的限时会员权益。每个 VIP 等级都可以单独购买,单次购买有效期固定 7 天,不同等级价格不同。购买成功后,系统根据后台配置的资源组发放头像框、座驾、气泡、徽章、礼物等权益。
|
||||
|
||||
首版只做当前业务真实需要的能力:
|
||||
|
||||
- 用户可以购买任意有效 VIP 等级。
|
||||
- 用户可以从低等级升级到高等级。
|
||||
- 用户当前 VIP 未过期时,不能购买比当前等级低的 VIP。
|
||||
- 同等级重复购买按续期处理。
|
||||
- VIP 扣金币、会员状态变更、资源组发放必须在 `wallet-service` 内形成一致账务闭环。
|
||||
- 后台只配置 VIP 等级、金币价格、有效期和赠送资源组;客户端不能传价格或资源明细。
|
||||
|
||||
## 服务边界
|
||||
|
||||
VIP 属于 `wallet-service` 的账务和权益域,不放在 `user-service`。
|
||||
|
||||
原因:
|
||||
|
||||
- 购买 VIP 必须先扣金币,扣费成功才允许生成 VIP 状态和权益。
|
||||
- 购买 VIP 会发放后台资源组,资源组、资源权益和钱包资产已经归 `wallet-service` 管。
|
||||
- 如果 VIP 状态放在 `user-service`,扣费、会员状态、资源发放会跨服务,需要分布式事务或补偿,复杂度和失败窗口都更大。
|
||||
|
||||
服务职责:
|
||||
|
||||
- `gateway-service`:提供 App HTTP API,做鉴权、参数校验、request_id/command_id 透传,调用 `wallet-service`。
|
||||
- `wallet-service`:拥有 VIP 等级配置、用户 VIP 状态、VIP 购买订单、金币扣费、资源组发放、VIP 事件 outbox。
|
||||
- `user-service`:只在用户资料聚合时读取 VIP 摘要,不拥有 VIP 状态。
|
||||
- `room-service`:需要展示 VIP 标识时读取用户资料/VIP 投影,不参与 VIP 购买主链路。
|
||||
- `server/admin`:后台配置 VIP 等级和资源组,具体后台接口单独设计。
|
||||
|
||||
## 核心模型
|
||||
|
||||
### VIP 等级
|
||||
|
||||
`vip_levels`
|
||||
|
||||
| 字段 | 说明 |
|
||||
| -------------------------- | -------------------------------------------------- |
|
||||
| `id` | 内部主键 |
|
||||
| `level` | VIP 等级,数字越大等级越高 |
|
||||
| `name` | 展示名称,例如 VIP1、VIP2 |
|
||||
| `coin_price` | 购买该等级需要消耗的金币数 |
|
||||
| `duration_ms` | 有效期,首版固定 `7 * 24 * 60 * 60 * 1000` |
|
||||
| `reward_resource_group_id` | 购买成功后发放的资源组 |
|
||||
| `status` | `draft` / `active` / `disabled` |
|
||||
| `sort_order` | App 展示排序 |
|
||||
| `display_config_json` | 展示配置快照,例如颜色、权益说明、角标,不参与结算 |
|
||||
| `created_at_ms` | 创建时间 |
|
||||
| `updated_at_ms` | 更新时间 |
|
||||
|
||||
约束:
|
||||
|
||||
- `level` 唯一。
|
||||
- `coin_price > 0`。
|
||||
- `duration_ms` 首版必须等于 7 天,不允许后台临时改成其它值。
|
||||
- `active` 状态必须绑定有效资源组。
|
||||
|
||||
### 用户 VIP 当前状态
|
||||
|
||||
`user_vip_memberships`
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --------------- | ---------------------------- |
|
||||
| `user_id` | 用户 ID,唯一 |
|
||||
| `level` | 当前等级 |
|
||||
| `status` | `active` / `expired` |
|
||||
| `started_at_ms` | 当前等级周期开始时间 |
|
||||
| `expires_at_ms` | 当前 VIP 到期时间 |
|
||||
| `last_order_id` | 最近一次购买订单 |
|
||||
| `version` | 乐观版本,配合行锁防并发覆盖 |
|
||||
| `created_at_ms` | 创建时间 |
|
||||
| `updated_at_ms` | 更新时间 |
|
||||
|
||||
读取时以 `expires_at_ms > now` 判断是否有效,`status` 只作为后台检索和事件处理的辅助状态。这样即使过期任务延迟,App 也不会把过期 VIP 当成有效权益。
|
||||
|
||||
### VIP 购买订单
|
||||
|
||||
`vip_purchase_orders`
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ---------------------------- | --------------------------------- |
|
||||
| `order_id` | VIP 订单号 |
|
||||
| `command_id` | 客户端生成的幂等命令 ID |
|
||||
| `user_id` | 购买用户 |
|
||||
| `target_level` | 目标 VIP 等级 |
|
||||
| `coin_price_snapshot` | 下单时价格快照 |
|
||||
| `duration_ms_snapshot` | 下单时有效期快照 |
|
||||
| `resource_group_id_snapshot` | 下单时资源组快照 |
|
||||
| `previous_level` | 购买前有效等级,无有效 VIP 则为 0 |
|
||||
| `previous_expires_at_ms` | 购买前到期时间 |
|
||||
| `new_level` | 购买后等级 |
|
||||
| `new_expires_at_ms` | 购买后到期时间 |
|
||||
| `wallet_transaction_id` | 金币扣费交易 ID |
|
||||
| `resource_grant_id` | 资源组发放记录 ID |
|
||||
| `status` | `succeeded` / `failed` |
|
||||
| `failure_reason` | 失败原因 |
|
||||
| `request_hash` | 幂等请求摘要 |
|
||||
| `created_at_ms` | 创建时间 |
|
||||
| `updated_at_ms` | 更新时间 |
|
||||
|
||||
约束:
|
||||
|
||||
- `(user_id, command_id)` 唯一。
|
||||
- 相同 `command_id` + 相同 `request_hash` 返回原订单结果。
|
||||
- 相同 `command_id` + 不同 `request_hash` 返回幂等冲突。
|
||||
|
||||
### VIP 变更历史
|
||||
|
||||
`user_vip_history`
|
||||
|
||||
记录每次购买、续期、升级、过期,供客服、后台账单、风控排查使用。历史表不参与 App 主链路强查询。
|
||||
|
||||
核心字段:
|
||||
|
||||
- `history_id`
|
||||
- `user_id`
|
||||
- `event_type`: `purchase` / `renew` / `upgrade` / `expire`
|
||||
- `from_level`
|
||||
- `to_level`
|
||||
- `from_expires_at_ms`
|
||||
- `to_expires_at_ms`
|
||||
- `order_id`
|
||||
- `created_at_ms`
|
||||
|
||||
## 购买和升级规则
|
||||
|
||||
定义:
|
||||
|
||||
- 当前有效 VIP:`membership.expires_at_ms > now && membership.level > 0`
|
||||
- 目标等级:用户本次购买的 `vip_level`
|
||||
|
||||
规则:
|
||||
|
||||
| 场景 | 处理 |
|
||||
| ---------------------- | ------------------------------------------------------------------------ |
|
||||
| 无有效 VIP | 可以购买任意 active 等级,`expires_at = now + 7d` |
|
||||
| 有效 VIP,购买同等级 | 续期,`expires_at = current_expires_at + 7d` |
|
||||
| 有效 VIP,购买更高等级 | 立即升级,`level = target_level`,`expires_at = current_expires_at + 7d` |
|
||||
| 有效 VIP,购买更低等级 | 拒绝,返回 `VIP_DOWNGRADE_NOT_ALLOWED` |
|
||||
| VIP 已过期 | 按无有效 VIP 处理,可以购买任意 active 等级 |
|
||||
|
||||
首版升级采用“保留剩余时间并升级”的策略:如果用户 VIP1 还剩 3 天,又购买 VIP3,则 VIP3 到期时间为当前 VIP1 到期时间再加 7 天。这个规则实现简单、用户感知清晰,也避免升级时损失已付费时长。
|
||||
|
||||
如果后续业务希望“补差价升级”或“升级只生效 7 天”,只需要扩展 VIP 等级的升级策略,不改变主链路表结构。
|
||||
|
||||
## 资源组发放规则
|
||||
|
||||
购买 VIP 成功后,`wallet-service` 根据 `vip_levels.reward_resource_group_id` 发放资源组。
|
||||
|
||||
资源组继续复用现有资源组能力:
|
||||
|
||||
- 头像框:`avatar_frame`
|
||||
- 座驾:`vehicle`
|
||||
- 聊天气泡:`chat_bubble`
|
||||
- 徽章:`badge`
|
||||
- 飘屏:`floating_screen`
|
||||
- 礼物:`gift`
|
||||
- 金币等钱包资产:仅在明确配置时发放,默认不建议 VIP 购买返金币,避免账务绕圈。
|
||||
|
||||
VIP 资源组需要增加一个明确约束:
|
||||
|
||||
- VIP 专属装扮类资源默认与 VIP 到期时间对齐。
|
||||
- 资源组 item 如果配置了独立 `duration_ms`,后台必须能看到它和 VIP 7 天周期的差异。
|
||||
- 首版推荐新增 `duration_policy = inherit_vip_expiry`,购买 VIP 时将资源权益到期时间直接设置为 `new_expires_at_ms`。
|
||||
|
||||
续期和升级时:
|
||||
|
||||
- 同一资源重复发放时,使用资源权益的 `extend_expiry` 或 `align_to_expiry` 策略,避免重复插入导致用户出现多个同资源有效权益。
|
||||
- 升级后只发放目标等级配置的资源组;低等级已获得的资源不立即删除,到期后自然失效。
|
||||
- App 展示和自动佩戴时按用户当前有效资源、资源等级和佩戴状态决定,不从 VIP 等级反推资源。
|
||||
|
||||
## 主链路流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant App
|
||||
participant Gateway as gateway-service
|
||||
participant Wallet as wallet-service
|
||||
participant DB as Wallet MySQL
|
||||
participant Outbox as Wallet Outbox
|
||||
|
||||
App->>Gateway: POST /api/v1/vip/purchase {level, command_id}
|
||||
Gateway->>Wallet: PurchaseVip(user_id, level, command_id, request_meta)
|
||||
Wallet->>DB: BEGIN
|
||||
Wallet->>DB: 查询并锁定 vip_levels(level)
|
||||
Wallet->>DB: 查询并锁定 user_vip_memberships(user_id)
|
||||
Wallet->>DB: 校验不能降级、计算 new_expires_at_ms
|
||||
Wallet->>DB: 锁定用户 COIN 钱包账户
|
||||
Wallet->>DB: 扣减金币,写 wallet_transaction / wallet_entries
|
||||
Wallet->>DB: 发放 VIP 资源组,写 resource_entitlements
|
||||
Wallet->>DB: upsert user_vip_memberships
|
||||
Wallet->>DB: 写 vip_purchase_orders / user_vip_history
|
||||
Wallet->>Outbox: 写 VipPurchased / VipUpgraded / VipRenewed
|
||||
Wallet->>DB: COMMIT
|
||||
Wallet-->>Gateway: 返回当前 VIP、扣费、权益摘要
|
||||
Gateway-->>App: {code,message,request_id,data}
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
- 金币扣费、VIP 状态、资源组发放必须在同一个 `wallet-service` 数据库事务中完成。
|
||||
- 客户端只传 `level` 和 `command_id`,价格和资源组完全由服务端配置决定。
|
||||
- 扣费成功但资源发放失败不能提交事务;否则用户会付费但拿不到权益。
|
||||
- 资源发放成功但 VIP 状态更新失败也不能提交事务;否则权益和会员状态会不一致。
|
||||
|
||||
## App 一级接口
|
||||
|
||||
### 查询 VIP 等级
|
||||
|
||||
`GET /api/v1/vip/levels`
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"current_vip": {
|
||||
"level": 2,
|
||||
"name": "VIP2",
|
||||
"expires_at_ms": 1760000000000,
|
||||
"remaining_ms": 604800000
|
||||
},
|
||||
"levels": [
|
||||
{
|
||||
"level": 1,
|
||||
"name": "VIP1",
|
||||
"coin_price": 1000,
|
||||
"duration_ms": 604800000,
|
||||
"can_purchase": true,
|
||||
"purchase_block_reason": "",
|
||||
"benefits": [
|
||||
{
|
||||
"resource_type": "avatar_frame",
|
||||
"resource_id": "vip1_frame",
|
||||
"name": "VIP1 Avatar Frame",
|
||||
"duration_policy": "inherit_vip_expiry"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `can_purchase=false` 通常表示当前有效 VIP 等级高于该等级。
|
||||
- App 可以展示所有等级,但购买按钮状态以服务端返回为准。
|
||||
|
||||
### 查询我的 VIP
|
||||
|
||||
`GET /api/v1/vip/me`
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"level": 2,
|
||||
"name": "VIP2",
|
||||
"status": "active",
|
||||
"expires_at_ms": 1760000000000,
|
||||
"remaining_ms": 604800000,
|
||||
"renewable": true,
|
||||
"upgradeable_levels": [3, 4, 5]
|
||||
}
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 底部“我的”页面只需要拿 VIP 摘要,不要拉完整资源列表。
|
||||
- 完整装扮资源仍从资源/背包接口查询。
|
||||
|
||||
### 购买 VIP
|
||||
|
||||
`POST /api/v1/vip/purchase`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"level": 3,
|
||||
"command_id": "client-generated-id"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"order_id": "vip_order_123",
|
||||
"event_type": "upgrade",
|
||||
"coin_spent": 3000,
|
||||
"vip": {
|
||||
"level": 3,
|
||||
"name": "VIP3",
|
||||
"expires_at_ms": 1760604800000
|
||||
},
|
||||
"granted_resources": [
|
||||
{
|
||||
"resource_type": "vehicle",
|
||||
"resource_id": "vip3_vehicle",
|
||||
"expires_at_ms": 1760604800000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## gRPC 契约建议
|
||||
|
||||
新增 `wallet.v1.VipService`:
|
||||
|
||||
```protobuf
|
||||
service VipService {
|
||||
rpc ListVipLevels(ListVipLevelsRequest) returns (ListVipLevelsResponse);
|
||||
rpc GetMyVip(GetMyVipRequest) returns (GetMyVipResponse);
|
||||
rpc PurchaseVip(PurchaseVipRequest) returns (PurchaseVipResponse);
|
||||
}
|
||||
```
|
||||
|
||||
核心字段:
|
||||
|
||||
- `RequestMeta meta`
|
||||
- `int64 user_id`
|
||||
- `int32 level`
|
||||
- `string command_id`
|
||||
- `int64 now_ms` 不由客户端传,由服务端生成;测试可通过 clock 注入。
|
||||
|
||||
错误码建议纳入 `pkg/xerr` catalog:
|
||||
|
||||
- `VIP_LEVEL_NOT_FOUND`
|
||||
- `VIP_LEVEL_DISABLED`
|
||||
- `VIP_DOWNGRADE_NOT_ALLOWED`
|
||||
- `VIP_PRICE_CHANGED`,仅在未来支持客户端价格确认时使用
|
||||
- `WALLET_INSUFFICIENT_BALANCE`
|
||||
- `IDEMPOTENCY_CONFLICT`
|
||||
|
||||
gRPC status 仍保持少量通用 code:
|
||||
|
||||
- 参数错误:`InvalidArgument`
|
||||
- 余额不足、不能降级:`FailedPrecondition`
|
||||
- 等级不存在或禁用:`NotFound` / `FailedPrecondition`
|
||||
- 幂等冲突:`AlreadyExists`
|
||||
- 内部事务失败:`Internal`
|
||||
|
||||
业务错误放在 `google.rpc.ErrorInfo.reason`。
|
||||
|
||||
## 并发和幂等
|
||||
|
||||
同一用户可能快速点击多次购买或同时升级,必须按用户串行化处理。
|
||||
|
||||
事务内锁顺序固定:
|
||||
|
||||
1. 锁 `vip_levels(level)`,读取配置快照。
|
||||
2. 锁 `user_vip_memberships(user_id)`。
|
||||
3. 锁用户 `COIN` 钱包账户。
|
||||
4. 写订单、流水、权益、会员状态。
|
||||
|
||||
如果 `user_vip_memberships` 不存在,先插入一行占位再锁,或者用用户级 advisory lock。不要只依赖应用内 mutex,因为多实例部署时无效。
|
||||
|
||||
幂等规则:
|
||||
|
||||
- `command_id` 是购买命令幂等键,不使用 `request_id`。
|
||||
- 已成功订单重复请求直接返回原结果。
|
||||
- 已失败订单是否允许重试取决于失败类型:余额不足可以用新 `command_id` 重试;系统错误如果事务未提交,可以继续用原 `command_id` 重试。
|
||||
- 订单内保存价格、资源组、有效期快照,后台改配置不影响已成功订单解释。
|
||||
|
||||
## VIP 过期
|
||||
|
||||
VIP 是否有效以 `expires_at_ms` 为准。过期处理分两层:
|
||||
|
||||
- App 查询时实时判断,过期立即返回 `status=expired`。
|
||||
- 后台定时任务扫描过期会员,写 `user_vip_history(expire)` 和 `VipExpired` outbox,用于消息通知、缓存清理和搜索投影更新。
|
||||
|
||||
资源权益也以自己的 `expires_at_ms` 判断有效性。如果使用 `inherit_vip_expiry`,VIP 到期后头像框、座驾等权益自然失效,不需要额外删除。
|
||||
|
||||
## Outbox 事件
|
||||
|
||||
`wallet-service` 写出以下事件:
|
||||
|
||||
- `VipPurchased`
|
||||
- `VipRenewed`
|
||||
- `VipUpgraded`
|
||||
- `VipExpired`
|
||||
|
||||
事件内容:
|
||||
|
||||
- `event_id`
|
||||
- `user_id`
|
||||
- `order_id`
|
||||
- `from_level`
|
||||
- `to_level`
|
||||
- `expires_at_ms`
|
||||
- `coin_spent`
|
||||
- `resource_group_id`
|
||||
- `occurred_at_ms`
|
||||
|
||||
消费者:
|
||||
|
||||
- `user-service` 可消费后更新用户资料投影,便于列表页快速展示 VIP 标识。
|
||||
- `room-service` 如需房间内展示 VIP 角标,可消费投影事件或通过用户资料聚合读取,不接入购买主链路。
|
||||
- `activity-service` 可用于 VIP 活动任务统计。
|
||||
|
||||
## 缓存策略
|
||||
|
||||
VIP 配置可以缓存,用户 VIP 状态不要只放 Redis。
|
||||
|
||||
- `vip_levels`:本地内存缓存 + 短 TTL,后台改配置后通过版本号或事件刷新。
|
||||
- `user_vip_memberships`:MySQL 为准,Redis 只做读优化。
|
||||
- 购买成功后删除或刷新 `vip:me:{user_id}` 缓存。
|
||||
- 过期时间短且强相关权益展示,缓存 TTL 不能超过 `min(60s, expires_at_ms-now)`。
|
||||
|
||||
## 后台配置约束
|
||||
|
||||
后台配置 VIP 等级时必须校验:
|
||||
|
||||
- 等级唯一且大于 0。
|
||||
- 金币价格大于 0。
|
||||
- 有效期首版固定 7 天。
|
||||
- 绑定资源组必须存在且可用。
|
||||
- 资源组里的装扮资源建议使用 `inherit_vip_expiry`。
|
||||
- 禁用等级只影响新购买,不影响已购买用户的剩余有效期。
|
||||
- 修改价格只影响修改后的新订单,不回改历史订单。
|
||||
|
||||
后台可以修改资源组,但已成功订单使用订单快照解释;用户已经拿到的权益不因为后台换资源组被自动替换。
|
||||
|
||||
## 数据一致性原则
|
||||
|
||||
- VIP 购买不是普通“资源组发放”,它必须先扣金币。
|
||||
- 不允许 gateway 先扣金币再单独调用资源发放。
|
||||
- 不允许 user-service 写 VIP 状态。
|
||||
- 不允许客户端传资源 ID、资源组 ID、价格。
|
||||
- 不允许用 Redis 作为 VIP 当前状态唯一来源。
|
||||
- 不允许后台直接改用户 VIP 状态来修单,修单必须走后台补单/补偿命令,并写历史和审计。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 扩展 protobuf:新增 `VipService`、VIP 等级、我的 VIP、购买 VIP 响应结构。
|
||||
2. 增加 MySQL 表:`vip_levels`、`user_vip_memberships`、`vip_purchase_orders`、`user_vip_history`。
|
||||
3. 在 `wallet-service` 实现 VIP repository,先完成等级查询和用户 VIP 查询。
|
||||
4. 实现 `PurchaseVip` 事务:等级校验、幂等、行锁、扣金币、资源组发放、会员状态更新、历史和 outbox。
|
||||
5. 在 `gateway-service` 增加 App HTTP API:`/api/v1/vip/levels`、`/api/v1/vip/me`、`/api/v1/vip/purchase`。
|
||||
6. 接入用户资料聚合:底部“我的”和房间用户信息只展示 VIP 摘要。
|
||||
7. 增加过期扫描任务和 outbox 消费投影。
|
||||
8. 再做后台 VIP 配置页面和后台补单/审计能力。
|
||||
|
||||
## 测试重点
|
||||
|
||||
必须覆盖:
|
||||
|
||||
- 无 VIP 购买 VIP1 成功。
|
||||
- VIP1 未过期时续费 VIP1,到期时间追加 7 天。
|
||||
- VIP1 未过期时购买 VIP3,等级变 VIP3,到期时间追加 7 天。
|
||||
- VIP3 未过期时购买 VIP1 被拒绝。
|
||||
- VIP 过期后可以购买任意等级。
|
||||
- 金币不足时不写 VIP 状态、不发资源。
|
||||
- 资源组发放失败时金币扣费回滚。
|
||||
- 同一 `command_id` 重试返回同一订单。
|
||||
- 同一用户并发购买时只产生符合顺序的一条最终状态。
|
||||
- 后台禁用等级后不能新购买,但不影响已购买用户有效期。
|
||||
549
pkg/logx/logx.go
549
pkg/logx/logx.go
@ -1,18 +1,24 @@
|
||||
// Package logx 提供服务边界日志能力。
|
||||
// 它只记录 transport 层可见的请求、响应、错误语义和耗时,不承载业务判断。
|
||||
// Package logx provides structured runtime logging for service boundaries and workers.
|
||||
// Services emit JSON/text to stdout only; deployment collects stdout into CLS or local logs.
|
||||
package logx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/xerr"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
@ -23,88 +29,281 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// maxLoggedBytes 限制单个 request/response 字段的日志体积,避免大响应污染 stdout。
|
||||
maxLoggedBytes = 8192
|
||||
// redactedValue 是敏感字段统一替换值,保证日志可读但不可用于重放凭证。
|
||||
redactedValue = "***REDACTED***"
|
||||
defaultLevel = "info"
|
||||
defaultFormat = "json"
|
||||
defaultEnv = "local"
|
||||
defaultMaxPayloadBytes = 2048
|
||||
redactedValue = "***REDACTED***"
|
||||
)
|
||||
|
||||
// HTTPMiddleware 在 gateway HTTP 边界记录请求体、响应体、状态码和耗时。
|
||||
// requestIDFromContext 由 gateway 注入,logx 不直接依赖具体 transport 包,避免反向耦合。
|
||||
// Config controls process-wide structured logging. Service code must not write to CLS directly.
|
||||
type Config struct {
|
||||
Service string `yaml:"service"`
|
||||
Env string `yaml:"env"`
|
||||
Version string `yaml:"version"`
|
||||
NodeID string `yaml:"node_id"`
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
IncludeRequestBody bool `yaml:"include_request_body"`
|
||||
IncludeResponseBody bool `yaml:"include_response_body"`
|
||||
MaxPayloadBytes int `yaml:"max_payload_bytes"`
|
||||
Output io.Writer `yaml:"-"`
|
||||
}
|
||||
|
||||
// WithRuntimeFields fills process identity fields from service config without duplicating YAML.
|
||||
func (cfg Config) WithRuntimeFields(service string, env string, nodeID string) Config {
|
||||
if strings.TrimSpace(cfg.Service) == "" {
|
||||
cfg.Service = service
|
||||
}
|
||||
if strings.TrimSpace(cfg.Env) == "" {
|
||||
cfg.Env = env
|
||||
}
|
||||
if strings.TrimSpace(cfg.NodeID) == "" {
|
||||
cfg.NodeID = nodeID
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
type contextAttrsKey struct{}
|
||||
|
||||
var (
|
||||
globalMu sync.RWMutex
|
||||
globalLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo, ReplaceAttr: replaceAttr}))
|
||||
globalConfig = Config{Env: defaultEnv, Level: defaultLevel, Format: defaultFormat, MaxPayloadBytes: defaultMaxPayloadBytes}
|
||||
)
|
||||
|
||||
// Init installs the process logger and redirects the standard library log package into JSON/text logs.
|
||||
func Init(cfg Config) error {
|
||||
normalized, err := NormalizeConfig(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
level, err := parseLevel(normalized.Level)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
output := normalized.Output
|
||||
if output == nil {
|
||||
output = os.Stdout
|
||||
}
|
||||
|
||||
handlerOptions := &slog.HandlerOptions{Level: level, ReplaceAttr: replaceAttr}
|
||||
var handler slog.Handler
|
||||
switch normalized.Format {
|
||||
case "json":
|
||||
handler = slog.NewJSONHandler(output, handlerOptions)
|
||||
case "text":
|
||||
handler = slog.NewTextHandler(output, handlerOptions)
|
||||
default:
|
||||
return fmt.Errorf("unsupported log format %q", normalized.Format)
|
||||
}
|
||||
|
||||
baseAttrs := make([]any, 0, 8)
|
||||
baseAttrs = appendNonEmpty(baseAttrs, "env", normalized.Env)
|
||||
baseAttrs = appendNonEmpty(baseAttrs, "service", normalized.Service)
|
||||
baseAttrs = appendNonEmpty(baseAttrs, "version", normalized.Version)
|
||||
baseAttrs = appendNonEmpty(baseAttrs, "node_id", normalized.NodeID)
|
||||
logger := slog.New(handler).With(baseAttrs...)
|
||||
|
||||
globalMu.Lock()
|
||||
globalLogger = logger
|
||||
globalConfig = normalized
|
||||
globalMu.Unlock()
|
||||
|
||||
slog.SetDefault(logger)
|
||||
log.SetFlags(0)
|
||||
log.SetOutput(stdLogWriter{})
|
||||
return nil
|
||||
}
|
||||
|
||||
// NormalizeConfig applies safe defaults and validates static logging knobs.
|
||||
func NormalizeConfig(cfg Config) (Config, error) {
|
||||
cfg.Service = strings.TrimSpace(cfg.Service)
|
||||
cfg.Env = strings.ToLower(strings.TrimSpace(cfg.Env))
|
||||
if cfg.Env == "" {
|
||||
cfg.Env = defaultEnv
|
||||
}
|
||||
cfg.Version = strings.TrimSpace(cfg.Version)
|
||||
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
|
||||
cfg.Level = strings.ToLower(strings.TrimSpace(cfg.Level))
|
||||
if cfg.Level == "" {
|
||||
cfg.Level = defaultLevel
|
||||
}
|
||||
cfg.Format = strings.ToLower(strings.TrimSpace(cfg.Format))
|
||||
if cfg.Format == "" {
|
||||
cfg.Format = defaultFormat
|
||||
}
|
||||
if cfg.MaxPayloadBytes <= 0 {
|
||||
cfg.MaxPayloadBytes = defaultMaxPayloadBytes
|
||||
}
|
||||
if _, err := parseLevel(cfg.Level); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.Format != "json" && cfg.Format != "text" {
|
||||
return Config{}, fmt.Errorf("unsupported log format %q", cfg.Format)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Logger returns a logger enriched with attributes attached to ctx through With.
|
||||
func Logger(ctx context.Context) *slog.Logger {
|
||||
globalMu.RLock()
|
||||
logger := globalLogger
|
||||
globalMu.RUnlock()
|
||||
attrs := attrsFromContext(ctx)
|
||||
if len(attrs) == 0 {
|
||||
return logger
|
||||
}
|
||||
args := make([]any, 0, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
args = append(args, attr)
|
||||
}
|
||||
return logger.With(args...)
|
||||
}
|
||||
|
||||
// With attaches structured fields to ctx for subsequent Logger/Info/Warn/Error calls.
|
||||
func With(ctx context.Context, attrs ...slog.Attr) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if len(attrs) == 0 {
|
||||
return ctx
|
||||
}
|
||||
existing := attrsFromContext(ctx)
|
||||
merged := make([]slog.Attr, 0, len(existing)+len(attrs))
|
||||
merged = append(merged, existing...)
|
||||
merged = append(merged, attrs...)
|
||||
return context.WithValue(ctx, contextAttrsKey{}, merged)
|
||||
}
|
||||
|
||||
// Info records a normal runtime event.
|
||||
func Info(ctx context.Context, msg string, attrs ...slog.Attr) {
|
||||
Logger(ctx).LogAttrs(ctx, slog.LevelInfo, msg, attrs...)
|
||||
}
|
||||
|
||||
// Warn records a recoverable runtime failure or degraded path.
|
||||
func Warn(ctx context.Context, msg string, attrs ...slog.Attr) {
|
||||
Logger(ctx).LogAttrs(ctx, slog.LevelWarn, msg, attrs...)
|
||||
}
|
||||
|
||||
// Error records a request, transaction, worker, or external dependency failure.
|
||||
func Error(ctx context.Context, msg string, err error, attrs ...slog.Attr) {
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.String("error", err.Error()))
|
||||
}
|
||||
Logger(ctx).LogAttrs(ctx, slog.LevelError, msg, attrs...)
|
||||
}
|
||||
|
||||
// HTTPMiddleware records gateway HTTP access logs. Health checks are intentionally excluded by callers.
|
||||
func HTTPMiddleware(service string, requestIDFromContext func(context.Context) string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
startedAt := time.Now()
|
||||
requestBody := readAndRestoreBody(request)
|
||||
capture := newHTTPResponseCapture(writer)
|
||||
cfg := configSnapshot()
|
||||
requestBody := readAndRestoreBody(request, cfg)
|
||||
capture := newHTTPResponseCapture(writer, cfg.IncludeResponseBody, cfg.MaxPayloadBytes)
|
||||
|
||||
next.ServeHTTP(capture, request)
|
||||
|
||||
requestID := strings.TrimSpace(requestIDFromContext(request.Context()))
|
||||
args := []any{
|
||||
"service", service,
|
||||
"request_id", requestID,
|
||||
"method", request.Method,
|
||||
"path", request.URL.Path,
|
||||
"query", request.URL.RawQuery,
|
||||
"status", capture.statusCode,
|
||||
"duration_ms", time.Since(startedAt).Milliseconds(),
|
||||
"request", safePayload(requestBody),
|
||||
"response", safePayload(capture.body.Bytes()),
|
||||
ctx := With(request.Context(), slog.String("request_id", requestID), slog.String("app_code", appcode.FromContext(request.Context())))
|
||||
attrs := []slog.Attr{
|
||||
slog.String("method", request.Method),
|
||||
slog.String("path", request.URL.Path),
|
||||
slog.String("query", request.URL.RawQuery),
|
||||
slog.Int("status", capture.statusCode),
|
||||
slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
|
||||
slog.String("client_ip", clientIP(request)),
|
||||
}
|
||||
if capture.statusCode >= http.StatusInternalServerError {
|
||||
slog.Error("http_access", args...)
|
||||
return
|
||||
attrs = appendServiceAttr(attrs, service)
|
||||
if cfg.IncludeRequestBody {
|
||||
attrs = append(attrs, slog.Any("request", safePayload(requestBody, cfg.MaxPayloadBytes, len(requestBody) > cfg.MaxPayloadBytes)))
|
||||
}
|
||||
if cfg.IncludeResponseBody {
|
||||
attrs = append(attrs, slog.Any("response", safePayload(capture.body.Bytes(), cfg.MaxPayloadBytes, capture.truncated)))
|
||||
}
|
||||
|
||||
switch levelForHTTPStatus(capture.statusCode) {
|
||||
case slog.LevelError:
|
||||
Error(ctx, "http_access", nil, attrs...)
|
||||
case slog.LevelWarn:
|
||||
Warn(ctx, "http_access", attrs...)
|
||||
default:
|
||||
Info(ctx, "http_access", attrs...)
|
||||
}
|
||||
slog.Info("http_access", args...)
|
||||
})
|
||||
}
|
||||
|
||||
// UnaryServerInterceptor 在内部 gRPC 服务边界记录 protobuf request/response。
|
||||
// 内部链路统一 gRPC + protobuf,因此这里是排查跨服务参数和返回值的主入口。
|
||||
// UnaryServerInterceptor records internal gRPC access logs without coupling business services to a logger.
|
||||
func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
if strings.Contains(info.FullMethod, "/grpc.health.v1.Health/") {
|
||||
// 健康检查高频且无业务参数,跳过可避免日志噪音淹没真实业务请求。
|
||||
return handler(ctx, request)
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
cfg := configSnapshot()
|
||||
response, err := handler(ctx, request)
|
||||
code := codes.OK
|
||||
if err != nil {
|
||||
code = status.Code(err)
|
||||
}
|
||||
meta := requestMetaFromPayload(request)
|
||||
ctx = With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", appcode.Normalize(meta.AppCode)))
|
||||
|
||||
args := []any{
|
||||
"service", service,
|
||||
"request_id", requestIDFromPayload(request),
|
||||
"method", info.FullMethod,
|
||||
"grpc_code", code.String(),
|
||||
"reason", string(xerr.ReasonFromGRPC(err)),
|
||||
"duration_ms", time.Since(startedAt).Milliseconds(),
|
||||
"request", safePayloadFromValue(request),
|
||||
"response", safePayloadFromValue(response),
|
||||
attrs := []slog.Attr{
|
||||
slog.String("grpc_method", info.FullMethod),
|
||||
slog.String("grpc_code", code.String()),
|
||||
slog.String("reason", string(xerr.ReasonFromGRPC(err))),
|
||||
slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("grpc_access", args...)
|
||||
return response, err
|
||||
attrs = appendServiceAttr(attrs, service)
|
||||
attrs = append(attrs, commonAttrsFromPayload(request)...)
|
||||
if cfg.IncludeRequestBody {
|
||||
attrs = append(attrs, slog.Any("request", safePayloadFromValue(request, cfg.MaxPayloadBytes)))
|
||||
}
|
||||
slog.Info("grpc_access", args...)
|
||||
return response, nil
|
||||
if cfg.IncludeResponseBody {
|
||||
attrs = append(attrs, slog.Any("response", safePayloadFromValue(response, cfg.MaxPayloadBytes)))
|
||||
}
|
||||
|
||||
switch levelForGRPCCode(code) {
|
||||
case slog.LevelError:
|
||||
Error(ctx, "grpc_access", err, attrs...)
|
||||
case slog.LevelWarn:
|
||||
Warn(ctx, "grpc_access", attrs...)
|
||||
default:
|
||||
Info(ctx, "grpc_access", attrs...)
|
||||
}
|
||||
return response, err
|
||||
}
|
||||
}
|
||||
|
||||
type stdLogWriter struct{}
|
||||
|
||||
func (stdLogWriter) Write(data []byte) (int, error) {
|
||||
line := strings.TrimSpace(string(data))
|
||||
if line != "" {
|
||||
Info(context.Background(), "stdlog", slog.String("message", redactLogLine(line)))
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
type requestMeta struct {
|
||||
RequestID string
|
||||
AppCode string
|
||||
}
|
||||
|
||||
type httpResponseCapture struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
capture bool
|
||||
maxBytes int
|
||||
truncated bool
|
||||
body bytes.Buffer
|
||||
}
|
||||
|
||||
func newHTTPResponseCapture(writer http.ResponseWriter) *httpResponseCapture {
|
||||
return &httpResponseCapture{
|
||||
ResponseWriter: writer,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
func newHTTPResponseCapture(writer http.ResponseWriter, capture bool, maxBytes int) *httpResponseCapture {
|
||||
return &httpResponseCapture{ResponseWriter: writer, statusCode: http.StatusOK, capture: capture, maxBytes: maxBytes}
|
||||
}
|
||||
|
||||
func (c *httpResponseCapture) WriteHeader(statusCode int) {
|
||||
@ -113,85 +312,98 @@ func (c *httpResponseCapture) WriteHeader(statusCode int) {
|
||||
}
|
||||
|
||||
func (c *httpResponseCapture) Write(data []byte) (int, error) {
|
||||
if c.body.Len() < maxLoggedBytes {
|
||||
remaining := maxLoggedBytes - c.body.Len()
|
||||
if c.capture && c.body.Len() < c.maxBytes {
|
||||
remaining := c.maxBytes - c.body.Len()
|
||||
if len(data) > remaining {
|
||||
c.body.Write(data[:remaining])
|
||||
c.truncated = true
|
||||
} else {
|
||||
c.body.Write(data)
|
||||
}
|
||||
} else if c.capture && len(data) > 0 {
|
||||
c.truncated = true
|
||||
}
|
||||
|
||||
return c.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
func readAndRestoreBody(request *http.Request) []byte {
|
||||
if request.Body == nil {
|
||||
func readAndRestoreBody(request *http.Request, cfg Config) []byte {
|
||||
if !cfg.IncludeRequestBody || request.Body == nil {
|
||||
return nil
|
||||
}
|
||||
mediaType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type"))
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
// 文件上传请求体可能很大,且包含二进制内容;日志只记录占位符并保留原始流给 handler。
|
||||
return []byte("<multipart body omitted>")
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
// 读取失败时把空 body 交还给后续 handler,让真实解码错误仍由 handler 返回。
|
||||
request.Body = io.NopCloser(bytes.NewReader(nil))
|
||||
return nil
|
||||
}
|
||||
request.Body.Close()
|
||||
_ = request.Body.Close()
|
||||
request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return body
|
||||
}
|
||||
|
||||
func safePayloadFromValue(value any) any {
|
||||
func safePayloadFromValue(value any, maxBytes int) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if message, ok := value.(proto.Message); ok {
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(message)
|
||||
if err != nil {
|
||||
return "<proto_marshal_failed>"
|
||||
}
|
||||
return safePayload(raw)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(value)
|
||||
raw, err := marshalPayload(value)
|
||||
if err != nil {
|
||||
return "<json_marshal_failed>"
|
||||
return "<payload_marshal_failed>"
|
||||
}
|
||||
return safePayload(raw)
|
||||
return safePayload(raw, maxBytes, len(raw) > maxBytes)
|
||||
}
|
||||
|
||||
func requestIDFromPayload(value any) string {
|
||||
payload, ok := safePayloadFromValue(value).(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
func requestMetaFromPayload(value any) requestMeta {
|
||||
raw, err := marshalPayload(value)
|
||||
if err != nil || len(raw) == 0 {
|
||||
return requestMeta{}
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return requestMeta{}
|
||||
}
|
||||
result := requestMeta{RequestID: stringFromMap(payload, "request_id"), AppCode: stringFromMap(payload, "app_code")}
|
||||
meta, ok := payload["meta"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
return result
|
||||
}
|
||||
if requestID, ok := meta["request_id"].(string); ok {
|
||||
return requestID
|
||||
if result.RequestID == "" {
|
||||
result.RequestID = stringFromMap(meta, "request_id")
|
||||
}
|
||||
return ""
|
||||
if result.AppCode == "" {
|
||||
result.AppCode = stringFromMap(meta, "app_code")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func safePayload(raw []byte) any {
|
||||
func marshalPayload(value any) ([]byte, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if message, ok := value.(proto.Message); ok {
|
||||
return protojson.MarshalOptions{UseProtoNames: true}.Marshal(message)
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
func safePayload(raw []byte, maxBytes int, truncated bool) any {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(raw) > maxLoggedBytes {
|
||||
raw = raw[:maxLoggedBytes]
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = defaultMaxPayloadBytes
|
||||
}
|
||||
if truncated || len(raw) > maxBytes {
|
||||
return map[string]any{"truncated": true, "bytes": len(raw)}
|
||||
}
|
||||
|
||||
var payload any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return string(raw)
|
||||
return redactLogLine(string(raw))
|
||||
}
|
||||
return redactValue(payload)
|
||||
}
|
||||
@ -209,15 +421,23 @@ func redactValue(value any) any {
|
||||
}
|
||||
return redacted
|
||||
case []any:
|
||||
redacted := make([]any, len(typed))
|
||||
for index, item := range typed {
|
||||
typed[index] = redactValue(item)
|
||||
redacted[index] = redactValue(item)
|
||||
}
|
||||
return typed
|
||||
return redacted
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func replaceAttr(_ []string, attr slog.Attr) slog.Attr {
|
||||
if isSensitiveKey(attr.Key) {
|
||||
return slog.String(attr.Key, redactedValue)
|
||||
}
|
||||
return attr
|
||||
}
|
||||
|
||||
func isSensitiveKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
|
||||
sensitiveParts := []string{
|
||||
@ -234,6 +454,7 @@ func isSensitiveKey(key string) bool {
|
||||
"signature",
|
||||
"private_key",
|
||||
"callback_auth",
|
||||
"receipt",
|
||||
}
|
||||
for _, part := range sensitiveParts {
|
||||
if strings.Contains(normalized, part) {
|
||||
@ -242,3 +463,171 @@ func isSensitiveKey(key string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redactLogLine(line string) string {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 {
|
||||
return line
|
||||
}
|
||||
changed := false
|
||||
for index, field := range fields {
|
||||
separator := strings.IndexAny(field, "=:")
|
||||
if separator <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.Trim(field[:separator], "\"'")
|
||||
if isSensitiveKey(key) {
|
||||
fields[index] = field[:separator+1] + redactedValue
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return line
|
||||
}
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
func attrsFromContext(ctx context.Context) []slog.Attr {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
attrs, _ := ctx.Value(contextAttrsKey{}).([]slog.Attr)
|
||||
return attrs
|
||||
}
|
||||
|
||||
func configSnapshot() Config {
|
||||
globalMu.RLock()
|
||||
cfg := globalConfig
|
||||
globalMu.RUnlock()
|
||||
if cfg.MaxPayloadBytes <= 0 {
|
||||
cfg.MaxPayloadBytes = defaultMaxPayloadBytes
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func parseLevel(value string) (slog.Level, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug, nil
|
||||
case "", "info":
|
||||
return slog.LevelInfo, nil
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn, nil
|
||||
case "error":
|
||||
return slog.LevelError, nil
|
||||
default:
|
||||
return slog.LevelInfo, fmt.Errorf("%w %q", ErrUnsupportedLevel, value)
|
||||
}
|
||||
}
|
||||
|
||||
func levelForHTTPStatus(statusCode int) slog.Level {
|
||||
switch {
|
||||
case statusCode >= http.StatusInternalServerError:
|
||||
return slog.LevelError
|
||||
case statusCode >= http.StatusBadRequest:
|
||||
return slog.LevelWarn
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
|
||||
func levelForGRPCCode(code codes.Code) slog.Level {
|
||||
switch code {
|
||||
case codes.OK:
|
||||
return slog.LevelInfo
|
||||
case codes.Internal, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unknown:
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelWarn
|
||||
}
|
||||
}
|
||||
|
||||
func appendServiceAttr(attrs []slog.Attr, service string) []slog.Attr {
|
||||
if strings.TrimSpace(service) == "" {
|
||||
return attrs
|
||||
}
|
||||
cfg := configSnapshot()
|
||||
if cfg.Service != "" {
|
||||
return attrs
|
||||
}
|
||||
return append(attrs, slog.String("service", service))
|
||||
}
|
||||
|
||||
func appendNonEmpty(attrs []any, key string, value string) []any {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return attrs
|
||||
}
|
||||
return append(attrs, key, value)
|
||||
}
|
||||
|
||||
func stringFromMap(values map[string]any, key string) string {
|
||||
value, ok := values[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
default:
|
||||
return fmt.Sprint(typed)
|
||||
}
|
||||
}
|
||||
|
||||
func clientIP(request *http.Request) string {
|
||||
if request == nil {
|
||||
return ""
|
||||
}
|
||||
for _, header := range []string{"X-Forwarded-For", "X-Real-IP"} {
|
||||
value := strings.TrimSpace(request.Header.Get(header))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if comma := strings.IndexByte(value, ','); comma >= 0 {
|
||||
return strings.TrimSpace(value[:comma])
|
||||
}
|
||||
return value
|
||||
}
|
||||
return request.RemoteAddr
|
||||
}
|
||||
|
||||
func commonAttrsFromPayload(value any) []slog.Attr {
|
||||
raw, err := marshalPayload(value)
|
||||
if err != nil || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
keys := []string{
|
||||
"command_id",
|
||||
"user_id",
|
||||
"room_id",
|
||||
"room_version",
|
||||
"transaction_id",
|
||||
"biz_type",
|
||||
"asset_type",
|
||||
"sender_user_id",
|
||||
"target_user_id",
|
||||
"seller_user_id",
|
||||
"gift_id",
|
||||
"gift_count",
|
||||
"event_id",
|
||||
"event_type",
|
||||
}
|
||||
attrs := make([]slog.Attr, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
value, ok := payload[key]
|
||||
if !ok || value == nil {
|
||||
continue
|
||||
}
|
||||
attrs = append(attrs, slog.Any(key, value))
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
// ErrUnsupportedLevel is kept as a stable sentinel for configuration tests.
|
||||
var ErrUnsupportedLevel = errors.New("unsupported log level")
|
||||
|
||||
177
pkg/logx/logx_test.go
Normal file
177
pkg/logx/logx_test.go
Normal file
@ -0,0 +1,177 @@
|
||||
package logx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestNormalizeConfigRejectsBadLevel(t *testing.T) {
|
||||
_, err := NormalizeConfig(Config{Level: "verbose"})
|
||||
if !errors.Is(err, ErrUnsupportedLevel) {
|
||||
t.Fatalf("NormalizeConfig error=%v, want ErrUnsupportedLevel", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSafePayloadRedactsSensitiveFieldsAndOmitsLargePayload(t *testing.T) {
|
||||
payload := safePayload([]byte(`{"username":"tom","password":"pw","nested":{"user_sig":"sig"}}`), 2048, false)
|
||||
values, ok := payload.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("payload type=%T, want map", payload)
|
||||
}
|
||||
if values["password"] != redactedValue {
|
||||
t.Fatalf("password was not redacted: %+v", values)
|
||||
}
|
||||
nested := values["nested"].(map[string]any)
|
||||
if nested["user_sig"] != redactedValue {
|
||||
t.Fatalf("nested user_sig was not redacted: %+v", nested)
|
||||
}
|
||||
|
||||
large := safePayload([]byte(`{"token":"secret-token","data":"abcdef"}`), 8, true)
|
||||
largeMap, ok := large.(map[string]any)
|
||||
if !ok || largeMap["truncated"] != true {
|
||||
t.Fatalf("large payload=%+v, want truncated marker", large)
|
||||
}
|
||||
if encoded := toJSON(t, large); bytes.Contains(encoded, []byte("secret-token")) {
|
||||
t.Fatalf("truncated payload leaked sensitive value: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMiddlewareUsesConfiguredBodyLoggingAndWarnLevel(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
if err := Init(Config{Service: "gateway-service", Env: "test", Level: "debug", Format: "json", IncludeRequestBody: true, IncludeResponseBody: true, MaxPayloadBytes: 2048, Output: &output}); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
handler := HTTPMiddleware("gateway-service", func(context.Context) string { return "req-http-1" }, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = writer.Write([]byte(`{"code":"INVALID_ARGUMENT","token":"server-token"}`))
|
||||
}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login?debug=1", bytes.NewBufferString(`{"username":"tom","password":"client-password"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
entry := decodeOne(t, output.Bytes())
|
||||
if entry["level"] != "WARN" || entry["msg"] != "http_access" || entry["service"] != "gateway-service" {
|
||||
t.Fatalf("unexpected http log entry: %+v", entry)
|
||||
}
|
||||
if entry["request_id"] != "req-http-1" || entry["path"] != "/api/v1/auth/login" || entry["client_ip"] != "203.0.113.10" {
|
||||
t.Fatalf("missing access fields: %+v", entry)
|
||||
}
|
||||
request := entry["request"].(map[string]any)
|
||||
if request["password"] != redactedValue {
|
||||
t.Fatalf("request body was not redacted: %+v", request)
|
||||
}
|
||||
response := entry["response"].(map[string]any)
|
||||
if response["token"] != redactedValue {
|
||||
t.Fatalf("response body was not redacted: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPMiddlewareOmitsBodiesByDefault(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
if err := Init(Config{Service: "gateway-service", Env: "test", Format: "json", Output: &output}); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
handler := HTTPMiddleware("gateway-service", func(context.Context) string { return "req-http-2" }, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
_, _ = writer.Write([]byte(`{"ok":true}`))
|
||||
}))
|
||||
handler.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/api/v1/ping", bytes.NewBufferString(`{"password":"client-password"}`)))
|
||||
|
||||
entry := decodeOne(t, output.Bytes())
|
||||
if _, exists := entry["request"]; exists {
|
||||
t.Fatalf("request body should be omitted by default: %+v", entry)
|
||||
}
|
||||
if _, exists := entry["response"]; exists {
|
||||
t.Fatalf("response body should be omitted by default: %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnaryServerInterceptorLevelsAndRequestMeta(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
if err := Init(Config{Service: "user-service", Env: "test", Format: "json", IncludeRequestBody: true, Output: &output}); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
interceptor := UnaryServerInterceptor("user-service")
|
||||
request := &userv1.GetUserRequest{Meta: &userv1.RequestMeta{RequestId: "req-grpc-1", AppCode: "lalu"}, UserId: 1001}
|
||||
_, err := interceptor(context.Background(), request, &grpc.UnaryServerInfo{FullMethod: "/hyapp.user.v1.UserService/GetUser"}, func(ctx context.Context, req any) (any, error) {
|
||||
return nil, status.Error(codes.InvalidArgument, "bad user id")
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("interceptor should return handler error")
|
||||
}
|
||||
|
||||
entry := decodeOne(t, output.Bytes())
|
||||
if entry["level"] != "WARN" || entry["msg"] != "grpc_access" || entry["request_id"] != "req-grpc-1" || entry["app_code"] != "lalu" {
|
||||
t.Fatalf("unexpected grpc log entry: %+v", entry)
|
||||
}
|
||||
if entry["grpc_code"] != "InvalidArgument" {
|
||||
t.Fatalf("grpc_code=%v, want InvalidArgument", entry["grpc_code"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnaryServerInterceptorExtractsTopLevelTraceFieldsWithoutBody(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
if err := Init(Config{Service: "wallet-service", Env: "test", Format: "json", Output: &output}); err != nil {
|
||||
t.Fatalf("Init failed: %v", err)
|
||||
}
|
||||
|
||||
interceptor := UnaryServerInterceptor("wallet-service")
|
||||
request := &walletv1.AdminCreditAssetRequest{
|
||||
CommandId: "cmd-credit-1",
|
||||
TargetUserId: 42001,
|
||||
AssetType: "COIN_SELLER_COIN",
|
||||
Amount: 80000,
|
||||
AppCode: "lalu",
|
||||
}
|
||||
_, err := interceptor(context.Background(), request, &grpc.UnaryServerInfo{FullMethod: "/hyapp.wallet.v1.WalletService/AdminCreditAsset"}, func(ctx context.Context, req any) (any, error) {
|
||||
return &walletv1.AdminCreditAssetResponse{}, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("interceptor returned error: %v", err)
|
||||
}
|
||||
|
||||
entry := decodeOne(t, output.Bytes())
|
||||
if entry["app_code"] != "lalu" || entry["command_id"] != "cmd-credit-1" || entry["asset_type"] != "COIN_SELLER_COIN" {
|
||||
t.Fatalf("missing top-level trace fields: %+v", entry)
|
||||
}
|
||||
if _, exists := entry["request"]; exists {
|
||||
t.Fatalf("request body should remain omitted: %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func decodeOne(t *testing.T, raw []byte) map[string]any {
|
||||
t.Helper()
|
||||
lines := bytes.Split(bytes.TrimSpace(raw), []byte("\n"))
|
||||
if len(lines) != 1 || len(lines[0]) == 0 {
|
||||
t.Fatalf("log lines=%q, want exactly one", raw)
|
||||
}
|
||||
var entry map[string]any
|
||||
if err := json.Unmarshal(lines[0], &entry); err != nil {
|
||||
t.Fatalf("decode log JSON failed: %v raw=%s", err, lines[0])
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func toJSON(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatalf("json marshal failed: %v", err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
113
pkg/xerr/catalog.go
Normal file
113
pkg/xerr/catalog.go
Normal file
@ -0,0 +1,113 @@
|
||||
package xerr
|
||||
|
||||
import "google.golang.org/grpc/codes"
|
||||
|
||||
const (
|
||||
httpStatusBadRequest = 400
|
||||
httpStatusUnauthorized = 401
|
||||
httpStatusForbidden = 403
|
||||
httpStatusNotFound = 404
|
||||
httpStatusConflict = 409
|
||||
httpStatusInternalServerError = 500
|
||||
httpStatusBadGateway = 502
|
||||
)
|
||||
|
||||
// Spec 是错误码注册表中的一条稳定契约。
|
||||
// GRPCCode 是服务间传输语义;PublicCode/PublicMessage 是 gateway 对 App 暴露的 envelope。
|
||||
type Spec struct {
|
||||
GRPCCode codes.Code
|
||||
HTTPStatus int
|
||||
PublicCode string
|
||||
PublicMessage string
|
||||
Retryable bool
|
||||
}
|
||||
|
||||
var catalog = map[Code]Spec{
|
||||
InvalidArgument: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidArgument, "invalid argument"),
|
||||
NotFound: spec(codes.NotFound, httpStatusNotFound, NotFound, "not found"),
|
||||
Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "conflict"),
|
||||
RoomClosed: spec(codes.FailedPrecondition, httpStatusConflict, RoomClosed, "room closed"),
|
||||
|
||||
Unauthorized: spec(codes.Unauthenticated, httpStatusUnauthorized, Unauthorized, "unauthorized"),
|
||||
PermissionDenied: spec(codes.PermissionDenied, httpStatusForbidden, PermissionDenied, "permission denied"),
|
||||
ProfileRequired: spec(codes.PermissionDenied, httpStatusForbidden, ProfileRequired, "profile required"),
|
||||
Unavailable: {
|
||||
GRPCCode: codes.Unavailable,
|
||||
HTTPStatus: httpStatusBadGateway,
|
||||
PublicCode: "UPSTREAM_ERROR",
|
||||
PublicMessage: "upstream service error",
|
||||
Retryable: true,
|
||||
},
|
||||
Internal: spec(codes.Internal, httpStatusInternalServerError, Internal, "internal error"),
|
||||
|
||||
AuthFailed: spec(codes.Unauthenticated, httpStatusUnauthorized, AuthFailed, "authentication failed"),
|
||||
PasswordAlreadySet: spec(codes.AlreadyExists, httpStatusConflict, PasswordAlreadySet, "conflict"),
|
||||
UserDisabled: spec(codes.PermissionDenied, httpStatusForbidden, UserDisabled, "permission denied"),
|
||||
SessionExpired: spec(codes.Unauthenticated, httpStatusUnauthorized, SessionExpired, "unauthorized"),
|
||||
SessionRevoked: spec(codes.Unauthenticated, httpStatusUnauthorized, SessionRevoked, "unauthorized"),
|
||||
|
||||
DisplayUserIDInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, DisplayUserIDInvalid, "invalid argument"),
|
||||
DisplayUserIDExists: spec(codes.AlreadyExists, httpStatusConflict, DisplayUserIDExists, "conflict"),
|
||||
DisplayUserIDCooldown: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDCooldown, "conflict"),
|
||||
DisplayUserIDNotFound: spec(codes.NotFound, httpStatusNotFound, DisplayUserIDNotFound, "not found"),
|
||||
DisplayUserIDAllocateFailed: spec(codes.Internal, httpStatusInternalServerError, Internal, "internal error"),
|
||||
DisplayUserIDPrettyNotAvailable: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPrettyNotAvailable, "conflict"),
|
||||
DisplayUserIDPrettyActive: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPrettyActive, "conflict"),
|
||||
DisplayUserIDLeaseExpired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDLeaseExpired, "conflict"),
|
||||
DisplayUserIDPaymentRequired: spec(codes.FailedPrecondition, httpStatusConflict, DisplayUserIDPaymentRequired, "conflict"),
|
||||
|
||||
CountryChangeCooldown: spec(codes.FailedPrecondition, httpStatusConflict, CountryChangeCooldown, "conflict"),
|
||||
CountryNotFound: spec(codes.NotFound, httpStatusNotFound, CountryNotFound, "not found"),
|
||||
RegionNotFound: spec(codes.NotFound, httpStatusNotFound, RegionNotFound, "not found"),
|
||||
RegionCountryConflict: spec(codes.FailedPrecondition, httpStatusConflict, RegionCountryConflict, "conflict"),
|
||||
RegionDisabled: spec(codes.FailedPrecondition, httpStatusConflict, RegionDisabled, "conflict"),
|
||||
InvalidInviteCode: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidInviteCode, "invalid argument"),
|
||||
|
||||
InsufficientBalance: spec(codes.FailedPrecondition, httpStatusConflict, InsufficientBalance, "insufficient balance"),
|
||||
DuplicateBillingCommand: spec(codes.AlreadyExists, httpStatusConflict, DuplicateBillingCommand, "conflict"),
|
||||
LedgerConflict: spec(codes.Aborted, httpStatusConflict, LedgerConflict, "conflict"),
|
||||
CoinSellerNotActive: spec(codes.FailedPrecondition, httpStatusConflict, CoinSellerNotActive, "conflict"),
|
||||
CoinSellerStockTypeInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockTypeInvalid, "invalid argument"),
|
||||
CoinSellerStockAmountInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, CoinSellerStockAmountInvalid, "invalid argument"),
|
||||
CoinSellerPaymentRefDuplicated: spec(codes.AlreadyExists, httpStatusConflict, CoinSellerPaymentRefDuplicated, "conflict"),
|
||||
IdempotencyConflict: spec(codes.AlreadyExists, httpStatusConflict, IdempotencyConflict, "conflict"),
|
||||
|
||||
RuleNotActive: spec(codes.FailedPrecondition, httpStatusConflict, RuleNotActive, "conflict"),
|
||||
EventAlreadyConsumed: spec(codes.AlreadyExists, httpStatusConflict, EventAlreadyConsumed, "conflict"),
|
||||
RewardPending: spec(codes.FailedPrecondition, httpStatusConflict, RewardPending, "conflict"),
|
||||
|
||||
InvalidSection: spec(codes.InvalidArgument, httpStatusBadRequest, InvalidSection, "invalid argument"),
|
||||
MessageNotFound: spec(codes.NotFound, httpStatusNotFound, MessageNotFound, "not found"),
|
||||
MessageRecalled: spec(codes.NotFound, httpStatusNotFound, MessageRecalled, "not found"),
|
||||
PageTokenInvalid: spec(codes.InvalidArgument, httpStatusBadRequest, PageTokenInvalid, "invalid argument"),
|
||||
RequestConflict: spec(codes.FailedPrecondition, httpStatusConflict, RequestConflict, "conflict"),
|
||||
ProducerEventConflict: spec(codes.FailedPrecondition, httpStatusConflict, ProducerEventConflict, "conflict"),
|
||||
}
|
||||
|
||||
func spec(grpcCode codes.Code, httpStatus int, publicCode Code, publicMessage string) Spec {
|
||||
return Spec{
|
||||
GRPCCode: grpcCode,
|
||||
HTTPStatus: httpStatus,
|
||||
PublicCode: string(publicCode),
|
||||
PublicMessage: publicMessage,
|
||||
}
|
||||
}
|
||||
|
||||
// SpecOf 返回业务码的注册表项。未知业务码降级为 INTERNAL_ERROR,避免泄漏未登记 reason。
|
||||
func SpecOf(code Code) Spec {
|
||||
if registered, ok := catalog[code]; ok {
|
||||
return registered
|
||||
}
|
||||
|
||||
return catalog[Internal]
|
||||
}
|
||||
|
||||
// RegisteredCodes 返回当前注册表中全部业务码,供测试和文档生成使用。
|
||||
func RegisteredCodes() []Code {
|
||||
registered := make([]Code, 0, len(catalog))
|
||||
for code := range catalog {
|
||||
registered = append(registered, code)
|
||||
}
|
||||
|
||||
return registered
|
||||
}
|
||||
@ -76,6 +76,16 @@ const (
|
||||
DuplicateBillingCommand Code = "DUPLICATE_BILLING_COMMAND"
|
||||
// LedgerConflict 表示账本并发写入或余额版本冲突。
|
||||
LedgerConflict Code = "LEDGER_CONFLICT"
|
||||
// CoinSellerNotActive 表示目标用户不存在 active 币商身份。
|
||||
CoinSellerNotActive Code = "COIN_SELLER_NOT_ACTIVE"
|
||||
// CoinSellerStockTypeInvalid 表示币商库存入账类型不属于支持范围。
|
||||
CoinSellerStockTypeInvalid Code = "COIN_SELLER_STOCK_TYPE_INVALID"
|
||||
// CoinSellerStockAmountInvalid 表示币商库存金币数或线下付款金额不合法。
|
||||
CoinSellerStockAmountInvalid Code = "COIN_SELLER_STOCK_AMOUNT_INVALID"
|
||||
// CoinSellerPaymentRefDuplicated 表示同一 USDT 收款凭证已经入账。
|
||||
CoinSellerPaymentRefDuplicated Code = "COIN_SELLER_PAYMENT_REF_DUPLICATED"
|
||||
// IdempotencyConflict 表示 command_id 相同但业务 payload 不一致。
|
||||
IdempotencyConflict Code = "IDEMPOTENCY_CONFLICT"
|
||||
|
||||
// RuleNotActive 表示活动规则当前不可用。
|
||||
RuleNotActive Code = "RULE_NOT_ACTIVE"
|
||||
|
||||
@ -13,30 +13,7 @@ const errorDomain = "hyapp"
|
||||
// GRPCCode 把稳定业务 reason 映射为 gRPC 标准 code。
|
||||
// gateway 后续只需要读取 gRPC code 和 ErrorInfo.reason 即可生成 HTTP envelope。
|
||||
func GRPCCode(code Code) codes.Code {
|
||||
switch code {
|
||||
case InvalidArgument, DisplayUserIDInvalid, InvalidInviteCode, InvalidSection, PageTokenInvalid:
|
||||
return codes.InvalidArgument
|
||||
case NotFound, DisplayUserIDNotFound, CountryNotFound, RegionNotFound, MessageNotFound, MessageRecalled:
|
||||
return codes.NotFound
|
||||
case Conflict, RoomClosed, RegionCountryConflict, RegionDisabled, RequestConflict, ProducerEventConflict:
|
||||
return codes.FailedPrecondition
|
||||
case Unauthorized, AuthFailed, SessionExpired, SessionRevoked:
|
||||
return codes.Unauthenticated
|
||||
case PermissionDenied, ProfileRequired, UserDisabled:
|
||||
return codes.PermissionDenied
|
||||
case Unavailable:
|
||||
return codes.Unavailable
|
||||
case PasswordAlreadySet, DisplayUserIDExists, DuplicateBillingCommand, EventAlreadyConsumed:
|
||||
return codes.AlreadyExists
|
||||
case DisplayUserIDCooldown, DisplayUserIDPrettyNotAvailable, DisplayUserIDPrettyActive, DisplayUserIDLeaseExpired, DisplayUserIDPaymentRequired, CountryChangeCooldown, InsufficientBalance, RuleNotActive, RewardPending:
|
||||
return codes.FailedPrecondition
|
||||
case DisplayUserIDAllocateFailed:
|
||||
return codes.Internal
|
||||
case LedgerConflict:
|
||||
return codes.Aborted
|
||||
default:
|
||||
return codes.Internal
|
||||
}
|
||||
return SpecOf(code).GRPCCode
|
||||
}
|
||||
|
||||
// ToGRPCError 只在 transport/grpc 层调用。
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
package xerr
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"google.golang.org/grpc/codes"
|
||||
@ -24,3 +28,109 @@ func TestToGRPCErrorCarriesReason(t *testing.T) {
|
||||
t.Fatalf("reason mismatch: got %s want %s", got, PasswordAlreadySet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogCoversDeclaredCodes(t *testing.T) {
|
||||
declared := declaredCodesFromSource(t)
|
||||
|
||||
for code, name := range declared {
|
||||
if _, ok := catalog[code]; !ok {
|
||||
t.Fatalf("declared xerr code %s (%s) is missing from catalog", name, code)
|
||||
}
|
||||
}
|
||||
|
||||
for code := range catalog {
|
||||
if _, ok := declared[code]; !ok {
|
||||
t.Fatalf("catalog registers undeclared xerr code %s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogMappings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
code Code
|
||||
grpcCode codes.Code
|
||||
httpStatus int
|
||||
publicCode string
|
||||
publicMessage string
|
||||
}{
|
||||
{
|
||||
name: "business reason keeps canonical grpc code",
|
||||
code: InvalidInviteCode,
|
||||
grpcCode: codes.InvalidArgument,
|
||||
httpStatus: httpStatusBadRequest,
|
||||
publicCode: string(InvalidInviteCode),
|
||||
publicMessage: "invalid argument",
|
||||
},
|
||||
{
|
||||
name: "unavailable is exposed as upstream error",
|
||||
code: Unavailable,
|
||||
grpcCode: codes.Unavailable,
|
||||
httpStatus: httpStatusBadGateway,
|
||||
publicCode: "UPSTREAM_ERROR",
|
||||
publicMessage: "upstream service error",
|
||||
},
|
||||
{
|
||||
name: "ledger conflict uses aborted grpc but http conflict",
|
||||
code: LedgerConflict,
|
||||
grpcCode: codes.Aborted,
|
||||
httpStatus: httpStatusConflict,
|
||||
publicCode: string(LedgerConflict),
|
||||
publicMessage: "conflict",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
spec := SpecOf(test.code)
|
||||
if spec.GRPCCode != test.grpcCode ||
|
||||
spec.HTTPStatus != test.httpStatus ||
|
||||
spec.PublicCode != test.publicCode ||
|
||||
spec.PublicMessage != test.publicMessage {
|
||||
t.Fatalf("catalog mismatch: got %+v", spec)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func declaredCodesFromSource(t *testing.T) map[Code]string {
|
||||
t.Helper()
|
||||
|
||||
file, err := parser.ParseFile(token.NewFileSet(), "errors.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse errors.go: %v", err)
|
||||
}
|
||||
|
||||
declared := make(map[Code]string)
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
decl, ok := node.(*ast.GenDecl)
|
||||
if !ok || decl.Tok != token.CONST {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, spec := range decl.Specs {
|
||||
valueSpec, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for index, name := range valueSpec.Names {
|
||||
if index >= len(valueSpec.Values) {
|
||||
continue
|
||||
}
|
||||
literal, ok := valueSpec.Values[index].(*ast.BasicLit)
|
||||
if !ok || literal.Kind != token.STRING {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.Unquote(literal.Value)
|
||||
if err != nil {
|
||||
t.Fatalf("unquote %s: %v", literal.Value, err)
|
||||
}
|
||||
declared[Code(value)] = name.Name
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
})
|
||||
|
||||
return declared
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"flag"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
@ -38,6 +39,7 @@ import (
|
||||
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
|
||||
searchmodule "hyapp-admin-server/internal/modules/search"
|
||||
uploadmodule "hyapp-admin-server/internal/modules/upload"
|
||||
"hyapp-admin-server/internal/platform/logging"
|
||||
"hyapp-admin-server/internal/platform/tencentcos"
|
||||
"hyapp-admin-server/internal/repository"
|
||||
"hyapp-admin-server/internal/router"
|
||||
@ -60,37 +62,40 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := logging.Init(cfg.ServiceName, cfg.Environment, cfg.NodeID, cfg.Log); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureDatabase(cfg.MySQLDSN); err != nil {
|
||||
log.Fatalf("ensure mysql database: %v", err)
|
||||
fatalRuntime("ensure_mysql_database_failed", err)
|
||||
}
|
||||
if err := ensureDatabase(cfg.WalletMySQLDSN); err != nil {
|
||||
log.Fatalf("ensure wallet mysql database: %v", err)
|
||||
fatalRuntime("ensure_wallet_mysql_database_failed", err)
|
||||
}
|
||||
if err := ensureDatabase(cfg.RoomMySQLDSN); err != nil {
|
||||
log.Fatalf("ensure room mysql database: %v", err)
|
||||
fatalRuntime("ensure_room_mysql_database_failed", err)
|
||||
}
|
||||
|
||||
dbLogger := logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{
|
||||
dbLogger := logger.New(log.New(logging.StdWriter(), "", 0), logger.Config{
|
||||
LogLevel: logger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
})
|
||||
db, err := gorm.Open(mysql.Open(cfg.MySQLDSN), &gorm.Config{Logger: dbLogger})
|
||||
if err != nil {
|
||||
log.Fatalf("connect mysql: %v", err)
|
||||
fatalRuntime("connect_mysql_failed", err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
log.Fatalf("mysql db handle: %v", err)
|
||||
fatalRuntime("mysql_db_handle_failed", err)
|
||||
}
|
||||
if cfg.Migrations.Enabled {
|
||||
if err := migration.Apply(context.Background(), sqlDB, cfg.Migrations.Dir); err != nil {
|
||||
log.Fatalf("apply migrations: %v", err)
|
||||
fatalRuntime("apply_migrations_failed", err)
|
||||
}
|
||||
}
|
||||
redisClient, err := cache.NewRedis(context.Background(), cfg.Redis)
|
||||
if err != nil {
|
||||
log.Fatalf("connect redis: %v", err)
|
||||
fatalRuntime("connect_redis_failed", err)
|
||||
}
|
||||
if redisClient != nil {
|
||||
defer redisClient.Close()
|
||||
@ -98,52 +103,52 @@ func main() {
|
||||
|
||||
userDB, err := sql.Open("mysql", cfg.UserMySQLDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("connect user mysql: %v", err)
|
||||
fatalRuntime("connect_user_mysql_failed", err)
|
||||
}
|
||||
if err := userDB.PingContext(context.Background()); err != nil {
|
||||
log.Fatalf("ping user mysql: %v", err)
|
||||
fatalRuntime("ping_user_mysql_failed", err)
|
||||
}
|
||||
defer userDB.Close()
|
||||
|
||||
walletDB, err := sql.Open("mysql", cfg.WalletMySQLDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("connect wallet mysql: %v", err)
|
||||
fatalRuntime("connect_wallet_mysql_failed", err)
|
||||
}
|
||||
if err := walletDB.PingContext(context.Background()); err != nil {
|
||||
log.Fatalf("ping wallet mysql: %v", err)
|
||||
fatalRuntime("ping_wallet_mysql_failed", err)
|
||||
}
|
||||
defer walletDB.Close()
|
||||
|
||||
roomDB, err := sql.Open("mysql", cfg.RoomMySQLDSN)
|
||||
if err != nil {
|
||||
log.Fatalf("connect room mysql: %v", err)
|
||||
fatalRuntime("connect_room_mysql_failed", err)
|
||||
}
|
||||
if err := roomDB.PingContext(context.Background()); err != nil {
|
||||
log.Fatalf("ping room mysql: %v", err)
|
||||
fatalRuntime("ping_room_mysql_failed", err)
|
||||
}
|
||||
defer roomDB.Close()
|
||||
|
||||
store := repository.New(db)
|
||||
if cfg.MySQLAutoMigrate {
|
||||
if err := store.AutoMigrate(); err != nil {
|
||||
log.Fatalf("auto migrate: %v", err)
|
||||
fatalRuntime("auto_migrate_failed", err)
|
||||
}
|
||||
}
|
||||
if *runBootstrap || cfg.Bootstrap.Enabled {
|
||||
if err := store.Seed(cfg); err != nil {
|
||||
log.Fatalf("seed database: %v", err)
|
||||
fatalRuntime("seed_database_failed", err)
|
||||
}
|
||||
}
|
||||
|
||||
userConn, err := grpc.Dial(cfg.UserService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
log.Fatalf("connect user-service: %v", err)
|
||||
fatalRuntime("connect_user_service_failed", err)
|
||||
}
|
||||
defer userConn.Close()
|
||||
|
||||
walletConn, err := grpc.Dial(cfg.WalletService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
if err != nil {
|
||||
log.Fatalf("connect wallet-service: %v", err)
|
||||
fatalRuntime("connect_wallet_service_failed", err)
|
||||
}
|
||||
defer walletConn.Close()
|
||||
|
||||
@ -168,7 +173,7 @@ func main() {
|
||||
AccessURL: cfg.TencentCOS.AccessURL,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
log.Fatalf("create tencent cos uploader: %v", err)
|
||||
fatalRuntime("create_tencent_cos_uploader_failed", err)
|
||||
}
|
||||
objectUploader = uploader
|
||||
}
|
||||
@ -182,7 +187,7 @@ func main() {
|
||||
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
|
||||
Dashboard: dashboardmodule.New(store),
|
||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), userDB, walletDB, auditHandler),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||
Job: jobmodule.New(store, cfg, auditHandler),
|
||||
Menu: menumodule.New(store, auditHandler),
|
||||
Notification: notificationmodule.New(store, auditHandler),
|
||||
@ -202,9 +207,9 @@ func main() {
|
||||
}
|
||||
|
||||
go func() {
|
||||
log.Printf("hyapp-admin-server listening on %s", cfg.HTTPAddr)
|
||||
slog.Info("service_listening", "addr", cfg.HTTPAddr)
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("listen: %v", err)
|
||||
fatalRuntime("listen_failed", err)
|
||||
}
|
||||
}()
|
||||
|
||||
@ -215,10 +220,15 @@ func main() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
slog.Error("shutdown_failed", "error", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func fatalRuntime(msg string, err error) {
|
||||
slog.Error(msg, "error", err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func ensureDatabase(dsn string) error {
|
||||
parsed, err := mysqlDriver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
service_name: admin-server
|
||||
node_id: admin-local
|
||||
environment: local
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
http_addr: ":13100"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
|
||||
@ -325,6 +325,7 @@
|
||||
| `coin-seller:view` | `menu` | 币商列表、币商余额 |
|
||||
| `coin-seller:create` | `button` | 新增币商 |
|
||||
| `coin-seller:update` | `button` | 启用或禁用币商 |
|
||||
| `coin-seller:stock-credit` | `button` | 币商进货、金币补偿 |
|
||||
| `salary:view` | `menu` | 薪资周期、薪资明细 |
|
||||
| `salary:calculate` | `button` | 计算薪资 |
|
||||
| `salary:approve` | `button` | 审批薪资 |
|
||||
|
||||
@ -7,6 +7,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/platform/logging"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@ -14,6 +16,7 @@ type Config struct {
|
||||
ServiceName string `yaml:"service_name"`
|
||||
NodeID string `yaml:"node_id"`
|
||||
Environment string `yaml:"environment"`
|
||||
Log logging.Config `yaml:"log"`
|
||||
HTTPAddr string `yaml:"http_addr"`
|
||||
MySQLDSN string `yaml:"mysql_dsn"`
|
||||
UserMySQLDSN string `yaml:"user_mysql_dsn"`
|
||||
@ -90,6 +93,7 @@ func Default() Config {
|
||||
ServiceName: "admin-server",
|
||||
NodeID: "admin-local",
|
||||
Environment: "local",
|
||||
Log: logging.DefaultConfig(),
|
||||
HTTPAddr: ":13100",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
@ -172,6 +176,15 @@ func (cfg *Config) Normalize() {
|
||||
if cfg.Environment == "" {
|
||||
cfg.Environment = "local"
|
||||
}
|
||||
if cfg.Log.Level == "" {
|
||||
cfg.Log.Level = "info"
|
||||
}
|
||||
if cfg.Log.Format == "" {
|
||||
cfg.Log.Format = "json"
|
||||
}
|
||||
if cfg.Log.MaxPayloadBytes <= 0 {
|
||||
cfg.Log.MaxPayloadBytes = 2048
|
||||
}
|
||||
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
||||
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
|
||||
if cfg.NodeID == "" {
|
||||
|
||||
@ -24,6 +24,7 @@ type Client interface {
|
||||
SetBDStatus(ctx context.Context, req SetBDStatusRequest) (*BDProfile, error)
|
||||
CreateCoinSeller(ctx context.Context, req CreateCoinSellerRequest) (*CoinSellerProfile, error)
|
||||
SetCoinSellerStatus(ctx context.Context, req SetCoinSellerStatusRequest) (*CoinSellerProfile, error)
|
||||
GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error)
|
||||
CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error)
|
||||
CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error)
|
||||
SetAgencyJoinEnabled(ctx context.Context, req SetAgencyJoinEnabledRequest) (*Agency, error)
|
||||
@ -85,6 +86,7 @@ type GRPCClient struct {
|
||||
identityClient userv1.UserIdentityServiceClient
|
||||
countryClient userv1.CountryAdminServiceClient
|
||||
regionClient userv1.RegionAdminServiceClient
|
||||
hostClient userv1.UserHostServiceClient
|
||||
hostAdminClient userv1.UserHostAdminServiceClient
|
||||
}
|
||||
|
||||
@ -94,6 +96,7 @@ func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
|
||||
identityClient: userv1.NewUserIdentityServiceClient(conn),
|
||||
countryClient: userv1.NewCountryAdminServiceClient(conn),
|
||||
regionClient: userv1.NewRegionAdminServiceClient(conn),
|
||||
hostClient: userv1.NewUserHostServiceClient(conn),
|
||||
hostAdminClient: userv1.NewUserHostAdminServiceClient(conn),
|
||||
}
|
||||
}
|
||||
@ -223,6 +226,12 @@ type SetCoinSellerStatusRequest struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
type GetCoinSellerProfileRequest struct {
|
||||
RequestID string
|
||||
Caller string
|
||||
UserID int64
|
||||
}
|
||||
|
||||
type CreateAgencyRequest struct {
|
||||
RequestID string
|
||||
Caller string
|
||||
@ -256,28 +265,46 @@ type SetAgencyJoinEnabledRequest struct {
|
||||
}
|
||||
|
||||
type BDProfile struct {
|
||||
UserID int64 `json:"userId,string"`
|
||||
Role string `json:"role"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
ParentLeaderUserID int64 `json:"parentLeaderUserId,string"`
|
||||
Status string `json:"status"`
|
||||
CreatedByUserID int64 `json:"createdByUserId"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
UserID int64 `json:"userId,string"`
|
||||
Role string `json:"role"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
ParentLeaderUserID int64 `json:"parentLeaderUserId,string"`
|
||||
Status string `json:"status"`
|
||||
CreatedByUserID int64 `json:"createdByUserId"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
RegionName string `json:"regionName"`
|
||||
ParentLeaderDisplayID string `json:"parentLeaderDisplayUserId"`
|
||||
ParentLeaderUsername string `json:"parentLeaderUsername"`
|
||||
ParentLeaderAvatar string `json:"parentLeaderAvatar"`
|
||||
CreatedByDisplayUserID string `json:"createdByDisplayUserId"`
|
||||
CreatedByUsername string `json:"createdByUsername"`
|
||||
CreatedByAvatar string `json:"createdByAvatar"`
|
||||
SubBDCount int64 `json:"subBdCount"`
|
||||
}
|
||||
|
||||
type Agency struct {
|
||||
AgencyID int64 `json:"agencyId"`
|
||||
OwnerUserID int64 `json:"ownerUserId,string"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
ParentBDUserID int64 `json:"parentBdUserId,string"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
JoinEnabled bool `json:"joinEnabled"`
|
||||
MaxHosts int32 `json:"maxHosts"`
|
||||
CreatedByUserID int64 `json:"createdByUserId"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
AgencyID int64 `json:"agencyId"`
|
||||
OwnerUserID int64 `json:"ownerUserId,string"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
ParentBDUserID int64 `json:"parentBdUserId,string"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
JoinEnabled bool `json:"joinEnabled"`
|
||||
MaxHosts int32 `json:"maxHosts"`
|
||||
CreatedByUserID int64 `json:"createdByUserId"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
OwnerDisplayUserID string `json:"ownerDisplayUserId"`
|
||||
OwnerUsername string `json:"ownerUsername"`
|
||||
OwnerAvatar string `json:"ownerAvatar"`
|
||||
ParentBDDisplayUserID string `json:"parentBdDisplayUserId"`
|
||||
ParentBDUsername string `json:"parentBdUsername"`
|
||||
ParentBDAvatar string `json:"parentBdAvatar"`
|
||||
RegionName string `json:"regionName"`
|
||||
}
|
||||
|
||||
type HostProfile struct {
|
||||
@ -290,6 +317,11 @@ type HostProfile struct {
|
||||
FirstBecameHostAtMs int64 `json:"firstBecameHostAtMs"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
RegionName string `json:"regionName"`
|
||||
CurrentAgencyName string `json:"currentAgencyName"`
|
||||
}
|
||||
|
||||
type CoinSellerProfile struct {
|
||||
@ -396,6 +428,17 @@ func (c *GRPCClient) SetCoinSellerStatus(ctx context.Context, req SetCoinSellerS
|
||||
return fromProtoCoinSellerProfile(resp.GetCoinSellerProfile()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error) {
|
||||
resp, err := c.hostClient.GetCoinSellerProfile(ctx, &userv1.GetCoinSellerProfileRequest{
|
||||
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
||||
UserId: req.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fromProtoCoinSellerProfile(resp.GetCoinSellerProfile()), nil
|
||||
}
|
||||
|
||||
func (c *GRPCClient) CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error) {
|
||||
resp, err := c.hostAdminClient.CreateAgency(ctx, &userv1.CreateAgencyRequest{
|
||||
Meta: requestMeta(ctx, req.RequestID, req.Caller),
|
||||
|
||||
@ -27,6 +27,7 @@ type Client interface {
|
||||
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
|
||||
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
|
||||
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
|
||||
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
|
||||
}
|
||||
|
||||
@ -106,6 +107,10 @@ func (c *GRPCClient) ListResourceGrants(ctx context.Context, req *walletv1.ListR
|
||||
return c.client.ListResourceGrants(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) {
|
||||
return c.client.AdminCreditCoinSellerStock(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error) {
|
||||
return c.client.ListRechargeBills(ctx, req)
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@ -128,12 +128,12 @@ func (r *Runner) runOnce(ctx context.Context) {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return
|
||||
}
|
||||
log.Printf("find pending job: %v", err)
|
||||
slog.Error("job_find_pending_failed", "worker_id", r.workerID, "error", err.Error())
|
||||
return
|
||||
}
|
||||
locked, unlock, err := r.lock(ctx, job)
|
||||
if err != nil {
|
||||
log.Printf("lock job: %v", err)
|
||||
slog.Error("job_lock_failed", "worker_id", r.workerID, "job_id", job.ID, "error", err.Error())
|
||||
_ = r.store.ReleaseJobLease(job.ID)
|
||||
return
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/integration/userclient"
|
||||
"hyapp-admin-server/internal/integration/walletclient"
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
@ -19,8 +20,8 @@ type Handler struct {
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(userClient userclient.Client, userDB *sql.DB, walletDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(userClient, NewReader(userDB, walletDB)), audit: audit}
|
||||
func New(userClient userclient.Client, walletClient walletclient.Client, userDB *sql.DB, walletDB *sql.DB, adminDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(userClient, walletClient, NewReader(userDB, walletDB, adminDB)), audit: audit}
|
||||
}
|
||||
|
||||
func (h *Handler) ListBDLeaders(c *gin.Context) {
|
||||
@ -140,6 +141,21 @@ func (h *Handler) SetBDStatus(c *gin.Context) {
|
||||
response.OK(c, profile)
|
||||
}
|
||||
|
||||
func (h *Handler) DeleteBDLeader(c *gin.Context) {
|
||||
targetUserID, ok := parseInt64ID(c, "user_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
profile, err := h.service.DeleteBDLeader(c.Request.Context(), targetUserID)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
writeHostOrgAuditLog(c, h.audit, "delete-bd-leader", "bd_profiles", profile.UserID,
|
||||
fmt.Sprintf("user_id=%d", profile.UserID))
|
||||
response.OK(c, profile)
|
||||
}
|
||||
|
||||
func (h *Handler) CreateCoinSeller(c *gin.Context) {
|
||||
var req createCoinSellerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
@ -176,6 +192,27 @@ func (h *Handler) SetCoinSellerStatus(c *gin.Context) {
|
||||
response.OK(c, profile)
|
||||
}
|
||||
|
||||
func (h *Handler) CreditCoinSellerStock(c *gin.Context) {
|
||||
sellerUserID, ok := parseInt64ID(c, "user_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req coinSellerStockCreditRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "币商进货参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.CreditCoinSellerStock(c.Request.Context(), adminActorID(c), sellerUserID, middleware.CurrentRequestID(c), req)
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
writeHostOrgAuditLog(c, h.audit, "coin-seller-stock-credit", "coin_seller_stock_records", sellerUserID,
|
||||
fmt.Sprintf("command_id=%s seller_user_id=%d stock_type=%s coin_amount=%d paid_currency_code=%s paid_amount_micro=%d transaction_id=%s reason=%q",
|
||||
strings.TrimSpace(req.CommandID), sellerUserID, result.GetStockType(), result.GetCoinAmount(), result.GetPaidCurrencyCode(), result.GetPaidAmountMicro(), result.GetTransactionId(), strings.TrimSpace(req.Reason)))
|
||||
response.Created(c, coinSellerStockCreditFromProto(result))
|
||||
}
|
||||
|
||||
func (h *Handler) CreateAgency(c *gin.Context) {
|
||||
var req createAgencyRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/userclient"
|
||||
@ -13,10 +14,11 @@ import (
|
||||
type Reader struct {
|
||||
db *sql.DB
|
||||
walletDB *sql.DB
|
||||
adminDB *sql.DB
|
||||
}
|
||||
|
||||
func NewReader(db *sql.DB, walletDB *sql.DB) *Reader {
|
||||
return &Reader{db: db, walletDB: walletDB}
|
||||
func NewReader(db *sql.DB, walletDB *sql.DB, adminDB *sql.DB) *Reader {
|
||||
return &Reader{db: db, walletDB: walletDB, adminDB: adminDB}
|
||||
}
|
||||
|
||||
type CoinSellerListItem struct {
|
||||
@ -24,6 +26,7 @@ type CoinSellerListItem struct {
|
||||
Status string `json:"status"`
|
||||
MerchantAssetType string `json:"merchantAssetType"`
|
||||
MerchantBalance int64 `json:"merchantBalance"`
|
||||
Contact string `json:"contact"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
@ -40,7 +43,13 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
|
||||
return nil, 0, errUserDBNotConfigured()
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
whereSQL := "FROM bd_profiles bp LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id WHERE bp.app_code = ? AND bp.role = ?"
|
||||
whereSQL := `
|
||||
FROM bd_profiles bp
|
||||
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
|
||||
LEFT JOIN users parent_leader ON parent_leader.app_code = bp.app_code AND parent_leader.user_id = bp.parent_leader_user_id
|
||||
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
|
||||
LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id
|
||||
WHERE bp.app_code = ? AND bp.role = ?`
|
||||
args := []any{appCode, role}
|
||||
if query.Status != "" {
|
||||
whereSQL += " AND bp.status = ?"
|
||||
@ -56,8 +65,8 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ?)"
|
||||
args = append(args, like, like, like)
|
||||
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR r.name LIKE ?)"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
|
||||
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||||
@ -66,7 +75,20 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT bp.user_id, bp.role, bp.region_id, COALESCE(bp.parent_leader_user_id, 0),
|
||||
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms
|
||||
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
|
||||
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
|
||||
COALESCE(u.avatar, ''), COALESCE(r.name, ''),
|
||||
COALESCE(parent_leader.current_display_user_id, ''), COALESCE(parent_leader.username, ''),
|
||||
COALESCE(parent_leader.avatar, ''),
|
||||
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
|
||||
COALESCE(creator.avatar, ''),
|
||||
(
|
||||
SELECT COUNT(1)
|
||||
FROM bd_profiles child
|
||||
WHERE child.app_code = bp.app_code
|
||||
AND child.role = 'bd'
|
||||
AND child.parent_leader_user_id = bp.user_id
|
||||
)
|
||||
%s
|
||||
ORDER BY bp.created_at_ms DESC, bp.user_id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
@ -79,12 +101,139 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
|
||||
items := make([]*userclient.BDProfile, 0, query.PageSize)
|
||||
for rows.Next() {
|
||||
item := &userclient.BDProfile{}
|
||||
if err := rows.Scan(&item.UserID, &item.Role, &item.RegionID, &item.ParentLeaderUserID, &item.Status, &item.CreatedByUserID, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil {
|
||||
if err := rows.Scan(
|
||||
&item.UserID,
|
||||
&item.Role,
|
||||
&item.RegionID,
|
||||
&item.ParentLeaderUserID,
|
||||
&item.Status,
|
||||
&item.CreatedByUserID,
|
||||
&item.CreatedAtMs,
|
||||
&item.UpdatedAtMs,
|
||||
&item.DisplayUserID,
|
||||
&item.Username,
|
||||
&item.Avatar,
|
||||
&item.RegionName,
|
||||
&item.ParentLeaderDisplayID,
|
||||
&item.ParentLeaderUsername,
|
||||
&item.ParentLeaderAvatar,
|
||||
&item.CreatedByDisplayUserID,
|
||||
&item.CreatedByUsername,
|
||||
&item.CreatedByAvatar,
|
||||
&item.SubBDCount,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := r.fillBDProfileCreators(ctx, items); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (r *Reader) fillBDProfileCreators(ctx context.Context, items []*userclient.BDProfile) error {
|
||||
if r == nil || r.adminDB == nil || len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(items))
|
||||
ids := make([]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.CreatedByUserID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[item.CreatedByUserID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[item.CreatedByUserID] = struct{}{}
|
||||
ids = append(ids, item.CreatedByUserID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rows, err := r.adminDB.QueryContext(ctx, `
|
||||
SELECT id, username, name
|
||||
FROM admin_users
|
||||
WHERE id IN (`+sqlPlaceholders(len(ids))+`)
|
||||
`, ids...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type creator struct {
|
||||
account string
|
||||
name string
|
||||
}
|
||||
creators := make(map[int64]creator, len(ids))
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var item creator
|
||||
if err := rows.Scan(&id, &item.account, &item.name); err != nil {
|
||||
return err
|
||||
}
|
||||
creators[id] = item
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
creator, ok := creators[item.CreatedByUserID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
item.CreatedByDisplayUserID = creator.account
|
||||
item.CreatedByUsername = firstNonBlank(creator.name, creator.account)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient.BDProfile, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errUserDBNotConfigured()
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
item := &userclient.BDProfile{}
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT bp.user_id, bp.role, bp.region_id, COALESCE(bp.parent_leader_user_id, 0),
|
||||
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms
|
||||
FROM bd_profiles bp
|
||||
WHERE bp.app_code = ? AND bp.user_id = ? AND bp.role = 'bd_leader'
|
||||
`, appCode, userID).Scan(
|
||||
&item.UserID,
|
||||
&item.Role,
|
||||
&item.RegionID,
|
||||
&item.ParentLeaderUserID,
|
||||
&item.Status,
|
||||
&item.CreatedByUserID,
|
||||
&item.CreatedAtMs,
|
||||
&item.UpdatedAtMs,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("bd leader not found")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM bd_profiles
|
||||
WHERE app_code = ? AND user_id = ? AND role = 'bd_leader'
|
||||
`, appCode, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected == 0 {
|
||||
return nil, fmt.Errorf("bd leader not found")
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
// ListAgencies 只读 user-service 的 Agency 事实表;后台关闭和入会开关仍走写命令。
|
||||
@ -93,7 +242,12 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
|
||||
return nil, 0, errUserDBNotConfigured()
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
whereSQL := "FROM agencies a LEFT JOIN users owner ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id WHERE a.app_code = ?"
|
||||
whereSQL := `
|
||||
FROM agencies a
|
||||
LEFT JOIN users owner ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id
|
||||
LEFT JOIN users parent_bd ON parent_bd.app_code = a.app_code AND parent_bd.user_id = a.parent_bd_user_id
|
||||
LEFT JOIN regions r ON r.app_code = a.app_code AND r.region_id = a.region_id
|
||||
WHERE a.app_code = ?`
|
||||
args := []any{appCode}
|
||||
if query.Status != "" {
|
||||
whereSQL += " AND a.status = ?"
|
||||
@ -109,8 +263,8 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ?)"
|
||||
args = append(args, like, like, like, like, like)
|
||||
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ? OR CAST(a.parent_bd_user_id AS CHAR) LIKE ? OR parent_bd.current_display_user_id LIKE ? OR parent_bd.username LIKE ? OR r.name LIKE ?)"
|
||||
args = append(args, like, like, like, like, like, like, like, like, like)
|
||||
}
|
||||
|
||||
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||||
@ -120,7 +274,11 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
|
||||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT a.agency_id, a.owner_user_id, a.region_id, a.parent_bd_user_id,
|
||||
a.name, a.status, a.join_enabled, a.max_hosts,
|
||||
a.created_by_user_id, a.created_at_ms, a.updated_at_ms
|
||||
a.created_by_user_id, a.created_at_ms, a.updated_at_ms,
|
||||
COALESCE(owner.current_display_user_id, ''), COALESCE(owner.username, ''),
|
||||
COALESCE(owner.avatar, ''),
|
||||
COALESCE(parent_bd.current_display_user_id, ''), COALESCE(parent_bd.username, ''),
|
||||
COALESCE(parent_bd.avatar, ''), COALESCE(r.name, '')
|
||||
%s
|
||||
ORDER BY a.created_at_ms DESC, a.agency_id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
@ -133,7 +291,26 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
|
||||
items := make([]*userclient.Agency, 0, query.PageSize)
|
||||
for rows.Next() {
|
||||
item := &userclient.Agency{}
|
||||
if err := rows.Scan(&item.AgencyID, &item.OwnerUserID, &item.RegionID, &item.ParentBDUserID, &item.Name, &item.Status, &item.JoinEnabled, &item.MaxHosts, &item.CreatedByUserID, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil {
|
||||
if err := rows.Scan(
|
||||
&item.AgencyID,
|
||||
&item.OwnerUserID,
|
||||
&item.RegionID,
|
||||
&item.ParentBDUserID,
|
||||
&item.Name,
|
||||
&item.Status,
|
||||
&item.JoinEnabled,
|
||||
&item.MaxHosts,
|
||||
&item.CreatedByUserID,
|
||||
&item.CreatedAtMs,
|
||||
&item.UpdatedAtMs,
|
||||
&item.OwnerDisplayUserID,
|
||||
&item.OwnerUsername,
|
||||
&item.OwnerAvatar,
|
||||
&item.ParentBDDisplayUserID,
|
||||
&item.ParentBDUsername,
|
||||
&item.ParentBDAvatar,
|
||||
&item.RegionName,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
@ -147,7 +324,12 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
|
||||
return nil, 0, errUserDBNotConfigured()
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
whereSQL := "FROM host_profiles hp LEFT JOIN users u ON u.app_code = hp.app_code AND u.user_id = hp.user_id LEFT JOIN agencies a ON a.app_code = hp.app_code AND a.agency_id = hp.current_agency_id WHERE hp.app_code = ?"
|
||||
whereSQL := `
|
||||
FROM host_profiles hp
|
||||
LEFT JOIN users u ON u.app_code = hp.app_code AND u.user_id = hp.user_id
|
||||
LEFT JOIN agencies a ON a.app_code = hp.app_code AND a.agency_id = hp.current_agency_id
|
||||
LEFT JOIN regions r ON r.app_code = hp.app_code AND r.region_id = hp.region_id
|
||||
WHERE hp.app_code = ?`
|
||||
args := []any{appCode}
|
||||
if query.Status != "" {
|
||||
whereSQL += " AND hp.status = ?"
|
||||
@ -163,8 +345,8 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ?)"
|
||||
args = append(args, like, like, like, like)
|
||||
whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ? OR r.name LIKE ?)"
|
||||
args = append(args, like, like, like, like, like)
|
||||
}
|
||||
|
||||
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||||
@ -174,7 +356,9 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
|
||||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT hp.user_id, hp.status, hp.region_id, COALESCE(hp.current_agency_id, 0),
|
||||
COALESCE(hp.current_membership_id, 0), hp.source,
|
||||
hp.first_became_host_at_ms, hp.created_at_ms, hp.updated_at_ms
|
||||
hp.first_became_host_at_ms, hp.created_at_ms, hp.updated_at_ms,
|
||||
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
|
||||
COALESCE(u.avatar, ''), COALESCE(r.name, ''), COALESCE(a.name, '')
|
||||
%s
|
||||
ORDER BY hp.created_at_ms DESC, hp.user_id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
@ -187,7 +371,22 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
|
||||
items := make([]*userclient.HostProfile, 0, query.PageSize)
|
||||
for rows.Next() {
|
||||
item := &userclient.HostProfile{}
|
||||
if err := rows.Scan(&item.UserID, &item.Status, &item.RegionID, &item.CurrentAgencyID, &item.CurrentMembershipID, &item.Source, &item.FirstBecameHostAtMs, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil {
|
||||
if err := rows.Scan(
|
||||
&item.UserID,
|
||||
&item.Status,
|
||||
&item.RegionID,
|
||||
&item.CurrentAgencyID,
|
||||
&item.CurrentMembershipID,
|
||||
&item.Source,
|
||||
&item.FirstBecameHostAtMs,
|
||||
&item.CreatedAtMs,
|
||||
&item.UpdatedAtMs,
|
||||
&item.DisplayUserID,
|
||||
&item.Username,
|
||||
&item.Avatar,
|
||||
&item.RegionName,
|
||||
&item.CurrentAgencyName,
|
||||
); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items = append(items, item)
|
||||
@ -201,6 +400,9 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
||||
return nil, 0, errUserDBNotConfigured()
|
||||
}
|
||||
appCode := appctx.FromContext(ctx)
|
||||
if err := r.ensureCoinSellerContactColumn(ctx); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
whereSQL := `
|
||||
FROM coin_seller_profiles csp
|
||||
LEFT JOIN users u ON u.app_code = csp.app_code AND u.user_id = csp.user_id
|
||||
@ -217,8 +419,8 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
||||
}
|
||||
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
|
||||
like := "%" + keyword + "%"
|
||||
whereSQL += " AND (CAST(csp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ?)"
|
||||
args = append(args, like, like, like)
|
||||
whereSQL += " AND (CAST(csp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR csp.contact_info LIKE ?)"
|
||||
args = append(args, like, like, like, like)
|
||||
}
|
||||
|
||||
total, err := countRows(ctx, r.db, whereSQL, args...)
|
||||
@ -227,6 +429,7 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT csp.user_id, csp.status, csp.merchant_asset_type,
|
||||
COALESCE(csp.contact_info, ''),
|
||||
csp.created_by_user_id, csp.created_at_ms, csp.updated_at_ms,
|
||||
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
|
||||
COALESCE(u.avatar, ''), COALESCE(u.region_id, 0),
|
||||
@ -248,6 +451,7 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
||||
&item.UserID,
|
||||
&item.Status,
|
||||
&item.MerchantAssetType,
|
||||
&item.Contact,
|
||||
&item.CreatedByUserID,
|
||||
&item.CreatedAtMs,
|
||||
&item.UpdatedAtMs,
|
||||
@ -275,6 +479,42 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (r *Reader) UpdateCoinSellerContact(ctx context.Context, userID int64, contact string) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errUserDBNotConfigured()
|
||||
}
|
||||
if err := r.ensureCoinSellerContactColumn(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE coin_seller_profiles
|
||||
SET contact_info = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND user_id = ?
|
||||
`, strings.TrimSpace(contact), time.Now().UnixMilli(), appctx.FromContext(ctx), userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Reader) ensureCoinSellerContactColumn(ctx context.Context) error {
|
||||
var count int
|
||||
if err := r.db.QueryRowContext(ctx, `
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'coin_seller_profiles'
|
||||
AND column_name = 'contact_info'
|
||||
`).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
ALTER TABLE coin_seller_profiles
|
||||
ADD COLUMN contact_info VARCHAR(128) NOT NULL DEFAULT '' AFTER merchant_asset_type
|
||||
`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
|
||||
result := make(map[int64]int64, len(userIDs))
|
||||
if r == nil || r.walletDB == nil || len(userIDs) == 0 {
|
||||
@ -305,6 +545,13 @@ func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func sqlPlaceholders(count int) string {
|
||||
if count <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimRight(strings.Repeat("?,", count), ",")
|
||||
}
|
||||
|
||||
func countRows(ctx context.Context, db *sql.DB, whereSQL string, args ...any) (int64, error) {
|
||||
var total int64
|
||||
err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total)
|
||||
|
||||
@ -33,14 +33,26 @@ type bdStatusRequest struct {
|
||||
|
||||
type createCoinSellerRequest struct {
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
Contact string `json:"contact"`
|
||||
TargetUserID int64 `json:"targetUserId" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type coinSellerStatusRequest struct {
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
Status string `json:"status" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
Contact *string `json:"contact"`
|
||||
Status string `json:"status" binding:"required"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type coinSellerStockCreditRequest struct {
|
||||
CommandID string `json:"commandId" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
CoinAmount int64 `json:"coinAmount" binding:"required"`
|
||||
RechargeAmount string `json:"rechargeAmount"`
|
||||
PaymentRef string `json:"paymentRef"`
|
||||
EvidenceRef string `json:"evidenceRef"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type createAgencyRequest struct {
|
||||
|
||||
32
server/admin/internal/modules/hostorg/response.go
Normal file
32
server/admin/internal/modules/hostorg/response.go
Normal file
@ -0,0 +1,32 @@
|
||||
package hostorg
|
||||
|
||||
import walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
|
||||
type coinSellerStockCreditResponse struct {
|
||||
TransactionID string `json:"transactionId"`
|
||||
SellerUserID int64 `json:"sellerUserId,string"`
|
||||
StockType string `json:"stockType"`
|
||||
CoinAmount int64 `json:"coinAmount"`
|
||||
PaidCurrencyCode string `json:"paidCurrencyCode"`
|
||||
PaidAmountMicro int64 `json:"paidAmountMicro"`
|
||||
CountsAsSellerRecharge bool `json:"countsAsSellerRecharge"`
|
||||
BalanceAfter int64 `json:"balanceAfter"`
|
||||
CreatedAtMS int64 `json:"createdAtMs"`
|
||||
}
|
||||
|
||||
func coinSellerStockCreditFromProto(item *walletv1.AdminCreditCoinSellerStockResponse) coinSellerStockCreditResponse {
|
||||
if item == nil {
|
||||
return coinSellerStockCreditResponse{}
|
||||
}
|
||||
return coinSellerStockCreditResponse{
|
||||
TransactionID: item.GetTransactionId(),
|
||||
SellerUserID: item.GetSellerUserId(),
|
||||
StockType: item.GetStockType(),
|
||||
CoinAmount: item.GetCoinAmount(),
|
||||
PaidCurrencyCode: item.GetPaidCurrencyCode(),
|
||||
PaidAmountMicro: item.GetPaidAmountMicro(),
|
||||
CountsAsSellerRecharge: item.GetCountsAsSellerRecharge(),
|
||||
BalanceAfter: item.GetBalanceAfter(),
|
||||
CreatedAtMS: item.GetCreatedAtMs(),
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.GET("/admin/bd-leaders", middleware.RequirePermission("bd:view"), h.ListBDLeaders)
|
||||
protected.POST("/admin/bd-leaders", middleware.RequirePermission("bd:create"), h.CreateBDLeader)
|
||||
protected.PATCH("/admin/bd-leaders/:user_id/status", middleware.RequirePermission("bd:update"), h.SetBDStatus)
|
||||
protected.DELETE("/admin/bd-leaders/:user_id", middleware.RequirePermission("bd:update"), h.DeleteBDLeader)
|
||||
protected.GET("/admin/bds", middleware.RequirePermission("bd:view"), h.ListBDs)
|
||||
protected.POST("/admin/bds", middleware.RequirePermission("bd:create"), h.CreateBD)
|
||||
protected.PATCH("/admin/bds/:user_id/status", middleware.RequirePermission("bd:update"), h.SetBDStatus)
|
||||
@ -21,4 +22,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.GET("/admin/coin-sellers", middleware.RequirePermission("coin-seller:view"), h.ListCoinSellers)
|
||||
protected.POST("/admin/coin-sellers", middleware.RequirePermission("coin-seller:create"), h.CreateCoinSeller)
|
||||
protected.PATCH("/admin/coin-sellers/:user_id/status", middleware.RequirePermission("coin-seller:update"), h.SetCoinSellerStatus)
|
||||
protected.POST("/admin/coin-sellers/:user_id/stock-credits", middleware.RequirePermission("coin-seller:stock-credit"), h.CreditCoinSellerStock)
|
||||
}
|
||||
|
||||
@ -6,16 +6,28 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/integration/userclient"
|
||||
"hyapp-admin-server/internal/integration/walletclient"
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
coinSellerStatusActive = "active"
|
||||
coinSellerMerchantAssetType = "COIN_SELLER_COIN"
|
||||
coinSellerStockTypePurchase = "usdt_purchase"
|
||||
coinSellerStockTypeCompensate = "coin_compensation"
|
||||
paidCurrencyUSDT = "USDT"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
userClient userclient.Client
|
||||
reader *Reader
|
||||
userClient userclient.Client
|
||||
walletClient walletclient.Client
|
||||
reader *Reader
|
||||
}
|
||||
|
||||
func NewService(userClient userclient.Client, reader *Reader) *Service {
|
||||
return &Service{userClient: userClient, reader: reader}
|
||||
func NewService(userClient userclient.Client, walletClient walletclient.Client, reader *Reader) *Service {
|
||||
return &Service{userClient: userClient, walletClient: walletClient, reader: reader}
|
||||
}
|
||||
|
||||
func (s *Service) ListBDLeaders(ctx context.Context, query listQuery) ([]*userclient.BDProfile, int64, error) {
|
||||
@ -92,12 +104,19 @@ func (s *Service) SetBDStatus(ctx context.Context, actorID int64, targetUserID i
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) DeleteBDLeader(ctx context.Context, targetUserID int64) (*userclient.BDProfile, error) {
|
||||
if targetUserID <= 0 {
|
||||
return nil, fmt.Errorf("target_user_id is required")
|
||||
}
|
||||
return s.reader.DeleteBDLeader(ctx, targetUserID)
|
||||
}
|
||||
|
||||
func (s *Service) CreateCoinSeller(ctx context.Context, actorID int64, requestID string, req createCoinSellerRequest) (*userclient.CoinSellerProfile, error) {
|
||||
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.userClient.CreateCoinSeller(ctx, userclient.CreateCoinSellerRequest{
|
||||
profile, err := s.userClient.CreateCoinSeller(ctx, userclient.CreateCoinSellerRequest{
|
||||
RequestID: requestID,
|
||||
Caller: "hyapp-admin-server",
|
||||
CommandID: strings.TrimSpace(req.CommandID),
|
||||
@ -105,10 +124,20 @@ func (s *Service) CreateCoinSeller(ctx context.Context, actorID int64, requestID
|
||||
TargetUserID: targetUserID,
|
||||
Reason: strings.TrimSpace(req.Reason),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contact := firstNonBlank(req.Contact, req.Reason)
|
||||
if contact != "" {
|
||||
if err := s.reader.UpdateCoinSellerContact(ctx, targetUserID, contact); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (s *Service) SetCoinSellerStatus(ctx context.Context, actorID int64, targetUserID int64, requestID string, req coinSellerStatusRequest) (*userclient.CoinSellerProfile, error) {
|
||||
return s.userClient.SetCoinSellerStatus(ctx, userclient.SetCoinSellerStatusRequest{
|
||||
profile, err := s.userClient.SetCoinSellerStatus(ctx, userclient.SetCoinSellerStatusRequest{
|
||||
RequestID: requestID,
|
||||
Caller: "hyapp-admin-server",
|
||||
CommandID: strings.TrimSpace(req.CommandID),
|
||||
@ -117,6 +146,76 @@ func (s *Service) SetCoinSellerStatus(ctx context.Context, actorID int64, target
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
Reason: strings.TrimSpace(req.Reason),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.Contact != nil {
|
||||
if err := s.reader.UpdateCoinSellerContact(ctx, targetUserID, *req.Contact); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sellerUserID int64, requestID string, req coinSellerStockCreditRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) {
|
||||
if s.walletClient == nil {
|
||||
return nil, fmt.Errorf("wallet client is not configured")
|
||||
}
|
||||
if sellerUserID <= 0 || actorID <= 0 {
|
||||
return nil, fmt.Errorf("seller_user_id and operator_user_id are required")
|
||||
}
|
||||
commandID := strings.TrimSpace(req.CommandID)
|
||||
stockType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||
evidenceRef := strings.TrimSpace(req.EvidenceRef)
|
||||
reason := strings.TrimSpace(req.Reason)
|
||||
if commandID == "" || req.CoinAmount <= 0 {
|
||||
return nil, fmt.Errorf("command_id and coin_amount are required")
|
||||
}
|
||||
|
||||
paidCurrencyCode := ""
|
||||
paidAmountMicro := int64(0)
|
||||
paymentRef := strings.TrimSpace(req.PaymentRef)
|
||||
switch stockType {
|
||||
case coinSellerStockTypePurchase:
|
||||
paidCurrencyCode = paidCurrencyUSDT
|
||||
parsed, err := parseUSDTAmountMicro(req.RechargeAmount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paidAmountMicro = parsed
|
||||
case coinSellerStockTypeCompensate:
|
||||
if strings.TrimSpace(req.RechargeAmount) != "" || paymentRef != "" {
|
||||
return nil, fmt.Errorf("coin compensation must not include recharge_amount or payment_ref")
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("stock type is invalid")
|
||||
}
|
||||
|
||||
profile, err := s.userClient.GetCoinSellerProfile(ctx, userclient.GetCoinSellerProfileRequest{
|
||||
RequestID: requestID,
|
||||
Caller: "hyapp-admin-server",
|
||||
UserID: sellerUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if profile == nil || profile.Status != coinSellerStatusActive || profile.MerchantAssetType != coinSellerMerchantAssetType {
|
||||
return nil, fmt.Errorf("target user is not an active coin seller")
|
||||
}
|
||||
|
||||
return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
|
||||
CommandId: commandID,
|
||||
SellerUserId: sellerUserID,
|
||||
StockType: stockType,
|
||||
CoinAmount: req.CoinAmount,
|
||||
PaidCurrencyCode: paidCurrencyCode,
|
||||
PaidAmountMicro: paidAmountMicro,
|
||||
PaymentRef: paymentRef,
|
||||
EvidenceRef: evidenceRef,
|
||||
OperatorUserId: actorID,
|
||||
Reason: reason,
|
||||
AppCode: appctx.FromContext(ctx),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) CreateAgency(ctx context.Context, actorID int64, requestID string, req createAgencyRequest) (*userclient.CreateAgencyResult, error) {
|
||||
@ -191,6 +290,42 @@ func (s *Service) SetAgencyJoinEnabled(ctx context.Context, actorID int64, agenc
|
||||
})
|
||||
}
|
||||
|
||||
func parseUSDTAmountMicro(raw string) (int64, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, fmt.Errorf("recharge_amount is required for usdt purchase")
|
||||
}
|
||||
parts := strings.Split(raw, ".")
|
||||
if len(parts) > 2 || parts[0] == "" {
|
||||
return 0, fmt.Errorf("recharge_amount is invalid")
|
||||
}
|
||||
whole := parts[0]
|
||||
frac := ""
|
||||
if len(parts) == 2 {
|
||||
frac = parts[1]
|
||||
if frac == "" || len(frac) > 6 {
|
||||
return 0, fmt.Errorf("recharge_amount supports up to 6 decimals")
|
||||
}
|
||||
}
|
||||
digits := whole + frac + strings.Repeat("0", 6-len(frac))
|
||||
var amount int64
|
||||
const maxInt64 = int64(^uint64(0) >> 1)
|
||||
for _, char := range digits {
|
||||
if char < '0' || char > '9' {
|
||||
return 0, fmt.Errorf("recharge_amount is invalid")
|
||||
}
|
||||
next := int64(char - '0')
|
||||
if amount > (maxInt64-next)/10 {
|
||||
return 0, fmt.Errorf("recharge_amount is too large")
|
||||
}
|
||||
amount = amount*10 + next
|
||||
}
|
||||
if amount <= 0 {
|
||||
return 0, fmt.Errorf("recharge_amount must be positive")
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func normalizeListQuery(query listQuery) listQuery {
|
||||
if query.Page < 1 {
|
||||
query.Page = 1
|
||||
@ -205,3 +340,12 @@ func normalizeListQuery(query listQuery) listQuery {
|
||||
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
|
||||
return query
|
||||
}
|
||||
|
||||
func firstNonBlank(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
200
server/admin/internal/platform/logging/logging.go
Normal file
200
server/admin/internal/platform/logging/logging.go
Normal file
@ -0,0 +1,200 @@
|
||||
// Package logging provides admin-server-local structured stdout logging.
|
||||
// It intentionally lives under server/admin/internal/platform because admin is a separate Go module.
|
||||
package logging
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLevel = "info"
|
||||
defaultFormat = "json"
|
||||
defaultMaxPayloadBytes = 2048
|
||||
redactedValue = "***REDACTED***"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
IncludeRequestBody bool `yaml:"include_request_body"`
|
||||
IncludeResponseBody bool `yaml:"include_response_body"`
|
||||
MaxPayloadBytes int `yaml:"max_payload_bytes"`
|
||||
}
|
||||
|
||||
var (
|
||||
globalMu sync.RWMutex
|
||||
globalLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo, ReplaceAttr: replaceAttr}))
|
||||
)
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{Level: defaultLevel, Format: defaultFormat, MaxPayloadBytes: defaultMaxPayloadBytes}
|
||||
}
|
||||
|
||||
func Init(service string, env string, nodeID string, cfg Config) error {
|
||||
level, err := parseLevel(cfg.Level)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
format := strings.ToLower(strings.TrimSpace(cfg.Format))
|
||||
if format == "" {
|
||||
format = defaultFormat
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
options := &slog.HandlerOptions{Level: level, ReplaceAttr: replaceAttr}
|
||||
switch format {
|
||||
case "json":
|
||||
handler = slog.NewJSONHandler(os.Stdout, options)
|
||||
case "text":
|
||||
handler = slog.NewTextHandler(os.Stdout, options)
|
||||
default:
|
||||
return fmt.Errorf("unsupported log format %q", cfg.Format)
|
||||
}
|
||||
logger := slog.New(handler).With(
|
||||
"env", nonEmpty(env, "local"),
|
||||
"service", nonEmpty(service, "admin-server"),
|
||||
"node_id", nonEmpty(nodeID, service),
|
||||
)
|
||||
|
||||
globalMu.Lock()
|
||||
globalLogger = logger
|
||||
globalMu.Unlock()
|
||||
slog.SetDefault(logger)
|
||||
log.SetFlags(0)
|
||||
log.SetOutput(stdWriter{})
|
||||
return nil
|
||||
}
|
||||
|
||||
func StdWriter() io.Writer {
|
||||
return stdWriter{}
|
||||
}
|
||||
|
||||
func GinAccessLogger() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if c.Request.URL.Path == "/healthz" || c.Request.URL.Path == "/readyz" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
startedAt := time.Now()
|
||||
c.Next()
|
||||
|
||||
attrs := []slog.Attr{
|
||||
slog.String("request_id", requestID(c)),
|
||||
slog.String("app_code", appctx.FromContext(c.Request.Context())),
|
||||
slog.String("method", c.Request.Method),
|
||||
slog.String("path", c.Request.URL.Path),
|
||||
slog.Int("status", c.Writer.Status()),
|
||||
slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
|
||||
slog.String("client_ip", c.ClientIP()),
|
||||
}
|
||||
if len(c.Errors) > 0 {
|
||||
attrs = append(attrs, slog.String("error", c.Errors.String()))
|
||||
}
|
||||
switch {
|
||||
case c.Writer.Status() >= http.StatusInternalServerError:
|
||||
logAttrs(context.Background(), slog.LevelError, "http_access", attrs...)
|
||||
case c.Writer.Status() >= http.StatusBadRequest:
|
||||
logAttrs(context.Background(), slog.LevelWarn, "http_access", attrs...)
|
||||
default:
|
||||
logAttrs(context.Background(), slog.LevelInfo, "http_access", attrs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type stdWriter struct{}
|
||||
|
||||
func (stdWriter) Write(data []byte) (int, error) {
|
||||
line := strings.TrimSpace(string(data))
|
||||
if line != "" {
|
||||
logAttrs(context.Background(), slog.LevelInfo, "stdlog", slog.String("message", redactLine(line)))
|
||||
}
|
||||
return len(data), nil
|
||||
}
|
||||
|
||||
func logAttrs(ctx context.Context, level slog.Level, msg string, attrs ...slog.Attr) {
|
||||
globalMu.RLock()
|
||||
logger := globalLogger
|
||||
globalMu.RUnlock()
|
||||
logger.LogAttrs(ctx, level, msg, attrs...)
|
||||
}
|
||||
|
||||
func requestID(c *gin.Context) string {
|
||||
if value, ok := c.Get("requestID"); ok {
|
||||
if requestID, ok := value.(string); ok {
|
||||
return requestID
|
||||
}
|
||||
}
|
||||
return c.GetHeader("X-Request-ID")
|
||||
}
|
||||
|
||||
func parseLevel(value string) (slog.Level, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "debug":
|
||||
return slog.LevelDebug, nil
|
||||
case "", "info":
|
||||
return slog.LevelInfo, nil
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn, nil
|
||||
case "error":
|
||||
return slog.LevelError, nil
|
||||
default:
|
||||
return slog.LevelInfo, fmt.Errorf("unsupported log level %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func replaceAttr(_ []string, attr slog.Attr) slog.Attr {
|
||||
if isSensitiveKey(attr.Key) {
|
||||
return slog.String(attr.Key, redactedValue)
|
||||
}
|
||||
return attr
|
||||
}
|
||||
|
||||
func isSensitiveKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
|
||||
for _, part := range []string{"password", "credential", "secret", "token", "user_sig", "usersig", "authorization", "signature", "private_key", "receipt"} {
|
||||
if strings.Contains(normalized, part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func redactLine(line string) string {
|
||||
fields := strings.Fields(line)
|
||||
changed := false
|
||||
for index, field := range fields {
|
||||
separator := strings.IndexAny(field, "=:")
|
||||
if separator <= 0 {
|
||||
continue
|
||||
}
|
||||
if isSensitiveKey(strings.Trim(field[:separator], "\"'")) {
|
||||
fields[index] = field[:separator+1] + redactedValue
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return line
|
||||
}
|
||||
return strings.Join(fields, " ")
|
||||
}
|
||||
|
||||
func nonEmpty(value string, fallback string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
@ -55,6 +55,7 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "币商查看", Code: "coin-seller:view", Kind: "menu"},
|
||||
{Name: "币商创建", Code: "coin-seller:create", Kind: "button"},
|
||||
{Name: "币商更新", Code: "coin-seller:update", Kind: "button"},
|
||||
{Name: "币商进货", Code: "coin-seller:stock-credit", Kind: "button"},
|
||||
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
|
||||
{Name: "角色查看", Code: "role:view", Kind: "menu"},
|
||||
{Name: "角色创建", Code: "role:create", Kind: "button"},
|
||||
@ -105,7 +106,7 @@ func (s *Store) seedMenus() error {
|
||||
paymentID := uint(0)
|
||||
menus := []model.Menu{
|
||||
{Title: "总览", Code: "overview", Path: "/overview", Icon: "dashboard", PermissionCode: "overview:view", Sort: 10, Visible: true},
|
||||
{Title: "系统管理", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 30, Visible: true},
|
||||
{Title: "后台设置", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 30, Visible: true},
|
||||
{Title: "日志审计", Code: "logs", Path: "", Icon: "history", PermissionCode: "", Sort: 40, Visible: true},
|
||||
{Title: "通知中心", Code: "notifications", Path: "/notifications", Icon: "notifications", PermissionCode: "notification:view", Sort: 50, Visible: true},
|
||||
{Title: "用户管理", Code: "app-users", Path: "", Icon: "users", PermissionCode: "", Sort: 60, Visible: true},
|
||||
@ -152,7 +153,7 @@ func (s *Store) seedMenus() error {
|
||||
}
|
||||
|
||||
children := []model.Menu{
|
||||
{ParentID: &systemID, Title: "用户管理", Code: "system-users", Path: "/system/users", Icon: "users", PermissionCode: "user:view", Sort: 31, Visible: true},
|
||||
{ParentID: &systemID, Title: "后台用户", Code: "system-users", Path: "/system/users", Icon: "users", PermissionCode: "user:view", Sort: 31, Visible: true},
|
||||
{ParentID: &systemID, Title: "角色配置", Code: "system-roles", Path: "/system/roles", Icon: "shield", PermissionCode: "role:view", Sort: 32, Visible: true},
|
||||
{ParentID: &systemID, Title: "菜单权限", Code: "system-menus", Path: "/system/menus", Icon: "menu", PermissionCode: "menu:view", Sort: 33, Visible: true},
|
||||
{ParentID: &logID, Title: "登录日志", Code: "logs-login", Path: "/logs/login", Icon: "login", PermissionCode: "log:view", Sort: 41, Visible: true},
|
||||
@ -409,7 +410,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"host:view",
|
||||
"agency:view", "agency:create", "agency:status",
|
||||
"bd:view", "bd:create", "bd:update",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
|
||||
"payment-bill:view",
|
||||
"log:view",
|
||||
"notification:view", "notification:read",
|
||||
@ -462,7 +463,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"upload:create",
|
||||
"country:view", "country:create", "country:update", "country:status",
|
||||
"region:view", "region:create", "region:update", "region:status",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
|
||||
"payment-bill:view",
|
||||
}
|
||||
case "auditor", "readonly":
|
||||
|
||||
@ -22,6 +22,7 @@ import (
|
||||
"hyapp-admin-server/internal/modules/roomadmin"
|
||||
"hyapp-admin-server/internal/modules/search"
|
||||
"hyapp-admin-server/internal/modules/upload"
|
||||
"hyapp-admin-server/internal/platform/logging"
|
||||
"hyapp-admin-server/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@ -51,7 +52,7 @@ type Handlers struct {
|
||||
|
||||
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
||||
engine := gin.New()
|
||||
engine.Use(middleware.RequestID(), middleware.AppCode(), gin.Logger(), gin.Recovery(), middleware.CORS(cfg))
|
||||
engine.Use(middleware.RequestID(), middleware.AppCode(), logging.GinAccessLogger(), gin.Recovery(), middleware.CORS(cfg))
|
||||
|
||||
engine.GET("/healthz", h.Health.Health)
|
||||
engine.GET("/readyz", h.Health.Ready)
|
||||
|
||||
@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/services/activity-service/internal/app"
|
||||
"hyapp/services/activity-service/internal/config"
|
||||
)
|
||||
@ -20,10 +22,13 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := logx.Init(cfg.Log.WithRuntimeFields(cfg.ServiceName, cfg.Environment, cfg.NodeID)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
application, err := app.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
fatalRuntime("app_new_failed", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
@ -39,7 +44,12 @@ func main() {
|
||||
application.Close()
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
fatalRuntime("service_run_failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fatalRuntime(msg string, err error) {
|
||||
logx.Error(context.Background(), msg, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
service_name: activity-service
|
||||
node_id: activity-docker
|
||||
environment: local
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
grpc_addr: ":13006"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
user_service_addr: "user-service:13005"
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
service_name: activity-service
|
||||
node_id: activity-tencent
|
||||
environment: prod
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
grpc_addr: ":13006"
|
||||
mysql_dsn: "${TENCENT_MYSQL_ACTIVITY_DSN}"
|
||||
user_service_addr: "user-service.internal:13005"
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
service_name: activity-service
|
||||
node_id: activity-local
|
||||
environment: local
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
grpc_addr: ":13006"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
user_service_addr: "127.0.0.1:13005"
|
||||
|
||||
@ -4,12 +4,14 @@ import (
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/configx"
|
||||
"hyapp/pkg/logx"
|
||||
)
|
||||
|
||||
// Config 描述 activity-service 启动配置。
|
||||
type Config struct {
|
||||
ServiceName string `yaml:"service_name"`
|
||||
NodeID string `yaml:"node_id"`
|
||||
Environment string `yaml:"environment"`
|
||||
GRPCAddr string `yaml:"grpc_addr"`
|
||||
// MySQLDSN 是活动状态、事件消费幂等和 outbox 的必需存储连接串。
|
||||
MySQLDSN string `yaml:"mysql_dsn"`
|
||||
@ -19,6 +21,7 @@ type Config struct {
|
||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||
Consumer ConsumerConfig `yaml:"consumer"`
|
||||
MessageInbox MessageInboxConfig `yaml:"message_inbox"`
|
||||
Log logx.Config `yaml:"log"`
|
||||
}
|
||||
|
||||
// ConsumerConfig 保存 room outbox 消费底座配置。
|
||||
@ -47,6 +50,7 @@ func Default() Config {
|
||||
return Config{
|
||||
ServiceName: "activity-service",
|
||||
NodeID: "activity-local",
|
||||
Environment: "local",
|
||||
GRPCAddr: ":13006",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
UserServiceAddr: "127.0.0.1:13005",
|
||||
@ -65,6 +69,11 @@ func Default() Config {
|
||||
MaxRetry: 8,
|
||||
},
|
||||
},
|
||||
Log: logx.Config{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
MaxPayloadBytes: 2048,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,6 +90,18 @@ func Load(path string) (Config, error) {
|
||||
if strings.TrimSpace(cfg.UserServiceAddr) == "" {
|
||||
cfg.UserServiceAddr = Default().UserServiceAddr
|
||||
}
|
||||
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
||||
if cfg.ServiceName == "" {
|
||||
cfg.ServiceName = "activity-service"
|
||||
}
|
||||
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
|
||||
if cfg.NodeID == "" {
|
||||
cfg.NodeID = cfg.ServiceName
|
||||
}
|
||||
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
|
||||
if cfg.Environment == "" {
|
||||
cfg.Environment = "local"
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/services/gateway-service/internal/app"
|
||||
"hyapp/services/gateway-service/internal/config"
|
||||
)
|
||||
@ -20,10 +22,13 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := logx.Init(cfg.Log.WithRuntimeFields(cfg.ServiceName, cfg.Environment, cfg.NodeID)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
application, err := app.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
fatalRuntime("app_new_failed", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
@ -37,11 +42,16 @@ func main() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if err := application.Close(); err != nil {
|
||||
log.Fatal(err)
|
||||
fatalRuntime("app_close_failed", err)
|
||||
}
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
fatalRuntime("service_run_failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fatalRuntime(msg string, err error) {
|
||||
logx.Error(context.Background(), msg, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@ -1,3 +1,12 @@
|
||||
service_name: gateway-service
|
||||
node_id: gateway-docker
|
||||
environment: local
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
http_addr: ":13000"
|
||||
jwt_secret: "dev-shared-secret"
|
||||
room_service_addr: "room-service:13001"
|
||||
|
||||
@ -1,3 +1,12 @@
|
||||
service_name: gateway-service
|
||||
node_id: gateway-tencent
|
||||
environment: prod
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
http_addr: ":13000"
|
||||
jwt_secret: "REPLACE_WITH_GATEWAY_JWT_SECRET"
|
||||
room_service_addr: "room-service.internal:13001"
|
||||
|
||||
@ -1,3 +1,12 @@
|
||||
service_name: gateway-service
|
||||
node_id: gateway-local
|
||||
environment: local
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
http_addr: ":13000"
|
||||
jwt_secret: "dev-shared-secret"
|
||||
room_service_addr: "127.0.0.1:13001"
|
||||
|
||||
@ -6,11 +6,15 @@ import (
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/configx"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/tencentrtc"
|
||||
)
|
||||
|
||||
// Config 描述 gateway-service 启动所需的最小配置。
|
||||
type Config struct {
|
||||
ServiceName string `yaml:"service_name"`
|
||||
NodeID string `yaml:"node_id"`
|
||||
Environment string `yaml:"environment"`
|
||||
HTTPAddr string `yaml:"http_addr"`
|
||||
JWTSecret string `yaml:"jwt_secret"`
|
||||
RoomServiceAddr string `yaml:"room_service_addr"`
|
||||
@ -22,6 +26,7 @@ type Config struct {
|
||||
TencentIM TencentIMConfig `yaml:"tencent_im"`
|
||||
TencentRTC TencentRTCConfig `yaml:"tencent_rtc"`
|
||||
TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
|
||||
Log logx.Config `yaml:"log"`
|
||||
}
|
||||
|
||||
// AppConfigConfig 描述 gateway 读取后台 App 运行时配置的只读数据源。
|
||||
@ -110,6 +115,9 @@ type TencentCOSConfig struct {
|
||||
// Default 返回本地开发默认配置。
|
||||
func Default() Config {
|
||||
return Config{
|
||||
ServiceName: "gateway-service",
|
||||
NodeID: "gateway-local",
|
||||
Environment: "local",
|
||||
HTTPAddr: ":13000",
|
||||
JWTSecret: "gateway-dev-secret",
|
||||
RoomServiceAddr: "127.0.0.1:13001",
|
||||
@ -139,6 +147,11 @@ func Default() Config {
|
||||
RoomIDType: "string",
|
||||
AppScene: "voice_chat_room",
|
||||
},
|
||||
Log: logx.Config{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
MaxPayloadBytes: 2048,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,6 +179,18 @@ func Load(path string) (Config, error) {
|
||||
|
||||
// Normalize fills policy defaults and rejects unsupported RTC room/app modes at startup.
|
||||
func (cfg *Config) Normalize() error {
|
||||
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
||||
if cfg.ServiceName == "" {
|
||||
cfg.ServiceName = "gateway-service"
|
||||
}
|
||||
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
|
||||
if cfg.NodeID == "" {
|
||||
cfg.NodeID = cfg.ServiceName
|
||||
}
|
||||
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
|
||||
if cfg.Environment == "" {
|
||||
cfg.Environment = "local"
|
||||
}
|
||||
cfg.RoomServiceAddr = strings.TrimSpace(cfg.RoomServiceAddr)
|
||||
cfg.UserServiceAddr = strings.TrimSpace(cfg.UserServiceAddr)
|
||||
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
|
||||
|
||||
@ -104,34 +104,8 @@ func writeRPCError(writer http.ResponseWriter, request *http.Request, err error)
|
||||
}
|
||||
|
||||
func mapReasonToHTTP(reason xerr.Code) (int, string, string) {
|
||||
switch reason {
|
||||
case xerr.InvalidArgument, xerr.DisplayUserIDInvalid, xerr.InvalidInviteCode, xerr.InvalidSection, xerr.PageTokenInvalid:
|
||||
return http.StatusBadRequest, string(reason), "invalid argument"
|
||||
case xerr.AuthFailed:
|
||||
return http.StatusUnauthorized, string(reason), "authentication failed"
|
||||
case xerr.Unauthorized, xerr.SessionExpired, xerr.SessionRevoked:
|
||||
return http.StatusUnauthorized, string(reason), "unauthorized"
|
||||
case xerr.Conflict, xerr.PasswordAlreadySet, xerr.DisplayUserIDExists, xerr.RequestConflict, xerr.ProducerEventConflict:
|
||||
return http.StatusConflict, string(reason), "conflict"
|
||||
case xerr.RoomClosed:
|
||||
return http.StatusConflict, string(reason), "room closed"
|
||||
case xerr.DisplayUserIDCooldown, xerr.DisplayUserIDPrettyNotAvailable, xerr.DisplayUserIDPrettyActive, xerr.DisplayUserIDLeaseExpired, xerr.DisplayUserIDPaymentRequired, xerr.CountryChangeCooldown, xerr.RegionCountryConflict, xerr.RegionDisabled:
|
||||
return http.StatusConflict, string(reason), "conflict"
|
||||
case xerr.InsufficientBalance:
|
||||
return http.StatusConflict, string(reason), "insufficient balance"
|
||||
case xerr.ProfileRequired:
|
||||
return http.StatusForbidden, codeProfileRequired, "profile required"
|
||||
case xerr.UserDisabled, xerr.PermissionDenied:
|
||||
return http.StatusForbidden, string(reason), "permission denied"
|
||||
case xerr.NotFound, xerr.DisplayUserIDNotFound, xerr.CountryNotFound, xerr.RegionNotFound, xerr.MessageNotFound, xerr.MessageRecalled:
|
||||
return http.StatusNotFound, string(reason), "not found"
|
||||
case xerr.Unavailable:
|
||||
return http.StatusBadGateway, codeUpstreamError, "upstream service error"
|
||||
case xerr.Internal:
|
||||
return http.StatusInternalServerError, codeInternalError, "internal error"
|
||||
default:
|
||||
return http.StatusBadGateway, codeUpstreamError, "upstream service error"
|
||||
}
|
||||
spec := xerr.SpecOf(reason)
|
||||
return spec.HTTPStatus, spec.PublicCode, spec.PublicMessage
|
||||
}
|
||||
|
||||
// writeEnvelope 是 gateway 业务 API 的唯一 JSON 响应出口。
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/roomid"
|
||||
"hyapp/pkg/tencentrtc"
|
||||
"hyapp/pkg/xerr"
|
||||
@ -34,6 +35,7 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
|
||||
writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
logCtx := logx.With(request.Context(), slog.String("request_id", requestIDFromContext(request.Context())), slog.String("app_code", appcode.FromContext(request.Context())))
|
||||
|
||||
rtcConfig := tencentrtc.TokenConfig{
|
||||
Enabled: h.tencentRTC.Enabled,
|
||||
@ -44,7 +46,7 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
|
||||
AppScene: h.tencentRTC.AppScene,
|
||||
}
|
||||
if err := tencentrtc.ValidateConfig(rtcConfig); err != nil {
|
||||
log.Printf("gateway_rtc_token result=config_error request_id=%s user_id=%d room_id=%s reason=%q", requestIDFromContext(request.Context()), userID, body.RoomID, err.Error())
|
||||
logx.Error(logCtx, "gateway_rtc_token_config_error", err, slog.Int64("user_id", userID), slog.String("room_id", body.RoomID))
|
||||
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
|
||||
return
|
||||
}
|
||||
@ -72,12 +74,18 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
|
||||
|
||||
token, err := tencentrtc.GenerateToken(rtcConfig, userID, body.RoomID, timeNow())
|
||||
if err != nil {
|
||||
log.Printf("gateway_rtc_token result=config_error request_id=%s user_id=%d room_id=%s reason=%q", requestIDFromContext(request.Context()), userID, body.RoomID, err.Error())
|
||||
logx.Error(logCtx, "gateway_rtc_token_sign_failed", err, slog.Int64("user_id", userID), slog.String("room_id", body.RoomID))
|
||||
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("gateway_rtc_token result=issued request_id=%s user_id=%d rtc_user_id=%s room_id=%s str_room_id=%s room_version=%d", requestIDFromContext(request.Context()), userID, token.RTCUserID, body.RoomID, token.StrRoomID, guardResp.GetRoomVersion())
|
||||
logx.Info(logCtx, "gateway_rtc_token_issued",
|
||||
slog.Int64("user_id", userID),
|
||||
slog.String("rtc_user_id", token.RTCUserID),
|
||||
slog.String("room_id", body.RoomID),
|
||||
slog.String("str_room_id", token.StrRoomID),
|
||||
slog.Int64("room_version", guardResp.GetRoomVersion()),
|
||||
)
|
||||
writeOK(writer, request, token)
|
||||
}
|
||||
|
||||
@ -89,7 +97,8 @@ func (h *Handler) writeRTCPresenceDenied(writer http.ResponseWriter, request *ht
|
||||
roomVersion = guardResp.GetRoomVersion()
|
||||
}
|
||||
|
||||
log.Printf("gateway_rtc_token result=denied request_id=%s user_id=%d room_id=%s room_version=%d reason=%q", requestIDFromContext(request.Context()), userID, targetRoomID, roomVersion, reason)
|
||||
logCtx := logx.With(request.Context(), slog.String("request_id", requestIDFromContext(request.Context())), slog.String("app_code", appcode.FromContext(request.Context())))
|
||||
logx.Warn(logCtx, "gateway_rtc_token_denied", slog.Int64("user_id", userID), slog.String("room_id", targetRoomID), slog.Int64("room_version", roomVersion), slog.String("reason", reason))
|
||||
if reason == "room_not_found" {
|
||||
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
|
||||
return
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@ -15,6 +15,7 @@ import (
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/tencentrtc"
|
||||
"hyapp/pkg/xerr"
|
||||
)
|
||||
@ -86,8 +87,16 @@ func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *
|
||||
roomID, ok := parseTencentRTCRoomID(body.EventInfo.RoomID)
|
||||
userID, userOK := parseTencentIMUserID(body.EventInfo.UserID)
|
||||
eventTimeMS := tencentRTCEventTimeMS(body)
|
||||
logCtx := logx.With(request.Context(), slog.String("request_id", requestIDFromContext(request.Context())), slog.String("app_code", tencentRTCCallbackAppCode(request)))
|
||||
if !ok || !userOK || eventTimeMS <= 0 || h.roomClient == nil {
|
||||
log.Printf("gateway_rtc_callback result=ignored request_id=%s event_group=%d event_type=%d room_id=%q user_id=%q reason=input_invalid", requestIDFromContext(request.Context()), body.EventGroupID, body.EventType, roomID, body.EventInfo.UserID)
|
||||
logx.Warn(logCtx, "gateway_rtc_callback_ignored",
|
||||
slog.String("provider", "tencent"),
|
||||
slog.Int("event_group", body.EventGroupID),
|
||||
slog.Int("event_type", body.EventType),
|
||||
slog.String("room_id", roomID),
|
||||
slog.String("external_user_id", body.EventInfo.UserID),
|
||||
slog.String("reason", "input_invalid"),
|
||||
)
|
||||
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
|
||||
return
|
||||
}
|
||||
@ -112,16 +121,37 @@ func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *
|
||||
})
|
||||
if err != nil {
|
||||
if tencentRTCCallbackCanAckBusinessError(err) {
|
||||
log.Printf("gateway_rtc_callback result=business_ignored request_id=%s room_id=%s user_id=%d event_group=%d event_type=%d reason=%s", requestIDFromContext(request.Context()), roomID, userID, body.EventGroupID, body.EventType, xerr.ReasonFromGRPC(err))
|
||||
logx.Warn(logCtx, "gateway_rtc_callback_business_ignored",
|
||||
slog.String("provider", "tencent"),
|
||||
slog.String("room_id", roomID),
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int("event_group", body.EventGroupID),
|
||||
slog.Int("event_type", body.EventType),
|
||||
slog.String("reason", string(xerr.ReasonFromGRPC(err))),
|
||||
)
|
||||
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
|
||||
return
|
||||
}
|
||||
log.Printf("gateway_rtc_callback result=room_service_error request_id=%s room_id=%s user_id=%d event_group=%d event_type=%d error=%q", requestIDFromContext(request.Context()), roomID, userID, body.EventGroupID, body.EventType, err.Error())
|
||||
logx.Error(logCtx, "gateway_rtc_callback_room_service_error", err,
|
||||
slog.String("provider", "tencent"),
|
||||
slog.String("room_id", roomID),
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int("event_group", body.EventGroupID),
|
||||
slog.Int("event_type", body.EventType),
|
||||
)
|
||||
writeTencentRTCCallback(writer, http.StatusInternalServerError, 1, "room service error")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("gateway_rtc_callback result=accepted request_id=%s room_id=%s user_id=%d event_group=%d event_type=%d room_version=%d applied=%t", requestIDFromContext(request.Context()), roomID, userID, body.EventGroupID, body.EventType, resp.GetResult().GetRoomVersion(), resp.GetResult().GetApplied())
|
||||
logx.Info(logCtx, "gateway_rtc_callback_accepted",
|
||||
slog.String("provider", "tencent"),
|
||||
slog.String("room_id", roomID),
|
||||
slog.Int64("user_id", userID),
|
||||
slog.Int("event_group", body.EventGroupID),
|
||||
slog.Int("event_type", body.EventType),
|
||||
slog.Int64("room_version", resp.GetResult().GetRoomVersion()),
|
||||
slog.Bool("applied", resp.GetResult().GetApplied()),
|
||||
)
|
||||
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
|
||||
}
|
||||
|
||||
|
||||
@ -5,9 +5,11 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/services/room-service/internal/app"
|
||||
"hyapp/services/room-service/internal/config"
|
||||
)
|
||||
@ -23,11 +25,14 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := logx.Init(cfg.Log.WithRuntimeFields(cfg.ServiceName, cfg.Environment, cfg.NodeID)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// App.New 会建立 MySQL repository、Redis directory、wallet/im gRPC client 和 gRPC server。
|
||||
application, err := app.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
fatalRuntime("app_new_failed", err)
|
||||
}
|
||||
|
||||
// SIGINT/SIGTERM 是正常下线入口,下线时要先进入 draining 再停止 gRPC。
|
||||
@ -47,7 +52,12 @@ func main() {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
// gRPC listener 异常退出属于进程级失败,必须暴露给 supervisor。
|
||||
log.Fatal(err)
|
||||
fatalRuntime("service_run_failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fatalRuntime(msg string, err error) {
|
||||
logx.Error(context.Background(), msg, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
service_name: room-service
|
||||
environment: local
|
||||
node_id: "room-node-1"
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
grpc_addr: ":13001"
|
||||
lease_ttl: "10s"
|
||||
rank_limit: 20
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
service_name: room-service
|
||||
environment: prod
|
||||
node_id: "room-node-prod-1"
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
grpc_addr: ":13001"
|
||||
lease_ttl: "10s"
|
||||
rank_limit: 20
|
||||
|
||||
@ -1,4 +1,12 @@
|
||||
service_name: room-service
|
||||
environment: local
|
||||
node_id: "room-node-1"
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
lease_ttl: "10s"
|
||||
rank_limit: 20
|
||||
snapshot_every_n: 1
|
||||
|
||||
@ -8,11 +8,16 @@ import (
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/configx"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/tencentim"
|
||||
)
|
||||
|
||||
// Config 描述 room-service 进程启动所需的最小配置。
|
||||
type Config struct {
|
||||
// ServiceName 是日志和部署识别使用的稳定服务名。
|
||||
ServiceName string `yaml:"service_name"`
|
||||
// Environment 是 local/dev/staging/prod 等运行环境标签。
|
||||
Environment string `yaml:"environment"`
|
||||
// NodeID 是当前 room-service 实例标识,Redis lease owner 使用它。
|
||||
NodeID string `yaml:"node_id"`
|
||||
// GRPCAddr 是内部 gRPC 监听地址,gateway-service 通过它访问 room-service。
|
||||
@ -51,6 +56,8 @@ type Config struct {
|
||||
MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"`
|
||||
// OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。
|
||||
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
||||
// Log 控制 stdout 结构化日志格式、等级和 access body 策略。
|
||||
Log logx.Config `yaml:"log"`
|
||||
}
|
||||
|
||||
// OutboxWorkerConfig 控制 room_outbox pending 事件的补偿投递循环。
|
||||
@ -110,6 +117,8 @@ func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig {
|
||||
func Default() Config {
|
||||
// 默认端口遵守项目 13xxx 约束,MySQL/Redis 指向本地 Docker 暴露端口。
|
||||
return Config{
|
||||
ServiceName: "room-service",
|
||||
Environment: "local",
|
||||
NodeID: "room-node-local",
|
||||
GRPCAddr: ":13001",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
@ -135,6 +144,11 @@ func Default() Config {
|
||||
MicPublishTimeout: 15 * time.Second,
|
||||
MicPublishScanInterval: time.Second,
|
||||
OutboxWorker: defaultOutboxWorkerConfig(),
|
||||
Log: logx.Config{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
MaxPayloadBytes: 2048,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -152,6 +166,18 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig {
|
||||
// Normalize 补齐动态配置默认值,并拒绝会改变补偿语义的未知策略。
|
||||
func Normalize(cfg Config) (Config, error) {
|
||||
// 配置归一化只处理需要跨环境保持语义的字段,其他默认值由 Default 提供。
|
||||
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
||||
if cfg.ServiceName == "" {
|
||||
cfg.ServiceName = "room-service"
|
||||
}
|
||||
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
|
||||
if cfg.Environment == "" {
|
||||
cfg.Environment = "local"
|
||||
}
|
||||
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
|
||||
if cfg.NodeID == "" {
|
||||
cfg.NodeID = cfg.ServiceName
|
||||
}
|
||||
outboxWorker, err := normalizeOutboxWorkerConfig(cfg.OutboxWorker)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
|
||||
@ -2,10 +2,11 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
@ -92,7 +93,11 @@ func (s *Service) projectRoomPresenceBestEffort(ctx context.Context, snapshot *r
|
||||
return
|
||||
}
|
||||
if err := s.repository.ProjectRoomPresence(ctx, projection); err != nil {
|
||||
log.Printf("component=room_presence_projector room_id=%s version=%d status=project_failed error=%q", projection.RoomID, projection.RoomVersion, err.Error())
|
||||
logx.Error(ctx, "room_presence_project_failed", err,
|
||||
slog.String("component", "room_presence_projector"),
|
||||
slog.String("room_id", projection.RoomID),
|
||||
slog.Int64("room_version", projection.RoomVersion),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,11 +4,12 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/xerr"
|
||||
)
|
||||
|
||||
@ -102,11 +103,11 @@ func (s *Service) projectRoomListBestEffort(ctx context.Context, snapshot *roomv
|
||||
meta, exists, err := s.repository.GetRoomMeta(ctx, snapshot.GetRoomId())
|
||||
if err != nil {
|
||||
// 投影不能回滚已提交 Room Cell 命令,先记录日志;后续可用 projection task 补偿。
|
||||
log.Printf("component=room_list_projector room_id=%s status=load_meta_failed error=%q", snapshot.GetRoomId(), err.Error())
|
||||
logx.Error(ctx, "room_list_load_meta_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()))
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("component=room_list_projector room_id=%s status=meta_missing", snapshot.GetRoomId())
|
||||
logx.Warn(ctx, "room_list_meta_missing", slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()))
|
||||
return
|
||||
}
|
||||
|
||||
@ -114,7 +115,7 @@ func (s *Service) projectRoomListBestEffort(ctx context.Context, snapshot *roomv
|
||||
entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS)
|
||||
if err := s.repository.UpsertRoomListEntry(ctx, entry); err != nil {
|
||||
// 列表读模型是最终一致;失败不改变命令成功语义。
|
||||
log.Printf("component=room_list_projector room_id=%s region_id=%d status=upsert_failed error=%q", snapshot.GetRoomId(), meta.VisibleRegionID, err.Error())
|
||||
logx.Error(ctx, "room_list_upsert_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()), slog.Int64("region_id", meta.VisibleRegionID))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,11 +3,12 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -68,12 +69,12 @@ func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptio
|
||||
options = normalizeOutboxWorkerOptions(options)
|
||||
if options.RetryStrategy != outboxRetryFixedInterval {
|
||||
// 配置层会拒绝未知策略;这里兜底避免测试或手写构造静默改变语义。
|
||||
log.Printf("worker=%s node_id=%s status=stopped error=%q", outboxWorkerName, s.nodeID, "unsupported retry_strategy")
|
||||
logx.Error(ctx, "worker_stopped", fmt.Errorf("unsupported retry_strategy"), slog.String("worker", outboxWorkerName), slog.String("node_id", s.nodeID))
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.ProcessPendingOutbox(ctx, options); err != nil && ctx.Err() == nil {
|
||||
log.Printf("worker=%s node_id=%s status=batch_failed error=%q", outboxWorkerName, s.nodeID, trimOutboxError(err.Error()))
|
||||
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", outboxWorkerName), slog.String("node_id", s.nodeID), slog.Int("batch_size", options.BatchSize))
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(options.PollInterval)
|
||||
@ -85,7 +86,7 @@ func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptio
|
||||
return
|
||||
case <-ticker.C:
|
||||
if err := s.ProcessPendingOutbox(ctx, options); err != nil && ctx.Err() == nil {
|
||||
log.Printf("worker=%s node_id=%s status=batch_failed error=%q", outboxWorkerName, s.nodeID, trimOutboxError(err.Error()))
|
||||
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", outboxWorkerName), slog.String("node_id", s.nodeID), slog.Int("batch_size", options.BatchSize))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -123,7 +124,15 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
|
||||
return markErr
|
||||
}
|
||||
|
||||
log.Printf("worker=%s node_id=%s event_id=%s event_type=%s room_id=%s retry_count=%d status=retryable error=%q", outboxWorkerName, s.nodeID, record.EventID, record.EventType, record.RoomID, record.RetryCount+1, trimOutboxError(err.Error()))
|
||||
logx.Warn(ctx, "outbox_publish_retryable",
|
||||
slog.String("worker", outboxWorkerName),
|
||||
slog.String("node_id", s.nodeID),
|
||||
slog.String("event_id", record.EventID),
|
||||
slog.String("event_type", record.EventType),
|
||||
slog.String("room_id", record.RoomID),
|
||||
slog.Int("retry_count", record.RetryCount+1),
|
||||
slog.String("error", trimOutboxError(err.Error())),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -135,7 +144,14 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
|
||||
return markErr
|
||||
}
|
||||
|
||||
log.Printf("worker=%s node_id=%s event_id=%s event_type=%s room_id=%s retry_count=%d status=delivered", outboxWorkerName, s.nodeID, record.EventID, record.EventType, record.RoomID, record.RetryCount)
|
||||
logx.Info(ctx, "outbox_delivered",
|
||||
slog.String("worker", outboxWorkerName),
|
||||
slog.String("node_id", s.nodeID),
|
||||
slog.String("event_id", record.EventID),
|
||||
slog.String("event_type", record.EventType),
|
||||
slog.String("room_id", record.RoomID),
|
||||
slog.Int("retry_count", record.RetryCount),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@ -3,10 +3,11 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"hyapp/services/room-service/internal/room/outbox"
|
||||
@ -145,7 +146,12 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
markErr := s.repository.MarkOutboxDelivered(markCtx, result.syncEvent.EventID)
|
||||
cancel()
|
||||
if markErr != nil {
|
||||
log.Printf("worker=room_outbox node_id=%s event_id=%s room_id=%s status=mark_delivered_failed error=%q", s.nodeID, result.syncEvent.EventID, result.syncEvent.RoomID, trimOutboxError(markErr.Error()))
|
||||
logx.Error(ctx, "outbox_mark_delivered_failed", markErr,
|
||||
slog.String("worker", outboxWorkerName),
|
||||
slog.String("node_id", s.nodeID),
|
||||
slog.String("event_id", result.syncEvent.EventID),
|
||||
slog.String("room_id", result.syncEvent.RoomID),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,9 +5,11 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/services/user-service/internal/app"
|
||||
"hyapp/services/user-service/internal/config"
|
||||
)
|
||||
@ -23,11 +25,14 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := logx.Init(cfg.Log.WithRuntimeFields(cfg.ServiceName, cfg.Environment, cfg.NodeID)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// App.New 会建立 MySQL repository、创建 auth/user service,并注册 gRPC 服务。
|
||||
application, err := app.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
fatalRuntime("app_new_failed", err)
|
||||
}
|
||||
|
||||
// SIGINT/SIGTERM 是正常下线入口,Close 内部会进入 draining 并 GracefulStop。
|
||||
@ -47,7 +52,12 @@ func main() {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
// listener 异常退出属于进程级失败,必须暴露给 supervisor。
|
||||
log.Fatal(err)
|
||||
fatalRuntime("service_run_failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fatalRuntime(msg string, err error) {
|
||||
logx.Error(context.Background(), msg, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
service_name: user-service
|
||||
node_id: user-docker
|
||||
environment: local
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
grpc_addr: ":13005"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
@ -30,6 +37,11 @@ mic_time_worker:
|
||||
room_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
compensation_enabled: true
|
||||
compensation_interval_sec: 60
|
||||
compensation_batch_size: 100
|
||||
pending_publish_max_age_sec: 120
|
||||
publishing_session_max_age_sec: 43200
|
||||
jwt:
|
||||
issuer: "hyapp"
|
||||
access_token_ttl_sec: 1800
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
service_name: user-service
|
||||
node_id: user-tencent
|
||||
environment: prod
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
grpc_addr: ":13005"
|
||||
mysql_dsn: "${TENCENT_MYSQL_USER_DSN}"
|
||||
mysql_auto_migrate: false
|
||||
@ -30,6 +37,11 @@ mic_time_worker:
|
||||
room_mysql_dsn: "${TENCENT_MYSQL_ROOM_DSN}"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
compensation_enabled: true
|
||||
compensation_interval_sec: 60
|
||||
compensation_batch_size: 100
|
||||
pending_publish_max_age_sec: 120
|
||||
publishing_session_max_age_sec: 43200
|
||||
jwt:
|
||||
issuer: "hyapp"
|
||||
access_token_ttl_sec: 1800
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
service_name: user-service
|
||||
node_id: user-local
|
||||
environment: local
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
grpc_addr: ":13005"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
@ -30,6 +37,11 @@ mic_time_worker:
|
||||
room_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
poll_interval_sec: 5
|
||||
batch_size: 100
|
||||
compensation_enabled: true
|
||||
compensation_interval_sec: 60
|
||||
compensation_batch_size: 100
|
||||
pending_publish_max_age_sec: 120
|
||||
publishing_session_max_age_sec: 43200
|
||||
jwt:
|
||||
issuer: "hyapp"
|
||||
access_token_ttl_sec: 1800
|
||||
|
||||
@ -415,6 +415,7 @@ CREATE TABLE IF NOT EXISTS coin_seller_profiles (
|
||||
user_id BIGINT NOT NULL PRIMARY KEY,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
merchant_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN_SELLER_COIN',
|
||||
contact_info VARCHAR(128) NOT NULL DEFAULT '',
|
||||
created_by_user_id BIGINT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
|
||||
@ -249,6 +249,19 @@ func (a *App) Run() error {
|
||||
})
|
||||
}()
|
||||
}
|
||||
if a.cfg.MicTimeWorker.CompensationEnabled && a.micTimeSvc != nil {
|
||||
a.workerWG.Add(1)
|
||||
go func() {
|
||||
defer a.workerWG.Done()
|
||||
a.micTimeSvc.RunOpenSessionCompensationWorker(a.workerCtx, mictimeservice.CompensationWorkerOptions{
|
||||
WorkerID: a.cfg.NodeID,
|
||||
PollInterval: time.Duration(a.cfg.MicTimeWorker.CompensationIntervalSec) * time.Second,
|
||||
BatchSize: a.cfg.MicTimeWorker.CompensationBatchSize,
|
||||
PendingPublishMaxAge: time.Duration(a.cfg.MicTimeWorker.PendingPublishMaxAgeSec) * time.Second,
|
||||
PublishingSessionMaxAge: time.Duration(a.cfg.MicTimeWorker.PublishingSessionMaxAgeSec) * time.Second,
|
||||
})
|
||||
}()
|
||||
}
|
||||
err := a.server.Serve(a.listener)
|
||||
a.health.MarkStopped()
|
||||
if errors.Is(err, grpc.ErrServerStopped) {
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
// Package config 定义 user-service 的启动配置和 YAML 加载入口。
|
||||
package config
|
||||
|
||||
import "hyapp/pkg/configx"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/configx"
|
||||
"hyapp/pkg/logx"
|
||||
)
|
||||
|
||||
// Config 描述 user-service 启动配置。
|
||||
type Config struct {
|
||||
@ -9,6 +14,8 @@ type Config struct {
|
||||
ServiceName string `yaml:"service_name"`
|
||||
// NodeID 是当前服务节点标识,预留给日志和后续健康响应使用。
|
||||
NodeID string `yaml:"node_id"`
|
||||
// Environment 是日志和部署策略使用的环境标签。
|
||||
Environment string `yaml:"environment"`
|
||||
// GRPCAddr 是 user-service 内部 gRPC 监听地址。
|
||||
GRPCAddr string `yaml:"grpc_addr"`
|
||||
// MySQLDSN 是用户主数据、认证身份、session 和短号事务的必需存储连接串。
|
||||
@ -29,6 +36,8 @@ type Config struct {
|
||||
MicTimeWorker MicTimeWorkerConfig `yaml:"mic_time_worker"`
|
||||
// JWT 控制 access token 和 refresh session 的签发参数。
|
||||
JWT JWTConfig `yaml:"jwt"`
|
||||
// Log 控制 stdout 结构化日志格式、等级和 access body 策略。
|
||||
Log logx.Config `yaml:"log"`
|
||||
}
|
||||
|
||||
// IDGeneratorConfig 保存 user_id 发号节点配置。
|
||||
@ -97,6 +106,16 @@ type MicTimeWorkerConfig struct {
|
||||
PollIntervalSec int64 `yaml:"poll_interval_sec"`
|
||||
// BatchSize 是单轮最多消费的 RoomMicChanged 事件数。
|
||||
BatchSize int `yaml:"batch_size"`
|
||||
// CompensationEnabled 控制是否启动异常 open session 补偿。
|
||||
CompensationEnabled bool `yaml:"compensation_enabled"`
|
||||
// CompensationIntervalSec 是补偿扫描间隔秒数。
|
||||
CompensationIntervalSec int64 `yaml:"compensation_interval_sec"`
|
||||
// CompensationBatchSize 是单轮最多关闭的异常 session 数。
|
||||
CompensationBatchSize int `yaml:"compensation_batch_size"`
|
||||
// PendingPublishMaxAgeSec 是 pending_publish 缺失 down 事件时的兜底关闭窗口。
|
||||
PendingPublishMaxAgeSec int64 `yaml:"pending_publish_max_age_sec"`
|
||||
// PublishingSessionMaxAgeSec 是 publishing 缺失 down 事件时允许累计的最大窗口。
|
||||
PublishingSessionMaxAgeSec int64 `yaml:"publishing_session_max_age_sec"`
|
||||
}
|
||||
|
||||
// JWTConfig 保存 access token 和 refresh token 的签发策略。
|
||||
@ -119,6 +138,7 @@ func Default() Config {
|
||||
return Config{
|
||||
ServiceName: "user-service",
|
||||
NodeID: "user-local",
|
||||
Environment: "local",
|
||||
GRPCAddr: ":13005",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLAutoMigrate: false,
|
||||
@ -148,10 +168,15 @@ func Default() Config {
|
||||
BatchSize: 100,
|
||||
},
|
||||
MicTimeWorker: MicTimeWorkerConfig{
|
||||
Enabled: true,
|
||||
RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
PollIntervalSec: 5,
|
||||
BatchSize: 100,
|
||||
Enabled: true,
|
||||
RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
PollIntervalSec: 5,
|
||||
BatchSize: 100,
|
||||
CompensationEnabled: true,
|
||||
CompensationIntervalSec: 60,
|
||||
CompensationBatchSize: 100,
|
||||
PendingPublishMaxAgeSec: 120,
|
||||
PublishingSessionMaxAgeSec: 43200,
|
||||
},
|
||||
JWT: JWTConfig{
|
||||
Issuer: "hyapp",
|
||||
@ -160,6 +185,11 @@ func Default() Config {
|
||||
SigningAlg: "HS256",
|
||||
SigningSecret: "dev-shared-secret",
|
||||
},
|
||||
Log: logx.Config{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
MaxPayloadBytes: 2048,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -176,5 +206,18 @@ func Load(path string) (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
||||
if cfg.ServiceName == "" {
|
||||
cfg.ServiceName = "user-service"
|
||||
}
|
||||
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
|
||||
if cfg.NodeID == "" {
|
||||
cfg.NodeID = cfg.ServiceName
|
||||
}
|
||||
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
|
||||
if cfg.Environment == "" {
|
||||
cfg.Environment = "local"
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@ -27,6 +27,18 @@ const (
|
||||
SessionStatusPublishing = "publishing"
|
||||
// SessionStatusEnded is terminal and must never be reopened by replayed events.
|
||||
SessionStatusEnded = "ended"
|
||||
|
||||
// EventTypeUserMicSessionClosed is emitted after a session is closed and aggregates are durable.
|
||||
EventTypeUserMicSessionClosed = "UserMicSessionClosed"
|
||||
// EventTypeUserMicDailyStatsUpdated is emitted for each UTC day touched by a closed session.
|
||||
EventTypeUserMicDailyStatsUpdated = "UserMicDailyStatsUpdated"
|
||||
// EventTypeUserMicLifetimeStatsUpdated is emitted when lifetime totals receive one session delta.
|
||||
EventTypeUserMicLifetimeStatsUpdated = "UserMicLifetimeStatsUpdated"
|
||||
|
||||
// CompensationReasonPendingPublishTimeout closes pending sessions whose expected room-service timeout event never arrived.
|
||||
CompensationReasonPendingPublishTimeout = "compensated_publish_timeout"
|
||||
// CompensationReasonPublishingMaxWindow closes publishing sessions at a configured safety ceiling.
|
||||
CompensationReasonPublishingMaxWindow = "compensated_publishing_max_window"
|
||||
)
|
||||
|
||||
// RoomMicEvent is the decoded RoomMicChanged fact used by user-service duration aggregation.
|
||||
@ -64,6 +76,21 @@ type ApplyResult struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
// CompensationOptions bounds abnormal open sessions so missing down events cannot accumulate forever.
|
||||
type CompensationOptions struct {
|
||||
NowMs int64
|
||||
BatchSize int
|
||||
PendingPublishMaxAgeMs int64
|
||||
PublishingSessionMaxAgeMs int64
|
||||
}
|
||||
|
||||
// CompensationResult summarizes one compensation scan.
|
||||
type CompensationResult struct {
|
||||
ClosedCount int
|
||||
PendingPublishClosed int
|
||||
PublishingSessionClosed int
|
||||
}
|
||||
|
||||
// LifetimeStats is the user-wide aggregate consumed by host tasks and profile views.
|
||||
type LifetimeStats struct {
|
||||
AppCode string
|
||||
|
||||
@ -2,13 +2,14 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
"unicode/utf8"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/xerr"
|
||||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
@ -74,7 +75,7 @@ func (s *Service) LoginThirdParty(ctx context.Context, provider string, credenti
|
||||
}
|
||||
if !xerr.IsCode(err, xerr.NotFound) {
|
||||
// 非 not found 的存储错误不能退化为注册。
|
||||
log.Printf("component=user_auth op=third_party_lookup request_id=%s provider=%s status=failed error=%q", meta.RequestID, provider, err.Error())
|
||||
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_lookup_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
|
||||
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
|
||||
return authdomain.Token{}, false, err
|
||||
}
|
||||
@ -178,7 +179,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
|
||||
}
|
||||
|
||||
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
|
||||
log.Printf("component=user_auth op=third_party_register request_id=%s provider=%s status=failed error=%q", meta.RequestID, provider, err.Error())
|
||||
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_register_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
|
||||
return authdomain.Token{}, false, err
|
||||
}
|
||||
|
||||
|
||||
@ -3,9 +3,10 @@ package invite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/logx"
|
||||
invitedomain "hyapp/services/user-service/internal/domain/invite"
|
||||
)
|
||||
|
||||
@ -40,7 +41,7 @@ func New(repository Repository, reader WalletOutboxReader) *Service {
|
||||
// RunWalletRechargeWorker consumes WalletRechargeRecorded facts and advances valid_invite_count.
|
||||
func (s *Service) RunWalletRechargeWorker(ctx context.Context, options WorkerOptions) {
|
||||
if s == nil || s.repository == nil || s.reader == nil {
|
||||
log.Printf("component=user_invite op=wallet_recharge_worker status=disabled reason=missing_dependency")
|
||||
logx.Warn(ctx, "worker_disabled", slog.String("worker", "wallet_recharge"), slog.String("component", "user_invite"), slog.String("reason", "missing_dependency"))
|
||||
return
|
||||
}
|
||||
if options.PollInterval <= 0 {
|
||||
@ -54,7 +55,7 @@ func (s *Service) RunWalletRechargeWorker(ctx context.Context, options WorkerOpt
|
||||
for {
|
||||
processed, err := s.processBatch(ctx, &cursor, options.BatchSize)
|
||||
if err != nil {
|
||||
log.Printf("component=user_invite op=wallet_recharge_worker worker_id=%s status=failed error=%q", options.WorkerID, err.Error())
|
||||
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", "wallet_recharge"), slog.String("component", "user_invite"), slog.String("worker_id", options.WorkerID), slog.Int("batch_size", options.BatchSize))
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
|
||||
@ -3,9 +3,10 @@ package mictime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/xerr"
|
||||
mictimedomain "hyapp/services/user-service/internal/domain/mictime"
|
||||
)
|
||||
@ -13,6 +14,7 @@ import (
|
||||
// Repository applies room mic events and returns user duration aggregates.
|
||||
type Repository interface {
|
||||
ProcessRoomMicEvent(ctx context.Context, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error)
|
||||
CompensateOpenSessions(ctx context.Context, options mictimedomain.CompensationOptions) (mictimedomain.CompensationResult, error)
|
||||
GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error)
|
||||
}
|
||||
|
||||
@ -34,6 +36,15 @@ type WorkerOptions struct {
|
||||
BatchSize int
|
||||
}
|
||||
|
||||
// CompensationWorkerOptions controls abnormal open session closure.
|
||||
type CompensationWorkerOptions struct {
|
||||
WorkerID string
|
||||
PollInterval time.Duration
|
||||
BatchSize int
|
||||
PendingPublishMaxAge time.Duration
|
||||
PublishingSessionMaxAge time.Duration
|
||||
}
|
||||
|
||||
// New creates a mic time service with persistence and optional room outbox reader.
|
||||
func New(repository Repository, reader RoomOutboxReader) *Service {
|
||||
return &Service{repository: repository, reader: reader}
|
||||
@ -50,7 +61,7 @@ func (s *Service) GetLifetimeStats(ctx context.Context, appCode string, userID i
|
||||
// RunRoomMicWorker consumes RoomMicChanged facts from room-service outbox.
|
||||
func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) {
|
||||
if s == nil || s.repository == nil || s.reader == nil {
|
||||
log.Printf("component=user_mic_time op=room_mic_worker status=disabled reason=missing_dependency")
|
||||
logx.Warn(ctx, "worker_disabled", slog.String("worker", "room_mic"), slog.String("component", "user_mic_time"), slog.String("reason", "missing_dependency"))
|
||||
return
|
||||
}
|
||||
if options.PollInterval <= 0 {
|
||||
@ -64,7 +75,7 @@ func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) {
|
||||
for {
|
||||
processed, err := s.processBatch(ctx, &cursor, options.BatchSize)
|
||||
if err != nil {
|
||||
log.Printf("component=user_mic_time op=room_mic_worker worker_id=%s status=failed error=%q", options.WorkerID, err.Error())
|
||||
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", "room_mic"), slog.String("component", "user_mic_time"), slog.String("worker_id", options.WorkerID), slog.Int("batch_size", options.BatchSize))
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
@ -81,6 +92,45 @@ func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) {
|
||||
}
|
||||
}
|
||||
|
||||
// RunOpenSessionCompensationWorker closes stale open sessions when room-service down events are missing.
|
||||
func (s *Service) RunOpenSessionCompensationWorker(ctx context.Context, options CompensationWorkerOptions) {
|
||||
if s == nil || s.repository == nil {
|
||||
logx.Warn(ctx, "worker_disabled", slog.String("worker", "open_session_compensation"), slog.String("component", "user_mic_time"), slog.String("reason", "missing_dependency"))
|
||||
return
|
||||
}
|
||||
if options.PollInterval <= 0 {
|
||||
options.PollInterval = time.Minute
|
||||
}
|
||||
if options.BatchSize <= 0 {
|
||||
options.BatchSize = 100
|
||||
}
|
||||
if options.PendingPublishMaxAge <= 0 {
|
||||
options.PendingPublishMaxAge = 2 * time.Minute
|
||||
}
|
||||
if options.PublishingSessionMaxAge <= 0 {
|
||||
options.PublishingSessionMaxAge = 12 * time.Hour
|
||||
}
|
||||
|
||||
for {
|
||||
closed, err := s.compensateBatch(ctx, options)
|
||||
if err != nil {
|
||||
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", "open_session_compensation"), slog.String("component", "user_mic_time"), slog.String("worker_id", options.WorkerID), slog.Int("batch_size", options.BatchSize))
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if closed >= options.BatchSize {
|
||||
// A full batch means backlog exists; continue immediately to keep abnormal sessions bounded.
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(options.PollInterval):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) processBatch(ctx context.Context, cursor *mictimedomain.RoomOutboxCursor, batchSize int) (int, error) {
|
||||
events, err := s.reader.ListRoomMicEventsAfter(ctx, *cursor, batchSize)
|
||||
if err != nil {
|
||||
@ -95,3 +145,16 @@ func (s *Service) processBatch(ctx context.Context, cursor *mictimedomain.RoomOu
|
||||
}
|
||||
return len(events), nil
|
||||
}
|
||||
|
||||
func (s *Service) compensateBatch(ctx context.Context, options CompensationWorkerOptions) (int, error) {
|
||||
result, err := s.repository.CompensateOpenSessions(ctx, mictimedomain.CompensationOptions{
|
||||
NowMs: time.Now().UnixMilli(),
|
||||
BatchSize: options.BatchSize,
|
||||
PendingPublishMaxAgeMs: options.PendingPublishMaxAge.Milliseconds(),
|
||||
PublishingSessionMaxAgeMs: options.PublishingSessionMaxAge.Milliseconds(),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.ClosedCount, nil
|
||||
}
|
||||
|
||||
@ -2,10 +2,11 @@ package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/logx"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
)
|
||||
|
||||
@ -49,7 +50,7 @@ func normalizeRegionRebuildWorkerOptions(options RegionRebuildWorkerOptions) Reg
|
||||
func (s *Service) RunRegionRebuildWorker(ctx context.Context, options RegionRebuildWorkerOptions) {
|
||||
options = normalizeRegionRebuildWorkerOptions(options)
|
||||
if s.regionRebuildRepository == nil {
|
||||
log.Printf("worker=user_region_rebuild status=stopped reason=%q", "repository is not configured")
|
||||
logx.Warn(ctx, "worker_disabled", slog.String("worker", "user_region_rebuild"), slog.String("reason", "repository is not configured"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -76,13 +77,22 @@ func (s *Service) ProcessNextRegionRebuildTask(ctx context.Context, options Regi
|
||||
|
||||
result, err := s.regionRebuildRepository.ProcessNextRegionRebuildTask(ctx, options.WorkerID, s.now().UnixMilli(), options.LockTTL, options.BatchSize)
|
||||
if err != nil {
|
||||
log.Printf("worker=user_region_rebuild worker_id=%s status=failed error=%q", options.WorkerID, err.Error())
|
||||
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", "user_region_rebuild"), slog.String("worker_id", options.WorkerID), slog.Int("batch_size", options.BatchSize))
|
||||
return result, err
|
||||
}
|
||||
if result.NoTask {
|
||||
return result, nil
|
||||
}
|
||||
log.Printf("worker=user_region_rebuild worker_id=%s task_id=%d country=%s target_region_id=%d status=%s processed_users=%d cursor_user_id=%d", options.WorkerID, result.TaskID, result.CountryCode, result.TargetRegionID, result.Status, result.ProcessedUsers, result.CursorUserID)
|
||||
logx.Info(ctx, "worker_batch_processed",
|
||||
slog.String("worker", "user_region_rebuild"),
|
||||
slog.String("worker_id", options.WorkerID),
|
||||
slog.Int64("task_id", result.TaskID),
|
||||
slog.String("country", result.CountryCode),
|
||||
slog.Int64("target_region_id", result.TargetRegionID),
|
||||
slog.String("status", result.Status),
|
||||
slog.Int("processed_users", result.ProcessedUsers),
|
||||
slog.Int64("cursor_user_id", result.CursorUserID),
|
||||
)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
@ -4,7 +4,9 @@ package mictime
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -19,6 +21,13 @@ type Repository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
const (
|
||||
defaultCompensationBatchSize = 100
|
||||
defaultPendingPublishCompensationMaxAgeMs = int64(2 * 60 * 1000)
|
||||
defaultPublishingCompensationMaxAgeMs = int64(12 * 60 * 60 * 1000)
|
||||
userOutboxStatusPending = "pending"
|
||||
)
|
||||
|
||||
// New creates a mic duration repository backed by the shared user-service connection pool.
|
||||
func New(db *sql.DB) *Repository {
|
||||
return &Repository{db: db}
|
||||
@ -121,6 +130,83 @@ func (r *Repository) GetLifetimeStats(ctx context.Context, appCode string, userI
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// CompensateOpenSessions closes abnormal sessions whose authoritative down event never reached user-service.
|
||||
func (r *Repository) CompensateOpenSessions(ctx context.Context, options mictimedomain.CompensationOptions) (mictimedomain.CompensationResult, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return mictimedomain.CompensationResult{}, xerr.New(xerr.Unavailable, "mic time repository is not configured")
|
||||
}
|
||||
options = normalizeCompensationOptions(options)
|
||||
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return mictimedomain.CompensationResult{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
pendingCutoff := options.NowMs - options.PendingPublishMaxAgeMs
|
||||
publishingCutoff := options.NowMs - options.PublishingSessionMaxAgeMs
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms,
|
||||
publishing_started_at_ms, ended_at_ms, last_room_version
|
||||
FROM user_mic_sessions
|
||||
WHERE (
|
||||
status = ? AND seat_started_at_ms <= ?
|
||||
) OR (
|
||||
status = ? AND seat_started_at_ms <= ?
|
||||
)
|
||||
ORDER BY seat_started_at_ms ASC, mic_session_id ASC
|
||||
LIMIT ?
|
||||
FOR UPDATE SKIP LOCKED
|
||||
`, mictimedomain.SessionStatusPendingPublish, pendingCutoff, mictimedomain.SessionStatusPublishing, publishingCutoff, options.BatchSize)
|
||||
if err != nil {
|
||||
return mictimedomain.CompensationResult{}, err
|
||||
}
|
||||
|
||||
candidates := make([]micSessionRow, 0, options.BatchSize)
|
||||
for rows.Next() {
|
||||
var session micSessionRow
|
||||
if err := rows.Scan(&session.AppCode, &session.MicSessionID, &session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status, &session.SeatStartedAtMs, &session.PublishingStartedAtMs, &session.EndedAtMs, &session.LastRoomVersion); err != nil {
|
||||
_ = rows.Close()
|
||||
return mictimedomain.CompensationResult{}, err
|
||||
}
|
||||
candidates = append(candidates, session)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return mictimedomain.CompensationResult{}, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return mictimedomain.CompensationResult{}, err
|
||||
}
|
||||
|
||||
var result mictimedomain.CompensationResult
|
||||
for _, session := range candidates {
|
||||
input := closeMicSessionInput{
|
||||
ClosedEventID: fmt.Sprintf("compensated:%s", session.MicSessionID),
|
||||
EndAtMs: compensationEndAt(session, options),
|
||||
UpdatedAtMs: options.NowMs,
|
||||
RoomVersion: session.LastRoomVersion,
|
||||
}
|
||||
switch session.Status {
|
||||
case mictimedomain.SessionStatusPendingPublish:
|
||||
input.EndReason = mictimedomain.CompensationReasonPendingPublishTimeout
|
||||
result.PendingPublishClosed++
|
||||
case mictimedomain.SessionStatusPublishing:
|
||||
input.EndReason = mictimedomain.CompensationReasonPublishingMaxWindow
|
||||
result.PublishingSessionClosed++
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if err := closeMicSession(ctx, tx, session, input); err != nil {
|
||||
return mictimedomain.CompensationResult{}, err
|
||||
}
|
||||
result.ClosedCount++
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return mictimedomain.CompensationResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func applyUp(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
|
||||
seatNo := event.ToSeat
|
||||
if seatNo <= 0 {
|
||||
@ -233,44 +319,87 @@ func applyDown(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent
|
||||
return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil
|
||||
}
|
||||
|
||||
endAt := event.OccurredAtMs
|
||||
if endAt < session.SeatStartedAtMs {
|
||||
endAt = session.SeatStartedAtMs
|
||||
endReason := strings.TrimSpace(event.Reason)
|
||||
if endReason == "" {
|
||||
endReason = "down"
|
||||
}
|
||||
seatOccupiedMs := maxInt64(0, endAt-session.SeatStartedAtMs)
|
||||
if err := closeMicSession(ctx, tx, session, closeMicSessionInput{
|
||||
ClosedEventID: event.EventID,
|
||||
EndAtMs: event.OccurredAtMs,
|
||||
EndReason: endReason,
|
||||
UpdatedAtMs: event.OccurredAtMs,
|
||||
RoomVersion: event.RoomVersion,
|
||||
}); err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Consumed: true, Closed: true}, nil
|
||||
}
|
||||
|
||||
type closeMicSessionInput struct {
|
||||
ClosedEventID string
|
||||
EndAtMs int64
|
||||
EndReason string
|
||||
UpdatedAtMs int64
|
||||
RoomVersion int64
|
||||
}
|
||||
|
||||
func closeMicSession(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput) error {
|
||||
input.ClosedEventID = strings.TrimSpace(input.ClosedEventID)
|
||||
if input.ClosedEventID == "" {
|
||||
input.ClosedEventID = "closed:" + session.MicSessionID
|
||||
}
|
||||
input.EndReason = strings.TrimSpace(input.EndReason)
|
||||
if input.EndReason == "" {
|
||||
input.EndReason = "down"
|
||||
}
|
||||
if input.EndAtMs < session.SeatStartedAtMs {
|
||||
input.EndAtMs = session.SeatStartedAtMs
|
||||
}
|
||||
if input.UpdatedAtMs <= 0 {
|
||||
input.UpdatedAtMs = input.EndAtMs
|
||||
}
|
||||
|
||||
seatOccupiedMs := maxInt64(0, input.EndAtMs-session.SeatStartedAtMs)
|
||||
micOnlineMs := int64(0)
|
||||
if session.PublishingStartedAtMs.Valid {
|
||||
publishStart := session.PublishingStartedAtMs.Int64
|
||||
if publishStart < session.SeatStartedAtMs {
|
||||
publishStart = session.SeatStartedAtMs
|
||||
}
|
||||
micOnlineMs = maxInt64(0, endAt-publishStart)
|
||||
}
|
||||
endReason := strings.TrimSpace(event.Reason)
|
||||
if endReason == "" {
|
||||
endReason = "down"
|
||||
micOnlineMs = maxInt64(0, input.EndAtMs-publishStart)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_mic_sessions
|
||||
SET status = ?, ended_at_ms = ?, end_reason = ?, seat_occupied_ms = ?, mic_online_ms = ?,
|
||||
closed_event_id = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ?
|
||||
WHERE app_code = ? AND mic_session_id = ? AND status <> ?
|
||||
`, mictimedomain.SessionStatusEnded, endAt, endReason, seatOccupiedMs, micOnlineMs,
|
||||
event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded)
|
||||
`, mictimedomain.SessionStatusEnded, input.EndAtMs, input.EndReason, seatOccupiedMs, micOnlineMs,
|
||||
input.ClosedEventID, input.ClosedEventID, input.RoomVersion, input.UpdatedAtMs, session.AppCode, session.MicSessionID, mictimedomain.SessionStatusEnded)
|
||||
if err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
return err
|
||||
}
|
||||
if err := incrementLifetime(ctx, tx, session, event, endAt, seatOccupiedMs, micOnlineMs); err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
if err := incrementLifetime(ctx, tx, session, input, seatOccupiedMs, micOnlineMs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := incrementDaily(ctx, tx, session, event, endAt); err != nil {
|
||||
return mictimedomain.ApplyResult{}, err
|
||||
if err := incrementDaily(ctx, tx, session, input); err != nil {
|
||||
return err
|
||||
}
|
||||
return mictimedomain.ApplyResult{Consumed: true, Closed: true}, nil
|
||||
return insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicSessionClosed, session.MicSessionID, ""), mictimedomain.EventTypeUserMicSessionClosed, "user_mic_session", session.UserID, input.UpdatedAtMs, map[string]any{
|
||||
"app_code": session.AppCode,
|
||||
"user_id": session.UserID,
|
||||
"room_id": session.RoomID,
|
||||
"mic_session_id": session.MicSessionID,
|
||||
"seat_occupied_ms": seatOccupiedMs,
|
||||
"mic_online_ms": micOnlineMs,
|
||||
"seat_started_at_ms": session.SeatStartedAtMs,
|
||||
"ended_at_ms": input.EndAtMs,
|
||||
"end_reason": input.EndReason,
|
||||
"closed_event_id": input.ClosedEventID,
|
||||
})
|
||||
}
|
||||
|
||||
func incrementLifetime(ctx context.Context, tx *sql.Tx, session micSessionRow, event mictimedomain.RoomMicEvent, endAt int64, seatMs int64, micMs int64) error {
|
||||
func incrementLifetime(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput, seatMs int64, micMs int64) error {
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO user_mic_lifetime_stats (
|
||||
app_code, user_id, seat_occupied_ms, mic_online_ms, session_count, first_mic_at_ms, last_mic_at_ms, updated_at_ms
|
||||
@ -282,19 +411,30 @@ func incrementLifetime(ctx context.Context, tx *sql.Tx, session micSessionRow, e
|
||||
first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END,
|
||||
last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, event.AppCode, session.UserID, seatMs, micMs, session.SeatStartedAtMs, endAt, event.OccurredAtMs)
|
||||
return err
|
||||
`, session.AppCode, session.UserID, seatMs, micMs, session.SeatStartedAtMs, input.EndAtMs, input.UpdatedAtMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicLifetimeStatsUpdated, session.MicSessionID, ""), mictimedomain.EventTypeUserMicLifetimeStatsUpdated, "user_mic_lifetime_stats", session.UserID, input.UpdatedAtMs, map[string]any{
|
||||
"app_code": session.AppCode,
|
||||
"user_id": session.UserID,
|
||||
"mic_session_id": session.MicSessionID,
|
||||
"seat_occupied_delta_ms": seatMs,
|
||||
"mic_online_delta_ms": micMs,
|
||||
"session_count_delta": 1,
|
||||
"updated_at_ms": input.UpdatedAtMs,
|
||||
})
|
||||
}
|
||||
|
||||
func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, event mictimedomain.RoomMicEvent, endAt int64) error {
|
||||
seatParts := splitByUTCDay(session.SeatStartedAtMs, endAt)
|
||||
func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput) error {
|
||||
seatParts := splitByUTCDay(session.SeatStartedAtMs, input.EndAtMs)
|
||||
micParts := map[string]dayDuration{}
|
||||
if session.PublishingStartedAtMs.Valid {
|
||||
publishStart := session.PublishingStartedAtMs.Int64
|
||||
if publishStart < session.SeatStartedAtMs {
|
||||
publishStart = session.SeatStartedAtMs
|
||||
}
|
||||
micParts = splitByUTCDay(publishStart, endAt)
|
||||
micParts = splitByUTCDay(publishStart, input.EndAtMs)
|
||||
}
|
||||
dates := map[string]struct{}{}
|
||||
for date := range seatParts {
|
||||
@ -331,15 +471,30 @@ func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, even
|
||||
first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END,
|
||||
last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, event.AppCode, session.UserID, date, seatPart.DurationMs, micPart.DurationMs, sessionCount, roomCount, firstAt, lastAt, event.OccurredAtMs)
|
||||
`, session.AppCode, session.UserID, date, seatPart.DurationMs, micPart.DurationMs, sessionCount, roomCount, firstAt, lastAt, input.UpdatedAtMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicDailyStatsUpdated, session.MicSessionID, date), mictimedomain.EventTypeUserMicDailyStatsUpdated, "user_mic_daily_stats", session.UserID, input.UpdatedAtMs, map[string]any{
|
||||
"app_code": session.AppCode,
|
||||
"user_id": session.UserID,
|
||||
"stat_date": date,
|
||||
"mic_session_id": session.MicSessionID,
|
||||
"seat_occupied_delta_ms": seatPart.DurationMs,
|
||||
"mic_online_delta_ms": micPart.DurationMs,
|
||||
"session_count_delta": sessionCount,
|
||||
"room_count_delta": roomCount,
|
||||
"updated_at_ms": input.UpdatedAtMs,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type micSessionRow struct {
|
||||
AppCode string
|
||||
MicSessionID string
|
||||
UserID int64
|
||||
RoomID string
|
||||
FirstSeatNo int32
|
||||
@ -354,12 +509,12 @@ type micSessionRow struct {
|
||||
func sessionForUpdate(ctx context.Context, tx *sql.Tx, appCode string, micSessionID string) (micSessionRow, bool, error) {
|
||||
var session micSessionRow
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms,
|
||||
SELECT app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms,
|
||||
publishing_started_at_ms, ended_at_ms, last_room_version
|
||||
FROM user_mic_sessions
|
||||
WHERE app_code = ? AND mic_session_id = ?
|
||||
FOR UPDATE
|
||||
`, appCode, micSessionID).Scan(&session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status,
|
||||
`, appCode, micSessionID).Scan(&session.AppCode, &session.MicSessionID, &session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status,
|
||||
&session.SeatStartedAtMs, &session.PublishingStartedAtMs, &session.EndedAtMs, &session.LastRoomVersion)
|
||||
if err == sql.ErrNoRows {
|
||||
return micSessionRow{}, false, nil
|
||||
@ -410,6 +565,52 @@ func normalizeRoomMicEvent(event mictimedomain.RoomMicEvent) mictimedomain.RoomM
|
||||
return event
|
||||
}
|
||||
|
||||
func normalizeCompensationOptions(options mictimedomain.CompensationOptions) mictimedomain.CompensationOptions {
|
||||
if options.NowMs <= 0 {
|
||||
options.NowMs = time.Now().UnixMilli()
|
||||
}
|
||||
if options.BatchSize <= 0 {
|
||||
options.BatchSize = defaultCompensationBatchSize
|
||||
}
|
||||
if options.PendingPublishMaxAgeMs <= 0 {
|
||||
options.PendingPublishMaxAgeMs = defaultPendingPublishCompensationMaxAgeMs
|
||||
}
|
||||
if options.PublishingSessionMaxAgeMs <= 0 {
|
||||
options.PublishingSessionMaxAgeMs = defaultPublishingCompensationMaxAgeMs
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func compensationEndAt(session micSessionRow, options mictimedomain.CompensationOptions) int64 {
|
||||
switch session.Status {
|
||||
case mictimedomain.SessionStatusPendingPublish:
|
||||
return minInt64(options.NowMs, session.SeatStartedAtMs+options.PendingPublishMaxAgeMs)
|
||||
case mictimedomain.SessionStatusPublishing:
|
||||
return minInt64(options.NowMs, session.SeatStartedAtMs+options.PublishingSessionMaxAgeMs)
|
||||
default:
|
||||
return options.NowMs
|
||||
}
|
||||
}
|
||||
|
||||
func insertUserOutbox(ctx context.Context, tx *sql.Tx, appCode string, eventID string, eventType string, aggregateType string, aggregateID int64, createdAtMs int64, payload map[string]any) error {
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, appcode.Normalize(appCode), eventID, eventType, aggregateType, aggregateID, userOutboxStatusPending, string(payloadBytes), createdAtMs, createdAtMs)
|
||||
return err
|
||||
}
|
||||
|
||||
func userMicOutboxEventID(eventType string, micSessionID string, suffix string) string {
|
||||
if strings.TrimSpace(suffix) == "" {
|
||||
return eventType + ":" + micSessionID
|
||||
}
|
||||
return eventType + ":" + micSessionID + ":" + suffix
|
||||
}
|
||||
|
||||
type dayDuration struct {
|
||||
DurationMs int64
|
||||
FirstAtMs int64
|
||||
|
||||
@ -2,6 +2,7 @@ package mictime_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -10,7 +11,8 @@ import (
|
||||
)
|
||||
|
||||
func TestProcessRoomMicEventsAggregatesLifetimeAndDeduplicates(t *testing.T) {
|
||||
repo := mysqltest.NewRepository(t).Repository.MicTimeRepository()
|
||||
wrapped := mysqltest.NewRepository(t)
|
||||
repo := wrapped.Repository.MicTimeRepository()
|
||||
ctx := context.Background()
|
||||
base := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC).UnixMilli()
|
||||
micSessionID := "mic_session_normal"
|
||||
@ -27,6 +29,9 @@ func TestProcessRoomMicEventsAggregatesLifetimeAndDeduplicates(t *testing.T) {
|
||||
if stats.SeatOccupiedMs != 11*60_000 || stats.MicOnlineMs != 10*60_000 || stats.SessionCount != 1 {
|
||||
t.Fatalf("normal mic duration mismatch: %+v", stats)
|
||||
}
|
||||
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicSessionClosed:"+micSessionID)
|
||||
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicLifetimeStatsUpdated:"+micSessionID)
|
||||
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicDailyStatsUpdated:"+micSessionID+":2026-05-08")
|
||||
|
||||
duplicate, err := repo.ProcessRoomMicEvent(ctx, roomMicEvent("evt-down", micSessionID, mictimedomain.ActionDown, 10001, 2, 0, base+11*60_000))
|
||||
if err != nil || !duplicate.Skipped || duplicate.Reason != "already_consumed" {
|
||||
@ -58,6 +63,54 @@ func TestProcessPendingPublishTimeoutCountsSeatButNotMicOnline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompensateOpenSessionsClosesPendingAndPublishingSessions(t *testing.T) {
|
||||
wrapped := mysqltest.NewRepository(t)
|
||||
repo := wrapped.Repository.MicTimeRepository()
|
||||
ctx := context.Background()
|
||||
base := time.Date(2026, 5, 8, 14, 0, 0, 0, time.UTC).UnixMilli()
|
||||
|
||||
pendingSessionID := "mic_session_comp_pending"
|
||||
mustApply(t, repo, roomMicEvent("evt-comp-pending-up", pendingSessionID, mictimedomain.ActionUp, 10004, 0, 1, base))
|
||||
|
||||
publishingSessionID := "mic_session_comp_publishing"
|
||||
mustApply(t, repo, roomMicEvent("evt-comp-pub-up", publishingSessionID, mictimedomain.ActionUp, 10005, 0, 1, base))
|
||||
mustApply(t, repo, roomMicEvent("evt-comp-pub-confirm", publishingSessionID, mictimedomain.ActionPublishConfirmed, 10005, 1, 1, base+60_000))
|
||||
|
||||
result, err := repo.CompensateOpenSessions(ctx, mictimedomain.CompensationOptions{
|
||||
NowMs: base + 3*60*60*1000,
|
||||
BatchSize: 10,
|
||||
PendingPublishMaxAgeMs: 2 * 60 * 1000,
|
||||
PublishingSessionMaxAgeMs: 60 * 60 * 1000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CompensateOpenSessions failed: %v", err)
|
||||
}
|
||||
if result.ClosedCount != 2 || result.PendingPublishClosed != 1 || result.PublishingSessionClosed != 1 {
|
||||
t.Fatalf("compensation result mismatch: %+v", result)
|
||||
}
|
||||
|
||||
pendingStats, err := repo.GetLifetimeStats(ctx, "lalu", 10004)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLifetimeStats pending failed: %v", err)
|
||||
}
|
||||
if pendingStats.SeatOccupiedMs != 2*60*1000 || pendingStats.MicOnlineMs != 0 || pendingStats.SessionCount != 1 {
|
||||
t.Fatalf("pending compensation stats mismatch: %+v", pendingStats)
|
||||
}
|
||||
assertSessionEnded(t, wrapped.RawDB(), pendingSessionID, mictimedomain.CompensationReasonPendingPublishTimeout)
|
||||
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicSessionClosed:"+pendingSessionID)
|
||||
|
||||
publishingStats, err := repo.GetLifetimeStats(ctx, "lalu", 10005)
|
||||
if err != nil {
|
||||
t.Fatalf("GetLifetimeStats publishing failed: %v", err)
|
||||
}
|
||||
if publishingStats.SeatOccupiedMs != 60*60*1000 || publishingStats.MicOnlineMs != 59*60*1000 || publishingStats.SessionCount != 1 {
|
||||
t.Fatalf("publishing compensation stats mismatch: %+v", publishingStats)
|
||||
}
|
||||
assertSessionEnded(t, wrapped.RawDB(), publishingSessionID, mictimedomain.CompensationReasonPublishingMaxWindow)
|
||||
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicSessionClosed:"+publishingSessionID)
|
||||
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicLifetimeStatsUpdated:"+publishingSessionID)
|
||||
}
|
||||
|
||||
func TestProcessCrossDayMicSessionKeepsLifetimeTotals(t *testing.T) {
|
||||
repo := mysqltest.NewRepository(t).Repository.MicTimeRepository()
|
||||
ctx := context.Background()
|
||||
@ -113,3 +166,34 @@ func roomMicEvent(eventID string, micSessionID string, action string, userID int
|
||||
}
|
||||
return event
|
||||
}
|
||||
|
||||
func assertUserOutboxEvent(t *testing.T, db *sql.DB, eventID string) {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := db.QueryRowContext(context.Background(), `
|
||||
SELECT COUNT(*)
|
||||
FROM user_outbox
|
||||
WHERE app_code = 'lalu' AND event_id = ?
|
||||
`, eventID).Scan(&count); err != nil {
|
||||
t.Fatalf("query user_outbox failed: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("user_outbox event %s count=%d, want 1", eventID, count)
|
||||
}
|
||||
}
|
||||
|
||||
func assertSessionEnded(t *testing.T, db *sql.DB, micSessionID string, reason string) {
|
||||
t.Helper()
|
||||
var status string
|
||||
var endReason string
|
||||
if err := db.QueryRowContext(context.Background(), `
|
||||
SELECT status, end_reason
|
||||
FROM user_mic_sessions
|
||||
WHERE app_code = 'lalu' AND mic_session_id = ?
|
||||
`, micSessionID).Scan(&status, &endReason); err != nil {
|
||||
t.Fatalf("query user_mic_sessions failed: %v", err)
|
||||
}
|
||||
if status != mictimedomain.SessionStatusEnded || endReason != reason {
|
||||
t.Fatalf("session %s terminal state mismatch: status=%s reason=%s", micSessionID, status, endReason)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package mysqltest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@ -52,6 +53,14 @@ func (r *Repository) Close() {
|
||||
}
|
||||
}
|
||||
|
||||
// RawDB exposes the isolated test schema for assertions that span read-model tables.
|
||||
func (r *Repository) RawDB() *sql.DB {
|
||||
if r == nil || r.schema == nil {
|
||||
return nil
|
||||
}
|
||||
return r.schema.DB
|
||||
}
|
||||
|
||||
// GetUser 让测试 wrapper 继续满足业务层用户主数据接口。
|
||||
func (r *Repository) GetUser(ctx context.Context, userID int64) (userdomain.User, error) {
|
||||
return r.Repository.UserRepository().GetUser(ctx, userID)
|
||||
|
||||
@ -4,9 +4,11 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/services/wallet-service/internal/app"
|
||||
"hyapp/services/wallet-service/internal/config"
|
||||
)
|
||||
@ -20,10 +22,13 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := logx.Init(cfg.Log.WithRuntimeFields(cfg.ServiceName, cfg.Environment, cfg.NodeID)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
application, err := app.New(cfg)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
fatalRuntime("app_new_failed", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
@ -39,7 +44,12 @@ func main() {
|
||||
application.Close()
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
fatalRuntime("service_run_failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fatalRuntime(msg string, err error) {
|
||||
logx.Error(context.Background(), msg, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
service_name: wallet-service
|
||||
node_id: wallet-docker
|
||||
environment: local
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
grpc_addr: ":13004"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
service_name: wallet-service
|
||||
node_id: wallet-tencent
|
||||
environment: prod
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
grpc_addr: ":13004"
|
||||
mysql_dsn: "${TENCENT_MYSQL_WALLET_DSN}"
|
||||
mysql_auto_migrate: false
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
service_name: wallet-service
|
||||
node_id: wallet-local
|
||||
environment: local
|
||||
log:
|
||||
level: info
|
||||
format: json
|
||||
include_request_body: false
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
grpc_addr: ":13004"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
|
||||
@ -127,6 +127,31 @@ CREATE TABLE IF NOT EXISTS wallet_recharge_records (
|
||||
KEY idx_wallet_recharge_records_region_time (app_code, target_region_id, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coin_seller_stock_records (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
stock_id VARCHAR(96) NOT NULL,
|
||||
transaction_id VARCHAR(96) NOT NULL,
|
||||
command_id VARCHAR(128) NOT NULL,
|
||||
seller_user_id BIGINT NOT NULL,
|
||||
stock_type VARCHAR(32) NOT NULL,
|
||||
counts_as_seller_recharge BOOLEAN NOT NULL,
|
||||
coin_amount BIGINT NOT NULL,
|
||||
paid_currency_code VARCHAR(8) NOT NULL DEFAULT '',
|
||||
paid_amount_micro BIGINT NOT NULL DEFAULT 0,
|
||||
payment_ref VARCHAR(128) NULL,
|
||||
evidence_ref VARCHAR(256) NOT NULL DEFAULT '',
|
||||
operator_user_id BIGINT NOT NULL,
|
||||
reason VARCHAR(512) NOT NULL DEFAULT '',
|
||||
balance_after BIGINT NOT NULL,
|
||||
metadata_json JSON NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (app_code, stock_id),
|
||||
UNIQUE KEY uk_coin_seller_stock_command (app_code, command_id),
|
||||
UNIQUE KEY uk_coin_seller_stock_payment_ref (app_code, stock_type, payment_ref),
|
||||
KEY idx_coin_seller_stock_seller_time (app_code, seller_user_id, created_at_ms),
|
||||
KEY idx_coin_seller_stock_type_time (app_code, stock_type, created_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS resources (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
|
||||
resource_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
@ -1,16 +1,23 @@
|
||||
package config
|
||||
|
||||
import "hyapp/pkg/configx"
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/configx"
|
||||
"hyapp/pkg/logx"
|
||||
)
|
||||
|
||||
// Config 描述 wallet-service 启动配置。
|
||||
type Config struct {
|
||||
ServiceName string `yaml:"service_name"`
|
||||
NodeID string `yaml:"node_id"`
|
||||
Environment string `yaml:"environment"`
|
||||
GRPCAddr string `yaml:"grpc_addr"`
|
||||
// MySQLDSN 是账户、交易、分录和 outbox 的必需存储连接串。
|
||||
MySQLDSN string `yaml:"mysql_dsn"`
|
||||
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
|
||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
|
||||
Log logx.Config `yaml:"log"`
|
||||
}
|
||||
|
||||
// Default 返回本地开发默认配置。
|
||||
@ -18,9 +25,15 @@ func Default() Config {
|
||||
return Config{
|
||||
ServiceName: "wallet-service",
|
||||
NodeID: "wallet-local",
|
||||
Environment: "local",
|
||||
GRPCAddr: ":13004",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLAutoMigrate: false,
|
||||
Log: logx.Config{
|
||||
Level: "info",
|
||||
Format: "json",
|
||||
MaxPayloadBytes: 2048,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,5 +48,18 @@ func Load(path string) (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
|
||||
if cfg.ServiceName == "" {
|
||||
cfg.ServiceName = "wallet-service"
|
||||
}
|
||||
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
|
||||
if cfg.NodeID == "" {
|
||||
cfg.NodeID = cfg.ServiceName
|
||||
}
|
||||
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
|
||||
if cfg.Environment == "" {
|
||||
cfg.Environment = "local"
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
@ -13,6 +13,13 @@ const (
|
||||
AssetDiamond = "DIAMOND"
|
||||
// AssetUSDBalance 是主播可提现美元余额,单位由后续提现策略固定。
|
||||
AssetUSDBalance = "USD_BALANCE"
|
||||
|
||||
// StockTypeUSDTPurchase 表示币商线下 USDT 进货后发放专用金币库存。
|
||||
StockTypeUSDTPurchase = "usdt_purchase"
|
||||
// StockTypeCoinCompensation 表示平台人工补偿币商专用金币库存,不计入进货金额。
|
||||
StockTypeCoinCompensation = "coin_compensation"
|
||||
// PaidCurrencyUSDT 是首版币商进货支持的唯一线下付款币种。
|
||||
PaidCurrencyUSDT = "USDT"
|
||||
)
|
||||
|
||||
// DebitGiftCommand 是 room-service 送礼扣费的账务命令。
|
||||
@ -67,6 +74,34 @@ type CoinSellerTransferReceipt struct {
|
||||
RechargePolicyUSDMinorUnit int64
|
||||
}
|
||||
|
||||
// CoinSellerStockCreditCommand 是后台给币商专用金币库存入账的最小账务命令。
|
||||
type CoinSellerStockCreditCommand struct {
|
||||
AppCode string
|
||||
CommandID string
|
||||
SellerUserID int64
|
||||
StockType string
|
||||
CoinAmount int64
|
||||
PaidCurrencyCode string
|
||||
PaidAmountMicro int64
|
||||
PaymentRef string
|
||||
EvidenceRef string
|
||||
OperatorUserID int64
|
||||
Reason string
|
||||
}
|
||||
|
||||
// CoinSellerStockCreditReceipt 是币商库存入账成功后的稳定回执。
|
||||
type CoinSellerStockCreditReceipt struct {
|
||||
TransactionID string
|
||||
SellerUserID int64
|
||||
StockType string
|
||||
CoinAmount int64
|
||||
PaidCurrencyCode string
|
||||
PaidAmountMicro int64
|
||||
CountsAsSellerRecharge bool
|
||||
BalanceAfter int64
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
// RechargeBill 是充值账单的只读投影;充值事实由 wallet_recharge_records 保存。
|
||||
type RechargeBill struct {
|
||||
AppCode string
|
||||
@ -154,3 +189,14 @@ func ValidGiftChargeAssetType(assetType string) bool {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizeCoinSellerStockType(stockType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(stockType)) {
|
||||
case StockTypeUSDTPurchase:
|
||||
return StockTypeUSDTPurchase
|
||||
case StockTypeCoinCompensation:
|
||||
return StockTypeCoinCompensation
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ type Repository interface {
|
||||
DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error)
|
||||
GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error)
|
||||
AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error)
|
||||
AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error)
|
||||
TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error)
|
||||
ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error)
|
||||
ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error)
|
||||
@ -113,6 +114,52 @@ func (s *Service) AdminCreditAsset(ctx context.Context, command ledger.AdminCred
|
||||
return s.repository.AdminCreditAsset(ctx, command)
|
||||
}
|
||||
|
||||
// AdminCreditCoinSellerStock 只给 COIN_SELLER_COIN 库存入账;USDT 进货和金币补偿使用不同账务类型。
|
||||
func (s *Service) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) {
|
||||
if command.CommandID == "" || command.SellerUserID <= 0 || command.OperatorUserID <= 0 {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock command is incomplete")
|
||||
}
|
||||
if len(command.CommandID) > 128 {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long")
|
||||
}
|
||||
if command.CoinAmount <= 0 {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin_amount must be positive")
|
||||
}
|
||||
command.StockType = ledger.NormalizeCoinSellerStockType(command.StockType)
|
||||
if command.StockType == "" {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid")
|
||||
}
|
||||
command.PaidCurrencyCode = strings.ToUpper(strings.TrimSpace(command.PaidCurrencyCode))
|
||||
command.PaymentRef = strings.TrimSpace(command.PaymentRef)
|
||||
command.EvidenceRef = strings.TrimSpace(command.EvidenceRef)
|
||||
command.Reason = strings.TrimSpace(command.Reason)
|
||||
if len(command.PaidCurrencyCode) > 8 || len(command.PaymentRef) > 128 || len(command.EvidenceRef) > 256 || len(command.Reason) > 512 {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock text fields are too long")
|
||||
}
|
||||
switch command.StockType {
|
||||
case ledger.StockTypeUSDTPurchase:
|
||||
if command.PaidCurrencyCode == "" {
|
||||
command.PaidCurrencyCode = ledger.PaidCurrencyUSDT
|
||||
}
|
||||
if command.PaidCurrencyCode != ledger.PaidCurrencyUSDT || command.PaidAmountMicro <= 0 {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "usdt purchase requires paid amount")
|
||||
}
|
||||
case ledger.StockTypeCoinCompensation:
|
||||
if command.PaidCurrencyCode != "" || command.PaidAmountMicro != 0 || command.PaymentRef != "" {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin compensation must not include paid amount")
|
||||
}
|
||||
default:
|
||||
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid")
|
||||
}
|
||||
if s.repository == nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
|
||||
}
|
||||
command.AppCode = appcode.Normalize(command.AppCode)
|
||||
ctx = appcode.WithContext(ctx, command.AppCode)
|
||||
|
||||
return s.repository.AdminCreditCoinSellerStock(ctx, command)
|
||||
}
|
||||
|
||||
// TransferCoinFromSeller 执行币商向玩家转金币;身份和区域都由 gateway/user-service 校验并传入。
|
||||
func (s *Service) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) {
|
||||
if command.CommandID == "" || command.SellerUserID <= 0 || command.TargetUserID <= 0 || command.Amount <= 0 {
|
||||
|
||||
@ -297,6 +297,150 @@ func TestTransferCoinFromSellerRequiresRechargePolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance 验证 USDT 进货只增加币商专用库存并写专用进货记录。
|
||||
func TestAdminCreditCoinSellerStockPurchaseCreditsDedicatedBalance(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
command := ledger.CoinSellerStockCreditCommand{
|
||||
CommandID: "cmd-stock-usdt",
|
||||
SellerUserID: 33001,
|
||||
StockType: ledger.StockTypeUSDTPurchase,
|
||||
CoinAmount: 8000000,
|
||||
PaidAmountMicro: 100000000,
|
||||
OperatorUserID: 90001,
|
||||
}
|
||||
|
||||
first, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
|
||||
if err != nil {
|
||||
t.Fatalf("AdminCreditCoinSellerStock purchase failed: %v", err)
|
||||
}
|
||||
second, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
|
||||
if err != nil {
|
||||
t.Fatalf("AdminCreditCoinSellerStock retry failed: %v", err)
|
||||
}
|
||||
if first.TransactionID == "" || first.TransactionID != second.TransactionID || first.BalanceAfter != 8000000 || !first.CountsAsSellerRecharge || first.PaidCurrencyCode != ledger.PaidCurrencyUSDT {
|
||||
t.Fatalf("stock purchase receipt mismatch: first=%+v second=%+v", first, second)
|
||||
}
|
||||
|
||||
balances, err := svc.GetBalances(context.Background(), 33001, []string{ledger.AssetCoinSellerCoin, ledger.AssetCoin})
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalances seller failed: %v", err)
|
||||
}
|
||||
if balanceAmount(balances, ledger.AssetCoinSellerCoin) != 8000000 || balanceAmount(balances, ledger.AssetCoin) != 0 {
|
||||
t.Fatalf("seller stock balances mismatch: %+v", balances)
|
||||
}
|
||||
if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = TRUE", first.TransactionID); got != 1 {
|
||||
t.Fatalf("stock purchase should write one seller recharge record, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_entries", "transaction_id = ?", first.TransactionID); got != 1 {
|
||||
t.Fatalf("stock purchase should write one wallet entry, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_recharge_records", "transaction_id = ?", first.TransactionID); got != 0 {
|
||||
t.Fatalf("seller stock purchase must not write player recharge record, got %d", got)
|
||||
}
|
||||
if got := repository.CountRows("wallet_outbox", "transaction_id = ?", first.TransactionID); got != 2 {
|
||||
t.Fatalf("stock purchase should write balance and stock outbox events, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount 锁定补偿不能携带充值金额的边界。
|
||||
func TestAdminCreditCoinSellerStockCompensationRejectsPaidAmount(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
_, err := svc.AdminCreditCoinSellerStock(context.Background(), ledger.CoinSellerStockCreditCommand{
|
||||
CommandID: "cmd-stock-comp-invalid",
|
||||
SellerUserID: 34001,
|
||||
StockType: ledger.StockTypeCoinCompensation,
|
||||
CoinAmount: 50000,
|
||||
PaidCurrencyCode: ledger.PaidCurrencyUSDT,
|
||||
PaidAmountMicro: 1,
|
||||
OperatorUserID: 90001,
|
||||
})
|
||||
if !xerr.IsCode(err, xerr.CoinSellerStockAmountInvalid) {
|
||||
t.Fatalf("expected COIN_SELLER_STOCK_AMOUNT_INVALID, got %v", err)
|
||||
}
|
||||
if got := repository.CountRows("coin_seller_stock_records", "command_id = ?", "cmd-stock-comp-invalid"); got != 0 {
|
||||
t.Fatalf("invalid compensation must not write stock record, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreditCoinSellerStockCompensationCreditsWithoutRechargeAmount 验证金币补偿不计入币商进货金额。
|
||||
func TestAdminCreditCoinSellerStockCompensationCreditsWithoutRechargeAmount(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
receipt, err := svc.AdminCreditCoinSellerStock(context.Background(), ledger.CoinSellerStockCreditCommand{
|
||||
CommandID: "cmd-stock-comp",
|
||||
SellerUserID: 35001,
|
||||
StockType: ledger.StockTypeCoinCompensation,
|
||||
CoinAmount: 50000,
|
||||
OperatorUserID: 90001,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AdminCreditCoinSellerStock compensation failed: %v", err)
|
||||
}
|
||||
if receipt.CountsAsSellerRecharge || receipt.PaidAmountMicro != 0 || receipt.PaidCurrencyCode != "" || receipt.BalanceAfter != 50000 {
|
||||
t.Fatalf("stock compensation receipt mismatch: %+v", receipt)
|
||||
}
|
||||
if got := repository.CountRows("coin_seller_stock_records", "transaction_id = ? AND counts_as_seller_recharge = FALSE AND paid_amount_micro = 0", receipt.TransactionID); got != 1 {
|
||||
t.Fatalf("compensation should write one non-recharge stock record, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreditCoinSellerStockRejectsDuplicatePaymentRef 防止同一 USDT 凭证重复给库存入账。
|
||||
func TestAdminCreditCoinSellerStockRejectsDuplicatePaymentRef(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
command := ledger.CoinSellerStockCreditCommand{
|
||||
CommandID: "cmd-stock-ref-1",
|
||||
SellerUserID: 36001,
|
||||
StockType: ledger.StockTypeUSDTPurchase,
|
||||
CoinAmount: 100,
|
||||
PaidAmountMicro: 1000000,
|
||||
PaymentRef: "usdt-ref-dup",
|
||||
EvidenceRef: "oss://receipt-dup-1",
|
||||
OperatorUserID: 90001,
|
||||
Reason: "first purchase",
|
||||
}
|
||||
if _, err := svc.AdminCreditCoinSellerStock(context.Background(), command); err != nil {
|
||||
t.Fatalf("first stock purchase failed: %v", err)
|
||||
}
|
||||
command.CommandID = "cmd-stock-ref-2"
|
||||
command.SellerUserID = 36002
|
||||
command.EvidenceRef = "oss://receipt-dup-2"
|
||||
_, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
|
||||
if !xerr.IsCode(err, xerr.CoinSellerPaymentRefDuplicated) {
|
||||
t.Fatalf("expected COIN_SELLER_PAYMENT_REF_DUPLICATED, got %v", err)
|
||||
}
|
||||
if got := repository.CountRows("coin_seller_stock_records", "payment_ref = ?", "usdt-ref-dup"); got != 1 {
|
||||
t.Fatalf("duplicate payment_ref should keep one stock record, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreditCoinSellerStockRejectsIdempotencyConflict 锁定 command_id 相同但 payload 不同必须 fail-closed。
|
||||
func TestAdminCreditCoinSellerStockRejectsIdempotencyConflict(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
command := ledger.CoinSellerStockCreditCommand{
|
||||
CommandID: "cmd-stock-conflict",
|
||||
SellerUserID: 37001,
|
||||
StockType: ledger.StockTypeUSDTPurchase,
|
||||
CoinAmount: 100,
|
||||
PaidAmountMicro: 1000000,
|
||||
PaymentRef: "usdt-ref-conflict",
|
||||
EvidenceRef: "oss://receipt-conflict",
|
||||
OperatorUserID: 90001,
|
||||
Reason: "first purchase",
|
||||
}
|
||||
if _, err := svc.AdminCreditCoinSellerStock(context.Background(), command); err != nil {
|
||||
t.Fatalf("first stock purchase failed: %v", err)
|
||||
}
|
||||
command.CoinAmount = 200
|
||||
_, err := svc.AdminCreditCoinSellerStock(context.Background(), command)
|
||||
if !xerr.IsCode(err, xerr.IdempotencyConflict) {
|
||||
t.Fatalf("expected IDEMPOTENCY_CONFLICT, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGiftConfigMustSelectGiftResource 验证后台新增礼物只能绑定 resource_type=gift 的资源。
|
||||
func TestGiftConfigMustSelectGiftResource(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -17,15 +17,17 @@ import (
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
const (
|
||||
transactionStatusSucceeded = "succeeded"
|
||||
bizTypeGiftDebit = "gift_debit"
|
||||
bizTypeManualCredit = "manual_credit"
|
||||
bizTypeCoinSellerTransfer = "coin_seller_transfer"
|
||||
outboxStatusPending = "pending"
|
||||
transactionStatusSucceeded = "succeeded"
|
||||
bizTypeGiftDebit = "gift_debit"
|
||||
bizTypeManualCredit = "manual_credit"
|
||||
bizTypeCoinSellerTransfer = "coin_seller_transfer"
|
||||
bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
|
||||
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
|
||||
outboxStatusPending = "pending"
|
||||
)
|
||||
|
||||
// Repository 是 wallet-service 的 MySQL v2 账本入口。
|
||||
@ -345,6 +347,106 @@ func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminC
|
||||
return balance, transactionID, nil
|
||||
}
|
||||
|
||||
// AdminCreditCoinSellerStock 在同一事务里给币商 COIN_SELLER_COIN 库存入账,并记录专用进货明细。
|
||||
func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, 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.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
bizType := coinSellerStockBizType(command.StockType)
|
||||
if bizType == "" {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid")
|
||||
}
|
||||
requestHash := coinSellerStockRequestHash(command)
|
||||
if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizType, xerr.IdempotencyConflict); err != nil || exists {
|
||||
if err != nil || !exists {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
return r.receiptForCoinSellerStockTransaction(ctx, tx, txRow.TransactionID)
|
||||
}
|
||||
if command.PaymentRef != "" {
|
||||
if duplicated, err := r.coinSellerStockPaymentRefExists(ctx, tx, command.StockType, command.PaymentRef); err != nil || duplicated {
|
||||
if err != nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated")
|
||||
}
|
||||
}
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
account, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs)
|
||||
if err != nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
balanceAfter, err := checkedAdd(account.AvailableAmount, command.CoinAmount)
|
||||
if err != nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
|
||||
transactionID := transactionID(command.AppCode, command.CommandID)
|
||||
countsAsSellerRecharge := command.StockType == ledger.StockTypeUSDTPurchase
|
||||
metadata := coinSellerStockMetadata{
|
||||
AppCode: command.AppCode,
|
||||
SellerUserID: command.SellerUserID,
|
||||
StockType: command.StockType,
|
||||
CoinAmount: command.CoinAmount,
|
||||
PaidCurrencyCode: command.PaidCurrencyCode,
|
||||
PaidAmountMicro: command.PaidAmountMicro,
|
||||
PaymentRef: command.PaymentRef,
|
||||
EvidenceRef: command.EvidenceRef,
|
||||
OperatorUserID: command.OperatorUserID,
|
||||
Reason: command.Reason,
|
||||
AssetType: ledger.AssetCoinSellerCoin,
|
||||
CountsAsSellerRecharge: countsAsSellerRecharge,
|
||||
BalanceAfter: balanceAfter,
|
||||
CreatedAtMS: nowMs,
|
||||
}
|
||||
externalRef := command.EvidenceRef
|
||||
if command.PaymentRef != "" {
|
||||
externalRef = command.PaymentRef
|
||||
}
|
||||
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, externalRef, metadata, nowMs); err != nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
if err := r.applyAccountDelta(ctx, tx, account, command.CoinAmount, 0, nowMs); err != nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
if err := r.insertEntry(ctx, tx, walletEntry{
|
||||
TransactionID: transactionID,
|
||||
UserID: command.SellerUserID,
|
||||
AssetType: ledger.AssetCoinSellerCoin,
|
||||
AvailableDelta: command.CoinAmount,
|
||||
FrozenDelta: 0,
|
||||
AvailableAfter: balanceAfter,
|
||||
FrozenAfter: account.FrozenAmount,
|
||||
CreatedAtMS: nowMs,
|
||||
}); err != nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
if err := r.insertCoinSellerStockRecord(ctx, tx, transactionID, command.CommandID, metadata, nowMs); err != nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||
balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, command.CoinAmount, 0, balanceAfter, account.FrozenAmount, metadata, nowMs),
|
||||
coinSellerStockCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
|
||||
}); err != nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
|
||||
return receiptFromCoinSellerStockMetadata(transactionID, metadata), nil
|
||||
}
|
||||
|
||||
// TransferCoinFromSeller 在同一事务里扣币商专用金币、给玩家普通 COIN 入账,并记录充值口径。
|
||||
func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) {
|
||||
if r == nil || r.db == nil {
|
||||
@ -469,6 +571,10 @@ type transactionRow struct {
|
||||
}
|
||||
|
||||
func (r *Repository) lookupTransaction(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string) (transactionRow, bool, error) {
|
||||
return r.lookupTransactionWithConflictCode(ctx, tx, commandID, requestHash, bizType, xerr.LedgerConflict)
|
||||
}
|
||||
|
||||
func (r *Repository) lookupTransactionWithConflictCode(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string, conflictCode xerr.Code) (transactionRow, bool, error) {
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT transaction_id, request_hash, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}')
|
||||
FROM wallet_transactions
|
||||
@ -489,7 +595,7 @@ func (r *Repository) lookupTransaction(ctx context.Context, tx *sql.Tx, commandI
|
||||
}
|
||||
if storedHash != requestHash || storedBizType != bizType {
|
||||
// command_id 是钱包幂等主键,业务 payload 或命令类型变化必须 fail-closed。
|
||||
return transactionRow{}, true, xerr.New(xerr.LedgerConflict, "wallet command idempotency conflict")
|
||||
return transactionRow{}, true, xerr.New(conflictCode, "wallet command idempotency conflict")
|
||||
}
|
||||
return txRow, true, nil
|
||||
}
|
||||
@ -729,6 +835,65 @@ func (r *Repository) insertRechargeRecord(ctx context.Context, tx *sql.Tx, trans
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) insertCoinSellerStockRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) error {
|
||||
metadataJSON, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var paymentRef any
|
||||
if metadata.PaymentRef != "" {
|
||||
paymentRef = metadata.PaymentRef
|
||||
}
|
||||
_, err = tx.ExecContext(ctx,
|
||||
`INSERT INTO coin_seller_stock_records (
|
||||
app_code, stock_id, transaction_id, command_id, seller_user_id, stock_type,
|
||||
counts_as_seller_recharge, coin_amount, paid_currency_code, paid_amount_micro,
|
||||
payment_ref, evidence_ref, operator_user_id, reason, balance_after, metadata_json, created_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
normalizedEntryAppCode(ctx, metadata.AppCode),
|
||||
coinSellerStockID(metadata.AppCode, commandID),
|
||||
transactionID,
|
||||
commandID,
|
||||
metadata.SellerUserID,
|
||||
metadata.StockType,
|
||||
metadata.CountsAsSellerRecharge,
|
||||
metadata.CoinAmount,
|
||||
metadata.PaidCurrencyCode,
|
||||
metadata.PaidAmountMicro,
|
||||
paymentRef,
|
||||
metadata.EvidenceRef,
|
||||
metadata.OperatorUserID,
|
||||
metadata.Reason,
|
||||
metadata.BalanceAfter,
|
||||
string(metadataJSON),
|
||||
nowMs,
|
||||
)
|
||||
if err != nil && isMySQLDuplicateError(err) && metadata.PaymentRef != "" {
|
||||
return xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated")
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) coinSellerStockPaymentRefExists(ctx context.Context, tx *sql.Tx, stockType string, paymentRef string) (bool, error) {
|
||||
var stockID string
|
||||
err := tx.QueryRowContext(ctx,
|
||||
`SELECT stock_id
|
||||
FROM coin_seller_stock_records
|
||||
WHERE app_code = ? AND stock_type = ? AND payment_ref = ?
|
||||
LIMIT 1`,
|
||||
appcode.FromContext(ctx),
|
||||
stockType,
|
||||
paymentRef,
|
||||
).Scan(&stockID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
type walletEntry struct {
|
||||
AppCode string
|
||||
TransactionID string
|
||||
@ -895,6 +1060,23 @@ type coinSellerTransferMetadata struct {
|
||||
RechargePolicyUSDMinorAmount int64 `json:"recharge_policy_usd_minor_amount"`
|
||||
}
|
||||
|
||||
type coinSellerStockMetadata struct {
|
||||
AppCode string `json:"app_code"`
|
||||
SellerUserID int64 `json:"seller_user_id"`
|
||||
StockType string `json:"stock_type"`
|
||||
CoinAmount int64 `json:"coin_amount"`
|
||||
PaidCurrencyCode string `json:"paid_currency_code"`
|
||||
PaidAmountMicro int64 `json:"paid_amount_micro"`
|
||||
PaymentRef string `json:"payment_ref"`
|
||||
EvidenceRef string `json:"evidence_ref"`
|
||||
OperatorUserID int64 `json:"operator_user_id"`
|
||||
Reason string `json:"reason"`
|
||||
AssetType string `json:"asset_type"`
|
||||
CountsAsSellerRecharge bool `json:"counts_as_seller_recharge"`
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
}
|
||||
|
||||
func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger.Receipt {
|
||||
chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType)
|
||||
chargeAmount := metadata.ChargeAmount
|
||||
@ -914,6 +1096,46 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) receiptForCoinSellerStockTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerStockCreditReceipt, error) {
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT seller_user_id, stock_type, coin_amount, paid_currency_code, paid_amount_micro,
|
||||
counts_as_seller_recharge, balance_after, created_at_ms
|
||||
FROM coin_seller_stock_records
|
||||
WHERE app_code = ? AND transaction_id = ?`,
|
||||
appcode.FromContext(ctx),
|
||||
transactionID,
|
||||
)
|
||||
var receipt ledger.CoinSellerStockCreditReceipt
|
||||
if err := row.Scan(
|
||||
&receipt.SellerUserID,
|
||||
&receipt.StockType,
|
||||
&receipt.CoinAmount,
|
||||
&receipt.PaidCurrencyCode,
|
||||
&receipt.PaidAmountMicro,
|
||||
&receipt.CountsAsSellerRecharge,
|
||||
&receipt.BalanceAfter,
|
||||
&receipt.CreatedAtMS,
|
||||
); err != nil {
|
||||
return ledger.CoinSellerStockCreditReceipt{}, err
|
||||
}
|
||||
receipt.TransactionID = transactionID
|
||||
return receipt, nil
|
||||
}
|
||||
|
||||
func receiptFromCoinSellerStockMetadata(transactionID string, metadata coinSellerStockMetadata) ledger.CoinSellerStockCreditReceipt {
|
||||
return ledger.CoinSellerStockCreditReceipt{
|
||||
TransactionID: transactionID,
|
||||
SellerUserID: metadata.SellerUserID,
|
||||
StockType: metadata.StockType,
|
||||
CoinAmount: metadata.CoinAmount,
|
||||
PaidCurrencyCode: metadata.PaidCurrencyCode,
|
||||
PaidAmountMicro: metadata.PaidAmountMicro,
|
||||
CountsAsSellerRecharge: metadata.CountsAsSellerRecharge,
|
||||
BalanceAfter: metadata.BalanceAfter,
|
||||
CreatedAtMS: metadata.CreatedAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repository) receiptForCoinSellerTransferTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerTransferReceipt, error) {
|
||||
var metadataJSON string
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
@ -1003,6 +1225,36 @@ func rechargeRecordedEvent(transactionID string, commandID string, targetUserID
|
||||
}
|
||||
}
|
||||
|
||||
func coinSellerStockCreditedEvent(transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) walletOutboxEvent {
|
||||
eventType := "WalletCoinSellerCoinCompensated"
|
||||
if metadata.StockType == ledger.StockTypeUSDTPurchase {
|
||||
eventType = "WalletCoinSellerStockPurchased"
|
||||
}
|
||||
return walletOutboxEvent{
|
||||
EventID: eventID(transactionID, eventType, metadata.SellerUserID, ledger.AssetCoinSellerCoin),
|
||||
EventType: eventType,
|
||||
TransactionID: transactionID,
|
||||
CommandID: commandID,
|
||||
UserID: metadata.SellerUserID,
|
||||
AssetType: ledger.AssetCoinSellerCoin,
|
||||
AvailableDelta: metadata.CoinAmount,
|
||||
FrozenDelta: 0,
|
||||
Payload: map[string]any{
|
||||
"transaction_id": transactionID,
|
||||
"command_id": commandID,
|
||||
"seller_user_id": metadata.SellerUserID,
|
||||
"stock_type": metadata.StockType,
|
||||
"coin_amount": metadata.CoinAmount,
|
||||
"paid_currency_code": metadata.PaidCurrencyCode,
|
||||
"paid_amount_micro": metadata.PaidAmountMicro,
|
||||
"counts_as_seller_recharge": metadata.CountsAsSellerRecharge,
|
||||
"operator_user_id": metadata.OperatorUserID,
|
||||
"created_at_ms": nowMs,
|
||||
},
|
||||
CreatedAtMS: nowMs,
|
||||
}
|
||||
}
|
||||
|
||||
func giftDebitedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, payload any, nowMs int64) walletOutboxEvent {
|
||||
return walletOutboxEvent{
|
||||
EventID: eventID(transactionID, "WalletGiftDebited", userID, assetType),
|
||||
@ -1058,6 +1310,13 @@ func checkedMul(unit int64, count int64) (int64, error) {
|
||||
return unit * count, nil
|
||||
}
|
||||
|
||||
func checkedAdd(left int64, right int64) (int64, error) {
|
||||
if right < 0 || left > math.MaxInt64-right {
|
||||
return 0, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin amount overflow")
|
||||
}
|
||||
return left + right, nil
|
||||
}
|
||||
|
||||
func debitRequestHash(command ledger.DebitGiftCommand) string {
|
||||
// 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。
|
||||
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s",
|
||||
@ -1083,6 +1342,21 @@ func adminCreditRequestHash(command ledger.AdminCreditAssetCommand) string {
|
||||
))
|
||||
}
|
||||
|
||||
func coinSellerStockRequestHash(command ledger.CoinSellerStockCreditCommand) string {
|
||||
return stableHash(fmt.Sprintf("coin_seller_stock|%s|%d|%s|%d|%s|%d|%s|%s|%d|%s",
|
||||
appcode.Normalize(command.AppCode),
|
||||
command.SellerUserID,
|
||||
command.StockType,
|
||||
command.CoinAmount,
|
||||
command.PaidCurrencyCode,
|
||||
command.PaidAmountMicro,
|
||||
command.PaymentRef,
|
||||
command.EvidenceRef,
|
||||
command.OperatorUserID,
|
||||
command.Reason,
|
||||
))
|
||||
}
|
||||
|
||||
func coinSellerTransferRequestHash(command ledger.CoinSellerTransferCommand) string {
|
||||
return stableHash(fmt.Sprintf("coin_seller_transfer|%s|%d|%d|%d|%d|%d|%s",
|
||||
appcode.Normalize(command.AppCode),
|
||||
@ -1095,6 +1369,17 @@ func coinSellerTransferRequestHash(command ledger.CoinSellerTransferCommand) str
|
||||
))
|
||||
}
|
||||
|
||||
func coinSellerStockBizType(stockType string) string {
|
||||
switch stockType {
|
||||
case ledger.StockTypeUSDTPurchase:
|
||||
return bizTypeCoinSellerStockPurchase
|
||||
case ledger.StockTypeCoinCompensation:
|
||||
return bizTypeCoinSellerCoinCompensation
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func stableHash(value string) string {
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return hex.EncodeToString(sum[:])
|
||||
@ -1112,6 +1397,10 @@ func transactionID(appCode string, commandID string) string {
|
||||
return "wtx_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
|
||||
}
|
||||
|
||||
func coinSellerStockID(appCode string, commandID string) string {
|
||||
return "cstock_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
|
||||
}
|
||||
|
||||
func billingReceiptID(appCode string, commandID string) string {
|
||||
return "bill_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
|
||||
}
|
||||
@ -1119,3 +1408,8 @@ func billingReceiptID(appCode string, commandID string) string {
|
||||
func eventID(transactionID string, eventType string, userID int64, assetType string) string {
|
||||
return "wev_" + stableHash(fmt.Sprintf("%s|%s|%d|%s", transactionID, eventType, userID, assetType))
|
||||
}
|
||||
|
||||
func isMySQLDuplicateError(err error) bool {
|
||||
var mysqlErr *mysqlDriver.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||||
}
|
||||
|
||||
@ -158,6 +158,7 @@ func initDBPath(t testing.TB) string {
|
||||
func validateTableName(table string) string {
|
||||
switch table {
|
||||
case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records",
|
||||
"coin_seller_stock_records",
|
||||
"resources", "resource_groups", "resource_group_items", "gift_configs", "gift_config_regions", "resource_grants", "resource_grant_items", "user_resource_entitlements", "user_resource_equipment":
|
||||
return table
|
||||
default:
|
||||
|
||||
@ -104,6 +104,39 @@ func (s *Server) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCredit
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AdminCreditCoinSellerStock 处理后台币商进货或金币补偿,币商身份由 admin-server 先行校验。
|
||||
func (s *Server) AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
receipt, err := s.svc.AdminCreditCoinSellerStock(ctx, ledger.CoinSellerStockCreditCommand{
|
||||
AppCode: req.GetAppCode(),
|
||||
CommandID: req.GetCommandId(),
|
||||
SellerUserID: req.GetSellerUserId(),
|
||||
StockType: req.GetStockType(),
|
||||
CoinAmount: req.GetCoinAmount(),
|
||||
PaidCurrencyCode: req.GetPaidCurrencyCode(),
|
||||
PaidAmountMicro: req.GetPaidAmountMicro(),
|
||||
PaymentRef: req.GetPaymentRef(),
|
||||
EvidenceRef: req.GetEvidenceRef(),
|
||||
OperatorUserID: req.GetOperatorUserId(),
|
||||
Reason: req.GetReason(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
|
||||
return &walletv1.AdminCreditCoinSellerStockResponse{
|
||||
TransactionId: receipt.TransactionID,
|
||||
SellerUserId: receipt.SellerUserID,
|
||||
StockType: receipt.StockType,
|
||||
CoinAmount: receipt.CoinAmount,
|
||||
PaidCurrencyCode: receipt.PaidCurrencyCode,
|
||||
PaidAmountMicro: receipt.PaidAmountMicro,
|
||||
CountsAsSellerRecharge: receipt.CountsAsSellerRecharge,
|
||||
BalanceAfter: receipt.BalanceAfter,
|
||||
CreatedAtMs: receipt.CreatedAtMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TransferCoinFromSeller 处理币商转玩家金币,调用方必须先完成币商身份校验。
|
||||
func (s *Server) TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user