Merge branch 'main' into test
This commit is contained in:
commit
f453ede08b
3
Makefile
3
Makefile
@ -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
@ -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 {
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
387
docs/wallet-service模块化拆分方案.md
Normal file
387
docs/wallet-service模块化拆分方案.md
Normal 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` 不再承载大批业务 handler;mapper 按业务域归位;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 方法名;这是结构整理,不是协议重构。
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
61
pkg/servicekit/app/background.go
Normal file
61
pkg/servicekit/app/background.go
Normal 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()
|
||||
}
|
||||
22
pkg/servicekit/app/background_test.go
Normal file
22
pkg/servicekit/app/background_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
37
pkg/servicekit/app/http.go
Normal file
37
pkg/servicekit/app/http.go
Normal 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
|
||||
}
|
||||
78
pkg/servicekit/config/config.go
Normal file
78
pkg/servicekit/config/config.go
Normal 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
|
||||
}
|
||||
53
pkg/servicekit/config/config_test.go
Normal file
53
pkg/servicekit/config/config_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
28
pkg/servicekit/grpcserver/grpcserver.go
Normal file
28
pkg/servicekit/grpcserver/grpcserver.go
Normal 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)
|
||||
}
|
||||
56
pkg/servicekit/health/health.go
Normal file
56
pkg/servicekit/health/health.go
Normal 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
|
||||
}
|
||||
48
pkg/servicekit/health/health_test.go
Normal file
48
pkg/servicekit/health/health_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
66
pkg/servicekit/mq/rocketmq.go
Normal file
66
pkg/servicekit/mq/rocketmq.go
Normal 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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
|
||||
14
scripts/mysql/050_login_risk_ip_whitelist.sql
Normal file
14
scripts/mysql/050_login_risk_ip_whitelist.sql
Normal 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 白名单表';
|
||||
48
scripts/prepare-local-rocketmq-config.sh
Executable file
48
scripts/prepare-local-rocketmq-config.sh
Executable 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}"
|
||||
@ -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
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
26
server/admin/internal/modules/giftdiamond/service_test.go
Normal file
26
server/admin/internal/modules/giftdiamond/service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -266,8 +266,9 @@ type HumanRoomRobotCountryPool struct {
|
||||
}
|
||||
|
||||
type HumanRoomRobotCountryRule struct {
|
||||
CountryCode string
|
||||
MaxRoomCount int32
|
||||
CountryCode string
|
||||
MaxRoomCount int32
|
||||
AllowedOwnerIDs []string
|
||||
}
|
||||
|
||||
type HumanRoomRobotConfig struct {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 白名单表';
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 实际生效的登录地区风控词表。
|
||||
// 静态配置和后台动态配置都下发给 App;App 可异步做本地风险提示,最终封禁仍以服务端 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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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 的领域投影。
|
||||
|
||||
@ -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 仍能继续使用。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
28
services/wallet-service/internal/app/background_workers.go
Normal file
28
services/wallet-service/internal/app/background_workers.go
Normal 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)
|
||||
}
|
||||
}
|
||||
20
services/wallet-service/internal/app/clients.go
Normal file
20
services/wallet-service/internal/app/clients.go
Normal 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) != ""
|
||||
}
|
||||
@ -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:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
28
services/wallet-service/internal/app/health.go
Normal file
28
services/wallet-service/internal/app/health.go
Normal 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)
|
||||
}
|
||||
89
services/wallet-service/internal/app/mq.go
Normal file
89
services/wallet-service/internal/app/mq.go
Normal 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)
|
||||
}
|
||||
148
services/wallet-service/internal/app/outbox_worker.go
Normal file
148
services/wallet-service/internal/app/outbox_worker.go
Normal 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()
|
||||
}
|
||||
42
services/wallet-service/internal/app/red_packet_worker.go
Normal file
42
services/wallet-service/internal/app/red_packet_worker.go
Normal 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:
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
138
services/wallet-service/internal/domain/ledger/coin_seller.go
Normal file
138
services/wallet-service/internal/domain/ledger/coin_seller.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
132
services/wallet-service/internal/domain/ledger/constants.go
Normal file
132
services/wallet-service/internal/domain/ledger/constants.go
Normal 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
|
||||
)
|
||||
42
services/wallet-service/internal/domain/ledger/game.go
Normal file
42
services/wallet-service/internal/domain/ledger/game.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
119
services/wallet-service/internal/domain/ledger/gift.go
Normal file
119
services/wallet-service/internal/domain/ledger/gift.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
325
services/wallet-service/internal/domain/ledger/recharge.go
Normal file
325
services/wallet-service/internal/domain/ledger/recharge.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
146
services/wallet-service/internal/domain/ledger/red_packet.go
Normal file
146
services/wallet-service/internal/domain/ledger/red_packet.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
170
services/wallet-service/internal/domain/ledger/reward.go
Normal file
170
services/wallet-service/internal/domain/ledger/reward.go
Normal 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
|
||||
}
|
||||
111
services/wallet-service/internal/domain/ledger/vip.go
Normal file
111
services/wallet-service/internal/domain/ledger/vip.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
195
services/wallet-service/internal/domain/ledger/wallet_query.go
Normal file
195
services/wallet-service/internal/domain/ledger/wallet_query.go
Normal 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
|
||||
}
|
||||
}
|
||||
30
services/wallet-service/internal/domain/resource/badge.go
Normal file
30
services/wallet-service/internal/domain/resource/badge.go
Normal 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"`
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
@ -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
|
||||
}
|
||||
168
services/wallet-service/internal/domain/resource/gift_config.go
Normal file
168
services/wallet-service/internal/domain/resource/gift_config.go
Normal 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
|
||||
}
|
||||
}
|
||||
60
services/wallet-service/internal/domain/resource/grant.go
Normal file
60
services/wallet-service/internal/domain/resource/grant.go
Normal 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
|
||||
}
|
||||
92
services/wallet-service/internal/domain/resource/group.go
Normal file
92
services/wallet-service/internal/domain/resource/group.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
134
services/wallet-service/internal/domain/resource/shop.go
Normal file
134
services/wallet-service/internal/domain/resource/shop.go
Normal 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
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
package resource
|
||||
|
||||
func ValidStatus(value string) bool {
|
||||
switch NormalizeStatus(value) {
|
||||
case StatusActive, StatusDisabled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
98
services/wallet-service/internal/service/wallet/balance.go
Normal file
98
services/wallet-service/internal/service/wallet/balance.go
Normal 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)
|
||||
}
|
||||
151
services/wallet-service/internal/service/wallet/coin_seller.go
Normal file
151
services/wallet-service/internal/service/wallet/coin_seller.go
Normal 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)
|
||||
}
|
||||
36
services/wallet-service/internal/service/wallet/compat.go
Normal file
36
services/wallet-service/internal/service/wallet/compat.go
Normal 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
|
||||
@ -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)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
39
services/wallet-service/internal/service/wallet/game.go
Normal file
39
services/wallet-service/internal/service/wallet/game.go
Normal 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
Loading…
x
Reference in New Issue
Block a user