Merge branch 'main' into test

This commit is contained in:
zhx 2026-06-23 14:05:38 +08:00
commit f453ede08b
181 changed files with 23887 additions and 20806 deletions

View File

@ -48,7 +48,8 @@ build:
# 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。
run:
./scripts/resolve-compose-container-conflicts.sh
docker compose up -d mysql redis
./scripts/prepare-local-rocketmq-config.sh
ROCKETMQ_BROKER_CONFIG=./tmp/rocketmq/broker.local.conf docker compose up -d mysql redis rocketmq-namesrv rocketmq-broker
./scripts/apply-local-mysql-initdb.sh
./scripts/apply-local-rocketmq-topics.sh
docker compose stop $(GO_RUN_SERVICES) >/dev/null 2>&1 || true

File diff suppressed because it is too large Load Diff

View File

@ -276,6 +276,8 @@ message AdminHumanRoomRobotCountryPool {
message AdminHumanRoomRobotCountryRule {
string country_code = 1;
int32 max_room_count = 2;
// allowed_owner_ids / owner_user_id
repeated string allowed_owner_ids = 3;
}
message AdminHumanRoomRobotConfig {

View File

@ -1042,6 +1042,106 @@ func (x *RecordLoginBlockedResponse) GetRecorded() bool {
return false
}
// CheckLoginRiskIPWhitelistRequest 查询入口 IP 是否命中后台白名单。
type CheckLoginRiskIPWhitelistRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
ClientIp string `protobuf:"bytes,2,opt,name=client_ip,json=clientIp,proto3" json:"client_ip,omitempty"`
}
func (x *CheckLoginRiskIPWhitelistRequest) Reset() {
*x = CheckLoginRiskIPWhitelistRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CheckLoginRiskIPWhitelistRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckLoginRiskIPWhitelistRequest) ProtoMessage() {}
func (x *CheckLoginRiskIPWhitelistRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckLoginRiskIPWhitelistRequest.ProtoReflect.Descriptor instead.
func (*CheckLoginRiskIPWhitelistRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{13}
}
func (x *CheckLoginRiskIPWhitelistRequest) GetMeta() *RequestMeta {
if x != nil {
return x.Meta
}
return nil
}
func (x *CheckLoginRiskIPWhitelistRequest) GetClientIp() string {
if x != nil {
return x.ClientIp
}
return ""
}
// CheckLoginRiskIPWhitelistResponse 返回入口 IP 是否跳过登录地区屏蔽。
type CheckLoginRiskIPWhitelistResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Whitelisted bool `protobuf:"varint,1,opt,name=whitelisted,proto3" json:"whitelisted,omitempty"`
}
func (x *CheckLoginRiskIPWhitelistResponse) Reset() {
*x = CheckLoginRiskIPWhitelistResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CheckLoginRiskIPWhitelistResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckLoginRiskIPWhitelistResponse) ProtoMessage() {}
func (x *CheckLoginRiskIPWhitelistResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckLoginRiskIPWhitelistResponse.ProtoReflect.Descriptor instead.
func (*CheckLoginRiskIPWhitelistResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{14}
}
func (x *CheckLoginRiskIPWhitelistResponse) GetWhitelisted() bool {
if x != nil {
return x.Whitelisted
}
return false
}
// AppHeartbeatRequest 刷新当前登录会话的 App 在线心跳。
type AppHeartbeatRequest struct {
state protoimpl.MessageState
@ -1055,7 +1155,7 @@ type AppHeartbeatRequest struct {
func (x *AppHeartbeatRequest) Reset() {
*x = AppHeartbeatRequest{}
mi := &file_proto_user_v1_auth_proto_msgTypes[13]
mi := &file_proto_user_v1_auth_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1067,7 +1167,7 @@ func (x *AppHeartbeatRequest) String() string {
func (*AppHeartbeatRequest) ProtoMessage() {}
func (x *AppHeartbeatRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[13]
mi := &file_proto_user_v1_auth_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1080,7 +1180,7 @@ func (x *AppHeartbeatRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use AppHeartbeatRequest.ProtoReflect.Descriptor instead.
func (*AppHeartbeatRequest) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{13}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{15}
}
func (x *AppHeartbeatRequest) GetMeta() *RequestMeta {
@ -1118,7 +1218,7 @@ type AppHeartbeatResponse struct {
func (x *AppHeartbeatResponse) Reset() {
*x = AppHeartbeatResponse{}
mi := &file_proto_user_v1_auth_proto_msgTypes[14]
mi := &file_proto_user_v1_auth_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1130,7 +1230,7 @@ func (x *AppHeartbeatResponse) String() string {
func (*AppHeartbeatResponse) ProtoMessage() {}
func (x *AppHeartbeatResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_user_v1_auth_proto_msgTypes[14]
mi := &file_proto_user_v1_auth_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1143,7 +1243,7 @@ func (x *AppHeartbeatResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use AppHeartbeatResponse.ProtoReflect.Descriptor instead.
func (*AppHeartbeatResponse) Descriptor() ([]byte, []int) {
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{14}
return file_proto_user_v1_auth_proto_rawDescGZIP(), []int{16}
}
func (x *AppHeartbeatResponse) GetAccepted() bool {
@ -1335,75 +1435,95 @@ var file_proto_user_v1_auth_proto_rawDesc = []byte{
0x73, 0x6f, 0x6e, 0x22, 0x38, 0x0a, 0x1a, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67,
0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x64, 0x18, 0x01, 0x20,
0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x64, 0x22, 0x7d, 0x0a,
0x13, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01,
0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e,
0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04,
0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a,
0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28,
0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x22, 0xb0, 0x01, 0x0a,
0x14, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65,
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65,
0x64, 0x12, 0x26, 0x0a, 0x0f, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x5f, 0x61,
0x74, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x68, 0x65, 0x61, 0x72,
0x74, 0x62, 0x65, 0x61, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x72,
0x76, 0x65, 0x72, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x5f, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28,
0x03, 0x52, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x54, 0x69, 0x6d, 0x65, 0x4d, 0x73, 0x12,
0x2e, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41,
0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x32,
0xdc, 0x05, 0x0a, 0x0b, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12,
0x51, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64,
0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31,
0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65,
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x55, 0x0a, 0x0f, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x68, 0x69, 0x72, 0x64,
0x50, 0x61, 0x72, 0x74, 0x79, 0x12, 0x25, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x68, 0x69, 0x72, 0x64,
0x50, 0x61, 0x72, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x68,
0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74,
0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0b, 0x53, 0x65, 0x74,
0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73,
0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x50,
0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x69, 0x0a, 0x12, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x63,
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x43, 0x72, 0x65, 0x61, 0x74,
0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e,
0x51, 0x75, 0x69, 0x63, 0x6b, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75,
0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0c, 0x52, 0x65,
0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x22, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65,
0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23,
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52,
0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e,
0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x65, 0x64, 0x22, 0x6f, 0x0a,
0x20, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x49,
0x50, 0x57, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74,
0x61, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x70, 0x18, 0x02,
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x49, 0x70, 0x22, 0x45,
0x0a, 0x21, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b,
0x49, 0x50, 0x57, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x77, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74,
0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x77, 0x68, 0x69, 0x74, 0x65, 0x6c,
0x69, 0x73, 0x74, 0x65, 0x64, 0x22, 0x7d, 0x0a, 0x13, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72,
0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04,
0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07,
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75,
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69,
0x6f, 0x6e, 0x49, 0x64, 0x22, 0xb0, 0x01, 0x0a, 0x14, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72,
0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a,
0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
0x08, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, 0x12, 0x26, 0x0a, 0x0f, 0x68, 0x65, 0x61,
0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x01,
0x28, 0x03, 0x52, 0x0d, 0x68, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x41, 0x74, 0x4d,
0x73, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x5f, 0x74, 0x69, 0x6d, 0x65,
0x5f, 0x6d, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x72, 0x76, 0x65,
0x72, 0x54, 0x69, 0x6d, 0x65, 0x4d, 0x73, 0x12, 0x2e, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75,
0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x32, 0xdc, 0x06, 0x0a, 0x0b, 0x41, 0x75, 0x74, 0x68,
0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x51, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69, 0x6e,
0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x50, 0x61,
0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75,
0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, 0x0a, 0x0f, 0x4c, 0x6f,
0x67, 0x69, 0x6e, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x12, 0x25, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f,
0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f,
0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, 0x12, 0x52, 0x65,
0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64,
0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31,
0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63,
0x6b, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72,
0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x73,
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0c, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72,
0x74, 0x62, 0x65, 0x61, 0x74, 0x12, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65,
0x61, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70,
0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61,
0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x26,
0x5a, 0x24, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70,
0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x3b,
0x75, 0x73, 0x65, 0x72, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
0x67, 0x69, 0x6e, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65,
0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
0x65, 0x12, 0x54, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64,
0x12, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31,
0x2e, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72,
0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, 0x12, 0x51, 0x75, 0x69, 0x63, 0x6b,
0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x28, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75,
0x69, 0x63, 0x6b, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74,
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x69, 0x63, 0x6b, 0x43, 0x72, 0x65,
0x61, 0x74, 0x65, 0x41, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x57, 0x0a, 0x0c, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b,
0x65, 0x6e, 0x12, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e,
0x76, 0x31, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52,
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75,
0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f,
0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x45, 0x0a, 0x06, 0x4c,
0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72,
0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
0x73, 0x65, 0x12, 0x69, 0x0a, 0x12, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69,
0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x28, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c,
0x6f, 0x67, 0x69, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65,
0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e,
0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x42, 0x6c,
0x6f, 0x63, 0x6b, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7e, 0x0a,
0x19, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x49,
0x50, 0x57, 0x68, 0x69, 0x74, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x12, 0x2f, 0x2e, 0x68, 0x79, 0x61,
0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b,
0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x49, 0x50, 0x57, 0x68, 0x69, 0x74, 0x65,
0x6c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x30, 0x2e, 0x68, 0x79,
0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x65, 0x63,
0x6b, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x52, 0x69, 0x73, 0x6b, 0x49, 0x50, 0x57, 0x68, 0x69, 0x74,
0x65, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a,
0x0c, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x12, 0x22, 0x2e,
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x70,
0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x1a, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
0x31, 0x2e, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x72, 0x74, 0x62, 0x65, 0x61, 0x74, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x26, 0x5a, 0x24, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
0x6c, 0x6f, 0x63, 0x61, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
0x75, 0x73, 0x65, 0x72, 0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x76, 0x31, 0x62, 0x06,
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
@ -1418,60 +1538,65 @@ func file_proto_user_v1_auth_proto_rawDescGZIP() []byte {
return file_proto_user_v1_auth_proto_rawDescData
}
var file_proto_user_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
var file_proto_user_v1_auth_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
var file_proto_user_v1_auth_proto_goTypes = []any{
(*LoginPasswordRequest)(nil), // 0: hyapp.user.v1.LoginPasswordRequest
(*LoginThirdPartyRequest)(nil), // 1: hyapp.user.v1.LoginThirdPartyRequest
(*AuthResponse)(nil), // 2: hyapp.user.v1.AuthResponse
(*SetPasswordRequest)(nil), // 3: hyapp.user.v1.SetPasswordRequest
(*SetPasswordResponse)(nil), // 4: hyapp.user.v1.SetPasswordResponse
(*QuickCreateAccountRequest)(nil), // 5: hyapp.user.v1.QuickCreateAccountRequest
(*QuickCreateAccountResponse)(nil), // 6: hyapp.user.v1.QuickCreateAccountResponse
(*RefreshTokenRequest)(nil), // 7: hyapp.user.v1.RefreshTokenRequest
(*RefreshTokenResponse)(nil), // 8: hyapp.user.v1.RefreshTokenResponse
(*LogoutRequest)(nil), // 9: hyapp.user.v1.LogoutRequest
(*LogoutResponse)(nil), // 10: hyapp.user.v1.LogoutResponse
(*RecordLoginBlockedRequest)(nil), // 11: hyapp.user.v1.RecordLoginBlockedRequest
(*RecordLoginBlockedResponse)(nil), // 12: hyapp.user.v1.RecordLoginBlockedResponse
(*AppHeartbeatRequest)(nil), // 13: hyapp.user.v1.AppHeartbeatRequest
(*AppHeartbeatResponse)(nil), // 14: hyapp.user.v1.AppHeartbeatResponse
(*RequestMeta)(nil), // 15: hyapp.user.v1.RequestMeta
(*AuthToken)(nil), // 16: hyapp.user.v1.AuthToken
(*LoginPasswordRequest)(nil), // 0: hyapp.user.v1.LoginPasswordRequest
(*LoginThirdPartyRequest)(nil), // 1: hyapp.user.v1.LoginThirdPartyRequest
(*AuthResponse)(nil), // 2: hyapp.user.v1.AuthResponse
(*SetPasswordRequest)(nil), // 3: hyapp.user.v1.SetPasswordRequest
(*SetPasswordResponse)(nil), // 4: hyapp.user.v1.SetPasswordResponse
(*QuickCreateAccountRequest)(nil), // 5: hyapp.user.v1.QuickCreateAccountRequest
(*QuickCreateAccountResponse)(nil), // 6: hyapp.user.v1.QuickCreateAccountResponse
(*RefreshTokenRequest)(nil), // 7: hyapp.user.v1.RefreshTokenRequest
(*RefreshTokenResponse)(nil), // 8: hyapp.user.v1.RefreshTokenResponse
(*LogoutRequest)(nil), // 9: hyapp.user.v1.LogoutRequest
(*LogoutResponse)(nil), // 10: hyapp.user.v1.LogoutResponse
(*RecordLoginBlockedRequest)(nil), // 11: hyapp.user.v1.RecordLoginBlockedRequest
(*RecordLoginBlockedResponse)(nil), // 12: hyapp.user.v1.RecordLoginBlockedResponse
(*CheckLoginRiskIPWhitelistRequest)(nil), // 13: hyapp.user.v1.CheckLoginRiskIPWhitelistRequest
(*CheckLoginRiskIPWhitelistResponse)(nil), // 14: hyapp.user.v1.CheckLoginRiskIPWhitelistResponse
(*AppHeartbeatRequest)(nil), // 15: hyapp.user.v1.AppHeartbeatRequest
(*AppHeartbeatResponse)(nil), // 16: hyapp.user.v1.AppHeartbeatResponse
(*RequestMeta)(nil), // 17: hyapp.user.v1.RequestMeta
(*AuthToken)(nil), // 18: hyapp.user.v1.AuthToken
}
var file_proto_user_v1_auth_proto_depIdxs = []int32{
15, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
15, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
16, // 2: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
15, // 3: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
15, // 4: hyapp.user.v1.QuickCreateAccountRequest.meta:type_name -> hyapp.user.v1.RequestMeta
16, // 5: hyapp.user.v1.QuickCreateAccountResponse.token:type_name -> hyapp.user.v1.AuthToken
15, // 6: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
16, // 7: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
15, // 8: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
15, // 9: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
15, // 10: hyapp.user.v1.AppHeartbeatRequest.meta:type_name -> hyapp.user.v1.RequestMeta
16, // 11: hyapp.user.v1.AppHeartbeatResponse.token:type_name -> hyapp.user.v1.AuthToken
0, // 12: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 13: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 14: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 15: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
7, // 16: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
9, // 17: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
11, // 18: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
13, // 19: hyapp.user.v1.AuthService.AppHeartbeat:input_type -> hyapp.user.v1.AppHeartbeatRequest
2, // 20: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 21: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 22: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 23: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
8, // 24: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
10, // 25: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
12, // 26: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
14, // 27: hyapp.user.v1.AuthService.AppHeartbeat:output_type -> hyapp.user.v1.AppHeartbeatResponse
20, // [20:28] is the sub-list for method output_type
12, // [12:20] is the sub-list for method input_type
12, // [12:12] is the sub-list for extension type_name
12, // [12:12] is the sub-list for extension extendee
0, // [0:12] is the sub-list for field type_name
17, // 0: hyapp.user.v1.LoginPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
17, // 1: hyapp.user.v1.LoginThirdPartyRequest.meta:type_name -> hyapp.user.v1.RequestMeta
18, // 2: hyapp.user.v1.AuthResponse.token:type_name -> hyapp.user.v1.AuthToken
17, // 3: hyapp.user.v1.SetPasswordRequest.meta:type_name -> hyapp.user.v1.RequestMeta
17, // 4: hyapp.user.v1.QuickCreateAccountRequest.meta:type_name -> hyapp.user.v1.RequestMeta
18, // 5: hyapp.user.v1.QuickCreateAccountResponse.token:type_name -> hyapp.user.v1.AuthToken
17, // 6: hyapp.user.v1.RefreshTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta
18, // 7: hyapp.user.v1.RefreshTokenResponse.token:type_name -> hyapp.user.v1.AuthToken
17, // 8: hyapp.user.v1.LogoutRequest.meta:type_name -> hyapp.user.v1.RequestMeta
17, // 9: hyapp.user.v1.RecordLoginBlockedRequest.meta:type_name -> hyapp.user.v1.RequestMeta
17, // 10: hyapp.user.v1.CheckLoginRiskIPWhitelistRequest.meta:type_name -> hyapp.user.v1.RequestMeta
17, // 11: hyapp.user.v1.AppHeartbeatRequest.meta:type_name -> hyapp.user.v1.RequestMeta
18, // 12: hyapp.user.v1.AppHeartbeatResponse.token:type_name -> hyapp.user.v1.AuthToken
0, // 13: hyapp.user.v1.AuthService.LoginPassword:input_type -> hyapp.user.v1.LoginPasswordRequest
1, // 14: hyapp.user.v1.AuthService.LoginThirdParty:input_type -> hyapp.user.v1.LoginThirdPartyRequest
3, // 15: hyapp.user.v1.AuthService.SetPassword:input_type -> hyapp.user.v1.SetPasswordRequest
5, // 16: hyapp.user.v1.AuthService.QuickCreateAccount:input_type -> hyapp.user.v1.QuickCreateAccountRequest
7, // 17: hyapp.user.v1.AuthService.RefreshToken:input_type -> hyapp.user.v1.RefreshTokenRequest
9, // 18: hyapp.user.v1.AuthService.Logout:input_type -> hyapp.user.v1.LogoutRequest
11, // 19: hyapp.user.v1.AuthService.RecordLoginBlocked:input_type -> hyapp.user.v1.RecordLoginBlockedRequest
13, // 20: hyapp.user.v1.AuthService.CheckLoginRiskIPWhitelist:input_type -> hyapp.user.v1.CheckLoginRiskIPWhitelistRequest
15, // 21: hyapp.user.v1.AuthService.AppHeartbeat:input_type -> hyapp.user.v1.AppHeartbeatRequest
2, // 22: hyapp.user.v1.AuthService.LoginPassword:output_type -> hyapp.user.v1.AuthResponse
2, // 23: hyapp.user.v1.AuthService.LoginThirdParty:output_type -> hyapp.user.v1.AuthResponse
4, // 24: hyapp.user.v1.AuthService.SetPassword:output_type -> hyapp.user.v1.SetPasswordResponse
6, // 25: hyapp.user.v1.AuthService.QuickCreateAccount:output_type -> hyapp.user.v1.QuickCreateAccountResponse
8, // 26: hyapp.user.v1.AuthService.RefreshToken:output_type -> hyapp.user.v1.RefreshTokenResponse
10, // 27: hyapp.user.v1.AuthService.Logout:output_type -> hyapp.user.v1.LogoutResponse
12, // 28: hyapp.user.v1.AuthService.RecordLoginBlocked:output_type -> hyapp.user.v1.RecordLoginBlockedResponse
14, // 29: hyapp.user.v1.AuthService.CheckLoginRiskIPWhitelist:output_type -> hyapp.user.v1.CheckLoginRiskIPWhitelistResponse
16, // 30: hyapp.user.v1.AuthService.AppHeartbeat:output_type -> hyapp.user.v1.AppHeartbeatResponse
22, // [22:31] is the sub-list for method output_type
13, // [13:22] is the sub-list for method input_type
13, // [13:13] is the sub-list for extension type_name
13, // [13:13] is the sub-list for extension extendee
0, // [0:13] is the sub-list for field type_name
}
func init() { file_proto_user_v1_auth_proto_init() }
@ -1486,7 +1611,7 @@ func file_proto_user_v1_auth_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_proto_user_v1_auth_proto_rawDesc,
NumEnums: 0,
NumMessages: 15,
NumMessages: 17,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -124,6 +124,17 @@ message RecordLoginBlockedResponse {
bool recorded = 1;
}
// CheckLoginRiskIPWhitelistRequest IP
message CheckLoginRiskIPWhitelistRequest {
RequestMeta meta = 1;
string client_ip = 2;
}
// CheckLoginRiskIPWhitelistResponse IP
message CheckLoginRiskIPWhitelistResponse {
bool whitelisted = 1;
}
// AppHeartbeatRequest App 线
message AppHeartbeatRequest {
RequestMeta meta = 1;
@ -148,5 +159,6 @@ service AuthService {
rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse);
rpc Logout(LogoutRequest) returns (LogoutResponse);
rpc RecordLoginBlocked(RecordLoginBlockedRequest) returns (RecordLoginBlockedResponse);
rpc CheckLoginRiskIPWhitelist(CheckLoginRiskIPWhitelistRequest) returns (CheckLoginRiskIPWhitelistResponse);
rpc AppHeartbeat(AppHeartbeatRequest) returns (AppHeartbeatResponse);
}

View File

@ -19,14 +19,15 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount"
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
AuthService_Logout_FullMethodName = "/hyapp.user.v1.AuthService/Logout"
AuthService_RecordLoginBlocked_FullMethodName = "/hyapp.user.v1.AuthService/RecordLoginBlocked"
AuthService_AppHeartbeat_FullMethodName = "/hyapp.user.v1.AuthService/AppHeartbeat"
AuthService_LoginPassword_FullMethodName = "/hyapp.user.v1.AuthService/LoginPassword"
AuthService_LoginThirdParty_FullMethodName = "/hyapp.user.v1.AuthService/LoginThirdParty"
AuthService_SetPassword_FullMethodName = "/hyapp.user.v1.AuthService/SetPassword"
AuthService_QuickCreateAccount_FullMethodName = "/hyapp.user.v1.AuthService/QuickCreateAccount"
AuthService_RefreshToken_FullMethodName = "/hyapp.user.v1.AuthService/RefreshToken"
AuthService_Logout_FullMethodName = "/hyapp.user.v1.AuthService/Logout"
AuthService_RecordLoginBlocked_FullMethodName = "/hyapp.user.v1.AuthService/RecordLoginBlocked"
AuthService_CheckLoginRiskIPWhitelist_FullMethodName = "/hyapp.user.v1.AuthService/CheckLoginRiskIPWhitelist"
AuthService_AppHeartbeat_FullMethodName = "/hyapp.user.v1.AuthService/AppHeartbeat"
)
// AuthServiceClient is the client API for AuthService service.
@ -42,6 +43,7 @@ type AuthServiceClient interface {
RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*RefreshTokenResponse, error)
Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*LogoutResponse, error)
RecordLoginBlocked(ctx context.Context, in *RecordLoginBlockedRequest, opts ...grpc.CallOption) (*RecordLoginBlockedResponse, error)
CheckLoginRiskIPWhitelist(ctx context.Context, in *CheckLoginRiskIPWhitelistRequest, opts ...grpc.CallOption) (*CheckLoginRiskIPWhitelistResponse, error)
AppHeartbeat(ctx context.Context, in *AppHeartbeatRequest, opts ...grpc.CallOption) (*AppHeartbeatResponse, error)
}
@ -123,6 +125,16 @@ func (c *authServiceClient) RecordLoginBlocked(ctx context.Context, in *RecordLo
return out, nil
}
func (c *authServiceClient) CheckLoginRiskIPWhitelist(ctx context.Context, in *CheckLoginRiskIPWhitelistRequest, opts ...grpc.CallOption) (*CheckLoginRiskIPWhitelistResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CheckLoginRiskIPWhitelistResponse)
err := c.cc.Invoke(ctx, AuthService_CheckLoginRiskIPWhitelist_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *authServiceClient) AppHeartbeat(ctx context.Context, in *AppHeartbeatRequest, opts ...grpc.CallOption) (*AppHeartbeatResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AppHeartbeatResponse)
@ -146,6 +158,7 @@ type AuthServiceServer interface {
RefreshToken(context.Context, *RefreshTokenRequest) (*RefreshTokenResponse, error)
Logout(context.Context, *LogoutRequest) (*LogoutResponse, error)
RecordLoginBlocked(context.Context, *RecordLoginBlockedRequest) (*RecordLoginBlockedResponse, error)
CheckLoginRiskIPWhitelist(context.Context, *CheckLoginRiskIPWhitelistRequest) (*CheckLoginRiskIPWhitelistResponse, error)
AppHeartbeat(context.Context, *AppHeartbeatRequest) (*AppHeartbeatResponse, error)
mustEmbedUnimplementedAuthServiceServer()
}
@ -178,6 +191,9 @@ func (UnimplementedAuthServiceServer) Logout(context.Context, *LogoutRequest) (*
func (UnimplementedAuthServiceServer) RecordLoginBlocked(context.Context, *RecordLoginBlockedRequest) (*RecordLoginBlockedResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method RecordLoginBlocked not implemented")
}
func (UnimplementedAuthServiceServer) CheckLoginRiskIPWhitelist(context.Context, *CheckLoginRiskIPWhitelistRequest) (*CheckLoginRiskIPWhitelistResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method CheckLoginRiskIPWhitelist not implemented")
}
func (UnimplementedAuthServiceServer) AppHeartbeat(context.Context, *AppHeartbeatRequest) (*AppHeartbeatResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method AppHeartbeat not implemented")
}
@ -328,6 +344,24 @@ func _AuthService_RecordLoginBlocked_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _AuthService_CheckLoginRiskIPWhitelist_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CheckLoginRiskIPWhitelistRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(AuthServiceServer).CheckLoginRiskIPWhitelist(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: AuthService_CheckLoginRiskIPWhitelist_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(AuthServiceServer).CheckLoginRiskIPWhitelist(ctx, req.(*CheckLoginRiskIPWhitelistRequest))
}
return interceptor(ctx, in, info, handler)
}
func _AuthService_AppHeartbeat_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AppHeartbeatRequest)
if err := dec(in); err != nil {
@ -381,6 +415,10 @@ var AuthService_ServiceDesc = grpc.ServiceDesc{
MethodName: "RecordLoginBlocked",
Handler: _AuthService_RecordLoginBlocked_Handler,
},
{
MethodName: "CheckLoginRiskIPWhitelist",
Handler: _AuthService_CheckLoginRiskIPWhitelist_Handler,
},
{
MethodName: "AppHeartbeat",
Handler: _AuthService_AppHeartbeat_Handler,

View File

@ -27,7 +27,7 @@ services:
- "10911:10911"
- "19011:10911"
volumes:
- ./deploy/rocketmq/broker.local.conf:/home/rocketmq/broker.conf:ro
- ${ROCKETMQ_BROKER_CONFIG:-./deploy/rocketmq/broker.local.conf}:/home/rocketmq/broker.conf:ro
- rocketmq-store:/home/rocketmq/store
depends_on:
- rocketmq-namesrv

View File

@ -0,0 +1,387 @@
# wallet-service 模块化拆分方案
## 结论
`wallet-service` 需要一起拆三层:`service` 用例层、`storage/mysql` 持久化层、`transport/grpc` 协议适配层。只拆 `repository.go` 不够,因为当前 service 层的 `Repository` 接口已经把礼物扣费、充值、红包、资源、VIP、游戏账变、币商和投影混成一个大端口文件变小后边界仍然不清晰。
推荐路线是两阶段:
| 阶段 | 目标 | 变更方式 |
| --- | --- | --- |
| 第一阶段 | 低风险整理文件,不改行为 | 保持 `walletservice.Service``mysql.Repository` 和 gRPC 注册方式不变,只在同 package 内按业务域拆文件,并把大 `Repository` 接口拆成嵌入式能力接口 |
| 第二阶段 | 真正目录模块化 | 抽出 `ports``usecase/*` 子包,`wallet.Service` 只保留门面方法transport 继续依赖门面,避免 gRPC 层直接感知内部用例拆分 |
不要第一步就把 MySQL repository 拆成多个 Go package。钱包账务依赖同一个 `*sql.Tx` 保证账户、交易、分录、outbox 和幂等结果同事务提交;过早拆 package 会迫使大量事务 helper、metadata 和 outbox 构造函数导出,风险比收益大。
## 当前结构和问题
当前 wallet-service 的主要文件规模:
| 路径 | 行数 | 问题 |
| --- | ---: | --- |
| `internal/storage/mysql/repository.go` | 5089 | 混合核心账本、送礼、活动奖励、游戏账变、币商、工资兑换、outbox、账号锁、receipt 和 metadata |
| `internal/storage/mysql/resource_repository.go` | 3987 | 资源目录、资源组、礼物配置、发放、穿戴、商城和资源 outbox 混在一起 |
| `internal/service/wallet/service.go` | 1259 | service 门面、依赖注入、礼物、工资、币商、奖励、游戏、充值、VIP、投影混在一个文件 |
| `internal/service/wallet/service_test.go` | 5625 | 测试文件过大,说明 service 用例边界没有被稳定拆开 |
| `internal/transport/grpc/server.go` | 1389 | gRPC handler 和 proto/domain mapper 混合,资源和红包已经拆出一部分,但主文件仍覆盖过多业务 |
| `internal/app/app.go` | 576 | 进程装配、MQ 生命周期、outbox worker、投影 consumer、三方支付补偿和红包过期 worker 混合 |
| `internal/domain/ledger/ledger.go` | 1407 | 账本领域对象混合所有业务命令和回执 |
| `internal/domain/resource/resource.go` | 729 | 资源目录、礼物配置、用户权益、商城和发放对象混合 |
当前最核心的问题不是单个文件长,而是变化原因互相交叉:
| 变化原因 | 当前落点 | 应独立出来的边界 |
| --- | --- | --- |
| 送礼扣费和背包送礼 | `service.go` + `repository.go` + `resource_repository.go` | gift ledger |
| 后台调账、活动奖励、游戏账变 | `service.go` + `repository.go` | ledger operations |
| Google、H5、MiFaPay、V5Pay、USDT | `service.go` + `external_recharge.go` + `third_party_payment_repository.go` | recharge |
| 资源、礼物、穿戴、商城 | `resource.go` + `resource_repository.go` + `transport/grpc/resource.go` | resource |
| VIP 和钱包首页查询 | `service.go` + `app_wallet_repository.go` | app wallet |
| 红包 | `red_packet.go` + `red_packet_repository.go` + `transport/grpc/red_packet.go` | red packet |
| 钱包 outbox 和投影 | `app.go` + `service.go` + `repository.go` + projection files | outbox/projection |
| 主播工资、币商库存和工资换币 | `service.go` + `repository.go` + `host_salary_settlement.go` | host salary / coin seller |
## 拆分原则
1. `wallet-service` 仍然是账务事实 owner。余额、交易、分录和 `wallet_outbox` 不拆到其他服务。
2. `wallet_outbox` 仍然只由 wallet-service 直接读取和发布。下游服务只能消费 RocketMQ 或自己的投影表,不能回扫钱包库补业务事实。
3. 第一阶段不改 protobuf、不改 gRPC 方法名、不改 `walletservice.New(repository, activity)` 装配方式。
4. MySQL 第一阶段保持一个 `mysql.Repository` 类型,方法可以拆文件,但事务 helper 仍留在同 package。
5. service 第一阶段保持一个 `wallet.Service` 门面,外部调用不变;先拆文件和接口,再考虑 usecase 子包。
6. 每一步只搬同一业务域的方法、metadata、request hash、receipt 和 outbox event避免把一个事务链路拆成多次提交。
## 第一阶段目标结构
### service 层
保留 package
```text
services/wallet-service/internal/service/wallet/
```
第一阶段目标文件:
```text
service.go # Service struct、New、依赖注入 setter、通用 guard
repository.go # Repository 总接口和嵌入式能力接口
clients.go # ActivityBadgeClient、GooglePlayClient、MifaPayClient、V5PayClient、TronUSDTClient
gift.go # DebitGift、DebitRobotGift、BatchDebitGift
balance.go # GetBalances、GetWalletOverview、GetWalletValueSummary、GetUserGiftWall、ListWalletTransactions
host_salary.go # GetActiveHostSalaryPolicy、GetHostSalaryProgress、ProcessHostSalarySettlementBatch
coin_seller.go # AdminCreditCoinSellerStock、TransferCoinFromSeller、ExchangeSalaryToCoin、TransferSalaryToCoinSeller
admin_ledger.go # AdminCreditAsset
reward.go # CreditTaskReward、CreditLuckyGiftReward、CreditWheelReward、CreditRoomTurnoverReward、CreditInviteActivityReward、CreditAgencyOpeningReward
game.go # ApplyGameCoinChange
recharge.go # ListRechargeBills、ListRechargeProducts、ListAdminRechargeProducts、Create/Update/DeleteRechargeProduct、ConfirmGooglePayment
external_recharge.go # H5 三方充值、通知、查单补偿,保留现有文件但只放外部充值
vip.go # ListVipPackages、GetMyVip、PurchaseVip、GrantVip、ListAdminVipLevels、UpdateAdminVipLevels
red_packet.go # 红包用例,保留现有文件
resource.go # 资源目录、资源组、礼物配置、发放、穿戴、商城,后续再细拆
projection.go # ProjectPendingGiftWallEvents、ProcessWalletProjectionMessage、ProjectPendingBadgeGrantEvents
```
`repository.go` 建议按 room-service 的方式拆成嵌入式能力接口:
```go
type Repository interface {
GiftLedgerStore
BalanceStore
HostSalaryStore
CoinSellerStore
AdminLedgerStore
RewardLedgerStore
GameLedgerStore
RechargeStore
ExternalRechargeStore
VIPStore
RedPacketStore
ResourceCatalogStore
ResourceGrantStore
ResourceEquipmentStore
ResourceShopStore
WalletProjectionStore
BadgeProjectionStore
}
```
接口边界建议:
| 接口 | 方法范围 |
| --- | --- |
| `GiftLedgerStore` | `DebitGift``BatchDebitGift` |
| `BalanceStore` | `GetBalances`、钱包首页、钱包流水、礼物墙 |
| `HostSalaryStore` | 工资政策、工资进度、工资结算批处理 |
| `CoinSellerStore` | 币商库存、币商转币、工资换币 |
| `AdminLedgerStore` | 后台调账 |
| `RewardLedgerStore` | 任务、幸运礼物、转盘、房间流水、邀请、开业奖励 |
| `GameLedgerStore` | 游戏扣款、返奖、退款、冲正 |
| `RechargeStore` | 充值档位、Google 支付、充值账单 |
| `ExternalRechargeStore` | H5 三方支付方式、下单、回调、查单补偿 |
| `VIPStore` | VIP 包、购买、发放、后台配置 |
| `RedPacketStore` | 红包配置、创建、领取、过期退款、后台重试 |
| `ResourceCatalogStore` | 资源、资源组、礼物配置、礼物类型 |
| `ResourceGrantStore` | 单资源发放、资源组发放、发放记录 |
| `ResourceEquipmentStore` | 用户权益、穿戴、卸下、批量读取穿戴 |
| `ResourceShopStore` | 资源商城档位、购买记录、购买 |
| `WalletProjectionStore` | 礼物墙投影和 wallet outbox message 投影入口 |
| `BadgeProjectionStore` | badge grant 投影 claim、delivered、failed |
### storage/mysql 层
保留 package
```text
services/wallet-service/internal/storage/mysql/
```
`repository.go` 第一阶段只保留:
```text
Repository struct
Open / Close / Ping
通用常量中真正跨业务域使用的部分
```
建议拆出的文件:
```text
schema.go # ensure*Schema、重复列/重复索引兼容判断
account.go # walletAccount、lockAccount、lockAccountsOrdered、applyAccountDelta
transaction.go # transactionRow、lookupTransaction、insertTransaction、transactionID、request hash 公共能力
entry.go # walletEntry、insertEntry、balanceAfterTransaction
outbox.go # WalletOutboxRecord、WalletOutboxClaimFilter、Claim/Mark/insertWalletOutbox、事件类型过滤
gift_ledger.go # DebitGift、BatchDebitGift、giftDebitBizType、背包送礼消耗
gift_pricing.go # resolveGiftPrice、resolveGiftDiamondRatio、resolveActiveGiftConfig
gift_receipt.go # giftMetadata、receiptFromGiftMetadata、gift outbox events
host_diamond.go # creditHostPeriodDiamonds、host period diamond account/entry
admin_ledger.go # AdminCreditAsset、adminCreditMetadata、adminCreditRequestHash
reward_ledger.go # 各活动奖励入账、metadata、receipt、request hash、outbox event
game_ledger.go # ApplyGameCoinChange、gameCoinMetadata、gameBizTypeAndDelta
coin_seller_ledger.go # stock、seller transfer、salary exchange、salary transfer、相关 metadata/hash/receipt
```
已存在文件的后续整理:
```text
app_wallet_repository.go # VIP + 钱包首页较大,后续拆 vip_repository.go / wallet_query_repository.go
resource_repository.go # 后续拆 resource_catalog.go / resource_group.go / resource_grant.go / user_resource.go / resource_shop.go / resource_outbox.go
third_party_payment_repository.go # 后续拆 payment_method_repository.go / external_recharge_order_repository.go
red_packet_repository.go # 已独立,但可继续拆 config/create/claim/refund/query
```
### transport/grpc 层
保留 `Server` 类型和 protobuf 注册方式不变,先按 RPC 业务域拆文件:
```text
server.go # Server struct、NewServer、通用 helper
gift.go # DebitGift、BatchDebitGift、DebitRobotGift、gift response mapper
balance.go # GetBalances、钱包首页、钱包流水、礼物墙 mapper
host_salary.go # 工资政策、工资进度、cron 结果 mapper
admin_ledger.go # AdminCreditAsset
coin_seller.go # 币商库存、转币、工资换币
reward.go # 活动奖励 RPC
game.go # ApplyGameCoinChange
recharge.go # 充值账单、Google、充值档位
external_recharge.go # H5 三方支付 RPC 和 mapper
vip.go # VIP RPC 和 mapper
resource.go # 资源 handler后续再拆 catalog/grant/shop/equipment
resource_mapper.go # resource proto/domain mapper
red_packet.go # 红包 handler
red_packet_mapper.go # 红包 proto/domain mapper
cron_server.go # cron gRPC server保留独立
```
`transport/grpc` 的拆分原则是handler 和 mapper 可以同业务域放一起,但一个文件超过 500 行时应把 mapper 单独拆出。transport 只能做 proto/domain 转换和错误透传,不要在这里补账务规则。
### app 装配层
`internal/app/app.go` 可以低风险拆成:
```text
app.go # App struct、New、Run、Close
clients.go # Activity、Google、MiFaPay、V5Pay、TronGrid 客户端装配
mq.go # RocketMQ producer/consumer 创建、start/shutdown
outbox_worker.go # wallet outbox claim/publish/mark
projection_worker.go # wallet projection consumer
external_recharge_worker.go # 外部充值查单补偿 worker
red_packet_worker.go # 红包过期退款 worker
health.go # health HTTP run/close
```
app 层不承载业务规则。worker 只负责任务循环和调用 service用例幂等必须仍在 service/storage 内保证。
### domain 层
第一阶段不拆 package只拆文件
```text
internal/domain/ledger/
ledger.go # 通用常量、AssetBalance、基础类型
gift.go # 送礼 command/receipt
host_salary.go # 工资 policy/progress/settlement
coin_seller.go # 币商库存、转币、工资换币
recharge.go # 充值档位、Google、充值账单
external_recharge.go # H5 三方订单、通知、支付方式
wallet_query.go # 钱包首页、流水、礼物墙
vip.go # VIP
reward.go # 活动奖励
game.go # 游戏账变
red_packet.go # 红包
internal/domain/resource/
resource.go # 通用 Resource
gift_config.go # GiftConfig、GiftTypeConfig
group.go # ResourceGroup
grant.go # GrantResource、ResourceGrant
entitlement.go # UserResourceEntitlement、穿戴
shop.go # ResourceShopItem、PurchaseOrder
```
领域对象拆文件的目标是让业务域可读,不要把领域对象拆成过细 package。否则 transport、service、storage 都要同时改 import收益不高。
## 第二阶段目标目录
第一阶段稳定后,再考虑 service 真正目录化。目标是 `wallet.Service` 作为门面,内部 usecase 子包只依赖 ports 和 domain
```text
services/wallet-service/internal/service/wallet/
service.go # 门面,保留现有对外方法
ports/
repository.go # repository 能力接口
clients.go # 三方 client 能力接口
clock.go # now/clock 抽象,如需要
usecase/
gift/
balance/
hostsalary/
coinseller/
adminledger/
reward/
game/
recharge/
externalrecharge/
vip/
redpacket/
resource/
projection/
```
依赖方向:
```text
transport/grpc -> service/wallet facade -> service/wallet/usecase/* -> service/wallet/ports
storage/mysql -> implements service/wallet/ports.Repository
client/* -> implements service/wallet/ports third-party clients
domain/* -> shared pure data and validation helpers
```
第二阶段不要让 `transport/grpc` 直接调用 `usecase/*`。gRPC 层直接感知所有 usecase 会把装配复杂度扩散到 transport后续新增 RPC 时更容易乱。
## 推荐实施顺序
### 1. service 低风险拆文件
先拆 `internal/service/wallet/service.go`,不改任何方法签名:
1. 新建 `repository.go`,移动 `Repository interface`,并改成嵌入式能力接口。
2. 新建 `clients.go`,移动 Activity、Google、MiFaPay、V5Pay、TronUSDT client 接口。
3. 按业务域移动 Service 方法到 `gift.go``host_salary.go``coin_seller.go``reward.go``game.go``recharge.go``vip.go``balance.go``projection.go`
4. 每移动一个业务域,运行 `go test ./services/wallet-service/internal/service/wallet`
### 2. transport 跟随拆分
`server.go` 只保留 `Server``NewServer` 和通用 helper。按 service 文件域拆 handler 文件mapper 超过 500 行时单独放 `*_mapper.go`
验证:
```bash
go test ./services/wallet-service/internal/transport/grpc
```
### 3. storage/mysql 拆核心 repository
先拆基础设施,再拆业务:
1. `schema.go`
2. `account.go`
3. `transaction.go`
4. `entry.go`
5. `outbox.go`
6. `gift_ledger.go``gift_pricing.go``gift_receipt.go``host_diamond.go`
7. `admin_ledger.go``reward_ledger.go``game_ledger.go``coin_seller_ledger.go`
每一步只搬代码,不改 SQL不改事务边界不改 outbox payload。
验证:
```bash
go test ./services/wallet-service/internal/storage/mysql
go test ./services/wallet-service/...
```
### 4. 拆 resource_repository.go
`resource_repository.go` 单独作为第二个大 storage 任务处理,避免和核心账本拆分混在一起:
```text
resource_catalog.go
resource_group.go
resource_grant.go
user_resource.go
resource_equipment.go
resource_shop.go
resource_outbox.go
```
这一步要特别保护背包送礼链路,因为 `gift_ledger.go` 会调用用户权益消耗相关 helper。
### 5. app worker 拆分
最后拆 `internal/app/app.go`。原因是 app 层只是装配和 worker 循环,等 service/storage 边界清晰后再拆更不容易来回调整。
验证:
```bash
go test ./services/wallet-service/...
```
## 每阶段完成标准
| 阶段 | 完成标准 |
| --- | --- |
| service 拆分 | `service.go` 只保留 Service 结构、构造和依赖 setter`Repository` 变成嵌入式能力接口service 单测通过 |
| transport 拆分 | `server.go` 不再承载大批业务 handlermapper 按业务域归位transport 单测通过 |
| storage 核心拆分 | `repository.go` 小于 250 行;核心账本 helper 有明确文件wallet storage 单测通过 |
| resource storage 拆分 | `resource_repository.go` 小于 500 行;资源目录、发放、穿戴、商城各自可定位;资源 storage 单测通过 |
| app 拆分 | `app.go` 只负责生命周期worker 文件按 MQ/outbox/projection/reconcile/expiry 分开wallet-service 全包测试通过 |
最终验证:
```bash
go test ./services/wallet-service/...
make test
```
如果 `make test` 暴露其他服务或既有配置失败,需要单独记录为基线问题,不能把它当成钱包拆分失败,也不能因为无关失败回滚已验证的 wallet-service 拆分。
## 风险点
| 风险 | 规避方式 |
| --- | --- |
| 事务 helper 被跨 package 导出 | 第一阶段 storage 保持同 package只拆文件 |
| request hash 或 biz type 和原逻辑不一致 | 每个业务域移动时把 command、metadata、request hash、receipt、outbox event 一起移动 |
| service 接口拆完但测试 mock 更复杂 | 总 `Repository` 保持嵌入式组合,测试按用例只依赖小接口 |
| transport 拆分后 mapper 找不到 | handler 和 mapper 同域移动,通用 mapper 留在 `server.go``mapper.go` |
| app worker 拆早导致来回改 | app 层最后拆,等 service 方法和 storage outbox 边界稳定 |
| 背包送礼跨 resource 和 gift ledger | `consumeGiftEntitlementForSend` 先留在 gift ledger 同 package第二阶段再决定是否抽资源权益端口 |
## 不建议做的事
1. 不要第一步创建多个 MySQL repository 实例。账务事务必须共享同一个连接池和同一个事务提交边界。
2. 不要让 `room-service``activity-service``notice-service` 直连钱包库查询交易明细。
3. 不要把 outbox 发布状态交给下游服务更新。wallet outbox 位点仍归 wallet-service。
4. 不要在 transport 层补业务幂等、扣费判断或支付状态机。
5. 不要为了拆目录改变 protobuf 字段或 gRPC 方法名;这是结构整理,不是协议重构。

View File

@ -257,6 +257,10 @@ func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor {
slog.String("reason", string(xerr.ReasonFromGRPC(err))),
slog.Int64("duration_ms", time.Since(startedAt).Milliseconds()),
}
if err != nil {
// gRPC access 日志是跨服务排障入口reason 只给稳定错误码message 保留服务端校验分支,便于定位 400/409 的真实拒绝原因。
attrs = append(attrs, slog.String("error_message", grpcErrorMessage(err)))
}
attrs = appendServiceAttr(attrs, service)
attrs = append(attrs, commonAttrsFromPayload(request)...)
if cfg.IncludeRequestBody {
@ -278,6 +282,16 @@ func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor {
}
}
func grpcErrorMessage(err error) string {
if err == nil {
return ""
}
if st, ok := status.FromError(err); ok {
return st.Message()
}
return err.Error()
}
type stdLogWriter struct{}
func (stdLogWriter) Write(data []byte) (int, error) {

View File

@ -122,6 +122,9 @@ func TestUnaryServerInterceptorLevelsAndRequestMeta(t *testing.T) {
if entry["grpc_code"] != "InvalidArgument" {
t.Fatalf("grpc_code=%v, want InvalidArgument", entry["grpc_code"])
}
if entry["reason"] != "INVALID_ARGUMENT" || entry["error_message"] != "bad user id" {
t.Fatalf("grpc error fields=%+v, want stable reason and service message", entry)
}
}
func TestUnaryServerInterceptorExtractsTopLevelTraceFieldsWithoutBody(t *testing.T) {

View File

@ -0,0 +1,61 @@
// Package app provides small process lifecycle primitives for service app packages.
package app
import (
"context"
"sync"
)
// BackgroundGroup owns a cancellable worker context and waits for all registered workers to finish.
type BackgroundGroup struct {
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
once sync.Once
}
// NewBackground creates a worker group rooted at parent; nil parent means context.Background.
func NewBackground(parent context.Context) *BackgroundGroup {
if parent == nil {
parent = context.Background()
}
ctx, cancel := context.WithCancel(parent)
return &BackgroundGroup{ctx: ctx, cancel: cancel}
}
// Context returns the shared worker context. Workers should stop when it is canceled.
func (g *BackgroundGroup) Context() context.Context {
if g == nil || g.ctx == nil {
return context.Background()
}
return g.ctx
}
// Go starts one worker and gives it the shared cancellable context.
func (g *BackgroundGroup) Go(run func(context.Context)) {
if g == nil || run == nil {
return
}
g.wg.Add(1)
go func() {
defer g.wg.Done()
run(g.Context())
}()
}
// Stop cancels the shared worker context. It is safe to call more than once.
func (g *BackgroundGroup) Stop() {
if g == nil {
return
}
g.once.Do(g.cancel)
}
// StopAndWait cancels the shared context and waits for all workers to return.
func (g *BackgroundGroup) StopAndWait() {
if g == nil {
return
}
g.Stop()
g.wg.Wait()
}

View File

@ -0,0 +1,22 @@
package app
import (
"context"
"testing"
"time"
)
func TestBackgroundGroupStopAndWait(t *testing.T) {
group := NewBackground(context.Background())
done := make(chan struct{})
group.Go(func(ctx context.Context) {
<-ctx.Done()
close(done)
})
group.StopAndWait()
select {
case <-done:
case <-time.After(time.Second):
t.Fatalf("worker did not observe cancellation")
}
}

View File

@ -0,0 +1,37 @@
package app
import (
"context"
"errors"
"net"
"net/http"
"time"
)
// ServeHTTP runs an HTTP server and treats graceful shutdown as a normal lifecycle exit.
func ServeHTTP(server *http.Server, listener net.Listener) error {
if server == nil {
return errors.New("http server is required")
}
if listener == nil {
return errors.New("http listener is required")
}
err := server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
// ShutdownHTTP first drains accepted requests and then force-closes the server if the drain budget is exceeded.
func ShutdownHTTP(server *http.Server, timeout time.Duration) error {
if server == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
return errors.Join(err, server.Close())
}
return nil
}

View File

@ -0,0 +1,78 @@
// Package config provides shared config loading and environment override helpers.
package config
import (
"errors"
"os"
"strconv"
"strings"
"time"
"hyapp/pkg/configx"
)
// Validator lets a service config enforce cross-field invariants after file and env overrides are applied.
type Validator interface {
Validate() error
}
// LoadYAML loads YAML into out and calls Validate when the target config implements Validator.
func LoadYAML(path string, out any) error {
if strings.TrimSpace(path) == "" {
return errors.New("config path is required")
}
if out == nil {
return errors.New("config target is required")
}
if err := configx.LoadYAML(path, out); err != nil {
return err
}
if validator, ok := out.(Validator); ok {
return validator.Validate()
}
return nil
}
// OverrideStringFromEnv replaces target when envName exists and is not blank.
func OverrideStringFromEnv(target *string, envName string) {
if target == nil || strings.TrimSpace(envName) == "" {
return
}
if value, ok := os.LookupEnv(envName); ok && strings.TrimSpace(value) != "" {
*target = strings.TrimSpace(value)
}
}
// OverrideBoolFromEnv parses a bool environment value into target when envName exists.
func OverrideBoolFromEnv(target *bool, envName string) error {
if target == nil || strings.TrimSpace(envName) == "" {
return nil
}
value, ok := os.LookupEnv(envName)
if !ok || strings.TrimSpace(value) == "" {
return nil
}
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
if err != nil {
return err
}
*target = parsed
return nil
}
// OverrideDurationFromEnv parses a Go duration environment value into target when envName exists.
func OverrideDurationFromEnv(target *time.Duration, envName string) error {
if target == nil || strings.TrimSpace(envName) == "" {
return nil
}
value, ok := os.LookupEnv(envName)
if !ok || strings.TrimSpace(value) == "" {
return nil
}
parsed, err := time.ParseDuration(strings.TrimSpace(value))
if err != nil {
return err
}
*target = parsed
return nil
}

View File

@ -0,0 +1,53 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
)
type testConfig struct {
Name string `yaml:"name"`
}
func (c *testConfig) Validate() error {
if c.Name == "" {
return os.ErrInvalid
}
return nil
}
func TestLoadYAMLValidatesConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("name: activity-service\n"), 0o644); err != nil {
t.Fatalf("write config failed: %v", err)
}
var cfg testConfig
if err := LoadYAML(path, &cfg); err != nil {
t.Fatalf("load yaml failed: %v", err)
}
if cfg.Name != "activity-service" {
t.Fatalf("unexpected name: %q", cfg.Name)
}
}
func TestEnvOverrides(t *testing.T) {
t.Setenv("SERVICEKIT_STRING", " value ")
t.Setenv("SERVICEKIT_BOOL", "true")
t.Setenv("SERVICEKIT_DURATION", "3s")
text := ""
enabled := false
delay := time.Duration(0)
OverrideStringFromEnv(&text, "SERVICEKIT_STRING")
if err := OverrideBoolFromEnv(&enabled, "SERVICEKIT_BOOL"); err != nil {
t.Fatalf("bool override failed: %v", err)
}
if err := OverrideDurationFromEnv(&delay, "SERVICEKIT_DURATION"); err != nil {
t.Fatalf("duration override failed: %v", err)
}
if text != "value" || !enabled || delay != 3*time.Second {
t.Fatalf("env overrides mismatch: text=%q enabled=%t delay=%s", text, enabled, delay)
}
}

View File

@ -0,0 +1,28 @@
// Package grpcserver owns the shared gRPC process wiring used by backend services.
package grpcserver
import (
"time"
"google.golang.org/grpc"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/logx"
)
const defaultGracefulStopTimeout = 15 * time.Second
// New creates the standard unary gRPC server used by owner services.
// Business services still register protobuf handlers themselves; this helper only centralizes runtime middleware.
func New(serviceName string, options ...grpc.ServerOption) *grpc.Server {
base := []grpc.ServerOption{grpc.UnaryInterceptor(logx.UnaryServerInterceptor(serviceName))}
base = append(base, options...)
return grpc.NewServer(base...)
}
// GracefulStop drains in-flight RPCs with the same timeout behavior across services.
func GracefulStop(server *grpc.Server, timeout time.Duration) {
if timeout <= 0 {
timeout = defaultGracefulStopTimeout
}
grpcshutdown.GracefulStop(server, timeout)
}

View File

@ -0,0 +1,56 @@
// Package health wires standard gRPC health and the optional HTTP readiness endpoint.
package health
import (
"errors"
"google.golang.org/grpc"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/healthhttp"
)
// Config describes one service's shared gRPC health and HTTP readiness endpoint.
type Config struct {
ServiceName string
HTTPAddr string
NodeID string
Dependencies []grpchealth.Dependency
}
// New registers standard gRPC health and creates the companion HTTP health server.
// Both endpoints share one checker so readiness/draining cannot diverge between CLB and gRPC probes.
func New(server *grpc.Server, cfg Config) (*grpchealth.ServingChecker, *healthhttp.Server, error) {
if server == nil {
return nil, nil, errors.New("grpc server is required")
}
health := grpchealth.NewServingChecker(cfg.ServiceName, cfg.Dependencies...)
healthHTTP, err := Register(server, health, cfg.HTTPAddr, cfg.NodeID)
if err != nil {
return nil, nil, err
}
return health, healthHTTP, nil
}
// Register exposes an existing checker through standard gRPC health and HTTP readiness.
// Services with richer runtime checks keep their domain-specific checker but share the same transport wiring.
func Register(server *grpc.Server, checker grpchealth.Checker, httpAddr string, nodeID string) (*healthhttp.Server, error) {
if server == nil {
return nil, errors.New("grpc server is required")
}
if checker == nil {
return nil, errors.New("health checker is required")
}
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(checker))
return healthhttp.New(httpAddr, nodeID, checker)
}
// NewHTTP creates the shared checker and HTTP readiness endpoint for services that do not expose gRPC.
func NewHTTP(cfg Config) (*grpchealth.ServingChecker, *healthhttp.Server, error) {
health := grpchealth.NewServingChecker(cfg.ServiceName, cfg.Dependencies...)
healthHTTP, err := healthhttp.New(cfg.HTTPAddr, cfg.NodeID, health)
if err != nil {
return nil, nil, err
}
return health, healthHTTP, nil
}

View File

@ -0,0 +1,48 @@
package health
import (
"context"
"testing"
"hyapp/pkg/grpchealth"
"hyapp/pkg/servicekit/grpcserver"
)
func TestNewRegistersSharedChecker(t *testing.T) {
server := grpcserver.New("test-service")
health, healthHTTP, err := New(server, Config{
ServiceName: "test-service",
NodeID: "node-1",
Dependencies: []grpchealth.Dependency{{
Name: "mysql",
Check: func(context.Context) error { return nil },
}},
})
if err != nil {
t.Fatalf("new health failed: %v", err)
}
if health == nil {
t.Fatalf("health checker is required")
}
if healthHTTP != nil {
t.Fatalf("empty HTTP addr should disable health HTTP server")
}
health.MarkServing()
if !grpchealth.Healthy(health.Ready(context.Background())) {
t.Fatalf("health should be ready after serving mark and passing dependency")
}
grpcserver.GracefulStop(server, 0)
}
func TestNewHTTPCreatesCheckerWithoutGRPC(t *testing.T) {
health, healthHTTP, err := NewHTTP(Config{ServiceName: "statistics-service", NodeID: "node-1"})
if err != nil {
t.Fatalf("new HTTP health failed: %v", err)
}
if health == nil {
t.Fatalf("health checker is required")
}
if healthHTTP != nil {
t.Fatalf("empty HTTP addr should disable health HTTP server")
}
}

View File

@ -0,0 +1,66 @@
// Package mq owns shared RocketMQ lifecycle helpers.
package mq
import (
"context"
"log/slog"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
)
// StartConsumers starts consumers in order; if one fails, already-started consumers are shut down.
func StartConsumers(consumers []*rocketmqx.Consumer) error {
started := make([]*rocketmqx.Consumer, 0, len(consumers))
for _, consumer := range consumers {
if consumer == nil {
continue
}
if err := consumer.Start(); err != nil {
ShutdownConsumers(started)
return err
}
started = append(started, consumer)
}
return nil
}
// ShutdownConsumers releases consumer groups and logs shutdown failures without masking the caller's main error.
func ShutdownConsumers(consumers []*rocketmqx.Consumer) {
for _, consumer := range consumers {
if consumer == nil {
continue
}
if err := consumer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error()))
}
}
}
// StartProducers starts producers in order; if one fails, already-started producers are shut down.
func StartProducers(producers []*rocketmqx.Producer) error {
started := make([]*rocketmqx.Producer, 0, len(producers))
for _, producer := range producers {
if producer == nil {
continue
}
if err := producer.Start(); err != nil {
ShutdownProducers(started)
return err
}
started = append(started, producer)
}
return nil
}
// ShutdownProducers releases producer groups and logs shutdown failures without masking the caller's main error.
func ShutdownProducers(producers []*rocketmqx.Producer) {
for _, producer := range producers {
if producer == nil {
continue
}
if err := producer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error()))
}
}
}

View File

@ -9,8 +9,11 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "${PROJECT_ROOT}"
"${SCRIPT_DIR}/prepare-local-rocketmq-config.sh" >/dev/null
export ROCKETMQ_BROKER_CONFIG="${PROJECT_ROOT}/tmp/rocketmq/broker.local.conf"
"${SCRIPT_DIR}/resolve-compose-container-conflicts.sh" rocketmq-namesrv rocketmq-broker
docker compose up -d rocketmq-namesrv rocketmq-broker >/dev/null
docker compose up -d rocketmq-namesrv >/dev/null
docker compose up -d --force-recreate rocketmq-broker >/dev/null
MQADMIN="/home/rocketmq/rocketmq-5.3.1/bin/mqadmin"
NAMESRV="rocketmq-namesrv:9876"

View File

@ -4,15 +4,33 @@ USE hyapp_wallet;
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gift_diamond_ratio_configs' AND COLUMN_NAME = 'coin_return_ratio_percent') = 0,
'ALTER TABLE gift_diamond_ratio_configs ADD COLUMN coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT ''收礼人金币返还比例,百分比'' AFTER ratio_percent',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
UPDATE gift_diamond_ratio_configs
SET coin_return_ratio_percent = CASE gift_type_code
WHEN 'lucky' THEN 10.00
WHEN 'super_lucky' THEN 1.00
ELSE 30.00
END
WHERE coin_return_ratio_percent = 0.00;
INSERT INTO gift_diamond_ratio_configs (
app_code, region_id, gift_type_code, status, ratio_percent,
app_code, region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent,
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES
('lalu', 0, 'normal', 'active', 100.00, 0, 0, @now_ms, @now_ms),
('lalu', 0, 'lucky', 'active', 10.00, 0, 0, @now_ms, @now_ms),
('lalu', 0, 'super_lucky', 'active', 1.00, 0, 0, @now_ms, @now_ms)
('lalu', 0, 'normal', 'active', 100.00, 30.00, 0, 0, @now_ms, @now_ms),
('lalu', 0, 'lucky', 'active', 10.00, 10.00, 0, 0, @now_ms, @now_ms),
('lalu', 0, 'super_lucky', 'active', 1.00, 1.00, 0, 0, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
ratio_percent = VALUES(ratio_percent),
coin_return_ratio_percent = VALUES(coin_return_ratio_percent),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms);

View File

@ -0,0 +1,14 @@
CREATE TABLE IF NOT EXISTS login_risk_ip_whitelist (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
whitelist_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '白名单 ID',
ip_address VARCHAR(64) NOT NULL COMMENT '允许跳过登录地区屏蔽的客户端 IP',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启用',
created_by_user_id BIGINT NULL COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NULL COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (whitelist_id),
UNIQUE KEY uk_login_risk_ip_whitelist_ip (app_code, ip_address),
KEY idx_login_risk_ip_whitelist_enabled (app_code, enabled, ip_address),
KEY idx_login_risk_ip_whitelist_updated (app_code, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录风险 IP 白名单表';

View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
# RocketMQ NameServer returns brokerIP1 to every producer and consumer. In
# `make run` the clients are host-side `go run` processes, while Docker mode may
# still have container clients, so advertise the host LAN IP that both sides can
# dial through the compose port mapping.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
OUTPUT_PATH="${PROJECT_ROOT}/tmp/rocketmq/broker.local.conf"
detect_host_ip() {
if [[ -n "${ROCKETMQ_BROKER_IP:-}" ]]; then
printf '%s\n' "${ROCKETMQ_BROKER_IP}"
return
fi
local iface=""
if command -v route >/dev/null 2>&1; then
iface="$(route -n get default 2>/dev/null | awk '/interface:/{print $2; exit}')"
fi
if [[ -n "${iface}" ]] && command -v ipconfig >/dev/null 2>&1; then
ipconfig getifaddr "${iface}" 2>/dev/null && return
fi
if command -v ifconfig >/dev/null 2>&1; then
ifconfig | awk '/inet / && $2 != "127.0.0.1" {print $2; exit}' && return
fi
printf '127.0.0.1\n'
}
broker_ip="$(detect_host_ip)"
mkdir -p "$(dirname "${OUTPUT_PATH}")"
cat > "${OUTPUT_PATH}" <<EOF
brokerClusterName = hyapp-local
brokerName = broker-a
brokerId = 0
deleteWhen = 04
fileReservedTime = 48
brokerRole = ASYNC_MASTER
flushDiskType = ASYNC_FLUSH
autoCreateTopicEnable = true
brokerIP1 = ${broker_ip}
listenPort = 10911
EOF
printf '%s\n' "${OUTPUT_PATH}"

View File

@ -195,8 +195,9 @@ type HumanRoomRobotConfig struct {
}
type HumanRoomRobotCountryRule struct {
CountryCode string
MaxRoomCount int32
CountryCode string
MaxRoomCount int32
AllowedOwnerIDs []string
}
type UpdateHumanRoomRobotConfigRequest struct {
@ -762,8 +763,9 @@ func humanRoomRobotConfigFromProto(input *roomv1.AdminHumanRoomRobotConfig) Huma
}
for _, rule := range input.GetCountryRules() {
config.CountryRules = append(config.CountryRules, HumanRoomRobotCountryRule{
CountryCode: rule.GetCountryCode(),
MaxRoomCount: rule.GetMaxRoomCount(),
CountryCode: rule.GetCountryCode(),
MaxRoomCount: rule.GetMaxRoomCount(),
AllowedOwnerIDs: append([]string(nil), rule.GetAllowedOwnerIds()...),
})
}
return config
@ -800,8 +802,9 @@ func humanRoomRobotConfigToProto(input HumanRoomRobotConfig) *roomv1.AdminHumanR
}
for _, rule := range input.CountryRules {
out.CountryRules = append(out.CountryRules, &roomv1.AdminHumanRoomRobotCountryRule{
CountryCode: rule.CountryCode,
MaxRoomCount: rule.MaxRoomCount,
CountryCode: rule.CountryCode,
MaxRoomCount: rule.MaxRoomCount,
AllowedOwnerIds: append([]string(nil), rule.AllowedOwnerIDs...),
})
}
return out

View File

@ -1,12 +1,13 @@
package giftdiamond
type ratioDTO struct {
GiftTypeCode string `json:"giftTypeCode"`
RatioPercent string `json:"ratioPercent"`
RegionID int64 `json:"regionId"`
EffectiveRegionID int64 `json:"effectiveRegionId"`
Status string `json:"status"`
UpdatedAtMS int64 `json:"updatedAtMs"`
GiftTypeCode string `json:"giftTypeCode"`
RatioPercent string `json:"ratioPercent"`
ReturnCoinRatioPercent string `json:"returnCoinRatioPercent"`
RegionID int64 `json:"regionId"`
EffectiveRegionID int64 `json:"effectiveRegionId"`
Status string `json:"status"`
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type ratioListDTO struct {
@ -15,6 +16,7 @@ type ratioListDTO struct {
}
type updateRequest struct {
RegionID int64 `json:"regionId"`
Ratios map[string]interface{} `json:"ratios"`
RegionID int64 `json:"regionId"`
Ratios map[string]interface{} `json:"ratios"`
ReturnCoinRatios map[string]interface{} `json:"returnCoinRatios"`
}

View File

@ -70,7 +70,7 @@ func (h *Handler) Update(c *gin.Context) {
func ratioSummary(items []ratioDTO) []string {
result := make([]string, 0, len(items))
for _, item := range items {
result = append(result, item.GiftTypeCode+"="+item.RatioPercent)
result = append(result, item.GiftTypeCode+"="+item.RatioPercent+"/return="+item.ReturnCoinRatioPercent)
}
return result
}

View File

@ -54,23 +54,32 @@ func (s *Service) Update(ctx context.Context, appCode string, req updateRequest,
for _, giftType := range giftTypes {
raw, exists := req.Ratios[giftType]
if !exists {
return ratioListDTO{}, fmt.Errorf("%s 比例不能为空", giftType)
return ratioListDTO{}, fmt.Errorf("%s 主播钻石比例不能为空", giftType)
}
ratio, err := normalizePercent(raw)
if err != nil {
return ratioListDTO{}, fmt.Errorf("%s 比例不正确", giftType)
return ratioListDTO{}, fmt.Errorf("%s 主播钻石比例不正确", giftType)
}
rawReturnCoin, exists := req.ReturnCoinRatios[giftType]
if !exists {
return ratioListDTO{}, fmt.Errorf("%s 返还金币比例不能为空", giftType)
}
returnCoinRatio, err := normalizePercent(rawReturnCoin)
if err != nil {
return ratioListDTO{}, fmt.Errorf("%s 返还金币比例不正确", giftType)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO gift_diamond_ratio_configs (
app_code, region_id, gift_type_code, status, ratio_percent,
app_code, region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent,
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
status = VALUES(status),
ratio_percent = VALUES(ratio_percent),
coin_return_ratio_percent = VALUES(coin_return_ratio_percent),
updated_by_admin_id = VALUES(updated_by_admin_id),
updated_at_ms = VALUES(updated_at_ms)`,
appCode, req.RegionID, giftType, ratio, adminID, adminID, nowMs, nowMs,
appCode, req.RegionID, giftType, ratio, returnCoinRatio, adminID, adminID, nowMs, nowMs,
); err != nil {
return ratioListDTO{}, err
}
@ -91,17 +100,18 @@ func (s *Service) resolveRatio(ctx context.Context, appCode string, regionID int
return item, err
}
}
return ratioDTO{GiftTypeCode: giftType, RatioPercent: defaultRatioPercent(giftType), RegionID: regionID, EffectiveRegionID: 0, Status: statusActive}, nil
return ratioDTO{GiftTypeCode: giftType, RatioPercent: defaultRatioPercent(giftType), ReturnCoinRatioPercent: defaultReturnCoinRatioPercent(giftType), RegionID: regionID, EffectiveRegionID: 0, Status: statusActive}, nil
}
func (s *Service) getRatio(ctx context.Context, appCode string, regionID int64, giftType string) (ratioDTO, bool, error) {
var item ratioDTO
var percent string
var returnCoinPercent string
err := s.db.QueryRowContext(ctx, `
SELECT region_id, gift_type_code, CAST(ratio_percent AS CHAR), status, updated_at_ms
SELECT region_id, gift_type_code, CAST(ratio_percent AS CHAR), CAST(coin_return_ratio_percent AS CHAR), status, updated_at_ms
FROM gift_diamond_ratio_configs
WHERE app_code = ? AND region_id = ? AND gift_type_code = ?
LIMIT 1`, appCode, regionID, giftType).Scan(&item.EffectiveRegionID, &item.GiftTypeCode, &percent, &item.Status, &item.UpdatedAtMS)
LIMIT 1`, appCode, regionID, giftType).Scan(&item.EffectiveRegionID, &item.GiftTypeCode, &percent, &returnCoinPercent, &item.Status, &item.UpdatedAtMS)
if errors.Is(err, sql.ErrNoRows) {
return ratioDTO{}, false, nil
}
@ -110,6 +120,7 @@ func (s *Service) getRatio(ctx context.Context, appCode string, regionID int64,
}
item.RegionID = regionID
item.RatioPercent = formatPercentString(percent)
item.ReturnCoinRatioPercent = formatPercentStringWithDefault(returnCoinPercent, defaultReturnCoinRatioPercent(giftType))
return item, true, nil
}
@ -126,9 +137,13 @@ func normalizePercent(raw interface{}) (string, error) {
}
func formatPercentString(raw string) string {
return formatPercentStringWithDefault(raw, "100.00")
}
func formatPercentStringWithDefault(raw string, fallback string) string {
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil {
return "100.00"
return fallback
}
return fmt.Sprintf("%.2f", value)
}
@ -144,6 +159,17 @@ func defaultRatioPercent(giftType string) string {
}
}
func defaultReturnCoinRatioPercent(giftType string) string {
switch strings.TrimSpace(giftType) {
case "lucky":
return "10.00"
case "super_lucky":
return "1.00"
default:
return "30.00"
}
}
func (s *Service) ensureSchema(ctx context.Context) error {
if s == nil || s.db == nil {
return errors.New("wallet db is not configured")
@ -155,6 +181,7 @@ func (s *Service) ensureSchema(ctx context.Context) error {
gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态',
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石入账比例百分比',
coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '收礼人金币返还比例百分比',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -164,14 +191,45 @@ func (s *Service) ensureSchema(ctx context.Context) error {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表'`); err != nil {
return err
}
if added, err := ensureReturnCoinRatioColumn(ctx, s.db); err != nil {
return err
} else if added {
if err := seedReturnCoinRatioDefaults(ctx, s.db); err != nil {
return err
}
}
_, err := s.db.ExecContext(ctx, `
INSERT IGNORE INTO gift_diamond_ratio_configs (
app_code, region_id, gift_type_code, status, ratio_percent,
app_code, region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent,
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES
(?, 0, 'normal', 'active', 100.00, 0, 0, 0, 0),
(?, 0, 'lucky', 'active', 10.00, 0, 0, 0, 0),
(?, 0, 'super_lucky', 'active', 1.00, 0, 0, 0, 0)`,
(?, 0, 'normal', 'active', 100.00, 30.00, 0, 0, 0, 0),
(?, 0, 'lucky', 'active', 10.00, 10.00, 0, 0, 0, 0),
(?, 0, 'super_lucky', 'active', 1.00, 1.00, 0, 0, 0, 0)`,
"lalu", "lalu", "lalu")
return err
}
func ensureReturnCoinRatioColumn(ctx context.Context, db *sql.DB) (bool, error) {
// 该列和 ratio_percent 共用区域/礼物类型维度,但服务于收礼人金币收入,避免后台调整主播钻石时误改用户返还。
if _, err := db.ExecContext(ctx, `
ALTER TABLE gift_diamond_ratio_configs
ADD COLUMN coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '收礼人金币返还比例百分比' AFTER ratio_percent`); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
return false, nil
}
return false, err
}
return true, nil
}
func seedReturnCoinRatioDefaults(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
UPDATE gift_diamond_ratio_configs
SET coin_return_ratio_percent = CASE gift_type_code
WHEN 'lucky' THEN 10.00
WHEN 'super_lucky' THEN 1.00
ELSE 30.00
END`)
return err
}

View File

@ -0,0 +1,26 @@
package giftdiamond
import "testing"
func TestDefaultReturnCoinRatioPercentByGiftType(t *testing.T) {
cases := map[string]string{
"normal": "30.00",
"lucky": "10.00",
"super_lucky": "1.00",
"": "30.00",
}
for giftType, want := range cases {
if got := defaultReturnCoinRatioPercent(giftType); got != want {
t.Fatalf("defaultReturnCoinRatioPercent(%q) = %s, want %s", giftType, got, want)
}
}
}
func TestFormatReturnCoinRatioUsesGiftTypeDefault(t *testing.T) {
if got := formatPercentStringWithDefault("", defaultReturnCoinRatioPercent("normal")); got != "30.00" {
t.Fatalf("normal return coin fallback = %s, want 30.00", got)
}
if got := formatPercentStringWithDefault("12.345", defaultReturnCoinRatioPercent("lucky")); got != "12.35" {
t.Fatalf("return coin rounding = %s, want 12.35", got)
}
}

View File

@ -18,7 +18,8 @@ type Handler struct {
}
type replaceRequest struct {
Keywords *[]string `json:"keywords"`
Keywords *[]string `json:"keywords"`
WhitelistIPs *[]string `json:"whitelist_ips"`
}
func New(userDB *sql.DB, audit shared.OperationLogger) *Handler {
@ -32,7 +33,7 @@ func (h *Handler) ListRegionBlocks(c *gin.Context) {
response.ServerError(c, "获取地区屏蔽配置失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
response.OK(c, items)
}
// ReplaceRegionBlocks 保存整页地区屏蔽词配置;空数组表示清空屏蔽词。
@ -42,7 +43,7 @@ func (h *Handler) ReplaceRegionBlocks(c *gin.Context) {
response.BadRequest(c, "地区屏蔽词参数不正确")
return
}
items, err := h.service.Replace(c.Request.Context(), int64(shared.ActorFromContext(c).UserID), *req.Keywords)
items, err := h.service.Replace(c.Request.Context(), int64(shared.ActorFromContext(c).UserID), *req.Keywords, req.WhitelistIPs)
if err != nil {
if errors.Is(err, errInvalidPayload) {
response.BadRequest(c, "地区屏蔽词参数不正确")
@ -51,6 +52,6 @@ func (h *Handler) ReplaceRegionBlocks(c *gin.Context) {
response.ServerError(c, "保存地区屏蔽配置失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "replace-region-blocks", "login_risk_country_blocks", middleware.CurrentRequestID(c), "success", fmt.Sprintf("keywords=%d", len(items)))
response.OK(c, gin.H{"items": items, "total": len(items)})
shared.OperationLogWithResourceID(c, h.audit, "replace-region-blocks", "login_risk_country_blocks", middleware.CurrentRequestID(c), "success", fmt.Sprintf("keywords=%d whitelist_ips=%d", len(items.Items), len(items.WhitelistItems)))
response.OK(c, items)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"net/netip"
"regexp"
"strings"
"time"
@ -15,6 +16,7 @@ import (
const (
maxRegionBlockKeywords = 200
maxRegionBlockKeywordLength = 128
maxIPWhitelistItems = 500
)
var (
@ -35,6 +37,21 @@ type RegionBlock struct {
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type IPWhitelistItem struct {
WhitelistID int64 `json:"whitelist_id"`
IPAddress string `json:"ip_address"`
Enabled bool `json:"enabled"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type Config struct {
Items []RegionBlock `json:"items"`
Total int `json:"total"`
WhitelistItems []IPWhitelistItem `json:"whitelist_items"`
WhitelistTotal int `json:"whitelist_total"`
}
type normalizedKeyword struct {
Keyword string
CountryCode string
@ -48,28 +65,44 @@ func NewService(userDB *sql.DB) *Service {
return &Service{userDB: userDB}
}
// List 返回当前 App 已启用的登录地区屏蔽词
// List 返回当前 App 已启用的登录地区屏蔽词和 IP 白名单
// 表属于 user-service 登录风控事实admin-server 只作为后台维护入口直接写 user DB。
func (s *Service) List(ctx context.Context) ([]RegionBlock, error) {
func (s *Service) List(ctx context.Context) (Config, error) {
if s.userDB == nil {
return nil, errors.New("user db is not configured")
return Config{}, errors.New("user db is not configured")
}
return queryEnabledBlocks(ctx, s.userDB, appctx.FromContext(ctx))
appCode := appctx.FromContext(ctx)
blocks, err := queryEnabledBlocks(ctx, s.userDB, appCode)
if err != nil {
return Config{}, err
}
whitelist, err := queryEnabledIPWhitelist(ctx, s.userDB, appCode)
if err != nil {
return Config{}, err
}
return Config{Items: blocks, Total: len(blocks), WhitelistItems: whitelist, WhitelistTotal: len(whitelist)}, nil
}
// Replace 用页面提交的词表整体替换当前 App 的生效配置。
// 整体替换比逐条增删更适合配置页保存语义,也能避免客户端和服务端出现半旧半新的屏蔽词集合。
func (s *Service) Replace(ctx context.Context, actorID int64, keywords []string) ([]RegionBlock, error) {
func (s *Service) Replace(ctx context.Context, actorID int64, keywords []string, whitelistIPs *[]string) (Config, error) {
if s.userDB == nil {
return nil, errors.New("user db is not configured")
return Config{}, errors.New("user db is not configured")
}
normalized, err := normalizeKeywords(ctx, s.userDB, appctx.FromContext(ctx), keywords)
if err != nil {
return nil, err
return Config{}, err
}
var normalizedIPs []string
if whitelistIPs != nil {
normalizedIPs, err = normalizeIPWhitelist(*whitelistIPs)
if err != nil {
return Config{}, err
}
}
tx, err := s.userDB.BeginTx(ctx, nil)
if err != nil {
return nil, err
return Config{}, err
}
defer tx.Rollback()
@ -80,7 +113,7 @@ func (s *Service) Replace(ctx context.Context, actorID int64, keywords []string)
SET enabled = 0, updated_by_user_id = ?, updated_at_ms = ?
WHERE app_code = ? AND enabled = 1
`, nullableActor(actorID), nowMS, appCode); err != nil {
return nil, err
return Config{}, err
}
for _, keyword := range normalized {
if _, err := tx.ExecContext(ctx, `
@ -95,11 +128,35 @@ func (s *Service) Replace(ctx context.Context, actorID int64, keywords []string)
updated_by_user_id = VALUES(updated_by_user_id),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, keyword.Keyword, keyword.CountryCode, nullableActor(actorID), nullableActor(actorID), nowMS, nowMS); err != nil {
return nil, err
return Config{}, err
}
}
if whitelistIPs != nil {
if _, err := tx.ExecContext(ctx, `
UPDATE login_risk_ip_whitelist
SET enabled = 0, updated_by_user_id = ?, updated_at_ms = ?
WHERE app_code = ? AND enabled = 1
`, nullableActor(actorID), nowMS, appCode); err != nil {
return Config{}, err
}
for _, ip := range normalizedIPs {
if _, err := tx.ExecContext(ctx, `
INSERT INTO login_risk_ip_whitelist (
app_code, ip_address, enabled,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
)
VALUES (?, ?, 1, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
enabled = 1,
updated_by_user_id = VALUES(updated_by_user_id),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, ip, nullableActor(actorID), nullableActor(actorID), nowMS, nowMS); err != nil {
return Config{}, err
}
}
}
if err := tx.Commit(); err != nil {
return nil, err
return Config{}, err
}
return s.List(ctx)
}
@ -132,6 +189,27 @@ func normalizeKeywords(ctx context.Context, querier countryCodeQuerier, appCode
return result, nil
}
func normalizeIPWhitelist(raw []string) ([]string, error) {
if len(raw) > maxIPWhitelistItems {
return nil, errInvalidPayload
}
result := make([]string, 0, len(raw))
seen := map[string]struct{}{}
for _, item := range raw {
addr, err := netip.ParseAddr(strings.TrimSpace(item))
if err != nil {
return nil, errInvalidPayload
}
ip := addr.String()
if _, ok := seen[ip]; ok {
continue
}
seen[ip] = struct{}{}
result = append(result, ip)
}
return result, nil
}
func resolveCountryCode(ctx context.Context, querier countryCodeQuerier, appCode string, keyword string) (string, error) {
if countryCodePattern.MatchString(keyword) {
return strings.ToUpper(keyword), nil
@ -186,6 +264,32 @@ func queryEnabledBlocks(ctx context.Context, db *sql.DB, appCode string) ([]Regi
return items, nil
}
func queryEnabledIPWhitelist(ctx context.Context, db *sql.DB, appCode string) ([]IPWhitelistItem, error) {
rows, err := db.QueryContext(ctx, `
SELECT whitelist_id, ip_address, enabled, created_at_ms, updated_at_ms
FROM login_risk_ip_whitelist
WHERE app_code = ? AND enabled = 1
ORDER BY ip_address ASC, whitelist_id ASC
`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]IPWhitelistItem, 0)
for rows.Next() {
var item IPWhitelistItem
if err := rows.Scan(&item.WhitelistID, &item.IPAddress, &item.Enabled, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func nullableActor(actorID int64) any {
if actorID <= 0 {
return nil

View File

@ -71,7 +71,7 @@ func TestHumanRoomRobotAllowedOwnerIDsAcceptsCommaStringAndArray(t *testing.T) {
func TestHumanRoomRobotCountryRulesDecode(t *testing.T) {
var req updateHumanRoomRobotConfigRequest
if err := json.Unmarshal([]byte(`{"countryLimitEnabled":true,"countryRules":[{"countryCode":"sa","maxRoomCount":2}]}`), &req); err != nil {
if err := json.Unmarshal([]byte(`{"countryLimitEnabled":true,"countryRules":[{"countryCode":"sa","maxRoomCount":2,"allowedOwnerIds":"1001, 1002,,1001"}]}`), &req); err != nil {
t.Fatalf("unmarshal human room robot country rules failed: %v", err)
}
if !req.CountryLimitEnabled || len(req.CountryRules) != 1 {
@ -80,4 +80,7 @@ func TestHumanRoomRobotCountryRulesDecode(t *testing.T) {
if req.CountryRules[0].CountryCode != "sa" || req.CountryRules[0].MaxRoomCount != 2 {
t.Fatalf("country rule mismatch: %+v", req.CountryRules[0])
}
if got := normalizeStringList([]string(req.CountryRules[0].AllowedOwnerIDs)); !reflect.DeepEqual(got, []string{"1001", "1002"}) {
t.Fatalf("country rule allowed owner ids mismatch: %+v", got)
}
}

View File

@ -89,8 +89,9 @@ type HumanRoomRobotConfig struct {
}
type HumanRoomRobotCountryRule struct {
CountryCode string `json:"countryCode"`
MaxRoomCount int32 `json:"maxRoomCount"`
CountryCode string `json:"countryCode"`
MaxRoomCount int32 `json:"maxRoomCount"`
AllowedOwnerIDs flexibleCommaStringList `json:"allowedOwnerIds"`
}
func (s *Service) ListRobotRooms(ctx context.Context, query robotRoomListQuery) ([]RobotRoom, int64, error) {
@ -308,8 +309,9 @@ func humanRoomRobotCountryRulesToClient(values []HumanRoomRobotCountryRule) []ro
out := make([]roomclient.HumanRoomRobotCountryRule, 0, len(values))
for _, value := range values {
out = append(out, roomclient.HumanRoomRobotCountryRule{
CountryCode: strings.ToUpper(strings.TrimSpace(value.CountryCode)),
MaxRoomCount: value.MaxRoomCount,
CountryCode: strings.ToUpper(strings.TrimSpace(value.CountryCode)),
MaxRoomCount: value.MaxRoomCount,
AllowedOwnerIDs: normalizeStringList([]string(value.AllowedOwnerIDs)),
})
}
return out
@ -319,8 +321,9 @@ func humanRoomRobotCountryRulesFromClient(values []roomclient.HumanRoomRobotCoun
out := make([]HumanRoomRobotCountryRule, 0, len(values))
for _, value := range values {
out = append(out, HumanRoomRobotCountryRule{
CountryCode: value.CountryCode,
MaxRoomCount: value.MaxRoomCount,
CountryCode: value.CountryCode,
MaxRoomCount: value.MaxRoomCount,
AllowedOwnerIDs: flexibleCommaStringList(append([]string(nil), value.AllowedOwnerIDs...)),
})
}
return out

View File

@ -3,17 +3,18 @@ package app
import (
"context"
"errors"
"log/slog"
"net"
"sync"
"time"
"google.golang.org/grpc"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
serviceapp "hyapp/pkg/servicekit/app"
"hyapp/pkg/servicekit/grpcserver"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/services/activity-service/internal/config"
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
cumulativerechargeservice "hyapp/services/activity-service/internal/service/cumulativerecharge"
@ -44,9 +45,7 @@ type App struct {
userConn *grpc.ClientConn
walletConn *grpc.ClientConn
roomConn *grpc.ClientConn
workerCtx context.Context
workerStop context.CancelFunc
workerWG sync.WaitGroup
workers *serviceapp.BackgroundGroup
closeOnce sync.Once
}
@ -82,7 +81,7 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service")))
server := grpcserver.New("activity-service")
services, err := buildServiceBundle(cfg, repository, clients)
if err != nil {
cleanup()
@ -101,7 +100,6 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
workerCtx, workerStop := context.WithCancel(context.Background())
return &App{
server: server,
listener: listener,
@ -121,8 +119,7 @@ func New(cfg config.Config) (*App, error) {
userConn: clients.userConn,
walletConn: clients.walletConn,
roomConn: clients.roomConn,
workerCtx: workerCtx,
workerStop: workerStop,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
@ -134,21 +131,20 @@ func (a *App) Run() error {
a.health.MarkStopped()
return err
}
if a.broadcastWorkerEnabled && a.broadcast != nil && a.workerCtx != nil {
a.workerWG.Go(func() {
a.broadcast.RunWorker(a.workerCtx, broadcastservice.WorkerOptions{})
if a.broadcastWorkerEnabled && a.broadcast != nil && a.workers != nil {
a.workers.Go(func(ctx context.Context) {
a.broadcast.RunWorker(ctx, broadcastservice.WorkerOptions{})
})
}
if a.luckyGiftWorkerEnabled && a.luckyGift != nil && a.workerCtx != nil {
a.workerWG.Go(func() {
a.luckyGift.RunWorker(a.workerCtx, a.luckyGiftWorkerOptions)
if a.luckyGiftWorkerEnabled && a.luckyGift != nil && a.workers != nil {
a.workers.Go(func(ctx context.Context) {
a.luckyGift.RunWorker(ctx, a.luckyGiftWorkerOptions)
})
}
err := a.server.Serve(a.listener)
if a.workerStop != nil {
a.workerStop()
if a.workers != nil {
a.workers.StopAndWait()
}
a.workerWG.Wait()
a.shutdownMQ()
a.health.MarkStopped()
if errors.Is(err, grpc.ErrServerStopped) {
@ -162,12 +158,11 @@ func (a *App) Run() error {
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
if a.workerStop != nil {
a.workerStop()
if a.workers != nil {
a.workers.StopAndWait()
}
a.workerWG.Wait()
a.shutdownMQ()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
grpcserver.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.userConn != nil {
_ = a.userConn.Close()
@ -206,19 +201,9 @@ func (a *App) closeHealthHTTP() {
}
func (a *App) startMQ() error {
for _, consumer := range a.mqConsumers {
if err := consumer.Start(); err != nil {
a.shutdownMQ()
return err
}
}
return nil
return servicemq.StartConsumers(a.mqConsumers)
}
func (a *App) shutdownMQ() {
for _, consumer := range a.mqConsumers {
if err := consumer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error()))
}
}
servicemq.ShutdownConsumers(a.mqConsumers)
}

View File

@ -2,23 +2,22 @@ package app
import (
"google.golang.org/grpc"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/healthhttp"
servicehealth "hyapp/pkg/servicekit/health"
"hyapp/services/activity-service/internal/config"
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
)
func newHealthServers(cfg config.Config, server *grpc.Server, repository *mysqlstorage.Repository) (*grpchealth.ServingChecker, *healthhttp.Server, error) {
// gRPC health 和 HTTP ready 共用同一个 checker避免一个入口显示 ready、另一个入口还在 draining。
health := grpchealth.NewServingChecker("activity-service", grpchealth.Dependency{
Name: "mysql",
Check: repository.Ping,
return servicehealth.New(server, servicehealth.Config{
ServiceName: "activity-service",
HTTPAddr: cfg.HealthHTTPAddr,
NodeID: cfg.NodeID,
Dependencies: []grpchealth.Dependency{{
Name: "mysql",
Check: repository.Ping,
}},
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
return nil, nil, err
}
return health, healthHTTP, nil
}

View File

@ -9,6 +9,7 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/pkg/usermq"
"hyapp/pkg/walletmq"
"hyapp/services/activity-service/internal/config"
@ -348,9 +349,7 @@ func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsum
}
func shutdownConsumers(consumers []*rocketmqx.Consumer) {
for _, consumer := range consumers {
_ = consumer.Shutdown()
}
servicemq.ShutdownConsumers(consumers)
}
type userRegionChangedPayload struct {

View File

@ -10,15 +10,16 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
activityv1 "hyapp.local/api/proto/activity/v1"
gamev1 "hyapp.local/api/proto/game/v1"
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
serviceapp "hyapp/pkg/servicekit/app"
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
"hyapp/services/cron-service/internal/config"
"hyapp/services/cron-service/internal/integration"
"hyapp/services/cron-service/internal/scheduler"
@ -37,9 +38,7 @@ type App struct {
activityConn *grpc.ClientConn
gameConn *grpc.ClientConn
walletConn *grpc.ClientConn
workerCtx context.Context
workerCancel context.CancelFunc
workerWG sync.WaitGroup
workers *serviceapp.BackgroundGroup
closeOnce sync.Once
}
@ -90,13 +89,16 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("cron-service")))
health := grpchealth.NewServingChecker("cron-service", grpchealth.Dependency{
Name: "mysql",
Check: repository.Ping,
server := servicegrpc.New("cron-service")
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
ServiceName: "cron-service",
HTTPAddr: cfg.HealthHTTPAddr,
NodeID: cfg.NodeID,
Dependencies: []grpchealth.Dependency{{
Name: "mysql",
Check: repository.Ping,
}},
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
_ = walletConn.Close()
_ = gameConn.Close()
@ -107,7 +109,6 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
workerCtx, workerCancel := context.WithCancel(context.Background())
userCron := integration.NewUserCronClient(userv1.NewUserCronServiceClient(userConn))
activityCron := integration.NewActivityCronClient(activityv1.NewActivityCronServiceClient(activityConn))
gameCron := integration.NewGameCronClient(gamev1.NewGameCronServiceClient(gameConn))
@ -143,8 +144,7 @@ func New(cfg config.Config) (*App, error) {
activityConn: activityConn,
gameConn: gameConn,
walletConn: walletConn,
workerCtx: workerCtx,
workerCancel: workerCancel,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
@ -153,14 +153,11 @@ func (a *App) Run() error {
a.runHealthHTTP()
a.health.MarkServing()
defer func() {
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
a.workers.StopAndWait()
}()
if a.scheduler != nil {
a.workerWG.Go(func() {
a.scheduler.Run(a.workerCtx)
a.workers.Go(func(ctx context.Context) {
a.scheduler.Run(ctx)
})
}
@ -176,11 +173,8 @@ func (a *App) Run() error {
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
a.workers.StopAndWait()
servicegrpc.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.repository != nil {
_ = a.repository.Close()

View File

@ -10,14 +10,15 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
gamev1 "hyapp.local/api/proto/game/v1"
"hyapp/pkg/gamemq"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/services/game-service/internal/client"
"hyapp/services/game-service/internal/config"
diceservice "hyapp/services/game-service/internal/service/dice"
@ -103,7 +104,7 @@ func New(cfg config.Config) (*App, error) {
}
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("game-service")))
server := servicegrpc.New("game-service")
walletClient := client.NewWalletClient(walletConn)
userClient := client.NewUserClient(userConn)
svc := gameservice.New(gameservice.Config{LaunchSessionTTL: cfg.LaunchSessionTTL}, repo, walletClient, userClient, client.NewActivityGrowthClient(activityConn))
@ -133,12 +134,15 @@ func New(cfg config.Config) (*App, error) {
gamev1.RegisterGameCallbackServiceServer(server, transport)
gamev1.RegisterGameAdminServiceServer(server, transport)
gamev1.RegisterGameCronServiceServer(server, transport)
health := grpchealth.NewServingChecker("game-service", grpchealth.Dependency{
Name: "mysql",
Check: repo.Ping,
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
ServiceName: "game-service",
HTTPAddr: cfg.HealthHTTPAddr,
NodeID: cfg.NodeID,
Dependencies: []grpchealth.Dependency{{
Name: "mysql",
Check: repo.Ping,
}},
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
_ = listener.Close()
_ = robotConn.Close()
@ -175,7 +179,7 @@ func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
a.stopOutboxWorker()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
servicegrpc.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.walletConn != nil {
_ = a.walletConn.Close()
@ -276,16 +280,11 @@ func (a *App) startMQ() error {
if a.outboxProducer == nil {
return nil
}
return a.outboxProducer.Start()
return servicemq.StartProducers([]*rocketmqx.Producer{a.outboxProducer})
}
func (a *App) shutdownMQ() {
if a.outboxProducer == nil {
return
}
if err := a.outboxProducer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error()))
}
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.outboxProducer})
}
func (a *App) stopOutboxWorker() {

View File

@ -15,6 +15,7 @@ import (
_ "github.com/go-sql-driver/mysql"
"google.golang.org/grpc"
"hyapp/pkg/grpcclient"
serviceapp "hyapp/pkg/servicekit/app"
"hyapp/pkg/tencentcos"
"hyapp/pkg/tencentim"
"hyapp/services/gateway-service/internal/appconfig"
@ -446,12 +447,8 @@ func (i gatewayTencentIMAccountImporter) ImportAccount(ctx context.Context, user
// Run 启动 HTTP 服务。
func (a *App) Run() error {
a.health.MarkHTTPServing()
err := a.server.Serve(a.listener)
err := serviceapp.ServeHTTP(a.server, a.listener)
a.health.MarkHTTPStopped()
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
@ -460,14 +457,8 @@ func (a *App) Close() error {
var err error
a.closeOnce.Do(func() {
a.health.MarkDraining()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
// HTTP Shutdown drains accepted requests before client connections close,
// so in-flight handlers can finish their internal gRPC calls.
if shutdownErr := a.server.Shutdown(shutdownCtx); shutdownErr != nil {
// 超过 drain 预算后强制关闭,避免发布脚本被异常长连接卡住。
err = errors.Join(err, shutdownErr, a.server.Close())
}
// HTTP drain 要先于内部 gRPC 连接关闭,保证已进入 handler 的编排请求能自然完成。
err = errors.Join(err, serviceapp.ShutdownHTTP(a.server, 15*time.Second))
if a.roomConn != nil {
err = errors.Join(err, a.roomConn.Close())
}

View File

@ -17,6 +17,7 @@ type UserAuthClient interface {
RefreshToken(ctx context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error)
Logout(ctx context.Context, req *userv1.LogoutRequest) (*userv1.LogoutResponse, error)
RecordLoginBlocked(ctx context.Context, req *userv1.RecordLoginBlockedRequest) (*userv1.RecordLoginBlockedResponse, error)
CheckLoginRiskIPWhitelist(ctx context.Context, req *userv1.CheckLoginRiskIPWhitelistRequest) (*userv1.CheckLoginRiskIPWhitelistResponse, error)
AppHeartbeat(ctx context.Context, req *userv1.AppHeartbeatRequest) (*userv1.AppHeartbeatResponse, error)
}
@ -273,6 +274,10 @@ func (c *grpcUserAuthClient) RecordLoginBlocked(ctx context.Context, req *userv1
return c.client.RecordLoginBlocked(ctx, req)
}
func (c *grpcUserAuthClient) CheckLoginRiskIPWhitelist(ctx context.Context, req *userv1.CheckLoginRiskIPWhitelistRequest) (*userv1.CheckLoginRiskIPWhitelistResponse, error) {
return c.client.CheckLoginRiskIPWhitelist(ctx, req)
}
func (c *grpcUserAuthClient) AppHeartbeat(ctx context.Context, req *userv1.AppHeartbeatRequest) (*userv1.AppHeartbeatResponse, error) {
return c.client.AppHeartbeat(ctx, req)
}

View File

@ -189,6 +189,9 @@ func (h *Handler) allowLoginRisk(writer http.ResponseWriter, request *http.Reque
if !config.Enabled {
return true
}
if h.loginRiskIPWhitelisted(request, input) {
return true
}
if reason := config.FastGuard.blockReason(input.Language, input.Timezone); reason != "" {
h.recordLoginBlocked(request, input, reason)
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodeAuthLoginBlocked, "login blocked")
@ -212,6 +215,28 @@ func (h *Handler) allowLoginRisk(writer http.ResponseWriter, request *http.Reque
return true
}
func (h *Handler) loginRiskIPWhitelisted(request *http.Request, input loginRiskInput) bool {
if h.userClient == nil {
return false
}
ip := strings.TrimSpace(clientIP(request))
if ip == "" {
return false
}
ctx, cancel := context.WithTimeout(request.Context(), 500*time.Millisecond)
defer cancel()
resp, err := h.userClient.CheckLoginRiskIPWhitelist(ctx, &userv1.CheckLoginRiskIPWhitelistRequest{
Meta: authRequestMetaWithLoginContext(request, "", input.Platform, input.Language, input.Timezone),
ClientIp: ip,
})
if err != nil {
// 白名单读取失败不能制造额外放行;继续执行原地区屏蔽链路,并保留日志用于排查 Redis/DB 回源异常。
logx.Warn(request.Context(), "login_ip_whitelist_check_failed", slog.String("component", "gateway_login_risk"), slog.String("client_ip_hash", hashLoginRiskIP(ip)), slog.String("error", err.Error()))
return false
}
return resp.GetWhitelisted()
}
func (h *Handler) recordLoginBlocked(request *http.Request, input loginRiskInput, reason string) {
if h.userClient == nil {
return

View File

@ -293,12 +293,14 @@ type fakeUserAuthClient struct {
lastRefresh *userv1.RefreshTokenRequest
lastLogout *userv1.LogoutRequest
lastBlocked *userv1.RecordLoginBlockedRequest
lastWhitelist *userv1.CheckLoginRiskIPWhitelistRequest
lastAppHeartbeat *userv1.AppHeartbeatRequest
loginPasswordCount int
loginThirdCount int
refreshCount int
logoutCount int
blockedCount int
whitelisted bool
loginErr error
}
@ -681,6 +683,11 @@ func (f *fakeUserAuthClient) RecordLoginBlocked(_ context.Context, req *userv1.R
return &userv1.RecordLoginBlockedResponse{Recorded: true}, nil
}
func (f *fakeUserAuthClient) CheckLoginRiskIPWhitelist(_ context.Context, req *userv1.CheckLoginRiskIPWhitelistRequest) (*userv1.CheckLoginRiskIPWhitelistResponse, error) {
f.lastWhitelist = req
return &userv1.CheckLoginRiskIPWhitelistResponse{Whitelisted: f.whitelisted}, nil
}
func (f *fakeUserAuthClient) AppHeartbeat(_ context.Context, req *userv1.AppHeartbeatRequest) (*userv1.AppHeartbeatResponse, error) {
f.lastAppHeartbeat = req
return &userv1.AppHeartbeatResponse{Accepted: true, HeartbeatAtMs: 1_778_000_005_000, ServerTimeMs: 1_778_000_005_000, Token: &userv1.AuthToken{
@ -5366,6 +5373,33 @@ func TestLoginRiskFastGuardBlocksLanguage(t *testing.T) {
}
}
func TestLoginRiskIPWhitelistBypassesFastGuard(t *testing.T) {
userClient := &fakeUserAuthClient{whitelisted: true}
handler := NewHandler(&fakeRoomClient{}, userClient)
handler.SetLoginRisk(LoginRiskConfig{
Enabled: true,
FastGuard: LoginRiskFastGuardConfig{
BlockedLanguagePrimaryTags: []string{"zh"},
},
})
router := handler.Routes(auth.NewVerifier("secret"))
body := []byte(`{"account":"123456","password":"1234567","device_id":"ios","platform":"ios","language":"zh-CN","timezone":"America/Los_Angeles"}`)
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/account/login", bytes.NewReader(body))
request.Header.Set("X-Request-ID", "req-login-risk-whitelist")
request.Header.Set("X-Forwarded-For", "203.0.113.52")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-login-risk-whitelist")
if userClient.loginPasswordCount != 1 || userClient.blockedCount != 0 {
t.Fatalf("whitelisted IP must bypass fast guard: login=%d blocked=%d", userClient.loginPasswordCount, userClient.blockedCount)
}
if userClient.lastWhitelist == nil || userClient.lastWhitelist.GetClientIp() != "203.0.113.52" {
t.Fatalf("whitelist check request mismatch: %+v", userClient.lastWhitelist)
}
}
func TestLoginRiskIPCacheBlocksLogin(t *testing.T) {
userClient := &fakeUserAuthClient{}
cache := newMemoryLoginRiskCache()

View File

@ -4,20 +4,21 @@ package app
import (
"context"
"errors"
"log/slog"
"net"
"sync"
"time"
"google.golang.org/grpc"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
"hyapp/pkg/appcode"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
serviceapp "hyapp/pkg/servicekit/app"
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/pkg/tencentim"
"hyapp/pkg/usermq"
"hyapp/pkg/walletmq"
@ -44,9 +45,7 @@ type App struct {
cpNoticeService *cpnotice.Service
cpWorkerOptions cpnotice.CPNoticeWorkerOptions
mqConsumers []*rocketmqx.Consumer
workerCtx context.Context
workerCancel context.CancelFunc
workerWG sync.WaitGroup
workers *serviceapp.BackgroundGroup
closeOnce sync.Once
}
@ -114,13 +113,16 @@ func New(cfg config.Config) (*App, error) {
return nil, errors.New("user outbox mq consumer requires tencent_im.enabled")
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("notice-service")))
health := grpchealth.NewServingChecker("notice-service", grpchealth.Dependency{
Name: "mysql",
Check: store.Ping,
server := servicegrpc.New("notice-service")
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
ServiceName: "notice-service",
HTTPAddr: cfg.HealthHTTPAddr,
NodeID: cfg.NodeID,
Dependencies: []grpchealth.Dependency{{
Name: "mysql",
Check: store.Ping,
}},
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
_ = listener.Close()
_ = store.Close()
@ -201,7 +203,6 @@ func New(cfg config.Config) (*App, error) {
}
mqConsumers = append(mqConsumers, consumer)
}
workerCtx, workerCancel := context.WithCancel(context.Background())
return &App{
server: server,
listener: listener,
@ -217,8 +218,7 @@ func New(cfg config.Config) (*App, error) {
cpNoticeService: cpSvc,
cpWorkerOptions: cpOptions,
mqConsumers: mqConsumers,
workerCtx: workerCtx,
workerCancel: workerCancel,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
@ -231,21 +231,20 @@ func (a *App) Run() error {
return err
}
defer func() {
if a.workerCancel != nil {
a.workerCancel()
if a.workers != nil {
a.workers.StopAndWait()
}
a.workerWG.Wait()
a.shutdownMQ()
a.health.MarkStopped()
}()
if a.walletWorkerEnabled && a.walletNoticeService != nil {
a.workerWG.Go(func() {
a.walletNoticeService.RunWalletBalanceWorker(a.workerCtx, a.walletWorkerOptions)
a.workers.Go(func(ctx context.Context) {
a.walletNoticeService.RunWalletBalanceWorker(ctx, a.walletWorkerOptions)
})
}
if a.roomWorkerEnabled && a.roomNoticeService != nil {
a.workerWG.Go(func() {
a.roomNoticeService.RunRoomKickWorker(a.workerCtx, a.roomWorkerOptions)
a.workers.Go(func(ctx context.Context) {
a.roomNoticeService.RunRoomKickWorker(ctx, a.roomWorkerOptions)
})
}
err := a.server.Serve(a.listener)
@ -259,12 +258,11 @@ func (a *App) Run() error {
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
if a.workerCancel != nil {
a.workerCancel()
if a.workers != nil {
a.workers.StopAndWait()
}
a.workerWG.Wait()
a.shutdownMQ()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
servicegrpc.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.store != nil {
_ = a.store.Close()
@ -328,27 +326,15 @@ func cpNoticeWorkerOptions(cfg config.WalletNoticeWorkerConfig) cpnotice.CPNotic
}
func (a *App) startMQ() error {
for _, consumer := range a.mqConsumers {
if err := consumer.Start(); err != nil {
a.shutdownMQ()
return err
}
}
return nil
return servicemq.StartConsumers(a.mqConsumers)
}
func (a *App) shutdownMQ() {
for _, consumer := range a.mqConsumers {
if err := consumer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error()))
}
}
servicemq.ShutdownConsumers(a.mqConsumers)
}
func shutdownConsumers(consumers []*rocketmqx.Consumer) {
for _, consumer := range consumers {
_ = consumer.Shutdown()
}
servicemq.ShutdownConsumers(consumers)
}
func rocketMQConsumerConfig(cfg config.RocketMQConfig, group string, maxReconsumeTimes int32) rocketmqx.ConsumerConfig {

View File

@ -9,12 +9,12 @@ import (
"time"
"google.golang.org/grpc"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
robotv1 "hyapp.local/api/proto/robot/v1"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
"hyapp/services/robot-service/internal/config"
"hyapp/services/robot-service/internal/service/gamerobot"
"hyapp/services/robot-service/internal/service/roomrobot"
@ -61,16 +61,19 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("robot-service")))
server := servicegrpc.New("robot-service")
transport := grpcserver.NewServer(gamerobot.New(repo), roomrobot.New(repo))
robotv1.RegisterGameRobotServiceServer(server, transport)
robotv1.RegisterRoomRobotServiceServer(server, transport)
health := grpchealth.NewServingChecker("robot-service", grpchealth.Dependency{
Name: "mysql",
Check: repo.Ping,
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
ServiceName: "robot-service",
HTTPAddr: cfg.HealthHTTPAddr,
NodeID: cfg.NodeID,
Dependencies: []grpchealth.Dependency{{
Name: "mysql",
Check: repo.Ping,
}},
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
_ = listener.Close()
_ = repo.Close()
@ -94,7 +97,7 @@ func (a *App) Run() error {
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
servicegrpc.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.repo != nil {
_ = a.repo.Close()

View File

@ -11,16 +11,17 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
serviceapp "hyapp/pkg/servicekit/app"
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/pkg/tencentim"
"hyapp/pkg/tencentrtc"
"hyapp/pkg/usermq"
@ -65,12 +66,8 @@ type App struct {
mqProducers []*rocketmqx.Producer
// mqConsumers 在 gRPC 服务启动前订阅,承接 room_outbox fanout 和 delayed open。
mqConsumers []*rocketmqx.Consumer
// workerCtx 统一控制本节点后台 worker关闭时必须先停止 worker 再释放 MySQL/Redis。
workerCtx context.Context
// workerCancel 停止 presence stale 和 outbox 补偿 worker。
workerCancel context.CancelFunc
// workerWG 等待后台 worker 当前事件或当前扫描批次安全退出。
workerWG sync.WaitGroup
// workers 统一控制本节点后台 worker关闭时必须先停止 worker 再释放 MySQL/Redis。
workers *serviceapp.BackgroundGroup
// closeOnce 防止信号退出和 Serve 错误同时触发重复关闭。
closeOnce sync.Once
}
@ -366,15 +363,14 @@ func New(cfg config.Config) (*App, error) {
mqConsumers = append(mqConsumers, userConsumer)
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("room-service")))
server := servicegrpc.New("room-service")
roomServer := grpcserver.NewServer(svc, grpcserver.WithOwnerForwarding(cfg.NodeID, directory, directory, cfg.OwnerForwardTimeout))
// 命令服务处理房间状态变更,守卫服务给腾讯云 IM 回调或 gateway 做发言和进群校验。
roomv1.RegisterRoomCommandServiceServer(server, roomServer)
roomv1.RegisterRoomGuardServiceServer(server, roomServer)
roomv1.RegisterRoomQueryServiceServer(server, roomServer)
healthState := healthcheck.NewState(cfg.NodeID, svc, repository, directory)
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(healthState))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, healthState)
healthHTTP, err := servicehealth.Register(server, healthState, cfg.HealthHTTPAddr, cfg.NodeID)
if err != nil {
closeClientConn(activityConn)
_ = walletConn.Close()
@ -393,7 +389,6 @@ func New(cfg config.Config) (*App, error) {
_ = redisClient.Close()
return nil, err
}
workerCtx, workerCancel := context.WithCancel(context.Background())
closeRobotDepsOnError = false
return &App{
@ -412,8 +407,7 @@ func New(cfg config.Config) (*App, error) {
healthHTTP: healthHTTP,
mqProducers: mqProducers,
mqConsumers: mqConsumers,
workerCtx: workerCtx,
workerCancel: workerCancel,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
@ -431,36 +425,33 @@ func (a *App) Run() error {
return err
}
defer func() {
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
a.workers.StopAndWait()
a.shutdownMQ()
}()
a.workerWG.Go(func() {
a.runNodeRegistryHeartbeat(a.workerCtx)
a.workers.Go(func(ctx context.Context) {
a.runNodeRegistryHeartbeat(ctx)
})
if a.cfg.PresenceStaleScanInterval > 0 {
// presence worker 只清理本节点已装载 Cell命令仍走 Room Cell 持久化链路。
a.workerWG.Go(func() {
a.service.RunPresenceStaleWorker(a.workerCtx, a.cfg.PresenceStaleScanInterval)
a.workers.Go(func(ctx context.Context) {
a.service.RunPresenceStaleWorker(ctx, a.cfg.PresenceStaleScanInterval)
})
}
if a.cfg.MicPublishScanInterval > 0 {
// MicUp 只代表业务占麦;后台 worker 负责释放未确认 RTC 发流的 pending_publish 麦位。
a.workerWG.Go(func() {
a.service.RunMicPublishTimeoutWorker(a.workerCtx, a.cfg.MicPublishScanInterval)
a.workers.Go(func(ctx context.Context) {
a.service.RunMicPublishTimeoutWorker(ctx, a.cfg.MicPublishScanInterval)
})
}
if a.cfg.RoomRocketLaunchScanInterval > 0 {
// 火箭发射仍通过 Room Cell 命令链路提交worker 只负责触发到点的系统命令。
a.workerWG.Go(func() {
a.service.RunRoomRocketLaunchWorker(a.workerCtx, a.cfg.RoomRocketLaunchScanInterval)
a.workers.Go(func(ctx context.Context) {
a.service.RunRoomRocketLaunchWorker(ctx, a.cfg.RoomRocketLaunchScanInterval)
})
}
if a.cfg.OutboxWorker.Enabled {
a.workerWG.Go(func() {
a.service.RunOutboxWorker(a.workerCtx, roomservice.OutboxWorkerOptions{
a.workers.Go(func(ctx context.Context) {
a.service.RunOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
PollInterval: a.cfg.OutboxWorker.PollInterval,
BatchSize: a.cfg.OutboxWorker.BatchSize,
PublishTimeout: a.cfg.OutboxWorker.PublishTimeout,
@ -472,8 +463,8 @@ func (a *App) Run() error {
})
}
if a.cfg.RobotOutboxWorker.Enabled {
a.workerWG.Go(func() {
a.service.RunRobotOutboxWorker(a.workerCtx, roomservice.OutboxWorkerOptions{
a.workers.Go(func(ctx context.Context) {
a.service.RunRobotOutboxWorker(ctx, roomservice.OutboxWorkerOptions{
PollInterval: a.cfg.RobotOutboxWorker.PollInterval,
BatchSize: a.cfg.RobotOutboxWorker.BatchSize,
PublishTimeout: a.cfg.RobotOutboxWorker.PublishTimeout,
@ -484,11 +475,11 @@ func (a *App) Run() error {
})
})
}
a.workerWG.Go(func() {
a.service.RunRobotRoomRuntimeManager(a.workerCtx, 10*time.Second)
a.workers.Go(func(ctx context.Context) {
a.service.RunRobotRoomRuntimeManager(ctx, 10*time.Second)
})
a.workerWG.Go(func() {
a.service.RunHumanRoomRobotRuntimeManager(a.workerCtx, 15*time.Second)
a.workers.Go(func(ctx context.Context) {
a.service.RunHumanRoomRobotRuntimeManager(ctx, 15*time.Second)
})
err := a.grpcServer.Serve(a.listener)
@ -509,14 +500,11 @@ func (a *App) Close() {
if a.service != nil {
a.service.MarkDraining()
}
if a.workerCancel != nil {
// 先停后台 worker 并等待当前投递结果落库,再关闭 gRPC/MySQL/Redis。
a.workerCancel()
}
a.workerWG.Wait()
// 先停后台 worker 并等待当前投递结果落库,再关闭 gRPC/MySQL/Redis。
a.workers.StopAndWait()
a.shutdownMQ()
// GracefulStop 让已进入的 gRPC 请求自然结束。
grpcshutdown.GracefulStop(a.grpcServer, 15*time.Second)
servicegrpc.GracefulStop(a.grpcServer, 15*time.Second)
if a.service != nil {
// gRPC drain 完成后释放本节点持有的 room route降低滚动发布时等待 Redis TTL 的窗口。
releaseCtx, releaseCancel := context.WithTimeout(context.Background(), 5*time.Second)
@ -620,32 +608,19 @@ func (a *App) closeHealthHTTP() {
}
func (a *App) startMQ() error {
for _, producer := range a.mqProducers {
if err := producer.Start(); err != nil {
a.shutdownMQ()
return err
}
if err := servicemq.StartProducers(a.mqProducers); err != nil {
return err
}
for _, consumer := range a.mqConsumers {
if err := consumer.Start(); err != nil {
a.shutdownMQ()
return err
}
if err := servicemq.StartConsumers(a.mqConsumers); err != nil {
servicemq.ShutdownProducers(a.mqProducers)
return err
}
return nil
}
func (a *App) shutdownMQ() {
for _, consumer := range a.mqConsumers {
if err := consumer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error()))
}
}
for _, producer := range a.mqProducers {
if err := producer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error()))
}
}
servicemq.ShutdownConsumers(a.mqConsumers)
servicemq.ShutdownProducers(a.mqProducers)
}
func rocketMQProducerConfig(cfg config.RocketMQConfig, groupName string) rocketmqx.ProducerConfig {

View File

@ -118,8 +118,8 @@ func (s *Service) scanHumanRoomRobotRuntimes(ctx context.Context) {
continue
}
}
// 限定房主 ID 只影响本轮候选房 SQL不额外读取 Room Cell 或 presence为空时保持原来的全房间扫描语义
rooms, err := s.repository.ListHumanRobotCandidateRooms(ctx, config.AppCode, countryCode, config.CandidateRoomMaxOnline, config.RoomTargetMaxOnline, 50, config.AllowedOwnerIDs)
// 国家行里的房主白名单只收窄当前国家的候选 SQL不额外读取 Room Cell旧的全局白名单只作为未配置国家行白名单时的兼容回退
rooms, err := s.repository.ListHumanRobotCandidateRooms(ctx, config.AppCode, countryCode, config.CandidateRoomMaxOnline, config.RoomTargetMaxOnline, 50, humanRoomRobotAllowedOwnersForCountry(config, countryCode))
if err != nil {
logx.Warn(ctx, "human_room_robot_candidate_rooms_failed", slog.String("country_code", countryCode), slog.String("error", err.Error()))
continue
@ -852,7 +852,11 @@ func normalizeHumanRoomRobotCountryRules(values []*roomv1.AdminHumanRoomRobotCou
return nil, xerr.New(xerr.InvalidArgument, "country max_room_count is invalid")
}
seen[countryCode] = true
rules = append(rules, HumanRoomRobotCountryRule{CountryCode: countryCode, MaxRoomCount: value.GetMaxRoomCount()})
rules = append(rules, HumanRoomRobotCountryRule{
CountryCode: countryCode,
MaxRoomCount: value.GetMaxRoomCount(),
AllowedOwnerIDs: normalizeStringSet(value.GetAllowedOwnerIds()),
})
}
sort.Slice(rules, func(i, j int) bool {
return rules[i].CountryCode < rules[j].CountryCode
@ -876,6 +880,21 @@ func humanRoomRobotCountryLimits(config HumanRoomRobotConfig) map[string]int {
return limits
}
func humanRoomRobotAllowedOwnersForCountry(config HumanRoomRobotConfig, countryCode string) []string {
countryCode = normalizeRoomCountryCode(countryCode)
for _, rule := range config.CountryRules {
if normalizeRoomCountryCode(rule.CountryCode) != countryCode {
continue
}
// 国家规则里填写房主 ID 时,它是当前国家的精确白名单;为空时允许继续使用历史全局白名单。
if len(rule.AllowedOwnerIDs) > 0 {
return append([]string(nil), rule.AllowedOwnerIDs...)
}
return append([]string(nil), config.AllowedOwnerIDs...)
}
return append([]string(nil), config.AllowedOwnerIDs...)
}
func humanRoomRobotConfigToProto(config HumanRoomRobotConfig) *roomv1.AdminHumanRoomRobotConfig {
config = withHumanRoomRobotDefaults(config)
out := &roomv1.AdminHumanRoomRobotConfig{
@ -908,8 +927,9 @@ func humanRoomRobotConfigToProto(config HumanRoomRobotConfig) *roomv1.AdminHuman
}
for _, rule := range config.CountryRules {
out.CountryRules = append(out.CountryRules, &roomv1.AdminHumanRoomRobotCountryRule{
CountryCode: rule.CountryCode,
MaxRoomCount: rule.MaxRoomCount,
CountryCode: rule.CountryCode,
MaxRoomCount: rule.MaxRoomCount,
AllowedOwnerIds: append([]string(nil), rule.AllowedOwnerIDs...),
})
}
return out

View File

@ -82,7 +82,7 @@ func TestRandomHumanRoomTargetOnlineLegacyFixedValue(t *testing.T) {
func TestNormalizeHumanRoomRobotCountryRules(t *testing.T) {
rules, err := normalizeHumanRoomRobotCountryRules([]*roomv1.AdminHumanRoomRobotCountryRule{
{CountryCode: "sa", MaxRoomCount: 2},
{CountryCode: "sa", MaxRoomCount: 2, AllowedOwnerIds: []string{" 1001 ", "1001", "1002"}},
{CountryCode: "AE", MaxRoomCount: 1},
})
if err != nil {
@ -91,12 +91,34 @@ func TestNormalizeHumanRoomRobotCountryRules(t *testing.T) {
if len(rules) != 2 || rules[0].CountryCode != "AE" || rules[1].CountryCode != "SA" || rules[1].MaxRoomCount != 2 {
t.Fatalf("country rules mismatch: %+v", rules)
}
if len(rules[1].AllowedOwnerIDs) != 2 || rules[1].AllowedOwnerIDs[0] != "1001" || rules[1].AllowedOwnerIDs[1] != "1002" {
t.Fatalf("country rule allowed owners mismatch: %+v", rules[1].AllowedOwnerIDs)
}
limits := humanRoomRobotCountryLimits(HumanRoomRobotConfig{CountryLimitEnabled: true, CountryRules: rules})
if limits["SA"] != 2 || limits["AE"] != 1 || len(limits) != 2 {
t.Fatalf("country limits mismatch: %+v", limits)
}
}
func TestHumanRoomRobotAllowedOwnersForCountryPrefersCountryRule(t *testing.T) {
config := HumanRoomRobotConfig{
AllowedOwnerIDs: []string{"global-owner"},
CountryRules: []HumanRoomRobotCountryRule{
{CountryCode: "SA", MaxRoomCount: 2, AllowedOwnerIDs: []string{"sa-owner"}},
{CountryCode: "AE", MaxRoomCount: 1},
},
}
if got := humanRoomRobotAllowedOwnersForCountry(config, "sa"); len(got) != 1 || got[0] != "sa-owner" {
t.Fatalf("country rule allowed owners should win, got %+v", got)
}
if got := humanRoomRobotAllowedOwnersForCountry(config, "AE"); len(got) != 1 || got[0] != "global-owner" {
t.Fatalf("empty country rule should fall back to global owners, got %+v", got)
}
if got := humanRoomRobotAllowedOwnersForCountry(config, "KW"); len(got) != 1 || got[0] != "global-owner" {
t.Fatalf("missing country rule should fall back to global owners, got %+v", got)
}
}
func TestNormalizeHumanRoomRobotCountryRulesRejectsDuplicate(t *testing.T) {
if _, err := normalizeHumanRoomRobotCountryRules([]*roomv1.AdminHumanRoomRobotCountryRule{
{CountryCode: "SA", MaxRoomCount: 2},

View File

@ -266,8 +266,9 @@ type HumanRoomRobotCountryPool struct {
}
type HumanRoomRobotCountryRule struct {
CountryCode string
MaxRoomCount int32
CountryCode string
MaxRoomCount int32
AllowedOwnerIDs []string
}
type HumanRoomRobotConfig struct {

View File

@ -2,7 +2,6 @@ package app
import (
"context"
"log/slog"
"strings"
"sync"
"time"
@ -16,6 +15,8 @@ import (
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
servicehealth "hyapp/pkg/servicekit/health"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/pkg/usermq"
"hyapp/pkg/walletmq"
"hyapp/services/statistics-service/internal/config"
@ -45,8 +46,12 @@ func New(cfg config.Config) (*App, error) {
_ = repo.Close()
return nil, err
}
health := grpchealth.NewServingChecker("statistics-service", grpchealth.Dependency{Name: "mysql", Check: repo.Ping})
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
health, healthHTTP, err := servicehealth.NewHTTP(servicehealth.Config{
ServiceName: "statistics-service",
HTTPAddr: cfg.HealthHTTPAddr,
NodeID: cfg.NodeID,
Dependencies: []grpchealth.Dependency{{Name: "mysql", Check: repo.Ping}},
})
if err != nil {
shutdownConsumers(consumers)
_ = repo.Close()
@ -562,21 +567,11 @@ func int64Slice(raw any) []int64 {
}
func startConsumers(consumers []*rocketmqx.Consumer) error {
for _, consumer := range consumers {
if err := consumer.Start(); err != nil {
shutdownConsumers(consumers)
return err
}
}
return nil
return servicemq.StartConsumers(consumers)
}
func shutdownConsumers(consumers []*rocketmqx.Consumer) {
for _, consumer := range consumers {
if err := consumer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error()))
}
}
servicemq.ShutdownConsumers(consumers)
}
func consumerConfig(cfg config.RocketMQConfig, mq config.ConsumerMQConfig) rocketmqx.ConsumerConfig {

View File

@ -1236,3 +1236,18 @@ CREATE TABLE IF NOT EXISTS login_risk_country_blocks (
KEY idx_login_risk_country_blocks_enabled (app_code, enabled, country_code),
KEY idx_login_risk_country_blocks_updated (app_code, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录风险国家拦截表';
CREATE TABLE IF NOT EXISTS login_risk_ip_whitelist (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
whitelist_id BIGINT NOT NULL AUTO_INCREMENT COMMENT '白名单 ID',
ip_address VARCHAR(64) NOT NULL COMMENT '允许跳过登录地区屏蔽的客户端 IP',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启用',
created_by_user_id BIGINT NULL COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NULL COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (whitelist_id),
UNIQUE KEY uk_login_risk_ip_whitelist_ip (app_code, ip_address),
KEY idx_login_risk_ip_whitelist_enabled (app_code, enabled, ip_address),
KEY idx_login_risk_ip_whitelist_updated (app_code, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录风险 IP 白名单表';

View File

@ -17,12 +17,15 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/grpcclient"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/idgen"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/roommq"
serviceapp "hyapp/pkg/servicekit/app"
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/pkg/tencentim"
"hyapp/pkg/usermq"
"hyapp/pkg/walletmq"
@ -43,7 +46,6 @@ import (
grpcserver "hyapp/services/user-service/internal/transport/grpc"
"google.golang.org/grpc"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
)
// App 装配 user-service gRPC 入口和底座依赖。
@ -84,12 +86,8 @@ type App struct {
cpLeaderboardRedisClose func() error
// cfg 保存 worker 运行参数,避免 Run 阶段重新读配置。
cfg config.Config
// workerCtx 统一控制后台 worker 生命周期。
workerCtx context.Context
// workerCancel 停止补偿 worker。
workerCancel context.CancelFunc
// workerWG 等待后台 worker 当前批次安全退出。
workerWG sync.WaitGroup
// workers 统一控制后台 worker 生命周期。
workers *serviceapp.BackgroundGroup
// closeOnce 防止信号退出和 Serve 异常同时触发重复关闭。
closeOnce sync.Once
}
@ -114,7 +112,7 @@ func New(cfg config.Config) (*App, error) {
return nil, err
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("user-service")))
server := servicegrpc.New("user-service")
thirdPartyVerifier := authservice.NewRoutedThirdPartyVerifier(
authservice.NewFirebaseThirdPartyVerifier(authservice.FirebaseVerifierConfig{
ProjectID: cfg.ThirdParty.Firebase.ProjectID,
@ -384,9 +382,8 @@ func New(cfg config.Config) (*App, error) {
Check: mysqlRepo.Ping,
}}
health := grpchealth.NewServingChecker("user-service", healthDependencies...)
// user-service 目前使用简单 serving checker存储探测由后续专用 health 可扩展。
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
// user-service 目前使用简单 serving checker存储探测由 servicekit 统一注册。
healthHTTP, err := servicehealth.Register(server, health, cfg.HealthHTTPAddr, cfg.NodeID)
if err != nil {
shutdownMQConsumers(mqConsumers)
if loginRiskRedisClose != nil {
@ -408,8 +405,6 @@ func New(cfg config.Config) (*App, error) {
_ = mysqlRepo.Close()
return nil, err
}
workerCtx, workerCancel := context.WithCancel(context.Background())
return &App{
server: server,
listener: listener,
@ -429,8 +424,7 @@ func New(cfg config.Config) (*App, error) {
loginRiskRedisClose: loginRiskRedisClose,
cpLeaderboardRedisClose: cpLeaderboardRedisClose,
cfg: cfg,
workerCtx: workerCtx,
workerCancel: workerCancel,
workers: serviceapp.NewBackground(context.Background()),
}, nil
}
@ -449,10 +443,7 @@ func (a *App) Run() error {
// 只有 listener 进入 Serve 后才标记 serving避免健康检查提前放行。
a.health.MarkServing()
defer func() {
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
a.workers.StopAndWait()
shutdownMQConsumers(a.mqConsumers)
a.shutdownUserOutboxProducer()
}()
@ -471,14 +462,11 @@ func (a *App) Close() {
a.closeOnce.Do(func() {
// draining 会让 health 立即失败,避免下线进程继续接新 RPC。
a.health.MarkDraining()
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
a.workers.StopAndWait()
shutdownMQConsumers(a.mqConsumers)
a.shutdownUserOutboxProducer()
// GracefulStop 等待已进入的 RPC 结束,避免 token/session 写入被硬切。
grpcshutdown.GracefulStop(a.server, 15*time.Second)
servicegrpc.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.mysqlRepo != nil {
// MySQL 连接池最后关闭,保证 GracefulStop 期间 repository 仍可用。
@ -507,30 +495,28 @@ func (a *App) runUserOutboxWorker() {
return
}
workerID := "user-outbox-" + a.cfg.NodeID
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
a.workers.Go(func(ctx context.Context) {
ticker := time.NewTicker(a.cfg.OutboxWorker.PollInterval)
defer ticker.Stop()
for {
processed, err := a.processUserOutboxBatch(workerID)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(a.workerCtx, "user_outbox_publish_failed", err, slog.String("worker_id", workerID))
logx.Error(ctx, "user_outbox_publish_failed", err, slog.String("worker_id", workerID))
}
if processed >= a.cfg.OutboxWorker.BatchSize {
continue
}
select {
case <-a.workerCtx.Done():
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
})
}
func (a *App) processUserOutboxBatch(workerID string) (int, error) {
records, err := a.mysqlRepo.ClaimPendingUserOutbox(a.workerCtx, workerID, a.cfg.OutboxWorker.BatchSize)
records, err := a.mysqlRepo.ClaimPendingUserOutbox(a.workers.Context(), workerID, a.cfg.OutboxWorker.BatchSize)
if err != nil {
return 0, err
}
@ -582,16 +568,14 @@ func (a *App) startUserOutboxProducer() error {
if a.userOutboxProducer == nil {
return nil
}
return a.userOutboxProducer.Start()
return servicemq.StartProducers([]*rocketmqx.Producer{a.userOutboxProducer})
}
func (a *App) shutdownUserOutboxProducer() {
if a.userOutboxProducer == nil {
return
}
if err := a.userOutboxProducer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error()))
}
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.userOutboxProducer})
}
func (a *App) runHealthHTTP() {
@ -812,21 +796,11 @@ func normalizeInviteRechargeType(value string) string {
}
func startMQConsumers(consumers []*rocketmqx.Consumer) error {
for _, consumer := range consumers {
if err := consumer.Start(); err != nil {
shutdownMQConsumers(consumers)
return err
}
}
return nil
return servicemq.StartConsumers(consumers)
}
func shutdownMQConsumers(consumers []*rocketmqx.Consumer) {
for _, consumer := range consumers {
if err := consumer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_consumer_shutdown_failed", slog.String("error", err.Error()))
}
}
servicemq.ShutdownConsumers(consumers)
}
func walletOutboxConsumerConfig(cfg config.RocketMQConfig) rocketmqx.ConsumerConfig {

View File

@ -71,3 +71,14 @@ type LoginRiskCountryBlock struct {
CreatedAtMs int64
UpdatedAtMs int64
}
// LoginRiskIPWhitelist 是后台维护的登录地区屏蔽 IP 白名单事实。
// IPAddress 使用 netip 标准化后的明文地址,便于运营审计和登录入口精确匹配。
type LoginRiskIPWhitelist struct {
AppCode string
WhitelistID int64
IPAddress string
Enabled bool
CreatedAtMs int64
UpdatedAtMs int64
}

View File

@ -59,10 +59,37 @@ func (c *RedisIPDecisionCache) SetRevokedSession(ctx context.Context, appCode st
return c.client.Set(ctx, revokedSessionKey(appCode, sessionID), strings.TrimSpace(reason), ttl).Err()
}
func (c *RedisIPDecisionCache) GetIPWhitelist(ctx context.Context, appCode string) ([]string, bool, error) {
raw, err := c.client.Get(ctx, loginRiskIPWhitelistKey(appCode)).Result()
if errors.Is(err, redis.Nil) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
var ips []string
if err := json.Unmarshal([]byte(raw), &ips); err != nil {
return nil, false, err
}
return ips, true, nil
}
func (c *RedisIPDecisionCache) SetIPWhitelist(ctx context.Context, appCode string, ips []string, ttl time.Duration) error {
raw, err := json.Marshal(ips)
if err != nil {
return err
}
return c.client.Set(ctx, loginRiskIPWhitelistKey(appCode), raw, ttl).Err()
}
func loginRiskIPDecisionKey(appCode string, clientIPHash string) string {
return "login_risk:ip:" + appcode.Normalize(appCode) + ":" + strings.TrimSpace(clientIPHash)
}
func loginRiskIPWhitelistKey(appCode string) string {
return "login_risk:ip_whitelist:" + appcode.Normalize(appCode)
}
func revokedSessionKey(appCode string, sessionID string) string {
return "auth:revoked_session:" + appcode.Normalize(appCode) + ":" + strings.TrimSpace(sessionID)
}

View File

@ -2,13 +2,17 @@ package auth
import (
"context"
"net/netip"
"sort"
"strings"
"time"
"hyapp/pkg/appcode"
authdomain "hyapp/services/user-service/internal/domain/auth"
)
const loginRiskIPWhitelistCacheTTL = 30 * time.Second
// ListLoginRiskBlockedCountries 返回当前 App 实际生效的登录地区风控词表。
// 静态配置和后台动态配置都下发给 AppApp 可异步做本地风险提示,最终封禁仍以服务端 worker 为准。
func (s *Service) ListLoginRiskBlockedCountries(ctx context.Context) ([]authdomain.LoginRiskCountryBlock, error) {
@ -56,6 +60,54 @@ func (s *Service) ListLoginRiskBlockedCountries(ctx context.Context) ([]authdoma
return blocks, nil
}
// CheckLoginRiskIPWhitelisted 判断入口 IP 是否命中后台白名单。
// 白名单是“跳过登录地区屏蔽”的明确运营例外,只做标准化后的单 IP 精确匹配,不扩展为网段或地区推断。
func (s *Service) CheckLoginRiskIPWhitelisted(ctx context.Context, clientIP string) (bool, error) {
ip := normalizeLoginRiskIP(clientIP)
if ip == "" {
return false, nil
}
ips, err := s.listLoginRiskIPWhitelist(ctx)
if err != nil {
return false, err
}
for _, item := range ips {
if item == ip {
return true, nil
}
}
return false, nil
}
func (s *Service) listLoginRiskIPWhitelist(ctx context.Context) ([]string, error) {
appCode := appcode.FromContext(ctx)
if s.ipDecisionCache != nil {
ips, ok, err := s.ipDecisionCache.GetIPWhitelist(ctx, appCode)
if err == nil && ok {
return normalizeLoginRiskIPList(ips), nil
}
}
if s.authRepository == nil {
return nil, nil
}
items, err := s.authRepository.ListEnabledLoginRiskIPWhitelist(ctx)
if err != nil {
return nil, err
}
ips := make([]string, 0, len(items))
for _, item := range items {
if ip := normalizeLoginRiskIP(item.IPAddress); ip != "" {
ips = append(ips, ip)
}
}
ips = normalizeLoginRiskIPList(ips)
if s.ipDecisionCache != nil {
// 白名单读取结果按 App 整体缓存 30 秒;空列表也缓存,避免无配置时每次登录都回源 DB。
_ = s.ipDecisionCache.SetIPWhitelist(ctx, appCode, ips, loginRiskIPWhitelistCacheTTL)
}
return ips, nil
}
func appendUniqueCountryBlock(blocks []authdomain.LoginRiskCountryBlock, seen map[string]struct{}, block authdomain.LoginRiskCountryBlock) []authdomain.LoginRiskCountryBlock {
key := strings.ToLower(block.CountryCode + "\x00" + block.Keyword)
if _, ok := seen[key]; ok {
@ -89,3 +141,29 @@ func (s *Service) countryBlockedByDynamicPolicy(ctx context.Context, countryCode
}
return false, nil
}
func normalizeLoginRiskIP(raw string) string {
addr, err := netip.ParseAddr(strings.TrimSpace(raw))
if err != nil {
return ""
}
return addr.String()
}
func normalizeLoginRiskIPList(raw []string) []string {
seen := make(map[string]struct{}, len(raw))
ips := make([]string, 0, len(raw))
for _, item := range raw {
ip := normalizeLoginRiskIP(item)
if ip == "" {
continue
}
if _, ok := seen[ip]; ok {
continue
}
seen[ip] = struct{}{}
ips = append(ips, ip)
}
sort.Strings(ips)
return ips
}

View File

@ -71,6 +71,19 @@ func normalizeLoginIPRiskWorkerOptions(options LoginIPRiskWorkerOptions) LoginIP
func (s *Service) processLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error {
policy := s.loginRiskPolicy
nowMs := s.now().UnixMilli()
whitelisted, err := s.CheckLoginRiskIPWhitelisted(ctx, job.ClientIP)
if err != nil {
logx.Warn(ctx, "login_ip_whitelist_read_failed", slog.String("component", "user_auth_risk"), slog.String("job_id", job.JobID), slog.String("client_ip_hash", job.ClientIPHash), slog.String("error", err.Error()))
}
if whitelisted {
// 白名单是运营显式放行,命中后不再访问 GeoIP provider也不写 IP blocked 决策,避免后续登录被旧地区缓存误拦。
return s.applyLoginIPRiskDecision(ctx, job, IPDecision{
Decision: authdomain.LoginIPRiskDecisionAllowed,
Source: "ip_whitelist",
CheckedAtMs: nowMs,
ExpiresAtMs: nowMs + loginRiskIPWhitelistCacheTTL.Milliseconds(),
}, nil, authdomain.LoginIPRiskStatusCompleted)
}
if cached, ok := s.freshCachedIPDecision(ctx, job); ok {
return s.applyLoginIPRiskDecision(ctx, job, cached, nil, authdomain.LoginIPRiskStatusSkipped)
}

View File

@ -78,6 +78,8 @@ type AuthRepository interface {
CreateLoginIPRiskDecision(ctx context.Context, decision authdomain.LoginIPRiskDecision) error
// ListEnabledLoginRiskCountryBlocks 返回当前 App 的动态屏蔽国家词表。
ListEnabledLoginRiskCountryBlocks(ctx context.Context) ([]authdomain.LoginRiskCountryBlock, error)
// ListEnabledLoginRiskIPWhitelist 返回当前 App 的登录地区屏蔽 IP 白名单。
ListEnabledLoginRiskIPWhitelist(ctx context.Context) ([]authdomain.LoginRiskIPWhitelist, error)
// MarkLoginAuditBlocked 把登录成功后被异步风控撤销的审计行更新成阻断态。
MarkLoginAuditBlocked(ctx context.Context, requestID string, userID int64, countryCode string, blockReason string) error
}
@ -165,6 +167,8 @@ type IPDecisionCache interface {
GetIPDecision(ctx context.Context, appCode string, clientIPHash string) (IPDecision, bool, error)
SetIPDecision(ctx context.Context, appCode string, clientIPHash string, decision IPDecision, ttl time.Duration) error
SetRevokedSession(ctx context.Context, appCode string, sessionID string, reason string, ttl time.Duration) error
GetIPWhitelist(ctx context.Context, appCode string) ([]string, bool, error)
SetIPWhitelist(ctx context.Context, appCode string, ips []string, ttl time.Duration) error
}
// IPDecision 是 Redis 中 IP 决策 JSON 的领域投影。

View File

@ -23,10 +23,11 @@ type memoryDecisionCache struct {
mu sync.Mutex
ip map[string]authservice.IPDecision
revoked map[string]string
ips map[string][]string
}
func newMemoryDecisionCache() *memoryDecisionCache {
return &memoryDecisionCache{ip: make(map[string]authservice.IPDecision), revoked: make(map[string]string)}
return &memoryDecisionCache{ip: make(map[string]authservice.IPDecision), revoked: make(map[string]string), ips: make(map[string][]string)}
}
func (c *memoryDecisionCache) GetIPDecision(_ context.Context, appCode string, clientIPHash string) (authservice.IPDecision, bool, error) {
@ -50,6 +51,20 @@ func (c *memoryDecisionCache) SetRevokedSession(_ context.Context, appCode strin
return nil
}
func (c *memoryDecisionCache) GetIPWhitelist(_ context.Context, appCode string) ([]string, bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
ips, ok := c.ips[appCode]
return append([]string(nil), ips...), ok, nil
}
func (c *memoryDecisionCache) SetIPWhitelist(_ context.Context, appCode string, ips []string, _ time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.ips[appCode] = append([]string(nil), ips...)
return nil
}
type sequenceIDGenerator struct {
// values 是测试预设 user_id 序列。
values []int64
@ -552,6 +567,73 @@ func TestLoginIPRiskWorkerRevokesBlockedCountrySession(t *testing.T) {
}
}
func TestLoginIPRiskIPWhitelistBypassesBlockedCountry(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "CN")
now := time.UnixMilli(1000)
if _, err := repository.RawDB().ExecContext(ctx, `
INSERT INTO login_risk_ip_whitelist (
app_code, ip_address, enabled, created_at_ms, updated_at_ms
)
VALUES ('lalu', '203.0.113.80', 1, 1000, 1000)
`); err != nil {
t.Fatalf("seed ip whitelist failed: %v", err)
}
cache := newMemoryDecisionCache()
authSvc := newAuthService(repository, &now, []int64{900024}, []string{"100024"},
authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
Enabled: true,
UnsupportedCountries: []string{"CN"},
BlockedTTL: time.Hour,
AllowedTTL: time.Hour,
UnknownTTL: time.Minute,
ProviderTimeout: 50 * time.Millisecond,
ProviderNames: []string{"edge_country"},
DenylistTTL: time.Hour,
}),
authservice.WithIPDecisionCache(cache),
authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})),
)
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-whitelist", thirdPartyRegistration("ios"), authservice.Meta{
AppCode: "lalu",
RequestID: "req-risk-whitelist",
ClientIP: "203.0.113.80",
CountryByIP: "CN",
Platform: "ios",
})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
now = time.UnixMilli(2000)
processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-whitelist-test", LockTTL: time.Second, BatchSize: 10})
if err != nil || processed != 1 {
t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err)
}
if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-whitelist"}); err != nil {
t.Fatalf("whitelisted IP must keep refresh session active: %v", err)
}
if _, ok := cache.ip["lalu:"+authservice.HashLoginRiskIP("203.0.113.80")]; ok {
t.Fatalf("whitelisted IP must not write ordinary IP decision cache")
}
if got := cache.ips["lalu"]; len(got) != 1 || got[0] != "203.0.113.80" {
t.Fatalf("ip whitelist cache mismatch: %+v", got)
}
var decision string
if err := repository.RawDB().QueryRowContext(ctx, `
SELECT decision
FROM login_ip_risk_jobs
WHERE app_code = 'lalu' AND request_id = 'req-risk-whitelist'
`).Scan(&decision); err != nil {
t.Fatalf("query risk job failed: %v", err)
}
if decision != "allowed" {
t.Fatalf("whitelisted risk job decision mismatch: %s", decision)
}
}
func TestLoginIPRiskWorkerRevokesRotatedSameDeviceSessions(t *testing.T) {
// 复现线上竞态:登录后风控任务尚未完成,客户端连续 refresh 轮换 session。
// blocked 决策落地时必须沿源 session 的同设备登录链路清掉后续 session否则最新 access/refresh 仍能继续使用。

View File

@ -131,6 +131,36 @@ func (r *Repository) ListEnabledLoginRiskCountryBlocks(ctx context.Context) ([]a
return blocks, nil
}
func (r *Repository) ListEnabledLoginRiskIPWhitelist(ctx context.Context) ([]authdomain.LoginRiskIPWhitelist, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, whitelist_id, ip_address, enabled, created_at_ms, updated_at_ms
FROM login_risk_ip_whitelist
WHERE app_code = ? AND enabled = 1
ORDER BY ip_address ASC, whitelist_id ASC
`, appcode.FromContext(ctx))
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]authdomain.LoginRiskIPWhitelist, 0)
for rows.Next() {
var item authdomain.LoginRiskIPWhitelist
if err := rows.Scan(&item.AppCode, &item.WhitelistID, &item.IPAddress, &item.Enabled, &item.CreatedAtMs, &item.UpdatedAtMs); err != nil {
return nil, err
}
item.IPAddress = strings.TrimSpace(item.IPAddress)
if item.IPAddress == "" {
continue
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
type loginIPRiskJobScanner interface {
Scan(dest ...any) error
}

View File

@ -394,6 +394,11 @@ func (r *Repository) ListEnabledLoginRiskCountryBlocks(ctx context.Context) ([]a
return r.Repository.AuthRepository().ListEnabledLoginRiskCountryBlocks(ctx)
}
// ListEnabledLoginRiskIPWhitelist 让测试 wrapper 继续满足认证风控接口。
func (r *Repository) ListEnabledLoginRiskIPWhitelist(ctx context.Context) ([]authdomain.LoginRiskIPWhitelist, error) {
return r.Repository.AuthRepository().ListEnabledLoginRiskIPWhitelist(ctx)
}
// MarkLoginAuditBlocked 让测试 wrapper 继续满足认证接口。
func (r *Repository) MarkLoginAuditBlocked(ctx context.Context, requestID string, userID int64, countryCode string, blockReason string) error {
return r.Repository.AuthRepository().MarkLoginAuditBlocked(ctx, requestID, userID, countryCode, blockReason)

View File

@ -212,6 +212,16 @@ func (s *Server) RecordLoginBlocked(ctx context.Context, req *userv1.RecordLogin
return &userv1.RecordLoginBlockedResponse{Recorded: recorded}, nil
}
// CheckLoginRiskIPWhitelist 判断登录入口 IP 是否命中后台白名单gateway 用它在快拦截前保留运营例外。
func (s *Server) CheckLoginRiskIPWhitelist(ctx context.Context, req *userv1.CheckLoginRiskIPWhitelistRequest) (*userv1.CheckLoginRiskIPWhitelistResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
whitelisted, err := s.authSvc.CheckLoginRiskIPWhitelisted(ctx, req.GetClientIp())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.CheckLoginRiskIPWhitelistResponse{Whitelisted: whitelisted}, nil
}
// AppHeartbeat 刷新当前 App 登录会话最近在线时间,不创建房间或麦位 presence。
func (s *Server) AppHeartbeat(ctx context.Context, req *userv1.AppHeartbeatRequest) (*userv1.AppHeartbeatResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())

View File

@ -1213,6 +1213,7 @@ CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs (
gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态',
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '房间贡献热度和主播周期钻石入账比例,百分比',
coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT '收礼人金币返还比例,百分比',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -1221,6 +1222,15 @@ CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs (
KEY idx_gift_diamond_ratio_region (app_code, region_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物钻石和房间贡献比例配置表';
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'gift_diamond_ratio_configs' AND COLUMN_NAME = 'coin_return_ratio_percent') = 0,
'ALTER TABLE gift_diamond_ratio_configs ADD COLUMN coin_return_ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00 COMMENT ''收礼人金币返还比例,百分比'' AFTER ratio_percent',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
CREATE TABLE IF NOT EXISTS red_packet_configs (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否启用红包',
@ -1452,9 +1462,9 @@ INSERT IGNORE INTO gift_type_configs (
('lalu', 'custom', '定制礼物', 'Custom', 'active', 100, 0, 0, 0, 0);
INSERT IGNORE INTO gift_diamond_ratio_configs (
app_code, region_id, gift_type_code, status, ratio_percent,
app_code, region_id, gift_type_code, status, ratio_percent, coin_return_ratio_percent,
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES
('lalu', 0, 'normal', 'active', 100.00, 0, 0, 0, 0),
('lalu', 0, 'lucky', 'active', 10.00, 0, 0, 0, 0),
('lalu', 0, 'super_lucky', 'active', 1.00, 0, 0, 0, 0);
('lalu', 0, 'normal', 'active', 100.00, 30.00, 0, 0, 0, 0),
('lalu', 0, 'lucky', 'active', 10.00, 10.00, 0, 0, 0, 0),
('lalu', 0, 'super_lucky', 'active', 1.00, 1.00, 0, 0, 0, 0);

View File

@ -3,24 +3,19 @@ package app
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/walletmq"
servicegrpc "hyapp/pkg/servicekit/grpcserver"
servicehealth "hyapp/pkg/servicekit/health"
"hyapp/services/wallet-service/internal/client"
"hyapp/services/wallet-service/internal/client/googleplay"
"hyapp/services/wallet-service/internal/client/mifapay"
@ -62,7 +57,6 @@ func New(cfg config.Config) (*App, error) {
startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// wallet-service 的账户、交易、分录和 outbox 必须共享同一个 MySQL 事务边界。
repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN)
if err != nil {
return nil, err
@ -101,7 +95,7 @@ func New(cfg config.Config) (*App, error) {
}
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("wallet-service")))
server := servicegrpc.New("wallet-service")
svc := walletservice.New(repository, client.NewActivityAchievementClient(activityConn))
svc.SetExternalRechargeConfig(walletservice.ExternalRechargeConfig{
USDTTRC20Enabled: cfg.ExternalRecharge.USDTTRC20Enabled,
@ -134,7 +128,7 @@ func New(cfg config.Config) (*App, error) {
}
svc.SetMifaPayClient(mifaPayClient)
} else {
// 新功能配置默认打开,但商户私钥和平台公钥只能来自环境;缺失时启动不失败,只是不注入真实支付网关。
logx.Warn(context.Background(), "mifapay_enabled_without_credentials")
}
}
@ -142,7 +136,7 @@ func New(cfg config.Config) (*App, error) {
if v5payConfigReady(cfg.V5Pay) {
svc.SetV5PayClient(v5pay.New(cfg.V5Pay))
} else {
// V5Pay 使用 merchantNo/appKey/secretKey 做 MD5 签名;缺任一项时不能发真实下单请求,但本地服务仍可启动。
logx.Warn(context.Background(), "v5pay_enabled_without_credentials")
}
}
@ -151,12 +145,15 @@ func New(cfg config.Config) (*App, error) {
}
walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc))
walletv1.RegisterWalletCronServiceServer(server, grpcserver.NewCronServer(svc))
health := grpchealth.NewServingChecker("wallet-service", grpchealth.Dependency{
Name: "mysql",
Check: repository.Ping,
health, healthHTTP, err := servicehealth.New(server, servicehealth.Config{
ServiceName: "wallet-service",
HTTPAddr: cfg.HealthHTTPAddr,
NodeID: cfg.NodeID,
Dependencies: []grpchealth.Dependency{{
Name: "mysql",
Check: repository.Ping,
}},
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
shutdownProducers(outboxProducer, realtimeOutboxProducer)
_ = activityConn.Close()
@ -220,357 +217,15 @@ func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
a.closeBackgroundWorkers()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
servicegrpc.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.activityConn != nil {
_ = a.activityConn.Close()
}
a.shutdownMQ()
if a.mysqlRepo != nil {
// MySQL 连接池最后关闭,避免正在 drain 的扣费请求丢失提交能力。
_ = a.mysqlRepo.Close()
}
})
}
func (a *App) runBackgroundWorkers() {
if a.walletSvc == nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
a.stopWorker = cancel
if a.outboxWorkerCfg.Enabled && a.outboxProducer != nil {
excludedRealtimeTypes := []string(nil)
if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil {
// 普通账务 worker 在实时通道可用时主动跳过红包 UI 事件,避免两个 worker 抢同一行,
// 也避免红包事实继续排在 WalletBalanceChanged 和礼物流水之后。
excludedRealtimeTypes = a.realtimeOutboxWorkerCfg.EventTypes
}
a.startWalletOutboxWorkers(ctx, "wallet-outbox", a.outboxWorkerCfg, a.outboxProducer, a.walletOutboxTopic, nil, excludedRealtimeTypes)
}
if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil {
a.startWalletOutboxWorkers(ctx, "wallet-realtime-outbox", a.realtimeOutboxWorkerCfg, a.realtimeOutboxProducer, a.realtimeWalletOutboxTopic, a.realtimeOutboxWorkerCfg.EventTypes, nil)
}
if a.externalRechargeReconcileWorkerCfg.Enabled {
workerID := "external-recharge-reconcile-" + a.nodeID
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(a.externalRechargeReconcileWorkerCfg.PollInterval)
defer ticker.Stop()
for {
// 三方回调是主链路,但用户杀 App、本地/测试回调不可达、或支付平台短暂回调失败时,
// 服务端必须自己查单补偿;这里只扫 redirected 订单,入账仍由 repository 的幂等事务兜住。
_, err := a.walletSvc.ReconcileExternalRechargeOrders(ctx, a.externalRechargeReconcileWorkerCfg.AppCode, a.externalRechargeReconcileWorkerCfg.BatchSize)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "external_recharge_reconcile_failed", err, slog.String("worker_id", workerID))
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
if a.redPacketExpiryWorkerCfg.Enabled {
redPacketWorkerID := "red-packet-expiry-" + a.nodeID
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(a.redPacketExpiryWorkerCfg.PollInterval)
defer ticker.Stop()
for {
result, err := a.walletSvc.ExpireRedPackets(ctx, a.redPacketExpiryWorkerCfg.AppCode, a.redPacketExpiryWorkerCfg.BatchSize)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "red_packet_expiry_failed", err, slog.String("worker_id", redPacketWorkerID))
}
if result.ExpiredCount > 0 {
logx.Info(ctx, "red_packet_expired",
slog.String("worker_id", redPacketWorkerID),
slog.Int64("expired_count", int64(result.ExpiredCount)),
slog.Int64("refunded_amount", result.RefundedAmount),
)
}
if result.ExpiredCount >= a.redPacketExpiryWorkerCfg.BatchSize {
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
}
func (a *App) startWalletOutboxWorkers(ctx context.Context, workerName string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) {
if options.Concurrency <= 0 {
options.Concurrency = 1
}
for workerIndex := 1; workerIndex <= options.Concurrency; workerIndex++ {
workerID := fmt.Sprintf("%s-%s-%02d", workerName, a.nodeID, workerIndex)
a.workers.Add(1)
go func() {
defer a.workers.Done()
a.runWalletOutboxWorker(ctx, workerID, options, producer, topic, includeEventTypes, excludeEventTypes)
}()
}
}
func (a *App) runWalletOutboxWorker(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) {
ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop()
for {
processed, err := a.processWalletOutboxBatch(ctx, workerID, options, producer, topic, includeEventTypes, excludeEventTypes)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "wallet_outbox_publish_failed", err, slog.String("worker_id", workerID))
}
if processed >= options.BatchSize {
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (a *App) processWalletOutboxBatch(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) (int, error) {
if a.mysqlRepo == nil || producer == nil {
return 0, nil
}
records, err := a.mysqlRepo.ClaimPendingWalletOutboxFiltered(ctx, workerID, options.BatchSize, time.Now().UTC().Add(options.PublishTimeout).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{
IncludeEventTypes: includeEventTypes,
ExcludeEventTypes: excludeEventTypes,
})
if err != nil {
return 0, err
}
for _, record := range records {
if record.RetryCount >= options.MaxRetryCount {
markCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
err := a.mysqlRepo.MarkWalletOutboxDead(markCtx, record.EventID, deadWalletOutboxReason(record))
cancel()
if err != nil {
return 0, err
}
continue
}
publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
err := a.publishWalletOutboxRecord(publishCtx, producer, topic, record)
cancel()
if err != nil {
nextRetryAtMS := time.Now().UTC().Add(walletOutboxBackoff(record.RetryCount+1, options)).UnixMilli()
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := a.mysqlRepo.MarkWalletOutboxRetryable(markCtx, record.EventID, err.Error(), nextRetryAtMS)
markCancel()
if markErr != nil {
return 0, markErr
}
continue
}
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := a.mysqlRepo.MarkWalletOutboxDelivered(markCtx, record.EventID)
markCancel()
if markErr != nil {
return 0, markErr
}
}
return len(records), nil
}
func (a *App) publishWalletOutboxRecord(ctx context.Context, producer *rocketmqx.Producer, topic string, record mysqlstorage.WalletOutboxRecord) error {
body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{
AppCode: record.AppCode,
EventID: record.EventID,
EventType: record.EventType,
TransactionID: record.TransactionID,
CommandID: record.CommandID,
UserID: record.UserID,
AssetType: record.AssetType,
AvailableDelta: record.AvailableDelta,
FrozenDelta: record.FrozenDelta,
PayloadJSON: record.PayloadJSON,
OccurredAtMS: record.CreatedAtMS,
})
if err != nil {
return err
}
return producer.SendSync(ctx, rocketmqx.Message{
Topic: topic,
Tag: walletmq.TagWalletOutboxEvent,
Keys: []string{record.EventID, record.TransactionID, record.CommandID},
Body: body,
})
}
func deadWalletOutboxReason(record mysqlstorage.WalletOutboxRecord) string {
last := strings.TrimSpace(record.LastError)
if last == "" {
return fmt.Sprintf("wallet outbox exceeded retry limit: retry_count=%d", record.RetryCount)
}
return last
}
func walletOutboxBackoff(retryCount int, options config.OutboxWorkerConfig) time.Duration {
if retryCount <= 0 {
return options.InitialBackoff
}
backoff := options.InitialBackoff
for i := 1; i < retryCount; i++ {
backoff *= 2
if backoff >= options.MaxBackoff {
return options.MaxBackoff
}
}
return backoff
}
func (a *App) closeBackgroundWorkers() {
if a.stopWorker != nil {
a.stopWorker()
}
a.workers.Wait()
}
func (a *App) startMQ() error {
if a.outboxProducer != nil {
if err := a.outboxProducer.Start(); err != nil {
return err
}
}
if a.realtimeOutboxProducer != nil {
if err := a.realtimeOutboxProducer.Start(); err != nil {
if a.outboxProducer != nil {
_ = a.outboxProducer.Shutdown()
}
return err
}
}
if a.projectionConsumer != nil {
if err := a.projectionConsumer.Start(); err != nil {
shutdownProducers(a.outboxProducer, a.realtimeOutboxProducer)
return err
}
}
return nil
}
func (a *App) shutdownMQ() {
if a.projectionConsumer != nil {
if err := a.projectionConsumer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_projection_consumer_shutdown_failed", slog.String("error", err.Error()))
}
}
if a.outboxProducer != nil {
if err := a.outboxProducer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error()))
}
}
if a.realtimeOutboxProducer != nil {
if err := a.realtimeOutboxProducer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_realtime_producer_shutdown_failed", slog.String("error", err.Error()))
}
}
}
func newWalletProjectionConsumer(cfg config.Config, svc *walletservice.Service) (*rocketmqx.Consumer, error) {
if svc == nil || !cfg.ProjectionWorker.Enabled {
return nil, nil
}
consumer, err := rocketmqx.NewConsumer(rocketMQProjectionConsumerConfig(cfg.RocketMQ, cfg.ProjectionWorker))
if err != nil {
return nil, err
}
workerID := "wallet-projection-" + cfg.NodeID
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body)
if err != nil {
return err
}
_, err = svc.ProcessWalletProjectionMessage(appcode.WithContext(ctx, walletMessage.AppCode), workerID, walletMessage, cfg.ProjectionWorker.LockTTL)
return err
}); err != nil {
_ = consumer.Shutdown()
return nil, err
}
return consumer, nil
}
func rocketMQProjectionConsumerConfig(cfg config.RocketMQConfig, projection config.ProjectionWorkerConfig) rocketmqx.ConsumerConfig {
return rocketmqx.ConsumerConfig{
EndpointConfig: rocketmqx.EndpointConfig{
NameServers: cfg.NameServers,
NameServerDomain: cfg.NameServerDomain,
AccessKey: cfg.AccessKey,
SecretKey: cfg.SecretKey,
SecurityToken: cfg.SecurityToken,
Namespace: cfg.Namespace,
VIPChannel: cfg.VIPChannel,
},
GroupName: projection.ConsumerGroup,
MaxReconsumeTimes: projection.ConsumerMaxReconsumeTimes,
ConsumePullBatch: int32(projection.BatchSize),
}
}
func rocketMQProducerConfig(cfg config.RocketMQConfig, groupName string) rocketmqx.ProducerConfig {
return rocketmqx.ProducerConfig{
EndpointConfig: rocketmqx.EndpointConfig{
NameServers: cfg.NameServers,
NameServerDomain: cfg.NameServerDomain,
AccessKey: cfg.AccessKey,
SecretKey: cfg.SecretKey,
SecurityToken: cfg.SecurityToken,
Namespace: cfg.Namespace,
VIPChannel: cfg.VIPChannel,
},
GroupName: groupName,
SendTimeout: cfg.SendTimeout,
Retry: cfg.Retry,
}
}
func mifapayConfigReady(cfg config.MifaPayConfig) bool {
return strings.TrimSpace(cfg.MerAccount) != "" &&
strings.TrimSpace(cfg.MerNo) != "" &&
strings.TrimSpace(cfg.PrivateKey) != "" &&
strings.TrimSpace(cfg.PlatformPublicKey) != ""
}
func v5payConfigReady(cfg config.V5PayConfig) bool {
return strings.TrimSpace(cfg.MerchantNo) != "" &&
strings.TrimSpace(cfg.AppKey) != "" &&
strings.TrimSpace(cfg.SecretKey) != ""
}
func shutdownProducers(producers ...*rocketmqx.Producer) {
for _, producer := range producers {
if producer != nil {
_ = producer.Shutdown()
}
}
}
func (a *App) runHealthHTTP() {
if a.healthHTTP == nil {
return
}
go func() {
if err := a.healthHTTP.Run(); err != nil {
logx.Error(context.Background(), "health_http_run_failed", err)
}
}()
}
func (a *App) closeHealthHTTP() {
if a.healthHTTP == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = a.healthHTTP.Close(ctx)
}

View File

@ -0,0 +1,28 @@
package app
import "context"
func (a *App) runBackgroundWorkers() {
if a.walletSvc == nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
a.stopWorker = cancel
if a.outboxWorkerCfg.Enabled && a.outboxProducer != nil {
excludedRealtimeTypes := []string(nil)
if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil {
// 普通账务 worker 在实时通道可用时跳过红包 UI 事件,避免两个 worker 抢同一行。
excludedRealtimeTypes = a.realtimeOutboxWorkerCfg.EventTypes
}
a.startWalletOutboxWorkers(ctx, "wallet-outbox", a.outboxWorkerCfg, a.outboxProducer, a.walletOutboxTopic, nil, excludedRealtimeTypes)
}
if a.realtimeOutboxWorkerCfg.Enabled && a.realtimeOutboxProducer != nil {
a.startWalletOutboxWorkers(ctx, "wallet-realtime-outbox", a.realtimeOutboxWorkerCfg, a.realtimeOutboxProducer, a.realtimeWalletOutboxTopic, a.realtimeOutboxWorkerCfg.EventTypes, nil)
}
if a.externalRechargeReconcileWorkerCfg.Enabled {
a.startExternalRechargeReconcileWorker(ctx)
}
if a.redPacketExpiryWorkerCfg.Enabled {
a.startRedPacketExpiryWorker(ctx)
}
}

View File

@ -0,0 +1,20 @@
package app
import (
"strings"
"hyapp/services/wallet-service/internal/config"
)
func mifapayConfigReady(cfg config.MifaPayConfig) bool {
return strings.TrimSpace(cfg.MerAccount) != "" &&
strings.TrimSpace(cfg.MerNo) != "" &&
strings.TrimSpace(cfg.PrivateKey) != "" &&
strings.TrimSpace(cfg.PlatformPublicKey) != ""
}
func v5payConfigReady(cfg config.V5PayConfig) bool {
return strings.TrimSpace(cfg.MerchantNo) != "" &&
strings.TrimSpace(cfg.AppKey) != "" &&
strings.TrimSpace(cfg.SecretKey) != ""
}

View File

@ -0,0 +1,32 @@
package app
import (
"context"
"errors"
"log/slog"
"time"
"hyapp/pkg/logx"
)
func (a *App) startExternalRechargeReconcileWorker(ctx context.Context) {
workerID := "external-recharge-reconcile-" + a.nodeID
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(a.externalRechargeReconcileWorkerCfg.PollInterval)
defer ticker.Stop()
for {
// 三方回调是主链路;补偿 worker 只兜底 redirected 订单,真正入账仍由 service/storage 的幂等事务收敛。
_, err := a.walletSvc.ReconcileExternalRechargeOrders(ctx, a.externalRechargeReconcileWorkerCfg.AppCode, a.externalRechargeReconcileWorkerCfg.BatchSize)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "external_recharge_reconcile_failed", err, slog.String("worker_id", workerID))
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}

View File

@ -0,0 +1,28 @@
package app
import (
"context"
"time"
"hyapp/pkg/logx"
)
func (a *App) runHealthHTTP() {
if a.healthHTTP == nil {
return
}
go func() {
if err := a.healthHTTP.Run(); err != nil {
logx.Error(context.Background(), "health_http_run_failed", err)
}
}()
}
func (a *App) closeHealthHTTP() {
if a.healthHTTP == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = a.healthHTTP.Close(ctx)
}

View File

@ -0,0 +1,89 @@
package app
import (
"context"
"hyapp/pkg/appcode"
"hyapp/pkg/rocketmqx"
servicemq "hyapp/pkg/servicekit/mq"
"hyapp/pkg/walletmq"
"hyapp/services/wallet-service/internal/config"
walletservice "hyapp/services/wallet-service/internal/service/wallet"
)
func (a *App) startMQ() error {
if err := servicemq.StartProducers([]*rocketmqx.Producer{a.outboxProducer, a.realtimeOutboxProducer}); err != nil {
return err
}
if err := servicemq.StartConsumers([]*rocketmqx.Consumer{a.projectionConsumer}); err != nil {
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.outboxProducer, a.realtimeOutboxProducer})
return err
}
return nil
}
func (a *App) shutdownMQ() {
servicemq.ShutdownConsumers([]*rocketmqx.Consumer{a.projectionConsumer})
servicemq.ShutdownProducers([]*rocketmqx.Producer{a.outboxProducer, a.realtimeOutboxProducer})
}
func newWalletProjectionConsumer(cfg config.Config, svc *walletservice.Service) (*rocketmqx.Consumer, error) {
if svc == nil || !cfg.ProjectionWorker.Enabled {
return nil, nil
}
consumer, err := rocketmqx.NewConsumer(rocketMQProjectionConsumerConfig(cfg.RocketMQ, cfg.ProjectionWorker))
if err != nil {
return nil, err
}
workerID := "wallet-projection-" + cfg.NodeID
if err := consumer.Subscribe(cfg.RocketMQ.WalletOutbox.Topic, walletmq.TagWalletOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
walletMessage, err := walletmq.DecodeWalletOutboxMessage(message.Body)
if err != nil {
return err
}
_, err = svc.ProcessWalletProjectionMessage(appcode.WithContext(ctx, walletMessage.AppCode), workerID, walletMessage, cfg.ProjectionWorker.LockTTL)
return err
}); err != nil {
_ = consumer.Shutdown()
return nil, err
}
return consumer, nil
}
func rocketMQProjectionConsumerConfig(cfg config.RocketMQConfig, projection config.ProjectionWorkerConfig) rocketmqx.ConsumerConfig {
return rocketmqx.ConsumerConfig{
EndpointConfig: rocketmqx.EndpointConfig{
NameServers: cfg.NameServers,
NameServerDomain: cfg.NameServerDomain,
AccessKey: cfg.AccessKey,
SecretKey: cfg.SecretKey,
SecurityToken: cfg.SecurityToken,
Namespace: cfg.Namespace,
VIPChannel: cfg.VIPChannel,
},
GroupName: projection.ConsumerGroup,
MaxReconsumeTimes: projection.ConsumerMaxReconsumeTimes,
ConsumePullBatch: int32(projection.BatchSize),
}
}
func rocketMQProducerConfig(cfg config.RocketMQConfig, groupName string) rocketmqx.ProducerConfig {
return rocketmqx.ProducerConfig{
EndpointConfig: rocketmqx.EndpointConfig{
NameServers: cfg.NameServers,
NameServerDomain: cfg.NameServerDomain,
AccessKey: cfg.AccessKey,
SecretKey: cfg.SecretKey,
SecurityToken: cfg.SecurityToken,
Namespace: cfg.Namespace,
VIPChannel: cfg.VIPChannel,
},
GroupName: groupName,
SendTimeout: cfg.SendTimeout,
Retry: cfg.Retry,
}
}
func shutdownProducers(producers ...*rocketmqx.Producer) {
servicemq.ShutdownProducers(producers)
}

View File

@ -0,0 +1,148 @@
package app
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/walletmq"
"hyapp/services/wallet-service/internal/config"
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
)
func (a *App) startWalletOutboxWorkers(ctx context.Context, workerName string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) {
if options.Concurrency <= 0 {
options.Concurrency = 1
}
for workerIndex := 1; workerIndex <= options.Concurrency; workerIndex++ {
workerID := fmt.Sprintf("%s-%s-%02d", workerName, a.nodeID, workerIndex)
a.workers.Add(1)
go func() {
defer a.workers.Done()
a.runWalletOutboxWorker(ctx, workerID, options, producer, topic, includeEventTypes, excludeEventTypes)
}()
}
}
func (a *App) runWalletOutboxWorker(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) {
ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop()
for {
processed, err := a.processWalletOutboxBatch(ctx, workerID, options, producer, topic, includeEventTypes, excludeEventTypes)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "wallet_outbox_publish_failed", err, slog.String("worker_id", workerID))
}
if processed >= options.BatchSize {
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (a *App) processWalletOutboxBatch(ctx context.Context, workerID string, options config.OutboxWorkerConfig, producer *rocketmqx.Producer, topic string, includeEventTypes []string, excludeEventTypes []string) (int, error) {
if a.mysqlRepo == nil || producer == nil {
return 0, nil
}
records, err := a.mysqlRepo.ClaimPendingWalletOutboxFiltered(ctx, workerID, options.BatchSize, time.Now().UTC().Add(options.PublishTimeout).UnixMilli(), mysqlstorage.WalletOutboxClaimFilter{
IncludeEventTypes: includeEventTypes,
ExcludeEventTypes: excludeEventTypes,
})
if err != nil {
return 0, err
}
for _, record := range records {
if record.RetryCount >= options.MaxRetryCount {
markCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
err := a.mysqlRepo.MarkWalletOutboxDead(markCtx, record.EventID, deadWalletOutboxReason(record))
cancel()
if err != nil {
return 0, err
}
continue
}
publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
err := a.publishWalletOutboxRecord(publishCtx, producer, topic, record)
cancel()
if err != nil {
nextRetryAtMS := time.Now().UTC().Add(walletOutboxBackoff(record.RetryCount+1, options)).UnixMilli()
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := a.mysqlRepo.MarkWalletOutboxRetryable(markCtx, record.EventID, err.Error(), nextRetryAtMS)
markCancel()
if markErr != nil {
return 0, markErr
}
continue
}
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := a.mysqlRepo.MarkWalletOutboxDelivered(markCtx, record.EventID)
markCancel()
if markErr != nil {
return 0, markErr
}
}
return len(records), nil
}
func (a *App) publishWalletOutboxRecord(ctx context.Context, producer *rocketmqx.Producer, topic string, record mysqlstorage.WalletOutboxRecord) error {
body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{
AppCode: record.AppCode,
EventID: record.EventID,
EventType: record.EventType,
TransactionID: record.TransactionID,
CommandID: record.CommandID,
UserID: record.UserID,
AssetType: record.AssetType,
AvailableDelta: record.AvailableDelta,
FrozenDelta: record.FrozenDelta,
PayloadJSON: record.PayloadJSON,
OccurredAtMS: record.CreatedAtMS,
})
if err != nil {
return err
}
return producer.SendSync(ctx, rocketmqx.Message{
Topic: topic,
Tag: walletmq.TagWalletOutboxEvent,
Keys: []string{record.EventID, record.TransactionID, record.CommandID},
Body: body,
})
}
func deadWalletOutboxReason(record mysqlstorage.WalletOutboxRecord) string {
last := strings.TrimSpace(record.LastError)
if last == "" {
return fmt.Sprintf("wallet outbox exceeded retry limit: retry_count=%d", record.RetryCount)
}
return last
}
func walletOutboxBackoff(retryCount int, options config.OutboxWorkerConfig) time.Duration {
if retryCount <= 0 {
return options.InitialBackoff
}
backoff := options.InitialBackoff
for i := 1; i < retryCount; i++ {
backoff *= 2
if backoff >= options.MaxBackoff {
return options.MaxBackoff
}
}
return backoff
}
func (a *App) closeBackgroundWorkers() {
if a.stopWorker != nil {
a.stopWorker()
}
a.workers.Wait()
}

View File

@ -0,0 +1,42 @@
package app
import (
"context"
"errors"
"log/slog"
"time"
"hyapp/pkg/logx"
)
func (a *App) startRedPacketExpiryWorker(ctx context.Context) {
workerID := "red-packet-expiry-" + a.nodeID
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(a.redPacketExpiryWorkerCfg.PollInterval)
defer ticker.Stop()
for {
// 过期退款必须进入 wallet service 用例,保证红包状态、退款流水和 outbox 同一事务提交。
result, err := a.walletSvc.ExpireRedPackets(ctx, a.redPacketExpiryWorkerCfg.AppCode, a.redPacketExpiryWorkerCfg.BatchSize)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "red_packet_expiry_failed", err, slog.String("worker_id", workerID))
}
if result.ExpiredCount > 0 {
logx.Info(ctx, "red_packet_expired",
slog.String("worker_id", workerID),
slog.Int64("expired_count", int64(result.ExpiredCount)),
slog.Int64("refunded_amount", result.RefundedAmount),
)
}
if result.ExpiredCount >= a.redPacketExpiryWorkerCfg.BatchSize {
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}

View File

@ -0,0 +1,138 @@
package ledger
import (
"strings"
)
// CoinSellerTransferCommand 是币商给玩家转普通金币的账务命令。
type CoinSellerTransferCommand struct {
AppCode string
CommandID string
SellerUserID int64
TargetUserID int64
TargetCountryID int64
SellerRegionID int64
TargetRegionID int64
Amount int64
Reason string
}
// CoinSellerTransferReceipt 是币商转账完成后的稳定回执。
type CoinSellerTransferReceipt struct {
TransactionID string
SellerBalanceAfter int64
TargetBalanceAfter int64
Amount int64
RechargeSequence int64
RechargeUSDMinor int64
RechargeCurrencyCode string
RechargePolicyID int64
RechargePolicyVersion string
RechargePolicyCoinAmount int64
RechargePolicyUSDMinorUnit int64
}
// CoinSellerSalaryExchangeRateTier 是工资转给币商时按区域和美元金额匹配的金币兑换比例。
type CoinSellerSalaryExchangeRateTier struct {
RegionID int64
MinUSDMinor int64
MaxUSDMinor int64
CoinPerUSD int64
Status string
SortOrder int
UpdatedAtMS int64
}
// SalaryExchangeCommand 是用户把某个身份工资美元钱包兑换为自己普通金币的账务命令。
type SalaryExchangeCommand struct {
AppCode string
CommandID string
UserID int64
SalaryAssetType string
SalaryUSDMinor int64
Reason string
}
// SalaryExchangeReceipt 返回工资扣减和普通金币入账后的双边余额。
type SalaryExchangeReceipt struct {
TransactionID string
UserID int64
SalaryAssetType string
SalaryBalanceAfter int64
CoinBalanceAfter int64
SalaryUSDMinor int64
CoinAmount int64
CoinPerUSD int64
CreatedAtMS int64
}
// SalaryTransferToCoinSellerCommand 是用户把某个身份工资美元钱包转给同区域币商专用金币库存的账务命令。
type SalaryTransferToCoinSellerCommand struct {
AppCode string
CommandID string
SourceUserID int64
SellerUserID int64
SalaryAssetType string
SalaryUSDMinor int64
RegionID int64
Reason string
}
// SalaryTransferToCoinSellerReceipt 返回工资扣减和币商库存入账后的双边余额与命中的区间。
type SalaryTransferToCoinSellerReceipt struct {
TransactionID string
SourceUserID int64
SellerUserID int64
SalaryAssetType string
SourceSalaryBalanceAfter int64
SellerBalanceAfter int64
SalaryUSDMinor int64
CoinAmount int64
CoinPerUSD int64
RateMinUSDMinor int64
RateMaxUSDMinor int64
CreatedAtMS int64
}
// CoinSellerStockCreditCommand 是后台给币商专用金币库存入账的最小账务命令。
type CoinSellerStockCreditCommand struct {
AppCode string
CommandID string
SellerUserID int64
SellerCountryID int64
SellerRegionID 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
SellerCountryID int64
SellerRegionID int64
StockType string
CoinAmount int64
PaidCurrencyCode string
PaidAmountMicro int64
CountsAsSellerRecharge bool
BalanceAfter int64
CreatedAtMS int64
}
func NormalizeCoinSellerStockType(stockType string) string {
switch strings.ToLower(strings.TrimSpace(stockType)) {
case StockTypeUSDTPurchase:
return StockTypeUSDTPurchase
case StockTypeCoinCompensation:
return StockTypeCoinCompensation
default:
return ""
}
}

View File

@ -0,0 +1,132 @@
package ledger
const (
// AssetCoin 是用户送礼消费资产。
AssetCoin = "COIN"
// AssetCoinSellerCoin 是币商专用金币资产,只能通过币商转账兑换成玩家普通 COIN。
AssetCoinSellerCoin = "COIN_SELLER_COIN"
// AssetRobotCoin 是机器人房间专用金币资产,只允许内部机器人送礼链路扣减,不进入真人充值消费口径。
AssetRobotCoin = "ROBOT_COIN"
// AssetBagGift 是送礼回执里的支付来源标识不是钱包资产账户Bag 礼物只扣用户资源权益库存。
AssetBagGift = "BAG"
// AssetGiftPoint 是历史礼物积分资产,只保留历史账本查询兼容;新送礼不会再给它入账。
AssetGiftPoint = "GIFT_POINT"
// AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。
AssetHostPeriodDiamond = "HOST_PERIOD_DIAMOND"
// AssetHostSalaryUSD 是主播工资美元钱包,只接收主播工资和月底剩余钻石折美元。
AssetHostSalaryUSD = "HOST_SALARY_USD"
// AssetAgencySalaryUSD 是代理工资美元钱包,只接收主播结算同时产生的代理工资。
AssetAgencySalaryUSD = "AGENCY_SALARY_USD"
// AssetBDSalaryUSD 是 BD 工资美元钱包,只接收 BD 政策结算工资。
AssetBDSalaryUSD = "BD_SALARY_USD"
// AssetAdminSalaryUSD 是 Admin 工资美元钱包,只接收 Admin 政策结算工资。
AssetAdminSalaryUSD = "ADMIN_SALARY_USD"
// SalaryExchangeCoinPerUSD 是工资直接兑换普通金币的固定比例,金额以 1 USD 为单位。
SalaryExchangeCoinPerUSD int64 = 80000
// StockTypeUSDTPurchase 表示币商线下 USDT 进货后发放专用金币库存。
StockTypeUSDTPurchase = "usdt_purchase"
// StockTypeCoinCompensation 表示平台人工补偿币商专用金币库存,不计入进货金额。
StockTypeCoinCompensation = "coin_compensation"
// PaidCurrencyUSDT 是首版币商进货支持的唯一线下付款币种。
PaidCurrencyUSDT = "USDT"
// RechargeProductStatusActive 表示内购商品已上架,可进入 App 充值页。
RechargeProductStatusActive = "active"
// RechargeProductStatusDisabled 表示内购商品未上架,仅后台可见。
RechargeProductStatusDisabled = "disabled"
// RechargeProductPlatformAndroid 表示 Google Play 侧内购商品。
RechargeProductPlatformAndroid = "android"
// RechargeProductPlatformIOS 表示 Apple IAP 侧内购商品。
RechargeProductPlatformIOS = "ios"
// RechargeProductPlatformWeb 表示 H5 站外充值商品,支付方式由订单选择,不绑定应用商店。
RechargeProductPlatformWeb = "web"
// RechargeAudienceNormal 表示普通用户可购买的充值档位。
RechargeAudienceNormal = "normal"
// RechargeAudienceCoinSeller 表示币商可购买的充值档位。
RechargeAudienceCoinSeller = "coin_seller"
// RechargeChannelGoogle 是 android 平台内购渠道标识。
RechargeChannelGoogle = "google"
// RechargeChannelApple 是 iOS 平台内购渠道标识。
RechargeChannelApple = "apple"
// RechargeChannelExternal 是 H5 站外充值渠道,具体 provider 在外部订单上保存。
RechargeChannelExternal = "external"
// PaymentProviderGooglePlay 是 Google Play 一次性内购支付渠道。
PaymentProviderGooglePlay = "google_play"
// PaymentProviderMifaPay 是 MiFaPay 三方收银台支付渠道。
PaymentProviderMifaPay = "mifapay"
// PaymentProviderV5Pay 是 V5Pay 三方收银台支付渠道。
PaymentProviderV5Pay = "v5pay"
// PaymentProviderUSDTTRC20 是用户提交 TRC20 链上交易哈希的 USDT 充值渠道。
PaymentProviderUSDTTRC20 = "usdt_trc20"
// ThirdPartyPaymentStatusActive 表示渠道或支付方式可用于 H5 下单。
ThirdPartyPaymentStatusActive = "active"
// ThirdPartyPaymentStatusDisabled 表示渠道或支付方式已被后台关闭。
ThirdPartyPaymentStatusDisabled = "disabled"
// ExternalRechargeStatusPending 表示外部充值订单已创建但尚未确认到账。
ExternalRechargeStatusPending = "pending"
// ExternalRechargeStatusRedirected 表示 MiFaPay 下单成功,用户已拿到跳转链接。
ExternalRechargeStatusRedirected = "redirected"
// ExternalRechargeStatusCredited 表示外部充值已完成钱包入账。
ExternalRechargeStatusCredited = "credited"
// ExternalRechargeStatusFailed 表示外部充值被验签、金额或链上校验拒绝。
ExternalRechargeStatusFailed = "failed"
// PaymentStatusCredited 表示支付已完成校验并入账。
PaymentStatusCredited = "credited"
// PaymentConsumeStatePending 表示已入账但 Google consume 尚未确认完成。
PaymentConsumeStatePending = "consume_pending"
// PaymentConsumeStateConsumed 表示 Google consume 已确认完成。
PaymentConsumeStateConsumed = "consumed"
// GooglePurchaseStatePurchased 是 Google Play 已支付完成状态。
GooglePurchaseStatePurchased = "PURCHASED"
// HostSalarySettlementModeDaily 表示主播工资按日结算等级增量。
HostSalarySettlementModeDaily = "daily"
// HostSalarySettlementModeHalfMonth 表示主播工资按半月周期结算等级增量。
HostSalarySettlementModeHalfMonth = "half_month"
// HostSalarySettlementTypeMonthEnd 表示月底清算等级增量、剩余钻石折美元和周期关闭。
HostSalarySettlementTypeMonthEnd = "month_end"
// HostSalarySettlementTriggerAutomatic 表示由 cron-service 自动扫描结算的政策。
HostSalarySettlementTriggerAutomatic = "automatic"
// HostSalarySettlementTriggerManual 表示只允许后台工资结算页人工触发的政策。
HostSalarySettlementTriggerManual = "manual"
// VipStatusActive 表示 VIP 配置或用户会员状态当前有效。
VipStatusActive = "active"
// VipStatusDisabled 表示 VIP 配置被后台停用。
VipStatusDisabled = "disabled"
// VipGrantSourcePurchase 表示用户主动购买或续费 VIP。
VipGrantSourcePurchase = "vip_purchase"
// VipGrantSourceActivity 表示活动系统赠送 VIP。
VipGrantSourceActivity = "activity_grant"
// VipGrantSourceAdmin 表示后台人工赠送 VIP。
VipGrantSourceAdmin = "admin_grant"
// VipGrantSourceManagerCenter 表示经理中心按权限赠送 VIP账务仍复用后台赠送激活链路。
VipGrantSourceManagerCenter = "manager_center"
// GameOpDebit 表示游戏平台下注、局内道具等扣金币行为。
GameOpDebit = "debit"
// GameOpCredit 表示游戏平台中奖、派奖等加金币行为。
GameOpCredit = "credit"
// GameOpRefund 表示游戏平台扣款失败或取消后的退款入账。
GameOpRefund = "refund"
// GameOpReverse 表示平台冲正;首版按扣回用户 COIN 处理。
GameOpReverse = "reverse"
// RedPacketTypeNormal 表示发出后立即可抢的普通红包。
RedPacketTypeNormal = "normal"
// RedPacketTypeDelayed 表示发出后按后台秒数延迟打开的红包。
RedPacketTypeDelayed = "delayed"
// RedPacketStatusWaitingOpen 表示延迟红包已扣款但未到打开时间。
RedPacketStatusWaitingOpen = "waiting_open"
// RedPacketStatusActive 表示红包可领取。
RedPacketStatusActive = "active"
// RedPacketStatusFinished 表示红包所有份额已领取。
RedPacketStatusFinished = "finished"
// RedPacketStatusRefunded 表示红包过期未领取金额已退回。
RedPacketStatusRefunded = "refunded"
// RedPacketClaimStatusClaimed 表示红包份额领取成功。
RedPacketClaimStatusClaimed = "claimed"
// RedPacketExpireSeconds 固定房内红包 24 小时过期退款。
RedPacketExpireSeconds int32 = 24 * 60 * 60
)

View File

@ -0,0 +1,42 @@
package ledger
import (
"strings"
)
// GameCoinChangeCommand 是 game-service 对钱包发起的游戏专用金币改账命令。
type GameCoinChangeCommand struct {
AppCode string
CommandID string
UserID int64
PlatformCode string
GameID string
ProviderOrderID string
ProviderRoundID string
OpType string
CoinAmount int64
RoomID string
RequestHash string
}
// GameCoinChangeReceipt 是游戏改账成功或幂等重放后的稳定回执。
type GameCoinChangeReceipt struct {
TransactionID string
BalanceAfter int64
IdempotentReplay bool
}
func NormalizeGameOpType(opType string) string {
switch strings.ToLower(strings.TrimSpace(opType)) {
case GameOpDebit:
return GameOpDebit
case GameOpCredit:
return GameOpCredit
case GameOpRefund:
return GameOpRefund
case GameOpReverse:
return GameOpReverse
default:
return ""
}
}

View File

@ -0,0 +1,119 @@
package ledger
import (
"strings"
)
// DebitGiftCommand 是 room-service 送礼扣费的账务命令。
type DebitGiftCommand struct {
AppCode string
CommandID string
RoomID string
SenderUserID int64
TargetUserID int64
GiftID string
GiftCount int32
PriceVersion string
RegionID int64
// SenderRegionID 是送礼用户所属区域;用于按区域匹配礼物入主播周期钻石比例。
SenderRegionID int64
// TargetIsHost 只能由 gateway 根据 user-service active host profile 注入,客户端输入不可信。
TargetIsHost bool
// TargetHostRegionID 是主播身份所属区域;工资政策按该区域匹配,不能用房间可见区域替代。
TargetHostRegionID int64
// TargetAgencyOwnerUserID 是送礼瞬间主播上级代理的收款用户快照;结算按快照发放,避免月底组织变化错账。
TargetAgencyOwnerUserID int64
// RobotGift 表示本次扣费只服务机器人房间展示:扣 ROBOT_COIN不给主播钻石不进入真实礼物墙投影。
RobotGift bool
// EntitlementID 非空表示本次送礼来自用户背包礼物权益,账务只扣库存但仍按礼物价格计算房间贡献。
EntitlementID string
// ChargeSource 区分 coin/bag为空按 coin 兼容旧链路。
ChargeSource string
}
// DebitGiftTargetCommand 是批量送礼中单个接收方的账务语义。
type DebitGiftTargetCommand struct {
// CommandID 是单个目标交易的幂等键;同一批量送礼必须为每个目标派生独立值。
CommandID string
// TargetUserID 是本次收礼用户GIFT_POINT 已下线,新送礼不会再给目标用户积分入账。
TargetUserID int64
// TargetIsHost 只能由 gateway 注入,批量目标之间不能共享该身份快照。
TargetIsHost bool
// TargetHostRegionID 是该目标主播身份所属区域。
TargetHostRegionID int64
// TargetAgencyOwnerUserID 是该目标主播当前代理 owner 收款快照。
TargetAgencyOwnerUserID int64
}
// BatchDebitGiftCommand 在一个钱包事务中结算同一 sender 对多个 target 的同款礼物。
type BatchDebitGiftCommand struct {
AppCode string
CommandID string
RoomID string
SenderUserID int64
GiftID string
GiftCount int32
PriceVersion string
RegionID int64
SenderRegionID int64
Targets []DebitGiftTargetCommand
EntitlementID string
ChargeSource string
}
// Receipt 是账务命令落账后的稳定回执。
type Receipt struct {
BillingReceiptID string
TransactionID string
CoinSpent int64
ChargeAssetType string
ChargeAmount int64
// GiftPointAdded 是历史回执字段,新送礼固定为 0房间贡献和主播周期钻石只按真实扣费金额计算。
GiftPointAdded int64
HeatValue int64
GiftTypeCode string
// CPRelationType 只在 CP 礼物类型上有值room-service 会把它写入 RoomGiftSent 供 user-service 建关系申请。
CPRelationType string
// 礼物展示字段是扣费时的资源快照,避免异步消费者再查礼物配置导致历史 IM 展示被后续配置修改影响。
GiftName string
GiftIconURL string
GiftAnimationURL string
GiftEffectTypes []string
PriceVersion string
BalanceAfter int64
// HostPeriodDiamondAdded 是本次送礼写入主播工资周期账户的钻石数;非主播恒为 0。
HostPeriodDiamondAdded int64
// HostPeriodCycleKey 是工资周期键,当前按 UTC 月生成,后续结算按此键定位周期账户。
HostPeriodCycleKey string
EntitlementID string
ChargeSource string
}
// BatchGiftTargetReceipt 保留批量送礼里每个目标的独立交易回执。
type BatchGiftTargetReceipt struct {
TargetUserID int64
CommandID string
Receipt Receipt
}
// BatchGiftReceipt 同时返回聚合展示值和逐目标事实回执。
type BatchGiftReceipt struct {
Aggregate Receipt
Targets []BatchGiftTargetReceipt
}
func NormalizeGiftChargeAssetType(assetType string) string {
if strings.ToUpper(strings.TrimSpace(assetType)) == AssetBagGift {
return AssetBagGift
}
return AssetCoin
}
func ValidGiftChargeAssetType(assetType string) bool {
switch strings.ToUpper(strings.TrimSpace(assetType)) {
case AssetCoin:
return true
default:
return false
}
}

View File

@ -0,0 +1,64 @@
package ledger
// HostSalarySettlementBatchCommand 是 cron 或测试触发结算批处理的最小输入。
type HostSalarySettlementBatchCommand struct {
AppCode string
RunID string
WorkerID string
BatchSize int
SettlementType string
// TriggerMode 只决定本次入账动作读取 automatic 还是 manual 政策;待结算账单展示可以同时读取两类政策。
TriggerMode string
// SettlementRole 为空或 all 时完整结算主播和代理权益;后台人工入口传 host/agency 时只推进对应身份钱包。
SettlementRole string
CycleKey string
// UserIDs 为空时扫描全量候选;后台人工批量结算会传入勾选主播,防止跨区域或跨周期误结算。
UserIDs []int64
NowMs int64
}
// HostSalarySettlementBatchResult 汇总一个结算批次的扫描和入账结果。
type HostSalarySettlementBatchResult struct {
ClaimedCount int
ProcessedCount int
SuccessCount int
FailureCount int
HasMore bool
}
// HostSalaryPolicy 是 wallet-service 结算读取的后台政策运行快照。
type HostSalaryPolicy struct {
PolicyID uint64
Name string
RegionID int64
Status string
SettlementMode string
SettlementTriggerMode string
GiftCoinToDiamondRatio string
ResidualDiamondToUSDRate string
EffectiveFromMs int64
EffectiveToMs int64
Levels []HostSalaryPolicyLevel
}
// HostSalaryPolicyLevel 使用累计值表达工资和奖励,结算时只发放“当前累计 - 已发累计”的差额。
type HostSalaryPolicyLevel struct {
LevelNo int
RequiredDiamonds int64
HostSalaryUSDMinor int64
HostCoinReward int64
AgencySalaryUSDMinor int64
Status string
SortOrder int
}
// HostSalaryProgress 是主播当前工资周期的钻石累计投影,用于 H5 按工资政策计算距离下一等级的差值。
type HostSalaryProgress struct {
HostUserID int64
CycleKey string
RegionID int64
AgencyOwnerUserID int64
TotalDiamonds int64
GiftDiamondTotal int64
UpdatedAtMS int64
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,325 @@
package ledger
import (
"strings"
)
// RechargeProduct 是用户充值页读取的充值档位。
type RechargeProduct struct {
AppCode string
AudienceType string
ProductID int64
ProductCode string
ProductName string
Description string
Platform string
Channel string
CurrencyCode string
AmountMicro int64
AmountMinor int64
CoinAmount int64
PolicyVersion string
Enabled bool
Status string
SortOrder int32
RegionIDs []int64
ResourceAssetType string
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
}
// ListRechargeProductsQuery 是后台内购商品配置列表的筛选条件。
type ListRechargeProductsQuery struct {
AppCode string
Status string
Platform string
AudienceType string
RegionID int64
Keyword string
Page int32
PageSize int32
}
// RechargeProductCommand 是后台创建或更新内购商品配置的完整事实输入。
type RechargeProductCommand struct {
AppCode string
AudienceType string
ProductID int64
AmountMicro int64
CoinAmount int64
ProductName string
Description string
Platform string
RegionIDs []int64
Enabled bool
OperatorUserID int64
}
// GooglePaymentCommand 是 App 完成 Google Play 支付后提交给后端确认和入账的命令。
type GooglePaymentCommand struct {
AppCode string
CommandID string
UserID int64
RegionID int64
ProductID int64
ProductCode string
PackageName string
PurchaseToken string
OrderID string
PurchaseTimeMS int64
}
// GooglePlayPurchase 是 Google Play Developer API 返回的一次性商品购买状态快照。
type GooglePlayPurchase struct {
PackageName string
ProductID string
OrderID string
PurchaseState string
ConsumptionState string
AcknowledgementState string
PurchaseCompletionTime string
RawJSON string
}
// GooglePaymentReceipt 是 Google 支付确认接口返回给 App 的稳定入账回执。
type GooglePaymentReceipt struct {
PaymentOrderID string
TransactionID string
Status string
ProductID int64
ProductCode string
CoinAmount int64
Balance AssetBalance
IdempotentReplay bool
ConsumeState string
}
// ThirdPartyPaymentMethod 是 MiFaPay 等三方支付方式的后台配置事实。
type ThirdPartyPaymentMethod struct {
MethodID int64
AppCode string
ProviderCode string
ProviderName string
CountryCode string
CountryName string
CurrencyCode string
PayWay string
PayType string
MethodName string
LogoURL string
Status string
USDToCurrencyRate string
SortOrder int32
CreatedAtMS int64
UpdatedAtMS int64
}
// ThirdPartyPaymentChannel 聚合一个三方渠道及其国家/方式列表,供后台展开展示。
type ThirdPartyPaymentChannel struct {
AppCode string
ProviderCode string
ProviderName string
Status string
SortOrder int32
Methods []ThirdPartyPaymentMethod
}
type ListThirdPartyPaymentChannelsQuery struct {
AppCode string
ProviderCode string
Status string
IncludeDisabledMethods bool
}
type ThirdPartyPaymentMethodStatusCommand struct {
AppCode string
MethodID int64
Enabled bool
OperatorUserID int64
}
type ThirdPartyPaymentRateCommand struct {
AppCode string
MethodID int64
USDToCurrencyRate string
OperatorUserID int64
}
// H5RechargeOptionsQuery 是站外充值页在账号确认后读取商品和支付方式的最小条件。
type H5RechargeOptionsQuery struct {
AppCode string
TargetUserID int64
TargetRegionID int64
TargetCountryCode string
AudienceType string
}
type H5RechargeOptions struct {
Products []RechargeProduct
PaymentMethods []ThirdPartyPaymentMethod
USDTTRC20Enabled bool
USDTTRC20Address string
}
// ExternalRechargeOrder 保存 H5 外部充值订单的订单、支付和入账状态。
type ExternalRechargeOrder struct {
OrderID string
AppCode string
CommandID string
TargetUserID int64
TargetRegionID int64
TargetCountryCode string
AudienceType string
ProductID int64
ProductCode string
ProductName string
CoinAmount int64
USDMinorAmount int64
ProviderCode string
PaymentMethodID int64
CountryCode string
CurrencyCode string
ProviderAmountMinor int64
PayWay string
PayType string
PayURL string
ProviderOrderID string
TxHash string
ReceiveAddress string
Status string
FailureReason string
TransactionID string
ProviderPayloadJSON string
CreatedAtMS int64
UpdatedAtMS int64
IdempotentReplay bool
}
type CreateExternalRechargeOrderCommand struct {
AppCode string
CommandID string
TargetUserID int64
TargetRegionID int64
TargetCountryCode string
AudienceType string
ProductID int64
ProviderCode string
PaymentMethodID int64
ReturnURL string
NotifyURL string
ClientIP string
Language string
PayerName string
PayerAccount string
PayerEmail string
}
type SubmitExternalRechargeTxCommand struct {
AppCode string
OrderID string
TargetUserID int64
TxHash string
}
type MifaPayNotification struct {
MerAccount string
Data string
Sign string
}
// V5PayNotification 保存 V5Pay 回调原始字段;验签必须使用所有返回字段,避免未来新增字段绕过签名校验。
type V5PayNotification struct {
Fields map[string]string
RawJSON string
}
func NormalizeRechargeProductStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case RechargeProductStatusActive:
return RechargeProductStatusActive
case RechargeProductStatusDisabled:
return RechargeProductStatusDisabled
default:
return ""
}
}
func NormalizeRechargeProductPlatform(platform string) string {
switch strings.ToLower(strings.TrimSpace(platform)) {
case RechargeProductPlatformAndroid:
return RechargeProductPlatformAndroid
case RechargeProductPlatformIOS:
return RechargeProductPlatformIOS
case RechargeProductPlatformWeb:
return RechargeProductPlatformWeb
default:
return ""
}
}
func RechargeChannelForPlatform(platform string) string {
switch NormalizeRechargeProductPlatform(platform) {
case RechargeProductPlatformAndroid:
return RechargeChannelGoogle
case RechargeProductPlatformIOS:
return RechargeChannelApple
case RechargeProductPlatformWeb:
return RechargeChannelExternal
default:
return ""
}
}
func NormalizeRechargeAudienceType(audienceType string) string {
switch strings.ToLower(strings.TrimSpace(audienceType)) {
case "", RechargeAudienceNormal:
return RechargeAudienceNormal
case RechargeAudienceCoinSeller:
return RechargeAudienceCoinSeller
default:
return ""
}
}
func NormalizePaymentProvider(provider string) string {
switch strings.ToLower(strings.TrimSpace(provider)) {
case PaymentProviderMifaPay:
return PaymentProviderMifaPay
case PaymentProviderV5Pay:
return PaymentProviderV5Pay
case PaymentProviderUSDTTRC20:
return PaymentProviderUSDTTRC20
case PaymentProviderGooglePlay:
return PaymentProviderGooglePlay
default:
return ""
}
}
func NormalizeThirdPartyPaymentStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case ThirdPartyPaymentStatusActive:
return ThirdPartyPaymentStatusActive
case ThirdPartyPaymentStatusDisabled:
return ThirdPartyPaymentStatusDisabled
default:
return ""
}
}
func NormalizeExternalRechargeStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case ExternalRechargeStatusPending:
return ExternalRechargeStatusPending
case ExternalRechargeStatusRedirected:
return ExternalRechargeStatusRedirected
case ExternalRechargeStatusCredited:
return ExternalRechargeStatusCredited
case ExternalRechargeStatusFailed:
return ExternalRechargeStatusFailed
default:
return ""
}
}

View File

@ -0,0 +1,146 @@
package ledger
import (
"strings"
)
// RedPacketConfig 是后台红包配置的服务端事实,发送路径必须由 wallet-service 强校验。
type RedPacketConfig struct {
AppCode string
Enabled bool
CountTiers []int32
AmountTiers []int64
DelayedOpenSeconds int32
ExpireSeconds int32
DailySendLimit int32
UpdatedByAdminID int64
RuleURL string
CreatedAtMS int64
UpdatedAtMS int64
}
// RedPacket 是红包资金池和状态的只读投影。
type RedPacket struct {
AppCode string
PacketID string
CommandID string
SenderUserID int64
RoomID string
RegionID int64
PacketType string
TotalAmount int64
PacketCount int32
RemainingAmount int64
RemainingCount int32
Status string
OpenAtMS int64
ExpiresAtMS int64
CreatedAtMS int64
UpdatedAtMS int64
ClaimedByViewer bool
ViewerClaimAmount int64
Claims []RedPacketClaim
RefundAmount int64
RefundStatus string
RefundedAtMS int64
}
// RedPacketClaim 是单个用户抢红包的到账事实。
type RedPacketClaim struct {
AppCode string
ClaimID string
CommandID string
PacketID string
UserID int64
Amount int64
WalletTransactionID string
Status string
FailureReason string
CreatedAtMS int64
UpdatedAtMS int64
}
type RedPacketCreateCommand struct {
AppCode string
CommandID string
SenderUserID int64
RoomID string
RegionID int64
PacketType string
TotalAmount int64
PacketCount int32
}
type RedPacketCreateReceipt struct {
Packet RedPacket
BalanceAfter int64
}
type RedPacketClaimCommand struct {
AppCode string
CommandID string
PacketID string
UserID int64
}
type RedPacketClaimReceipt struct {
Claim RedPacketClaim
Packet RedPacket
BalanceAfter int64
}
type RedPacketRefundReceipt struct {
Packet RedPacket
RefundedAmount int64
}
type RedPacketQuery struct {
AppCode string
RoomID string
SenderUserID int64
RegionID int64
PacketType string
Status string
ViewerUserID int64
StartAtMS int64
EndAtMS int64
Page int32
PageSize int32
}
type RedPacketExpireResult struct {
ExpiredCount int32
RefundedAmount int64
}
func NormalizeRedPacketType(packetType string) string {
switch strings.ToUpper(strings.TrimSpace(packetType)) {
case "IMMEDIATE":
return RedPacketTypeNormal
case "DELAYED":
return RedPacketTypeDelayed
}
switch strings.ToLower(strings.TrimSpace(packetType)) {
case RedPacketTypeNormal:
return RedPacketTypeNormal
case RedPacketTypeDelayed:
return RedPacketTypeDelayed
default:
return ""
}
}
func NormalizeRedPacketStatus(status string) string {
switch strings.ToLower(strings.TrimSpace(status)) {
case RedPacketStatusWaitingOpen:
return RedPacketStatusWaitingOpen
case RedPacketStatusActive:
return RedPacketStatusActive
case RedPacketStatusFinished:
return RedPacketStatusFinished
case RedPacketStatusRefunded:
return RedPacketStatusRefunded
default:
return ""
}
}

View File

@ -0,0 +1,170 @@
package ledger
// CPBreakupFeeCommand 是解除 CP/兄弟/姐妹关系时的专用金币扣费命令。
type CPBreakupFeeCommand struct {
AppCode string
CommandID string
UserID int64
RelationshipID string
RelationType string
Amount int64
}
// CPBreakupFeeReceipt 返回解除关系扣费流水和 COIN 账后余额。
type CPBreakupFeeReceipt struct {
TransactionID string
CoinSpent int64
CoinBalanceAfter int64
Balance AssetBalance
}
// WheelDrawDebitCommand 是转盘抽奖扣费命令;中奖和 RTP 不在钱包内决策,钱包只保留独立 reason 的 COIN 支出事实。
type WheelDrawDebitCommand struct {
AppCode string
CommandID string
UserID int64
WheelID string
DrawCount int32
Amount int64
}
// WheelDrawDebitReceipt 返回转盘抽奖扣费流水activity-service 会用该流水作为抽奖已付费事实。
type WheelDrawDebitReceipt struct {
TransactionID string
CoinSpent int64
CoinBalanceAfter int64
Balance AssetBalance
}
// TaskRewardCommand 是 activity-service 任务领奖的 COIN 入账命令。
type TaskRewardCommand struct {
AppCode string
CommandID string
TargetUserID int64
Amount int64
TaskType string
TaskID string
CycleKey string
Reason string
}
// TaskRewardReceipt 是任务奖励入账后的稳定回执。
type TaskRewardReceipt struct {
TransactionID string
Balance AssetBalance
Amount int64
GrantedAtMS int64
}
// LuckyGiftRewardCommand 是 activity-service 用抽奖 draw_id 发起的 COIN 入账命令。
type LuckyGiftRewardCommand struct {
AppCode string
CommandID string
TargetUserID int64
Amount int64
DrawID string
RoomID string
VisibleRegionID int64
CountryID int64
GiftID string
PoolID string
Reason string
}
// LuckyGiftRewardReceipt 是幸运礼物返奖入账后的稳定回执。
type LuckyGiftRewardReceipt struct {
TransactionID string
Balance AssetBalance
Amount int64
GrantedAtMS int64
}
// WheelRewardCommand 是 activity-service 用转盘 draw_id 发起的 COIN 入账命令。
type WheelRewardCommand struct {
AppCode string
CommandID string
TargetUserID int64
Amount int64
DrawID string
WheelID string
SelectedTierID string
VisibleRegionID int64
Reason string
}
// WheelRewardReceipt 是转盘金币奖励入账后的稳定回执。
type WheelRewardReceipt struct {
TransactionID string
Balance AssetBalance
Amount int64
GrantedAtMS int64
}
// RoomTurnoverRewardCommand 是 activity-service 每周房间流水奖励的 COIN 入账命令。
type RoomTurnoverRewardCommand struct {
AppCode string
CommandID string
TargetUserID int64
Amount int64
SettlementID string
RoomID string
PeriodStartMS int64
PeriodEndMS int64
CoinSpent int64
TierID int64
TierCode string
Reason string
}
// RoomTurnoverRewardReceipt 是房间流水奖励入账后的稳定回执。
type RoomTurnoverRewardReceipt struct {
TransactionID string
Balance AssetBalance
Amount int64
GrantedAtMS int64
}
// InviteActivityRewardCommand 是 activity-service 邀请活动领奖的 COIN 入账命令。
type InviteActivityRewardCommand struct {
AppCode string
CommandID string
TargetUserID int64
Amount int64
ClaimID string
RewardType string
TierID int64
TierCode string
CycleKey string
ReachedValue int64
Reason string
}
// InviteActivityRewardReceipt 是邀请活动奖励入账后的稳定回执。
type InviteActivityRewardReceipt struct {
TransactionID string
Balance AssetBalance
Amount int64
GrantedAtMS int64
}
// AgencyOpeningRewardCommand 是 activity-service 代理开业流水档位奖励的 COIN 入账命令。
type AgencyOpeningRewardCommand struct {
AppCode string
CommandID string
TargetUserID int64
Amount int64
ApplicationID string
CycleID string
AgencyID int64
RankNo int32
ScoreCoins int64
Reason string
}
// AgencyOpeningRewardReceipt 是代理开业流水档位奖励入账后的稳定回执。
type AgencyOpeningRewardReceipt struct {
TransactionID string
Balance AssetBalance
Amount int64
GrantedAtMS int64
}

View File

@ -0,0 +1,111 @@
package ledger
import (
"strings"
)
// VipRewardItem 是 VIP 资源组权益的轻量展示投影。
type VipRewardItem struct {
ResourceID int64
ResourceCode string
ResourceType string
Name string
Quantity int64
ExpiresAtMS int64
AssetURL string
PreviewURL string
AnimationURL string
}
// VipLevel 是可购买 VIP 等级配置。
type VipLevel struct {
Level int32
Name string
Status string
PriceCoin int64
DurationMS int64
RewardResourceGroupID int64
RequiredRechargeCoinAmount int64
UserRechargeCoinAmount int64
RechargeGateRequired bool
PurchaseLockedReason string
RewardItems []VipRewardItem
CanPurchase bool
SortOrder int32
CreatedAtMS int64
UpdatedAtMS int64
}
// UserVip 是用户当前会员状态。是否有效以 ExpiresAtMS 和当前时间判断。
type UserVip struct {
UserID int64
Level int32
Name string
Active bool
StartedAtMS int64
ExpiresAtMS int64
UpdatedAtMS int64
}
// PurchaseVipCommand 是 App 购买或升级 VIP 的账务命令。
type PurchaseVipCommand struct {
AppCode string
CommandID string
UserID int64
Level int32
}
// PurchaseVipReceipt 是购买 VIP 成功后的稳定回执。
type PurchaseVipReceipt struct {
OrderID string
TransactionID string
Vip UserVip
CoinSpent int64
CoinBalanceAfter int64
RewardItems []VipRewardItem
}
// GrantVipCommand 是活动或后台发放 VIP 的统一入口命令。
type GrantVipCommand struct {
AppCode string
CommandID string
TargetUserID int64
Level int32
GrantSource string
OperatorUserID int64
Reason string
}
// GrantVipReceipt 是赠送 VIP 成功后的稳定回执。
type GrantVipReceipt struct {
TransactionID string
Vip UserVip
RewardItems []VipRewardItem
}
// AdminVipLevelCommand 是后台保存 VIP 等级配置的完整事实输入。
type AdminVipLevelCommand struct {
Level int32
Name string
Status string
PriceCoin int64
DurationMS int64
RewardResourceGroupID int64
RequiredRechargeCoinAmount int64
SortOrder int32
}
func NormalizeVipGrantSource(source string) string {
switch strings.ToLower(strings.TrimSpace(source)) {
case VipGrantSourcePurchase:
return VipGrantSourcePurchase
case VipGrantSourceActivity:
return VipGrantSourceActivity
case VipGrantSourceAdmin:
return VipGrantSourceAdmin
case VipGrantSourceManagerCenter:
return VipGrantSourceManagerCenter
default:
return ""
}
}

View File

@ -0,0 +1,195 @@
package ledger
import (
"strings"
)
// RechargeBill 是充值账单的只读投影;充值事实由 wallet_recharge_records 保存。
type RechargeBill struct {
AppCode string
TransactionID string
CommandID string
RechargeType string
Status string
ExternalRef string
UserID int64
SellerUserID int64
SellerRegionID int64
TargetRegionID int64
PolicyID int64
PolicyVersion string
CurrencyCode string
CoinAmount int64
USDMinorAmount int64
ExchangeCoinAmount int64
ExchangeUSDMinorAmount int64
CreatedAtMS int64
}
// ListRechargeBillsQuery 是后台查询所有充值渠道账单的筛选条件。
type ListRechargeBillsQuery struct {
AppCode string
UserID int64
SellerUserID int64
RegionID int64
RechargeType string
Status string
Keyword string
StartAtMS int64
EndAtMS int64
Page int32
PageSize int32
}
// WalletFeatureFlags 是 App 钱包入口的开关投影;首版由 wallet-service 固定返回,后续可迁移到配置表。
type WalletFeatureFlags struct {
RechargeEnabled bool
DiamondExchangeEnabled bool
}
// WalletOverview 是我的页和钱包首页共同使用的轻量摘要。
type WalletOverview struct {
Balances []AssetBalance
FeatureFlags WalletFeatureFlags
}
// WalletValueSummary 是我的页钱包卡片的最小余额投影。
type WalletValueSummary struct {
CoinAmount int64
UpdatedAtMS int64
}
// GiftWallResource 是礼物墙展示所需的资源快照字段,避免读取接口再依赖资源配置实时状态。
type GiftWallResource struct {
AppCode string
ResourceID int64
ResourceCode string
ResourceType string
Name string
Status string
Grantable bool
GrantStrategy string
WalletAssetType string
WalletAssetAmount int64
UsageScopes []string
AssetURL string
PreviewURL string
AnimationURL string
MetadataJSON string
SortOrder int32
CreatedAtMS int64
UpdatedAtMS int64
}
// GiftWallItem 是当前用户收到礼物的按 gift_id 聚合投影。
type GiftWallItem struct {
GiftID string
GiftName string
ResourceID int64
Resource GiftWallResource
ResourceSnapshotJSON string
GiftTypeCode string
PresentationJSON string
GiftCount int64
TotalValue int64
TotalCoinValue int64
TotalDiamondValue int64
// TotalGiftPoint 是历史礼物墙字段,新送礼不再累加,保留用于旧数据展示兼容。
TotalGiftPoint int64
TotalHeatValue int64
ChargeAssetType string
LastPriceVersion string
FirstReceivedAtMS int64
LastReceivedAtMS int64
SortOrder int32
}
// UserGiftWall 汇总礼物墙首屏需要的全量统计和按礼物类型聚合列表。
type UserGiftWall struct {
Items []GiftWallItem
GiftKindCount int64
GiftTotalCount int64
TotalValue int64
TotalCoinValue int64
TotalDiamondValue int64
// TotalGiftPoint 是历史礼物墙汇总字段,新送礼不再累加,保留用于旧数据展示兼容。
TotalGiftPoint int64
TotalHeatValue int64
}
// DiamondExchangeRule 是钻石兑换金币或余额的固定规则投影。
type DiamondExchangeRule struct {
ExchangeType string
FromAssetType string
ToAssetType string
FromAmount int64
ToAmount int64
Enabled bool
}
// WalletTransaction 是钱包流水页按用户分录查询的投影。
type WalletTransaction struct {
EntryID int64
TransactionID string
BizType string
AssetType string
AvailableDelta int64
FrozenDelta int64
AvailableAfter int64
FrozenAfter int64
CounterpartyUserID int64
RoomID string
CreatedAtMS int64
}
// ListWalletTransactionsQuery 描述 App 钱包流水分页条件。
type ListWalletTransactionsQuery struct {
AppCode string
UserID int64
AssetType string
Page int32
PageSize int32
}
// AssetBalance 是账户当前可用/冻结余额投影。
type AssetBalance struct {
AppCode string
UserID int64
AssetType string
AvailableAmount int64
FrozenAmount int64
Version int64
UpdatedAtMs int64
}
// AdminCreditAssetCommand 是后台手动调账的最小审计命令Amount 为正数入账、负数扣账。
type AdminCreditAssetCommand struct {
AppCode string
CommandID string
TargetUserID int64
AssetType string
Amount int64
OperatorUserID int64
Reason string
EvidenceRef string
}
// ValidAssetType 限定账本允许落账的资产类型,避免写入任意字符串资产。
func ValidAssetType(assetType string) bool {
switch assetType {
case AssetCoin, AssetCoinSellerCoin, AssetRobotCoin, AssetGiftPoint, AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD:
return true
default:
return false
}
}
// ValidSalaryAssetType 只允许身份工资美元钱包进入工资兑换和转币商链路,避免普通金币或币商库存被误扣。
func ValidSalaryAssetType(assetType string) bool {
switch strings.ToUpper(strings.TrimSpace(assetType)) {
case AssetHostSalaryUSD, AssetAgencySalaryUSD, AssetBDSalaryUSD, AssetAdminSalaryUSD:
return true
default:
return false
}
}

View File

@ -0,0 +1,30 @@
package resource
// BadgeGrantOutbox is a wallet resource grant event relayed to activity-service badge display projection.
type BadgeGrantOutbox struct {
AppCode string
EventID string
EventType string
CommandID string
UserID int64
GrantID string
GrantSource string
PayloadJSON string
Status string
AttemptCount int32
FailureReason string
CreatedAtMS int64
UpdatedAtMS int64
Items []BadgeGrantItem
}
// BadgeGrantItem is the badge subset of a resource grant item.
type BadgeGrantItem struct {
ResourceID int64 `json:"resource_id"`
ResourceCode string `json:"resource_code"`
ResourceType string `json:"resource_type"`
Name string `json:"name"`
EntitlementID string `json:"entitlement_id"`
ResourceSnapshotJSON string `json:"resource_snapshot_json"`
MetadataJSON string `json:"metadata_json"`
}

View File

@ -0,0 +1,73 @@
package resource
const (
TypeAvatarFrame = "avatar_frame"
TypeProfileCard = "profile_card"
TypeCoin = "coin"
TypeVehicle = "vehicle"
TypeChatBubble = "chat_bubble"
TypeBadge = "badge"
TypeFloatingScreen = "floating_screen"
TypeGift = "gift"
TypeMicSeatIcon = "mic_seat_icon"
TypeMicSeatAnimation = "mic_seat_animation"
TypeEmojiPack = "emoji_pack"
StatusActive = "active"
StatusDisabled = "disabled"
GrantStrategyWalletCredit = "wallet_credit"
GrantStrategyNewEntitlement = "new_entitlement"
GrantStrategyExtendExpiry = "extend_expiry"
GrantStrategyIncreaseQuantity = "increase_quantity"
GrantStrategySetActiveFlag = "set_active_flag"
GrantSubjectResource = "resource"
GrantSubjectGroup = "resource_group"
GrantSourceAdmin = "admin"
GrantSourceManagerCenter = "manager_center"
GrantSourceGrowthLevel = "growth_level"
GrantSourceAchievement = "achievement"
GrantSourceResourceShop = "resource_shop"
GrantSourceGameRobotInit = "game_robot_init"
GrantStatusDone = "succeeded"
ResultWalletCredit = "wallet_credit"
ResultEntitlement = "entitlement"
GroupItemTypeResource = "resource"
GroupItemTypeWalletAsset = "wallet_asset"
GiftTypeNormal = "normal"
GiftTypeCP = "cp"
GiftTypeLucky = "lucky"
GiftTypeSuperLucky = "super_lucky"
GiftTypeExclusive = "exclusive"
GiftTypeNoble = "noble"
GiftTypeFlag = "flag"
GiftTypeActivity = "activity"
GiftTypeMagic = "magic"
GiftTypeCustom = "custom"
GiftTypeTabKeyNormal = "Gift"
GiftTypeTabKeyCP = "CP"
GiftTypeTabKeyLucky = "Lucky"
GiftTypeTabKeySuperLucky = "Super Lucky"
GiftTypeTabKeyExclusive = "Exclusive"
GiftTypeTabKeyNoble = "Noble"
GiftTypeTabKeyFlag = "Flag"
GiftTypeTabKeyActivity = "Activity"
GiftTypeTabKeyMagic = "Magic"
GiftTypeTabKeyCustom = "Custom"
GiftEffectAnimation = "animation"
GiftEffectMusic = "music"
GiftEffectGlobalBroadcast = "global_broadcast"
PriceTypeCoin = "coin"
PriceTypeFree = "free"
ShopDurationOneDay int32 = 1
ShopDurationThreeDays int32 = 3
ShopDurationSevenDays int32 = 7
)

View File

@ -0,0 +1,55 @@
package resource
type UserResourceEntitlement struct {
AppCode string
EntitlementID string
UserID int64
ResourceID int64
Resource Resource
Status string
Quantity int64
RemainingQuantity int64
EffectiveAtMS int64
ExpiresAtMS int64
SourceGrantID string
CreatedAtMS int64
UpdatedAtMS int64
Equipped bool
}
type ListUserResourcesQuery struct {
AppCode string
UserID int64
ResourceType string
ActiveOnly bool
}
type EquipUserResourceCommand struct {
AppCode string
UserID int64
ResourceID int64
EntitlementID string
}
type UnequipUserResourceCommand struct {
AppCode string
UserID int64
ResourceType string
}
type UnequipUserResourceResult struct {
ResourceType string
Unequipped bool
UpdatedAtMS int64
}
type BatchGetUserEquippedResourcesQuery struct {
AppCode string
UserIDs []int64
ResourceTypes []string
}
type UserEquippedResources struct {
UserID int64
Resources []UserResourceEntitlement
}

View File

@ -0,0 +1,168 @@
package resource
import (
"strings"
"unicode/utf8"
)
type GiftConfig struct {
AppCode string
GiftID string
ResourceID int64
Resource Resource
Status string
Name string
SortOrder int32
PresentationJSON string
PriceVersion string
CoinPrice int64
// GiftPointAmount 是历史字段;送礼结算不再读取,新配置固定为 0。
GiftPointAmount int64
HeatValue int64
GiftTypeCode string
// CPRelationType 只在 gift_type_code=cp 的礼物上使用,值为 cp、brother 或 sister。
CPRelationType string
ChargeAssetType string
EffectiveFromMS int64
EffectiveToMS int64
EffectTypes []string
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
RegionIDs []int64
}
type GiftTypeConfig struct {
AppCode string
TypeCode string
Name string
TabKey string
Status string
SortOrder int32
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
}
type ListGiftConfigsQuery struct {
AppCode string
Status string
Keyword string
GiftTypeCode string
Page int32
PageSize int32
ActiveOnly bool
RegionID int64
FilterRegion bool
}
type ListGiftTypeConfigsQuery struct {
AppCode string
Status string
ActiveOnly bool
}
type GiftConfigCommand struct {
AppCode string
GiftID string
ResourceID int64
Status string
Name string
SortOrder int32
PresentationJSON string
PriceVersion string
CoinPrice int64
// GiftPointAmount 是历史字段;保存礼物价格时固定为 0业务计算只读 CoinPrice。
GiftPointAmount int64
HeatValue int64
EffectiveAtMS int64
GiftTypeCode string
// CPRelationType 会写入 presentation_json避免新增 gift_configs 表字段。
CPRelationType string
ChargeAssetType string
EffectiveFromMS int64
EffectiveToMS int64
EffectTypes []string
OperatorUserID int64
RegionIDs []int64
}
type GiftTypeConfigCommand struct {
AppCode string
TypeCode string
Name string
TabKey string
Status string
SortOrder int32
OperatorUserID int64
}
func NormalizeGiftTypeCode(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, "-", "_")
if value == "" {
return GiftTypeNormal
}
return value
}
func ValidGiftTypeCode(value string) bool {
value = NormalizeGiftTypeCode(value)
if value == "" || len(value) > 32 {
return false
}
for index, char := range value {
if index == 0 && (char < 'a' || char > 'z') {
return false
}
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' {
continue
}
return false
}
return true
}
func NormalizeGiftTypeTabKey(value string) string {
return strings.TrimSpace(value)
}
func ValidGiftTypeTabKey(value string) bool {
value = NormalizeGiftTypeTabKey(value)
return value != "" && utf8.RuneCountInString(value) <= 32
}
func DefaultGiftTypeConfigs(appCode string) []GiftTypeConfig {
appCode = strings.TrimSpace(appCode)
defaults := []GiftTypeConfig{
{TypeCode: GiftTypeNormal, Name: "普通礼物", TabKey: GiftTypeTabKeyNormal, Status: StatusActive, SortOrder: 10},
{TypeCode: GiftTypeCP, Name: "CP礼物", TabKey: GiftTypeTabKeyCP, Status: StatusActive, SortOrder: 20},
{TypeCode: GiftTypeLucky, Name: "幸运礼物", TabKey: GiftTypeTabKeyLucky, Status: StatusActive, SortOrder: 30},
{TypeCode: GiftTypeSuperLucky, Name: "超级幸运礼物", TabKey: GiftTypeTabKeySuperLucky, Status: StatusActive, SortOrder: 40},
{TypeCode: GiftTypeExclusive, Name: "专属礼物", TabKey: GiftTypeTabKeyExclusive, Status: StatusActive, SortOrder: 50},
{TypeCode: GiftTypeNoble, Name: "贵族礼物", TabKey: GiftTypeTabKeyNoble, Status: StatusActive, SortOrder: 60},
{TypeCode: GiftTypeFlag, Name: "国旗礼物", TabKey: GiftTypeTabKeyFlag, Status: StatusActive, SortOrder: 70},
{TypeCode: GiftTypeActivity, Name: "活动礼物", TabKey: GiftTypeTabKeyActivity, Status: StatusActive, SortOrder: 80},
{TypeCode: GiftTypeMagic, Name: "魔法礼物", TabKey: GiftTypeTabKeyMagic, Status: StatusActive, SortOrder: 90},
{TypeCode: GiftTypeCustom, Name: "定制礼物", TabKey: GiftTypeTabKeyCustom, Status: StatusActive, SortOrder: 100},
}
for index := range defaults {
defaults[index].AppCode = appCode
}
return defaults
}
func NormalizeGiftEffectType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func ValidGiftEffectType(value string) bool {
switch NormalizeGiftEffectType(value) {
case GiftEffectAnimation, GiftEffectMusic, GiftEffectGlobalBroadcast:
return true
default:
return false
}
}

View File

@ -0,0 +1,60 @@
package resource
type ResourceGrantItem struct {
GrantItemID int64
GrantID string
ResourceID int64
ResourceSnapshotJSON string
Quantity int64
DurationMS int64
ResultType string
WalletTransactionID string
EntitlementID string
CreatedAtMS int64
}
type ResourceGrant struct {
AppCode string
GrantID string
CommandID string
TargetUserID int64
GrantSource string
GrantSubjectType string
GrantSubjectID string
Status string
Reason string
OperatorUserID int64
Items []ResourceGrantItem
CreatedAtMS int64
UpdatedAtMS int64
}
type GrantResourceCommand struct {
AppCode string
CommandID string
TargetUserID int64
ResourceID int64
Quantity int64
DurationMS int64
Reason string
OperatorUserID int64
GrantSource string
}
type GrantResourceGroupCommand struct {
AppCode string
CommandID string
TargetUserID int64
GroupID int64
Reason string
OperatorUserID int64
GrantSource string
}
type ListResourceGrantsQuery struct {
AppCode string
TargetUserID int64
Status string
Page int32
PageSize int32
}

View File

@ -0,0 +1,92 @@
package resource
import (
"strings"
)
type ResourceGroupItem struct {
GroupItemID int64
GroupID int64
ItemType string
ResourceID int64
Resource Resource
WalletAssetType string
WalletAssetAmount int64
Quantity int64
DurationMS int64
SortOrder int32
CreatedAtMS int64
UpdatedAtMS int64
}
type ResourceGroup struct {
AppCode string
GroupID int64
GroupCode string
Name string
Status string
Description string
SortOrder int32
Items []ResourceGroupItem
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
}
type ResourceGroupItemInput struct {
ItemType string
ResourceID int64
WalletAssetType string
WalletAssetAmount int64
Quantity int64
DurationMS int64
SortOrder int32
}
type ListResourceGroupsQuery struct {
AppCode string
Status string
Keyword string
Page int32
PageSize int32
ActiveOnly bool
}
type ResourceGroupCommand struct {
AppCode string
GroupID int64
GroupCode string
Name string
Status string
Description string
SortOrder int32
Items []ResourceGroupItemInput
OperatorUserID int64
}
func ValidGrantStrategy(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case GrantStrategyWalletCredit, GrantStrategyNewEntitlement, GrantStrategyExtendExpiry, GrantStrategyIncreaseQuantity, GrantStrategySetActiveFlag:
return true
default:
return false
}
}
func NormalizeGroupItemType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return GroupItemTypeResource
}
return value
}
func ValidGroupItemType(value string) bool {
switch NormalizeGroupItemType(value) {
case GroupItemTypeResource, GroupItemTypeWalletAsset:
return true
default:
return false
}
}

View File

@ -1,82 +1,8 @@
package resource
import (
"strings"
"unicode/utf8"
"hyapp/services/wallet-service/internal/domain/ledger"
)
const (
TypeAvatarFrame = "avatar_frame"
TypeProfileCard = "profile_card"
TypeCoin = "coin"
TypeVehicle = "vehicle"
TypeChatBubble = "chat_bubble"
TypeBadge = "badge"
TypeFloatingScreen = "floating_screen"
TypeGift = "gift"
TypeMicSeatIcon = "mic_seat_icon"
TypeMicSeatAnimation = "mic_seat_animation"
TypeEmojiPack = "emoji_pack"
StatusActive = "active"
StatusDisabled = "disabled"
GrantStrategyWalletCredit = "wallet_credit"
GrantStrategyNewEntitlement = "new_entitlement"
GrantStrategyExtendExpiry = "extend_expiry"
GrantStrategyIncreaseQuantity = "increase_quantity"
GrantStrategySetActiveFlag = "set_active_flag"
GrantSubjectResource = "resource"
GrantSubjectGroup = "resource_group"
GrantSourceAdmin = "admin"
GrantSourceManagerCenter = "manager_center"
GrantSourceGrowthLevel = "growth_level"
GrantSourceAchievement = "achievement"
GrantSourceResourceShop = "resource_shop"
GrantSourceGameRobotInit = "game_robot_init"
GrantStatusDone = "succeeded"
ResultWalletCredit = "wallet_credit"
ResultEntitlement = "entitlement"
GroupItemTypeResource = "resource"
GroupItemTypeWalletAsset = "wallet_asset"
GiftTypeNormal = "normal"
GiftTypeCP = "cp"
GiftTypeLucky = "lucky"
GiftTypeSuperLucky = "super_lucky"
GiftTypeExclusive = "exclusive"
GiftTypeNoble = "noble"
GiftTypeFlag = "flag"
GiftTypeActivity = "activity"
GiftTypeMagic = "magic"
GiftTypeCustom = "custom"
GiftTypeTabKeyNormal = "Gift"
GiftTypeTabKeyCP = "CP"
GiftTypeTabKeyLucky = "Lucky"
GiftTypeTabKeySuperLucky = "Super Lucky"
GiftTypeTabKeyExclusive = "Exclusive"
GiftTypeTabKeyNoble = "Noble"
GiftTypeTabKeyFlag = "Flag"
GiftTypeTabKeyActivity = "Activity"
GiftTypeTabKeyMagic = "Magic"
GiftTypeTabKeyCustom = "Custom"
GiftEffectAnimation = "animation"
GiftEffectMusic = "music"
GiftEffectGlobalBroadcast = "global_broadcast"
PriceTypeCoin = "coin"
PriceTypeFree = "free"
ShopDurationOneDay int32 = 1
ShopDurationThreeDays int32 = 3
ShopDurationSevenDays int32 = 7
"strings"
)
type Resource struct {
@ -107,160 +33,6 @@ type Resource struct {
ManagerGrantEnabled bool
}
type ResourceGroupItem struct {
GroupItemID int64
GroupID int64
ItemType string
ResourceID int64
Resource Resource
WalletAssetType string
WalletAssetAmount int64
Quantity int64
DurationMS int64
SortOrder int32
CreatedAtMS int64
UpdatedAtMS int64
}
type ResourceGroup struct {
AppCode string
GroupID int64
GroupCode string
Name string
Status string
Description string
SortOrder int32
Items []ResourceGroupItem
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
}
type GiftConfig struct {
AppCode string
GiftID string
ResourceID int64
Resource Resource
Status string
Name string
SortOrder int32
PresentationJSON string
PriceVersion string
CoinPrice int64
// GiftPointAmount 是历史字段;送礼结算不再读取,新配置固定为 0。
GiftPointAmount int64
HeatValue int64
GiftTypeCode string
// CPRelationType 只在 gift_type_code=cp 的礼物上使用,值为 cp、brother 或 sister。
CPRelationType string
ChargeAssetType string
EffectiveFromMS int64
EffectiveToMS int64
EffectTypes []string
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
RegionIDs []int64
}
type GiftTypeConfig struct {
AppCode string
TypeCode string
Name string
TabKey string
Status string
SortOrder int32
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
}
type ResourceShopItem struct {
AppCode string
ShopItemID int64
ResourceID int64
Resource Resource
Status string
DurationDays int32
PriceType string
CoinPrice int64
EffectiveFromMS int64
EffectiveToMS int64
SortOrder int32
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
}
type ResourceShopPurchaseOrder struct {
AppCode string
OrderID string
CommandID string
UserID int64
ShopItemID int64
ResourceID int64
Resource Resource
ResourceSnapshotJSON string
DurationDays int32
PriceCoin int64
Status string
WalletTransactionID string
ResourceGrantID string
EntitlementID string
CreatedAtMS int64
UpdatedAtMS int64
}
type UserResourceEntitlement struct {
AppCode string
EntitlementID string
UserID int64
ResourceID int64
Resource Resource
Status string
Quantity int64
RemainingQuantity int64
EffectiveAtMS int64
ExpiresAtMS int64
SourceGrantID string
CreatedAtMS int64
UpdatedAtMS int64
Equipped bool
}
type ResourceGrantItem struct {
GrantItemID int64
GrantID string
ResourceID int64
ResourceSnapshotJSON string
Quantity int64
DurationMS int64
ResultType string
WalletTransactionID string
EntitlementID string
CreatedAtMS int64
}
type ResourceGrant struct {
AppCode string
GrantID string
CommandID string
TargetUserID int64
GrantSource string
GrantSubjectType string
GrantSubjectID string
Status string
Reason string
OperatorUserID int64
Items []ResourceGrantItem
CreatedAtMS int64
UpdatedAtMS int64
}
type ListResourcesQuery struct {
AppCode string
ResourceType string
@ -305,246 +77,6 @@ type StatusCommand struct {
OperatorUserID int64
}
type ResourceGroupItemInput struct {
ItemType string
ResourceID int64
WalletAssetType string
WalletAssetAmount int64
Quantity int64
DurationMS int64
SortOrder int32
}
type ListResourceGroupsQuery struct {
AppCode string
Status string
Keyword string
Page int32
PageSize int32
ActiveOnly bool
}
type ResourceGroupCommand struct {
AppCode string
GroupID int64
GroupCode string
Name string
Status string
Description string
SortOrder int32
Items []ResourceGroupItemInput
OperatorUserID int64
}
type ListGiftConfigsQuery struct {
AppCode string
Status string
Keyword string
GiftTypeCode string
Page int32
PageSize int32
ActiveOnly bool
RegionID int64
FilterRegion bool
}
type ListGiftTypeConfigsQuery struct {
AppCode string
Status string
ActiveOnly bool
}
type GiftConfigCommand struct {
AppCode string
GiftID string
ResourceID int64
Status string
Name string
SortOrder int32
PresentationJSON string
PriceVersion string
CoinPrice int64
// GiftPointAmount 是历史字段;保存礼物价格时固定为 0业务计算只读 CoinPrice。
GiftPointAmount int64
HeatValue int64
EffectiveAtMS int64
GiftTypeCode string
// CPRelationType 会写入 presentation_json避免新增 gift_configs 表字段。
CPRelationType string
ChargeAssetType string
EffectiveFromMS int64
EffectiveToMS int64
EffectTypes []string
OperatorUserID int64
RegionIDs []int64
}
type GiftTypeConfigCommand struct {
AppCode string
TypeCode string
Name string
TabKey string
Status string
SortOrder int32
OperatorUserID int64
}
type GrantResourceCommand struct {
AppCode string
CommandID string
TargetUserID int64
ResourceID int64
Quantity int64
DurationMS int64
Reason string
OperatorUserID int64
GrantSource string
}
type GrantResourceGroupCommand struct {
AppCode string
CommandID string
TargetUserID int64
GroupID int64
Reason string
OperatorUserID int64
GrantSource string
}
type ListUserResourcesQuery struct {
AppCode string
UserID int64
ResourceType string
ActiveOnly bool
}
type EquipUserResourceCommand struct {
AppCode string
UserID int64
ResourceID int64
EntitlementID string
}
type UnequipUserResourceCommand struct {
AppCode string
UserID int64
ResourceType string
}
type UnequipUserResourceResult struct {
ResourceType string
Unequipped bool
UpdatedAtMS int64
}
type BatchGetUserEquippedResourcesQuery struct {
AppCode string
UserIDs []int64
ResourceTypes []string
}
type UserEquippedResources struct {
UserID int64
Resources []UserResourceEntitlement
}
type ListResourceGrantsQuery struct {
AppCode string
TargetUserID int64
Status string
Page int32
PageSize int32
}
type ResourceShopItemInput struct {
ShopItemID int64
ResourceID int64
Status string
DurationDays int32
EffectiveFromMS int64
EffectiveToMS int64
SortOrder int32
}
type ListResourceShopItemsQuery struct {
AppCode string
ResourceType string
Status string
Keyword string
Page int32
PageSize int32
ActiveOnly bool
}
type ListResourceShopPurchaseOrdersQuery struct {
AppCode string
UserID int64
ResourceType string
Status string
Keyword string
Page int32
PageSize int32
}
type ResourceShopItemsCommand struct {
AppCode string
Items []ResourceShopItemInput
OperatorUserID int64
}
type ResourceShopItemStatusCommand struct {
AppCode string
ShopItemID int64
Status string
OperatorUserID int64
}
type ResourceShopPurchaseCommand struct {
AppCode string
CommandID string
UserID int64
ShopItemID int64
}
type ResourceShopPurchaseReceipt struct {
OrderID string
TransactionID string
ShopItem ResourceShopItem
Resource UserResourceEntitlement
Balance ledger.AssetBalance
CoinSpent int64
GrantID string
}
// BadgeGrantOutbox is a wallet resource grant event relayed to activity-service badge display projection.
type BadgeGrantOutbox struct {
AppCode string
EventID string
EventType string
CommandID string
UserID int64
GrantID string
GrantSource string
PayloadJSON string
Status string
AttemptCount int32
FailureReason string
CreatedAtMS int64
UpdatedAtMS int64
Items []BadgeGrantItem
}
// BadgeGrantItem is the badge subset of a resource grant item.
type BadgeGrantItem struct {
ResourceID int64 `json:"resource_id"`
ResourceCode string `json:"resource_code"`
ResourceType string `json:"resource_type"`
Name string `json:"name"`
EntitlementID string `json:"entitlement_id"`
ResourceSnapshotJSON string `json:"resource_snapshot_json"`
MetadataJSON string `json:"metadata_json"`
}
func NormalizeStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
@ -570,59 +102,6 @@ func ValidResourceType(value string) bool {
}
}
func ResourceTypeSellableInShop(value string) bool {
switch NormalizeResourceType(value) {
case TypeAvatarFrame, TypeProfileCard, TypeVehicle, TypeChatBubble, TypeBadge, TypeMicSeatIcon, TypeMicSeatAnimation:
return true
default:
return false
}
}
func ValidShopDurationDays(value int32) bool {
switch value {
case ShopDurationOneDay, ShopDurationThreeDays, ShopDurationSevenDays:
return true
default:
return false
}
}
func ValidStatus(value string) bool {
switch NormalizeStatus(value) {
case StatusActive, StatusDisabled:
return true
default:
return false
}
}
func ValidGrantStrategy(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case GrantStrategyWalletCredit, GrantStrategyNewEntitlement, GrantStrategyExtendExpiry, GrantStrategyIncreaseQuantity, GrantStrategySetActiveFlag:
return true
default:
return false
}
}
func NormalizeGroupItemType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return GroupItemTypeResource
}
return value
}
func ValidGroupItemType(value string) bool {
switch NormalizeGroupItemType(value) {
case GroupItemTypeResource, GroupItemTypeWalletAsset:
return true
default:
return false
}
}
func NormalizeResourceType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
@ -635,87 +114,6 @@ func NormalizeWalletAssetType(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}
func NormalizePriceType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func ValidPriceType(value string) bool {
switch NormalizePriceType(value) {
case PriceTypeCoin, PriceTypeFree:
return true
default:
return false
}
}
func NormalizeGiftTypeCode(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.ReplaceAll(value, "-", "_")
if value == "" {
return GiftTypeNormal
}
return value
}
func ValidGiftTypeCode(value string) bool {
value = NormalizeGiftTypeCode(value)
if value == "" || len(value) > 32 {
return false
}
for index, char := range value {
if index == 0 && (char < 'a' || char > 'z') {
return false
}
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' {
continue
}
return false
}
return true
}
func NormalizeGiftTypeTabKey(value string) string {
return strings.TrimSpace(value)
}
func ValidGiftTypeTabKey(value string) bool {
value = NormalizeGiftTypeTabKey(value)
return value != "" && utf8.RuneCountInString(value) <= 32
}
func DefaultGiftTypeConfigs(appCode string) []GiftTypeConfig {
appCode = strings.TrimSpace(appCode)
defaults := []GiftTypeConfig{
{TypeCode: GiftTypeNormal, Name: "普通礼物", TabKey: GiftTypeTabKeyNormal, Status: StatusActive, SortOrder: 10},
{TypeCode: GiftTypeCP, Name: "CP礼物", TabKey: GiftTypeTabKeyCP, Status: StatusActive, SortOrder: 20},
{TypeCode: GiftTypeLucky, Name: "幸运礼物", TabKey: GiftTypeTabKeyLucky, Status: StatusActive, SortOrder: 30},
{TypeCode: GiftTypeSuperLucky, Name: "超级幸运礼物", TabKey: GiftTypeTabKeySuperLucky, Status: StatusActive, SortOrder: 40},
{TypeCode: GiftTypeExclusive, Name: "专属礼物", TabKey: GiftTypeTabKeyExclusive, Status: StatusActive, SortOrder: 50},
{TypeCode: GiftTypeNoble, Name: "贵族礼物", TabKey: GiftTypeTabKeyNoble, Status: StatusActive, SortOrder: 60},
{TypeCode: GiftTypeFlag, Name: "国旗礼物", TabKey: GiftTypeTabKeyFlag, Status: StatusActive, SortOrder: 70},
{TypeCode: GiftTypeActivity, Name: "活动礼物", TabKey: GiftTypeTabKeyActivity, Status: StatusActive, SortOrder: 80},
{TypeCode: GiftTypeMagic, Name: "魔法礼物", TabKey: GiftTypeTabKeyMagic, Status: StatusActive, SortOrder: 90},
{TypeCode: GiftTypeCustom, Name: "定制礼物", TabKey: GiftTypeTabKeyCustom, Status: StatusActive, SortOrder: 100},
}
for index := range defaults {
defaults[index].AppCode = appCode
}
return defaults
}
func NormalizeGiftEffectType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func ValidGiftEffectType(value string) bool {
switch NormalizeGiftEffectType(value) {
case GiftEffectAnimation, GiftEffectMusic, GiftEffectGlobalBroadcast:
return true
default:
return false
}
}
func IsWalletCredit(resource Resource) bool {
return NormalizeGrantStrategy(resource.GrantStrategy) == GrantStrategyWalletCredit
}

View File

@ -0,0 +1,134 @@
package resource
import (
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
)
type ResourceShopItem struct {
AppCode string
ShopItemID int64
ResourceID int64
Resource Resource
Status string
DurationDays int32
PriceType string
CoinPrice int64
EffectiveFromMS int64
EffectiveToMS int64
SortOrder int32
CreatedByUserID int64
UpdatedByUserID int64
CreatedAtMS int64
UpdatedAtMS int64
}
type ResourceShopPurchaseOrder struct {
AppCode string
OrderID string
CommandID string
UserID int64
ShopItemID int64
ResourceID int64
Resource Resource
ResourceSnapshotJSON string
DurationDays int32
PriceCoin int64
Status string
WalletTransactionID string
ResourceGrantID string
EntitlementID string
CreatedAtMS int64
UpdatedAtMS int64
}
type ResourceShopItemInput struct {
ShopItemID int64
ResourceID int64
Status string
DurationDays int32
EffectiveFromMS int64
EffectiveToMS int64
SortOrder int32
}
type ListResourceShopItemsQuery struct {
AppCode string
ResourceType string
Status string
Keyword string
Page int32
PageSize int32
ActiveOnly bool
}
type ListResourceShopPurchaseOrdersQuery struct {
AppCode string
UserID int64
ResourceType string
Status string
Keyword string
Page int32
PageSize int32
}
type ResourceShopItemsCommand struct {
AppCode string
Items []ResourceShopItemInput
OperatorUserID int64
}
type ResourceShopItemStatusCommand struct {
AppCode string
ShopItemID int64
Status string
OperatorUserID int64
}
type ResourceShopPurchaseCommand struct {
AppCode string
CommandID string
UserID int64
ShopItemID int64
}
type ResourceShopPurchaseReceipt struct {
OrderID string
TransactionID string
ShopItem ResourceShopItem
Resource UserResourceEntitlement
Balance ledger.AssetBalance
CoinSpent int64
GrantID string
}
func ResourceTypeSellableInShop(value string) bool {
switch NormalizeResourceType(value) {
case TypeAvatarFrame, TypeProfileCard, TypeVehicle, TypeChatBubble, TypeBadge, TypeMicSeatIcon, TypeMicSeatAnimation:
return true
default:
return false
}
}
func ValidShopDurationDays(value int32) bool {
switch value {
case ShopDurationOneDay, ShopDurationThreeDays, ShopDurationSevenDays:
return true
default:
return false
}
}
func NormalizePriceType(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func ValidPriceType(value string) bool {
switch NormalizePriceType(value) {
case PriceTypeCoin, PriceTypeFree:
return true
default:
return false
}
}

View File

@ -0,0 +1,10 @@
package resource
func ValidStatus(value string) bool {
switch NormalizeStatus(value) {
case StatusActive, StatusDisabled:
return true
default:
return false
}
}

View File

@ -0,0 +1,30 @@
package wallet
import (
"context"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
)
// AdminCreditAsset 执行后台手动调账amount 为正数入账、负数扣账,审计字段必须随交易一起落库。
func (s *Service) AdminCreditAsset(ctx context.Context, command ledger.AdminCreditAssetCommand) (ledger.AssetBalance, string, error) {
if command.CommandID == "" || command.TargetUserID <= 0 || command.OperatorUserID <= 0 || command.Amount == 0 {
return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "admin adjustment command is incomplete")
}
command.AssetType = strings.ToUpper(strings.TrimSpace(command.AssetType))
if !ledger.ValidAssetType(command.AssetType) {
return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "asset_type is invalid")
}
if command.Reason == "" || command.EvidenceRef == "" {
return ledger.AssetBalance{}, "", xerr.New(xerr.InvalidArgument, "reason and evidence_ref are required")
}
if s.repository == nil {
return ledger.AssetBalance{}, "", xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.AdminCreditAsset(ctx, command)
}

View File

@ -0,0 +1,98 @@
package wallet
import (
"context"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
)
// GetBalances 返回用户多资产余额;鉴权范围由 gateway 或后台入口控制。
func (s *Service) GetBalances(ctx context.Context, userID int64, assetTypes []string) ([]ledger.AssetBalance, error) {
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
normalized := make([]string, 0, len(assetTypes))
for _, assetType := range assetTypes {
assetType = strings.ToUpper(strings.TrimSpace(assetType))
if assetType == "" {
continue
}
if !ledger.ValidAssetType(assetType) {
return nil, xerr.New(xerr.InvalidArgument, "asset_type is invalid")
}
normalized = append(normalized, assetType)
}
return s.repository.GetBalances(ctx, userID, normalized)
}
// GetWalletOverview 返回钱包首页摘要,余额和能力开关都由 wallet-service 统一给出。
func (s *Service) GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) {
if userID <= 0 {
return ledger.WalletOverview{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return ledger.WalletOverview{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.GetWalletOverview(ctx, userID)
}
// GetWalletValueSummary 返回我的页钱包卡片摘要,只读取 COIN 账户。
func (s *Service) GetWalletValueSummary(ctx context.Context, userID int64) (ledger.WalletValueSummary, error) {
if userID <= 0 {
return ledger.WalletValueSummary{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return ledger.WalletValueSummary{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.GetWalletValueSummary(ctx, userID)
}
// GetUserGiftWall 返回用户收礼聚合投影;收礼事实只来自成功送礼账务,不读取房间运行态。
func (s *Service) GetUserGiftWall(ctx context.Context, userID int64) (ledger.UserGiftWall, error) {
if userID <= 0 {
return ledger.UserGiftWall{}, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return ledger.UserGiftWall{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.GetUserGiftWall(ctx, userID)
}
// GetDiamondExchangeConfig 返回当前 App 支持的钻石兑换规则。
func (s *Service) GetDiamondExchangeConfig(ctx context.Context, userID int64) ([]ledger.DiamondExchangeRule, error) {
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.FromContext(ctx))
return s.repository.GetDiamondExchangeConfig(ctx, userID)
}
// ListWalletTransactions 返回当前用户钱包流水,避免我的页首屏扫描流水表。
func (s *Service) ListWalletTransactions(ctx context.Context, query ledger.ListWalletTransactionsQuery) ([]ledger.WalletTransaction, int64, error) {
if query.UserID <= 0 {
return nil, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
}
query.AssetType = strings.ToUpper(strings.TrimSpace(query.AssetType))
if query.AssetType != "" && !ledger.ValidAssetType(query.AssetType) {
return nil, 0, xerr.New(xerr.InvalidArgument, "asset_type is invalid")
}
if s.repository == nil {
return nil, 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.ListWalletTransactions(ctx, query)
}

View File

@ -0,0 +1,151 @@
package wallet
import (
"context"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
)
// 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 command.SellerCountryID <= 0 || command.SellerRegionID <= 0 {
return ledger.CoinSellerStockCreditReceipt{}, xerr.New(xerr.InvalidArgument, "seller country and region are required")
}
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 {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "coin seller transfer command is incomplete")
}
if command.SellerRegionID <= 0 || command.TargetRegionID <= 0 {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "seller and target region are required")
}
if command.TargetCountryID <= 0 {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "target country is required")
}
if command.SellerRegionID != command.TargetRegionID {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.Conflict, "seller and target region must match")
}
if strings.TrimSpace(command.Reason) == "" {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.InvalidArgument, "reason is required")
}
if s.repository == nil {
return ledger.CoinSellerTransferReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.Reason = strings.TrimSpace(command.Reason)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.TransferCoinFromSeller(ctx, command)
}
// ListCoinSellerSalaryExchangeRateTiers 返回指定区域的工资转币商比例区间gateway 用它给 H5 展示预计到账金币。
func (s *Service) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) {
if regionID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "region_id is required")
}
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
appCode = appcode.Normalize(appCode)
ctx = appcode.WithContext(ctx, appCode)
return s.repository.ListCoinSellerSalaryExchangeRateTiers(ctx, appCode, regionID, includeDisabled)
}
// ExchangeSalaryToCoin 校验工资兑换命令;身份归属由 gateway 校验wallet 只接受明确的工资资产和正向美分金额。
func (s *Service) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) {
if command.CommandID == "" || command.UserID <= 0 || command.SalaryUSDMinor <= 0 {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary exchange command is incomplete")
}
command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType))
if !ledger.ValidSalaryAssetType(command.SalaryAssetType) {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary_asset_type is invalid")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
command.Reason = "salary exchange"
}
if len(command.CommandID) > 128 || len(command.Reason) > 512 {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.InvalidArgument, "salary exchange text fields are too long")
}
if s.repository == nil {
return ledger.SalaryExchangeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.ExchangeSalaryToCoin(ctx, command)
}
// TransferSalaryToCoinSeller 校验工资转币商命令;比例区间命中和双边入账由 repository 在同一事务内完成。
func (s *Service) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) {
if command.CommandID == "" || command.SourceUserID <= 0 || command.SellerUserID <= 0 || command.SalaryUSDMinor <= 0 {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary transfer command is incomplete")
}
if command.RegionID <= 0 {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "region_id is required")
}
command.SalaryAssetType = strings.ToUpper(strings.TrimSpace(command.SalaryAssetType))
if !ledger.ValidSalaryAssetType(command.SalaryAssetType) {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary_asset_type is invalid")
}
command.Reason = strings.TrimSpace(command.Reason)
if command.Reason == "" {
command.Reason = "salary transfer"
}
if len(command.CommandID) > 128 || len(command.Reason) > 512 {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.InvalidArgument, "salary transfer text fields are too long")
}
if s.repository == nil {
return ledger.SalaryTransferToCoinSellerReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.TransferSalaryToCoinSeller(ctx, command)
}

View File

@ -0,0 +1,36 @@
package wallet
import "hyapp/services/wallet-service/internal/service/wallet/ports"
// Repository 是旧门面包暴露的仓储端口别名;新用例代码应直接依赖 ports.Repository。
type Repository = ports.Repository
// ActivityBadgeClient 是旧门面包暴露的 activity client 端口别名。
type ActivityBadgeClient = ports.ActivityBadgeClient
// GooglePlayClient 是旧门面包暴露的 Google Play client 端口别名。
type GooglePlayClient = ports.GooglePlayClient
// MifaPayClient 是旧门面包暴露的 MiFaPay client 端口别名。
type MifaPayClient = ports.MifaPayClient
// V5PayClient 是旧门面包暴露的 V5Pay client 端口别名。
type V5PayClient = ports.V5PayClient
// TronUSDTClient 是旧门面包暴露的 TRON USDT client 端口别名。
type TronUSDTClient = ports.TronUSDTClient
type MifaPayCreateOrderRequest = ports.MifaPayCreateOrderRequest
type MifaPayCreateOrderResponse = ports.MifaPayCreateOrderResponse
type MifaPayQueryOrderRequest = ports.MifaPayQueryOrderRequest
type MifaPayQueryOrderResponse = ports.MifaPayQueryOrderResponse
type MifaPayNotifyData = ports.MifaPayNotifyData
type V5PayCreateOrderRequest = ports.V5PayCreateOrderRequest
type V5PayCreateOrderResponse = ports.V5PayCreateOrderResponse
type V5PayQueryOrderRequest = ports.V5PayQueryOrderRequest
type V5PayQueryOrderResponse = ports.V5PayQueryOrderResponse
type V5PayNotifyData = ports.V5PayNotifyData
type V5PayProductTypesRequest = ports.V5PayProductTypesRequest
type V5PayProductType = ports.V5PayProductType
type V5PayProductTypesResponse = ports.V5PayProductTypesResponse

View File

@ -1,952 +0,0 @@
package wallet
import (
"context"
"encoding/json"
"errors"
"math"
"net/url"
"strconv"
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
)
// ExternalRechargeConfig 保存 H5 外部充值需要公开给业务链路的运行配置。
type ExternalRechargeConfig struct {
USDTTRC20Enabled bool
USDTTRC20Address string
MifaPayNotifyURL string
MifaPayReturnURL string
V5PayNotifyURL string
V5PayReturnURL string
}
// MifaPayCreateOrderRequest 是 wallet-service 传给 MiFaPay client 的已校验下单快照。
type MifaPayCreateOrderRequest struct {
OrderID string
TargetUserID int64
ProductName string
CoinAmount int64
AmountMinor int64
CurrencyCode string
CountryCode string
PayWay string
PayType string
NotifyURL string
ReturnURL string
ClientIP string
Language string
ProviderAmountMinor int64
PayerName string
PayerAccount string
PayerEmail string
}
type MifaPayCreateOrderResponse struct {
OrderID string
ProviderOrderID string
PayURL string
RawJSON string
}
type MifaPayQueryOrderRequest struct {
OrderID string
ProviderOrderID string
OrderCreatedAtMS int64
}
type MifaPayQueryOrderResponse struct {
OrderID string
ProviderOrderID string
Status string
Amount string
Currency string
PayType string
RawJSON string
}
type MifaPayNotifyData struct {
OrderID string
ProviderOrderID string
OrderStatus string
Amount string
Currency string
PayType string
RawJSON string
}
// V5PayCreateOrderRequest 是 wallet-service 传给 V5Pay client 的已校验下单快照。
type V5PayCreateOrderRequest struct {
OrderID string
TargetUserID int64
ProductName string
CoinAmount int64
AmountMinor int64
CurrencyCode string
CountryCode string
ProductType string
NotifyURL string
ReturnURL string
Language string
ProviderAmountMinor int64
PayerName string
PayerAccount string
PayerEmail string
}
type V5PayCreateOrderResponse struct {
OrderID string
ProviderOrderID string
PayURL string
RawJSON string
}
type V5PayQueryOrderRequest struct {
OrderID string
ProviderOrderID string
CountryCode string
CurrencyCode string
}
type V5PayQueryOrderResponse struct {
OrderID string
ProviderOrderID string
Status string
Amount string
Currency string
ProductType string
RawJSON string
}
type V5PayNotifyData struct {
OrderID string
ProviderOrderID string
OrderStatus string
Amount string
Currency string
ProductType string
RawJSON string
}
type V5PayProductTypesRequest struct {
CountryCode string
CurrencyCode string
}
type V5PayProductType struct {
ProductType string
ProductName string
Currency string
SupportCashierMode int
}
type V5PayProductTypesResponse struct {
PayinList []V5PayProductType
RawJSON string
}
// MifaPayClient 隔离 MiFaPay RSA 签名、验签和 HTTP 协议细节service 只处理订单状态。
type MifaPayClient interface {
CreateOrder(ctx context.Context, req MifaPayCreateOrderRequest) (MifaPayCreateOrderResponse, error)
QueryOrder(ctx context.Context, req MifaPayQueryOrderRequest) (MifaPayQueryOrderResponse, error)
ParseNotification(notification ledger.MifaPayNotification) (MifaPayNotifyData, error)
}
// V5PayClient 隔离 V5Pay MD5 签名、验签和 HTTP 协议细节service 只处理订单状态。
type V5PayClient interface {
CreateOrder(ctx context.Context, req V5PayCreateOrderRequest) (V5PayCreateOrderResponse, error)
QueryOrder(ctx context.Context, req V5PayQueryOrderRequest) (V5PayQueryOrderResponse, error)
ListProductTypes(ctx context.Context, req V5PayProductTypesRequest) (V5PayProductTypesResponse, error)
ParseNotification(notification ledger.V5PayNotification) (V5PayNotifyData, error)
}
// TronUSDTClient 校验用户提交的 TRC20 tx_hash 是否真实打到平台共享地址。
type TronUSDTClient interface {
VerifyUSDTTransfer(ctx context.Context, txHash string, toAddress string, amountMinor int64) (string, error)
}
func normalizeExternalRechargeConfig(config ExternalRechargeConfig) ExternalRechargeConfig {
config.USDTTRC20Address = strings.TrimSpace(config.USDTTRC20Address)
config.MifaPayNotifyURL = strings.TrimSpace(config.MifaPayNotifyURL)
config.MifaPayReturnURL = strings.TrimSpace(config.MifaPayReturnURL)
config.V5PayNotifyURL = strings.TrimSpace(config.V5PayNotifyURL)
config.V5PayReturnURL = strings.TrimSpace(config.V5PayReturnURL)
return config
}
// ListThirdPartyPaymentChannels 返回后台三方支付管理页需要的渠道、国家和方式树。
func (s *Service) ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
if strings.TrimSpace(query.Status) != "" {
query.Status = ledger.NormalizeThirdPartyPaymentStatus(query.Status)
if query.Status == "" {
return nil, xerr.New(xerr.InvalidArgument, "status is invalid")
}
}
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.ListThirdPartyPaymentChannels(ctx, query)
}
func (s *Service) SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error) {
if command.MethodID <= 0 || command.OperatorUserID <= 0 {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment method and operator are required")
}
if s.repository == nil {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.SetThirdPartyPaymentMethodStatus(ctx, command)
}
func (s *Service) UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error) {
command.AppCode = appcode.Normalize(command.AppCode)
command.USDToCurrencyRate = strings.TrimSpace(command.USDToCurrencyRate)
if command.MethodID <= 0 || command.OperatorUserID <= 0 || !validNonNegativeDecimal(command.USDToCurrencyRate) {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment rate command is invalid")
}
if s.repository == nil {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.UpdateThirdPartyPaymentRate(ctx, command)
}
// ListH5RechargeOptions 只返回目标用户当前可买商品和已配置汇率的支付方式。
func (s *Service) ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error) {
query.AppCode = appcode.Normalize(query.AppCode)
query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType)
query.TargetCountryCode = strings.ToUpper(strings.TrimSpace(query.TargetCountryCode))
if query.TargetUserID <= 0 || query.TargetRegionID <= 0 || query.AudienceType == "" {
return ledger.H5RechargeOptions{}, xerr.New(xerr.InvalidArgument, "h5 recharge options query is incomplete")
}
if s.repository == nil {
return ledger.H5RechargeOptions{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, query.AppCode)
options, err := s.repository.ListH5RechargeOptions(ctx, query)
if err != nil {
return ledger.H5RechargeOptions{}, err
}
if s.mifaPay == nil && s.v5Pay == nil {
options.PaymentMethods = nil
} else {
filtered := options.PaymentMethods[:0]
for _, method := range options.PaymentMethods {
// H5 只展示当前运行时已装配 client 的支付公司;后台仍可维护全部 provider 的开关和汇率。
if method.ProviderCode == ledger.PaymentProviderMifaPay && s.mifaPay != nil {
filtered = append(filtered, method)
}
if method.ProviderCode == ledger.PaymentProviderV5Pay && s.v5Pay != nil {
filtered = append(filtered, method)
}
}
options.PaymentMethods = s.filterV5PayRuntimeProductTypes(ctx, filtered)
}
options.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && s.externalRecharge.USDTTRC20Address != ""
options.USDTTRC20Address = s.externalRecharge.USDTTRC20Address
return options, nil
}
func (s *Service) filterV5PayRuntimeProductTypes(ctx context.Context, methods []ledger.ThirdPartyPaymentMethod) []ledger.ThirdPartyPaymentMethod {
if s.v5Pay == nil || len(methods) == 0 {
return methods
}
type productKey struct {
country string
currency string
}
allowedByKey := map[productKey]map[string]bool{}
filtered := methods[:0]
for _, method := range methods {
if method.ProviderCode != ledger.PaymentProviderV5Pay {
filtered = append(filtered, method)
continue
}
key := productKey{country: strings.ToUpper(strings.TrimSpace(method.CountryCode)), currency: strings.ToUpper(strings.TrimSpace(method.CurrencyCode))}
allowed, ok := allowedByKey[key]
if !ok {
allowed = map[string]bool{}
products, err := s.v5Pay.ListProductTypes(ctx, V5PayProductTypesRequest{CountryCode: key.country, CurrencyCode: key.currency})
if err == nil {
for _, product := range products.PayinList {
// V5Pay 的 appKey 可按国家/币种/产品单独开通H5 只展示当前应用实际可走收银台的产品,避免用户点到 1013 app invalid。
if product.SupportCashierMode == 0 {
continue
}
if product.Currency != "" && !strings.EqualFold(product.Currency, key.currency) {
continue
}
if productType := strings.ToUpper(strings.TrimSpace(product.ProductType)); productType != "" {
allowed[productType] = true
}
}
}
allowedByKey[key] = allowed
}
if allowed[strings.ToUpper(strings.TrimSpace(method.PayType))] {
filtered = append(filtered, method)
}
}
return filtered
}
// CreateH5RechargeOrder 创建 H5 外部充值订单;只有回调或链上校验成功后才会入账。
func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand) (ledger.ExternalRechargeOrder, error) {
command = normalizeCreateExternalRechargeOrderCommand(command)
if err := validateCreateExternalRechargeOrderCommand(command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, command.AppCode)
product, err := s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := validateExternalRechargeProduct(product, command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
switch command.ProviderCode {
case ledger.PaymentProviderUSDTTRC20:
return s.createUSDTRechargeOrder(ctx, command, product)
case ledger.PaymentProviderMifaPay:
return s.createMifaPayRechargeOrder(ctx, command, product)
case ledger.PaymentProviderV5Pay:
return s.createV5PayRechargeOrder(ctx, command, product)
default:
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "payment provider is invalid")
}
}
func (s *Service) createUSDTRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
if !s.externalRecharge.USDTTRC20Enabled || s.externalRecharge.USDTTRC20Address == "" {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "usdt trc20 recharge is not configured")
}
return s.repository.CreateExternalRechargeOrder(ctx, command, product, nil, amountMicroToUSDMinor(product.AmountMicro), s.externalRecharge.USDTTRC20Address)
}
func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
if s.mifaPay == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mifapay client is not configured")
}
method, err := s.repository.GetThirdPartyPaymentMethod(ctx, command.AppCode, command.PaymentMethodID)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := validateMifaPayMethodForOrder(method, command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &method, providerAmountMinor, "")
if err != nil || order.IdempotentReplay {
return order, err
}
notifyURL := firstNonEmpty(command.NotifyURL, s.externalRecharge.MifaPayNotifyURL)
returnURL := firstNonEmpty(command.ReturnURL, s.externalRecharge.MifaPayReturnURL)
if notifyURL == "" {
_, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, "mifapay notify_url is not configured", "")
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mifapay notify_url is not configured")
}
resp, err := s.mifaPay.CreateOrder(ctx, MifaPayCreateOrderRequest{
OrderID: order.OrderID,
TargetUserID: order.TargetUserID,
ProductName: order.ProductName,
CoinAmount: order.CoinAmount,
AmountMinor: order.USDMinorAmount,
CurrencyCode: order.CurrencyCode,
CountryCode: order.CountryCode,
PayWay: order.PayWay,
PayType: order.PayType,
NotifyURL: notifyURL,
ReturnURL: mifaPayProviderReturnURL(returnURL),
ClientIP: command.ClientIP,
Language: command.Language,
ProviderAmountMinor: providerAmountMinor,
PayerName: command.PayerName,
PayerAccount: command.PayerAccount,
PayerEmail: command.PayerEmail,
})
if err != nil {
_, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), mifaPayProviderPayloadJSON(err))
return ledger.ExternalRechargeOrder{}, normalizeMifaPayOrderError(err)
}
return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON)
}
func (s *Service) createV5PayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
if s.v5Pay == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
method, err := s.repository.GetThirdPartyPaymentMethod(ctx, command.AppCode, command.PaymentMethodID)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := validateV5PayMethodForOrder(method, command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &method, providerAmountMinor, "")
if err != nil || order.IdempotentReplay {
return order, err
}
notifyURL := firstNonEmpty(command.NotifyURL, s.externalRecharge.V5PayNotifyURL)
returnURL := firstNonEmpty(command.ReturnURL, s.externalRecharge.V5PayReturnURL)
if notifyURL == "" {
_, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, "v5pay notify_url is not configured", "")
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay notify_url is not configured")
}
resp, err := s.v5Pay.CreateOrder(ctx, V5PayCreateOrderRequest{
OrderID: order.OrderID,
TargetUserID: order.TargetUserID,
ProductName: order.ProductName,
CoinAmount: order.CoinAmount,
AmountMinor: order.USDMinorAmount,
CurrencyCode: order.CurrencyCode,
CountryCode: order.CountryCode,
ProductType: order.PayType,
NotifyURL: notifyURL,
ReturnURL: v5PayProviderReturnURL(returnURL),
Language: command.Language,
ProviderAmountMinor: providerAmountMinor,
PayerName: command.PayerName,
PayerAccount: command.PayerAccount,
PayerEmail: command.PayerEmail,
})
if err != nil {
_, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), providerPayloadJSON(err))
return ledger.ExternalRechargeOrder{}, normalizeExternalProviderOrderError(err, "v5pay")
}
return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON)
}
func normalizeMifaPayOrderError(err error) error {
return normalizeExternalProviderOrderError(err, "mifapay")
}
type mifaPayProviderPayloadError interface {
ProviderPayloadJSON() string
}
func mifaPayProviderPayloadJSON(err error) string {
return providerPayloadJSON(err)
}
func normalizeExternalProviderOrderError(err error, provider string) error {
if err == nil {
return nil
}
if _, ok := xerr.As(err); ok {
return err
}
// HTTP、JSON、签名和响应结构错误都是外部支付依赖不可用不能把普通 Go error 透到 gRPC 边界,
// 否则 gateway 只能降级成 INTERNAL_ERROR前端拿不到“上游支付失败”的可重试语义。
return xerr.New(xerr.Unavailable, provider+" order upstream response is invalid")
}
func providerPayloadJSON(err error) string {
var payloadErr mifaPayProviderPayloadError
if errors.As(err, &payloadErr) {
// 支付网关拒单的原始响应是和支付订单排障强相关的三方事实,落库后可直接给支付技术支持按 code/msg 查链路。
// 普通网络错误、JSON 解析错误或本地签名错误没有三方响应体,这里保持空值,避免写入误导性的本地错误文本。
return strings.TrimSpace(payloadErr.ProviderPayloadJSON())
}
return ""
}
// SubmitH5RechargeTx 把用户提交的 tx_hash 交给 TRON 适配器校验,通过后立即入账。
func (s *Service) SubmitH5RechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand) (ledger.ExternalRechargeOrder, error) {
command.AppCode = appcode.Normalize(command.AppCode)
command.OrderID = strings.TrimSpace(command.OrderID)
command.TxHash = strings.TrimSpace(command.TxHash)
if command.OrderID == "" || command.TargetUserID <= 0 || command.TxHash == "" {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "usdt tx command is incomplete")
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, command.AppCode)
order, err := s.repository.GetExternalRechargeOrder(ctx, command.AppCode, command.OrderID)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if order.TargetUserID != command.TargetUserID {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user")
}
if order.ProviderCode != ledger.PaymentProviderUSDTTRC20 || order.Status != ledger.ExternalRechargeStatusPending {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Conflict, "external recharge order is not pending usdt order")
}
if s.tronUSDT == nil {
return s.repository.AttachExternalRechargeTx(ctx, command, "")
}
rawJSON, err := s.tronUSDT.VerifyUSDTTransfer(ctx, command.TxHash, order.ReceiveAddress, order.USDMinorAmount)
if err != nil {
_, _ = s.repository.AttachExternalRechargeTx(ctx, command, rawJSON)
return ledger.ExternalRechargeOrder{}, err
}
if _, err := s.repository.AttachExternalRechargeTx(ctx, command, rawJSON); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
return s.repository.CreditExternalRechargeOrder(ctx, command.AppCode, command.OrderID, "", command.TxHash, rawJSON)
}
func (s *Service) GetH5RechargeOrder(ctx context.Context, appCode string, orderID string, targetUserID int64) (ledger.ExternalRechargeOrder, error) {
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), strings.TrimSpace(orderID))
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if targetUserID > 0 && order.TargetUserID != targetUserID {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user")
}
if refreshed, err := s.refreshMifaPayRechargeOrder(ctx, order); err == nil {
order = refreshed
}
if refreshed, err := s.refreshV5PayRechargeOrder(ctx, order); err == nil {
order = refreshed
}
return order, nil
}
func (s *Service) ReconcileExternalRechargeOrders(ctx context.Context, appCode string, limit int) (int, error) {
if s.repository == nil {
return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if limit <= 0 {
return 0, nil
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appcode.FromContext(ctx), limit)
if err != nil {
return 0, err
}
processed := 0
for _, order := range orders {
// 补偿任务只负责把“已经跳转到三方收银台但回调/回跳未完成”的订单拉回服务端状态机。
// 单笔三方查询失败不能中断整批;失败订单会保留 redirected下一轮继续查成功/终态失败则复用幂等落账。
if _, err := s.refreshMifaPayRechargeOrder(ctx, order); err != nil {
processed++
continue
}
if _, err := s.refreshV5PayRechargeOrder(ctx, order); err != nil {
processed++
continue
}
processed++
}
return processed, nil
}
func (s *Service) refreshMifaPayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) {
if s.mifaPay == nil || order.ProviderCode != ledger.PaymentProviderMifaPay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed {
return order, nil
}
query, err := s.mifaPay.QueryOrder(ctx, MifaPayQueryOrderRequest{
OrderID: order.OrderID,
ProviderOrderID: order.ProviderOrderID,
OrderCreatedAtMS: order.CreatedAtMS,
})
if err != nil {
// H5 轮询不能因为 MiFaPay 查询短暂失败而中断;异步回调仍然是主链路,查询只是用户返回页的补偿刷新。
return order, err
}
providerOrderID := firstNonEmpty(query.ProviderOrderID, order.ProviderOrderID)
data := MifaPayNotifyData{
OrderID: firstNonEmpty(query.OrderID, order.OrderID),
ProviderOrderID: providerOrderID,
Amount: query.Amount,
Currency: query.Currency,
PayType: query.PayType,
RawJSON: query.RawJSON,
}
switch strings.TrimSpace(query.Status) {
case "1":
if !mifaPayQueryMatchesOrder(data, order) {
// 主动查询和回调使用同一笔本地订单快照校验金额、币种和支付类型;不匹配时拒绝入账,避免三方串单或通道异常。
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay query order data mismatch", query.RawJSON)
}
return s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, providerOrderID, "", query.RawJSON)
case "4":
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay order status 4", query.RawJSON)
case "0", "5", "":
return order, nil
default:
return order, nil
}
}
func (s *Service) refreshV5PayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) {
if s.v5Pay == nil || order.ProviderCode != ledger.PaymentProviderV5Pay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed {
return order, nil
}
query, err := s.v5Pay.QueryOrder(ctx, V5PayQueryOrderRequest{
OrderID: order.OrderID,
ProviderOrderID: order.ProviderOrderID,
CountryCode: order.CountryCode,
CurrencyCode: order.CurrencyCode,
})
if err != nil {
// H5 轮询不能因为 V5Pay 查询短暂失败而中断;异步回调仍然是主链路,查询只是用户返回页的补偿刷新。
return order, err
}
providerOrderID := firstNonEmpty(query.ProviderOrderID, order.ProviderOrderID)
data := V5PayNotifyData{
OrderID: firstNonEmpty(query.OrderID, order.OrderID),
ProviderOrderID: providerOrderID,
OrderStatus: query.Status,
Amount: query.Amount,
Currency: query.Currency,
ProductType: query.ProductType,
RawJSON: query.RawJSON,
}
switch strings.TrimSpace(query.Status) {
case "2":
if !v5PayOrderDataMatchesOrder(data, order) {
// 主动查询和回调使用同一笔本地订单快照校验金额、币种和产品编码;不匹配时拒绝入账,避免三方串单或通道异常。
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay query order data mismatch", query.RawJSON)
}
return s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, providerOrderID, "", query.RawJSON)
case "3", "5", "6":
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+query.Status, query.RawJSON)
case "0", "1", "":
return order, nil
default:
return order, nil
}
}
// HandleMifapayNotify 只在验签、订单号、币种和金额全部匹配后入账;重复回调返回 SUCCESS。
func (s *Service) HandleMifapayNotify(ctx context.Context, appCode string, notification ledger.MifaPayNotification) (ledger.ExternalRechargeOrder, bool, error) {
if s.mifaPay == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "mifapay client is not configured")
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
data, err := s.mifaPay.ParseNotification(notification)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
if !strings.EqualFold(data.OrderStatus, "SUCCESS") {
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay order status "+data.OrderStatus, data.RawJSON)
return order, true, err
}
if !mifaPayNotifyMatchesOrder(data, order) {
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay callback amount or currency mismatch", data.RawJSON)
return order, false, err
}
credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON)
return credited, err == nil, err
}
// HandleV5PayNotify 只在验签、订单号、币种、金额和 productType 全部匹配后入账;重复回调返回 success。
func (s *Service) HandleV5PayNotify(ctx context.Context, appCode string, notification ledger.V5PayNotification) (ledger.ExternalRechargeOrder, bool, error) {
if s.v5Pay == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
data, err := s.v5Pay.ParseNotification(notification)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
switch strings.TrimSpace(data.OrderStatus) {
case "2":
if !v5PayOrderDataMatchesOrder(data, order) {
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay callback amount currency or product mismatch", data.RawJSON)
return order, false, err
}
credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON)
return credited, err == nil, err
case "3", "5", "6":
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+data.OrderStatus, data.RawJSON)
return order, true, err
default:
return order, true, nil
}
}
func normalizeCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) ledger.CreateExternalRechargeOrderCommand {
command.AppCode = appcode.Normalize(command.AppCode)
command.CommandID = strings.TrimSpace(command.CommandID)
command.TargetCountryCode = strings.ToUpper(strings.TrimSpace(command.TargetCountryCode))
command.AudienceType = ledger.NormalizeRechargeAudienceType(command.AudienceType)
command.ProviderCode = ledger.NormalizePaymentProvider(command.ProviderCode)
command.ReturnURL = strings.TrimSpace(command.ReturnURL)
command.NotifyURL = strings.TrimSpace(command.NotifyURL)
command.ClientIP = strings.TrimSpace(command.ClientIP)
command.Language = normalizeMifaPayLanguage(command.Language)
command.PayerName = strings.TrimSpace(command.PayerName)
command.PayerAccount = strings.TrimSpace(command.PayerAccount)
command.PayerEmail = strings.TrimSpace(command.PayerEmail)
return command
}
func normalizeMifaPayLanguage(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "en"
}
if comma := strings.IndexByte(value, ','); comma >= 0 {
value = value[:comma]
}
if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 {
value = value[:semicolon]
}
value = strings.TrimSpace(value)
if !validMifaPayLanguageTag(value) {
return "en"
}
// wallet-service 是 MiFaPay 的最后一道协议边界;即使未来有非 gateway 调用方传入浏览器语言列表,
// 这里也只向三方支付发送单个 BCP47 风格标签,避免订单落库后被 MiFaPay 参数格式校验拒掉。
return value
}
func validMifaPayLanguageTag(value string) bool {
if value == "" {
return false
}
parts := strings.Split(value, "-")
if len(parts) == 0 || len(parts) > 4 || len(parts[0]) < 2 || len(parts[0]) > 3 || !allASCIILetters(parts[0]) {
return false
}
for _, part := range parts[1:] {
if len(part) < 2 || len(part) > 8 || !allASCIILettersOrDigits(part) {
return false
}
}
return true
}
func allASCIILetters(value string) bool {
for _, char := range value {
if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') {
return false
}
}
return value != ""
}
func allASCIILettersOrDigits(value string) bool {
for _, char := range value {
if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') && (char < '0' || char > '9') {
return false
}
}
return value != ""
}
func validateCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) error {
if command.CommandID == "" || command.TargetUserID <= 0 || command.TargetRegionID <= 0 || command.ProductID <= 0 {
return xerr.New(xerr.InvalidArgument, "external recharge command is incomplete")
}
if command.AudienceType == "" || command.ProviderCode == "" {
return xerr.New(xerr.InvalidArgument, "external recharge command type is invalid")
}
if len(command.CommandID) > 128 {
return xerr.New(xerr.InvalidArgument, "command_id is too long")
}
return nil
}
func validateExternalRechargeProduct(product ledger.RechargeProduct, command ledger.CreateExternalRechargeOrderCommand) error {
if product.Status != ledger.RechargeProductStatusActive || !product.Enabled {
return xerr.New(xerr.Conflict, "recharge product is not active")
}
if product.Platform != ledger.RechargeProductPlatformWeb {
return xerr.New(xerr.InvalidArgument, "recharge product is not h5 product")
}
if product.AudienceType != command.AudienceType {
return xerr.New(xerr.Conflict, "recharge product audience does not match")
}
if !rechargeProductSupportsRegion(product, command.TargetRegionID) {
return xerr.New(xerr.Conflict, "recharge product is not available in user region")
}
return nil
}
func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error {
if method.ProviderCode != ledger.PaymentProviderMifaPay || method.Status != ledger.ThirdPartyPaymentStatusActive {
return xerr.New(xerr.Conflict, "mifapay method is not active")
}
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
return xerr.New(xerr.Conflict, "mifapay method country does not match")
}
if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil {
return err
}
return nil
}
func validateV5PayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error {
if method.ProviderCode != ledger.PaymentProviderV5Pay || method.Status != ledger.ThirdPartyPaymentStatusActive {
return xerr.New(xerr.Conflict, "v5pay method is not active")
}
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
return xerr.New(xerr.Conflict, "v5pay method country does not match")
}
if strings.TrimSpace(method.PayType) == "" {
return xerr.New(xerr.InvalidArgument, "v5pay product type is required")
}
if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil {
return err
}
return nil
}
func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) {
if usdMinor <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid")
}
value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64)
if err != nil || value <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "payment exchange rate is invalid")
}
return int64(math.Round(float64(usdMinor) * value)), nil
}
func amountMicroToUSDMinor(amountMicro int64) int64 {
if amountMicro <= 0 {
return 0
}
return amountMicro / 10_000
}
func validNonNegativeDecimal(value string) bool {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
return err == nil && parsed >= 0
}
func mifaPayNotifyMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool {
if !strings.EqualFold(data.Currency, order.CurrencyCode) {
return false
}
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
return false
}
amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64)
return err == nil && amount == order.ProviderAmountMinor
}
func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool {
if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) {
return false
}
if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) {
return false
}
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
return false
}
amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64)
return err == nil && amount == order.ProviderAmountMinor
}
func v5PayOrderDataMatchesOrder(data V5PayNotifyData, order ledger.ExternalRechargeOrder) bool {
if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) {
return false
}
if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) {
return false
}
if data.ProductType != "" && !strings.EqualFold(data.ProductType, order.PayType) {
return false
}
amount, err := parseProviderAmountMinor(data.Amount)
return err == nil && amount == order.ProviderAmountMinor
}
func parseProviderAmountMinor(value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, strconv.ErrSyntax
}
if !strings.Contains(value, ".") {
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, err
}
return parsed * 100, nil
}
parts := strings.SplitN(value, ".", 2)
whole, err := strconv.ParseInt(firstNonEmpty(parts[0], "0"), 10, 64)
if err != nil {
return 0, err
}
fraction := parts[1]
if len(fraction) > 2 {
fraction = fraction[:2]
}
for len(fraction) < 2 {
fraction += "0"
}
minor, err := strconv.ParseInt(fraction, 10, 64)
if err != nil {
return 0, err
}
return whole*100 + minor, nil
}
func mifaPayProviderReturnURL(returnURL string) string {
returnURL = strings.TrimSpace(returnURL)
if returnURL == "" {
return returnURL
}
parsed, err := url.Parse(returnURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return returnURL
}
// MiFaPay 生产网关会对 payment.resultRedirect 做严格格式校验,动态 query 会在下单阶段被拒。
// 订单 ID 已经通过创建订单响应返回给 H5 并落在钱包库,回跳页只能作为稳定入口,不能依赖三方回传本地查询参数。
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}
func v5PayProviderReturnURL(returnURL string) string {
return mifaPayProviderReturnURL(returnURL)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func mustJSONText(value any) string {
body, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(body)
}

View File

@ -0,0 +1,24 @@
package wallet
import (
"strings"
)
// ExternalRechargeConfig 保存 H5 外部充值需要公开给业务链路的运行配置。
type ExternalRechargeConfig struct {
USDTTRC20Enabled bool
USDTTRC20Address string
MifaPayNotifyURL string
MifaPayReturnURL string
V5PayNotifyURL string
V5PayReturnURL string
}
func normalizeExternalRechargeConfig(config ExternalRechargeConfig) ExternalRechargeConfig {
config.USDTTRC20Address = strings.TrimSpace(config.USDTTRC20Address)
config.MifaPayNotifyURL = strings.TrimSpace(config.MifaPayNotifyURL)
config.MifaPayReturnURL = strings.TrimSpace(config.MifaPayReturnURL)
config.V5PayNotifyURL = strings.TrimSpace(config.V5PayNotifyURL)
config.V5PayReturnURL = strings.TrimSpace(config.V5PayReturnURL)
return config
}

View File

@ -0,0 +1,71 @@
package wallet
import (
"context"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
)
// HandleMifapayNotify 只在验签、订单号、币种和金额全部匹配后入账;重复回调返回 SUCCESS。
func (s *Service) HandleMifapayNotify(ctx context.Context, appCode string, notification ledger.MifaPayNotification) (ledger.ExternalRechargeOrder, bool, error) {
if s.mifaPay == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "mifapay client is not configured")
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
data, err := s.mifaPay.ParseNotification(notification)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
if !strings.EqualFold(data.OrderStatus, "SUCCESS") {
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay order status "+data.OrderStatus, data.RawJSON)
return order, true, err
}
if !mifaPayNotifyMatchesOrder(data, order) {
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay callback amount or currency mismatch", data.RawJSON)
return order, false, err
}
credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON)
return credited, err == nil, err
}
// HandleV5PayNotify 只在验签、订单号、币种、金额和 productType 全部匹配后入账;重复回调返回 success。
func (s *Service) HandleV5PayNotify(ctx context.Context, appCode string, notification ledger.V5PayNotification) (ledger.ExternalRechargeOrder, bool, error) {
if s.v5Pay == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, false, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
data, err := s.v5Pay.ParseNotification(notification)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), data.OrderID)
if err != nil {
return ledger.ExternalRechargeOrder{}, false, err
}
switch strings.TrimSpace(data.OrderStatus) {
case "2":
if !v5PayOrderDataMatchesOrder(data, order) {
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay callback amount currency or product mismatch", data.RawJSON)
return order, false, err
}
credited, err := s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, data.ProviderOrderID, "", data.RawJSON)
return credited, err == nil, err
case "3", "5", "6":
order, err = s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+data.OrderStatus, data.RawJSON)
return order, true, err
default:
return order, true, nil
}
}

View File

@ -0,0 +1,235 @@
package wallet
import (
"context"
"errors"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
)
type mifaPayProviderPayloadError interface {
ProviderPayloadJSON() string
}
// CreateH5RechargeOrder 创建 H5 外部充值订单;只有回调或链上校验成功后才会入账。
func (s *Service) CreateH5RechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand) (ledger.ExternalRechargeOrder, error) {
command = normalizeCreateExternalRechargeOrderCommand(command)
if err := validateCreateExternalRechargeOrderCommand(command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, command.AppCode)
product, err := s.repository.GetRechargeProduct(ctx, command.AppCode, command.ProductID)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := validateExternalRechargeProduct(product, command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
switch command.ProviderCode {
case ledger.PaymentProviderUSDTTRC20:
return s.createUSDTRechargeOrder(ctx, command, product)
case ledger.PaymentProviderMifaPay:
return s.createMifaPayRechargeOrder(ctx, command, product)
case ledger.PaymentProviderV5Pay:
return s.createV5PayRechargeOrder(ctx, command, product)
default:
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "payment provider is invalid")
}
}
func (s *Service) createUSDTRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
if !s.externalRecharge.USDTTRC20Enabled || s.externalRecharge.USDTTRC20Address == "" {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "usdt trc20 recharge is not configured")
}
return s.repository.CreateExternalRechargeOrder(ctx, command, product, nil, amountMicroToUSDMinor(product.AmountMicro), s.externalRecharge.USDTTRC20Address)
}
func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
if s.mifaPay == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mifapay client is not configured")
}
method, err := s.repository.GetThirdPartyPaymentMethod(ctx, command.AppCode, command.PaymentMethodID)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := validateMifaPayMethodForOrder(method, command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &method, providerAmountMinor, "")
if err != nil || order.IdempotentReplay {
return order, err
}
notifyURL := firstNonEmpty(command.NotifyURL, s.externalRecharge.MifaPayNotifyURL)
returnURL := firstNonEmpty(command.ReturnURL, s.externalRecharge.MifaPayReturnURL)
if notifyURL == "" {
_, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, "mifapay notify_url is not configured", "")
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "mifapay notify_url is not configured")
}
resp, err := s.mifaPay.CreateOrder(ctx, MifaPayCreateOrderRequest{
OrderID: order.OrderID,
TargetUserID: order.TargetUserID,
ProductName: order.ProductName,
CoinAmount: order.CoinAmount,
AmountMinor: order.USDMinorAmount,
CurrencyCode: order.CurrencyCode,
CountryCode: order.CountryCode,
PayWay: order.PayWay,
PayType: order.PayType,
NotifyURL: notifyURL,
ReturnURL: mifaPayProviderReturnURL(returnURL),
ClientIP: command.ClientIP,
Language: command.Language,
ProviderAmountMinor: providerAmountMinor,
PayerName: command.PayerName,
PayerAccount: command.PayerAccount,
PayerEmail: command.PayerEmail,
})
if err != nil {
_, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), mifaPayProviderPayloadJSON(err))
return ledger.ExternalRechargeOrder{}, normalizeMifaPayOrderError(err)
}
return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON)
}
func (s *Service) createV5PayRechargeOrder(ctx context.Context, command ledger.CreateExternalRechargeOrderCommand, product ledger.RechargeProduct) (ledger.ExternalRechargeOrder, error) {
if s.v5Pay == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay client is not configured")
}
method, err := s.repository.GetThirdPartyPaymentMethod(ctx, command.AppCode, command.PaymentMethodID)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if err := validateV5PayMethodForOrder(method, command); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
providerAmountMinor, err := calculateProviderAmountMinor(amountMicroToUSDMinor(product.AmountMicro), method.USDToCurrencyRate)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
order, err := s.repository.CreateExternalRechargeOrder(ctx, command, product, &method, providerAmountMinor, "")
if err != nil || order.IdempotentReplay {
return order, err
}
notifyURL := firstNonEmpty(command.NotifyURL, s.externalRecharge.V5PayNotifyURL)
returnURL := firstNonEmpty(command.ReturnURL, s.externalRecharge.V5PayReturnURL)
if notifyURL == "" {
_, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, "v5pay notify_url is not configured", "")
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "v5pay notify_url is not configured")
}
resp, err := s.v5Pay.CreateOrder(ctx, V5PayCreateOrderRequest{
OrderID: order.OrderID,
TargetUserID: order.TargetUserID,
ProductName: order.ProductName,
CoinAmount: order.CoinAmount,
AmountMinor: order.USDMinorAmount,
CurrencyCode: order.CurrencyCode,
CountryCode: order.CountryCode,
ProductType: order.PayType,
NotifyURL: notifyURL,
ReturnURL: v5PayProviderReturnURL(returnURL),
Language: command.Language,
ProviderAmountMinor: providerAmountMinor,
PayerName: command.PayerName,
PayerAccount: command.PayerAccount,
PayerEmail: command.PayerEmail,
})
if err != nil {
_, _ = s.repository.MarkExternalRechargeOrderFailed(ctx, command.AppCode, order.OrderID, err.Error(), providerPayloadJSON(err))
return ledger.ExternalRechargeOrder{}, normalizeExternalProviderOrderError(err, "v5pay")
}
return s.repository.MarkExternalRechargeOrderRedirected(ctx, command.AppCode, order.OrderID, resp.ProviderOrderID, resp.PayURL, resp.RawJSON)
}
func normalizeMifaPayOrderError(err error) error {
return normalizeExternalProviderOrderError(err, "mifapay")
}
func mifaPayProviderPayloadJSON(err error) string {
return providerPayloadJSON(err)
}
func normalizeExternalProviderOrderError(err error, provider string) error {
if err == nil {
return nil
}
if _, ok := xerr.As(err); ok {
return err
}
return xerr.New(xerr.Unavailable, provider+" order upstream response is invalid")
}
func providerPayloadJSON(err error) string {
var payloadErr mifaPayProviderPayloadError
if errors.As(err, &payloadErr) {
return strings.TrimSpace(payloadErr.ProviderPayloadJSON())
}
return ""
}
// SubmitH5RechargeTx 把用户提交的 tx_hash 交给 TRON 适配器校验,通过后立即入账。
func (s *Service) SubmitH5RechargeTx(ctx context.Context, command ledger.SubmitExternalRechargeTxCommand) (ledger.ExternalRechargeOrder, error) {
command.AppCode = appcode.Normalize(command.AppCode)
command.OrderID = strings.TrimSpace(command.OrderID)
command.TxHash = strings.TrimSpace(command.TxHash)
if command.OrderID == "" || command.TargetUserID <= 0 || command.TxHash == "" {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.InvalidArgument, "usdt tx command is incomplete")
}
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, command.AppCode)
order, err := s.repository.GetExternalRechargeOrder(ctx, command.AppCode, command.OrderID)
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if order.TargetUserID != command.TargetUserID {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user")
}
if order.ProviderCode != ledger.PaymentProviderUSDTTRC20 || order.Status != ledger.ExternalRechargeStatusPending {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Conflict, "external recharge order is not pending usdt order")
}
if s.tronUSDT == nil {
return s.repository.AttachExternalRechargeTx(ctx, command, "")
}
rawJSON, err := s.tronUSDT.VerifyUSDTTransfer(ctx, command.TxHash, order.ReceiveAddress, order.USDMinorAmount)
if err != nil {
_, _ = s.repository.AttachExternalRechargeTx(ctx, command, rawJSON)
return ledger.ExternalRechargeOrder{}, err
}
if _, err := s.repository.AttachExternalRechargeTx(ctx, command, rawJSON); err != nil {
return ledger.ExternalRechargeOrder{}, err
}
return s.repository.CreditExternalRechargeOrder(ctx, command.AppCode, command.OrderID, "", command.TxHash, rawJSON)
}
func (s *Service) GetH5RechargeOrder(ctx context.Context, appCode string, orderID string, targetUserID int64) (ledger.ExternalRechargeOrder, error) {
if s.repository == nil {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
order, err := s.repository.GetExternalRechargeOrder(ctx, appcode.FromContext(ctx), strings.TrimSpace(orderID))
if err != nil {
return ledger.ExternalRechargeOrder{}, err
}
if targetUserID > 0 && order.TargetUserID != targetUserID {
return ledger.ExternalRechargeOrder{}, xerr.New(xerr.PermissionDenied, "external recharge order does not belong to user")
}
if refreshed, err := s.refreshMifaPayRechargeOrder(ctx, order); err == nil {
order = refreshed
}
if refreshed, err := s.refreshV5PayRechargeOrder(ctx, order); err == nil {
order = refreshed
}
return order, nil
}

View File

@ -0,0 +1,129 @@
package wallet
import (
"context"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
)
// ListThirdPartyPaymentChannels 返回后台三方支付管理页需要的渠道、国家和方式树。
func (s *Service) ListThirdPartyPaymentChannels(ctx context.Context, query ledger.ListThirdPartyPaymentChannelsQuery) ([]ledger.ThirdPartyPaymentChannel, error) {
if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
query.AppCode = appcode.Normalize(query.AppCode)
if strings.TrimSpace(query.Status) != "" {
query.Status = ledger.NormalizeThirdPartyPaymentStatus(query.Status)
if query.Status == "" {
return nil, xerr.New(xerr.InvalidArgument, "status is invalid")
}
}
ctx = appcode.WithContext(ctx, query.AppCode)
return s.repository.ListThirdPartyPaymentChannels(ctx, query)
}
func (s *Service) SetThirdPartyPaymentMethodStatus(ctx context.Context, command ledger.ThirdPartyPaymentMethodStatusCommand) (ledger.ThirdPartyPaymentMethod, error) {
if command.MethodID <= 0 || command.OperatorUserID <= 0 {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment method and operator are required")
}
if s.repository == nil {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.SetThirdPartyPaymentMethodStatus(ctx, command)
}
func (s *Service) UpdateThirdPartyPaymentRate(ctx context.Context, command ledger.ThirdPartyPaymentRateCommand) (ledger.ThirdPartyPaymentMethod, error) {
command.AppCode = appcode.Normalize(command.AppCode)
command.USDToCurrencyRate = strings.TrimSpace(command.USDToCurrencyRate)
if command.MethodID <= 0 || command.OperatorUserID <= 0 || !validNonNegativeDecimal(command.USDToCurrencyRate) {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.InvalidArgument, "payment rate command is invalid")
}
if s.repository == nil {
return ledger.ThirdPartyPaymentMethod{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.UpdateThirdPartyPaymentRate(ctx, command)
}
// ListH5RechargeOptions 只返回目标用户当前可买商品和已配置汇率的支付方式。
func (s *Service) ListH5RechargeOptions(ctx context.Context, query ledger.H5RechargeOptionsQuery) (ledger.H5RechargeOptions, error) {
query.AppCode = appcode.Normalize(query.AppCode)
query.AudienceType = ledger.NormalizeRechargeAudienceType(query.AudienceType)
query.TargetCountryCode = strings.ToUpper(strings.TrimSpace(query.TargetCountryCode))
if query.TargetUserID <= 0 || query.TargetRegionID <= 0 || query.AudienceType == "" {
return ledger.H5RechargeOptions{}, xerr.New(xerr.InvalidArgument, "h5 recharge options query is incomplete")
}
if s.repository == nil {
return ledger.H5RechargeOptions{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
ctx = appcode.WithContext(ctx, query.AppCode)
options, err := s.repository.ListH5RechargeOptions(ctx, query)
if err != nil {
return ledger.H5RechargeOptions{}, err
}
if s.mifaPay == nil && s.v5Pay == nil {
options.PaymentMethods = nil
} else {
filtered := options.PaymentMethods[:0]
for _, method := range options.PaymentMethods {
if method.ProviderCode == ledger.PaymentProviderMifaPay && s.mifaPay != nil {
filtered = append(filtered, method)
}
if method.ProviderCode == ledger.PaymentProviderV5Pay && s.v5Pay != nil {
filtered = append(filtered, method)
}
}
options.PaymentMethods = s.filterV5PayRuntimeProductTypes(ctx, filtered)
}
options.USDTTRC20Enabled = s.externalRecharge.USDTTRC20Enabled && s.externalRecharge.USDTTRC20Address != ""
options.USDTTRC20Address = s.externalRecharge.USDTTRC20Address
return options, nil
}
func (s *Service) filterV5PayRuntimeProductTypes(ctx context.Context, methods []ledger.ThirdPartyPaymentMethod) []ledger.ThirdPartyPaymentMethod {
if s.v5Pay == nil || len(methods) == 0 {
return methods
}
type productKey struct {
country string
currency string
}
allowedByKey := map[productKey]map[string]bool{}
filtered := methods[:0]
for _, method := range methods {
if method.ProviderCode != ledger.PaymentProviderV5Pay {
filtered = append(filtered, method)
continue
}
key := productKey{country: strings.ToUpper(strings.TrimSpace(method.CountryCode)), currency: strings.ToUpper(strings.TrimSpace(method.CurrencyCode))}
allowed, ok := allowedByKey[key]
if !ok {
allowed = map[string]bool{}
products, err := s.v5Pay.ListProductTypes(ctx, V5PayProductTypesRequest{CountryCode: key.country, CurrencyCode: key.currency})
if err == nil {
for _, product := range products.PayinList {
if product.SupportCashierMode == 0 {
continue
}
if product.Currency != "" && !strings.EqualFold(product.Currency, key.currency) {
continue
}
if productType := strings.ToUpper(strings.TrimSpace(product.ProductType)); productType != "" {
allowed[productType] = true
}
}
}
allowedByKey[key] = allowed
}
if allowed[strings.ToUpper(strings.TrimSpace(method.PayType))] {
filtered = append(filtered, method)
}
}
return filtered
}

View File

@ -0,0 +1,115 @@
package wallet
import (
"context"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
)
func (s *Service) ReconcileExternalRechargeOrders(ctx context.Context, appCode string, limit int) (int, error) {
if s.repository == nil {
return 0, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
if limit <= 0 {
return 0, nil
}
ctx = appcode.WithContext(ctx, appcode.Normalize(appCode))
orders, err := s.repository.ListExternalRechargeOrdersForReconcile(ctx, appcode.FromContext(ctx), limit)
if err != nil {
return 0, err
}
processed := 0
for _, order := range orders {
if _, err := s.refreshMifaPayRechargeOrder(ctx, order); err != nil {
processed++
continue
}
if _, err := s.refreshV5PayRechargeOrder(ctx, order); err != nil {
processed++
continue
}
processed++
}
return processed, nil
}
func (s *Service) refreshMifaPayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) {
if s.mifaPay == nil || order.ProviderCode != ledger.PaymentProviderMifaPay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed {
return order, nil
}
query, err := s.mifaPay.QueryOrder(ctx, MifaPayQueryOrderRequest{
OrderID: order.OrderID,
ProviderOrderID: order.ProviderOrderID,
OrderCreatedAtMS: order.CreatedAtMS,
})
if err != nil {
return order, err
}
providerOrderID := firstNonEmpty(query.ProviderOrderID, order.ProviderOrderID)
data := MifaPayNotifyData{
OrderID: firstNonEmpty(query.OrderID, order.OrderID),
ProviderOrderID: providerOrderID,
Amount: query.Amount,
Currency: query.Currency,
PayType: query.PayType,
RawJSON: query.RawJSON,
}
switch strings.TrimSpace(query.Status) {
case "1":
if !mifaPayQueryMatchesOrder(data, order) {
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay query order data mismatch", query.RawJSON)
}
return s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, providerOrderID, "", query.RawJSON)
case "4":
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "mifapay order status 4", query.RawJSON)
case "0", "5", "":
return order, nil
default:
return order, nil
}
}
func (s *Service) refreshV5PayRechargeOrder(ctx context.Context, order ledger.ExternalRechargeOrder) (ledger.ExternalRechargeOrder, error) {
if s.v5Pay == nil || order.ProviderCode != ledger.PaymentProviderV5Pay || order.Status == ledger.ExternalRechargeStatusCredited || order.Status == ledger.ExternalRechargeStatusFailed {
return order, nil
}
query, err := s.v5Pay.QueryOrder(ctx, V5PayQueryOrderRequest{
OrderID: order.OrderID,
ProviderOrderID: order.ProviderOrderID,
CountryCode: order.CountryCode,
CurrencyCode: order.CurrencyCode,
})
if err != nil {
return order, err
}
providerOrderID := firstNonEmpty(query.ProviderOrderID, order.ProviderOrderID)
data := V5PayNotifyData{
OrderID: firstNonEmpty(query.OrderID, order.OrderID),
ProviderOrderID: providerOrderID,
OrderStatus: query.Status,
Amount: query.Amount,
Currency: query.Currency,
ProductType: query.ProductType,
RawJSON: query.RawJSON,
}
switch strings.TrimSpace(query.Status) {
case "2":
if !v5PayOrderDataMatchesOrder(data, order) {
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay query order data mismatch", query.RawJSON)
}
return s.repository.CreditExternalRechargeOrder(ctx, order.AppCode, order.OrderID, providerOrderID, "", query.RawJSON)
case "3", "5", "6":
return s.repository.MarkExternalRechargeOrderFailed(ctx, order.AppCode, order.OrderID, "v5pay order status "+query.Status, query.RawJSON)
case "0", "1", "":
return order, nil
default:
return order, nil
}
}

View File

@ -0,0 +1,268 @@
package wallet
import (
"encoding/json"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"math"
"net/url"
"strconv"
"strings"
)
func normalizeCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) ledger.CreateExternalRechargeOrderCommand {
command.AppCode = appcode.Normalize(command.AppCode)
command.CommandID = strings.TrimSpace(command.CommandID)
command.TargetCountryCode = strings.ToUpper(strings.TrimSpace(command.TargetCountryCode))
command.AudienceType = ledger.NormalizeRechargeAudienceType(command.AudienceType)
command.ProviderCode = ledger.NormalizePaymentProvider(command.ProviderCode)
command.ReturnURL = strings.TrimSpace(command.ReturnURL)
command.NotifyURL = strings.TrimSpace(command.NotifyURL)
command.ClientIP = strings.TrimSpace(command.ClientIP)
command.Language = normalizeMifaPayLanguage(command.Language)
command.PayerName = strings.TrimSpace(command.PayerName)
command.PayerAccount = strings.TrimSpace(command.PayerAccount)
command.PayerEmail = strings.TrimSpace(command.PayerEmail)
return command
}
func normalizeMifaPayLanguage(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "en"
}
if comma := strings.IndexByte(value, ','); comma >= 0 {
value = value[:comma]
}
if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 {
value = value[:semicolon]
}
value = strings.TrimSpace(value)
if !validMifaPayLanguageTag(value) {
return "en"
}
return value
}
func validMifaPayLanguageTag(value string) bool {
if value == "" {
return false
}
parts := strings.Split(value, "-")
if len(parts) == 0 || len(parts) > 4 || len(parts[0]) < 2 || len(parts[0]) > 3 || !allASCIILetters(parts[0]) {
return false
}
for _, part := range parts[1:] {
if len(part) < 2 || len(part) > 8 || !allASCIILettersOrDigits(part) {
return false
}
}
return true
}
func allASCIILetters(value string) bool {
for _, char := range value {
if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') {
return false
}
}
return value != ""
}
func allASCIILettersOrDigits(value string) bool {
for _, char := range value {
if (char < 'A' || char > 'Z') && (char < 'a' || char > 'z') && (char < '0' || char > '9') {
return false
}
}
return value != ""
}
func validateCreateExternalRechargeOrderCommand(command ledger.CreateExternalRechargeOrderCommand) error {
if command.CommandID == "" || command.TargetUserID <= 0 || command.TargetRegionID <= 0 || command.ProductID <= 0 {
return xerr.New(xerr.InvalidArgument, "external recharge command is incomplete")
}
if command.AudienceType == "" || command.ProviderCode == "" {
return xerr.New(xerr.InvalidArgument, "external recharge command type is invalid")
}
if len(command.CommandID) > 128 {
return xerr.New(xerr.InvalidArgument, "command_id is too long")
}
return nil
}
func validateExternalRechargeProduct(product ledger.RechargeProduct, command ledger.CreateExternalRechargeOrderCommand) error {
if product.Status != ledger.RechargeProductStatusActive || !product.Enabled {
return xerr.New(xerr.Conflict, "recharge product is not active")
}
if product.Platform != ledger.RechargeProductPlatformWeb {
return xerr.New(xerr.InvalidArgument, "recharge product is not h5 product")
}
if product.AudienceType != command.AudienceType {
return xerr.New(xerr.Conflict, "recharge product audience does not match")
}
if !rechargeProductSupportsRegion(product, command.TargetRegionID) {
return xerr.New(xerr.Conflict, "recharge product is not available in user region")
}
return nil
}
func validateMifaPayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error {
if method.ProviderCode != ledger.PaymentProviderMifaPay || method.Status != ledger.ThirdPartyPaymentStatusActive {
return xerr.New(xerr.Conflict, "mifapay method is not active")
}
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
return xerr.New(xerr.Conflict, "mifapay method country does not match")
}
if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil {
return err
}
return nil
}
func validateV5PayMethodForOrder(method ledger.ThirdPartyPaymentMethod, command ledger.CreateExternalRechargeOrderCommand) error {
if method.ProviderCode != ledger.PaymentProviderV5Pay || method.Status != ledger.ThirdPartyPaymentStatusActive {
return xerr.New(xerr.Conflict, "v5pay method is not active")
}
if method.CountryCode != "GLOBAL" && !strings.EqualFold(method.CountryCode, command.TargetCountryCode) {
return xerr.New(xerr.Conflict, "v5pay method country does not match")
}
if strings.TrimSpace(method.PayType) == "" {
return xerr.New(xerr.InvalidArgument, "v5pay product type is required")
}
if _, err := calculateProviderAmountMinor(100, method.USDToCurrencyRate); err != nil {
return err
}
return nil
}
func calculateProviderAmountMinor(usdMinor int64, rate string) (int64, error) {
if usdMinor <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "usd amount is invalid")
}
value, err := strconv.ParseFloat(strings.TrimSpace(rate), 64)
if err != nil || value <= 0 {
return 0, xerr.New(xerr.InvalidArgument, "payment exchange rate is invalid")
}
return int64(math.Round(float64(usdMinor) * value)), nil
}
func amountMicroToUSDMinor(amountMicro int64) int64 {
if amountMicro <= 0 {
return 0
}
return amountMicro / 10_000
}
func validNonNegativeDecimal(value string) bool {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
return err == nil && parsed >= 0
}
func mifaPayNotifyMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool {
if !strings.EqualFold(data.Currency, order.CurrencyCode) {
return false
}
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
return false
}
amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64)
return err == nil && amount == order.ProviderAmountMinor
}
func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRechargeOrder) bool {
if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) {
return false
}
if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) {
return false
}
if data.PayType != "" && !strings.EqualFold(data.PayType, order.PayType) {
return false
}
amount, err := strconv.ParseInt(strings.TrimSpace(data.Amount), 10, 64)
return err == nil && amount == order.ProviderAmountMinor
}
func v5PayOrderDataMatchesOrder(data V5PayNotifyData, order ledger.ExternalRechargeOrder) bool {
if data.OrderID != "" && !strings.EqualFold(data.OrderID, order.OrderID) {
return false
}
if data.Currency != "" && !strings.EqualFold(data.Currency, order.CurrencyCode) {
return false
}
if data.ProductType != "" && !strings.EqualFold(data.ProductType, order.PayType) {
return false
}
amount, err := parseProviderAmountMinor(data.Amount)
return err == nil && amount == order.ProviderAmountMinor
}
func parseProviderAmountMinor(value string) (int64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, strconv.ErrSyntax
}
if !strings.Contains(value, ".") {
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil {
return 0, err
}
return parsed * 100, nil
}
parts := strings.SplitN(value, ".", 2)
whole, err := strconv.ParseInt(firstNonEmpty(parts[0], "0"), 10, 64)
if err != nil {
return 0, err
}
fraction := parts[1]
if len(fraction) > 2 {
fraction = fraction[:2]
}
for len(fraction) < 2 {
fraction += "0"
}
minor, err := strconv.ParseInt(fraction, 10, 64)
if err != nil {
return 0, err
}
return whole*100 + minor, nil
}
func mifaPayProviderReturnURL(returnURL string) string {
returnURL = strings.TrimSpace(returnURL)
if returnURL == "" {
return returnURL
}
parsed, err := url.Parse(returnURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return returnURL
}
parsed.RawQuery = ""
parsed.Fragment = ""
return parsed.String()
}
func v5PayProviderReturnURL(returnURL string) string {
return mifaPayProviderReturnURL(returnURL)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func mustJSONText(value any) string {
body, err := json.Marshal(value)
if err != nil {
return "{}"
}
return string(body)
}

View File

@ -0,0 +1,39 @@
package wallet
import (
"context"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
"strings"
)
// ApplyGameCoinChange 是游戏平台唯一的 COIN 改账入口,方向由 op_type 控制。
func (s *Service) ApplyGameCoinChange(ctx context.Context, command ledger.GameCoinChangeCommand) (ledger.GameCoinChangeReceipt, error) {
if command.CommandID == "" || command.UserID <= 0 || command.PlatformCode == "" || command.GameID == "" || command.ProviderOrderID == "" {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "game coin command is incomplete")
}
command.OpType = ledger.NormalizeGameOpType(command.OpType)
if command.OpType == "" {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "game op_type is invalid")
}
if command.CoinAmount <= 0 {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "coin_amount must be positive")
}
command.RequestHash = strings.TrimSpace(command.RequestHash)
if command.RequestHash == "" {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.InvalidArgument, "request_hash is required")
}
if s.repository == nil {
return ledger.GameCoinChangeReceipt{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
command.PlatformCode = strings.TrimSpace(command.PlatformCode)
command.GameID = strings.TrimSpace(command.GameID)
command.ProviderOrderID = strings.TrimSpace(command.ProviderOrderID)
command.ProviderRoundID = strings.TrimSpace(command.ProviderRoundID)
command.RoomID = strings.TrimSpace(command.RoomID)
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.ApplyGameCoinChange(ctx, command)
}

Some files were not shown because too many files have changed in this diff Show More