日志服务相关

This commit is contained in:
zhx 2026-05-09 13:36:41 +08:00
parent 544aee1860
commit 241ba59f38
83 changed files with 9172 additions and 1409 deletions

View File

@ -34,7 +34,10 @@ build:
go build ./server/admin/... go build ./server/admin/...
# `admin` 在本机直接启动后台管理后端;依赖默认从 server/admin/configs/config.yaml 读取。 # `admin` 在本机直接启动后台管理后端;依赖默认从 server/admin/configs/config.yaml 读取。
# 启动前自动杀掉占用 13100 端口的旧进程,避免 bind: address already in use。
ADMIN_PORT ?= 13100
admin: 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) cd server/admin && go run ./cmd/server -config $(ADMIN_CONFIG)
# `admin-up` 先拉起后台本地依赖,再在本机启动后台管理后端。 # `admin-up` 先拉起后台本地依赖,再在本机启动后台管理后端。
@ -50,6 +53,7 @@ admin-deps:
# `admin-bootstrap` 显式执行后台初始化种子后启动服务。 # `admin-bootstrap` 显式执行后台初始化种子后启动服务。
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 cd server/admin && go run ./cmd/server -config $(ADMIN_CONFIG) -bootstrap
# `admin-test` 只跑后台管理后端测试。 # `admin-test` 只跑后台管理后端测试。

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,33 @@ message AdminCreditAssetResponse {
AssetBalance balance = 2; 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 // TransferCoinFromSellerRequest
message TransferCoinFromSellerRequest { message TransferCoinFromSellerRequest {
string command_id = 1; string command_id = 1;
@ -558,6 +585,7 @@ service WalletService {
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse); rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse); rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse);
rpc AdminCreditAsset(AdminCreditAssetRequest) returns (AdminCreditAssetResponse); rpc AdminCreditAsset(AdminCreditAssetRequest) returns (AdminCreditAssetResponse);
rpc AdminCreditCoinSellerStock(AdminCreditCoinSellerStockRequest) returns (AdminCreditCoinSellerStockResponse);
rpc TransferCoinFromSeller(TransferCoinFromSellerRequest) returns (TransferCoinFromSellerResponse); rpc TransferCoinFromSeller(TransferCoinFromSellerRequest) returns (TransferCoinFromSellerResponse);
rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse); rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse);
rpc GetResource(GetResourceRequest) returns (GetResourceResponse); rpc GetResource(GetResourceRequest) returns (GetResourceResponse);

View File

@ -19,30 +19,31 @@ import (
const _ = grpc.SupportPackageIsVersion9 const _ = grpc.SupportPackageIsVersion9
const ( const (
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift" WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances" WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
WalletService_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset" WalletService_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset"
WalletService_TransferCoinFromSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferCoinFromSeller" WalletService_AdminCreditCoinSellerStock_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditCoinSellerStock"
WalletService_ListResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResources" WalletService_TransferCoinFromSeller_FullMethodName = "/hyapp.wallet.v1.WalletService/TransferCoinFromSeller"
WalletService_GetResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResource" WalletService_ListResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResources"
WalletService_CreateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResource" WalletService_GetResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResource"
WalletService_UpdateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResource" WalletService_CreateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResource"
WalletService_SetResourceStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceStatus" WalletService_UpdateResource_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResource"
WalletService_ListResourceGroups_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGroups" WalletService_SetResourceStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceStatus"
WalletService_GetResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResourceGroup" WalletService_ListResourceGroups_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGroups"
WalletService_CreateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResourceGroup" WalletService_GetResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GetResourceGroup"
WalletService_UpdateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResourceGroup" WalletService_CreateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateResourceGroup"
WalletService_SetResourceGroupStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceGroupStatus" WalletService_UpdateResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateResourceGroup"
WalletService_ListGiftConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftConfigs" WalletService_SetResourceGroupStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetResourceGroupStatus"
WalletService_CreateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateGiftConfig" WalletService_ListGiftConfigs_FullMethodName = "/hyapp.wallet.v1.WalletService/ListGiftConfigs"
WalletService_UpdateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateGiftConfig" WalletService_CreateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/CreateGiftConfig"
WalletService_SetGiftConfigStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetGiftConfigStatus" WalletService_UpdateGiftConfig_FullMethodName = "/hyapp.wallet.v1.WalletService/UpdateGiftConfig"
WalletService_GrantResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResource" WalletService_SetGiftConfigStatus_FullMethodName = "/hyapp.wallet.v1.WalletService/SetGiftConfigStatus"
WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup" WalletService_GrantResource_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResource"
WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources" WalletService_GrantResourceGroup_FullMethodName = "/hyapp.wallet.v1.WalletService/GrantResourceGroup"
WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource" WalletService_ListUserResources_FullMethodName = "/hyapp.wallet.v1.WalletService/ListUserResources"
WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants" WalletService_EquipUserResource_FullMethodName = "/hyapp.wallet.v1.WalletService/EquipUserResource"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills" WalletService_ListResourceGrants_FullMethodName = "/hyapp.wallet.v1.WalletService/ListResourceGrants"
WalletService_ListRechargeBills_FullMethodName = "/hyapp.wallet.v1.WalletService/ListRechargeBills"
) )
// WalletServiceClient is the client API for WalletService service. // 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) DebitGift(ctx context.Context, in *DebitGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error) GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error)
AdminCreditAsset(ctx context.Context, in *AdminCreditAssetRequest, opts ...grpc.CallOption) (*AdminCreditAssetResponse, 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) TransferCoinFromSeller(ctx context.Context, in *TransferCoinFromSellerRequest, opts ...grpc.CallOption) (*TransferCoinFromSellerResponse, error)
ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error) ListResources(ctx context.Context, in *ListResourcesRequest, opts ...grpc.CallOption) (*ListResourcesResponse, error)
GetResource(ctx context.Context, in *GetResourceRequest, opts ...grpc.CallOption) (*GetResourceResponse, 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 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) { func (c *walletServiceClient) TransferCoinFromSeller(ctx context.Context, in *TransferCoinFromSellerRequest, opts ...grpc.CallOption) (*TransferCoinFromSellerResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TransferCoinFromSellerResponse) out := new(TransferCoinFromSellerResponse)
@ -334,6 +346,7 @@ type WalletServiceServer interface {
DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error) DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error)
GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error) GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error)
AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error) AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error)
AdminCreditCoinSellerStock(context.Context, *AdminCreditCoinSellerStockRequest) (*AdminCreditCoinSellerStockResponse, error)
TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error) TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error)
ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error) ListResources(context.Context, *ListResourcesRequest) (*ListResourcesResponse, error)
GetResource(context.Context, *GetResourceRequest) (*GetResourceResponse, 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) { func (UnimplementedWalletServiceServer) AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AdminCreditAsset not implemented") 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) { func (UnimplementedWalletServiceServer) TransferCoinFromSeller(context.Context, *TransferCoinFromSellerRequest) (*TransferCoinFromSellerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method TransferCoinFromSeller not implemented") 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) 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) { func _WalletService_TransferCoinFromSeller_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TransferCoinFromSellerRequest) in := new(TransferCoinFromSellerRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -909,6 +943,10 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
MethodName: "AdminCreditAsset", MethodName: "AdminCreditAsset",
Handler: _WalletService_AdminCreditAsset_Handler, Handler: _WalletService_AdminCreditAsset_Handler,
}, },
{
MethodName: "AdminCreditCoinSellerStock",
Handler: _WalletService_AdminCreditCoinSellerStock_Handler,
},
{ {
MethodName: "TransferCoinFromSeller", MethodName: "TransferCoinFromSeller",
Handler: _WalletService_TransferCoinFromSeller_Handler, Handler: _WalletService_TransferCoinFromSeller_Handler,

264
docs/api-endpoints.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

View 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 金额和操作人。

View 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。
### 方案 ATKE 容器标准输出采集
如果服务部署在腾讯云 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。
### 方案 BCVM / 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` 关联。

View File

@ -181,7 +181,7 @@ Activity 专用 reason
### Error Implementation Boundary ### 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(...)` 不要在 service 层直接返回 `status.Error(...)`

View File

@ -270,22 +270,29 @@ user_mic_event_consumption(
## Open Session Compensation ## Open Session Compensation
需要一个补偿 worker 处理异常 open session user-service 已内置 open session compensation worker用于兜底关闭 room-service down 事件缺失导致的异常 open session。补偿只修正用户麦时长事实不反写 Room Cell也不向客户端发送房间控制消息。
| Case | Action | | Case | Action |
| --- | --- | | --- | --- |
| `pending_publish` 超过 room-service publish timeout | 等 room-service `publish_timeout` down 事件;若长期没有事件,按异常关闭 | | `pending_publish` 超过 `pending_publish_max_age_sec` | 关闭 session`end_reason=compensated_publish_timeout``mic_online_ms=0` |
| `publishing` 长时间没有 down但用户 presence 已离开 | 使用 presence/room close 事实补偿关闭 | | `publishing` 超过 `publishing_session_max_age_sec` | 关闭 session`end_reason=compensated_publishing_max_window`,按确认发流后到截断点累计 `mic_online_ms` |
| 房间关闭但仍有 open session | 以 `RoomClosed.occurred_at_ms` 关闭 | | 消费者停机后恢复 | room outbox worker 继续从位点扫描;补偿 worker 扫描 MySQL open session 兜底 |
| 消费者停机后恢复 | 从 `room_outbox` 或 MQ 位点继续消费;必要时用未结束 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 ## 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 | | Event | Trigger | Consumer |
| --- | --- | --- | | --- | --- | --- |
@ -293,6 +300,18 @@ user_mic_event_consumption(
| `UserMicDailyStatsUpdated` | daily stats 有增量 | task progress、growth system | | `UserMicDailyStatsUpdated` | daily stats 有增量 | task progress、growth system |
| `UserMicLifetimeStatsUpdated` | lifetime stats 有增量 | lifetime task cache | | `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` 重算同一套用户时长。 host/Agency/BD 统计应该优先消费 `UserMicSessionClosed` 或读取 user mic facts 派生关系快照,不应该再直接消费 `RoomMicChanged` 重算同一套用户时长。
## Task Usage ## Task Usage
@ -389,8 +408,8 @@ GET /api/v1/users/me/mic-stats?from_ms=...&to_ms=...
4. 已实现 session 状态机:`up -> publish_confirmed -> down`,支持 `change` 不切断。 4. 已实现 session 状态机:`up -> publish_confirmed -> down`,支持 `change` 不切断。
5. 已实现 daily/lifetime 聚合,跨 UTC 日切分。 5. 已实现 daily/lifetime 聚合,跨 UTC 日切分。
6. 已增加内部查询 RPC `GetUserMicLifetimeStats`,供任务、主播、后台读取 lifetime 聚合。 6. 已增加内部查询 RPC `GetUserMicLifetimeStats`,供任务、主播、后台读取 lifetime 聚合。
7. 下一步增加补偿 worker关闭异常 open session。 7. 增加补偿 worker关闭异常 open session。
8. 下一步增加 user mic outbox产出 `UserMicSessionClosed``UserMicDailyStatsUpdated``UserMicLifetimeStatsUpdated` 8. 增加 user mic outbox产出 `UserMicSessionClosed``UserMicDailyStatsUpdated``UserMicLifetimeStatsUpdated`
9. 任务系统接入 `user_mic_lifetime_stats`,不要重复消费 room 事件。 9. 任务系统接入 `user_mic_lifetime_stats`,不要重复消费 room 事件。
10. host stats 从 user mic facts 或 user mic outbox 派生,不直接重复处理 Room Cell 麦位事件。 10. host stats 从 user mic facts 或 user mic outbox 派生,不直接重复处理 Room Cell 麦位事件。

View 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` 重试返回同一订单。
- 同一用户并发购买时只产生符合顺序的一条最终状态。
- 后台禁用等级后不能新购买,但不影响已购买用户有效期。

View File

@ -1,18 +1,24 @@
// Package logx 提供服务边界日志能力。 // Package logx provides structured runtime logging for service boundaries and workers.
// 它只记录 transport 层可见的请求、响应、错误语义和耗时,不承载业务判断。 // Services emit JSON/text to stdout only; deployment collects stdout into CLS or local logs.
package logx package logx
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt"
"io" "io"
"log"
"log/slog" "log/slog"
"mime" "mime"
"net/http" "net/http"
"os"
"strings" "strings"
"sync"
"time" "time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"google.golang.org/grpc" "google.golang.org/grpc"
@ -23,88 +29,281 @@ import (
) )
const ( const (
// maxLoggedBytes 限制单个 request/response 字段的日志体积,避免大响应污染 stdout。 defaultLevel = "info"
maxLoggedBytes = 8192 defaultFormat = "json"
// redactedValue 是敏感字段统一替换值,保证日志可读但不可用于重放凭证。 defaultEnv = "local"
redactedValue = "***REDACTED***" defaultMaxPayloadBytes = 2048
redactedValue = "***REDACTED***"
) )
// HTTPMiddleware 在 gateway HTTP 边界记录请求体、响应体、状态码和耗时。 // Config controls process-wide structured logging. Service code must not write to CLS directly.
// requestIDFromContext 由 gateway 注入logx 不直接依赖具体 transport 包,避免反向耦合。 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 { func HTTPMiddleware(service string, requestIDFromContext func(context.Context) string, next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
startedAt := time.Now() startedAt := time.Now()
requestBody := readAndRestoreBody(request) cfg := configSnapshot()
capture := newHTTPResponseCapture(writer) requestBody := readAndRestoreBody(request, cfg)
capture := newHTTPResponseCapture(writer, cfg.IncludeResponseBody, cfg.MaxPayloadBytes)
next.ServeHTTP(capture, request) next.ServeHTTP(capture, request)
requestID := strings.TrimSpace(requestIDFromContext(request.Context())) requestID := strings.TrimSpace(requestIDFromContext(request.Context()))
args := []any{ ctx := With(request.Context(), slog.String("request_id", requestID), slog.String("app_code", appcode.FromContext(request.Context())))
"service", service, attrs := []slog.Attr{
"request_id", requestID, slog.String("method", request.Method),
"method", request.Method, slog.String("path", request.URL.Path),
"path", request.URL.Path, slog.String("query", request.URL.RawQuery),
"query", request.URL.RawQuery, slog.Int("status", capture.statusCode),
"status", capture.statusCode, slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
"duration_ms", time.Since(startedAt).Milliseconds(), slog.String("client_ip", clientIP(request)),
"request", safePayload(requestBody),
"response", safePayload(capture.body.Bytes()),
} }
if capture.statusCode >= http.StatusInternalServerError { attrs = appendServiceAttr(attrs, service)
slog.Error("http_access", args...) if cfg.IncludeRequestBody {
return 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。 // UnaryServerInterceptor records internal gRPC access logs without coupling business services to a logger.
// 内部链路统一 gRPC + protobuf因此这里是排查跨服务参数和返回值的主入口。
func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor { func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor {
return func(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { return func(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if strings.Contains(info.FullMethod, "/grpc.health.v1.Health/") { if strings.Contains(info.FullMethod, "/grpc.health.v1.Health/") {
// 健康检查高频且无业务参数,跳过可避免日志噪音淹没真实业务请求。
return handler(ctx, request) return handler(ctx, request)
} }
startedAt := time.Now() startedAt := time.Now()
cfg := configSnapshot()
response, err := handler(ctx, request) response, err := handler(ctx, request)
code := codes.OK code := codes.OK
if err != nil { if err != nil {
code = status.Code(err) 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{ attrs := []slog.Attr{
"service", service, slog.String("grpc_method", info.FullMethod),
"request_id", requestIDFromPayload(request), slog.String("grpc_code", code.String()),
"method", info.FullMethod, slog.String("reason", string(xerr.ReasonFromGRPC(err))),
"grpc_code", code.String(), slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
"reason", string(xerr.ReasonFromGRPC(err)),
"duration_ms", time.Since(startedAt).Milliseconds(),
"request", safePayloadFromValue(request),
"response", safePayloadFromValue(response),
} }
if err != nil { attrs = appendServiceAttr(attrs, service)
slog.Error("grpc_access", args...) attrs = append(attrs, commonAttrsFromPayload(request)...)
return response, err if cfg.IncludeRequestBody {
attrs = append(attrs, slog.Any("request", safePayloadFromValue(request, cfg.MaxPayloadBytes)))
} }
slog.Info("grpc_access", args...) if cfg.IncludeResponseBody {
return response, nil 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 { type httpResponseCapture struct {
http.ResponseWriter http.ResponseWriter
statusCode int statusCode int
capture bool
maxBytes int
truncated bool
body bytes.Buffer body bytes.Buffer
} }
func newHTTPResponseCapture(writer http.ResponseWriter) *httpResponseCapture { func newHTTPResponseCapture(writer http.ResponseWriter, capture bool, maxBytes int) *httpResponseCapture {
return &httpResponseCapture{ return &httpResponseCapture{ResponseWriter: writer, statusCode: http.StatusOK, capture: capture, maxBytes: maxBytes}
ResponseWriter: writer,
statusCode: http.StatusOK,
}
} }
func (c *httpResponseCapture) WriteHeader(statusCode int) { func (c *httpResponseCapture) WriteHeader(statusCode int) {
@ -113,85 +312,98 @@ func (c *httpResponseCapture) WriteHeader(statusCode int) {
} }
func (c *httpResponseCapture) Write(data []byte) (int, error) { func (c *httpResponseCapture) Write(data []byte) (int, error) {
if c.body.Len() < maxLoggedBytes { if c.capture && c.body.Len() < c.maxBytes {
remaining := maxLoggedBytes - c.body.Len() remaining := c.maxBytes - c.body.Len()
if len(data) > remaining { if len(data) > remaining {
c.body.Write(data[:remaining]) c.body.Write(data[:remaining])
c.truncated = true
} else { } else {
c.body.Write(data) c.body.Write(data)
} }
} else if c.capture && len(data) > 0 {
c.truncated = true
} }
return c.ResponseWriter.Write(data) return c.ResponseWriter.Write(data)
} }
func readAndRestoreBody(request *http.Request) []byte { func readAndRestoreBody(request *http.Request, cfg Config) []byte {
if request.Body == nil { if !cfg.IncludeRequestBody || request.Body == nil {
return nil return nil
} }
mediaType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type")) mediaType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type"))
if strings.HasPrefix(mediaType, "multipart/") { if strings.HasPrefix(mediaType, "multipart/") {
// 文件上传请求体可能很大,且包含二进制内容;日志只记录占位符并保留原始流给 handler。
return []byte("<multipart body omitted>") return []byte("<multipart body omitted>")
} }
body, err := io.ReadAll(request.Body) body, err := io.ReadAll(request.Body)
if err != nil { if err != nil {
// 读取失败时把空 body 交还给后续 handler让真实解码错误仍由 handler 返回。
request.Body = io.NopCloser(bytes.NewReader(nil)) request.Body = io.NopCloser(bytes.NewReader(nil))
return nil return nil
} }
request.Body.Close() _ = request.Body.Close()
request.Body = io.NopCloser(bytes.NewReader(body)) request.Body = io.NopCloser(bytes.NewReader(body))
return body return body
} }
func safePayloadFromValue(value any) any { func safePayloadFromValue(value any, maxBytes int) any {
if value == nil { if value == nil {
return nil return nil
} }
if message, ok := value.(proto.Message); ok { raw, err := marshalPayload(value)
raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(message)
if err != nil {
return "<proto_marshal_failed>"
}
return safePayload(raw)
}
raw, err := json.Marshal(value)
if err != nil { 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 { func requestMetaFromPayload(value any) requestMeta {
payload, ok := safePayloadFromValue(value).(map[string]any) raw, err := marshalPayload(value)
if !ok { if err != nil || len(raw) == 0 {
return "" 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) meta, ok := payload["meta"].(map[string]any)
if !ok { if !ok {
return "" return result
} }
if requestID, ok := meta["request_id"].(string); ok { if result.RequestID == "" {
return 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) raw = bytes.TrimSpace(raw)
if len(raw) == 0 { if len(raw) == 0 {
return nil return nil
} }
if len(raw) > maxLoggedBytes { if maxBytes <= 0 {
raw = raw[:maxLoggedBytes] maxBytes = defaultMaxPayloadBytes
}
if truncated || len(raw) > maxBytes {
return map[string]any{"truncated": true, "bytes": len(raw)}
} }
var payload any var payload any
if err := json.Unmarshal(raw, &payload); err != nil { if err := json.Unmarshal(raw, &payload); err != nil {
return string(raw) return redactLogLine(string(raw))
} }
return redactValue(payload) return redactValue(payload)
} }
@ -209,15 +421,23 @@ func redactValue(value any) any {
} }
return redacted return redacted
case []any: case []any:
redacted := make([]any, len(typed))
for index, item := range typed { for index, item := range typed {
typed[index] = redactValue(item) redacted[index] = redactValue(item)
} }
return typed return redacted
default: default:
return value 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 { func isSensitiveKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_")) normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
sensitiveParts := []string{ sensitiveParts := []string{
@ -234,6 +454,7 @@ func isSensitiveKey(key string) bool {
"signature", "signature",
"private_key", "private_key",
"callback_auth", "callback_auth",
"receipt",
} }
for _, part := range sensitiveParts { for _, part := range sensitiveParts {
if strings.Contains(normalized, part) { if strings.Contains(normalized, part) {
@ -242,3 +463,171 @@ func isSensitiveKey(key string) bool {
} }
return false 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
View 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
View 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
}

View File

@ -76,6 +76,16 @@ const (
DuplicateBillingCommand Code = "DUPLICATE_BILLING_COMMAND" DuplicateBillingCommand Code = "DUPLICATE_BILLING_COMMAND"
// LedgerConflict 表示账本并发写入或余额版本冲突。 // LedgerConflict 表示账本并发写入或余额版本冲突。
LedgerConflict Code = "LEDGER_CONFLICT" 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 表示活动规则当前不可用。
RuleNotActive Code = "RULE_NOT_ACTIVE" RuleNotActive Code = "RULE_NOT_ACTIVE"

View File

@ -13,30 +13,7 @@ const errorDomain = "hyapp"
// GRPCCode 把稳定业务 reason 映射为 gRPC 标准 code。 // GRPCCode 把稳定业务 reason 映射为 gRPC 标准 code。
// gateway 后续只需要读取 gRPC code 和 ErrorInfo.reason 即可生成 HTTP envelope。 // gateway 后续只需要读取 gRPC code 和 ErrorInfo.reason 即可生成 HTTP envelope。
func GRPCCode(code Code) codes.Code { func GRPCCode(code Code) codes.Code {
switch code { return SpecOf(code).GRPCCode
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
}
} }
// ToGRPCError 只在 transport/grpc 层调用。 // ToGRPCError 只在 transport/grpc 层调用。

View File

@ -1,6 +1,10 @@
package xerr package xerr
import ( import (
"go/ast"
"go/parser"
"go/token"
"strconv"
"testing" "testing"
"google.golang.org/grpc/codes" "google.golang.org/grpc/codes"
@ -24,3 +28,109 @@ func TestToGRPCErrorCarriesReason(t *testing.T) {
t.Fatalf("reason mismatch: got %s want %s", got, PasswordAlreadySet) 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
}

View File

@ -6,6 +6,7 @@ import (
"errors" "errors"
"flag" "flag"
"log" "log"
"log/slog"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
@ -38,6 +39,7 @@ import (
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin" roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
searchmodule "hyapp-admin-server/internal/modules/search" searchmodule "hyapp-admin-server/internal/modules/search"
uploadmodule "hyapp-admin-server/internal/modules/upload" uploadmodule "hyapp-admin-server/internal/modules/upload"
"hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/platform/tencentcos" "hyapp-admin-server/internal/platform/tencentcos"
"hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/router" "hyapp-admin-server/internal/router"
@ -60,37 +62,40 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) 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 { 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 { 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 { 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, LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true, IgnoreRecordNotFoundError: true,
}) })
db, err := gorm.Open(mysql.Open(cfg.MySQLDSN), &gorm.Config{Logger: dbLogger}) db, err := gorm.Open(mysql.Open(cfg.MySQLDSN), &gorm.Config{Logger: dbLogger})
if err != nil { if err != nil {
log.Fatalf("connect mysql: %v", err) fatalRuntime("connect_mysql_failed", err)
} }
sqlDB, err := db.DB() sqlDB, err := db.DB()
if err != nil { if err != nil {
log.Fatalf("mysql db handle: %v", err) fatalRuntime("mysql_db_handle_failed", err)
} }
if cfg.Migrations.Enabled { if cfg.Migrations.Enabled {
if err := migration.Apply(context.Background(), sqlDB, cfg.Migrations.Dir); err != nil { 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) redisClient, err := cache.NewRedis(context.Background(), cfg.Redis)
if err != nil { if err != nil {
log.Fatalf("connect redis: %v", err) fatalRuntime("connect_redis_failed", err)
} }
if redisClient != nil { if redisClient != nil {
defer redisClient.Close() defer redisClient.Close()
@ -98,52 +103,52 @@ func main() {
userDB, err := sql.Open("mysql", cfg.UserMySQLDSN) userDB, err := sql.Open("mysql", cfg.UserMySQLDSN)
if err != nil { if err != nil {
log.Fatalf("connect user mysql: %v", err) fatalRuntime("connect_user_mysql_failed", err)
} }
if err := userDB.PingContext(context.Background()); err != nil { if err := userDB.PingContext(context.Background()); err != nil {
log.Fatalf("ping user mysql: %v", err) fatalRuntime("ping_user_mysql_failed", err)
} }
defer userDB.Close() defer userDB.Close()
walletDB, err := sql.Open("mysql", cfg.WalletMySQLDSN) walletDB, err := sql.Open("mysql", cfg.WalletMySQLDSN)
if err != nil { if err != nil {
log.Fatalf("connect wallet mysql: %v", err) fatalRuntime("connect_wallet_mysql_failed", err)
} }
if err := walletDB.PingContext(context.Background()); err != nil { if err := walletDB.PingContext(context.Background()); err != nil {
log.Fatalf("ping wallet mysql: %v", err) fatalRuntime("ping_wallet_mysql_failed", err)
} }
defer walletDB.Close() defer walletDB.Close()
roomDB, err := sql.Open("mysql", cfg.RoomMySQLDSN) roomDB, err := sql.Open("mysql", cfg.RoomMySQLDSN)
if err != nil { if err != nil {
log.Fatalf("connect room mysql: %v", err) fatalRuntime("connect_room_mysql_failed", err)
} }
if err := roomDB.PingContext(context.Background()); err != nil { if err := roomDB.PingContext(context.Background()); err != nil {
log.Fatalf("ping room mysql: %v", err) fatalRuntime("ping_room_mysql_failed", err)
} }
defer roomDB.Close() defer roomDB.Close()
store := repository.New(db) store := repository.New(db)
if cfg.MySQLAutoMigrate { if cfg.MySQLAutoMigrate {
if err := store.AutoMigrate(); err != nil { if err := store.AutoMigrate(); err != nil {
log.Fatalf("auto migrate: %v", err) fatalRuntime("auto_migrate_failed", err)
} }
} }
if *runBootstrap || cfg.Bootstrap.Enabled { if *runBootstrap || cfg.Bootstrap.Enabled {
if err := store.Seed(cfg); err != nil { 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())) userConn, err := grpc.Dial(cfg.UserService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { if err != nil {
log.Fatalf("connect user-service: %v", err) fatalRuntime("connect_user_service_failed", err)
} }
defer userConn.Close() defer userConn.Close()
walletConn, err := grpc.Dial(cfg.WalletService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials())) walletConn, err := grpc.Dial(cfg.WalletService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil { if err != nil {
log.Fatalf("connect wallet-service: %v", err) fatalRuntime("connect_wallet_service_failed", err)
} }
defer walletConn.Close() defer walletConn.Close()
@ -168,7 +173,7 @@ func main() {
AccessURL: cfg.TencentCOS.AccessURL, AccessURL: cfg.TencentCOS.AccessURL,
}, nil) }, nil)
if err != nil { if err != nil {
log.Fatalf("create tencent cos uploader: %v", err) fatalRuntime("create_tencent_cos_uploader_failed", err)
} }
objectUploader = uploader objectUploader = uploader
} }
@ -182,7 +187,7 @@ func main() {
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler), CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
Dashboard: dashboardmodule.New(store), Dashboard: dashboardmodule.New(store),
Health: healthmodule.New(store, redisClient, jobStatus), 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), Job: jobmodule.New(store, cfg, auditHandler),
Menu: menumodule.New(store, auditHandler), Menu: menumodule.New(store, auditHandler),
Notification: notificationmodule.New(store, auditHandler), Notification: notificationmodule.New(store, auditHandler),
@ -202,9 +207,9 @@ func main() {
} }
go func() { 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) { 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) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := server.Shutdown(ctx); err != nil { 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 { func ensureDatabase(dsn string) error {
parsed, err := mysqlDriver.ParseDSN(dsn) parsed, err := mysqlDriver.ParseDSN(dsn)
if err != nil { if err != nil {

View File

@ -1,6 +1,12 @@
service_name: admin-server service_name: admin-server
node_id: admin-local node_id: admin-local
environment: local environment: local
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
http_addr: ":13100" http_addr: ":13100"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local" 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" user_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local"

View File

@ -325,6 +325,7 @@
| `coin-seller:view` | `menu` | 币商列表、币商余额 | | `coin-seller:view` | `menu` | 币商列表、币商余额 |
| `coin-seller:create` | `button` | 新增币商 | | `coin-seller:create` | `button` | 新增币商 |
| `coin-seller:update` | `button` | 启用或禁用币商 | | `coin-seller:update` | `button` | 启用或禁用币商 |
| `coin-seller:stock-credit` | `button` | 币商进货、金币补偿 |
| `salary:view` | `menu` | 薪资周期、薪资明细 | | `salary:view` | `menu` | 薪资周期、薪资明细 |
| `salary:calculate` | `button` | 计算薪资 | | `salary:calculate` | `button` | 计算薪资 |
| `salary:approve` | `button` | 审批薪资 | | `salary:approve` | `button` | 审批薪资 |

View File

@ -7,6 +7,8 @@ import (
"strings" "strings"
"time" "time"
"hyapp-admin-server/internal/platform/logging"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -14,6 +16,7 @@ type Config struct {
ServiceName string `yaml:"service_name"` ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"` NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"` Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"` HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"` MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"` UserMySQLDSN string `yaml:"user_mysql_dsn"`
@ -90,6 +93,7 @@ func Default() Config {
ServiceName: "admin-server", ServiceName: "admin-server",
NodeID: "admin-local", NodeID: "admin-local",
Environment: "local", Environment: "local",
Log: logging.DefaultConfig(),
HTTPAddr: ":13100", HTTPAddr: ":13100",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local", 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", 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 == "" { if cfg.Environment == "" {
cfg.Environment = "local" 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.ServiceName = strings.TrimSpace(cfg.ServiceName)
cfg.NodeID = strings.TrimSpace(cfg.NodeID) cfg.NodeID = strings.TrimSpace(cfg.NodeID)
if cfg.NodeID == "" { if cfg.NodeID == "" {

View File

@ -24,6 +24,7 @@ type Client interface {
SetBDStatus(ctx context.Context, req SetBDStatusRequest) (*BDProfile, error) SetBDStatus(ctx context.Context, req SetBDStatusRequest) (*BDProfile, error)
CreateCoinSeller(ctx context.Context, req CreateCoinSellerRequest) (*CoinSellerProfile, error) CreateCoinSeller(ctx context.Context, req CreateCoinSellerRequest) (*CoinSellerProfile, error)
SetCoinSellerStatus(ctx context.Context, req SetCoinSellerStatusRequest) (*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) CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error)
CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error) CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error)
SetAgencyJoinEnabled(ctx context.Context, req SetAgencyJoinEnabledRequest) (*Agency, error) SetAgencyJoinEnabled(ctx context.Context, req SetAgencyJoinEnabledRequest) (*Agency, error)
@ -85,6 +86,7 @@ type GRPCClient struct {
identityClient userv1.UserIdentityServiceClient identityClient userv1.UserIdentityServiceClient
countryClient userv1.CountryAdminServiceClient countryClient userv1.CountryAdminServiceClient
regionClient userv1.RegionAdminServiceClient regionClient userv1.RegionAdminServiceClient
hostClient userv1.UserHostServiceClient
hostAdminClient userv1.UserHostAdminServiceClient hostAdminClient userv1.UserHostAdminServiceClient
} }
@ -94,6 +96,7 @@ func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
identityClient: userv1.NewUserIdentityServiceClient(conn), identityClient: userv1.NewUserIdentityServiceClient(conn),
countryClient: userv1.NewCountryAdminServiceClient(conn), countryClient: userv1.NewCountryAdminServiceClient(conn),
regionClient: userv1.NewRegionAdminServiceClient(conn), regionClient: userv1.NewRegionAdminServiceClient(conn),
hostClient: userv1.NewUserHostServiceClient(conn),
hostAdminClient: userv1.NewUserHostAdminServiceClient(conn), hostAdminClient: userv1.NewUserHostAdminServiceClient(conn),
} }
} }
@ -223,6 +226,12 @@ type SetCoinSellerStatusRequest struct {
Reason string Reason string
} }
type GetCoinSellerProfileRequest struct {
RequestID string
Caller string
UserID int64
}
type CreateAgencyRequest struct { type CreateAgencyRequest struct {
RequestID string RequestID string
Caller string Caller string
@ -256,28 +265,46 @@ type SetAgencyJoinEnabledRequest struct {
} }
type BDProfile struct { type BDProfile struct {
UserID int64 `json:"userId,string"` UserID int64 `json:"userId,string"`
Role string `json:"role"` Role string `json:"role"`
RegionID int64 `json:"regionId"` RegionID int64 `json:"regionId"`
ParentLeaderUserID int64 `json:"parentLeaderUserId,string"` ParentLeaderUserID int64 `json:"parentLeaderUserId,string"`
Status string `json:"status"` Status string `json:"status"`
CreatedByUserID int64 `json:"createdByUserId"` CreatedByUserID int64 `json:"createdByUserId"`
CreatedAtMs int64 `json:"createdAtMs"` CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"` 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 { type Agency struct {
AgencyID int64 `json:"agencyId"` AgencyID int64 `json:"agencyId"`
OwnerUserID int64 `json:"ownerUserId,string"` OwnerUserID int64 `json:"ownerUserId,string"`
RegionID int64 `json:"regionId"` RegionID int64 `json:"regionId"`
ParentBDUserID int64 `json:"parentBdUserId,string"` ParentBDUserID int64 `json:"parentBdUserId,string"`
Name string `json:"name"` Name string `json:"name"`
Status string `json:"status"` Status string `json:"status"`
JoinEnabled bool `json:"joinEnabled"` JoinEnabled bool `json:"joinEnabled"`
MaxHosts int32 `json:"maxHosts"` MaxHosts int32 `json:"maxHosts"`
CreatedByUserID int64 `json:"createdByUserId"` CreatedByUserID int64 `json:"createdByUserId"`
CreatedAtMs int64 `json:"createdAtMs"` CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"` 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 { type HostProfile struct {
@ -290,6 +317,11 @@ type HostProfile struct {
FirstBecameHostAtMs int64 `json:"firstBecameHostAtMs"` FirstBecameHostAtMs int64 `json:"firstBecameHostAtMs"`
CreatedAtMs int64 `json:"createdAtMs"` CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"` 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 { type CoinSellerProfile struct {
@ -396,6 +428,17 @@ func (c *GRPCClient) SetCoinSellerStatus(ctx context.Context, req SetCoinSellerS
return fromProtoCoinSellerProfile(resp.GetCoinSellerProfile()), nil 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) { func (c *GRPCClient) CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error) {
resp, err := c.hostAdminClient.CreateAgency(ctx, &userv1.CreateAgencyRequest{ resp, err := c.hostAdminClient.CreateAgency(ctx, &userv1.CreateAgencyRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller), Meta: requestMeta(ctx, req.RequestID, req.Caller),

View File

@ -27,6 +27,7 @@ type Client interface {
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error) GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, 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) 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) 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) { func (c *GRPCClient) ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error) {
return c.client.ListRechargeBills(ctx, req) return c.client.ListRechargeBills(ctx, req)
} }

View File

@ -7,7 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -128,12 +128,12 @@ func (r *Runner) runOnce(ctx context.Context) {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return return
} }
log.Printf("find pending job: %v", err) slog.Error("job_find_pending_failed", "worker_id", r.workerID, "error", err.Error())
return return
} }
locked, unlock, err := r.lock(ctx, job) locked, unlock, err := r.lock(ctx, job)
if err != nil { 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) _ = r.store.ReleaseJobLease(job.ID)
return return
} }

View File

@ -7,6 +7,7 @@ import (
"strings" "strings"
"hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response" "hyapp-admin-server/internal/response"
@ -19,8 +20,8 @@ type Handler struct {
audit shared.OperationLogger audit shared.OperationLogger
} }
func New(userClient userclient.Client, userDB *sql.DB, walletDB *sql.DB, audit shared.OperationLogger) *Handler { 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, NewReader(userDB, walletDB)), audit: audit} return &Handler{service: NewService(userClient, walletClient, NewReader(userDB, walletDB, adminDB)), audit: audit}
} }
func (h *Handler) ListBDLeaders(c *gin.Context) { func (h *Handler) ListBDLeaders(c *gin.Context) {
@ -140,6 +141,21 @@ func (h *Handler) SetBDStatus(c *gin.Context) {
response.OK(c, profile) 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) { func (h *Handler) CreateCoinSeller(c *gin.Context) {
var req createCoinSellerRequest var req createCoinSellerRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -176,6 +192,27 @@ func (h *Handler) SetCoinSellerStatus(c *gin.Context) {
response.OK(c, profile) 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) { func (h *Handler) CreateAgency(c *gin.Context) {
var req createAgencyRequest var req createAgencyRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"strings" "strings"
"time"
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/integration/userclient"
@ -13,10 +14,11 @@ import (
type Reader struct { type Reader struct {
db *sql.DB db *sql.DB
walletDB *sql.DB walletDB *sql.DB
adminDB *sql.DB
} }
func NewReader(db *sql.DB, walletDB *sql.DB) *Reader { func NewReader(db *sql.DB, walletDB *sql.DB, adminDB *sql.DB) *Reader {
return &Reader{db: db, walletDB: walletDB} return &Reader{db: db, walletDB: walletDB, adminDB: adminDB}
} }
type CoinSellerListItem struct { type CoinSellerListItem struct {
@ -24,6 +26,7 @@ type CoinSellerListItem struct {
Status string `json:"status"` Status string `json:"status"`
MerchantAssetType string `json:"merchantAssetType"` MerchantAssetType string `json:"merchantAssetType"`
MerchantBalance int64 `json:"merchantBalance"` MerchantBalance int64 `json:"merchantBalance"`
Contact string `json:"contact"`
DisplayUserID string `json:"displayUserId"` DisplayUserID string `json:"displayUserId"`
Username string `json:"username"` Username string `json:"username"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
@ -40,7 +43,13 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
return nil, 0, errUserDBNotConfigured() return nil, 0, errUserDBNotConfigured()
} }
appCode := appctx.FromContext(ctx) 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} args := []any{appCode, role}
if query.Status != "" { if query.Status != "" {
whereSQL += " AND bp.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 != "" { if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%" like := "%" + keyword + "%"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username 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) args = append(args, like, like, like, like)
} }
total, err := countRows(ctx, r.db, whereSQL, args...) 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(` rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, bp.role, bp.region_id, COALESCE(bp.parent_leader_user_id, 0), 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 %s
ORDER BY bp.created_at_ms DESC, bp.user_id DESC ORDER BY bp.created_at_ms DESC, bp.user_id DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@ -79,12 +101,139 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
items := make([]*userclient.BDProfile, 0, query.PageSize) items := make([]*userclient.BDProfile, 0, query.PageSize)
for rows.Next() { for rows.Next() {
item := &userclient.BDProfile{} 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 return nil, 0, err
} }
items = append(items, item) 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 事实表;后台关闭和入会开关仍走写命令。 // ListAgencies 只读 user-service 的 Agency 事实表;后台关闭和入会开关仍走写命令。
@ -93,7 +242,12 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
return nil, 0, errUserDBNotConfigured() return nil, 0, errUserDBNotConfigured()
} }
appCode := appctx.FromContext(ctx) 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} args := []any{appCode}
if query.Status != "" { if query.Status != "" {
whereSQL += " AND a.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 != "" { if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + 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 ?)" 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) args = append(args, like, like, like, like, like, like, like, like, like)
} }
total, err := countRows(ctx, r.db, whereSQL, args...) 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(` rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT a.agency_id, a.owner_user_id, a.region_id, a.parent_bd_user_id, 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.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 %s
ORDER BY a.created_at_ms DESC, a.agency_id DESC ORDER BY a.created_at_ms DESC, a.agency_id DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@ -133,7 +291,26 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
items := make([]*userclient.Agency, 0, query.PageSize) items := make([]*userclient.Agency, 0, query.PageSize)
for rows.Next() { for rows.Next() {
item := &userclient.Agency{} 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 return nil, 0, err
} }
items = append(items, item) items = append(items, item)
@ -147,7 +324,12 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
return nil, 0, errUserDBNotConfigured() return nil, 0, errUserDBNotConfigured()
} }
appCode := appctx.FromContext(ctx) 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} args := []any{appCode}
if query.Status != "" { if query.Status != "" {
whereSQL += " AND hp.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 != "" { if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + 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 ?)" 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) args = append(args, like, like, like, like, like)
} }
total, err := countRows(ctx, r.db, whereSQL, args...) 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(` rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT hp.user_id, hp.status, hp.region_id, COALESCE(hp.current_agency_id, 0), SELECT hp.user_id, hp.status, hp.region_id, COALESCE(hp.current_agency_id, 0),
COALESCE(hp.current_membership_id, 0), hp.source, 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 %s
ORDER BY hp.created_at_ms DESC, hp.user_id DESC ORDER BY hp.created_at_ms DESC, hp.user_id DESC
LIMIT ? OFFSET ? LIMIT ? OFFSET ?
@ -187,7 +371,22 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
items := make([]*userclient.HostProfile, 0, query.PageSize) items := make([]*userclient.HostProfile, 0, query.PageSize)
for rows.Next() { for rows.Next() {
item := &userclient.HostProfile{} 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 return nil, 0, err
} }
items = append(items, item) items = append(items, item)
@ -201,6 +400,9 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
return nil, 0, errUserDBNotConfigured() return nil, 0, errUserDBNotConfigured()
} }
appCode := appctx.FromContext(ctx) appCode := appctx.FromContext(ctx)
if err := r.ensureCoinSellerContactColumn(ctx); err != nil {
return nil, 0, err
}
whereSQL := ` whereSQL := `
FROM coin_seller_profiles csp FROM coin_seller_profiles csp
LEFT JOIN users u ON u.app_code = csp.app_code AND u.user_id = csp.user_id 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 != "" { if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%" like := "%" + keyword + "%"
whereSQL += " AND (CAST(csp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username 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) args = append(args, like, like, like, like)
} }
total, err := countRows(ctx, r.db, whereSQL, args...) 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(` rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT csp.user_id, csp.status, csp.merchant_asset_type, 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, csp.created_by_user_id, csp.created_at_ms, csp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''), COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(u.region_id, 0), 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.UserID,
&item.Status, &item.Status,
&item.MerchantAssetType, &item.MerchantAssetType,
&item.Contact,
&item.CreatedByUserID, &item.CreatedByUserID,
&item.CreatedAtMs, &item.CreatedAtMs,
&item.UpdatedAtMs, &item.UpdatedAtMs,
@ -275,6 +479,42 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
return items, total, nil 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) { func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
result := make(map[int64]int64, len(userIDs)) result := make(map[int64]int64, len(userIDs))
if r == nil || r.walletDB == nil || len(userIDs) == 0 { 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() 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) { func countRows(ctx context.Context, db *sql.DB, whereSQL string, args ...any) (int64, error) {
var total int64 var total int64
err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total) err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total)

View File

@ -33,14 +33,26 @@ type bdStatusRequest struct {
type createCoinSellerRequest struct { type createCoinSellerRequest struct {
CommandID string `json:"commandId" binding:"required"` CommandID string `json:"commandId" binding:"required"`
Contact string `json:"contact"`
TargetUserID int64 `json:"targetUserId" binding:"required"` TargetUserID int64 `json:"targetUserId" binding:"required"`
Reason string `json:"reason"` Reason string `json:"reason"`
} }
type coinSellerStatusRequest struct { type coinSellerStatusRequest struct {
CommandID string `json:"commandId" binding:"required"` CommandID string `json:"commandId" binding:"required"`
Status string `json:"status" binding:"required"` Contact *string `json:"contact"`
Reason string `json:"reason"` 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 { type createAgencyRequest struct {

View 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(),
}
}

View File

@ -10,6 +10,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/bd-leaders", middleware.RequirePermission("bd:view"), h.ListBDLeaders) protected.GET("/admin/bd-leaders", middleware.RequirePermission("bd:view"), h.ListBDLeaders)
protected.POST("/admin/bd-leaders", middleware.RequirePermission("bd:create"), h.CreateBDLeader) 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.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.GET("/admin/bds", middleware.RequirePermission("bd:view"), h.ListBDs)
protected.POST("/admin/bds", middleware.RequirePermission("bd:create"), h.CreateBD) protected.POST("/admin/bds", middleware.RequirePermission("bd:create"), h.CreateBD)
protected.PATCH("/admin/bds/:user_id/status", middleware.RequirePermission("bd:update"), h.SetBDStatus) 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.GET("/admin/coin-sellers", middleware.RequirePermission("coin-seller:view"), h.ListCoinSellers)
protected.POST("/admin/coin-sellers", middleware.RequirePermission("coin-seller:create"), h.CreateCoinSeller) 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.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)
} }

View File

@ -6,16 +6,28 @@ import (
"strconv" "strconv"
"strings" "strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient" "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 { type Service struct {
userClient userclient.Client userClient userclient.Client
reader *Reader walletClient walletclient.Client
reader *Reader
} }
func NewService(userClient userclient.Client, reader *Reader) *Service { func NewService(userClient userclient.Client, walletClient walletclient.Client, reader *Reader) *Service {
return &Service{userClient: userClient, reader: reader} return &Service{userClient: userClient, walletClient: walletClient, reader: reader}
} }
func (s *Service) ListBDLeaders(ctx context.Context, query listQuery) ([]*userclient.BDProfile, int64, error) { 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) { func (s *Service) CreateCoinSeller(ctx context.Context, actorID int64, requestID string, req createCoinSellerRequest) (*userclient.CoinSellerProfile, error) {
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID) targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.userClient.CreateCoinSeller(ctx, userclient.CreateCoinSellerRequest{ profile, err := s.userClient.CreateCoinSeller(ctx, userclient.CreateCoinSellerRequest{
RequestID: requestID, RequestID: requestID,
Caller: "hyapp-admin-server", Caller: "hyapp-admin-server",
CommandID: strings.TrimSpace(req.CommandID), CommandID: strings.TrimSpace(req.CommandID),
@ -105,10 +124,20 @@ func (s *Service) CreateCoinSeller(ctx context.Context, actorID int64, requestID
TargetUserID: targetUserID, TargetUserID: targetUserID,
Reason: strings.TrimSpace(req.Reason), 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) { 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, RequestID: requestID,
Caller: "hyapp-admin-server", Caller: "hyapp-admin-server",
CommandID: strings.TrimSpace(req.CommandID), CommandID: strings.TrimSpace(req.CommandID),
@ -117,6 +146,76 @@ func (s *Service) SetCoinSellerStatus(ctx context.Context, actorID int64, target
Status: strings.TrimSpace(req.Status), Status: strings.TrimSpace(req.Status),
Reason: strings.TrimSpace(req.Reason), 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) { 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 { func normalizeListQuery(query listQuery) listQuery {
if query.Page < 1 { if query.Page < 1 {
query.Page = 1 query.Page = 1
@ -205,3 +340,12 @@ func normalizeListQuery(query listQuery) listQuery {
query.Status = strings.ToLower(strings.TrimSpace(query.Status)) query.Status = strings.ToLower(strings.TrimSpace(query.Status))
return query return query
} }
func firstNonBlank(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

View 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
}

View File

@ -55,6 +55,7 @@ var defaultPermissions = []model.Permission{
{Name: "币商查看", Code: "coin-seller:view", Kind: "menu"}, {Name: "币商查看", Code: "coin-seller:view", Kind: "menu"},
{Name: "币商创建", Code: "coin-seller:create", Kind: "button"}, {Name: "币商创建", Code: "coin-seller:create", Kind: "button"},
{Name: "币商更新", Code: "coin-seller:update", 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: "payment-bill:view", Kind: "menu"},
{Name: "角色查看", Code: "role:view", Kind: "menu"}, {Name: "角色查看", Code: "role:view", Kind: "menu"},
{Name: "角色创建", Code: "role:create", Kind: "button"}, {Name: "角色创建", Code: "role:create", Kind: "button"},
@ -105,7 +106,7 @@ func (s *Store) seedMenus() error {
paymentID := uint(0) paymentID := uint(0)
menus := []model.Menu{ menus := []model.Menu{
{Title: "总览", Code: "overview", Path: "/overview", Icon: "dashboard", PermissionCode: "overview:view", Sort: 10, Visible: true}, {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: "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: "notifications", Path: "/notifications", Icon: "notifications", PermissionCode: "notification:view", Sort: 50, Visible: true},
{Title: "用户管理", Code: "app-users", Path: "", Icon: "users", PermissionCode: "", Sort: 60, 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{ 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-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: &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}, {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", "host:view",
"agency:view", "agency:create", "agency:status", "agency:view", "agency:create", "agency:status",
"bd:view", "bd:create", "bd:update", "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", "payment-bill:view",
"log:view", "log:view",
"notification:view", "notification:read", "notification:view", "notification:read",
@ -462,7 +463,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"upload:create", "upload:create",
"country:view", "country:create", "country:update", "country:status", "country:view", "country:create", "country:update", "country:status",
"region:view", "region:create", "region:update", "region: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", "payment-bill:view",
} }
case "auditor", "readonly": case "auditor", "readonly":

View File

@ -22,6 +22,7 @@ import (
"hyapp-admin-server/internal/modules/roomadmin" "hyapp-admin-server/internal/modules/roomadmin"
"hyapp-admin-server/internal/modules/search" "hyapp-admin-server/internal/modules/search"
"hyapp-admin-server/internal/modules/upload" "hyapp-admin-server/internal/modules/upload"
"hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/service" "hyapp-admin-server/internal/service"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@ -51,7 +52,7 @@ type Handlers struct {
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine { func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
engine := gin.New() 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("/healthz", h.Health.Health)
engine.GET("/readyz", h.Health.Ready) engine.GET("/readyz", h.Health.Ready)

View File

@ -4,9 +4,11 @@ import (
"context" "context"
"flag" "flag"
"log" "log"
"os"
"os/signal" "os/signal"
"syscall" "syscall"
"hyapp/pkg/logx"
"hyapp/services/activity-service/internal/app" "hyapp/services/activity-service/internal/app"
"hyapp/services/activity-service/internal/config" "hyapp/services/activity-service/internal/config"
) )
@ -20,10 +22,13 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) 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) application, err := app.New(cfg)
if err != nil { if err != nil {
log.Fatal(err) fatalRuntime("app_new_failed", err)
} }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@ -39,7 +44,12 @@ func main() {
application.Close() application.Close()
case err := <-errCh: case err := <-errCh:
if err != nil { 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)
}

View File

@ -1,5 +1,12 @@
service_name: activity-service service_name: activity-service
node_id: activity-docker 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" grpc_addr: ":13006"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local"
user_service_addr: "user-service:13005" user_service_addr: "user-service:13005"

View File

@ -1,5 +1,12 @@
service_name: activity-service service_name: activity-service
node_id: activity-tencent 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" grpc_addr: ":13006"
mysql_dsn: "${TENCENT_MYSQL_ACTIVITY_DSN}" mysql_dsn: "${TENCENT_MYSQL_ACTIVITY_DSN}"
user_service_addr: "user-service.internal:13005" user_service_addr: "user-service.internal:13005"

View File

@ -1,5 +1,12 @@
service_name: activity-service service_name: activity-service
node_id: activity-local 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" grpc_addr: ":13006"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local" 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" user_service_addr: "127.0.0.1:13005"

View File

@ -4,12 +4,14 @@ import (
"strings" "strings"
"hyapp/pkg/configx" "hyapp/pkg/configx"
"hyapp/pkg/logx"
) )
// Config 描述 activity-service 启动配置。 // Config 描述 activity-service 启动配置。
type Config struct { type Config struct {
ServiceName string `yaml:"service_name"` ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"` NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
GRPCAddr string `yaml:"grpc_addr"` GRPCAddr string `yaml:"grpc_addr"`
// MySQLDSN 是活动状态、事件消费幂等和 outbox 的必需存储连接串。 // MySQLDSN 是活动状态、事件消费幂等和 outbox 的必需存储连接串。
MySQLDSN string `yaml:"mysql_dsn"` MySQLDSN string `yaml:"mysql_dsn"`
@ -19,6 +21,7 @@ type Config struct {
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Consumer ConsumerConfig `yaml:"consumer"` Consumer ConsumerConfig `yaml:"consumer"`
MessageInbox MessageInboxConfig `yaml:"message_inbox"` MessageInbox MessageInboxConfig `yaml:"message_inbox"`
Log logx.Config `yaml:"log"`
} }
// ConsumerConfig 保存 room outbox 消费底座配置。 // ConsumerConfig 保存 room outbox 消费底座配置。
@ -47,6 +50,7 @@ func Default() Config {
return Config{ return Config{
ServiceName: "activity-service", ServiceName: "activity-service",
NodeID: "activity-local", NodeID: "activity-local",
Environment: "local",
GRPCAddr: ":13006", GRPCAddr: ":13006",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local",
UserServiceAddr: "127.0.0.1:13005", UserServiceAddr: "127.0.0.1:13005",
@ -65,6 +69,11 @@ func Default() Config {
MaxRetry: 8, 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) == "" { if strings.TrimSpace(cfg.UserServiceAddr) == "" {
cfg.UserServiceAddr = Default().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 return cfg, nil
} }

View File

@ -4,9 +4,11 @@ import (
"context" "context"
"flag" "flag"
"log" "log"
"os"
"os/signal" "os/signal"
"syscall" "syscall"
"hyapp/pkg/logx"
"hyapp/services/gateway-service/internal/app" "hyapp/services/gateway-service/internal/app"
"hyapp/services/gateway-service/internal/config" "hyapp/services/gateway-service/internal/config"
) )
@ -20,10 +22,13 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) 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) application, err := app.New(cfg)
if err != nil { if err != nil {
log.Fatal(err) fatalRuntime("app_new_failed", err)
} }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@ -37,11 +42,16 @@ func main() {
select { select {
case <-ctx.Done(): case <-ctx.Done():
if err := application.Close(); err != nil { if err := application.Close(); err != nil {
log.Fatal(err) fatalRuntime("app_close_failed", err)
} }
case err := <-errCh: case err := <-errCh:
if err != nil { 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)
}

View File

@ -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" http_addr: ":13000"
jwt_secret: "dev-shared-secret" jwt_secret: "dev-shared-secret"
room_service_addr: "room-service:13001" room_service_addr: "room-service:13001"

View File

@ -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" http_addr: ":13000"
jwt_secret: "REPLACE_WITH_GATEWAY_JWT_SECRET" jwt_secret: "REPLACE_WITH_GATEWAY_JWT_SECRET"
room_service_addr: "room-service.internal:13001" room_service_addr: "room-service.internal:13001"

View File

@ -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" http_addr: ":13000"
jwt_secret: "dev-shared-secret" jwt_secret: "dev-shared-secret"
room_service_addr: "127.0.0.1:13001" room_service_addr: "127.0.0.1:13001"

View File

@ -6,11 +6,15 @@ import (
"time" "time"
"hyapp/pkg/configx" "hyapp/pkg/configx"
"hyapp/pkg/logx"
"hyapp/pkg/tencentrtc" "hyapp/pkg/tencentrtc"
) )
// Config 描述 gateway-service 启动所需的最小配置。 // Config 描述 gateway-service 启动所需的最小配置。
type Config struct { type Config struct {
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
HTTPAddr string `yaml:"http_addr"` HTTPAddr string `yaml:"http_addr"`
JWTSecret string `yaml:"jwt_secret"` JWTSecret string `yaml:"jwt_secret"`
RoomServiceAddr string `yaml:"room_service_addr"` RoomServiceAddr string `yaml:"room_service_addr"`
@ -22,6 +26,7 @@ type Config struct {
TencentIM TencentIMConfig `yaml:"tencent_im"` TencentIM TencentIMConfig `yaml:"tencent_im"`
TencentRTC TencentRTCConfig `yaml:"tencent_rtc"` TencentRTC TencentRTCConfig `yaml:"tencent_rtc"`
TencentCOS TencentCOSConfig `yaml:"tencent-cos"` TencentCOS TencentCOSConfig `yaml:"tencent-cos"`
Log logx.Config `yaml:"log"`
} }
// AppConfigConfig 描述 gateway 读取后台 App 运行时配置的只读数据源。 // AppConfigConfig 描述 gateway 读取后台 App 运行时配置的只读数据源。
@ -110,6 +115,9 @@ type TencentCOSConfig struct {
// Default 返回本地开发默认配置。 // Default 返回本地开发默认配置。
func Default() Config { func Default() Config {
return Config{ return Config{
ServiceName: "gateway-service",
NodeID: "gateway-local",
Environment: "local",
HTTPAddr: ":13000", HTTPAddr: ":13000",
JWTSecret: "gateway-dev-secret", JWTSecret: "gateway-dev-secret",
RoomServiceAddr: "127.0.0.1:13001", RoomServiceAddr: "127.0.0.1:13001",
@ -139,6 +147,11 @@ func Default() Config {
RoomIDType: "string", RoomIDType: "string",
AppScene: "voice_chat_room", 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. // Normalize fills policy defaults and rejects unsupported RTC room/app modes at startup.
func (cfg *Config) Normalize() error { 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.RoomServiceAddr = strings.TrimSpace(cfg.RoomServiceAddr)
cfg.UserServiceAddr = strings.TrimSpace(cfg.UserServiceAddr) cfg.UserServiceAddr = strings.TrimSpace(cfg.UserServiceAddr)
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr) cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)

View File

@ -104,34 +104,8 @@ func writeRPCError(writer http.ResponseWriter, request *http.Request, err error)
} }
func mapReasonToHTTP(reason xerr.Code) (int, string, string) { func mapReasonToHTTP(reason xerr.Code) (int, string, string) {
switch reason { spec := xerr.SpecOf(reason)
case xerr.InvalidArgument, xerr.DisplayUserIDInvalid, xerr.InvalidInviteCode, xerr.InvalidSection, xerr.PageTokenInvalid: return spec.HTTPStatus, spec.PublicCode, spec.PublicMessage
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"
}
} }
// writeEnvelope 是 gateway 业务 API 的唯一 JSON 响应出口。 // writeEnvelope 是 gateway 业务 API 的唯一 JSON 响应出口。

View File

@ -1,12 +1,13 @@
package http package http
import ( import (
"log" "log/slog"
"net/http" "net/http"
"time" "time"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/roomid" "hyapp/pkg/roomid"
"hyapp/pkg/tencentrtc" "hyapp/pkg/tencentrtc"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
@ -34,6 +35,7 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized") writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized")
return return
} }
logCtx := logx.With(request.Context(), slog.String("request_id", requestIDFromContext(request.Context())), slog.String("app_code", appcode.FromContext(request.Context())))
rtcConfig := tencentrtc.TokenConfig{ rtcConfig := tencentrtc.TokenConfig{
Enabled: h.tencentRTC.Enabled, Enabled: h.tencentRTC.Enabled,
@ -44,7 +46,7 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
AppScene: h.tencentRTC.AppScene, AppScene: h.tencentRTC.AppScene,
} }
if err := tencentrtc.ValidateConfig(rtcConfig); err != nil { 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") writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
return return
} }
@ -72,12 +74,18 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
token, err := tencentrtc.GenerateToken(rtcConfig, userID, body.RoomID, timeNow()) token, err := tencentrtc.GenerateToken(rtcConfig, userID, body.RoomID, timeNow())
if err != nil { 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") writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
return 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) writeOK(writer, request, token)
} }
@ -89,7 +97,8 @@ func (h *Handler) writeRTCPresenceDenied(writer http.ResponseWriter, request *ht
roomVersion = guardResp.GetRoomVersion() 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" { if reason == "room_not_found" {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found") writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
return return

View File

@ -7,7 +7,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -15,6 +15,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/tencentrtc" "hyapp/pkg/tencentrtc"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
) )
@ -86,8 +87,16 @@ func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *
roomID, ok := parseTencentRTCRoomID(body.EventInfo.RoomID) roomID, ok := parseTencentRTCRoomID(body.EventInfo.RoomID)
userID, userOK := parseTencentIMUserID(body.EventInfo.UserID) userID, userOK := parseTencentIMUserID(body.EventInfo.UserID)
eventTimeMS := tencentRTCEventTimeMS(body) 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 { 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, "") writeTencentRTCCallback(writer, http.StatusOK, 0, "")
return return
} }
@ -112,16 +121,37 @@ func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *
}) })
if err != nil { if err != nil {
if tencentRTCCallbackCanAckBusinessError(err) { 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, "") writeTencentRTCCallback(writer, http.StatusOK, 0, "")
return 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") writeTencentRTCCallback(writer, http.StatusInternalServerError, 1, "room service error")
return 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, "") writeTencentRTCCallback(writer, http.StatusOK, 0, "")
} }

View File

@ -5,9 +5,11 @@ import (
"context" "context"
"flag" "flag"
"log" "log"
"os"
"os/signal" "os/signal"
"syscall" "syscall"
"hyapp/pkg/logx"
"hyapp/services/room-service/internal/app" "hyapp/services/room-service/internal/app"
"hyapp/services/room-service/internal/config" "hyapp/services/room-service/internal/config"
) )
@ -23,11 +25,14 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) 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。 // App.New 会建立 MySQL repository、Redis directory、wallet/im gRPC client 和 gRPC server。
application, err := app.New(cfg) application, err := app.New(cfg)
if err != nil { if err != nil {
log.Fatal(err) fatalRuntime("app_new_failed", err)
} }
// SIGINT/SIGTERM 是正常下线入口,下线时要先进入 draining 再停止 gRPC。 // SIGINT/SIGTERM 是正常下线入口,下线时要先进入 draining 再停止 gRPC。
@ -47,7 +52,12 @@ func main() {
case err := <-errCh: case err := <-errCh:
if err != nil { if err != nil {
// gRPC listener 异常退出属于进程级失败,必须暴露给 supervisor。 // 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)
}

View File

@ -1,4 +1,12 @@
service_name: room-service
environment: local
node_id: "room-node-1" 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" grpc_addr: ":13001"
lease_ttl: "10s" lease_ttl: "10s"
rank_limit: 20 rank_limit: 20

View File

@ -1,4 +1,12 @@
service_name: room-service
environment: prod
node_id: "room-node-prod-1" 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" grpc_addr: ":13001"
lease_ttl: "10s" lease_ttl: "10s"
rank_limit: 20 rank_limit: 20

View File

@ -1,4 +1,12 @@
service_name: room-service
environment: local
node_id: "room-node-1" 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" lease_ttl: "10s"
rank_limit: 20 rank_limit: 20
snapshot_every_n: 1 snapshot_every_n: 1

View File

@ -8,11 +8,16 @@ import (
"time" "time"
"hyapp/pkg/configx" "hyapp/pkg/configx"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
) )
// Config 描述 room-service 进程启动所需的最小配置。 // Config 描述 room-service 进程启动所需的最小配置。
type Config struct { 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 是当前 room-service 实例标识Redis lease owner 使用它。
NodeID string `yaml:"node_id"` NodeID string `yaml:"node_id"`
// GRPCAddr 是内部 gRPC 监听地址gateway-service 通过它访问 room-service。 // GRPCAddr 是内部 gRPC 监听地址gateway-service 通过它访问 room-service。
@ -51,6 +56,8 @@ type Config struct {
MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"` MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"`
// OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。 // OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"` OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
// Log 控制 stdout 结构化日志格式、等级和 access body 策略。
Log logx.Config `yaml:"log"`
} }
// OutboxWorkerConfig 控制 room_outbox pending 事件的补偿投递循环。 // OutboxWorkerConfig 控制 room_outbox pending 事件的补偿投递循环。
@ -110,6 +117,8 @@ func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig {
func Default() Config { func Default() Config {
// 默认端口遵守项目 13xxx 约束MySQL/Redis 指向本地 Docker 暴露端口。 // 默认端口遵守项目 13xxx 约束MySQL/Redis 指向本地 Docker 暴露端口。
return Config{ return Config{
ServiceName: "room-service",
Environment: "local",
NodeID: "room-node-local", NodeID: "room-node-local",
GRPCAddr: ":13001", GRPCAddr: ":13001",
LeaseTTL: 10 * time.Second, LeaseTTL: 10 * time.Second,
@ -135,6 +144,11 @@ func Default() Config {
MicPublishTimeout: 15 * time.Second, MicPublishTimeout: 15 * time.Second,
MicPublishScanInterval: time.Second, MicPublishScanInterval: time.Second,
OutboxWorker: defaultOutboxWorkerConfig(), OutboxWorker: defaultOutboxWorkerConfig(),
Log: logx.Config{
Level: "info",
Format: "json",
MaxPayloadBytes: 2048,
},
} }
} }
@ -152,6 +166,18 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig {
// Normalize 补齐动态配置默认值,并拒绝会改变补偿语义的未知策略。 // Normalize 补齐动态配置默认值,并拒绝会改变补偿语义的未知策略。
func Normalize(cfg Config) (Config, error) { func Normalize(cfg Config) (Config, error) {
// 配置归一化只处理需要跨环境保持语义的字段,其他默认值由 Default 提供。 // 配置归一化只处理需要跨环境保持语义的字段,其他默认值由 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) outboxWorker, err := normalizeOutboxWorkerConfig(cfg.OutboxWorker)
if err != nil { if err != nil {
return Config{}, err return Config{}, err

View File

@ -2,10 +2,11 @@ package service
import ( import (
"context" "context"
"log" "log/slog"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/state" "hyapp/services/room-service/internal/room/state"
) )
@ -92,7 +93,11 @@ func (s *Service) projectRoomPresenceBestEffort(ctx context.Context, snapshot *r
return return
} }
if err := s.repository.ProjectRoomPresence(ctx, projection); err != nil { 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),
)
} }
} }

View File

@ -4,11 +4,12 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"log" "log/slog"
"strings" "strings"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr" "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()) meta, exists, err := s.repository.GetRoomMeta(ctx, snapshot.GetRoomId())
if err != nil { if err != nil {
// 投影不能回滚已提交 Room Cell 命令,先记录日志;后续可用 projection task 补偿。 // 投影不能回滚已提交 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 return
} }
if !exists { 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 return
} }
@ -114,7 +115,7 @@ func (s *Service) projectRoomListBestEffort(ctx context.Context, snapshot *roomv
entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS) entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS)
if err := s.repository.UpsertRoomListEntry(ctx, entry); err != nil { 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))
} }
} }

View File

@ -3,11 +3,12 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log/slog"
"strings" "strings"
"time" "time"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/logx"
) )
const ( const (
@ -68,12 +69,12 @@ func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptio
options = normalizeOutboxWorkerOptions(options) options = normalizeOutboxWorkerOptions(options)
if options.RetryStrategy != outboxRetryFixedInterval { 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 return
} }
if err := s.ProcessPendingOutbox(ctx, options); err != nil && ctx.Err() == nil { 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) ticker := time.NewTicker(options.PollInterval)
@ -85,7 +86,7 @@ func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptio
return return
case <-ticker.C: case <-ticker.C:
if err := s.ProcessPendingOutbox(ctx, options); err != nil && ctx.Err() == nil { 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 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 continue
} }
@ -135,7 +144,14 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
return markErr 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 return nil

View File

@ -3,10 +3,11 @@ package service
import ( import (
"bytes" "bytes"
"context" "context"
"log" "log/slog"
"time" "time"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox" "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) markErr := s.repository.MarkOutboxDelivered(markCtx, result.syncEvent.EventID)
cancel() cancel()
if markErr != nil { 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),
)
} }
} }
} }

View File

@ -5,9 +5,11 @@ import (
"context" "context"
"flag" "flag"
"log" "log"
"os"
"os/signal" "os/signal"
"syscall" "syscall"
"hyapp/pkg/logx"
"hyapp/services/user-service/internal/app" "hyapp/services/user-service/internal/app"
"hyapp/services/user-service/internal/config" "hyapp/services/user-service/internal/config"
) )
@ -23,11 +25,14 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) 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 服务。 // App.New 会建立 MySQL repository、创建 auth/user service并注册 gRPC 服务。
application, err := app.New(cfg) application, err := app.New(cfg)
if err != nil { if err != nil {
log.Fatal(err) fatalRuntime("app_new_failed", err)
} }
// SIGINT/SIGTERM 是正常下线入口Close 内部会进入 draining 并 GracefulStop。 // SIGINT/SIGTERM 是正常下线入口Close 内部会进入 draining 并 GracefulStop。
@ -47,7 +52,12 @@ func main() {
case err := <-errCh: case err := <-errCh:
if err != nil { if err != nil {
// listener 异常退出属于进程级失败,必须暴露给 supervisor。 // 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)
}

View File

@ -1,5 +1,12 @@
service_name: user-service service_name: user-service
node_id: user-docker 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" grpc_addr: ":13005"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local"
mysql_auto_migrate: false 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" room_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
poll_interval_sec: 5 poll_interval_sec: 5
batch_size: 100 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: jwt:
issuer: "hyapp" issuer: "hyapp"
access_token_ttl_sec: 1800 access_token_ttl_sec: 1800

View File

@ -1,5 +1,12 @@
service_name: user-service service_name: user-service
node_id: user-tencent 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" grpc_addr: ":13005"
mysql_dsn: "${TENCENT_MYSQL_USER_DSN}" mysql_dsn: "${TENCENT_MYSQL_USER_DSN}"
mysql_auto_migrate: false mysql_auto_migrate: false
@ -30,6 +37,11 @@ mic_time_worker:
room_mysql_dsn: "${TENCENT_MYSQL_ROOM_DSN}" room_mysql_dsn: "${TENCENT_MYSQL_ROOM_DSN}"
poll_interval_sec: 5 poll_interval_sec: 5
batch_size: 100 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: jwt:
issuer: "hyapp" issuer: "hyapp"
access_token_ttl_sec: 1800 access_token_ttl_sec: 1800

View File

@ -1,5 +1,12 @@
service_name: user-service service_name: user-service
node_id: user-local 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" grpc_addr: ":13005"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local"
mysql_auto_migrate: false 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" room_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
poll_interval_sec: 5 poll_interval_sec: 5
batch_size: 100 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: jwt:
issuer: "hyapp" issuer: "hyapp"
access_token_ttl_sec: 1800 access_token_ttl_sec: 1800

View File

@ -415,6 +415,7 @@ CREATE TABLE IF NOT EXISTS coin_seller_profiles (
user_id BIGINT NOT NULL PRIMARY KEY, user_id BIGINT NOT NULL PRIMARY KEY,
status VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL,
merchant_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN_SELLER_COIN', 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_by_user_id BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL, created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL,

View File

@ -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) err := a.server.Serve(a.listener)
a.health.MarkStopped() a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) { if errors.Is(err, grpc.ErrServerStopped) {

View File

@ -1,7 +1,12 @@
// Package config 定义 user-service 的启动配置和 YAML 加载入口。 // Package config 定义 user-service 的启动配置和 YAML 加载入口。
package config package config
import "hyapp/pkg/configx" import (
"strings"
"hyapp/pkg/configx"
"hyapp/pkg/logx"
)
// Config 描述 user-service 启动配置。 // Config 描述 user-service 启动配置。
type Config struct { type Config struct {
@ -9,6 +14,8 @@ type Config struct {
ServiceName string `yaml:"service_name"` ServiceName string `yaml:"service_name"`
// NodeID 是当前服务节点标识,预留给日志和后续健康响应使用。 // NodeID 是当前服务节点标识,预留给日志和后续健康响应使用。
NodeID string `yaml:"node_id"` NodeID string `yaml:"node_id"`
// Environment 是日志和部署策略使用的环境标签。
Environment string `yaml:"environment"`
// GRPCAddr 是 user-service 内部 gRPC 监听地址。 // GRPCAddr 是 user-service 内部 gRPC 监听地址。
GRPCAddr string `yaml:"grpc_addr"` GRPCAddr string `yaml:"grpc_addr"`
// MySQLDSN 是用户主数据、认证身份、session 和短号事务的必需存储连接串。 // MySQLDSN 是用户主数据、认证身份、session 和短号事务的必需存储连接串。
@ -29,6 +36,8 @@ type Config struct {
MicTimeWorker MicTimeWorkerConfig `yaml:"mic_time_worker"` MicTimeWorker MicTimeWorkerConfig `yaml:"mic_time_worker"`
// JWT 控制 access token 和 refresh session 的签发参数。 // JWT 控制 access token 和 refresh session 的签发参数。
JWT JWTConfig `yaml:"jwt"` JWT JWTConfig `yaml:"jwt"`
// Log 控制 stdout 结构化日志格式、等级和 access body 策略。
Log logx.Config `yaml:"log"`
} }
// IDGeneratorConfig 保存 user_id 发号节点配置。 // IDGeneratorConfig 保存 user_id 发号节点配置。
@ -97,6 +106,16 @@ type MicTimeWorkerConfig struct {
PollIntervalSec int64 `yaml:"poll_interval_sec"` PollIntervalSec int64 `yaml:"poll_interval_sec"`
// BatchSize 是单轮最多消费的 RoomMicChanged 事件数。 // BatchSize 是单轮最多消费的 RoomMicChanged 事件数。
BatchSize int `yaml:"batch_size"` 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 的签发策略。 // JWTConfig 保存 access token 和 refresh token 的签发策略。
@ -119,6 +138,7 @@ func Default() Config {
return Config{ return Config{
ServiceName: "user-service", ServiceName: "user-service",
NodeID: "user-local", NodeID: "user-local",
Environment: "local",
GRPCAddr: ":13005", GRPCAddr: ":13005",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local",
MySQLAutoMigrate: false, MySQLAutoMigrate: false,
@ -148,10 +168,15 @@ func Default() Config {
BatchSize: 100, BatchSize: 100,
}, },
MicTimeWorker: MicTimeWorkerConfig{ MicTimeWorker: MicTimeWorkerConfig{
Enabled: true, Enabled: true,
RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local", RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local",
PollIntervalSec: 5, PollIntervalSec: 5,
BatchSize: 100, BatchSize: 100,
CompensationEnabled: true,
CompensationIntervalSec: 60,
CompensationBatchSize: 100,
PendingPublishMaxAgeSec: 120,
PublishingSessionMaxAgeSec: 43200,
}, },
JWT: JWTConfig{ JWT: JWTConfig{
Issuer: "hyapp", Issuer: "hyapp",
@ -160,6 +185,11 @@ func Default() Config {
SigningAlg: "HS256", SigningAlg: "HS256",
SigningSecret: "dev-shared-secret", 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 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 return cfg, nil
} }

View File

@ -27,6 +27,18 @@ const (
SessionStatusPublishing = "publishing" SessionStatusPublishing = "publishing"
// SessionStatusEnded is terminal and must never be reopened by replayed events. // SessionStatusEnded is terminal and must never be reopened by replayed events.
SessionStatusEnded = "ended" 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. // RoomMicEvent is the decoded RoomMicChanged fact used by user-service duration aggregation.
@ -64,6 +76,21 @@ type ApplyResult struct {
Reason string 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. // LifetimeStats is the user-wide aggregate consumed by host tasks and profile views.
type LifetimeStats struct { type LifetimeStats struct {
AppCode string AppCode string

View File

@ -2,13 +2,14 @@ package auth
import ( import (
"context" "context"
"log" "log/slog"
"strings" "strings"
"time" "time"
_ "time/tzdata" _ "time/tzdata"
"unicode/utf8" "unicode/utf8"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
authdomain "hyapp/services/user-service/internal/domain/auth" authdomain "hyapp/services/user-service/internal/domain/auth"
invitedomain "hyapp/services/user-service/internal/domain/invite" 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) { if !xerr.IsCode(err, xerr.NotFound) {
// 非 not found 的存储错误不能退化为注册。 // 非 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))) s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, 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))) 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 return authdomain.Token{}, false, err
} }

View File

@ -3,9 +3,10 @@ package invite
import ( import (
"context" "context"
"log" "log/slog"
"time" "time"
"hyapp/pkg/logx"
invitedomain "hyapp/services/user-service/internal/domain/invite" 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. // RunWalletRechargeWorker consumes WalletRechargeRecorded facts and advances valid_invite_count.
func (s *Service) RunWalletRechargeWorker(ctx context.Context, options WorkerOptions) { func (s *Service) RunWalletRechargeWorker(ctx context.Context, options WorkerOptions) {
if s == nil || s.repository == nil || s.reader == nil { 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 return
} }
if options.PollInterval <= 0 { if options.PollInterval <= 0 {
@ -54,7 +55,7 @@ func (s *Service) RunWalletRechargeWorker(ctx context.Context, options WorkerOpt
for { for {
processed, err := s.processBatch(ctx, &cursor, options.BatchSize) processed, err := s.processBatch(ctx, &cursor, options.BatchSize)
if err != nil { 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 { if ctx.Err() != nil {
return return

View File

@ -3,9 +3,10 @@ package mictime
import ( import (
"context" "context"
"log" "log/slog"
"time" "time"
"hyapp/pkg/logx"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
mictimedomain "hyapp/services/user-service/internal/domain/mictime" mictimedomain "hyapp/services/user-service/internal/domain/mictime"
) )
@ -13,6 +14,7 @@ import (
// Repository applies room mic events and returns user duration aggregates. // Repository applies room mic events and returns user duration aggregates.
type Repository interface { type Repository interface {
ProcessRoomMicEvent(ctx context.Context, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) 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) GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error)
} }
@ -34,6 +36,15 @@ type WorkerOptions struct {
BatchSize int 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. // New creates a mic time service with persistence and optional room outbox reader.
func New(repository Repository, reader RoomOutboxReader) *Service { func New(repository Repository, reader RoomOutboxReader) *Service {
return &Service{repository: repository, reader: reader} 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. // RunRoomMicWorker consumes RoomMicChanged facts from room-service outbox.
func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) { func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) {
if s == nil || s.repository == nil || s.reader == nil { 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 return
} }
if options.PollInterval <= 0 { if options.PollInterval <= 0 {
@ -64,7 +75,7 @@ func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) {
for { for {
processed, err := s.processBatch(ctx, &cursor, options.BatchSize) processed, err := s.processBatch(ctx, &cursor, options.BatchSize)
if err != nil { 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 { if ctx.Err() != nil {
return 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) { func (s *Service) processBatch(ctx context.Context, cursor *mictimedomain.RoomOutboxCursor, batchSize int) (int, error) {
events, err := s.reader.ListRoomMicEventsAfter(ctx, *cursor, batchSize) events, err := s.reader.ListRoomMicEventsAfter(ctx, *cursor, batchSize)
if err != nil { if err != nil {
@ -95,3 +145,16 @@ func (s *Service) processBatch(ctx context.Context, cursor *mictimedomain.RoomOu
} }
return len(events), nil 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
}

View File

@ -2,10 +2,11 @@ package user
import ( import (
"context" "context"
"log" "log/slog"
"strings" "strings"
"time" "time"
"hyapp/pkg/logx"
userdomain "hyapp/services/user-service/internal/domain/user" 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) { func (s *Service) RunRegionRebuildWorker(ctx context.Context, options RegionRebuildWorkerOptions) {
options = normalizeRegionRebuildWorkerOptions(options) options = normalizeRegionRebuildWorkerOptions(options)
if s.regionRebuildRepository == nil { 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 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) result, err := s.regionRebuildRepository.ProcessNextRegionRebuildTask(ctx, options.WorkerID, s.now().UnixMilli(), options.LockTTL, options.BatchSize)
if err != nil { 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 return result, err
} }
if result.NoTask { if result.NoTask {
return result, nil 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 return result, nil
} }

View File

@ -4,7 +4,9 @@ package mictime
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt"
"strings" "strings"
"time" "time"
@ -19,6 +21,13 @@ type Repository struct {
db *sql.DB 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. // New creates a mic duration repository backed by the shared user-service connection pool.
func New(db *sql.DB) *Repository { func New(db *sql.DB) *Repository {
return &Repository{db: db} return &Repository{db: db}
@ -121,6 +130,83 @@ func (r *Repository) GetLifetimeStats(ctx context.Context, appCode string, userI
return stats, nil 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) { func applyUp(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
seatNo := event.ToSeat seatNo := event.ToSeat
if seatNo <= 0 { 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 return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil
} }
endAt := event.OccurredAtMs endReason := strings.TrimSpace(event.Reason)
if endAt < session.SeatStartedAtMs { if endReason == "" {
endAt = session.SeatStartedAtMs 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) micOnlineMs := int64(0)
if session.PublishingStartedAtMs.Valid { if session.PublishingStartedAtMs.Valid {
publishStart := session.PublishingStartedAtMs.Int64 publishStart := session.PublishingStartedAtMs.Int64
if publishStart < session.SeatStartedAtMs { if publishStart < session.SeatStartedAtMs {
publishStart = session.SeatStartedAtMs publishStart = session.SeatStartedAtMs
} }
micOnlineMs = maxInt64(0, endAt-publishStart) micOnlineMs = maxInt64(0, input.EndAtMs-publishStart)
}
endReason := strings.TrimSpace(event.Reason)
if endReason == "" {
endReason = "down"
} }
_, err = tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
UPDATE user_mic_sessions UPDATE user_mic_sessions
SET status = ?, ended_at_ms = ?, end_reason = ?, seat_occupied_ms = ?, mic_online_ms = ?, 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 = ? 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 <> ? WHERE app_code = ? AND mic_session_id = ? AND status <> ?
`, mictimedomain.SessionStatusEnded, endAt, endReason, seatOccupiedMs, micOnlineMs, `, mictimedomain.SessionStatusEnded, input.EndAtMs, input.EndReason, seatOccupiedMs, micOnlineMs,
event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded) input.ClosedEventID, input.ClosedEventID, input.RoomVersion, input.UpdatedAtMs, session.AppCode, session.MicSessionID, mictimedomain.SessionStatusEnded)
if err != nil { if err != nil {
return mictimedomain.ApplyResult{}, err return err
} }
if err := incrementLifetime(ctx, tx, session, event, endAt, seatOccupiedMs, micOnlineMs); err != nil { if err := incrementLifetime(ctx, tx, session, input, seatOccupiedMs, micOnlineMs); err != nil {
return mictimedomain.ApplyResult{}, err return err
} }
if err := incrementDaily(ctx, tx, session, event, endAt); err != nil { if err := incrementDaily(ctx, tx, session, input); err != nil {
return mictimedomain.ApplyResult{}, err 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, ` _, err := tx.ExecContext(ctx, `
INSERT INTO user_mic_lifetime_stats ( 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 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, 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)), last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)),
updated_at_ms = VALUES(updated_at_ms) updated_at_ms = VALUES(updated_at_ms)
`, event.AppCode, session.UserID, seatMs, micMs, session.SeatStartedAtMs, endAt, event.OccurredAtMs) `, session.AppCode, session.UserID, seatMs, micMs, session.SeatStartedAtMs, input.EndAtMs, input.UpdatedAtMs)
return err 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 { func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput) error {
seatParts := splitByUTCDay(session.SeatStartedAtMs, endAt) seatParts := splitByUTCDay(session.SeatStartedAtMs, input.EndAtMs)
micParts := map[string]dayDuration{} micParts := map[string]dayDuration{}
if session.PublishingStartedAtMs.Valid { if session.PublishingStartedAtMs.Valid {
publishStart := session.PublishingStartedAtMs.Int64 publishStart := session.PublishingStartedAtMs.Int64
if publishStart < session.SeatStartedAtMs { if publishStart < session.SeatStartedAtMs {
publishStart = session.SeatStartedAtMs publishStart = session.SeatStartedAtMs
} }
micParts = splitByUTCDay(publishStart, endAt) micParts = splitByUTCDay(publishStart, input.EndAtMs)
} }
dates := map[string]struct{}{} dates := map[string]struct{}{}
for date := range seatParts { 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, 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)), last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)),
updated_at_ms = VALUES(updated_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 { if err != nil {
return err 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 return nil
} }
type micSessionRow struct { type micSessionRow struct {
AppCode string
MicSessionID string
UserID int64 UserID int64
RoomID string RoomID string
FirstSeatNo int32 FirstSeatNo int32
@ -354,12 +509,12 @@ type micSessionRow struct {
func sessionForUpdate(ctx context.Context, tx *sql.Tx, appCode string, micSessionID string) (micSessionRow, bool, error) { func sessionForUpdate(ctx context.Context, tx *sql.Tx, appCode string, micSessionID string) (micSessionRow, bool, error) {
var session micSessionRow var session micSessionRow
err := tx.QueryRowContext(ctx, ` 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 publishing_started_at_ms, ended_at_ms, last_room_version
FROM user_mic_sessions FROM user_mic_sessions
WHERE app_code = ? AND mic_session_id = ? WHERE app_code = ? AND mic_session_id = ?
FOR UPDATE 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) &session.SeatStartedAtMs, &session.PublishingStartedAtMs, &session.EndedAtMs, &session.LastRoomVersion)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return micSessionRow{}, false, nil return micSessionRow{}, false, nil
@ -410,6 +565,52 @@ func normalizeRoomMicEvent(event mictimedomain.RoomMicEvent) mictimedomain.RoomM
return event 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 { type dayDuration struct {
DurationMs int64 DurationMs int64
FirstAtMs int64 FirstAtMs int64

View File

@ -2,6 +2,7 @@ package mictime_test
import ( import (
"context" "context"
"database/sql"
"testing" "testing"
"time" "time"
@ -10,7 +11,8 @@ import (
) )
func TestProcessRoomMicEventsAggregatesLifetimeAndDeduplicates(t *testing.T) { func TestProcessRoomMicEventsAggregatesLifetimeAndDeduplicates(t *testing.T) {
repo := mysqltest.NewRepository(t).Repository.MicTimeRepository() wrapped := mysqltest.NewRepository(t)
repo := wrapped.Repository.MicTimeRepository()
ctx := context.Background() ctx := context.Background()
base := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC).UnixMilli() base := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC).UnixMilli()
micSessionID := "mic_session_normal" 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 { if stats.SeatOccupiedMs != 11*60_000 || stats.MicOnlineMs != 10*60_000 || stats.SessionCount != 1 {
t.Fatalf("normal mic duration mismatch: %+v", stats) 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)) 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" { 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) { func TestProcessCrossDayMicSessionKeepsLifetimeTotals(t *testing.T) {
repo := mysqltest.NewRepository(t).Repository.MicTimeRepository() repo := mysqltest.NewRepository(t).Repository.MicTimeRepository()
ctx := context.Background() ctx := context.Background()
@ -113,3 +166,34 @@ func roomMicEvent(eventID string, micSessionID string, action string, userID int
} }
return event 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)
}
}

View File

@ -3,6 +3,7 @@ package mysqltest
import ( import (
"context" "context"
"database/sql"
"strings" "strings"
"testing" "testing"
"time" "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 继续满足业务层用户主数据接口。 // GetUser 让测试 wrapper 继续满足业务层用户主数据接口。
func (r *Repository) GetUser(ctx context.Context, userID int64) (userdomain.User, error) { func (r *Repository) GetUser(ctx context.Context, userID int64) (userdomain.User, error) {
return r.Repository.UserRepository().GetUser(ctx, userID) return r.Repository.UserRepository().GetUser(ctx, userID)

View File

@ -4,9 +4,11 @@ import (
"context" "context"
"flag" "flag"
"log" "log"
"os"
"os/signal" "os/signal"
"syscall" "syscall"
"hyapp/pkg/logx"
"hyapp/services/wallet-service/internal/app" "hyapp/services/wallet-service/internal/app"
"hyapp/services/wallet-service/internal/config" "hyapp/services/wallet-service/internal/config"
) )
@ -20,10 +22,13 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) 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) application, err := app.New(cfg)
if err != nil { if err != nil {
log.Fatal(err) fatalRuntime("app_new_failed", err)
} }
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@ -39,7 +44,12 @@ func main() {
application.Close() application.Close()
case err := <-errCh: case err := <-errCh:
if err != nil { 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)
}

View File

@ -1,5 +1,12 @@
service_name: wallet-service service_name: wallet-service
node_id: wallet-docker 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" grpc_addr: ":13004"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
mysql_auto_migrate: false mysql_auto_migrate: false

View File

@ -1,5 +1,12 @@
service_name: wallet-service service_name: wallet-service
node_id: wallet-tencent 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" grpc_addr: ":13004"
mysql_dsn: "${TENCENT_MYSQL_WALLET_DSN}" mysql_dsn: "${TENCENT_MYSQL_WALLET_DSN}"
mysql_auto_migrate: false mysql_auto_migrate: false

View File

@ -1,5 +1,12 @@
service_name: wallet-service service_name: wallet-service
node_id: wallet-local 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" grpc_addr: ":13004"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local" mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
mysql_auto_migrate: false mysql_auto_migrate: false

View File

@ -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) KEY idx_wallet_recharge_records_region_time (app_code, target_region_id, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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 ( CREATE TABLE IF NOT EXISTS resources (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu', app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
resource_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, resource_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,

View File

@ -1,16 +1,23 @@
package config package config
import "hyapp/pkg/configx" import (
"strings"
"hyapp/pkg/configx"
"hyapp/pkg/logx"
)
// Config 描述 wallet-service 启动配置。 // Config 描述 wallet-service 启动配置。
type Config struct { type Config struct {
ServiceName string `yaml:"service_name"` ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"` NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
GRPCAddr string `yaml:"grpc_addr"` GRPCAddr string `yaml:"grpc_addr"`
// MySQLDSN 是账户、交易、分录和 outbox 的必需存储连接串。 // MySQLDSN 是账户、交易、分录和 outbox 的必需存储连接串。
MySQLDSN string `yaml:"mysql_dsn"` MySQLDSN string `yaml:"mysql_dsn"`
// MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。 // MySQLAutoMigrate 保留给显式本地迁移;线上 schema 由发布流程或 initdb 管理。
MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"` MySQLAutoMigrate bool `yaml:"mysql_auto_migrate"`
Log logx.Config `yaml:"log"`
} }
// Default 返回本地开发默认配置。 // Default 返回本地开发默认配置。
@ -18,9 +25,15 @@ func Default() Config {
return Config{ return Config{
ServiceName: "wallet-service", ServiceName: "wallet-service",
NodeID: "wallet-local", NodeID: "wallet-local",
Environment: "local",
GRPCAddr: ":13004", GRPCAddr: ":13004",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local", MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local",
MySQLAutoMigrate: false, MySQLAutoMigrate: false,
Log: logx.Config{
Level: "info",
Format: "json",
MaxPayloadBytes: 2048,
},
} }
} }
@ -35,5 +48,18 @@ func Load(path string) (Config, error) {
return Config{}, err 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 return cfg, nil
} }

View File

@ -13,6 +13,13 @@ const (
AssetDiamond = "DIAMOND" AssetDiamond = "DIAMOND"
// AssetUSDBalance 是主播可提现美元余额,单位由后续提现策略固定。 // AssetUSDBalance 是主播可提现美元余额,单位由后续提现策略固定。
AssetUSDBalance = "USD_BALANCE" AssetUSDBalance = "USD_BALANCE"
// StockTypeUSDTPurchase 表示币商线下 USDT 进货后发放专用金币库存。
StockTypeUSDTPurchase = "usdt_purchase"
// StockTypeCoinCompensation 表示平台人工补偿币商专用金币库存,不计入进货金额。
StockTypeCoinCompensation = "coin_compensation"
// PaidCurrencyUSDT 是首版币商进货支持的唯一线下付款币种。
PaidCurrencyUSDT = "USDT"
) )
// DebitGiftCommand 是 room-service 送礼扣费的账务命令。 // DebitGiftCommand 是 room-service 送礼扣费的账务命令。
@ -67,6 +74,34 @@ type CoinSellerTransferReceipt struct {
RechargePolicyUSDMinorUnit int64 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 保存。 // RechargeBill 是充值账单的只读投影;充值事实由 wallet_recharge_records 保存。
type RechargeBill struct { type RechargeBill struct {
AppCode string AppCode string
@ -154,3 +189,14 @@ func ValidGiftChargeAssetType(assetType string) bool {
return false return false
} }
} }
func NormalizeCoinSellerStockType(stockType string) string {
switch strings.ToLower(strings.TrimSpace(stockType)) {
case StockTypeUSDTPurchase:
return StockTypeUSDTPurchase
case StockTypeCoinCompensation:
return StockTypeCoinCompensation
default:
return ""
}
}

View File

@ -15,6 +15,7 @@ type Repository interface {
DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error) DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error)
GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error)
AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, 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) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error)
ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error) ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error)
ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, 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) 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 校验并传入。 // TransferCoinFromSeller 执行币商向玩家转金币;身份和区域都由 gateway/user-service 校验并传入。
func (s *Service) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) { 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 { if command.CommandID == "" || command.SellerUserID <= 0 || command.TargetUserID <= 0 || command.Amount <= 0 {

View File

@ -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 的资源。 // TestGiftConfigMustSelectGiftResource 验证后台新增礼物只能绑定 resource_type=gift 的资源。
func TestGiftConfigMustSelectGiftResource(t *testing.T) { func TestGiftConfigMustSelectGiftResource(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)

View File

@ -17,15 +17,17 @@ import (
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger" "hyapp/services/wallet-service/internal/domain/ledger"
_ "github.com/go-sql-driver/mysql" mysqlDriver "github.com/go-sql-driver/mysql"
) )
const ( const (
transactionStatusSucceeded = "succeeded" transactionStatusSucceeded = "succeeded"
bizTypeGiftDebit = "gift_debit" bizTypeGiftDebit = "gift_debit"
bizTypeManualCredit = "manual_credit" bizTypeManualCredit = "manual_credit"
bizTypeCoinSellerTransfer = "coin_seller_transfer" bizTypeCoinSellerTransfer = "coin_seller_transfer"
outboxStatusPending = "pending" bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
outboxStatusPending = "pending"
) )
// Repository 是 wallet-service 的 MySQL v2 账本入口。 // Repository 是 wallet-service 的 MySQL v2 账本入口。
@ -345,6 +347,106 @@ func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminC
return balance, transactionID, nil 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 入账,并记录充值口径。 // TransferCoinFromSeller 在同一事务里扣币商专用金币、给玩家普通 COIN 入账,并记录充值口径。
func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) { func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) {
if r == nil || r.db == nil { 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) { 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, row := tx.QueryRowContext(ctx,
`SELECT transaction_id, request_hash, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}') `SELECT transaction_id, request_hash, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}')
FROM wallet_transactions FROM wallet_transactions
@ -489,7 +595,7 @@ func (r *Repository) lookupTransaction(ctx context.Context, tx *sql.Tx, commandI
} }
if storedHash != requestHash || storedBizType != bizType { if storedHash != requestHash || storedBizType != bizType {
// command_id 是钱包幂等主键,业务 payload 或命令类型变化必须 fail-closed。 // 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 return txRow, true, nil
} }
@ -729,6 +835,65 @@ func (r *Repository) insertRechargeRecord(ctx context.Context, tx *sql.Tx, trans
return err 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 { type walletEntry struct {
AppCode string AppCode string
TransactionID string TransactionID string
@ -895,6 +1060,23 @@ type coinSellerTransferMetadata struct {
RechargePolicyUSDMinorAmount int64 `json:"recharge_policy_usd_minor_amount"` 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 { func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger.Receipt {
chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType) chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType)
chargeAmount := metadata.ChargeAmount 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) { func (r *Repository) receiptForCoinSellerTransferTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerTransferReceipt, error) {
var metadataJSON string var metadataJSON string
if err := tx.QueryRowContext(ctx, 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 { func giftDebitedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, payload any, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{ return walletOutboxEvent{
EventID: eventID(transactionID, "WalletGiftDebited", userID, assetType), EventID: eventID(transactionID, "WalletGiftDebited", userID, assetType),
@ -1058,6 +1310,13 @@ func checkedMul(unit int64, count int64) (int64, error) {
return unit * count, nil 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 { func debitRequestHash(command ledger.DebitGiftCommand) string {
// 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。 // 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s", 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 { func coinSellerTransferRequestHash(command ledger.CoinSellerTransferCommand) string {
return stableHash(fmt.Sprintf("coin_seller_transfer|%s|%d|%d|%d|%d|%d|%s", return stableHash(fmt.Sprintf("coin_seller_transfer|%s|%d|%d|%d|%d|%d|%s",
appcode.Normalize(command.AppCode), 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 { func stableHash(value string) string {
sum := sha256.Sum256([]byte(value)) sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
@ -1112,6 +1397,10 @@ func transactionID(appCode string, commandID string) string {
return "wtx_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) 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 { func billingReceiptID(appCode string, commandID string) string {
return "bill_" + stableHash(appcode.Normalize(appCode)+"|"+commandID) 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 { func eventID(transactionID string, eventType string, userID int64, assetType string) string {
return "wev_" + stableHash(fmt.Sprintf("%s|%s|%d|%s", transactionID, eventType, userID, assetType)) 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
}

View File

@ -158,6 +158,7 @@ func initDBPath(t testing.TB) string {
func validateTableName(table string) string { func validateTableName(table string) string {
switch table { switch table {
case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records", 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": "resources", "resource_groups", "resource_group_items", "gift_configs", "gift_config_regions", "resource_grants", "resource_grant_items", "user_resource_entitlements", "user_resource_equipment":
return table return table
default: default:

View File

@ -104,6 +104,39 @@ func (s *Server) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCredit
}, nil }, 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 处理币商转玩家金币,调用方必须先完成币商身份校验。 // TransferCoinFromSeller 处理币商转玩家金币,调用方必须先完成币商身份校验。
func (s *Server) TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) { func (s *Server) TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode()) ctx = appcode.WithContext(ctx, req.GetAppCode())