日志服务相关

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

File diff suppressed because it is too large Load Diff

View File

@ -71,6 +71,33 @@ message AdminCreditAssetResponse {
AssetBalance balance = 2;
}
// AdminCreditCoinSellerStockRequest active
message AdminCreditCoinSellerStockRequest {
string command_id = 1;
int64 seller_user_id = 2;
string stock_type = 3;
int64 coin_amount = 4;
string paid_currency_code = 5;
int64 paid_amount_micro = 6;
string payment_ref = 7;
string evidence_ref = 8;
int64 operator_user_id = 9;
string reason = 10;
string app_code = 11;
}
message AdminCreditCoinSellerStockResponse {
string transaction_id = 1;
int64 seller_user_id = 2;
string stock_type = 3;
int64 coin_amount = 4;
string paid_currency_code = 5;
int64 paid_amount_micro = 6;
bool counts_as_seller_recharge = 7;
int64 balance_after = 8;
int64 created_at_ms = 9;
}
// TransferCoinFromSellerRequest
message TransferCoinFromSellerRequest {
string command_id = 1;
@ -558,6 +585,7 @@ service WalletService {
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse);
rpc AdminCreditAsset(AdminCreditAssetRequest) returns (AdminCreditAssetResponse);
rpc AdminCreditCoinSellerStock(AdminCreditCoinSellerStockRequest) returns (AdminCreditCoinSellerStockResponse);
rpc TransferCoinFromSeller(TransferCoinFromSellerRequest) returns (TransferCoinFromSellerResponse);
rpc ListResources(ListResourcesRequest) returns (ListResourcesResponse);
rpc GetResource(GetResourceRequest) returns (GetResourceResponse);

View File

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

264
docs/api-endpoints.md Normal file
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
建议扩展 `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(...)`

View File

@ -270,22 +270,29 @@ user_mic_event_consumption(
## Open Session Compensation
需要一个补偿 worker 处理异常 open session
user-service 已内置 open session compensation worker用于兜底关闭 room-service down 事件缺失导致的异常 open session。补偿只修正用户麦时长事实不反写 Room Cell也不向客户端发送房间控制消息。
| Case | Action |
| --- | --- |
| `pending_publish` 超过 room-service publish timeout | 等 room-service `publish_timeout` down 事件;若长期没有事件,按异常关闭 |
| `publishing` 长时间没有 down但用户 presence 已离开 | 使用 presence/room close 事实补偿关闭 |
| 房间关闭但仍有 open session | 以 `RoomClosed.occurred_at_ms` 关闭 |
| 消费者停机后恢复 | 从 `room_outbox` 或 MQ 位点继续消费;必要时用未结束 session 扫描补偿 |
| `pending_publish` 超过 `pending_publish_max_age_sec` | 关闭 session`end_reason=compensated_publish_timeout``mic_online_ms=0` |
| `publishing` 超过 `publishing_session_max_age_sec` | 关闭 session`end_reason=compensated_publishing_max_window`,按确认发流后到截断点累计 `mic_online_ms` |
| 消费者停机后恢复 | room outbox worker 继续从位点扫描;补偿 worker 扫描 MySQL open session 兜底 |
补偿关闭必须写 `end_reason=compensated_*`,不能伪装成用户主动下麦
补偿扫描使用 MySQL `FOR UPDATE SKIP LOCKED` 锁定候选 session。单批 claim、关闭、daily/lifetime 聚合和 outbox 写入在同一个事务内完成,避免关闭成功但派生事件丢失
补偿必须设置最大可累计窗口,避免房间事件缺失时把异常 open session 算成超长在线。首版建议按房间关闭时间、presence 离开时间或 `publish_deadline_ms` 截断;仍无法确认时只关闭 session 并告警,不增加 `mic_online_ms`
补偿关闭必须写 `end_reason=compensated_*`,不能伪装成用户主动下麦。补偿必须设置最大可累计窗口,避免房间事件缺失时把异常 open session 算成超长在线。当前默认配置:
| Config | Default | Meaning |
| --- | ---: | --- |
| `compensation_enabled` | `true` | 是否启动异常 session 补偿 worker |
| `compensation_interval_sec` | `60` | 没有积压时两轮扫描间隔 |
| `compensation_batch_size` | `100` | 单轮最多关闭 session 数 |
| `pending_publish_max_age_sec` | `120` | pending 未确认发流的最大保留窗口 |
| `publishing_session_max_age_sec` | `43200` | publishing session 最大可累计窗口,默认 12 小时 |
## Derived Events And Consumers
用户麦时长消费者写入 MySQL 后,可以通过 user-service outbox 产出派生事件,供 host stats、任务、活动和 BI 消费。派生事件不能替代基础事实表,消费者失败不影响 `user_mic_sessions`、daily 和 lifetime 事实。
用户麦时长消费者关闭 session 时,在同一个 MySQL 事务内写入 user-service `user_outbox` 派生事件,供 host stats、任务、活动和 BI 消费。派生事件不能替代基础事实表,消费者失败不影响 `user_mic_sessions`、daily 和 lifetime 事实。
| Event | Trigger | Consumer |
| --- | --- | --- |
@ -293,6 +300,18 @@ user_mic_event_consumption(
| `UserMicDailyStatsUpdated` | daily stats 有增量 | task progress、growth system |
| `UserMicLifetimeStatsUpdated` | lifetime stats 有增量 | lifetime task cache |
事件幂等键:
| Event | `event_id` |
| --- | --- |
| `UserMicSessionClosed` | `UserMicSessionClosed:{mic_session_id}` |
| `UserMicDailyStatsUpdated` | `UserMicDailyStatsUpdated:{mic_session_id}:{stat_date}` |
| `UserMicLifetimeStatsUpdated` | `UserMicLifetimeStatsUpdated:{mic_session_id}` |
`UserMicSessionClosed` payload 至少包含 `app_code``user_id``room_id``mic_session_id``seat_occupied_ms``mic_online_ms``seat_started_at_ms``ended_at_ms``end_reason``closed_event_id`
daily/lifetime update payload 使用 delta 语义,包含 `seat_occupied_delta_ms``mic_online_delta_ms``session_count_delta``updated_at_ms`daily 事件额外包含 `stat_date``room_count_delta`
host/Agency/BD 统计应该优先消费 `UserMicSessionClosed` 或读取 user mic facts 派生关系快照,不应该再直接消费 `RoomMicChanged` 重算同一套用户时长。
## Task Usage
@ -389,8 +408,8 @@ GET /api/v1/users/me/mic-stats?from_ms=...&to_ms=...
4. 已实现 session 状态机:`up -> publish_confirmed -> down`,支持 `change` 不切断。
5. 已实现 daily/lifetime 聚合,跨 UTC 日切分。
6. 已增加内部查询 RPC `GetUserMicLifetimeStats`,供任务、主播、后台读取 lifetime 聚合。
7. 下一步增加补偿 worker关闭异常 open session。
8. 下一步增加 user mic outbox产出 `UserMicSessionClosed``UserMicDailyStatsUpdated``UserMicLifetimeStatsUpdated`
7. 增加补偿 worker关闭异常 open session。
8. 增加 user mic outbox产出 `UserMicSessionClosed``UserMicDailyStatsUpdated``UserMicLifetimeStatsUpdated`
9. 任务系统接入 `user_mic_lifetime_stats`,不要重复消费 room 事件。
10. host stats 从 user mic facts 或 user mic outbox 派生,不直接重复处理 Room Cell 麦位事件。

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 提供服务边界日志能力。
// 它只记录 transport 层可见的请求、响应、错误语义和耗时,不承载业务判断。
// Package logx provides structured runtime logging for service boundaries and workers.
// Services emit JSON/text to stdout only; deployment collects stdout into CLS or local logs.
package logx
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"log/slog"
"mime"
"net/http"
"os"
"strings"
"sync"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"google.golang.org/grpc"
@ -23,88 +29,281 @@ import (
)
const (
// maxLoggedBytes 限制单个 request/response 字段的日志体积,避免大响应污染 stdout。
maxLoggedBytes = 8192
// redactedValue 是敏感字段统一替换值,保证日志可读但不可用于重放凭证。
redactedValue = "***REDACTED***"
defaultLevel = "info"
defaultFormat = "json"
defaultEnv = "local"
defaultMaxPayloadBytes = 2048
redactedValue = "***REDACTED***"
)
// HTTPMiddleware 在 gateway HTTP 边界记录请求体、响应体、状态码和耗时。
// requestIDFromContext 由 gateway 注入logx 不直接依赖具体 transport 包,避免反向耦合。
// Config controls process-wide structured logging. Service code must not write to CLS directly.
type Config struct {
Service string `yaml:"service"`
Env string `yaml:"env"`
Version string `yaml:"version"`
NodeID string `yaml:"node_id"`
Level string `yaml:"level"`
Format string `yaml:"format"`
IncludeRequestBody bool `yaml:"include_request_body"`
IncludeResponseBody bool `yaml:"include_response_body"`
MaxPayloadBytes int `yaml:"max_payload_bytes"`
Output io.Writer `yaml:"-"`
}
// WithRuntimeFields fills process identity fields from service config without duplicating YAML.
func (cfg Config) WithRuntimeFields(service string, env string, nodeID string) Config {
if strings.TrimSpace(cfg.Service) == "" {
cfg.Service = service
}
if strings.TrimSpace(cfg.Env) == "" {
cfg.Env = env
}
if strings.TrimSpace(cfg.NodeID) == "" {
cfg.NodeID = nodeID
}
return cfg
}
type contextAttrsKey struct{}
var (
globalMu sync.RWMutex
globalLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo, ReplaceAttr: replaceAttr}))
globalConfig = Config{Env: defaultEnv, Level: defaultLevel, Format: defaultFormat, MaxPayloadBytes: defaultMaxPayloadBytes}
)
// Init installs the process logger and redirects the standard library log package into JSON/text logs.
func Init(cfg Config) error {
normalized, err := NormalizeConfig(cfg)
if err != nil {
return err
}
level, err := parseLevel(normalized.Level)
if err != nil {
return err
}
output := normalized.Output
if output == nil {
output = os.Stdout
}
handlerOptions := &slog.HandlerOptions{Level: level, ReplaceAttr: replaceAttr}
var handler slog.Handler
switch normalized.Format {
case "json":
handler = slog.NewJSONHandler(output, handlerOptions)
case "text":
handler = slog.NewTextHandler(output, handlerOptions)
default:
return fmt.Errorf("unsupported log format %q", normalized.Format)
}
baseAttrs := make([]any, 0, 8)
baseAttrs = appendNonEmpty(baseAttrs, "env", normalized.Env)
baseAttrs = appendNonEmpty(baseAttrs, "service", normalized.Service)
baseAttrs = appendNonEmpty(baseAttrs, "version", normalized.Version)
baseAttrs = appendNonEmpty(baseAttrs, "node_id", normalized.NodeID)
logger := slog.New(handler).With(baseAttrs...)
globalMu.Lock()
globalLogger = logger
globalConfig = normalized
globalMu.Unlock()
slog.SetDefault(logger)
log.SetFlags(0)
log.SetOutput(stdLogWriter{})
return nil
}
// NormalizeConfig applies safe defaults and validates static logging knobs.
func NormalizeConfig(cfg Config) (Config, error) {
cfg.Service = strings.TrimSpace(cfg.Service)
cfg.Env = strings.ToLower(strings.TrimSpace(cfg.Env))
if cfg.Env == "" {
cfg.Env = defaultEnv
}
cfg.Version = strings.TrimSpace(cfg.Version)
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
cfg.Level = strings.ToLower(strings.TrimSpace(cfg.Level))
if cfg.Level == "" {
cfg.Level = defaultLevel
}
cfg.Format = strings.ToLower(strings.TrimSpace(cfg.Format))
if cfg.Format == "" {
cfg.Format = defaultFormat
}
if cfg.MaxPayloadBytes <= 0 {
cfg.MaxPayloadBytes = defaultMaxPayloadBytes
}
if _, err := parseLevel(cfg.Level); err != nil {
return Config{}, err
}
if cfg.Format != "json" && cfg.Format != "text" {
return Config{}, fmt.Errorf("unsupported log format %q", cfg.Format)
}
return cfg, nil
}
// Logger returns a logger enriched with attributes attached to ctx through With.
func Logger(ctx context.Context) *slog.Logger {
globalMu.RLock()
logger := globalLogger
globalMu.RUnlock()
attrs := attrsFromContext(ctx)
if len(attrs) == 0 {
return logger
}
args := make([]any, 0, len(attrs))
for _, attr := range attrs {
args = append(args, attr)
}
return logger.With(args...)
}
// With attaches structured fields to ctx for subsequent Logger/Info/Warn/Error calls.
func With(ctx context.Context, attrs ...slog.Attr) context.Context {
if ctx == nil {
ctx = context.Background()
}
if len(attrs) == 0 {
return ctx
}
existing := attrsFromContext(ctx)
merged := make([]slog.Attr, 0, len(existing)+len(attrs))
merged = append(merged, existing...)
merged = append(merged, attrs...)
return context.WithValue(ctx, contextAttrsKey{}, merged)
}
// Info records a normal runtime event.
func Info(ctx context.Context, msg string, attrs ...slog.Attr) {
Logger(ctx).LogAttrs(ctx, slog.LevelInfo, msg, attrs...)
}
// Warn records a recoverable runtime failure or degraded path.
func Warn(ctx context.Context, msg string, attrs ...slog.Attr) {
Logger(ctx).LogAttrs(ctx, slog.LevelWarn, msg, attrs...)
}
// Error records a request, transaction, worker, or external dependency failure.
func Error(ctx context.Context, msg string, err error, attrs ...slog.Attr) {
if err != nil {
attrs = append(attrs, slog.String("error", err.Error()))
}
Logger(ctx).LogAttrs(ctx, slog.LevelError, msg, attrs...)
}
// HTTPMiddleware records gateway HTTP access logs. Health checks are intentionally excluded by callers.
func HTTPMiddleware(service string, requestIDFromContext func(context.Context) string, next http.Handler) http.Handler {
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
startedAt := time.Now()
requestBody := readAndRestoreBody(request)
capture := newHTTPResponseCapture(writer)
cfg := configSnapshot()
requestBody := readAndRestoreBody(request, cfg)
capture := newHTTPResponseCapture(writer, cfg.IncludeResponseBody, cfg.MaxPayloadBytes)
next.ServeHTTP(capture, request)
requestID := strings.TrimSpace(requestIDFromContext(request.Context()))
args := []any{
"service", service,
"request_id", requestID,
"method", request.Method,
"path", request.URL.Path,
"query", request.URL.RawQuery,
"status", capture.statusCode,
"duration_ms", time.Since(startedAt).Milliseconds(),
"request", safePayload(requestBody),
"response", safePayload(capture.body.Bytes()),
ctx := With(request.Context(), slog.String("request_id", requestID), slog.String("app_code", appcode.FromContext(request.Context())))
attrs := []slog.Attr{
slog.String("method", request.Method),
slog.String("path", request.URL.Path),
slog.String("query", request.URL.RawQuery),
slog.Int("status", capture.statusCode),
slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
slog.String("client_ip", clientIP(request)),
}
if capture.statusCode >= http.StatusInternalServerError {
slog.Error("http_access", args...)
return
attrs = appendServiceAttr(attrs, service)
if cfg.IncludeRequestBody {
attrs = append(attrs, slog.Any("request", safePayload(requestBody, cfg.MaxPayloadBytes, len(requestBody) > cfg.MaxPayloadBytes)))
}
if cfg.IncludeResponseBody {
attrs = append(attrs, slog.Any("response", safePayload(capture.body.Bytes(), cfg.MaxPayloadBytes, capture.truncated)))
}
switch levelForHTTPStatus(capture.statusCode) {
case slog.LevelError:
Error(ctx, "http_access", nil, attrs...)
case slog.LevelWarn:
Warn(ctx, "http_access", attrs...)
default:
Info(ctx, "http_access", attrs...)
}
slog.Info("http_access", args...)
})
}
// UnaryServerInterceptor 在内部 gRPC 服务边界记录 protobuf request/response。
// 内部链路统一 gRPC + protobuf因此这里是排查跨服务参数和返回值的主入口。
// UnaryServerInterceptor records internal gRPC access logs without coupling business services to a logger.
func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor {
return func(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if strings.Contains(info.FullMethod, "/grpc.health.v1.Health/") {
// 健康检查高频且无业务参数,跳过可避免日志噪音淹没真实业务请求。
return handler(ctx, request)
}
startedAt := time.Now()
cfg := configSnapshot()
response, err := handler(ctx, request)
code := codes.OK
if err != nil {
code = status.Code(err)
}
meta := requestMetaFromPayload(request)
ctx = With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", appcode.Normalize(meta.AppCode)))
args := []any{
"service", service,
"request_id", requestIDFromPayload(request),
"method", info.FullMethod,
"grpc_code", code.String(),
"reason", string(xerr.ReasonFromGRPC(err)),
"duration_ms", time.Since(startedAt).Milliseconds(),
"request", safePayloadFromValue(request),
"response", safePayloadFromValue(response),
attrs := []slog.Attr{
slog.String("grpc_method", info.FullMethod),
slog.String("grpc_code", code.String()),
slog.String("reason", string(xerr.ReasonFromGRPC(err))),
slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
}
if err != nil {
slog.Error("grpc_access", args...)
return response, err
attrs = appendServiceAttr(attrs, service)
attrs = append(attrs, commonAttrsFromPayload(request)...)
if cfg.IncludeRequestBody {
attrs = append(attrs, slog.Any("request", safePayloadFromValue(request, cfg.MaxPayloadBytes)))
}
slog.Info("grpc_access", args...)
return response, nil
if cfg.IncludeResponseBody {
attrs = append(attrs, slog.Any("response", safePayloadFromValue(response, cfg.MaxPayloadBytes)))
}
switch levelForGRPCCode(code) {
case slog.LevelError:
Error(ctx, "grpc_access", err, attrs...)
case slog.LevelWarn:
Warn(ctx, "grpc_access", attrs...)
default:
Info(ctx, "grpc_access", attrs...)
}
return response, err
}
}
type stdLogWriter struct{}
func (stdLogWriter) Write(data []byte) (int, error) {
line := strings.TrimSpace(string(data))
if line != "" {
Info(context.Background(), "stdlog", slog.String("message", redactLogLine(line)))
}
return len(data), nil
}
type requestMeta struct {
RequestID string
AppCode string
}
type httpResponseCapture struct {
http.ResponseWriter
statusCode int
capture bool
maxBytes int
truncated bool
body bytes.Buffer
}
func newHTTPResponseCapture(writer http.ResponseWriter) *httpResponseCapture {
return &httpResponseCapture{
ResponseWriter: writer,
statusCode: http.StatusOK,
}
func newHTTPResponseCapture(writer http.ResponseWriter, capture bool, maxBytes int) *httpResponseCapture {
return &httpResponseCapture{ResponseWriter: writer, statusCode: http.StatusOK, capture: capture, maxBytes: maxBytes}
}
func (c *httpResponseCapture) WriteHeader(statusCode int) {
@ -113,85 +312,98 @@ func (c *httpResponseCapture) WriteHeader(statusCode int) {
}
func (c *httpResponseCapture) Write(data []byte) (int, error) {
if c.body.Len() < maxLoggedBytes {
remaining := maxLoggedBytes - c.body.Len()
if c.capture && c.body.Len() < c.maxBytes {
remaining := c.maxBytes - c.body.Len()
if len(data) > remaining {
c.body.Write(data[:remaining])
c.truncated = true
} else {
c.body.Write(data)
}
} else if c.capture && len(data) > 0 {
c.truncated = true
}
return c.ResponseWriter.Write(data)
}
func readAndRestoreBody(request *http.Request) []byte {
if request.Body == nil {
func readAndRestoreBody(request *http.Request, cfg Config) []byte {
if !cfg.IncludeRequestBody || request.Body == nil {
return nil
}
mediaType, _, _ := mime.ParseMediaType(request.Header.Get("Content-Type"))
if strings.HasPrefix(mediaType, "multipart/") {
// 文件上传请求体可能很大,且包含二进制内容;日志只记录占位符并保留原始流给 handler。
return []byte("<multipart body omitted>")
}
body, err := io.ReadAll(request.Body)
if err != nil {
// 读取失败时把空 body 交还给后续 handler让真实解码错误仍由 handler 返回。
request.Body = io.NopCloser(bytes.NewReader(nil))
return nil
}
request.Body.Close()
_ = request.Body.Close()
request.Body = io.NopCloser(bytes.NewReader(body))
return body
}
func safePayloadFromValue(value any) any {
func safePayloadFromValue(value any, maxBytes int) any {
if value == nil {
return nil
}
if message, ok := value.(proto.Message); ok {
raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(message)
if err != nil {
return "<proto_marshal_failed>"
}
return safePayload(raw)
}
raw, err := json.Marshal(value)
raw, err := marshalPayload(value)
if err != nil {
return "<json_marshal_failed>"
return "<payload_marshal_failed>"
}
return safePayload(raw)
return safePayload(raw, maxBytes, len(raw) > maxBytes)
}
func requestIDFromPayload(value any) string {
payload, ok := safePayloadFromValue(value).(map[string]any)
if !ok {
return ""
func requestMetaFromPayload(value any) requestMeta {
raw, err := marshalPayload(value)
if err != nil || len(raw) == 0 {
return requestMeta{}
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return requestMeta{}
}
result := requestMeta{RequestID: stringFromMap(payload, "request_id"), AppCode: stringFromMap(payload, "app_code")}
meta, ok := payload["meta"].(map[string]any)
if !ok {
return ""
return result
}
if requestID, ok := meta["request_id"].(string); ok {
return requestID
if result.RequestID == "" {
result.RequestID = stringFromMap(meta, "request_id")
}
return ""
if result.AppCode == "" {
result.AppCode = stringFromMap(meta, "app_code")
}
return result
}
func safePayload(raw []byte) any {
func marshalPayload(value any) ([]byte, error) {
if value == nil {
return nil, nil
}
if message, ok := value.(proto.Message); ok {
return protojson.MarshalOptions{UseProtoNames: true}.Marshal(message)
}
return json.Marshal(value)
}
func safePayload(raw []byte, maxBytes int, truncated bool) any {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return nil
}
if len(raw) > maxLoggedBytes {
raw = raw[:maxLoggedBytes]
if maxBytes <= 0 {
maxBytes = defaultMaxPayloadBytes
}
if truncated || len(raw) > maxBytes {
return map[string]any{"truncated": true, "bytes": len(raw)}
}
var payload any
if err := json.Unmarshal(raw, &payload); err != nil {
return string(raw)
return redactLogLine(string(raw))
}
return redactValue(payload)
}
@ -209,15 +421,23 @@ func redactValue(value any) any {
}
return redacted
case []any:
redacted := make([]any, len(typed))
for index, item := range typed {
typed[index] = redactValue(item)
redacted[index] = redactValue(item)
}
return typed
return redacted
default:
return value
}
}
func replaceAttr(_ []string, attr slog.Attr) slog.Attr {
if isSensitiveKey(attr.Key) {
return slog.String(attr.Key, redactedValue)
}
return attr
}
func isSensitiveKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
sensitiveParts := []string{
@ -234,6 +454,7 @@ func isSensitiveKey(key string) bool {
"signature",
"private_key",
"callback_auth",
"receipt",
}
for _, part := range sensitiveParts {
if strings.Contains(normalized, part) {
@ -242,3 +463,171 @@ func isSensitiveKey(key string) bool {
}
return false
}
func redactLogLine(line string) string {
fields := strings.Fields(line)
if len(fields) == 0 {
return line
}
changed := false
for index, field := range fields {
separator := strings.IndexAny(field, "=:")
if separator <= 0 {
continue
}
key := strings.Trim(field[:separator], "\"'")
if isSensitiveKey(key) {
fields[index] = field[:separator+1] + redactedValue
changed = true
}
}
if !changed {
return line
}
return strings.Join(fields, " ")
}
func attrsFromContext(ctx context.Context) []slog.Attr {
if ctx == nil {
return nil
}
attrs, _ := ctx.Value(contextAttrsKey{}).([]slog.Attr)
return attrs
}
func configSnapshot() Config {
globalMu.RLock()
cfg := globalConfig
globalMu.RUnlock()
if cfg.MaxPayloadBytes <= 0 {
cfg.MaxPayloadBytes = defaultMaxPayloadBytes
}
return cfg
}
func parseLevel(value string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "debug":
return slog.LevelDebug, nil
case "", "info":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return slog.LevelInfo, fmt.Errorf("%w %q", ErrUnsupportedLevel, value)
}
}
func levelForHTTPStatus(statusCode int) slog.Level {
switch {
case statusCode >= http.StatusInternalServerError:
return slog.LevelError
case statusCode >= http.StatusBadRequest:
return slog.LevelWarn
default:
return slog.LevelInfo
}
}
func levelForGRPCCode(code codes.Code) slog.Level {
switch code {
case codes.OK:
return slog.LevelInfo
case codes.Internal, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unknown:
return slog.LevelError
default:
return slog.LevelWarn
}
}
func appendServiceAttr(attrs []slog.Attr, service string) []slog.Attr {
if strings.TrimSpace(service) == "" {
return attrs
}
cfg := configSnapshot()
if cfg.Service != "" {
return attrs
}
return append(attrs, slog.String("service", service))
}
func appendNonEmpty(attrs []any, key string, value string) []any {
if strings.TrimSpace(value) == "" {
return attrs
}
return append(attrs, key, value)
}
func stringFromMap(values map[string]any, key string) string {
value, ok := values[key]
if !ok {
return ""
}
switch typed := value.(type) {
case string:
return typed
case fmt.Stringer:
return typed.String()
default:
return fmt.Sprint(typed)
}
}
func clientIP(request *http.Request) string {
if request == nil {
return ""
}
for _, header := range []string{"X-Forwarded-For", "X-Real-IP"} {
value := strings.TrimSpace(request.Header.Get(header))
if value == "" {
continue
}
if comma := strings.IndexByte(value, ','); comma >= 0 {
return strings.TrimSpace(value[:comma])
}
return value
}
return request.RemoteAddr
}
func commonAttrsFromPayload(value any) []slog.Attr {
raw, err := marshalPayload(value)
if err != nil || len(raw) == 0 {
return nil
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return nil
}
keys := []string{
"command_id",
"user_id",
"room_id",
"room_version",
"transaction_id",
"biz_type",
"asset_type",
"sender_user_id",
"target_user_id",
"seller_user_id",
"gift_id",
"gift_count",
"event_id",
"event_type",
}
attrs := make([]slog.Attr, 0, len(keys))
for _, key := range keys {
value, ok := payload[key]
if !ok || value == nil {
continue
}
attrs = append(attrs, slog.Any(key, value))
}
return attrs
}
// ErrUnsupportedLevel is kept as a stable sentinel for configuration tests.
var ErrUnsupportedLevel = errors.New("unsupported log level")

177
pkg/logx/logx_test.go Normal file
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"
// LedgerConflict 表示账本并发写入或余额版本冲突。
LedgerConflict Code = "LEDGER_CONFLICT"
// CoinSellerNotActive 表示目标用户不存在 active 币商身份。
CoinSellerNotActive Code = "COIN_SELLER_NOT_ACTIVE"
// CoinSellerStockTypeInvalid 表示币商库存入账类型不属于支持范围。
CoinSellerStockTypeInvalid Code = "COIN_SELLER_STOCK_TYPE_INVALID"
// CoinSellerStockAmountInvalid 表示币商库存金币数或线下付款金额不合法。
CoinSellerStockAmountInvalid Code = "COIN_SELLER_STOCK_AMOUNT_INVALID"
// CoinSellerPaymentRefDuplicated 表示同一 USDT 收款凭证已经入账。
CoinSellerPaymentRefDuplicated Code = "COIN_SELLER_PAYMENT_REF_DUPLICATED"
// IdempotencyConflict 表示 command_id 相同但业务 payload 不一致。
IdempotencyConflict Code = "IDEMPOTENCY_CONFLICT"
// RuleNotActive 表示活动规则当前不可用。
RuleNotActive Code = "RULE_NOT_ACTIVE"

View File

@ -13,30 +13,7 @@ const errorDomain = "hyapp"
// GRPCCode 把稳定业务 reason 映射为 gRPC 标准 code。
// gateway 后续只需要读取 gRPC code 和 ErrorInfo.reason 即可生成 HTTP envelope。
func GRPCCode(code Code) codes.Code {
switch code {
case InvalidArgument, DisplayUserIDInvalid, InvalidInviteCode, InvalidSection, PageTokenInvalid:
return codes.InvalidArgument
case NotFound, DisplayUserIDNotFound, CountryNotFound, RegionNotFound, MessageNotFound, MessageRecalled:
return codes.NotFound
case Conflict, RoomClosed, RegionCountryConflict, RegionDisabled, RequestConflict, ProducerEventConflict:
return codes.FailedPrecondition
case Unauthorized, AuthFailed, SessionExpired, SessionRevoked:
return codes.Unauthenticated
case PermissionDenied, ProfileRequired, UserDisabled:
return codes.PermissionDenied
case Unavailable:
return codes.Unavailable
case PasswordAlreadySet, DisplayUserIDExists, DuplicateBillingCommand, EventAlreadyConsumed:
return codes.AlreadyExists
case DisplayUserIDCooldown, DisplayUserIDPrettyNotAvailable, DisplayUserIDPrettyActive, DisplayUserIDLeaseExpired, DisplayUserIDPaymentRequired, CountryChangeCooldown, InsufficientBalance, RuleNotActive, RewardPending:
return codes.FailedPrecondition
case DisplayUserIDAllocateFailed:
return codes.Internal
case LedgerConflict:
return codes.Aborted
default:
return codes.Internal
}
return SpecOf(code).GRPCCode
}
// ToGRPCError 只在 transport/grpc 层调用。

View File

@ -1,6 +1,10 @@
package xerr
import (
"go/ast"
"go/parser"
"go/token"
"strconv"
"testing"
"google.golang.org/grpc/codes"
@ -24,3 +28,109 @@ func TestToGRPCErrorCarriesReason(t *testing.T) {
t.Fatalf("reason mismatch: got %s want %s", got, PasswordAlreadySet)
}
}
func TestCatalogCoversDeclaredCodes(t *testing.T) {
declared := declaredCodesFromSource(t)
for code, name := range declared {
if _, ok := catalog[code]; !ok {
t.Fatalf("declared xerr code %s (%s) is missing from catalog", name, code)
}
}
for code := range catalog {
if _, ok := declared[code]; !ok {
t.Fatalf("catalog registers undeclared xerr code %s", code)
}
}
}
func TestCatalogMappings(t *testing.T) {
tests := []struct {
name string
code Code
grpcCode codes.Code
httpStatus int
publicCode string
publicMessage string
}{
{
name: "business reason keeps canonical grpc code",
code: InvalidInviteCode,
grpcCode: codes.InvalidArgument,
httpStatus: httpStatusBadRequest,
publicCode: string(InvalidInviteCode),
publicMessage: "invalid argument",
},
{
name: "unavailable is exposed as upstream error",
code: Unavailable,
grpcCode: codes.Unavailable,
httpStatus: httpStatusBadGateway,
publicCode: "UPSTREAM_ERROR",
publicMessage: "upstream service error",
},
{
name: "ledger conflict uses aborted grpc but http conflict",
code: LedgerConflict,
grpcCode: codes.Aborted,
httpStatus: httpStatusConflict,
publicCode: string(LedgerConflict),
publicMessage: "conflict",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
spec := SpecOf(test.code)
if spec.GRPCCode != test.grpcCode ||
spec.HTTPStatus != test.httpStatus ||
spec.PublicCode != test.publicCode ||
spec.PublicMessage != test.publicMessage {
t.Fatalf("catalog mismatch: got %+v", spec)
}
})
}
}
func declaredCodesFromSource(t *testing.T) map[Code]string {
t.Helper()
file, err := parser.ParseFile(token.NewFileSet(), "errors.go", nil, 0)
if err != nil {
t.Fatalf("parse errors.go: %v", err)
}
declared := make(map[Code]string)
ast.Inspect(file, func(node ast.Node) bool {
decl, ok := node.(*ast.GenDecl)
if !ok || decl.Tok != token.CONST {
return true
}
for _, spec := range decl.Specs {
valueSpec, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
for index, name := range valueSpec.Names {
if index >= len(valueSpec.Values) {
continue
}
literal, ok := valueSpec.Values[index].(*ast.BasicLit)
if !ok || literal.Kind != token.STRING {
continue
}
value, err := strconv.Unquote(literal.Value)
if err != nil {
t.Fatalf("unquote %s: %v", literal.Value, err)
}
declared[Code(value)] = name.Name
}
}
return false
})
return declared
}

View File

@ -6,6 +6,7 @@ import (
"errors"
"flag"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
@ -38,6 +39,7 @@ import (
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
searchmodule "hyapp-admin-server/internal/modules/search"
uploadmodule "hyapp-admin-server/internal/modules/upload"
"hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/platform/tencentcos"
"hyapp-admin-server/internal/repository"
"hyapp-admin-server/internal/router"
@ -60,37 +62,40 @@ func main() {
if err != nil {
log.Fatal(err)
}
if err := logging.Init(cfg.ServiceName, cfg.Environment, cfg.NodeID, cfg.Log); err != nil {
log.Fatal(err)
}
if err := ensureDatabase(cfg.MySQLDSN); err != nil {
log.Fatalf("ensure mysql database: %v", err)
fatalRuntime("ensure_mysql_database_failed", err)
}
if err := ensureDatabase(cfg.WalletMySQLDSN); err != nil {
log.Fatalf("ensure wallet mysql database: %v", err)
fatalRuntime("ensure_wallet_mysql_database_failed", err)
}
if err := ensureDatabase(cfg.RoomMySQLDSN); err != nil {
log.Fatalf("ensure room mysql database: %v", err)
fatalRuntime("ensure_room_mysql_database_failed", err)
}
dbLogger := logger.New(log.New(os.Stdout, "\r\n", log.LstdFlags), logger.Config{
dbLogger := logger.New(log.New(logging.StdWriter(), "", 0), logger.Config{
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
})
db, err := gorm.Open(mysql.Open(cfg.MySQLDSN), &gorm.Config{Logger: dbLogger})
if err != nil {
log.Fatalf("connect mysql: %v", err)
fatalRuntime("connect_mysql_failed", err)
}
sqlDB, err := db.DB()
if err != nil {
log.Fatalf("mysql db handle: %v", err)
fatalRuntime("mysql_db_handle_failed", err)
}
if cfg.Migrations.Enabled {
if err := migration.Apply(context.Background(), sqlDB, cfg.Migrations.Dir); err != nil {
log.Fatalf("apply migrations: %v", err)
fatalRuntime("apply_migrations_failed", err)
}
}
redisClient, err := cache.NewRedis(context.Background(), cfg.Redis)
if err != nil {
log.Fatalf("connect redis: %v", err)
fatalRuntime("connect_redis_failed", err)
}
if redisClient != nil {
defer redisClient.Close()
@ -98,52 +103,52 @@ func main() {
userDB, err := sql.Open("mysql", cfg.UserMySQLDSN)
if err != nil {
log.Fatalf("connect user mysql: %v", err)
fatalRuntime("connect_user_mysql_failed", err)
}
if err := userDB.PingContext(context.Background()); err != nil {
log.Fatalf("ping user mysql: %v", err)
fatalRuntime("ping_user_mysql_failed", err)
}
defer userDB.Close()
walletDB, err := sql.Open("mysql", cfg.WalletMySQLDSN)
if err != nil {
log.Fatalf("connect wallet mysql: %v", err)
fatalRuntime("connect_wallet_mysql_failed", err)
}
if err := walletDB.PingContext(context.Background()); err != nil {
log.Fatalf("ping wallet mysql: %v", err)
fatalRuntime("ping_wallet_mysql_failed", err)
}
defer walletDB.Close()
roomDB, err := sql.Open("mysql", cfg.RoomMySQLDSN)
if err != nil {
log.Fatalf("connect room mysql: %v", err)
fatalRuntime("connect_room_mysql_failed", err)
}
if err := roomDB.PingContext(context.Background()); err != nil {
log.Fatalf("ping room mysql: %v", err)
fatalRuntime("ping_room_mysql_failed", err)
}
defer roomDB.Close()
store := repository.New(db)
if cfg.MySQLAutoMigrate {
if err := store.AutoMigrate(); err != nil {
log.Fatalf("auto migrate: %v", err)
fatalRuntime("auto_migrate_failed", err)
}
}
if *runBootstrap || cfg.Bootstrap.Enabled {
if err := store.Seed(cfg); err != nil {
log.Fatalf("seed database: %v", err)
fatalRuntime("seed_database_failed", err)
}
}
userConn, err := grpc.Dial(cfg.UserService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("connect user-service: %v", err)
fatalRuntime("connect_user_service_failed", err)
}
defer userConn.Close()
walletConn, err := grpc.Dial(cfg.WalletService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatalf("connect wallet-service: %v", err)
fatalRuntime("connect_wallet_service_failed", err)
}
defer walletConn.Close()
@ -168,7 +173,7 @@ func main() {
AccessURL: cfg.TencentCOS.AccessURL,
}, nil)
if err != nil {
log.Fatalf("create tencent cos uploader: %v", err)
fatalRuntime("create_tencent_cos_uploader_failed", err)
}
objectUploader = uploader
}
@ -182,7 +187,7 @@ func main() {
CountryRegion: countryregionmodule.New(userclient.NewGRPC(userConn), userDB, cfg, auditHandler),
Dashboard: dashboardmodule.New(store),
Health: healthmodule.New(store, redisClient, jobStatus),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), userDB, walletDB, auditHandler),
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
Job: jobmodule.New(store, cfg, auditHandler),
Menu: menumodule.New(store, auditHandler),
Notification: notificationmodule.New(store, auditHandler),
@ -202,9 +207,9 @@ func main() {
}
go func() {
log.Printf("hyapp-admin-server listening on %s", cfg.HTTPAddr)
slog.Info("service_listening", "addr", cfg.HTTPAddr)
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("listen: %v", err)
fatalRuntime("listen_failed", err)
}
}()
@ -215,10 +220,15 @@ func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("shutdown: %v", err)
slog.Error("shutdown_failed", "error", err.Error())
}
}
func fatalRuntime(msg string, err error) {
slog.Error(msg, "error", err.Error())
os.Exit(1)
}
func ensureDatabase(dsn string) error {
parsed, err := mysqlDriver.ParseDSN(dsn)
if err != nil {

View File

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

View File

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

View File

@ -7,6 +7,8 @@ import (
"strings"
"time"
"hyapp-admin-server/internal/platform/logging"
"gopkg.in/yaml.v3"
)
@ -14,6 +16,7 @@ type Config struct {
ServiceName string `yaml:"service_name"`
NodeID string `yaml:"node_id"`
Environment string `yaml:"environment"`
Log logging.Config `yaml:"log"`
HTTPAddr string `yaml:"http_addr"`
MySQLDSN string `yaml:"mysql_dsn"`
UserMySQLDSN string `yaml:"user_mysql_dsn"`
@ -90,6 +93,7 @@ func Default() Config {
ServiceName: "admin-server",
NodeID: "admin-local",
Environment: "local",
Log: logging.DefaultConfig(),
HTTPAddr: ":13100",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_admin?parseTime=true&charset=utf8mb4&loc=Local",
UserMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local",
@ -172,6 +176,15 @@ func (cfg *Config) Normalize() {
if cfg.Environment == "" {
cfg.Environment = "local"
}
if cfg.Log.Level == "" {
cfg.Log.Level = "info"
}
if cfg.Log.Format == "" {
cfg.Log.Format = "json"
}
if cfg.Log.MaxPayloadBytes <= 0 {
cfg.Log.MaxPayloadBytes = 2048
}
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
if cfg.NodeID == "" {

View File

@ -24,6 +24,7 @@ type Client interface {
SetBDStatus(ctx context.Context, req SetBDStatusRequest) (*BDProfile, error)
CreateCoinSeller(ctx context.Context, req CreateCoinSellerRequest) (*CoinSellerProfile, error)
SetCoinSellerStatus(ctx context.Context, req SetCoinSellerStatusRequest) (*CoinSellerProfile, error)
GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error)
CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error)
CloseAgency(ctx context.Context, req CloseAgencyRequest) (*Agency, error)
SetAgencyJoinEnabled(ctx context.Context, req SetAgencyJoinEnabledRequest) (*Agency, error)
@ -85,6 +86,7 @@ type GRPCClient struct {
identityClient userv1.UserIdentityServiceClient
countryClient userv1.CountryAdminServiceClient
regionClient userv1.RegionAdminServiceClient
hostClient userv1.UserHostServiceClient
hostAdminClient userv1.UserHostAdminServiceClient
}
@ -94,6 +96,7 @@ func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
identityClient: userv1.NewUserIdentityServiceClient(conn),
countryClient: userv1.NewCountryAdminServiceClient(conn),
regionClient: userv1.NewRegionAdminServiceClient(conn),
hostClient: userv1.NewUserHostServiceClient(conn),
hostAdminClient: userv1.NewUserHostAdminServiceClient(conn),
}
}
@ -223,6 +226,12 @@ type SetCoinSellerStatusRequest struct {
Reason string
}
type GetCoinSellerProfileRequest struct {
RequestID string
Caller string
UserID int64
}
type CreateAgencyRequest struct {
RequestID string
Caller string
@ -256,28 +265,46 @@ type SetAgencyJoinEnabledRequest struct {
}
type BDProfile struct {
UserID int64 `json:"userId,string"`
Role string `json:"role"`
RegionID int64 `json:"regionId"`
ParentLeaderUserID int64 `json:"parentLeaderUserId,string"`
Status string `json:"status"`
CreatedByUserID int64 `json:"createdByUserId"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
UserID int64 `json:"userId,string"`
Role string `json:"role"`
RegionID int64 `json:"regionId"`
ParentLeaderUserID int64 `json:"parentLeaderUserId,string"`
Status string `json:"status"`
CreatedByUserID int64 `json:"createdByUserId"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
RegionName string `json:"regionName"`
ParentLeaderDisplayID string `json:"parentLeaderDisplayUserId"`
ParentLeaderUsername string `json:"parentLeaderUsername"`
ParentLeaderAvatar string `json:"parentLeaderAvatar"`
CreatedByDisplayUserID string `json:"createdByDisplayUserId"`
CreatedByUsername string `json:"createdByUsername"`
CreatedByAvatar string `json:"createdByAvatar"`
SubBDCount int64 `json:"subBdCount"`
}
type Agency struct {
AgencyID int64 `json:"agencyId"`
OwnerUserID int64 `json:"ownerUserId,string"`
RegionID int64 `json:"regionId"`
ParentBDUserID int64 `json:"parentBdUserId,string"`
Name string `json:"name"`
Status string `json:"status"`
JoinEnabled bool `json:"joinEnabled"`
MaxHosts int32 `json:"maxHosts"`
CreatedByUserID int64 `json:"createdByUserId"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
AgencyID int64 `json:"agencyId"`
OwnerUserID int64 `json:"ownerUserId,string"`
RegionID int64 `json:"regionId"`
ParentBDUserID int64 `json:"parentBdUserId,string"`
Name string `json:"name"`
Status string `json:"status"`
JoinEnabled bool `json:"joinEnabled"`
MaxHosts int32 `json:"maxHosts"`
CreatedByUserID int64 `json:"createdByUserId"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
OwnerDisplayUserID string `json:"ownerDisplayUserId"`
OwnerUsername string `json:"ownerUsername"`
OwnerAvatar string `json:"ownerAvatar"`
ParentBDDisplayUserID string `json:"parentBdDisplayUserId"`
ParentBDUsername string `json:"parentBdUsername"`
ParentBDAvatar string `json:"parentBdAvatar"`
RegionName string `json:"regionName"`
}
type HostProfile struct {
@ -290,6 +317,11 @@ type HostProfile struct {
FirstBecameHostAtMs int64 `json:"firstBecameHostAtMs"`
CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
RegionName string `json:"regionName"`
CurrentAgencyName string `json:"currentAgencyName"`
}
type CoinSellerProfile struct {
@ -396,6 +428,17 @@ func (c *GRPCClient) SetCoinSellerStatus(ctx context.Context, req SetCoinSellerS
return fromProtoCoinSellerProfile(resp.GetCoinSellerProfile()), nil
}
func (c *GRPCClient) GetCoinSellerProfile(ctx context.Context, req GetCoinSellerProfileRequest) (*CoinSellerProfile, error) {
resp, err := c.hostClient.GetCoinSellerProfile(ctx, &userv1.GetCoinSellerProfileRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),
UserId: req.UserID,
})
if err != nil {
return nil, err
}
return fromProtoCoinSellerProfile(resp.GetCoinSellerProfile()), nil
}
func (c *GRPCClient) CreateAgency(ctx context.Context, req CreateAgencyRequest) (*CreateAgencyResult, error) {
resp, err := c.hostAdminClient.CreateAgency(ctx, &userv1.CreateAgencyRequest{
Meta: requestMeta(ctx, req.RequestID, req.Caller),

View File

@ -27,6 +27,7 @@ type Client interface {
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error)
ListResourceGrants(ctx context.Context, req *walletv1.ListResourceGrantsRequest) (*walletv1.ListResourceGrantsResponse, error)
AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error)
ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error)
}
@ -106,6 +107,10 @@ func (c *GRPCClient) ListResourceGrants(ctx context.Context, req *walletv1.ListR
return c.client.ListResourceGrants(ctx, req)
}
func (c *GRPCClient) AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) {
return c.client.AdminCreditCoinSellerStock(ctx, req)
}
func (c *GRPCClient) ListRechargeBills(ctx context.Context, req *walletv1.ListRechargeBillsRequest) (*walletv1.ListRechargeBillsResponse, error) {
return c.client.ListRechargeBills(ctx, req)
}

View File

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

View File

@ -7,6 +7,7 @@ import (
"strings"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
@ -19,8 +20,8 @@ type Handler struct {
audit shared.OperationLogger
}
func New(userClient userclient.Client, userDB *sql.DB, walletDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(userClient, NewReader(userDB, walletDB)), audit: audit}
func New(userClient userclient.Client, walletClient walletclient.Client, userDB *sql.DB, walletDB *sql.DB, adminDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(userClient, walletClient, NewReader(userDB, walletDB, adminDB)), audit: audit}
}
func (h *Handler) ListBDLeaders(c *gin.Context) {
@ -140,6 +141,21 @@ func (h *Handler) SetBDStatus(c *gin.Context) {
response.OK(c, profile)
}
func (h *Handler) DeleteBDLeader(c *gin.Context) {
targetUserID, ok := parseInt64ID(c, "user_id")
if !ok {
return
}
profile, err := h.service.DeleteBDLeader(c.Request.Context(), targetUserID)
if err != nil {
response.BadRequest(c, err.Error())
return
}
writeHostOrgAuditLog(c, h.audit, "delete-bd-leader", "bd_profiles", profile.UserID,
fmt.Sprintf("user_id=%d", profile.UserID))
response.OK(c, profile)
}
func (h *Handler) CreateCoinSeller(c *gin.Context) {
var req createCoinSellerRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -176,6 +192,27 @@ func (h *Handler) SetCoinSellerStatus(c *gin.Context) {
response.OK(c, profile)
}
func (h *Handler) CreditCoinSellerStock(c *gin.Context) {
sellerUserID, ok := parseInt64ID(c, "user_id")
if !ok {
return
}
var req coinSellerStockCreditRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "币商进货参数不正确")
return
}
result, err := h.service.CreditCoinSellerStock(c.Request.Context(), adminActorID(c), sellerUserID, middleware.CurrentRequestID(c), req)
if err != nil {
response.BadRequest(c, err.Error())
return
}
writeHostOrgAuditLog(c, h.audit, "coin-seller-stock-credit", "coin_seller_stock_records", sellerUserID,
fmt.Sprintf("command_id=%s seller_user_id=%d stock_type=%s coin_amount=%d paid_currency_code=%s paid_amount_micro=%d transaction_id=%s reason=%q",
strings.TrimSpace(req.CommandID), sellerUserID, result.GetStockType(), result.GetCoinAmount(), result.GetPaidCurrencyCode(), result.GetPaidAmountMicro(), result.GetTransactionId(), strings.TrimSpace(req.Reason)))
response.Created(c, coinSellerStockCreditFromProto(result))
}
func (h *Handler) CreateAgency(c *gin.Context) {
var req createAgencyRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
@ -13,10 +14,11 @@ import (
type Reader struct {
db *sql.DB
walletDB *sql.DB
adminDB *sql.DB
}
func NewReader(db *sql.DB, walletDB *sql.DB) *Reader {
return &Reader{db: db, walletDB: walletDB}
func NewReader(db *sql.DB, walletDB *sql.DB, adminDB *sql.DB) *Reader {
return &Reader{db: db, walletDB: walletDB, adminDB: adminDB}
}
type CoinSellerListItem struct {
@ -24,6 +26,7 @@ type CoinSellerListItem struct {
Status string `json:"status"`
MerchantAssetType string `json:"merchantAssetType"`
MerchantBalance int64 `json:"merchantBalance"`
Contact string `json:"contact"`
DisplayUserID string `json:"displayUserId"`
Username string `json:"username"`
Avatar string `json:"avatar"`
@ -40,7 +43,13 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
return nil, 0, errUserDBNotConfigured()
}
appCode := appctx.FromContext(ctx)
whereSQL := "FROM bd_profiles bp LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id WHERE bp.app_code = ? AND bp.role = ?"
whereSQL := `
FROM bd_profiles bp
LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id
LEFT JOIN users parent_leader ON parent_leader.app_code = bp.app_code AND parent_leader.user_id = bp.parent_leader_user_id
LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id
LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id
WHERE bp.app_code = ? AND bp.role = ?`
args := []any{appCode, role}
if query.Status != "" {
whereSQL += " AND bp.status = ?"
@ -56,8 +65,8 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ?)"
args = append(args, like, like, like)
whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR r.name LIKE ?)"
args = append(args, like, like, like, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
@ -66,7 +75,20 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT bp.user_id, bp.role, bp.region_id, COALESCE(bp.parent_leader_user_id, 0),
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''),
COALESCE(parent_leader.current_display_user_id, ''), COALESCE(parent_leader.username, ''),
COALESCE(parent_leader.avatar, ''),
COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''),
COALESCE(creator.avatar, ''),
(
SELECT COUNT(1)
FROM bd_profiles child
WHERE child.app_code = bp.app_code
AND child.role = 'bd'
AND child.parent_leader_user_id = bp.user_id
)
%s
ORDER BY bp.created_at_ms DESC, bp.user_id DESC
LIMIT ? OFFSET ?
@ -79,12 +101,139 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin
items := make([]*userclient.BDProfile, 0, query.PageSize)
for rows.Next() {
item := &userclient.BDProfile{}
if err := rows.Scan(&item.UserID, &item.Role, &item.RegionID, &item.ParentLeaderUserID, &item.Status, &item.CreatedByUserID, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil {
if err := rows.Scan(
&item.UserID,
&item.Role,
&item.RegionID,
&item.ParentLeaderUserID,
&item.Status,
&item.CreatedByUserID,
&item.CreatedAtMs,
&item.UpdatedAtMs,
&item.DisplayUserID,
&item.Username,
&item.Avatar,
&item.RegionName,
&item.ParentLeaderDisplayID,
&item.ParentLeaderUsername,
&item.ParentLeaderAvatar,
&item.CreatedByDisplayUserID,
&item.CreatedByUsername,
&item.CreatedByAvatar,
&item.SubBDCount,
); err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, rows.Err()
if err := rows.Err(); err != nil {
return nil, 0, err
}
if err := r.fillBDProfileCreators(ctx, items); err != nil {
return nil, 0, err
}
return items, total, nil
}
func (r *Reader) fillBDProfileCreators(ctx context.Context, items []*userclient.BDProfile) error {
if r == nil || r.adminDB == nil || len(items) == 0 {
return nil
}
seen := make(map[int64]struct{}, len(items))
ids := make([]any, 0, len(items))
for _, item := range items {
if item.CreatedByUserID <= 0 {
continue
}
if _, ok := seen[item.CreatedByUserID]; ok {
continue
}
seen[item.CreatedByUserID] = struct{}{}
ids = append(ids, item.CreatedByUserID)
}
if len(ids) == 0 {
return nil
}
rows, err := r.adminDB.QueryContext(ctx, `
SELECT id, username, name
FROM admin_users
WHERE id IN (`+sqlPlaceholders(len(ids))+`)
`, ids...)
if err != nil {
return err
}
defer rows.Close()
type creator struct {
account string
name string
}
creators := make(map[int64]creator, len(ids))
for rows.Next() {
var id int64
var item creator
if err := rows.Scan(&id, &item.account, &item.name); err != nil {
return err
}
creators[id] = item
}
if err := rows.Err(); err != nil {
return err
}
for _, item := range items {
creator, ok := creators[item.CreatedByUserID]
if !ok {
continue
}
item.CreatedByDisplayUserID = creator.account
item.CreatedByUsername = firstNonBlank(creator.name, creator.account)
}
return nil
}
func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient.BDProfile, error) {
if r == nil || r.db == nil {
return nil, errUserDBNotConfigured()
}
appCode := appctx.FromContext(ctx)
item := &userclient.BDProfile{}
err := r.db.QueryRowContext(ctx, `
SELECT bp.user_id, bp.role, bp.region_id, COALESCE(bp.parent_leader_user_id, 0),
bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms
FROM bd_profiles bp
WHERE bp.app_code = ? AND bp.user_id = ? AND bp.role = 'bd_leader'
`, appCode, userID).Scan(
&item.UserID,
&item.Role,
&item.RegionID,
&item.ParentLeaderUserID,
&item.Status,
&item.CreatedByUserID,
&item.CreatedAtMs,
&item.UpdatedAtMs,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("bd leader not found")
}
if err != nil {
return nil, err
}
result, err := r.db.ExecContext(ctx, `
DELETE FROM bd_profiles
WHERE app_code = ? AND user_id = ? AND role = 'bd_leader'
`, appCode, userID)
if err != nil {
return nil, err
}
affected, err := result.RowsAffected()
if err != nil {
return nil, err
}
if affected == 0 {
return nil, fmt.Errorf("bd leader not found")
}
return item, nil
}
// ListAgencies 只读 user-service 的 Agency 事实表;后台关闭和入会开关仍走写命令。
@ -93,7 +242,12 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
return nil, 0, errUserDBNotConfigured()
}
appCode := appctx.FromContext(ctx)
whereSQL := "FROM agencies a LEFT JOIN users owner ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id WHERE a.app_code = ?"
whereSQL := `
FROM agencies a
LEFT JOIN users owner ON owner.app_code = a.app_code AND owner.user_id = a.owner_user_id
LEFT JOIN users parent_bd ON parent_bd.app_code = a.app_code AND parent_bd.user_id = a.parent_bd_user_id
LEFT JOIN regions r ON r.app_code = a.app_code AND r.region_id = a.region_id
WHERE a.app_code = ?`
args := []any{appCode}
if query.Status != "" {
whereSQL += " AND a.status = ?"
@ -109,8 +263,8 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ?)"
args = append(args, like, like, like, like, like)
whereSQL += " AND (a.name LIKE ? OR CAST(a.agency_id AS CHAR) LIKE ? OR CAST(a.owner_user_id AS CHAR) LIKE ? OR owner.current_display_user_id LIKE ? OR owner.username LIKE ? OR CAST(a.parent_bd_user_id AS CHAR) LIKE ? OR parent_bd.current_display_user_id LIKE ? OR parent_bd.username LIKE ? OR r.name LIKE ?)"
args = append(args, like, like, like, like, like, like, like, like, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
@ -120,7 +274,11 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT a.agency_id, a.owner_user_id, a.region_id, a.parent_bd_user_id,
a.name, a.status, a.join_enabled, a.max_hosts,
a.created_by_user_id, a.created_at_ms, a.updated_at_ms
a.created_by_user_id, a.created_at_ms, a.updated_at_ms,
COALESCE(owner.current_display_user_id, ''), COALESCE(owner.username, ''),
COALESCE(owner.avatar, ''),
COALESCE(parent_bd.current_display_user_id, ''), COALESCE(parent_bd.username, ''),
COALESCE(parent_bd.avatar, ''), COALESCE(r.name, '')
%s
ORDER BY a.created_at_ms DESC, a.agency_id DESC
LIMIT ? OFFSET ?
@ -133,7 +291,26 @@ func (r *Reader) ListAgencies(ctx context.Context, query listQuery) ([]*userclie
items := make([]*userclient.Agency, 0, query.PageSize)
for rows.Next() {
item := &userclient.Agency{}
if err := rows.Scan(&item.AgencyID, &item.OwnerUserID, &item.RegionID, &item.ParentBDUserID, &item.Name, &item.Status, &item.JoinEnabled, &item.MaxHosts, &item.CreatedByUserID, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil {
if err := rows.Scan(
&item.AgencyID,
&item.OwnerUserID,
&item.RegionID,
&item.ParentBDUserID,
&item.Name,
&item.Status,
&item.JoinEnabled,
&item.MaxHosts,
&item.CreatedByUserID,
&item.CreatedAtMs,
&item.UpdatedAtMs,
&item.OwnerDisplayUserID,
&item.OwnerUsername,
&item.OwnerAvatar,
&item.ParentBDDisplayUserID,
&item.ParentBDUsername,
&item.ParentBDAvatar,
&item.RegionName,
); err != nil {
return nil, 0, err
}
items = append(items, item)
@ -147,7 +324,12 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
return nil, 0, errUserDBNotConfigured()
}
appCode := appctx.FromContext(ctx)
whereSQL := "FROM host_profiles hp LEFT JOIN users u ON u.app_code = hp.app_code AND u.user_id = hp.user_id LEFT JOIN agencies a ON a.app_code = hp.app_code AND a.agency_id = hp.current_agency_id WHERE hp.app_code = ?"
whereSQL := `
FROM host_profiles hp
LEFT JOIN users u ON u.app_code = hp.app_code AND u.user_id = hp.user_id
LEFT JOIN agencies a ON a.app_code = hp.app_code AND a.agency_id = hp.current_agency_id
LEFT JOIN regions r ON r.app_code = hp.app_code AND r.region_id = hp.region_id
WHERE hp.app_code = ?`
args := []any{appCode}
if query.Status != "" {
whereSQL += " AND hp.status = ?"
@ -163,8 +345,8 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ?)"
args = append(args, like, like, like, like)
whereSQL += " AND (CAST(hp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR a.name LIKE ? OR r.name LIKE ?)"
args = append(args, like, like, like, like, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
@ -174,7 +356,9 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT hp.user_id, hp.status, hp.region_id, COALESCE(hp.current_agency_id, 0),
COALESCE(hp.current_membership_id, 0), hp.source,
hp.first_became_host_at_ms, hp.created_at_ms, hp.updated_at_ms
hp.first_became_host_at_ms, hp.created_at_ms, hp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(r.name, ''), COALESCE(a.name, '')
%s
ORDER BY hp.created_at_ms DESC, hp.user_id DESC
LIMIT ? OFFSET ?
@ -187,7 +371,22 @@ func (r *Reader) ListHostProfiles(ctx context.Context, query listQuery) ([]*user
items := make([]*userclient.HostProfile, 0, query.PageSize)
for rows.Next() {
item := &userclient.HostProfile{}
if err := rows.Scan(&item.UserID, &item.Status, &item.RegionID, &item.CurrentAgencyID, &item.CurrentMembershipID, &item.Source, &item.FirstBecameHostAtMs, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil {
if err := rows.Scan(
&item.UserID,
&item.Status,
&item.RegionID,
&item.CurrentAgencyID,
&item.CurrentMembershipID,
&item.Source,
&item.FirstBecameHostAtMs,
&item.CreatedAtMs,
&item.UpdatedAtMs,
&item.DisplayUserID,
&item.Username,
&item.Avatar,
&item.RegionName,
&item.CurrentAgencyName,
); err != nil {
return nil, 0, err
}
items = append(items, item)
@ -201,6 +400,9 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
return nil, 0, errUserDBNotConfigured()
}
appCode := appctx.FromContext(ctx)
if err := r.ensureCoinSellerContactColumn(ctx); err != nil {
return nil, 0, err
}
whereSQL := `
FROM coin_seller_profiles csp
LEFT JOIN users u ON u.app_code = csp.app_code AND u.user_id = csp.user_id
@ -217,8 +419,8 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
}
if keyword := strings.TrimSpace(query.Keyword); keyword != "" {
like := "%" + keyword + "%"
whereSQL += " AND (CAST(csp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ?)"
args = append(args, like, like, like)
whereSQL += " AND (CAST(csp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR csp.contact_info LIKE ?)"
args = append(args, like, like, like, like)
}
total, err := countRows(ctx, r.db, whereSQL, args...)
@ -227,6 +429,7 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
}
rows, err := r.db.QueryContext(ctx, fmt.Sprintf(`
SELECT csp.user_id, csp.status, csp.merchant_asset_type,
COALESCE(csp.contact_info, ''),
csp.created_by_user_id, csp.created_at_ms, csp.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''),
COALESCE(u.avatar, ''), COALESCE(u.region_id, 0),
@ -248,6 +451,7 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
&item.UserID,
&item.Status,
&item.MerchantAssetType,
&item.Contact,
&item.CreatedByUserID,
&item.CreatedAtMs,
&item.UpdatedAtMs,
@ -275,6 +479,42 @@ func (r *Reader) ListCoinSellers(ctx context.Context, query listQuery) ([]*CoinS
return items, total, nil
}
func (r *Reader) UpdateCoinSellerContact(ctx context.Context, userID int64, contact string) error {
if r == nil || r.db == nil {
return errUserDBNotConfigured()
}
if err := r.ensureCoinSellerContactColumn(ctx); err != nil {
return err
}
_, err := r.db.ExecContext(ctx, `
UPDATE coin_seller_profiles
SET contact_info = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ?
`, strings.TrimSpace(contact), time.Now().UnixMilli(), appctx.FromContext(ctx), userID)
return err
}
func (r *Reader) ensureCoinSellerContactColumn(ctx context.Context) error {
var count int
if err := r.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'coin_seller_profiles'
AND column_name = 'contact_info'
`).Scan(&count); err != nil {
return err
}
if count > 0 {
return nil
}
_, err := r.db.ExecContext(ctx, `
ALTER TABLE coin_seller_profiles
ADD COLUMN contact_info VARCHAR(128) NOT NULL DEFAULT '' AFTER merchant_asset_type
`)
return err
}
func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs []int64) (map[int64]int64, error) {
result := make(map[int64]int64, len(userIDs))
if r == nil || r.walletDB == nil || len(userIDs) == 0 {
@ -305,6 +545,13 @@ func (r *Reader) coinSellerBalances(ctx context.Context, appCode string, userIDs
return result, rows.Err()
}
func sqlPlaceholders(count int) string {
if count <= 0 {
return ""
}
return strings.TrimRight(strings.Repeat("?,", count), ",")
}
func countRows(ctx context.Context, db *sql.DB, whereSQL string, args ...any) (int64, error) {
var total int64
err := db.QueryRowContext(ctx, "SELECT COUNT(*) "+whereSQL, args...).Scan(&total)

View File

@ -33,14 +33,26 @@ type bdStatusRequest struct {
type createCoinSellerRequest struct {
CommandID string `json:"commandId" binding:"required"`
Contact string `json:"contact"`
TargetUserID int64 `json:"targetUserId" binding:"required"`
Reason string `json:"reason"`
}
type coinSellerStatusRequest struct {
CommandID string `json:"commandId" binding:"required"`
Status string `json:"status" binding:"required"`
Reason string `json:"reason"`
CommandID string `json:"commandId" binding:"required"`
Contact *string `json:"contact"`
Status string `json:"status" binding:"required"`
Reason string `json:"reason"`
}
type coinSellerStockCreditRequest struct {
CommandID string `json:"commandId" binding:"required"`
Type string `json:"type" binding:"required"`
CoinAmount int64 `json:"coinAmount" binding:"required"`
RechargeAmount string `json:"rechargeAmount"`
PaymentRef string `json:"paymentRef"`
EvidenceRef string `json:"evidenceRef"`
Reason string `json:"reason"`
}
type createAgencyRequest struct {

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.POST("/admin/bd-leaders", middleware.RequirePermission("bd:create"), h.CreateBDLeader)
protected.PATCH("/admin/bd-leaders/:user_id/status", middleware.RequirePermission("bd:update"), h.SetBDStatus)
protected.DELETE("/admin/bd-leaders/:user_id", middleware.RequirePermission("bd:update"), h.DeleteBDLeader)
protected.GET("/admin/bds", middleware.RequirePermission("bd:view"), h.ListBDs)
protected.POST("/admin/bds", middleware.RequirePermission("bd:create"), h.CreateBD)
protected.PATCH("/admin/bds/:user_id/status", middleware.RequirePermission("bd:update"), h.SetBDStatus)
@ -21,4 +22,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/coin-sellers", middleware.RequirePermission("coin-seller:view"), h.ListCoinSellers)
protected.POST("/admin/coin-sellers", middleware.RequirePermission("coin-seller:create"), h.CreateCoinSeller)
protected.PATCH("/admin/coin-sellers/:user_id/status", middleware.RequirePermission("coin-seller:update"), h.SetCoinSellerStatus)
protected.POST("/admin/coin-sellers/:user_id/stock-credits", middleware.RequirePermission("coin-seller:stock-credit"), h.CreditCoinSellerStock)
}

View File

@ -6,16 +6,28 @@ import (
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
const (
coinSellerStatusActive = "active"
coinSellerMerchantAssetType = "COIN_SELLER_COIN"
coinSellerStockTypePurchase = "usdt_purchase"
coinSellerStockTypeCompensate = "coin_compensation"
paidCurrencyUSDT = "USDT"
)
type Service struct {
userClient userclient.Client
reader *Reader
userClient userclient.Client
walletClient walletclient.Client
reader *Reader
}
func NewService(userClient userclient.Client, reader *Reader) *Service {
return &Service{userClient: userClient, reader: reader}
func NewService(userClient userclient.Client, walletClient walletclient.Client, reader *Reader) *Service {
return &Service{userClient: userClient, walletClient: walletClient, reader: reader}
}
func (s *Service) ListBDLeaders(ctx context.Context, query listQuery) ([]*userclient.BDProfile, int64, error) {
@ -92,12 +104,19 @@ func (s *Service) SetBDStatus(ctx context.Context, actorID int64, targetUserID i
})
}
func (s *Service) DeleteBDLeader(ctx context.Context, targetUserID int64) (*userclient.BDProfile, error) {
if targetUserID <= 0 {
return nil, fmt.Errorf("target_user_id is required")
}
return s.reader.DeleteBDLeader(ctx, targetUserID)
}
func (s *Service) CreateCoinSeller(ctx context.Context, actorID int64, requestID string, req createCoinSellerRequest) (*userclient.CoinSellerProfile, error) {
targetUserID, err := s.resolveDisplayUserID(ctx, requestID, req.TargetUserID)
if err != nil {
return nil, err
}
return s.userClient.CreateCoinSeller(ctx, userclient.CreateCoinSellerRequest{
profile, err := s.userClient.CreateCoinSeller(ctx, userclient.CreateCoinSellerRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
CommandID: strings.TrimSpace(req.CommandID),
@ -105,10 +124,20 @@ func (s *Service) CreateCoinSeller(ctx context.Context, actorID int64, requestID
TargetUserID: targetUserID,
Reason: strings.TrimSpace(req.Reason),
})
if err != nil {
return nil, err
}
contact := firstNonBlank(req.Contact, req.Reason)
if contact != "" {
if err := s.reader.UpdateCoinSellerContact(ctx, targetUserID, contact); err != nil {
return nil, err
}
}
return profile, nil
}
func (s *Service) SetCoinSellerStatus(ctx context.Context, actorID int64, targetUserID int64, requestID string, req coinSellerStatusRequest) (*userclient.CoinSellerProfile, error) {
return s.userClient.SetCoinSellerStatus(ctx, userclient.SetCoinSellerStatusRequest{
profile, err := s.userClient.SetCoinSellerStatus(ctx, userclient.SetCoinSellerStatusRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
CommandID: strings.TrimSpace(req.CommandID),
@ -117,6 +146,76 @@ func (s *Service) SetCoinSellerStatus(ctx context.Context, actorID int64, target
Status: strings.TrimSpace(req.Status),
Reason: strings.TrimSpace(req.Reason),
})
if err != nil {
return nil, err
}
if req.Contact != nil {
if err := s.reader.UpdateCoinSellerContact(ctx, targetUserID, *req.Contact); err != nil {
return nil, err
}
}
return profile, nil
}
func (s *Service) CreditCoinSellerStock(ctx context.Context, actorID int64, sellerUserID int64, requestID string, req coinSellerStockCreditRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) {
if s.walletClient == nil {
return nil, fmt.Errorf("wallet client is not configured")
}
if sellerUserID <= 0 || actorID <= 0 {
return nil, fmt.Errorf("seller_user_id and operator_user_id are required")
}
commandID := strings.TrimSpace(req.CommandID)
stockType := strings.ToLower(strings.TrimSpace(req.Type))
evidenceRef := strings.TrimSpace(req.EvidenceRef)
reason := strings.TrimSpace(req.Reason)
if commandID == "" || req.CoinAmount <= 0 {
return nil, fmt.Errorf("command_id and coin_amount are required")
}
paidCurrencyCode := ""
paidAmountMicro := int64(0)
paymentRef := strings.TrimSpace(req.PaymentRef)
switch stockType {
case coinSellerStockTypePurchase:
paidCurrencyCode = paidCurrencyUSDT
parsed, err := parseUSDTAmountMicro(req.RechargeAmount)
if err != nil {
return nil, err
}
paidAmountMicro = parsed
case coinSellerStockTypeCompensate:
if strings.TrimSpace(req.RechargeAmount) != "" || paymentRef != "" {
return nil, fmt.Errorf("coin compensation must not include recharge_amount or payment_ref")
}
default:
return nil, fmt.Errorf("stock type is invalid")
}
profile, err := s.userClient.GetCoinSellerProfile(ctx, userclient.GetCoinSellerProfileRequest{
RequestID: requestID,
Caller: "hyapp-admin-server",
UserID: sellerUserID,
})
if err != nil {
return nil, err
}
if profile == nil || profile.Status != coinSellerStatusActive || profile.MerchantAssetType != coinSellerMerchantAssetType {
return nil, fmt.Errorf("target user is not an active coin seller")
}
return s.walletClient.AdminCreditCoinSellerStock(ctx, &walletv1.AdminCreditCoinSellerStockRequest{
CommandId: commandID,
SellerUserId: sellerUserID,
StockType: stockType,
CoinAmount: req.CoinAmount,
PaidCurrencyCode: paidCurrencyCode,
PaidAmountMicro: paidAmountMicro,
PaymentRef: paymentRef,
EvidenceRef: evidenceRef,
OperatorUserId: actorID,
Reason: reason,
AppCode: appctx.FromContext(ctx),
})
}
func (s *Service) CreateAgency(ctx context.Context, actorID int64, requestID string, req createAgencyRequest) (*userclient.CreateAgencyResult, error) {
@ -191,6 +290,42 @@ func (s *Service) SetAgencyJoinEnabled(ctx context.Context, actorID int64, agenc
})
}
func parseUSDTAmountMicro(raw string) (int64, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return 0, fmt.Errorf("recharge_amount is required for usdt purchase")
}
parts := strings.Split(raw, ".")
if len(parts) > 2 || parts[0] == "" {
return 0, fmt.Errorf("recharge_amount is invalid")
}
whole := parts[0]
frac := ""
if len(parts) == 2 {
frac = parts[1]
if frac == "" || len(frac) > 6 {
return 0, fmt.Errorf("recharge_amount supports up to 6 decimals")
}
}
digits := whole + frac + strings.Repeat("0", 6-len(frac))
var amount int64
const maxInt64 = int64(^uint64(0) >> 1)
for _, char := range digits {
if char < '0' || char > '9' {
return 0, fmt.Errorf("recharge_amount is invalid")
}
next := int64(char - '0')
if amount > (maxInt64-next)/10 {
return 0, fmt.Errorf("recharge_amount is too large")
}
amount = amount*10 + next
}
if amount <= 0 {
return 0, fmt.Errorf("recharge_amount must be positive")
}
return amount, nil
}
func normalizeListQuery(query listQuery) listQuery {
if query.Page < 1 {
query.Page = 1
@ -205,3 +340,12 @@ func normalizeListQuery(query listQuery) listQuery {
query.Status = strings.ToLower(strings.TrimSpace(query.Status))
return query
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if trimmed := strings.TrimSpace(value); trimmed != "" {
return trimmed
}
}
return ""
}

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:create", Kind: "button"},
{Name: "币商更新", Code: "coin-seller:update", Kind: "button"},
{Name: "币商进货", Code: "coin-seller:stock-credit", Kind: "button"},
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
{Name: "角色查看", Code: "role:view", Kind: "menu"},
{Name: "角色创建", Code: "role:create", Kind: "button"},
@ -105,7 +106,7 @@ func (s *Store) seedMenus() error {
paymentID := uint(0)
menus := []model.Menu{
{Title: "总览", Code: "overview", Path: "/overview", Icon: "dashboard", PermissionCode: "overview:view", Sort: 10, Visible: true},
{Title: "系统管理", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 30, Visible: true},
{Title: "后台设置", Code: "system", Path: "", Icon: "settings", PermissionCode: "", Sort: 30, Visible: true},
{Title: "日志审计", Code: "logs", Path: "", Icon: "history", PermissionCode: "", Sort: 40, Visible: true},
{Title: "通知中心", Code: "notifications", Path: "/notifications", Icon: "notifications", PermissionCode: "notification:view", Sort: 50, Visible: true},
{Title: "用户管理", Code: "app-users", Path: "", Icon: "users", PermissionCode: "", Sort: 60, Visible: true},
@ -152,7 +153,7 @@ func (s *Store) seedMenus() error {
}
children := []model.Menu{
{ParentID: &systemID, Title: "用户管理", Code: "system-users", Path: "/system/users", Icon: "users", PermissionCode: "user:view", Sort: 31, Visible: true},
{ParentID: &systemID, Title: "后台用户", Code: "system-users", Path: "/system/users", Icon: "users", PermissionCode: "user:view", Sort: 31, Visible: true},
{ParentID: &systemID, Title: "角色配置", Code: "system-roles", Path: "/system/roles", Icon: "shield", PermissionCode: "role:view", Sort: 32, Visible: true},
{ParentID: &systemID, Title: "菜单权限", Code: "system-menus", Path: "/system/menus", Icon: "menu", PermissionCode: "menu:view", Sort: 33, Visible: true},
{ParentID: &logID, Title: "登录日志", Code: "logs-login", Path: "/logs/login", Icon: "login", PermissionCode: "log:view", Sort: 41, Visible: true},
@ -409,7 +410,7 @@ func defaultRolePermissionCodes(code string) []string {
"host:view",
"agency:view", "agency:create", "agency:status",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"payment-bill:view",
"log:view",
"notification:view", "notification:read",
@ -462,7 +463,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"upload:create",
"country:view", "country:create", "country:update", "country:status",
"region:view", "region:create", "region:update", "region:status",
"coin-seller:view", "coin-seller:create", "coin-seller:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"payment-bill:view",
}
case "auditor", "readonly":

View File

@ -22,6 +22,7 @@ import (
"hyapp-admin-server/internal/modules/roomadmin"
"hyapp-admin-server/internal/modules/search"
"hyapp-admin-server/internal/modules/upload"
"hyapp-admin-server/internal/platform/logging"
"hyapp-admin-server/internal/service"
"github.com/gin-gonic/gin"
@ -51,7 +52,7 @@ type Handlers struct {
func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
engine := gin.New()
engine.Use(middleware.RequestID(), middleware.AppCode(), gin.Logger(), gin.Recovery(), middleware.CORS(cfg))
engine.Use(middleware.RequestID(), middleware.AppCode(), logging.GinAccessLogger(), gin.Recovery(), middleware.CORS(cfg))
engine.GET("/healthz", h.Health.Health)
engine.GET("/readyz", h.Health.Ready)

View File

@ -4,9 +4,11 @@ import (
"context"
"flag"
"log"
"os"
"os/signal"
"syscall"
"hyapp/pkg/logx"
"hyapp/services/activity-service/internal/app"
"hyapp/services/activity-service/internal/config"
)
@ -20,10 +22,13 @@ func main() {
if err != nil {
log.Fatal(err)
}
if err := logx.Init(cfg.Log.WithRuntimeFields(cfg.ServiceName, cfg.Environment, cfg.NodeID)); err != nil {
log.Fatal(err)
}
application, err := app.New(cfg)
if err != nil {
log.Fatal(err)
fatalRuntime("app_new_failed", err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@ -39,7 +44,12 @@ func main() {
application.Close()
case err := <-errCh:
if err != nil {
log.Fatal(err)
fatalRuntime("service_run_failed", err)
}
}
}
func fatalRuntime(msg string, err error) {
logx.Error(context.Background(), msg, err)
os.Exit(1)
}

View File

@ -1,5 +1,12 @@
service_name: activity-service
node_id: activity-docker
environment: local
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13006"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local"
user_service_addr: "user-service:13005"

View File

@ -1,5 +1,12 @@
service_name: activity-service
node_id: activity-tencent
environment: prod
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13006"
mysql_dsn: "${TENCENT_MYSQL_ACTIVITY_DSN}"
user_service_addr: "user-service.internal:13005"

View File

@ -1,5 +1,12 @@
service_name: activity-service
node_id: activity-local
environment: local
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13006"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local"
user_service_addr: "127.0.0.1:13005"

View File

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

View File

@ -4,9 +4,11 @@ import (
"context"
"flag"
"log"
"os"
"os/signal"
"syscall"
"hyapp/pkg/logx"
"hyapp/services/gateway-service/internal/app"
"hyapp/services/gateway-service/internal/config"
)
@ -20,10 +22,13 @@ func main() {
if err != nil {
log.Fatal(err)
}
if err := logx.Init(cfg.Log.WithRuntimeFields(cfg.ServiceName, cfg.Environment, cfg.NodeID)); err != nil {
log.Fatal(err)
}
application, err := app.New(cfg)
if err != nil {
log.Fatal(err)
fatalRuntime("app_new_failed", err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@ -37,11 +42,16 @@ func main() {
select {
case <-ctx.Done():
if err := application.Close(); err != nil {
log.Fatal(err)
fatalRuntime("app_close_failed", err)
}
case err := <-errCh:
if err != nil {
log.Fatal(err)
fatalRuntime("service_run_failed", err)
}
}
}
func fatalRuntime(msg string, err error) {
logx.Error(context.Background(), msg, err)
os.Exit(1)
}

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

View File

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

View File

@ -104,34 +104,8 @@ func writeRPCError(writer http.ResponseWriter, request *http.Request, err error)
}
func mapReasonToHTTP(reason xerr.Code) (int, string, string) {
switch reason {
case xerr.InvalidArgument, xerr.DisplayUserIDInvalid, xerr.InvalidInviteCode, xerr.InvalidSection, xerr.PageTokenInvalid:
return http.StatusBadRequest, string(reason), "invalid argument"
case xerr.AuthFailed:
return http.StatusUnauthorized, string(reason), "authentication failed"
case xerr.Unauthorized, xerr.SessionExpired, xerr.SessionRevoked:
return http.StatusUnauthorized, string(reason), "unauthorized"
case xerr.Conflict, xerr.PasswordAlreadySet, xerr.DisplayUserIDExists, xerr.RequestConflict, xerr.ProducerEventConflict:
return http.StatusConflict, string(reason), "conflict"
case xerr.RoomClosed:
return http.StatusConflict, string(reason), "room closed"
case xerr.DisplayUserIDCooldown, xerr.DisplayUserIDPrettyNotAvailable, xerr.DisplayUserIDPrettyActive, xerr.DisplayUserIDLeaseExpired, xerr.DisplayUserIDPaymentRequired, xerr.CountryChangeCooldown, xerr.RegionCountryConflict, xerr.RegionDisabled:
return http.StatusConflict, string(reason), "conflict"
case xerr.InsufficientBalance:
return http.StatusConflict, string(reason), "insufficient balance"
case xerr.ProfileRequired:
return http.StatusForbidden, codeProfileRequired, "profile required"
case xerr.UserDisabled, xerr.PermissionDenied:
return http.StatusForbidden, string(reason), "permission denied"
case xerr.NotFound, xerr.DisplayUserIDNotFound, xerr.CountryNotFound, xerr.RegionNotFound, xerr.MessageNotFound, xerr.MessageRecalled:
return http.StatusNotFound, string(reason), "not found"
case xerr.Unavailable:
return http.StatusBadGateway, codeUpstreamError, "upstream service error"
case xerr.Internal:
return http.StatusInternalServerError, codeInternalError, "internal error"
default:
return http.StatusBadGateway, codeUpstreamError, "upstream service error"
}
spec := xerr.SpecOf(reason)
return spec.HTTPStatus, spec.PublicCode, spec.PublicMessage
}
// writeEnvelope 是 gateway 业务 API 的唯一 JSON 响应出口。

View File

@ -1,12 +1,13 @@
package http
import (
"log"
"log/slog"
"net/http"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/roomid"
"hyapp/pkg/tencentrtc"
"hyapp/pkg/xerr"
@ -34,6 +35,7 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized")
return
}
logCtx := logx.With(request.Context(), slog.String("request_id", requestIDFromContext(request.Context())), slog.String("app_code", appcode.FromContext(request.Context())))
rtcConfig := tencentrtc.TokenConfig{
Enabled: h.tencentRTC.Enabled,
@ -44,7 +46,7 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
AppScene: h.tencentRTC.AppScene,
}
if err := tencentrtc.ValidateConfig(rtcConfig); err != nil {
log.Printf("gateway_rtc_token result=config_error request_id=%s user_id=%d room_id=%s reason=%q", requestIDFromContext(request.Context()), userID, body.RoomID, err.Error())
logx.Error(logCtx, "gateway_rtc_token_config_error", err, slog.Int64("user_id", userID), slog.String("room_id", body.RoomID))
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
return
}
@ -72,12 +74,18 @@ func (h *Handler) issueTencentRTCToken(writer http.ResponseWriter, request *http
token, err := tencentrtc.GenerateToken(rtcConfig, userID, body.RoomID, timeNow())
if err != nil {
log.Printf("gateway_rtc_token result=config_error request_id=%s user_id=%d room_id=%s reason=%q", requestIDFromContext(request.Context()), userID, body.RoomID, err.Error())
logx.Error(logCtx, "gateway_rtc_token_sign_failed", err, slog.Int64("user_id", userID), slog.String("room_id", body.RoomID))
writeError(writer, request, http.StatusInternalServerError, codeInternalError, "internal error")
return
}
log.Printf("gateway_rtc_token result=issued request_id=%s user_id=%d rtc_user_id=%s room_id=%s str_room_id=%s room_version=%d", requestIDFromContext(request.Context()), userID, token.RTCUserID, body.RoomID, token.StrRoomID, guardResp.GetRoomVersion())
logx.Info(logCtx, "gateway_rtc_token_issued",
slog.Int64("user_id", userID),
slog.String("rtc_user_id", token.RTCUserID),
slog.String("room_id", body.RoomID),
slog.String("str_room_id", token.StrRoomID),
slog.Int64("room_version", guardResp.GetRoomVersion()),
)
writeOK(writer, request, token)
}
@ -89,7 +97,8 @@ func (h *Handler) writeRTCPresenceDenied(writer http.ResponseWriter, request *ht
roomVersion = guardResp.GetRoomVersion()
}
log.Printf("gateway_rtc_token result=denied request_id=%s user_id=%d room_id=%s room_version=%d reason=%q", requestIDFromContext(request.Context()), userID, targetRoomID, roomVersion, reason)
logCtx := logx.With(request.Context(), slog.String("request_id", requestIDFromContext(request.Context())), slog.String("app_code", appcode.FromContext(request.Context())))
logx.Warn(logCtx, "gateway_rtc_token_denied", slog.Int64("user_id", userID), slog.String("room_id", targetRoomID), slog.Int64("room_version", roomVersion), slog.String("reason", reason))
if reason == "room_not_found" {
writeError(writer, request, http.StatusNotFound, codeNotFound, "not found")
return

View File

@ -7,7 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"strconv"
"strings"
@ -15,6 +15,7 @@ import (
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/tencentrtc"
"hyapp/pkg/xerr"
)
@ -86,8 +87,16 @@ func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *
roomID, ok := parseTencentRTCRoomID(body.EventInfo.RoomID)
userID, userOK := parseTencentIMUserID(body.EventInfo.UserID)
eventTimeMS := tencentRTCEventTimeMS(body)
logCtx := logx.With(request.Context(), slog.String("request_id", requestIDFromContext(request.Context())), slog.String("app_code", tencentRTCCallbackAppCode(request)))
if !ok || !userOK || eventTimeMS <= 0 || h.roomClient == nil {
log.Printf("gateway_rtc_callback result=ignored request_id=%s event_group=%d event_type=%d room_id=%q user_id=%q reason=input_invalid", requestIDFromContext(request.Context()), body.EventGroupID, body.EventType, roomID, body.EventInfo.UserID)
logx.Warn(logCtx, "gateway_rtc_callback_ignored",
slog.String("provider", "tencent"),
slog.Int("event_group", body.EventGroupID),
slog.Int("event_type", body.EventType),
slog.String("room_id", roomID),
slog.String("external_user_id", body.EventInfo.UserID),
slog.String("reason", "input_invalid"),
)
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
return
}
@ -112,16 +121,37 @@ func (h *Handler) handleTencentRTCCallback(writer http.ResponseWriter, request *
})
if err != nil {
if tencentRTCCallbackCanAckBusinessError(err) {
log.Printf("gateway_rtc_callback result=business_ignored request_id=%s room_id=%s user_id=%d event_group=%d event_type=%d reason=%s", requestIDFromContext(request.Context()), roomID, userID, body.EventGroupID, body.EventType, xerr.ReasonFromGRPC(err))
logx.Warn(logCtx, "gateway_rtc_callback_business_ignored",
slog.String("provider", "tencent"),
slog.String("room_id", roomID),
slog.Int64("user_id", userID),
slog.Int("event_group", body.EventGroupID),
slog.Int("event_type", body.EventType),
slog.String("reason", string(xerr.ReasonFromGRPC(err))),
)
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
return
}
log.Printf("gateway_rtc_callback result=room_service_error request_id=%s room_id=%s user_id=%d event_group=%d event_type=%d error=%q", requestIDFromContext(request.Context()), roomID, userID, body.EventGroupID, body.EventType, err.Error())
logx.Error(logCtx, "gateway_rtc_callback_room_service_error", err,
slog.String("provider", "tencent"),
slog.String("room_id", roomID),
slog.Int64("user_id", userID),
slog.Int("event_group", body.EventGroupID),
slog.Int("event_type", body.EventType),
)
writeTencentRTCCallback(writer, http.StatusInternalServerError, 1, "room service error")
return
}
log.Printf("gateway_rtc_callback result=accepted request_id=%s room_id=%s user_id=%d event_group=%d event_type=%d room_version=%d applied=%t", requestIDFromContext(request.Context()), roomID, userID, body.EventGroupID, body.EventType, resp.GetResult().GetRoomVersion(), resp.GetResult().GetApplied())
logx.Info(logCtx, "gateway_rtc_callback_accepted",
slog.String("provider", "tencent"),
slog.String("room_id", roomID),
slog.Int64("user_id", userID),
slog.Int("event_group", body.EventGroupID),
slog.Int("event_type", body.EventType),
slog.Int64("room_version", resp.GetResult().GetRoomVersion()),
slog.Bool("applied", resp.GetResult().GetApplied()),
)
writeTencentRTCCallback(writer, http.StatusOK, 0, "")
}

View File

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

View File

@ -1,4 +1,12 @@
service_name: room-service
environment: local
node_id: "room-node-1"
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13001"
lease_ttl: "10s"
rank_limit: 20

View File

@ -1,4 +1,12 @@
service_name: room-service
environment: prod
node_id: "room-node-prod-1"
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13001"
lease_ttl: "10s"
rank_limit: 20

View File

@ -1,4 +1,12 @@
service_name: room-service
environment: local
node_id: "room-node-1"
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
lease_ttl: "10s"
rank_limit: 20
snapshot_every_n: 1

View File

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

View File

@ -2,10 +2,11 @@ package service
import (
"context"
"log"
"log/slog"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/state"
)
@ -92,7 +93,11 @@ func (s *Service) projectRoomPresenceBestEffort(ctx context.Context, snapshot *r
return
}
if err := s.repository.ProjectRoomPresence(ctx, projection); err != nil {
log.Printf("component=room_presence_projector room_id=%s version=%d status=project_failed error=%q", projection.RoomID, projection.RoomVersion, err.Error())
logx.Error(ctx, "room_presence_project_failed", err,
slog.String("component", "room_presence_projector"),
slog.String("room_id", projection.RoomID),
slog.Int64("room_version", projection.RoomVersion),
)
}
}

View File

@ -4,11 +4,12 @@ import (
"context"
"encoding/base64"
"encoding/json"
"log"
"log/slog"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
)
@ -102,11 +103,11 @@ func (s *Service) projectRoomListBestEffort(ctx context.Context, snapshot *roomv
meta, exists, err := s.repository.GetRoomMeta(ctx, snapshot.GetRoomId())
if err != nil {
// 投影不能回滚已提交 Room Cell 命令,先记录日志;后续可用 projection task 补偿。
log.Printf("component=room_list_projector room_id=%s status=load_meta_failed error=%q", snapshot.GetRoomId(), err.Error())
logx.Error(ctx, "room_list_load_meta_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()))
return
}
if !exists {
log.Printf("component=room_list_projector room_id=%s status=meta_missing", snapshot.GetRoomId())
logx.Warn(ctx, "room_list_meta_missing", slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()))
return
}
@ -114,7 +115,7 @@ func (s *Service) projectRoomListBestEffort(ctx context.Context, snapshot *roomv
entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS)
if err := s.repository.UpsertRoomListEntry(ctx, entry); err != nil {
// 列表读模型是最终一致;失败不改变命令成功语义。
log.Printf("component=room_list_projector room_id=%s region_id=%d status=upsert_failed error=%q", snapshot.GetRoomId(), meta.VisibleRegionID, err.Error())
logx.Error(ctx, "room_list_upsert_failed", err, slog.String("component", "room_list_projector"), slog.String("room_id", snapshot.GetRoomId()), slog.Int64("region_id", meta.VisibleRegionID))
}
}

View File

@ -3,11 +3,12 @@ package service
import (
"context"
"fmt"
"log"
"log/slog"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
)
const (
@ -68,12 +69,12 @@ func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptio
options = normalizeOutboxWorkerOptions(options)
if options.RetryStrategy != outboxRetryFixedInterval {
// 配置层会拒绝未知策略;这里兜底避免测试或手写构造静默改变语义。
log.Printf("worker=%s node_id=%s status=stopped error=%q", outboxWorkerName, s.nodeID, "unsupported retry_strategy")
logx.Error(ctx, "worker_stopped", fmt.Errorf("unsupported retry_strategy"), slog.String("worker", outboxWorkerName), slog.String("node_id", s.nodeID))
return
}
if err := s.ProcessPendingOutbox(ctx, options); err != nil && ctx.Err() == nil {
log.Printf("worker=%s node_id=%s status=batch_failed error=%q", outboxWorkerName, s.nodeID, trimOutboxError(err.Error()))
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", outboxWorkerName), slog.String("node_id", s.nodeID), slog.Int("batch_size", options.BatchSize))
}
ticker := time.NewTicker(options.PollInterval)
@ -85,7 +86,7 @@ func (s *Service) RunOutboxWorker(ctx context.Context, options OutboxWorkerOptio
return
case <-ticker.C:
if err := s.ProcessPendingOutbox(ctx, options); err != nil && ctx.Err() == nil {
log.Printf("worker=%s node_id=%s status=batch_failed error=%q", outboxWorkerName, s.nodeID, trimOutboxError(err.Error()))
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", outboxWorkerName), slog.String("node_id", s.nodeID), slog.Int("batch_size", options.BatchSize))
}
}
}
@ -123,7 +124,15 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
return markErr
}
log.Printf("worker=%s node_id=%s event_id=%s event_type=%s room_id=%s retry_count=%d status=retryable error=%q", outboxWorkerName, s.nodeID, record.EventID, record.EventType, record.RoomID, record.RetryCount+1, trimOutboxError(err.Error()))
logx.Warn(ctx, "outbox_publish_retryable",
slog.String("worker", outboxWorkerName),
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.Int("retry_count", record.RetryCount+1),
slog.String("error", trimOutboxError(err.Error())),
)
continue
}
@ -135,7 +144,14 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
return markErr
}
log.Printf("worker=%s node_id=%s event_id=%s event_type=%s room_id=%s retry_count=%d status=delivered", outboxWorkerName, s.nodeID, record.EventID, record.EventType, record.RoomID, record.RetryCount)
logx.Info(ctx, "outbox_delivered",
slog.String("worker", outboxWorkerName),
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.Int("retry_count", record.RetryCount),
)
}
return nil

View File

@ -3,10 +3,11 @@ package service
import (
"bytes"
"context"
"log"
"log/slog"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/outbox"
@ -145,7 +146,12 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
markErr := s.repository.MarkOutboxDelivered(markCtx, result.syncEvent.EventID)
cancel()
if markErr != nil {
log.Printf("worker=room_outbox node_id=%s event_id=%s room_id=%s status=mark_delivered_failed error=%q", s.nodeID, result.syncEvent.EventID, result.syncEvent.RoomID, trimOutboxError(markErr.Error()))
logx.Error(ctx, "outbox_mark_delivered_failed", markErr,
slog.String("worker", outboxWorkerName),
slog.String("node_id", s.nodeID),
slog.String("event_id", result.syncEvent.EventID),
slog.String("room_id", result.syncEvent.RoomID),
)
}
}
}

View File

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

View File

@ -1,5 +1,12 @@
service_name: user-service
node_id: user-docker
environment: local
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13005"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local"
mysql_auto_migrate: false
@ -30,6 +37,11 @@ mic_time_worker:
room_mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
poll_interval_sec: 5
batch_size: 100
compensation_enabled: true
compensation_interval_sec: 60
compensation_batch_size: 100
pending_publish_max_age_sec: 120
publishing_session_max_age_sec: 43200
jwt:
issuer: "hyapp"
access_token_ttl_sec: 1800

View File

@ -1,5 +1,12 @@
service_name: user-service
node_id: user-tencent
environment: prod
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13005"
mysql_dsn: "${TENCENT_MYSQL_USER_DSN}"
mysql_auto_migrate: false
@ -30,6 +37,11 @@ mic_time_worker:
room_mysql_dsn: "${TENCENT_MYSQL_ROOM_DSN}"
poll_interval_sec: 5
batch_size: 100
compensation_enabled: true
compensation_interval_sec: 60
compensation_batch_size: 100
pending_publish_max_age_sec: 120
publishing_session_max_age_sec: 43200
jwt:
issuer: "hyapp"
access_token_ttl_sec: 1800

View File

@ -1,5 +1,12 @@
service_name: user-service
node_id: user-local
environment: local
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13005"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local"
mysql_auto_migrate: false
@ -30,6 +37,11 @@ mic_time_worker:
room_mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
poll_interval_sec: 5
batch_size: 100
compensation_enabled: true
compensation_interval_sec: 60
compensation_batch_size: 100
pending_publish_max_age_sec: 120
publishing_session_max_age_sec: 43200
jwt:
issuer: "hyapp"
access_token_ttl_sec: 1800

View File

@ -415,6 +415,7 @@ CREATE TABLE IF NOT EXISTS coin_seller_profiles (
user_id BIGINT NOT NULL PRIMARY KEY,
status VARCHAR(32) NOT NULL,
merchant_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN_SELLER_COIN',
contact_info VARCHAR(128) NOT NULL DEFAULT '',
created_by_user_id BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,

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)
a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) {

View File

@ -1,7 +1,12 @@
// Package config 定义 user-service 的启动配置和 YAML 加载入口。
package config
import "hyapp/pkg/configx"
import (
"strings"
"hyapp/pkg/configx"
"hyapp/pkg/logx"
)
// Config 描述 user-service 启动配置。
type Config struct {
@ -9,6 +14,8 @@ type Config struct {
ServiceName string `yaml:"service_name"`
// NodeID 是当前服务节点标识,预留给日志和后续健康响应使用。
NodeID string `yaml:"node_id"`
// Environment 是日志和部署策略使用的环境标签。
Environment string `yaml:"environment"`
// GRPCAddr 是 user-service 内部 gRPC 监听地址。
GRPCAddr string `yaml:"grpc_addr"`
// MySQLDSN 是用户主数据、认证身份、session 和短号事务的必需存储连接串。
@ -29,6 +36,8 @@ type Config struct {
MicTimeWorker MicTimeWorkerConfig `yaml:"mic_time_worker"`
// JWT 控制 access token 和 refresh session 的签发参数。
JWT JWTConfig `yaml:"jwt"`
// Log 控制 stdout 结构化日志格式、等级和 access body 策略。
Log logx.Config `yaml:"log"`
}
// IDGeneratorConfig 保存 user_id 发号节点配置。
@ -97,6 +106,16 @@ type MicTimeWorkerConfig struct {
PollIntervalSec int64 `yaml:"poll_interval_sec"`
// BatchSize 是单轮最多消费的 RoomMicChanged 事件数。
BatchSize int `yaml:"batch_size"`
// CompensationEnabled 控制是否启动异常 open session 补偿。
CompensationEnabled bool `yaml:"compensation_enabled"`
// CompensationIntervalSec 是补偿扫描间隔秒数。
CompensationIntervalSec int64 `yaml:"compensation_interval_sec"`
// CompensationBatchSize 是单轮最多关闭的异常 session 数。
CompensationBatchSize int `yaml:"compensation_batch_size"`
// PendingPublishMaxAgeSec 是 pending_publish 缺失 down 事件时的兜底关闭窗口。
PendingPublishMaxAgeSec int64 `yaml:"pending_publish_max_age_sec"`
// PublishingSessionMaxAgeSec 是 publishing 缺失 down 事件时允许累计的最大窗口。
PublishingSessionMaxAgeSec int64 `yaml:"publishing_session_max_age_sec"`
}
// JWTConfig 保存 access token 和 refresh token 的签发策略。
@ -119,6 +138,7 @@ func Default() Config {
return Config{
ServiceName: "user-service",
NodeID: "user-local",
Environment: "local",
GRPCAddr: ":13005",
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local",
MySQLAutoMigrate: false,
@ -148,10 +168,15 @@ func Default() Config {
BatchSize: 100,
},
MicTimeWorker: MicTimeWorkerConfig{
Enabled: true,
RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local",
PollIntervalSec: 5,
BatchSize: 100,
Enabled: true,
RoomMySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local",
PollIntervalSec: 5,
BatchSize: 100,
CompensationEnabled: true,
CompensationIntervalSec: 60,
CompensationBatchSize: 100,
PendingPublishMaxAgeSec: 120,
PublishingSessionMaxAgeSec: 43200,
},
JWT: JWTConfig{
Issuer: "hyapp",
@ -160,6 +185,11 @@ func Default() Config {
SigningAlg: "HS256",
SigningSecret: "dev-shared-secret",
},
Log: logx.Config{
Level: "info",
Format: "json",
MaxPayloadBytes: 2048,
},
}
}
@ -176,5 +206,18 @@ func Load(path string) (Config, error) {
return Config{}, err
}
cfg.ServiceName = strings.TrimSpace(cfg.ServiceName)
if cfg.ServiceName == "" {
cfg.ServiceName = "user-service"
}
cfg.NodeID = strings.TrimSpace(cfg.NodeID)
if cfg.NodeID == "" {
cfg.NodeID = cfg.ServiceName
}
cfg.Environment = strings.ToLower(strings.TrimSpace(cfg.Environment))
if cfg.Environment == "" {
cfg.Environment = "local"
}
return cfg, nil
}

View File

@ -27,6 +27,18 @@ const (
SessionStatusPublishing = "publishing"
// SessionStatusEnded is terminal and must never be reopened by replayed events.
SessionStatusEnded = "ended"
// EventTypeUserMicSessionClosed is emitted after a session is closed and aggregates are durable.
EventTypeUserMicSessionClosed = "UserMicSessionClosed"
// EventTypeUserMicDailyStatsUpdated is emitted for each UTC day touched by a closed session.
EventTypeUserMicDailyStatsUpdated = "UserMicDailyStatsUpdated"
// EventTypeUserMicLifetimeStatsUpdated is emitted when lifetime totals receive one session delta.
EventTypeUserMicLifetimeStatsUpdated = "UserMicLifetimeStatsUpdated"
// CompensationReasonPendingPublishTimeout closes pending sessions whose expected room-service timeout event never arrived.
CompensationReasonPendingPublishTimeout = "compensated_publish_timeout"
// CompensationReasonPublishingMaxWindow closes publishing sessions at a configured safety ceiling.
CompensationReasonPublishingMaxWindow = "compensated_publishing_max_window"
)
// RoomMicEvent is the decoded RoomMicChanged fact used by user-service duration aggregation.
@ -64,6 +76,21 @@ type ApplyResult struct {
Reason string
}
// CompensationOptions bounds abnormal open sessions so missing down events cannot accumulate forever.
type CompensationOptions struct {
NowMs int64
BatchSize int
PendingPublishMaxAgeMs int64
PublishingSessionMaxAgeMs int64
}
// CompensationResult summarizes one compensation scan.
type CompensationResult struct {
ClosedCount int
PendingPublishClosed int
PublishingSessionClosed int
}
// LifetimeStats is the user-wide aggregate consumed by host tasks and profile views.
type LifetimeStats struct {
AppCode string

View File

@ -2,13 +2,14 @@ package auth
import (
"context"
"log"
"log/slog"
"strings"
"time"
_ "time/tzdata"
"unicode/utf8"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
authdomain "hyapp/services/user-service/internal/domain/auth"
invitedomain "hyapp/services/user-service/internal/domain/invite"
@ -74,7 +75,7 @@ func (s *Service) LoginThirdParty(ctx context.Context, provider string, credenti
}
if !xerr.IsCode(err, xerr.NotFound) {
// 非 not found 的存储错误不能退化为注册。
log.Printf("component=user_auth op=third_party_lookup request_id=%s provider=%s status=failed error=%q", meta.RequestID, provider, err.Error())
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_lookup_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, false, err
}
@ -178,7 +179,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
}
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
log.Printf("component=user_auth op=third_party_register request_id=%s provider=%s status=failed error=%q", meta.RequestID, provider, err.Error())
logx.Error(logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", meta.AppCode)), "third_party_register_failed", err, slog.String("component", "user_auth"), slog.String("provider", provider))
return authdomain.Token{}, false, err
}

View File

@ -3,9 +3,10 @@ package invite
import (
"context"
"log"
"log/slog"
"time"
"hyapp/pkg/logx"
invitedomain "hyapp/services/user-service/internal/domain/invite"
)
@ -40,7 +41,7 @@ func New(repository Repository, reader WalletOutboxReader) *Service {
// RunWalletRechargeWorker consumes WalletRechargeRecorded facts and advances valid_invite_count.
func (s *Service) RunWalletRechargeWorker(ctx context.Context, options WorkerOptions) {
if s == nil || s.repository == nil || s.reader == nil {
log.Printf("component=user_invite op=wallet_recharge_worker status=disabled reason=missing_dependency")
logx.Warn(ctx, "worker_disabled", slog.String("worker", "wallet_recharge"), slog.String("component", "user_invite"), slog.String("reason", "missing_dependency"))
return
}
if options.PollInterval <= 0 {
@ -54,7 +55,7 @@ func (s *Service) RunWalletRechargeWorker(ctx context.Context, options WorkerOpt
for {
processed, err := s.processBatch(ctx, &cursor, options.BatchSize)
if err != nil {
log.Printf("component=user_invite op=wallet_recharge_worker worker_id=%s status=failed error=%q", options.WorkerID, err.Error())
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", "wallet_recharge"), slog.String("component", "user_invite"), slog.String("worker_id", options.WorkerID), slog.Int("batch_size", options.BatchSize))
}
if ctx.Err() != nil {
return

View File

@ -3,9 +3,10 @@ package mictime
import (
"context"
"log"
"log/slog"
"time"
"hyapp/pkg/logx"
"hyapp/pkg/xerr"
mictimedomain "hyapp/services/user-service/internal/domain/mictime"
)
@ -13,6 +14,7 @@ import (
// Repository applies room mic events and returns user duration aggregates.
type Repository interface {
ProcessRoomMicEvent(ctx context.Context, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error)
CompensateOpenSessions(ctx context.Context, options mictimedomain.CompensationOptions) (mictimedomain.CompensationResult, error)
GetLifetimeStats(ctx context.Context, appCode string, userID int64) (mictimedomain.LifetimeStats, error)
}
@ -34,6 +36,15 @@ type WorkerOptions struct {
BatchSize int
}
// CompensationWorkerOptions controls abnormal open session closure.
type CompensationWorkerOptions struct {
WorkerID string
PollInterval time.Duration
BatchSize int
PendingPublishMaxAge time.Duration
PublishingSessionMaxAge time.Duration
}
// New creates a mic time service with persistence and optional room outbox reader.
func New(repository Repository, reader RoomOutboxReader) *Service {
return &Service{repository: repository, reader: reader}
@ -50,7 +61,7 @@ func (s *Service) GetLifetimeStats(ctx context.Context, appCode string, userID i
// RunRoomMicWorker consumes RoomMicChanged facts from room-service outbox.
func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) {
if s == nil || s.repository == nil || s.reader == nil {
log.Printf("component=user_mic_time op=room_mic_worker status=disabled reason=missing_dependency")
logx.Warn(ctx, "worker_disabled", slog.String("worker", "room_mic"), slog.String("component", "user_mic_time"), slog.String("reason", "missing_dependency"))
return
}
if options.PollInterval <= 0 {
@ -64,7 +75,7 @@ func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) {
for {
processed, err := s.processBatch(ctx, &cursor, options.BatchSize)
if err != nil {
log.Printf("component=user_mic_time op=room_mic_worker worker_id=%s status=failed error=%q", options.WorkerID, err.Error())
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", "room_mic"), slog.String("component", "user_mic_time"), slog.String("worker_id", options.WorkerID), slog.Int("batch_size", options.BatchSize))
}
if ctx.Err() != nil {
return
@ -81,6 +92,45 @@ func (s *Service) RunRoomMicWorker(ctx context.Context, options WorkerOptions) {
}
}
// RunOpenSessionCompensationWorker closes stale open sessions when room-service down events are missing.
func (s *Service) RunOpenSessionCompensationWorker(ctx context.Context, options CompensationWorkerOptions) {
if s == nil || s.repository == nil {
logx.Warn(ctx, "worker_disabled", slog.String("worker", "open_session_compensation"), slog.String("component", "user_mic_time"), slog.String("reason", "missing_dependency"))
return
}
if options.PollInterval <= 0 {
options.PollInterval = time.Minute
}
if options.BatchSize <= 0 {
options.BatchSize = 100
}
if options.PendingPublishMaxAge <= 0 {
options.PendingPublishMaxAge = 2 * time.Minute
}
if options.PublishingSessionMaxAge <= 0 {
options.PublishingSessionMaxAge = 12 * time.Hour
}
for {
closed, err := s.compensateBatch(ctx, options)
if err != nil {
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", "open_session_compensation"), slog.String("component", "user_mic_time"), slog.String("worker_id", options.WorkerID), slog.Int("batch_size", options.BatchSize))
}
if ctx.Err() != nil {
return
}
if closed >= options.BatchSize {
// A full batch means backlog exists; continue immediately to keep abnormal sessions bounded.
continue
}
select {
case <-ctx.Done():
return
case <-time.After(options.PollInterval):
}
}
}
func (s *Service) processBatch(ctx context.Context, cursor *mictimedomain.RoomOutboxCursor, batchSize int) (int, error) {
events, err := s.reader.ListRoomMicEventsAfter(ctx, *cursor, batchSize)
if err != nil {
@ -95,3 +145,16 @@ func (s *Service) processBatch(ctx context.Context, cursor *mictimedomain.RoomOu
}
return len(events), nil
}
func (s *Service) compensateBatch(ctx context.Context, options CompensationWorkerOptions) (int, error) {
result, err := s.repository.CompensateOpenSessions(ctx, mictimedomain.CompensationOptions{
NowMs: time.Now().UnixMilli(),
BatchSize: options.BatchSize,
PendingPublishMaxAgeMs: options.PendingPublishMaxAge.Milliseconds(),
PublishingSessionMaxAgeMs: options.PublishingSessionMaxAge.Milliseconds(),
})
if err != nil {
return 0, err
}
return result.ClosedCount, nil
}

View File

@ -2,10 +2,11 @@ package user
import (
"context"
"log"
"log/slog"
"strings"
"time"
"hyapp/pkg/logx"
userdomain "hyapp/services/user-service/internal/domain/user"
)
@ -49,7 +50,7 @@ func normalizeRegionRebuildWorkerOptions(options RegionRebuildWorkerOptions) Reg
func (s *Service) RunRegionRebuildWorker(ctx context.Context, options RegionRebuildWorkerOptions) {
options = normalizeRegionRebuildWorkerOptions(options)
if s.regionRebuildRepository == nil {
log.Printf("worker=user_region_rebuild status=stopped reason=%q", "repository is not configured")
logx.Warn(ctx, "worker_disabled", slog.String("worker", "user_region_rebuild"), slog.String("reason", "repository is not configured"))
return
}
@ -76,13 +77,22 @@ func (s *Service) ProcessNextRegionRebuildTask(ctx context.Context, options Regi
result, err := s.regionRebuildRepository.ProcessNextRegionRebuildTask(ctx, options.WorkerID, s.now().UnixMilli(), options.LockTTL, options.BatchSize)
if err != nil {
log.Printf("worker=user_region_rebuild worker_id=%s status=failed error=%q", options.WorkerID, err.Error())
logx.Error(ctx, "worker_batch_failed", err, slog.String("worker", "user_region_rebuild"), slog.String("worker_id", options.WorkerID), slog.Int("batch_size", options.BatchSize))
return result, err
}
if result.NoTask {
return result, nil
}
log.Printf("worker=user_region_rebuild worker_id=%s task_id=%d country=%s target_region_id=%d status=%s processed_users=%d cursor_user_id=%d", options.WorkerID, result.TaskID, result.CountryCode, result.TargetRegionID, result.Status, result.ProcessedUsers, result.CursorUserID)
logx.Info(ctx, "worker_batch_processed",
slog.String("worker", "user_region_rebuild"),
slog.String("worker_id", options.WorkerID),
slog.Int64("task_id", result.TaskID),
slog.String("country", result.CountryCode),
slog.Int64("target_region_id", result.TargetRegionID),
slog.String("status", result.Status),
slog.Int("processed_users", result.ProcessedUsers),
slog.Int64("cursor_user_id", result.CursorUserID),
)
return result, nil
}

View File

@ -4,7 +4,9 @@ package mictime
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
@ -19,6 +21,13 @@ type Repository struct {
db *sql.DB
}
const (
defaultCompensationBatchSize = 100
defaultPendingPublishCompensationMaxAgeMs = int64(2 * 60 * 1000)
defaultPublishingCompensationMaxAgeMs = int64(12 * 60 * 60 * 1000)
userOutboxStatusPending = "pending"
)
// New creates a mic duration repository backed by the shared user-service connection pool.
func New(db *sql.DB) *Repository {
return &Repository{db: db}
@ -121,6 +130,83 @@ func (r *Repository) GetLifetimeStats(ctx context.Context, appCode string, userI
return stats, nil
}
// CompensateOpenSessions closes abnormal sessions whose authoritative down event never reached user-service.
func (r *Repository) CompensateOpenSessions(ctx context.Context, options mictimedomain.CompensationOptions) (mictimedomain.CompensationResult, error) {
if r == nil || r.db == nil {
return mictimedomain.CompensationResult{}, xerr.New(xerr.Unavailable, "mic time repository is not configured")
}
options = normalizeCompensationOptions(options)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return mictimedomain.CompensationResult{}, err
}
defer tx.Rollback()
pendingCutoff := options.NowMs - options.PendingPublishMaxAgeMs
publishingCutoff := options.NowMs - options.PublishingSessionMaxAgeMs
rows, err := tx.QueryContext(ctx, `
SELECT app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms,
publishing_started_at_ms, ended_at_ms, last_room_version
FROM user_mic_sessions
WHERE (
status = ? AND seat_started_at_ms <= ?
) OR (
status = ? AND seat_started_at_ms <= ?
)
ORDER BY seat_started_at_ms ASC, mic_session_id ASC
LIMIT ?
FOR UPDATE SKIP LOCKED
`, mictimedomain.SessionStatusPendingPublish, pendingCutoff, mictimedomain.SessionStatusPublishing, publishingCutoff, options.BatchSize)
if err != nil {
return mictimedomain.CompensationResult{}, err
}
candidates := make([]micSessionRow, 0, options.BatchSize)
for rows.Next() {
var session micSessionRow
if err := rows.Scan(&session.AppCode, &session.MicSessionID, &session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status, &session.SeatStartedAtMs, &session.PublishingStartedAtMs, &session.EndedAtMs, &session.LastRoomVersion); err != nil {
_ = rows.Close()
return mictimedomain.CompensationResult{}, err
}
candidates = append(candidates, session)
}
if err := rows.Close(); err != nil {
return mictimedomain.CompensationResult{}, err
}
if err := rows.Err(); err != nil {
return mictimedomain.CompensationResult{}, err
}
var result mictimedomain.CompensationResult
for _, session := range candidates {
input := closeMicSessionInput{
ClosedEventID: fmt.Sprintf("compensated:%s", session.MicSessionID),
EndAtMs: compensationEndAt(session, options),
UpdatedAtMs: options.NowMs,
RoomVersion: session.LastRoomVersion,
}
switch session.Status {
case mictimedomain.SessionStatusPendingPublish:
input.EndReason = mictimedomain.CompensationReasonPendingPublishTimeout
result.PendingPublishClosed++
case mictimedomain.SessionStatusPublishing:
input.EndReason = mictimedomain.CompensationReasonPublishingMaxWindow
result.PublishingSessionClosed++
default:
continue
}
if err := closeMicSession(ctx, tx, session, input); err != nil {
return mictimedomain.CompensationResult{}, err
}
result.ClosedCount++
}
if err := tx.Commit(); err != nil {
return mictimedomain.CompensationResult{}, err
}
return result, nil
}
func applyUp(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent) (mictimedomain.ApplyResult, error) {
seatNo := event.ToSeat
if seatNo <= 0 {
@ -233,44 +319,87 @@ func applyDown(ctx context.Context, tx *sql.Tx, event mictimedomain.RoomMicEvent
return mictimedomain.ApplyResult{Skipped: true, Reason: "session_already_ended"}, nil
}
endAt := event.OccurredAtMs
if endAt < session.SeatStartedAtMs {
endAt = session.SeatStartedAtMs
endReason := strings.TrimSpace(event.Reason)
if endReason == "" {
endReason = "down"
}
seatOccupiedMs := maxInt64(0, endAt-session.SeatStartedAtMs)
if err := closeMicSession(ctx, tx, session, closeMicSessionInput{
ClosedEventID: event.EventID,
EndAtMs: event.OccurredAtMs,
EndReason: endReason,
UpdatedAtMs: event.OccurredAtMs,
RoomVersion: event.RoomVersion,
}); err != nil {
return mictimedomain.ApplyResult{}, err
}
return mictimedomain.ApplyResult{Consumed: true, Closed: true}, nil
}
type closeMicSessionInput struct {
ClosedEventID string
EndAtMs int64
EndReason string
UpdatedAtMs int64
RoomVersion int64
}
func closeMicSession(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput) error {
input.ClosedEventID = strings.TrimSpace(input.ClosedEventID)
if input.ClosedEventID == "" {
input.ClosedEventID = "closed:" + session.MicSessionID
}
input.EndReason = strings.TrimSpace(input.EndReason)
if input.EndReason == "" {
input.EndReason = "down"
}
if input.EndAtMs < session.SeatStartedAtMs {
input.EndAtMs = session.SeatStartedAtMs
}
if input.UpdatedAtMs <= 0 {
input.UpdatedAtMs = input.EndAtMs
}
seatOccupiedMs := maxInt64(0, input.EndAtMs-session.SeatStartedAtMs)
micOnlineMs := int64(0)
if session.PublishingStartedAtMs.Valid {
publishStart := session.PublishingStartedAtMs.Int64
if publishStart < session.SeatStartedAtMs {
publishStart = session.SeatStartedAtMs
}
micOnlineMs = maxInt64(0, endAt-publishStart)
}
endReason := strings.TrimSpace(event.Reason)
if endReason == "" {
endReason = "down"
micOnlineMs = maxInt64(0, input.EndAtMs-publishStart)
}
_, err = tx.ExecContext(ctx, `
_, err := tx.ExecContext(ctx, `
UPDATE user_mic_sessions
SET status = ?, ended_at_ms = ?, end_reason = ?, seat_occupied_ms = ?, mic_online_ms = ?,
closed_event_id = ?, last_event_id = ?, last_room_version = GREATEST(last_room_version, ?), updated_at_ms = ?
WHERE app_code = ? AND mic_session_id = ? AND status <> ?
`, mictimedomain.SessionStatusEnded, endAt, endReason, seatOccupiedMs, micOnlineMs,
event.EventID, event.EventID, event.RoomVersion, event.OccurredAtMs, event.AppCode, event.MicSessionID, mictimedomain.SessionStatusEnded)
`, mictimedomain.SessionStatusEnded, input.EndAtMs, input.EndReason, seatOccupiedMs, micOnlineMs,
input.ClosedEventID, input.ClosedEventID, input.RoomVersion, input.UpdatedAtMs, session.AppCode, session.MicSessionID, mictimedomain.SessionStatusEnded)
if err != nil {
return mictimedomain.ApplyResult{}, err
return err
}
if err := incrementLifetime(ctx, tx, session, event, endAt, seatOccupiedMs, micOnlineMs); err != nil {
return mictimedomain.ApplyResult{}, err
if err := incrementLifetime(ctx, tx, session, input, seatOccupiedMs, micOnlineMs); err != nil {
return err
}
if err := incrementDaily(ctx, tx, session, event, endAt); err != nil {
return mictimedomain.ApplyResult{}, err
if err := incrementDaily(ctx, tx, session, input); err != nil {
return err
}
return mictimedomain.ApplyResult{Consumed: true, Closed: true}, nil
return insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicSessionClosed, session.MicSessionID, ""), mictimedomain.EventTypeUserMicSessionClosed, "user_mic_session", session.UserID, input.UpdatedAtMs, map[string]any{
"app_code": session.AppCode,
"user_id": session.UserID,
"room_id": session.RoomID,
"mic_session_id": session.MicSessionID,
"seat_occupied_ms": seatOccupiedMs,
"mic_online_ms": micOnlineMs,
"seat_started_at_ms": session.SeatStartedAtMs,
"ended_at_ms": input.EndAtMs,
"end_reason": input.EndReason,
"closed_event_id": input.ClosedEventID,
})
}
func incrementLifetime(ctx context.Context, tx *sql.Tx, session micSessionRow, event mictimedomain.RoomMicEvent, endAt int64, seatMs int64, micMs int64) error {
func incrementLifetime(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput, seatMs int64, micMs int64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO user_mic_lifetime_stats (
app_code, user_id, seat_occupied_ms, mic_online_ms, session_count, first_mic_at_ms, last_mic_at_ms, updated_at_ms
@ -282,19 +411,30 @@ func incrementLifetime(ctx context.Context, tx *sql.Tx, session micSessionRow, e
first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END,
last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)),
updated_at_ms = VALUES(updated_at_ms)
`, event.AppCode, session.UserID, seatMs, micMs, session.SeatStartedAtMs, endAt, event.OccurredAtMs)
return err
`, session.AppCode, session.UserID, seatMs, micMs, session.SeatStartedAtMs, input.EndAtMs, input.UpdatedAtMs)
if err != nil {
return err
}
return insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicLifetimeStatsUpdated, session.MicSessionID, ""), mictimedomain.EventTypeUserMicLifetimeStatsUpdated, "user_mic_lifetime_stats", session.UserID, input.UpdatedAtMs, map[string]any{
"app_code": session.AppCode,
"user_id": session.UserID,
"mic_session_id": session.MicSessionID,
"seat_occupied_delta_ms": seatMs,
"mic_online_delta_ms": micMs,
"session_count_delta": 1,
"updated_at_ms": input.UpdatedAtMs,
})
}
func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, event mictimedomain.RoomMicEvent, endAt int64) error {
seatParts := splitByUTCDay(session.SeatStartedAtMs, endAt)
func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, input closeMicSessionInput) error {
seatParts := splitByUTCDay(session.SeatStartedAtMs, input.EndAtMs)
micParts := map[string]dayDuration{}
if session.PublishingStartedAtMs.Valid {
publishStart := session.PublishingStartedAtMs.Int64
if publishStart < session.SeatStartedAtMs {
publishStart = session.SeatStartedAtMs
}
micParts = splitByUTCDay(publishStart, endAt)
micParts = splitByUTCDay(publishStart, input.EndAtMs)
}
dates := map[string]struct{}{}
for date := range seatParts {
@ -331,15 +471,30 @@ func incrementDaily(ctx context.Context, tx *sql.Tx, session micSessionRow, even
first_mic_at_ms = CASE WHEN first_mic_at_ms = 0 OR first_mic_at_ms > VALUES(first_mic_at_ms) THEN VALUES(first_mic_at_ms) ELSE first_mic_at_ms END,
last_mic_at_ms = GREATEST(last_mic_at_ms, VALUES(last_mic_at_ms)),
updated_at_ms = VALUES(updated_at_ms)
`, event.AppCode, session.UserID, date, seatPart.DurationMs, micPart.DurationMs, sessionCount, roomCount, firstAt, lastAt, event.OccurredAtMs)
`, session.AppCode, session.UserID, date, seatPart.DurationMs, micPart.DurationMs, sessionCount, roomCount, firstAt, lastAt, input.UpdatedAtMs)
if err != nil {
return err
}
if err := insertUserOutbox(ctx, tx, session.AppCode, userMicOutboxEventID(mictimedomain.EventTypeUserMicDailyStatsUpdated, session.MicSessionID, date), mictimedomain.EventTypeUserMicDailyStatsUpdated, "user_mic_daily_stats", session.UserID, input.UpdatedAtMs, map[string]any{
"app_code": session.AppCode,
"user_id": session.UserID,
"stat_date": date,
"mic_session_id": session.MicSessionID,
"seat_occupied_delta_ms": seatPart.DurationMs,
"mic_online_delta_ms": micPart.DurationMs,
"session_count_delta": sessionCount,
"room_count_delta": roomCount,
"updated_at_ms": input.UpdatedAtMs,
}); err != nil {
return err
}
}
return nil
}
type micSessionRow struct {
AppCode string
MicSessionID string
UserID int64
RoomID string
FirstSeatNo int32
@ -354,12 +509,12 @@ type micSessionRow struct {
func sessionForUpdate(ctx context.Context, tx *sql.Tx, appCode string, micSessionID string) (micSessionRow, bool, error) {
var session micSessionRow
err := tx.QueryRowContext(ctx, `
SELECT user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms,
SELECT app_code, mic_session_id, user_id, room_id, first_seat_no, current_seat_no, status, seat_started_at_ms,
publishing_started_at_ms, ended_at_ms, last_room_version
FROM user_mic_sessions
WHERE app_code = ? AND mic_session_id = ?
FOR UPDATE
`, appCode, micSessionID).Scan(&session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status,
`, appCode, micSessionID).Scan(&session.AppCode, &session.MicSessionID, &session.UserID, &session.RoomID, &session.FirstSeatNo, &session.CurrentSeatNo, &session.Status,
&session.SeatStartedAtMs, &session.PublishingStartedAtMs, &session.EndedAtMs, &session.LastRoomVersion)
if err == sql.ErrNoRows {
return micSessionRow{}, false, nil
@ -410,6 +565,52 @@ func normalizeRoomMicEvent(event mictimedomain.RoomMicEvent) mictimedomain.RoomM
return event
}
func normalizeCompensationOptions(options mictimedomain.CompensationOptions) mictimedomain.CompensationOptions {
if options.NowMs <= 0 {
options.NowMs = time.Now().UnixMilli()
}
if options.BatchSize <= 0 {
options.BatchSize = defaultCompensationBatchSize
}
if options.PendingPublishMaxAgeMs <= 0 {
options.PendingPublishMaxAgeMs = defaultPendingPublishCompensationMaxAgeMs
}
if options.PublishingSessionMaxAgeMs <= 0 {
options.PublishingSessionMaxAgeMs = defaultPublishingCompensationMaxAgeMs
}
return options
}
func compensationEndAt(session micSessionRow, options mictimedomain.CompensationOptions) int64 {
switch session.Status {
case mictimedomain.SessionStatusPendingPublish:
return minInt64(options.NowMs, session.SeatStartedAtMs+options.PendingPublishMaxAgeMs)
case mictimedomain.SessionStatusPublishing:
return minInt64(options.NowMs, session.SeatStartedAtMs+options.PublishingSessionMaxAgeMs)
default:
return options.NowMs
}
}
func insertUserOutbox(ctx context.Context, tx *sql.Tx, appCode string, eventID string, eventType string, aggregateType string, aggregateID int64, createdAtMs int64, payload map[string]any) error {
payloadBytes, err := json.Marshal(payload)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, `
INSERT IGNORE INTO user_outbox (app_code, event_id, event_type, aggregate_type, aggregate_id, status, payload_json, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, appcode.Normalize(appCode), eventID, eventType, aggregateType, aggregateID, userOutboxStatusPending, string(payloadBytes), createdAtMs, createdAtMs)
return err
}
func userMicOutboxEventID(eventType string, micSessionID string, suffix string) string {
if strings.TrimSpace(suffix) == "" {
return eventType + ":" + micSessionID
}
return eventType + ":" + micSessionID + ":" + suffix
}
type dayDuration struct {
DurationMs int64
FirstAtMs int64

View File

@ -2,6 +2,7 @@ package mictime_test
import (
"context"
"database/sql"
"testing"
"time"
@ -10,7 +11,8 @@ import (
)
func TestProcessRoomMicEventsAggregatesLifetimeAndDeduplicates(t *testing.T) {
repo := mysqltest.NewRepository(t).Repository.MicTimeRepository()
wrapped := mysqltest.NewRepository(t)
repo := wrapped.Repository.MicTimeRepository()
ctx := context.Background()
base := time.Date(2026, 5, 8, 10, 0, 0, 0, time.UTC).UnixMilli()
micSessionID := "mic_session_normal"
@ -27,6 +29,9 @@ func TestProcessRoomMicEventsAggregatesLifetimeAndDeduplicates(t *testing.T) {
if stats.SeatOccupiedMs != 11*60_000 || stats.MicOnlineMs != 10*60_000 || stats.SessionCount != 1 {
t.Fatalf("normal mic duration mismatch: %+v", stats)
}
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicSessionClosed:"+micSessionID)
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicLifetimeStatsUpdated:"+micSessionID)
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicDailyStatsUpdated:"+micSessionID+":2026-05-08")
duplicate, err := repo.ProcessRoomMicEvent(ctx, roomMicEvent("evt-down", micSessionID, mictimedomain.ActionDown, 10001, 2, 0, base+11*60_000))
if err != nil || !duplicate.Skipped || duplicate.Reason != "already_consumed" {
@ -58,6 +63,54 @@ func TestProcessPendingPublishTimeoutCountsSeatButNotMicOnline(t *testing.T) {
}
}
func TestCompensateOpenSessionsClosesPendingAndPublishingSessions(t *testing.T) {
wrapped := mysqltest.NewRepository(t)
repo := wrapped.Repository.MicTimeRepository()
ctx := context.Background()
base := time.Date(2026, 5, 8, 14, 0, 0, 0, time.UTC).UnixMilli()
pendingSessionID := "mic_session_comp_pending"
mustApply(t, repo, roomMicEvent("evt-comp-pending-up", pendingSessionID, mictimedomain.ActionUp, 10004, 0, 1, base))
publishingSessionID := "mic_session_comp_publishing"
mustApply(t, repo, roomMicEvent("evt-comp-pub-up", publishingSessionID, mictimedomain.ActionUp, 10005, 0, 1, base))
mustApply(t, repo, roomMicEvent("evt-comp-pub-confirm", publishingSessionID, mictimedomain.ActionPublishConfirmed, 10005, 1, 1, base+60_000))
result, err := repo.CompensateOpenSessions(ctx, mictimedomain.CompensationOptions{
NowMs: base + 3*60*60*1000,
BatchSize: 10,
PendingPublishMaxAgeMs: 2 * 60 * 1000,
PublishingSessionMaxAgeMs: 60 * 60 * 1000,
})
if err != nil {
t.Fatalf("CompensateOpenSessions failed: %v", err)
}
if result.ClosedCount != 2 || result.PendingPublishClosed != 1 || result.PublishingSessionClosed != 1 {
t.Fatalf("compensation result mismatch: %+v", result)
}
pendingStats, err := repo.GetLifetimeStats(ctx, "lalu", 10004)
if err != nil {
t.Fatalf("GetLifetimeStats pending failed: %v", err)
}
if pendingStats.SeatOccupiedMs != 2*60*1000 || pendingStats.MicOnlineMs != 0 || pendingStats.SessionCount != 1 {
t.Fatalf("pending compensation stats mismatch: %+v", pendingStats)
}
assertSessionEnded(t, wrapped.RawDB(), pendingSessionID, mictimedomain.CompensationReasonPendingPublishTimeout)
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicSessionClosed:"+pendingSessionID)
publishingStats, err := repo.GetLifetimeStats(ctx, "lalu", 10005)
if err != nil {
t.Fatalf("GetLifetimeStats publishing failed: %v", err)
}
if publishingStats.SeatOccupiedMs != 60*60*1000 || publishingStats.MicOnlineMs != 59*60*1000 || publishingStats.SessionCount != 1 {
t.Fatalf("publishing compensation stats mismatch: %+v", publishingStats)
}
assertSessionEnded(t, wrapped.RawDB(), publishingSessionID, mictimedomain.CompensationReasonPublishingMaxWindow)
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicSessionClosed:"+publishingSessionID)
assertUserOutboxEvent(t, wrapped.RawDB(), "UserMicLifetimeStatsUpdated:"+publishingSessionID)
}
func TestProcessCrossDayMicSessionKeepsLifetimeTotals(t *testing.T) {
repo := mysqltest.NewRepository(t).Repository.MicTimeRepository()
ctx := context.Background()
@ -113,3 +166,34 @@ func roomMicEvent(eventID string, micSessionID string, action string, userID int
}
return event
}
func assertUserOutboxEvent(t *testing.T, db *sql.DB, eventID string) {
t.Helper()
var count int
if err := db.QueryRowContext(context.Background(), `
SELECT COUNT(*)
FROM user_outbox
WHERE app_code = 'lalu' AND event_id = ?
`, eventID).Scan(&count); err != nil {
t.Fatalf("query user_outbox failed: %v", err)
}
if count != 1 {
t.Fatalf("user_outbox event %s count=%d, want 1", eventID, count)
}
}
func assertSessionEnded(t *testing.T, db *sql.DB, micSessionID string, reason string) {
t.Helper()
var status string
var endReason string
if err := db.QueryRowContext(context.Background(), `
SELECT status, end_reason
FROM user_mic_sessions
WHERE app_code = 'lalu' AND mic_session_id = ?
`, micSessionID).Scan(&status, &endReason); err != nil {
t.Fatalf("query user_mic_sessions failed: %v", err)
}
if status != mictimedomain.SessionStatusEnded || endReason != reason {
t.Fatalf("session %s terminal state mismatch: status=%s reason=%s", micSessionID, status, endReason)
}
}

View File

@ -3,6 +3,7 @@ package mysqltest
import (
"context"
"database/sql"
"strings"
"testing"
"time"
@ -52,6 +53,14 @@ func (r *Repository) Close() {
}
}
// RawDB exposes the isolated test schema for assertions that span read-model tables.
func (r *Repository) RawDB() *sql.DB {
if r == nil || r.schema == nil {
return nil
}
return r.schema.DB
}
// GetUser 让测试 wrapper 继续满足业务层用户主数据接口。
func (r *Repository) GetUser(ctx context.Context, userID int64) (userdomain.User, error) {
return r.Repository.UserRepository().GetUser(ctx, userID)

View File

@ -4,9 +4,11 @@ import (
"context"
"flag"
"log"
"os"
"os/signal"
"syscall"
"hyapp/pkg/logx"
"hyapp/services/wallet-service/internal/app"
"hyapp/services/wallet-service/internal/config"
)
@ -20,10 +22,13 @@ func main() {
if err != nil {
log.Fatal(err)
}
if err := logx.Init(cfg.Log.WithRuntimeFields(cfg.ServiceName, cfg.Environment, cfg.NodeID)); err != nil {
log.Fatal(err)
}
application, err := app.New(cfg)
if err != nil {
log.Fatal(err)
fatalRuntime("app_new_failed", err)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@ -39,7 +44,12 @@ func main() {
application.Close()
case err := <-errCh:
if err != nil {
log.Fatal(err)
fatalRuntime("service_run_failed", err)
}
}
}
func fatalRuntime(msg string, err error) {
logx.Error(context.Background(), msg, err)
os.Exit(1)
}

View File

@ -1,5 +1,12 @@
service_name: wallet-service
node_id: wallet-docker
environment: local
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13004"
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
mysql_auto_migrate: false

View File

@ -1,5 +1,12 @@
service_name: wallet-service
node_id: wallet-tencent
environment: prod
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13004"
mysql_dsn: "${TENCENT_MYSQL_WALLET_DSN}"
mysql_auto_migrate: false

View File

@ -1,5 +1,12 @@
service_name: wallet-service
node_id: wallet-local
environment: local
log:
level: info
format: json
include_request_body: false
include_response_body: false
max_payload_bytes: 2048
grpc_addr: ":13004"
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
mysql_auto_migrate: false

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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS coin_seller_stock_records (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
stock_id VARCHAR(96) NOT NULL,
transaction_id VARCHAR(96) NOT NULL,
command_id VARCHAR(128) NOT NULL,
seller_user_id BIGINT NOT NULL,
stock_type VARCHAR(32) NOT NULL,
counts_as_seller_recharge BOOLEAN NOT NULL,
coin_amount BIGINT NOT NULL,
paid_currency_code VARCHAR(8) NOT NULL DEFAULT '',
paid_amount_micro BIGINT NOT NULL DEFAULT 0,
payment_ref VARCHAR(128) NULL,
evidence_ref VARCHAR(256) NOT NULL DEFAULT '',
operator_user_id BIGINT NOT NULL,
reason VARCHAR(512) NOT NULL DEFAULT '',
balance_after BIGINT NOT NULL,
metadata_json JSON NULL,
created_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stock_id),
UNIQUE KEY uk_coin_seller_stock_command (app_code, command_id),
UNIQUE KEY uk_coin_seller_stock_payment_ref (app_code, stock_type, payment_ref),
KEY idx_coin_seller_stock_seller_time (app_code, seller_user_id, created_at_ms),
KEY idx_coin_seller_stock_type_time (app_code, stock_type, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS resources (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
resource_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,

View File

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

View File

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

View File

@ -15,6 +15,7 @@ type Repository interface {
DebitGift(ctx context.Context, command ledger.DebitGiftCommand) (ledger.Receipt, error)
GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error)
AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error)
AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error)
TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error)
ListRechargeBills(ctx context.Context, query ledger.ListRechargeBillsQuery) ([]ledger.RechargeBill, int64, error)
ListResources(ctx context.Context, query resourcedomain.ListResourcesQuery) ([]resourcedomain.Resource, int64, error)
@ -113,6 +114,52 @@ func (s *Service) AdminCreditAsset(ctx context.Context, command ledger.AdminCred
return s.repository.AdminCreditAsset(ctx, command)
}
// AdminCreditCoinSellerStock 只给 COIN_SELLER_COIN 库存入账USDT 进货和金币补偿使用不同账务类型。
func (s *Service) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) {
if command.CommandID == "" || command.SellerUserID <= 0 || command.OperatorUserID <= 0 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock command is incomplete")
}
if len(command.CommandID) > 128 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "command_id is too long")
}
if command.CoinAmount <= 0 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin_amount must be positive")
}
command.StockType = ledger.NormalizeCoinSellerStockType(command.StockType)
if command.StockType == "" {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid")
}
command.PaidCurrencyCode = strings.ToUpper(strings.TrimSpace(command.PaidCurrencyCode))
command.PaymentRef = strings.TrimSpace(command.PaymentRef)
command.EvidenceRef = strings.TrimSpace(command.EvidenceRef)
command.Reason = strings.TrimSpace(command.Reason)
if len(command.PaidCurrencyCode) > 8 || len(command.PaymentRef) > 128 || len(command.EvidenceRef) > 256 || len(command.Reason) > 512 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller stock text fields are too long")
}
switch command.StockType {
case ledger.StockTypeUSDTPurchase:
if command.PaidCurrencyCode == "" {
command.PaidCurrencyCode = ledger.PaidCurrencyUSDT
}
if command.PaidCurrencyCode != ledger.PaidCurrencyUSDT || command.PaidAmountMicro <= 0 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "usdt purchase requires paid amount")
}
case ledger.StockTypeCoinCompensation:
if command.PaidCurrencyCode != "" || command.PaidAmountMicro != 0 || command.PaymentRef != "" {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin compensation must not include paid amount")
}
default:
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid")
}
if s.repository == nil {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.AdminCreditCoinSellerStock(ctx, command)
}
// TransferCoinFromSeller 执行币商向玩家转金币;身份和区域都由 gateway/user-service 校验并传入。
func (s *Service) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) {
if command.CommandID == "" || command.SellerUserID <= 0 || command.TargetUserID <= 0 || command.Amount <= 0 {

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 的资源。
func TestGiftConfigMustSelectGiftResource(t *testing.T) {
repository := mysqltest.NewRepository(t)

View File

@ -17,15 +17,17 @@ import (
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
_ "github.com/go-sql-driver/mysql"
mysqlDriver "github.com/go-sql-driver/mysql"
)
const (
transactionStatusSucceeded = "succeeded"
bizTypeGiftDebit = "gift_debit"
bizTypeManualCredit = "manual_credit"
bizTypeCoinSellerTransfer = "coin_seller_transfer"
outboxStatusPending = "pending"
transactionStatusSucceeded = "succeeded"
bizTypeGiftDebit = "gift_debit"
bizTypeManualCredit = "manual_credit"
bizTypeCoinSellerTransfer = "coin_seller_transfer"
bizTypeCoinSellerStockPurchase = "coin_seller_stock_purchase"
bizTypeCoinSellerCoinCompensation = "coin_seller_coin_compensation"
outboxStatusPending = "pending"
)
// Repository 是 wallet-service 的 MySQL v2 账本入口。
@ -345,6 +347,106 @@ func (r *Repository) AdminCreditAsset(ctx context.Context, command ledger.AdminC
return balance, transactionID, nil
}
// AdminCreditCoinSellerStock 在同一事务里给币商 COIN_SELLER_COIN 库存入账,并记录专用进货明细。
func (r *Repository) AdminCreditCoinSellerStock(ctx context.Context, command ledger.CoinSellerStockCreditCommand) (ledger.CoinSellerStockCreditReceipt, error) {
if r == nil || r.db == nil {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
}
ctx = contextWithCommandApp(ctx, command.AppCode)
command.AppCode = appcode.FromContext(ctx)
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
defer func() { _ = tx.Rollback() }()
bizType := coinSellerStockBizType(command.StockType)
if bizType == "" {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerStockTypeInvalid, "stock_type is invalid")
}
requestHash := coinSellerStockRequestHash(command)
if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, bizType, xerr.IdempotencyConflict); err != nil || exists {
if err != nil || !exists {
return ledger.CoinSellerStockCreditReceipt{}, err
}
return r.receiptForCoinSellerStockTransaction(ctx, tx, txRow.TransactionID)
}
if command.PaymentRef != "" {
if duplicated, err := r.coinSellerStockPaymentRefExists(ctx, tx, command.StockType, command.PaymentRef); err != nil || duplicated {
if err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated")
}
}
nowMs := time.Now().UnixMilli()
account, err := r.lockAccount(ctx, tx, command.SellerUserID, ledger.AssetCoinSellerCoin, true, nowMs)
if err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
balanceAfter, err := checkedAdd(account.AvailableAmount, command.CoinAmount)
if err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
transactionID := transactionID(command.AppCode, command.CommandID)
countsAsSellerRecharge := command.StockType == ledger.StockTypeUSDTPurchase
metadata := coinSellerStockMetadata{
AppCode: command.AppCode,
SellerUserID: command.SellerUserID,
StockType: command.StockType,
CoinAmount: command.CoinAmount,
PaidCurrencyCode: command.PaidCurrencyCode,
PaidAmountMicro: command.PaidAmountMicro,
PaymentRef: command.PaymentRef,
EvidenceRef: command.EvidenceRef,
OperatorUserID: command.OperatorUserID,
Reason: command.Reason,
AssetType: ledger.AssetCoinSellerCoin,
CountsAsSellerRecharge: countsAsSellerRecharge,
BalanceAfter: balanceAfter,
CreatedAtMS: nowMs,
}
externalRef := command.EvidenceRef
if command.PaymentRef != "" {
externalRef = command.PaymentRef
}
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizType, requestHash, externalRef, metadata, nowMs); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
if err := r.applyAccountDelta(ctx, tx, account, command.CoinAmount, 0, nowMs); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
if err := r.insertEntry(ctx, tx, walletEntry{
TransactionID: transactionID,
UserID: command.SellerUserID,
AssetType: ledger.AssetCoinSellerCoin,
AvailableDelta: command.CoinAmount,
FrozenDelta: 0,
AvailableAfter: balanceAfter,
FrozenAfter: account.FrozenAmount,
CreatedAtMS: nowMs,
}); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
if err := r.insertCoinSellerStockRecord(ctx, tx, transactionID, command.CommandID, metadata, nowMs); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
balanceChangedEvent(transactionID, command.CommandID, command.SellerUserID, ledger.AssetCoinSellerCoin, command.CoinAmount, 0, balanceAfter, account.FrozenAmount, metadata, nowMs),
coinSellerStockCreditedEvent(transactionID, command.CommandID, metadata, nowMs),
}); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
if err := tx.Commit(); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
return receiptFromCoinSellerStockMetadata(transactionID, metadata), nil
}
// TransferCoinFromSeller 在同一事务里扣币商专用金币、给玩家普通 COIN 入账,并记录充值口径。
func (r *Repository) TransferCoinFromSeller(ctx context.Context, command ledger.CoinSellerTransferCommand) (ledger.CoinSellerTransferReceipt, error) {
if r == nil || r.db == nil {
@ -469,6 +571,10 @@ type transactionRow struct {
}
func (r *Repository) lookupTransaction(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string) (transactionRow, bool, error) {
return r.lookupTransactionWithConflictCode(ctx, tx, commandID, requestHash, bizType, xerr.LedgerConflict)
}
func (r *Repository) lookupTransactionWithConflictCode(ctx context.Context, tx *sql.Tx, commandID string, requestHash string, bizType string, conflictCode xerr.Code) (transactionRow, bool, error) {
row := tx.QueryRowContext(ctx,
`SELECT transaction_id, request_hash, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}')
FROM wallet_transactions
@ -489,7 +595,7 @@ func (r *Repository) lookupTransaction(ctx context.Context, tx *sql.Tx, commandI
}
if storedHash != requestHash || storedBizType != bizType {
// command_id 是钱包幂等主键,业务 payload 或命令类型变化必须 fail-closed。
return transactionRow{}, true, xerr.New(xerr.LedgerConflict, "wallet command idempotency conflict")
return transactionRow{}, true, xerr.New(conflictCode, "wallet command idempotency conflict")
}
return txRow, true, nil
}
@ -729,6 +835,65 @@ func (r *Repository) insertRechargeRecord(ctx context.Context, tx *sql.Tx, trans
return err
}
func (r *Repository) insertCoinSellerStockRecord(ctx context.Context, tx *sql.Tx, transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) error {
metadataJSON, err := json.Marshal(metadata)
if err != nil {
return err
}
var paymentRef any
if metadata.PaymentRef != "" {
paymentRef = metadata.PaymentRef
}
_, err = tx.ExecContext(ctx,
`INSERT INTO coin_seller_stock_records (
app_code, stock_id, transaction_id, command_id, seller_user_id, stock_type,
counts_as_seller_recharge, coin_amount, paid_currency_code, paid_amount_micro,
payment_ref, evidence_ref, operator_user_id, reason, balance_after, metadata_json, created_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
normalizedEntryAppCode(ctx, metadata.AppCode),
coinSellerStockID(metadata.AppCode, commandID),
transactionID,
commandID,
metadata.SellerUserID,
metadata.StockType,
metadata.CountsAsSellerRecharge,
metadata.CoinAmount,
metadata.PaidCurrencyCode,
metadata.PaidAmountMicro,
paymentRef,
metadata.EvidenceRef,
metadata.OperatorUserID,
metadata.Reason,
metadata.BalanceAfter,
string(metadataJSON),
nowMs,
)
if err != nil && isMySQLDuplicateError(err) && metadata.PaymentRef != "" {
return xerr.New(xerr.CoinSellerPaymentRefDuplicated, "payment_ref is duplicated")
}
return err
}
func (r *Repository) coinSellerStockPaymentRefExists(ctx context.Context, tx *sql.Tx, stockType string, paymentRef string) (bool, error) {
var stockID string
err := tx.QueryRowContext(ctx,
`SELECT stock_id
FROM coin_seller_stock_records
WHERE app_code = ? AND stock_type = ? AND payment_ref = ?
LIMIT 1`,
appcode.FromContext(ctx),
stockType,
paymentRef,
).Scan(&stockID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
return true, nil
}
type walletEntry struct {
AppCode string
TransactionID string
@ -895,6 +1060,23 @@ type coinSellerTransferMetadata struct {
RechargePolicyUSDMinorAmount int64 `json:"recharge_policy_usd_minor_amount"`
}
type coinSellerStockMetadata struct {
AppCode string `json:"app_code"`
SellerUserID int64 `json:"seller_user_id"`
StockType string `json:"stock_type"`
CoinAmount int64 `json:"coin_amount"`
PaidCurrencyCode string `json:"paid_currency_code"`
PaidAmountMicro int64 `json:"paid_amount_micro"`
PaymentRef string `json:"payment_ref"`
EvidenceRef string `json:"evidence_ref"`
OperatorUserID int64 `json:"operator_user_id"`
Reason string `json:"reason"`
AssetType string `json:"asset_type"`
CountsAsSellerRecharge bool `json:"counts_as_seller_recharge"`
BalanceAfter int64 `json:"balance_after"`
CreatedAtMS int64 `json:"created_at_ms"`
}
func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger.Receipt {
chargeAssetType := ledger.NormalizeGiftChargeAssetType(metadata.ChargeAssetType)
chargeAmount := metadata.ChargeAmount
@ -914,6 +1096,46 @@ func receiptFromGiftMetadata(transactionID string, metadata giftMetadata) ledger
}
}
func (r *Repository) receiptForCoinSellerStockTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerStockCreditReceipt, error) {
row := tx.QueryRowContext(ctx,
`SELECT seller_user_id, stock_type, coin_amount, paid_currency_code, paid_amount_micro,
counts_as_seller_recharge, balance_after, created_at_ms
FROM coin_seller_stock_records
WHERE app_code = ? AND transaction_id = ?`,
appcode.FromContext(ctx),
transactionID,
)
var receipt ledger.CoinSellerStockCreditReceipt
if err := row.Scan(
&receipt.SellerUserID,
&receipt.StockType,
&receipt.CoinAmount,
&receipt.PaidCurrencyCode,
&receipt.PaidAmountMicro,
&receipt.CountsAsSellerRecharge,
&receipt.BalanceAfter,
&receipt.CreatedAtMS,
); err != nil {
return ledger.CoinSellerStockCreditReceipt{}, err
}
receipt.TransactionID = transactionID
return receipt, nil
}
func receiptFromCoinSellerStockMetadata(transactionID string, metadata coinSellerStockMetadata) ledger.CoinSellerStockCreditReceipt {
return ledger.CoinSellerStockCreditReceipt{
TransactionID: transactionID,
SellerUserID: metadata.SellerUserID,
StockType: metadata.StockType,
CoinAmount: metadata.CoinAmount,
PaidCurrencyCode: metadata.PaidCurrencyCode,
PaidAmountMicro: metadata.PaidAmountMicro,
CountsAsSellerRecharge: metadata.CountsAsSellerRecharge,
BalanceAfter: metadata.BalanceAfter,
CreatedAtMS: metadata.CreatedAtMS,
}
}
func (r *Repository) receiptForCoinSellerTransferTransaction(ctx context.Context, tx *sql.Tx, transactionID string) (ledger.CoinSellerTransferReceipt, error) {
var metadataJSON string
if err := tx.QueryRowContext(ctx,
@ -1003,6 +1225,36 @@ func rechargeRecordedEvent(transactionID string, commandID string, targetUserID
}
}
func coinSellerStockCreditedEvent(transactionID string, commandID string, metadata coinSellerStockMetadata, nowMs int64) walletOutboxEvent {
eventType := "WalletCoinSellerCoinCompensated"
if metadata.StockType == ledger.StockTypeUSDTPurchase {
eventType = "WalletCoinSellerStockPurchased"
}
return walletOutboxEvent{
EventID: eventID(transactionID, eventType, metadata.SellerUserID, ledger.AssetCoinSellerCoin),
EventType: eventType,
TransactionID: transactionID,
CommandID: commandID,
UserID: metadata.SellerUserID,
AssetType: ledger.AssetCoinSellerCoin,
AvailableDelta: metadata.CoinAmount,
FrozenDelta: 0,
Payload: map[string]any{
"transaction_id": transactionID,
"command_id": commandID,
"seller_user_id": metadata.SellerUserID,
"stock_type": metadata.StockType,
"coin_amount": metadata.CoinAmount,
"paid_currency_code": metadata.PaidCurrencyCode,
"paid_amount_micro": metadata.PaidAmountMicro,
"counts_as_seller_recharge": metadata.CountsAsSellerRecharge,
"operator_user_id": metadata.OperatorUserID,
"created_at_ms": nowMs,
},
CreatedAtMS: nowMs,
}
}
func giftDebitedEvent(transactionID string, commandID string, userID int64, assetType string, availableDelta int64, frozenDelta int64, payload any, nowMs int64) walletOutboxEvent {
return walletOutboxEvent{
EventID: eventID(transactionID, "WalletGiftDebited", userID, assetType),
@ -1058,6 +1310,13 @@ func checkedMul(unit int64, count int64) (int64, error) {
return unit * count, nil
}
func checkedAdd(left int64, right int64) (int64, error) {
if right < 0 || left > math.MaxInt64-right {
return 0, xerr.New(xerr.CoinSellerStockAmountInvalid, "coin amount overflow")
}
return left + right, nil
}
func debitRequestHash(command ledger.DebitGiftCommand) string {
// 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s",
@ -1083,6 +1342,21 @@ func adminCreditRequestHash(command ledger.AdminCreditAssetCommand) string {
))
}
func coinSellerStockRequestHash(command ledger.CoinSellerStockCreditCommand) string {
return stableHash(fmt.Sprintf("coin_seller_stock|%s|%d|%s|%d|%s|%d|%s|%s|%d|%s",
appcode.Normalize(command.AppCode),
command.SellerUserID,
command.StockType,
command.CoinAmount,
command.PaidCurrencyCode,
command.PaidAmountMicro,
command.PaymentRef,
command.EvidenceRef,
command.OperatorUserID,
command.Reason,
))
}
func coinSellerTransferRequestHash(command ledger.CoinSellerTransferCommand) string {
return stableHash(fmt.Sprintf("coin_seller_transfer|%s|%d|%d|%d|%d|%d|%s",
appcode.Normalize(command.AppCode),
@ -1095,6 +1369,17 @@ func coinSellerTransferRequestHash(command ledger.CoinSellerTransferCommand) str
))
}
func coinSellerStockBizType(stockType string) string {
switch stockType {
case ledger.StockTypeUSDTPurchase:
return bizTypeCoinSellerStockPurchase
case ledger.StockTypeCoinCompensation:
return bizTypeCoinSellerCoinCompensation
default:
return ""
}
}
func stableHash(value string) string {
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
@ -1112,6 +1397,10 @@ func transactionID(appCode string, commandID string) string {
return "wtx_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
}
func coinSellerStockID(appCode string, commandID string) string {
return "cstock_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
}
func billingReceiptID(appCode string, commandID string) string {
return "bill_" + stableHash(appcode.Normalize(appCode)+"|"+commandID)
}
@ -1119,3 +1408,8 @@ func billingReceiptID(appCode string, commandID string) string {
func eventID(transactionID string, eventType string, userID int64, assetType string) string {
return "wev_" + stableHash(fmt.Sprintf("%s|%s|%d|%s", transactionID, eventType, userID, assetType))
}
func isMySQLDuplicateError(err error) bool {
var mysqlErr *mysqlDriver.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
}

View File

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

View File

@ -104,6 +104,39 @@ func (s *Server) AdminCreditAsset(ctx context.Context, req *walletv1.AdminCredit
}, nil
}
// AdminCreditCoinSellerStock 处理后台币商进货或金币补偿,币商身份由 admin-server 先行校验。
func (s *Server) AdminCreditCoinSellerStock(ctx context.Context, req *walletv1.AdminCreditCoinSellerStockRequest) (*walletv1.AdminCreditCoinSellerStockResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())
receipt, err := s.svc.AdminCreditCoinSellerStock(ctx, ledger.CoinSellerStockCreditCommand{
AppCode: req.GetAppCode(),
CommandID: req.GetCommandId(),
SellerUserID: req.GetSellerUserId(),
StockType: req.GetStockType(),
CoinAmount: req.GetCoinAmount(),
PaidCurrencyCode: req.GetPaidCurrencyCode(),
PaidAmountMicro: req.GetPaidAmountMicro(),
PaymentRef: req.GetPaymentRef(),
EvidenceRef: req.GetEvidenceRef(),
OperatorUserID: req.GetOperatorUserId(),
Reason: req.GetReason(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &walletv1.AdminCreditCoinSellerStockResponse{
TransactionId: receipt.TransactionID,
SellerUserId: receipt.SellerUserID,
StockType: receipt.StockType,
CoinAmount: receipt.CoinAmount,
PaidCurrencyCode: receipt.PaidCurrencyCode,
PaidAmountMicro: receipt.PaidAmountMicro,
CountsAsSellerRecharge: receipt.CountsAsSellerRecharge,
BalanceAfter: receipt.BalanceAfter,
CreatedAtMs: receipt.CreatedAtMS,
}, nil
}
// TransferCoinFromSeller 处理币商转玩家金币,调用方必须先完成币商身份校验。
func (s *Server) TransferCoinFromSeller(ctx context.Context, req *walletv1.TransferCoinFromSellerRequest) (*walletv1.TransferCoinFromSellerResponse, error) {
ctx = appcode.WithContext(ctx, req.GetAppCode())