完善登录功能
This commit is contained in:
parent
1225a0c3a0
commit
11c3f4ddc5
19
Makefile
19
Makefile
@ -3,7 +3,7 @@ SERVICE_ALIASES := gs gateway rs room ws wallet us user as activity mysql redis
|
||||
SERVICE_TOKEN := $(firstword $(filter $(SERVICE_ALIASES),$(MAKECMDGOALS)))
|
||||
SERVICE_ID := $(strip $(or $(SERVICE),$(if $(filter gs gateway,$(SERVICE_TOKEN)),gateway-service,$(if $(filter rs room,$(SERVICE_TOKEN)),room-service,$(if $(filter ws wallet,$(SERVICE_TOKEN)),wallet-service,$(if $(filter us user,$(SERVICE_TOKEN)),user-service,$(if $(filter as activity,$(SERVICE_TOKEN)),activity-service,$(SERVICE_TOKEN))))))))
|
||||
|
||||
.PHONY: proto test build up rebuild restart stop logs ps addr down up-service check-service $(SERVICE_ALIASES)
|
||||
.PHONY: proto test build up db-init rebuild restart stop logs ps addr down up-service check-service $(SERVICE_ALIASES)
|
||||
|
||||
# `proto` 负责把仓库内的 .proto 重新生成成 Go 代码。
|
||||
proto:
|
||||
@ -31,14 +31,26 @@ build:
|
||||
# `up` 后台拉起本地服务;带 SERVICE 时只启动单个服务。
|
||||
up:
|
||||
@if [ -z "$(SERVICE_ID)" ]; then \
|
||||
docker compose up --build -d; \
|
||||
docker compose up -d mysql redis; \
|
||||
./scripts/apply-local-mysql-initdb.sh; \
|
||||
docker compose up --build -d gateway-service room-service wallet-service user-service activity-service; \
|
||||
elif [ "$(SERVICE_ID)" = "mysql" ] || [ "$(SERVICE_ID)" = "redis" ]; then \
|
||||
docker compose up -d $(SERVICE_ID); \
|
||||
else \
|
||||
./scripts/apply-local-mysql-initdb.sh; \
|
||||
docker compose up -d $(SERVICE_ID); \
|
||||
fi
|
||||
./scripts/print-compose-addresses.sh
|
||||
|
||||
# `db-init` 对当前 compose MySQL 补齐所有服务 schema 和本地授权。
|
||||
db-init:
|
||||
./scripts/apply-local-mysql-initdb.sh
|
||||
|
||||
# `rebuild` 重新构建并启动单个本地服务。
|
||||
rebuild: check-service
|
||||
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
|
||||
./scripts/apply-local-mysql-initdb.sh; \
|
||||
fi
|
||||
docker compose up -d --build $(SERVICE_ID)
|
||||
./scripts/print-compose-addresses.sh
|
||||
|
||||
@ -91,6 +103,9 @@ check-service:
|
||||
|
||||
# `up-service` 是 `up SERVICE=...` 的内部实现。
|
||||
up-service: check-service
|
||||
@if [ "$(SERVICE_ID)" != "mysql" ] && [ "$(SERVICE_ID)" != "redis" ]; then \
|
||||
./scripts/apply-local-mysql-initdb.sh; \
|
||||
fi
|
||||
docker compose up -d $(SERVICE_ID)
|
||||
./scripts/print-compose-addresses.sh
|
||||
|
||||
|
||||
@ -110,9 +110,9 @@ docs/voice-room-basic-loop-implementation.md
|
||||
MySQL 是本地和线上运行的持久化底座:
|
||||
|
||||
- `room-service`: `hyapp_room`,保存 room meta、snapshot、command log 和 outbox。
|
||||
- `user-service`: `hy_user`,保存用户主数据、注册资料快照、登录身份、session、默认短号和靓号租约。
|
||||
- `wallet-service`: `hy_wallet`,保存余额、账务流水和扣费幂等记录。
|
||||
- `activity-service`: `hy_activity`,保存活动状态、事件消费幂等和 activity outbox。
|
||||
- `user-service`: `hyapp_user`,保存用户主数据、注册资料快照、登录身份、session、默认短号和靓号租约。
|
||||
- `wallet-service`: `hyapp_wallet`,保存余额、账务流水和扣费幂等记录。
|
||||
- `activity-service`: `hyapp_activity`,保存活动状态、事件消费幂等和 activity outbox。
|
||||
|
||||
`room-service` uses MySQL as the room recovery durable source:
|
||||
|
||||
@ -159,7 +159,7 @@ services/activity-service/ activity foundation and room event consumer boundary
|
||||
|
||||
- 腾讯云 IM 回调入口已经落在 gateway;线上仍必须在腾讯云 IM 控制台启用 `Group.CallbackBeforeApplyJoinGroup` 和 `Group.CallbackBeforeSendMsg`,并配置公网 callback URL 与鉴权 token。
|
||||
- 房间命令未传 `command_id` 时,gateway 会自动生成新值;客户端 HTTP 重试同一业务动作时必须复用原 `command_id` 才能命中 room-service command log 幂等。
|
||||
- `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hy_wallet`。
|
||||
- `wallet-service` App 运行路径使用 MySQL 扣费事务;本地需要先通过 compose initdb 建好 `hyapp_wallet`。
|
||||
- `activity-service` 当前只有同步查询和 consumer 边界;`room_outbox` 到 activity/audit 的实际消费链路未完整实现。
|
||||
- `room-service` App 已启动 `room_outbox` 补偿 worker;该状态只表示腾讯云 IM 系统消息补偿结果,activity/audit 后续必须使用独立消费位点。
|
||||
- JWT、腾讯云 IM secret 等本地配置只能用开发值,线上必须走安全配置源。
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc v5.27.3
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v5.29.2
|
||||
// source: api/proto/activity/v1/activity.proto
|
||||
|
||||
package activityv1
|
||||
@ -11,7 +11,6 @@ import (
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -23,20 +22,23 @@ const (
|
||||
|
||||
// RequestMeta 是 activity-service 内部 RPC 的最小追踪元信息。
|
||||
type RequestMeta struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
||||
Caller string `protobuf:"bytes,2,opt,name=caller,proto3" json:"caller,omitempty"`
|
||||
GatewayNodeId string `protobuf:"bytes,3,opt,name=gateway_node_id,json=gatewayNodeId,proto3" json:"gateway_node_id,omitempty"`
|
||||
SentAtMs int64 `protobuf:"varint,4,opt,name=sent_at_ms,json=sentAtMs,proto3" json:"sent_at_ms,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
||||
Caller string `protobuf:"bytes,2,opt,name=caller,proto3" json:"caller,omitempty"`
|
||||
GatewayNodeId string `protobuf:"bytes,3,opt,name=gateway_node_id,json=gatewayNodeId,proto3" json:"gateway_node_id,omitempty"`
|
||||
SentAtMs int64 `protobuf:"varint,4,opt,name=sent_at_ms,json=sentAtMs,proto3" json:"sent_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
func (x *RequestMeta) Reset() {
|
||||
*x = RequestMeta{}
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *RequestMeta) String() string {
|
||||
@ -47,7 +49,7 @@ func (*RequestMeta) ProtoMessage() {}
|
||||
|
||||
func (x *RequestMeta) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -92,17 +94,20 @@ func (x *RequestMeta) GetSentAtMs() int64 {
|
||||
|
||||
// PingActivityRequest 用于验证 activity-service gRPC 链路可用。
|
||||
type PingActivityRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
func (x *PingActivityRequest) Reset() {
|
||||
*x = PingActivityRequest{}
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *PingActivityRequest) String() string {
|
||||
@ -113,7 +118,7 @@ func (*PingActivityRequest) ProtoMessage() {}
|
||||
|
||||
func (x *PingActivityRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -137,18 +142,21 @@ func (x *PingActivityRequest) GetMeta() *RequestMeta {
|
||||
|
||||
// PingActivityResponse 返回服务是否接受请求。
|
||||
type PingActivityResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
|
||||
NodeId string `protobuf:"bytes,2,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
|
||||
NodeId string `protobuf:"bytes,2,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
|
||||
}
|
||||
|
||||
func (x *PingActivityResponse) Reset() {
|
||||
*x = PingActivityResponse{}
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *PingActivityResponse) String() string {
|
||||
@ -159,7 +167,7 @@ func (*PingActivityResponse) ProtoMessage() {}
|
||||
|
||||
func (x *PingActivityResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -190,18 +198,21 @@ func (x *PingActivityResponse) GetNodeId() string {
|
||||
|
||||
// GetActivityStatusRequest 查询活动服务底座状态。
|
||||
type GetActivityStatusRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
ActivityId string `protobuf:"bytes,2,opt,name=activity_id,json=activityId,proto3" json:"activity_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
ActivityId string `protobuf:"bytes,2,opt,name=activity_id,json=activityId,proto3" json:"activity_id,omitempty"`
|
||||
}
|
||||
|
||||
func (x *GetActivityStatusRequest) Reset() {
|
||||
*x = GetActivityStatusRequest{}
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *GetActivityStatusRequest) String() string {
|
||||
@ -212,7 +223,7 @@ func (*GetActivityStatusRequest) ProtoMessage() {}
|
||||
|
||||
func (x *GetActivityStatusRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -243,18 +254,21 @@ func (x *GetActivityStatusRequest) GetActivityId() string {
|
||||
|
||||
// GetActivityStatusResponse 返回活动是否启用。
|
||||
type GetActivityStatusResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
ActivityId string `protobuf:"bytes,1,opt,name=activity_id,json=activityId,proto3" json:"activity_id,omitempty"`
|
||||
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
ActivityId string `protobuf:"bytes,1,opt,name=activity_id,json=activityId,proto3" json:"activity_id,omitempty"`
|
||||
Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func (x *GetActivityStatusResponse) Reset() {
|
||||
*x = GetActivityStatusResponse{}
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *GetActivityStatusResponse) String() string {
|
||||
@ -265,7 +279,7 @@ func (*GetActivityStatusResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetActivityStatusResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -296,41 +310,68 @@ func (x *GetActivityStatusResponse) GetStatus() string {
|
||||
|
||||
var File_api_proto_activity_v1_activity_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_api_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"$api/proto/activity/v1/activity.proto\x12\x11hyapp.activity.v1\"\x8a\x01\n" +
|
||||
"\vRequestMeta\x12\x1d\n" +
|
||||
"\n" +
|
||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x16\n" +
|
||||
"\x06caller\x18\x02 \x01(\tR\x06caller\x12&\n" +
|
||||
"\x0fgateway_node_id\x18\x03 \x01(\tR\rgatewayNodeId\x12\x1c\n" +
|
||||
"\n" +
|
||||
"sent_at_ms\x18\x04 \x01(\x03R\bsentAtMs\"I\n" +
|
||||
"\x13PingActivityRequest\x122\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\"?\n" +
|
||||
"\x14PingActivityResponse\x12\x0e\n" +
|
||||
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x17\n" +
|
||||
"\anode_id\x18\x02 \x01(\tR\x06nodeId\"o\n" +
|
||||
"\x18GetActivityStatusRequest\x122\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x1f\n" +
|
||||
"\vactivity_id\x18\x02 \x01(\tR\n" +
|
||||
"activityId\"T\n" +
|
||||
"\x19GetActivityStatusResponse\x12\x1f\n" +
|
||||
"\vactivity_id\x18\x01 \x01(\tR\n" +
|
||||
"activityId\x12\x16\n" +
|
||||
"\x06status\x18\x02 \x01(\tR\x06status2\xe2\x01\n" +
|
||||
"\x0fActivityService\x12_\n" +
|
||||
"\fPingActivity\x12&.hyapp.activity.v1.PingActivityRequest\x1a'.hyapp.activity.v1.PingActivityResponse\x12n\n" +
|
||||
"\x11GetActivityStatus\x12+.hyapp.activity.v1.GetActivityStatusRequest\x1a,.hyapp.activity.v1.GetActivityStatusResponseB(Z&hyapp/api/proto/activity/v1;activityv1b\x06proto3"
|
||||
var file_api_proto_activity_v1_activity_proto_rawDesc = []byte{
|
||||
0x0a, 0x24, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x63, 0x74, 0x69,
|
||||
0x76, 0x69, 0x74, 0x79, 0x2f, 0x76, 0x31, 0x2f, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79,
|
||||
0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x11, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x61, 0x63,
|
||||
0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x22, 0x8a, 0x01, 0x0a, 0x0b, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x61, 0x6c, 0x6c,
|
||||
0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x61, 0x6c, 0x6c, 0x65, 0x72,
|
||||
0x12, 0x26, 0x0a, 0x0f, 0x67, 0x61, 0x74, 0x65, 0x77, 0x61, 0x79, 0x5f, 0x6e, 0x6f, 0x64, 0x65,
|
||||
0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x67, 0x61, 0x74, 0x65, 0x77,
|
||||
0x61, 0x79, 0x4e, 0x6f, 0x64, 0x65, 0x49, 0x64, 0x12, 0x1c, 0x0a, 0x0a, 0x73, 0x65, 0x6e, 0x74,
|
||||
0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x73, 0x65,
|
||||
0x6e, 0x74, 0x41, 0x74, 0x4d, 0x73, 0x22, 0x49, 0x0a, 0x13, 0x50, 0x69, 0x6e, 0x67, 0x41, 0x63,
|
||||
0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a,
|
||||
0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x68, 0x79,
|
||||
0x61, 0x70, 0x70, 0x2e, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x2e,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74,
|
||||
0x61, 0x22, 0x3f, 0x0a, 0x14, 0x50, 0x69, 0x6e, 0x67, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74,
|
||||
0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x6f, 0x6b, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x02, 0x6f, 0x6b, 0x12, 0x17, 0x0a, 0x07, 0x6e, 0x6f, 0x64,
|
||||
0x65, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6e, 0x6f, 0x64, 0x65,
|
||||
0x49, 0x64, 0x22, 0x6f, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74,
|
||||
0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32,
|
||||
0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x68,
|
||||
0x79, 0x61, 0x70, 0x70, 0x2e, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31,
|
||||
0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65,
|
||||
0x74, 0x61, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74,
|
||||
0x79, 0x49, 0x64, 0x22, 0x54, 0x0a, 0x19, 0x47, 0x65, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69,
|
||||
0x74, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x5f, 0x69, 0x64, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x49,
|
||||
0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x32, 0xe2, 0x01, 0x0a, 0x0f, 0x41, 0x63,
|
||||
0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a,
|
||||
0x0c, 0x50, 0x69, 0x6e, 0x67, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x12, 0x26, 0x2e,
|
||||
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x2e, 0x76,
|
||||
0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x61, 0x63,
|
||||
0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x69, 0x6e, 0x67, 0x41, 0x63,
|
||||
0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6e,
|
||||
0x0a, 0x11, 0x47, 0x65, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x53, 0x74, 0x61,
|
||||
0x74, 0x75, 0x73, 0x12, 0x2b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x61, 0x63, 0x74, 0x69,
|
||||
0x76, 0x69, 0x74, 0x79, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76,
|
||||
0x69, 0x74, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x2c, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74,
|
||||
0x79, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79,
|
||||
0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x28,
|
||||
0x5a, 0x26, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x2f, 0x61, 0x63, 0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x2f, 0x76, 0x31, 0x3b, 0x61, 0x63,
|
||||
0x74, 0x69, 0x76, 0x69, 0x74, 0x79, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_api_proto_activity_v1_activity_proto_rawDescOnce sync.Once
|
||||
file_api_proto_activity_v1_activity_proto_rawDescData []byte
|
||||
file_api_proto_activity_v1_activity_proto_rawDescData = file_api_proto_activity_v1_activity_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_api_proto_activity_v1_activity_proto_rawDescGZIP() []byte {
|
||||
file_api_proto_activity_v1_activity_proto_rawDescOnce.Do(func() {
|
||||
file_api_proto_activity_v1_activity_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_proto_activity_v1_activity_proto_rawDesc), len(file_api_proto_activity_v1_activity_proto_rawDesc)))
|
||||
file_api_proto_activity_v1_activity_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_proto_activity_v1_activity_proto_rawDescData)
|
||||
})
|
||||
return file_api_proto_activity_v1_activity_proto_rawDescData
|
||||
}
|
||||
@ -362,11 +403,73 @@ func file_api_proto_activity_v1_activity_proto_init() {
|
||||
if File_api_proto_activity_v1_activity_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_api_proto_activity_v1_activity_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RequestMeta); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_activity_v1_activity_proto_msgTypes[1].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*PingActivityRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_activity_v1_activity_proto_msgTypes[2].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*PingActivityResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_activity_v1_activity_proto_msgTypes[3].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*GetActivityStatusRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_activity_v1_activity_proto_msgTypes[4].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*GetActivityStatusResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_proto_activity_v1_activity_proto_rawDesc), len(file_api_proto_activity_v1_activity_proto_rawDesc)),
|
||||
RawDescriptor: file_api_proto_activity_v1_activity_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 5,
|
||||
NumExtensions: 0,
|
||||
@ -377,6 +480,7 @@ func file_api_proto_activity_v1_activity_proto_init() {
|
||||
MessageInfos: file_api_proto_activity_v1_activity_proto_msgTypes,
|
||||
}.Build()
|
||||
File_api_proto_activity_v1_activity_proto = out.File
|
||||
file_api_proto_activity_v1_activity_proto_rawDesc = nil
|
||||
file_api_proto_activity_v1_activity_proto_goTypes = nil
|
||||
file_api_proto_activity_v1_activity_proto_depIdxs = nil
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.27.3
|
||||
// - protoc v5.29.2
|
||||
// source: api/proto/activity/v1/activity.proto
|
||||
|
||||
package activityv1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -85,6 +85,8 @@ message CreateRoomRequest {
|
||||
int32 seat_count = 2;
|
||||
// mode 必须非空;当前只作为房间状态字段保存和透出。
|
||||
string mode = 3;
|
||||
// visible_region_id 由 gateway 按创建者 user-service region_id 填充;0 表示 GLOBAL 兜底列表桶。
|
||||
int64 visible_region_id = 4;
|
||||
}
|
||||
|
||||
// CreateRoomResponse 返回新建后的房间快照。
|
||||
@ -311,6 +313,39 @@ message VerifyRoomPresenceResponse {
|
||||
int64 room_version = 3;
|
||||
}
|
||||
|
||||
// ListRoomsRequest 查询当前用户所在区域可见的房间列表。
|
||||
// visible_region_id 必须来自 gateway 对 user-service 的 GetUser 结果,客户端不能直接提交。
|
||||
message ListRoomsRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 viewer_user_id = 2;
|
||||
int64 visible_region_id = 3;
|
||||
string tab = 4;
|
||||
string cursor = 5;
|
||||
int32 limit = 6;
|
||||
}
|
||||
|
||||
// RoomListItem 是房间发现页卡片读模型,不承载完整 Room Cell 状态。
|
||||
message RoomListItem {
|
||||
string room_id = 1;
|
||||
int64 owner_user_id = 2;
|
||||
int64 host_user_id = 3;
|
||||
string title = 4;
|
||||
string cover_url = 5;
|
||||
string mode = 6;
|
||||
string status = 7;
|
||||
int64 heat = 8;
|
||||
int32 online_count = 9;
|
||||
int32 seat_count = 10;
|
||||
int32 occupied_seat_count = 11;
|
||||
int64 visible_region_id = 12;
|
||||
}
|
||||
|
||||
// ListRoomsResponse 返回一页房间卡片和下一页不透明 cursor。
|
||||
message ListRoomsResponse {
|
||||
repeated RoomListItem rooms = 1;
|
||||
string next_cursor = 2;
|
||||
}
|
||||
|
||||
// RoomCommandService 承载所有会改变房间状态的命令。
|
||||
service RoomCommandService {
|
||||
rpc CreateRoom(CreateRoomRequest) returns (CreateRoomResponse);
|
||||
@ -335,3 +370,8 @@ service RoomGuardService {
|
||||
rpc CheckSpeakPermission(CheckSpeakPermissionRequest) returns (CheckSpeakPermissionResponse);
|
||||
rpc VerifyRoomPresence(VerifyRoomPresenceRequest) returns (VerifyRoomPresenceResponse);
|
||||
}
|
||||
|
||||
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
|
||||
service RoomQueryService {
|
||||
rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse);
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.27.3
|
||||
// - protoc v5.29.2
|
||||
// source: api/proto/room/v1/room.proto
|
||||
|
||||
package roomv1
|
||||
@ -799,3 +799,109 @@ var RoomGuardService_ServiceDesc = grpc.ServiceDesc{
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "api/proto/room/v1/room.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
RoomQueryService_ListRooms_FullMethodName = "/hyapp.room.v1.RoomQueryService/ListRooms"
|
||||
)
|
||||
|
||||
// RoomQueryServiceClient is the client API for RoomQueryService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
|
||||
type RoomQueryServiceClient interface {
|
||||
ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error)
|
||||
}
|
||||
|
||||
type roomQueryServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewRoomQueryServiceClient(cc grpc.ClientConnInterface) RoomQueryServiceClient {
|
||||
return &roomQueryServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *roomQueryServiceClient) ListRooms(ctx context.Context, in *ListRoomsRequest, opts ...grpc.CallOption) (*ListRoomsResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListRoomsResponse)
|
||||
err := c.cc.Invoke(ctx, RoomQueryService_ListRooms_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RoomQueryServiceServer is the server API for RoomQueryService service.
|
||||
// All implementations must embed UnimplementedRoomQueryServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// RoomQueryService 承载不会改变 Room Cell 状态的读模型查询。
|
||||
type RoomQueryServiceServer interface {
|
||||
ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error)
|
||||
mustEmbedUnimplementedRoomQueryServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedRoomQueryServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedRoomQueryServiceServer struct{}
|
||||
|
||||
func (UnimplementedRoomQueryServiceServer) ListRooms(context.Context, *ListRoomsRequest) (*ListRoomsResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListRooms not implemented")
|
||||
}
|
||||
func (UnimplementedRoomQueryServiceServer) mustEmbedUnimplementedRoomQueryServiceServer() {}
|
||||
func (UnimplementedRoomQueryServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeRoomQueryServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to RoomQueryServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeRoomQueryServiceServer interface {
|
||||
mustEmbedUnimplementedRoomQueryServiceServer()
|
||||
}
|
||||
|
||||
func RegisterRoomQueryServiceServer(s grpc.ServiceRegistrar, srv RoomQueryServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedRoomQueryServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&RoomQueryService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _RoomQueryService_ListRooms_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRoomsRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomQueryServiceServer).ListRooms(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomQueryService_ListRooms_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomQueryServiceServer).ListRooms(ctx, req.(*ListRoomsRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// RoomQueryService_ServiceDesc is the grpc.ServiceDesc for RoomQueryService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var RoomQueryService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "hyapp.room.v1.RoomQueryService",
|
||||
HandlerType: (*RoomQueryServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "ListRooms",
|
||||
Handler: _RoomQueryService_ListRooms_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "api/proto/room/v1/room.proto",
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -6,20 +6,6 @@ import "api/proto/user/v1/user.proto";
|
||||
|
||||
option go_package = "hyapp/api/proto/user/v1;userv1";
|
||||
|
||||
// AuthToken 是登录和刷新后返回给 gateway 的令牌集合。
|
||||
message AuthToken {
|
||||
int64 user_id = 1;
|
||||
string session_id = 2;
|
||||
string access_token = 3;
|
||||
string refresh_token = 4;
|
||||
int64 expires_in_sec = 5;
|
||||
string token_type = 6;
|
||||
string display_user_id = 7;
|
||||
string default_display_user_id = 8;
|
||||
string display_user_id_kind = 9;
|
||||
int64 display_user_id_expires_at_ms = 10;
|
||||
}
|
||||
|
||||
// LoginPasswordRequest 使用当前 display_user_id 和已设置密码登录。
|
||||
message LoginPasswordRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -55,6 +41,8 @@ message LoginThirdPartyRequest {
|
||||
message AuthResponse {
|
||||
AuthToken token = 1;
|
||||
bool is_new_user = 2;
|
||||
bool profile_completed = 3;
|
||||
string onboarding_status = 4;
|
||||
}
|
||||
|
||||
// SetPasswordRequest 为已经三方登录的用户首次设置密码。
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.27.3
|
||||
// - protoc v5.29.2
|
||||
// source: api/proto/user/v1/auth.proto
|
||||
|
||||
package userv1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -14,6 +14,7 @@ message RequestMeta {
|
||||
string client_ip = 6;
|
||||
string user_agent = 7;
|
||||
string country_by_ip = 8;
|
||||
string session_id = 9;
|
||||
}
|
||||
|
||||
// UserStatus 表达用户主状态,业务服务只消费稳定枚举。
|
||||
@ -46,6 +47,28 @@ message User {
|
||||
int64 region_id = 18;
|
||||
string region_code = 19;
|
||||
string region_name = 20;
|
||||
bool profile_completed = 21;
|
||||
int64 profile_completed_at_ms = 22;
|
||||
string onboarding_status = 23;
|
||||
string iso_numeric = 24;
|
||||
string phone_country_code = 25;
|
||||
bool country_enabled = 26;
|
||||
}
|
||||
|
||||
// AuthToken 是登录、刷新和注册完成后返回给 gateway 的访问令牌投影。
|
||||
message AuthToken {
|
||||
int64 user_id = 1;
|
||||
string session_id = 2;
|
||||
string access_token = 3;
|
||||
string refresh_token = 4;
|
||||
int64 expires_in_sec = 5;
|
||||
string token_type = 6;
|
||||
string display_user_id = 7;
|
||||
string default_display_user_id = 8;
|
||||
string display_user_id_kind = 9;
|
||||
int64 display_user_id_expires_at_ms = 10;
|
||||
bool profile_completed = 11;
|
||||
string onboarding_status = 12;
|
||||
}
|
||||
|
||||
// GetUserRequest 查询单个用户主状态。
|
||||
@ -97,16 +120,38 @@ message ChangeUserCountryResponse {
|
||||
int64 next_change_allowed_at_ms = 2;
|
||||
}
|
||||
|
||||
// CompleteOnboardingRequest 是注册页唯一资料提交入口。
|
||||
message CompleteOnboardingRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
string username = 3;
|
||||
string avatar = 4;
|
||||
string country = 5;
|
||||
}
|
||||
|
||||
// CompleteOnboardingResponse 返回固化后的最小注册状态。
|
||||
message CompleteOnboardingResponse {
|
||||
User user = 1;
|
||||
bool profile_completed = 2;
|
||||
int64 profile_completed_at_ms = 3;
|
||||
string onboarding_status = 4;
|
||||
AuthToken token = 5;
|
||||
}
|
||||
|
||||
// Country 是国家主数据投影,客户端和普通用户流程只提交 country_code。
|
||||
message Country {
|
||||
int64 country_id = 1;
|
||||
string country_name = 2;
|
||||
string country_code = 3;
|
||||
string country_display_name = 4;
|
||||
string status = 5;
|
||||
int32 sort_order = 6;
|
||||
int64 created_at_ms = 7;
|
||||
int64 updated_at_ms = 8;
|
||||
string iso_alpha3 = 9;
|
||||
string iso_numeric = 10;
|
||||
string phone_country_code = 11;
|
||||
bool enabled = 12;
|
||||
string flag = 13;
|
||||
}
|
||||
|
||||
// Region 是区域主数据投影,countries 只包含当前 active 国家码。
|
||||
@ -129,11 +174,16 @@ message CreateCountryRequest {
|
||||
string country_display_name = 4;
|
||||
int32 sort_order = 5;
|
||||
int64 operator_user_id = 6;
|
||||
string iso_alpha3 = 7;
|
||||
string iso_numeric = 8;
|
||||
string phone_country_code = 9;
|
||||
optional bool enabled = 10;
|
||||
string flag = 11;
|
||||
}
|
||||
|
||||
message ListCountriesRequest {
|
||||
RequestMeta meta = 1;
|
||||
string status = 2;
|
||||
optional bool enabled = 3;
|
||||
}
|
||||
|
||||
message ListCountriesResponse {
|
||||
@ -148,6 +198,16 @@ message UpdateCountryRequest {
|
||||
string country_display_name = 4;
|
||||
int32 sort_order = 5;
|
||||
int64 operator_user_id = 6;
|
||||
string iso_alpha3 = 7;
|
||||
string iso_numeric = 8;
|
||||
string phone_country_code = 9;
|
||||
string flag = 10;
|
||||
}
|
||||
|
||||
message EnableCountryRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 country_id = 2;
|
||||
int64 operator_user_id = 3;
|
||||
}
|
||||
|
||||
message DisableCountryRequest {
|
||||
@ -160,6 +220,14 @@ message CountryResponse {
|
||||
Country country = 1;
|
||||
}
|
||||
|
||||
message ListRegistrationCountriesRequest {
|
||||
RequestMeta meta = 1;
|
||||
}
|
||||
|
||||
message ListRegistrationCountriesResponse {
|
||||
repeated Country countries = 1;
|
||||
}
|
||||
|
||||
// CreateRegionRequest 创建区域并配置 active 国家列表。
|
||||
message CreateRegionRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -290,6 +358,7 @@ service UserService {
|
||||
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
|
||||
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
|
||||
rpc ChangeUserCountry(ChangeUserCountryRequest) returns (ChangeUserCountryResponse);
|
||||
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
|
||||
}
|
||||
|
||||
// CountryAdminService 承载国家主数据管理 RPC,公网暴露前必须由 gateway admin 鉴权。
|
||||
@ -297,9 +366,15 @@ service CountryAdminService {
|
||||
rpc CreateCountry(CreateCountryRequest) returns (CountryResponse);
|
||||
rpc ListCountries(ListCountriesRequest) returns (ListCountriesResponse);
|
||||
rpc UpdateCountry(UpdateCountryRequest) returns (CountryResponse);
|
||||
rpc EnableCountry(EnableCountryRequest) returns (CountryResponse);
|
||||
rpc DisableCountry(DisableCountryRequest) returns (CountryResponse);
|
||||
}
|
||||
|
||||
// CountryQueryService 承载 App 注册页公开国家列表查询。
|
||||
service CountryQueryService {
|
||||
rpc ListRegistrationCountries(ListRegistrationCountriesRequest) returns (ListRegistrationCountriesResponse);
|
||||
}
|
||||
|
||||
// RegionAdminService 承载区域和国家归属管理 RPC,公网暴露前必须由 gateway admin 鉴权。
|
||||
service RegionAdminService {
|
||||
rpc CreateRegion(CreateRegionRequest) returns (RegionResponse);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.27.3
|
||||
// - protoc v5.29.2
|
||||
// source: api/proto/user/v1/user.proto
|
||||
|
||||
package userv1
|
||||
@ -19,10 +19,11 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
|
||||
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
|
||||
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
|
||||
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
|
||||
UserService_GetUser_FullMethodName = "/hyapp.user.v1.UserService/GetUser"
|
||||
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
|
||||
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
|
||||
UserService_ChangeUserCountry_FullMethodName = "/hyapp.user.v1.UserService/ChangeUserCountry"
|
||||
UserService_CompleteOnboarding_FullMethodName = "/hyapp.user.v1.UserService/CompleteOnboarding"
|
||||
)
|
||||
|
||||
// UserServiceClient is the client API for UserService service.
|
||||
@ -35,6 +36,7 @@ type UserServiceClient interface {
|
||||
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
|
||||
UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error)
|
||||
ChangeUserCountry(ctx context.Context, in *ChangeUserCountryRequest, opts ...grpc.CallOption) (*ChangeUserCountryResponse, error)
|
||||
CompleteOnboarding(ctx context.Context, in *CompleteOnboardingRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error)
|
||||
}
|
||||
|
||||
type userServiceClient struct {
|
||||
@ -85,6 +87,16 @@ func (c *userServiceClient) ChangeUserCountry(ctx context.Context, in *ChangeUse
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceClient) CompleteOnboarding(ctx context.Context, in *CompleteOnboardingRequest, opts ...grpc.CallOption) (*CompleteOnboardingResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CompleteOnboardingResponse)
|
||||
err := c.cc.Invoke(ctx, UserService_CompleteOnboarding_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UserServiceServer is the server API for UserService service.
|
||||
// All implementations must embed UnimplementedUserServiceServer
|
||||
// for forward compatibility.
|
||||
@ -95,6 +107,7 @@ type UserServiceServer interface {
|
||||
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
|
||||
UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error)
|
||||
ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error)
|
||||
CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error)
|
||||
mustEmbedUnimplementedUserServiceServer()
|
||||
}
|
||||
|
||||
@ -117,6 +130,9 @@ func (UnimplementedUserServiceServer) UpdateUserProfile(context.Context, *Update
|
||||
func (UnimplementedUserServiceServer) ChangeUserCountry(context.Context, *ChangeUserCountryRequest) (*ChangeUserCountryResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ChangeUserCountry not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) CompleteOnboarding(context.Context, *CompleteOnboardingRequest) (*CompleteOnboardingResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method CompleteOnboarding not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) mustEmbedUnimplementedUserServiceServer() {}
|
||||
func (UnimplementedUserServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -210,6 +226,24 @@ func _UserService_ChangeUserCountry_Handler(srv interface{}, ctx context.Context
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserService_CompleteOnboarding_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(CompleteOnboardingRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserServiceServer).CompleteOnboarding(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserService_CompleteOnboarding_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserServiceServer).CompleteOnboarding(ctx, req.(*CompleteOnboardingRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// UserService_ServiceDesc is the grpc.ServiceDesc for UserService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -233,6 +267,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ChangeUserCountry",
|
||||
Handler: _UserService_ChangeUserCountry_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "CompleteOnboarding",
|
||||
Handler: _UserService_CompleteOnboarding_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "api/proto/user/v1/user.proto",
|
||||
@ -242,6 +280,7 @@ const (
|
||||
CountryAdminService_CreateCountry_FullMethodName = "/hyapp.user.v1.CountryAdminService/CreateCountry"
|
||||
CountryAdminService_ListCountries_FullMethodName = "/hyapp.user.v1.CountryAdminService/ListCountries"
|
||||
CountryAdminService_UpdateCountry_FullMethodName = "/hyapp.user.v1.CountryAdminService/UpdateCountry"
|
||||
CountryAdminService_EnableCountry_FullMethodName = "/hyapp.user.v1.CountryAdminService/EnableCountry"
|
||||
CountryAdminService_DisableCountry_FullMethodName = "/hyapp.user.v1.CountryAdminService/DisableCountry"
|
||||
)
|
||||
|
||||
@ -254,6 +293,7 @@ type CountryAdminServiceClient interface {
|
||||
CreateCountry(ctx context.Context, in *CreateCountryRequest, opts ...grpc.CallOption) (*CountryResponse, error)
|
||||
ListCountries(ctx context.Context, in *ListCountriesRequest, opts ...grpc.CallOption) (*ListCountriesResponse, error)
|
||||
UpdateCountry(ctx context.Context, in *UpdateCountryRequest, opts ...grpc.CallOption) (*CountryResponse, error)
|
||||
EnableCountry(ctx context.Context, in *EnableCountryRequest, opts ...grpc.CallOption) (*CountryResponse, error)
|
||||
DisableCountry(ctx context.Context, in *DisableCountryRequest, opts ...grpc.CallOption) (*CountryResponse, error)
|
||||
}
|
||||
|
||||
@ -295,6 +335,16 @@ func (c *countryAdminServiceClient) UpdateCountry(ctx context.Context, in *Updat
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *countryAdminServiceClient) EnableCountry(ctx context.Context, in *EnableCountryRequest, opts ...grpc.CallOption) (*CountryResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CountryResponse)
|
||||
err := c.cc.Invoke(ctx, CountryAdminService_EnableCountry_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *countryAdminServiceClient) DisableCountry(ctx context.Context, in *DisableCountryRequest, opts ...grpc.CallOption) (*CountryResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(CountryResponse)
|
||||
@ -314,6 +364,7 @@ type CountryAdminServiceServer interface {
|
||||
CreateCountry(context.Context, *CreateCountryRequest) (*CountryResponse, error)
|
||||
ListCountries(context.Context, *ListCountriesRequest) (*ListCountriesResponse, error)
|
||||
UpdateCountry(context.Context, *UpdateCountryRequest) (*CountryResponse, error)
|
||||
EnableCountry(context.Context, *EnableCountryRequest) (*CountryResponse, error)
|
||||
DisableCountry(context.Context, *DisableCountryRequest) (*CountryResponse, error)
|
||||
mustEmbedUnimplementedCountryAdminServiceServer()
|
||||
}
|
||||
@ -334,6 +385,9 @@ func (UnimplementedCountryAdminServiceServer) ListCountries(context.Context, *Li
|
||||
func (UnimplementedCountryAdminServiceServer) UpdateCountry(context.Context, *UpdateCountryRequest) (*CountryResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UpdateCountry not implemented")
|
||||
}
|
||||
func (UnimplementedCountryAdminServiceServer) EnableCountry(context.Context, *EnableCountryRequest) (*CountryResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method EnableCountry not implemented")
|
||||
}
|
||||
func (UnimplementedCountryAdminServiceServer) DisableCountry(context.Context, *DisableCountryRequest) (*CountryResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DisableCountry not implemented")
|
||||
}
|
||||
@ -412,6 +466,24 @@ func _CountryAdminService_UpdateCountry_Handler(srv interface{}, ctx context.Con
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CountryAdminService_EnableCountry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(EnableCountryRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CountryAdminServiceServer).EnableCountry(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CountryAdminService_EnableCountry_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CountryAdminServiceServer).EnableCountry(ctx, req.(*EnableCountryRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _CountryAdminService_DisableCountry_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(DisableCountryRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -449,6 +521,10 @@ var CountryAdminService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "UpdateCountry",
|
||||
Handler: _CountryAdminService_UpdateCountry_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "EnableCountry",
|
||||
Handler: _CountryAdminService_EnableCountry_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "DisableCountry",
|
||||
Handler: _CountryAdminService_DisableCountry_Handler,
|
||||
@ -458,6 +534,112 @@ var CountryAdminService_ServiceDesc = grpc.ServiceDesc{
|
||||
Metadata: "api/proto/user/v1/user.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
CountryQueryService_ListRegistrationCountries_FullMethodName = "/hyapp.user.v1.CountryQueryService/ListRegistrationCountries"
|
||||
)
|
||||
|
||||
// CountryQueryServiceClient is the client API for CountryQueryService service.
|
||||
//
|
||||
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
|
||||
//
|
||||
// CountryQueryService 承载 App 注册页公开国家列表查询。
|
||||
type CountryQueryServiceClient interface {
|
||||
ListRegistrationCountries(ctx context.Context, in *ListRegistrationCountriesRequest, opts ...grpc.CallOption) (*ListRegistrationCountriesResponse, error)
|
||||
}
|
||||
|
||||
type countryQueryServiceClient struct {
|
||||
cc grpc.ClientConnInterface
|
||||
}
|
||||
|
||||
func NewCountryQueryServiceClient(cc grpc.ClientConnInterface) CountryQueryServiceClient {
|
||||
return &countryQueryServiceClient{cc}
|
||||
}
|
||||
|
||||
func (c *countryQueryServiceClient) ListRegistrationCountries(ctx context.Context, in *ListRegistrationCountriesRequest, opts ...grpc.CallOption) (*ListRegistrationCountriesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListRegistrationCountriesResponse)
|
||||
err := c.cc.Invoke(ctx, CountryQueryService_ListRegistrationCountries_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CountryQueryServiceServer is the server API for CountryQueryService service.
|
||||
// All implementations must embed UnimplementedCountryQueryServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// CountryQueryService 承载 App 注册页公开国家列表查询。
|
||||
type CountryQueryServiceServer interface {
|
||||
ListRegistrationCountries(context.Context, *ListRegistrationCountriesRequest) (*ListRegistrationCountriesResponse, error)
|
||||
mustEmbedUnimplementedCountryQueryServiceServer()
|
||||
}
|
||||
|
||||
// UnimplementedCountryQueryServiceServer must be embedded to have
|
||||
// forward compatible implementations.
|
||||
//
|
||||
// NOTE: this should be embedded by value instead of pointer to avoid a nil
|
||||
// pointer dereference when methods are called.
|
||||
type UnimplementedCountryQueryServiceServer struct{}
|
||||
|
||||
func (UnimplementedCountryQueryServiceServer) ListRegistrationCountries(context.Context, *ListRegistrationCountriesRequest) (*ListRegistrationCountriesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ListRegistrationCountries not implemented")
|
||||
}
|
||||
func (UnimplementedCountryQueryServiceServer) mustEmbedUnimplementedCountryQueryServiceServer() {}
|
||||
func (UnimplementedCountryQueryServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
// UnsafeCountryQueryServiceServer may be embedded to opt out of forward compatibility for this service.
|
||||
// Use of this interface is not recommended, as added methods to CountryQueryServiceServer will
|
||||
// result in compilation errors.
|
||||
type UnsafeCountryQueryServiceServer interface {
|
||||
mustEmbedUnimplementedCountryQueryServiceServer()
|
||||
}
|
||||
|
||||
func RegisterCountryQueryServiceServer(s grpc.ServiceRegistrar, srv CountryQueryServiceServer) {
|
||||
// If the following call pancis, it indicates UnimplementedCountryQueryServiceServer was
|
||||
// embedded by pointer and is nil. This will cause panics if an
|
||||
// unimplemented method is ever invoked, so we test this at initialization
|
||||
// time to prevent it from happening at runtime later due to I/O.
|
||||
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
|
||||
t.testEmbeddedByValue()
|
||||
}
|
||||
s.RegisterService(&CountryQueryService_ServiceDesc, srv)
|
||||
}
|
||||
|
||||
func _CountryQueryService_ListRegistrationCountries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListRegistrationCountriesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(CountryQueryServiceServer).ListRegistrationCountries(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: CountryQueryService_ListRegistrationCountries_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(CountryQueryServiceServer).ListRegistrationCountries(ctx, req.(*ListRegistrationCountriesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// CountryQueryService_ServiceDesc is the grpc.ServiceDesc for CountryQueryService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
var CountryQueryService_ServiceDesc = grpc.ServiceDesc{
|
||||
ServiceName: "hyapp.user.v1.CountryQueryService",
|
||||
HandlerType: (*CountryQueryServiceServer)(nil),
|
||||
Methods: []grpc.MethodDesc{
|
||||
{
|
||||
MethodName: "ListRegistrationCountries",
|
||||
Handler: _CountryQueryService_ListRegistrationCountries_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "api/proto/user/v1/user.proto",
|
||||
}
|
||||
|
||||
const (
|
||||
RegionAdminService_CreateRegion_FullMethodName = "/hyapp.user.v1.RegionAdminService/CreateRegion"
|
||||
RegionAdminService_ListRegions_FullMethodName = "/hyapp.user.v1.RegionAdminService/ListRegions"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc v5.27.3
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v5.29.2
|
||||
// source: api/proto/wallet/v1/wallet.proto
|
||||
|
||||
package walletv1
|
||||
@ -11,7 +11,6 @@ import (
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -23,24 +22,27 @@ const (
|
||||
|
||||
// DebitGiftRequest 是 room-service 送礼同步扣费的最小账务输入。
|
||||
type DebitGiftRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
|
||||
RoomId string `protobuf:"bytes,2,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
|
||||
SenderUserId int64 `protobuf:"varint,3,opt,name=sender_user_id,json=senderUserId,proto3" json:"sender_user_id,omitempty"`
|
||||
TargetUserId int64 `protobuf:"varint,4,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
|
||||
GiftId string `protobuf:"bytes,5,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
|
||||
GiftCount int32 `protobuf:"varint,6,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"`
|
||||
// price_version 为空时使用当前生效价格;非空时必须命中 active 价格版本。
|
||||
PriceVersion string `protobuf:"bytes,7,opt,name=price_version,json=priceVersion,proto3" json:"price_version,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
|
||||
RoomId string `protobuf:"bytes,2,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
|
||||
SenderUserId int64 `protobuf:"varint,3,opt,name=sender_user_id,json=senderUserId,proto3" json:"sender_user_id,omitempty"`
|
||||
TargetUserId int64 `protobuf:"varint,4,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
|
||||
GiftId string `protobuf:"bytes,5,opt,name=gift_id,json=giftId,proto3" json:"gift_id,omitempty"`
|
||||
GiftCount int32 `protobuf:"varint,6,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"`
|
||||
// price_version 为空时使用当前生效价格;非空时必须命中 active 价格版本。
|
||||
PriceVersion string `protobuf:"bytes,7,opt,name=price_version,json=priceVersion,proto3" json:"price_version,omitempty"`
|
||||
}
|
||||
|
||||
func (x *DebitGiftRequest) Reset() {
|
||||
*x = DebitGiftRequest{}
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *DebitGiftRequest) String() string {
|
||||
@ -51,7 +53,7 @@ func (*DebitGiftRequest) ProtoMessage() {}
|
||||
|
||||
func (x *DebitGiftRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[0]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -117,24 +119,27 @@ func (x *DebitGiftRequest) GetPriceVersion() string {
|
||||
|
||||
// DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。
|
||||
type DebitGiftResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
BillingReceiptId string `protobuf:"bytes,1,opt,name=billing_receipt_id,json=billingReceiptId,proto3" json:"billing_receipt_id,omitempty"`
|
||||
TransactionId string `protobuf:"bytes,2,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
CoinSpent int64 `protobuf:"varint,3,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"`
|
||||
GiftPointAdded int64 `protobuf:"varint,4,opt,name=gift_point_added,json=giftPointAdded,proto3" json:"gift_point_added,omitempty"`
|
||||
HeatValue int64 `protobuf:"varint,5,opt,name=heat_value,json=heatValue,proto3" json:"heat_value,omitempty"`
|
||||
PriceVersion string `protobuf:"bytes,6,opt,name=price_version,json=priceVersion,proto3" json:"price_version,omitempty"`
|
||||
// balance_after 是 sender COIN available_amount 账后余额。
|
||||
BalanceAfter int64 `protobuf:"varint,7,opt,name=balance_after,json=balanceAfter,proto3" json:"balance_after,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
BillingReceiptId string `protobuf:"bytes,1,opt,name=billing_receipt_id,json=billingReceiptId,proto3" json:"billing_receipt_id,omitempty"`
|
||||
TransactionId string `protobuf:"bytes,2,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
CoinSpent int64 `protobuf:"varint,3,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"`
|
||||
GiftPointAdded int64 `protobuf:"varint,4,opt,name=gift_point_added,json=giftPointAdded,proto3" json:"gift_point_added,omitempty"`
|
||||
HeatValue int64 `protobuf:"varint,5,opt,name=heat_value,json=heatValue,proto3" json:"heat_value,omitempty"`
|
||||
PriceVersion string `protobuf:"bytes,6,opt,name=price_version,json=priceVersion,proto3" json:"price_version,omitempty"`
|
||||
// balance_after 是 sender COIN available_amount 账后余额。
|
||||
BalanceAfter int64 `protobuf:"varint,7,opt,name=balance_after,json=balanceAfter,proto3" json:"balance_after,omitempty"`
|
||||
}
|
||||
|
||||
func (x *DebitGiftResponse) Reset() {
|
||||
*x = DebitGiftResponse{}
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *DebitGiftResponse) String() string {
|
||||
@ -145,7 +150,7 @@ func (*DebitGiftResponse) ProtoMessage() {}
|
||||
|
||||
func (x *DebitGiftResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -211,20 +216,23 @@ func (x *DebitGiftResponse) GetBalanceAfter() int64 {
|
||||
|
||||
// AssetBalance 是用户某类资产的余额投影。
|
||||
type AssetBalance struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
AssetType string `protobuf:"bytes,1,opt,name=asset_type,json=assetType,proto3" json:"asset_type,omitempty"`
|
||||
AvailableAmount int64 `protobuf:"varint,2,opt,name=available_amount,json=availableAmount,proto3" json:"available_amount,omitempty"`
|
||||
FrozenAmount int64 `protobuf:"varint,3,opt,name=frozen_amount,json=frozenAmount,proto3" json:"frozen_amount,omitempty"`
|
||||
Version int64 `protobuf:"varint,4,opt,name=version,proto3" json:"version,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
AssetType string `protobuf:"bytes,1,opt,name=asset_type,json=assetType,proto3" json:"asset_type,omitempty"`
|
||||
AvailableAmount int64 `protobuf:"varint,2,opt,name=available_amount,json=availableAmount,proto3" json:"available_amount,omitempty"`
|
||||
FrozenAmount int64 `protobuf:"varint,3,opt,name=frozen_amount,json=frozenAmount,proto3" json:"frozen_amount,omitempty"`
|
||||
Version int64 `protobuf:"varint,4,opt,name=version,proto3" json:"version,omitempty"`
|
||||
}
|
||||
|
||||
func (x *AssetBalance) Reset() {
|
||||
*x = AssetBalance{}
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *AssetBalance) String() string {
|
||||
@ -235,7 +243,7 @@ func (*AssetBalance) ProtoMessage() {}
|
||||
|
||||
func (x *AssetBalance) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -280,19 +288,22 @@ func (x *AssetBalance) GetVersion() int64 {
|
||||
|
||||
// GetBalancesRequest 查询用户多资产余额;内部调用方负责鉴权和用户范围控制。
|
||||
type GetBalancesRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
||||
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
AssetTypes []string `protobuf:"bytes,3,rep,name=asset_types,json=assetTypes,proto3" json:"asset_types,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
|
||||
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
AssetTypes []string `protobuf:"bytes,3,rep,name=asset_types,json=assetTypes,proto3" json:"asset_types,omitempty"`
|
||||
}
|
||||
|
||||
func (x *GetBalancesRequest) Reset() {
|
||||
*x = GetBalancesRequest{}
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *GetBalancesRequest) String() string {
|
||||
@ -303,7 +314,7 @@ func (*GetBalancesRequest) ProtoMessage() {}
|
||||
|
||||
func (x *GetBalancesRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -340,17 +351,20 @@ func (x *GetBalancesRequest) GetAssetTypes() []string {
|
||||
}
|
||||
|
||||
type GetBalancesResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Balances []*AssetBalance `protobuf:"bytes,1,rep,name=balances,proto3" json:"balances,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Balances []*AssetBalance `protobuf:"bytes,1,rep,name=balances,proto3" json:"balances,omitempty"`
|
||||
}
|
||||
|
||||
func (x *GetBalancesResponse) Reset() {
|
||||
*x = GetBalancesResponse{}
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *GetBalancesResponse) String() string {
|
||||
@ -361,7 +375,7 @@ func (*GetBalancesResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetBalancesResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -385,23 +399,26 @@ func (x *GetBalancesResponse) GetBalances() []*AssetBalance {
|
||||
|
||||
// AdminCreditAssetRequest 是后台手动入账/调账的最小审计命令。
|
||||
type AdminCreditAssetRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
|
||||
TargetUserId int64 `protobuf:"varint,2,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
|
||||
AssetType string `protobuf:"bytes,3,opt,name=asset_type,json=assetType,proto3" json:"asset_type,omitempty"`
|
||||
Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"`
|
||||
OperatorUserId int64 `protobuf:"varint,5,opt,name=operator_user_id,json=operatorUserId,proto3" json:"operator_user_id,omitempty"`
|
||||
Reason string `protobuf:"bytes,6,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
EvidenceRef string `protobuf:"bytes,7,opt,name=evidence_ref,json=evidenceRef,proto3" json:"evidence_ref,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
|
||||
TargetUserId int64 `protobuf:"varint,2,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"`
|
||||
AssetType string `protobuf:"bytes,3,opt,name=asset_type,json=assetType,proto3" json:"asset_type,omitempty"`
|
||||
Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"`
|
||||
OperatorUserId int64 `protobuf:"varint,5,opt,name=operator_user_id,json=operatorUserId,proto3" json:"operator_user_id,omitempty"`
|
||||
Reason string `protobuf:"bytes,6,opt,name=reason,proto3" json:"reason,omitempty"`
|
||||
EvidenceRef string `protobuf:"bytes,7,opt,name=evidence_ref,json=evidenceRef,proto3" json:"evidence_ref,omitempty"`
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetRequest) Reset() {
|
||||
*x = AdminCreditAssetRequest{}
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetRequest) String() string {
|
||||
@ -412,7 +429,7 @@ func (*AdminCreditAssetRequest) ProtoMessage() {}
|
||||
|
||||
func (x *AdminCreditAssetRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -477,18 +494,21 @@ func (x *AdminCreditAssetRequest) GetEvidenceRef() string {
|
||||
}
|
||||
|
||||
type AdminCreditAssetResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
Balance *AssetBalance `protobuf:"bytes,2,opt,name=balance,proto3" json:"balance,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
TransactionId string `protobuf:"bytes,1,opt,name=transaction_id,json=transactionId,proto3" json:"transaction_id,omitempty"`
|
||||
Balance *AssetBalance `protobuf:"bytes,2,opt,name=balance,proto3" json:"balance,omitempty"`
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetResponse) Reset() {
|
||||
*x = AdminCreditAssetResponse{}
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetResponse) String() string {
|
||||
@ -499,7 +519,7 @@ func (*AdminCreditAssetResponse) ProtoMessage() {}
|
||||
|
||||
func (x *AdminCreditAssetResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -530,69 +550,121 @@ func (x *AdminCreditAssetResponse) GetBalance() *AssetBalance {
|
||||
|
||||
var File_api_proto_wallet_v1_wallet_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_api_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
" api/proto/wallet/v1/wallet.proto\x12\x0fhyapp.wallet.v1\"\xf3\x01\n" +
|
||||
"\x10DebitGiftRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" +
|
||||
"\aroom_id\x18\x02 \x01(\tR\x06roomId\x12$\n" +
|
||||
"\x0esender_user_id\x18\x03 \x01(\x03R\fsenderUserId\x12$\n" +
|
||||
"\x0etarget_user_id\x18\x04 \x01(\x03R\ftargetUserId\x12\x17\n" +
|
||||
"\agift_id\x18\x05 \x01(\tR\x06giftId\x12\x1d\n" +
|
||||
"\n" +
|
||||
"gift_count\x18\x06 \x01(\x05R\tgiftCount\x12#\n" +
|
||||
"\rprice_version\x18\a \x01(\tR\fpriceVersion\"\x9a\x02\n" +
|
||||
"\x11DebitGiftResponse\x12,\n" +
|
||||
"\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" +
|
||||
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" +
|
||||
"\n" +
|
||||
"coin_spent\x18\x03 \x01(\x03R\tcoinSpent\x12(\n" +
|
||||
"\x10gift_point_added\x18\x04 \x01(\x03R\x0egiftPointAdded\x12\x1d\n" +
|
||||
"\n" +
|
||||
"heat_value\x18\x05 \x01(\x03R\theatValue\x12#\n" +
|
||||
"\rprice_version\x18\x06 \x01(\tR\fpriceVersion\x12#\n" +
|
||||
"\rbalance_after\x18\a \x01(\x03R\fbalanceAfter\"\x97\x01\n" +
|
||||
"\fAssetBalance\x12\x1d\n" +
|
||||
"\n" +
|
||||
"asset_type\x18\x01 \x01(\tR\tassetType\x12)\n" +
|
||||
"\x10available_amount\x18\x02 \x01(\x03R\x0favailableAmount\x12#\n" +
|
||||
"\rfrozen_amount\x18\x03 \x01(\x03R\ffrozenAmount\x12\x18\n" +
|
||||
"\aversion\x18\x04 \x01(\x03R\aversion\"m\n" +
|
||||
"\x12GetBalancesRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"request_id\x18\x01 \x01(\tR\trequestId\x12\x17\n" +
|
||||
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x1f\n" +
|
||||
"\vasset_types\x18\x03 \x03(\tR\n" +
|
||||
"assetTypes\"P\n" +
|
||||
"\x13GetBalancesResponse\x129\n" +
|
||||
"\bbalances\x18\x01 \x03(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\bbalances\"\xfa\x01\n" +
|
||||
"\x17AdminCreditAssetRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12$\n" +
|
||||
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x1d\n" +
|
||||
"\n" +
|
||||
"asset_type\x18\x03 \x01(\tR\tassetType\x12\x16\n" +
|
||||
"\x06amount\x18\x04 \x01(\x03R\x06amount\x12(\n" +
|
||||
"\x10operator_user_id\x18\x05 \x01(\x03R\x0eoperatorUserId\x12\x16\n" +
|
||||
"\x06reason\x18\x06 \x01(\tR\x06reason\x12!\n" +
|
||||
"\fevidence_ref\x18\a \x01(\tR\vevidenceRef\"z\n" +
|
||||
"\x18AdminCreditAssetResponse\x12%\n" +
|
||||
"\x0etransaction_id\x18\x01 \x01(\tR\rtransactionId\x127\n" +
|
||||
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance2\xa6\x02\n" +
|
||||
"\rWalletService\x12R\n" +
|
||||
"\tDebitGift\x12!.hyapp.wallet.v1.DebitGiftRequest\x1a\".hyapp.wallet.v1.DebitGiftResponse\x12X\n" +
|
||||
"\vGetBalances\x12#.hyapp.wallet.v1.GetBalancesRequest\x1a$.hyapp.wallet.v1.GetBalancesResponse\x12g\n" +
|
||||
"\x10AdminCreditAsset\x12(.hyapp.wallet.v1.AdminCreditAssetRequest\x1a).hyapp.wallet.v1.AdminCreditAssetResponseB$Z\"hyapp/api/proto/wallet/v1;walletv1b\x06proto3"
|
||||
var file_api_proto_wallet_v1_wallet_proto_rawDesc = []byte{
|
||||
0x0a, 0x20, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x77, 0x61, 0x6c, 0x6c,
|
||||
0x65, 0x74, 0x2f, 0x76, 0x31, 0x2f, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x70, 0x72, 0x6f,
|
||||
0x74, 0x6f, 0x12, 0x0f, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74,
|
||||
0x2e, 0x76, 0x31, 0x22, 0xf3, 0x01, 0x0a, 0x10, 0x44, 0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66,
|
||||
0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x6d,
|
||||
0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f,
|
||||
0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x72, 0x6f, 0x6f, 0x6d, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x6f, 0x6d, 0x49, 0x64,
|
||||
0x12, 0x24, 0x0a, 0x0e, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x73, 0x65, 0x6e, 0x64, 0x65, 0x72,
|
||||
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74,
|
||||
0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c,
|
||||
0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07,
|
||||
0x67, 0x69, 0x66, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x67,
|
||||
0x69, 0x66, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x63, 0x6f,
|
||||
0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x67, 0x69, 0x66, 0x74, 0x43,
|
||||
0x6f, 0x75, 0x6e, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x72, 0x69, 0x63, 0x65, 0x5f, 0x76, 0x65,
|
||||
0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x69,
|
||||
0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x9a, 0x02, 0x0a, 0x11, 0x44, 0x65,
|
||||
0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x2c, 0x0a, 0x12, 0x62, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x72, 0x65, 0x63, 0x65, 0x69,
|
||||
0x70, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x62, 0x69, 0x6c,
|
||||
0x6c, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x49, 0x64, 0x12, 0x25, 0x0a,
|
||||
0x0e, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x69, 0x6e, 0x5f, 0x73, 0x70, 0x65,
|
||||
0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x63, 0x6f, 0x69, 0x6e, 0x53, 0x70,
|
||||
0x65, 0x6e, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x70, 0x6f, 0x69, 0x6e,
|
||||
0x74, 0x5f, 0x61, 0x64, 0x64, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x67,
|
||||
0x69, 0x66, 0x74, 0x50, 0x6f, 0x69, 0x6e, 0x74, 0x41, 0x64, 0x64, 0x65, 0x64, 0x12, 0x1d, 0x0a,
|
||||
0x0a, 0x68, 0x65, 0x61, 0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28,
|
||||
0x03, 0x52, 0x09, 0x68, 0x65, 0x61, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x23, 0x0a, 0x0d,
|
||||
0x70, 0x72, 0x69, 0x63, 0x65, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x72, 0x69, 0x63, 0x65, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
|
||||
0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x61, 0x66, 0x74,
|
||||
0x65, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63,
|
||||
0x65, 0x41, 0x66, 0x74, 0x65, 0x72, 0x22, 0x97, 0x01, 0x0a, 0x0c, 0x41, 0x73, 0x73, 0x65, 0x74,
|
||||
0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x73, 0x73, 0x65, 0x74,
|
||||
0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x73, 0x73,
|
||||
0x65, 0x74, 0x54, 0x79, 0x70, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61,
|
||||
0x62, 0x6c, 0x65, 0x5f, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03,
|
||||
0x52, 0x0f, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x6c, 0x65, 0x41, 0x6d, 0x6f, 0x75, 0x6e,
|
||||
0x74, 0x12, 0x23, 0x0a, 0x0d, 0x66, 0x72, 0x6f, 0x7a, 0x65, 0x6e, 0x5f, 0x61, 0x6d, 0x6f, 0x75,
|
||||
0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x66, 0x72, 0x6f, 0x7a, 0x65, 0x6e,
|
||||
0x41, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f,
|
||||
0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
|
||||
0x22, 0x6d, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x49, 0x64, 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, 0x1f,
|
||||
0x0a, 0x0b, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, 0x03, 0x20,
|
||||
0x03, 0x28, 0x09, 0x52, 0x0a, 0x61, 0x73, 0x73, 0x65, 0x74, 0x54, 0x79, 0x70, 0x65, 0x73, 0x22,
|
||||
0x50, 0x0a, 0x13, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x39, 0x0a, 0x08, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63,
|
||||
0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
|
||||
0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74,
|
||||
0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x52, 0x08, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65,
|
||||
0x73, 0x22, 0xfa, 0x01, 0x0a, 0x17, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69,
|
||||
0x74, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a,
|
||||
0x0a, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x49, 0x64, 0x12, 0x24, 0x0a, 0x0e,
|
||||
0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72,
|
||||
0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x73, 0x73, 0x65, 0x74, 0x5f, 0x74, 0x79, 0x70, 0x65,
|
||||
0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x73, 0x73, 0x65, 0x74, 0x54, 0x79, 0x70,
|
||||
0x65, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||
0x03, 0x52, 0x06, 0x61, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x28, 0x0a, 0x10, 0x6f, 0x70, 0x65,
|
||||
0x72, 0x61, 0x74, 0x6f, 0x72, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x0e, 0x6f, 0x70, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x55, 0x73, 0x65,
|
||||
0x72, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x65,
|
||||
0x76, 0x69, 0x64, 0x65, 0x6e, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x66, 0x18, 0x07, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x0b, 0x65, 0x76, 0x69, 0x64, 0x65, 0x6e, 0x63, 0x65, 0x52, 0x65, 0x66, 0x22, 0x7a,
|
||||
0x0a, 0x18, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x41, 0x73, 0x73,
|
||||
0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x74, 0x72,
|
||||
0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0d, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x49,
|
||||
0x64, 0x12, 0x37, 0x0a, 0x07, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65,
|
||||
0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x73, 0x73, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63,
|
||||
0x65, 0x52, 0x07, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x32, 0xa6, 0x02, 0x0a, 0x0d, 0x57,
|
||||
0x61, 0x6c, 0x6c, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x52, 0x0a, 0x09,
|
||||
0x44, 0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74, 0x12, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70,
|
||||
0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x62, 0x69,
|
||||
0x74, 0x47, 0x69, 0x66, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x68,
|
||||
0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x44,
|
||||
0x65, 0x62, 0x69, 0x74, 0x47, 0x69, 0x66, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x58, 0x0a, 0x0b, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x12,
|
||||
0x23, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76,
|
||||
0x31, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c,
|
||||
0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x42, 0x61, 0x6c, 0x61, 0x6e, 0x63,
|
||||
0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x67, 0x0a, 0x10, 0x41, 0x64,
|
||||
0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x41, 0x73, 0x73, 0x65, 0x74, 0x12, 0x28,
|
||||
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31,
|
||||
0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x41, 0x73, 0x73, 0x65,
|
||||
0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
|
||||
0x2e, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x64, 0x6d, 0x69, 0x6e,
|
||||
0x43, 0x72, 0x65, 0x64, 0x69, 0x74, 0x41, 0x73, 0x73, 0x65, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x42, 0x24, 0x5a, 0x22, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2f, 0x61, 0x70, 0x69,
|
||||
0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x2f, 0x76, 0x31,
|
||||
0x3b, 0x77, 0x61, 0x6c, 0x6c, 0x65, 0x74, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
file_api_proto_wallet_v1_wallet_proto_rawDescOnce sync.Once
|
||||
file_api_proto_wallet_v1_wallet_proto_rawDescData []byte
|
||||
file_api_proto_wallet_v1_wallet_proto_rawDescData = file_api_proto_wallet_v1_wallet_proto_rawDesc
|
||||
)
|
||||
|
||||
func file_api_proto_wallet_v1_wallet_proto_rawDescGZIP() []byte {
|
||||
file_api_proto_wallet_v1_wallet_proto_rawDescOnce.Do(func() {
|
||||
file_api_proto_wallet_v1_wallet_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_proto_wallet_v1_wallet_proto_rawDesc), len(file_api_proto_wallet_v1_wallet_proto_rawDesc)))
|
||||
file_api_proto_wallet_v1_wallet_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_proto_wallet_v1_wallet_proto_rawDescData)
|
||||
})
|
||||
return file_api_proto_wallet_v1_wallet_proto_rawDescData
|
||||
}
|
||||
@ -628,11 +700,97 @@ func file_api_proto_wallet_v1_wallet_proto_init() {
|
||||
if File_api_proto_wallet_v1_wallet_proto != nil {
|
||||
return
|
||||
}
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_api_proto_wallet_v1_wallet_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*DebitGiftRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_wallet_v1_wallet_proto_msgTypes[1].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*DebitGiftResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_wallet_v1_wallet_proto_msgTypes[2].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*AssetBalance); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_wallet_v1_wallet_proto_msgTypes[3].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*GetBalancesRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_wallet_v1_wallet_proto_msgTypes[4].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*GetBalancesResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_wallet_v1_wallet_proto_msgTypes[5].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*AdminCreditAssetRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_wallet_v1_wallet_proto_msgTypes[6].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*AdminCreditAssetResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_proto_wallet_v1_wallet_proto_rawDesc), len(file_api_proto_wallet_v1_wallet_proto_rawDesc)),
|
||||
RawDescriptor: file_api_proto_wallet_v1_wallet_proto_rawDesc,
|
||||
NumEnums: 0,
|
||||
NumMessages: 7,
|
||||
NumExtensions: 0,
|
||||
@ -643,6 +801,7 @@ func file_api_proto_wallet_v1_wallet_proto_init() {
|
||||
MessageInfos: file_api_proto_wallet_v1_wallet_proto_msgTypes,
|
||||
}.Build()
|
||||
File_api_proto_wallet_v1_wallet_proto = out.File
|
||||
file_api_proto_wallet_v1_wallet_proto_rawDesc = nil
|
||||
file_api_proto_wallet_v1_wallet_proto_goTypes = nil
|
||||
file_api_proto_wallet_v1_wallet_proto_depIdxs = nil
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.27.3
|
||||
// - protoc v5.29.2
|
||||
// source: api/proto/wallet/v1/wallet.proto
|
||||
|
||||
package walletv1
|
||||
|
||||
44
deploy/mysql/initdb/005_utf8mb4_chinese_support.sql
Normal file
44
deploy/mysql/initdb/005_utf8mb4_chinese_support.sql
Normal file
@ -0,0 +1,44 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- MySQL's docker entrypoint creates MYSQL_DATABASE before service DDL runs.
|
||||
-- These ALTER statements keep both fresh and long-lived local volumes on the
|
||||
-- same utf8mb4 collation so Chinese text and emoji are stored by default.
|
||||
ALTER DATABASE hyapp_room CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER DATABASE hyapp_user CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER DATABASE hyapp_wallet CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER DATABASE hyapp_activity CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_room;
|
||||
ALTER TABLE rooms CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_list_entries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_snapshots CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_command_log CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE room_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_user;
|
||||
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE countries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE regions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE region_countries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE region_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE user_region_rebuild_tasks CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE user_display_user_ids CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE pretty_display_user_id_leases CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE display_user_id_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE user_country_change_logs CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE password_accounts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE third_party_identities CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE auth_sessions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE login_audit CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_wallet;
|
||||
ALTER TABLE wallet_accounts CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_transactions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_entries CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE wallet_gift_prices CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_activity;
|
||||
ALTER TABLE activities CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE activity_event_consumption CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
ALTER TABLE activity_outbox CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
@ -1,8 +1,10 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE USER IF NOT EXISTS 'hyapp'@'%' IDENTIFIED BY 'hyapp';
|
||||
|
||||
GRANT ALL PRIVILEGES ON hyapp_room.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hy_user.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hy_wallet.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hy_activity.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_user.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_wallet.* TO 'hyapp'@'%';
|
||||
GRANT ALL PRIVILEGES ON hyapp_activity.* TO 'hyapp'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
|
||||
@ -95,6 +95,9 @@ services:
|
||||
mysql:
|
||||
image: mysql:8.4
|
||||
container_name: hyapp-mysql
|
||||
command:
|
||||
- "--character-set-server=utf8mb4"
|
||||
- "--collation-server=utf8mb4_unicode_ci"
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: hyapp_room
|
||||
@ -108,6 +111,7 @@ services:
|
||||
- ./services/user-service/deploy/mysql/initdb/001_user_service.sql:/docker-entrypoint-initdb.d/002_user_service.sql:ro
|
||||
- ./services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql:/docker-entrypoint-initdb.d/003_wallet_service.sql:ro
|
||||
- ./services/activity-service/deploy/mysql/initdb/001_activity_service.sql:/docker-entrypoint-initdb.d/004_activity_service.sql:ro
|
||||
- ./deploy/mysql/initdb/005_utf8mb4_chinese_support.sql:/docker-entrypoint-initdb.d/005_utf8mb4_chinese_support.sql:ro
|
||||
- ./deploy/mysql/initdb/999_local_grants.sql:/docker-entrypoint-initdb.d/999_local_grants.sql:ro
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -proot --silent"]
|
||||
|
||||
@ -8,6 +8,8 @@ v1 只有一个用户创建入口:
|
||||
|
||||
- 三方身份登录即注册。`provider + provider_subject` 不存在时创建用户、生成 `default_display_user_id`、设置当前 `display_user_id`、绑定三方身份并签发 token。
|
||||
|
||||
三方登录不拆成“先登录、发现未注册、再注册”两步。客户端调用三方 SDK 拿到 credential 后,只调用 `LoginThirdParty`。服务端根据 `provider + provider_subject` 决定登录还是创建用户,并通过 `is_new_user` 和 `profile_completed` 告诉客户端下一步是否进入资料补全页。
|
||||
|
||||
v1 的密码能力只有一个前置条件:
|
||||
|
||||
- 用户必须已经通过三方登录拿到 access token,之后才能设置密码。
|
||||
@ -19,6 +21,7 @@ v1 不支持:
|
||||
- 未登录状态设置密码。
|
||||
- 手机验证码登录。
|
||||
- 匿名游客登录。
|
||||
- 三方登录返回 `NOT_REGISTERED` 后再调用注册接口。
|
||||
- 多账号主动合并。
|
||||
- 客户端直接访问 `user-service`。
|
||||
- gateway 保存密码、三方凭证或 refresh token。
|
||||
@ -31,17 +34,18 @@ v1 不支持:
|
||||
| --- | --- | --- |
|
||||
| HTTP JSON API | `gateway-service` | 只暴露 `/api/v1/auth/*`,统一返回 envelope |
|
||||
| 用户创建 | `user-service` | 只能由三方身份首次登录触发 |
|
||||
| 注册完成状态 | `user-service` | 创建用户后默认未完成资料,资料补全后显式标记完成 |
|
||||
| 展示短号 | `user-service` | 新用户创建时生成默认短号,后续登录用当前有效短号;临时靓号有效期内会覆盖默认短号 |
|
||||
| 密码身份 | `user-service` | 已登录用户手动设置后才存在 |
|
||||
| 三方身份绑定 | `user-service` | `provider + subject` 唯一绑定用户 |
|
||||
| 三方凭证校验 | `user-service` | 生产环境必须由 provider 专用 verifier 校验 credential 后得到 subject |
|
||||
| 三方凭证校验 | `user-service` | 生产环境必须由 Firebase/provider verifier 校验 credential 后得到 subject |
|
||||
| access token 签发 | `user-service` | 登录成功后签发 |
|
||||
| access token 校验 | `gateway-service` | 业务 API 和设置密码入口校验 |
|
||||
| refresh token | `user-service` | 只保存 hash,支持轮换和失效 |
|
||||
| 房间身份态 | `room-service` | 只消费已鉴权 `actor_user_id`,不处理登录 |
|
||||
| 腾讯云 IM 身份态 | `gateway-service` | 用 access token 鉴权后签发腾讯云 IM UserSig,不处理注册 |
|
||||
|
||||
`gateway-service` 不读取用户表,不校验密码 hash,不调用三方平台做最终身份判断。三方 provider secret 只存在于 `user-service` verifier 配置或服务端密钥系统。
|
||||
`gateway-service` 不读取用户表,不校验密码 hash,不调用 Firebase 或三方平台做最终身份判断。Firebase service account、Firebase project id 和三方 provider secret 只存在于 `user-service` verifier 配置或服务端密钥系统。
|
||||
|
||||
## External HTTP API
|
||||
|
||||
@ -68,8 +72,8 @@ Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "google",
|
||||
"credential": "google_id_token_or_auth_code",
|
||||
"provider": "firebase",
|
||||
"credential": "firebase_id_token",
|
||||
"username": "hy",
|
||||
"gender": "male",
|
||||
"country": "CN",
|
||||
@ -104,13 +108,33 @@ Response `data`:
|
||||
"refresh_token": "refresh_xxx",
|
||||
"expires_in_sec": 1800,
|
||||
"token_type": "Bearer",
|
||||
"is_new_user": true
|
||||
"is_new_user": true,
|
||||
"profile_completed": false,
|
||||
"onboarding_status": "profile_required"
|
||||
}
|
||||
```
|
||||
|
||||
三方接口不拆注册和登录。新用户不会默认生成密码;注册资料和来源快照写入 `users`,同时 `user_display_user_ids`、`third_party_identities` 和首个 `auth_sessions` 会在同一事务中创建。`birth` 必须是 `yyyy-mm-dd`;`platform` 当前只接受 `android` 或 `ios`;gateway 只接收 `app_version`,不会读取误拼字段。客户端不能提交注册 IP、UA 或 IP 国家,`register_ip` 和 `register_user_agent` 来自 gateway 请求上下文,`country_by_ip` 来自服务端/可信边缘层根据入口 IP 形成的国家快照。`provider_subject` 只允许由 user-service 校验 provider credential 后得到,不能信任客户端直传,也不能把客户端传入的 `credential` 原样当成 subject。
|
||||
三方接口不拆注册和登录。新用户不会默认生成密码;注册上下文和来源快照写入 `users`,同时 `user_display_user_ids`、`third_party_identities` 和首个 `auth_sessions` 会在同一事务中创建。`birth` 必须是 `yyyy-mm-dd`;`platform` 当前只接受 `android` 或 `ios`;gateway 只接收 `app_version`,不会读取误拼字段。客户端不能提交注册 IP、UA 或 IP 国家,`register_ip` 和 `register_user_agent` 来自 gateway 请求上下文,`country_by_ip` 来自服务端/可信边缘层根据入口 IP 形成的国家快照。`provider_subject` 只允许由 user-service 校验 provider credential 后得到,不能信任客户端直传,也不能把客户端传入的 `credential` 原样当成 subject。
|
||||
|
||||
注册字段必须在 user-service 写库前完成校验,失败统一返回 `INVALID_ARGUMENT`,不能让 MySQL `VARCHAR` 或 `DATE` 约束错误泄漏成 gateway `UPSTREAM_ERROR`。
|
||||
`LoginThirdParty` 中的 `username/avatar/country` 只作为 provider 或客户端当次传入的注册资料快照,不代表注册完成。App 注册页的权威提交点是 `CompleteOnboarding(username, avatar, country)`;只有该接口成功后才设置 `profile_completed=true`。
|
||||
|
||||
注册链路分成两类数据,不能混用:
|
||||
|
||||
| Category | Submit Time | Source | Fields |
|
||||
| --- | --- | --- | --- |
|
||||
| 注册上下文 | `POST /api/v1/auth/third-party/login` | 客户端自动上报或 gateway 观察 | `device_id`、`platform`、`device`、`os_version`、`app_version`、`build_number`、`language`、`timezone`、`source`、`install_channel`、`campaign`、`register_ip`、`register_user_agent`、`country_by_ip` |
|
||||
| 注册页用户资料 | `POST /api/v1/users/me/onboarding/complete` | 用户在注册页主动填写或选择 | `username`、`avatar`、`country` |
|
||||
|
||||
`device_id` 和 `platform` 是注册创建用户时必须登记的客户端上下文,不是注册页让用户填写的内容,也不进入 `CompleteOnboarding` request body。注册页只负责让用户完成可展示、可分区的最小资料。
|
||||
|
||||
客户端跳转规则:
|
||||
|
||||
- `is_new_user=true` 表示本次调用首次创建了用户和三方绑定,客户端应进入资料补全页。
|
||||
- `profile_completed=false` 表示该用户仍未完成 App 注册资料,即使 `is_new_user=false` 也必须继续进入资料补全页。
|
||||
- `is_new_user=false && profile_completed=true` 才能直接进入 App 首页。
|
||||
- 服务端不返回 `NOT_REGISTERED`。三方身份不存在时由本接口原子创建;credential 无效、provider 不允许或 provider 校验失败统一返回 `AUTH_FAILED`。
|
||||
|
||||
`LoginThirdParty` 入参字段必须在 user-service 写库前完成校验,失败统一返回 `INVALID_ARGUMENT`,不能让 MySQL `VARCHAR` 或 `DATE` 约束错误泄漏成 gateway `UPSTREAM_ERROR`。
|
||||
|
||||
| Field | Max | Format |
|
||||
| --- | ---: | --- |
|
||||
@ -139,7 +163,7 @@ Response `data`:
|
||||
|
||||
### Third Party Credential Verification
|
||||
|
||||
生产环境的三方登录必须使用 provider 专用 verifier。`StaticThirdPartyVerifier` 只能用于单元测试、本地隔离联调或明确的假 provider,不能配置到线上 provider allowlist。
|
||||
生产环境的三方登录必须使用真实身份源 verifier。首版 App 已接入 Firebase Auth,因此 `provider=firebase` 的 `credential` 统一表示 Firebase ID token。`StaticThirdPartyVerifier` 只能用于单元测试、本地隔离联调或明确的假 provider,不能配置到线上 provider allowlist。
|
||||
|
||||
校验规则:
|
||||
|
||||
@ -154,13 +178,27 @@ Response `data`:
|
||||
|
||||
| Provider | Credential | Required Checks |
|
||||
| --- | --- | --- |
|
||||
| `google` | ID token or server auth code | signature/JWKS, issuer, audience, expiry, nonce when present |
|
||||
| `firebase` | Firebase ID token | signature/Admin SDK or Firebase public keys, `iss`, `aud`, `exp/iat/auth_time`, non-empty `sub`, allowed `firebase.sign_in_provider` |
|
||||
| `apple` | identity token or authorization code | signature/JWKS, issuer, audience, expiry, nonce when present |
|
||||
| `facebook` | access token or limited-login token | app id, token validity, expiry, subject from provider API or signed token |
|
||||
| `tiktok` | auth code or access token | app/client key, expiry, subject from provider API |
|
||||
|
||||
如果首版仍使用本地静态 verifier 联调,配置名和注释必须明确标记为 local/mock,线上配置示例不能把真实 provider 指向静态 verifier。
|
||||
|
||||
Firebase 首版是 App 三方登录的统一身份入口。客户端先用 Firebase Auth 完成 Google 登录,再调用 Firebase Auth 获取当前用户的 Firebase ID token,并把该 token 作为 `credential` 提交给 gateway。gateway 只做协议转发,不解析、不缓存、不落日志;user-service 的 Firebase verifier 必须执行:
|
||||
|
||||
| Check | Rule |
|
||||
| --- | --- |
|
||||
| `iss` | 必须是 `https://securetoken.google.com/<firebase_project_id>` |
|
||||
| `aud` | 必须等于当前 App 后端配置的 Firebase project id |
|
||||
| `exp/iat/auth_time` | token 未过期,签发和认证时间不能明显偏离当前时间 |
|
||||
| `signature` | 优先使用 Firebase Admin SDK 校验;不用 Admin SDK 时必须按 Firebase 公钥和缓存过期时间校验 |
|
||||
| `sub` / `uid` | 作为 `provider_subject`,不能为空,不能来自客户端 body |
|
||||
| `firebase.sign_in_provider` | 首版只开放 Google 时必须等于 `google.com`;后续开放 Apple/phone 等方式时改成配置 allowlist |
|
||||
| `email/email_verified/name/picture` | 只作为资料快照辅助字段,不能替代 `sub` 做绑定主键 |
|
||||
|
||||
Firebase verifier 出现项目 ID 缺失、issuer/audience 不匹配、签名错误、token 过期、`sub` 为空、`sign_in_provider` 不允许或公钥加载失败时,统一返回 `AUTH_FAILED`。服务端不能把 Firebase ID token 写入日志、审计或数据库。
|
||||
|
||||
### Set Password
|
||||
|
||||
```text
|
||||
@ -205,6 +243,8 @@ Request:
|
||||
|
||||
该接口只修改用户名、头像和生日。`birth` 非空时必须是 `yyyy-mm-dd`;国家不能混在这个接口中修改。
|
||||
|
||||
未完成注册用户不通过该接口补齐注册页资料;注册页必须调用 `CompleteOnboarding` 一次性提交 `username/avatar/country`。
|
||||
|
||||
### Change Country
|
||||
|
||||
```text
|
||||
@ -222,6 +262,136 @@ Request:
|
||||
|
||||
国家修改由 user-service 单独写 `user_country_change_logs`。同一用户按滚动 30 天冷却窗口只能成功修改一次;提交相同国家不写新日志,也不消耗新的冷却窗口。
|
||||
|
||||
未完成注册用户的首次国家选择不走该接口,而是跟随 `CompleteOnboarding` 在同一事务内写入。注册完成后的国家修改才进入冷却和审计链路。
|
||||
|
||||
### Complete Onboarding
|
||||
|
||||
```text
|
||||
POST /api/v1/users/me/onboarding/complete
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "hy",
|
||||
"avatar": "https://cdn.example/avatar.png",
|
||||
"country": "SG"
|
||||
}
|
||||
```
|
||||
|
||||
Response `data`:
|
||||
|
||||
```json
|
||||
{
|
||||
"user_id": 10001,
|
||||
"username": "hy",
|
||||
"avatar": "https://cdn.example/avatar.png",
|
||||
"country": "SG",
|
||||
"region_id": 1001,
|
||||
"profile_completed": true,
|
||||
"profile_completed_at_ms": 1777000000000,
|
||||
"onboarding_status": "completed",
|
||||
"token": {
|
||||
"user_id": 10001,
|
||||
"session_id": "sess_xxx",
|
||||
"access_token": "jwt_xxx",
|
||||
"expires_in_sec": 1800,
|
||||
"token_type": "Bearer",
|
||||
"display_user_id": "100001",
|
||||
"profile_completed": true,
|
||||
"onboarding_status": "completed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
该接口是注册页唯一提交入口。客户端在注册页一次性提交国家、用户名和头像;user-service 在同一事务内校验资料、解析国家和区域、更新用户资料,并把用户标记为注册完成。
|
||||
|
||||
`token.access_token` 是注册完成后立即替换旧 access token 的新 JWT,包含 `profile_completed=true`。该接口不轮换 refresh token,`token.refresh_token` 为空或不返回;客户端必须保留登录时已有的 refresh token。
|
||||
|
||||
注册页用户必填字段:
|
||||
|
||||
| Field | Required | Reason |
|
||||
| --- | --- | --- |
|
||||
| `username` | yes | 房间、资料页和 IM 展示需要稳定昵称 |
|
||||
| `avatar` | yes | App 注册页要求用户头像,房间和 IM 展示不使用空头像 |
|
||||
| `country` | yes | 房间列表按区域分发,必须有用户选择国家 |
|
||||
| `region_id` | no | 国家可以暂时没有区域映射,此时进入 `GLOBAL` 房间列表 |
|
||||
|
||||
注册上下文必需字段:
|
||||
|
||||
| Field | Required | Submit Time | Reason |
|
||||
| --- | --- | --- | --- |
|
||||
| `device_id` | yes | `LoginThirdParty` | refresh session 绑定、风控和设备级频控基础字段 |
|
||||
| `platform` | yes | `LoginThirdParty` | App 侧环境字段,只允许 `android` 或 `ios` |
|
||||
| `app_version/build_number/os_version` | no | `LoginThirdParty` | 注册来源排障和灰度分析字段,不阻塞 onboarding 完成 |
|
||||
|
||||
完成规则:
|
||||
|
||||
- `username` 必须 trim 后非空,并满足 `users.username` 长度限制。
|
||||
- `avatar` 必须是合法 `http` 或 `https` URL,并满足 `users.avatar` 长度限制。
|
||||
- `country` 必须命中 active `countries.country_code`,并同步解析 `region_id`。
|
||||
- 初次完成注册时设置国家不消耗国家修改冷却期;注册完成后的国家修改才走 `ChangeUserCountry` 和 30 天冷却。
|
||||
- 如果用户已经 `profile_completed=true`,该接口不再修改资料;客户端需要改资料时使用资料修改接口,改国家时使用国家修改接口。
|
||||
- `CompleteOnboarding` 成功后必须返回新的 access token,客户端立即替换旧 token;refresh token 不轮换。
|
||||
|
||||
如果必填字段缺失或格式非法,返回 `INVALID_ARGUMENT`。如果资料未完成用户访问被门禁保护的业务能力,返回 `PROFILE_REQUIRED`。`PROFILE_REQUIRED` 只用于已登录但资料未完成的业务门禁,不用于三方登录接口。
|
||||
|
||||
## App Login State Machine
|
||||
|
||||
客户端登录页不需要先问服务端“是否注册”。状态机固定为:
|
||||
|
||||
```text
|
||||
unauthenticated
|
||||
-> provider_sdk_login
|
||||
-> POST /api/v1/auth/third-party/login
|
||||
-> token_received
|
||||
-> if is_new_user || !profile_completed: onboarding_required
|
||||
-> else: authenticated_home
|
||||
```
|
||||
|
||||
资料补全页状态机:
|
||||
|
||||
```text
|
||||
onboarding_required
|
||||
-> POST /api/v1/users/me/onboarding/complete(username, avatar, country)
|
||||
-> token_received(profile_completed=true)
|
||||
-> authenticated_home
|
||||
```
|
||||
|
||||
客户端判断规则:
|
||||
|
||||
- 只要已经拿到 access token,就认为用户主身份已经创建。
|
||||
- 资料未完成不是未注册;它是已登录用户的 App 准入状态。
|
||||
- App 启动后如果本地已有 token,应先用业务接口或用户资料接口刷新 `profile_completed`,不要只依赖本地缓存。
|
||||
- `is_new_user` 只在本次三方登录响应中有意义;长期准入判断必须使用 `profile_completed`。
|
||||
- `CompleteOnboarding` 成功响应内的 `token.access_token` 是解除 profile gate 的唯一新快照,客户端必须立即替换旧 access token。
|
||||
|
||||
## Profile Gate
|
||||
|
||||
资料未完成用户允许访问的接口:
|
||||
|
||||
| API | Allowed | Reason |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/v1/users/me/onboarding/complete` | yes | 一次性提交国家、用户名和头像并固化完成状态 |
|
||||
| `POST /api/v1/auth/password/set` | yes | 允许补齐密码身份 |
|
||||
| `POST /api/v1/auth/token/refresh` | yes | 避免补资料时 token 过期导致重登 |
|
||||
| `POST /api/v1/auth/logout` | yes | 用户必须始终能退出 |
|
||||
|
||||
资料未完成用户默认禁止访问:
|
||||
|
||||
| API / Capability | Rule |
|
||||
| --- | --- |
|
||||
| 房间列表 | 返回 `PROFILE_REQUIRED` |
|
||||
| 创建房间 | 返回 `PROFILE_REQUIRED` |
|
||||
| 进入房间 | 返回 `PROFILE_REQUIRED` |
|
||||
| 腾讯云 IM UserSig | 返回 `PROFILE_REQUIRED`,避免未完成资料用户进入实时系统 |
|
||||
| 腾讯云 RTC Token | 返回 `PROFILE_REQUIRED` |
|
||||
| 钱包和付费行为 | 返回 `PROFILE_REQUIRED` |
|
||||
|
||||
gateway 是 profile gate 的外部入口执行点。为了避免每个业务请求都同步打 user-service,首版在 access token 中携带 `profile_completed` 快照;高风险接口仍可回查 user-service。用户完成 onboarding 后,客户端必须使用 `CompleteOnboardingResponse.token.access_token` 进入房间、IM、RTC 或付费链路。
|
||||
|
||||
### Password Login
|
||||
|
||||
```text
|
||||
@ -379,11 +549,60 @@ message AuthToken {
|
||||
string default_display_user_id = 8;
|
||||
string display_user_id_kind = 9;
|
||||
int64 display_user_id_expires_at_ms = 10;
|
||||
bool profile_completed = 11;
|
||||
string onboarding_status = 12;
|
||||
}
|
||||
|
||||
message AuthResponse {
|
||||
AuthToken token = 1;
|
||||
bool is_new_user = 2;
|
||||
bool profile_completed = 3;
|
||||
string onboarding_status = 4;
|
||||
}
|
||||
```
|
||||
|
||||
`SetPasswordRequest.user_id` 必须来自 gateway 已校验 access token 后的上下文,不能来自客户端 JSON body。
|
||||
|
||||
`api/proto/user/v1/user.proto` 需要扩展用户资料和 onboarding RPC:
|
||||
|
||||
```protobuf
|
||||
message User {
|
||||
// existing fields...
|
||||
bool profile_completed = 21;
|
||||
int64 profile_completed_at_ms = 22;
|
||||
string onboarding_status = 23;
|
||||
}
|
||||
|
||||
message CompleteOnboardingRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
string username = 3;
|
||||
string avatar = 4;
|
||||
string country = 5;
|
||||
}
|
||||
|
||||
message CompleteOnboardingResponse {
|
||||
User user = 1;
|
||||
bool profile_completed = 2;
|
||||
int64 profile_completed_at_ms = 3;
|
||||
string onboarding_status = 4;
|
||||
AuthToken token = 5;
|
||||
}
|
||||
|
||||
service UserService {
|
||||
// existing rpc...
|
||||
rpc CompleteOnboarding(CompleteOnboardingRequest) returns (CompleteOnboardingResponse);
|
||||
}
|
||||
```
|
||||
|
||||
字段语义:
|
||||
|
||||
- `profile_completed=false` 表示用户已经创建但未完成 App 最小注册资料。
|
||||
- `profile_completed_at_ms=0` 表示从未完成 onboarding。
|
||||
- `onboarding_status` 首版只允许 `profile_required` 和 `completed`,后续可以追加 `country_required`、`age_required` 等更细状态。
|
||||
- `CompleteOnboardingRequest.username/avatar/country` 是注册页必填字段,不能从客户端拆成多次部分提交。
|
||||
- `CompleteOnboardingResponse.token.access_token` 是注册完成后用于替换旧 JWT 的新 access token;它复用当前 `session_id`,不轮换 refresh token。
|
||||
|
||||
## Data Model
|
||||
|
||||
### `users`
|
||||
@ -417,10 +636,29 @@ message AuthToken {
|
||||
| `platform` | varchar | android or ios |
|
||||
| `language` | varchar | BCP 47 language tag subset |
|
||||
| `timezone` | varchar | client IANA timezone |
|
||||
| `profile_completed` | tinyint/bool | whether App minimum registration profile is complete |
|
||||
| `profile_completed_at_ms` | bigint | completion time, 0 or null means incomplete |
|
||||
| `onboarding_status` | varchar | profile_required or completed |
|
||||
| `status` | varchar | active, disabled, banned |
|
||||
| `created_at_ms` | bigint | creation time |
|
||||
| `updated_at_ms` | bigint | update time |
|
||||
|
||||
新用户创建时:
|
||||
|
||||
- `profile_completed=false`
|
||||
- `profile_completed_at_ms=0`
|
||||
- `onboarding_status=profile_required`
|
||||
|
||||
`CompleteOnboarding` 成功时:
|
||||
|
||||
- 校验 `username/avatar/country`。
|
||||
- 写入 `users.username`、`users.avatar`、`users.country` 和 `users.region_id`。
|
||||
- 设置 `profile_completed=true`。
|
||||
- 设置 `profile_completed_at_ms=now`。
|
||||
- 设置 `onboarding_status=completed`。
|
||||
|
||||
资料完成状态只能由 user-service 修改,gateway 和客户端不能直接提交该字段。
|
||||
|
||||
### `password_accounts`
|
||||
|
||||
密码身份表。它表达“该用户已经设置过密码”,不表达独立账号体系。
|
||||
@ -519,26 +757,49 @@ sequenceDiagram
|
||||
participant C as Client
|
||||
participant G as gateway-service
|
||||
participant U as user-service
|
||||
participant P as Provider
|
||||
participant P as Firebase Auth / Provider
|
||||
|
||||
C->>G: POST /api/v1/auth/third-party/login
|
||||
G->>U: LoginThirdParty(provider, credential, registration_profile)
|
||||
U->>P: verify credential
|
||||
P-->>U: provider_subject
|
||||
G->>U: LoginThirdParty(provider, credential, registration_context)
|
||||
U->>P: verify Firebase ID token or provider credential
|
||||
P-->>U: provider_subject(uid/sub)
|
||||
U->>U: find identity
|
||||
alt identity exists
|
||||
U->>U: check user status
|
||||
U->>U: create session
|
||||
else identity missing
|
||||
U->>U: create user/profile + default_display_user_id + third_party identity + session
|
||||
U->>U: create user + registration context + default_display_user_id + third_party identity + session
|
||||
end
|
||||
U-->>G: AuthToken + is_new_user
|
||||
U-->>G: AuthToken + is_new_user + profile_completed
|
||||
G-->>C: envelope(data=AuthToken)
|
||||
```
|
||||
|
||||
三方 credential 校验必须在 `user-service` 内完成。gateway 不保存 provider app secret。
|
||||
三方 credential 校验必须在 `user-service` 内完成。gateway 不保存 Firebase service account、provider app secret 或公钥缓存状态。
|
||||
|
||||
生产路径中,`U->>P: verify credential` 必须是真实 provider 校验或服务端换票。静态 verifier 把 `credential` 当 subject 的实现只允许在本地测试替身中使用,不能用于线上注册入口。
|
||||
生产路径中,`U->>P: verify Firebase ID token or provider credential` 必须是真实 Firebase/Admin SDK 校验、真实 provider 校验或服务端换票。静态 verifier 把 `credential` 当 subject 的实现只允许在本地测试替身中使用,不能用于线上注册入口。
|
||||
|
||||
新用户首次登录会直接得到 token,但 `profile_completed=false`。客户端必须跳转资料补全页,而不是再次调用注册接口。`registration_context` 来自客户端环境和 gateway 请求上下文,不等于用户完成资料。
|
||||
|
||||
### Complete Onboarding
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant G as gateway-service
|
||||
participant U as user-service
|
||||
|
||||
C->>G: POST /api/v1/users/me/onboarding/complete(username, avatar, country)
|
||||
G->>G: verify access token
|
||||
G->>U: CompleteOnboarding(user_id from token, username, avatar, country)
|
||||
U->>U: validate username/avatar/country
|
||||
U->>U: resolve active country and region_id
|
||||
U->>U: update profile + mark profile_completed
|
||||
U->>U: re-sign access token with profile_completed=true
|
||||
U-->>G: User + profile_completed=true + AuthToken(access_token only)
|
||||
G-->>C: envelope(data=completion + token)
|
||||
```
|
||||
|
||||
`CompleteOnboarding` 是注册页的原子提交点,避免用户资料已经更新但国家或完成状态失败造成半完成状态。注册完成后的资料修改仍走 `UpdateUserProfile`,国家修改仍走 `ChangeUserCountry`。由于 gateway profile gate 读取 access token 快照,完成 onboarding 后必须使用响应里的新 access token 才能解除 `PROFILE_REQUIRED`。
|
||||
|
||||
### Set Password
|
||||
|
||||
@ -622,6 +883,8 @@ JWT claims:
|
||||
"default_display_user_id": "100001",
|
||||
"display_user_id_kind": "default",
|
||||
"display_user_id_expires_at_ms": 0,
|
||||
"profile_completed": false,
|
||||
"onboarding_status": "profile_required",
|
||||
"sid": "sess_xxx",
|
||||
"iat": 1777000000,
|
||||
"exp": 1777001800,
|
||||
@ -651,6 +914,7 @@ gateway HTTP envelope 的 `code` 不使用数字。
|
||||
| `RATE_LIMITED` | 429 | public auth rate limit exceeded |
|
||||
| `AUTH_FAILED` | 401 | display_user_id missing, password wrong, no password set, or provider rejected |
|
||||
| `UNAUTHORIZED` | 401 | missing or invalid access token |
|
||||
| `PROFILE_REQUIRED` | 403 | authenticated user has not completed required registration profile |
|
||||
| `PASSWORD_ALREADY_SET` | 409 | user already has password identity |
|
||||
| `COUNTRY_CHANGE_COOLDOWN` | 409 | country change is within the 30-day cooldown window |
|
||||
| `USER_DISABLED` | 403 | user is disabled or banned |
|
||||
@ -681,8 +945,9 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所
|
||||
- 明文密码只允许存在于 gateway 入参对象和 user-service hash 前的内存短路径中,不能写日志。
|
||||
- `password_hash` 必须使用强 hash,不能使用 SHA、MD5 或可逆加密。
|
||||
- refresh token 原文不能入库,只能入库 hash。
|
||||
- 三方 provider secret 只属于 `user-service` 配置或服务端密钥系统。
|
||||
- 生产三方 verifier 必须校验 provider credential 的真实性和用途,不能接受客户端自报 subject,不能把静态 verifier 配到真实 provider。
|
||||
- 三方 provider secret、Firebase service account 和 Firebase project id 只属于 `user-service` 配置或服务端密钥系统。
|
||||
- 生产三方 verifier 必须校验 Firebase ID token 或 provider credential 的真实性和用途,不能接受客户端自报 subject,不能把静态 verifier 配到真实 provider。
|
||||
- 注册完成必须由 user-service 在同一事务内写入 `username/avatar/country/region_id/profile_completed`,不能由 gateway 分多步拼状态。
|
||||
- 登录失败日志不能包含明文密码、三方 credential、refresh token 原文。
|
||||
- gateway 统一过滤内部错误,客户端只拿稳定错误码和 `request_id`。
|
||||
- access token 校验失败不访问 `user-service`,避免每个业务请求都打用户服务。
|
||||
@ -696,8 +961,11 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所
|
||||
- HTTP JSON 只接受当前文档字段,旧拼写和废弃字段不再读取。
|
||||
- protobuf 按当前字段编号生成,删除的旧字段不保留解析分支。
|
||||
- 新增 provider 必须通过配置 allowlist 控制,不能让客户端任意传 provider 后触发未知逻辑。
|
||||
- 新增 provider 必须同时新增专用 verifier、配置项和测试;不能只把 provider 名加入静态 allowlist。
|
||||
- 三方登录创建用户必须使用事务,保证 `users` 注册资料、`user_display_user_ids` 和 `third_party_identities` 不出现半绑定。
|
||||
- 首版 Firebase 登录必须配置 `provider=firebase`、Firebase project id、Firebase verifier 和 `firebase.sign_in_provider` allowlist;不能只把 `firebase` 加入静态 allowlist。
|
||||
- 新增直接 provider 必须同时新增专用 verifier、配置项和测试;不能只把 provider 名加入静态 allowlist。
|
||||
- 三方登录创建用户必须使用事务,保证 `users` 注册上下文、`user_display_user_ids` 和 `third_party_identities` 不出现半绑定。
|
||||
- 注册页只调用 `CompleteOnboarding(username, avatar, country)`;不要让客户端分别调用资料、国家和完成状态三个接口拼注册流程,也不要把 `device_id/platform` 放进注册页表单。
|
||||
- `CompleteOnboarding` 成功后必须返回新的 access token;否则旧 access token 的 `profile_completed=false` 仍会被 gateway profile gate 拦截。
|
||||
- 设置密码必须使用 `user_id` 主键幂等判断,不能引入独立账号唯一键。
|
||||
- 登录响应中的 `display_user_id` 表示当前有效短号,服务端权限、账务、房间和 IM 身份一律使用长 `user_id`。
|
||||
|
||||
@ -706,9 +974,17 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所
|
||||
必须覆盖:
|
||||
|
||||
- 三方身份首次登录创建用户、生成 `default_display_user_id`,并返回当前 `display_user_id` 和 `is_new_user=true`。
|
||||
- 三方 verifier 拒绝未知 provider、伪造签名、错误 audience、过期 credential、nonce 不匹配和 provider 配置缺失。
|
||||
- 三方新用户首次登录返回 `profile_completed=false` 和 `onboarding_status=profile_required`。
|
||||
- 三方新用户创建时写入 `device_id/platform/app_version/build_number/os_version/language/timezone` 等注册上下文,但不把这些字段作为注册页用户输入。
|
||||
- 注册页提交 `username/avatar/country` 后,user-service 在同一事务内写用户资料、解析 `region_id` 并返回 `profile_completed=true`。
|
||||
- 注册页完成后旧 access token 仍被 profile gate 拒绝;`CompleteOnboardingResponse.token.access_token` 能访问房间列表、IM UserSig、RTC Token 和付费链路。
|
||||
- 注册完成缺少 `username`、缺少 `avatar`、缺少 `country`、头像 URL 非法或国家不存在时返回 `INVALID_ARGUMENT`。
|
||||
- 未完成注册用户访问房间列表、创建房间、进房、IM UserSig、RTC Token 或付费行为时返回 `PROFILE_REQUIRED`。
|
||||
- 初次注册完成写入国家不消耗国家修改冷却;注册完成后的国家修改才受 `COUNTRY_CHANGE_COOLDOWN` 约束。
|
||||
- Firebase verifier 拒绝未知 provider、伪造签名、错误 issuer/audience、过期 token、空 `sub`、不允许的 `firebase.sign_in_provider` 和 Firebase 配置缺失。
|
||||
- 直接 provider verifier 拒绝伪造签名、错误 audience、过期 credential、nonce 不匹配和 provider 配置缺失。
|
||||
- 真实 provider 配置不能回退到 `StaticThirdPartyVerifier`;静态 verifier 只在测试或 local/mock 配置中出现。
|
||||
- 三方注册资料写入 `users`;任意写入 `users` 的注册字段超过 schema 长度,或 `birth/platform/country/language/timezone/avatar` 格式非法时返回 `INVALID_ARGUMENT`。
|
||||
- 三方注册上下文和可选资料快照写入 `users`;任意写入 `users` 的注册字段超过 schema 长度,或 `birth/platform/country/language/timezone/avatar` 格式非法时返回 `INVALID_ARGUMENT`。
|
||||
- 用户资料接口只修改 `username/avatar/birth`,非法生日返回 `INVALID_ARGUMENT`。
|
||||
- 用户国家接口写 `user_country_change_logs`,冷却期内第二次修改返回 `COUNTRY_CHANGE_COOLDOWN`。
|
||||
- 三方身份再次登录返回同一个 `user_id` 和 `is_new_user=false`。
|
||||
@ -731,8 +1007,9 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所
|
||||
|
||||
这些点需要在实现前定死,不能靠代码猜:
|
||||
|
||||
- 首批三方 provider 名单。
|
||||
- 每个 provider 的 credential 类型、client_id/app_id、issuer/audience、nonce 策略和公钥/JWKS 缓存策略。
|
||||
- 首批三方 provider 名单,以及哪些走 Firebase Auth,哪些走直接 provider verifier。
|
||||
- Firebase project id、service account/公钥校验方式、允许的 `firebase.sign_in_provider` 列表。
|
||||
- 每个直接 provider 的 credential 类型、client_id/app_id、issuer/audience、nonce 策略和公钥/JWKS 缓存策略。
|
||||
- 密码复杂度策略。
|
||||
- access token 有效期和 refresh token 有效期。
|
||||
- HS256 继续使用多久,以及迁移非对称签名的时间点。
|
||||
|
||||
299
docs/google-login-integration.md
Normal file
299
docs/google-login-integration.md
Normal file
@ -0,0 +1,299 @@
|
||||
# Firebase Google 登录接入流程
|
||||
|
||||
本文档对应当前 Flutter Android 测试 App 和后端登录契约。Google 登录由 Firebase Auth 承接;后端不接收 Google account id,不把 Google 资料字段当身份凭证,只接收 Firebase ID token。
|
||||
|
||||
当前目标包名:
|
||||
|
||||
```text
|
||||
com.org.laluparty
|
||||
```
|
||||
|
||||
## 当前项目状态
|
||||
|
||||
已接入:
|
||||
|
||||
- Android `applicationId`: `com.org.laluparty`
|
||||
- Android `namespace`: `com.org.laluparty`
|
||||
- Firebase Android App 配置文件:`android/app/google-services.json`
|
||||
- Firebase Authentication Google Sign-In
|
||||
- `google_sign_in`: `7.2.0`
|
||||
- `google_sign_in_android`: `7.2.10`
|
||||
- `android/settings.gradle.kts` 已声明 `com.google.gms.google-services`
|
||||
- `android/app/build.gradle.kts` 已应用 `com.google.gms.google-services`
|
||||
|
||||
当前 `google-services.json` 包含:
|
||||
|
||||
```text
|
||||
package_name: com.org.laluparty
|
||||
web client_id: 821303287694-o0g9nugq6fglssugri1ls4auud549rll.apps.googleusercontent.com
|
||||
```
|
||||
|
||||
## Firebase 控制台配置
|
||||
|
||||
1. 进入 Firebase Console,选择项目。
|
||||
|
||||
2. 添加 Android App。
|
||||
|
||||
Android package name 填:
|
||||
|
||||
```text
|
||||
com.org.laluparty
|
||||
```
|
||||
|
||||
3. 添加 debug 证书指纹。
|
||||
|
||||
当前本机 debug keystore:
|
||||
|
||||
```text
|
||||
SHA1: C2:F0:20:54:81:57:F4:D1:55:1E:21:98:08:22:69:E1:F6:4A:92:CA
|
||||
SHA-256: F5:91:FC:90:F8:17:26:24:67:2E:FF:77:AA:AE:12:42:21:B4:A8:50:E2:E3:00:B8:BE:DE:B7:D3:1E:0A:D2:28
|
||||
```
|
||||
|
||||
重新查询命令:
|
||||
|
||||
```bash
|
||||
cd /Users/zuozuo/Desktop/HY/hyapp-flutter/android
|
||||
./gradlew signingReport
|
||||
```
|
||||
|
||||
4. 启用 Google 登录。
|
||||
|
||||
Firebase Console:
|
||||
|
||||
```text
|
||||
Authentication -> Sign-in method -> Google -> Enable
|
||||
```
|
||||
|
||||
需要选择项目支持邮箱。
|
||||
|
||||
5. 重新下载 `google-services.json`。
|
||||
|
||||
添加 SHA 指纹或启用 Google 登录后,都建议重新下载一次。
|
||||
|
||||
放置路径:
|
||||
|
||||
```text
|
||||
/Users/zuozuo/Desktop/HY/hyapp-flutter/android/app/google-services.json
|
||||
```
|
||||
|
||||
6. 确认 `google-services.json` 里有 Web OAuth Client。
|
||||
|
||||
检查命令:
|
||||
|
||||
```bash
|
||||
jq -r '.client[] | .client_info.android_client_info.package_name as $pkg | [$pkg, (.oauth_client[]? | select(.client_type==3) | .client_id)] | @tsv' android/app/google-services.json
|
||||
```
|
||||
|
||||
必须能看到 `client_type: 3` 对应的 Web client id。`google_sign_in_android` 会用它做 Android 登录配置。
|
||||
|
||||
## Google Cloud 侧配置
|
||||
|
||||
Firebase 通常会自动创建 OAuth 客户端。需要确认:
|
||||
|
||||
- Android OAuth client 的包名是 `com.org.laluparty`
|
||||
- Android OAuth client 里有当前 debug SHA1
|
||||
- Web OAuth client 存在
|
||||
- OAuth consent screen 已配置
|
||||
|
||||
如果以后要调用 People API、Drive API 等 Google API,再去 Google Cloud API Library 单独启用;当前只做登录不需要额外 API。
|
||||
|
||||
## Flutter 代码接入
|
||||
|
||||
当前登录入口:
|
||||
|
||||
```text
|
||||
lib/src/pages/login_page.dart
|
||||
```
|
||||
|
||||
Google 登录封装:
|
||||
|
||||
```text
|
||||
lib/src/google_auth_service.dart
|
||||
```
|
||||
|
||||
当前初始化方式:
|
||||
|
||||
```dart
|
||||
GoogleSignIn.instance.initialize(
|
||||
clientId: AppConfig.googleClientId,
|
||||
serverClientId: AppConfig.googleServerClientId,
|
||||
);
|
||||
```
|
||||
|
||||
Android 已接入 `google-services.json` 时,可以不传 `GOOGLE_SERVER_CLIENT_ID`。如果不使用 `google-services.json`,运行时必须传 Web client id:
|
||||
|
||||
```bash
|
||||
flutter run -d emulator-5554 \
|
||||
--dart-define=GOOGLE_SERVER_CLIENT_ID=你的Web客户端ID
|
||||
```
|
||||
|
||||
## 后端登录请求
|
||||
|
||||
Flutter 侧必须先完成 Firebase Auth 登录,再从 Firebase 当前用户获取 ID token。后端登录只认 Firebase ID token:
|
||||
|
||||
```text
|
||||
POST /api/v1/auth/third-party/login
|
||||
```
|
||||
|
||||
请求字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"provider": "firebase",
|
||||
"credential": "Firebase ID token",
|
||||
"username": "optional Firebase display name snapshot",
|
||||
"avatar": "optional Firebase photo url snapshot",
|
||||
"device_id": "local device id",
|
||||
"device": "flutter-android",
|
||||
"source": "hyapp-flutter",
|
||||
"platform": "android"
|
||||
}
|
||||
```
|
||||
|
||||
`username/avatar` 在这个接口里只允许作为 Firebase 当次返回的资料快照,不代表注册完成。注册页的权威提交入口仍然是:
|
||||
|
||||
```text
|
||||
POST /api/v1/users/me/onboarding/complete
|
||||
```
|
||||
|
||||
注册页只提交用户主动确认的 `username/avatar/country`。`device_id/platform/device/source` 是客户端自动上报的注册上下文,不需要用户填写。
|
||||
|
||||
## 后端校验规则
|
||||
|
||||
后端 `user-service` 必须使用 Firebase verifier 校验 ID token。`StaticThirdPartyVerifier` 只能用于单元测试或显式 mock provider,不能用于 `provider=firebase`:
|
||||
|
||||
| Item | Rule |
|
||||
| --- | --- |
|
||||
| `provider` | 固定传 `firebase`,并出现在 user-service allowlist |
|
||||
| `credential` | Firebase Auth 当前用户的 ID token,不能是 Google account id、email 或 access token |
|
||||
| `provider_subject` | 从校验后的 Firebase token `sub`/`uid` 提取,作为 `third_party_identities(provider, provider_subject)` 的绑定主键 |
|
||||
| `iss` | 必须是 `https://securetoken.google.com/<firebase_project_id>` |
|
||||
| `aud` | 必须等于后端配置的 Firebase project id |
|
||||
| `signature` | 使用 Firebase Admin SDK 或 Firebase 公钥校验,不能跳过签名 |
|
||||
| `firebase.sign_in_provider` | 首版 Google 登录必须等于 `google.com`;后续支持 Apple/phone 时改成配置 allowlist |
|
||||
| `email/name/picture` | 只作为资料快照辅助字段,不能作为登录绑定主键 |
|
||||
|
||||
Firebase ID token、Google access token、provider 原始 credential 都不能写入日志、审计表或数据库。gateway 只负责 HTTP JSON 到 gRPC 的转发和 `request_id` 透传,不保存 Firebase service account。
|
||||
|
||||
## Android 运行验证
|
||||
|
||||
启动 Google 手机模拟器:
|
||||
|
||||
```bash
|
||||
/Users/zuozuo/Library/Android/sdk/emulator/emulator -avd Pixel_8_API_35
|
||||
```
|
||||
|
||||
确认设备在线:
|
||||
|
||||
```bash
|
||||
adb devices -l
|
||||
```
|
||||
|
||||
运行 App:
|
||||
|
||||
```bash
|
||||
cd /Users/zuozuo/Desktop/HY/hyapp-flutter
|
||||
flutter run -d emulator-5554
|
||||
```
|
||||
|
||||
Android 模拟器访问本机后端默认地址:
|
||||
|
||||
```text
|
||||
http://10.0.2.2:13000
|
||||
```
|
||||
|
||||
如果要指定后端地址:
|
||||
|
||||
```bash
|
||||
flutter run -d emulator-5554 \
|
||||
--dart-define=API_BASE_URL=http://10.0.2.2:13000
|
||||
```
|
||||
|
||||
如果旧包名已经安装过,可以卸载旧包:
|
||||
|
||||
```bash
|
||||
adb uninstall com.hyapp.hyapp_flutter
|
||||
adb uninstall com.org.laluparty
|
||||
```
|
||||
|
||||
然后重新运行:
|
||||
|
||||
```bash
|
||||
flutter run -d emulator-5554
|
||||
```
|
||||
|
||||
## Release 前必须补充
|
||||
|
||||
当前 release 还临时使用 debug signing config。正式包必须:
|
||||
|
||||
1. 创建 release keystore。
|
||||
2. 配置 Android release signing。
|
||||
3. 用 release keystore 跑:
|
||||
|
||||
```bash
|
||||
cd /Users/zuozuo/Desktop/HY/hyapp-flutter/android
|
||||
./gradlew signingReport
|
||||
```
|
||||
|
||||
4. 把 release SHA1 和 SHA-256 添加到 Firebase Android App。
|
||||
5. 重新下载并替换 `android/app/google-services.json`。
|
||||
6. 如果上架 Google Play,还要把 Play Console 的 App signing key SHA1/SHA-256 添加到 Firebase。
|
||||
|
||||
## 常见错误
|
||||
|
||||
### 选择账号后直接取消
|
||||
|
||||
常见原因:
|
||||
|
||||
- Firebase 包名不是 `com.org.laluparty`
|
||||
- SHA1 没添加或添加错了
|
||||
- `google-services.json` 不是最新文件
|
||||
- `google-services.json` 没放在 `android/app/`
|
||||
- 没有 Web OAuth client
|
||||
|
||||
### serverClientId must be provided on Android
|
||||
|
||||
说明 Android 没从 `google-services.json` 读到 Web client id。
|
||||
|
||||
处理:
|
||||
|
||||
1. 确认 `android/app/google-services.json` 存在。
|
||||
2. 确认 Gradle 已应用 `com.google.gms.google-services`。
|
||||
3. 确认 JSON 内有 `client_type: 3` 的 OAuth client。
|
||||
4. 重新下载 Firebase 配置文件。
|
||||
5. 或运行时传:
|
||||
|
||||
```bash
|
||||
--dart-define=GOOGLE_SERVER_CLIENT_ID=你的Web客户端ID
|
||||
```
|
||||
|
||||
### DEVELOPER_ERROR / clientConfigurationError
|
||||
|
||||
常见原因:
|
||||
|
||||
- 包名不匹配
|
||||
- SHA 指纹不匹配
|
||||
- 使用了错误 Firebase 项目的 `google-services.json`
|
||||
- Android OAuth client 和 Web OAuth client 配置不完整
|
||||
|
||||
### 后端返回 AUTH_FAILED
|
||||
|
||||
后端要求:
|
||||
|
||||
- `provider` 必须在允许列表里,当前 Firebase 登录应包含 `firebase`
|
||||
- `credential` 必须是未过期的 Firebase ID token
|
||||
- `device_id` 不能为空
|
||||
- Firebase project id 必须和 token 的 `aud` 一致
|
||||
- token issuer 必须匹配 `https://securetoken.google.com/<firebase_project_id>`
|
||||
- token 签名必须能通过 Firebase Admin SDK 或 Firebase 公钥校验
|
||||
- `firebase.sign_in_provider` 必须在后端 allowlist 中,首版 Google 登录为 `google.com`
|
||||
|
||||
如果 Firebase 登录成功但后端失败,优先用响应 `request_id` 查 gateway/user-service 日志。日志只能记录失败类型和 Firebase UID 摘要,不能记录原始 ID token。
|
||||
|
||||
## 参考
|
||||
|
||||
- Firebase Android setup: https://firebase.google.com/docs/android/setup
|
||||
- Firebase Android Google Sign-In: https://firebase.google.com/docs/auth/android/google-signin
|
||||
- Firebase verify ID tokens: https://firebase.google.com/docs/auth/admin/verify-id-tokens
|
||||
- google_sign_in: https://pub.dev/packages/google_sign_in
|
||||
- google_sign_in_android integration: https://pub.dev/packages/google_sign_in_android
|
||||
@ -25,6 +25,8 @@ tags:
|
||||
description: 腾讯 RTC 语音进房票据入口。
|
||||
- name: users
|
||||
description: 当前展示短号查询和变更。
|
||||
- name: countries
|
||||
description: App 注册页国家选择公开读接口。
|
||||
- name: rooms
|
||||
description: 房间命令入口,最终由 room-service 执行。
|
||||
securityDefinitions:
|
||||
@ -276,6 +278,22 @@ paths:
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/countries:
|
||||
get:
|
||||
tags:
|
||||
- countries
|
||||
summary: 获取 App 注册页可选国家
|
||||
operationId: listRegistrationCountries
|
||||
description: 公开读接口,只返回 user-service 判定的 `enabled=true` 国家,并按 `sort_order,country_code` 排序。
|
||||
parameters:
|
||||
- $ref: "#/parameters/RequestIDHeader"
|
||||
responses:
|
||||
"200":
|
||||
description: 查询成功,`data.countries` 返回当前开放国家。
|
||||
schema:
|
||||
$ref: "#/definitions/CountryListEnvelope"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/im/usersig:
|
||||
get:
|
||||
tags:
|
||||
@ -414,6 +432,37 @@ paths:
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/users/me/onboarding/complete:
|
||||
post:
|
||||
tags:
|
||||
- users
|
||||
summary: 完成注册页必填资料
|
||||
operationId: completeMyOnboarding
|
||||
description: 三方登录即注册后唯一资料提交入口;`username/avatar/country` 在 user-service 同事务校验并写入。
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: "#/parameters/RequestIDHeader"
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/CompleteOnboardingRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 注册资料完成,`data.profile_completed=true`,`data.token.access_token` 是替换旧 JWT 的新 access token。
|
||||
schema:
|
||||
$ref: "#/definitions/CompleteOnboardingEnvelope"
|
||||
"400":
|
||||
$ref: "#/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"404":
|
||||
$ref: "#/responses/NotFound"
|
||||
"500":
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/users/me/profile/update:
|
||||
post:
|
||||
tags:
|
||||
@ -1167,12 +1216,18 @@ definitions:
|
||||
LoginThirdPartyRequest:
|
||||
type: object
|
||||
description: 三方登录即注册;首次注册时资料字段写入 user-service users 主记录。`ip`、`user_agent` 和 `country_by_ip` 由 gateway/服务端上下文生成,不接受客户端 JSON。
|
||||
required:
|
||||
- provider
|
||||
- credential
|
||||
- device_id
|
||||
- platform
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
example: google
|
||||
example: firebase
|
||||
credential:
|
||||
type: string
|
||||
description: Firebase Auth ID token;不能提交 Google account id、email 或 access token。
|
||||
username:
|
||||
type: string
|
||||
maxLength: 64
|
||||
@ -1243,6 +1298,7 @@ definitions:
|
||||
properties:
|
||||
refresh_token:
|
||||
type: string
|
||||
description: 登录和 refresh 响应返回;CompleteOnboarding 不轮换 refresh token,可能为空或缺失。
|
||||
device_id:
|
||||
type: string
|
||||
LogoutRequest:
|
||||
@ -1281,6 +1337,13 @@ definitions:
|
||||
example: Bearer
|
||||
is_new_user:
|
||||
type: boolean
|
||||
profile_completed:
|
||||
type: boolean
|
||||
onboarding_status:
|
||||
type: string
|
||||
enum:
|
||||
- profile_required
|
||||
- completed
|
||||
PasswordSetData:
|
||||
type: object
|
||||
properties:
|
||||
@ -1443,6 +1506,15 @@ definitions:
|
||||
type: string
|
||||
country_display_name:
|
||||
type: string
|
||||
iso_numeric:
|
||||
type: string
|
||||
description: ISO numeric / UNSD M49 三位字符串,客户端不能按数字解析。
|
||||
phone_country_code:
|
||||
type: string
|
||||
description: E.164 国家呼叫码,例如 `+86`;可能为空或多个国家共享。
|
||||
country_enabled:
|
||||
type: boolean
|
||||
description: 当前国家是否仍对 App 注册和改国家开放。
|
||||
region_id:
|
||||
type: integer
|
||||
format: int64
|
||||
@ -1458,12 +1530,82 @@ definitions:
|
||||
description: yyyy-mm-dd。
|
||||
language:
|
||||
type: string
|
||||
profile_completed:
|
||||
type: boolean
|
||||
profile_completed_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
onboarding_status:
|
||||
type: string
|
||||
enum:
|
||||
- profile_required
|
||||
- completed
|
||||
updated_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
next_country_change_allowed_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
CountryData:
|
||||
type: object
|
||||
properties:
|
||||
country_id:
|
||||
type: integer
|
||||
format: int64
|
||||
country_name:
|
||||
type: string
|
||||
country_code:
|
||||
type: string
|
||||
maxLength: 3
|
||||
iso_alpha3:
|
||||
type: string
|
||||
maxLength: 3
|
||||
iso_numeric:
|
||||
type: string
|
||||
description: 三位 numeric 字符串。
|
||||
country_display_name:
|
||||
type: string
|
||||
phone_country_code:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
flag:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
format: int32
|
||||
CountryListData:
|
||||
type: object
|
||||
properties:
|
||||
countries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/definitions/CountryData"
|
||||
CompleteOnboardingRequest:
|
||||
type: object
|
||||
required:
|
||||
- username
|
||||
- avatar
|
||||
- country
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
avatar:
|
||||
type: string
|
||||
description: HTTP/HTTPS 头像 URL。
|
||||
country:
|
||||
type: string
|
||||
maxLength: 3
|
||||
pattern: "^[A-Za-z]{2,3}$"
|
||||
description: 注册页从 `/api/v1/countries` 取得的 country_code。
|
||||
CompleteOnboardingData:
|
||||
allOf:
|
||||
- $ref: "#/definitions/UserProfileData"
|
||||
- type: object
|
||||
properties:
|
||||
token:
|
||||
$ref: "#/definitions/AuthTokenData"
|
||||
description: 注册完成后替换旧 access token;refresh_token 为空或不返回。
|
||||
UpdateUserProfileRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -1481,7 +1623,7 @@ definitions:
|
||||
type: string
|
||||
maxLength: 3
|
||||
pattern: "^[A-Za-z]{2,3}$"
|
||||
description: 国家主数据 country_code,服务端会转大写并要求 active。
|
||||
description: 国家主数据 country_code,服务端会转大写并要求 `enabled=true`。
|
||||
ChangeDisplayUserIDRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -2008,6 +2150,20 @@ definitions:
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/UserProfileData"
|
||||
CompleteOnboardingEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/CompleteOnboardingData"
|
||||
CountryListEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/CountryListData"
|
||||
CreateRoomEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
|
||||
@ -22,6 +22,8 @@ tags:
|
||||
description: UserIdentityService,展示短号解析和变更。
|
||||
- name: country-admin
|
||||
description: CountryAdminService,国家主数据内部管理 RPC。
|
||||
- name: country-query
|
||||
description: CountryQueryService,App 注册页公开国家列表内部 RPC。
|
||||
- name: region-admin
|
||||
description: RegionAdminService,区域和国家归属内部管理 RPC。
|
||||
- name: health
|
||||
@ -213,6 +215,27 @@ paths:
|
||||
$ref: "#/definitions/ChangeUserCountryResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.user.v1.UserService/CompleteOnboarding:
|
||||
post:
|
||||
tags:
|
||||
- users
|
||||
summary: 完成注册页必填资料
|
||||
operationId: userCompleteOnboarding
|
||||
description: 同一事务校验并写入 username、avatar、country、region_id 和 profile_completed 状态。
|
||||
x-grpc-full-method: /hyapp.user.v1.UserService/CompleteOnboarding
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/CompleteOnboardingRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 注册资料完成。
|
||||
schema:
|
||||
$ref: "#/definitions/CompleteOnboardingResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.user.v1.CountryAdminService/CreateCountry:
|
||||
post:
|
||||
tags:
|
||||
@ -273,12 +296,33 @@ paths:
|
||||
$ref: "#/definitions/CountryResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.user.v1.CountryAdminService/EnableCountry:
|
||||
post:
|
||||
tags:
|
||||
- country-admin
|
||||
summary: 开放国家给 App 选择
|
||||
operationId: userEnableCountry
|
||||
x-grpc-full-method: /hyapp.user.v1.CountryAdminService/EnableCountry
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/EnableCountryRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 开放成功。
|
||||
schema:
|
||||
$ref: "#/definitions/CountryResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.user.v1.CountryAdminService/DisableCountry:
|
||||
post:
|
||||
tags:
|
||||
- country-admin
|
||||
summary: 停用国家
|
||||
summary: 关闭国家 App 可选状态
|
||||
operationId: userDisableCountry
|
||||
description: 只写 `enabled=false`,不改变国家生命周期,不释放 region_countries 配置。
|
||||
x-grpc-full-method: /hyapp.user.v1.CountryAdminService/DisableCountry
|
||||
parameters:
|
||||
- name: body
|
||||
@ -293,6 +337,26 @@ paths:
|
||||
$ref: "#/definitions/CountryResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.user.v1.CountryQueryService/ListRegistrationCountries:
|
||||
post:
|
||||
tags:
|
||||
- country-query
|
||||
summary: 查询 App 注册页开放国家列表
|
||||
operationId: userListRegistrationCountries
|
||||
x-grpc-full-method: /hyapp.user.v1.CountryQueryService/ListRegistrationCountries
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/ListRegistrationCountriesRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 查询成功;只返回 active 且 enabled 的国家。
|
||||
schema:
|
||||
$ref: "#/definitions/ListRegistrationCountriesResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.user.v1.RegionAdminService/CreateRegion:
|
||||
post:
|
||||
tags:
|
||||
@ -604,6 +668,13 @@ definitions:
|
||||
display_user_id_expires_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
profile_completed:
|
||||
type: boolean
|
||||
onboarding_status:
|
||||
type: string
|
||||
enum:
|
||||
- profile_required
|
||||
- completed
|
||||
LoginPasswordRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -616,13 +687,20 @@ definitions:
|
||||
LoginThirdPartyRequest:
|
||||
type: object
|
||||
description: 三方登录或注册的内部 gRPC 请求;首次注册时资料字段写入 users 主记录。
|
||||
required:
|
||||
- provider
|
||||
- credential
|
||||
- device_id
|
||||
- platform
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
provider:
|
||||
type: string
|
||||
example: firebase
|
||||
credential:
|
||||
type: string
|
||||
description: Firebase Auth ID token;不能提交 Google account id、email 或 access token。
|
||||
username:
|
||||
type: string
|
||||
maxLength: 64
|
||||
@ -633,7 +711,7 @@ definitions:
|
||||
type: string
|
||||
maxLength: 3
|
||||
pattern: "^[A-Za-z]{2,3}$"
|
||||
description: 国家主数据 country_code,服务端会转大写并要求 active。
|
||||
description: 国家主数据 country_code,服务端会转大写并要求 `enabled=true`。
|
||||
invite_code:
|
||||
type: string
|
||||
maxLength: 64
|
||||
@ -690,6 +768,13 @@ definitions:
|
||||
$ref: "#/definitions/AuthToken"
|
||||
is_new_user:
|
||||
type: boolean
|
||||
profile_completed:
|
||||
type: boolean
|
||||
onboarding_status:
|
||||
type: string
|
||||
enum:
|
||||
- profile_required
|
||||
- completed
|
||||
SetPasswordRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -789,6 +874,15 @@ definitions:
|
||||
type: string
|
||||
country_display_name:
|
||||
type: string
|
||||
iso_numeric:
|
||||
type: string
|
||||
description: ISO numeric / UNSD M49 三位字符串,客户端不能按数字解析。
|
||||
phone_country_code:
|
||||
type: string
|
||||
description: E.164 国家呼叫码,例如 `+86`;可能为空或多个国家共享。
|
||||
country_enabled:
|
||||
type: boolean
|
||||
description: 当前国家是否仍对 App 注册和改国家开放。
|
||||
region_id:
|
||||
type: integer
|
||||
format: int64
|
||||
@ -797,6 +891,16 @@ definitions:
|
||||
type: string
|
||||
region_name:
|
||||
type: string
|
||||
profile_completed:
|
||||
type: boolean
|
||||
profile_completed_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
onboarding_status:
|
||||
type: string
|
||||
enum:
|
||||
- profile_required
|
||||
- completed
|
||||
GetUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -849,6 +953,35 @@ definitions:
|
||||
properties:
|
||||
user:
|
||||
$ref: "#/definitions/User"
|
||||
CompleteOnboardingRequest:
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
username:
|
||||
type: string
|
||||
avatar:
|
||||
type: string
|
||||
country:
|
||||
type: string
|
||||
maxLength: 3
|
||||
pattern: "^[A-Z]{2,3}$"
|
||||
description: 注册页开放国家列表返回的 country_code。
|
||||
CompleteOnboardingResponse:
|
||||
type: object
|
||||
properties:
|
||||
user:
|
||||
$ref: "#/definitions/User"
|
||||
profile_completed:
|
||||
type: boolean
|
||||
profile_completed_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
onboarding_status:
|
||||
type: string
|
||||
ChangeUserCountryRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -861,7 +994,7 @@ definitions:
|
||||
type: string
|
||||
maxLength: 3
|
||||
pattern: "^[A-Z]{2,3}$"
|
||||
description: 国家主数据 country_code,服务端会归一并要求 active。
|
||||
description: 国家主数据 country_code,服务端会归一并要求 `enabled=true`。
|
||||
ChangeUserCountryResponse:
|
||||
type: object
|
||||
properties:
|
||||
@ -883,14 +1016,21 @@ definitions:
|
||||
type: string
|
||||
maxLength: 3
|
||||
pattern: "^[A-Z]{2,3}$"
|
||||
iso_alpha3:
|
||||
type: string
|
||||
maxLength: 3
|
||||
iso_numeric:
|
||||
type: string
|
||||
description: 三位 numeric 字符串。
|
||||
country_display_name:
|
||||
type: string
|
||||
maxLength: 128
|
||||
status:
|
||||
phone_country_code:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
flag:
|
||||
type: string
|
||||
enum:
|
||||
- active
|
||||
- disabled
|
||||
sort_order:
|
||||
type: integer
|
||||
format: int32
|
||||
@ -908,7 +1048,8 @@ definitions:
|
||||
format: int64
|
||||
region_code:
|
||||
type: string
|
||||
pattern: "^[A-Z0-9_]{2,32}$"
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9 _-]{1,63}$"
|
||||
description: 支持短业务码,也支持 countries.json 的 subregion 原值。
|
||||
name:
|
||||
type: string
|
||||
maxLength: 128
|
||||
@ -944,9 +1085,22 @@ definitions:
|
||||
type: string
|
||||
maxLength: 3
|
||||
pattern: "^[A-Za-z]{2,3}$"
|
||||
iso_alpha3:
|
||||
type: string
|
||||
maxLength: 3
|
||||
iso_numeric:
|
||||
type: string
|
||||
description: 三位 numeric 字符串。
|
||||
country_display_name:
|
||||
type: string
|
||||
maxLength: 128
|
||||
phone_country_code:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
description: 不传时默认 true。
|
||||
flag:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
format: int32
|
||||
@ -958,8 +1112,9 @@ definitions:
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
status:
|
||||
type: string
|
||||
enabled:
|
||||
type: boolean
|
||||
description: 可选过滤;不传返回全部 enabled 状态。
|
||||
ListCountriesResponse:
|
||||
type: object
|
||||
properties:
|
||||
@ -978,15 +1133,36 @@ definitions:
|
||||
country_name:
|
||||
type: string
|
||||
maxLength: 128
|
||||
iso_alpha3:
|
||||
type: string
|
||||
maxLength: 3
|
||||
iso_numeric:
|
||||
type: string
|
||||
description: 三位 numeric 字符串。
|
||||
country_display_name:
|
||||
type: string
|
||||
maxLength: 128
|
||||
phone_country_code:
|
||||
type: string
|
||||
flag:
|
||||
type: string
|
||||
sort_order:
|
||||
type: integer
|
||||
format: int32
|
||||
operator_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
EnableCountryRequest:
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
country_id:
|
||||
type: integer
|
||||
format: int64
|
||||
operator_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
DisableCountryRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -1003,6 +1179,18 @@ definitions:
|
||||
properties:
|
||||
country:
|
||||
$ref: "#/definitions/Country"
|
||||
ListRegistrationCountriesRequest:
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
ListRegistrationCountriesResponse:
|
||||
type: object
|
||||
properties:
|
||||
countries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/definitions/Country"
|
||||
CreateRegionRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -1010,7 +1198,7 @@ definitions:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
region_code:
|
||||
type: string
|
||||
pattern: "^[A-Za-z0-9_]{2,32}$"
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9 _-]{1,63}$"
|
||||
name:
|
||||
type: string
|
||||
maxLength: 128
|
||||
@ -1058,7 +1246,7 @@ definitions:
|
||||
format: int64
|
||||
region_code:
|
||||
type: string
|
||||
pattern: "^[A-Za-z0-9_]{2,32}$"
|
||||
pattern: "^[A-Za-z0-9][A-Za-z0-9 _-]{1,63}$"
|
||||
name:
|
||||
type: string
|
||||
maxLength: 128
|
||||
|
||||
@ -80,7 +80,7 @@ services/<service>/
|
||||
service_name: user-service
|
||||
node_id: user-local
|
||||
grpc_addr: ":13005"
|
||||
mysql_dsn: "root:root@tcp(mysql:3306)/hy_user?parseTime=true"
|
||||
mysql_dsn: "root:root@tcp(mysql:3306)/hyapp_user?parseTime=true"
|
||||
mysql_auto_migrate: false
|
||||
```
|
||||
|
||||
@ -100,7 +100,7 @@ jwt:
|
||||
`wallet-service`:
|
||||
|
||||
```yaml
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
```
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@
|
||||
|
||||
### Country
|
||||
|
||||
国家是用户资料和区域归属的基础主数据。客户端和外部 HTTP API 只使用 `country_code`;`country_id` 是服务端内部主键,便于管理端编辑和审计。
|
||||
国家是用户资料、区域归属和注册页国家选择的基础主数据。客户端和外部 HTTP API 只使用 `country_code`;`country_id` 是服务端内部主键,便于管理端编辑和审计。
|
||||
|
||||
字段语义:
|
||||
|
||||
@ -49,18 +49,69 @@
|
||||
| --- | --- |
|
||||
| `country_id` | 服务端生成的内部主键,不给客户端提交 |
|
||||
| `country_name` | 稳定英文或拼音名称,例如 `China`、`United States` |
|
||||
| `country_code` | 稳定 canonical 国家码,统一大写,格式为 `^[A-Z]{2,3}$` |
|
||||
| `country_code` | App 内部 canonical 国家码,首版按 ISO 3166-1 alpha-2 保存,例如 `CN`、`SG`;`XK` 作为产品覆盖需要的 user-assigned code |
|
||||
| `iso_alpha3` | ISO 3166-1 alpha-3 代码,例如 `CHN`、`SGP`;没有正式 ISO 分配时允许为空或 user-assigned 值 |
|
||||
| `iso_numeric` | ISO 3166-1 numeric / UNSD M49 三位数字代码,例如中国 `156`;没有正式数字码时为空 |
|
||||
| `country_display_name` | 中文展示名,例如 `中国`、`美国` |
|
||||
| `status` | `active` 或 `disabled`;disabled 国家不能被新注册、改国家或区域配置选中 |
|
||||
| `phone_country_code` | 国际电话区号,使用 E.164 国家呼叫码格式,例如中国 `+86`、新加坡 `+65`;没有可用电话元数据时为空 |
|
||||
| `flag` | 国家或地区 emoji flag,注册页展示用;没有可展示 flag 时保存空字符串 |
|
||||
| `enabled` | App 是否开放该国家,默认 `true`;`false` 时不出现在 App 注册国家列表,且不能被新注册或改国家选择 |
|
||||
|
||||
规则:
|
||||
|
||||
- `countries.country_code` 是本系统国家码权威来源。同一个真实国家只能有一条记录,运营配置必须避免同时把 `US` 和 `USA` 当成两个国家主键。
|
||||
- `country_code` 创建后视为不可变。需要改码时新增国家记录并通过重算任务更新 `users.country`、`region_countries` 和审计口径。
|
||||
- 注册、改国家、区域国家配置都必须先把输入 trim、转大写,再校验 `^[A-Z]{2,3}$`,然后命中 active `countries`。
|
||||
- `users.country` 保存 `country_code` 字符串;服务端展示时可以按 `country_code` 拼出 `country_id/country_name/country_display_name`。
|
||||
- `iso_alpha3`、`iso_numeric` 和 `phone_country_code` 是国家主数据展示和搜索辅助字段,不参与用户区域归属判断。
|
||||
- `iso_numeric` 必须按三位字符串保存,不能保存成整数,否则 `020` 这类前导零数字码会被破坏。
|
||||
- `phone_country_code` 可以被多个国家或地区共享,例如 `US`、`CA` 都是 `+1`,因此不能加唯一约束。
|
||||
- `flag` 只做展示,不参与国家归属、区域映射、排序和权限判断。
|
||||
- `enabled` 是产品开放开关,不是删除标记。禁用国家不会清空历史用户的 `users.country`,只阻止后续注册和改国家选择。
|
||||
- App 注册页国家列表只返回 `enabled=true` 的国家。
|
||||
- 注册和改国家都必须先把输入 trim、转大写,再校验 `country_code`,然后命中 `enabled=true` 的 `countries`。
|
||||
- 区域国家配置只要求国家记录存在;`enabled=false` 的国家允许预先配置到区域,但不允许 App 用户选择。
|
||||
- `users.country` 保存 `country_code` 字符串;服务端展示时可以按 `country_code` 拼出 `country_id/country_name/country_display_name/iso_numeric/phone_country_code`。
|
||||
- `country_by_ip` 只做审计和风控快照;如果边缘层给出的国家码不在 `countries` 中,user-service 应丢弃或记录为空,不能参与区域归属。
|
||||
|
||||
### Country Seed Data
|
||||
|
||||
`services/user-service/deploy/data/countries.json` 是国家初始化和后续映射配置的种子数据。它不是 `countries` 表的一比一结构。
|
||||
|
||||
每个国家对象必须保留这些字段:
|
||||
|
||||
| Field | Stored In `countries` | Rule |
|
||||
| --- | --- | --- |
|
||||
| `country_name` | yes | 英文国家名 |
|
||||
| `country_code` | yes | App canonical 国家码 |
|
||||
| `country_display_name` | yes | 中文展示名 |
|
||||
| `iso_alpha3` | yes | ISO alpha-3 或 user-assigned 值 |
|
||||
| `iso_numeric` | yes | 三位 numeric 字符串 |
|
||||
| `phone_country_code` | yes | 国际电话区号 |
|
||||
| `flag` | yes | App 展示用 emoji flag |
|
||||
| `status` | yes | 默认 `active` |
|
||||
| `sort_order` | yes | App 注册页排序 |
|
||||
| `region` | no | 地理大区,例如 `Asia`、`Africa`,只用于后续国家到业务区域的初始映射建议 |
|
||||
| `region_display_name` | no | `region` 的中文展示名,例如 `亚洲`、`非洲`,只用于配置和运营展示 |
|
||||
| `subregion` | no | 地理子区域,例如 `South-Eastern Asia`,只用于后续映射规则或运营筛选 |
|
||||
| `subregion_display_name` | no | `subregion` 的中文展示名,例如 `东南亚`、`西亚`,只用于配置和运营展示 |
|
||||
|
||||
不再保留这些源数据字段:
|
||||
|
||||
```text
|
||||
independent
|
||||
un_member
|
||||
source_assignment_status
|
||||
```
|
||||
|
||||
`region`、`region_display_name`、`subregion` 和 `subregion_display_name` 不能进入 `countries` 表。业务区域仍然以 `regions` 和 `region_countries` 为准,不能用种子数据里的地理 `region/subregion` 直接替代业务 `region_id`。
|
||||
|
||||
当前开发态初始化会把种子数据导入三张表:
|
||||
|
||||
- `countries` 写入国家主数据字段,不写 `region/subregion` 文本。
|
||||
- `regions.region_code` 使用 `subregion` 原值,`regions.name` 使用 `subregion_display_name`。
|
||||
- `region_countries` 按每个国家的 `subregion` 建立 active 映射。
|
||||
- 一级 `region/region_display_name` 不入库,不参与业务区域判断。
|
||||
- 没有 `subregion` 的少数地区统一落到 `UNSPECIFIED`,避免注册和用户投影出现无区域主记录。
|
||||
|
||||
### Region
|
||||
|
||||
区域是业务管理维度,不是地理标准库。它可以表示运营分区、价格分区、合规分区或内容分发分区。
|
||||
@ -70,16 +121,16 @@
|
||||
| Field | Rule |
|
||||
| --- | --- |
|
||||
| `region_id` | 服务端生成的内部主键,不给客户端提交 |
|
||||
| `region_code` | 稳定业务编码,例如 `SEA`、`LATAM`、`MENA` |
|
||||
| `region_code` | 稳定业务编码;当前初始化直接使用 `countries.json.subregion`,例如 `South-Eastern Asia` |
|
||||
| `name` | 管理端展示名 |
|
||||
| `status` | `active` 或 `disabled` |
|
||||
| `countries` | 国家码列表,元素必须存在于 active `countries.country_code` |
|
||||
| `countries` | 国家码列表,元素必须存在于 `countries.country_code` |
|
||||
|
||||
### Country Mapping
|
||||
|
||||
国家到区域的映射规则:
|
||||
|
||||
- 只有 active 国家才能配置到区域。
|
||||
- 只要国家记录存在即可配置到区域,`enabled=false` 只影响 App 用户选择。
|
||||
- 一个国家最多只能属于一个 active 区域。
|
||||
- 国家不属于任何区域时,用户 `region_id` 为空。
|
||||
- 区域停用后,该区域下国家不再产生归属。
|
||||
@ -114,19 +165,37 @@ CREATE TABLE IF NOT EXISTS countries (
|
||||
country_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
country_name VARCHAR(128) NOT NULL,
|
||||
country_code VARCHAR(3) NOT NULL,
|
||||
iso_alpha3 VARCHAR(3) NULL,
|
||||
iso_numeric CHAR(3) NULL,
|
||||
country_display_name VARCHAR(128) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
phone_country_code VARCHAR(8) NULL,
|
||||
flag VARCHAR(16) NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_by_user_id BIGINT NULL,
|
||||
updated_by_user_id BIGINT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_countries_code (country_code),
|
||||
KEY idx_countries_status_sort (status, sort_order)
|
||||
UNIQUE KEY uk_countries_iso_numeric (iso_numeric),
|
||||
KEY idx_countries_phone_country_code (phone_country_code),
|
||||
KEY idx_countries_app_enabled_sort (enabled, sort_order, country_code),
|
||||
KEY idx_countries_sort (sort_order, country_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
`country_code` 使用 `VARCHAR(3)` 而不是 `CHAR(2)`,因为产品允许两位或三位国家码。服务端不维护国家码别名表;客户端和运营后台必须提交最终 `country_code`。
|
||||
`country_code` 继续使用 `VARCHAR(3)` 是为了兼容已有字段长度和少量 user-assigned code,但首版业务校验按两位大写国家码收敛。服务端不维护国家码别名表;客户端和运营后台必须提交最终 `country_code`。
|
||||
|
||||
字段规则:
|
||||
|
||||
- `iso_alpha3` 只保存 ISO alpha-3 或明确的 user-assigned 值,空字符串入库前必须转为 `NULL`。
|
||||
- `iso_numeric` 只保存三位 numeric 字符串,空字符串入库前必须转为 `NULL`。
|
||||
- `uk_countries_iso_numeric` 允许多个 `NULL`,但阻止两个国家共享同一个正式 numeric 代码。
|
||||
- `phone_country_code` 保存包含 `+` 的 E.164 国家呼叫码,例如 `+86`;没有电话区号的 ISO 区域保存 `NULL`。
|
||||
- `phone_country_code` 不唯一,因为多个国家或地区可以共享同一个国际电话区号。
|
||||
- `flag` 保存 emoji flag,作为 App 注册页展示字段;它不是国家唯一性或区域归属依据。
|
||||
- `enabled` 默认 `TRUE`,确保初始化国家数据默认都可被 App 注册页展示;不开放的国家由后台显式禁用。
|
||||
- `idx_countries_app_enabled_sort` 服务 App 注册国家列表,必须覆盖 `enabled=true ORDER BY sort_order,country_code`。
|
||||
|
||||
### `regions`
|
||||
|
||||
@ -168,7 +237,7 @@ CREATE TABLE IF NOT EXISTS region_countries (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
`uk_region_countries_active_country` 明确阻止一个国家同时挂到多个 active 区域,同时允许 disabled 区域保留历史映射记录。写入 `region_countries` 前必须确认 `country_code` 存在于 active `countries`。
|
||||
`uk_region_countries_active_country` 明确阻止一个国家同时挂到多个 active 区域,同时允许 disabled 区域保留历史映射记录。写入 `region_countries` 前必须确认 `country_code` 存在于 `countries`。
|
||||
|
||||
### `region_change_logs`
|
||||
|
||||
@ -266,6 +335,9 @@ message User {
|
||||
int64 region_id = 18;
|
||||
string region_code = 19;
|
||||
string region_name = 20;
|
||||
string iso_numeric = 21;
|
||||
string phone_country_code = 22;
|
||||
bool country_enabled = 23;
|
||||
}
|
||||
```
|
||||
|
||||
@ -274,9 +346,50 @@ message User {
|
||||
- `country` 是 `country_code`,例如 `CN`、`US`。
|
||||
- `country_id = 0` 表示当前 `country` 为空或未命中国家表。
|
||||
- `country_display_name` 是中文展示名,面向需要中文国家描述的客户端或管理端。
|
||||
- `iso_numeric` 是三位 numeric 字符串,客户端不能按数字解析。
|
||||
- `phone_country_code` 是国际电话区号,可能为空,也可能被多个国家共享。
|
||||
- `country_enabled` 表示当前国家是否仍向 App 开放;历史用户可能持有已禁用国家,因此该字段可能为 `false`。
|
||||
- `region_id = 0` 表示无区域归属。
|
||||
- `region_code` 和 `region_name` 只用于展示或筛选,不参与权限判断。
|
||||
|
||||
`iso_numeric`、`phone_country_code` 和 `country_enabled` 必须追加在现有 `User` 字段之后,不能移动 `region_id/region_code/region_name` 的字段号。
|
||||
|
||||
### App Country List API
|
||||
|
||||
App 注册页通过 gateway 获取可选国家列表。该接口是公开读接口,返回值只包含当前开放国家。
|
||||
|
||||
```http
|
||||
GET /api/v1/countries
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"countries": [
|
||||
{
|
||||
"country_id": 45,
|
||||
"country_name": "China",
|
||||
"country_code": "CN",
|
||||
"iso_alpha3": "CHN",
|
||||
"iso_numeric": "156",
|
||||
"country_display_name": "中国",
|
||||
"phone_country_code": "+86",
|
||||
"enabled": true,
|
||||
"sort_order": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 只返回 `enabled=true` 的国家。
|
||||
- 按 `sort_order ASC, country_code ASC` 排序。
|
||||
- `country_code` 是注册页提交给 `CompleteOnboarding` 的唯一国家值。
|
||||
- `phone_country_code` 只用于注册页展示或后续手机号能力预填,不参与国家归属判断。
|
||||
- 该接口可以由 gateway 短 TTL 缓存,但必须在后台启用/禁用国家后尽快失效。
|
||||
|
||||
### Admin Region APIs
|
||||
|
||||
管理端接口后续放在 gateway admin API 下,gateway 负责管理端鉴权和 request envelope,user-service 执行业务事务。
|
||||
@ -287,6 +400,7 @@ message User {
|
||||
POST /api/v1/admin/countries
|
||||
GET /api/v1/admin/countries
|
||||
PUT /api/v1/admin/countries/{country_id}
|
||||
POST /api/v1/admin/countries/{country_id}/enable
|
||||
POST /api/v1/admin/countries/{country_id}/disable
|
||||
POST /api/v1/admin/regions
|
||||
GET /api/v1/admin/regions
|
||||
@ -302,11 +416,27 @@ POST /api/v1/admin/regions/{region_id}/disable
|
||||
{
|
||||
"country_name": "China",
|
||||
"country_code": "CN",
|
||||
"iso_alpha3": "CHN",
|
||||
"iso_numeric": "156",
|
||||
"country_display_name": "中国",
|
||||
"phone_country_code": "+86",
|
||||
"enabled": true,
|
||||
"sort_order": 10
|
||||
}
|
||||
```
|
||||
|
||||
`enabled` 不传时默认 `true`。后台可以创建后立即禁用,也可以创建时传 `false` 作为暂不开放国家。
|
||||
|
||||
禁用国家请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "not_open_for_registration"
|
||||
}
|
||||
```
|
||||
|
||||
禁用只把 `enabled=false`,不删除国家,不清空历史用户国家,也不改变 `region_countries` 历史配置。重新启用国家只把 `enabled=true`,后续 App 注册页和注册完成接口立即可以选择该国家。
|
||||
|
||||
创建区域请求:
|
||||
|
||||
```json
|
||||
@ -348,6 +478,7 @@ service CountryAdminService {
|
||||
rpc CreateCountry(CreateCountryRequest) returns (CountryResponse);
|
||||
rpc ListCountries(ListCountriesRequest) returns (ListCountriesResponse);
|
||||
rpc UpdateCountry(UpdateCountryRequest) returns (CountryResponse);
|
||||
rpc EnableCountry(EnableCountryRequest) returns (CountryResponse);
|
||||
rpc DisableCountry(DisableCountryRequest) returns (CountryResponse);
|
||||
}
|
||||
|
||||
@ -361,6 +492,39 @@ service RegionAdminService {
|
||||
}
|
||||
```
|
||||
|
||||
App 注册页读接口建议独立为普通查询服务,避免把管理 RPC 暴露给客户端:
|
||||
|
||||
```proto
|
||||
service CountryQueryService {
|
||||
rpc ListRegistrationCountries(ListRegistrationCountriesRequest) returns (ListRegistrationCountriesResponse);
|
||||
}
|
||||
|
||||
message Country {
|
||||
int64 country_id = 1;
|
||||
string country_name = 2;
|
||||
string country_code = 3;
|
||||
string country_display_name = 4;
|
||||
string status = 5;
|
||||
int32 sort_order = 6;
|
||||
int64 created_at_ms = 7;
|
||||
int64 updated_at_ms = 8;
|
||||
string iso_alpha3 = 9;
|
||||
string iso_numeric = 10;
|
||||
string phone_country_code = 11;
|
||||
bool enabled = 12;
|
||||
}
|
||||
|
||||
message ListRegistrationCountriesRequest {
|
||||
RequestMeta meta = 1;
|
||||
}
|
||||
|
||||
message ListRegistrationCountriesResponse {
|
||||
repeated Country countries = 1;
|
||||
}
|
||||
```
|
||||
|
||||
`ListRegistrationCountries` 必须只返回 `enabled=true` 的国家;管理端 `ListCountries` 可以按 enabled 过滤并返回全部国家。
|
||||
|
||||
所有管理 RPC 必须携带 `RequestMeta.request_id`,审计写入 `region_change_logs.request_id`。`request_id` 仍只做追踪,不做幂等键。
|
||||
|
||||
## Registration Flow
|
||||
@ -375,7 +539,7 @@ sequenceDiagram
|
||||
C->>G: POST /api/v1/auth/third-party/login(country=SG)
|
||||
G->>U: LoginThirdParty(registration.country=SG)
|
||||
U->>U: normalize country_code to SG
|
||||
U->>DB: resolve active country SG
|
||||
U->>DB: resolve enabled country SG
|
||||
U->>DB: resolve active region by country SG
|
||||
DB-->>U: region_id=SEA
|
||||
U->>DB: create users(country=SG, region_id=SEA)
|
||||
@ -387,9 +551,9 @@ Implementation notes:
|
||||
|
||||
- `LoginThirdParty` 首次注册才写 `users.region_id`。
|
||||
- 已存在三方身份的登录不因为请求体带 country 而修改国家或区域。
|
||||
- `country` 非空时必须命中 active `countries.country_code`,否则返回 `INVALID_ARGUMENT`。
|
||||
- `country` 非空时必须命中 `enabled=true` 的 `countries.country_code`,否则返回 `INVALID_ARGUMENT`。
|
||||
- 如果 `country` 为空或没有映射,`region_id` 写 NULL。
|
||||
- 如果国家格式非法或国家表无 active 记录,继续沿用现有 `INVALID_ARGUMENT`。
|
||||
- 如果国家格式非法、国家表无记录或国家未启用,继续沿用现有 `INVALID_ARGUMENT`。
|
||||
|
||||
## Country Change Flow
|
||||
|
||||
@ -404,7 +568,7 @@ sequenceDiagram
|
||||
G->>U: ChangeUserCountry(user_id, country=US)
|
||||
U->>DB: lock users row
|
||||
U->>DB: check country cooldown
|
||||
U->>DB: resolve active country US
|
||||
U->>DB: resolve enabled country US
|
||||
U->>DB: resolve active region by country US
|
||||
U->>DB: update users(country=US, region_id=...)
|
||||
U->>DB: insert user_country_change_logs
|
||||
@ -415,7 +579,7 @@ sequenceDiagram
|
||||
Rules:
|
||||
|
||||
- 修改为相同国家不写国家日志,也不消耗冷却期;如果区域映射已经变化,可以修正 `region_id`。
|
||||
- 改国家必须先通过 active `countries` 校验;不能把国家表不存在或 disabled 的 code 写入 `users.country`。
|
||||
- 改国家必须先通过 `enabled=true` 的 `countries` 校验;不能把国家表不存在或未启用的 code 写入 `users.country`。
|
||||
- 国家冷却判断仍以 `user_country_change_logs` 为准。
|
||||
- 区域重算不能绕过国家修改冷却;管理端变更映射导致的重算不属于用户修改国家,不写 `user_country_change_logs`。
|
||||
|
||||
@ -433,7 +597,7 @@ sequenceDiagram
|
||||
|
||||
A->>G: ReplaceRegionCountries(region=SEA, countries=[SG,MY])
|
||||
G->>U: ReplaceRegionCountries
|
||||
U->>DB: validate active countries and uniqueness
|
||||
U->>DB: validate countries exist and active mapping uniqueness
|
||||
U->>DB: replace region_countries
|
||||
U->>DB: insert region_change_logs
|
||||
U->>DB: create rebuild tasks for affected countries
|
||||
@ -452,15 +616,20 @@ Consistency target:
|
||||
|
||||
| Input | Rule | Error |
|
||||
| --- | --- | --- |
|
||||
| `country_code` | trim 后大写,`^[A-Z]{2,3}$`,创建后不可变 | `INVALID_ARGUMENT` |
|
||||
| `country_code` | trim 后大写,首版按 `^[A-Z]{2}$` 校验,`XK` 作为产品覆盖需要保留;创建后不可变 | `INVALID_ARGUMENT` |
|
||||
| `country_name` | 1 到 128 字符 | `INVALID_ARGUMENT` |
|
||||
| `iso_alpha3` | 可空;非空时 trim 后大写,`^[A-Z]{3}$` | `INVALID_ARGUMENT` |
|
||||
| `iso_numeric` | 可空;非空时必须是三位数字字符串 `^[0-9]{3}$`,不能转 int | `INVALID_ARGUMENT` |
|
||||
| `country_display_name` | 1 到 128 字符,中文展示名 | `INVALID_ARGUMENT` |
|
||||
| `region_code` | trim 后大写,建议 `^[A-Z0-9_]{2,32}$` | `INVALID_ARGUMENT` |
|
||||
| `phone_country_code` | 可空;非空时必须是 E.164 国家呼叫码格式,建议 `^\+[1-9][0-9]{0,2}$` | `INVALID_ARGUMENT` |
|
||||
| `enabled` | 可空;创建国家默认 `true`,管理端启用/禁用只能写布尔值 | `INVALID_ARGUMENT` |
|
||||
| `region_code` | 短业务码 trim 后大写;描述型 subregion 保留原值,允许英文字母、数字、空格、`_`、`-`,最长 64 | `INVALID_ARGUMENT` |
|
||||
| `name` | 1 到 128 字符 | `INVALID_ARGUMENT` |
|
||||
| `countries` | 非空数组,元素是 active `countries.country_code` | `INVALID_ARGUMENT` |
|
||||
| `countries` | 非空数组,元素必须存在于 `countries.country_code` | `INVALID_ARGUMENT` |
|
||||
| duplicate country | 同一次请求内不能重复 | `INVALID_ARGUMENT` |
|
||||
| country already mapped | 已属于其他 active 区域 | `REGION_COUNTRY_CONFLICT` |
|
||||
| disabled region update countries | 不允许给 disabled 区域改 countries | `FAILED_PRECONDITION` |
|
||||
| disabled country selected by App | `enabled=false` 的国家不能被注册完成或改国家选择 | `INVALID_ARGUMENT` |
|
||||
|
||||
新增错误码建议:
|
||||
|
||||
@ -484,8 +653,11 @@ type Country struct {
|
||||
CountryID int64
|
||||
CountryName string
|
||||
CountryCode string
|
||||
ISOAlpha3 string
|
||||
ISONumeric string
|
||||
CountryDisplayName string
|
||||
Status string
|
||||
PhoneCountryCode string
|
||||
Enabled bool
|
||||
SortOrder int
|
||||
CreatedAtMs int64
|
||||
UpdatedAtMs int64
|
||||
@ -514,9 +686,11 @@ type RegionCountryMapping struct {
|
||||
|
||||
```go
|
||||
type CountryRepository interface {
|
||||
ResolveActiveCountryByCode(ctx context.Context, countryCode string) (Country, bool, error)
|
||||
ResolveEnabledCountryByCode(ctx context.Context, countryCode string) (Country, bool, error)
|
||||
ListRegistrationCountries(ctx context.Context) ([]Country, error)
|
||||
CreateCountry(ctx context.Context, command CreateCountryCommand) (Country, error)
|
||||
UpdateCountry(ctx context.Context, command UpdateCountryCommand) (Country, error)
|
||||
EnableCountry(ctx context.Context, command EnableCountryCommand) (Country, error)
|
||||
ListCountries(ctx context.Context, filter CountryFilter) ([]Country, error)
|
||||
DisableCountry(ctx context.Context, command DisableCountryCommand) (Country, error)
|
||||
}
|
||||
@ -540,19 +714,29 @@ type RegionRepository interface {
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| key | uppercase `country_code` |
|
||||
| value | active `country_id`、展示字段、active `region_id` 或 empty marker |
|
||||
| value | active `country_id`、展示字段、`enabled`、active `region_id` 或 empty marker |
|
||||
| ttl | 30s 到 300s |
|
||||
| invalidation | 管理端变更后清本进程缓存;多实例依赖短 TTL 或后续 Redis pub/sub |
|
||||
|
||||
App 注册国家列表可以单独缓存:
|
||||
|
||||
| Setting | Value |
|
||||
| --- | --- |
|
||||
| key | `registration_countries:v1` |
|
||||
| value | `enabled=true` 的国家投影 |
|
||||
| ttl | 30s 到 300s |
|
||||
| invalidation | 创建、启用、禁用、更新国家后失效 |
|
||||
|
||||
首版为保证管理变更后新注册和改国家立即生效,优先不加缓存,直接 MySQL 查询。等注册量上来后再加缓存;一旦加缓存,必须使用版本校验、Redis pub/sub 或其他跨实例失效机制,不能只依赖 TTL。
|
||||
|
||||
## Development Plan
|
||||
|
||||
1. 新增 `countries`、`regions`、`region_countries`、`region_change_logs`、`user_region_rebuild_tasks`。
|
||||
1. 新增 `countries`、`regions`、`region_countries`、`region_change_logs`、`user_region_rebuild_tasks`,其中 `countries.enabled` 默认 `true`。
|
||||
2. `users` 新增 nullable `region_id` 和索引,暂不收窄 `country` 字段长度。
|
||||
3. 初始化国家表种子数据。
|
||||
4. 代码读取时允许 `country_id=0`、`region_id` 为空和国家表未命中的用户快照。
|
||||
5. 上线国家管理、区域管理和注册/改国家写入逻辑。
|
||||
3. 初始化国家表种子数据,默认全部 `enabled=true`,不开放国家由后台禁用。
|
||||
4. 增加 `CountryQueryService.ListRegistrationCountries` 和 `GET /api/v1/countries`,供 App 注册页获取开放国家。
|
||||
5. 代码读取时允许 `country_id=0`、`region_id` 为空和国家表未命中的用户快照。
|
||||
6. 上线国家管理、区域管理和注册/改国家写入逻辑。
|
||||
6. 对历史 `users.country` 做一次 rebuild,把已有用户填充 `region_id`,并产出未命中国家表的异常列表。
|
||||
7. 后续如果需要强制所有用户都有国家或区域,再单独评估数据质量和产品规则。
|
||||
|
||||
@ -667,8 +851,8 @@ docker compose down
|
||||
1. 新增 `countries`、区域相关数据库表和 `users.region_id`,更新 initdb。
|
||||
2. 新增 country/region domain、repository、service。
|
||||
3. 抽取共享国家码归一和校验逻辑,注册和 `ChangeUserCountry` 都使用国家表校验。
|
||||
4. 在注册链路中按 `registration.Country` 解析 active country,再解析并写 `user.RegionID`。
|
||||
5. 在 `ChangeUserCountry` 事务中解析 active country 和 region,并同步更新 `country/region_id`。
|
||||
4. 在注册链路中按 `registration.Country` 解析 enabled country,再解析并写 `user.RegionID`。
|
||||
5. 在 `ChangeUserCountry` 事务中解析 enabled country 和 active region,并同步更新 `country/region_id`。
|
||||
6. 追加 user proto 国家/区域投影字段,运行 `make proto`。
|
||||
7. 补 user-service 单测和 MySQL repository 测试。
|
||||
8. 新增 CountryAdminService、RegionAdminService 内部 RPC 和管理审计。
|
||||
@ -681,7 +865,7 @@ docker compose down
|
||||
- 管理端可以创建区域并配置国家列表。
|
||||
- 管理端可以创建国家,保存 `country_name/country_code/country_display_name`。
|
||||
- 国家码支持 2 到 3 位大写英文字母;客户端提交小写时服务端归一。
|
||||
- 注册和改国家只能选择 active 国家表中存在的 `country_code`。
|
||||
- 注册和改国家只能选择国家表中存在且 `enabled=true` 的 `country_code`。
|
||||
- 一个国家不能同时属于多个 active 区域。
|
||||
- 用户注册时选择的国家如果命中区域,用户主记录可以查到对应区域。
|
||||
- 用户资料投影可以返回国家码、国家内部 ID、国家名称和中文展示名。
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
|
||||
| Feature | Status | Current Evidence | Remaining Work |
|
||||
| --- | --- | --- | --- |
|
||||
| 三方登录即注册 | `DONE` | gateway `/api/v1/auth/third-party/login`,user-service 事务创建用户、短号、三方身份、session | Google/Facebook/TikTok 等真实 provider verifier 仍需逐个接入 |
|
||||
| 三方登录即注册 | `DONE` | gateway `/api/v1/auth/third-party/login`,user-service 事务创建用户、短号、三方身份、session,并接入 Firebase Auth verifier | 真实 Firebase project id、Google sign_in_provider 和客户端 ID token 联调;直接 provider 后续再逐个接入 |
|
||||
| 密码登录和设置密码 | `DONE` | gateway/user-service 已有 password login/set | 生产密码策略和风控可继续增强 |
|
||||
| access token 解析 `user_id` | `DONE` | gateway 从 JWT context 获取 `user_id`,房间命令 actor 来自 token | 多端设备策略仍需产品确认 |
|
||||
| 默认短号和当前展示号 | `DONE` | user-service 生成默认短号,支持 display id 查询和修改 | 房间内资料缓存和 IM 展示资料同步仍需做 |
|
||||
@ -184,7 +184,7 @@
|
||||
|
||||
| Feature | Status | Server Dependency | Remaining Work |
|
||||
| --- | --- | --- | --- |
|
||||
| 登录/注册 | `PARTIAL` | gateway/user-service 已有接口 | 接真实 Google/Apple/Facebook/TikTok credential |
|
||||
| 登录/注册 | `PARTIAL` | gateway/user-service 已有接口 | 客户端提交 Firebase ID token;服务端接 Firebase verifier 后形成生产登录闭环 |
|
||||
| CreateRoom/JoinRoom/LeaveRoom | `PARTIAL` | gateway/room-service 已有接口 | 客户端 command_id、重试、状态覆盖 |
|
||||
| 腾讯 IM 登录和进群 | `PARTIAL` | `/api/v1/im/usersig` 和回调守卫已存在 | SDK login、joinGroup、回调配置、历史补拉 |
|
||||
| 腾讯 RTC 进房 | `PARTIAL` | `/api/v1/rtc/token` 已存在 | TRTC enterRoom、角色切换、断线重连 |
|
||||
|
||||
442
docs/voice-room-region-room-list-architecture.md
Normal file
442
docs/voice-room-region-room-list-architecture.md
Normal file
@ -0,0 +1,442 @@
|
||||
# Voice Room Region Room List Architecture
|
||||
|
||||
本文档定义 App 房间列表的区域化架构。目标是让同一区域内的国家看到同一套房间列表,不同区域看到不同列表,同时不破坏现有 `Room Cell` 单房间状态 owner 模型。
|
||||
|
||||
## Scope
|
||||
|
||||
| Capability | Owner | Required Result |
|
||||
| --- | --- | --- |
|
||||
| 国家和区域主数据 | `user-service` | 国家归属区域由服务端配置和计算 |
|
||||
| 用户区域归属 | `user-service` | 注册和改国家后写入 `users.region_id` |
|
||||
| 房间可见区域 | `room-service` | 房间创建时绑定 `visible_region_id` |
|
||||
| 房间列表读模型 | `room-service` | 按 `visible_region_id` 查询列表卡片 |
|
||||
| HTTP 列表入口 | `gateway-service` | 鉴权后解析用户区域并调用 room-service |
|
||||
| 实时进房校验 | `room-service` | 列表只负责发现,进入房间仍以 `JoinRoom` 为准 |
|
||||
|
||||
本阶段不做:
|
||||
|
||||
| Excluded | Reason |
|
||||
| --- | --- |
|
||||
| 推荐系统 | 首版先实现区域隔离和稳定排序,不做个性化推荐 |
|
||||
| 跨区域混排 | 产品语义要求不同区域列表隔离 |
|
||||
| 客户端提交 `region_id` | 区域必须由服务端按用户国家计算,避免伪造 |
|
||||
| IP 国家决定房间列表 | `country_by_ip` 只用于审计和风控辅助,不代表用户选择国家 |
|
||||
| 把列表塞进 Room Cell | Room Cell 是单房间高频状态 owner,不承担多房间列表查询 |
|
||||
| 创建者改国家自动迁移房间 | 房间可见区域是创建时确定的业务属性,避免直播中列表突然漂移 |
|
||||
|
||||
## Core Rule
|
||||
|
||||
房间列表按 `region_id` 隔离,而不是按 `country` 隔离。
|
||||
|
||||
例如:
|
||||
|
||||
| Country | Region | Visible List |
|
||||
| --- | --- | --- |
|
||||
| `SG` | `SEA` | `room:list:region:SEA` |
|
||||
| `MY` | `SEA` | `room:list:region:SEA` |
|
||||
| `SA` | `MENA` | `room:list:region:MENA` |
|
||||
|
||||
同一区域内的国家看到相同列表;不同区域看到不同列表。
|
||||
|
||||
用户无区域归属时进入默认列表桶:
|
||||
|
||||
```text
|
||||
region_id = 0
|
||||
region_code = GLOBAL
|
||||
```
|
||||
|
||||
`GLOBAL` 不是地理区域,只是兜底列表桶。后续如果产品要求“未选国家不能看列表”,gateway 可以在入口直接拒绝,不需要改 room-service 列表模型。
|
||||
|
||||
## Service Boundary
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
C["Client"] --> G["gateway-service"]
|
||||
G --> U["user-service"]
|
||||
G --> R["room-service"]
|
||||
R --> DB["MySQL"]
|
||||
R --> Redis["Redis"]
|
||||
R --> T["Tencent Cloud IM"]
|
||||
|
||||
U -. "country -> region_id" .-> G
|
||||
G -. "ListRooms(region_id)" .-> R
|
||||
R -. "room list projection" .-> DB
|
||||
R -. "hot/new sorted cache" .-> Redis
|
||||
R -. "system room events" .-> T
|
||||
```
|
||||
|
||||
边界规则:
|
||||
|
||||
- `user-service` 是国家和区域归属的唯一 owner。
|
||||
- `gateway-service` 只做鉴权、用户区域解析和协议转换。
|
||||
- `room-service` 是房间列表和房间状态的 owner。
|
||||
- `Room Cell` 不保存区域列表,不扫描全局房间。
|
||||
- 腾讯云 IM 只负责实时消息,不参与房间列表排序和区域可见性。
|
||||
|
||||
## Request Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant G as gateway-service
|
||||
participant U as user-service
|
||||
participant R as room-service
|
||||
participant S as MySQL/Redis
|
||||
|
||||
C->>G: GET /api/v1/rooms?tab=hot&limit=20
|
||||
G->>U: GetUser(user_id)
|
||||
U-->>G: region_id
|
||||
G->>R: ListRooms(region_id, tab, cursor, limit)
|
||||
R->>S: Read room_list_entries or Redis ZSET
|
||||
S-->>R: RoomListItem[]
|
||||
R-->>G: ListRoomsResponse
|
||||
G-->>C: {code,message,request_id,data}
|
||||
```
|
||||
|
||||
列表只是发现页。用户点击房间后仍然走:
|
||||
|
||||
```text
|
||||
gateway -> room-service.JoinRoom -> Tencent IM join group callback guard
|
||||
```
|
||||
|
||||
因此列表允许秒级最终一致;真正能否进入房间由 `JoinRoom` 和 IM 守卫决定。
|
||||
|
||||
## Room Region Ownership
|
||||
|
||||
### CreateRoom
|
||||
|
||||
创建房间时绑定 `visible_region_id`。
|
||||
|
||||
推荐链路:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant G as gateway-service
|
||||
participant U as user-service
|
||||
participant R as room-service
|
||||
|
||||
C->>G: POST /api/v1/rooms/create
|
||||
G->>U: GetUser(actor_user_id)
|
||||
U-->>G: region_id
|
||||
G->>R: CreateRoom(visible_region_id=region_id)
|
||||
R->>R: Create Room Cell + room_list_entries
|
||||
R-->>G: RoomSnapshot
|
||||
G-->>C: room
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 客户端不能提交 `visible_region_id`。
|
||||
- `visible_region_id` 来自创建者当前 `users.region_id`。
|
||||
- 创建者没有区域时写 `0`,进入 `GLOBAL` 列表桶。
|
||||
- 房间创建成功后,创建者改国家不自动修改房间区域。
|
||||
- 管理端迁移房间区域必须走独立命令,例如 `ChangeRoomVisibleRegion`,并写审计。
|
||||
|
||||
### Why Not Dynamic By Owner Region
|
||||
|
||||
不建议每次列表查询都按房主当前 `region_id` 过滤:
|
||||
|
||||
- 房主改国家会导致直播中房间突然从原区域消失。
|
||||
- 列表查询会被迫 join user-service 或用户表,破坏 room-service 的读模型独立性。
|
||||
- 历史房间的区域口径会随用户资料变化而漂移,不利于运营审计。
|
||||
|
||||
所以房间列表区域应该是房间属性,不是房主资料的实时派生字段。
|
||||
|
||||
## Data Model
|
||||
|
||||
### `rooms`
|
||||
|
||||
`rooms` 是房间恢复和基础元数据表。建议增加:
|
||||
|
||||
```sql
|
||||
ALTER TABLE rooms
|
||||
ADD COLUMN visible_region_id BIGINT NOT NULL DEFAULT 0 AFTER status,
|
||||
ADD KEY idx_rooms_region_status (visible_region_id, status);
|
||||
```
|
||||
|
||||
字段语义:
|
||||
|
||||
| Field | Rule |
|
||||
| --- | --- |
|
||||
| `visible_region_id` | 房间列表可见区域,`0` 表示 `GLOBAL` |
|
||||
| `status` | 房间生命周期状态,列表只展示 active/live 类状态 |
|
||||
|
||||
### `room_list_entries`
|
||||
|
||||
`room_list_entries` 是房间列表读模型,避免列表查询反序列化 `room_snapshots`。
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS room_list_entries (
|
||||
room_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
host_user_id BIGINT NOT NULL,
|
||||
title VARCHAR(128) NOT NULL DEFAULT '',
|
||||
cover_url VARCHAR(512) NOT NULL DEFAULT '',
|
||||
mode VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
heat BIGINT NOT NULL DEFAULT 0,
|
||||
online_count INT NOT NULL DEFAULT 0,
|
||||
seat_count INT NOT NULL DEFAULT 0,
|
||||
occupied_seat_count INT NOT NULL DEFAULT 0,
|
||||
sort_score BIGINT NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
KEY idx_room_list_region_hot (visible_region_id, status, sort_score DESC, room_id),
|
||||
KEY idx_room_list_region_new (visible_region_id, status, created_at_ms DESC, room_id),
|
||||
KEY idx_room_list_owner (owner_user_id, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
读模型字段只保存列表卡片需要的内容,不保存完整房间状态:
|
||||
|
||||
| Field | Source | Update Trigger |
|
||||
| --- | --- | --- |
|
||||
| `room_id` | CreateRoom | 创建房间 |
|
||||
| `visible_region_id` | creator `region_id` | 创建房间或管理端迁移 |
|
||||
| `owner_user_id` | RoomMeta | 创建房间 |
|
||||
| `host_user_id` | RoomState | 创建房间、TransferRoomHost |
|
||||
| `mode` | RoomMeta/RoomState | 创建房间或模式变更 |
|
||||
| `status` | RoomState | 创建、关闭、封禁、恢复 |
|
||||
| `heat` | RoomState | SendGift、热度刷新 |
|
||||
| `online_count` | RoomState presence | JoinRoom、LeaveRoom、stale cleanup |
|
||||
| `occupied_seat_count` | RoomState mic seats | MicUp、MicDown、ChangeMicSeat |
|
||||
| `sort_score` | room-service ranking policy | 任意影响排序的事件 |
|
||||
|
||||
## Redis Cache
|
||||
|
||||
MySQL 是事实来源,Redis 是列表排序和卡片缓存。
|
||||
|
||||
推荐 key:
|
||||
|
||||
```text
|
||||
room:list:{visible_region_id}:hot -> ZSET(room_id, sort_score)
|
||||
room:list:{visible_region_id}:new -> ZSET(room_id, created_at_ms)
|
||||
room:card:{room_id} -> HASH/JSON RoomListItem
|
||||
```
|
||||
|
||||
缓存规则:
|
||||
|
||||
- `CreateRoom` 写 MySQL 后写 Redis。
|
||||
- `JoinRoom`、`LeaveRoom`、`SendGift`、麦位变化更新 MySQL 投影,再更新 Redis。
|
||||
- Redis 写失败不回滚 Room Cell 命令。
|
||||
- Redis miss 时从 MySQL 回源并修复缓存。
|
||||
- Redis 中的列表成员必须定期清理非 active 房间,避免关闭房间长期残留。
|
||||
|
||||
首版可以先只做 MySQL 查询,等列表 QPS 上来再加 Redis ZSET。不要一开始为了缓存牺牲正确性。
|
||||
|
||||
## Sort Policy
|
||||
|
||||
首版只支持两个 tab:
|
||||
|
||||
| Tab | Sort | Use Case |
|
||||
| --- | --- | --- |
|
||||
| `hot` | `sort_score DESC, room_id ASC` | 默认热门列表 |
|
||||
| `new` | `created_at_ms DESC, room_id ASC` | 最新开播列表 |
|
||||
|
||||
`sort_score` 建议由 room-service 计算:
|
||||
|
||||
```text
|
||||
sort_score = heat * 1000 + online_count * 100 + occupied_seat_count * 10 + freshness_boost
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- 排序公式只影响列表展示,不进入 RoomState。
|
||||
- 排序公式调整不需要回放 Room Cell command log。
|
||||
- 需要大规模重排时使用后台 rebuild job 更新 `room_list_entries.sort_score`。
|
||||
|
||||
## API Contract
|
||||
|
||||
### HTTP
|
||||
|
||||
```text
|
||||
GET /api/v1/rooms?tab=hot&cursor=&limit=20
|
||||
GET /api/v1/rooms?tab=new&cursor=&limit=20
|
||||
```
|
||||
|
||||
响应继续使用 gateway envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"rooms": [],
|
||||
"next_cursor": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
请求规则:
|
||||
|
||||
- `tab` 默认 `hot`。
|
||||
- `limit` 默认 `20`,最大 `50`。
|
||||
- `cursor` 使用不透明字符串,客户端不能解析或拼装。
|
||||
- gateway 从 access token 得到 `user_id`,再查 user-service 获取 `region_id`。
|
||||
- 如果 user-service 查询失败,gateway 应返回错误,不应该用 IP 国家临时兜底。
|
||||
|
||||
### gRPC
|
||||
|
||||
建议新增查询服务,不混入 `RoomCommandService`:
|
||||
|
||||
```proto
|
||||
service RoomQueryService {
|
||||
rpc ListRooms(ListRoomsRequest) returns (ListRoomsResponse);
|
||||
}
|
||||
|
||||
message ListRoomsRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 viewer_user_id = 2;
|
||||
int64 visible_region_id = 3;
|
||||
string tab = 4;
|
||||
string cursor = 5;
|
||||
int32 limit = 6;
|
||||
}
|
||||
|
||||
message RoomListItem {
|
||||
string room_id = 1;
|
||||
int64 owner_user_id = 2;
|
||||
int64 host_user_id = 3;
|
||||
string title = 4;
|
||||
string cover_url = 5;
|
||||
string mode = 6;
|
||||
string status = 7;
|
||||
int64 heat = 8;
|
||||
int32 online_count = 9;
|
||||
int32 seat_count = 10;
|
||||
int32 occupied_seat_count = 11;
|
||||
int64 visible_region_id = 12;
|
||||
}
|
||||
|
||||
message ListRoomsResponse {
|
||||
repeated RoomListItem rooms = 1;
|
||||
string next_cursor = 2;
|
||||
}
|
||||
```
|
||||
|
||||
兼容规则:
|
||||
|
||||
- 新增 proto 字段只能追加,不能复用字段号。
|
||||
- `visible_region_id` 使用 `int64`,`0` 表示 `GLOBAL`。
|
||||
- 不要把 `country` 传给 room-service 列表接口;国家到区域的映射不属于 room-service。
|
||||
|
||||
## Write Path Updates
|
||||
|
||||
房间命令成功后要同步更新读模型。读模型失败不能破坏 Room Cell 已提交状态,但必须可补偿。
|
||||
|
||||
| Command | Room Cell | List Projection |
|
||||
| --- | --- | --- |
|
||||
| `CreateRoom` | 创建状态、快照、outbox | 插入 `room_list_entries` |
|
||||
| `JoinRoom` | 增加或刷新 presence | 更新 `online_count`、`sort_score` |
|
||||
| `LeaveRoom` | 移除 presence、释放麦位 | 更新 `online_count`、`occupied_seat_count` |
|
||||
| `MicUp` | 占用麦位 | 更新 `occupied_seat_count` |
|
||||
| `MicDown` | 释放麦位 | 更新 `occupied_seat_count` |
|
||||
| `TransferRoomHost` | 修改 host | 更新 `host_user_id` |
|
||||
| `SendGift` | 更新 heat、rank | 更新 `heat`、`sort_score` |
|
||||
| `CloseRoom` | 修改状态 | 从列表隐藏或标记非 active |
|
||||
|
||||
推荐实现:
|
||||
|
||||
- Room Cell 命令仍然是状态变更入口。
|
||||
- 命令成功提交后调用 `RoomListProjector` 更新 MySQL 投影。
|
||||
- 投影更新失败时写 `room_outbox` 或独立 `room_projection_tasks` 等待补偿。
|
||||
- 列表查询只读 `room_list_entries`,不直接读取 Room Cell 内存。
|
||||
|
||||
## Consistency Model
|
||||
|
||||
房间列表采用最终一致。
|
||||
|
||||
允许:
|
||||
|
||||
- 列表在线人数短暂落后。
|
||||
- 房间关闭后短时间仍出现在列表。
|
||||
- 热度和排序秒级延迟。
|
||||
|
||||
不允许:
|
||||
|
||||
- 用户看到其他区域专属列表。
|
||||
- 客户端通过伪造 `region_id` 越权获取列表。
|
||||
- 列表查询直接访问 Room Cell 全量内存。
|
||||
- Redis 成为唯一数据源。
|
||||
|
||||
点击列表进入房间时必须再次校验:
|
||||
|
||||
```text
|
||||
JoinRoom(room_id, user_id)
|
||||
```
|
||||
|
||||
如果房间已关闭、用户被封禁或区域策略后续收紧,`JoinRoom` 返回拒绝,客户端刷新列表。
|
||||
|
||||
## Failure Handling
|
||||
|
||||
| Failure | Handling |
|
||||
| --- | --- |
|
||||
| user-service GetUser 失败 | gateway 返回错误,不用 IP 国家兜底 |
|
||||
| 用户 `region_id` 为空 | 使用 `GLOBAL` 列表桶,除非产品要求强制选国家 |
|
||||
| room-service Redis miss | 回源 MySQL 并修复缓存 |
|
||||
| Redis 写失败 | 不回滚房间命令,记录日志或补偿任务 |
|
||||
| MySQL 投影写失败 | 房间命令已提交时必须进入补偿队列 |
|
||||
| 列表出现关闭房间 | `JoinRoom` 拒绝,客户端刷新列表 |
|
||||
| 区域配置变更 | 影响新用户和用户重算;已有房间不自动迁移 |
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: MySQL Read Model
|
||||
|
||||
1. `room.proto` 新增 `RoomQueryService.ListRooms` 和列表消息。
|
||||
2. `CreateRoomRequest` 增加 `visible_region_id`,由 gateway 填充。
|
||||
3. `rooms` 增加 `visible_region_id`。
|
||||
4. 新增 `room_list_entries` 表。
|
||||
5. room-service 新增 `room/list` 或 `room/query` 包,封装列表 repository 和 service。
|
||||
6. gateway 新增 `GET /api/v1/rooms`,先查 user-service,再调 room-service。
|
||||
|
||||
### Phase 2: Projection Updates
|
||||
|
||||
1. `CreateRoom` 插入列表投影。
|
||||
2. `JoinRoom`、`LeaveRoom` 更新在线人数。
|
||||
3. `MicUp`、`MicDown` 更新麦位占用。
|
||||
4. `SendGift` 更新热度和排序分。
|
||||
5. 增加投影补偿任务,避免命令成功但列表长期不更新。
|
||||
|
||||
### Phase 3: Redis Acceleration
|
||||
|
||||
1. 为 `hot/new` 增加 Redis ZSET。
|
||||
2. 列表查询优先读 Redis room_id,再批量读 `room:card`。
|
||||
3. Redis miss 或卡片缺失时回源 MySQL。
|
||||
4. 增加后台 reconcile job,把 MySQL 投影定期修复到 Redis。
|
||||
|
||||
### Phase 4: Admin Controls
|
||||
|
||||
1. 新增管理端房间区域迁移命令。
|
||||
2. 新增区域迁移审计表。
|
||||
3. 支持按区域隐藏、封禁或置顶房间。
|
||||
|
||||
## Tests
|
||||
|
||||
必须覆盖:
|
||||
|
||||
| Case | Expected |
|
||||
| --- | --- |
|
||||
| `SG` 和 `MY` 属于同一区域 | 两个用户看到相同列表 |
|
||||
| `SG` 和 `SA` 属于不同区域 | 两个用户看到不同列表 |
|
||||
| 用户无区域 | 进入 `GLOBAL` 列表 |
|
||||
| 客户端传伪造区域 | gateway 忽略客户端区域,只用 user-service 返回值 |
|
||||
| 创建房间 | 写 `rooms.visible_region_id` 和 `room_list_entries` |
|
||||
| 房主改国家 | 已创建房间列表区域不变 |
|
||||
| Join/Leave | 列表在线人数最终更新 |
|
||||
| SendGift | 热度和排序分更新 |
|
||||
| Redis miss | MySQL 回源成功 |
|
||||
| 列表房间已关闭 | `JoinRoom` 拒绝,客户端刷新 |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- App 房间列表只展示当前用户 `region_id` 对应房间。
|
||||
- 同一区域内不同国家用户看到同一套列表。
|
||||
- 不同区域用户不会看到彼此区域房间。
|
||||
- 房间列表查询不访问 Room Cell 内存集合。
|
||||
- Room Cell 状态变更仍只走命令流水线。
|
||||
- MySQL 可独立恢复完整房间列表读模型。
|
||||
- Redis 故障不影响创建房间、进入房间和房间状态提交。
|
||||
238
pkg/logx/logx.go
Normal file
238
pkg/logx/logx.go
Normal file
@ -0,0 +1,238 @@
|
||||
// Package logx 提供服务边界日志能力。
|
||||
// 它只记录 transport 层可见的请求、响应、错误语义和耗时,不承载业务判断。
|
||||
package logx
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/xerr"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/encoding/protojson"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxLoggedBytes 限制单个 request/response 字段的日志体积,避免大响应污染 stdout。
|
||||
maxLoggedBytes = 8192
|
||||
// redactedValue 是敏感字段统一替换值,保证日志可读但不可用于重放凭证。
|
||||
redactedValue = "***REDACTED***"
|
||||
)
|
||||
|
||||
// HTTPMiddleware 在 gateway HTTP 边界记录请求体、响应体、状态码和耗时。
|
||||
// requestIDFromContext 由 gateway 注入,logx 不直接依赖具体 transport 包,避免反向耦合。
|
||||
func HTTPMiddleware(service string, requestIDFromContext func(context.Context) string, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
startedAt := time.Now()
|
||||
requestBody := readAndRestoreBody(request)
|
||||
capture := newHTTPResponseCapture(writer)
|
||||
|
||||
next.ServeHTTP(capture, request)
|
||||
|
||||
requestID := strings.TrimSpace(requestIDFromContext(request.Context()))
|
||||
args := []any{
|
||||
"service", service,
|
||||
"request_id", requestID,
|
||||
"method", request.Method,
|
||||
"path", request.URL.Path,
|
||||
"query", request.URL.RawQuery,
|
||||
"status", capture.statusCode,
|
||||
"duration_ms", time.Since(startedAt).Milliseconds(),
|
||||
"request", safePayload(requestBody),
|
||||
"response", safePayload(capture.body.Bytes()),
|
||||
}
|
||||
if capture.statusCode >= http.StatusInternalServerError {
|
||||
slog.Error("http_access", args...)
|
||||
return
|
||||
}
|
||||
slog.Info("http_access", args...)
|
||||
})
|
||||
}
|
||||
|
||||
// UnaryServerInterceptor 在内部 gRPC 服务边界记录 protobuf request/response。
|
||||
// 内部链路统一 gRPC + protobuf,因此这里是排查跨服务参数和返回值的主入口。
|
||||
func UnaryServerInterceptor(service string) grpc.UnaryServerInterceptor {
|
||||
return func(ctx context.Context, request any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
|
||||
if strings.Contains(info.FullMethod, "/grpc.health.v1.Health/") {
|
||||
// 健康检查高频且无业务参数,跳过可避免日志噪音淹没真实业务请求。
|
||||
return handler(ctx, request)
|
||||
}
|
||||
|
||||
startedAt := time.Now()
|
||||
response, err := handler(ctx, request)
|
||||
code := codes.OK
|
||||
if err != nil {
|
||||
code = status.Code(err)
|
||||
}
|
||||
|
||||
args := []any{
|
||||
"service", service,
|
||||
"request_id", requestIDFromPayload(request),
|
||||
"method", info.FullMethod,
|
||||
"grpc_code", code.String(),
|
||||
"reason", string(xerr.ReasonFromGRPC(err)),
|
||||
"duration_ms", time.Since(startedAt).Milliseconds(),
|
||||
"request", safePayloadFromValue(request),
|
||||
"response", safePayloadFromValue(response),
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("grpc_access", args...)
|
||||
return response, err
|
||||
}
|
||||
slog.Info("grpc_access", args...)
|
||||
return response, nil
|
||||
}
|
||||
}
|
||||
|
||||
type httpResponseCapture struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
body bytes.Buffer
|
||||
}
|
||||
|
||||
func newHTTPResponseCapture(writer http.ResponseWriter) *httpResponseCapture {
|
||||
return &httpResponseCapture{
|
||||
ResponseWriter: writer,
|
||||
statusCode: http.StatusOK,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *httpResponseCapture) WriteHeader(statusCode int) {
|
||||
c.statusCode = statusCode
|
||||
c.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func (c *httpResponseCapture) Write(data []byte) (int, error) {
|
||||
if c.body.Len() < maxLoggedBytes {
|
||||
remaining := maxLoggedBytes - c.body.Len()
|
||||
if len(data) > remaining {
|
||||
c.body.Write(data[:remaining])
|
||||
} else {
|
||||
c.body.Write(data)
|
||||
}
|
||||
}
|
||||
|
||||
return c.ResponseWriter.Write(data)
|
||||
}
|
||||
|
||||
func readAndRestoreBody(request *http.Request) []byte {
|
||||
if request.Body == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(request.Body)
|
||||
if err != nil {
|
||||
// 读取失败时把空 body 交还给后续 handler,让真实解码错误仍由 handler 返回。
|
||||
request.Body = io.NopCloser(bytes.NewReader(nil))
|
||||
return nil
|
||||
}
|
||||
request.Body.Close()
|
||||
request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return body
|
||||
}
|
||||
|
||||
func safePayloadFromValue(value any) any {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
if message, ok := value.(proto.Message); ok {
|
||||
raw, err := protojson.MarshalOptions{UseProtoNames: true}.Marshal(message)
|
||||
if err != nil {
|
||||
return "<proto_marshal_failed>"
|
||||
}
|
||||
return safePayload(raw)
|
||||
}
|
||||
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return "<json_marshal_failed>"
|
||||
}
|
||||
return safePayload(raw)
|
||||
}
|
||||
|
||||
func requestIDFromPayload(value any) string {
|
||||
payload, ok := safePayloadFromValue(value).(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
meta, ok := payload["meta"].(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if requestID, ok := meta["request_id"].(string); ok {
|
||||
return requestID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func safePayload(raw []byte) any {
|
||||
raw = bytes.TrimSpace(raw)
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(raw) > maxLoggedBytes {
|
||||
raw = raw[:maxLoggedBytes]
|
||||
}
|
||||
|
||||
var payload any
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
return string(raw)
|
||||
}
|
||||
return redactValue(payload)
|
||||
}
|
||||
|
||||
func redactValue(value any) any {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
redacted := make(map[string]any, len(typed))
|
||||
for key, field := range typed {
|
||||
if isSensitiveKey(key) {
|
||||
redacted[key] = redactedValue
|
||||
continue
|
||||
}
|
||||
redacted[key] = redactValue(field)
|
||||
}
|
||||
return redacted
|
||||
case []any:
|
||||
for index, item := range typed {
|
||||
typed[index] = redactValue(item)
|
||||
}
|
||||
return typed
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func isSensitiveKey(key string) bool {
|
||||
normalized := strings.ToLower(strings.ReplaceAll(key, "-", "_"))
|
||||
sensitiveParts := []string{
|
||||
"password",
|
||||
"credential",
|
||||
"secret",
|
||||
"token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"id_token",
|
||||
"user_sig",
|
||||
"usersig",
|
||||
"authorization",
|
||||
"signature",
|
||||
"private_key",
|
||||
"callback_auth",
|
||||
}
|
||||
for _, part := range sensitiveParts {
|
||||
if strings.Contains(normalized, part) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@ -20,6 +20,8 @@ const (
|
||||
Unauthorized Code = "UNAUTHORIZED"
|
||||
// PermissionDenied 表示已认证但没有执行权限。
|
||||
PermissionDenied Code = "PERMISSION_DENIED"
|
||||
// ProfileRequired 表示已登录用户尚未完成 App 最小注册资料,不能进入门禁保护业务。
|
||||
ProfileRequired Code = "PROFILE_REQUIRED"
|
||||
// Unavailable 表示依赖或当前服务临时不可用。
|
||||
Unavailable Code = "UNAVAILABLE"
|
||||
// Internal 表示不能安全暴露更细原因的内部失败。
|
||||
|
||||
@ -22,7 +22,7 @@ func GRPCCode(code Code) codes.Code {
|
||||
return codes.FailedPrecondition
|
||||
case Unauthorized, AuthFailed, SessionExpired, SessionRevoked:
|
||||
return codes.Unauthenticated
|
||||
case PermissionDenied, UserDisabled:
|
||||
case PermissionDenied, ProfileRequired, UserDisabled:
|
||||
return codes.PermissionDenied
|
||||
case Unavailable:
|
||||
return codes.Unavailable
|
||||
|
||||
49
scripts/apply-local-mysql-initdb.sh
Executable file
49
scripts/apply-local-mysql-initdb.sh
Executable file
@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Run the repository DDL through the live compose MySQL container. Docker's
|
||||
# /docker-entrypoint-initdb.d hook only runs for an empty volume, while local
|
||||
# development volumes often outlive schema additions between services.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-root}"
|
||||
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
SQL_FILES=(
|
||||
"services/room-service/deploy/mysql/initdb/001_room_service.sql"
|
||||
"services/user-service/deploy/mysql/initdb/001_user_service.sql"
|
||||
"services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql"
|
||||
"services/activity-service/deploy/mysql/initdb/001_activity_service.sql"
|
||||
"deploy/mysql/initdb/005_utf8mb4_chinese_support.sql"
|
||||
"deploy/mysql/initdb/999_local_grants.sql"
|
||||
)
|
||||
|
||||
# Keep MySQL as the only required dependency for this script. Business services
|
||||
# are started after schema/grants are known to match the current repo.
|
||||
docker compose up -d mysql >/dev/null
|
||||
|
||||
# `docker compose up` may recreate MySQL after command/config changes. Wait on
|
||||
# the server port before applying DDL so initdb is reliable on fresh containers.
|
||||
for _ in {1..60}; do
|
||||
if docker compose exec -T mysql mysqladmin ping -h 127.0.0.1 -uroot -p"${MYSQL_ROOT_PASSWORD}" --silent >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if ! docker compose exec -T mysql mysqladmin ping -h 127.0.0.1 -uroot -p"${MYSQL_ROOT_PASSWORD}" --silent >/dev/null 2>&1; then
|
||||
printf 'mysql did not become ready before initdb\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for sql_file in "${SQL_FILES[@]}"; do
|
||||
if [[ ! -f "${sql_file}" ]]; then
|
||||
printf 'missing mysql init file: %s\n' "${sql_file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'applying mysql init file: %s\n' "${sql_file}"
|
||||
docker compose exec -T mysql mysql --default-character-set=utf8mb4 -h 127.0.0.1 -uroot -p"${MYSQL_ROOT_PASSWORD}" < "${sql_file}"
|
||||
done
|
||||
@ -1,7 +1,7 @@
|
||||
service_name: activity-service
|
||||
node_id: activity-docker
|
||||
grpc_addr: ":13006"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hy_activity?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
service_name: activity-service
|
||||
node_id: activity-local
|
||||
grpc_addr: ":13006"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_activity?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
CREATE DATABASE IF NOT EXISTS hy_activity DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hy_activity;
|
||||
CREATE DATABASE IF NOT EXISTS hyapp_activity DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_activity;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
activity_id VARCHAR(96) NOT NULL PRIMARY KEY,
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
||||
activityv1 "hyapp/api/proto/activity/v1"
|
||||
"hyapp/pkg/grpchealth"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/services/activity-service/internal/config"
|
||||
activityservice "hyapp/services/activity-service/internal/service/activity"
|
||||
mysqlstorage "hyapp/services/activity-service/internal/storage/mysql"
|
||||
@ -43,7 +44,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server := grpc.NewServer()
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("activity-service")))
|
||||
svc := activityservice.New(activityservice.Config{NodeID: cfg.NodeID}, repository)
|
||||
activityv1.RegisterActivityServiceServer(server, grpcserver.NewServer(svc))
|
||||
health := grpchealth.NewServingChecker("activity-service")
|
||||
|
||||
@ -26,7 +26,7 @@ func Default() Config {
|
||||
ServiceName: "activity-service",
|
||||
NodeID: "activity-local",
|
||||
GRPCAddr: ":13006",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_activity?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_activity?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLAutoMigrate: false,
|
||||
Consumer: ConsumerConfig{
|
||||
RoomOutboxPollIntervalMs: 1000,
|
||||
|
||||
@ -26,7 +26,7 @@ func NewRepository(t testing.TB) *Repository {
|
||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||
EnvVar: "ACTIVITY_SERVICE_MYSQL_TEST_DSN",
|
||||
InitDBPath: initDBPath(t),
|
||||
DatabasePrefix: "hy_activity_test",
|
||||
DatabasePrefix: "hyapp_activity_test",
|
||||
})
|
||||
repository, err := mysqlstorage.Open(context.Background(), schema.DSN)
|
||||
if err != nil {
|
||||
|
||||
@ -50,15 +50,19 @@ func New(cfg config.Config) (*App, error) {
|
||||
|
||||
var roomClient client.RoomClient = client.NewGRPCRoomClient(roomConn)
|
||||
var roomGuardClient client.RoomGuardClient = client.NewGRPCRoomGuardClient(roomConn)
|
||||
var roomQueryClient client.RoomQueryClient = client.NewGRPCRoomQueryClient(roomConn)
|
||||
var userClient client.UserAuthClient = client.NewGRPCUserAuthClient(userConn)
|
||||
var userIdentityClient client.UserIdentityClient = client.NewGRPCUserIdentityClient(userConn)
|
||||
var userProfileClient client.UserProfileClient = client.NewGRPCUserProfileClient(userConn)
|
||||
var userCountryQueryClient client.UserCountryQueryClient = client.NewGRPCUserCountryQueryClient(userConn)
|
||||
handler := httptransport.NewHandlerWithConfig(roomClient, roomGuardClient, userClient, userIdentityClient, httptransport.TencentIMConfig{
|
||||
SDKAppID: cfg.TencentIM.SDKAppID,
|
||||
SecretKey: cfg.TencentIM.SecretKey,
|
||||
UserSigTTL: cfg.TencentIM.UserSigTTL,
|
||||
CallbackAuthToken: cfg.TencentIM.CallbackAuthToken,
|
||||
}, userProfileClient)
|
||||
handler.SetRoomQueryClient(roomQueryClient)
|
||||
handler.SetUserCountryQueryClient(userCountryQueryClient)
|
||||
handler.SetTencentRTC(httptransport.TencentRTCConfig{
|
||||
Enabled: cfg.TencentRTC.Enabled,
|
||||
SDKAppID: cfg.TencentRTC.SDKAppID,
|
||||
|
||||
@ -10,7 +10,19 @@ import (
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "gateway_user_id"
|
||||
const (
|
||||
userContextKey contextKey = "gateway_user_id"
|
||||
claimsContextKey contextKey = "gateway_claims"
|
||||
)
|
||||
|
||||
// Claims 是 gateway 从 access token 中消费的最小用户状态快照。
|
||||
// 它只用于入口鉴权和 profile gate,不替代 user-service 的用户主数据。
|
||||
type Claims struct {
|
||||
UserID int64
|
||||
SessionID string
|
||||
ProfileCompleted bool
|
||||
OnboardingStatus string
|
||||
}
|
||||
|
||||
// Verifier 负责在 gateway 入口层校验用户 JWT。
|
||||
type Verifier struct {
|
||||
@ -25,8 +37,18 @@ func NewVerifier(secret string) *Verifier {
|
||||
// VerifyUserID 只负责从 Authorization header 中提取并校验用户身份。
|
||||
// HTTP 状态码和响应 envelope 属于 transport 层,auth 包不直接写 ResponseWriter。
|
||||
func (v *Verifier) VerifyUserID(header string) (int64, error) {
|
||||
claims, err := v.Verify(header)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return claims.UserID, nil
|
||||
}
|
||||
|
||||
// Verify 校验 access token 并返回 gateway 入口需要的用户状态快照。
|
||||
func (v *Verifier) Verify(header string) (Claims, error) {
|
||||
if !strings.HasPrefix(header, "Bearer ") {
|
||||
return 0, fmt.Errorf("missing bearer token")
|
||||
return Claims{}, fmt.Errorf("missing bearer token")
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(header, "Bearer ")
|
||||
@ -38,20 +60,29 @@ func (v *Verifier) VerifyUserID(header string) (int64, error) {
|
||||
return v.secret, nil
|
||||
})
|
||||
if err != nil || !token.Valid {
|
||||
return 0, fmt.Errorf("invalid token")
|
||||
return Claims{}, fmt.Errorf("invalid token")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("invalid claims")
|
||||
return Claims{}, fmt.Errorf("invalid claims")
|
||||
}
|
||||
|
||||
rawUserID, ok := claims["user_id"].(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("missing user_id")
|
||||
return Claims{}, fmt.Errorf("missing user_id")
|
||||
}
|
||||
|
||||
return int64(rawUserID), nil
|
||||
profileCompleted, _ := claims["profile_completed"].(bool)
|
||||
onboardingStatus, _ := claims["onboarding_status"].(string)
|
||||
sessionID, _ := claims["sid"].(string)
|
||||
|
||||
return Claims{
|
||||
UserID: int64(rawUserID),
|
||||
SessionID: strings.TrimSpace(sessionID),
|
||||
ProfileCompleted: profileCompleted,
|
||||
OnboardingStatus: strings.TrimSpace(onboardingStatus),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WithUserID 把已鉴权用户写入请求上下文,供 gateway 组装 RequestMeta。
|
||||
@ -59,9 +90,33 @@ func WithUserID(ctx context.Context, userID int64) context.Context {
|
||||
return context.WithValue(ctx, userContextKey, userID)
|
||||
}
|
||||
|
||||
// WithClaims 把 access token 中的准入快照写入上下文。
|
||||
func WithClaims(ctx context.Context, claims Claims) context.Context {
|
||||
ctx = WithUserID(ctx, claims.UserID)
|
||||
return context.WithValue(ctx, claimsContextKey, claims)
|
||||
}
|
||||
|
||||
// UserIDFromContext 提取鉴权后的用户 ID。
|
||||
func UserIDFromContext(ctx context.Context) int64 {
|
||||
userID, _ := ctx.Value(userContextKey).(int64)
|
||||
|
||||
return userID
|
||||
}
|
||||
|
||||
// SessionIDFromContext 提取 access token 中的服务端 session_id。
|
||||
func SessionIDFromContext(ctx context.Context) string {
|
||||
claims, _ := ctx.Value(claimsContextKey).(Claims)
|
||||
return strings.TrimSpace(claims.SessionID)
|
||||
}
|
||||
|
||||
// ProfileCompletedFromContext 返回 gateway profile gate 的判断依据。
|
||||
func ProfileCompletedFromContext(ctx context.Context) bool {
|
||||
claims, _ := ctx.Value(claimsContextKey).(Claims)
|
||||
return claims.ProfileCompleted
|
||||
}
|
||||
|
||||
// OnboardingStatusFromContext 暴露 token 中的注册状态,便于后续审计或灰度判断。
|
||||
func OnboardingStatusFromContext(ctx context.Context) string {
|
||||
claims, _ := ctx.Value(claimsContextKey).(Claims)
|
||||
return claims.OnboardingStatus
|
||||
}
|
||||
|
||||
@ -33,6 +33,11 @@ type RoomGuardClient interface {
|
||||
VerifyRoomPresence(ctx context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error)
|
||||
}
|
||||
|
||||
// RoomQueryClient 抽象 gateway 对 room-service 读模型查询的依赖。
|
||||
type RoomQueryClient interface {
|
||||
ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error)
|
||||
}
|
||||
|
||||
type grpcRoomClient struct {
|
||||
client roomv1.RoomCommandServiceClient
|
||||
}
|
||||
@ -41,6 +46,10 @@ type grpcRoomGuardClient struct {
|
||||
client roomv1.RoomGuardServiceClient
|
||||
}
|
||||
|
||||
type grpcRoomQueryClient struct {
|
||||
client roomv1.RoomQueryServiceClient
|
||||
}
|
||||
|
||||
// NewGRPCRoomClient 用 gRPC 连接构造 room-service client。
|
||||
func NewGRPCRoomClient(conn *grpc.ClientConn) RoomClient {
|
||||
return &grpcRoomClient{
|
||||
@ -55,6 +64,13 @@ func NewGRPCRoomGuardClient(conn *grpc.ClientConn) RoomGuardClient {
|
||||
}
|
||||
}
|
||||
|
||||
// NewGRPCRoomQueryClient 用同一条 room-service gRPC 连接构造查询 client。
|
||||
func NewGRPCRoomQueryClient(conn *grpc.ClientConn) RoomQueryClient {
|
||||
return &grpcRoomQueryClient{
|
||||
client: roomv1.NewRoomQueryServiceClient(conn),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *grpcRoomClient) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest) (*roomv1.CreateRoomResponse, error) {
|
||||
return c.client.CreateRoom(ctx, req)
|
||||
}
|
||||
@ -122,3 +138,7 @@ func (c *grpcRoomGuardClient) CheckSpeakPermission(ctx context.Context, req *roo
|
||||
func (c *grpcRoomGuardClient) VerifyRoomPresence(ctx context.Context, req *roomv1.VerifyRoomPresenceRequest) (*roomv1.VerifyRoomPresenceResponse, error) {
|
||||
return c.client.VerifyRoomPresence(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomQueryClient) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
|
||||
return c.client.ListRooms(ctx, req)
|
||||
}
|
||||
|
||||
@ -27,10 +27,17 @@ type UserIdentityClient interface {
|
||||
|
||||
// UserProfileClient 抽象 gateway 对 user-service 用户资料能力的依赖。
|
||||
type UserProfileClient interface {
|
||||
GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error)
|
||||
CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error)
|
||||
UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error)
|
||||
ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error)
|
||||
}
|
||||
|
||||
// UserCountryQueryClient 抽象 gateway 对 App 注册页国家列表的公开读依赖。
|
||||
type UserCountryQueryClient interface {
|
||||
ListRegistrationCountries(ctx context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error)
|
||||
}
|
||||
|
||||
type grpcUserAuthClient struct {
|
||||
client userv1.AuthServiceClient
|
||||
}
|
||||
@ -43,6 +50,10 @@ type grpcUserProfileClient struct {
|
||||
client userv1.UserServiceClient
|
||||
}
|
||||
|
||||
type grpcUserCountryQueryClient struct {
|
||||
client userv1.CountryQueryServiceClient
|
||||
}
|
||||
|
||||
// NewGRPCUserAuthClient 用 gRPC 连接构造 user-service auth client。
|
||||
func NewGRPCUserAuthClient(conn *grpc.ClientConn) UserAuthClient {
|
||||
return &grpcUserAuthClient{
|
||||
@ -64,6 +75,13 @@ func NewGRPCUserProfileClient(conn *grpc.ClientConn) UserProfileClient {
|
||||
}
|
||||
}
|
||||
|
||||
// NewGRPCUserCountryQueryClient 用 gRPC 连接构造国家公开查询 client。
|
||||
func NewGRPCUserCountryQueryClient(conn *grpc.ClientConn) UserCountryQueryClient {
|
||||
return &grpcUserCountryQueryClient{
|
||||
client: userv1.NewCountryQueryServiceClient(conn),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *grpcUserAuthClient) LoginPassword(ctx context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
|
||||
return c.client.LoginPassword(ctx, req)
|
||||
}
|
||||
@ -100,6 +118,14 @@ func (c *grpcUserIdentityClient) ApplyPrettyDisplayUserID(ctx context.Context, r
|
||||
return c.client.ApplyPrettyDisplayUserID(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserProfileClient) GetUser(ctx context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
|
||||
return c.client.GetUser(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserProfileClient) CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) {
|
||||
return c.client.CompleteOnboarding(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserProfileClient) UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error) {
|
||||
return c.client.UpdateUserProfile(ctx, req)
|
||||
}
|
||||
@ -107,3 +133,7 @@ func (c *grpcUserProfileClient) UpdateUserProfile(ctx context.Context, req *user
|
||||
func (c *grpcUserProfileClient) ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCountryRequest) (*userv1.ChangeUserCountryResponse, error) {
|
||||
return c.client.ChangeUserCountry(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserCountryQueryClient) ListRegistrationCountries(ctx context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error) {
|
||||
return c.client.ListRegistrationCountries(ctx, req)
|
||||
}
|
||||
|
||||
@ -19,10 +19,12 @@ type authTokenData struct {
|
||||
DisplayUserIDExpiresAtMs int64 `json:"display_user_id_expires_at_ms"`
|
||||
SessionID string `json:"session_id"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresInSec int64 `json:"expires_in_sec"`
|
||||
TokenType string `json:"token_type"`
|
||||
IsNewUser *bool `json:"is_new_user,omitempty"`
|
||||
ProfileCompleted bool `json:"profile_completed"`
|
||||
OnboardingStatus string `json:"onboarding_status"`
|
||||
}
|
||||
|
||||
// loginPassword 处理 display_user_id + 密码登录。
|
||||
@ -231,6 +233,7 @@ func authRequestMeta(request *http.Request, deviceID string) *userv1.RequestMeta
|
||||
ClientIp: clientIP(request),
|
||||
UserAgent: request.UserAgent(),
|
||||
CountryByIp: countryByIP(request),
|
||||
SessionId: auth.SessionIDFromContext(request.Context()),
|
||||
}
|
||||
}
|
||||
|
||||
@ -252,6 +255,8 @@ func authData(token *userv1.AuthToken, isNewUser *bool) authTokenData {
|
||||
ExpiresInSec: token.GetExpiresInSec(),
|
||||
TokenType: token.GetTokenType(),
|
||||
IsNewUser: isNewUser,
|
||||
ProfileCompleted: token.GetProfileCompleted(),
|
||||
OnboardingStatus: token.GetOnboardingStatus(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,9 +17,11 @@ import (
|
||||
type Handler struct {
|
||||
roomClient client.RoomClient
|
||||
roomGuardClient client.RoomGuardClient
|
||||
roomQueryClient client.RoomQueryClient
|
||||
userClient client.UserAuthClient
|
||||
userIdentityClient client.UserIdentityClient
|
||||
userProfileClient client.UserProfileClient
|
||||
userCountryClient client.UserCountryQueryClient
|
||||
tencentIM TencentIMConfig
|
||||
tencentRTC TencentRTCConfig
|
||||
authRateLimitConfig AuthRateLimitConfig
|
||||
@ -92,6 +94,16 @@ func (h *Handler) SetTencentRTC(config TencentRTCConfig) {
|
||||
h.tencentRTC = config
|
||||
}
|
||||
|
||||
// SetRoomQueryClient 注入 room-service 读模型查询 client。
|
||||
func (h *Handler) SetRoomQueryClient(roomQueryClient client.RoomQueryClient) {
|
||||
h.roomQueryClient = roomQueryClient
|
||||
}
|
||||
|
||||
// SetUserCountryQueryClient 注入注册页国家列表查询 client。
|
||||
func (h *Handler) SetUserCountryQueryClient(userCountryClient client.UserCountryQueryClient) {
|
||||
h.userCountryClient = userCountryClient
|
||||
}
|
||||
|
||||
// meta 把外部 HTTP 请求上下文转换成 room-service 命令元信息。
|
||||
// request_id 只做链路追踪;command_id 才是 room-service command log 使用的幂等键。
|
||||
func meta(request *http.Request, roomID string, commandID string) *roomv1.RequestMeta {
|
||||
|
||||
@ -3,32 +3,58 @@ package http
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
)
|
||||
|
||||
// apiHandler 包装所有 gateway 业务 HTTP API 的公共入口能力。
|
||||
// 顺序必须是 request_id -> auth -> handler,保证鉴权失败也能返回可追踪 request_id。
|
||||
func apiHandler(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
|
||||
return withRequestID(withAuth(jwtVerifier, next))
|
||||
return withRequestID(withAccessLog(withAuth(jwtVerifier, next)))
|
||||
}
|
||||
|
||||
// profileAPIHandler 包装必须完成注册资料才能访问的业务 API。
|
||||
// 三方登录后未完成资料的用户已经有合法 token,但不能进入房间、IM、RTC 或付费链路。
|
||||
func profileAPIHandler(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
|
||||
return withRequestID(withAccessLog(withAuth(jwtVerifier, requireCompletedProfile(next))))
|
||||
}
|
||||
|
||||
// publicAPIHandler 包装不需要 access token 的业务 API。
|
||||
// 登录注册链路仍然有 request_id,但不能要求用户已经有 access token。
|
||||
func publicAPIHandler(next http.HandlerFunc) http.Handler {
|
||||
return withRequestID(next)
|
||||
return withRequestID(withAccessLog(next))
|
||||
}
|
||||
|
||||
// withAccessLog 统一记录 gateway 业务 API 的请求体、响应体、状态码和耗时。
|
||||
// healthcheck 不经过该 middleware,避免探活日志淹没业务调用。
|
||||
func withAccessLog(next http.Handler) http.Handler {
|
||||
return logx.HTTPMiddleware("gateway-service", requestIDFromContext, next)
|
||||
}
|
||||
|
||||
// withAuth 在 transport 层完成鉴权失败响应写入。
|
||||
// auth 包只返回身份校验结果,避免底层鉴权模块绑定 gateway 的 JSON envelope。
|
||||
func withAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) http.Handler {
|
||||
return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
|
||||
userID, err := jwtVerifier.VerifyUserID(request.Header.Get("Authorization"))
|
||||
claims, err := jwtVerifier.Verify(request.Header.Get("Authorization"))
|
||||
if err != nil {
|
||||
writeError(writer, request, http.StatusUnauthorized, codeUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
|
||||
ctx := auth.WithUserID(request.Context(), userID)
|
||||
ctx := auth.WithClaims(request.Context(), claims)
|
||||
next.ServeHTTP(writer, request.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// requireCompletedProfile 在 gateway 层执行外部准入门禁。
|
||||
// 这里故意不访问 user-service,依赖 access token 快照,避免所有高频房间入口同步打用户服务。
|
||||
func requireCompletedProfile(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(writer http.ResponseWriter, request *http.Request) {
|
||||
if !auth.ProfileCompletedFromContext(request.Context()) {
|
||||
writeError(writer, request, http.StatusForbidden, codeProfileRequired, "profile required")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(writer, request)
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ const (
|
||||
codeInvalidArgument = "INVALID_ARGUMENT"
|
||||
codeUnauthorized = "UNAUTHORIZED"
|
||||
codePermissionDenied = "PERMISSION_DENIED"
|
||||
codeProfileRequired = "PROFILE_REQUIRED"
|
||||
codeNotFound = "NOT_FOUND"
|
||||
codeRateLimited = "RATE_LIMITED"
|
||||
codeUpstreamError = "UPSTREAM_ERROR"
|
||||
@ -114,6 +115,8 @@ func mapReasonToHTTP(reason xerr.Code) (int, string, string) {
|
||||
return http.StatusConflict, string(reason), "conflict"
|
||||
case xerr.DisplayUserIDCooldown, xerr.DisplayUserIDPrettyNotAvailable, xerr.DisplayUserIDPrettyActive, xerr.DisplayUserIDLeaseExpired, xerr.DisplayUserIDPaymentRequired, xerr.CountryChangeCooldown, xerr.RegionCountryConflict, xerr.RegionDisabled:
|
||||
return http.StatusConflict, string(reason), "conflict"
|
||||
case xerr.ProfileRequired:
|
||||
return http.StatusForbidden, codeProfileRequired, "profile required"
|
||||
case xerr.UserDisabled, xerr.PermissionDenied:
|
||||
return http.StatusForbidden, string(reason), "permission denied"
|
||||
case xerr.NotFound, xerr.DisplayUserIDNotFound, xerr.CountryNotFound, xerr.RegionNotFound:
|
||||
|
||||
@ -133,9 +133,26 @@ type fakeUserAuthClient struct {
|
||||
}
|
||||
|
||||
type fakeUserProfileClient struct {
|
||||
lastUpdate *userv1.UpdateUserProfileRequest
|
||||
lastCountry *userv1.ChangeUserCountryRequest
|
||||
countryErr error
|
||||
lastGet *userv1.GetUserRequest
|
||||
lastComplete *userv1.CompleteOnboardingRequest
|
||||
lastUpdate *userv1.UpdateUserProfileRequest
|
||||
lastCountry *userv1.ChangeUserCountryRequest
|
||||
getErr error
|
||||
completeErr error
|
||||
countryErr error
|
||||
regionID int64
|
||||
}
|
||||
|
||||
type fakeRoomQueryClient struct {
|
||||
lastList *roomv1.ListRoomsRequest
|
||||
resp *roomv1.ListRoomsResponse
|
||||
err error
|
||||
}
|
||||
|
||||
type fakeUserCountryQueryClient struct {
|
||||
last *userv1.ListRegistrationCountriesRequest
|
||||
resp *userv1.ListRegistrationCountriesResponse
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeUserAuthClient) LoginPassword(_ context.Context, req *userv1.LoginPasswordRequest) (*userv1.AuthResponse, error) {
|
||||
@ -153,15 +170,19 @@ func (f *fakeUserAuthClient) LoginThirdParty(_ context.Context, req *userv1.Logi
|
||||
f.loginThirdCount++
|
||||
return &userv1.AuthResponse{
|
||||
Token: &userv1.AuthToken{
|
||||
UserId: 10001,
|
||||
DisplayUserId: "100001",
|
||||
SessionId: "sess-1",
|
||||
AccessToken: "access-1",
|
||||
RefreshToken: "refresh-1",
|
||||
ExpiresInSec: 1800,
|
||||
TokenType: "Bearer",
|
||||
UserId: 10001,
|
||||
DisplayUserId: "100001",
|
||||
SessionId: "sess-1",
|
||||
AccessToken: "access-1",
|
||||
RefreshToken: "refresh-1",
|
||||
ExpiresInSec: 1800,
|
||||
TokenType: "Bearer",
|
||||
ProfileCompleted: false,
|
||||
OnboardingStatus: "profile_required",
|
||||
},
|
||||
IsNewUser: true,
|
||||
IsNewUser: true,
|
||||
ProfileCompleted: false,
|
||||
OnboardingStatus: "profile_required",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -173,7 +194,7 @@ func (f *fakeUserAuthClient) SetPassword(_ context.Context, req *userv1.SetPassw
|
||||
func (f *fakeUserAuthClient) RefreshToken(_ context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error) {
|
||||
f.lastRefresh = req
|
||||
f.refreshCount++
|
||||
return &userv1.RefreshTokenResponse{Token: &userv1.AuthToken{UserId: 10001, AccessToken: "access-2", RefreshToken: "refresh-2", ExpiresInSec: 1800, TokenType: "Bearer"}}, nil
|
||||
return &userv1.RefreshTokenResponse{Token: &userv1.AuthToken{UserId: 10001, AccessToken: "access-2", RefreshToken: "refresh-2", ExpiresInSec: 1800, TokenType: "Bearer", ProfileCompleted: true, OnboardingStatus: "completed"}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserAuthClient) Logout(_ context.Context, req *userv1.LogoutRequest) (*userv1.LogoutResponse, error) {
|
||||
@ -182,6 +203,53 @@ func (f *fakeUserAuthClient) Logout(_ context.Context, req *userv1.LogoutRequest
|
||||
return &userv1.LogoutResponse{Revoked: true}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
|
||||
f.lastGet = req
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
regionID := f.regionID
|
||||
if regionID == 0 {
|
||||
// 测试默认区域固定为 1001,避免新增入口在未配置 fake region 时误走 GLOBAL 桶。
|
||||
regionID = 1001
|
||||
}
|
||||
return &userv1.GetUserResponse{User: &userv1.User{
|
||||
UserId: req.GetUserId(),
|
||||
DisplayUserId: "100001",
|
||||
RegionId: regionID,
|
||||
ProfileCompleted: true,
|
||||
OnboardingStatus: "completed",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProfileClient) CompleteOnboarding(_ context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) {
|
||||
f.lastComplete = req
|
||||
if f.completeErr != nil {
|
||||
return nil, f.completeErr
|
||||
}
|
||||
return &userv1.CompleteOnboardingResponse{User: &userv1.User{
|
||||
UserId: req.GetUserId(),
|
||||
DisplayUserId: "100001",
|
||||
Username: req.GetUsername(),
|
||||
Avatar: req.GetAvatar(),
|
||||
Country: req.GetCountry(),
|
||||
RegionId: 1001,
|
||||
ProfileCompleted: true,
|
||||
ProfileCompletedAtMs: 4000,
|
||||
OnboardingStatus: "completed",
|
||||
UpdatedAtMs: 4000,
|
||||
}, ProfileCompleted: true, ProfileCompletedAtMs: 4000, OnboardingStatus: "completed", Token: &userv1.AuthToken{
|
||||
UserId: req.GetUserId(),
|
||||
SessionId: req.GetMeta().GetSessionId(),
|
||||
AccessToken: "access-completed",
|
||||
ExpiresInSec: 1800,
|
||||
TokenType: "Bearer",
|
||||
DisplayUserId: "100001",
|
||||
ProfileCompleted: true,
|
||||
OnboardingStatus: "completed",
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserProfileClient) UpdateUserProfile(_ context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error) {
|
||||
f.lastUpdate = req
|
||||
return &userv1.UpdateUserProfileResponse{User: &userv1.User{
|
||||
@ -207,6 +275,29 @@ func (f *fakeUserProfileClient) ChangeUserCountry(_ context.Context, req *userv1
|
||||
}, NextChangeAllowedAtMs: 3600000}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomQueryClient) ListRooms(_ context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
|
||||
f.lastList = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.resp != nil {
|
||||
return f.resp, nil
|
||||
}
|
||||
return &roomv1.ListRoomsResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserCountryQueryClient) ListRegistrationCountries(_ context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error) {
|
||||
f.last = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.resp != nil {
|
||||
return f.resp, nil
|
||||
}
|
||||
|
||||
return &userv1.ListRegistrationCountriesResponse{}, nil
|
||||
}
|
||||
|
||||
func valueOfString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
@ -250,7 +341,8 @@ func (f *fakeRoomGuardClient) VerifyRoomPresence(_ context.Context, req *roomv1.
|
||||
|
||||
func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
|
||||
client := &fakeRoomClient{}
|
||||
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
|
||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||
router := NewHandlerWithClients(client, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"room_id":"room-1","seat_count":8,"mode":"voice"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
@ -282,6 +374,9 @@ func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
|
||||
if client.lastCreate.GetMeta().GetCommandId() == "" || client.lastCreate.GetMeta().GetCommandId() == "req-test" {
|
||||
t.Fatalf("gateway-generated command_id must be non-empty and separate from request_id: %+v", client.lastCreate.GetMeta())
|
||||
}
|
||||
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 || client.lastCreate.GetVisibleRegionId() != 1001 {
|
||||
t.Fatalf("CreateRoom must derive visible_region_id from user-service: profile=%+v create=%+v", profileClient.lastGet, client.lastCreate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
@ -432,7 +527,7 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
client := &fakeRoomClient{}
|
||||
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
|
||||
router := NewHandlerWithClients(client, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-"+test.name)
|
||||
@ -461,6 +556,109 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRoomsUsesUserRegionFromUserService(t *testing.T) {
|
||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||
queryClient := &fakeRoomQueryClient{resp: &roomv1.ListRoomsResponse{
|
||||
Rooms: []*roomv1.RoomListItem{
|
||||
{RoomId: "room-1", VisibleRegionId: 1001, Heat: 90},
|
||||
},
|
||||
NextCursor: "cursor-2",
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||
handler.SetRoomQueryClient(queryClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?tab=new&limit=2&cursor=cursor-1&visible_region_id=9999", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-room-list")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 42 {
|
||||
t.Fatalf("ListRooms must fetch current user from user-service: %+v", profileClient.lastGet)
|
||||
}
|
||||
if queryClient.lastList == nil {
|
||||
t.Fatal("room query client was not called")
|
||||
}
|
||||
if queryClient.lastList.GetViewerUserId() != 42 || queryClient.lastList.GetVisibleRegionId() != 1001 {
|
||||
t.Fatalf("ListRooms must use authenticated user and server-side region: %+v", queryClient.lastList)
|
||||
}
|
||||
if queryClient.lastList.GetTab() != "new" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" {
|
||||
t.Fatalf("ListRooms query params were not propagated: %+v", queryClient.lastList)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRoomsRejectsInvalidLimitBeforeGRPC(t *testing.T) {
|
||||
queryClient := &fakeRoomQueryClient{}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{regionID: 1001})
|
||||
handler.SetRoomQueryClient(queryClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/rooms?limit=bad", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-room-list-limit")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidArgument, "req-room-list-limit")
|
||||
if queryClient.lastList != nil {
|
||||
t.Fatalf("invalid limit must not reach room-service: %+v", queryClient.lastList)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRegistrationCountriesIsPublicAndReturnsCountryMetadata(t *testing.T) {
|
||||
countryClient := &fakeUserCountryQueryClient{resp: &userv1.ListRegistrationCountriesResponse{
|
||||
Countries: []*userv1.Country{
|
||||
{
|
||||
CountryId: 86,
|
||||
CountryName: "China",
|
||||
CountryCode: "CN",
|
||||
IsoAlpha3: "CHN",
|
||||
IsoNumeric: "156",
|
||||
CountryDisplayName: "中国",
|
||||
PhoneCountryCode: "+86",
|
||||
Enabled: true,
|
||||
Flag: "CN",
|
||||
SortOrder: 10,
|
||||
},
|
||||
},
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserCountryQueryClient(countryClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/countries", nil)
|
||||
request.Header.Set("X-Request-ID", "req-countries")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if countryClient.last == nil || countryClient.last.GetMeta().GetRequestId() != "req-countries" {
|
||||
t.Fatalf("country query request metadata mismatch: %+v", countryClient.last)
|
||||
}
|
||||
var response responseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
data, ok := response.Data.(map[string]any)
|
||||
if response.Code != codeOK || !ok {
|
||||
t.Fatalf("unexpected envelope: %+v", response)
|
||||
}
|
||||
countries, ok := data["countries"].([]any)
|
||||
if !ok || len(countries) != 1 {
|
||||
t.Fatalf("countries response shape mismatch: %+v", data)
|
||||
}
|
||||
country, ok := countries[0].(map[string]any)
|
||||
if !ok || country["country_code"] != "CN" || country["iso_numeric"] != "156" || country["phone_country_code"] != "+86" || country["enabled"] != true {
|
||||
t.Fatalf("country metadata mismatch: %+v", country)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) {
|
||||
client := &fakeRoomClient{}
|
||||
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
|
||||
@ -488,7 +686,7 @@ func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) {
|
||||
func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
|
||||
userClient := &fakeUserAuthClient{}
|
||||
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"provider":"google","credential":"id-token-1","username":"hy","gender":"male","country":"CN","invite_code":"INV-1","device_id":"dev-1","device":"iPhone 15","os_version":"iOS 18.1","avatar":"https://cdn.example/avatar.png","birth":"2000-01-02","app_version":"1.2.3","build_number":"123","source":"campaign-a","install_channel":"app_store","campaign":"spring_launch","platform":"ios","language":"zh-CN","timezone":"Asia/Shanghai"}`)
|
||||
body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","username":"hy","gender":"male","country":"CN","invite_code":"INV-1","device_id":"dev-1","device":"iPhone 15","os_version":"iOS 18.1","avatar":"https://cdn.example/avatar.png","birth":"2000-01-02","app_version":"1.2.3","build_number":"123","source":"campaign-a","install_channel":"app_store","campaign":"spring_launch","platform":"ios","language":"zh-CN","timezone":"Asia/Shanghai"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body))
|
||||
request.Header.Set("X-Request-ID", "req-auth-third")
|
||||
request.Header.Set("X-Forwarded-For", "203.0.113.10, 10.0.0.1")
|
||||
@ -519,6 +717,9 @@ func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
|
||||
if req.GetUsername() != "hy" || req.GetInviteCode() != "INV-1" || req.GetDeviceId() != "dev-1" {
|
||||
t.Fatalf("third-party registration fields were not propagated: %+v", req)
|
||||
}
|
||||
if req.GetProvider() != "firebase" || req.GetCredential() != "firebase-id-token-1" {
|
||||
t.Fatalf("Firebase provider and ID token credential were not propagated: %+v", req)
|
||||
}
|
||||
if req.GetDevice() != "iPhone 15" || req.GetOsVersion() != "iOS 18.1" || req.GetAvatar() == "" || req.GetBirth() != "2000-01-02" || req.GetAppVersion() != "1.2.3" || req.GetBuildNumber() != "123" {
|
||||
t.Fatalf("third-party device/profile fields were not propagated: %+v", req)
|
||||
}
|
||||
@ -534,7 +735,7 @@ func TestThirdPartyLoginRateLimitByProviderAndIP(t *testing.T) {
|
||||
config.ProviderIPLimit = 1
|
||||
}))
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"provider":"google","credential":"id-token-1","device_id":"dev-1"}`)
|
||||
body := []byte(`{"provider":"firebase","credential":"firebase-id-token-1","device_id":"dev-1","platform":"ios"}`)
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/third-party/login", bytes.NewReader(body))
|
||||
@ -717,6 +918,113 @@ func TestChangeCountryUsesAuthenticatedUserIDAndMapsCooldown(t *testing.T) {
|
||||
assertEnvelope(t, recorder, http.StatusConflict, string(xerr.CountryChangeCooldown), "req-country-cooldown")
|
||||
}
|
||||
|
||||
func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *testing.T) {
|
||||
profileClient := &fakeUserProfileClient{}
|
||||
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"username":"hy","avatar":"https://cdn.example/a.png","country":"SG"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/users/me/onboarding/complete", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false))
|
||||
request.Header.Set("X-Request-ID", "req-onboarding")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if profileClient.lastComplete == nil || profileClient.lastComplete.GetUserId() != 42 {
|
||||
t.Fatalf("authenticated user_id was not propagated: %+v", profileClient.lastComplete)
|
||||
}
|
||||
if profileClient.lastComplete.GetMeta().GetRequestId() != "req-onboarding" || profileClient.lastComplete.GetUsername() != "hy" || profileClient.lastComplete.GetCountry() != "SG" {
|
||||
t.Fatalf("complete onboarding request mismatch: %+v", profileClient.lastComplete)
|
||||
}
|
||||
if profileClient.lastComplete.GetMeta().GetSessionId() != "sess-test" {
|
||||
t.Fatalf("complete onboarding must propagate current session_id: %+v", profileClient.lastComplete.GetMeta())
|
||||
}
|
||||
|
||||
var response responseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode response failed: %v", err)
|
||||
}
|
||||
data, ok := response.Data.(map[string]any)
|
||||
if response.Code != codeOK || !ok || data["profile_completed"] != true || data["onboarding_status"] != "completed" {
|
||||
t.Fatalf("unexpected onboarding response: %+v", response)
|
||||
}
|
||||
token, ok := data["token"].(map[string]any)
|
||||
if !ok || token["access_token"] != "access-completed" || token["profile_completed"] != true {
|
||||
t.Fatalf("complete onboarding must return replacement access token: %+v", data["token"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileMutationEndpointsRequireCompletedProfileToken(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{name: "profile_update", path: "/api/v1/users/me/profile/update", body: `{"username":"hy"}`},
|
||||
{name: "country_change", path: "/api/v1/users/me/country/change", body: `{"country":"US"}`},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
profileClient := &fakeUserProfileClient{}
|
||||
router := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, test.path, bytes.NewReader([]byte(test.body)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false))
|
||||
request.Header.Set("X-Request-ID", "req-profile-mutation")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
assertEnvelope(t, recorder, http.StatusForbidden, codeProfileRequired, "req-profile-mutation")
|
||||
if profileClient.lastUpdate != nil || profileClient.lastCountry != nil {
|
||||
t.Fatalf("profile gate must stop mutation before user-service: update=%+v country=%+v", profileClient.lastUpdate, profileClient.lastCountry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileGateRejectsIncompleteUsersForRoomIMRTCPaidCapabilities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{name: "room_list", method: http.MethodGet, path: "/api/v1/rooms"},
|
||||
{name: "create_room", method: http.MethodPost, path: "/api/v1/rooms/create", body: `{"room_id":"room-1"}`},
|
||||
{name: "join_room", method: http.MethodPost, path: "/api/v1/rooms/join", body: `{"room_id":"room-1"}`},
|
||||
{name: "im_usersig", method: http.MethodGet, path: "/api/v1/im/usersig"},
|
||||
{name: "rtc_token", method: http.MethodPost, path: "/api/v1/rtc/token", body: `{"room_id":"room-1"}`},
|
||||
{name: "send_gift", method: http.MethodPost, path: "/api/v1/rooms/gift/send", body: `{"room_id":"room-1","gift_id":"rose"}`},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
roomClient := &fakeRoomClient{}
|
||||
handler := NewHandlerWithConfig(roomClient, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
|
||||
SDKAppID: 1400000000,
|
||||
SecretKey: "secret",
|
||||
UserSigTTL: time.Hour,
|
||||
})
|
||||
handler.SetTencentRTC(TencentRTCConfig{Enabled: true, SDKAppID: 1400000000, SecretKey: "secret", UserSigTTL: time.Hour})
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(test.method, test.path, bytes.NewReader([]byte(test.body)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 42, false))
|
||||
request.Header.Set("X-Request-ID", "req-profile-required")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
assertEnvelope(t, recorder, http.StatusForbidden, codeProfileRequired, "req-profile-required")
|
||||
if roomClient.lastCreate != nil || roomClient.lastJoin != nil || roomClient.lastGift != nil {
|
||||
t.Fatalf("profile gate must stop protected request before room-service: %+v", roomClient)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthLoginMapsGRPCReason(t *testing.T) {
|
||||
userClient := &fakeUserAuthClient{loginErr: xerr.ToGRPCError(xerr.New(xerr.AuthFailed, "authentication failed"))}
|
||||
router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret"))
|
||||
@ -807,7 +1115,7 @@ func TestCreateRoomRejectsMissingRequiredFieldsBeforeGRPC(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRoutesWriteUpstreamErrorEnvelope(t *testing.T) {
|
||||
router := NewHandler(&fakeRoomClient{createErr: errors.New("room unavailable")}).Routes(auth.NewVerifier("secret"))
|
||||
router := NewHandlerWithClients(&fakeRoomClient{createErr: errors.New("room unavailable")}, nil, nil, &fakeUserProfileClient{regionID: 1001}).Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"room_id":"room-1","seat_count":8,"mode":"voice"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
@ -1127,8 +1435,17 @@ func authRateLimitForTest(mutator func(*AuthRateLimitConfig)) AuthRateLimitConfi
|
||||
func signGatewayToken(t *testing.T, secret string, userID int64) string {
|
||||
t.Helper()
|
||||
|
||||
return signGatewayTokenWithProfile(t, secret, userID, true)
|
||||
}
|
||||
|
||||
func signGatewayTokenWithProfile(t *testing.T, secret string, userID int64, profileCompleted bool) string {
|
||||
t.Helper()
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||
"user_id": float64(userID),
|
||||
"user_id": float64(userID),
|
||||
"sid": "sess-test",
|
||||
"profile_completed": profileCompleted,
|
||||
"onboarding_status": map[bool]string{true: "completed", false: "profile_required"}[profileCompleted],
|
||||
})
|
||||
signed, err := token.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
|
||||
@ -2,12 +2,67 @@ package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
roomv1 "hyapp/api/proto/room/v1"
|
||||
userv1 "hyapp/api/proto/user/v1"
|
||||
"hyapp/pkg/roomid"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
)
|
||||
|
||||
// listRooms 按当前登录用户的服务端 region_id 查询房间发现列表。
|
||||
func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userProfileClient == nil || h.roomQueryClient == nil {
|
||||
// 列表入口必须同时依赖 user-service 区域归属和 room-service 读模型。
|
||||
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
|
||||
limit, ok := parseRoomListLimit(request.URL.Query().Get("limit"))
|
||||
if !ok {
|
||||
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
|
||||
viewerUserID := auth.UserIDFromContext(request.Context())
|
||||
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: authRequestMeta(request, ""),
|
||||
UserId: viewerUserID,
|
||||
})
|
||||
if err != nil {
|
||||
// user-service 是 region_id 的唯一 owner,查询失败不能用 IP 国家兜底。
|
||||
writeRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.roomQueryClient.ListRooms(request.Context(), &roomv1.ListRoomsRequest{
|
||||
Meta: meta(request, "", ""),
|
||||
ViewerUserId: viewerUserID,
|
||||
VisibleRegionId: userResp.GetUser().GetRegionId(),
|
||||
Tab: request.URL.Query().Get("tab"),
|
||||
Cursor: request.URL.Query().Get("cursor"),
|
||||
Limit: limit,
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// parseRoomListLimit 只做 HTTP 字符串到数字的传输层解析。
|
||||
// 默认值和最大值仍由 room-service 统一裁剪,避免 gateway 和查询服务出现双份策略。
|
||||
func parseRoomListLimit(raw string) (int32, bool) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return 0, true
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
return int32(value), true
|
||||
}
|
||||
|
||||
// createRoom 把创建房间 HTTP JSON 请求转换为 room-service CreateRoom 命令。
|
||||
func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
@ -31,11 +86,25 @@ func (h *Handler) createRoom(writer http.ResponseWriter, request *http.Request)
|
||||
writeError(writer, request, http.StatusBadRequest, codeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
if h.userProfileClient == nil {
|
||||
// CreateRoom 的 visible_region_id 必须来自 user-service,不能让客户端传入或 gateway 猜测。
|
||||
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: authRequestMeta(request, ""),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
writeRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.roomClient.CreateRoom(request.Context(), &roomv1.CreateRoomRequest{
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
SeatCount: body.SeatCount,
|
||||
Mode: body.Mode,
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
SeatCount: body.SeatCount,
|
||||
Mode: body.Mode,
|
||||
VisibleRegionId: userResp.GetUser().GetRegionId(),
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
@ -19,32 +19,35 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
mux.Handle(apiV1Prefix+"/auth/token/refresh", publicAPIHandler(h.refreshToken))
|
||||
mux.Handle(apiV1Prefix+"/auth/logout", publicAPIHandler(h.logout))
|
||||
|
||||
mux.Handle(apiV1Prefix+"/im/usersig", apiHandler(jwtVerifier, h.issueTencentIMUserSig))
|
||||
mux.Handle(apiV1Prefix+"/rtc/token", apiHandler(jwtVerifier, h.issueTencentRTCToken))
|
||||
mux.Handle(apiV1Prefix+"/countries", publicAPIHandler(h.listRegistrationCountries))
|
||||
mux.Handle(apiV1Prefix+"/im/usersig", profileAPIHandler(jwtVerifier, h.issueTencentIMUserSig))
|
||||
mux.Handle(apiV1Prefix+"/rtc/token", profileAPIHandler(jwtVerifier, h.issueTencentRTCToken))
|
||||
mux.Handle(apiV1Prefix+"/tencent-im/callback", publicAPIHandler(h.handleTencentIMCallback))
|
||||
|
||||
mux.Handle(apiV1Prefix+"/users/by-display-user-id/", publicAPIHandler(h.resolveDisplayUserID))
|
||||
mux.Handle(apiV1Prefix+"/users/me/identity", apiHandler(jwtVerifier, h.getMyIdentity))
|
||||
mux.Handle(apiV1Prefix+"/users/me/profile/update", apiHandler(jwtVerifier, h.updateMyProfile))
|
||||
mux.Handle(apiV1Prefix+"/users/me/country/change", apiHandler(jwtVerifier, h.changeMyCountry))
|
||||
mux.Handle(apiV1Prefix+"/users/me/onboarding/complete", apiHandler(jwtVerifier, h.completeMyOnboarding))
|
||||
mux.Handle(apiV1Prefix+"/users/me/profile/update", profileAPIHandler(jwtVerifier, h.updateMyProfile))
|
||||
mux.Handle(apiV1Prefix+"/users/me/country/change", profileAPIHandler(jwtVerifier, h.changeMyCountry))
|
||||
mux.Handle(apiV1Prefix+"/users/me/display-id/change", apiHandler(jwtVerifier, h.changeMyDisplayUserID))
|
||||
mux.Handle(apiV1Prefix+"/users/me/display-id/pretty/apply", apiHandler(jwtVerifier, h.applyMyPrettyDisplayUserID))
|
||||
|
||||
mux.Handle(apiV1Prefix+"/rooms/create", apiHandler(jwtVerifier, h.createRoom))
|
||||
mux.Handle(apiV1Prefix+"/rooms/join", apiHandler(jwtVerifier, h.joinRoom))
|
||||
mux.Handle(apiV1Prefix+"/rooms/leave", apiHandler(jwtVerifier, h.leaveRoom))
|
||||
mux.Handle(apiV1Prefix+"/rooms/mic/up", apiHandler(jwtVerifier, h.micUp))
|
||||
mux.Handle(apiV1Prefix+"/rooms/mic/down", apiHandler(jwtVerifier, h.micDown))
|
||||
mux.Handle(apiV1Prefix+"/rooms/mic/change", apiHandler(jwtVerifier, h.changeMicSeat))
|
||||
mux.Handle(apiV1Prefix+"/rooms/mic/publishing/confirm", apiHandler(jwtVerifier, h.confirmMicPublishing))
|
||||
mux.Handle(apiV1Prefix+"/rooms/mic/lock", apiHandler(jwtVerifier, h.setMicSeatLock))
|
||||
mux.Handle(apiV1Prefix+"/rooms/chat/enabled", apiHandler(jwtVerifier, h.setChatEnabled))
|
||||
mux.Handle(apiV1Prefix+"/rooms/admin/set", apiHandler(jwtVerifier, h.setRoomAdmin))
|
||||
mux.Handle(apiV1Prefix+"/rooms/host/transfer", apiHandler(jwtVerifier, h.transferRoomHost))
|
||||
mux.Handle(apiV1Prefix+"/rooms/user/mute", apiHandler(jwtVerifier, h.muteUser))
|
||||
mux.Handle(apiV1Prefix+"/rooms/user/kick", apiHandler(jwtVerifier, h.kickUser))
|
||||
mux.Handle(apiV1Prefix+"/rooms/user/unban", apiHandler(jwtVerifier, h.unbanUser))
|
||||
mux.Handle(apiV1Prefix+"/rooms/gift/send", apiHandler(jwtVerifier, h.sendGift))
|
||||
mux.Handle(apiV1Prefix+"/rooms", profileAPIHandler(jwtVerifier, h.listRooms))
|
||||
mux.Handle(apiV1Prefix+"/rooms/create", profileAPIHandler(jwtVerifier, h.createRoom))
|
||||
mux.Handle(apiV1Prefix+"/rooms/join", profileAPIHandler(jwtVerifier, h.joinRoom))
|
||||
mux.Handle(apiV1Prefix+"/rooms/leave", profileAPIHandler(jwtVerifier, h.leaveRoom))
|
||||
mux.Handle(apiV1Prefix+"/rooms/mic/up", profileAPIHandler(jwtVerifier, h.micUp))
|
||||
mux.Handle(apiV1Prefix+"/rooms/mic/down", profileAPIHandler(jwtVerifier, h.micDown))
|
||||
mux.Handle(apiV1Prefix+"/rooms/mic/change", profileAPIHandler(jwtVerifier, h.changeMicSeat))
|
||||
mux.Handle(apiV1Prefix+"/rooms/mic/publishing/confirm", profileAPIHandler(jwtVerifier, h.confirmMicPublishing))
|
||||
mux.Handle(apiV1Prefix+"/rooms/mic/lock", profileAPIHandler(jwtVerifier, h.setMicSeatLock))
|
||||
mux.Handle(apiV1Prefix+"/rooms/chat/enabled", profileAPIHandler(jwtVerifier, h.setChatEnabled))
|
||||
mux.Handle(apiV1Prefix+"/rooms/admin/set", profileAPIHandler(jwtVerifier, h.setRoomAdmin))
|
||||
mux.Handle(apiV1Prefix+"/rooms/host/transfer", profileAPIHandler(jwtVerifier, h.transferRoomHost))
|
||||
mux.Handle(apiV1Prefix+"/rooms/user/mute", profileAPIHandler(jwtVerifier, h.muteUser))
|
||||
mux.Handle(apiV1Prefix+"/rooms/user/kick", profileAPIHandler(jwtVerifier, h.kickUser))
|
||||
mux.Handle(apiV1Prefix+"/rooms/user/unban", profileAPIHandler(jwtVerifier, h.unbanUser))
|
||||
mux.Handle(apiV1Prefix+"/rooms/gift/send", profileAPIHandler(jwtVerifier, h.sendGift))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
@ -34,16 +34,67 @@ type userProfileData struct {
|
||||
CountryID int64 `json:"country_id,omitempty"`
|
||||
CountryName string `json:"country_name,omitempty"`
|
||||
CountryDisplayName string `json:"country_display_name,omitempty"`
|
||||
ISONumeric string `json:"iso_numeric,omitempty"`
|
||||
PhoneCountryCode string `json:"phone_country_code,omitempty"`
|
||||
CountryEnabled bool `json:"country_enabled"`
|
||||
RegionID int64 `json:"region_id,omitempty"`
|
||||
RegionCode string `json:"region_code,omitempty"`
|
||||
RegionName string `json:"region_name,omitempty"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
Birth string `json:"birth,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
ProfileCompleted bool `json:"profile_completed"`
|
||||
ProfileCompletedAtMs int64 `json:"profile_completed_at_ms"`
|
||||
OnboardingStatus string `json:"onboarding_status"`
|
||||
UpdatedAtMs int64 `json:"updated_at_ms"`
|
||||
NextCountryChangeAtMs int64 `json:"next_country_change_allowed_at_ms,omitempty"`
|
||||
}
|
||||
|
||||
// completeOnboardingData 在保留资料字段顶层兼容性的同时返回新的 access token。
|
||||
// token.refresh_token 首版为空,客户端继续持有登录时的 refresh token,仅替换 access_token。
|
||||
type completeOnboardingData struct {
|
||||
userProfileData
|
||||
Token authTokenData `json:"token"`
|
||||
}
|
||||
|
||||
// countryData 是注册页国家列表的公开响应投影。
|
||||
// 只包含客户端选择和展示所需字段,不暴露管理端审计字段。
|
||||
type countryData struct {
|
||||
CountryID int64 `json:"country_id"`
|
||||
CountryName string `json:"country_name"`
|
||||
CountryCode string `json:"country_code"`
|
||||
ISOAlpha3 string `json:"iso_alpha3,omitempty"`
|
||||
ISONumeric string `json:"iso_numeric,omitempty"`
|
||||
CountryDisplayName string `json:"country_display_name"`
|
||||
PhoneCountryCode string `json:"phone_country_code,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Flag string `json:"flag,omitempty"`
|
||||
SortOrder int32 `json:"sort_order"`
|
||||
}
|
||||
|
||||
// listRegistrationCountries 返回 App 注册页可选国家。
|
||||
// 该接口公开读,不要求 access token;user-service 负责只返回 active 且 enabled 的国家。
|
||||
func (h *Handler) listRegistrationCountries(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userCountryClient == nil {
|
||||
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.userCountryClient.ListRegistrationCountries(request.Context(), &userv1.ListRegistrationCountriesRequest{
|
||||
Meta: authRequestMeta(request, ""),
|
||||
})
|
||||
if err != nil {
|
||||
writeRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
|
||||
countries := make([]countryData, 0, len(resp.GetCountries()))
|
||||
for _, country := range resp.GetCountries() {
|
||||
countries = append(countries, registrationCountryData(country))
|
||||
}
|
||||
writeOK(writer, request, map[string]any{"countries": countries})
|
||||
}
|
||||
|
||||
// getMyIdentity 返回当前登录用户的短号状态。
|
||||
func (h *Handler) getMyIdentity(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userIdentityClient == nil {
|
||||
@ -83,6 +134,40 @@ func (h *Handler) resolveDisplayUserID(writer http.ResponseWriter, request *http
|
||||
writeOK(writer, request, identityData(resp.GetIdentity(), ""))
|
||||
}
|
||||
|
||||
// completeMyOnboarding 是注册页唯一资料提交入口。
|
||||
// gateway 只负责从 access token 取 user_id,资料校验和国家/区域解析必须由 user-service 同事务完成。
|
||||
func (h *Handler) completeMyOnboarding(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"`
|
||||
Country string `json:"country"`
|
||||
}
|
||||
if !decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
if h.userProfileClient == nil {
|
||||
writeError(writer, request, http.StatusBadGateway, codeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.userProfileClient.CompleteOnboarding(request.Context(), &userv1.CompleteOnboardingRequest{
|
||||
Meta: authRequestMeta(request, ""),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
Username: body.Username,
|
||||
Avatar: body.Avatar,
|
||||
Country: body.Country,
|
||||
})
|
||||
if err != nil {
|
||||
writeRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
|
||||
writeOK(writer, request, completeOnboardingData{
|
||||
userProfileData: profileData(resp.GetUser(), 0),
|
||||
Token: authData(resp.GetToken(), nil),
|
||||
})
|
||||
}
|
||||
|
||||
// updateMyProfile 修改当前用户的用户名、头像和生日。
|
||||
func (h *Handler) updateMyProfile(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
@ -231,13 +316,38 @@ func profileData(user *userv1.User, nextCountryChangeAtMs int64) userProfileData
|
||||
CountryID: user.GetCountryId(),
|
||||
CountryName: user.GetCountryName(),
|
||||
CountryDisplayName: user.GetCountryDisplayName(),
|
||||
ISONumeric: user.GetIsoNumeric(),
|
||||
PhoneCountryCode: user.GetPhoneCountryCode(),
|
||||
CountryEnabled: user.GetCountryEnabled(),
|
||||
RegionID: user.GetRegionId(),
|
||||
RegionCode: user.GetRegionCode(),
|
||||
RegionName: user.GetRegionName(),
|
||||
Avatar: user.GetAvatar(),
|
||||
Birth: user.GetBirth(),
|
||||
Language: user.GetLanguage(),
|
||||
ProfileCompleted: user.GetProfileCompleted(),
|
||||
ProfileCompletedAtMs: user.GetProfileCompletedAtMs(),
|
||||
OnboardingStatus: user.GetOnboardingStatus(),
|
||||
UpdatedAtMs: user.GetUpdatedAtMs(),
|
||||
NextCountryChangeAtMs: nextCountryChangeAtMs,
|
||||
}
|
||||
}
|
||||
|
||||
func registrationCountryData(country *userv1.Country) countryData {
|
||||
if country == nil {
|
||||
return countryData{}
|
||||
}
|
||||
|
||||
return countryData{
|
||||
CountryID: country.GetCountryId(),
|
||||
CountryName: country.GetCountryName(),
|
||||
CountryCode: country.GetCountryCode(),
|
||||
ISOAlpha3: country.GetIsoAlpha3(),
|
||||
ISONumeric: country.GetIsoNumeric(),
|
||||
CountryDisplayName: country.GetCountryDisplayName(),
|
||||
PhoneCountryCode: country.GetPhoneCountryCode(),
|
||||
Enabled: country.GetEnabled(),
|
||||
Flag: country.GetFlag(),
|
||||
SortOrder: country.GetSortOrder(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS hyapp_room
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_unicode_ci;
|
||||
@ -11,9 +13,32 @@ CREATE TABLE IF NOT EXISTS rooms (
|
||||
seat_count INT NOT NULL,
|
||||
mode VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
KEY idx_rooms_region_status (visible_region_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_list_entries (
|
||||
room_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
host_user_id BIGINT NOT NULL,
|
||||
title VARCHAR(128) NOT NULL DEFAULT '',
|
||||
cover_url VARCHAR(512) NOT NULL DEFAULT '',
|
||||
mode VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
heat BIGINT NOT NULL DEFAULT 0,
|
||||
online_count INT NOT NULL DEFAULT 0,
|
||||
seat_count INT NOT NULL DEFAULT 0,
|
||||
occupied_seat_count INT NOT NULL DEFAULT 0,
|
||||
sort_score BIGINT NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
KEY idx_room_list_region_hot (visible_region_id, status, sort_score DESC, room_id),
|
||||
KEY idx_room_list_region_new (visible_region_id, status, created_at_ms DESC, room_id),
|
||||
KEY idx_room_list_owner (owner_user_id, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_snapshots (
|
||||
room_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
@ -22,7 +47,7 @@ CREATE TABLE IF NOT EXISTS room_snapshots (
|
||||
created_at TIMESTAMP(3) NOT NULL,
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
INDEX idx_room_snapshots_version (room_id, room_version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_command_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
@ -35,7 +60,7 @@ CREATE TABLE IF NOT EXISTS room_command_log (
|
||||
created_at TIMESTAMP(3) NOT NULL,
|
||||
UNIQUE KEY uk_room_command (room_id, command_id),
|
||||
KEY idx_room_command_replay (room_id, room_version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_outbox (
|
||||
event_id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
@ -48,5 +73,4 @@ CREATE TABLE IF NOT EXISTS room_outbox (
|
||||
last_error TEXT NULL,
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
KEY idx_room_outbox_pending (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
||||
roomv1 "hyapp/api/proto/room/v1"
|
||||
"hyapp/pkg/grpchealth"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/services/room-service/internal/config"
|
||||
"hyapp/services/room-service/internal/healthcheck"
|
||||
@ -119,10 +120,11 @@ func New(cfg config.Config) (*App, error) {
|
||||
MicPublishTimeout: cfg.MicPublishTimeout,
|
||||
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher)
|
||||
|
||||
server := grpc.NewServer()
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("room-service")))
|
||||
// 命令服务处理房间状态变更,守卫服务给腾讯云 IM 回调或 gateway 做发言和进群校验。
|
||||
roomv1.RegisterRoomCommandServiceServer(server, grpcserver.NewServer(svc))
|
||||
roomv1.RegisterRoomGuardServiceServer(server, grpcserver.NewServer(svc))
|
||||
roomv1.RegisterRoomQueryServiceServer(server, grpcserver.NewServer(svc))
|
||||
healthState := healthcheck.NewState(cfg.NodeID, svc, repository, directory)
|
||||
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(healthState))
|
||||
|
||||
|
||||
@ -62,6 +62,8 @@ type CreateRoom struct {
|
||||
SeatCount int32 `json:"seat_count"`
|
||||
// Mode 记录房间模式,当前版本只保存不做复杂策略分发。
|
||||
Mode string `json:"mode"`
|
||||
// VisibleRegionID 是创建时固定的房间列表区域,0 表示 GLOBAL。
|
||||
VisibleRegionID int64 `json:"visible_region_id"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
|
||||
@ -39,11 +39,12 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
|
||||
// protobuf 请求先收敛为 command,后续幂等、持久化和恢复都只认识 command 模型。
|
||||
cmd := command.CreateRoom{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
OwnerUserID: actorUserID,
|
||||
HostUserID: actorUserID,
|
||||
SeatCount: req.GetSeatCount(),
|
||||
Mode: mode,
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
OwnerUserID: actorUserID,
|
||||
HostUserID: actorUserID,
|
||||
SeatCount: req.GetSeatCount(),
|
||||
Mode: mode,
|
||||
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
|
||||
}
|
||||
|
||||
if cmd.SeatCount <= 0 {
|
||||
@ -120,12 +121,13 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
|
||||
// 首次创建按 meta -> command log -> outbox -> snapshot 的顺序落盘,保证恢复有基础元数据和首版本命令。
|
||||
if err := s.repository.SaveRoomMeta(ctx, RoomMeta{
|
||||
RoomID: cmd.RoomID(),
|
||||
OwnerUserID: cmd.OwnerUserID,
|
||||
HostUserID: cmd.HostUserID,
|
||||
SeatCount: cmd.SeatCount,
|
||||
Mode: cmd.Mode,
|
||||
Status: "active",
|
||||
RoomID: cmd.RoomID(),
|
||||
OwnerUserID: cmd.OwnerUserID,
|
||||
HostUserID: cmd.HostUserID,
|
||||
SeatCount: cmd.SeatCount,
|
||||
Mode: cmd.Mode,
|
||||
Status: "active",
|
||||
VisibleRegionID: cmd.VisibleRegionID,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -150,6 +152,7 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
if err := s.persistSnapshot(ctx, snapshot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.projectRoomListBestEffort(ctx, snapshot)
|
||||
|
||||
// 持久化成功后再安装内存 Cell,避免内存有房间但恢复来源不完整。
|
||||
s.installCell(cmd.RoomID(), cell.New(roomState.Clone(), rank.FromState(roomState.GiftRank, s.rankLimit)))
|
||||
|
||||
233
services/room-service/internal/room/service/list.go
Normal file
233
services/room-service/internal/room/service/list.go
Normal file
@ -0,0 +1,233 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
roomv1 "hyapp/api/proto/room/v1"
|
||||
"hyapp/pkg/xerr"
|
||||
)
|
||||
|
||||
const (
|
||||
roomListTabHot = "hot"
|
||||
roomListTabNew = "new"
|
||||
defaultRoomListLimit = 20
|
||||
maxRoomListLimit = 50
|
||||
)
|
||||
|
||||
// roomListCursor 是服务端生成的不透明翻页游标。
|
||||
// 客户端只能原样传回,不能依赖内部字段;tab 写入游标用于拒绝跨 tab 复用。
|
||||
type roomListCursor struct {
|
||||
Tab string `json:"tab"`
|
||||
SortScore int64 `json:"sort_score,omitempty"`
|
||||
CreatedAtMS int64 `json:"created_at_ms,omitempty"`
|
||||
RoomID string `json:"room_id"`
|
||||
}
|
||||
|
||||
// ListRooms 查询当前用户区域内的房间列表读模型。
|
||||
func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
|
||||
viewerUserID := req.GetViewerUserId()
|
||||
if viewerUserID <= 0 {
|
||||
// viewer_user_id 必须由 gateway 鉴权后填充,room-service 不接受匿名列表查询。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "viewer_user_id is required")
|
||||
}
|
||||
|
||||
tab := normalizeRoomListTab(req.GetTab())
|
||||
if tab == "" {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "tab is invalid")
|
||||
}
|
||||
|
||||
limit := normalizeRoomListLimit(req.GetLimit())
|
||||
cursor, err := decodeRoomListCursor(tab, req.GetCursor())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := s.repository.ListRoomListEntries(ctx, RoomListQuery{
|
||||
VisibleRegionID: normalizeVisibleRegionID(req.GetVisibleRegionId()),
|
||||
Tab: tab,
|
||||
Limit: limit + 1,
|
||||
CursorSortScore: cursor.SortScore,
|
||||
CursorCreatedAtMS: cursor.CreatedAtMS,
|
||||
CursorRoomID: cursor.RoomID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
nextCursor := ""
|
||||
if len(entries) > limit {
|
||||
// 多查一条用于判断是否存在下一页;返回数据只包含请求的 limit 条。
|
||||
last := entries[limit-1]
|
||||
nextCursor = encodeRoomListCursor(tab, last)
|
||||
entries = entries[:limit]
|
||||
}
|
||||
|
||||
items := make([]*roomv1.RoomListItem, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
items = append(items, roomListItemToProto(entry))
|
||||
}
|
||||
|
||||
return &roomv1.ListRoomsResponse{
|
||||
Rooms: items,
|
||||
NextCursor: nextCursor,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// projectRoomListBestEffort 根据最新快照刷新房间列表投影。
|
||||
func (s *Service) projectRoomListBestEffort(ctx context.Context, snapshot *roomv1.RoomSnapshot) {
|
||||
if snapshot == nil || snapshot.GetRoomId() == "" {
|
||||
return
|
||||
}
|
||||
|
||||
meta, exists, err := s.repository.GetRoomMeta(ctx, snapshot.GetRoomId())
|
||||
if err != nil {
|
||||
// 投影不能回滚已提交 Room Cell 命令,先记录日志;后续可用 projection task 补偿。
|
||||
log.Printf("component=room_list_projector room_id=%s status=load_meta_failed error=%q", snapshot.GetRoomId(), err.Error())
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("component=room_list_projector room_id=%s status=meta_missing", snapshot.GetRoomId())
|
||||
return
|
||||
}
|
||||
|
||||
nowMS := s.clock.Now().UnixMilli()
|
||||
entry := roomListEntryFromSnapshot(snapshot, meta.VisibleRegionID, nowMS)
|
||||
if err := s.repository.UpsertRoomListEntry(ctx, entry); err != nil {
|
||||
// 列表读模型是最终一致;失败不改变命令成功语义。
|
||||
log.Printf("component=room_list_projector room_id=%s region_id=%d status=upsert_failed error=%q", snapshot.GetRoomId(), meta.VisibleRegionID, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// roomListEntryFromSnapshot 把完整房间快照压缩成列表卡片读模型。
|
||||
// 这里刻意只提取列表需要的字段,避免列表表结构反向绑定 RoomState 全量细节。
|
||||
func roomListEntryFromSnapshot(snapshot *roomv1.RoomSnapshot, visibleRegionID int64, nowMS int64) RoomListEntry {
|
||||
onlineCount := int32(len(snapshot.GetOnlineUsers()))
|
||||
seatCount := int32(len(snapshot.GetMicSeats()))
|
||||
occupiedSeatCount := int32(0)
|
||||
for _, seat := range snapshot.GetMicSeats() {
|
||||
if seat.GetUserId() != 0 {
|
||||
occupiedSeatCount++
|
||||
}
|
||||
}
|
||||
|
||||
title := snapshot.GetRoomExt()["title"]
|
||||
coverURL := snapshot.GetRoomExt()["cover_url"]
|
||||
|
||||
return RoomListEntry{
|
||||
RoomID: snapshot.GetRoomId(),
|
||||
VisibleRegionID: normalizeVisibleRegionID(visibleRegionID),
|
||||
OwnerUserID: snapshot.GetOwnerUserId(),
|
||||
HostUserID: snapshot.GetHostUserId(),
|
||||
Title: title,
|
||||
CoverURL: coverURL,
|
||||
Mode: snapshot.GetMode(),
|
||||
Status: snapshot.GetStatus(),
|
||||
Heat: snapshot.GetHeat(),
|
||||
OnlineCount: onlineCount,
|
||||
SeatCount: seatCount,
|
||||
OccupiedSeatCount: occupiedSeatCount,
|
||||
SortScore: roomListSortScore(snapshot.GetHeat(), onlineCount, occupiedSeatCount),
|
||||
CreatedAtMS: nowMS,
|
||||
UpdatedAtMS: nowMS,
|
||||
}
|
||||
}
|
||||
|
||||
func roomListSortScore(heat int64, onlineCount int32, occupiedSeatCount int32) int64 {
|
||||
// 排序分只属于列表读模型,不进入 RoomState,后续调整公式不需要回放 command log。
|
||||
return heat*1000 + int64(onlineCount)*100 + int64(occupiedSeatCount)*10
|
||||
}
|
||||
|
||||
// normalizeRoomListTab 固定首版只支持 hot/new,未知 tab fail-closed。
|
||||
func normalizeRoomListTab(tab string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(tab)) {
|
||||
case "", roomListTabHot:
|
||||
return roomListTabHot
|
||||
case roomListTabNew:
|
||||
return roomListTabNew
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeRoomListLimit 统一列表分页上限,防止客户端用大 limit 打穿 MySQL 读模型。
|
||||
func normalizeRoomListLimit(limit int32) int {
|
||||
if limit <= 0 {
|
||||
return defaultRoomListLimit
|
||||
}
|
||||
if limit > maxRoomListLimit {
|
||||
return maxRoomListLimit
|
||||
}
|
||||
return int(limit)
|
||||
}
|
||||
|
||||
// normalizeVisibleRegionID 把负数区域收敛到 GLOBAL 桶,避免非法区域影响 SQL 查询边界。
|
||||
func normalizeVisibleRegionID(regionID int64) int64 {
|
||||
if regionID < 0 {
|
||||
return 0
|
||||
}
|
||||
return regionID
|
||||
}
|
||||
|
||||
// decodeRoomListCursor 校验不透明 cursor 和当前 tab 一致,禁止客户端跨排序维度复用游标。
|
||||
func decodeRoomListCursor(tab string, encoded string) (roomListCursor, error) {
|
||||
encoded = strings.TrimSpace(encoded)
|
||||
if encoded == "" {
|
||||
return roomListCursor{Tab: tab}, nil
|
||||
}
|
||||
|
||||
payload, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
|
||||
}
|
||||
|
||||
var cursor roomListCursor
|
||||
if err := json.Unmarshal(payload, &cursor); err != nil {
|
||||
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
|
||||
}
|
||||
if cursor.Tab != tab || cursor.RoomID == "" {
|
||||
return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid")
|
||||
}
|
||||
|
||||
return cursor, nil
|
||||
}
|
||||
|
||||
// encodeRoomListCursor 把最后一条列表记录编码成下一页边界。
|
||||
// cursor 不暴露契约语义,后续调整排序字段时可以只兼容服务端解析逻辑。
|
||||
func encodeRoomListCursor(tab string, entry RoomListEntry) string {
|
||||
cursor := roomListCursor{
|
||||
Tab: tab,
|
||||
SortScore: entry.SortScore,
|
||||
CreatedAtMS: entry.CreatedAtMS,
|
||||
RoomID: entry.RoomID,
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(cursor)
|
||||
if err != nil {
|
||||
// 固定结构理论上不会 marshal 失败;失败时返回空游标让客户端停止翻页。
|
||||
return ""
|
||||
}
|
||||
|
||||
return base64.RawURLEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
// roomListItemToProto 将 repository 投影转换为跨服务响应对象。
|
||||
func roomListItemToProto(entry RoomListEntry) *roomv1.RoomListItem {
|
||||
return &roomv1.RoomListItem{
|
||||
RoomId: entry.RoomID,
|
||||
OwnerUserId: entry.OwnerUserID,
|
||||
HostUserId: entry.HostUserID,
|
||||
Title: entry.Title,
|
||||
CoverUrl: entry.CoverURL,
|
||||
Mode: entry.Mode,
|
||||
Status: entry.Status,
|
||||
Heat: entry.Heat,
|
||||
OnlineCount: entry.OnlineCount,
|
||||
SeatCount: entry.SeatCount,
|
||||
OccupiedSeatCount: entry.OccupiedSeatCount,
|
||||
VisibleRegionId: entry.VisibleRegionID,
|
||||
}
|
||||
}
|
||||
@ -112,6 +112,10 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
return mutationResult{}, err
|
||||
}
|
||||
}
|
||||
if result.applied {
|
||||
// 列表读模型最终一致,失败只记录日志,不回滚已经提交的 Room Cell 命令。
|
||||
s.projectRoomListBestEffort(ctx, result.snapshot)
|
||||
}
|
||||
|
||||
if result.applied && result.syncEvent != nil {
|
||||
// 同步腾讯云 IM 失败不回滚房间状态;outbox 后续负责补偿投递。
|
||||
|
||||
@ -22,6 +22,8 @@ type RoomMeta struct {
|
||||
Mode string
|
||||
// Status 是房间基础状态,快照缺失时用于恢复 RoomState.Status。
|
||||
Status string
|
||||
// VisibleRegionID 是房间列表可见区域,0 表示 GLOBAL 兜底桶。
|
||||
VisibleRegionID int64
|
||||
}
|
||||
|
||||
// CommandRecord 对应 room_command_log 中一条成功命令。
|
||||
@ -54,6 +56,56 @@ type SnapshotRecord struct {
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// RoomListEntry 是 room_list_entries 的领域投影,只保存列表卡片所需字段。
|
||||
type RoomListEntry struct {
|
||||
// RoomID 是列表卡片和 JoinRoom 的跳转目标。
|
||||
RoomID string
|
||||
// VisibleRegionID 是列表隔离桶,查询只能按该字段过滤。
|
||||
VisibleRegionID int64
|
||||
// OwnerUserID 是房间所有者,用于卡片展示和运营排查。
|
||||
OwnerUserID int64
|
||||
// HostUserID 是当前主持人,用于卡片展示。
|
||||
HostUserID int64
|
||||
// Title 是卡片标题;空值由客户端或后续资料聚合兜底。
|
||||
Title string
|
||||
// CoverURL 是卡片封面;空值表示没有单独封面。
|
||||
CoverURL string
|
||||
// Mode 是房间模式,来自 RoomState/RoomMeta。
|
||||
Mode string
|
||||
// Status 是房间生命周期状态,列表默认只查 active。
|
||||
Status string
|
||||
// Heat 是房间热度,当前主要由送礼累计。
|
||||
Heat int64
|
||||
// OnlineCount 是 room-service 业务 presence 数量。
|
||||
OnlineCount int32
|
||||
// SeatCount 是麦位总数。
|
||||
SeatCount int32
|
||||
// OccupiedSeatCount 是当前被占用麦位数。
|
||||
OccupiedSeatCount int32
|
||||
// SortScore 是热门列表排序分,算法不进入 RoomState。
|
||||
SortScore int64
|
||||
// CreatedAtMS 是创建房间时的毫秒时间,new tab 使用它排序。
|
||||
CreatedAtMS int64
|
||||
// UpdatedAtMS 是投影最后更新时间。
|
||||
UpdatedAtMS int64
|
||||
}
|
||||
|
||||
// RoomListQuery 是 room-service 房间列表读模型查询条件。
|
||||
type RoomListQuery struct {
|
||||
// VisibleRegionID 是服务端解析出的用户区域,客户端不能伪造。
|
||||
VisibleRegionID int64
|
||||
// Tab 只允许 hot/new,决定排序字段。
|
||||
Tab string
|
||||
// Limit 是实际查询数量,service 层会限制最大值。
|
||||
Limit int
|
||||
// CursorSortScore 是 hot tab 的游标 score。
|
||||
CursorSortScore int64
|
||||
// CursorCreatedAtMS 是 new tab 的游标创建时间。
|
||||
CursorCreatedAtMS int64
|
||||
// CursorRoomID 是同分或同时间下的稳定翻页边界。
|
||||
CursorRoomID string
|
||||
}
|
||||
|
||||
// Repository 聚合 room-service 在首版需要的全部持久化读写。
|
||||
type Repository interface {
|
||||
// SaveRoomMeta 保存房间基础元数据,创建房间时必须先建立它。
|
||||
@ -78,4 +130,8 @@ type Repository interface {
|
||||
MarkOutboxPublished(ctx context.Context, eventID string) error
|
||||
// MarkOutboxFailed 记录一次投递失败并保留 pending 状态等待重试。
|
||||
MarkOutboxFailed(ctx context.Context, eventID string, lastErr string) error
|
||||
// UpsertRoomListEntry 写入或更新房间列表读模型;失败不应破坏 Room Cell 已提交状态。
|
||||
UpsertRoomListEntry(ctx context.Context, entry RoomListEntry) error
|
||||
// ListRoomListEntries 按区域和 tab 查询房间列表,不访问 Room Cell 内存。
|
||||
ListRoomListEntries(ctx context.Context, query RoomListQuery) ([]RoomListEntry, error)
|
||||
}
|
||||
|
||||
@ -148,6 +148,81 @@ func TestCreateRoomDefaultsOwnerAndHostFromActor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomListUsesRegionReadModelAndProjectionUpdates(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||||
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||||
|
||||
roomID := "room-list-region-a"
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
VisibleRegionId: 1001,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRoom region 1001 failed: %v", err)
|
||||
}
|
||||
|
||||
usedClock.now = usedClock.now.Add(time.Second)
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta("room-list-region-b", 3),
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
VisibleRegionId: 2002,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRoom region 2002 failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom failed: %v", err)
|
||||
}
|
||||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||||
t.Fatalf("MicUp failed: %v", err)
|
||||
}
|
||||
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
TargetUserId: 2,
|
||||
GiftId: "rose",
|
||||
GiftCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("SendGift failed: %v", err)
|
||||
}
|
||||
|
||||
listResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
|
||||
ViewerUserId: 99,
|
||||
VisibleRegionId: 1001,
|
||||
Tab: "hot",
|
||||
Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRooms region 1001 failed: %v", err)
|
||||
}
|
||||
if len(listResp.GetRooms()) != 1 {
|
||||
t.Fatalf("region list should only expose matching rooms: %+v", listResp.GetRooms())
|
||||
}
|
||||
item := listResp.GetRooms()[0]
|
||||
if item.GetRoomId() != roomID || item.GetVisibleRegionId() != 1001 {
|
||||
t.Fatalf("room list item identity mismatch: %+v", item)
|
||||
}
|
||||
if item.GetHeat() != 10 || item.GetOnlineCount() != 2 || item.GetOccupiedSeatCount() != 1 || item.GetSeatCount() != 4 {
|
||||
t.Fatalf("room list projection did not track Room Cell state: %+v", item)
|
||||
}
|
||||
|
||||
otherRegionResp, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{
|
||||
ViewerUserId: 99,
|
||||
VisibleRegionId: 3003,
|
||||
Tab: "hot",
|
||||
Limit: 20,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ListRooms other region failed: %v", err)
|
||||
}
|
||||
if len(otherRegionResp.GetRooms()) != 0 {
|
||||
t.Fatalf("region isolation should not leak rooms: %+v", otherRegionResp.GetRooms())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoomRejectsInvalidRoomID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -73,9 +73,31 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
seat_count INT NOT NULL,
|
||||
mode VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
KEY idx_rooms_region_status (visible_region_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS room_list_entries (
|
||||
room_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
visible_region_id BIGINT NOT NULL DEFAULT 0,
|
||||
owner_user_id BIGINT NOT NULL,
|
||||
host_user_id BIGINT NOT NULL,
|
||||
title VARCHAR(128) NOT NULL DEFAULT '',
|
||||
cover_url VARCHAR(512) NOT NULL DEFAULT '',
|
||||
mode VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
heat BIGINT NOT NULL DEFAULT 0,
|
||||
online_count INT NOT NULL DEFAULT 0,
|
||||
seat_count INT NOT NULL DEFAULT 0,
|
||||
occupied_seat_count INT NOT NULL DEFAULT 0,
|
||||
sort_score BIGINT NOT NULL DEFAULT 0,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
KEY idx_room_list_region_hot (visible_region_id, status, sort_score DESC, room_id),
|
||||
KEY idx_room_list_region_new (visible_region_id, status, created_at_ms DESC, room_id),
|
||||
KEY idx_room_list_owner (owner_user_id, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS room_snapshots (
|
||||
room_id VARCHAR(64) NOT NULL PRIMARY KEY,
|
||||
room_version BIGINT NOT NULL,
|
||||
@ -83,7 +105,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
created_at TIMESTAMP(3) NOT NULL,
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
INDEX idx_room_snapshots_version (room_id, room_version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS room_command_log (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
room_id VARCHAR(64) NOT NULL,
|
||||
@ -95,7 +117,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
created_at TIMESTAMP(3) NOT NULL,
|
||||
UNIQUE KEY uk_room_command (room_id, command_id),
|
||||
KEY idx_room_command_replay (room_id, room_version)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS room_outbox (
|
||||
event_id VARCHAR(128) NOT NULL PRIMARY KEY,
|
||||
event_type VARCHAR(64) NOT NULL,
|
||||
@ -107,7 +129,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
last_error TEXT NULL,
|
||||
updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
KEY idx_room_outbox_pending (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||
}
|
||||
|
||||
for _, statement := range statements {
|
||||
@ -124,14 +146,15 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta) error {
|
||||
// rooms 表只保存恢复初始状态需要的元数据,实时态以 snapshot/command log 为准。
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO rooms (room_id, owner_user_id, host_user_id, seat_count, mode, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
`INSERT INTO rooms (room_id, owner_user_id, host_user_id, seat_count, mode, status, visible_region_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
meta.RoomID,
|
||||
meta.OwnerUserID,
|
||||
meta.HostUserID,
|
||||
meta.SeatCount,
|
||||
meta.Mode,
|
||||
meta.Status,
|
||||
meta.VisibleRegionID,
|
||||
)
|
||||
|
||||
return err
|
||||
@ -141,14 +164,14 @@ func (r *Repository) SaveRoomMeta(ctx context.Context, meta roomservice.RoomMeta
|
||||
func (r *Repository) GetRoomMeta(ctx context.Context, roomID string) (roomservice.RoomMeta, bool, error) {
|
||||
// meta 是无内存 Cell 恢复的入口;不存在时上层返回 room not found。
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT room_id, owner_user_id, host_user_id, seat_count, mode, status
|
||||
`SELECT room_id, owner_user_id, host_user_id, seat_count, mode, status, visible_region_id
|
||||
FROM rooms
|
||||
WHERE room_id = ?`,
|
||||
roomID,
|
||||
)
|
||||
|
||||
var meta roomservice.RoomMeta
|
||||
if err := row.Scan(&meta.RoomID, &meta.OwnerUserID, &meta.HostUserID, &meta.SeatCount, &meta.Mode, &meta.Status); err != nil {
|
||||
if err := row.Scan(&meta.RoomID, &meta.OwnerUserID, &meta.HostUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.VisibleRegionID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// 未创建房间不是数据库错误,用 exists=false 表达。
|
||||
return roomservice.RoomMeta{}, false, nil
|
||||
@ -402,6 +425,135 @@ func (r *Repository) MarkOutboxFailed(ctx context.Context, eventID string, lastE
|
||||
return err
|
||||
}
|
||||
|
||||
// UpsertRoomListEntry 写入房间列表读模型。
|
||||
func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice.RoomListEntry) error {
|
||||
// created_at_ms 首次插入后保持不变,避免 Join/Leave/SendGift 等更新把 new tab 顺序洗掉。
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO room_list_entries (
|
||||
room_id, visible_region_id, owner_user_id, host_user_id, title, cover_url, mode, status,
|
||||
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
visible_region_id = VALUES(visible_region_id),
|
||||
owner_user_id = VALUES(owner_user_id),
|
||||
host_user_id = VALUES(host_user_id),
|
||||
title = VALUES(title),
|
||||
cover_url = VALUES(cover_url),
|
||||
mode = VALUES(mode),
|
||||
status = VALUES(status),
|
||||
heat = VALUES(heat),
|
||||
online_count = VALUES(online_count),
|
||||
seat_count = VALUES(seat_count),
|
||||
occupied_seat_count = VALUES(occupied_seat_count),
|
||||
sort_score = VALUES(sort_score),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
entry.RoomID,
|
||||
entry.VisibleRegionID,
|
||||
entry.OwnerUserID,
|
||||
entry.HostUserID,
|
||||
entry.Title,
|
||||
entry.CoverURL,
|
||||
entry.Mode,
|
||||
entry.Status,
|
||||
entry.Heat,
|
||||
entry.OnlineCount,
|
||||
entry.SeatCount,
|
||||
entry.OccupiedSeatCount,
|
||||
entry.SortScore,
|
||||
entry.CreatedAtMS,
|
||||
entry.UpdatedAtMS,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// ListRoomListEntries 按区域读取房间列表读模型。
|
||||
func (r *Repository) ListRoomListEntries(ctx context.Context, query roomservice.RoomListQuery) ([]roomservice.RoomListEntry, error) {
|
||||
if query.Limit <= 0 {
|
||||
query.Limit = 20
|
||||
}
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if query.Tab == "new" {
|
||||
if query.CursorRoomID == "" {
|
||||
// new tab 按创建时间倒序,同一毫秒下用 room_id 升序保证翻页稳定。
|
||||
rows, err = r.db.QueryContext(ctx, roomListNewFirstPageSQL, query.VisibleRegionID, "active", query.Limit)
|
||||
} else {
|
||||
rows, err = r.db.QueryContext(ctx, roomListNewNextPageSQL, query.VisibleRegionID, "active", query.CursorCreatedAtMS, query.CursorCreatedAtMS, query.CursorRoomID, query.Limit)
|
||||
}
|
||||
} else {
|
||||
if query.CursorRoomID == "" {
|
||||
// hot tab 按 sort_score 倒序,同分下用 room_id 升序保证翻页稳定。
|
||||
rows, err = r.db.QueryContext(ctx, roomListHotFirstPageSQL, query.VisibleRegionID, "active", query.Limit)
|
||||
} else {
|
||||
rows, err = r.db.QueryContext(ctx, roomListHotNextPageSQL, query.VisibleRegionID, "active", query.CursorSortScore, query.CursorSortScore, query.CursorRoomID, query.Limit)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := make([]roomservice.RoomListEntry, 0, query.Limit)
|
||||
for rows.Next() {
|
||||
var entry roomservice.RoomListEntry
|
||||
if err := rows.Scan(
|
||||
&entry.RoomID,
|
||||
&entry.VisibleRegionID,
|
||||
&entry.OwnerUserID,
|
||||
&entry.HostUserID,
|
||||
&entry.Title,
|
||||
&entry.CoverURL,
|
||||
&entry.Mode,
|
||||
&entry.Status,
|
||||
&entry.Heat,
|
||||
&entry.OnlineCount,
|
||||
&entry.SeatCount,
|
||||
&entry.OccupiedSeatCount,
|
||||
&entry.SortScore,
|
||||
&entry.CreatedAtMS,
|
||||
&entry.UpdatedAtMS,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
const roomListSelectColumns = `
|
||||
SELECT room_id, visible_region_id, owner_user_id, host_user_id, title, cover_url, mode, status,
|
||||
heat, online_count, seat_count, occupied_seat_count, sort_score, created_at_ms, updated_at_ms
|
||||
FROM room_list_entries`
|
||||
|
||||
const roomListHotFirstPageSQL = roomListSelectColumns + `
|
||||
WHERE visible_region_id = ? AND status = ?
|
||||
ORDER BY sort_score DESC, room_id ASC
|
||||
LIMIT ?`
|
||||
|
||||
const roomListHotNextPageSQL = roomListSelectColumns + `
|
||||
WHERE visible_region_id = ? AND status = ?
|
||||
AND (sort_score < ? OR (sort_score = ? AND room_id > ?))
|
||||
ORDER BY sort_score DESC, room_id ASC
|
||||
LIMIT ?`
|
||||
|
||||
const roomListNewFirstPageSQL = roomListSelectColumns + `
|
||||
WHERE visible_region_id = ? AND status = ?
|
||||
ORDER BY created_at_ms DESC, room_id ASC
|
||||
LIMIT ?`
|
||||
|
||||
const roomListNewNextPageSQL = roomListSelectColumns + `
|
||||
WHERE visible_region_id = ? AND status = ?
|
||||
AND (created_at_ms < ? OR (created_at_ms = ? AND room_id > ?))
|
||||
ORDER BY created_at_ms DESC, room_id ASC
|
||||
LIMIT ?`
|
||||
|
||||
func nullableString(value string) sql.NullString {
|
||||
// 空错误写 NULL,便于 SQL 查询区分没有错误和空字符串。
|
||||
return sql.NullString{
|
||||
|
||||
@ -28,7 +28,7 @@ func NewRepository(t testing.TB) *Repository {
|
||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||
EnvVar: "ROOM_SERVICE_MYSQL_TEST_DSN",
|
||||
InitDBPath: initDBPath(t),
|
||||
DatabasePrefix: "hy_room_test",
|
||||
DatabasePrefix: "hyapp_room_test",
|
||||
})
|
||||
repository, err := mysqlstorage.Open(context.Background(), schema.DSN, 4, 4)
|
||||
if err != nil {
|
||||
|
||||
@ -15,6 +15,8 @@ type Server struct {
|
||||
roomv1.UnimplementedRoomCommandServiceServer
|
||||
// UnimplementedRoomGuardServiceServer 提供守卫服务未实现 RPC 的标准 gRPC 返回。
|
||||
roomv1.UnimplementedRoomGuardServiceServer
|
||||
// UnimplementedRoomQueryServiceServer 提供查询服务未实现 RPC 的标准 gRPC 返回。
|
||||
roomv1.UnimplementedRoomQueryServiceServer
|
||||
|
||||
// svc 是领域服务入口,gRPC 层不保存任何房间状态。
|
||||
svc *roomservice.Service
|
||||
@ -138,3 +140,9 @@ func (s *Server) VerifyRoomPresence(ctx context.Context, req *roomv1.VerifyRoomP
|
||||
// 外部 IM 进群前调用该守卫,防止未进房或被踢用户收消息。
|
||||
return mapServiceResult(s.svc.VerifyRoomPresence(ctx, req))
|
||||
}
|
||||
|
||||
// ListRooms 代理到 room-service 列表读模型。
|
||||
func (s *Server) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) (*roomv1.ListRoomsResponse, error) {
|
||||
// 列表查询只读 MySQL 投影,不扫描 Room Cell 内存集合。
|
||||
return mapServiceResult(s.svc.ListRooms(ctx, req))
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
service_name: user-service
|
||||
node_id: user-docker
|
||||
grpc_addr: ":13005"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hy_user?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
id_generator:
|
||||
node_id: 1
|
||||
@ -10,10 +10,11 @@ display_user_id:
|
||||
change_cooldown_sec: 2592000
|
||||
third_party:
|
||||
allowed_providers:
|
||||
- "facebook"
|
||||
- "apple"
|
||||
- "google"
|
||||
- "tiktok"
|
||||
- "firebase"
|
||||
firebase:
|
||||
project_id: "lalu-party"
|
||||
allowed_sign_in_providers:
|
||||
- "google.com"
|
||||
region_rebuild_worker:
|
||||
enabled: true
|
||||
poll_interval_sec: 60
|
||||
|
||||
@ -10,10 +10,11 @@ display_user_id:
|
||||
change_cooldown_sec: 2592000
|
||||
third_party:
|
||||
allowed_providers:
|
||||
- "facebook"
|
||||
- "apple"
|
||||
- "google"
|
||||
- "tiktok"
|
||||
- "firebase"
|
||||
firebase:
|
||||
project_id: "lalu-party"
|
||||
allowed_sign_in_providers:
|
||||
- "google.com"
|
||||
region_rebuild_worker:
|
||||
enabled: true
|
||||
poll_interval_sec: 60
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
service_name: user-service
|
||||
node_id: user-local
|
||||
grpc_addr: ":13005"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_user?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
id_generator:
|
||||
node_id: 1
|
||||
@ -10,10 +10,11 @@ display_user_id:
|
||||
change_cooldown_sec: 2592000
|
||||
third_party:
|
||||
allowed_providers:
|
||||
- "facebook"
|
||||
- "apple"
|
||||
- "google"
|
||||
- "tiktok"
|
||||
- "firebase"
|
||||
firebase:
|
||||
project_id: "lalu-party"
|
||||
allowed_sign_in_providers:
|
||||
- "google.com"
|
||||
region_rebuild_worker:
|
||||
enabled: true
|
||||
poll_interval_sec: 60
|
||||
|
||||
3786
services/user-service/deploy/data/countries.json
Normal file
3786
services/user-service/deploy/data/countries.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,8 @@
|
||||
CREATE DATABASE IF NOT EXISTS hy_user DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hy_user;
|
||||
CREATE DATABASE IF NOT EXISTS hyapp_user DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_user;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id BIGINT NOT NULL PRIMARY KEY,
|
||||
@ -29,6 +31,9 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
platform VARCHAR(16) NULL,
|
||||
language VARCHAR(32) NULL,
|
||||
timezone VARCHAR(64) NULL,
|
||||
profile_completed TINYINT(1) NOT NULL DEFAULT 0,
|
||||
profile_completed_at_ms BIGINT NOT NULL DEFAULT 0,
|
||||
onboarding_status VARCHAR(32) NOT NULL DEFAULT 'profile_required',
|
||||
status VARCHAR(32) NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
@ -43,15 +48,22 @@ CREATE TABLE IF NOT EXISTS countries (
|
||||
country_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
country_name VARCHAR(128) NOT NULL,
|
||||
country_code VARCHAR(3) NOT NULL,
|
||||
iso_alpha3 VARCHAR(3) NULL,
|
||||
iso_numeric CHAR(3) NULL,
|
||||
country_display_name VARCHAR(128) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
phone_country_code VARCHAR(8) NULL,
|
||||
flag VARCHAR(16) NOT NULL DEFAULT '',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_by_user_id BIGINT NULL,
|
||||
updated_by_user_id BIGINT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_countries_code (country_code),
|
||||
KEY idx_countries_status_sort (status, sort_order)
|
||||
UNIQUE KEY uk_countries_iso_numeric (iso_numeric),
|
||||
KEY idx_countries_phone_country_code (phone_country_code),
|
||||
KEY idx_countries_app_enabled_sort (enabled, sort_order, country_code),
|
||||
KEY idx_countries_sort (sort_order, country_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS regions (
|
||||
@ -118,19 +130,811 @@ CREATE TABLE IF NOT EXISTS user_region_rebuild_tasks (
|
||||
KEY idx_user_region_rebuild_tasks_lock (status, locked_until_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO countries (country_name, country_code, country_display_name, status, sort_order, created_at_ms, updated_at_ms) VALUES
|
||||
('China', 'CN', '中国', 'active', 10, 0, 0),
|
||||
('United States', 'US', '美国', 'active', 20, 0, 0),
|
||||
('Japan', 'JP', '日本', 'active', 30, 0, 0),
|
||||
('Singapore', 'SG', '新加坡', 'active', 40, 0, 0),
|
||||
('Malaysia', 'MY', '马来西亚', 'active', 50, 0, 0),
|
||||
('Thailand', 'TH', '泰国', 'active', 60, 0, 0),
|
||||
('Indonesia', 'ID', '印度尼西亚', 'active', 70, 0, 0),
|
||||
('Vietnam', 'VN', '越南', 'active', 80, 0, 0),
|
||||
('Philippines', 'PH', '菲律宾', 'active', 90, 0, 0),
|
||||
('Hong Kong', 'HK', '中国香港', 'active', 100, 0, 0),
|
||||
('Macao', 'MO', '中国澳门', 'active', 110, 0, 0),
|
||||
('Taiwan', 'TW', '中国台湾', 'active', 120, 0, 0);
|
||||
-- Country/region bootstrap is generated from services/user-service/deploy/data/countries.json.
|
||||
-- region_code intentionally uses source subregion directly; top-level region is not imported.
|
||||
INSERT INTO countries (country_name, country_code, iso_alpha3, iso_numeric, country_display_name, phone_country_code, flag, enabled, sort_order, created_at_ms, updated_at_ms) VALUES
|
||||
('Andorra', 'AD', 'AND', '020', '安道尔', '+376', '🇦🇩', TRUE, 10, 0, 0),
|
||||
('United Arab Emirates', 'AE', 'ARE', '784', '阿拉伯联合酋长国', '+971', '🇦🇪', TRUE, 20, 0, 0),
|
||||
('Afghanistan', 'AF', 'AFG', '004', '阿富汗', '+93', '🇦🇫', TRUE, 30, 0, 0),
|
||||
('Antigua and Barbuda', 'AG', 'ATG', '028', '安提瓜和巴布达', '+1', '🇦🇬', TRUE, 40, 0, 0),
|
||||
('Anguilla', 'AI', 'AIA', '660', '安圭拉', '+1', '🇦🇮', TRUE, 50, 0, 0),
|
||||
('Albania', 'AL', 'ALB', '008', '阿尔巴尼亚', '+355', '🇦🇱', TRUE, 60, 0, 0),
|
||||
('Armenia', 'AM', 'ARM', '051', '亚美尼亚', '+374', '🇦🇲', TRUE, 70, 0, 0),
|
||||
('Angola', 'AO', 'AGO', '024', '安哥拉', '+244', '🇦🇴', TRUE, 80, 0, 0),
|
||||
('Antarctica', 'AQ', 'ATA', '010', '南极洲', NULL, '🇦🇶', TRUE, 90, 0, 0),
|
||||
('Argentina', 'AR', 'ARG', '032', '阿根廷', '+54', '🇦🇷', TRUE, 100, 0, 0),
|
||||
('American Samoa', 'AS', 'ASM', '016', '美属萨摩亚', '+1', '🇦🇸', TRUE, 110, 0, 0),
|
||||
('Austria', 'AT', 'AUT', '040', '奥地利', '+43', '🇦🇹', TRUE, 120, 0, 0),
|
||||
('Australia', 'AU', 'AUS', '036', '澳大利亚', '+61', '🇦🇺', TRUE, 130, 0, 0),
|
||||
('Aruba', 'AW', 'ABW', '533', '阿鲁巴', '+297', '🇦🇼', TRUE, 140, 0, 0),
|
||||
('Åland Islands', 'AX', 'ALA', '248', '奥兰群岛', '+358', '🇦🇽', TRUE, 150, 0, 0),
|
||||
('Azerbaijan', 'AZ', 'AZE', '031', '阿塞拜疆', '+994', '🇦🇿', TRUE, 160, 0, 0),
|
||||
('Bosnia and Herzegovina', 'BA', 'BIH', '070', '波斯尼亚和黑塞哥维那', '+387', '🇧🇦', TRUE, 170, 0, 0),
|
||||
('Barbados', 'BB', 'BRB', '052', '巴巴多斯', '+1', '🇧🇧', TRUE, 180, 0, 0),
|
||||
('Bangladesh', 'BD', 'BGD', '050', '孟加拉国', '+880', '🇧🇩', TRUE, 190, 0, 0),
|
||||
('Belgium', 'BE', 'BEL', '056', '比利时', '+32', '🇧🇪', TRUE, 200, 0, 0),
|
||||
('Burkina Faso', 'BF', 'BFA', '854', '布基纳法索', '+226', '🇧🇫', TRUE, 210, 0, 0),
|
||||
('Bulgaria', 'BG', 'BGR', '100', '保加利亚', '+359', '🇧🇬', TRUE, 220, 0, 0),
|
||||
('Bahrain', 'BH', 'BHR', '048', '巴林', '+973', '🇧🇭', TRUE, 230, 0, 0),
|
||||
('Burundi', 'BI', 'BDI', '108', '布隆迪', '+257', '🇧🇮', TRUE, 240, 0, 0),
|
||||
('Benin', 'BJ', 'BEN', '204', '贝宁', '+229', '🇧🇯', TRUE, 250, 0, 0),
|
||||
('Saint Barthélemy', 'BL', 'BLM', '652', '圣巴泰勒米', '+590', '🇧🇱', TRUE, 260, 0, 0),
|
||||
('Bermuda', 'BM', 'BMU', '060', '百慕大', '+1', '🇧🇲', TRUE, 270, 0, 0),
|
||||
('Brunei', 'BN', 'BRN', '096', '文莱', '+673', '🇧🇳', TRUE, 280, 0, 0),
|
||||
('Bolivia', 'BO', 'BOL', '068', '玻利维亚', '+591', '🇧🇴', TRUE, 290, 0, 0),
|
||||
('Caribbean Netherlands', 'BQ', 'BES', '535', '荷蘭加勒比區', '+599', '', TRUE, 300, 0, 0),
|
||||
('Brazil', 'BR', 'BRA', '076', '巴西', '+55', '🇧🇷', TRUE, 310, 0, 0),
|
||||
('Bahamas', 'BS', 'BHS', '044', '巴哈马', '+1', '🇧🇸', TRUE, 320, 0, 0),
|
||||
('Bhutan', 'BT', 'BTN', '064', '不丹', '+975', '🇧🇹', TRUE, 330, 0, 0),
|
||||
('Bouvet Island', 'BV', 'BVT', '074', '布维岛', NULL, '🇧🇻', TRUE, 340, 0, 0),
|
||||
('Botswana', 'BW', 'BWA', '072', '博茨瓦纳', '+267', '🇧🇼', TRUE, 350, 0, 0),
|
||||
('Belarus', 'BY', 'BLR', '112', '白俄罗斯', '+375', '🇧🇾', TRUE, 360, 0, 0),
|
||||
('Belize', 'BZ', 'BLZ', '084', '伯利兹', '+501', '🇧🇿', TRUE, 370, 0, 0),
|
||||
('Canada', 'CA', 'CAN', '124', '加拿大', '+1', '🇨🇦', TRUE, 380, 0, 0),
|
||||
('Cocos (Keeling) Islands', 'CC', 'CCK', '166', '科科斯', '+61', '🇨🇨', TRUE, 390, 0, 0),
|
||||
('DR Congo', 'CD', 'COD', '180', '民主刚果', '+243', '🇨🇩', TRUE, 400, 0, 0),
|
||||
('Central African Republic', 'CF', 'CAF', '140', '中非共和国', '+236', '🇨🇫', TRUE, 410, 0, 0),
|
||||
('Congo', 'CG', 'COG', '178', '刚果', '+242', '🇨🇬', TRUE, 420, 0, 0),
|
||||
('Switzerland', 'CH', 'CHE', '756', '瑞士', '+41', '🇨🇭', TRUE, 430, 0, 0),
|
||||
('Ivory Coast', 'CI', 'CIV', '384', '科特迪瓦', '+225', '🇨🇮', TRUE, 440, 0, 0),
|
||||
('Cook Islands', 'CK', 'COK', '184', '库克群岛', '+682', '🇨🇰', TRUE, 450, 0, 0),
|
||||
('Chile', 'CL', 'CHL', '152', '智利', '+56', '🇨🇱', TRUE, 460, 0, 0),
|
||||
('Cameroon', 'CM', 'CMR', '120', '喀麦隆', '+237', '🇨🇲', TRUE, 470, 0, 0),
|
||||
('China', 'CN', 'CHN', '156', '中国', '+86', '🇨🇳', TRUE, 480, 0, 0),
|
||||
('Colombia', 'CO', 'COL', '170', '哥伦比亚', '+57', '🇨🇴', TRUE, 490, 0, 0),
|
||||
('Costa Rica', 'CR', 'CRI', '188', '哥斯达黎加', '+506', '🇨🇷', TRUE, 500, 0, 0),
|
||||
('Cuba', 'CU', 'CUB', '192', '古巴', '+53', '🇨🇺', TRUE, 510, 0, 0),
|
||||
('Cape Verde', 'CV', 'CPV', '132', '佛得角', '+238', '🇨🇻', TRUE, 520, 0, 0),
|
||||
('Curaçao', 'CW', 'CUW', '531', '库拉索', '+599', '🇨🇼', TRUE, 530, 0, 0),
|
||||
('Christmas Island', 'CX', 'CXR', '162', '圣诞岛', '+61', '🇨🇽', TRUE, 540, 0, 0),
|
||||
('Cyprus', 'CY', 'CYP', '196', '塞浦路斯', '+357', '🇨🇾', TRUE, 550, 0, 0),
|
||||
('Czechia', 'CZ', 'CZE', '203', '捷克', '+420', '🇨🇿', TRUE, 560, 0, 0),
|
||||
('Germany', 'DE', 'DEU', '276', '德国', '+49', '🇩🇪', TRUE, 570, 0, 0),
|
||||
('Djibouti', 'DJ', 'DJI', '262', '吉布提', '+253', '🇩🇯', TRUE, 580, 0, 0),
|
||||
('Denmark', 'DK', 'DNK', '208', '丹麦', '+45', '🇩🇰', TRUE, 590, 0, 0),
|
||||
('Dominica', 'DM', 'DMA', '212', '多米尼加', '+1', '🇩🇲', TRUE, 600, 0, 0),
|
||||
('Dominican Republic', 'DO', 'DOM', '214', '多明尼加', '+1', '🇩🇴', TRUE, 610, 0, 0),
|
||||
('Algeria', 'DZ', 'DZA', '012', '阿尔及利亚', '+213', '🇩🇿', TRUE, 620, 0, 0),
|
||||
('Ecuador', 'EC', 'ECU', '218', '厄瓜多尔', '+593', '🇪🇨', TRUE, 630, 0, 0),
|
||||
('Estonia', 'EE', 'EST', '233', '爱沙尼亚', '+372', '🇪🇪', TRUE, 640, 0, 0),
|
||||
('Egypt', 'EG', 'EGY', '818', '埃及', '+20', '🇪🇬', TRUE, 650, 0, 0),
|
||||
('Western Sahara', 'EH', 'ESH', '732', '西撒哈拉', '+212', '🇪🇭', TRUE, 660, 0, 0),
|
||||
('Eritrea', 'ER', 'ERI', '232', '厄立特里亚', '+291', '🇪🇷', TRUE, 670, 0, 0),
|
||||
('Spain', 'ES', 'ESP', '724', '西班牙', '+34', '🇪🇸', TRUE, 680, 0, 0),
|
||||
('Ethiopia', 'ET', 'ETH', '231', '埃塞俄比亚', '+251', '🇪🇹', TRUE, 690, 0, 0),
|
||||
('Finland', 'FI', 'FIN', '246', '芬兰', '+358', '🇫🇮', TRUE, 700, 0, 0),
|
||||
('Fiji', 'FJ', 'FJI', '242', '斐济', '+679', '🇫🇯', TRUE, 710, 0, 0),
|
||||
('Falkland Islands', 'FK', 'FLK', '238', '福克兰群岛', '+500', '🇫🇰', TRUE, 720, 0, 0),
|
||||
('Micronesia', 'FM', 'FSM', '583', '密克罗尼西亚', '+691', '🇫🇲', TRUE, 730, 0, 0),
|
||||
('Faroe Islands', 'FO', 'FRO', '234', '法罗群岛', '+298', '🇫🇴', TRUE, 740, 0, 0),
|
||||
('France', 'FR', 'FRA', '250', '法国', '+33', '🇫🇷', TRUE, 750, 0, 0),
|
||||
('Gabon', 'GA', 'GAB', '266', '加蓬', '+241', '🇬🇦', TRUE, 760, 0, 0),
|
||||
('United Kingdom', 'GB', 'GBR', '826', '英国', '+44', '🇬🇧', TRUE, 770, 0, 0),
|
||||
('Grenada', 'GD', 'GRD', '308', '格林纳达', '+1', '🇬🇩', TRUE, 780, 0, 0),
|
||||
('Georgia', 'GE', 'GEO', '268', '格鲁吉亚', '+995', '🇬🇪', TRUE, 790, 0, 0),
|
||||
('French Guiana', 'GF', 'GUF', '254', '法属圭亚那', '+594', '🇬🇫', TRUE, 800, 0, 0),
|
||||
('Guernsey', 'GG', 'GGY', '831', '根西岛', '+44', '🇬🇬', TRUE, 810, 0, 0),
|
||||
('Ghana', 'GH', 'GHA', '288', '加纳', '+233', '🇬🇭', TRUE, 820, 0, 0),
|
||||
('Gibraltar', 'GI', 'GIB', '292', '直布罗陀', '+350', '🇬🇮', TRUE, 830, 0, 0),
|
||||
('Greenland', 'GL', 'GRL', '304', '格陵兰', '+299', '🇬🇱', TRUE, 840, 0, 0),
|
||||
('Gambia', 'GM', 'GMB', '270', '冈比亚', '+220', '🇬🇲', TRUE, 850, 0, 0),
|
||||
('Guinea', 'GN', 'GIN', '324', '几内亚', '+224', '🇬🇳', TRUE, 860, 0, 0),
|
||||
('Guadeloupe', 'GP', 'GLP', '312', '瓜德罗普岛', '+590', '🇬🇵', TRUE, 870, 0, 0),
|
||||
('Equatorial Guinea', 'GQ', 'GNQ', '226', '赤道几内亚', '+240', '🇬🇶', TRUE, 880, 0, 0),
|
||||
('Greece', 'GR', 'GRC', '300', '希腊', '+30', '🇬🇷', TRUE, 890, 0, 0),
|
||||
('South Georgia', 'GS', 'SGS', '239', '南乔治亚', NULL, '🇬🇸', TRUE, 900, 0, 0),
|
||||
('Guatemala', 'GT', 'GTM', '320', '危地马拉', '+502', '🇬🇹', TRUE, 910, 0, 0),
|
||||
('Guam', 'GU', 'GUM', '316', '关岛', '+1', '🇬🇺', TRUE, 920, 0, 0),
|
||||
('Guinea-Bissau', 'GW', 'GNB', '624', '几内亚比绍', '+245', '🇬🇼', TRUE, 930, 0, 0),
|
||||
('Guyana', 'GY', 'GUY', '328', '圭亚那', '+592', '🇬🇾', TRUE, 940, 0, 0),
|
||||
('Hong Kong', 'HK', 'HKG', '344', '中国香港', '+852', '🇭🇰', TRUE, 950, 0, 0),
|
||||
('Heard Island and McDonald Islands', 'HM', 'HMD', '334', '赫德岛和麦当劳群岛', NULL, '🇭🇲', TRUE, 960, 0, 0),
|
||||
('Honduras', 'HN', 'HND', '340', '洪都拉斯', '+504', '🇭🇳', TRUE, 970, 0, 0),
|
||||
('Croatia', 'HR', 'HRV', '191', '克罗地亚', '+385', '🇭🇷', TRUE, 980, 0, 0),
|
||||
('Haiti', 'HT', 'HTI', '332', '海地', '+509', '🇭🇹', TRUE, 990, 0, 0),
|
||||
('Hungary', 'HU', 'HUN', '348', '匈牙利', '+36', '🇭🇺', TRUE, 1000, 0, 0),
|
||||
('Indonesia', 'ID', 'IDN', '360', '印度尼西亚', '+62', '🇮🇩', TRUE, 1010, 0, 0),
|
||||
('Ireland', 'IE', 'IRL', '372', '爱尔兰', '+353', '🇮🇪', TRUE, 1020, 0, 0),
|
||||
('Israel', 'IL', 'ISR', '376', '以色列', '+972', '🇮🇱', TRUE, 1030, 0, 0),
|
||||
('Isle of Man', 'IM', 'IMN', '833', '马恩岛', '+44', '🇮🇲', TRUE, 1040, 0, 0),
|
||||
('India', 'IN', 'IND', '356', '印度', '+91', '🇮🇳', TRUE, 1050, 0, 0),
|
||||
('British Indian Ocean Territory', 'IO', 'IOT', '086', '英属印度洋领地', '+246', '🇮🇴', TRUE, 1060, 0, 0),
|
||||
('Iraq', 'IQ', 'IRQ', '368', '伊拉克', '+964', '🇮🇶', TRUE, 1070, 0, 0),
|
||||
('Iran', 'IR', 'IRN', '364', '伊朗', '+98', '🇮🇷', TRUE, 1080, 0, 0),
|
||||
('Iceland', 'IS', 'ISL', '352', '冰岛', '+354', '🇮🇸', TRUE, 1090, 0, 0),
|
||||
('Italy', 'IT', 'ITA', '380', '意大利', '+39', '🇮🇹', TRUE, 1100, 0, 0),
|
||||
('Jersey', 'JE', 'JEY', '832', '泽西岛', '+44', '🇯🇪', TRUE, 1110, 0, 0),
|
||||
('Jamaica', 'JM', 'JAM', '388', '牙买加', '+1', '🇯🇲', TRUE, 1120, 0, 0),
|
||||
('Jordan', 'JO', 'JOR', '400', '约旦', '+962', '🇯🇴', TRUE, 1130, 0, 0),
|
||||
('Japan', 'JP', 'JPN', '392', '日本', '+81', '🇯🇵', TRUE, 1140, 0, 0),
|
||||
('Kenya', 'KE', 'KEN', '404', '肯尼亚', '+254', '🇰🇪', TRUE, 1150, 0, 0),
|
||||
('Kyrgyzstan', 'KG', 'KGZ', '417', '吉尔吉斯斯坦', '+996', '🇰🇬', TRUE, 1160, 0, 0),
|
||||
('Cambodia', 'KH', 'KHM', '116', '柬埔寨', '+855', '🇰🇭', TRUE, 1170, 0, 0),
|
||||
('Kiribati', 'KI', 'KIR', '296', '基里巴斯', '+686', '🇰🇮', TRUE, 1180, 0, 0),
|
||||
('Comoros', 'KM', 'COM', '174', '科摩罗', '+269', '🇰🇲', TRUE, 1190, 0, 0),
|
||||
('Saint Kitts and Nevis', 'KN', 'KNA', '659', '圣基茨和尼维斯', '+1', '🇰🇳', TRUE, 1200, 0, 0),
|
||||
('North Korea', 'KP', 'PRK', '408', '朝鲜', '+850', '🇰🇵', TRUE, 1210, 0, 0),
|
||||
('South Korea', 'KR', 'KOR', '410', '韩国', '+82', '🇰🇷', TRUE, 1220, 0, 0),
|
||||
('Kuwait', 'KW', 'KWT', '414', '科威特', '+965', '🇰🇼', TRUE, 1230, 0, 0),
|
||||
('Cayman Islands', 'KY', 'CYM', '136', '开曼群岛', '+1', '🇰🇾', TRUE, 1240, 0, 0),
|
||||
('Kazakhstan', 'KZ', 'KAZ', '398', '哈萨克斯坦', '+7', '🇰🇿', TRUE, 1250, 0, 0),
|
||||
('Laos', 'LA', 'LAO', '418', '老挝', '+856', '🇱🇦', TRUE, 1260, 0, 0),
|
||||
('Lebanon', 'LB', 'LBN', '422', '黎巴嫩', '+961', '🇱🇧', TRUE, 1270, 0, 0),
|
||||
('Saint Lucia', 'LC', 'LCA', '662', '圣卢西亚', '+1', '🇱🇨', TRUE, 1280, 0, 0),
|
||||
('Liechtenstein', 'LI', 'LIE', '438', '列支敦士登', '+423', '🇱🇮', TRUE, 1290, 0, 0),
|
||||
('Sri Lanka', 'LK', 'LKA', '144', '斯里兰卡', '+94', '🇱🇰', TRUE, 1300, 0, 0),
|
||||
('Liberia', 'LR', 'LBR', '430', '利比里亚', '+231', '🇱🇷', TRUE, 1310, 0, 0),
|
||||
('Lesotho', 'LS', 'LSO', '426', '莱索托', '+266', '🇱🇸', TRUE, 1320, 0, 0),
|
||||
('Lithuania', 'LT', 'LTU', '440', '立陶宛', '+370', '🇱🇹', TRUE, 1330, 0, 0),
|
||||
('Luxembourg', 'LU', 'LUX', '442', '卢森堡', '+352', '🇱🇺', TRUE, 1340, 0, 0),
|
||||
('Latvia', 'LV', 'LVA', '428', '拉脱维亚', '+371', '🇱🇻', TRUE, 1350, 0, 0),
|
||||
('Libya', 'LY', 'LBY', '434', '利比亚', '+218', '🇱🇾', TRUE, 1360, 0, 0),
|
||||
('Morocco', 'MA', 'MAR', '504', '摩洛哥', '+212', '🇲🇦', TRUE, 1370, 0, 0),
|
||||
('Monaco', 'MC', 'MCO', '492', '摩纳哥', '+377', '🇲🇨', TRUE, 1380, 0, 0),
|
||||
('Moldova', 'MD', 'MDA', '498', '摩尔多瓦', '+373', '🇲🇩', TRUE, 1390, 0, 0),
|
||||
('Montenegro', 'ME', 'MNE', '499', '黑山', '+382', '🇲🇪', TRUE, 1400, 0, 0),
|
||||
('Saint Martin', 'MF', 'MAF', '663', '圣马丁', '+590', '🇲🇫', TRUE, 1410, 0, 0),
|
||||
('Madagascar', 'MG', 'MDG', '450', '马达加斯加', '+261', '🇲🇬', TRUE, 1420, 0, 0),
|
||||
('Marshall Islands', 'MH', 'MHL', '584', '马绍尔群岛', '+692', '🇲🇭', TRUE, 1430, 0, 0),
|
||||
('North Macedonia', 'MK', 'MKD', '807', '北馬其頓', '+389', '🇲🇰', TRUE, 1440, 0, 0),
|
||||
('Mali', 'ML', 'MLI', '466', '马里', '+223', '🇲🇱', TRUE, 1450, 0, 0),
|
||||
('Myanmar', 'MM', 'MMR', '104', '缅甸', '+95', '🇲🇲', TRUE, 1460, 0, 0),
|
||||
('Mongolia', 'MN', 'MNG', '496', '蒙古', '+976', '🇲🇳', TRUE, 1470, 0, 0),
|
||||
('Macao', 'MO', 'MAC', '446', '中国澳门', '+853', '🇲🇴', TRUE, 1480, 0, 0),
|
||||
('Northern Mariana Islands', 'MP', 'MNP', '580', '北马里亚纳群岛', '+1', '🇲🇵', TRUE, 1490, 0, 0),
|
||||
('Martinique', 'MQ', 'MTQ', '474', '马提尼克', '+596', '🇲🇶', TRUE, 1500, 0, 0),
|
||||
('Mauritania', 'MR', 'MRT', '478', '毛里塔尼亚', '+222', '🇲🇷', TRUE, 1510, 0, 0),
|
||||
('Montserrat', 'MS', 'MSR', '500', '蒙特塞拉特', '+1', '🇲🇸', TRUE, 1520, 0, 0),
|
||||
('Malta', 'MT', 'MLT', '470', '马耳他', '+356', '🇲🇹', TRUE, 1530, 0, 0),
|
||||
('Mauritius', 'MU', 'MUS', '480', '毛里求斯', '+230', '🇲🇺', TRUE, 1540, 0, 0),
|
||||
('Maldives', 'MV', 'MDV', '462', '马尔代夫', '+960', '🇲🇻', TRUE, 1550, 0, 0),
|
||||
('Malawi', 'MW', 'MWI', '454', '马拉维', '+265', '🇲🇼', TRUE, 1560, 0, 0),
|
||||
('Mexico', 'MX', 'MEX', '484', '墨西哥', '+52', '🇲🇽', TRUE, 1570, 0, 0),
|
||||
('Malaysia', 'MY', 'MYS', '458', '马来西亚', '+60', '🇲🇾', TRUE, 1580, 0, 0),
|
||||
('Mozambique', 'MZ', 'MOZ', '508', '莫桑比克', '+258', '🇲🇿', TRUE, 1590, 0, 0),
|
||||
('Namibia', 'NA', 'NAM', '516', '纳米比亚', '+264', '🇳🇦', TRUE, 1600, 0, 0),
|
||||
('New Caledonia', 'NC', 'NCL', '540', '新喀里多尼亚', '+687', '🇳🇨', TRUE, 1610, 0, 0),
|
||||
('Niger', 'NE', 'NER', '562', '尼日尔', '+227', '🇳🇪', TRUE, 1620, 0, 0),
|
||||
('Norfolk Island', 'NF', 'NFK', '574', '诺福克岛', '+672', '🇳🇫', TRUE, 1630, 0, 0),
|
||||
('Nigeria', 'NG', 'NGA', '566', '尼日利亚', '+234', '🇳🇬', TRUE, 1640, 0, 0),
|
||||
('Nicaragua', 'NI', 'NIC', '558', '尼加拉瓜', '+505', '🇳🇮', TRUE, 1650, 0, 0),
|
||||
('Netherlands', 'NL', 'NLD', '528', '荷兰', '+31', '🇳🇱', TRUE, 1660, 0, 0),
|
||||
('Norway', 'NO', 'NOR', '578', '挪威', '+47', '🇳🇴', TRUE, 1670, 0, 0),
|
||||
('Nepal', 'NP', 'NPL', '524', '尼泊尔', '+977', '🇳🇵', TRUE, 1680, 0, 0),
|
||||
('Nauru', 'NR', 'NRU', '520', '瑙鲁', '+674', '🇳🇷', TRUE, 1690, 0, 0),
|
||||
('Niue', 'NU', 'NIU', '570', '纽埃', '+683', '🇳🇺', TRUE, 1700, 0, 0),
|
||||
('New Zealand', 'NZ', 'NZL', '554', '新西兰', '+64', '🇳🇿', TRUE, 1710, 0, 0),
|
||||
('Oman', 'OM', 'OMN', '512', '阿曼', '+968', '🇴🇲', TRUE, 1720, 0, 0),
|
||||
('Panama', 'PA', 'PAN', '591', '巴拿马', '+507', '🇵🇦', TRUE, 1730, 0, 0),
|
||||
('Peru', 'PE', 'PER', '604', '秘鲁', '+51', '🇵🇪', TRUE, 1740, 0, 0),
|
||||
('French Polynesia', 'PF', 'PYF', '258', '法属波利尼西亚', '+689', '🇵🇫', TRUE, 1750, 0, 0),
|
||||
('Papua New Guinea', 'PG', 'PNG', '598', '巴布亚新几内亚', '+675', '🇵🇬', TRUE, 1760, 0, 0),
|
||||
('Philippines', 'PH', 'PHL', '608', '菲律宾', '+63', '🇵🇭', TRUE, 1770, 0, 0),
|
||||
('Pakistan', 'PK', 'PAK', '586', '巴基斯坦', '+92', '🇵🇰', TRUE, 1780, 0, 0),
|
||||
('Poland', 'PL', 'POL', '616', '波兰', '+48', '🇵🇱', TRUE, 1790, 0, 0),
|
||||
('Saint Pierre and Miquelon', 'PM', 'SPM', '666', '圣皮埃尔和密克隆', '+508', '🇵🇲', TRUE, 1800, 0, 0),
|
||||
('Pitcairn Islands', 'PN', 'PCN', '612', '皮特凯恩群岛', NULL, '🇵🇳', TRUE, 1810, 0, 0),
|
||||
('Puerto Rico', 'PR', 'PRI', '630', '波多黎各', '+1', '🇵🇷', TRUE, 1820, 0, 0),
|
||||
('Palestine', 'PS', 'PSE', '275', '巴勒斯坦', '+970', '🇵🇸', TRUE, 1830, 0, 0),
|
||||
('Portugal', 'PT', 'PRT', '620', '葡萄牙', '+351', '🇵🇹', TRUE, 1840, 0, 0),
|
||||
('Palau', 'PW', 'PLW', '585', '帕劳', '+680', '🇵🇼', TRUE, 1850, 0, 0),
|
||||
('Paraguay', 'PY', 'PRY', '600', '巴拉圭', '+595', '🇵🇾', TRUE, 1860, 0, 0),
|
||||
('Qatar', 'QA', 'QAT', '634', '卡塔尔', '+974', '🇶🇦', TRUE, 1870, 0, 0),
|
||||
('Réunion', 'RE', 'REU', '638', '留尼旺岛', '+262', '🇷🇪', TRUE, 1880, 0, 0),
|
||||
('Romania', 'RO', 'ROU', '642', '罗马尼亚', '+40', '🇷🇴', TRUE, 1890, 0, 0),
|
||||
('Serbia', 'RS', 'SRB', '688', '塞尔维亚', '+381', '🇷🇸', TRUE, 1900, 0, 0),
|
||||
('Russia', 'RU', 'RUS', '643', '俄罗斯', '+7', '🇷🇺', TRUE, 1910, 0, 0),
|
||||
('Rwanda', 'RW', 'RWA', '646', '卢旺达', '+250', '🇷🇼', TRUE, 1920, 0, 0),
|
||||
('Saudi Arabia', 'SA', 'SAU', '682', '沙特阿拉伯', '+966', '🇸🇦', TRUE, 1930, 0, 0),
|
||||
('Solomon Islands', 'SB', 'SLB', '090', '所罗门群岛', '+677', '🇸🇧', TRUE, 1940, 0, 0),
|
||||
('Seychelles', 'SC', 'SYC', '690', '塞舌尔', '+248', '🇸🇨', TRUE, 1950, 0, 0),
|
||||
('Sudan', 'SD', 'SDN', '729', '苏丹', '+249', '🇸🇩', TRUE, 1960, 0, 0),
|
||||
('Sweden', 'SE', 'SWE', '752', '瑞典', '+46', '🇸🇪', TRUE, 1970, 0, 0),
|
||||
('Singapore', 'SG', 'SGP', '702', '新加坡', '+65', '🇸🇬', TRUE, 1980, 0, 0),
|
||||
('Saint Helena, Ascension and Tristan da Cunha', 'SH', 'SHN', '654', '圣赫勒拿、阿森松和特里斯坦-达库尼亚', '+290', '🇸🇭', TRUE, 1990, 0, 0),
|
||||
('Slovenia', 'SI', 'SVN', '705', '斯洛文尼亚', '+386', '🇸🇮', TRUE, 2000, 0, 0),
|
||||
('Svalbard and Jan Mayen', 'SJ', 'SJM', '744', '斯瓦尔巴特', '+47', '🇸🇯', TRUE, 2010, 0, 0),
|
||||
('Slovakia', 'SK', 'SVK', '703', '斯洛伐克', '+421', '🇸🇰', TRUE, 2020, 0, 0),
|
||||
('Sierra Leone', 'SL', 'SLE', '694', '塞拉利昂', '+232', '🇸🇱', TRUE, 2030, 0, 0),
|
||||
('San Marino', 'SM', 'SMR', '674', '圣马力诺', '+378', '🇸🇲', TRUE, 2040, 0, 0),
|
||||
('Senegal', 'SN', 'SEN', '686', '塞内加尔', '+221', '🇸🇳', TRUE, 2050, 0, 0),
|
||||
('Somalia', 'SO', 'SOM', '706', '索马里', '+252', '🇸🇴', TRUE, 2060, 0, 0),
|
||||
('Suriname', 'SR', 'SUR', '740', '苏里南', '+597', '🇸🇷', TRUE, 2070, 0, 0),
|
||||
('South Sudan', 'SS', 'SSD', '728', '南苏丹', '+211', '🇸🇸', TRUE, 2080, 0, 0),
|
||||
('São Tomé and Príncipe', 'ST', 'STP', '678', '圣多美和普林西比', '+239', '🇸🇹', TRUE, 2090, 0, 0),
|
||||
('El Salvador', 'SV', 'SLV', '222', '萨尔瓦多', '+503', '🇸🇻', TRUE, 2100, 0, 0),
|
||||
('Sint Maarten', 'SX', 'SXM', '534', '圣马丁岛', '+1', '🇸🇽', TRUE, 2110, 0, 0),
|
||||
('Syria', 'SY', 'SYR', '760', '叙利亚', '+963', '🇸🇾', TRUE, 2120, 0, 0),
|
||||
('Eswatini', 'SZ', 'SWZ', '748', '斯威士兰', '+268', '🇸🇿', TRUE, 2130, 0, 0),
|
||||
('Turks and Caicos Islands', 'TC', 'TCA', '796', '特克斯和凯科斯群岛', '+1', '🇹🇨', TRUE, 2140, 0, 0),
|
||||
('Chad', 'TD', 'TCD', '148', '乍得', '+235', '🇹🇩', TRUE, 2150, 0, 0),
|
||||
('French Southern and Antarctic Lands', 'TF', 'ATF', '260', '法国南部和南极土地', NULL, '🇹🇫', TRUE, 2160, 0, 0),
|
||||
('Togo', 'TG', 'TGO', '768', '多哥', '+228', '🇹🇬', TRUE, 2170, 0, 0),
|
||||
('Thailand', 'TH', 'THA', '764', '泰国', '+66', '🇹🇭', TRUE, 2180, 0, 0),
|
||||
('Tajikistan', 'TJ', 'TJK', '762', '塔吉克斯坦', '+992', '🇹🇯', TRUE, 2190, 0, 0),
|
||||
('Tokelau', 'TK', 'TKL', '772', '托克劳', '+690', '🇹🇰', TRUE, 2200, 0, 0),
|
||||
('Timor-Leste', 'TL', 'TLS', '626', '东帝汶', '+670', '🇹🇱', TRUE, 2210, 0, 0),
|
||||
('Turkmenistan', 'TM', 'TKM', '795', '土库曼斯坦', '+993', '🇹🇲', TRUE, 2220, 0, 0),
|
||||
('Tunisia', 'TN', 'TUN', '788', '突尼斯', '+216', '🇹🇳', TRUE, 2230, 0, 0),
|
||||
('Tonga', 'TO', 'TON', '776', '汤加', '+676', '🇹🇴', TRUE, 2240, 0, 0),
|
||||
('Türkiye', 'TR', 'TUR', '792', '土耳其', '+90', '🇹🇷', TRUE, 2250, 0, 0),
|
||||
('Trinidad and Tobago', 'TT', 'TTO', '780', '特立尼达和多巴哥', '+1', '🇹🇹', TRUE, 2260, 0, 0),
|
||||
('Tuvalu', 'TV', 'TUV', '798', '图瓦卢', '+688', '🇹🇻', TRUE, 2270, 0, 0),
|
||||
('Taiwan', 'TW', 'TWN', '158', '中国台湾', '+886', '🇹🇼', TRUE, 2280, 0, 0),
|
||||
('Tanzania', 'TZ', 'TZA', '834', '坦桑尼亚', '+255', '🇹🇿', TRUE, 2290, 0, 0),
|
||||
('Ukraine', 'UA', 'UKR', '804', '乌克兰', '+380', '🇺🇦', TRUE, 2300, 0, 0),
|
||||
('Uganda', 'UG', 'UGA', '800', '乌干达', '+256', '🇺🇬', TRUE, 2310, 0, 0),
|
||||
('United States Minor Outlying Islands', 'UM', 'UMI', '581', '美国本土外小岛屿', NULL, '🇺🇲', TRUE, 2320, 0, 0),
|
||||
('United States', 'US', 'USA', '840', '美国', '+1', '🇺🇸', TRUE, 2330, 0, 0),
|
||||
('Uruguay', 'UY', 'URY', '858', '乌拉圭', '+598', '🇺🇾', TRUE, 2340, 0, 0),
|
||||
('Uzbekistan', 'UZ', 'UZB', '860', '乌兹别克斯坦', '+998', '🇺🇿', TRUE, 2350, 0, 0),
|
||||
('Vatican City', 'VA', 'VAT', '336', '梵蒂冈', '+39', '🇻🇦', TRUE, 2360, 0, 0),
|
||||
('Saint Vincent and the Grenadines', 'VC', 'VCT', '670', '圣文森特和格林纳丁斯', '+1', '🇻🇨', TRUE, 2370, 0, 0),
|
||||
('Venezuela', 'VE', 'VEN', '862', '委内瑞拉', '+58', '🇻🇪', TRUE, 2380, 0, 0),
|
||||
('British Virgin Islands', 'VG', 'VGB', '092', '英属维尔京群岛', '+1', '🇻🇬', TRUE, 2390, 0, 0),
|
||||
('United States Virgin Islands', 'VI', 'VIR', '850', '美属维尔京群岛', '+1', '🇻🇮', TRUE, 2400, 0, 0),
|
||||
('Vietnam', 'VN', 'VNM', '704', '越南', '+84', '🇻🇳', TRUE, 2410, 0, 0),
|
||||
('Vanuatu', 'VU', 'VUT', '548', '瓦努阿图', '+678', '🇻🇺', TRUE, 2420, 0, 0),
|
||||
('Wallis and Futuna', 'WF', 'WLF', '876', '瓦利斯和富图纳群岛', '+681', '🇼🇫', TRUE, 2430, 0, 0),
|
||||
('Samoa', 'WS', 'WSM', '882', '萨摩亚', '+685', '🇼🇸', TRUE, 2440, 0, 0),
|
||||
('Kosovo', 'XK', 'UNK', NULL, '科索沃', '+383', '🇽🇰', TRUE, 2450, 0, 0),
|
||||
('Yemen', 'YE', 'YEM', '887', '也门', '+967', '🇾🇪', TRUE, 2460, 0, 0),
|
||||
('Mayotte', 'YT', 'MYT', '175', '马约特', '+262', '🇾🇹', TRUE, 2470, 0, 0),
|
||||
('South Africa', 'ZA', 'ZAF', '710', '南非', '+27', '🇿🇦', TRUE, 2480, 0, 0),
|
||||
('Zambia', 'ZM', 'ZMB', '894', '赞比亚', '+260', '🇿🇲', TRUE, 2490, 0, 0),
|
||||
('Zimbabwe', 'ZW', 'ZWE', '716', '津巴布韦', '+263', '🇿🇼', TRUE, 2500, 0, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
country_name = VALUES(country_name),
|
||||
iso_alpha3 = VALUES(iso_alpha3),
|
||||
iso_numeric = VALUES(iso_numeric),
|
||||
country_display_name = VALUES(country_display_name),
|
||||
phone_country_code = VALUES(phone_country_code),
|
||||
flag = VALUES(flag),
|
||||
enabled = VALUES(enabled),
|
||||
sort_order = VALUES(sort_order),
|
||||
updated_at_ms = VALUES(updated_at_ms);
|
||||
|
||||
INSERT INTO regions (region_code, name, status, sort_order, created_at_ms, updated_at_ms) VALUES
|
||||
('Southern Europe', '南欧', 'active', 10, 0, 0),
|
||||
('Western Asia', '西亚', 'active', 20, 0, 0),
|
||||
('Southern Asia', '南亚', 'active', 30, 0, 0),
|
||||
('Caribbean', '加勒比地区', 'active', 40, 0, 0),
|
||||
('Southeast Europe', '东南欧', 'active', 50, 0, 0),
|
||||
('Middle Africa', '中非', 'active', 60, 0, 0),
|
||||
('UNSPECIFIED', 'UNSPECIFIED', 'active', 70, 0, 0),
|
||||
('South America', '南美洲', 'active', 80, 0, 0),
|
||||
('Polynesia', '波利尼西亚', 'active', 90, 0, 0),
|
||||
('Central Europe', '中欧', 'active', 100, 0, 0),
|
||||
('Australia and New Zealand', '澳大利亚和新西兰', 'active', 110, 0, 0),
|
||||
('Northern Europe', '北欧', 'active', 120, 0, 0),
|
||||
('Western Europe', '西欧', 'active', 130, 0, 0),
|
||||
('Western Africa', '西非', 'active', 140, 0, 0),
|
||||
('Eastern Africa', '东非', 'active', 150, 0, 0),
|
||||
('North America', '北美洲', 'active', 160, 0, 0),
|
||||
('South-Eastern Asia', '东南亚', 'active', 170, 0, 0),
|
||||
('Southern Africa', '南部非洲', 'active', 180, 0, 0),
|
||||
('Eastern Europe', '东欧', 'active', 190, 0, 0),
|
||||
('Central America', '中美洲', 'active', 200, 0, 0),
|
||||
('Eastern Asia', '东亚', 'active', 210, 0, 0),
|
||||
('Northern Africa', '北非', 'active', 220, 0, 0),
|
||||
('Melanesia', '美拉尼西亚', 'active', 230, 0, 0),
|
||||
('Micronesia', '密克罗尼西亚', 'active', 240, 0, 0),
|
||||
('Central Asia', '中亚', 'active', 250, 0, 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
status = VALUES(status),
|
||||
sort_order = VALUES(sort_order),
|
||||
updated_at_ms = VALUES(updated_at_ms);
|
||||
|
||||
INSERT INTO region_countries (region_id, country_code, status, created_at_ms, updated_at_ms)
|
||||
SELECT r.region_id, seed.country_code, 'active', 0, 0
|
||||
FROM (
|
||||
SELECT 'AD' AS country_code, 'Southern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AE' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AF' AS country_code, 'Southern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AG' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AI' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AL' AS country_code, 'Southeast Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AM' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AO' AS country_code, 'Middle Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AQ' AS country_code, 'UNSPECIFIED' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AR' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AS' AS country_code, 'Polynesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AT' AS country_code, 'Central Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AU' AS country_code, 'Australia and New Zealand' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AW' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AX' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'AZ' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BA' AS country_code, 'Southeast Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BB' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BD' AS country_code, 'Southern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BE' AS country_code, 'Western Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BF' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BG' AS country_code, 'Southeast Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BH' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BI' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BJ' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BL' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BM' AS country_code, 'North America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BN' AS country_code, 'South-Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BO' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BQ' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BR' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BS' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BT' AS country_code, 'Southern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BV' AS country_code, 'UNSPECIFIED' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BW' AS country_code, 'Southern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BY' AS country_code, 'Eastern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'BZ' AS country_code, 'Central America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CA' AS country_code, 'North America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CC' AS country_code, 'Australia and New Zealand' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CD' AS country_code, 'Middle Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CF' AS country_code, 'Middle Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CG' AS country_code, 'Middle Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CH' AS country_code, 'Western Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CI' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CK' AS country_code, 'Polynesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CL' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CM' AS country_code, 'Middle Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CN' AS country_code, 'Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CO' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CR' AS country_code, 'Central America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CU' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CV' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CW' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CX' AS country_code, 'Australia and New Zealand' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CY' AS country_code, 'Southern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'CZ' AS country_code, 'Central Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'DE' AS country_code, 'Western Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'DJ' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'DK' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'DM' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'DO' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'DZ' AS country_code, 'Northern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'EC' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'EE' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'EG' AS country_code, 'Northern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'EH' AS country_code, 'Northern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'ER' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'ES' AS country_code, 'Southern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'ET' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'FI' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'FJ' AS country_code, 'Melanesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'FK' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'FM' AS country_code, 'Micronesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'FO' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'FR' AS country_code, 'Western Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GA' AS country_code, 'Middle Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GB' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GD' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GE' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GF' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GG' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GH' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GI' AS country_code, 'Southern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GL' AS country_code, 'North America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GM' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GN' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GP' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GQ' AS country_code, 'Middle Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GR' AS country_code, 'Southern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GS' AS country_code, 'UNSPECIFIED' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GT' AS country_code, 'Central America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GU' AS country_code, 'Micronesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GW' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'GY' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'HK' AS country_code, 'Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'HM' AS country_code, 'UNSPECIFIED' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'HN' AS country_code, 'Central America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'HR' AS country_code, 'Southeast Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'HT' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'HU' AS country_code, 'Central Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'ID' AS country_code, 'South-Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'IE' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'IL' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'IM' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'IN' AS country_code, 'Southern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'IO' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'IQ' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'IR' AS country_code, 'Southern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'IS' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'IT' AS country_code, 'Southern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'JE' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'JM' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'JO' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'JP' AS country_code, 'Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'KE' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'KG' AS country_code, 'Central Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'KH' AS country_code, 'South-Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'KI' AS country_code, 'Micronesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'KM' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'KN' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'KP' AS country_code, 'Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'KR' AS country_code, 'Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'KW' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'KY' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'KZ' AS country_code, 'Central Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'LA' AS country_code, 'South-Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'LB' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'LC' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'LI' AS country_code, 'Western Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'LK' AS country_code, 'Southern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'LR' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'LS' AS country_code, 'Southern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'LT' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'LU' AS country_code, 'Western Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'LV' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'LY' AS country_code, 'Northern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MA' AS country_code, 'Northern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MC' AS country_code, 'Western Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MD' AS country_code, 'Eastern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'ME' AS country_code, 'Southeast Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MF' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MG' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MH' AS country_code, 'Micronesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MK' AS country_code, 'Southeast Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'ML' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MM' AS country_code, 'South-Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MN' AS country_code, 'Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MO' AS country_code, 'Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MP' AS country_code, 'Micronesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MQ' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MR' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MS' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MT' AS country_code, 'Southern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MU' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MV' AS country_code, 'Southern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MW' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MX' AS country_code, 'North America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MY' AS country_code, 'South-Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'MZ' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NA' AS country_code, 'Southern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NC' AS country_code, 'Melanesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NE' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NF' AS country_code, 'Australia and New Zealand' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NG' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NI' AS country_code, 'Central America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NL' AS country_code, 'Western Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NO' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NP' AS country_code, 'Southern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NR' AS country_code, 'Micronesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NU' AS country_code, 'Polynesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'NZ' AS country_code, 'Australia and New Zealand' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'OM' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PA' AS country_code, 'Central America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PE' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PF' AS country_code, 'Polynesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PG' AS country_code, 'Melanesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PH' AS country_code, 'South-Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PK' AS country_code, 'Southern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PL' AS country_code, 'Central Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PM' AS country_code, 'North America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PN' AS country_code, 'Polynesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PR' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PS' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PT' AS country_code, 'Southern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PW' AS country_code, 'Micronesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'PY' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'QA' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'RE' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'RO' AS country_code, 'Southeast Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'RS' AS country_code, 'Southeast Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'RU' AS country_code, 'Eastern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'RW' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SA' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SB' AS country_code, 'Melanesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SC' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SD' AS country_code, 'Northern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SE' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SG' AS country_code, 'South-Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SH' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SI' AS country_code, 'Central Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SJ' AS country_code, 'Northern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SK' AS country_code, 'Central Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SL' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SM' AS country_code, 'Southern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SN' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SO' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SR' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SS' AS country_code, 'Middle Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'ST' AS country_code, 'Middle Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SV' AS country_code, 'Central America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SX' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SY' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'SZ' AS country_code, 'Southern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TC' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TD' AS country_code, 'Middle Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TF' AS country_code, 'UNSPECIFIED' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TG' AS country_code, 'Western Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TH' AS country_code, 'South-Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TJ' AS country_code, 'Central Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TK' AS country_code, 'Polynesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TL' AS country_code, 'South-Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TM' AS country_code, 'Central Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TN' AS country_code, 'Northern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TO' AS country_code, 'Polynesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TR' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TT' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TV' AS country_code, 'Polynesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TW' AS country_code, 'Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'TZ' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'UA' AS country_code, 'Eastern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'UG' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'UM' AS country_code, 'North America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'US' AS country_code, 'North America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'UY' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'UZ' AS country_code, 'Central Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'VA' AS country_code, 'Southern Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'VC' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'VE' AS country_code, 'South America' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'VG' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'VI' AS country_code, 'Caribbean' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'VN' AS country_code, 'South-Eastern Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'VU' AS country_code, 'Melanesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'WF' AS country_code, 'Polynesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'WS' AS country_code, 'Polynesia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'XK' AS country_code, 'Southeast Europe' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'YE' AS country_code, 'Western Asia' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'YT' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'ZA' AS country_code, 'Southern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'ZM' AS country_code, 'Eastern Africa' AS region_code
|
||||
UNION ALL
|
||||
SELECT 'ZW' AS country_code, 'Eastern Africa' AS region_code
|
||||
) AS seed
|
||||
INNER JOIN regions r ON r.region_code = seed.region_code
|
||||
INNER JOIN countries c ON c.country_code = seed.country_code
|
||||
ON DUPLICATE KEY UPDATE
|
||||
region_id = VALUES(region_id),
|
||||
status = VALUES(status),
|
||||
updated_at_ms = VALUES(updated_at_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_display_user_ids (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
userv1 "hyapp/api/proto/user/v1"
|
||||
"hyapp/pkg/grpchealth"
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/services/user-service/internal/config"
|
||||
authservice "hyapp/services/user-service/internal/service/auth"
|
||||
userservice "hyapp/services/user-service/internal/service/user"
|
||||
@ -65,7 +66,15 @@ func New(cfg config.Config) (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server := grpc.NewServer()
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("user-service")))
|
||||
thirdPartyVerifier := authservice.NewRoutedThirdPartyVerifier(
|
||||
authservice.NewFirebaseThirdPartyVerifier(authservice.FirebaseVerifierConfig{
|
||||
ProjectID: cfg.ThirdParty.Firebase.ProjectID,
|
||||
AllowedSignInProviders: cfg.ThirdParty.Firebase.AllowedSignInProviders,
|
||||
CertsURL: cfg.ThirdParty.Firebase.CertsURL,
|
||||
}),
|
||||
authservice.NewStaticThirdPartyVerifier(cfg.ThirdParty.AllowedProviders),
|
||||
)
|
||||
// auth service 负责登录、session 和 token;用户主数据和短号事务仍通过 repository 完成。
|
||||
authSvc := authservice.New(authservice.Config{
|
||||
Issuer: cfg.JWT.Issuer,
|
||||
@ -80,7 +89,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
authservice.WithCountryRegionRepository(mysqlRepo),
|
||||
authservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)),
|
||||
authservice.WithDisplayUserIDAllocateMaxAttempts(cfg.DisplayUserID.AllocateMaxAttempts),
|
||||
authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier(cfg.ThirdParty.AllowedProviders)),
|
||||
authservice.WithThirdPartyVerifier(thirdPartyVerifier),
|
||||
)
|
||||
// user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。
|
||||
userSvc := userservice.New(mysqlRepo,
|
||||
@ -98,6 +107,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
userv1.RegisterUserServiceServer(server, userServer)
|
||||
userv1.RegisterUserIdentityServiceServer(server, userServer)
|
||||
userv1.RegisterCountryAdminServiceServer(server, userServer)
|
||||
userv1.RegisterCountryQueryServiceServer(server, userServer)
|
||||
userv1.RegisterRegionAdminServiceServer(server, userServer)
|
||||
|
||||
health := grpchealth.NewServingChecker("user-service")
|
||||
|
||||
@ -41,10 +41,22 @@ type DisplayUserIDConfig struct {
|
||||
ChangeCooldownSec int64 `yaml:"change_cooldown_sec"`
|
||||
}
|
||||
|
||||
// ThirdPartyConfig 保存三方登录 provider allowlist。
|
||||
// ThirdPartyConfig 保存三方登录 provider allowlist 和真实 verifier 配置。
|
||||
type ThirdPartyConfig struct {
|
||||
// AllowedProviders 是本地 verifier 接受的 provider 列表。
|
||||
// AllowedProviders 是 provider allowlist;firebase 会被强制路由到 Firebase verifier。
|
||||
AllowedProviders []string `yaml:"allowed_providers"`
|
||||
// Firebase 保存 Firebase Auth ID token verifier 配置。
|
||||
Firebase FirebaseConfig `yaml:"firebase"`
|
||||
}
|
||||
|
||||
// FirebaseConfig 保存 Firebase Auth ID token verifier 配置。
|
||||
type FirebaseConfig struct {
|
||||
// ProjectID 必须匹配 Firebase token 的 aud 和 issuer。
|
||||
ProjectID string `yaml:"project_id"`
|
||||
// AllowedSignInProviders 限制 firebase.sign_in_provider,首版默认 google.com。
|
||||
AllowedSignInProviders []string `yaml:"allowed_sign_in_providers"`
|
||||
// CertsURL 只用于测试或私有代理;生产为空时使用 Firebase 官方公钥端点。
|
||||
CertsURL string `yaml:"certs_url"`
|
||||
}
|
||||
|
||||
// RegionRebuildWorkerConfig 保存区域映射变更后历史用户快照重算策略。
|
||||
@ -80,7 +92,7 @@ func Default() Config {
|
||||
ServiceName: "user-service",
|
||||
NodeID: "user-local",
|
||||
GRPCAddr: ":13005",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_user?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_user?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLAutoMigrate: false,
|
||||
IDGenerator: IDGeneratorConfig{
|
||||
NodeID: 1,
|
||||
@ -90,7 +102,10 @@ func Default() Config {
|
||||
ChangeCooldownSec: 2592000,
|
||||
},
|
||||
ThirdParty: ThirdPartyConfig{
|
||||
AllowedProviders: []string{"wechat"},
|
||||
AllowedProviders: []string{"firebase"},
|
||||
Firebase: FirebaseConfig{
|
||||
AllowedSignInProviders: []string{"google.com"},
|
||||
},
|
||||
},
|
||||
RegionRebuildWorker: RegionRebuildWorkerConfig{
|
||||
Enabled: true,
|
||||
|
||||
@ -31,6 +31,10 @@ type Token struct {
|
||||
DisplayUserIDKind string
|
||||
// DisplayUserIDExpiresAtMs 是当前靓号过期时间,默认短号为 0。
|
||||
DisplayUserIDExpiresAtMs int64
|
||||
// ProfileCompleted 是 access token 中用于 gateway profile gate 的资料完成快照。
|
||||
ProfileCompleted bool
|
||||
// OnboardingStatus 是 access token 中用于客户端跳转的注册状态。
|
||||
OnboardingStatus string
|
||||
// SessionID 是服务端 refresh session 标识。
|
||||
SessionID string
|
||||
// AccessToken 是返回给 gateway/client 的 JWT。
|
||||
|
||||
@ -23,8 +23,13 @@ func ProfileStringTooLong(value string, maxRunes int) bool {
|
||||
|
||||
// ValidProfileBirthDate 校验生日只使用 yyyy-mm-dd,避免客户端时区解析造成日期偏移。
|
||||
func ValidProfileBirthDate(value string) bool {
|
||||
_, err := time.Parse(ProfileBirthDateLayout, value)
|
||||
return err == nil
|
||||
parsed, err := time.Parse(ProfileBirthDateLayout, value)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// MySQL DATE 在严格模式下不能写入 Go 零值年份;注册入口必须先拦截,避免写库阶段退化为 INTERNAL_ERROR。
|
||||
return parsed.Year() >= 1000
|
||||
}
|
||||
|
||||
// ValidProfileAvatarURL 限制头像为绝对 HTTP(S) URL,防止展示资料写入不可渲染或危险 scheme。
|
||||
|
||||
@ -6,11 +6,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// CountryStatusActive 表示国家可以被注册、改国家和区域配置选中。
|
||||
CountryStatusActive = "active"
|
||||
// CountryStatusDisabled 表示国家只保留历史展示,不再接受新写入。
|
||||
CountryStatusDisabled = "disabled"
|
||||
|
||||
// RegionStatusActive 表示区域映射参与国家到区域的实时解析。
|
||||
RegionStatusActive = "active"
|
||||
// RegionStatusDisabled 表示区域停止解析并释放 active 国家归属。
|
||||
@ -29,17 +24,25 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
countryCodePattern = regexp.MustCompile(`^[A-Z]{2,3}$`)
|
||||
regionCodePattern = regexp.MustCompile(`^[A-Z0-9_]{2,32}$`)
|
||||
countryCodePattern = regexp.MustCompile(`^[A-Z]{2,3}$`)
|
||||
isoAlpha3Pattern = regexp.MustCompile(`^[A-Z]{3}$`)
|
||||
isoNumericPattern = regexp.MustCompile(`^[0-9]{3}$`)
|
||||
phoneCountryCodePattern = regexp.MustCompile(`^\+[1-9][0-9]{0,2}$`)
|
||||
shortRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9_]{2,32}$`)
|
||||
descriptiveRegionCodePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9 _-]{1,63}$`)
|
||||
)
|
||||
|
||||
// Country 是国家主数据;外部只提交 CountryCode,CountryID 只用于管理和审计。
|
||||
// Country 是国家主数据;enabled 是唯一可用性开关,外部只提交 CountryCode。
|
||||
type Country struct {
|
||||
CountryID int64
|
||||
CountryName string
|
||||
CountryCode string
|
||||
ISOAlpha3 string
|
||||
ISONumeric string
|
||||
CountryDisplayName string
|
||||
Status string
|
||||
PhoneCountryCode string
|
||||
Flag string
|
||||
Enabled bool
|
||||
SortOrder int32
|
||||
CreatedByUserID int64
|
||||
UpdatedByUserID int64
|
||||
@ -109,9 +112,9 @@ type RegionRebuildProcessResult struct {
|
||||
NoTask bool
|
||||
}
|
||||
|
||||
// CountryFilter 描述国家管理列表过滤条件。
|
||||
// CountryFilter 描述国家管理列表过滤条件;国家只支持 enabled 维度过滤。
|
||||
type CountryFilter struct {
|
||||
Status string
|
||||
Enabled *bool
|
||||
}
|
||||
|
||||
// RegionFilter 描述区域管理列表过滤条件。
|
||||
@ -123,7 +126,12 @@ type RegionFilter struct {
|
||||
type CreateCountryCommand struct {
|
||||
CountryName string
|
||||
CountryCode string
|
||||
ISOAlpha3 string
|
||||
ISONumeric string
|
||||
CountryDisplayName string
|
||||
PhoneCountryCode string
|
||||
Flag string
|
||||
Enabled bool
|
||||
SortOrder int32
|
||||
OperatorUserID int64
|
||||
RequestID string
|
||||
@ -134,14 +142,18 @@ type CreateCountryCommand struct {
|
||||
type UpdateCountryCommand struct {
|
||||
CountryID int64
|
||||
CountryName string
|
||||
ISOAlpha3 string
|
||||
ISONumeric string
|
||||
CountryDisplayName string
|
||||
PhoneCountryCode string
|
||||
Flag string
|
||||
SortOrder int32
|
||||
OperatorUserID int64
|
||||
RequestID string
|
||||
NowMs int64
|
||||
}
|
||||
|
||||
// DisableCountryCommand 描述停用国家主数据的事务输入。
|
||||
// DisableCountryCommand 描述关闭 App 国家选择入口的事务输入。
|
||||
type DisableCountryCommand struct {
|
||||
CountryID int64
|
||||
OperatorUserID int64
|
||||
@ -149,6 +161,14 @@ type DisableCountryCommand struct {
|
||||
NowMs int64
|
||||
}
|
||||
|
||||
// EnableCountryCommand 描述开放国家给 App 注册页和用户改国家链路的事务输入。
|
||||
type EnableCountryCommand struct {
|
||||
CountryID int64
|
||||
OperatorUserID int64
|
||||
RequestID string
|
||||
NowMs int64
|
||||
}
|
||||
|
||||
// CreateRegionCommand 描述创建区域及其 active 国家列表的事务输入。
|
||||
type CreateRegionCommand struct {
|
||||
RegionCode string
|
||||
@ -198,12 +218,48 @@ func ValidCountryCode(code string) bool {
|
||||
return countryCodePattern.MatchString(code)
|
||||
}
|
||||
|
||||
// NormalizeISOAlpha3 统一 ISO alpha-3 辅助展示字段形态;空值表示未配置。
|
||||
func NormalizeISOAlpha3(code string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
// ValidISOAlpha3 校验非空 ISO alpha-3 或明确 user-assigned 三字码。
|
||||
func ValidISOAlpha3(code string) bool {
|
||||
return code == "" || isoAlpha3Pattern.MatchString(code)
|
||||
}
|
||||
|
||||
// NormalizeISONumeric 统一 ISO numeric/M49 字符串形态,不能转整数以免破坏前导零。
|
||||
func NormalizeISONumeric(code string) string {
|
||||
return strings.TrimSpace(code)
|
||||
}
|
||||
|
||||
// ValidISONumeric 校验非空 numeric 必须是三位数字字符串。
|
||||
func ValidISONumeric(code string) bool {
|
||||
return code == "" || isoNumericPattern.MatchString(code)
|
||||
}
|
||||
|
||||
// NormalizePhoneCountryCode 统一 E.164 国家呼叫码字段,空值表示无可用电话元数据。
|
||||
func NormalizePhoneCountryCode(code string) string {
|
||||
return strings.TrimSpace(code)
|
||||
}
|
||||
|
||||
// ValidPhoneCountryCode 校验国家呼叫码,不做唯一性假设,因为多个国家可以共享 +1。
|
||||
func ValidPhoneCountryCode(code string) bool {
|
||||
return code == "" || phoneCountryCodePattern.MatchString(code)
|
||||
}
|
||||
|
||||
// NormalizeRegionCode 统一区域业务编码形态。
|
||||
func NormalizeRegionCode(code string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(code))
|
||||
code = strings.TrimSpace(code)
|
||||
if shortRegionCodePattern.MatchString(code) {
|
||||
// 手工业务短码继续统一大写;导入的 subregion 描述码保留原始大小写和空格。
|
||||
return strings.ToUpper(code)
|
||||
}
|
||||
|
||||
return strings.Join(strings.Fields(code), " ")
|
||||
}
|
||||
|
||||
// ValidRegionCode 校验区域编码,避免自由文本进入唯一键和审计口径。
|
||||
func ValidRegionCode(code string) bool {
|
||||
return regionCodePattern.MatchString(code)
|
||||
return shortRegionCodePattern.MatchString(code) || descriptiveRegionCodePattern.MatchString(code)
|
||||
}
|
||||
|
||||
@ -15,6 +15,16 @@ const (
|
||||
StatusBanned Status = "banned"
|
||||
)
|
||||
|
||||
// OnboardingStatus 表达用户是否完成 App 最小注册资料。
|
||||
type OnboardingStatus string
|
||||
|
||||
const (
|
||||
// OnboardingStatusProfileRequired 表示用户已创建但还不能进入房间、IM、RTC 或付费链路。
|
||||
OnboardingStatusProfileRequired OnboardingStatus = "profile_required"
|
||||
// OnboardingStatusCompleted 表示用户名、头像、国家和区域快照已经在注册页原子固化。
|
||||
OnboardingStatusCompleted OnboardingStatus = "completed"
|
||||
)
|
||||
|
||||
// DisplayUserIDStatus 表达短号历史记录状态。
|
||||
type DisplayUserIDStatus string
|
||||
|
||||
@ -87,6 +97,12 @@ type User struct {
|
||||
CountryName string
|
||||
// CountryDisplayName 是按 Country 拼出的中文展示名。
|
||||
CountryDisplayName string
|
||||
// ISONumeric 是按 Country 拼出的三位 numeric/M49 字符串,客户端不能当数字解析。
|
||||
ISONumeric string
|
||||
// PhoneCountryCode 是按 Country 拼出的 E.164 国家呼叫码,可能为空或被多个国家共享。
|
||||
PhoneCountryCode string
|
||||
// CountryEnabled 表示当前国家是否仍对 App 注册和改国家开放,历史用户可能为 false。
|
||||
CountryEnabled bool
|
||||
// RegionCode 是按 RegionID 拼出的区域业务编码。
|
||||
RegionCode string
|
||||
// RegionName 是按 RegionID 拼出的区域展示名。
|
||||
@ -125,6 +141,12 @@ type User struct {
|
||||
Language string
|
||||
// Timezone 是客户端 IANA 时区,例如 America/Los_Angeles。
|
||||
Timezone string
|
||||
// ProfileCompleted 表示用户是否已经完成 App 最小注册资料。
|
||||
ProfileCompleted bool
|
||||
// ProfileCompletedAtMs 是资料完成时间;0 表示从未完成。
|
||||
ProfileCompletedAtMs int64
|
||||
// OnboardingStatus 是客户端准入状态,首版只使用 profile_required/completed。
|
||||
OnboardingStatus OnboardingStatus
|
||||
// Status 控制登录和业务可用性。
|
||||
Status Status
|
||||
// CreatedAtMs 是用户创建时间。
|
||||
@ -181,6 +203,22 @@ type ProfileUpdateCommand struct {
|
||||
UpdatedAtMs int64
|
||||
}
|
||||
|
||||
// CompleteOnboardingCommand 描述注册页一次性补齐资料的事务输入。
|
||||
type CompleteOnboardingCommand struct {
|
||||
// UserID 是已三方登录创建出的系统用户。
|
||||
UserID int64
|
||||
// Username 是注册页最终昵称,必填且已校验长度。
|
||||
Username string
|
||||
// Avatar 是注册页最终头像 URL,必填且已校验为 HTTP(S) 绝对地址。
|
||||
Avatar string
|
||||
// Country 是 active countries.country_code 中的规范国家码。
|
||||
Country string
|
||||
// RegionID 是按 Country 当前 active 映射解析出的区域快照,0 表示进入 GLOBAL。
|
||||
RegionID int64
|
||||
// CompletedAtMs 是资料完成时间,同时作为 updated_at_ms。
|
||||
CompletedAtMs int64
|
||||
}
|
||||
|
||||
// CountryChangeCommand 描述用户国家修改事务。
|
||||
type CountryChangeCommand struct {
|
||||
// UserID 是要修改国家的用户。
|
||||
|
||||
302
services/user-service/internal/service/auth/firebase_verifier.go
Normal file
302
services/user-service/internal/service/auth/firebase_verifier.go
Normal file
@ -0,0 +1,302 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultFirebaseCertsURL 是 Firebase Secure Token 服务公开 x509 证书端点。
|
||||
defaultFirebaseCertsURL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"
|
||||
// defaultFirebaseCertTTL 是证书响应缺少 max-age 时的保守缓存时间。
|
||||
defaultFirebaseCertTTL = time.Hour
|
||||
// firebaseClockLeeway 是 Firebase token 时间校验允许的轻微客户端/服务端时钟偏差。
|
||||
firebaseClockLeeway = 30 * time.Second
|
||||
// maxFirebaseSubjectLength 跟 Firebase uid 约束保持一致,避免异常 sub 进入身份绑定表。
|
||||
maxFirebaseSubjectLength = 128
|
||||
)
|
||||
|
||||
// FirebaseVerifierConfig 描述 Firebase ID token verifier 的最小运行配置。
|
||||
type FirebaseVerifierConfig struct {
|
||||
// ProjectID 必须匹配 Firebase ID token 的 aud 和 issuer 尾部项目 ID。
|
||||
ProjectID string
|
||||
// AllowedSignInProviders 限制 Firebase token 内 firebase.sign_in_provider,首版只允许 google.com。
|
||||
AllowedSignInProviders []string
|
||||
// CertsURL 允许测试替换 Firebase 公钥端点;生产为空时使用官方默认端点。
|
||||
CertsURL string
|
||||
// HTTPClient 允许测试注入短超时 client;生产为空时使用默认超时 client。
|
||||
HTTPClient *http.Client
|
||||
// Now 允许测试固定 verifier 时钟。
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// FirebaseThirdPartyVerifier 校验 Firebase Auth ID token 并抽取稳定 Firebase uid。
|
||||
type FirebaseThirdPartyVerifier struct {
|
||||
// projectID 是 Firebase project id,参与 aud/iss 双重校验。
|
||||
projectID string
|
||||
// issuer 是 Firebase Secure Token 固定 issuer 前缀加 project id。
|
||||
issuer string
|
||||
// allowedSignInProviders 是允许进入当前三方登录链路的 Firebase 登录方式。
|
||||
allowedSignInProviders map[string]bool
|
||||
// certsURL 是 Firebase Secure Token x509 证书端点。
|
||||
certsURL string
|
||||
// httpClient 拉取 Firebase 证书。
|
||||
httpClient *http.Client
|
||||
// now 提供可测试时钟。
|
||||
now func() time.Time
|
||||
// mu 保护证书缓存。
|
||||
mu sync.Mutex
|
||||
// cachedKeys 保存 kid 到 RSA 公钥的缓存。
|
||||
cachedKeys map[string]*rsa.PublicKey
|
||||
// cacheExpiresAt 是证书缓存失效时间。
|
||||
cacheExpiresAt time.Time
|
||||
}
|
||||
|
||||
// firebaseIDTokenClaims 是 Firebase ID token 中本服务关心的字段集合。
|
||||
type firebaseIDTokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
// AuthTime 是 Firebase 用户最近完成认证的时间,必须存在。
|
||||
AuthTime *jwt.NumericDate `json:"auth_time,omitempty"`
|
||||
// Firebase 包含 Firebase 专有登录来源信息。
|
||||
Firebase struct {
|
||||
SignInProvider string `json:"sign_in_provider"`
|
||||
} `json:"firebase"`
|
||||
}
|
||||
|
||||
// NewFirebaseThirdPartyVerifier 创建 Firebase ID token verifier。
|
||||
func NewFirebaseThirdPartyVerifier(config FirebaseVerifierConfig) *FirebaseThirdPartyVerifier {
|
||||
projectID := strings.TrimSpace(config.ProjectID)
|
||||
certsURL := strings.TrimSpace(config.CertsURL)
|
||||
if certsURL == "" {
|
||||
certsURL = defaultFirebaseCertsURL
|
||||
}
|
||||
httpClient := config.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: 5 * time.Second}
|
||||
}
|
||||
now := config.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
allowedSignInProviders := normalizeAllowedSignInProviders(config.AllowedSignInProviders)
|
||||
|
||||
return &FirebaseThirdPartyVerifier{
|
||||
projectID: projectID,
|
||||
issuer: "https://securetoken.google.com/" + projectID,
|
||||
allowedSignInProviders: allowedSignInProviders,
|
||||
certsURL: certsURL,
|
||||
httpClient: httpClient,
|
||||
now: now,
|
||||
cachedKeys: map[string]*rsa.PublicKey{},
|
||||
}
|
||||
}
|
||||
|
||||
// Verify 校验 Firebase ID token,并把 sub/uid 作为 provider_subject 返回。
|
||||
func (v *FirebaseThirdPartyVerifier) Verify(ctx context.Context, provider string, credential string) (authdomain.ThirdPartyProfile, error) {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
credential = strings.TrimSpace(credential)
|
||||
if provider != "firebase" || credential == "" || v == nil || v.projectID == "" {
|
||||
// provider 不匹配、token 为空或 Firebase 项目未配置都统一认证失败,避免泄漏配置状态。
|
||||
return authdomain.ThirdPartyProfile{}, authFailed()
|
||||
}
|
||||
|
||||
claims := &firebaseIDTokenClaims{}
|
||||
parser := jwt.NewParser(
|
||||
jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}),
|
||||
jwt.WithIssuer(v.issuer),
|
||||
jwt.WithAudience(v.projectID),
|
||||
jwt.WithExpirationRequired(),
|
||||
jwt.WithIssuedAt(),
|
||||
jwt.WithLeeway(firebaseClockLeeway),
|
||||
jwt.WithTimeFunc(v.now),
|
||||
)
|
||||
token, err := parser.ParseWithClaims(credential, claims, func(token *jwt.Token) (any, error) {
|
||||
kid, _ := token.Header["kid"].(string)
|
||||
return v.publicKey(ctx, kid)
|
||||
})
|
||||
if err != nil || token == nil || !token.Valid {
|
||||
return authdomain.ThirdPartyProfile{}, authFailed()
|
||||
}
|
||||
|
||||
subject := strings.TrimSpace(claims.Subject)
|
||||
if subject == "" || len(subject) > maxFirebaseSubjectLength {
|
||||
// Firebase uid/sub 是唯一登录绑定主键,不能为空或异常超长。
|
||||
return authdomain.ThirdPartyProfile{}, authFailed()
|
||||
}
|
||||
if claims.AuthTime == nil || claims.AuthTime.Time.After(v.now().Add(firebaseClockLeeway)) {
|
||||
// auth_time 缺失或明显来自未来时拒绝,避免接受非 Firebase Auth 形态的 JWT。
|
||||
return authdomain.ThirdPartyProfile{}, authFailed()
|
||||
}
|
||||
signInProvider := strings.TrimSpace(claims.Firebase.SignInProvider)
|
||||
if !v.allowedSignInProviders[signInProvider] {
|
||||
// 首版只允许 Firebase Google 登录,其他 sign_in_provider 后续按产品确认再开放。
|
||||
return authdomain.ThirdPartyProfile{}, authFailed()
|
||||
}
|
||||
|
||||
return authdomain.ThirdPartyProfile{ProviderSubject: subject}, nil
|
||||
}
|
||||
|
||||
func (v *FirebaseThirdPartyVerifier) publicKey(ctx context.Context, kid string) (*rsa.PublicKey, error) {
|
||||
kid = strings.TrimSpace(kid)
|
||||
if kid == "" {
|
||||
return nil, authFailed()
|
||||
}
|
||||
if key, cacheValid := v.cachedPublicKey(kid); key != nil {
|
||||
return key, nil
|
||||
} else if cacheValid {
|
||||
// Firebase 证书缓存仍有效但 kid 不存在时直接拒绝,避免随机 kid 伪造 token 放大到外部证书端点。
|
||||
return nil, authFailed()
|
||||
}
|
||||
keys, expiresAt, err := v.fetchPublicKeys(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v.mu.Lock()
|
||||
v.cachedKeys = keys
|
||||
v.cacheExpiresAt = expiresAt
|
||||
key, ok := v.cachedKeys[kid]
|
||||
v.mu.Unlock()
|
||||
if !ok {
|
||||
// 未知 kid 说明 token 不是当前 Firebase Secure Token 证书签发。
|
||||
return nil, authFailed()
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (v *FirebaseThirdPartyVerifier) cachedPublicKey(kid string) (*rsa.PublicKey, bool) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
if v.cacheExpiresAt.IsZero() || !v.now().Before(v.cacheExpiresAt) {
|
||||
// 缓存未初始化或已过期时允许调用方刷新;未知 kid 只有在有效缓存窗口内才被本地拒绝。
|
||||
return nil, false
|
||||
}
|
||||
key := v.cachedKeys[kid]
|
||||
return key, true
|
||||
}
|
||||
|
||||
func (v *FirebaseThirdPartyVerifier) fetchPublicKeys(ctx context.Context) (map[string]*rsa.PublicKey, time.Time, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, v.certsURL, nil)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, authFailed()
|
||||
}
|
||||
response, err := v.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, authFailed()
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, time.Time{}, authFailed()
|
||||
}
|
||||
|
||||
var certs map[string]string
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&certs); err != nil {
|
||||
return nil, time.Time{}, authFailed()
|
||||
}
|
||||
keys := make(map[string]*rsa.PublicKey, len(certs))
|
||||
for kid, encodedCert := range certs {
|
||||
key, err := parseFirebaseCertPublicKey(encodedCert)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, authFailed()
|
||||
}
|
||||
keys[strings.TrimSpace(kid)] = key
|
||||
}
|
||||
if len(keys) == 0 {
|
||||
return nil, time.Time{}, authFailed()
|
||||
}
|
||||
|
||||
return keys, firebaseCacheExpiry(v.now, response.Header.Get("Cache-Control")), nil
|
||||
}
|
||||
|
||||
func parseFirebaseCertPublicKey(encodedCert string) (*rsa.PublicKey, error) {
|
||||
block, _ := pem.Decode([]byte(encodedCert))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("firebase cert pem decode failed")
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, ok := cert.PublicKey.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("firebase cert public key is not rsa")
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func firebaseCacheExpiry(now func() time.Time, cacheControl string) time.Time {
|
||||
for _, part := range strings.Split(cacheControl, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if !strings.HasPrefix(strings.ToLower(part), "max-age=") {
|
||||
continue
|
||||
}
|
||||
seconds, err := strconv.ParseInt(strings.TrimPrefix(part, "max-age="), 10, 64)
|
||||
if err == nil && seconds > 0 {
|
||||
return now().Add(time.Duration(seconds) * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return now().Add(defaultFirebaseCertTTL)
|
||||
}
|
||||
|
||||
func normalizeAllowedSignInProviders(providers []string) map[string]bool {
|
||||
if len(providers) == 0 {
|
||||
// 首版 Firebase 只开放 Google 登录。
|
||||
providers = []string{"google.com"}
|
||||
}
|
||||
allowed := make(map[string]bool, len(providers))
|
||||
for _, provider := range providers {
|
||||
provider = strings.TrimSpace(provider)
|
||||
if provider != "" {
|
||||
allowed[provider] = true
|
||||
}
|
||||
}
|
||||
|
||||
return allowed
|
||||
}
|
||||
|
||||
// RoutedThirdPartyVerifier 把 firebase provider 路由到 Firebase verifier,其余 provider 交给本地/mock verifier。
|
||||
type RoutedThirdPartyVerifier struct {
|
||||
firebase ThirdPartyVerifier
|
||||
fallback ThirdPartyVerifier
|
||||
}
|
||||
|
||||
// NewRoutedThirdPartyVerifier 创建按 provider 分发的三方 verifier。
|
||||
func NewRoutedThirdPartyVerifier(firebase ThirdPartyVerifier, fallback ThirdPartyVerifier) *RoutedThirdPartyVerifier {
|
||||
return &RoutedThirdPartyVerifier{
|
||||
firebase: firebase,
|
||||
fallback: fallback,
|
||||
}
|
||||
}
|
||||
|
||||
// Verify 保证 provider=firebase 永远不会落到静态 verifier。
|
||||
func (v *RoutedThirdPartyVerifier) Verify(ctx context.Context, provider string, credential string) (authdomain.ThirdPartyProfile, error) {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
if provider == "firebase" {
|
||||
if v == nil || v.firebase == nil {
|
||||
return authdomain.ThirdPartyProfile{}, authFailed()
|
||||
}
|
||||
return v.firebase.Verify(ctx, provider, credential)
|
||||
}
|
||||
if v == nil || v.fallback == nil {
|
||||
return authdomain.ThirdPartyProfile{}, authFailed()
|
||||
}
|
||||
|
||||
return v.fallback.Verify(ctx, provider, credential)
|
||||
}
|
||||
@ -0,0 +1,229 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
jwt "github.com/golang-jwt/jwt/v5"
|
||||
"hyapp/pkg/xerr"
|
||||
authservice "hyapp/services/user-service/internal/service/auth"
|
||||
)
|
||||
|
||||
func TestFirebaseThirdPartyVerifierAcceptsGoogleFirebaseIDToken(t *testing.T) {
|
||||
// Firebase verifier 必须从已签名 ID token 中抽取 sub/uid,而不是信任客户端直传 account id。
|
||||
now := time.Unix(2000, 0)
|
||||
privateKey, certPEM := firebaseTestKeyPair(t, now)
|
||||
certsServer := firebaseCertsServer(t, "kid-1", certPEM)
|
||||
verifier := authservice.NewFirebaseThirdPartyVerifier(authservice.FirebaseVerifierConfig{
|
||||
ProjectID: "hyapp-test",
|
||||
AllowedSignInProviders: []string{"google.com"},
|
||||
CertsURL: certsServer.URL,
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
token := signFirebaseTestToken(t, privateKey, "kid-1", map[string]any{
|
||||
"iss": "https://securetoken.google.com/hyapp-test",
|
||||
"aud": "hyapp-test",
|
||||
"sub": "firebase-uid-1",
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
"auth_time": now.Add(-time.Minute).Unix(),
|
||||
"firebase": map[string]any{"sign_in_provider": "google.com"},
|
||||
})
|
||||
|
||||
profile, err := verifier.Verify(context.Background(), "firebase", token)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify failed: %v", err)
|
||||
}
|
||||
if profile.ProviderSubject != "firebase-uid-1" {
|
||||
t.Fatalf("provider_subject must come from Firebase sub: %+v", profile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirebaseThirdPartyVerifierRejectsInvalidClaims(t *testing.T) {
|
||||
// AUTH_FAILED 分支必须覆盖 provider、audience、登录方式和过期时间,避免身份枚举和配置泄漏。
|
||||
now := time.Unix(2000, 0)
|
||||
privateKey, certPEM := firebaseTestKeyPair(t, now)
|
||||
certsServer := firebaseCertsServer(t, "kid-1", certPEM)
|
||||
verifier := authservice.NewFirebaseThirdPartyVerifier(authservice.FirebaseVerifierConfig{
|
||||
ProjectID: "hyapp-test",
|
||||
AllowedSignInProviders: []string{"google.com"},
|
||||
CertsURL: certsServer.URL,
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
validClaims := map[string]any{
|
||||
"iss": "https://securetoken.google.com/hyapp-test",
|
||||
"aud": "hyapp-test",
|
||||
"sub": "firebase-uid-1",
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
"auth_time": now.Add(-time.Minute).Unix(),
|
||||
"firebase": map[string]any{"sign_in_provider": "google.com"},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
provider string
|
||||
mutate func(map[string]any)
|
||||
}{
|
||||
{
|
||||
name: "wrong provider",
|
||||
provider: "google",
|
||||
},
|
||||
{
|
||||
name: "wrong audience",
|
||||
provider: "firebase",
|
||||
mutate: func(claims map[string]any) {
|
||||
claims["aud"] = "other-project"
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wrong sign in provider",
|
||||
provider: "firebase",
|
||||
mutate: func(claims map[string]any) {
|
||||
claims["firebase"] = map[string]any{"sign_in_provider": "password"}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "expired token",
|
||||
provider: "firebase",
|
||||
mutate: func(claims map[string]any) {
|
||||
claims["exp"] = now.Add(-time.Minute).Unix()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty subject",
|
||||
provider: "firebase",
|
||||
mutate: func(claims map[string]any) {
|
||||
claims["sub"] = ""
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
claims := cloneFirebaseClaims(validClaims)
|
||||
if tc.mutate != nil {
|
||||
tc.mutate(claims)
|
||||
}
|
||||
token := signFirebaseTestToken(t, privateKey, "kid-1", claims)
|
||||
_, err := verifier.Verify(context.Background(), tc.provider, token)
|
||||
if !xerr.IsCode(err, xerr.AuthFailed) {
|
||||
t.Fatalf("expected AUTH_FAILED, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirebaseThirdPartyVerifierRejectsUnknownKidWithoutRefreshingValidCache(t *testing.T) {
|
||||
// 缓存有效期内的未知 kid 只能本地拒绝,不能让随机 token 放大成 Firebase 证书端点请求。
|
||||
now := time.Unix(2000, 0)
|
||||
privateKey, certPEM := firebaseTestKeyPair(t, now)
|
||||
var fetches atomic.Int32
|
||||
certsServer := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
fetches.Add(1)
|
||||
writer.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
_ = json.NewEncoder(writer).Encode(map[string]string{"kid-1": certPEM})
|
||||
}))
|
||||
t.Cleanup(certsServer.Close)
|
||||
verifier := authservice.NewFirebaseThirdPartyVerifier(authservice.FirebaseVerifierConfig{
|
||||
ProjectID: "hyapp-test",
|
||||
AllowedSignInProviders: []string{"google.com"},
|
||||
CertsURL: certsServer.URL,
|
||||
Now: func() time.Time { return now },
|
||||
})
|
||||
claims := map[string]any{
|
||||
"iss": "https://securetoken.google.com/hyapp-test",
|
||||
"aud": "hyapp-test",
|
||||
"sub": "firebase-uid-1",
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(time.Hour).Unix(),
|
||||
"auth_time": now.Add(-time.Minute).Unix(),
|
||||
"firebase": map[string]any{"sign_in_provider": "google.com"},
|
||||
}
|
||||
if _, err := verifier.Verify(context.Background(), "firebase", signFirebaseTestToken(t, privateKey, "kid-1", claims)); err != nil {
|
||||
t.Fatalf("initial valid token should warm cache: %v", err)
|
||||
}
|
||||
if fetches.Load() != 1 {
|
||||
t.Fatalf("valid token should fetch certs once, got %d", fetches.Load())
|
||||
}
|
||||
|
||||
_, err := verifier.Verify(context.Background(), "firebase", signFirebaseTestToken(t, privateKey, "random-kid", claims))
|
||||
if !xerr.IsCode(err, xerr.AuthFailed) {
|
||||
t.Fatalf("expected AUTH_FAILED for unknown kid, got %v", err)
|
||||
}
|
||||
if fetches.Load() != 1 {
|
||||
t.Fatalf("unknown kid must not refresh valid cert cache, got fetches=%d", fetches.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutedThirdPartyVerifierNeverRoutesFirebaseToStaticVerifier(t *testing.T) {
|
||||
// 即使 static allowlist 误配了 firebase,provider=firebase 也必须走真实 Firebase verifier 并 fail-closed。
|
||||
verifier := authservice.NewRoutedThirdPartyVerifier(nil, authservice.NewStaticThirdPartyVerifier([]string{"firebase", "wechat"}))
|
||||
if _, err := verifier.Verify(context.Background(), "firebase", "client-supplied-subject"); !xerr.IsCode(err, xerr.AuthFailed) {
|
||||
t.Fatalf("firebase must not fall back to static verifier, got %v", err)
|
||||
}
|
||||
profile, err := verifier.Verify(context.Background(), "wechat", "mock-openid")
|
||||
if err != nil || profile.ProviderSubject != "mock-openid" {
|
||||
t.Fatalf("non-firebase mock provider should still use fallback: profile=%+v err=%v", profile, err)
|
||||
}
|
||||
}
|
||||
|
||||
func firebaseTestKeyPair(t *testing.T, now time.Time) (*rsa.PrivateKey, string) {
|
||||
t.Helper()
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate rsa key failed: %v", err)
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
NotBefore: now.Add(-time.Hour),
|
||||
NotAfter: now.Add(time.Hour),
|
||||
}, &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
NotBefore: now.Add(-time.Hour),
|
||||
NotAfter: now.Add(time.Hour),
|
||||
}, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate failed: %v", err)
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
return privateKey, string(certPEM)
|
||||
}
|
||||
|
||||
func firebaseCertsServer(t *testing.T, kid string, certPEM string) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
|
||||
writer.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
_ = json.NewEncoder(writer).Encode(map[string]string{kid: certPEM})
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func signFirebaseTestToken(t *testing.T, privateKey *rsa.PrivateKey, kid string, claims map[string]any) string {
|
||||
t.Helper()
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims(claims))
|
||||
token.Header["kid"] = kid
|
||||
signed, err := token.SignedString(privateKey)
|
||||
if err != nil {
|
||||
t.Fatalf("sign firebase token failed: %v", err)
|
||||
}
|
||||
return signed
|
||||
}
|
||||
|
||||
func cloneFirebaseClaims(input map[string]any) map[string]any {
|
||||
result := make(map[string]any, len(input))
|
||||
for key, value := range input {
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
@ -22,6 +22,8 @@ const (
|
||||
loginThird = "third_party"
|
||||
// loginRefresh 是 refresh token 轮换审计类型。
|
||||
loginRefresh = "refresh"
|
||||
// loginAccessResign 是不轮换 refresh token 的 access token 重签审计类型。
|
||||
loginAccessResign = "access_resign"
|
||||
// loginSetPass 是设置密码审计类型。
|
||||
loginSetPass = "set_password"
|
||||
// resultSuccess 是审计成功结果。
|
||||
@ -41,6 +43,8 @@ type AuthRepository interface {
|
||||
CreateSession(ctx context.Context, session authdomain.Session) error
|
||||
// FindActiveSessionByRefreshHash 通过 refresh token hash 查找未过期未吊销 session。
|
||||
FindActiveSessionByRefreshHash(ctx context.Context, refreshTokenHash string, nowMs int64) (authdomain.Session, error)
|
||||
// FindActiveSessionByID 通过 session_id 查找未过期未吊销 session,用于只重签 access token 的链路。
|
||||
FindActiveSessionByID(ctx context.Context, sessionID string, nowMs int64) (authdomain.Session, error)
|
||||
// ReplaceSession 原子吊销旧 session 并创建新 session。
|
||||
ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error
|
||||
// RevokeSession 按 session_id 或 refresh token hash 吊销 session。
|
||||
|
||||
@ -77,20 +77,27 @@ func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int6
|
||||
)
|
||||
}
|
||||
|
||||
func thirdPartyRegistration(deviceID string) authdomain.ThirdPartyRegistration {
|
||||
// 测试默认只关心 device_id 绑定,注册资料字段在专门用例中覆盖。
|
||||
return authdomain.ThirdPartyRegistration{DeviceID: deviceID}
|
||||
func thirdPartyRegistration(platform string) authdomain.ThirdPartyRegistration {
|
||||
// 三方登录是注册入口,测试默认同时提供设备和平台,避免生成缺失注册上下文的用户。
|
||||
return authdomain.ThirdPartyRegistration{DeviceID: "dev-" + platform, Platform: platform}
|
||||
}
|
||||
|
||||
func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) userdomain.Country {
|
||||
t.Helper()
|
||||
if country, ok, err := repository.ResolveActiveCountryByCode(context.Background(), code); err != nil {
|
||||
if country, ok, err := repository.ResolveEnabledCountryByCode(context.Background(), code); err != nil {
|
||||
t.Fatalf("resolve country %s failed: %v", code, err)
|
||||
} else if ok {
|
||||
return country
|
||||
}
|
||||
svc := newUserService(repository)
|
||||
country, err := svc.CreateCountry(context.Background(), code+" Name", code, code+" Display", 0, 1, "seed-country-"+code)
|
||||
country, err := svc.CreateCountry(context.Background(), userdomain.CreateCountryCommand{
|
||||
CountryName: code + " Name",
|
||||
CountryCode: code,
|
||||
CountryDisplayName: code + " Display",
|
||||
Enabled: true,
|
||||
OperatorUserID: 1,
|
||||
RequestID: "seed-country-" + code,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed country %s failed: %v", code, err)
|
||||
}
|
||||
@ -107,6 +114,19 @@ func seedRegion(t *testing.T, repository *mysqltest.Repository, code string, cou
|
||||
return region
|
||||
}
|
||||
|
||||
func mustResolveRegionByCountry(t *testing.T, repository *mysqltest.Repository, country string) userdomain.Region {
|
||||
t.Helper()
|
||||
region, ok, err := repository.ResolveActiveRegionByCountry(context.Background(), country)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve region for country %s failed: %v", country, err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("country %s should have seeded region mapping", country)
|
||||
}
|
||||
|
||||
return region
|
||||
}
|
||||
|
||||
func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
|
||||
// 覆盖三方注册、设置密码、密码登录、refresh 轮换和 logout 的完整认证生命周期。
|
||||
ctx := context.Background()
|
||||
@ -122,6 +142,9 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
|
||||
if !isNewUser || thirdToken.UserID != 900001 || thirdToken.DisplayUserID != "100001" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" {
|
||||
t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser)
|
||||
}
|
||||
if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
|
||||
t.Fatalf("new third-party user must require onboarding: %+v", thirdToken)
|
||||
}
|
||||
|
||||
if _, err := svc.LoginPassword(ctx, "100001", "secret-pass", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
|
||||
// 未设置密码前不能通过短号密码登录,且错误必须统一 AUTH_FAILED。
|
||||
@ -171,6 +194,69 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenCarriesCompletedOnboardingStatus(t *testing.T) {
|
||||
// gateway profile gate 依赖 access token 快照;refresh 必须在用户完成注册后带出最新状态。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
now := time.UnixMilli(1000)
|
||||
authSvc := newAuthService(repository, &now, []int64{900009}, []string{"100009"})
|
||||
userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
|
||||
|
||||
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginThirdParty failed: %v", err)
|
||||
}
|
||||
if token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
|
||||
t.Fatalf("new token should require onboarding: %+v", token)
|
||||
}
|
||||
|
||||
now = time.UnixMilli(2000)
|
||||
if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "SG"); err != nil {
|
||||
t.Fatalf("CompleteOnboarding failed: %v", err)
|
||||
}
|
||||
|
||||
now = time.UnixMilli(3000)
|
||||
refreshed, err := authSvc.RefreshToken(ctx, token.RefreshToken, "ios", authservice.Meta{RequestID: "req-refresh"})
|
||||
if err != nil {
|
||||
t.Fatalf("RefreshToken failed: %v", err)
|
||||
}
|
||||
if !refreshed.ProfileCompleted || refreshed.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
|
||||
t.Fatalf("refreshed token must carry completed onboarding: %+v", refreshed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotatingRefresh(t *testing.T) {
|
||||
// CompleteOnboarding 后只需要替换 access token;refresh token 原文不可从数据库反查,也不应被静默轮换。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
now := time.UnixMilli(1000)
|
||||
authSvc := newAuthService(repository, &now, []int64{900010}, []string{"100010"})
|
||||
userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
|
||||
|
||||
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-onboarding-resign", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-login"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginThirdParty failed: %v", err)
|
||||
}
|
||||
now = time.UnixMilli(2000)
|
||||
if _, err := userSvc.CompleteOnboarding(ctx, token.UserID, "hy", "https://cdn.example/a.png", "SG"); err != nil {
|
||||
t.Fatalf("CompleteOnboarding failed: %v", err)
|
||||
}
|
||||
|
||||
now = time.UnixMilli(3000)
|
||||
reissued, err := authSvc.IssueAccessTokenForSession(ctx, token.UserID, token.SessionID, authservice.Meta{RequestID: "req-resign"})
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAccessTokenForSession failed: %v", err)
|
||||
}
|
||||
if !reissued.ProfileCompleted || reissued.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) || reissued.AccessToken == "" {
|
||||
t.Fatalf("reissued token must carry completed onboarding: %+v", reissued)
|
||||
}
|
||||
if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
|
||||
t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledUserCannotLogin(t *testing.T) {
|
||||
// 用户状态被禁用后,即使密码正确也不能继续签发 token。
|
||||
ctx := context.Background()
|
||||
@ -288,12 +374,13 @@ func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t *
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
region := seedRegion(t, repository, "SEA", []string{"SG"})
|
||||
region := mustResolveRegionByCountry(t, repository, "SG")
|
||||
now := time.UnixMilli(1000)
|
||||
svc := newAuthService(repository, &now, []int64{900006}, []string{"100006"})
|
||||
|
||||
firstToken, isNew, err := svc.LoginThirdParty(ctx, "wechat", "openid-region", authdomain.ThirdPartyRegistration{
|
||||
DeviceID: "ios",
|
||||
Platform: "ios",
|
||||
Country: " sg ",
|
||||
}, authservice.Meta{RequestID: "req-region-first"})
|
||||
if err != nil {
|
||||
@ -306,12 +393,13 @@ func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t *
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser failed: %v", err)
|
||||
}
|
||||
if user.Country != "SG" || user.RegionID != region.RegionID || user.RegionCode != "SEA" {
|
||||
if user.Country != "SG" || user.RegionID != region.RegionID || user.RegionCode != region.RegionCode {
|
||||
t.Fatalf("region assignment mismatch: %+v", user)
|
||||
}
|
||||
|
||||
_, isNew, err = svc.LoginThirdParty(ctx, "wechat", "openid-region", authdomain.ThirdPartyRegistration{
|
||||
DeviceID: "ios",
|
||||
Platform: "ios",
|
||||
Country: "ZZ",
|
||||
}, authservice.Meta{RequestID: "req-region-second"})
|
||||
if err != nil {
|
||||
@ -348,7 +436,7 @@ func TestThirdPartyRegisterRejectsUnknownOrDisabledCountry(t *testing.T) {
|
||||
{name: "unknown", country: "ZZ"},
|
||||
{name: "disabled", country: "MY"},
|
||||
} {
|
||||
_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, authdomain.ThirdPartyRegistration{DeviceID: "ios", Country: tc.country}, authservice.Meta{})
|
||||
_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: tc.country}, authservice.Meta{})
|
||||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("expected INVALID_ARGUMENT for %s country, got %v", tc.name, err)
|
||||
}
|
||||
@ -366,12 +454,14 @@ func TestThirdPartyRegisterValidatesRegistrationProfile(t *testing.T) {
|
||||
name string
|
||||
registration authdomain.ThirdPartyRegistration
|
||||
}{
|
||||
{name: "birth", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", BirthDate: "2000/01/02"}},
|
||||
{name: "platform", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "web"}},
|
||||
{name: "country", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Country: "U1"}},
|
||||
{name: "avatar", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Avatar: "ftp://cdn.example/avatar.png"}},
|
||||
{name: "language", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Language: "en_US"}},
|
||||
{name: "timezone", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Timezone: "Mars/Phobos"}},
|
||||
{name: "birth_format", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BirthDate: "2000/01/02"}},
|
||||
{name: "birth_mysql_range", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BirthDate: "0001-01-01"}},
|
||||
{name: "platform_missing", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios"}},
|
||||
{name: "platform_invalid", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "web"}},
|
||||
{name: "country", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: "U1"}},
|
||||
{name: "avatar", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Avatar: "ftp://cdn.example/avatar.png"}},
|
||||
{name: "language", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Language: "en_US"}},
|
||||
{name: "timezone", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Timezone: "Mars/Phobos"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, tc.registration, authservice.Meta{})
|
||||
@ -393,24 +483,24 @@ func TestThirdPartyRegisterValidatesSQLBackedLengths(t *testing.T) {
|
||||
registration authdomain.ThirdPartyRegistration
|
||||
meta authservice.Meta
|
||||
}{
|
||||
{name: "username", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Username: strings.Repeat("a", 65)}},
|
||||
{name: "gender", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Gender: strings.Repeat("a", 33)}},
|
||||
{name: "country", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Country: strings.Repeat("a", 65)}},
|
||||
{name: "invite_code", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", InviteCode: strings.Repeat("a", 65)}},
|
||||
{name: "username", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Username: strings.Repeat("a", 65)}},
|
||||
{name: "gender", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Gender: strings.Repeat("a", 33)}},
|
||||
{name: "country", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Country: strings.Repeat("a", 65)}},
|
||||
{name: "invite_code", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", InviteCode: strings.Repeat("a", 65)}},
|
||||
{name: "register_ip", registration: thirdPartyRegistration("ios"), meta: authservice.Meta{ClientIP: strings.Repeat("1", 65)}},
|
||||
{name: "register_user_agent", registration: thirdPartyRegistration("ios"), meta: authservice.Meta{UserAgent: strings.Repeat("a", 513)}},
|
||||
{name: "device_id", registration: authdomain.ThirdPartyRegistration{DeviceID: strings.Repeat("a", 129)}},
|
||||
{name: "device", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Device: strings.Repeat("a", 129)}},
|
||||
{name: "os_version", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", OSVersion: strings.Repeat("a", 65)}},
|
||||
{name: "avatar", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Avatar: longURL}},
|
||||
{name: "app_version", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", AppVersion: strings.Repeat("a", 65)}},
|
||||
{name: "build_number", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", BuildNumber: strings.Repeat("a", 65)}},
|
||||
{name: "source", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Source: strings.Repeat("a", 65)}},
|
||||
{name: "install_channel", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", InstallChannel: strings.Repeat("a", 65)}},
|
||||
{name: "campaign", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Campaign: strings.Repeat("a", 129)}},
|
||||
{name: "device_id", registration: authdomain.ThirdPartyRegistration{DeviceID: strings.Repeat("a", 129), Platform: "ios"}},
|
||||
{name: "device", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Device: strings.Repeat("a", 129)}},
|
||||
{name: "os_version", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", OSVersion: strings.Repeat("a", 65)}},
|
||||
{name: "avatar", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Avatar: longURL}},
|
||||
{name: "app_version", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", AppVersion: strings.Repeat("a", 65)}},
|
||||
{name: "build_number", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", BuildNumber: strings.Repeat("a", 65)}},
|
||||
{name: "source", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Source: strings.Repeat("a", 65)}},
|
||||
{name: "install_channel", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", InstallChannel: strings.Repeat("a", 65)}},
|
||||
{name: "campaign", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Campaign: strings.Repeat("a", 129)}},
|
||||
{name: "platform", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: strings.Repeat("a", 17)}},
|
||||
{name: "language", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Language: strings.Repeat("a", 33)}},
|
||||
{name: "timezone", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Timezone: strings.Repeat("a", 65)}},
|
||||
{name: "language", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Language: strings.Repeat("a", 33)}},
|
||||
{name: "timezone", registration: authdomain.ThirdPartyRegistration{DeviceID: "ios", Platform: "ios", Timezone: strings.Repeat("a", 65)}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
_, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-"+tc.name, tc.registration, tc.meta)
|
||||
|
||||
@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
@ -40,13 +41,17 @@ func (s *Service) LoginThirdParty(ctx context.Context, provider string, credenti
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
credential = strings.TrimSpace(credential)
|
||||
registration = normalizeThirdPartyRegistration(registration, meta)
|
||||
if provider == "" || credential == "" {
|
||||
// provider/credential 是认证材料,缺失时属于请求参数错误,不进入 provider verifier。
|
||||
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "provider and credential are required")
|
||||
}
|
||||
if registration.DeviceID == "" || registration.Platform == "" {
|
||||
// device_id 绑定 refresh session,platform 是注册来源必填快照;两者缺失都不能创建用户主记录。
|
||||
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "device_id and platform are required")
|
||||
}
|
||||
if err := validateThirdPartyRegistration(registration); err != nil {
|
||||
return authdomain.Token{}, false, err
|
||||
}
|
||||
if provider == "" || credential == "" || registration.DeviceID == "" {
|
||||
// device_id 用于后续 refresh token 轮换绑定。
|
||||
return authdomain.Token{}, false, xerr.New(xerr.InvalidArgument, "provider, credential and device_id are required")
|
||||
}
|
||||
if s.authRepository == nil {
|
||||
return authdomain.Token{}, false, xerr.New(xerr.Unavailable, "auth repository is not configured")
|
||||
}
|
||||
@ -65,6 +70,7 @@ func (s *Service) LoginThirdParty(ctx context.Context, provider string, credenti
|
||||
}
|
||||
if !xerr.IsCode(err, xerr.NotFound) {
|
||||
// 非 not found 的存储错误不能退化为注册。
|
||||
log.Printf("component=user_auth op=third_party_lookup request_id=%s provider=%s status=failed error=%q", meta.RequestID, provider, err.Error())
|
||||
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
|
||||
return authdomain.Token{}, false, err
|
||||
}
|
||||
@ -149,6 +155,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
|
||||
}
|
||||
|
||||
s.audit(ctx, meta, 0, loginThird, provider, resultFailed, string(xerr.CodeOf(err)))
|
||||
log.Printf("component=user_auth op=third_party_register request_id=%s provider=%s status=failed error=%q", meta.RequestID, provider, err.Error())
|
||||
return authdomain.Token{}, false, err
|
||||
}
|
||||
|
||||
@ -204,7 +211,7 @@ func validateThirdPartyRegistration(registration authdomain.ThirdPartyRegistrati
|
||||
// avatar 进入可展示资料,不接受相对地址或非 HTTP(S) scheme。
|
||||
return xerr.New(xerr.InvalidArgument, "avatar must be an absolute http or https URL")
|
||||
}
|
||||
if registration.Platform != "" && registration.Platform != "android" && registration.Platform != "ios" {
|
||||
if registration.Platform != "android" && registration.Platform != "ios" {
|
||||
// 平台值进入统计和渠道分析,先收敛到明确枚举。
|
||||
return xerr.New(xerr.InvalidArgument, "platform must be android or ios")
|
||||
}
|
||||
@ -269,7 +276,7 @@ func (s *Service) resolveRegistrationCountry(ctx context.Context, registration a
|
||||
}
|
||||
|
||||
if registration.Country != "" {
|
||||
country, ok, err := s.countryRegionRepository.ResolveActiveCountryByCode(ctx, registration.Country)
|
||||
country, ok, err := s.countryRegionRepository.ResolveEnabledCountryByCode(ctx, registration.Country)
|
||||
if err != nil {
|
||||
return registration, err
|
||||
}
|
||||
@ -289,7 +296,7 @@ func (s *Service) resolveRegistrationCountry(ctx context.Context, registration a
|
||||
}
|
||||
|
||||
if registration.CountryByIP != "" {
|
||||
country, ok, err := s.countryRegionRepository.ResolveActiveCountryByCode(ctx, registration.CountryByIP)
|
||||
country, ok, err := s.countryRegionRepository.ResolveEnabledCountryByCode(ctx, registration.CountryByIP)
|
||||
if err != nil {
|
||||
return registration, err
|
||||
}
|
||||
@ -370,6 +377,9 @@ func (s *Service) newUserWithIdentity(attempt int) (userdomain.User, userdomain.
|
||||
DefaultDisplayUserID: displayUserID,
|
||||
CurrentDisplayUserID: displayUserID,
|
||||
CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault,
|
||||
ProfileCompleted: false,
|
||||
ProfileCompletedAtMs: 0,
|
||||
OnboardingStatus: userdomain.OnboardingStatusProfileRequired,
|
||||
Status: userdomain.StatusActive,
|
||||
CreatedAtMs: nowMs,
|
||||
UpdatedAtMs: nowMs,
|
||||
|
||||
@ -71,6 +71,44 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceI
|
||||
return s.issueToken(user, newSession.SessionID, newRefreshToken)
|
||||
}
|
||||
|
||||
// IssueAccessTokenForSession 基于当前 active session 重签 access token,不轮换 refresh token。
|
||||
// 该链路用于 CompleteOnboarding 后刷新 profile_completed 快照,避免客户端必须额外 refresh。
|
||||
func (s *Service) IssueAccessTokenForSession(ctx context.Context, userID int64, sessionID string, meta Meta) (authdomain.Token, error) {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
if userID <= 0 || sessionID == "" {
|
||||
// session_id 来自已校验 JWT 的 sid claim,缺失说明调用方不能安全重签。
|
||||
return authdomain.Token{}, xerr.New(xerr.InvalidArgument, "user_id and session_id are required")
|
||||
}
|
||||
if s.authRepository == nil {
|
||||
return authdomain.Token{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
|
||||
}
|
||||
|
||||
nowMs := s.now().UnixMilli()
|
||||
session, err := s.authRepository.FindActiveSessionByID(ctx, sessionID, nowMs)
|
||||
if err != nil {
|
||||
s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.CodeOf(err)))
|
||||
return authdomain.Token{}, err
|
||||
}
|
||||
if session.UserID != userID {
|
||||
// sid 和 user_id 必须同源于同一个 access token,任何不一致都按会话失效处理。
|
||||
s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.SessionRevoked))
|
||||
return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked")
|
||||
}
|
||||
|
||||
user, err := s.freshUser(ctx, userID, nowMs, meta.RequestID)
|
||||
if err != nil {
|
||||
s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.CodeOf(err)))
|
||||
return authdomain.Token{}, err
|
||||
}
|
||||
if !user.CanLogin() {
|
||||
s.audit(ctx, meta, userID, loginAccessResign, "", resultFailed, string(xerr.UserDisabled))
|
||||
return authdomain.Token{}, xerr.New(xerr.UserDisabled, "user is disabled")
|
||||
}
|
||||
|
||||
s.audit(ctx, meta, userID, loginAccessResign, "", resultSuccess, "")
|
||||
return s.issueToken(user, session.SessionID, "")
|
||||
}
|
||||
|
||||
// Logout 失效指定 session 或 refresh token。
|
||||
func (s *Service) Logout(ctx context.Context, sessionID string, refreshToken string, meta Meta) (bool, error) {
|
||||
if strings.TrimSpace(sessionID) == "" && strings.TrimSpace(refreshToken) == "" {
|
||||
@ -122,6 +160,7 @@ func (s *Service) newSession(userID int64, deviceID string) (authdomain.Session,
|
||||
func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToken string) (authdomain.Token, error) {
|
||||
now := s.now()
|
||||
expiresAt := now.Add(time.Duration(s.cfg.AccessTokenTTLSec) * time.Second)
|
||||
onboardingStatus := tokenOnboardingStatus(user)
|
||||
// claim 同时包含 JWT subject 和网关鉴权需要的数值 user_id。
|
||||
claims := jwt.MapClaims{
|
||||
"iss": s.cfg.Issuer,
|
||||
@ -131,6 +170,8 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
|
||||
"default_display_user_id": user.DefaultDisplayUserID,
|
||||
"display_user_id_kind": string(user.CurrentDisplayUserIDKind),
|
||||
"display_user_id_expires_at_ms": user.CurrentDisplayUserIDExpiresAtMs,
|
||||
"profile_completed": user.ProfileCompleted,
|
||||
"onboarding_status": onboardingStatus,
|
||||
"sid": sessionID,
|
||||
"typ": "access",
|
||||
"iat": now.Unix(),
|
||||
@ -150,6 +191,8 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
|
||||
DefaultDisplayUserID: user.DefaultDisplayUserID,
|
||||
DisplayUserIDKind: string(user.CurrentDisplayUserIDKind),
|
||||
DisplayUserIDExpiresAtMs: user.CurrentDisplayUserIDExpiresAtMs,
|
||||
ProfileCompleted: user.ProfileCompleted,
|
||||
OnboardingStatus: onboardingStatus,
|
||||
SessionID: sessionID,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
@ -158,6 +201,18 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
|
||||
}, nil
|
||||
}
|
||||
|
||||
func tokenOnboardingStatus(user userdomain.User) string {
|
||||
// 旧测试数据或手工种子可能没有显式状态;token 按 profile_completed 推导安全默认值。
|
||||
if user.OnboardingStatus != "" {
|
||||
return string(user.OnboardingStatus)
|
||||
}
|
||||
if user.ProfileCompleted {
|
||||
return string(userdomain.OnboardingStatusCompleted)
|
||||
}
|
||||
|
||||
return string(userdomain.OnboardingStatusProfileRequired)
|
||||
}
|
||||
|
||||
func (s *Service) freshUser(ctx context.Context, userID int64, nowMs int64, requestID string) (userdomain.User, error) {
|
||||
if s.userRepository == nil {
|
||||
// 没有用户读取能力时不能签发 token。
|
||||
|
||||
@ -7,8 +7,8 @@ import (
|
||||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||||
)
|
||||
|
||||
// StaticThirdPartyVerifier 是本地 v1 verifier。
|
||||
// credential 在首版本地实现中就是 provider_subject;真实 SDK 可替换该接口。
|
||||
// StaticThirdPartyVerifier 是测试和本地 mock verifier。
|
||||
// credential 会被当作 provider_subject,不能用于 provider=firebase 或任何真实线上 provider。
|
||||
type StaticThirdPartyVerifier struct {
|
||||
// allowed 保存 provider allowlist,key 已统一小写。
|
||||
allowed map[string]bool
|
||||
@ -28,9 +28,9 @@ func NewStaticThirdPartyVerifier(providers []string) *StaticThirdPartyVerifier {
|
||||
return &StaticThirdPartyVerifier{allowed: allowed}
|
||||
}
|
||||
|
||||
// Verify 把本地 credential 解析为 provider_subject。
|
||||
// Verify 把 mock credential 解析为 provider_subject。
|
||||
func (v *StaticThirdPartyVerifier) Verify(_ context.Context, provider string, credential string) (authdomain.ThirdPartyProfile, error) {
|
||||
// 本地 v1 把 credential 当 provider_subject,真实环境替换该接口即可。
|
||||
// mock verifier 只服务显式 allowlist;provider=firebase 会被 RoutedThirdPartyVerifier 截获。
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
subject := strings.TrimSpace(credential)
|
||||
if !v.allowed[provider] || subject == "" {
|
||||
|
||||
@ -14,6 +14,13 @@ func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username
|
||||
// user_id 必须来自 gateway 鉴权上下文,service 层仍兜底校验。
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||
}
|
||||
if s.userRepository == nil {
|
||||
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||
}
|
||||
if err := s.requireCompletedProfile(ctx, userID); err != nil {
|
||||
// 注册页唯一入口是 CompleteOnboarding;未完成资料的用户不能通过资料编辑拼出半完成状态。
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
username = trimmedStringPtr(username)
|
||||
avatar = trimmedStringPtr(avatar)
|
||||
birth = trimmedStringPtr(birth)
|
||||
@ -45,9 +52,6 @@ func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "birth must use yyyy-mm-dd")
|
||||
}
|
||||
}
|
||||
if s.userRepository == nil {
|
||||
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||
}
|
||||
|
||||
return s.userRepository.UpdateUserProfile(ctx, userdomain.ProfileUpdateCommand{
|
||||
UserID: userID,
|
||||
@ -58,25 +62,91 @@ func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username
|
||||
})
|
||||
}
|
||||
|
||||
// CompleteOnboarding 是注册页唯一提交入口。
|
||||
// 它必须一次性校验并写入 username/avatar/country/region_id/profile_completed,避免客户端拆接口拼出半完成状态。
|
||||
func (s *Service) CompleteOnboarding(ctx context.Context, userID int64, username string, avatar string, country string) (userdomain.User, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
avatar = strings.TrimSpace(avatar)
|
||||
country = userdomain.NormalizeCountryCode(country)
|
||||
if userID <= 0 {
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||
}
|
||||
if username == "" {
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "username is required")
|
||||
}
|
||||
if userdomain.ProfileStringTooLong(username, userdomain.ProfileUsernameMaxRunes) {
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "username is too long")
|
||||
}
|
||||
if avatar == "" {
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar is required")
|
||||
}
|
||||
if userdomain.ProfileStringTooLong(avatar, userdomain.ProfileAvatarMaxRunes) {
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar is too long")
|
||||
}
|
||||
if !userdomain.ValidProfileAvatarURL(avatar) {
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar must be an absolute http or https URL")
|
||||
}
|
||||
if country == "" {
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "country is required")
|
||||
}
|
||||
if !userdomain.ValidCountryCode(country) {
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "country must use 2 to 3 uppercase letters")
|
||||
}
|
||||
if s.userRepository == nil {
|
||||
return userdomain.User{}, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||
}
|
||||
if s.countryRegionRepository == nil {
|
||||
return userdomain.User{}, xerr.New(xerr.Unavailable, "country repository is not configured")
|
||||
}
|
||||
|
||||
resolvedCountry, ok, err := s.countryRegionRepository.ResolveEnabledCountryByCode(ctx, country)
|
||||
if err != nil {
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
if !ok {
|
||||
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "country is not supported")
|
||||
}
|
||||
var regionID int64
|
||||
if region, ok, err := s.countryRegionRepository.ResolveActiveRegionByCountry(ctx, resolvedCountry.CountryCode); err != nil {
|
||||
return userdomain.User{}, err
|
||||
} else if ok {
|
||||
// 国家可能暂时没有区域映射,region_id=0 时由房间列表落到 GLOBAL。
|
||||
regionID = region.RegionID
|
||||
}
|
||||
|
||||
return s.userRepository.CompleteOnboarding(ctx, userdomain.CompleteOnboardingCommand{
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Avatar: avatar,
|
||||
Country: resolvedCountry.CountryCode,
|
||||
RegionID: regionID,
|
||||
CompletedAtMs: s.now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// ChangeUserCountry 修改用户国家并写审计日志;同一用户滚动 30 天内只能成功修改一次。
|
||||
func (s *Service) ChangeUserCountry(ctx context.Context, userID int64, country string, requestID string) (userdomain.User, int64, error) {
|
||||
country = userdomain.NormalizeCountryCode(country)
|
||||
if userID <= 0 {
|
||||
return userdomain.User{}, 0, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||
}
|
||||
if s.userRepository == nil {
|
||||
return userdomain.User{}, 0, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||
}
|
||||
if err := s.requireCompletedProfile(ctx, userID); err != nil {
|
||||
// 未完成 onboarding 时拒绝在这里改国家,避免首次国家选择写入变更日志并消耗冷却期。
|
||||
return userdomain.User{}, 0, err
|
||||
}
|
||||
if country == "" {
|
||||
return userdomain.User{}, 0, xerr.New(xerr.InvalidArgument, "country is required")
|
||||
}
|
||||
if !userdomain.ValidCountryCode(country) {
|
||||
return userdomain.User{}, 0, xerr.New(xerr.InvalidArgument, "country must use 2 to 3 uppercase letters")
|
||||
}
|
||||
if s.userRepository == nil {
|
||||
return userdomain.User{}, 0, xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||
}
|
||||
if s.countryRegionRepository == nil {
|
||||
return userdomain.User{}, 0, xerr.New(xerr.Unavailable, "country repository is not configured")
|
||||
}
|
||||
resolvedCountry, ok, err := s.countryRegionRepository.ResolveActiveCountryByCode(ctx, country)
|
||||
resolvedCountry, ok, err := s.countryRegionRepository.ResolveEnabledCountryByCode(ctx, country)
|
||||
if err != nil {
|
||||
return userdomain.User{}, 0, err
|
||||
}
|
||||
@ -101,6 +171,24 @@ func (s *Service) ChangeUserCountry(ctx context.Context, userID int64, country s
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) requireCompletedProfile(ctx context.Context, userID int64) error {
|
||||
if s.userRepository == nil {
|
||||
return xerr.New(xerr.Unavailable, "user repository is not configured")
|
||||
}
|
||||
|
||||
user, err := s.userRepository.GetUser(ctx, userID)
|
||||
if err != nil {
|
||||
// GetUser 已经把缺失用户映射成稳定 NOT_FOUND,这里不改写错误语义。
|
||||
return err
|
||||
}
|
||||
if !user.ProfileCompleted {
|
||||
// service 层必须基于数据库事实兜底,而不是只信 gateway token 里的 profile_completed 快照。
|
||||
return xerr.New(xerr.ProfileRequired, "profile required")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func trimmedStringPtr(value *string) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
|
||||
@ -12,80 +12,87 @@ import (
|
||||
const (
|
||||
maxCountryNameLength = 128
|
||||
maxCountryDisplayNameLength = 128
|
||||
maxCountryFlagLength = 16
|
||||
maxRegionNameLength = 128
|
||||
)
|
||||
|
||||
// CreateCountry 创建国家主数据;country_code 创建后不可变。
|
||||
func (s *Service) CreateCountry(ctx context.Context, countryName string, countryCode string, countryDisplayName string, sortOrder int32, operatorUserID int64, requestID string) (userdomain.Country, error) {
|
||||
countryName = strings.TrimSpace(countryName)
|
||||
countryCode = userdomain.NormalizeCountryCode(countryCode)
|
||||
countryDisplayName = strings.TrimSpace(countryDisplayName)
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if err := validateCountryPayload(countryName, countryCode, countryDisplayName); err != nil {
|
||||
func (s *Service) CreateCountry(ctx context.Context, command userdomain.CreateCountryCommand) (userdomain.Country, error) {
|
||||
command = normalizeCreateCountryCommand(command)
|
||||
if err := validateCountryPayload(command.CountryName, command.CountryCode, command.ISOAlpha3, command.ISONumeric, command.CountryDisplayName, command.PhoneCountryCode, command.Flag); err != nil {
|
||||
return userdomain.Country{}, err
|
||||
}
|
||||
if requestID == "" {
|
||||
if strings.TrimSpace(command.RequestID) == "" {
|
||||
return userdomain.Country{}, xerr.New(xerr.InvalidArgument, "request_id is required")
|
||||
}
|
||||
if s.countryAdminRepository == nil {
|
||||
return userdomain.Country{}, xerr.New(xerr.Unavailable, "country repository is not configured")
|
||||
}
|
||||
|
||||
nowMs := s.now().UnixMilli()
|
||||
return s.countryAdminRepository.CreateCountry(ctx, userdomain.CreateCountryCommand{
|
||||
CountryName: countryName,
|
||||
CountryCode: countryCode,
|
||||
CountryDisplayName: countryDisplayName,
|
||||
SortOrder: sortOrder,
|
||||
OperatorUserID: operatorUserID,
|
||||
RequestID: requestID,
|
||||
NowMs: nowMs,
|
||||
})
|
||||
command.NowMs = s.now().UnixMilli()
|
||||
return s.countryAdminRepository.CreateCountry(ctx, command)
|
||||
}
|
||||
|
||||
// UpdateCountry 修改国家展示字段;国家码不允许原地修改。
|
||||
func (s *Service) UpdateCountry(ctx context.Context, countryID int64, countryName string, countryDisplayName string, sortOrder int32, operatorUserID int64, requestID string) (userdomain.Country, error) {
|
||||
countryName = strings.TrimSpace(countryName)
|
||||
countryDisplayName = strings.TrimSpace(countryDisplayName)
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if countryID <= 0 {
|
||||
func (s *Service) UpdateCountry(ctx context.Context, command userdomain.UpdateCountryCommand) (userdomain.Country, error) {
|
||||
command = normalizeUpdateCountryCommand(command)
|
||||
if command.CountryID <= 0 {
|
||||
return userdomain.Country{}, xerr.New(xerr.InvalidArgument, "country_id is required")
|
||||
}
|
||||
if err := validateCountryNames(countryName, countryDisplayName); err != nil {
|
||||
if err := validateCountryMetadata(command.CountryName, command.ISOAlpha3, command.ISONumeric, command.CountryDisplayName, command.PhoneCountryCode, command.Flag); err != nil {
|
||||
return userdomain.Country{}, err
|
||||
}
|
||||
if requestID == "" {
|
||||
if strings.TrimSpace(command.RequestID) == "" {
|
||||
return userdomain.Country{}, xerr.New(xerr.InvalidArgument, "request_id is required")
|
||||
}
|
||||
if s.countryAdminRepository == nil {
|
||||
return userdomain.Country{}, xerr.New(xerr.Unavailable, "country repository is not configured")
|
||||
}
|
||||
|
||||
return s.countryAdminRepository.UpdateCountry(ctx, userdomain.UpdateCountryCommand{
|
||||
CountryID: countryID,
|
||||
CountryName: countryName,
|
||||
CountryDisplayName: countryDisplayName,
|
||||
SortOrder: sortOrder,
|
||||
OperatorUserID: operatorUserID,
|
||||
RequestID: requestID,
|
||||
NowMs: s.now().UnixMilli(),
|
||||
})
|
||||
command.NowMs = s.now().UnixMilli()
|
||||
return s.countryAdminRepository.UpdateCountry(ctx, command)
|
||||
}
|
||||
|
||||
// ListCountries 返回国家主数据,status 为空时返回全部。
|
||||
func (s *Service) ListCountries(ctx context.Context, status string) ([]userdomain.Country, error) {
|
||||
status = strings.TrimSpace(status)
|
||||
if status != "" && status != userdomain.CountryStatusActive && status != userdomain.CountryStatusDisabled {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "country status is invalid")
|
||||
}
|
||||
// ListCountries 返回国家主数据;enabled 为空时返回全部国家。
|
||||
func (s *Service) ListCountries(ctx context.Context, enabled *bool) ([]userdomain.Country, error) {
|
||||
if s.countryAdminRepository == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "country repository is not configured")
|
||||
}
|
||||
|
||||
return s.countryAdminRepository.ListCountries(ctx, userdomain.CountryFilter{Status: status})
|
||||
return s.countryAdminRepository.ListCountries(ctx, userdomain.CountryFilter{Enabled: enabled})
|
||||
}
|
||||
|
||||
// DisableCountry 停用国家;后续注册、改国家和区域配置都不能再选择它。
|
||||
// ListRegistrationCountries 返回 App 注册页可选国家列表。
|
||||
func (s *Service) ListRegistrationCountries(ctx context.Context) ([]userdomain.Country, error) {
|
||||
if s.countryRegionRepository == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "country repository is not configured")
|
||||
}
|
||||
|
||||
return s.countryRegionRepository.ListRegistrationCountries(ctx)
|
||||
}
|
||||
|
||||
// EnableCountry 重新开放国家;历史区域映射和历史用户国家快照不需要改写。
|
||||
func (s *Service) EnableCountry(ctx context.Context, countryID int64, operatorUserID int64, requestID string) (userdomain.Country, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if countryID <= 0 {
|
||||
return userdomain.Country{}, xerr.New(xerr.InvalidArgument, "country_id is required")
|
||||
}
|
||||
if requestID == "" {
|
||||
return userdomain.Country{}, xerr.New(xerr.InvalidArgument, "request_id is required")
|
||||
}
|
||||
if s.countryAdminRepository == nil {
|
||||
return userdomain.Country{}, xerr.New(xerr.Unavailable, "country repository is not configured")
|
||||
}
|
||||
|
||||
return s.countryAdminRepository.EnableCountry(ctx, userdomain.EnableCountryCommand{
|
||||
CountryID: countryID,
|
||||
OperatorUserID: operatorUserID,
|
||||
RequestID: requestID,
|
||||
NowMs: s.now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// DisableCountry 只关闭 App 选择入口;不删除国家、不清空历史用户,也不释放区域配置。
|
||||
func (s *Service) DisableCountry(ctx context.Context, countryID int64, operatorUserID int64, requestID string) (userdomain.Country, error) {
|
||||
requestID = strings.TrimSpace(requestID)
|
||||
if countryID <= 0 {
|
||||
@ -237,20 +244,55 @@ func (s *Service) DisableRegion(ctx context.Context, regionID int64, operatorUse
|
||||
})
|
||||
}
|
||||
|
||||
func validateCountryPayload(countryName string, countryCode string, countryDisplayName string) error {
|
||||
func normalizeCreateCountryCommand(command userdomain.CreateCountryCommand) userdomain.CreateCountryCommand {
|
||||
command.CountryName = strings.TrimSpace(command.CountryName)
|
||||
command.CountryCode = userdomain.NormalizeCountryCode(command.CountryCode)
|
||||
command.ISOAlpha3 = userdomain.NormalizeISOAlpha3(command.ISOAlpha3)
|
||||
command.ISONumeric = userdomain.NormalizeISONumeric(command.ISONumeric)
|
||||
command.CountryDisplayName = strings.TrimSpace(command.CountryDisplayName)
|
||||
command.PhoneCountryCode = userdomain.NormalizePhoneCountryCode(command.PhoneCountryCode)
|
||||
command.Flag = strings.TrimSpace(command.Flag)
|
||||
command.RequestID = strings.TrimSpace(command.RequestID)
|
||||
return command
|
||||
}
|
||||
|
||||
func normalizeUpdateCountryCommand(command userdomain.UpdateCountryCommand) userdomain.UpdateCountryCommand {
|
||||
command.CountryName = strings.TrimSpace(command.CountryName)
|
||||
command.ISOAlpha3 = userdomain.NormalizeISOAlpha3(command.ISOAlpha3)
|
||||
command.ISONumeric = userdomain.NormalizeISONumeric(command.ISONumeric)
|
||||
command.CountryDisplayName = strings.TrimSpace(command.CountryDisplayName)
|
||||
command.PhoneCountryCode = userdomain.NormalizePhoneCountryCode(command.PhoneCountryCode)
|
||||
command.Flag = strings.TrimSpace(command.Flag)
|
||||
command.RequestID = strings.TrimSpace(command.RequestID)
|
||||
return command
|
||||
}
|
||||
|
||||
func validateCountryPayload(countryName string, countryCode string, isoAlpha3 string, isoNumeric string, countryDisplayName string, phoneCountryCode string, flag string) error {
|
||||
if !userdomain.ValidCountryCode(countryCode) {
|
||||
return xerr.New(xerr.InvalidArgument, "country_code must use 2 to 3 uppercase letters")
|
||||
}
|
||||
return validateCountryNames(countryName, countryDisplayName)
|
||||
return validateCountryMetadata(countryName, isoAlpha3, isoNumeric, countryDisplayName, phoneCountryCode, flag)
|
||||
}
|
||||
|
||||
func validateCountryNames(countryName string, countryDisplayName string) error {
|
||||
func validateCountryMetadata(countryName string, isoAlpha3 string, isoNumeric string, countryDisplayName string, phoneCountryCode string, flag string) error {
|
||||
if countryName == "" || utf8.RuneCountInString(countryName) > maxCountryNameLength {
|
||||
return xerr.New(xerr.InvalidArgument, "country_name length is invalid")
|
||||
}
|
||||
if !userdomain.ValidISOAlpha3(isoAlpha3) {
|
||||
return xerr.New(xerr.InvalidArgument, "iso_alpha3 is invalid")
|
||||
}
|
||||
if !userdomain.ValidISONumeric(isoNumeric) {
|
||||
return xerr.New(xerr.InvalidArgument, "iso_numeric is invalid")
|
||||
}
|
||||
if countryDisplayName == "" || utf8.RuneCountInString(countryDisplayName) > maxCountryDisplayNameLength {
|
||||
return xerr.New(xerr.InvalidArgument, "country_display_name length is invalid")
|
||||
}
|
||||
if !userdomain.ValidPhoneCountryCode(phoneCountryCode) {
|
||||
return xerr.New(xerr.InvalidArgument, "phone_country_code is invalid")
|
||||
}
|
||||
if utf8.RuneCountInString(flag) > maxCountryFlagLength {
|
||||
return xerr.New(xerr.InvalidArgument, "flag is too long")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -20,6 +20,8 @@ type UserRepository interface {
|
||||
CreateUserWithIdentity(ctx context.Context, createdUser userdomain.User, identity userdomain.Identity) error
|
||||
// UpdateUserProfile 原子修改用户基础资料。
|
||||
UpdateUserProfile(ctx context.Context, command userdomain.ProfileUpdateCommand) (userdomain.User, error)
|
||||
// CompleteOnboarding 原子写注册页必填资料并标记 profile_completed。
|
||||
CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error)
|
||||
// ChangeUserCountry 原子修改用户国家并写变更日志。
|
||||
ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, error)
|
||||
}
|
||||
@ -27,8 +29,10 @@ type UserRepository interface {
|
||||
// CountryRegionRepository 负责把用户选择的 country_code 解析成国家主数据和当前 active 区域。
|
||||
// 注册和改国家都依赖这组同步读,避免未知国家码进入 users 主表。
|
||||
type CountryRegionRepository interface {
|
||||
// ResolveActiveCountryByCode 按国家码读取 active 国家主数据。
|
||||
ResolveActiveCountryByCode(ctx context.Context, countryCode string) (userdomain.Country, bool, error)
|
||||
// ResolveEnabledCountryByCode 按国家码读取 App 可选国家;必须存在且 enabled=true。
|
||||
ResolveEnabledCountryByCode(ctx context.Context, countryCode string) (userdomain.Country, bool, error)
|
||||
// ListRegistrationCountries 返回 App 注册页可选国家列表。
|
||||
ListRegistrationCountries(ctx context.Context) ([]userdomain.Country, error)
|
||||
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域;无映射返回 ok=false。
|
||||
ResolveActiveRegionByCountry(ctx context.Context, countryCode string) (userdomain.Region, bool, error)
|
||||
}
|
||||
@ -39,6 +43,7 @@ type CountryAdminRepository interface {
|
||||
CreateCountry(ctx context.Context, command userdomain.CreateCountryCommand) (userdomain.Country, error)
|
||||
UpdateCountry(ctx context.Context, command userdomain.UpdateCountryCommand) (userdomain.Country, error)
|
||||
ListCountries(ctx context.Context, filter userdomain.CountryFilter) ([]userdomain.Country, error)
|
||||
EnableCountry(ctx context.Context, command userdomain.EnableCountryCommand) (userdomain.Country, error)
|
||||
DisableCountry(ctx context.Context, command userdomain.DisableCountryCommand) (userdomain.Country, error)
|
||||
}
|
||||
|
||||
|
||||
@ -48,6 +48,39 @@ func strptr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func containsCountryCode(countries []userdomain.Country, code string) bool {
|
||||
for _, country := range countries {
|
||||
if country.CountryCode == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func mustResolveRegionByCountry(t *testing.T, repository *mysqltest.Repository, country string) userdomain.Region {
|
||||
t.Helper()
|
||||
region, ok, err := repository.ResolveActiveRegionByCountry(context.Background(), country)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve region for country %s failed: %v", country, err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatalf("country %s should have seeded region mapping", country)
|
||||
}
|
||||
|
||||
return region
|
||||
}
|
||||
|
||||
func completedUser(user userdomain.User) userdomain.User {
|
||||
// 普通资料编辑和国家修改测试默认模拟已完成注册资料的用户;未完成场景用专门用例覆盖。
|
||||
user.ProfileCompleted = true
|
||||
if user.ProfileCompletedAtMs == 0 {
|
||||
user.ProfileCompletedAtMs = 1
|
||||
}
|
||||
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
|
||||
return user
|
||||
}
|
||||
|
||||
func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service {
|
||||
base := []userservice.Option{
|
||||
userservice.WithIdentityRepository(repository),
|
||||
@ -61,13 +94,20 @@ func newUserService(repository *mysqltest.Repository, options ...userservice.Opt
|
||||
|
||||
func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) userdomain.Country {
|
||||
t.Helper()
|
||||
if country, ok, err := repository.ResolveActiveCountryByCode(context.Background(), code); err != nil {
|
||||
if country, ok, err := repository.ResolveEnabledCountryByCode(context.Background(), code); err != nil {
|
||||
t.Fatalf("resolve country %s failed: %v", code, err)
|
||||
} else if ok {
|
||||
return country
|
||||
}
|
||||
svc := newUserService(repository)
|
||||
country, err := svc.CreateCountry(context.Background(), code+" Name", code, code+" Display", 0, 1, "seed-country-"+code)
|
||||
country, err := svc.CreateCountry(context.Background(), userdomain.CreateCountryCommand{
|
||||
CountryName: code + " Name",
|
||||
CountryCode: code,
|
||||
CountryDisplayName: code + " Display",
|
||||
Enabled: true,
|
||||
OperatorUserID: 1,
|
||||
RequestID: "seed-country-" + code,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed country %s failed: %v", code, err)
|
||||
}
|
||||
@ -88,7 +128,7 @@ func seedRegion(t *testing.T, repository *mysqltest.Repository, code string, cou
|
||||
func TestGetUserUsesRepository(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
// PutUser 是测试辅助入口,直接准备用户主记录。
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})
|
||||
repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive}))
|
||||
|
||||
svc := newUserService(repository)
|
||||
user, err := svc.GetUser(context.Background(), 10001)
|
||||
@ -103,14 +143,14 @@ func TestGetUserUsesRepository(t *testing.T) {
|
||||
|
||||
func TestUpdateUserProfile(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.PutUser(userdomain.User{
|
||||
repository.PutUser(completedUser(userdomain.User{
|
||||
UserID: 10001,
|
||||
CurrentDisplayUserID: "100001",
|
||||
Username: "old-name",
|
||||
Avatar: "old-avatar",
|
||||
BirthDate: "1999-01-01",
|
||||
Status: userdomain.StatusActive,
|
||||
})
|
||||
}))
|
||||
now := time.UnixMilli(2000)
|
||||
svc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
|
||||
|
||||
@ -126,6 +166,11 @@ func TestUpdateUserProfile(t *testing.T) {
|
||||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("expected invalid birth format, got %v", err)
|
||||
}
|
||||
_, err = svc.UpdateUserProfile(context.Background(), 10001, nil, nil, strptr("0001-01-01"))
|
||||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
// Go 零值日期虽然能被 time.Parse 接受,但 MySQL DATE 严格模式不能写入,必须在 service 层拒绝。
|
||||
t.Fatalf("expected invalid birth range, got %v", err)
|
||||
}
|
||||
|
||||
invalidCases := []struct {
|
||||
name string
|
||||
@ -168,12 +213,81 @@ func TestUpdateUserProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOnboardingWritesProfileRegionAndSkipsCountryCooldown(t *testing.T) {
|
||||
// CompleteOnboarding 是注册页原子提交点,首次国家写入不能污染后续国家修改冷却日志。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
seedCountry(t, repository, "US")
|
||||
region := mustResolveRegionByCountry(t, repository, "SG")
|
||||
repository.PutUser(userdomain.User{
|
||||
UserID: 10001,
|
||||
CurrentDisplayUserID: "100001",
|
||||
Status: userdomain.StatusActive,
|
||||
})
|
||||
current := time.UnixMilli(1000)
|
||||
svc := newUserService(repository,
|
||||
userservice.WithClock(func() time.Time { return current }),
|
||||
userservice.WithCountryChangeCooldown(time.Hour),
|
||||
)
|
||||
|
||||
user, err := svc.CompleteOnboarding(ctx, 10001, " hy ", " https://cdn.example/a.png ", " sg ")
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteOnboarding failed: %v", err)
|
||||
}
|
||||
if user.Username != "hy" || user.Avatar != "https://cdn.example/a.png" || user.Country != "SG" || user.RegionID != region.RegionID {
|
||||
t.Fatalf("onboarding profile mismatch: %+v", user)
|
||||
}
|
||||
if !user.ProfileCompleted || user.ProfileCompletedAtMs != 1000 || user.OnboardingStatus != userdomain.OnboardingStatusCompleted {
|
||||
t.Fatalf("onboarding completion flags mismatch: %+v", user)
|
||||
}
|
||||
|
||||
current = current.Add(10 * time.Minute)
|
||||
user, _, err = svc.ChangeUserCountry(ctx, 10001, "US", "req-change-after-onboarding")
|
||||
if err != nil {
|
||||
t.Fatalf("first country change after onboarding must not be cooled down: %v", err)
|
||||
}
|
||||
if user.Country != "US" {
|
||||
t.Fatalf("country after first change mismatch: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOnboardingValidation(t *testing.T) {
|
||||
// 必填字段和展示资料格式在 service 层稳定返回 INVALID_ARGUMENT,不能依赖 MySQL 约束报错。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})
|
||||
svc := newUserService(repository)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
username string
|
||||
avatar string
|
||||
country string
|
||||
}{
|
||||
{name: "username", username: " ", avatar: "https://cdn.example/a.png", country: "SG"},
|
||||
{name: "avatar", username: "hy", avatar: "", country: "SG"},
|
||||
{name: "avatar_url", username: "hy", avatar: "ftp://cdn.example/a.png", country: "SG"},
|
||||
{name: "country", username: "hy", avatar: "https://cdn.example/a.png", country: ""},
|
||||
{name: "unknown_country", username: "hy", avatar: "https://cdn.example/a.png", country: "ZZ"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := svc.CompleteOnboarding(ctx, 10001, tc.username, tc.avatar, tc.country)
|
||||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("expected INVALID_ARGUMENT, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeUserCountryWritesCooldown(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "CN")
|
||||
seedCountry(t, repository, "US")
|
||||
seedCountry(t, repository, "JP")
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive})
|
||||
repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive}))
|
||||
current := time.UnixMilli(1000)
|
||||
svc := newUserService(repository,
|
||||
userservice.WithClock(func() time.Time { return current }),
|
||||
@ -207,32 +321,87 @@ func TestChangeUserCountryWritesCooldown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileMutationsRequireCompletedOnboarding(t *testing.T) {
|
||||
// 未完成注册资料时,CompleteOnboarding 是唯一资料写入口;改国家不能提前写日志或消耗冷却。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "CN")
|
||||
seedCountry(t, repository, "US")
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive})
|
||||
current := time.UnixMilli(1000)
|
||||
svc := newUserService(repository,
|
||||
userservice.WithClock(func() time.Time { return current }),
|
||||
userservice.WithCountryChangeCooldown(time.Hour),
|
||||
)
|
||||
|
||||
if _, err := svc.UpdateUserProfile(ctx, 10001, strptr("new-name"), nil, nil); !xerr.IsCode(err, xerr.ProfileRequired) {
|
||||
t.Fatalf("expected PROFILE_REQUIRED for incomplete profile update, got %v", err)
|
||||
}
|
||||
if _, _, err := svc.ChangeUserCountry(ctx, 10001, "US", "req-before-onboarding"); !xerr.IsCode(err, xerr.ProfileRequired) {
|
||||
t.Fatalf("expected PROFILE_REQUIRED for incomplete country change, got %v", err)
|
||||
}
|
||||
|
||||
completed := completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive})
|
||||
repository.PutUser(completed)
|
||||
user, _, err := svc.ChangeUserCountry(ctx, 10001, "US", "req-after-onboarding")
|
||||
if err != nil {
|
||||
// 如果未完成阶段误写了国家变更日志,这里会被冷却期挡住。
|
||||
t.Fatalf("first country change after completing profile should not be cooled down: %v", err)
|
||||
}
|
||||
if user.Country != "US" {
|
||||
t.Fatalf("country after completed profile mismatch: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountryAndRegionAdminValidation(t *testing.T) {
|
||||
// 国家管理要接受小写归一,同时拒绝非法国家码、重复国家码和重复区域归属。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := newUserService(repository)
|
||||
|
||||
aa, err := svc.CreateCountry(ctx, "Alpha Area", " aa ", "测试国家", 10, 1, "req-aa")
|
||||
aa, err := svc.CreateCountry(ctx, userdomain.CreateCountryCommand{
|
||||
CountryName: "Alpha Area",
|
||||
CountryCode: " aa ",
|
||||
ISOAlpha3: " aaa ",
|
||||
ISONumeric: " 001 ",
|
||||
CountryDisplayName: "测试国家",
|
||||
PhoneCountryCode: " +1 ",
|
||||
Flag: "AA",
|
||||
Enabled: true,
|
||||
SortOrder: 10,
|
||||
OperatorUserID: 1,
|
||||
RequestID: "req-aa",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCountry AA failed: %v", err)
|
||||
}
|
||||
if aa.CountryCode != "AA" {
|
||||
if aa.CountryCode != "AA" || aa.ISOAlpha3 != "AAA" || aa.ISONumeric != "001" || aa.PhoneCountryCode != "+1" || !aa.Enabled {
|
||||
t.Fatalf("country code should normalize to AA: %+v", aa)
|
||||
}
|
||||
tla, err := svc.CreateCountry(ctx, "Three Letter Land", "tla", "三字码国家", 30, 1, "req-tla")
|
||||
tla, err := svc.CreateCountry(ctx, userdomain.CreateCountryCommand{
|
||||
CountryName: "Three Letter Land",
|
||||
CountryCode: "tla",
|
||||
CountryDisplayName: "三字码国家",
|
||||
Enabled: true,
|
||||
SortOrder: 30,
|
||||
OperatorUserID: 1,
|
||||
RequestID: "req-tla",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCountry TLA failed: %v", err)
|
||||
}
|
||||
if tla.CountryCode != "TLA" {
|
||||
t.Fatalf("three-letter canonical country code mismatch: %+v", tla)
|
||||
}
|
||||
if _, err := svc.CreateCountry(ctx, "Alpha Area Duplicate", "AA", "重复国家", 11, 1, "req-aa-dup"); !xerr.IsCode(err, xerr.Conflict) {
|
||||
if _, err := svc.CreateCountry(ctx, userdomain.CreateCountryCommand{CountryName: "Alpha Area Duplicate", CountryCode: "AA", CountryDisplayName: "重复国家", Enabled: true, SortOrder: 11, OperatorUserID: 1, RequestID: "req-aa-dup"}); !xerr.IsCode(err, xerr.Conflict) {
|
||||
t.Fatalf("expected duplicate country conflict, got %v", err)
|
||||
}
|
||||
if _, err := svc.CreateCountry(ctx, "Bad", "C1", "Bad", 0, 1, "req-bad"); !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
if _, err := svc.CreateCountry(ctx, userdomain.CreateCountryCommand{CountryName: "Bad", CountryCode: "C1", CountryDisplayName: "Bad", Enabled: true, OperatorUserID: 1, RequestID: "req-bad"}); !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("expected invalid country code, got %v", err)
|
||||
}
|
||||
if _, err := svc.CreateCountry(ctx, userdomain.CreateCountryCommand{CountryName: "Bad ISO", CountryCode: "BI", ISOAlpha3: "B1D", CountryDisplayName: "Bad ISO", Enabled: true, OperatorUserID: 1, RequestID: "req-bad-iso"}); !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("expected invalid iso alpha3, got %v", err)
|
||||
}
|
||||
|
||||
sea, err := svc.CreateRegion(ctx, "sea", "Southeast Asia", []string{"AA"}, 0, 1, "req-sea")
|
||||
if err != nil {
|
||||
@ -241,19 +410,104 @@ func TestCountryAndRegionAdminValidation(t *testing.T) {
|
||||
if sea.RegionCode != "SEA" || len(sea.Countries) != 1 || sea.Countries[0] != "AA" {
|
||||
t.Fatalf("region normalization mismatch: %+v", sea)
|
||||
}
|
||||
subregion, err := svc.CreateRegion(ctx, "Test Subregion", "测试子区域", []string{"TLA"}, 0, 1, "req-subregion")
|
||||
if err != nil {
|
||||
t.Fatalf("descriptive subregion code should be accepted: %v", err)
|
||||
}
|
||||
if subregion.RegionCode != "Test Subregion" || len(subregion.Countries) != 1 || subregion.Countries[0] != "TLA" {
|
||||
t.Fatalf("descriptive region code mismatch: %+v", subregion)
|
||||
}
|
||||
if _, err := svc.CreateRegion(ctx, "APAC", "Asia Pacific", []string{"AA"}, 0, 1, "req-apac"); !xerr.IsCode(err, xerr.RegionCountryConflict) {
|
||||
t.Fatalf("expected region country conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationCountriesUseEnabledFlagAndRegionKeepsDisabledCountryMapping(t *testing.T) {
|
||||
// enabled 只控制 App 用户能否选择;区域预配置仍允许引用暂未开放的国家。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := newUserService(repository, userservice.WithCountryChangeCooldown(0))
|
||||
|
||||
openCountry, err := svc.CreateCountry(ctx, userdomain.CreateCountryCommand{
|
||||
CountryName: "Beta Bay",
|
||||
CountryCode: "BB",
|
||||
ISONumeric: "997",
|
||||
CountryDisplayName: "Beta",
|
||||
PhoneCountryCode: "+997",
|
||||
Enabled: true,
|
||||
SortOrder: -50,
|
||||
OperatorUserID: 1,
|
||||
RequestID: "req-bb",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCountry BB failed: %v", err)
|
||||
}
|
||||
closedCountry, err := svc.CreateCountry(ctx, userdomain.CreateCountryCommand{
|
||||
CountryName: "Closed Country",
|
||||
CountryCode: "CC",
|
||||
ISONumeric: "998",
|
||||
CountryDisplayName: "Closed",
|
||||
PhoneCountryCode: "+998",
|
||||
Enabled: false,
|
||||
SortOrder: -100,
|
||||
OperatorUserID: 1,
|
||||
RequestID: "req-cc",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCountry CC failed: %v", err)
|
||||
}
|
||||
|
||||
countries, err := svc.ListRegistrationCountries(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRegistrationCountries failed: %v", err)
|
||||
}
|
||||
if !containsCountryCode(countries, openCountry.CountryCode) || containsCountryCode(countries, closedCountry.CountryCode) {
|
||||
t.Fatalf("registration list must include enabled and exclude disabled countries: %+v", countries)
|
||||
}
|
||||
region, err := svc.CreateRegion(ctx, "pre", "Preconfigured", []string{closedCountry.CountryCode}, 0, 1, "req-region-closed")
|
||||
if err != nil {
|
||||
t.Fatalf("enabled=false country should still be usable by region admin: %v", err)
|
||||
}
|
||||
if len(region.Countries) != 1 || region.Countries[0] != closedCountry.CountryCode {
|
||||
t.Fatalf("region countries mismatch: %+v", region)
|
||||
}
|
||||
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})
|
||||
if _, _, err := svc.ChangeUserCountry(ctx, 10001, closedCountry.CountryCode, "req-change-closed"); !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("disabled country must be rejected for user selection, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := svc.EnableCountry(ctx, closedCountry.CountryID, 1, "req-enable-cc"); err != nil {
|
||||
t.Fatalf("EnableCountry failed: %v", err)
|
||||
}
|
||||
countries, err = svc.ListRegistrationCountries(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListRegistrationCountries after enable failed: %v", err)
|
||||
}
|
||||
if len(countries) == 0 || countries[0].CountryCode != closedCountry.CountryCode || !containsCountryCode(countries, openCountry.CountryCode) {
|
||||
t.Fatalf("registration list should sort enabled countries by sort_order: %+v", countries)
|
||||
}
|
||||
|
||||
if _, err := svc.DisableCountry(ctx, closedCountry.CountryID, 1, "req-disable-cc"); err != nil {
|
||||
t.Fatalf("DisableCountry failed: %v", err)
|
||||
}
|
||||
region, err = svc.GetRegion(ctx, region.RegionID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetRegion failed: %v", err)
|
||||
}
|
||||
if len(region.Countries) != 1 || region.Countries[0] != closedCountry.CountryCode {
|
||||
t.Fatalf("DisableCountry must not release region mapping: %+v", region)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeUserCountryUpdatesRegionAndSameCountryRepairsStaleRegion(t *testing.T) {
|
||||
// 改国家同步重算 region_id;相同国家不写冷却日志,但允许修正历史 stale region_id。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "US")
|
||||
seedCountry(t, repository, "SG")
|
||||
region := seedRegion(t, repository, "SEA", []string{"SG"})
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "US", Status: userdomain.StatusActive})
|
||||
region := mustResolveRegionByCountry(t, repository, "SG")
|
||||
repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "US", Status: userdomain.StatusActive}))
|
||||
current := time.UnixMilli(1000)
|
||||
svc := newUserService(repository,
|
||||
userservice.WithClock(func() time.Time { return current }),
|
||||
@ -264,7 +518,7 @@ func TestChangeUserCountryUpdatesRegionAndSameCountryRepairsStaleRegion(t *testi
|
||||
if err != nil {
|
||||
t.Fatalf("ChangeUserCountry SG failed: %v", err)
|
||||
}
|
||||
if user.Country != "SG" || user.RegionID != region.RegionID || user.RegionCode != "SEA" {
|
||||
if user.Country != "SG" || user.RegionID != region.RegionID || user.RegionCode != region.RegionCode {
|
||||
t.Fatalf("region after country change mismatch: %+v", user)
|
||||
}
|
||||
|
||||
@ -291,7 +545,7 @@ func TestChangeUserCountryRejectsUnknownOrDisabledCountry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
country := seedCountry(t, repository, "MY")
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "", Status: userdomain.StatusActive})
|
||||
repository.PutUser(completedUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "", Status: userdomain.StatusActive}))
|
||||
svc := newUserService(repository, userservice.WithCountryChangeCooldown(0))
|
||||
if _, err := svc.DisableCountry(ctx, country.CountryID, 1, "disable-my"); err != nil {
|
||||
t.Fatalf("DisableCountry failed: %v", err)
|
||||
@ -308,12 +562,12 @@ func TestRegionRebuildWorkerUpdatesHistoricalUsers(t *testing.T) {
|
||||
// 管理端新增区域后,worker 必须消费 rebuild task,把历史同 country 用户刷到目标 region_id。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "SG", Status: userdomain.StatusActive})
|
||||
repository.PutUser(userdomain.User{UserID: 10002, CurrentDisplayUserID: "100002", Country: "SG", Status: userdomain.StatusActive})
|
||||
seedCountry(t, repository, "QAA")
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "QAA", Status: userdomain.StatusActive})
|
||||
repository.PutUser(userdomain.User{UserID: 10002, CurrentDisplayUserID: "100002", Country: "QAA", Status: userdomain.StatusActive})
|
||||
current := time.UnixMilli(1000)
|
||||
svc := newUserService(repository, userservice.WithClock(func() time.Time { return current }))
|
||||
region, err := svc.CreateRegion(ctx, "SEA", "Southeast Asia", []string{"SG"}, 0, 1, "req-sea")
|
||||
region, err := svc.CreateRegion(ctx, "SEA", "Southeast Asia", []string{"QAA"}, 0, 1, "req-sea")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegion failed: %v", err)
|
||||
}
|
||||
@ -341,14 +595,14 @@ func TestRegionRebuildWorkerSkipsStaleRevision(t *testing.T) {
|
||||
// 同一 country 出现更新 revision 后,旧任务必须 skipped,避免旧区域覆盖新区域或清空结果。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
seedCountry(t, repository, "QAB")
|
||||
current := time.UnixMilli(1000)
|
||||
svc := newUserService(repository, userservice.WithClock(func() time.Time { return current }))
|
||||
region, err := svc.CreateRegion(ctx, "SEA", "Southeast Asia", []string{"SG"}, 0, 1, "req-sea")
|
||||
region, err := svc.CreateRegion(ctx, "SEA", "Southeast Asia", []string{"QAB"}, 0, 1, "req-sea")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegion failed: %v", err)
|
||||
}
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "SG", RegionID: region.RegionID, Status: userdomain.StatusActive})
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "QAB", RegionID: region.RegionID, Status: userdomain.StatusActive})
|
||||
|
||||
current = time.UnixMilli(2000)
|
||||
if _, err := svc.DisableRegion(ctx, region.RegionID, 1, "req-disable-sea"); err != nil {
|
||||
|
||||
@ -87,6 +87,25 @@ func (r *Repository) FindActiveSessionByRefreshHash(ctx context.Context, refresh
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// FindActiveSessionByID 查找未过期且未吊销的 session。
|
||||
func (r *Repository) FindActiveSessionByID(ctx context.Context, sessionID string, nowMs int64) (authdomain.Session, error) {
|
||||
// onboarding 完成后只需要重签 access token,不能要求客户端再提交 refresh token 原文。
|
||||
session, err := r.findSessionByID(ctx, sessionID)
|
||||
if err != nil {
|
||||
return authdomain.Session{}, err
|
||||
}
|
||||
if session.RevokedAtMs > 0 {
|
||||
// 已吊销 session 不能继续签发任何 access token。
|
||||
return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked")
|
||||
}
|
||||
if session.ExpiresAtMs <= nowMs {
|
||||
// session 过期后只能重新登录,不能用完成注册接口续命。
|
||||
return authdomain.Session{}, xerr.New(xerr.SessionExpired, "session expired")
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// ReplaceSession 原子吊销旧 session 并创建新 session。
|
||||
func (r *Repository) ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error {
|
||||
// refresh 轮换必须原子吊销旧 session 并插入新 session。
|
||||
@ -240,6 +259,29 @@ func (r *Repository) findSessionByRefreshHash(ctx context.Context, refreshTokenH
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func (r *Repository) findSessionByID(ctx context.Context, sessionID string) (authdomain.Session, error) {
|
||||
// session_id 来自已校验 access token 的 sid claim,仍必须回查服务端 session 状态。
|
||||
var session authdomain.Session
|
||||
var revokedAt sql.NullInt64
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT session_id, user_id, refresh_token_hash, device_id, expires_at_ms, revoked_at_ms, created_at_ms, updated_at_ms
|
||||
FROM auth_sessions
|
||||
WHERE session_id = ?
|
||||
`, sessionID).Scan(&session.SessionID, &session.UserID, &session.RefreshTokenHash, &session.DeviceID, &session.ExpiresAtMs, &revokedAt, &session.CreatedAtMs, &session.UpdatedAtMs)
|
||||
if err == sql.ErrNoRows {
|
||||
// 不暴露 session_id 是否存在,统一按 revoked 处理。
|
||||
return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked")
|
||||
}
|
||||
if err != nil {
|
||||
return authdomain.Session{}, err
|
||||
}
|
||||
if revokedAt.Valid {
|
||||
session.RevokedAtMs = revokedAt.Int64
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func insertSession(ctx context.Context, tx *sql.Tx, session authdomain.Session) error {
|
||||
// insertSession 只在已有事务中使用,保证与 session 替换或三方注册同提交。
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
|
||||
@ -17,6 +17,41 @@ const (
|
||||
regionRebuildIntegrationDisplayUser = "990001001"
|
||||
)
|
||||
|
||||
func TestInitDBSeedsCountriesAndSubregionMappings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
schema := mysqlschema.New(t)
|
||||
repository, err := Open(ctx, schema.DSN)
|
||||
if err != nil {
|
||||
t.Fatalf("Open failed: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = repository.Close() })
|
||||
|
||||
// initdb 是本地和测试库的国家/区域来源;这里锁定 JSON 导入后的规模和核心映射。
|
||||
counts := map[string]int{
|
||||
"countries": 0,
|
||||
"regions": 0,
|
||||
"region_countries": 0,
|
||||
}
|
||||
for table := range counts {
|
||||
var count int
|
||||
if err := repository.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM "+table).Scan(&count); err != nil {
|
||||
t.Fatalf("count %s failed: %v", table, err)
|
||||
}
|
||||
counts[table] = count
|
||||
}
|
||||
if counts["countries"] != 250 || counts["regions"] != 25 || counts["region_countries"] != 250 {
|
||||
t.Fatalf("seed counts mismatch: %+v", counts)
|
||||
}
|
||||
|
||||
region, ok, err := repository.ResolveActiveRegionByCountry(ctx, "SG")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("resolve SG region failed: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if region.RegionCode != "South-Eastern Asia" || region.Name != "东南亚" {
|
||||
t.Fatalf("SG should map by subregion fields, got %+v", region)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessNextRegionRebuildTaskClaimsExpiredRunningTask(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
schema := mysqlschema.New(t)
|
||||
@ -76,9 +111,9 @@ func seedRegionRebuildIntegrationRows(t *testing.T, repository *Repository) int6
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
if _, err := repository.db.ExecContext(ctx, `
|
||||
INSERT INTO countries (country_name, country_code, country_display_name, status, sort_order, created_at_ms, updated_at_ms)
|
||||
VALUES ('Integration Country', ?, 'Integration Country', ?, 999, 1000, 1000)
|
||||
`, regionRebuildIntegrationCountry, userdomain.CountryStatusActive); err != nil {
|
||||
INSERT INTO countries (country_name, country_code, country_display_name, sort_order, created_at_ms, updated_at_ms)
|
||||
VALUES ('Integration Country', ?, 'Integration Country', 999, 1000, 1000)
|
||||
`, regionRebuildIntegrationCountry); err != nil {
|
||||
t.Fatalf("insert country failed: %v", err)
|
||||
}
|
||||
result, err := repository.db.ExecContext(ctx, `
|
||||
|
||||
@ -15,14 +15,19 @@ import (
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
)
|
||||
|
||||
// ResolveActiveCountryByCode 按国家码读取 active 国家主数据,注册和改国家都会经过这里。
|
||||
func (r *Repository) ResolveActiveCountryByCode(ctx context.Context, countryCode string) (userdomain.Country, bool, error) {
|
||||
return resolveActiveCountryByCode(ctx, r.db, countryCode)
|
||||
// ResolveEnabledCountryByCode 按国家码读取 App 可选国家,注册和改国家都会经过这里。
|
||||
func (r *Repository) ResolveEnabledCountryByCode(ctx context.Context, countryCode string) (userdomain.Country, bool, error) {
|
||||
return resolveCountryByCode(ctx, r.db, countryCode, true)
|
||||
}
|
||||
|
||||
// ListRegistrationCountries 返回注册页可选国家,只包含 enabled 的国家。
|
||||
func (r *Repository) ListRegistrationCountries(ctx context.Context) ([]userdomain.Country, error) {
|
||||
return listCountries(ctx, r.db, userdomain.CountryFilter{Enabled: boolPtr(true)})
|
||||
}
|
||||
|
||||
// ResolveActiveRegionByCountry 按国家码读取当前 active 区域映射;无映射返回 ok=false。
|
||||
func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCode string) (userdomain.Region, bool, error) {
|
||||
country, ok, err := resolveActiveCountryByCode(ctx, r.db, countryCode)
|
||||
country, ok, err := resolveCountryByCode(ctx, r.db, countryCode, true)
|
||||
if err != nil || !ok {
|
||||
return userdomain.Region{}, false, err
|
||||
}
|
||||
@ -32,10 +37,9 @@ func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCo
|
||||
COALESCE(rg.created_by_user_id, 0), COALESCE(rg.updated_by_user_id, 0), rg.created_at_ms, rg.updated_at_ms
|
||||
FROM region_countries rc
|
||||
INNER JOIN regions rg ON rg.region_id = rc.region_id
|
||||
INNER JOIN countries c ON c.country_code = rc.country_code
|
||||
WHERE rc.country_code = ? AND rc.status = ? AND rg.status = ? AND c.status = ?
|
||||
WHERE rc.country_code = ? AND rc.status = ? AND rg.status = ?
|
||||
LIMIT 1
|
||||
`, country.CountryCode, userdomain.RegionStatusActive, userdomain.RegionStatusActive, userdomain.CountryStatusActive).Scan(
|
||||
`, country.CountryCode, userdomain.RegionStatusActive, userdomain.RegionStatusActive).Scan(
|
||||
®ion.RegionID,
|
||||
®ion.RegionCode,
|
||||
®ion.Name,
|
||||
@ -61,12 +65,16 @@ func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCo
|
||||
return region, true, nil
|
||||
}
|
||||
|
||||
// CreateCountry 创建 active 国家主数据。
|
||||
// CreateCountry 创建国家主数据;enabled 是 App 注册页和改国家链路的唯一可用性开关。
|
||||
func (r *Repository) CreateCountry(ctx context.Context, command userdomain.CreateCountryCommand) (userdomain.Country, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO countries (country_name, country_code, country_display_name, status, sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, command.CountryName, command.CountryCode, command.CountryDisplayName, userdomain.CountryStatusActive, command.SortOrder, nullableOperator(command.OperatorUserID), nullableOperator(command.OperatorUserID), command.NowMs, command.NowMs)
|
||||
INSERT INTO countries (
|
||||
country_name, country_code, iso_alpha3, iso_numeric, country_display_name,
|
||||
phone_country_code, flag, enabled, sort_order,
|
||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, command.CountryName, command.CountryCode, nullableString(command.ISOAlpha3), nullableString(command.ISONumeric), command.CountryDisplayName, nullableString(command.PhoneCountryCode), command.Flag, command.Enabled, command.SortOrder, nullableOperator(command.OperatorUserID), nullableOperator(command.OperatorUserID), command.NowMs, command.NowMs)
|
||||
if err != nil {
|
||||
return userdomain.Country{}, mapCountryDuplicateError(err)
|
||||
}
|
||||
@ -82,9 +90,41 @@ func (r *Repository) CreateCountry(ctx context.Context, command userdomain.Creat
|
||||
func (r *Repository) UpdateCountry(ctx context.Context, command userdomain.UpdateCountryCommand) (userdomain.Country, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE countries
|
||||
SET country_name = ?, country_display_name = ?, sort_order = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||||
SET country_name = ?,
|
||||
iso_alpha3 = ?,
|
||||
iso_numeric = ?,
|
||||
country_display_name = ?,
|
||||
phone_country_code = ?,
|
||||
flag = ?,
|
||||
sort_order = ?,
|
||||
updated_by_user_id = ?,
|
||||
updated_at_ms = ?
|
||||
WHERE country_id = ?
|
||||
`, command.CountryName, command.CountryDisplayName, command.SortOrder, nullableOperator(command.OperatorUserID), command.NowMs, command.CountryID)
|
||||
`, command.CountryName, nullableString(command.ISOAlpha3), nullableString(command.ISONumeric), command.CountryDisplayName, nullableString(command.PhoneCountryCode), command.Flag, command.SortOrder, nullableOperator(command.OperatorUserID), command.NowMs, command.CountryID)
|
||||
if err != nil {
|
||||
return userdomain.Country{}, mapCountryDuplicateError(err)
|
||||
}
|
||||
if affected, err := result.RowsAffected(); err != nil {
|
||||
return userdomain.Country{}, err
|
||||
} else if affected == 0 {
|
||||
return userdomain.Country{}, xerr.New(xerr.CountryNotFound, "country not found")
|
||||
}
|
||||
|
||||
return queryCountry(ctx, r.db, "WHERE country_id = ?", command.CountryID)
|
||||
}
|
||||
|
||||
// ListCountries 返回国家主数据,enabled 为空时返回全部。
|
||||
func (r *Repository) ListCountries(ctx context.Context, filter userdomain.CountryFilter) ([]userdomain.Country, error) {
|
||||
return listCountries(ctx, r.db, filter)
|
||||
}
|
||||
|
||||
// EnableCountry 重新开放国家给 App 注册和改国家链路。
|
||||
func (r *Repository) EnableCountry(ctx context.Context, command userdomain.EnableCountryCommand) (userdomain.Country, error) {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE countries
|
||||
SET enabled = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||||
WHERE country_id = ?
|
||||
`, true, nullableOperator(command.OperatorUserID), command.NowMs, command.CountryID)
|
||||
if err != nil {
|
||||
return userdomain.Country{}, err
|
||||
}
|
||||
@ -97,82 +137,23 @@ func (r *Repository) UpdateCountry(ctx context.Context, command userdomain.Updat
|
||||
return queryCountry(ctx, r.db, "WHERE country_id = ?", command.CountryID)
|
||||
}
|
||||
|
||||
// ListCountries 返回国家主数据,status 为空时返回全部。
|
||||
func (r *Repository) ListCountries(ctx context.Context, filter userdomain.CountryFilter) ([]userdomain.Country, error) {
|
||||
query := `
|
||||
SELECT country_id, country_name, country_code, country_display_name, status, sort_order,
|
||||
COALESCE(created_by_user_id, 0), COALESCE(updated_by_user_id, 0), created_at_ms, updated_at_ms
|
||||
FROM countries`
|
||||
args := []any{}
|
||||
if filter.Status != "" {
|
||||
query += " WHERE status = ?"
|
||||
args = append(args, filter.Status)
|
||||
}
|
||||
query += " ORDER BY sort_order ASC, country_code ASC"
|
||||
rows, err := r.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
countries := []userdomain.Country{}
|
||||
for rows.Next() {
|
||||
country, err := scanCountry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
countries = append(countries, country)
|
||||
}
|
||||
|
||||
return countries, rows.Err()
|
||||
}
|
||||
|
||||
// DisableCountry 停用国家,并释放当前 active 区域归属。
|
||||
// DisableCountry 只关闭 App 选择入口,不删除国家,也不释放区域配置。
|
||||
func (r *Repository) DisableCountry(ctx context.Context, command userdomain.DisableCountryCommand) (userdomain.Country, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE countries
|
||||
SET enabled = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||||
WHERE country_id = ?
|
||||
`, false, nullableOperator(command.OperatorUserID), command.NowMs, command.CountryID)
|
||||
if err != nil {
|
||||
return userdomain.Country{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
country, err := queryCountry(ctx, tx, "WHERE country_id = ? FOR UPDATE", command.CountryID)
|
||||
if err == sql.ErrNoRows {
|
||||
if affected, err := result.RowsAffected(); err != nil {
|
||||
return userdomain.Country{}, err
|
||||
} else if affected == 0 {
|
||||
return userdomain.Country{}, xerr.New(xerr.CountryNotFound, "country not found")
|
||||
}
|
||||
if err != nil {
|
||||
return userdomain.Country{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE countries
|
||||
SET status = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||||
WHERE country_id = ?
|
||||
`, userdomain.CountryStatusDisabled, nullableOperator(command.OperatorUserID), command.NowMs, command.CountryID); err != nil {
|
||||
return userdomain.Country{}, err
|
||||
}
|
||||
regionID, mapped, err := activeRegionIDByCountry(ctx, tx, country.CountryCode)
|
||||
if err != nil {
|
||||
return userdomain.Country{}, err
|
||||
}
|
||||
if mapped {
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
UPDATE region_countries
|
||||
SET status = ?, updated_by_user_id = ?, updated_at_ms = ?
|
||||
WHERE country_code = ? AND status = ?
|
||||
`, userdomain.CountryStatusDisabled, nullableOperator(command.OperatorUserID), command.NowMs, country.CountryCode, userdomain.RegionStatusActive); err != nil {
|
||||
return userdomain.Country{}, err
|
||||
}
|
||||
if err := insertRebuildTask(ctx, tx, regionID, country.CountryCode, 0, command.NowMs); err != nil {
|
||||
return userdomain.Country{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return userdomain.Country{}, err
|
||||
}
|
||||
|
||||
country.Status = userdomain.CountryStatusDisabled
|
||||
country.UpdatedByUserID = command.OperatorUserID
|
||||
country.UpdatedAtMs = command.NowMs
|
||||
return country, nil
|
||||
return queryCountry(ctx, r.db, "WHERE country_id = ?", command.CountryID)
|
||||
}
|
||||
|
||||
// CreateRegion 创建区域并写 active 国家归属、审计和历史用户重算任务。
|
||||
@ -183,7 +164,7 @@ func (r *Repository) CreateRegion(ctx context.Context, command userdomain.Create
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
countries, err := canonicalActiveCountryCodes(ctx, tx, command.Countries)
|
||||
countries, err := canonicalCountryCodes(ctx, tx, command.Countries)
|
||||
if err != nil {
|
||||
return userdomain.Region{}, err
|
||||
}
|
||||
@ -335,7 +316,7 @@ func (r *Repository) ReplaceRegionCountries(ctx context.Context, command userdom
|
||||
if before.Status != userdomain.RegionStatusActive {
|
||||
return userdomain.Region{}, xerr.New(xerr.RegionDisabled, "region is disabled")
|
||||
}
|
||||
countries, err := canonicalActiveCountryCodes(ctx, tx, command.Countries)
|
||||
countries, err := canonicalCountryCodes(ctx, tx, command.Countries)
|
||||
if err != nil {
|
||||
return userdomain.Region{}, err
|
||||
}
|
||||
@ -527,21 +508,64 @@ func (r *Repository) processClaimedRegionRebuildTask(ctx context.Context, taskID
|
||||
|
||||
func queryCountry(ctx context.Context, q queryer, clause string, args ...any) (userdomain.Country, error) {
|
||||
row := q.QueryRowContext(ctx, `
|
||||
SELECT country_id, country_name, country_code, country_display_name, status, sort_order,
|
||||
SELECT country_id, country_name, country_code,
|
||||
COALESCE(iso_alpha3, ''), COALESCE(iso_numeric, ''),
|
||||
country_display_name, COALESCE(phone_country_code, ''), COALESCE(flag, ''), enabled,
|
||||
sort_order,
|
||||
COALESCE(created_by_user_id, 0), COALESCE(updated_by_user_id, 0), created_at_ms, updated_at_ms
|
||||
FROM countries
|
||||
`+clause, args...)
|
||||
return scanCountry(row)
|
||||
}
|
||||
|
||||
func listCountries(ctx context.Context, q queryer, filter userdomain.CountryFilter) ([]userdomain.Country, error) {
|
||||
query := `
|
||||
SELECT country_id, country_name, country_code,
|
||||
COALESCE(iso_alpha3, ''), COALESCE(iso_numeric, ''),
|
||||
country_display_name, COALESCE(phone_country_code, ''), COALESCE(flag, ''), enabled,
|
||||
sort_order,
|
||||
COALESCE(created_by_user_id, 0), COALESCE(updated_by_user_id, 0), created_at_ms, updated_at_ms
|
||||
FROM countries`
|
||||
args := []any{}
|
||||
conditions := []string{}
|
||||
if filter.Enabled != nil {
|
||||
conditions = append(conditions, "enabled = ?")
|
||||
args = append(args, *filter.Enabled)
|
||||
}
|
||||
if len(conditions) > 0 {
|
||||
query += " WHERE " + strings.Join(conditions, " AND ")
|
||||
}
|
||||
query += " ORDER BY sort_order ASC, country_code ASC"
|
||||
rows, err := queryRows(ctx, q, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
countries := []userdomain.Country{}
|
||||
for rows.Next() {
|
||||
country, err := scanCountry(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
countries = append(countries, country)
|
||||
}
|
||||
|
||||
return countries, rows.Err()
|
||||
}
|
||||
|
||||
func scanCountry(scanner interface{ Scan(dest ...any) error }) (userdomain.Country, error) {
|
||||
var country userdomain.Country
|
||||
err := scanner.Scan(
|
||||
&country.CountryID,
|
||||
&country.CountryName,
|
||||
&country.CountryCode,
|
||||
&country.ISOAlpha3,
|
||||
&country.ISONumeric,
|
||||
&country.CountryDisplayName,
|
||||
&country.Status,
|
||||
&country.PhoneCountryCode,
|
||||
&country.Flag,
|
||||
&country.Enabled,
|
||||
&country.SortOrder,
|
||||
&country.CreatedByUserID,
|
||||
&country.UpdatedByUserID,
|
||||
@ -551,6 +575,10 @@ func scanCountry(scanner interface{ Scan(dest ...any) error }) (userdomain.Count
|
||||
return country, err
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
func queryRegion(ctx context.Context, q queryer, clause string, args ...any) (userdomain.Region, error) {
|
||||
row := q.QueryRowContext(ctx, `
|
||||
SELECT region_id, region_code, name, status, sort_order,
|
||||
@ -610,11 +638,11 @@ func activeCountriesForRegion(ctx context.Context, q queryer, regionID int64) ([
|
||||
return countries, rows.Err()
|
||||
}
|
||||
|
||||
func canonicalActiveCountryCodes(ctx context.Context, q queryer, countries []string) ([]string, error) {
|
||||
func canonicalCountryCodes(ctx context.Context, q queryer, countries []string) ([]string, error) {
|
||||
seen := make(map[string]struct{}, len(countries))
|
||||
result := make([]string, 0, len(countries))
|
||||
for _, input := range countries {
|
||||
country, ok, err := resolveActiveCountryByCode(ctx, q, input)
|
||||
country, ok, err := resolveCountryByCode(ctx, q, input, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -632,9 +660,15 @@ func canonicalActiveCountryCodes(ctx context.Context, q queryer, countries []str
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func resolveActiveCountryByCode(ctx context.Context, q queryer, countryCode string) (userdomain.Country, bool, error) {
|
||||
func resolveCountryByCode(ctx context.Context, q queryer, countryCode string, requireEnabled bool) (userdomain.Country, bool, error) {
|
||||
countryCode = userdomain.NormalizeCountryCode(countryCode)
|
||||
country, err := queryCountry(ctx, q, "WHERE country_code = ? AND status = ?", countryCode, userdomain.CountryStatusActive)
|
||||
clause := "WHERE country_code = ?"
|
||||
args := []any{countryCode}
|
||||
if requireEnabled {
|
||||
clause += " AND enabled = ?"
|
||||
args = append(args, true)
|
||||
}
|
||||
country, err := queryCountry(ctx, q, clause, args...)
|
||||
if err == sql.ErrNoRows {
|
||||
return userdomain.Country{}, false, nil
|
||||
}
|
||||
|
||||
@ -24,6 +24,9 @@ const userSelectColumns = `
|
||||
COALESCE((SELECT c.country_id FROM countries c WHERE c.country_code = users.country LIMIT 1), 0),
|
||||
COALESCE((SELECT c.country_name FROM countries c WHERE c.country_code = users.country LIMIT 1), ''),
|
||||
COALESCE((SELECT c.country_display_name FROM countries c WHERE c.country_code = users.country LIMIT 1), ''),
|
||||
COALESCE((SELECT c.iso_numeric FROM countries c WHERE c.country_code = users.country LIMIT 1), ''),
|
||||
COALESCE((SELECT c.phone_country_code FROM countries c WHERE c.country_code = users.country LIMIT 1), ''),
|
||||
COALESCE((SELECT c.enabled FROM countries c WHERE c.country_code = users.country LIMIT 1), 0),
|
||||
COALESCE((SELECT rg.region_code FROM regions rg WHERE rg.region_id = users.region_id LIMIT 1), ''),
|
||||
COALESCE((SELECT rg.name FROM regions rg WHERE rg.region_id = users.region_id LIMIT 1), ''),
|
||||
COALESCE(invite_code, ''),
|
||||
@ -43,6 +46,9 @@ const userSelectColumns = `
|
||||
COALESCE(platform, ''),
|
||||
COALESCE(language, ''),
|
||||
COALESCE(timezone, ''),
|
||||
COALESCE(profile_completed, 0),
|
||||
COALESCE(profile_completed_at_ms, 0),
|
||||
COALESCE(onboarding_status, 'profile_required'),
|
||||
status,
|
||||
created_at_ms,
|
||||
updated_at_ms`
|
||||
@ -155,6 +161,52 @@ func (r *Repository) UpdateUserProfile(ctx context.Context, command userdomain.P
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// CompleteOnboarding 是注册页资料固化的事务入口。
|
||||
// 该方法不写 user_country_change_logs,确保首次国家选择不消耗改国家冷却窗口。
|
||||
func (r *Repository) CompleteOnboarding(ctx context.Context, command userdomain.CompleteOnboardingCommand) (userdomain.User, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
user, err := r.queryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
|
||||
if err == sql.ErrNoRows {
|
||||
return userdomain.User{}, xerr.New(xerr.NotFound, "user not found")
|
||||
}
|
||||
if err != nil {
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
if user.ProfileCompleted {
|
||||
// 已完成用户不能用注册页接口重写资料;提交入口保持幂等只返回当前事实。
|
||||
if err := tx.Commit(); err != nil {
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
return r.GetUser(ctx, command.UserID)
|
||||
}
|
||||
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
UPDATE users
|
||||
SET username = ?,
|
||||
avatar = ?,
|
||||
country = ?,
|
||||
region_id = ?,
|
||||
profile_completed = ?,
|
||||
profile_completed_at_ms = ?,
|
||||
onboarding_status = ?,
|
||||
updated_at_ms = ?
|
||||
WHERE user_id = ?
|
||||
`, command.Username, command.Avatar, command.Country, nullableRegionID(command.RegionID), true, command.CompletedAtMs, string(userdomain.OnboardingStatusCompleted), command.CompletedAtMs, command.UserID)
|
||||
if err != nil {
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
|
||||
return r.GetUser(ctx, command.UserID)
|
||||
}
|
||||
|
||||
// ChangeUserCountry 修改国家并写日志;冷却期检查和日志写入必须在同一事务内完成。
|
||||
func (r *Repository) ChangeUserCountry(ctx context.Context, command userdomain.CountryChangeCommand) (userdomain.User, int64, error) {
|
||||
tx, err := r.db.BeginTx(ctx, nil)
|
||||
@ -247,6 +299,7 @@ func scanUser(scanner interface {
|
||||
var user userdomain.User
|
||||
var status string
|
||||
var kind string
|
||||
var onboardingStatus string
|
||||
err := scanner.Scan(
|
||||
&user.UserID,
|
||||
&user.DefaultDisplayUserID,
|
||||
@ -260,6 +313,9 @@ func scanUser(scanner interface {
|
||||
&user.CountryID,
|
||||
&user.CountryName,
|
||||
&user.CountryDisplayName,
|
||||
&user.ISONumeric,
|
||||
&user.PhoneCountryCode,
|
||||
&user.CountryEnabled,
|
||||
&user.RegionCode,
|
||||
&user.RegionName,
|
||||
&user.InviteCode,
|
||||
@ -279,6 +335,9 @@ func scanUser(scanner interface {
|
||||
&user.RegisterPlatform,
|
||||
&user.Language,
|
||||
&user.Timezone,
|
||||
&user.ProfileCompleted,
|
||||
&user.ProfileCompletedAtMs,
|
||||
&onboardingStatus,
|
||||
&status,
|
||||
&user.CreatedAtMs,
|
||||
&user.UpdatedAtMs,
|
||||
@ -289,6 +348,14 @@ func scanUser(scanner interface {
|
||||
|
||||
user.Status = userdomain.Status(status)
|
||||
user.CurrentDisplayUserIDKind = userdomain.DisplayUserIDKind(kind)
|
||||
user.OnboardingStatus = userdomain.OnboardingStatus(onboardingStatus)
|
||||
if user.OnboardingStatus == "" {
|
||||
if user.ProfileCompleted {
|
||||
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
|
||||
} else {
|
||||
user.OnboardingStatus = userdomain.OnboardingStatusProfileRequired
|
||||
}
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@ -336,12 +403,15 @@ func insertUserIdentity(ctx context.Context, tx *sql.Tx, user userdomain.User, i
|
||||
platform,
|
||||
language,
|
||||
timezone,
|
||||
profile_completed,
|
||||
profile_completed_at_ms,
|
||||
onboarding_status,
|
||||
status,
|
||||
created_at_ms,
|
||||
updated_at_ms
|
||||
)
|
||||
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), nullableString(user.Username), nullableString(user.Gender), nullableString(user.Country), nullableRegionID(user.RegionID), nullableString(user.InviteCode), nullableString(user.RegisterIP), nullableString(user.RegisterUserAgent), nullableString(user.CountryByIP), nullableString(user.RegisterDeviceID), nullableString(user.RegisterDevice), nullableString(user.RegisterOSVersion), nullableString(user.Avatar), nullableString(user.BirthDate), nullableString(user.RegisterAppVersion), nullableString(user.RegisterBuildNumber), nullableString(user.RegisterSource), nullableString(user.RegisterInstallChannel), nullableString(user.RegisterCampaign), nullableString(user.RegisterPlatform), nullableString(user.Language), nullableString(user.Timezone), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
|
||||
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), nullableString(user.Username), nullableString(user.Gender), nullableString(user.Country), nullableRegionID(user.RegionID), nullableString(user.InviteCode), nullableString(user.RegisterIP), nullableString(user.RegisterUserAgent), nullableString(user.CountryByIP), nullableString(user.RegisterDeviceID), nullableString(user.RegisterDevice), nullableString(user.RegisterOSVersion), nullableString(user.Avatar), nullableString(user.BirthDate), nullableString(user.RegisterAppVersion), nullableString(user.RegisterBuildNumber), nullableString(user.RegisterSource), nullableString(user.RegisterInstallChannel), nullableString(user.RegisterCampaign), nullableString(user.RegisterPlatform), nullableString(user.Language), nullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, onboardingStatusForInsert(user), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -354,6 +424,18 @@ func insertUserIdentity(ctx context.Context, tx *sql.Tx, user userdomain.User, i
|
||||
return err
|
||||
}
|
||||
|
||||
func onboardingStatusForInsert(user userdomain.User) string {
|
||||
// 创建路径默认保持未完成状态;只有 CompleteOnboarding 能把状态固化为 completed。
|
||||
if user.OnboardingStatus != "" {
|
||||
return string(user.OnboardingStatus)
|
||||
}
|
||||
if user.ProfileCompleted {
|
||||
return string(userdomain.OnboardingStatusCompleted)
|
||||
}
|
||||
|
||||
return string(userdomain.OnboardingStatusProfileRequired)
|
||||
}
|
||||
|
||||
func latestCountryChangedAt(ctx context.Context, tx *sql.Tx, userID int64) (int64, error) {
|
||||
// users 行已 FOR UPDATE;按同一用户串行化后读取最新日志即可防止并发绕过冷却。
|
||||
var changedAtMs int64
|
||||
|
||||
@ -17,7 +17,7 @@ func New(t testing.TB) *Schema {
|
||||
return sharedmysqlschema.New(t, sharedmysqlschema.Config{
|
||||
EnvVar: "USER_SERVICE_MYSQL_TEST_DSN",
|
||||
InitDBPath: initDBPath(t),
|
||||
DatabasePrefix: "hy_user_test",
|
||||
DatabasePrefix: "hyapp_user_test",
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -64,6 +64,14 @@ func (r *Repository) PutUser(user userdomain.User) {
|
||||
if user.Status == "" {
|
||||
user.Status = userdomain.StatusActive
|
||||
}
|
||||
if user.OnboardingStatus == "" {
|
||||
// 测试种子默认模拟三方刚创建但未完成注册资料的用户。
|
||||
if user.ProfileCompleted {
|
||||
user.OnboardingStatus = userdomain.OnboardingStatusCompleted
|
||||
} else {
|
||||
user.OnboardingStatus = userdomain.OnboardingStatusProfileRequired
|
||||
}
|
||||
}
|
||||
nowMs := user.UpdatedAtMs
|
||||
if nowMs == 0 {
|
||||
nowMs = time.Now().UnixMilli()
|
||||
@ -79,9 +87,10 @@ func (r *Repository) PutUser(user userdomain.User) {
|
||||
current_display_user_id_expires_at_ms, username, gender, country, region_id, invite_code,
|
||||
register_ip, register_user_agent, country_by_ip, register_device_id, register_device,
|
||||
os_version, avatar, birth_date, app_version, build_number, source, install_channel,
|
||||
campaign, platform, language, timezone, status, created_at_ms, updated_at_ms
|
||||
campaign, platform, language, timezone, profile_completed, profile_completed_at_ms,
|
||||
onboarding_status, status, created_at_ms, updated_at_ms
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
default_display_user_id = VALUES(default_display_user_id),
|
||||
current_display_user_id = VALUES(current_display_user_id),
|
||||
@ -108,9 +117,12 @@ func (r *Repository) PutUser(user userdomain.User) {
|
||||
platform = VALUES(platform),
|
||||
language = VALUES(language),
|
||||
timezone = VALUES(timezone),
|
||||
profile_completed = VALUES(profile_completed),
|
||||
profile_completed_at_ms = VALUES(profile_completed_at_ms),
|
||||
onboarding_status = VALUES(onboarding_status),
|
||||
status = VALUES(status),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), nullableInt64(user.CurrentDisplayUserIDExpiresAtMs), nullableString(user.Username), nullableString(user.Gender), nullableString(user.Country), nullableInt64(user.RegionID), nullableString(user.InviteCode), nullableString(user.RegisterIP), nullableString(user.RegisterUserAgent), nullableString(user.CountryByIP), nullableString(user.RegisterDeviceID), nullableString(user.RegisterDevice), nullableString(user.RegisterOSVersion), nullableString(user.Avatar), nullableString(user.BirthDate), nullableString(user.RegisterAppVersion), nullableString(user.RegisterBuildNumber), nullableString(user.RegisterSource), nullableString(user.RegisterInstallChannel), nullableString(user.RegisterCampaign), nullableString(user.RegisterPlatform), nullableString(user.Language), nullableString(user.Timezone), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
|
||||
`, user.UserID, user.DefaultDisplayUserID, user.CurrentDisplayUserID, string(user.CurrentDisplayUserIDKind), nullableInt64(user.CurrentDisplayUserIDExpiresAtMs), nullableString(user.Username), nullableString(user.Gender), nullableString(user.Country), nullableInt64(user.RegionID), nullableString(user.InviteCode), nullableString(user.RegisterIP), nullableString(user.RegisterUserAgent), nullableString(user.CountryByIP), nullableString(user.RegisterDeviceID), nullableString(user.RegisterDevice), nullableString(user.RegisterOSVersion), nullableString(user.Avatar), nullableString(user.BirthDate), nullableString(user.RegisterAppVersion), nullableString(user.RegisterBuildNumber), nullableString(user.RegisterSource), nullableString(user.RegisterInstallChannel), nullableString(user.RegisterCampaign), nullableString(user.RegisterPlatform), nullableString(user.Language), nullableString(user.Timezone), user.ProfileCompleted, user.ProfileCompletedAtMs, string(user.OnboardingStatus), string(user.Status), user.CreatedAtMs, user.UpdatedAtMs)
|
||||
if err != nil {
|
||||
r.t.Fatalf("seed user %d failed: %v", user.UserID, err)
|
||||
}
|
||||
|
||||
@ -17,6 +17,8 @@ func toProtoToken(token authdomain.Token) *userv1.AuthToken {
|
||||
DefaultDisplayUserId: token.DefaultDisplayUserID,
|
||||
DisplayUserIdKind: token.DisplayUserIDKind,
|
||||
DisplayUserIdExpiresAtMs: token.DisplayUserIDExpiresAtMs,
|
||||
ProfileCompleted: token.ProfileCompleted,
|
||||
OnboardingStatus: token.OnboardingStatus,
|
||||
SessionId: token.SessionID,
|
||||
AccessToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
@ -49,6 +51,12 @@ func toProtoUser(user userdomain.User) *userv1.User {
|
||||
RegionId: user.RegionID,
|
||||
RegionCode: user.RegionCode,
|
||||
RegionName: user.RegionName,
|
||||
ProfileCompleted: user.ProfileCompleted,
|
||||
ProfileCompletedAtMs: user.ProfileCompletedAtMs,
|
||||
OnboardingStatus: string(user.OnboardingStatus),
|
||||
IsoNumeric: user.ISONumeric,
|
||||
PhoneCountryCode: user.PhoneCountryCode,
|
||||
CountryEnabled: user.CountryEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,10 +67,14 @@ func toProtoCountry(country userdomain.Country) *userv1.Country {
|
||||
CountryName: country.CountryName,
|
||||
CountryCode: country.CountryCode,
|
||||
CountryDisplayName: country.CountryDisplayName,
|
||||
Status: country.Status,
|
||||
SortOrder: country.SortOrder,
|
||||
CreatedAtMs: country.CreatedAtMs,
|
||||
UpdatedAtMs: country.UpdatedAtMs,
|
||||
IsoAlpha3: country.ISOAlpha3,
|
||||
IsoNumeric: country.ISONumeric,
|
||||
PhoneCountryCode: country.PhoneCountryCode,
|
||||
Enabled: country.Enabled,
|
||||
Flag: country.Flag,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ import (
|
||||
userv1 "hyapp/api/proto/user/v1"
|
||||
"hyapp/pkg/xerr"
|
||||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
authservice "hyapp/services/user-service/internal/service/auth"
|
||||
userservice "hyapp/services/user-service/internal/service/user"
|
||||
)
|
||||
@ -21,6 +22,8 @@ type Server struct {
|
||||
userv1.UnimplementedUserIdentityServiceServer
|
||||
// UnimplementedCountryAdminServiceServer 提供未实现 RPC 的默认返回。
|
||||
userv1.UnimplementedCountryAdminServiceServer
|
||||
// UnimplementedCountryQueryServiceServer 提供注册页国家查询 RPC 的前向兼容默认返回。
|
||||
userv1.UnimplementedCountryQueryServiceServer
|
||||
// UnimplementedRegionAdminServiceServer 提供未实现 RPC 的默认返回。
|
||||
userv1.UnimplementedRegionAdminServiceServer
|
||||
|
||||
@ -47,7 +50,7 @@ func (s *Server) LoginPassword(ctx context.Context, req *userv1.LoginPasswordReq
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
|
||||
return &userv1.AuthResponse{Token: toProtoToken(token)}, nil
|
||||
return &userv1.AuthResponse{Token: toProtoToken(token), ProfileCompleted: token.ProfileCompleted, OnboardingStatus: token.OnboardingStatus}, nil
|
||||
}
|
||||
|
||||
// LoginThirdParty 只接受 provider 凭证,不在 gateway 泄漏 provider secret。
|
||||
@ -78,7 +81,7 @@ func (s *Server) LoginThirdParty(ctx context.Context, req *userv1.LoginThirdPart
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
|
||||
return &userv1.AuthResponse{Token: toProtoToken(token), IsNewUser: isNewUser}, nil
|
||||
return &userv1.AuthResponse{Token: toProtoToken(token), IsNewUser: isNewUser, ProfileCompleted: token.ProfileCompleted, OnboardingStatus: token.OnboardingStatus}, nil
|
||||
}
|
||||
|
||||
// SetPassword 为已登录用户首次写入密码身份。
|
||||
@ -163,9 +166,55 @@ func (s *Server) ChangeUserCountry(ctx context.Context, req *userv1.ChangeUserCo
|
||||
return &userv1.ChangeUserCountryResponse{User: toProtoUser(user), NextChangeAllowedAtMs: nextChangeAllowedAtMs}, nil
|
||||
}
|
||||
|
||||
// CompleteOnboarding 原子完成注册页必填资料。
|
||||
func (s *Server) CompleteOnboarding(ctx context.Context, req *userv1.CompleteOnboardingRequest) (*userv1.CompleteOnboardingResponse, error) {
|
||||
if s.authSvc == nil {
|
||||
// 完成注册后必须重签 access token;缺少 auth service 时不能先写资料再让客户端继续拿旧 token。
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "auth service is not configured"))
|
||||
}
|
||||
if !s.authSvc.TokenConfigValid() {
|
||||
// 签名配置缺失时提前失败,避免资料已完成但响应无法带新 access token。
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "token service is not configured"))
|
||||
}
|
||||
// gateway 只透传已鉴权 user_id;资料校验和国家解析必须停留在 user-service。
|
||||
user, err := s.userSvc.CompleteOnboarding(ctx, req.GetUserId(), req.GetUsername(), req.GetAvatar(), req.GetCountry())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
token, err := s.authSvc.IssueAccessTokenForSession(ctx, req.GetUserId(), req.GetMeta().GetSessionId(), authMeta(req.GetMeta()))
|
||||
if err != nil {
|
||||
// token 签发失败时返回错误,避免客户端拿旧 profile_required JWT 继续撞 profile gate。
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
|
||||
return &userv1.CompleteOnboardingResponse{
|
||||
User: toProtoUser(user),
|
||||
ProfileCompleted: user.ProfileCompleted,
|
||||
ProfileCompletedAtMs: user.ProfileCompletedAtMs,
|
||||
OnboardingStatus: string(user.OnboardingStatus),
|
||||
Token: toProtoToken(token),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateCountry 创建国家主数据;公网 admin API 暴露前必须由 gateway 做管理鉴权。
|
||||
func (s *Server) CreateCountry(ctx context.Context, req *userv1.CreateCountryRequest) (*userv1.CountryResponse, error) {
|
||||
country, err := s.userSvc.CreateCountry(ctx, req.GetCountryName(), req.GetCountryCode(), req.GetCountryDisplayName(), req.GetSortOrder(), req.GetOperatorUserId(), req.GetMeta().GetRequestId())
|
||||
enabled := true
|
||||
if req.Enabled != nil {
|
||||
enabled = req.GetEnabled()
|
||||
}
|
||||
country, err := s.userSvc.CreateCountry(ctx, userdomain.CreateCountryCommand{
|
||||
CountryName: req.GetCountryName(),
|
||||
CountryCode: req.GetCountryCode(),
|
||||
ISOAlpha3: req.GetIsoAlpha3(),
|
||||
ISONumeric: req.GetIsoNumeric(),
|
||||
CountryDisplayName: req.GetCountryDisplayName(),
|
||||
PhoneCountryCode: req.GetPhoneCountryCode(),
|
||||
Flag: req.GetFlag(),
|
||||
Enabled: enabled,
|
||||
SortOrder: req.GetSortOrder(),
|
||||
OperatorUserID: req.GetOperatorUserId(),
|
||||
RequestID: req.GetMeta().GetRequestId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
@ -175,7 +224,7 @@ func (s *Server) CreateCountry(ctx context.Context, req *userv1.CreateCountryReq
|
||||
|
||||
// ListCountries 返回国家主数据列表。
|
||||
func (s *Server) ListCountries(ctx context.Context, req *userv1.ListCountriesRequest) (*userv1.ListCountriesResponse, error) {
|
||||
countries, err := s.userSvc.ListCountries(ctx, req.GetStatus())
|
||||
countries, err := s.userSvc.ListCountries(ctx, req.Enabled)
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
@ -189,7 +238,18 @@ func (s *Server) ListCountries(ctx context.Context, req *userv1.ListCountriesReq
|
||||
|
||||
// UpdateCountry 修改国家展示字段,country_code 不允许原地修改。
|
||||
func (s *Server) UpdateCountry(ctx context.Context, req *userv1.UpdateCountryRequest) (*userv1.CountryResponse, error) {
|
||||
country, err := s.userSvc.UpdateCountry(ctx, req.GetCountryId(), req.GetCountryName(), req.GetCountryDisplayName(), req.GetSortOrder(), req.GetOperatorUserId(), req.GetMeta().GetRequestId())
|
||||
country, err := s.userSvc.UpdateCountry(ctx, userdomain.UpdateCountryCommand{
|
||||
CountryID: req.GetCountryId(),
|
||||
CountryName: req.GetCountryName(),
|
||||
ISOAlpha3: req.GetIsoAlpha3(),
|
||||
ISONumeric: req.GetIsoNumeric(),
|
||||
CountryDisplayName: req.GetCountryDisplayName(),
|
||||
PhoneCountryCode: req.GetPhoneCountryCode(),
|
||||
Flag: req.GetFlag(),
|
||||
SortOrder: req.GetSortOrder(),
|
||||
OperatorUserID: req.GetOperatorUserId(),
|
||||
RequestID: req.GetMeta().GetRequestId(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
@ -197,7 +257,17 @@ func (s *Server) UpdateCountry(ctx context.Context, req *userv1.UpdateCountryReq
|
||||
return &userv1.CountryResponse{Country: toProtoCountry(country)}, nil
|
||||
}
|
||||
|
||||
// DisableCountry 停用国家,后续注册、改国家和区域配置都不能再选它。
|
||||
// EnableCountry 重新开放国家给 App 注册页和用户改国家链路。
|
||||
func (s *Server) EnableCountry(ctx context.Context, req *userv1.EnableCountryRequest) (*userv1.CountryResponse, error) {
|
||||
country, err := s.userSvc.EnableCountry(ctx, req.GetCountryId(), req.GetOperatorUserId(), req.GetMeta().GetRequestId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
|
||||
return &userv1.CountryResponse{Country: toProtoCountry(country)}, nil
|
||||
}
|
||||
|
||||
// DisableCountry 关闭国家 App 可选状态,不改变国家记录生命周期。
|
||||
func (s *Server) DisableCountry(ctx context.Context, req *userv1.DisableCountryRequest) (*userv1.CountryResponse, error) {
|
||||
country, err := s.userSvc.DisableCountry(ctx, req.GetCountryId(), req.GetOperatorUserId(), req.GetMeta().GetRequestId())
|
||||
if err != nil {
|
||||
@ -207,6 +277,20 @@ func (s *Server) DisableCountry(ctx context.Context, req *userv1.DisableCountryR
|
||||
return &userv1.CountryResponse{Country: toProtoCountry(country)}, nil
|
||||
}
|
||||
|
||||
// ListRegistrationCountries 返回 App 注册页公开国家列表。
|
||||
func (s *Server) ListRegistrationCountries(ctx context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error) {
|
||||
countries, err := s.userSvc.ListRegistrationCountries(ctx)
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
result := make([]*userv1.Country, 0, len(countries))
|
||||
for _, country := range countries {
|
||||
result = append(result, toProtoCountry(country))
|
||||
}
|
||||
|
||||
return &userv1.ListRegistrationCountriesResponse{Countries: result}, nil
|
||||
}
|
||||
|
||||
// CreateRegion 创建区域并配置 active 国家列表。
|
||||
func (s *Server) CreateRegion(ctx context.Context, req *userv1.CreateRegionRequest) (*userv1.RegionResponse, error) {
|
||||
region, err := s.userSvc.CreateRegion(ctx, req.GetRegionCode(), req.GetName(), req.GetCountries(), req.GetSortOrder(), req.GetOperatorUserId(), req.GetMeta().GetRequestId())
|
||||
|
||||
@ -28,13 +28,20 @@ func newUserService(repository *mysqltest.Repository, options ...userservice.Opt
|
||||
|
||||
func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) {
|
||||
t.Helper()
|
||||
if _, ok, err := repository.ResolveActiveCountryByCode(context.Background(), code); err != nil {
|
||||
if _, ok, err := repository.ResolveEnabledCountryByCode(context.Background(), code); err != nil {
|
||||
t.Fatalf("resolve country %s failed: %v", code, err)
|
||||
} else if ok {
|
||||
return
|
||||
}
|
||||
svc := newUserService(repository)
|
||||
if _, err := svc.CreateCountry(context.Background(), code+" Name", code, code+" Display", 0, 1, "seed-country-"+code); err != nil {
|
||||
if _, err := svc.CreateCountry(context.Background(), userdomain.CreateCountryCommand{
|
||||
CountryName: code + " Name",
|
||||
CountryCode: code,
|
||||
CountryDisplayName: code + " Display",
|
||||
Enabled: true,
|
||||
OperatorUserID: 1,
|
||||
RequestID: "seed-country-" + code,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed country %s failed: %v", code, err)
|
||||
}
|
||||
}
|
||||
@ -108,13 +115,66 @@ func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteOnboardingReturnsReplacementAccessToken(t *testing.T) {
|
||||
// gRPC 完成注册响应必须直接带新 access token,避免 gateway profile gate 继续读取旧 JWT 快照。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
authSvc := authservice.New(authservice.Config{
|
||||
Issuer: "hyapp-test",
|
||||
AccessTokenTTLSec: 1800,
|
||||
RefreshTokenTTLSec: 2592000,
|
||||
SigningAlg: "HS256",
|
||||
SigningSecret: "test-secret",
|
||||
}, authservice.WithAuthRepository(repository),
|
||||
authservice.WithUserRepository(repository),
|
||||
authservice.WithIdentityRepository(repository),
|
||||
authservice.WithCountryRegionRepository(repository),
|
||||
authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"})))
|
||||
server := grpcserver.NewServer(authSvc, newUserService(repository))
|
||||
|
||||
loginResp, err := server.LoginThirdParty(ctx, &userv1.LoginThirdPartyRequest{
|
||||
Meta: &userv1.RequestMeta{RequestId: "req-login", DeviceId: "ios"},
|
||||
Provider: "wechat",
|
||||
Credential: "openid-complete-token",
|
||||
DeviceId: "ios",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginThirdParty failed: %v", err)
|
||||
}
|
||||
resp, err := server.CompleteOnboarding(ctx, &userv1.CompleteOnboardingRequest{
|
||||
Meta: &userv1.RequestMeta{RequestId: "req-complete", SessionId: loginResp.GetToken().GetSessionId()},
|
||||
UserId: loginResp.GetToken().GetUserId(),
|
||||
Username: "hy",
|
||||
Avatar: "https://cdn.example/a.png",
|
||||
Country: "SG",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CompleteOnboarding failed: %v", err)
|
||||
}
|
||||
if !resp.GetProfileCompleted() || resp.GetToken().GetAccessToken() == "" || !resp.GetToken().GetProfileCompleted() {
|
||||
t.Fatalf("complete onboarding must return completed replacement token: %+v", resp)
|
||||
}
|
||||
if resp.GetToken().GetSessionId() != loginResp.GetToken().GetSessionId() || resp.GetToken().GetRefreshToken() != "" {
|
||||
t.Fatalf("complete onboarding must reuse session without rotating refresh token: %+v", resp.GetToken())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserProfileAndCountry(t *testing.T) {
|
||||
// UserService 新增资料写接口时,gRPC 层必须保留 optional 字段的“未传/清空”语义。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "CN")
|
||||
seedCountry(t, repository, "US")
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive})
|
||||
repository.PutUser(userdomain.User{
|
||||
UserID: 10001,
|
||||
CurrentDisplayUserID: "100001",
|
||||
Country: "CN",
|
||||
ProfileCompleted: true,
|
||||
ProfileCompletedAtMs: 1,
|
||||
OnboardingStatus: userdomain.OnboardingStatusCompleted,
|
||||
Status: userdomain.StatusActive,
|
||||
})
|
||||
server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(repository, userservice.WithCountryChangeCooldown(0)))
|
||||
username := "hy"
|
||||
avatar := "https://cdn.example/a.png"
|
||||
@ -158,14 +218,18 @@ func TestCountryAndRegionAdminRPC(t *testing.T) {
|
||||
Meta: &userv1.RequestMeta{RequestId: "req-country"},
|
||||
CountryName: "Example Area",
|
||||
CountryCode: "xa",
|
||||
IsoAlpha3: "xaa",
|
||||
IsoNumeric: "901",
|
||||
CountryDisplayName: "测试区域",
|
||||
PhoneCountryCode: "+901",
|
||||
Flag: "XA",
|
||||
SortOrder: 10,
|
||||
OperatorUserId: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCountry RPC failed: %v", err)
|
||||
}
|
||||
if countryResp.GetCountry().GetCountryCode() != "XA" || countryResp.GetCountry().GetCreatedAtMs() != 1000 {
|
||||
if countryResp.GetCountry().GetCountryCode() != "XA" || countryResp.GetCountry().GetIsoAlpha3() != "XAA" || countryResp.GetCountry().GetIsoNumeric() != "901" || countryResp.GetCountry().GetPhoneCountryCode() != "+901" || !countryResp.GetCountry().GetEnabled() || countryResp.GetCountry().GetCreatedAtMs() != 1000 {
|
||||
t.Fatalf("country response mismatch: %+v", countryResp.GetCountry())
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
service_name: wallet-service
|
||||
node_id: wallet-docker
|
||||
grpc_addr: ":13004"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hy_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
service_name: wallet-service
|
||||
node_id: wallet-local
|
||||
grpc_addr: ":13004"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
CREATE DATABASE IF NOT EXISTS hy_wallet DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hy_wallet;
|
||||
CREATE DATABASE IF NOT EXISTS hyapp_wallet DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_wallet;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wallet_accounts (
|
||||
user_id BIGINT NOT NULL,
|
||||
|
||||
@ -11,6 +11,7 @@ import (
|
||||
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
|
||||
walletv1 "hyapp/api/proto/wallet/v1"
|
||||
"hyapp/pkg/grpchealth"
|
||||
"hyapp/pkg/logx"
|
||||
"hyapp/services/wallet-service/internal/config"
|
||||
walletservice "hyapp/services/wallet-service/internal/service/wallet"
|
||||
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
|
||||
@ -43,7 +44,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
server := grpc.NewServer()
|
||||
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("wallet-service")))
|
||||
svc := walletservice.New(repository)
|
||||
walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc))
|
||||
health := grpchealth.NewServingChecker("wallet-service")
|
||||
|
||||
@ -19,7 +19,7 @@ func Default() Config {
|
||||
ServiceName: "wallet-service",
|
||||
NodeID: "wallet-local",
|
||||
GRPCAddr: ":13004",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_wallet?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_wallet?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLAutoMigrate: false,
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,9 +13,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
const (
|
||||
|
||||
@ -57,7 +57,7 @@ func NewRepository(t testing.TB) *Repository {
|
||||
schema := mysqlschema.New(t, mysqlschema.Config{
|
||||
EnvVar: "WALLET_SERVICE_MYSQL_TEST_DSN",
|
||||
InitDBPath: initDBPath(t),
|
||||
DatabasePrefix: "hy_wallet_test",
|
||||
DatabasePrefix: "hyapp_wallet_test",
|
||||
})
|
||||
repository, err := mysqlstorage.Open(context.Background(), schema.DSN)
|
||||
if err != nil {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user