增加钱包功能,去掉兼容
This commit is contained in:
parent
4c731c9970
commit
1225a0c3a0
18
AGENTS.md
18
AGENTS.md
@ -2,6 +2,7 @@
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
0. 任何开发,不需要历史兼容,目前只是在开发阶段
|
||||
1. 写代码不要猜。先读当前实现、接口、配置和测试,再改。
|
||||
2. 不写流水账。文档、注释和总结都要解释边界、决策和可执行动作。
|
||||
3. 前端或客户端改动不要添加无意义描述文案。界面文字必须服务真实操作或状态。
|
||||
@ -34,23 +35,10 @@
|
||||
- `request_id` 是链路追踪字段,必须透传到内部 gRPC `RequestMeta.request_id`;不要把它当幂等键使用。
|
||||
- 外部实时消息入口在腾讯云 IM SDK;本仓库只提供 `/api/v1/im/usersig` 签发登录票据。
|
||||
- 内部服务通信统一 gRPC + protobuf。
|
||||
- Proto/API 必须保持向后兼容。不能直接删除字段、复用字段编号、修改字段含义、修改已有枚举值语义。
|
||||
- 新旧版本会在滚动发布期间同时在线,必须允许旧 `gateway-service` 调新 `room-service`,也必须允许新 `gateway-service` 调旧 `room-service`。
|
||||
- 当前处于开发阶段,不保留历史协议;Proto/API/DB 可以按当前事实直接调整。
|
||||
- 修改 `api/proto` 后必须运行 `make proto`,并提交生成后的 `.pb.go` / `_grpc.pb.go`。
|
||||
- 不要在服务之间直接 import 对方 `internal` 包。跨服务只走 protobuf client 或明确的接口抽象。
|
||||
|
||||
## Compatibility Rules
|
||||
|
||||
以下的注释都是目前不采用的策略,不需要根据这个策略去做
|
||||
|
||||
<!-- - 所有跨服务协议变更必须按“先加字段、双端兼容、灰度发布、确认无旧版本后再清理”的顺序推进。
|
||||
- Proto 字段废弃时只能标记 `deprecated` 或保留字段号和字段名,不能直接删除后复用。
|
||||
- 枚举只能追加新值,不能改已有数值和语义;旧服务收到未知枚举时必须有安全默认行为。
|
||||
- HTTP JSON 请求和响应只能做兼容扩展;不能让旧客户端因为缺少新字段而失败。
|
||||
- 数据库变更必须兼容滚动发布。先新增 nullable 字段、默认值或新表,代码同时兼容新旧结构后,再考虑删除旧字段。
|
||||
- 数据库字段语义不能原地改写。需要改变语义时新增字段承载新语义,并写清迁移窗口和回滚策略。
|
||||
- 删除 proto 字段、API 字段或数据库字段前,必须确认线上没有旧版本实例、旧客户端或旧数据路径依赖。 -->
|
||||
|
||||
## Port Rules
|
||||
|
||||
本项目本地端口使用 `13xxx`:
|
||||
@ -60,7 +48,7 @@
|
||||
- `13004`: wallet gRPC
|
||||
- `13005`: user gRPC
|
||||
- `13006`: activity gRPC
|
||||
- `13306`: local Docker MySQL host port
|
||||
- `23306`: local Docker MySQL host port
|
||||
- `13379`: local Docker Redis host port
|
||||
|
||||
不要重新引入 `808x` 作为默认端口。
|
||||
|
||||
45
Makefile
45
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 compose-up compose-addresses compose-logs compose-down compose-up-service compose-rebuild-service compose-restart-service compose-stop-service compose-logs-service compose-ps-service check-service $(SERVICE_ALIASES)
|
||||
.PHONY: proto test build up rebuild restart stop logs ps addr down up-service check-service $(SERVICE_ALIASES)
|
||||
|
||||
# `proto` 负责把仓库内的 .proto 重新生成成 Go 代码。
|
||||
proto:
|
||||
@ -74,19 +74,6 @@ addr:
|
||||
down:
|
||||
docker compose down
|
||||
|
||||
# `compose-up` 兼容旧命令,等价于 `up`。
|
||||
compose-up:
|
||||
docker compose up --build -d
|
||||
./scripts/print-compose-addresses.sh
|
||||
|
||||
# `compose-addresses` 兼容旧命令,等价于 `addr`。
|
||||
compose-addresses:
|
||||
./scripts/print-compose-addresses.sh
|
||||
|
||||
# `compose-logs` 兼容旧命令,等价于 `logs`。
|
||||
compose-logs:
|
||||
docker compose logs -f
|
||||
|
||||
# `check-service` 校验单服务部署目标的 SERVICE 参数。
|
||||
check-service:
|
||||
@if [ -z "$(SERVICE_ID)" ]; then \
|
||||
@ -107,36 +94,6 @@ up-service: check-service
|
||||
docker compose up -d $(SERVICE_ID)
|
||||
./scripts/print-compose-addresses.sh
|
||||
|
||||
# `compose-up-service` 兼容旧命令,等价于 `up SERVICE=...`。
|
||||
compose-up-service: check-service
|
||||
docker compose up -d $(SERVICE_ID)
|
||||
./scripts/print-compose-addresses.sh
|
||||
|
||||
# `compose-rebuild-service` 兼容旧命令,等价于 `rebuild SERVICE=...`。
|
||||
compose-rebuild-service: check-service
|
||||
docker compose up -d --build $(SERVICE_ID)
|
||||
./scripts/print-compose-addresses.sh
|
||||
|
||||
# `compose-restart-service` 兼容旧命令,等价于 `restart SERVICE=...`。
|
||||
compose-restart-service: check-service
|
||||
docker compose restart $(SERVICE_ID)
|
||||
|
||||
# `compose-stop-service` 兼容旧命令,等价于 `stop SERVICE=...`。
|
||||
compose-stop-service: check-service
|
||||
docker compose stop $(SERVICE_ID)
|
||||
|
||||
# `compose-logs-service` 兼容旧命令,等价于 `logs SERVICE=...`。
|
||||
compose-logs-service: check-service
|
||||
docker compose logs -f --tail=200 $(SERVICE_ID)
|
||||
|
||||
# `compose-ps-service` 兼容旧命令,等价于 `ps SERVICE=...`。
|
||||
compose-ps-service: check-service
|
||||
docker compose ps $(SERVICE_ID)
|
||||
|
||||
# 服务别名只用于 `make restart rs` 这类第二参数,不执行动作。
|
||||
$(SERVICE_ALIASES):
|
||||
@:
|
||||
|
||||
# `compose-down` 兼容旧命令,等价于 `down`。
|
||||
compose-down:
|
||||
docker compose down
|
||||
|
||||
20
README.md
20
README.md
@ -6,6 +6,8 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
|
||||
|
||||
核心边界:
|
||||
|
||||
任何开发,不需要历史兼容,目前只是在开发阶段
|
||||
|
||||
- `gateway-service` 是客户端 HTTP JSON 入口,负责鉴权、协议转换、签发腾讯云 IM UserSig,以及调用内部 gRPC 服务。
|
||||
- `room-service` 是房间业务状态 owner,负责 Room Cell、麦位、房间 presence、本地礼物榜、送礼落房间、snapshot、command log、outbox、Redis lease,以及腾讯云 IM 群组/系统消息桥接。
|
||||
- `wallet-service` 当前提供 `DebitGift` 送礼扣费语义,余额、流水和幂等记录落 MySQL;`room-service` 的 `SendGift` 同步调用它。
|
||||
@ -24,15 +26,15 @@ HY Voice Room 是语音房后端 monorepo。当前架构不再自研 IM 长连
|
||||
|
||||
本地和 Docker 编排统一使用 `13xxx` 端口:
|
||||
|
||||
| Component | Port | Purpose |
|
||||
| --- | ---: | --- |
|
||||
| `gateway-service` | `13000` | HTTP JSON |
|
||||
| `room-service` | `13001` | gRPC |
|
||||
| `wallet-service` | `13004` | gRPC |
|
||||
| `user-service` | `13005` | gRPC |
|
||||
| `activity-service` | `13006` | gRPC |
|
||||
| MySQL | `13306 -> 3306` | local Docker only |
|
||||
| Redis | `13379 -> 6379` | local Docker only |
|
||||
| Component | Port | Purpose |
|
||||
| ------------------ | --------------: | ----------------- |
|
||||
| `gateway-service` | `13000` | HTTP JSON |
|
||||
| `room-service` | `13001` | gRPC |
|
||||
| `wallet-service` | `13004` | gRPC |
|
||||
| `user-service` | `13005` | gRPC |
|
||||
| `activity-service` | `13006` | gRPC |
|
||||
| MySQL | `23306 -> 3306` | local Docker only |
|
||||
| Redis | `13379 -> 6379` | local Docker only |
|
||||
|
||||
## Run Locally
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v5.29.2
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc v5.27.3
|
||||
// source: api/proto/activity/v1/activity.proto
|
||||
|
||||
package activityv1
|
||||
@ -11,6 +11,7 @@ import (
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -22,23 +23,20 @@ const (
|
||||
|
||||
// RequestMeta 是 activity-service 内部 RPC 的最小追踪元信息。
|
||||
type RequestMeta struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
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
|
||||
|
||||
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"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RequestMeta) Reset() {
|
||||
*x = RequestMeta{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
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 {
|
||||
@ -49,7 +47,7 @@ func (*RequestMeta) ProtoMessage() {}
|
||||
|
||||
func (x *RequestMeta) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -94,20 +92,17 @@ func (x *RequestMeta) GetSentAtMs() int64 {
|
||||
|
||||
// PingActivityRequest 用于验证 activity-service gRPC 链路可用。
|
||||
type PingActivityRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PingActivityRequest) Reset() {
|
||||
*x = PingActivityRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
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 {
|
||||
@ -118,7 +113,7 @@ func (*PingActivityRequest) ProtoMessage() {}
|
||||
|
||||
func (x *PingActivityRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -142,21 +137,18 @@ func (x *PingActivityRequest) GetMeta() *RequestMeta {
|
||||
|
||||
// PingActivityResponse 返回服务是否接受请求。
|
||||
type PingActivityResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
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
|
||||
|
||||
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"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PingActivityResponse) Reset() {
|
||||
*x = PingActivityResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
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 {
|
||||
@ -167,7 +159,7 @@ func (*PingActivityResponse) ProtoMessage() {}
|
||||
|
||||
func (x *PingActivityResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -198,21 +190,18 @@ func (x *PingActivityResponse) GetNodeId() string {
|
||||
|
||||
// GetActivityStatusRequest 查询活动服务底座状态。
|
||||
type GetActivityStatusRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
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
|
||||
|
||||
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"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetActivityStatusRequest) Reset() {
|
||||
*x = GetActivityStatusRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
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 {
|
||||
@ -223,7 +212,7 @@ func (*GetActivityStatusRequest) ProtoMessage() {}
|
||||
|
||||
func (x *GetActivityStatusRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[3]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -254,21 +243,18 @@ func (x *GetActivityStatusRequest) GetActivityId() string {
|
||||
|
||||
// GetActivityStatusResponse 返回活动是否启用。
|
||||
type GetActivityStatusResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
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
|
||||
|
||||
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"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *GetActivityStatusResponse) Reset() {
|
||||
*x = GetActivityStatusResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
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 {
|
||||
@ -279,7 +265,7 @@ func (*GetActivityStatusResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetActivityStatusResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_activity_v1_activity_proto_msgTypes[4]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -310,68 +296,41 @@ func (x *GetActivityStatusResponse) GetStatus() string {
|
||||
|
||||
var File_api_proto_activity_v1_activity_proto protoreflect.FileDescriptor
|
||||
|
||||
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,
|
||||
}
|
||||
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_rawDescOnce sync.Once
|
||||
file_api_proto_activity_v1_activity_proto_rawDescData = file_api_proto_activity_v1_activity_proto_rawDesc
|
||||
file_api_proto_activity_v1_activity_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
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(file_api_proto_activity_v1_activity_proto_rawDescData)
|
||||
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)))
|
||||
})
|
||||
return file_api_proto_activity_v1_activity_proto_rawDescData
|
||||
}
|
||||
@ -403,73 +362,11 @@ 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: file_api_proto_activity_v1_activity_proto_rawDesc,
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_proto_activity_v1_activity_proto_rawDesc), len(file_api_proto_activity_v1_activity_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 5,
|
||||
NumExtensions: 0,
|
||||
@ -480,7 +377,6 @@ 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.29.2
|
||||
// - protoc v5.27.3
|
||||
// source: api/proto/activity/v1/activity.proto
|
||||
|
||||
package activityv1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -41,6 +41,41 @@ message RoomMicChanged {
|
||||
int32 from_seat = 3;
|
||||
int32 to_seat = 4;
|
||||
string action = 5;
|
||||
// mic_session_id 用于把业务麦位事件和 RTC 发流会话绑定,客户端和 webhook 都必须透传。
|
||||
string mic_session_id = 6;
|
||||
// publish_state 透出 pending_publish/publishing/idle,避免把“占麦”和“已发流”混成一个状态。
|
||||
string publish_state = 7;
|
||||
// reason 表示系统触发的下麦原因,例如 publish_timeout。
|
||||
string reason = 8;
|
||||
int64 publish_deadline_ms = 9;
|
||||
int64 publish_event_time_ms = 10;
|
||||
}
|
||||
|
||||
// RoomMicSeatLocked 表达单个麦位锁定状态变更。
|
||||
message RoomMicSeatLocked {
|
||||
int64 actor_user_id = 1;
|
||||
int32 seat_no = 2;
|
||||
bool locked = 3;
|
||||
}
|
||||
|
||||
// RoomChatEnabledChanged 表达公屏开关状态变更。
|
||||
message RoomChatEnabledChanged {
|
||||
int64 actor_user_id = 1;
|
||||
bool enabled = 2;
|
||||
}
|
||||
|
||||
// RoomAdminChanged 表达房间管理员集合变更。
|
||||
message RoomAdminChanged {
|
||||
int64 actor_user_id = 1;
|
||||
int64 target_user_id = 2;
|
||||
bool enabled = 3;
|
||||
}
|
||||
|
||||
// RoomHostTransferred 表达主持人身份转移。
|
||||
message RoomHostTransferred {
|
||||
int64 actor_user_id = 1;
|
||||
int64 old_host_user_id = 2;
|
||||
int64 new_host_user_id = 3;
|
||||
}
|
||||
|
||||
// RoomUserMuted 表达禁言状态变更。
|
||||
@ -56,6 +91,12 @@ message RoomUserKicked {
|
||||
int64 target_user_id = 2;
|
||||
}
|
||||
|
||||
// RoomUserUnbanned 表达解除房间 ban。
|
||||
message RoomUserUnbanned {
|
||||
int64 actor_user_id = 1;
|
||||
int64 target_user_id = 2;
|
||||
}
|
||||
|
||||
// RoomGiftSent 表达送礼成功且已落房间状态。
|
||||
message RoomGiftSent {
|
||||
int64 sender_user_id = 1;
|
||||
@ -78,4 +119,3 @@ message RoomRankChanged {
|
||||
int64 score = 2;
|
||||
int64 gift_value = 3;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -33,11 +33,21 @@ message RoomUser {
|
||||
int64 last_seen_at_ms = 4;
|
||||
}
|
||||
|
||||
// SeatState 表达单个麦位当前占用关系。
|
||||
// SeatState 表达单个麦位当前占用和 RTC 发流确认状态。
|
||||
message SeatState {
|
||||
int32 seat_no = 1;
|
||||
int64 user_id = 2;
|
||||
bool locked = 3;
|
||||
// publish_state 为空表示空麦或未进入发流流程;上麦后先进入 pending_publish,确认后进入 publishing。
|
||||
string publish_state = 4;
|
||||
// mic_session_id 是单次上麦发流会话 ID;所有客户端确认或 RTC webhook 必须带回它。
|
||||
string mic_session_id = 5;
|
||||
// publish_deadline_ms 是 pending_publish 必须确认发流的截止时间。
|
||||
int64 publish_deadline_ms = 6;
|
||||
// mic_session_room_version 是创建本次 mic_session 的房间版本,用于丢弃旧版本 RTC 事件。
|
||||
int64 mic_session_room_version = 7;
|
||||
// last_publish_event_time_ms 记录已接受的最新 RTC/客户端发流事件时间。
|
||||
int64 last_publish_event_time_ms = 8;
|
||||
}
|
||||
|
||||
// RankItem 表达房间内本地礼物榜项目。
|
||||
@ -69,15 +79,12 @@ message RoomSnapshot {
|
||||
|
||||
// CreateRoomRequest 创建一个新的房间执行单元。
|
||||
// 必填语义:meta.room_id、meta.command_id、meta.actor_user_id、seat_count 和 mode。
|
||||
// owner_user_id/host_user_id 是内部兼容字段,外部 gateway 创建路径不接收客户端自报值。
|
||||
message CreateRoomRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 owner_user_id = 2;
|
||||
int64 host_user_id = 3;
|
||||
// seat_count 必须大于 0;没有麦位的语音房不进入 Room Cell。
|
||||
int32 seat_count = 4;
|
||||
int32 seat_count = 2;
|
||||
// mode 必须非空;当前只作为房间状态字段保存和透出。
|
||||
string mode = 5;
|
||||
string mode = 3;
|
||||
}
|
||||
|
||||
// CreateRoomResponse 返回新建后的房间快照。
|
||||
@ -121,12 +128,16 @@ message MicUpResponse {
|
||||
CommandResult result = 1;
|
||||
int32 seat_no = 2;
|
||||
RoomSnapshot room = 3;
|
||||
string mic_session_id = 4;
|
||||
int64 publish_deadline_ms = 5;
|
||||
}
|
||||
|
||||
// MicDownRequest 把用户从当前麦位移下。
|
||||
message MicDownRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 target_user_id = 2;
|
||||
// reason 为空表示主动下麦;系统自动释放麦位时会写入稳定原因,例如 publish_timeout。
|
||||
string reason = 3;
|
||||
}
|
||||
|
||||
// MicDownResponse 返回最新房间快照与目标麦位。
|
||||
@ -149,6 +160,75 @@ message ChangeMicSeatResponse {
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
// ConfirmMicPublishingRequest 确认当前 mic_session 已经在 RTC 侧成功发布音频。
|
||||
// 客户端 SDK 回调和后续 RTC webhook 都必须带 mic_session_id、room_version 和 event_time_ms。
|
||||
message ConfirmMicPublishingRequest {
|
||||
RequestMeta meta = 1;
|
||||
// target_user_id 为空时默认确认 actor_user_id;RTC webhook 入口可显式指定目标用户。
|
||||
int64 target_user_id = 2;
|
||||
string mic_session_id = 3;
|
||||
int64 room_version = 4;
|
||||
int64 event_time_ms = 5;
|
||||
string source = 6;
|
||||
}
|
||||
|
||||
// ConfirmMicPublishingResponse 返回确认后的最新房间快照。
|
||||
message ConfirmMicPublishingResponse {
|
||||
CommandResult result = 1;
|
||||
int32 seat_no = 2;
|
||||
RoomSnapshot room = 3;
|
||||
}
|
||||
|
||||
// SetMicSeatLockRequest 锁定或解锁指定麦位。
|
||||
message SetMicSeatLockRequest {
|
||||
RequestMeta meta = 1;
|
||||
int32 seat_no = 2;
|
||||
bool locked = 3;
|
||||
}
|
||||
|
||||
// SetMicSeatLockResponse 返回锁麦后的最新房间快照。
|
||||
message SetMicSeatLockResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
// SetChatEnabledRequest 开启或关闭房间公屏。
|
||||
message SetChatEnabledRequest {
|
||||
RequestMeta meta = 1;
|
||||
bool enabled = 2;
|
||||
}
|
||||
|
||||
// SetChatEnabledResponse 返回聊天开关变更后的最新房间快照。
|
||||
message SetChatEnabledResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
// SetRoomAdminRequest 添加或移除房间管理员。
|
||||
message SetRoomAdminRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 target_user_id = 2;
|
||||
bool enabled = 3;
|
||||
}
|
||||
|
||||
// SetRoomAdminResponse 返回管理员集合变更后的最新房间快照。
|
||||
message SetRoomAdminResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
// TransferRoomHostRequest 把主持人身份转移给房间内用户。
|
||||
message TransferRoomHostRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 target_user_id = 2;
|
||||
}
|
||||
|
||||
// TransferRoomHostResponse 返回主持人转移后的最新房间快照。
|
||||
message TransferRoomHostResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
// MuteUserRequest 修改房间内禁言态。
|
||||
message MuteUserRequest {
|
||||
RequestMeta meta = 1;
|
||||
@ -174,13 +254,24 @@ message KickUserResponse {
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
// UnbanUserRequest 解除房间内 ban,用户仍需重新 JoinRoom。
|
||||
message UnbanUserRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 target_user_id = 2;
|
||||
}
|
||||
|
||||
// UnbanUserResponse 返回解封后的最新房间快照。
|
||||
message UnbanUserResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
// SendGiftRequest 是首版房间内最重要的变现命令。
|
||||
message SendGiftRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 target_user_id = 2;
|
||||
string gift_id = 3;
|
||||
int32 gift_count = 4;
|
||||
int64 gift_unit_value = 5;
|
||||
}
|
||||
|
||||
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||
@ -228,8 +319,14 @@ service RoomCommandService {
|
||||
rpc MicUp(MicUpRequest) returns (MicUpResponse);
|
||||
rpc MicDown(MicDownRequest) returns (MicDownResponse);
|
||||
rpc ChangeMicSeat(ChangeMicSeatRequest) returns (ChangeMicSeatResponse);
|
||||
rpc ConfirmMicPublishing(ConfirmMicPublishingRequest) returns (ConfirmMicPublishingResponse);
|
||||
rpc SetMicSeatLock(SetMicSeatLockRequest) returns (SetMicSeatLockResponse);
|
||||
rpc SetChatEnabled(SetChatEnabledRequest) returns (SetChatEnabledResponse);
|
||||
rpc SetRoomAdmin(SetRoomAdminRequest) returns (SetRoomAdminResponse);
|
||||
rpc TransferRoomHost(TransferRoomHostRequest) returns (TransferRoomHostResponse);
|
||||
rpc MuteUser(MuteUserRequest) returns (MuteUserResponse);
|
||||
rpc KickUser(KickUserRequest) returns (KickUserResponse);
|
||||
rpc UnbanUser(UnbanUserRequest) returns (UnbanUserResponse);
|
||||
rpc SendGift(SendGiftRequest) returns (SendGiftResponse);
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.29.2
|
||||
// - protoc v5.27.3
|
||||
// source: api/proto/room/v1/room.proto
|
||||
|
||||
package roomv1
|
||||
@ -19,15 +19,21 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
RoomCommandService_CreateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CreateRoom"
|
||||
RoomCommandService_JoinRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/JoinRoom"
|
||||
RoomCommandService_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom"
|
||||
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
|
||||
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
|
||||
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
|
||||
RoomCommandService_MuteUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/MuteUser"
|
||||
RoomCommandService_KickUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/KickUser"
|
||||
RoomCommandService_SendGift_FullMethodName = "/hyapp.room.v1.RoomCommandService/SendGift"
|
||||
RoomCommandService_CreateRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/CreateRoom"
|
||||
RoomCommandService_JoinRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/JoinRoom"
|
||||
RoomCommandService_LeaveRoom_FullMethodName = "/hyapp.room.v1.RoomCommandService/LeaveRoom"
|
||||
RoomCommandService_MicUp_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicUp"
|
||||
RoomCommandService_MicDown_FullMethodName = "/hyapp.room.v1.RoomCommandService/MicDown"
|
||||
RoomCommandService_ChangeMicSeat_FullMethodName = "/hyapp.room.v1.RoomCommandService/ChangeMicSeat"
|
||||
RoomCommandService_ConfirmMicPublishing_FullMethodName = "/hyapp.room.v1.RoomCommandService/ConfirmMicPublishing"
|
||||
RoomCommandService_SetMicSeatLock_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetMicSeatLock"
|
||||
RoomCommandService_SetChatEnabled_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetChatEnabled"
|
||||
RoomCommandService_SetRoomAdmin_FullMethodName = "/hyapp.room.v1.RoomCommandService/SetRoomAdmin"
|
||||
RoomCommandService_TransferRoomHost_FullMethodName = "/hyapp.room.v1.RoomCommandService/TransferRoomHost"
|
||||
RoomCommandService_MuteUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/MuteUser"
|
||||
RoomCommandService_KickUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/KickUser"
|
||||
RoomCommandService_UnbanUser_FullMethodName = "/hyapp.room.v1.RoomCommandService/UnbanUser"
|
||||
RoomCommandService_SendGift_FullMethodName = "/hyapp.room.v1.RoomCommandService/SendGift"
|
||||
)
|
||||
|
||||
// RoomCommandServiceClient is the client API for RoomCommandService service.
|
||||
@ -42,8 +48,14 @@ type RoomCommandServiceClient interface {
|
||||
MicUp(ctx context.Context, in *MicUpRequest, opts ...grpc.CallOption) (*MicUpResponse, error)
|
||||
MicDown(ctx context.Context, in *MicDownRequest, opts ...grpc.CallOption) (*MicDownResponse, error)
|
||||
ChangeMicSeat(ctx context.Context, in *ChangeMicSeatRequest, opts ...grpc.CallOption) (*ChangeMicSeatResponse, error)
|
||||
ConfirmMicPublishing(ctx context.Context, in *ConfirmMicPublishingRequest, opts ...grpc.CallOption) (*ConfirmMicPublishingResponse, error)
|
||||
SetMicSeatLock(ctx context.Context, in *SetMicSeatLockRequest, opts ...grpc.CallOption) (*SetMicSeatLockResponse, error)
|
||||
SetChatEnabled(ctx context.Context, in *SetChatEnabledRequest, opts ...grpc.CallOption) (*SetChatEnabledResponse, error)
|
||||
SetRoomAdmin(ctx context.Context, in *SetRoomAdminRequest, opts ...grpc.CallOption) (*SetRoomAdminResponse, error)
|
||||
TransferRoomHost(ctx context.Context, in *TransferRoomHostRequest, opts ...grpc.CallOption) (*TransferRoomHostResponse, error)
|
||||
MuteUser(ctx context.Context, in *MuteUserRequest, opts ...grpc.CallOption) (*MuteUserResponse, error)
|
||||
KickUser(ctx context.Context, in *KickUserRequest, opts ...grpc.CallOption) (*KickUserResponse, error)
|
||||
UnbanUser(ctx context.Context, in *UnbanUserRequest, opts ...grpc.CallOption) (*UnbanUserResponse, error)
|
||||
SendGift(ctx context.Context, in *SendGiftRequest, opts ...grpc.CallOption) (*SendGiftResponse, error)
|
||||
}
|
||||
|
||||
@ -115,6 +127,56 @@ func (c *roomCommandServiceClient) ChangeMicSeat(ctx context.Context, in *Change
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) ConfirmMicPublishing(ctx context.Context, in *ConfirmMicPublishingRequest, opts ...grpc.CallOption) (*ConfirmMicPublishingResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ConfirmMicPublishingResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_ConfirmMicPublishing_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) SetMicSeatLock(ctx context.Context, in *SetMicSeatLockRequest, opts ...grpc.CallOption) (*SetMicSeatLockResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SetMicSeatLockResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_SetMicSeatLock_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) SetChatEnabled(ctx context.Context, in *SetChatEnabledRequest, opts ...grpc.CallOption) (*SetChatEnabledResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SetChatEnabledResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_SetChatEnabled_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) SetRoomAdmin(ctx context.Context, in *SetRoomAdminRequest, opts ...grpc.CallOption) (*SetRoomAdminResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SetRoomAdminResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_SetRoomAdmin_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) TransferRoomHost(ctx context.Context, in *TransferRoomHostRequest, opts ...grpc.CallOption) (*TransferRoomHostResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(TransferRoomHostResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_TransferRoomHost_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) MuteUser(ctx context.Context, in *MuteUserRequest, opts ...grpc.CallOption) (*MuteUserResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(MuteUserResponse)
|
||||
@ -135,6 +197,16 @@ func (c *roomCommandServiceClient) KickUser(ctx context.Context, in *KickUserReq
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) UnbanUser(ctx context.Context, in *UnbanUserRequest, opts ...grpc.CallOption) (*UnbanUserResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(UnbanUserResponse)
|
||||
err := c.cc.Invoke(ctx, RoomCommandService_UnbanUser_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *roomCommandServiceClient) SendGift(ctx context.Context, in *SendGiftRequest, opts ...grpc.CallOption) (*SendGiftResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(SendGiftResponse)
|
||||
@ -157,8 +229,14 @@ type RoomCommandServiceServer interface {
|
||||
MicUp(context.Context, *MicUpRequest) (*MicUpResponse, error)
|
||||
MicDown(context.Context, *MicDownRequest) (*MicDownResponse, error)
|
||||
ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error)
|
||||
ConfirmMicPublishing(context.Context, *ConfirmMicPublishingRequest) (*ConfirmMicPublishingResponse, error)
|
||||
SetMicSeatLock(context.Context, *SetMicSeatLockRequest) (*SetMicSeatLockResponse, error)
|
||||
SetChatEnabled(context.Context, *SetChatEnabledRequest) (*SetChatEnabledResponse, error)
|
||||
SetRoomAdmin(context.Context, *SetRoomAdminRequest) (*SetRoomAdminResponse, error)
|
||||
TransferRoomHost(context.Context, *TransferRoomHostRequest) (*TransferRoomHostResponse, error)
|
||||
MuteUser(context.Context, *MuteUserRequest) (*MuteUserResponse, error)
|
||||
KickUser(context.Context, *KickUserRequest) (*KickUserResponse, error)
|
||||
UnbanUser(context.Context, *UnbanUserRequest) (*UnbanUserResponse, error)
|
||||
SendGift(context.Context, *SendGiftRequest) (*SendGiftResponse, error)
|
||||
mustEmbedUnimplementedRoomCommandServiceServer()
|
||||
}
|
||||
@ -188,12 +266,30 @@ func (UnimplementedRoomCommandServiceServer) MicDown(context.Context, *MicDownRe
|
||||
func (UnimplementedRoomCommandServiceServer) ChangeMicSeat(context.Context, *ChangeMicSeatRequest) (*ChangeMicSeatResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ChangeMicSeat not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) ConfirmMicPublishing(context.Context, *ConfirmMicPublishingRequest) (*ConfirmMicPublishingResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method ConfirmMicPublishing not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) SetMicSeatLock(context.Context, *SetMicSeatLockRequest) (*SetMicSeatLockResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetMicSeatLock not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) SetChatEnabled(context.Context, *SetChatEnabledRequest) (*SetChatEnabledResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetChatEnabled not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) SetRoomAdmin(context.Context, *SetRoomAdminRequest) (*SetRoomAdminResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SetRoomAdmin not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) TransferRoomHost(context.Context, *TransferRoomHostRequest) (*TransferRoomHostResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method TransferRoomHost not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) MuteUser(context.Context, *MuteUserRequest) (*MuteUserResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method MuteUser not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) KickUser(context.Context, *KickUserRequest) (*KickUserResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method KickUser not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) UnbanUser(context.Context, *UnbanUserRequest) (*UnbanUserResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method UnbanUser not implemented")
|
||||
}
|
||||
func (UnimplementedRoomCommandServiceServer) SendGift(context.Context, *SendGiftRequest) (*SendGiftResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method SendGift not implemented")
|
||||
}
|
||||
@ -326,6 +422,96 @@ func _RoomCommandService_ChangeMicSeat_Handler(srv interface{}, ctx context.Cont
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_ConfirmMicPublishing_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ConfirmMicPublishingRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).ConfirmMicPublishing(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_ConfirmMicPublishing_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).ConfirmMicPublishing(ctx, req.(*ConfirmMicPublishingRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_SetMicSeatLock_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetMicSeatLockRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).SetMicSeatLock(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_SetMicSeatLock_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).SetMicSeatLock(ctx, req.(*SetMicSeatLockRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_SetChatEnabled_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetChatEnabledRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).SetChatEnabled(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_SetChatEnabled_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).SetChatEnabled(ctx, req.(*SetChatEnabledRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_SetRoomAdmin_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SetRoomAdminRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).SetRoomAdmin(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_SetRoomAdmin_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).SetRoomAdmin(ctx, req.(*SetRoomAdminRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_TransferRoomHost_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(TransferRoomHostRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).TransferRoomHost(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_TransferRoomHost_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).TransferRoomHost(ctx, req.(*TransferRoomHostRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_MuteUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(MuteUserRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -362,6 +548,24 @@ func _RoomCommandService_KickUser_Handler(srv interface{}, ctx context.Context,
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_UnbanUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(UnbanUserRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(RoomCommandServiceServer).UnbanUser(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: RoomCommandService_UnbanUser_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(RoomCommandServiceServer).UnbanUser(ctx, req.(*UnbanUserRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _RoomCommandService_SendGift_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(SendGiftRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -411,6 +615,26 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "ChangeMicSeat",
|
||||
Handler: _RoomCommandService_ChangeMicSeat_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ConfirmMicPublishing",
|
||||
Handler: _RoomCommandService_ConfirmMicPublishing_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetMicSeatLock",
|
||||
Handler: _RoomCommandService_SetMicSeatLock_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetChatEnabled",
|
||||
Handler: _RoomCommandService_SetChatEnabled_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SetRoomAdmin",
|
||||
Handler: _RoomCommandService_SetRoomAdmin_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "TransferRoomHost",
|
||||
Handler: _RoomCommandService_TransferRoomHost_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "MuteUser",
|
||||
Handler: _RoomCommandService_MuteUser_Handler,
|
||||
@ -419,6 +643,10 @@ var RoomCommandService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "KickUser",
|
||||
Handler: _RoomCommandService_KickUser_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "UnbanUser",
|
||||
Handler: _RoomCommandService_UnbanUser_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "SendGift",
|
||||
Handler: _RoomCommandService_SendGift_Handler,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v5.29.2
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc v5.27.3
|
||||
// source: api/proto/user/v1/auth.proto
|
||||
|
||||
package userv1
|
||||
@ -11,6 +11,7 @@ import (
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -22,29 +23,26 @@ const (
|
||||
|
||||
// AuthToken 是登录和刷新后返回给 gateway 的令牌集合。
|
||||
type AuthToken struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
||||
AccessToken string `protobuf:"bytes,3,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"`
|
||||
RefreshToken string `protobuf:"bytes,4,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"`
|
||||
ExpiresInSec int64 `protobuf:"varint,5,opt,name=expires_in_sec,json=expiresInSec,proto3" json:"expires_in_sec,omitempty"`
|
||||
TokenType string `protobuf:"bytes,6,opt,name=token_type,json=tokenType,proto3" json:"token_type,omitempty"`
|
||||
DisplayUserId string `protobuf:"bytes,7,opt,name=display_user_id,json=displayUserId,proto3" json:"display_user_id,omitempty"`
|
||||
DefaultDisplayUserId string `protobuf:"bytes,8,opt,name=default_display_user_id,json=defaultDisplayUserId,proto3" json:"default_display_user_id,omitempty"`
|
||||
DisplayUserIdKind string `protobuf:"bytes,9,opt,name=display_user_id_kind,json=displayUserIdKind,proto3" json:"display_user_id_kind,omitempty"`
|
||||
DisplayUserIdExpiresAtMs int64 `protobuf:"varint,10,opt,name=display_user_id_expires_at_ms,json=displayUserIdExpiresAtMs,proto3" json:"display_user_id_expires_at_ms,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
||||
AccessToken string `protobuf:"bytes,3,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"`
|
||||
RefreshToken string `protobuf:"bytes,4,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"`
|
||||
ExpiresInSec int64 `protobuf:"varint,5,opt,name=expires_in_sec,json=expiresInSec,proto3" json:"expires_in_sec,omitempty"`
|
||||
TokenType string `protobuf:"bytes,6,opt,name=token_type,json=tokenType,proto3" json:"token_type,omitempty"`
|
||||
DisplayUserId string `protobuf:"bytes,7,opt,name=display_user_id,json=displayUserId,proto3" json:"display_user_id,omitempty"`
|
||||
DefaultDisplayUserId string `protobuf:"bytes,8,opt,name=default_display_user_id,json=defaultDisplayUserId,proto3" json:"default_display_user_id,omitempty"`
|
||||
DisplayUserIdKind string `protobuf:"bytes,9,opt,name=display_user_id_kind,json=displayUserIdKind,proto3" json:"display_user_id_kind,omitempty"`
|
||||
DisplayUserIdExpiresAtMs int64 `protobuf:"varint,10,opt,name=display_user_id_expires_at_ms,json=displayUserIdExpiresAtMs,proto3" json:"display_user_id_expires_at_ms,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *AuthToken) Reset() {
|
||||
*x = AuthToken{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *AuthToken) String() string {
|
||||
@ -55,7 +53,7 @@ func (*AuthToken) ProtoMessage() {}
|
||||
|
||||
func (x *AuthToken) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -142,22 +140,19 @@ func (x *AuthToken) GetDisplayUserIdExpiresAtMs() int64 {
|
||||
|
||||
// LoginPasswordRequest 使用当前 display_user_id 和已设置密码登录。
|
||||
type LoginPasswordRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
DisplayUserId string `protobuf:"bytes,2,opt,name=display_user_id,json=displayUserId,proto3" json:"display_user_id,omitempty"`
|
||||
Password string `protobuf:"bytes,3,opt,name=password,proto3" json:"password,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
DisplayUserId string `protobuf:"bytes,2,opt,name=display_user_id,json=displayUserId,proto3" json:"display_user_id,omitempty"`
|
||||
Password string `protobuf:"bytes,3,opt,name=password,proto3" json:"password,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *LoginPasswordRequest) Reset() {
|
||||
*x = LoginPasswordRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *LoginPasswordRequest) String() string {
|
||||
@ -168,7 +163,7 @@ func (*LoginPasswordRequest) ProtoMessage() {}
|
||||
|
||||
func (x *LoginPasswordRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -206,41 +201,36 @@ func (x *LoginPasswordRequest) GetPassword() string {
|
||||
|
||||
// LoginThirdPartyRequest 使用三方凭证登录或注册。
|
||||
type LoginThirdPartyRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"`
|
||||
Credential string `protobuf:"bytes,3,opt,name=credential,proto3" json:"credential,omitempty"`
|
||||
Username string `protobuf:"bytes,4,opt,name=username,proto3" json:"username,omitempty"`
|
||||
Gender string `protobuf:"bytes,5,opt,name=gender,proto3" json:"gender,omitempty"`
|
||||
Country string `protobuf:"bytes,6,opt,name=country,proto3" json:"country,omitempty"`
|
||||
InviteCode string `protobuf:"bytes,7,opt,name=invite_code,json=inviteCode,proto3" json:"invite_code,omitempty"`
|
||||
// Deprecated: Marked as deprecated in api/proto/user/v1/auth.proto.
|
||||
Ip string `protobuf:"bytes,8,opt,name=ip,proto3" json:"ip,omitempty"`
|
||||
DeviceId string `protobuf:"bytes,9,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||
Device string `protobuf:"bytes,10,opt,name=device,proto3" json:"device,omitempty"`
|
||||
Avatar string `protobuf:"bytes,11,opt,name=avatar,proto3" json:"avatar,omitempty"`
|
||||
Birth string `protobuf:"bytes,12,opt,name=birth,proto3" json:"birth,omitempty"`
|
||||
AppVersion string `protobuf:"bytes,13,opt,name=app_version,json=appVersion,proto3" json:"app_version,omitempty"`
|
||||
Source string `protobuf:"bytes,14,opt,name=source,proto3" json:"source,omitempty"`
|
||||
Platform string `protobuf:"bytes,15,opt,name=platform,proto3" json:"platform,omitempty"`
|
||||
Language string `protobuf:"bytes,16,opt,name=language,proto3" json:"language,omitempty"`
|
||||
OsVersion string `protobuf:"bytes,17,opt,name=os_version,json=osVersion,proto3" json:"os_version,omitempty"`
|
||||
BuildNumber string `protobuf:"bytes,18,opt,name=build_number,json=buildNumber,proto3" json:"build_number,omitempty"`
|
||||
Timezone string `protobuf:"bytes,19,opt,name=timezone,proto3" json:"timezone,omitempty"`
|
||||
InstallChannel string `protobuf:"bytes,20,opt,name=install_channel,json=installChannel,proto3" json:"install_channel,omitempty"`
|
||||
Campaign string `protobuf:"bytes,21,opt,name=campaign,proto3" json:"campaign,omitempty"`
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
Provider string `protobuf:"bytes,2,opt,name=provider,proto3" json:"provider,omitempty"`
|
||||
Credential string `protobuf:"bytes,3,opt,name=credential,proto3" json:"credential,omitempty"`
|
||||
Username string `protobuf:"bytes,4,opt,name=username,proto3" json:"username,omitempty"`
|
||||
Gender string `protobuf:"bytes,5,opt,name=gender,proto3" json:"gender,omitempty"`
|
||||
Country string `protobuf:"bytes,6,opt,name=country,proto3" json:"country,omitempty"`
|
||||
InviteCode string `protobuf:"bytes,7,opt,name=invite_code,json=inviteCode,proto3" json:"invite_code,omitempty"`
|
||||
DeviceId string `protobuf:"bytes,8,opt,name=device_id,json=deviceId,proto3" json:"device_id,omitempty"`
|
||||
Device string `protobuf:"bytes,9,opt,name=device,proto3" json:"device,omitempty"`
|
||||
Avatar string `protobuf:"bytes,10,opt,name=avatar,proto3" json:"avatar,omitempty"`
|
||||
Birth string `protobuf:"bytes,11,opt,name=birth,proto3" json:"birth,omitempty"`
|
||||
AppVersion string `protobuf:"bytes,12,opt,name=app_version,json=appVersion,proto3" json:"app_version,omitempty"`
|
||||
Source string `protobuf:"bytes,13,opt,name=source,proto3" json:"source,omitempty"`
|
||||
Platform string `protobuf:"bytes,14,opt,name=platform,proto3" json:"platform,omitempty"`
|
||||
Language string `protobuf:"bytes,15,opt,name=language,proto3" json:"language,omitempty"`
|
||||
OsVersion string `protobuf:"bytes,16,opt,name=os_version,json=osVersion,proto3" json:"os_version,omitempty"`
|
||||
BuildNumber string `protobuf:"bytes,17,opt,name=build_number,json=buildNumber,proto3" json:"build_number,omitempty"`
|
||||
Timezone string `protobuf:"bytes,18,opt,name=timezone,proto3" json:"timezone,omitempty"`
|
||||
InstallChannel string `protobuf:"bytes,19,opt,name=install_channel,json=installChannel,proto3" json:"install_channel,omitempty"`
|
||||
Campaign string `protobuf:"bytes,20,opt,name=campaign,proto3" json:"campaign,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *LoginThirdPartyRequest) Reset() {
|
||||
*x = LoginThirdPartyRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *LoginThirdPartyRequest) String() string {
|
||||
@ -251,7 +241,7 @@ func (*LoginThirdPartyRequest) ProtoMessage() {}
|
||||
|
||||
func (x *LoginThirdPartyRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[2]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -315,14 +305,6 @@ func (x *LoginThirdPartyRequest) GetInviteCode() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Deprecated: Marked as deprecated in api/proto/user/v1/auth.proto.
|
||||
func (x *LoginThirdPartyRequest) GetIp() string {
|
||||
if x != nil {
|
||||
return x.Ip
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LoginThirdPartyRequest) GetDeviceId() string {
|
||||
if x != nil {
|
||||
return x.DeviceId
|
||||
@ -416,21 +398,18 @@ func (x *LoginThirdPartyRequest) GetCampaign() string {
|
||||
|
||||
// AuthResponse 返回标准认证令牌。
|
||||
type AuthResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Token *AuthToken `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
|
||||
IsNewUser bool `protobuf:"varint,2,opt,name=is_new_user,json=isNewUser,proto3" json:"is_new_user,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Token *AuthToken `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
|
||||
IsNewUser bool `protobuf:"varint,2,opt,name=is_new_user,json=isNewUser,proto3" json:"is_new_user,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *AuthResponse) Reset() {
|
||||
*x = AuthResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *AuthResponse) String() string {
|
||||
@ -441,7 +420,7 @@ func (*AuthResponse) ProtoMessage() {}
|
||||
|
||||
func (x *AuthResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[3]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -472,22 +451,19 @@ func (x *AuthResponse) GetIsNewUser() bool {
|
||||
|
||||
// SetPasswordRequest 为已经三方登录的用户首次设置密码。
|
||||
type SetPasswordRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
Password string `protobuf:"bytes,3,opt,name=password,proto3" json:"password,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
|
||||
Password string `protobuf:"bytes,3,opt,name=password,proto3" json:"password,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SetPasswordRequest) Reset() {
|
||||
*x = SetPasswordRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SetPasswordRequest) String() string {
|
||||
@ -498,7 +474,7 @@ func (*SetPasswordRequest) ProtoMessage() {}
|
||||
|
||||
func (x *SetPasswordRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[4]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -536,20 +512,17 @@ func (x *SetPasswordRequest) GetPassword() string {
|
||||
|
||||
// SetPasswordResponse 返回密码是否已经成功设置。
|
||||
type SetPasswordResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
PasswordSet bool `protobuf:"varint,1,opt,name=password_set,json=passwordSet,proto3" json:"password_set,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
PasswordSet bool `protobuf:"varint,1,opt,name=password_set,json=passwordSet,proto3" json:"password_set,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SetPasswordResponse) Reset() {
|
||||
*x = SetPasswordResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *SetPasswordResponse) String() string {
|
||||
@ -560,7 +533,7 @@ func (*SetPasswordResponse) ProtoMessage() {}
|
||||
|
||||
func (x *SetPasswordResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[5]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -584,21 +557,18 @@ func (x *SetPasswordResponse) GetPasswordSet() bool {
|
||||
|
||||
// RefreshTokenRequest 使用 refresh token 换取新 token。
|
||||
type RefreshTokenRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
RefreshToken string `protobuf:"bytes,2,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
RefreshToken string `protobuf:"bytes,2,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RefreshTokenRequest) Reset() {
|
||||
*x = RefreshTokenRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RefreshTokenRequest) String() string {
|
||||
@ -609,7 +579,7 @@ func (*RefreshTokenRequest) ProtoMessage() {}
|
||||
|
||||
func (x *RefreshTokenRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[6]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -640,20 +610,17 @@ func (x *RefreshTokenRequest) GetRefreshToken() string {
|
||||
|
||||
// RefreshTokenResponse 返回轮换后的令牌。
|
||||
type RefreshTokenResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Token *AuthToken `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Token *AuthToken `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RefreshTokenResponse) Reset() {
|
||||
*x = RefreshTokenResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *RefreshTokenResponse) String() string {
|
||||
@ -664,7 +631,7 @@ func (*RefreshTokenResponse) ProtoMessage() {}
|
||||
|
||||
func (x *RefreshTokenResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[7]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -688,22 +655,19 @@ func (x *RefreshTokenResponse) GetToken() *AuthToken {
|
||||
|
||||
// LogoutRequest 失效指定会话或 refresh token。
|
||||
type LogoutRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
||||
RefreshToken string `protobuf:"bytes,3,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
|
||||
SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
|
||||
RefreshToken string `protobuf:"bytes,3,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *LogoutRequest) Reset() {
|
||||
*x = LogoutRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *LogoutRequest) String() string {
|
||||
@ -714,7 +678,7 @@ func (*LogoutRequest) ProtoMessage() {}
|
||||
|
||||
func (x *LogoutRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[8]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -752,20 +716,17 @@ func (x *LogoutRequest) GetRefreshToken() string {
|
||||
|
||||
// LogoutResponse 返回会话是否被失效。
|
||||
type LogoutResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Revoked bool `protobuf:"varint,1,opt,name=revoked,proto3" json:"revoked,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
|
||||
Revoked bool `protobuf:"varint,1,opt,name=revoked,proto3" json:"revoked,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *LogoutResponse) Reset() {
|
||||
*x = LogoutResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *LogoutResponse) String() string {
|
||||
@ -776,7 +737,7 @@ func (*LogoutResponse) ProtoMessage() {}
|
||||
|
||||
func (x *LogoutResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_user_v1_auth_proto_msgTypes[9]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -800,167 +761,90 @@ func (x *LogoutResponse) GetRevoked() bool {
|
||||
|
||||
var File_api_proto_user_v1_auth_proto protoreflect.FileDescriptor
|
||||
|
||||
var file_api_proto_user_v1_auth_proto_rawDesc = []byte{
|
||||
0x0a, 0x1c, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72,
|
||||
0x2f, 0x76, 0x31, 0x2f, 0x61, 0x75, 0x74, 0x68, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0d,
|
||||
0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x1a, 0x1c, 0x61,
|
||||
0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x2f, 0x76, 0x31,
|
||||
0x2f, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xa1, 0x03, 0x0a, 0x09,
|
||||
0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65,
|
||||
0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72,
|
||||
0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49,
|
||||
0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x74, 0x6f, 0x6b, 0x65,
|
||||
0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x54,
|
||||
0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f,
|
||||
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x72, 0x65, 0x66,
|
||||
0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x65, 0x78, 0x70,
|
||||
0x69, 0x72, 0x65, 0x73, 0x5f, 0x69, 0x6e, 0x5f, 0x73, 0x65, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28,
|
||||
0x03, 0x52, 0x0c, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x49, 0x6e, 0x53, 0x65, 0x63, 0x12,
|
||||
0x1d, 0x0a, 0x0a, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x06, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x09, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x54, 0x79, 0x70, 0x65, 0x12, 0x26,
|
||||
0x0a, 0x0f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79,
|
||||
0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x35, 0x0a, 0x17, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c,
|
||||
0x74, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
|
||||
0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2f, 0x0a,
|
||||
0x14, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64,
|
||||
0x5f, 0x6b, 0x69, 0x6e, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x64, 0x69, 0x73,
|
||||
0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x3f,
|
||||
0x0a, 0x1d, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
|
||||
0x64, 0x5f, 0x65, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x5f, 0x61, 0x74, 0x5f, 0x6d, 0x73, 0x18,
|
||||
0x0a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x18, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73,
|
||||
0x65, 0x72, 0x49, 0x64, 0x45, 0x78, 0x70, 0x69, 0x72, 0x65, 0x73, 0x41, 0x74, 0x4d, 0x73, 0x22,
|
||||
0x8a, 0x01, 0x0a, 0x14, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72,
|
||||
0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75,
|
||||
0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65,
|
||||
0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x26, 0x0a, 0x0f, 0x64, 0x69, 0x73, 0x70,
|
||||
0x6c, 0x61, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x0d, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x55, 0x73, 0x65, 0x72, 0x49, 0x64,
|
||||
0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0xfe, 0x04, 0x0a,
|
||||
0x16, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
|
||||
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74,
|
||||
0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69,
|
||||
0x64, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69,
|
||||
0x64, 0x65, 0x72, 0x12, 0x1e, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61,
|
||||
0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74,
|
||||
0x69, 0x61, 0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x18,
|
||||
0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x75, 0x73, 0x65, 0x72, 0x6e, 0x61, 0x6d, 0x65, 0x12,
|
||||
0x16, 0x0a, 0x06, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x06, 0x67, 0x65, 0x6e, 0x64, 0x65, 0x72, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74,
|
||||
0x72, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72,
|
||||
0x79, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65,
|
||||
0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x76, 0x69, 0x74, 0x65, 0x43, 0x6f,
|
||||
0x64, 0x65, 0x12, 0x12, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02,
|
||||
0x18, 0x01, 0x52, 0x02, 0x69, 0x70, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65,
|
||||
0x5f, 0x69, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, 0x69, 0x63,
|
||||
0x65, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x18, 0x0a, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x61,
|
||||
0x76, 0x61, 0x74, 0x61, 0x72, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x61, 0x76, 0x61,
|
||||
0x74, 0x61, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x69, 0x72, 0x74, 0x68, 0x18, 0x0c, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x05, 0x62, 0x69, 0x72, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x61, 0x70, 0x70,
|
||||
0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a,
|
||||
0x61, 0x70, 0x70, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x6f,
|
||||
0x75, 0x72, 0x63, 0x65, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x6f, 0x75, 0x72,
|
||||
0x63, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18, 0x0f,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12, 0x1a,
|
||||
0x0a, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x6c, 0x61, 0x6e, 0x67, 0x75, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6f, 0x73,
|
||||
0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x11, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
|
||||
0x6f, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x62, 0x75, 0x69,
|
||||
0x6c, 0x64, 0x5f, 0x6e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x18, 0x12, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x0b, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x4e, 0x75, 0x6d, 0x62, 0x65, 0x72, 0x12, 0x1a, 0x0a, 0x08,
|
||||
0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, 0x18, 0x13, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
|
||||
0x74, 0x69, 0x6d, 0x65, 0x7a, 0x6f, 0x6e, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x69, 0x6e, 0x73, 0x74,
|
||||
0x61, 0x6c, 0x6c, 0x5f, 0x63, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x18, 0x14, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x0e, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6c, 0x6c, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65,
|
||||
0x6c, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x61, 0x6d, 0x70, 0x61, 0x69, 0x67, 0x6e, 0x18, 0x15, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x08, 0x63, 0x61, 0x6d, 0x70, 0x61, 0x69, 0x67, 0x6e, 0x22, 0x5e, 0x0a,
|
||||
0x0c, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a,
|
||||
0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x68,
|
||||
0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74,
|
||||
0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1e, 0x0a,
|
||||
0x0b, 0x69, 0x73, 0x5f, 0x6e, 0x65, 0x77, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x08, 0x52, 0x09, 0x69, 0x73, 0x4e, 0x65, 0x77, 0x55, 0x73, 0x65, 0x72, 0x22, 0x79, 0x0a,
|
||||
0x12, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
|
||||
0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x52, 0x04, 0x6d,
|
||||
0x65, 0x74, 0x61, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x03, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1a, 0x0a, 0x08,
|
||||
0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
|
||||
0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x38, 0x0a, 0x13, 0x53, 0x65, 0x74, 0x50,
|
||||
0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x21, 0x0a, 0x0c, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x5f, 0x73, 0x65, 0x74, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x53,
|
||||
0x65, 0x74, 0x22, 0x6a, 0x0a, 0x13, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b,
|
||||
0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74,
|
||||
0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e,
|
||||
0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d,
|
||||
0x65, 0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x66,
|
||||
0x72, 0x65, 0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x0c, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x46,
|
||||
0x0a, 0x14, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
|
||||
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52,
|
||||
0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x83, 0x01, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x6f, 0x75,
|
||||
0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6d, 0x65, 0x74, 0x61,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75,
|
||||
0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x4d, 0x65,
|
||||
0x74, 0x61, 0x52, 0x04, 0x6d, 0x65, 0x74, 0x61, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73,
|
||||
0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65,
|
||||
0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x66, 0x72, 0x65,
|
||||
0x73, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
|
||||
0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x2a, 0x0a, 0x0e,
|
||||
0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18,
|
||||
0x0a, 0x07, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
|
||||
0x07, 0x72, 0x65, 0x76, 0x6f, 0x6b, 0x65, 0x64, 0x32, 0xad, 0x03, 0x0a, 0x0b, 0x41, 0x75, 0x74,
|
||||
0x68, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x51, 0x0a, 0x0d, 0x4c, 0x6f, 0x67, 0x69,
|
||||
0x6e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x23, 0x2e, 0x68, 0x79, 0x61, 0x70,
|
||||
0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x69, 0x6e, 0x50,
|
||||
0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b,
|
||||
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41,
|
||||
0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, 0x0a, 0x0f, 0x4c,
|
||||
0x6f, 0x67, 0x69, 0x6e, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x12, 0x25,
|
||||
0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c,
|
||||
0x6f, 0x67, 0x69, 0x6e, 0x54, 0x68, 0x69, 0x72, 0x64, 0x50, 0x61, 0x72, 0x74, 0x79, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73,
|
||||
0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x54, 0x0a, 0x0b, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72,
|
||||
0x64, 0x12, 0x21, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76,
|
||||
0x31, 0x2e, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65,
|
||||
0x72, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0c, 0x52, 0x65, 0x66, 0x72,
|
||||
0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x22, 0x2e, 0x68, 0x79, 0x61, 0x70, 0x70,
|
||||
0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68,
|
||||
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x68,
|
||||
0x79, 0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x66,
|
||||
0x72, 0x65, 0x73, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x45, 0x0a, 0x06, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x12, 0x1c, 0x2e, 0x68, 0x79,
|
||||
0x61, 0x70, 0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f,
|
||||
0x75, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x68, 0x79, 0x61, 0x70,
|
||||
0x70, 0x2e, 0x75, 0x73, 0x65, 0x72, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x67, 0x6f, 0x75, 0x74,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x20, 0x5a, 0x1e, 0x68, 0x79, 0x61, 0x70,
|
||||
0x70, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72,
|
||||
0x2f, 0x76, 0x31, 0x3b, 0x75, 0x73, 0x65, 0x72, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
|
||||
0x6f, 0x33,
|
||||
}
|
||||
const file_api_proto_user_v1_auth_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x1capi/proto/user/v1/auth.proto\x12\rhyapp.user.v1\x1a\x1capi/proto/user/v1/user.proto\"\xa1\x03\n" +
|
||||
"\tAuthToken\x12\x17\n" +
|
||||
"\auser_id\x18\x01 \x01(\x03R\x06userId\x12\x1d\n" +
|
||||
"\n" +
|
||||
"session_id\x18\x02 \x01(\tR\tsessionId\x12!\n" +
|
||||
"\faccess_token\x18\x03 \x01(\tR\vaccessToken\x12#\n" +
|
||||
"\rrefresh_token\x18\x04 \x01(\tR\frefreshToken\x12$\n" +
|
||||
"\x0eexpires_in_sec\x18\x05 \x01(\x03R\fexpiresInSec\x12\x1d\n" +
|
||||
"\n" +
|
||||
"token_type\x18\x06 \x01(\tR\ttokenType\x12&\n" +
|
||||
"\x0fdisplay_user_id\x18\a \x01(\tR\rdisplayUserId\x125\n" +
|
||||
"\x17default_display_user_id\x18\b \x01(\tR\x14defaultDisplayUserId\x12/\n" +
|
||||
"\x14display_user_id_kind\x18\t \x01(\tR\x11displayUserIdKind\x12?\n" +
|
||||
"\x1ddisplay_user_id_expires_at_ms\x18\n" +
|
||||
" \x01(\x03R\x18displayUserIdExpiresAtMs\"\x8a\x01\n" +
|
||||
"\x14LoginPasswordRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12&\n" +
|
||||
"\x0fdisplay_user_id\x18\x02 \x01(\tR\rdisplayUserId\x12\x1a\n" +
|
||||
"\bpassword\x18\x03 \x01(\tR\bpassword\"\xea\x04\n" +
|
||||
"\x16LoginThirdPartyRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x1a\n" +
|
||||
"\bprovider\x18\x02 \x01(\tR\bprovider\x12\x1e\n" +
|
||||
"\n" +
|
||||
"credential\x18\x03 \x01(\tR\n" +
|
||||
"credential\x12\x1a\n" +
|
||||
"\busername\x18\x04 \x01(\tR\busername\x12\x16\n" +
|
||||
"\x06gender\x18\x05 \x01(\tR\x06gender\x12\x18\n" +
|
||||
"\acountry\x18\x06 \x01(\tR\acountry\x12\x1f\n" +
|
||||
"\vinvite_code\x18\a \x01(\tR\n" +
|
||||
"inviteCode\x12\x1b\n" +
|
||||
"\tdevice_id\x18\b \x01(\tR\bdeviceId\x12\x16\n" +
|
||||
"\x06device\x18\t \x01(\tR\x06device\x12\x16\n" +
|
||||
"\x06avatar\x18\n" +
|
||||
" \x01(\tR\x06avatar\x12\x14\n" +
|
||||
"\x05birth\x18\v \x01(\tR\x05birth\x12\x1f\n" +
|
||||
"\vapp_version\x18\f \x01(\tR\n" +
|
||||
"appVersion\x12\x16\n" +
|
||||
"\x06source\x18\r \x01(\tR\x06source\x12\x1a\n" +
|
||||
"\bplatform\x18\x0e \x01(\tR\bplatform\x12\x1a\n" +
|
||||
"\blanguage\x18\x0f \x01(\tR\blanguage\x12\x1d\n" +
|
||||
"\n" +
|
||||
"os_version\x18\x10 \x01(\tR\tosVersion\x12!\n" +
|
||||
"\fbuild_number\x18\x11 \x01(\tR\vbuildNumber\x12\x1a\n" +
|
||||
"\btimezone\x18\x12 \x01(\tR\btimezone\x12'\n" +
|
||||
"\x0finstall_channel\x18\x13 \x01(\tR\x0einstallChannel\x12\x1a\n" +
|
||||
"\bcampaign\x18\x14 \x01(\tR\bcampaign\"^\n" +
|
||||
"\fAuthResponse\x12.\n" +
|
||||
"\x05token\x18\x01 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token\x12\x1e\n" +
|
||||
"\vis_new_user\x18\x02 \x01(\bR\tisNewUser\"y\n" +
|
||||
"\x12SetPasswordRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x17\n" +
|
||||
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x1a\n" +
|
||||
"\bpassword\x18\x03 \x01(\tR\bpassword\"8\n" +
|
||||
"\x13SetPasswordResponse\x12!\n" +
|
||||
"\fpassword_set\x18\x01 \x01(\bR\vpasswordSet\"j\n" +
|
||||
"\x13RefreshTokenRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12#\n" +
|
||||
"\rrefresh_token\x18\x02 \x01(\tR\frefreshToken\"F\n" +
|
||||
"\x14RefreshTokenResponse\x12.\n" +
|
||||
"\x05token\x18\x01 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token\"\x83\x01\n" +
|
||||
"\rLogoutRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x1d\n" +
|
||||
"\n" +
|
||||
"session_id\x18\x02 \x01(\tR\tsessionId\x12#\n" +
|
||||
"\rrefresh_token\x18\x03 \x01(\tR\frefreshToken\"*\n" +
|
||||
"\x0eLogoutResponse\x12\x18\n" +
|
||||
"\arevoked\x18\x01 \x01(\bR\arevoked2\xad\x03\n" +
|
||||
"\vAuthService\x12Q\n" +
|
||||
"\rLoginPassword\x12#.hyapp.user.v1.LoginPasswordRequest\x1a\x1b.hyapp.user.v1.AuthResponse\x12U\n" +
|
||||
"\x0fLoginThirdParty\x12%.hyapp.user.v1.LoginThirdPartyRequest\x1a\x1b.hyapp.user.v1.AuthResponse\x12T\n" +
|
||||
"\vSetPassword\x12!.hyapp.user.v1.SetPasswordRequest\x1a\".hyapp.user.v1.SetPasswordResponse\x12W\n" +
|
||||
"\fRefreshToken\x12\".hyapp.user.v1.RefreshTokenRequest\x1a#.hyapp.user.v1.RefreshTokenResponse\x12E\n" +
|
||||
"\x06Logout\x12\x1c.hyapp.user.v1.LogoutRequest\x1a\x1d.hyapp.user.v1.LogoutResponseB Z\x1ehyapp/api/proto/user/v1;userv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_api_proto_user_v1_auth_proto_rawDescOnce sync.Once
|
||||
file_api_proto_user_v1_auth_proto_rawDescData = file_api_proto_user_v1_auth_proto_rawDesc
|
||||
file_api_proto_user_v1_auth_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
func file_api_proto_user_v1_auth_proto_rawDescGZIP() []byte {
|
||||
file_api_proto_user_v1_auth_proto_rawDescOnce.Do(func() {
|
||||
file_api_proto_user_v1_auth_proto_rawDescData = protoimpl.X.CompressGZIP(file_api_proto_user_v1_auth_proto_rawDescData)
|
||||
file_api_proto_user_v1_auth_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_proto_user_v1_auth_proto_rawDesc), len(file_api_proto_user_v1_auth_proto_rawDesc)))
|
||||
})
|
||||
return file_api_proto_user_v1_auth_proto_rawDescData
|
||||
}
|
||||
@ -1010,133 +894,11 @@ func file_api_proto_user_v1_auth_proto_init() {
|
||||
return
|
||||
}
|
||||
file_api_proto_user_v1_user_proto_init()
|
||||
if !protoimpl.UnsafeEnabled {
|
||||
file_api_proto_user_v1_auth_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*AuthToken); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_user_v1_auth_proto_msgTypes[1].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*LoginPasswordRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_user_v1_auth_proto_msgTypes[2].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*LoginThirdPartyRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_user_v1_auth_proto_msgTypes[3].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*AuthResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_user_v1_auth_proto_msgTypes[4].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*SetPasswordRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_user_v1_auth_proto_msgTypes[5].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*SetPasswordResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_user_v1_auth_proto_msgTypes[6].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RefreshTokenRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_user_v1_auth_proto_msgTypes[7].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*RefreshTokenResponse); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_user_v1_auth_proto_msgTypes[8].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*LogoutRequest); i {
|
||||
case 0:
|
||||
return &v.state
|
||||
case 1:
|
||||
return &v.sizeCache
|
||||
case 2:
|
||||
return &v.unknownFields
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
file_api_proto_user_v1_auth_proto_msgTypes[9].Exporter = func(v any, i int) any {
|
||||
switch v := v.(*LogoutResponse); 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: file_api_proto_user_v1_auth_proto_rawDesc,
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_proto_user_v1_auth_proto_rawDesc), len(file_api_proto_user_v1_auth_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 10,
|
||||
NumExtensions: 0,
|
||||
@ -1147,7 +909,6 @@ func file_api_proto_user_v1_auth_proto_init() {
|
||||
MessageInfos: file_api_proto_user_v1_auth_proto_msgTypes,
|
||||
}.Build()
|
||||
File_api_proto_user_v1_auth_proto = out.File
|
||||
file_api_proto_user_v1_auth_proto_rawDesc = nil
|
||||
file_api_proto_user_v1_auth_proto_goTypes = nil
|
||||
file_api_proto_user_v1_auth_proto_depIdxs = nil
|
||||
}
|
||||
|
||||
@ -36,20 +36,19 @@ message LoginThirdPartyRequest {
|
||||
string gender = 5;
|
||||
string country = 6;
|
||||
string invite_code = 7;
|
||||
string ip = 8 [deprecated = true];
|
||||
string device_id = 9;
|
||||
string device = 10;
|
||||
string avatar = 11;
|
||||
string birth = 12;
|
||||
string app_version = 13;
|
||||
string source = 14;
|
||||
string platform = 15;
|
||||
string language = 16;
|
||||
string os_version = 17;
|
||||
string build_number = 18;
|
||||
string timezone = 19;
|
||||
string install_channel = 20;
|
||||
string campaign = 21;
|
||||
string device_id = 8;
|
||||
string device = 9;
|
||||
string avatar = 10;
|
||||
string birth = 11;
|
||||
string app_version = 12;
|
||||
string source = 13;
|
||||
string platform = 14;
|
||||
string language = 15;
|
||||
string os_version = 16;
|
||||
string build_number = 17;
|
||||
string timezone = 18;
|
||||
string install_channel = 19;
|
||||
string campaign = 20;
|
||||
}
|
||||
|
||||
// AuthResponse 返回标准认证令牌。
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.29.2
|
||||
// - protoc v5.27.3
|
||||
// source: api/proto/user/v1/auth.proto
|
||||
|
||||
package userv1
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.29.2
|
||||
// - protoc v5.27.3
|
||||
// source: api/proto/user/v1/user.proto
|
||||
|
||||
package userv1
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.34.2
|
||||
// protoc v5.29.2
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc v5.27.3
|
||||
// source: api/proto/wallet/v1/wallet.proto
|
||||
|
||||
package walletv1
|
||||
@ -11,6 +11,7 @@ import (
|
||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||
reflect "reflect"
|
||||
sync "sync"
|
||||
unsafe "unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
@ -22,26 +23,24 @@ const (
|
||||
|
||||
// DebitGiftRequest 是 room-service 送礼同步扣费的最小账务输入。
|
||||
type DebitGiftRequest struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
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
|
||||
|
||||
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"`
|
||||
GiftUnitValue int64 `protobuf:"varint,7,opt,name=gift_unit_value,json=giftUnitValue,proto3" json:"gift_unit_value,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DebitGiftRequest) Reset() {
|
||||
*x = DebitGiftRequest{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[0]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
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 {
|
||||
@ -52,7 +51,7 @@ func (*DebitGiftRequest) ProtoMessage() {}
|
||||
|
||||
func (x *DebitGiftRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[0]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -109,31 +108,33 @@ func (x *DebitGiftRequest) GetGiftCount() int32 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DebitGiftRequest) GetGiftUnitValue() int64 {
|
||||
func (x *DebitGiftRequest) GetPriceVersion() string {
|
||||
if x != nil {
|
||||
return x.GiftUnitValue
|
||||
return x.PriceVersion
|
||||
}
|
||||
return 0
|
||||
return ""
|
||||
}
|
||||
|
||||
// DebitGiftResponse 返回扣费流水和账后余额。
|
||||
// DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。
|
||||
type DebitGiftResponse struct {
|
||||
state protoimpl.MessageState
|
||||
sizeCache protoimpl.SizeCache
|
||||
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
|
||||
|
||||
BillingReceiptId string `protobuf:"bytes,1,opt,name=billing_receipt_id,json=billingReceiptId,proto3" json:"billing_receipt_id,omitempty"`
|
||||
TotalGiftValue int64 `protobuf:"varint,2,opt,name=total_gift_value,json=totalGiftValue,proto3" json:"total_gift_value,omitempty"`
|
||||
BalanceAfter int64 `protobuf:"varint,3,opt,name=balance_after,json=balanceAfter,proto3" json:"balance_after,omitempty"`
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DebitGiftResponse) Reset() {
|
||||
*x = DebitGiftResponse{}
|
||||
if protoimpl.UnsafeEnabled {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
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 {
|
||||
@ -144,7 +145,7 @@ func (*DebitGiftResponse) ProtoMessage() {}
|
||||
|
||||
func (x *DebitGiftResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[1]
|
||||
if protoimpl.UnsafeEnabled && x != nil {
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
@ -166,13 +167,41 @@ func (x *DebitGiftResponse) GetBillingReceiptId() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DebitGiftResponse) GetTotalGiftValue() int64 {
|
||||
func (x *DebitGiftResponse) GetTransactionId() string {
|
||||
if x != nil {
|
||||
return x.TotalGiftValue
|
||||
return x.TransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DebitGiftResponse) GetCoinSpent() int64 {
|
||||
if x != nil {
|
||||
return x.CoinSpent
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DebitGiftResponse) GetGiftPointAdded() int64 {
|
||||
if x != nil {
|
||||
return x.GiftPointAdded
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DebitGiftResponse) GetHeatValue() int64 {
|
||||
if x != nil {
|
||||
return x.HeatValue
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DebitGiftResponse) GetPriceVersion() string {
|
||||
if x != nil {
|
||||
return x.PriceVersion
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *DebitGiftResponse) GetBalanceAfter() int64 {
|
||||
if x != nil {
|
||||
return x.BalanceAfter
|
||||
@ -180,74 +209,418 @@ func (x *DebitGiftResponse) GetBalanceAfter() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (x *AssetBalance) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*AssetBalance) ProtoMessage() {}
|
||||
|
||||
func (x *AssetBalance) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use AssetBalance.ProtoReflect.Descriptor instead.
|
||||
func (*AssetBalance) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *AssetBalance) GetAssetType() string {
|
||||
if x != nil {
|
||||
return x.AssetType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AssetBalance) GetAvailableAmount() int64 {
|
||||
if x != nil {
|
||||
return x.AvailableAmount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *AssetBalance) GetFrozenAmount() int64 {
|
||||
if x != nil {
|
||||
return x.FrozenAmount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *AssetBalance) GetVersion() int64 {
|
||||
if x != nil {
|
||||
return x.Version
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// 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
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (x *GetBalancesRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetBalancesRequest) ProtoMessage() {}
|
||||
|
||||
func (x *GetBalancesRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetBalancesRequest.ProtoReflect.Descriptor instead.
|
||||
func (*GetBalancesRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *GetBalancesRequest) GetRequestId() string {
|
||||
if x != nil {
|
||||
return x.RequestId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *GetBalancesRequest) GetUserId() int64 {
|
||||
if x != nil {
|
||||
return x.UserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GetBalancesRequest) GetAssetTypes() []string {
|
||||
if x != nil {
|
||||
return x.AssetTypes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type GetBalancesResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Balances []*AssetBalance `protobuf:"bytes,1,rep,name=balances,proto3" json:"balances,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (x *GetBalancesResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*GetBalancesResponse) ProtoMessage() {}
|
||||
|
||||
func (x *GetBalancesResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use GetBalancesResponse.ProtoReflect.Descriptor instead.
|
||||
func (*GetBalancesResponse) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *GetBalancesResponse) GetBalances() []*AssetBalance {
|
||||
if x != nil {
|
||||
return x.Balances
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetRequest) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*AdminCreditAssetRequest) ProtoMessage() {}
|
||||
|
||||
func (x *AdminCreditAssetRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use AdminCreditAssetRequest.ProtoReflect.Descriptor instead.
|
||||
func (*AdminCreditAssetRequest) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetRequest) GetCommandId() string {
|
||||
if x != nil {
|
||||
return x.CommandId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetRequest) GetTargetUserId() int64 {
|
||||
if x != nil {
|
||||
return x.TargetUserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetRequest) GetAssetType() string {
|
||||
if x != nil {
|
||||
return x.AssetType
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetRequest) GetAmount() int64 {
|
||||
if x != nil {
|
||||
return x.Amount
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetRequest) GetOperatorUserId() int64 {
|
||||
if x != nil {
|
||||
return x.OperatorUserId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetRequest) GetReason() string {
|
||||
if x != nil {
|
||||
return x.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetRequest) GetEvidenceRef() string {
|
||||
if x != nil {
|
||||
return x.EvidenceRef
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
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
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetResponse) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*AdminCreditAssetResponse) ProtoMessage() {}
|
||||
|
||||
func (x *AdminCreditAssetResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_api_proto_wallet_v1_wallet_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use AdminCreditAssetResponse.ProtoReflect.Descriptor instead.
|
||||
func (*AdminCreditAssetResponse) Descriptor() ([]byte, []int) {
|
||||
return file_api_proto_wallet_v1_wallet_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetResponse) GetTransactionId() string {
|
||||
if x != nil {
|
||||
return x.TransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *AdminCreditAssetResponse) GetBalance() *AssetBalance {
|
||||
if x != nil {
|
||||
return x.Balance
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var File_api_proto_wallet_v1_wallet_proto protoreflect.FileDescriptor
|
||||
|
||||
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, 0xf6, 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, 0x26, 0x0a, 0x0f, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x75, 0x6e, 0x69,
|
||||
0x74, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x67,
|
||||
0x69, 0x66, 0x74, 0x55, 0x6e, 0x69, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x90, 0x01, 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, 0x28, 0x0a, 0x10, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x67, 0x69, 0x66, 0x74, 0x5f, 0x76,
|
||||
0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0e, 0x74, 0x6f, 0x74, 0x61,
|
||||
0x6c, 0x47, 0x69, 0x66, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x62, 0x61,
|
||||
0x6c, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x61, 0x66, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28,
|
||||
0x03, 0x52, 0x0c, 0x62, 0x61, 0x6c, 0x61, 0x6e, 0x63, 0x65, 0x41, 0x66, 0x74, 0x65, 0x72, 0x32,
|
||||
0x63, 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, 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,
|
||||
}
|
||||
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_rawDescOnce sync.Once
|
||||
file_api_proto_wallet_v1_wallet_proto_rawDescData = file_api_proto_wallet_v1_wallet_proto_rawDesc
|
||||
file_api_proto_wallet_v1_wallet_proto_rawDescData []byte
|
||||
)
|
||||
|
||||
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(file_api_proto_wallet_v1_wallet_proto_rawDescData)
|
||||
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)))
|
||||
})
|
||||
return file_api_proto_wallet_v1_wallet_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_api_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
|
||||
var file_api_proto_wallet_v1_wallet_proto_msgTypes = make([]protoimpl.MessageInfo, 7)
|
||||
var file_api_proto_wallet_v1_wallet_proto_goTypes = []any{
|
||||
(*DebitGiftRequest)(nil), // 0: hyapp.wallet.v1.DebitGiftRequest
|
||||
(*DebitGiftResponse)(nil), // 1: hyapp.wallet.v1.DebitGiftResponse
|
||||
(*DebitGiftRequest)(nil), // 0: hyapp.wallet.v1.DebitGiftRequest
|
||||
(*DebitGiftResponse)(nil), // 1: hyapp.wallet.v1.DebitGiftResponse
|
||||
(*AssetBalance)(nil), // 2: hyapp.wallet.v1.AssetBalance
|
||||
(*GetBalancesRequest)(nil), // 3: hyapp.wallet.v1.GetBalancesRequest
|
||||
(*GetBalancesResponse)(nil), // 4: hyapp.wallet.v1.GetBalancesResponse
|
||||
(*AdminCreditAssetRequest)(nil), // 5: hyapp.wallet.v1.AdminCreditAssetRequest
|
||||
(*AdminCreditAssetResponse)(nil), // 6: hyapp.wallet.v1.AdminCreditAssetResponse
|
||||
}
|
||||
var file_api_proto_wallet_v1_wallet_proto_depIdxs = []int32{
|
||||
0, // 0: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest
|
||||
1, // 1: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
|
||||
1, // [1:2] is the sub-list for method output_type
|
||||
0, // [0:1] is the sub-list for method input_type
|
||||
0, // [0:0] is the sub-list for extension type_name
|
||||
0, // [0:0] is the sub-list for extension extendee
|
||||
0, // [0:0] is the sub-list for field type_name
|
||||
2, // 0: hyapp.wallet.v1.GetBalancesResponse.balances:type_name -> hyapp.wallet.v1.AssetBalance
|
||||
2, // 1: hyapp.wallet.v1.AdminCreditAssetResponse.balance:type_name -> hyapp.wallet.v1.AssetBalance
|
||||
0, // 2: hyapp.wallet.v1.WalletService.DebitGift:input_type -> hyapp.wallet.v1.DebitGiftRequest
|
||||
3, // 3: hyapp.wallet.v1.WalletService.GetBalances:input_type -> hyapp.wallet.v1.GetBalancesRequest
|
||||
5, // 4: hyapp.wallet.v1.WalletService.AdminCreditAsset:input_type -> hyapp.wallet.v1.AdminCreditAssetRequest
|
||||
1, // 5: hyapp.wallet.v1.WalletService.DebitGift:output_type -> hyapp.wallet.v1.DebitGiftResponse
|
||||
4, // 6: hyapp.wallet.v1.WalletService.GetBalances:output_type -> hyapp.wallet.v1.GetBalancesResponse
|
||||
6, // 7: hyapp.wallet.v1.WalletService.AdminCreditAsset:output_type -> hyapp.wallet.v1.AdminCreditAssetResponse
|
||||
5, // [5:8] is the sub-list for method output_type
|
||||
2, // [2:5] is the sub-list for method input_type
|
||||
2, // [2:2] is the sub-list for extension type_name
|
||||
2, // [2:2] is the sub-list for extension extendee
|
||||
0, // [0:2] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_api_proto_wallet_v1_wallet_proto_init() }
|
||||
@ -255,39 +628,13 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: file_api_proto_wallet_v1_wallet_proto_rawDesc,
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_proto_wallet_v1_wallet_proto_rawDesc), len(file_api_proto_wallet_v1_wallet_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 2,
|
||||
NumMessages: 7,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
@ -296,7 +643,6 @@ 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
|
||||
}
|
||||
|
||||
@ -12,18 +12,60 @@ message DebitGiftRequest {
|
||||
int64 target_user_id = 4;
|
||||
string gift_id = 5;
|
||||
int32 gift_count = 6;
|
||||
int64 gift_unit_value = 7;
|
||||
// price_version 为空时使用当前生效价格;非空时必须命中 active 价格版本。
|
||||
string price_version = 7;
|
||||
}
|
||||
|
||||
// DebitGiftResponse 返回扣费流水和账后余额。
|
||||
// DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。
|
||||
message DebitGiftResponse {
|
||||
string billing_receipt_id = 1;
|
||||
int64 total_gift_value = 2;
|
||||
int64 balance_after = 3;
|
||||
string transaction_id = 2;
|
||||
int64 coin_spent = 3;
|
||||
int64 gift_point_added = 4;
|
||||
int64 heat_value = 5;
|
||||
string price_version = 6;
|
||||
// balance_after 是 sender COIN available_amount 账后余额。
|
||||
int64 balance_after = 7;
|
||||
}
|
||||
|
||||
// WalletService 首版只暴露送礼扣费能力。
|
||||
// AssetBalance 是用户某类资产的余额投影。
|
||||
message AssetBalance {
|
||||
string asset_type = 1;
|
||||
int64 available_amount = 2;
|
||||
int64 frozen_amount = 3;
|
||||
int64 version = 4;
|
||||
}
|
||||
|
||||
// GetBalancesRequest 查询用户多资产余额;内部调用方负责鉴权和用户范围控制。
|
||||
message GetBalancesRequest {
|
||||
string request_id = 1;
|
||||
int64 user_id = 2;
|
||||
repeated string asset_types = 3;
|
||||
}
|
||||
|
||||
message GetBalancesResponse {
|
||||
repeated AssetBalance balances = 1;
|
||||
}
|
||||
|
||||
// AdminCreditAssetRequest 是后台手动入账/调账的最小审计命令。
|
||||
message AdminCreditAssetRequest {
|
||||
string command_id = 1;
|
||||
int64 target_user_id = 2;
|
||||
string asset_type = 3;
|
||||
int64 amount = 4;
|
||||
int64 operator_user_id = 5;
|
||||
string reason = 6;
|
||||
string evidence_ref = 7;
|
||||
}
|
||||
|
||||
message AdminCreditAssetResponse {
|
||||
string transaction_id = 1;
|
||||
AssetBalance balance = 2;
|
||||
}
|
||||
|
||||
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
|
||||
service WalletService {
|
||||
rpc DebitGift(DebitGiftRequest) returns (DebitGiftResponse);
|
||||
rpc GetBalances(GetBalancesRequest) returns (GetBalancesResponse);
|
||||
rpc AdminCreditAsset(AdminCreditAssetRequest) returns (AdminCreditAssetResponse);
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.5.1
|
||||
// - protoc v5.29.2
|
||||
// - protoc v5.27.3
|
||||
// source: api/proto/wallet/v1/wallet.proto
|
||||
|
||||
package walletv1
|
||||
@ -19,16 +19,20 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
|
||||
WalletService_DebitGift_FullMethodName = "/hyapp.wallet.v1.WalletService/DebitGift"
|
||||
WalletService_GetBalances_FullMethodName = "/hyapp.wallet.v1.WalletService/GetBalances"
|
||||
WalletService_AdminCreditAsset_FullMethodName = "/hyapp.wallet.v1.WalletService/AdminCreditAsset"
|
||||
)
|
||||
|
||||
// WalletServiceClient is the client API for WalletService 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.
|
||||
//
|
||||
// WalletService 首版只暴露送礼扣费能力。
|
||||
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
|
||||
type WalletServiceClient interface {
|
||||
DebitGift(ctx context.Context, in *DebitGiftRequest, opts ...grpc.CallOption) (*DebitGiftResponse, error)
|
||||
GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error)
|
||||
AdminCreditAsset(ctx context.Context, in *AdminCreditAssetRequest, opts ...grpc.CallOption) (*AdminCreditAssetResponse, error)
|
||||
}
|
||||
|
||||
type walletServiceClient struct {
|
||||
@ -49,13 +53,35 @@ func (c *walletServiceClient) DebitGift(ctx context.Context, in *DebitGiftReques
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) GetBalances(ctx context.Context, in *GetBalancesRequest, opts ...grpc.CallOption) (*GetBalancesResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetBalancesResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_GetBalances_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *walletServiceClient) AdminCreditAsset(ctx context.Context, in *AdminCreditAssetRequest, opts ...grpc.CallOption) (*AdminCreditAssetResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminCreditAssetResponse)
|
||||
err := c.cc.Invoke(ctx, WalletService_AdminCreditAsset_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// WalletServiceServer is the server API for WalletService service.
|
||||
// All implementations must embed UnimplementedWalletServiceServer
|
||||
// for forward compatibility.
|
||||
//
|
||||
// WalletService 首版只暴露送礼扣费能力。
|
||||
// WalletService 是内部账务 gRPC 边界,外部 HTTP 入口仍由 gateway-service 承载。
|
||||
type WalletServiceServer interface {
|
||||
DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error)
|
||||
GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error)
|
||||
AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error)
|
||||
mustEmbedUnimplementedWalletServiceServer()
|
||||
}
|
||||
|
||||
@ -69,6 +95,12 @@ type UnimplementedWalletServiceServer struct{}
|
||||
func (UnimplementedWalletServiceServer) DebitGift(context.Context, *DebitGiftRequest) (*DebitGiftResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method DebitGift not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) GetBalances(context.Context, *GetBalancesRequest) (*GetBalancesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method GetBalances not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) AdminCreditAsset(context.Context, *AdminCreditAssetRequest) (*AdminCreditAssetResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AdminCreditAsset not implemented")
|
||||
}
|
||||
func (UnimplementedWalletServiceServer) mustEmbedUnimplementedWalletServiceServer() {}
|
||||
func (UnimplementedWalletServiceServer) testEmbeddedByValue() {}
|
||||
|
||||
@ -108,6 +140,42 @@ func _WalletService_DebitGift_Handler(srv interface{}, ctx context.Context, dec
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_GetBalances_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetBalancesRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).GetBalances(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_GetBalances_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).GetBalances(ctx, req.(*GetBalancesRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _WalletService_AdminCreditAsset_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminCreditAssetRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(WalletServiceServer).AdminCreditAsset(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: WalletService_AdminCreditAsset_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(WalletServiceServer).AdminCreditAsset(ctx, req.(*AdminCreditAssetRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
// WalletService_ServiceDesc is the grpc.ServiceDesc for WalletService service.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@ -119,6 +187,14 @@ var WalletService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "DebitGift",
|
||||
Handler: _WalletService_DebitGift_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetBalances",
|
||||
Handler: _WalletService_GetBalances_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminCreditAsset",
|
||||
Handler: _WalletService_AdminCreditAsset_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{},
|
||||
Metadata: "api/proto/wallet/v1/wallet.proto",
|
||||
|
||||
@ -101,7 +101,7 @@ services:
|
||||
MYSQL_USER: hyapp
|
||||
MYSQL_PASSWORD: hyapp
|
||||
ports:
|
||||
- "13306:3306"
|
||||
- "23306:3306"
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- ./services/room-service/deploy/mysql/initdb/001_room_service.sql:/docker-entrypoint-initdb.d/001_room_service.sql:ro
|
||||
|
||||
@ -108,7 +108,7 @@ Response `data`:
|
||||
}
|
||||
```
|
||||
|
||||
三方接口不拆注册和登录。新用户不会默认生成密码;注册资料和来源快照写入 `users`,同时 `user_display_user_ids`、`third_party_identities` 和首个 `auth_sessions` 会在同一事务中创建。`birth` 必须是 `yyyy-mm-dd`;`platform` 当前只接受 `android` 或 `ios`;gateway 兼容客户端误拼的 `app_verison`,进入内部 RPC 时统一为 `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`。
|
||||
|
||||
@ -116,7 +116,7 @@ Response `data`:
|
||||
| --- | ---: | --- |
|
||||
| `username` | 64 | trimmed display name |
|
||||
| `gender` | 32 | client enum/string snapshot |
|
||||
| `country` | 2 | ISO 3166-1 alpha-2, normalized uppercase |
|
||||
| `country` | 3 | ISO 3166-1 alpha-2/alpha-3 canonical code, normalized uppercase |
|
||||
| `invite_code` | 64 | optional invite code |
|
||||
| `register_ip` | 64 | gateway observed IP only |
|
||||
| `register_user_agent` | 512 | gateway observed User-Agent only |
|
||||
@ -347,20 +347,19 @@ message LoginThirdPartyRequest {
|
||||
string gender = 5;
|
||||
string country = 6;
|
||||
string invite_code = 7;
|
||||
string ip = 8 [deprecated = true];
|
||||
string device_id = 9;
|
||||
string device = 10;
|
||||
string avatar = 11;
|
||||
string birth = 12;
|
||||
string app_version = 13;
|
||||
string source = 14;
|
||||
string platform = 15;
|
||||
string language = 16;
|
||||
string os_version = 17;
|
||||
string build_number = 18;
|
||||
string timezone = 19;
|
||||
string install_channel = 20;
|
||||
string campaign = 21;
|
||||
string device_id = 8;
|
||||
string device = 9;
|
||||
string avatar = 10;
|
||||
string birth = 11;
|
||||
string app_version = 12;
|
||||
string source = 13;
|
||||
string platform = 14;
|
||||
string language = 15;
|
||||
string os_version = 16;
|
||||
string build_number = 17;
|
||||
string timezone = 18;
|
||||
string install_channel = 19;
|
||||
string campaign = 20;
|
||||
}
|
||||
|
||||
message SetPasswordRequest {
|
||||
@ -394,13 +393,13 @@ message AuthToken {
|
||||
| Column | Type | Rule |
|
||||
| --- | --- | --- |
|
||||
| `user_id` | bigint | primary key |
|
||||
| `default_display_user_id` | varchar | user's permanent fallback short id |
|
||||
| `default_display_user_id` | varchar | user's permanent default short id |
|
||||
| `current_display_user_id` | varchar | current effective short id; pretty id can cover default id |
|
||||
| `current_display_user_id_kind` | varchar | default or pretty |
|
||||
| `current_display_user_id_expires_at_ms` | bigint | null for default id |
|
||||
| `username` | varchar | registration display name snapshot |
|
||||
| `gender` | varchar | client submitted gender value |
|
||||
| `country` | varchar | ISO 3166-1 alpha-2 country or region code |
|
||||
| `country` | varchar | ISO 3166-1 alpha-2/alpha-3 canonical country code |
|
||||
| `invite_code` | varchar | optional invite code |
|
||||
| `register_ip` | varchar | gateway observed request ip snapshot |
|
||||
| `register_user_agent` | varchar | gateway observed User-Agent snapshot |
|
||||
@ -653,7 +652,7 @@ gateway HTTP envelope 的 `code` 不使用数字。
|
||||
| `AUTH_FAILED` | 401 | display_user_id missing, password wrong, no password set, or provider rejected |
|
||||
| `UNAUTHORIZED` | 401 | missing or invalid access token |
|
||||
| `PASSWORD_ALREADY_SET` | 409 | user already has password identity |
|
||||
| `COUNTRY_CHANGE_COOLDOWN` | 409 | country change is within rolling 30-day cooldown |
|
||||
| `COUNTRY_CHANGE_COOLDOWN` | 409 | country change is within the 30-day cooldown window |
|
||||
| `USER_DISABLED` | 403 | user is disabled or banned |
|
||||
| `SESSION_EXPIRED` | 401 | refresh token expired |
|
||||
| `SESSION_REVOKED` | 401 | refresh token revoked |
|
||||
@ -690,17 +689,17 @@ Redis backend 使用 Lua 脚本一次性检查并递增同一请求命中的所
|
||||
- 设置密码必须从 access token 取 `user_id`,不接受客户端 body 指定用户。
|
||||
- access token 中的 `display_user_id` 不能作为权限、账务、房间或 IM 身份依据。
|
||||
|
||||
## Compatibility Rules
|
||||
## Development Contract
|
||||
|
||||
当前项目仍在架构阶段,没有外部调用方,本次契约直接删除了旧账号注册接口。后续一旦进入联调或灰度,必须恢复兼容演进纪律:
|
||||
当前项目处于开发阶段,没有线上旧客户端或旧服务版本需要保留;HTTP、protobuf 和数据库契约按当前代码直接收敛:
|
||||
|
||||
- HTTP JSON 字段只能兼容扩展,不能重命名或删除已发布字段。
|
||||
- protobuf 字段只能追加,不能复用字段编号。
|
||||
- HTTP JSON 只接受当前文档字段,旧拼写和废弃字段不再读取。
|
||||
- protobuf 按当前字段编号生成,删除的旧字段不保留解析分支。
|
||||
- 新增 provider 必须通过配置 allowlist 控制,不能让客户端任意传 provider 后触发未知逻辑。
|
||||
- 新增 provider 必须同时新增专用 verifier、配置项和测试;不能只把 provider 名加入静态 allowlist。
|
||||
- 三方登录创建用户必须使用事务,保证 `users` 注册资料、`user_display_user_ids` 和 `third_party_identities` 不出现半绑定。
|
||||
- 设置密码必须使用 `user_id` 主键幂等判断,不能引入独立账号唯一键。
|
||||
- 登录响应可以追加默认短号、当前短号来源和靓号过期时间;已发布后不能改变 `display_user_id` 表示当前有效短号的语义。
|
||||
- 登录响应中的 `display_user_id` 表示当前有效短号,服务端权限、账务、房间和 IM 身份一律使用长 `user_id`。
|
||||
|
||||
## Test Matrix
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
# Graceful Shutdown Development Guide
|
||||
|
||||
本文档定义服务下线、重启、滚动发布时必须遵守的进程生命周期规则。目标是让实例退出时不再接新流量,不截断关键请求,不留下错误 lease,不制造重复消费。
|
||||
本文档定义服务下线、重启和重新部署时必须遵守的进程生命周期规则。目标是让实例退出时不再接新流量,不截断关键请求,不留下错误 lease,不制造重复消费。
|
||||
|
||||
## Scope
|
||||
|
||||
|
||||
@ -299,7 +299,7 @@ running -> draining -> stopped
|
||||
5. lease 释放或自然过期。
|
||||
6. 新节点接到请求后通过 snapshot + command log 恢复。
|
||||
|
||||
禁止在 `ready=false` 之后继续接管新房间。否则滚动发布时会出现旧节点持续获得新 room lease,导致下线时间不可控。
|
||||
禁止在 `ready=false` 之后继续接管新房间。否则节点下线期间会持续获得新 room lease,导致退出时间不可控。
|
||||
|
||||
## Observability Fields
|
||||
|
||||
|
||||
@ -76,7 +76,8 @@ message CheckLuckyGiftRequest {
|
||||
LuckyGiftMeta meta = 1;
|
||||
string gift_id = 2;
|
||||
int32 gift_count = 3;
|
||||
int64 gift_unit_price = 4;
|
||||
int64 coin_spent = 4;
|
||||
int64 heat_value = 5;
|
||||
}
|
||||
|
||||
message CheckLuckyGiftResponse {
|
||||
@ -89,8 +90,8 @@ message ExecuteLuckyGiftDrawRequest {
|
||||
LuckyGiftMeta meta = 1;
|
||||
string gift_id = 2;
|
||||
int32 gift_count = 3;
|
||||
int64 gift_unit_price = 4;
|
||||
int64 total_gift_value = 5;
|
||||
int64 coin_spent = 4;
|
||||
int64 heat_value = 5;
|
||||
string billing_receipt_id = 6;
|
||||
string rule_version = 7;
|
||||
}
|
||||
@ -209,7 +210,8 @@ The primary key is `(scope_type, scope_id)`.
|
||||
| `target_user_id` | bigint | gift receiver |
|
||||
| `gift_id` | varchar | gift id |
|
||||
| `gift_count` | int | count |
|
||||
| `total_gift_value` | bigint | paid coins |
|
||||
| `coin_spent` | bigint | paid coins |
|
||||
| `heat_value` | bigint | room heat contributed by this gift |
|
||||
| `rule_version` | varchar | immutable rule version |
|
||||
| `candidate_tiers_json` | json | tiers after filtering |
|
||||
| `selected_tier_id` | varchar | final selected tier |
|
||||
@ -259,7 +261,7 @@ The primary key is `(scope_type, scope_id)`.
|
||||
All money-like values use integer coins. Percentages use ppm to avoid floating point drift.
|
||||
|
||||
```text
|
||||
gift_value = gift_unit_price * gift_count
|
||||
gift_value = heat_value
|
||||
pool_in = floor(gift_value * pool_rate_ppm / 1000000)
|
||||
platform_in = floor(pool_in * platform_weight_ppm / 1000000)
|
||||
room_in = floor(pool_in * room_weight_ppm / 1000000)
|
||||
|
||||
177
docs/openapi/activity.swagger.yaml
Normal file
177
docs/openapi/activity.swagger.yaml
Normal file
@ -0,0 +1,177 @@
|
||||
swagger: "2.0"
|
||||
info:
|
||||
title: HY Activity Service API
|
||||
version: "1.0.0"
|
||||
description: |
|
||||
activity-service 当前只暴露内部 gRPC + protobuf 同步查询接口,没有 HTTP JSON router。
|
||||
房间事件消费通过 consumer 边界接入,不伪装成同步 RPC。
|
||||
本文件用 OpenAPI 2.0 记录 gRPC 合约,`paths` 使用 gRPC full method 作为文档路径,不代表真实 HTTP endpoint。
|
||||
host: localhost:13006
|
||||
basePath: /
|
||||
schemes:
|
||||
- http
|
||||
consumes:
|
||||
- application/grpc+proto
|
||||
produces:
|
||||
- application/grpc+proto
|
||||
tags:
|
||||
- name: activity
|
||||
description: ActivityService,活动服务同步查询接口。
|
||||
- name: health
|
||||
description: 标准 grpc.health.v1.Health。
|
||||
responses:
|
||||
GRPCError:
|
||||
description: gRPC status error;稳定业务原因放在 google.rpc.ErrorInfo.reason。
|
||||
schema:
|
||||
$ref: "#/definitions/GRPCError"
|
||||
paths:
|
||||
/hyapp.activity.v1.ActivityService/PingActivity:
|
||||
post:
|
||||
tags:
|
||||
- activity
|
||||
summary: 活动服务连通性探测
|
||||
operationId: activityPingActivity
|
||||
x-grpc-full-method: /hyapp.activity.v1.ActivityService/PingActivity
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/PingActivityRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 服务当前可接受同步 RPC。
|
||||
schema:
|
||||
$ref: "#/definitions/PingActivityResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.activity.v1.ActivityService/GetActivityStatus:
|
||||
post:
|
||||
tags:
|
||||
- activity
|
||||
summary: 查询活动状态
|
||||
operationId: activityGetActivityStatus
|
||||
x-grpc-full-method: /hyapp.activity.v1.ActivityService/GetActivityStatus
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/GetActivityStatusRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 查询成功。
|
||||
schema:
|
||||
$ref: "#/definitions/GetActivityStatusResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/grpc.health.v1.Health/Check:
|
||||
post:
|
||||
tags:
|
||||
- health
|
||||
summary: 标准 gRPC health check
|
||||
operationId: activityHealthCheck
|
||||
x-grpc-full-method: /grpc.health.v1.Health/Check
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/HealthCheckRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 标准 health check 响应。
|
||||
schema:
|
||||
$ref: "#/definitions/HealthCheckResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/grpc.health.v1.Health/Watch:
|
||||
post:
|
||||
tags:
|
||||
- health
|
||||
summary: 标准 gRPC health watch
|
||||
operationId: activityHealthWatch
|
||||
x-grpc-full-method: /grpc.health.v1.Health/Watch
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/HealthCheckRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 标准 health watch 流式响应;OpenAPI 只记录单条消息结构。
|
||||
schema:
|
||||
$ref: "#/definitions/HealthCheckResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
definitions:
|
||||
RequestMeta:
|
||||
type: object
|
||||
properties:
|
||||
request_id:
|
||||
type: string
|
||||
description: 链路追踪 ID,不作为幂等键。
|
||||
caller:
|
||||
type: string
|
||||
gateway_node_id:
|
||||
type: string
|
||||
sent_at_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
PingActivityRequest:
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
PingActivityResponse:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
node_id:
|
||||
type: string
|
||||
GetActivityStatusRequest:
|
||||
type: object
|
||||
required:
|
||||
- activity_id
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
activity_id:
|
||||
type: string
|
||||
GetActivityStatusResponse:
|
||||
type: object
|
||||
properties:
|
||||
activity_id:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
description: 当前活动状态;具体业务活动会扩展自己的状态枚举。
|
||||
HealthCheckRequest:
|
||||
type: object
|
||||
properties:
|
||||
service:
|
||||
type: string
|
||||
description: gRPC health service name;activity-service 使用 `activity-service`。
|
||||
HealthCheckResponse:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- UNKNOWN
|
||||
- SERVING
|
||||
- NOT_SERVING
|
||||
- SERVICE_UNKNOWN
|
||||
GRPCError:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: gRPC status code,例如 INVALID_ARGUMENT、NOT_FOUND、UNAVAILABLE。
|
||||
message:
|
||||
type: string
|
||||
error_info_reason:
|
||||
type: string
|
||||
description: google.rpc.ErrorInfo.reason;活动常见值包括 RULE_NOT_ACTIVE、EVENT_ALREADY_CONSUMED、REWARD_PENDING。
|
||||
@ -52,7 +52,7 @@ parameters:
|
||||
required: true
|
||||
type: integer
|
||||
format: int64
|
||||
description: 腾讯云 IM 回调携带的 SDKAppID;也兼容 `SDKAppID` 查询名。
|
||||
description: 腾讯云 IM 回调携带的 SDKAppID;当前入口接受 `SdkAppid` 查询名。
|
||||
TencentIMCallbackCommand:
|
||||
name: CallbackCommand
|
||||
in: query
|
||||
@ -749,6 +749,176 @@ paths:
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms/mic/publishing/confirm:
|
||||
post:
|
||||
tags:
|
||||
- rooms
|
||||
summary: 确认 RTC 音频发布成功
|
||||
operationId: confirmMicPublishing
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: "#/parameters/RequestIDHeader"
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/ConfirmMicPublishingRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 确认成功或旧事件被安全忽略,`data` 返回最新房间快照。
|
||||
schema:
|
||||
$ref: "#/definitions/ConfirmMicPublishingEnvelope"
|
||||
"400":
|
||||
$ref: "#/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms/mic/lock:
|
||||
post:
|
||||
tags:
|
||||
- rooms
|
||||
summary: 锁定或解锁麦位
|
||||
operationId: setMicSeatLock
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: "#/parameters/RequestIDHeader"
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/SetMicSeatLockRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 锁麦状态修改成功,`data` 返回最新房间快照。
|
||||
schema:
|
||||
$ref: "#/definitions/SetMicSeatLockEnvelope"
|
||||
"400":
|
||||
$ref: "#/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms/chat/enabled:
|
||||
post:
|
||||
tags:
|
||||
- rooms
|
||||
summary: 开启或关闭房间公屏
|
||||
operationId: setChatEnabled
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: "#/parameters/RequestIDHeader"
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/SetChatEnabledRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 公屏开关修改成功,`data` 返回最新房间快照。
|
||||
schema:
|
||||
$ref: "#/definitions/SetChatEnabledEnvelope"
|
||||
"400":
|
||||
$ref: "#/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms/admin/set:
|
||||
post:
|
||||
tags:
|
||||
- rooms
|
||||
summary: 添加或移除房间管理员
|
||||
operationId: setRoomAdmin
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: "#/parameters/RequestIDHeader"
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/SetRoomAdminRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 管理员集合修改成功,`data` 返回最新房间快照。
|
||||
schema:
|
||||
$ref: "#/definitions/SetRoomAdminEnvelope"
|
||||
"400":
|
||||
$ref: "#/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms/host/transfer:
|
||||
post:
|
||||
tags:
|
||||
- rooms
|
||||
summary: 转移房间主持人
|
||||
operationId: transferRoomHost
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: "#/parameters/RequestIDHeader"
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/TransferRoomHostRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 主持人转移成功,`data` 返回最新房间快照。
|
||||
schema:
|
||||
$ref: "#/definitions/TransferRoomHostEnvelope"
|
||||
"400":
|
||||
$ref: "#/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms/user/mute:
|
||||
post:
|
||||
tags:
|
||||
@ -817,6 +987,40 @@ paths:
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms/user/unban:
|
||||
post:
|
||||
tags:
|
||||
- rooms
|
||||
summary: 解除房间 ban
|
||||
operationId: unbanUser
|
||||
security:
|
||||
- BearerAuth: []
|
||||
parameters:
|
||||
- $ref: "#/parameters/RequestIDHeader"
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/UnbanUserRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 解封成功,`data` 返回最新房间快照,不自动恢复 presence、麦位或管理员身份。
|
||||
schema:
|
||||
$ref: "#/definitions/UnbanUserEnvelope"
|
||||
"400":
|
||||
$ref: "#/responses/BadRequest"
|
||||
"401":
|
||||
$ref: "#/responses/Unauthorized"
|
||||
"403":
|
||||
$ref: "#/responses/Forbidden"
|
||||
"404":
|
||||
$ref: "#/responses/NotFound"
|
||||
"409":
|
||||
$ref: "#/responses/Conflict"
|
||||
"500":
|
||||
$ref: "#/responses/Internal"
|
||||
"502":
|
||||
$ref: "#/responses/UpstreamError"
|
||||
/api/v1/rooms/gift/send:
|
||||
post:
|
||||
tags:
|
||||
@ -962,7 +1166,7 @@ definitions:
|
||||
type: string
|
||||
LoginThirdPartyRequest:
|
||||
type: object
|
||||
description: 三方登录即注册;首次注册时资料字段写入 user-service users 主记录。`ip`、`user_agent` 和 `country_by_ip` 由 gateway/服务端上下文生成,不接受客户端 JSON。`app_verison` 拼写错误仅在 gateway 兼容,规范字段是 `app_version`。
|
||||
description: 三方登录即注册;首次注册时资料字段写入 user-service users 主记录。`ip`、`user_agent` 和 `country_by_ip` 由 gateway/服务端上下文生成,不接受客户端 JSON。
|
||||
properties:
|
||||
provider:
|
||||
type: string
|
||||
@ -1003,11 +1207,6 @@ definitions:
|
||||
app_version:
|
||||
type: string
|
||||
maxLength: 64
|
||||
app_verison:
|
||||
type: string
|
||||
maxLength: 64
|
||||
description: 拼写错误兼容字段;新客户端使用 app_version。
|
||||
x-deprecated: true
|
||||
build_number:
|
||||
type: string
|
||||
maxLength: 64
|
||||
@ -1177,7 +1376,7 @@ definitions:
|
||||
description: 发言用户;必须是内部长 `user_id` 的十进制字符串。
|
||||
Operator_Account:
|
||||
type: string
|
||||
description: 腾讯回调操作者字段,当前仅保留兼容。
|
||||
description: 腾讯云 IM 回调操作者账号字段。
|
||||
TencentIMCallbackResponse:
|
||||
type: object
|
||||
properties:
|
||||
@ -1311,7 +1510,7 @@ definitions:
|
||||
type: string
|
||||
pattern: "^[A-Za-z0-9_-]{1,48}$"
|
||||
maxLength: 48
|
||||
description: 兼容腾讯 IM GroupID 和 RTC strRoomId 的业务房间 ID。
|
||||
description: 同时满足腾讯 IM GroupID 和 RTC strRoomId 限制的业务房间 ID。
|
||||
command_id:
|
||||
type: string
|
||||
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
|
||||
@ -1364,6 +1563,9 @@ definitions:
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
reason:
|
||||
type: string
|
||||
description: 主动下麦可为空;系统自动下麦会使用 publish_timeout 等稳定原因。
|
||||
ChangeMicSeatRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -1378,6 +1580,81 @@ definitions:
|
||||
seat_no:
|
||||
type: integer
|
||||
format: int32
|
||||
ConfirmMicPublishingRequest:
|
||||
type: object
|
||||
required:
|
||||
- room_id
|
||||
- mic_session_id
|
||||
- room_version
|
||||
- event_time_ms
|
||||
properties:
|
||||
room_id:
|
||||
type: string
|
||||
command_id:
|
||||
type: string
|
||||
description: 房间命令幂等键;客户端重试同一确认事件必须复用该值。
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 为空时默认确认当前登录用户;RTC webhook 编排可显式传目标用户。
|
||||
mic_session_id:
|
||||
type: string
|
||||
room_version:
|
||||
type: integer
|
||||
format: int64
|
||||
event_time_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
source:
|
||||
type: string
|
||||
description: 例如 client 或 rtc_webhook。
|
||||
SetMicSeatLockRequest:
|
||||
type: object
|
||||
properties:
|
||||
room_id:
|
||||
type: string
|
||||
command_id:
|
||||
type: string
|
||||
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
|
||||
seat_no:
|
||||
type: integer
|
||||
format: int32
|
||||
locked:
|
||||
type: boolean
|
||||
SetChatEnabledRequest:
|
||||
type: object
|
||||
properties:
|
||||
room_id:
|
||||
type: string
|
||||
command_id:
|
||||
type: string
|
||||
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
|
||||
enabled:
|
||||
type: boolean
|
||||
SetRoomAdminRequest:
|
||||
type: object
|
||||
properties:
|
||||
room_id:
|
||||
type: string
|
||||
command_id:
|
||||
type: string
|
||||
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
enabled:
|
||||
type: boolean
|
||||
TransferRoomHostRequest:
|
||||
type: object
|
||||
properties:
|
||||
room_id:
|
||||
type: string
|
||||
command_id:
|
||||
type: string
|
||||
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
MuteUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -1402,6 +1679,17 @@ definitions:
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
UnbanUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
room_id:
|
||||
type: string
|
||||
command_id:
|
||||
type: string
|
||||
description: 房间命令幂等键;客户端重试同一业务动作必须复用该值。未传时 gateway 自动生成新值,仅保证本次请求可执行,不保证后续 HTTP 重试幂等。
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
SendGiftRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -1418,9 +1706,6 @@ definitions:
|
||||
gift_count:
|
||||
type: integer
|
||||
format: int32
|
||||
gift_unit_value:
|
||||
type: integer
|
||||
format: int64
|
||||
CommandResult:
|
||||
type: object
|
||||
properties:
|
||||
@ -1457,6 +1742,24 @@ definitions:
|
||||
format: int64
|
||||
locked:
|
||||
type: boolean
|
||||
publish_state:
|
||||
type: string
|
||||
enum:
|
||||
- pending_publish
|
||||
- publishing
|
||||
- idle
|
||||
description: pending_publish 表示业务占麦成功但 RTC 尚未确认发流;publishing 表示已确认发布音频。
|
||||
mic_session_id:
|
||||
type: string
|
||||
publish_deadline_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
mic_session_room_version:
|
||||
type: integer
|
||||
format: int64
|
||||
last_publish_event_time_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
RankItem:
|
||||
type: object
|
||||
properties:
|
||||
@ -1559,6 +1862,11 @@ definitions:
|
||||
format: int32
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
mic_session_id:
|
||||
type: string
|
||||
publish_deadline_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
MicDownResponse:
|
||||
type: object
|
||||
properties:
|
||||
@ -1576,6 +1884,44 @@ definitions:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
ConfirmMicPublishingResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
seat_no:
|
||||
type: integer
|
||||
format: int32
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
SetMicSeatLockResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
SetChatEnabledResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
SetRoomAdminResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
TransferRoomHostResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
MuteUserResponse:
|
||||
type: object
|
||||
properties:
|
||||
@ -1590,6 +1936,13 @@ definitions:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
UnbanUserResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
SendGiftResponse:
|
||||
type: object
|
||||
properties:
|
||||
@ -1697,6 +2050,41 @@ definitions:
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/ChangeMicSeatResponse"
|
||||
ConfirmMicPublishingEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/ConfirmMicPublishingResponse"
|
||||
SetMicSeatLockEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/SetMicSeatLockResponse"
|
||||
SetChatEnabledEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/SetChatEnabledResponse"
|
||||
SetRoomAdminEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/SetRoomAdminResponse"
|
||||
TransferRoomHostEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/TransferRoomHostResponse"
|
||||
MuteUserEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
@ -1711,6 +2099,13 @@ definitions:
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/KickUserResponse"
|
||||
UnbanUserEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/definitions/UnbanUserResponse"
|
||||
SendGiftEnvelope:
|
||||
allOf:
|
||||
- $ref: "#/definitions/GatewayOKEnvelopeBase"
|
||||
|
||||
@ -147,6 +147,106 @@ paths:
|
||||
$ref: "#/definitions/ChangeMicSeatResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.room.v1.RoomCommandService/ConfirmMicPublishing:
|
||||
post:
|
||||
tags:
|
||||
- room-command
|
||||
summary: 确认 RTC 音频发布成功
|
||||
operationId: roomConfirmMicPublishing
|
||||
x-grpc-full-method: /hyapp.room.v1.RoomCommandService/ConfirmMicPublishing
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/ConfirmMicPublishingRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 当前 mic_session 确认成功;旧 session、旧 room_version 或旧 event_time 会被安全忽略。
|
||||
schema:
|
||||
$ref: "#/definitions/ConfirmMicPublishingResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.room.v1.RoomCommandService/SetMicSeatLock:
|
||||
post:
|
||||
tags:
|
||||
- room-command
|
||||
summary: 锁定或解锁麦位
|
||||
operationId: roomSetMicSeatLock
|
||||
x-grpc-full-method: /hyapp.room.v1.RoomCommandService/SetMicSeatLock
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/SetMicSeatLockRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 锁麦状态修改成功。
|
||||
schema:
|
||||
$ref: "#/definitions/SetMicSeatLockResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.room.v1.RoomCommandService/SetChatEnabled:
|
||||
post:
|
||||
tags:
|
||||
- room-command
|
||||
summary: 开启或关闭公屏
|
||||
operationId: roomSetChatEnabled
|
||||
x-grpc-full-method: /hyapp.room.v1.RoomCommandService/SetChatEnabled
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/SetChatEnabledRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 公屏开关修改成功;结果会影响 CheckSpeakPermission。
|
||||
schema:
|
||||
$ref: "#/definitions/SetChatEnabledResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.room.v1.RoomCommandService/SetRoomAdmin:
|
||||
post:
|
||||
tags:
|
||||
- room-command
|
||||
summary: 添加或移除房间管理员
|
||||
operationId: roomSetRoomAdmin
|
||||
x-grpc-full-method: /hyapp.room.v1.RoomCommandService/SetRoomAdmin
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/SetRoomAdminRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 管理员集合修改成功。
|
||||
schema:
|
||||
$ref: "#/definitions/SetRoomAdminResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.room.v1.RoomCommandService/TransferRoomHost:
|
||||
post:
|
||||
tags:
|
||||
- room-command
|
||||
summary: 转移主持人
|
||||
operationId: roomTransferRoomHost
|
||||
x-grpc-full-method: /hyapp.room.v1.RoomCommandService/TransferRoomHost
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/TransferRoomHostRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 主持人转移成功。
|
||||
schema:
|
||||
$ref: "#/definitions/TransferRoomHostResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.room.v1.RoomCommandService/MuteUser:
|
||||
post:
|
||||
tags:
|
||||
@ -187,6 +287,26 @@ paths:
|
||||
$ref: "#/definitions/KickUserResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.room.v1.RoomCommandService/UnbanUser:
|
||||
post:
|
||||
tags:
|
||||
- room-command
|
||||
summary: 解除房间 ban
|
||||
operationId: roomUnbanUser
|
||||
x-grpc-full-method: /hyapp.room.v1.RoomCommandService/UnbanUser
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/UnbanUserRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 解封成功;不会自动恢复 presence、麦位或管理员身份。
|
||||
schema:
|
||||
$ref: "#/definitions/UnbanUserResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/hyapp.room.v1.RoomCommandService/SendGift:
|
||||
post:
|
||||
tags:
|
||||
@ -308,7 +428,7 @@ definitions:
|
||||
type: string
|
||||
pattern: "^[A-Za-z0-9_-]{1,48}$"
|
||||
maxLength: 48
|
||||
description: 命令所属房间 ID;CreateRoom 时必须兼容腾讯 IM GroupID 和 RTC strRoomId。
|
||||
description: 命令所属房间 ID;CreateRoom 时必须同时满足腾讯 IM GroupID 和 RTC strRoomId 限制。
|
||||
gateway_node_id:
|
||||
type: string
|
||||
session_id:
|
||||
@ -352,6 +472,28 @@ definitions:
|
||||
format: int64
|
||||
locked:
|
||||
type: boolean
|
||||
publish_state:
|
||||
type: string
|
||||
enum:
|
||||
- pending_publish
|
||||
- publishing
|
||||
- idle
|
||||
description: pending_publish 表示业务占麦成功但 RTC 尚未确认发流;publishing 表示已确认发布音频;空麦可为空或 idle。
|
||||
mic_session_id:
|
||||
type: string
|
||||
description: 单次上麦发流会话 ID;客户端确认和 RTC webhook 必须原样带回。
|
||||
publish_deadline_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
description: pending_publish 必须完成确认的截止时间;超时会自动下麦。
|
||||
mic_session_room_version:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 创建当前 mic_session 的房间版本,用于丢弃旧版本 RTC 事件。
|
||||
last_publish_event_time_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 当前麦位已接受的最新发布事件时间。
|
||||
RankItem:
|
||||
type: object
|
||||
properties:
|
||||
@ -430,14 +572,6 @@ definitions:
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
owner_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 内部 gRPC 字段;缺省时 room-service 使用 `meta.actor_user_id` 作为 owner。外部 gateway HTTP 不接收该字段。
|
||||
host_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 内部 gRPC 字段;缺省时 room-service 使用 owner 作为默认 host。外部 gateway HTTP 不接收该字段。
|
||||
seat_count:
|
||||
type: integer
|
||||
format: int32
|
||||
@ -500,6 +634,11 @@ definitions:
|
||||
format: int32
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
mic_session_id:
|
||||
type: string
|
||||
publish_deadline_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
MicDownRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -508,6 +647,9 @@ definitions:
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
reason:
|
||||
type: string
|
||||
description: 主动下麦可为空;系统自动下麦会使用 publish_timeout 等稳定原因。
|
||||
MicDownResponse:
|
||||
type: object
|
||||
properties:
|
||||
@ -518,6 +660,43 @@ definitions:
|
||||
format: int32
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
ConfirmMicPublishingRequest:
|
||||
type: object
|
||||
required:
|
||||
- meta
|
||||
- mic_session_id
|
||||
- room_version
|
||||
- event_time_ms
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 为空时默认确认 actor_user_id;RTC webhook 编排可显式指定目标用户。
|
||||
mic_session_id:
|
||||
type: string
|
||||
room_version:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 客户端传 MicUp 返回或快照中的 room_version;旧版本事件不会覆盖新会话。
|
||||
event_time_ms:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 客户端 SDK 或 RTC webhook 事件发生时间;只接受当前会话更新的事件。
|
||||
source:
|
||||
type: string
|
||||
description: 例如 client 或 rtc_webhook。
|
||||
ConfirmMicPublishingResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
seat_no:
|
||||
type: integer
|
||||
format: int32
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
ChangeMicSeatRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -536,6 +715,69 @@ definitions:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
SetMicSeatLockRequest:
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
seat_no:
|
||||
type: integer
|
||||
format: int32
|
||||
locked:
|
||||
type: boolean
|
||||
SetMicSeatLockResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
SetChatEnabledRequest:
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
enabled:
|
||||
type: boolean
|
||||
SetChatEnabledResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
SetRoomAdminRequest:
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
enabled:
|
||||
type: boolean
|
||||
SetRoomAdminResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
TransferRoomHostRequest:
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
TransferRoomHostResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
MuteUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -568,6 +810,21 @@ definitions:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
UnbanUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
meta:
|
||||
$ref: "#/definitions/RequestMeta"
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
UnbanUserResponse:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
$ref: "#/definitions/CommandResult"
|
||||
room:
|
||||
$ref: "#/definitions/RoomSnapshot"
|
||||
SendGiftRequest:
|
||||
type: object
|
||||
properties:
|
||||
@ -581,9 +838,6 @@ definitions:
|
||||
gift_count:
|
||||
type: integer
|
||||
format: int32
|
||||
gift_unit_value:
|
||||
type: integer
|
||||
format: int64
|
||||
SendGiftResponse:
|
||||
type: object
|
||||
properties:
|
||||
@ -634,6 +888,9 @@ definitions:
|
||||
format: int64
|
||||
session_id:
|
||||
type: string
|
||||
request_id:
|
||||
type: string
|
||||
description: 外部守卫调用的追踪 ID;不作为幂等键。
|
||||
VerifyRoomPresenceResponse:
|
||||
type: object
|
||||
properties:
|
||||
@ -662,8 +919,13 @@ definitions:
|
||||
- room_user_joined
|
||||
- room_user_left
|
||||
- room_mic_changed
|
||||
- room_mic_seat_locked
|
||||
- room_chat_enabled_changed
|
||||
- room_admin_changed
|
||||
- room_host_transferred
|
||||
- room_user_muted
|
||||
- room_user_kicked
|
||||
- room_user_unbanned
|
||||
- room_gift_sent
|
||||
- room_heat_changed
|
||||
- room_rank_changed
|
||||
|
||||
@ -637,10 +637,6 @@ definitions:
|
||||
invite_code:
|
||||
type: string
|
||||
maxLength: 64
|
||||
ip:
|
||||
type: string
|
||||
description: 废弃字段;注册 IP 只能来自 meta.client_ip。
|
||||
x-deprecated: true
|
||||
device_id:
|
||||
type: string
|
||||
maxLength: 128
|
||||
|
||||
175
docs/openapi/wallet.swagger.yaml
Normal file
175
docs/openapi/wallet.swagger.yaml
Normal file
@ -0,0 +1,175 @@
|
||||
swagger: "2.0"
|
||||
info:
|
||||
title: HY Wallet Service API
|
||||
version: "1.0.0"
|
||||
description: |
|
||||
wallet-service 当前只暴露内部 gRPC + protobuf 接口,没有 HTTP JSON router。
|
||||
本文件用 OpenAPI 2.0 记录 gRPC 合约,`paths` 使用 gRPC full method 作为文档路径,不代表真实 HTTP endpoint。
|
||||
host: localhost:13004
|
||||
basePath: /
|
||||
schemes:
|
||||
- http
|
||||
consumes:
|
||||
- application/grpc+proto
|
||||
produces:
|
||||
- application/grpc+proto
|
||||
tags:
|
||||
- name: wallet
|
||||
description: WalletService,账务扣费接口;当前房间送礼由 room-service 同步调用。
|
||||
- name: health
|
||||
description: 标准 grpc.health.v1.Health。
|
||||
responses:
|
||||
GRPCError:
|
||||
description: gRPC status error;稳定业务原因放在 google.rpc.ErrorInfo.reason。
|
||||
schema:
|
||||
$ref: "#/definitions/GRPCError"
|
||||
paths:
|
||||
/hyapp.wallet.v1.WalletService/DebitGift:
|
||||
post:
|
||||
tags:
|
||||
- wallet
|
||||
summary: 送礼扣费
|
||||
operationId: walletDebitGift
|
||||
x-grpc-full-method: /hyapp.wallet.v1.WalletService/DebitGift
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/DebitGiftRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 扣费成功;重复 `command_id` 命中幂等时返回原始扣费回执。
|
||||
schema:
|
||||
$ref: "#/definitions/DebitGiftResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/grpc.health.v1.Health/Check:
|
||||
post:
|
||||
tags:
|
||||
- health
|
||||
summary: 标准 gRPC health check
|
||||
operationId: walletHealthCheck
|
||||
x-grpc-full-method: /grpc.health.v1.Health/Check
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/HealthCheckRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 标准 health check 响应。
|
||||
schema:
|
||||
$ref: "#/definitions/HealthCheckResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
/grpc.health.v1.Health/Watch:
|
||||
post:
|
||||
tags:
|
||||
- health
|
||||
summary: 标准 gRPC health watch
|
||||
operationId: walletHealthWatch
|
||||
x-grpc-full-method: /grpc.health.v1.Health/Watch
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: "#/definitions/HealthCheckRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: 标准 health watch 流式响应;OpenAPI 只记录单条消息结构。
|
||||
schema:
|
||||
$ref: "#/definitions/HealthCheckResponse"
|
||||
default:
|
||||
$ref: "#/responses/GRPCError"
|
||||
definitions:
|
||||
DebitGiftRequest:
|
||||
type: object
|
||||
required:
|
||||
- command_id
|
||||
- room_id
|
||||
- sender_user_id
|
||||
- target_user_id
|
||||
- gift_id
|
||||
- gift_count
|
||||
properties:
|
||||
command_id:
|
||||
type: string
|
||||
description: 账务幂等键;room-service 传入房间命令 `command_id`。
|
||||
room_id:
|
||||
type: string
|
||||
description: 礼物发生的房间 ID。
|
||||
sender_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 扣费用户。
|
||||
target_user_id:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 礼物接收用户。
|
||||
gift_id:
|
||||
type: string
|
||||
gift_count:
|
||||
type: integer
|
||||
format: int32
|
||||
minimum: 1
|
||||
price_version:
|
||||
type: string
|
||||
description: 指定礼物价格版本;为空时使用当前生效价格。
|
||||
DebitGiftResponse:
|
||||
type: object
|
||||
properties:
|
||||
billing_receipt_id:
|
||||
type: string
|
||||
description: 扣费回执 ID,room-service 会写入房间礼物事件。
|
||||
transaction_id:
|
||||
type: string
|
||||
description: 钱包交易 ID。
|
||||
coin_spent:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 本次实际扣减的 COIN 数量。
|
||||
gift_point_added:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 本次实际增加的 GIFT_POINT 数量。
|
||||
heat_value:
|
||||
type: integer
|
||||
format: int64
|
||||
description: room-service 用于房间热度和榜单的结算值。
|
||||
price_version:
|
||||
type: string
|
||||
description: 本次命中的服务端价格版本。
|
||||
balance_after:
|
||||
type: integer
|
||||
format: int64
|
||||
description: 扣费后的发送方余额。
|
||||
HealthCheckRequest:
|
||||
type: object
|
||||
properties:
|
||||
service:
|
||||
type: string
|
||||
description: gRPC health service name;wallet-service 使用 `wallet-service`。
|
||||
HealthCheckResponse:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- UNKNOWN
|
||||
- SERVING
|
||||
- NOT_SERVING
|
||||
- SERVICE_UNKNOWN
|
||||
GRPCError:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
description: gRPC status code,例如 INVALID_ARGUMENT、FAILED_PRECONDITION、UNAVAILABLE。
|
||||
message:
|
||||
type: string
|
||||
error_info_reason:
|
||||
type: string
|
||||
description: google.rpc.ErrorInfo.reason;账务常见值包括 INSUFFICIENT_BALANCE、DUPLICATE_BILLING_COMMAND、LEDGER_CONFLICT。
|
||||
@ -100,9 +100,8 @@ jwt:
|
||||
`wallet-service`:
|
||||
|
||||
```yaml
|
||||
ledger:
|
||||
currency: "coin"
|
||||
idempotency_ttl_sec: 86400
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
```
|
||||
|
||||
`activity-service`:
|
||||
@ -331,15 +330,18 @@ internal/transport/grpc
|
||||
|
||||
```text
|
||||
wallet_accounts
|
||||
wallet_ledger
|
||||
wallet_idempotency
|
||||
wallet_transactions
|
||||
wallet_entries
|
||||
wallet_outbox
|
||||
wallet_gift_prices
|
||||
```
|
||||
|
||||
账务规则:
|
||||
|
||||
- 钱包余额不能只存在内存。
|
||||
- 每次扣费必须写 ledger。
|
||||
- 每个扣费命令必须幂等。
|
||||
- 每次账务动作必须写 transaction 和 entries。
|
||||
- 每个账务命令必须通过 `wallet_transactions.command_id` 幂等。
|
||||
- 跨服务事件必须先写 `wallet_outbox`,再由 worker 投递。
|
||||
- 幂等命中必须返回原始 `billing_receipt_id`,不能重复扣费。
|
||||
|
||||
### Readiness
|
||||
@ -490,7 +492,7 @@ activity-service 额外需要:
|
||||
|
||||
### Contract Tests
|
||||
|
||||
覆盖 proto 兼容和 transport 行为:
|
||||
覆盖 proto 契约和 transport 行为:
|
||||
|
||||
- 成功 response 字段稳定。
|
||||
- 业务错误返回正确 gRPC code 和 reason。
|
||||
|
||||
@ -112,8 +112,8 @@ RTC numeric roomId -> always 0 or omitted
|
||||
- 客户端进腾讯 RTC 房时只使用 `strRoomId`。
|
||||
- 客户端不能把 `"123"` 转成数字 `123`;这在 TRTC 中是两个不同房间。
|
||||
- 新建房间时的 `room_id` 必须满足 RTC 字符串房间 ID 限制;校验要前移到 `CreateRoom` HTTP/gRPC 入口或 room-service 领域层,不能创建出业务可用但永远拿不到 RTC token 的房间。
|
||||
- `/api/v1/rtc/token` 仍要重复校验 `room_id`,作为历史数据、内部调用和灰度期脏数据的 fail-closed 保护;历史房间 ID 不兼容时返回 `INVALID_ARGUMENT`。
|
||||
- 为兼容腾讯 IM 群组和 RTC 房间,建议收敛 `room_id` 格式到:`^[A-Za-z0-9_-]{1,48}$`。如果产品需要更丰富字符,必须同时确认 IM group id、RTC `strRoomId`、OpenAPI 和客户端 SDK 都接受。
|
||||
- `/api/v1/rtc/token` 仍要重复校验 `room_id`,作为内部调用和入口绕过场景的 fail-closed 保护;非法房间 ID 返回 `INVALID_ARGUMENT`。
|
||||
- 为同时满足腾讯 IM 群组和 RTC 房间限制,`room_id` 格式收敛到:`^[A-Za-z0-9_-]{1,48}$`。如果产品需要更丰富字符,必须同时确认 IM group id、RTC `strRoomId`、OpenAPI 和客户端 SDK 都接受。
|
||||
|
||||
## External HTTP API
|
||||
|
||||
@ -164,7 +164,7 @@ Response `data`:
|
||||
| Case | HTTP | Code | Rule |
|
||||
| --- | ---: | --- | --- |
|
||||
| 未登录 | 401 | `UNAUTHORIZED` | middleware 拦截 |
|
||||
| `room_id` 为空或不兼容 RTC | 400 | `INVALID_ARGUMENT` | gateway 本地校验 |
|
||||
| `room_id` 为空或不符合 RTC 限制 | 400 | `INVALID_ARGUMENT` | gateway 本地校验 |
|
||||
| 不在房间 | 403 | `PERMISSION_DENIED` | `VerifyRoomPresence.present=false` |
|
||||
| 房间不存在 | 404 | `NOT_FOUND` | guard reason 为 `room_not_found` |
|
||||
| 用户被踢/封禁 | 403 | `PERMISSION_DENIED` | guard reason 为 `user_banned` |
|
||||
@ -297,19 +297,31 @@ handler 装配规则:
|
||||
|
||||
## Room-Service Changes
|
||||
|
||||
首版不要求 room-service 新增 RTC 状态。
|
||||
room-service 不保存 RTC socket 或在线态,但必须保存“业务占麦后是否完成 RTC 发流确认”的麦位状态,避免把 `MicUp` 当成音频已经发布成功。
|
||||
|
||||
需要确认:
|
||||
当前麦位发流状态:
|
||||
|
||||
| State | Meaning | Transition |
|
||||
| --- | --- | --- |
|
||||
| empty / `idle` | 空麦或没有发流会话 | `MicUp` 后进入 `pending_publish` |
|
||||
| `pending_publish` | 业务麦位占用成功,等待客户端或 RTC webhook 确认音频已发布 | `ConfirmMicPublishing` 后进入 `publishing`;超时自动 `MicDown(reason=publish_timeout)` |
|
||||
| `publishing` | 当前 `mic_session_id` 已确认发布音频 | `MicDown`、`LeaveRoom`、`KickUser` 后清空 |
|
||||
|
||||
需要确认的稳定行为:
|
||||
|
||||
- `VerifyRoomPresence` 能区分 `room_not_found/not_in_room/user_banned`。
|
||||
- `JoinRoom` 重连刷新 `last_seen_at_ms`。
|
||||
- `LeaveRoom` 后 `VerifyRoomPresence` 返回 false。
|
||||
- Kick 后 `VerifyRoomPresence` 返回 `user_banned`。
|
||||
- `MicUp` 返回 `mic_session_id` 和 `publish_deadline_ms`,并在 `SeatState` 写入 `pending_publish`。
|
||||
- `ConfirmMicPublishing(mic_session_id, room_version, event_time_ms)` 只确认当前麦位会话。
|
||||
- pending 超过 `publish_deadline_ms` 未确认时,room-service 自动下麦并在事件 reason 中写 `publish_timeout`。
|
||||
|
||||
可选增强:
|
||||
|
||||
- `RoomSnapshot.room_ext` 后续可加入 `rtc_enabled=true`,但首版不需要。
|
||||
- 如果要根据麦位返回 `role=anchor/audience`,gateway 可以从 room-service 增加只读查询或扩展 guard response;不要让 gateway 自己缓存麦位状态。
|
||||
- 后续接 RTC webhook 时,webhook payload 必须带 `mic_session_id`、`room_version`、`event_time_ms`;room-service 只接受当前 session 且事件时间不旧于已接受事件的数据,旧 audience/leave 事件不能清掉新上麦状态。
|
||||
|
||||
## Client Flow
|
||||
|
||||
@ -329,10 +341,13 @@ handler 装配规则:
|
||||
|
||||
```text
|
||||
1. POST /api/v1/rooms/mic/up
|
||||
2. Client receives MicUp success and latest RoomSnapshot
|
||||
2. Client receives mic_session_id, publish_deadline_ms, and RoomSnapshot with publish_state=pending_publish
|
||||
3. RTC SDK switchRole(anchor)
|
||||
4. RTC SDK startLocalAudio
|
||||
5. Client uses room-service mic seat state as UI source of truth
|
||||
5. Client receives local publish success callback
|
||||
6. POST /api/v1/rooms/mic/publishing/confirm with room_id, mic_session_id, room_version, event_time_ms, source=client
|
||||
7. RoomSnapshot seat publish_state becomes publishing
|
||||
8. If step 6 is not completed before publish_deadline_ms, room-service automatically MicDown(reason=publish_timeout)
|
||||
```
|
||||
|
||||
重连:
|
||||
@ -363,6 +378,8 @@ handler 装配规则:
|
||||
- 超过 presence stale timeout 后,业务 presence 被清理,新的 RTC token 会被拒绝。
|
||||
- 客户端收到 Kick 系统消息后必须 `stopLocalAudio`、退出 RTC 房并退出 IM 群;服务端后续 token 签发也会拒绝。
|
||||
- 下麦成功后客户端必须 `stopLocalAudio`,并在 VoiceChatRoom 场景调用 `switchRole(audience)` 或按 SDK 推荐方式停止发布。
|
||||
- 客户端只能把 `publishing` 当成“服务端已接受音频发布确认”;`pending_publish` 只能展示为“正在连接麦克风/等待发流确认”。
|
||||
- 客户端重复确认同一 SDK 成功回调时必须复用同一个 `command_id`;如果没有复用,room-service 仍会用 `mic_session_id + room_version + event_time_ms` 防止旧事件覆盖新会话。
|
||||
|
||||
## Security Rules
|
||||
|
||||
@ -388,6 +405,10 @@ handler 装配规则:
|
||||
| `room_id` | 内部房间 ID |
|
||||
| `str_room_id` | RTC 字符串房间 ID |
|
||||
| `room_version` | guard 返回的房间版本 |
|
||||
| `mic_session_id` | 单次上麦发流会话 ID |
|
||||
| `publish_state` | pending_publish / publishing |
|
||||
| `publish_deadline_ms` | 发流确认截止时间 |
|
||||
| `publish_event_time_ms` | 客户端 SDK 或 RTC webhook 事件时间 |
|
||||
| `result` | issued / denied / config_error / upstream_error |
|
||||
| `reason` | guard 或配置失败原因 |
|
||||
|
||||
@ -399,6 +420,9 @@ gateway_rtc_token_denied_total
|
||||
gateway_rtc_token_config_error_total
|
||||
gateway_rtc_token_upstream_error_total
|
||||
gateway_rtc_token_latency_ms
|
||||
room_mic_publish_confirmed_total
|
||||
room_mic_publish_timeout_total
|
||||
room_mic_publish_stale_event_ignored_total
|
||||
```
|
||||
|
||||
腾讯 RTC 外部进房成功率由客户端 SDK 上报或日志采集,不放进 gateway readiness。
|
||||
@ -414,9 +438,12 @@ gateway_rtc_token_latency_ms
|
||||
7. 新增 gateway `/api/v1/rtc/token` handler 和 route。
|
||||
8. handler 调 `RoomGuardClient.VerifyRoomPresence`。
|
||||
9. 在 `CreateRoom` 入口或 room-service 领域层补统一 `room_id` 格式校验,并保持 `/api/v1/rtc/token` 二次校验。
|
||||
10. 更新 `docs/openapi/gateway.swagger.yaml`。
|
||||
11. 增加 gateway transport 单测、config 单测和 room_id 建房校验单测。
|
||||
12. 跑 Phase 2 最小手工联调:JoinRoom 后签 RTC token,客户端用 `strRoomId` 进入语音房。
|
||||
10. 扩展 room proto:`SeatState.publish_state/mic_session_id/publish_deadline_ms`、`MicUpResponse`、`ConfirmMicPublishing`。
|
||||
11. room-service 在 `MicUp` 写入 `pending_publish`,确认后写 `publishing`,超时自动 `MicDown(reason=publish_timeout)`。
|
||||
12. gateway 增加 `/api/v1/rooms/mic/publishing/confirm` 并透传 request_id 到 room-service。
|
||||
13. 更新 `docs/openapi/gateway.swagger.yaml` 和 `docs/openapi/room.swagger.yaml`。
|
||||
14. 增加 gateway transport、room-service command、stale event 和 timeout 单测。
|
||||
15. 跑 Phase 2 最小手工联调:JoinRoom 后签 RTC token,客户端用 `strRoomId` 进入语音房,再完成 MicUp -> ConfirmMicPublishing -> MicDown。
|
||||
|
||||
## Acceptance Tests
|
||||
|
||||
@ -430,13 +457,17 @@ gateway_rtc_token_latency_ms
|
||||
| 房间不存在 | HTTP 404 envelope |
|
||||
| 被踢用户请求 | HTTP 403 envelope |
|
||||
| RTC 配置缺失 | HTTP 500 envelope,不返回假 UserSig |
|
||||
| `room_id` 为空或不兼容 | HTTP 400 envelope |
|
||||
| CreateRoom 使用不兼容 RTC/IM 的 `room_id` | HTTP 400 envelope,房间不创建 |
|
||||
| `room_id` 为空或格式非法 | HTTP 400 envelope |
|
||||
| CreateRoom 使用不符合 RTC/IM 限制的 `room_id` | HTTP 400 envelope,房间不创建 |
|
||||
| UserID 映射 | `user_id=10001` 时 `rtc_user_id == "10001"` |
|
||||
| request_id 透传 | response envelope 和 guard RPC 使用同一个 `request_id` |
|
||||
| `tencent_rtc.enabled=false` | HTTP 500 envelope,不返回 UserSig |
|
||||
| `room_id_type` 非 `string` | handler fail-closed,不返回 UserSig |
|
||||
| IM UserSig 不回归 | 现有 `/api/v1/im/usersig` 测试仍通过 |
|
||||
| MicUp 后未确认发布 | 麦位进入 `pending_publish`,响应返回 `mic_session_id/publish_deadline_ms` |
|
||||
| ConfirmMicPublishing 当前 session | 麦位进入 `publishing` |
|
||||
| ConfirmMicPublishing 旧 session/旧版本/旧 event_time | 不覆盖当前麦位状态 |
|
||||
| publish deadline 超时 | 自动 `MicDown(reason=publish_timeout)` 并释放麦位 |
|
||||
|
||||
建议补充手工验证:
|
||||
|
||||
@ -449,10 +480,16 @@ Login
|
||||
-> Get RTC token
|
||||
-> RTC SDK enterRoom(strRoomId=room_id, userId=rtc_user_id)
|
||||
-> MicUp
|
||||
-> SeatState.publish_state=pending_publish, client receives mic_session_id
|
||||
-> RTC SDK switchRole(anchor)
|
||||
-> Client starts publishing audio
|
||||
-> ConfirmMicPublishing(mic_session_id, room_version, event_time_ms)
|
||||
-> SeatState.publish_state=publishing
|
||||
-> Retry stale ConfirmMicPublishing with old mic_session_id/event_time_ms and verify no state rollback
|
||||
-> MicDown
|
||||
-> Client stops publishing audio and switches to audience
|
||||
-> MicUp again but do not confirm
|
||||
-> publish_deadline_ms expires and room-service auto MicDown(reason=publish_timeout)
|
||||
-> LeaveRoom
|
||||
-> RTC token denied for same room
|
||||
```
|
||||
@ -476,7 +513,7 @@ go test ./...
|
||||
docker compose config
|
||||
```
|
||||
|
||||
如果修改 protobuf,本功能首版不需要;若后续增加 room guard response 字段,则必须运行:
|
||||
涉及 protobuf 时运行:
|
||||
|
||||
```bash
|
||||
make proto
|
||||
@ -495,6 +532,10 @@ Authenticated user
|
||||
-> gateway maps room_id to RTC strRoomId
|
||||
-> gateway signs RTC UserSig on server
|
||||
-> client enters Tencent RTC room
|
||||
-> MicUp creates pending_publish mic_session
|
||||
-> client confirms publishing with mic_session_id, room_version, event_time_ms
|
||||
-> stale RTC/client events cannot clear a newer mic_session
|
||||
-> unconfirmed pending_publish session auto MicDowns after deadline
|
||||
-> LeaveRoom/Kick/stale cleanup makes later RTC token request fail
|
||||
```
|
||||
|
||||
|
||||
@ -55,11 +55,10 @@
|
||||
|
||||
规则:
|
||||
|
||||
- `countries.country_code` 是本系统 canonical 国家码的权威来源。同一个真实国家只能有一条 canonical 记录,不能同时把 `US` 和 `USA` 当成两个国家主键。
|
||||
- `country_aliases.alias_code` 只用于输入兼容,例如 `USA -> US`。alias 解析后必须写回 canonical `country_code`。
|
||||
- `country_code` 创建后视为不可变。需要改码时新增 canonical 国家记录并通过迁移任务更新 `users.country`、`region_countries` 和审计口径。
|
||||
- 注册、改国家、区域国家配置都必须先把输入 trim、转大写,再校验 `^[A-Z]{2,3}$`,然后通过 alias/canonical 解析命中 active `countries`。
|
||||
- `users.country` 继续保存 canonical `country_code` 字符串,保持现有 proto 和 HTTP API 兼容;服务端展示时可以按 `country_code` 拼出 `country_id/country_name/country_display_name`。
|
||||
- `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`。
|
||||
- `country_by_ip` 只做审计和风控快照;如果边缘层给出的国家码不在 `countries` 中,user-service 应丢弃或记录为空,不能参与区域归属。
|
||||
|
||||
### Region
|
||||
@ -127,21 +126,7 @@ CREATE TABLE IF NOT EXISTS countries (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
`country_code` 使用 `VARCHAR(3)` 而不是 `CHAR(2)`,因为 canonical code 保留两位或三位的产品选择空间。首版 seed 采用 alpha-2 作为 canonical;需要兼容 alpha-3 输入时写入 `country_aliases`,不能在 `countries` 里再建一个独立国家。
|
||||
|
||||
### `country_aliases`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS country_aliases (
|
||||
alias_code VARCHAR(3) NOT NULL PRIMARY KEY,
|
||||
country_code VARCHAR(3) NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
KEY idx_country_aliases_country (country_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
alias 只参与解析输入。注册、改国家和区域配置收到 `USA` 时解析到 `US`;`users.country`、`region_countries.country_code` 和 rebuild task 都保存 `US`。
|
||||
`country_code` 使用 `VARCHAR(3)` 而不是 `CHAR(2)`,因为产品允许两位或三位国家码。服务端不维护国家码别名表;客户端和运营后台必须提交最终 `country_code`。
|
||||
|
||||
### `regions`
|
||||
|
||||
@ -258,7 +243,7 @@ ALTER TABLE users
|
||||
ADD KEY idx_users_country_region (country, region_id);
|
||||
```
|
||||
|
||||
`users.country` 继续保存 `country_code`。首个迁移不强制把 `country VARCHAR(64)` 收窄到 `VARCHAR(3)`,避免滚动发布期间旧脏数据或旧服务写入失败;代码上线并清理历史数据后,可以单独评估收窄字段长度。
|
||||
`users.country` 保存 `country_code`。开发阶段直接按当前 schema 和测试收敛字段长度与校验规则,不保留旧结构分支。
|
||||
|
||||
不建议在 `users` 冗余 `country_name`、`country_display_name`、`region_code` 和 `region_name`。展示时由 `user-service` 按 `users.country` 和 `users.region_id` 查询国家/区域主数据,或由读模型拼装。
|
||||
|
||||
@ -266,7 +251,7 @@ ALTER TABLE users
|
||||
|
||||
### Public User Projection
|
||||
|
||||
现有用户投影可以追加字段,保持 proto/API 向后兼容:
|
||||
用户投影字段:
|
||||
|
||||
```proto
|
||||
message User {
|
||||
@ -286,8 +271,7 @@ message User {
|
||||
|
||||
规则:
|
||||
|
||||
- 老客户端可以忽略新增字段。
|
||||
- `country` 仍然是 canonical `country_code`,例如 `CN`、`US`;客户端传入的 alias 不原样保存。
|
||||
- `country` 是 `country_code`,例如 `CN`、`US`。
|
||||
- `country_id = 0` 表示当前 `country` 为空或未命中国家表。
|
||||
- `country_display_name` 是中文展示名,面向需要中文国家描述的客户端或管理端。
|
||||
- `region_id = 0` 表示无区域归属。
|
||||
@ -562,14 +546,12 @@ type RegionRepository interface {
|
||||
|
||||
首版为保证管理变更后新注册和改国家立即生效,优先不加缓存,直接 MySQL 查询。等注册量上来后再加缓存;一旦加缓存,必须使用版本校验、Redis pub/sub 或其他跨实例失效机制,不能只依赖 TTL。
|
||||
|
||||
## Database Migration Plan
|
||||
## Development Plan
|
||||
|
||||
生产迁移必须兼容滚动发布:
|
||||
|
||||
1. 新增 `countries`、`country_aliases`、`regions`、`region_countries`、`region_change_logs`、`user_region_rebuild_tasks`。
|
||||
1. 新增 `countries`、`regions`、`region_countries`、`region_change_logs`、`user_region_rebuild_tasks`。
|
||||
2. `users` 新增 nullable `region_id` 和索引,暂不收窄 `country` 字段长度。
|
||||
3. 初始化 canonical 国家表种子数据和 alias 映射;例如只 seed `US` 到 `countries`,把 `USA -> US` 写入 `country_aliases`。
|
||||
4. 代码读取时兼容 `country_id=0`、`region_id` 为空和国家表未命中的历史用户。
|
||||
3. 初始化国家表种子数据。
|
||||
4. 代码读取时允许 `country_id=0`、`region_id` 为空和国家表未命中的用户快照。
|
||||
5. 上线国家管理、区域管理和注册/改国家写入逻辑。
|
||||
6. 对历史 `users.country` 做一次 rebuild,把已有用户填充 `region_id`,并产出未命中国家表的异常列表。
|
||||
7. 后续如果需要强制所有用户都有国家或区域,再单独评估数据质量和产品规则。
|
||||
@ -631,7 +613,7 @@ go test ./...
|
||||
| --- | --- |
|
||||
| 创建国家 `CN` | 国家表保存 `country_name/country_code/country_display_name` |
|
||||
| 创建三位 canonical 国家码 `TLA` | `country_code=TLA` 合法 |
|
||||
| 创建 alias 国家码 `USA` | 返回 `CONFLICT`,因为 `USA` 是 `US` 的 alias |
|
||||
| 创建重复国家码 | 返回 `CONFLICT` |
|
||||
| 创建小写国家码 `cn` | 归一为 `CN` |
|
||||
| 创建非法国家码 `C1` 或 `ABCD` | 返回 `INVALID_ARGUMENT` |
|
||||
| 注册国家命中区域 | 新用户 `Country=SG`,`RegionID=SEA` |
|
||||
@ -640,7 +622,6 @@ go test ./...
|
||||
| 注册国家小写 | `sg` 归一为 `SG` 后命中区域 |
|
||||
| 已有三方登录再次传国家 | 不修改原国家和区域 |
|
||||
| 修改国家命中区域 | `ChangeUserCountry` 同步更新 `country` 和 `region_id` |
|
||||
| 修改为 alias 国家码 | `ChangeUserCountry("USA")` 写入 canonical `US` |
|
||||
| 修改国家不存在或 disabled | 返回 `INVALID_ARGUMENT`,不写国家日志 |
|
||||
| 修改国家无映射 | `region_id` 清空 |
|
||||
| 修改相同国家 | 不写国家日志,不消耗冷却,允许修正 stale `region_id` |
|
||||
@ -710,7 +691,7 @@ docker compose down
|
||||
- 历史用户可以通过 rebuild task 被重算到新区域。
|
||||
- `country_by_ip` 不参与区域归属。
|
||||
- gateway 不保存区域规则,room-service 不感知区域。
|
||||
- 所有新增 proto 字段向后兼容,老客户端忽略区域字段不受影响。
|
||||
- 当前处于开发阶段,proto 字段按当前实现直接调整。
|
||||
|
||||
## Open Questions
|
||||
|
||||
@ -721,5 +702,5 @@ docker compose down
|
||||
- 管理端是否需要查看每个区域的用户数量。
|
||||
- disabled 区域下历史用户是清空 `region_id`,还是保留旧 region 作为历史标签。
|
||||
- 国家表的初始种子数据来源,是手工运营配置、ISO 导入,还是产品自定义列表。
|
||||
- 三位国家码如果作为输入兼容,应通过 `country_aliases` 映射到 canonical 国家;不要直接作为同一真实国家的第二条 `countries` 记录。
|
||||
- 两位或三位国家码都作为最终 `country_code` 处理,不做别名解析。
|
||||
- `HK`、`MO`、`TW` 这类地区码的中文展示名和产品文案口径。
|
||||
|
||||
@ -264,7 +264,7 @@ room-service 发给腾讯云 IM 的房间系统消息使用 `TIMCustomElem`。
|
||||
- `event_id` 是客户端去重键。
|
||||
- `room_version` 是房间状态排序参考,不替代腾讯云 IM 消息序列。
|
||||
- `event_type` 使用 snake_case,不能直接暴露 protobuf 事件名。
|
||||
- 新增字段必须兼容旧客户端,不能修改已有字段语义。
|
||||
- 系统事件字段按当前 OpenAPI/proto 契约输出;新增字段必须同步 gateway、room-service 和客户端测试。
|
||||
|
||||
首版系统事件:
|
||||
|
||||
|
||||
@ -96,7 +96,7 @@
|
||||
| --- | --- | --- | --- |
|
||||
| RTC UserSig 签发 | `DONE` | `pkg/tencentrtc` 和 `/api/v1/rtc/token` 已存在 | 真实 TRTC SDK enterRoom 联调 |
|
||||
| RTC user_id 映射 | `DONE` | 内部 `user_id` -> RTC `UserID` 十进制字符串 | 多端同房策略未做 |
|
||||
| RTC room_id 映射 | `DONE` | 统一使用 TRTC `strRoomId`,校验 room_id 子集 | 历史非法 room_id 迁移策略 |
|
||||
| RTC room_id 映射 | `DONE` | 统一使用 TRTC `strRoomId`,校验 room_id 子集 | 当前入口 fail-closed,非法 room_id 不创建房间 |
|
||||
| 进频道权限 | `DONE` | 签 RTC token 前调用 `VerifyRoomPresence` | 客户端必须按 JoinRoom 后再取 token |
|
||||
| RTC 角色 | `PARTIAL` | token 返回默认 `audience` | 上麦后切 `anchor` 或 SDK role 切换未闭环 |
|
||||
| 离频道 | `PARTIAL` | 服务端 LeaveRoom 会移除业务 presence | 客户端退出 TRTC 房间、异常重进策略未联调 |
|
||||
@ -122,12 +122,12 @@
|
||||
|
||||
| Feature | Status | Current Evidence | Remaining Work |
|
||||
| --- | --- | --- | --- |
|
||||
| wallet-service 账务表 | `DONE` | `wallet_accounts`、`wallet_ledger`、`wallet_idempotency` | 充值、退款、管理员调账未做 |
|
||||
| 礼物扣费幂等 | `DONE` | `DebitGift` 使用 command_id 幂等,测试覆盖 | 幂等记录过期和冲突请求告警 |
|
||||
| wallet-service 账务表 | `DONE` | `wallet_accounts`、`wallet_transactions`、`wallet_entries`、`wallet_outbox`、`wallet_gift_prices` | 充值、退款、管理员调账未做 |
|
||||
| 礼物扣费幂等 | `DONE` | `DebitGift` 使用 `wallet_transactions.command_id` 幂等,测试覆盖 | 冲突请求告警 |
|
||||
| SendGift 房间命令 | `DONE` | room-service 先扣费,再更新 heat、gift_rank、outbox | 礼物目标、礼物表现和客户端动画联调 |
|
||||
| 房间热度 | `DONE` | `RoomSnapshot.heat` 和 `RoomHeatChanged` 事件 | 热度衰减/排行榜周期未做 |
|
||||
| 本地礼物榜 | `DONE` | `LocalRank` 和 `gift_rank` 快照 | 全局榜、跨房榜、榜单重置未做 |
|
||||
| 礼物配置 | `TODO` | 当前请求直接传 `gift_id/gift_count/gift_unit_value` | 需要礼物表、价格、上下架、表现资源、版本 |
|
||||
| 礼物配置 | `PARTIAL` | `wallet_gift_prices` 提供服务端价格,SendGift 请求只传 `gift_id/gift_count` | 后台价格、上下架、表现资源、版本管理未做 |
|
||||
| 背包/道具 | `TODO` | 当前只有金币扣费 | 背包扣减、免费礼物、道具有效期未做 |
|
||||
| 礼物消息补偿 | `DONE` | GiftSent/Heat/Rank 事件进入 outbox,主事件同步 IM | 客户端去重和展示策略未验收 |
|
||||
|
||||
|
||||
747
docs/voice-room-mic-room-management-development.md
Normal file
747
docs/voice-room-mic-room-management-development.md
Normal file
@ -0,0 +1,747 @@
|
||||
# Voice Room Mic And Room Management Development Guide
|
||||
|
||||
本文档定义语音房“麦位与房间管理”下一阶段开发设计。目标是在现有 `MicUp/MicDown/ChangeMicSeat/MuteUser/KickUser` 基础上补齐权限矩阵、锁麦、聊天开关、管理员管理、解封、时长型管控和 RTC 推流联动。
|
||||
|
||||
## Scope
|
||||
|
||||
| Capability | Owner | Required Result |
|
||||
| --- | --- | --- |
|
||||
| 麦位状态 | `room-service` | Room Cell 串行维护麦位占用、锁定和换位 |
|
||||
| 房间角色 | `room-service` | owner、host、admin、audience 权限可判定、可恢复 |
|
||||
| 管理命令入口 | `gateway-service` | HTTP 只做鉴权和协议转换,actor 只能来自 access token |
|
||||
| IM 发言守卫 | `gateway-service` + `room-service` | 腾讯 IM 发言前回查禁言、封禁、聊天开关和 presence |
|
||||
| RTC 推流策略 | Client + Tencent RTC | 服务端给出麦位事实,客户端按麦位事实切换 TRTC role 或推流状态 |
|
||||
| 房间系统事件 | `room-service` + Tencent IM | 管理动作进入 outbox,并同步推送房间系统消息 |
|
||||
|
||||
## Non-Goals
|
||||
|
||||
| Non-goal | Reason |
|
||||
| --- | --- |
|
||||
| 不在 gateway 判断房间权限 | gateway 不持有 Room Cell,不能复制权限逻辑 |
|
||||
| 不把 RTC 在线态写进 Room Cell | RTC 连接态属于腾讯 RTC,Room Cell 只保存业务麦位事实 |
|
||||
| 不做客户端 socket 管理 | 长连接和群消息继续由腾讯 IM 承担 |
|
||||
| 不把全局封禁放进 room-service | 全局用户状态属于 user-service 或后台风控服务 |
|
||||
| 不在本阶段做复杂主持排班 | 当前只补房间内即时管理能力 |
|
||||
|
||||
## Current State
|
||||
|
||||
现有能力:
|
||||
|
||||
| Feature | Status | Current Behavior |
|
||||
| --- | --- | --- |
|
||||
| 上麦 | `DONE` | `MicUp` 校验用户在 presence、目标麦位存在、未锁、未占用 |
|
||||
| 下麦 | `DONE` | `MicDown` 可把指定 target 从麦位移下 |
|
||||
| 换麦 | `DONE` | `ChangeMicSeat` 原子清空源麦位并占用目标麦位 |
|
||||
| 禁言 | `DONE` | `MuteUser` 写 `MuteUsers` 集合,`CheckSpeakPermission` 拒绝发言 |
|
||||
| 踢人 | `DONE` | `KickUser` 移除 presence、释放麦位、写 `BanUsers` 集合 |
|
||||
| 麦位锁定字段 | `PARTIAL` | `SeatState.locked` 和 `RoomState.MicSeat.Locked` 已存在,但没有命令 |
|
||||
| 管理员集合 | `PARTIAL` | `RoomState.AdminUsers` 和 snapshot `admin_user_ids` 已存在,但没有管理命令 |
|
||||
| 聊天开关字段 | `PARTIAL` | `chat_enabled` 已影响 `CheckSpeakPermission`,但没有命令 |
|
||||
| RTC 发流确认 | `DONE` | `MicUp` 进入 `pending_publish`,客户端或 RTC webhook 确认后进入 `publishing`,超时自动下麦 |
|
||||
|
||||
现有主要缺口:
|
||||
|
||||
- 管理命令没有统一权限矩阵。
|
||||
- 管理员增删、主持转移、owner 转让未实现。
|
||||
- 锁麦、聊天开关、解封没有命令。
|
||||
- 禁言/封禁没有过期时间、原因、操作者审计字段。
|
||||
- RTC token 仍未按麦位事实动态返回 `anchor/audience`;当前由客户端在 `MicUp` 成功后切换角色并确认发流。
|
||||
|
||||
## Target State
|
||||
|
||||
本阶段完成后,房间管理应该满足:
|
||||
|
||||
- 普通用户只能自助上麦、主动下麦、主动离房和送礼。
|
||||
- owner/host/admin 可以管理麦位、禁言、踢人和聊天开关。
|
||||
- 只有 owner 可以增删 admin、转 host;转 owner 推迟到后续房间资产和后台审计阶段。
|
||||
- 锁麦位后任何角色都不能上麦或换入,必须先解锁。
|
||||
- 被禁言用户不能发公屏,但仍可收消息、上麦规则由权限策略决定。
|
||||
- 被踢/ban 用户不能 JoinRoom、不能 IM 进群、不能发言、不能拿 RTC token;已在腾讯 IM 群内的用户必须通过 IM bridge 触发踢出群副作用。
|
||||
- 所有管理动作写 command log、snapshot、outbox,并可恢复。
|
||||
- 客户端通过房间系统消息和最新 snapshot 修正本地 UI。
|
||||
|
||||
## Role Model
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Meaning | Source |
|
||||
| --- | --- | --- |
|
||||
| `owner` | 房间所有者,最高权限 | `RoomState.OwnerUserID` |
|
||||
| `host` | 主持人,负责房间现场管理 | `RoomState.HostUserID` |
|
||||
| `admin` | 房间管理员 | `RoomState.AdminUsers` 中除 owner/host 外的用户 |
|
||||
| `audience` | 普通观众 | `OnlineUsers[user].Role` 或默认值 |
|
||||
|
||||
角色判定不要只信 `RoomUser.role`。权威顺序:
|
||||
|
||||
```text
|
||||
if actor == owner_user_id -> owner
|
||||
else if actor == host_user_id -> host
|
||||
else if admin_users[actor] -> admin
|
||||
else -> audience
|
||||
```
|
||||
|
||||
`JoinRoom.role` 只能作为展示和默认入房角色,不能让客户端自报 `admin` 获得权限。
|
||||
|
||||
### Permission Matrix
|
||||
|
||||
| Action | Owner | Host | Admin | Audience |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 自己上麦 `MicUp` | allow | allow | allow | allow |
|
||||
| 自己下麦 `MicDown(self)` | allow | allow | allow | allow |
|
||||
| 下别人麦 `MicDown(target)` | allow | allow | allow | deny |
|
||||
| 换别人麦 `ChangeMicSeat(target)` | allow | allow | allow | deny |
|
||||
| 锁/解锁麦位 | allow | allow | allow | deny |
|
||||
| 禁言/解禁 | allow | allow | allow | deny |
|
||||
| 踢人/ban | allow | allow | allow | deny |
|
||||
| 解封/unban | allow | allow | deny | deny |
|
||||
| 开关公屏 | allow | allow | allow | deny |
|
||||
| 增删 admin | allow | deny | deny | deny |
|
||||
| 转 host | allow | deny | deny | deny |
|
||||
| 转 owner(后续批次) | allow | deny | deny | deny |
|
||||
| 关房(后续批次) | allow | deny by default | deny | deny |
|
||||
|
||||
补充约束:
|
||||
|
||||
- owner 不能被 host/admin 踢、禁言、下麦或移出管理员集合。
|
||||
- host 不能被普通 admin 踢、禁言或降级。
|
||||
- actor 对自己执行 `MuteUser` 和 `KickUser` 默认拒绝,避免错误操作造成不可恢复状态。
|
||||
- 非 presence 用户不能被设为 host/admin,除非后续明确支持预设管理员。
|
||||
- 锁麦是硬锁,owner/host/admin 也不能直接上锁定麦位;管理员需要先解锁,或先下麦再锁麦。
|
||||
- 管理命令 actor 必须在当前房间 presence 中;离房后的 host/admin 不能继续远程管理,重新 JoinRoom 后恢复其持久角色。
|
||||
|
||||
## Command Design
|
||||
|
||||
现有命令保留:
|
||||
|
||||
- `MicUp`
|
||||
- `MicDown`
|
||||
- `ChangeMicSeat`
|
||||
- `MuteUser`
|
||||
- `KickUser`
|
||||
|
||||
本阶段新增命令:
|
||||
|
||||
| Command | Purpose | Notes |
|
||||
| --- | --- | --- |
|
||||
| `SetMicSeatLock` | 锁定或解锁指定麦位 | 首版只允许锁空麦位 |
|
||||
| `SetChatEnabled` | 开启或关闭公屏 | 影响 `CheckSpeakPermission` |
|
||||
| `SetRoomAdmin` | 添加或移除管理员 | 只有 owner 可执行 |
|
||||
| `TransferRoomHost` | 转移主持人 | 只有 owner 可执行 |
|
||||
| `UnbanUser` | 解除房间 ban | 用户可重新 JoinRoom |
|
||||
|
||||
后续批次命令:
|
||||
|
||||
| Command | Reason To Defer |
|
||||
| --- | --- |
|
||||
| `TransferRoomOwner` | 影响房间资产、收益、后台审计和纠纷处理,本阶段只做现场主持转移 |
|
||||
| `CloseRoom` | 属于房间生命周期管理,需要和房间列表、搜索、推荐和恢复策略一起设计 |
|
||||
|
||||
### `SetMicSeatLock`
|
||||
|
||||
语义:
|
||||
|
||||
- `locked=true` 锁麦。
|
||||
- `locked=false` 解锁。
|
||||
- 首版锁麦只允许目标麦位为空;如果麦位有人,返回 `CONFLICT`。
|
||||
- 解锁空麦位或已解锁麦位幂等返回当前快照,不重复写事件。
|
||||
|
||||
拒绝锁 occupied seat 的原因:
|
||||
|
||||
- 避免一个命令同时产生“下麦”和“锁麦”两个 UI 事件。
|
||||
- 如果管理端要清空并锁定,应先 `MicDown(target)`,再 `SetMicSeatLock(seat_no,true)`。
|
||||
|
||||
### `SetChatEnabled`
|
||||
|
||||
语义:
|
||||
|
||||
- `enabled=false` 后 `CheckSpeakPermission` 返回 `chat_disabled`。
|
||||
- 关闭公屏对所有用户自发公屏消息生效,包括 owner/host/admin。
|
||||
- 系统消息和管理消息不受影响,因为它们由 room-service 通过腾讯 IM REST 发布。
|
||||
- 重复设置相同值幂等,不重复写事件。
|
||||
|
||||
### `SetRoomAdmin`
|
||||
|
||||
语义:
|
||||
|
||||
- `enabled=true` 添加管理员。
|
||||
- `enabled=false` 移除管理员。
|
||||
- owner/host 永远属于管理集合,不能通过该命令移除。
|
||||
- target 必须在当前房间 presence 中,避免后台误把不存在用户加入房间管理。
|
||||
- admin 离房后保留 admin 身份,重新 JoinRoom 后继续具备 admin 权限。
|
||||
- admin 被 KickUser/ban 后必须从 `AdminUsers` 删除;Unban 后不自动恢复 admin,必须 owner 重新添加。
|
||||
|
||||
### `TransferRoomHost`
|
||||
|
||||
语义:
|
||||
|
||||
- 新 host 必须在 room presence 中。
|
||||
- 新 host 自动进入 `AdminUsers`。
|
||||
- 旧 host 是否仍为 admin:首版保留为 admin,避免主持交接后失去管理权限造成现场中断。
|
||||
- host 主动离房后 `HostUserID` 不自动变化,但离房期间不能执行管理命令。
|
||||
- 当前 host 不能被 KickUser 直接踢出;owner 必须先 `TransferRoomHost`,再按普通 admin/audience 规则管理旧 host。
|
||||
|
||||
### Deferred `TransferRoomOwner`
|
||||
|
||||
语义:
|
||||
|
||||
- 本阶段不实现 HTTP/RPC,只保留后续语义边界。
|
||||
- 新 owner 必须在 room presence 中。
|
||||
- 新 owner 自动成为 owner、host 和 admin。
|
||||
- 旧 owner 是否降级:首版降级为 admin,不继续是 host,避免双 owner。
|
||||
- 转 owner 必须写强审计事件。
|
||||
|
||||
### `UnbanUser`
|
||||
|
||||
语义:
|
||||
|
||||
- 从 `BanUsers` 删除 target。
|
||||
- 不自动 JoinRoom;用户必须重新调用 `JoinRoom`。
|
||||
- 重复 unban 已不在 ban 集合的用户幂等返回当前快照。
|
||||
- 不恢复被 KickUser 时移除的 admin 身份、麦位或 presence。
|
||||
|
||||
### `KickUser` / Room Ban Extension
|
||||
|
||||
`KickUser` 表达“踢出房间并写入房间 ban 集合”。如果本阶段要支持限时 ban,直接把请求和快照契约调整为:
|
||||
|
||||
```proto
|
||||
message KickUserRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 target_user_id = 2;
|
||||
int64 duration_ms = 3;
|
||||
string reason = 4;
|
||||
}
|
||||
```
|
||||
|
||||
语义:
|
||||
|
||||
- `duration_ms=0` 表示永久 ban,直到 `UnbanUser`。
|
||||
- `duration_ms>0` 表示 ban 到 `now + duration_ms`;过期后 JoinRoom、IM 进群和 RTC token 可以重新通过。
|
||||
- KickUser 成功后必须原子移除 target 的 presence、麦位和 admin 身份,并写 `BanUsers[target]`。
|
||||
- KickUser 成功提交后,outbox/IM bridge 必须触发腾讯 IM 群成员踢出副作用;副作用失败不回滚 Room Cell,但必须重试。
|
||||
- 在 IM 踢出副作用成功前,`CheckSpeakPermission` 和 `VerifyRoomPresence` 仍必须拒绝 target,避免其继续发言或重新进群。
|
||||
|
||||
### `MuteUser` Extension
|
||||
|
||||
如果需要限时禁言,`MuteUser` 请求和快照契约直接调整为:
|
||||
|
||||
```proto
|
||||
message MuteUserRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 target_user_id = 2;
|
||||
bool muted = 3;
|
||||
int64 duration_ms = 4;
|
||||
string reason = 5;
|
||||
}
|
||||
```
|
||||
|
||||
语义:
|
||||
|
||||
- `muted=true,duration_ms=0` 表示永久禁言,直到手动解除。
|
||||
- `muted=true,duration_ms>0` 表示禁言到 `now + duration_ms`。
|
||||
- `muted=false` 表示解除禁言,忽略 `duration_ms`。
|
||||
- 当前 snapshot 仍保留 `mute_user_ids`,后续可追加 `mute_states` 暴露过期时间。
|
||||
- MuteUser 只阻断公屏发言,不移除 presence、不影响收消息、不改变 RTC 麦位;如果需要离房必须使用 KickUser。
|
||||
|
||||
## Proto Changes
|
||||
|
||||
只允许追加字段、消息和 RPC,不能改已有字段含义或编号。
|
||||
|
||||
本阶段固定追加:
|
||||
|
||||
```proto
|
||||
message SetMicSeatLockRequest {
|
||||
RequestMeta meta = 1;
|
||||
int32 seat_no = 2;
|
||||
bool locked = 3;
|
||||
}
|
||||
|
||||
message SetMicSeatLockResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
message SetChatEnabledRequest {
|
||||
RequestMeta meta = 1;
|
||||
bool enabled = 2;
|
||||
}
|
||||
|
||||
message SetChatEnabledResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
message SetRoomAdminRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 target_user_id = 2;
|
||||
bool enabled = 3;
|
||||
}
|
||||
|
||||
message SetRoomAdminResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
message TransferRoomHostRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 target_user_id = 2;
|
||||
}
|
||||
|
||||
message TransferRoomHostResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
|
||||
message UnbanUserRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 target_user_id = 2;
|
||||
}
|
||||
|
||||
message UnbanUserResponse {
|
||||
CommandResult result = 1;
|
||||
RoomSnapshot room = 2;
|
||||
}
|
||||
```
|
||||
|
||||
如果本阶段启用限时 mute/ban,再追加 moderation state 和 `RoomSnapshot` 字段:
|
||||
|
||||
```proto
|
||||
message ModerationState {
|
||||
int64 user_id = 1;
|
||||
int64 expires_at_ms = 2;
|
||||
int64 operator_user_id = 3;
|
||||
string reason = 4;
|
||||
}
|
||||
|
||||
message RoomSnapshot {
|
||||
...
|
||||
repeated ModerationState mute_states = 16;
|
||||
repeated ModerationState ban_states = 17;
|
||||
}
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `mute_user_ids` / `ban_user_ids` 输出当前仍有效的 user_id,供只关心布尔状态的客户端使用。
|
||||
- `mute_states` / `ban_states` 输出过期时间、原因和操作者,用于完整管理面展示。
|
||||
- 快照和恢复链路必须使用同一套 state 结构,不能同时维护两套来源。
|
||||
|
||||
`RoomCommandService` 追加:
|
||||
|
||||
```proto
|
||||
rpc SetMicSeatLock(SetMicSeatLockRequest) returns (SetMicSeatLockResponse);
|
||||
rpc SetChatEnabled(SetChatEnabledRequest) returns (SetChatEnabledResponse);
|
||||
rpc SetRoomAdmin(SetRoomAdminRequest) returns (SetRoomAdminResponse);
|
||||
rpc TransferRoomHost(TransferRoomHostRequest) returns (TransferRoomHostResponse);
|
||||
rpc UnbanUser(UnbanUserRequest) returns (UnbanUserResponse);
|
||||
```
|
||||
|
||||
`TransferRoomOwner` 和 `CloseRoom` 可以放到下一批,如果本阶段只做现场管理。
|
||||
|
||||
## HTTP API Design
|
||||
|
||||
gateway 追加 HTTP 路由:
|
||||
|
||||
```text
|
||||
POST /api/v1/rooms/mic/lock
|
||||
POST /api/v1/rooms/chat/enabled
|
||||
POST /api/v1/rooms/admin/set
|
||||
POST /api/v1/rooms/host/transfer
|
||||
POST /api/v1/rooms/user/unban
|
||||
```
|
||||
|
||||
所有请求都必须支持 `command_id`:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room_1001",
|
||||
"command_id": "cmd_room_1001_lock_seat_1_10001_1"
|
||||
}
|
||||
```
|
||||
|
||||
### Command Idempotency
|
||||
|
||||
`command_id` 是房间命令幂等键,不是链路追踪 ID。
|
||||
|
||||
规则:
|
||||
|
||||
- 幂等作用域为 `(room_id, command_id)`。
|
||||
- room-service 持久化 command log 时必须保存 `command_type` 和 payload hash。
|
||||
- 重复提交相同 `(room_id, command_id, command_type, payload_hash)` 返回幂等成功,不重复执行 mutate、不重复写 outbox。
|
||||
- 重复提交相同 `(room_id, command_id)` 但 `command_type` 或 payload hash 不一致,返回 `CONFLICT`。
|
||||
- 幂等返回可以携带当前最新 snapshot,但 `CommandResult.applied=false`,客户端按 snapshot `version` 修正 UI。
|
||||
- 业务 no-op 也必须写入幂等记录或 command log;例如重复 `SetChatEnabled(false)` 不能因为未递增版本而丢失幂等保护。
|
||||
- `request_id` 只用于追踪,可以每次重试不同;不能参与幂等判定。
|
||||
|
||||
### Lock Mic
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room_1001",
|
||||
"command_id": "cmd_lock_1",
|
||||
"seat_no": 3,
|
||||
"locked": true
|
||||
}
|
||||
```
|
||||
|
||||
### Chat Enabled
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room_1001",
|
||||
"command_id": "cmd_chat_off_1",
|
||||
"enabled": false
|
||||
}
|
||||
```
|
||||
|
||||
### Set Admin
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room_1001",
|
||||
"command_id": "cmd_admin_add_10002",
|
||||
"target_user_id": 10002,
|
||||
"enabled": true
|
||||
}
|
||||
```
|
||||
|
||||
### Transfer Host
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room_1001",
|
||||
"command_id": "cmd_host_to_10002",
|
||||
"target_user_id": 10002
|
||||
}
|
||||
```
|
||||
|
||||
### Unban
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "room_1001",
|
||||
"command_id": "cmd_unban_10002",
|
||||
"target_user_id": 10002
|
||||
}
|
||||
```
|
||||
|
||||
## RoomState Changes
|
||||
|
||||
短期最小变更:
|
||||
|
||||
```go
|
||||
type RoomState struct {
|
||||
...
|
||||
HostUserID int64
|
||||
ChatEnabled bool
|
||||
AdminUsers map[int64]bool
|
||||
BanUsers map[int64]bool
|
||||
MuteUsers map[int64]bool
|
||||
}
|
||||
```
|
||||
|
||||
这些字段已存在。
|
||||
|
||||
如果本阶段启用时长型禁言和封禁,内部状态替换为:
|
||||
|
||||
```go
|
||||
type UserModerationState struct {
|
||||
UserID int64
|
||||
ExpiresAtMS int64
|
||||
OperatorUserID int64
|
||||
Reason string
|
||||
}
|
||||
|
||||
MuteUsers map[int64]UserModerationState
|
||||
BanUsers map[int64]UserModerationState
|
||||
```
|
||||
|
||||
开发策略:
|
||||
|
||||
- snapshot 输出 `mute_user_ids`、`ban_user_ids`、`mute_states` 和 `ban_states` 时必须来自同一份状态。
|
||||
- 请求里没有 `duration_ms` 时按永久处理,不能依赖旧命令语义分支。
|
||||
- `CheckSpeakPermission`、`VerifyRoomPresence`、`JoinRoom` 和 `/api/v1/rtc/token` 都只看未过期的 ban/mute。
|
||||
- 如果本阶段不做限时 ban/mute,就不要追加 `duration_ms`、`mute_states`、`ban_states`;保留 bool set,避免半套协议。
|
||||
|
||||
## Authorization Design
|
||||
|
||||
在 room-service 内新增统一权限 helper:
|
||||
|
||||
```go
|
||||
type roomRole string
|
||||
|
||||
const (
|
||||
roleOwner roomRole = "owner"
|
||||
roleHost roomRole = "host"
|
||||
roleAdmin roomRole = "admin"
|
||||
roleAudience roomRole = "audience"
|
||||
)
|
||||
|
||||
func actorRole(state *RoomState, actorUserID int64) roomRole
|
||||
func canManageMic(state *RoomState, actorUserID int64, targetUserID int64) bool
|
||||
func canManageModeration(state *RoomState, actorUserID int64, targetUserID int64) bool
|
||||
func canManageAdmins(state *RoomState, actorUserID int64) bool
|
||||
func canTransferHost(state *RoomState, actorUserID int64) bool
|
||||
```
|
||||
|
||||
权限失败统一返回:
|
||||
|
||||
```text
|
||||
PERMISSION_DENIED
|
||||
```
|
||||
|
||||
不要在错误信息里暴露过多角色细节,避免客户端据此枚举房间管理结构。服务端日志可以记录:
|
||||
|
||||
```text
|
||||
request_id, command_id, room_id, actor_user_id, target_user_id, action, actor_role, denied_reason
|
||||
```
|
||||
|
||||
## RTC Push Linkage
|
||||
|
||||
服务端不直接控制 TRTC 推流,但必须区分“业务占麦成功”和“RTC 已确认发布音频”:
|
||||
|
||||
| Room State | Client RTC Behavior |
|
||||
| --- | --- |
|
||||
| 用户不在麦位 | TRTC role `audience`,不能发布音频 |
|
||||
| `MicUp` 成功且 `SeatState.user_id == self`、`publish_state=pending_publish` | 客户端切 `anchor` 并 `startLocalAudio`,UI 展示为等待发流确认 |
|
||||
| SDK 本地发流成功后调用 `ConfirmMicPublishing(mic_session_id, room_version, event_time_ms)` | 服务端把当前麦位切到 `publish_state=publishing` |
|
||||
| `pending_publish` 超过 `publish_deadline_ms` 未确认 | 服务端自动 `MicDown(reason=publish_timeout)`,客户端停止上行并切回 `audience` |
|
||||
| `MicDown`/`KickUser`/`LeaveRoom` 后麦位释放 | 客户端停止本地音频上行并切回 `audience` |
|
||||
| `ChangeMicSeat` | 客户端只更新 UI,不需要重进 RTC |
|
||||
| `SetMicSeatLock` | 客户端更新麦位 UI,不影响当前 RTC 连接 |
|
||||
|
||||
后续接 RTC webhook 时,payload 必须带 `mic_session_id`、`room_version`、`event_time_ms`。room-service 只接受当前 session 且事件时间不旧于已接受事件的数据,旧 audience/leave 事件不能清理新的上麦会话。
|
||||
|
||||
`/api/v1/rtc/token` 首版仍返回 `audience`。后续如果要服务端返回 `anchor`,必须在签 token 时读取 Room Cell 麦位事实:
|
||||
|
||||
```text
|
||||
if user is on mic -> role=anchor
|
||||
else -> role=audience
|
||||
```
|
||||
|
||||
但不要把 RTC 在线态写入 Room Cell。
|
||||
|
||||
## Event Design
|
||||
|
||||
新增或复用 room outbox 事件:
|
||||
|
||||
| Event Type | Trigger | IM Event Type |
|
||||
| --- | --- | --- |
|
||||
| `RoomMicChanged` | MicUp/MicDown/ChangeMicSeat | `room_mic_up/down/change` |
|
||||
| `RoomMicSeatLocked` | SetMicSeatLock | `room_mic_seat_locked` |
|
||||
| `RoomChatEnabledChanged` | SetChatEnabled | `room_chat_enabled_changed` |
|
||||
| `RoomAdminChanged` | SetRoomAdmin | `room_admin_changed` |
|
||||
| `RoomHostTransferred` | TransferRoomHost | `room_host_transferred` |
|
||||
| `RoomUserMuted` | MuteUser | `room_user_muted` |
|
||||
| `RoomUserKicked` | KickUser | `room_user_kicked` |
|
||||
| `RoomUserUnbanned` | UnbanUser | `room_user_unbanned` |
|
||||
|
||||
### Event Payload Contract
|
||||
|
||||
所有 room outbox 事件和腾讯 IM 系统消息必须使用同一套字段语义,避免客户端按不同来源写两套解析。
|
||||
|
||||
| Event Type | Required Payload |
|
||||
| --- | --- |
|
||||
| `RoomMicChanged` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`target_user_id`、`from_seat`、`to_seat`、`action`、`mic_session_id`、`publish_state`、`reason`、`publish_deadline_ms`、`publish_event_time_ms` |
|
||||
| `RoomMicSeatLocked` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`seat_no`、`locked` |
|
||||
| `RoomChatEnabledChanged` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`enabled` |
|
||||
| `RoomAdminChanged` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`target_user_id`、`enabled` |
|
||||
| `RoomHostTransferred` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`old_host_user_id`、`new_host_user_id` |
|
||||
| `RoomUserMuted` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`target_user_id`、`muted`、`expires_at_ms`、`reason` |
|
||||
| `RoomUserKicked` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`target_user_id`、`expires_at_ms`、`reason` |
|
||||
| `RoomUserUnbanned` | `event_id`、`room_id`、`room_version`、`actor_user_id`、`target_user_id` |
|
||||
|
||||
事件要求:
|
||||
|
||||
- `event_id` 由 outbox 生成。
|
||||
- `room_version` 必须等于命令提交后的版本。
|
||||
- 同步 IM publish 失败不回滚命令。
|
||||
- outbox worker 后续补投。
|
||||
- 客户端按 `event_id` 去重,按 `room_version` 判断是否需要拉最新 snapshot。
|
||||
- 客户端不能把 `room_mic_up` 当成音频已发布,只能在 `publish_state=publishing` 后展示为真正发流中。
|
||||
- IM 系统消息只是 UI 通知;KickUser 的群成员踢出是独立 side effect,必须由 outbox/IM bridge 可重试执行。
|
||||
|
||||
### IM Side Effects
|
||||
|
||||
`KickUser`/ban 成功提交后,需要两个层面的外部效果:
|
||||
|
||||
| Side Effect | Required Behavior |
|
||||
| --- | --- |
|
||||
| 房间系统消息 | 发布 `room_user_kicked`,让客户端立即关闭麦位、弹出提示、停止 RTC 上行 |
|
||||
| 腾讯 IM 群成员移除 | 调用腾讯 IM REST 把 target 从房间群移出,避免继续收公屏消息 |
|
||||
| 腾讯 IM 失败补偿 | 不回滚 Room Cell;outbox 保留待处理记录,worker 重试直到成功或进入人工告警 |
|
||||
| 失败窗口守卫 | IM 移除成功前,`CheckSpeakPermission` 继续拒绝发言,`VerifyRoomPresence` 继续拒绝进群 |
|
||||
|
||||
首版不在 room-service 管理客户端 socket;如果腾讯 IM REST 持续失败,服务端安全边界是“不能发言、不能重新进群、不能拿 RTC token”,但可能短时间继续收到群消息,需要告警和补偿。
|
||||
|
||||
## Recovery Replay
|
||||
|
||||
每个新增命令都必须加入:
|
||||
|
||||
- `command` 模型。
|
||||
- `command.Serialize/Deserialize`。
|
||||
- `replay` 分支。
|
||||
- command log 测试。
|
||||
|
||||
恢复语义:
|
||||
|
||||
| Command | Replay Behavior |
|
||||
| --- | --- |
|
||||
| `SetMicSeatLock` | 设置 `MicSeats[index].Locked` |
|
||||
| `SetChatEnabled` | 设置 `ChatEnabled` |
|
||||
| `SetRoomAdmin` | 增删 `AdminUsers[target]`,但 owner/host 保持 admin |
|
||||
| `TransferRoomHost` | 更新 `HostUserID`,新 host 加入 `AdminUsers` |
|
||||
| `UnbanUser` | 删除 `BanUsers[target]` |
|
||||
| `MuteUser` with duration | 如果恢复时已过期,清理;否则保留 |
|
||||
| `KickUser` with duration | 恢复 presence/mic/admin 移除结果;ban 未过期时保留,已过期时不恢复 ban |
|
||||
|
||||
如果支持时长型禁言/封禁,需要有清理 worker 或在 guard 查询时懒清理过期状态。首版建议 guard 查询时懒判断:
|
||||
|
||||
```text
|
||||
if expires_at_ms > 0 && now >= expires_at_ms -> treat as not muted/banned
|
||||
```
|
||||
|
||||
是否立即写回清理结果可以后续由 moderation sweep worker 完成。
|
||||
|
||||
恢复时不要重新执行腾讯 IM 踢人副作用;IM side effect 只从 outbox 待投递记录补偿,不从 command replay 直接调用外部服务。
|
||||
|
||||
## Validation Rules
|
||||
|
||||
| Input | Rule | Error |
|
||||
| --- | --- | --- |
|
||||
| `seat_no` | 必须存在于当前房间麦位 | `INVALID_ARGUMENT` |
|
||||
| `target_user_id` | 必须大于 0 | `INVALID_ARGUMENT` |
|
||||
| actor | 必须来自 `RequestMeta.actor_user_id` | `UNAUTHENTICATED` or `INVALID_ARGUMENT` |
|
||||
| room | 必须 active | `FAILED_PRECONDITION` |
|
||||
| locked occupied seat | 首版拒绝 | `CONFLICT` |
|
||||
| permission denied | actor 权限不足 | `PERMISSION_DENIED` |
|
||||
| target is owner | host/admin 不能管理 owner | `PERMISSION_DENIED` |
|
||||
| self kick/mute | 默认拒绝 | `INVALID_ARGUMENT` or `PERMISSION_DENIED` |
|
||||
| `command_id` duplicate with different payload | 同房间同 command_id 不能复用不同 payload | `CONFLICT` |
|
||||
| `duration_ms` | `>=0`,上限按配置限制,首版建议不超过 30 天 | `INVALID_ARGUMENT` |
|
||||
| `reason` | trim 后最多 128 字符,不写入客户端不可见敏感信息 | `INVALID_ARGUMENT` |
|
||||
| target is current host | 首版不能直接 KickUser,owner 需先转 host | `PERMISSION_DENIED` |
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. 新增 room-service 权限 helper 和单测,先接入现有 `MicDown/ChangeMicSeat/MuteUser/KickUser`。
|
||||
2. 补齐 command_id payload hash 校验,确保重复 command_id 不会静默吞掉不同请求。
|
||||
3. 追加 proto:`SetMicSeatLock`、`SetChatEnabled`、`SetRoomAdmin`、`TransferRoomHost`、`UnbanUser`;如果本阶段启用限时管控,再追加 moderation state 和 duration 字段。
|
||||
4. 运行 `make proto`,提交生成的 `.pb.go` 和 `_grpc.pb.go`。
|
||||
5. 扩展 gateway room client、HTTP handler 和 OpenAPI。
|
||||
6. 新增 command 模型和 `Deserialize` 分支。
|
||||
7. 在 room-service 实现新增命令,全部走 `mutateRoom`。
|
||||
8. 新增 room events,接入 outbox 和腾讯 IM system event。
|
||||
9. 为 KickUser 接入腾讯 IM 群成员移除 side effect,失败不回滚命令但必须由 outbox worker 补偿。
|
||||
10. 扩展 recovery replay,确保 replay 不直接调用外部 IM。
|
||||
11. 补全单元测试、gateway transport 测试和 outbox/IM bridge 补偿测试。
|
||||
12. 客户端联调麦位 UI、系统消息和 RTC 推流切换。
|
||||
|
||||
## Test Plan
|
||||
|
||||
### Permission Tests
|
||||
|
||||
| Case | Expected |
|
||||
| --- | --- |
|
||||
| audience 下别人麦 | `PERMISSION_DENIED` |
|
||||
| audience 自己下麦 | success |
|
||||
| admin 下 audience 麦 | success |
|
||||
| admin 下 owner 麦 | `PERMISSION_DENIED` |
|
||||
| host 禁言 audience | success |
|
||||
| admin 禁言 host | `PERMISSION_DENIED` |
|
||||
| owner 添加 admin | success |
|
||||
| host 添加 admin | `PERMISSION_DENIED` |
|
||||
| admin 解封用户 | `PERMISSION_DENIED` |
|
||||
| host 解封用户 | success |
|
||||
|
||||
### Mic Tests
|
||||
|
||||
| Case | Expected |
|
||||
| --- | --- |
|
||||
| 锁空麦 | `SeatState.locked=true`,写事件 |
|
||||
| 重复锁同一空麦 | 幂等返回,不重复事件 |
|
||||
| 锁 occupied 麦 | `CONFLICT` |
|
||||
| 解锁已锁麦 | `SeatState.locked=false` |
|
||||
| locked seat MicUp | `PERMISSION_DENIED` |
|
||||
| locked seat ChangeMicSeat | `PERMISSION_DENIED` |
|
||||
| owner 上 locked seat | `PERMISSION_DENIED`,必须先解锁 |
|
||||
|
||||
### Chat Tests
|
||||
|
||||
| Case | Expected |
|
||||
| --- | --- |
|
||||
| SetChatEnabled false | snapshot `chat_enabled=false` |
|
||||
| chat disabled 后发言守卫 | `allowed=false, reason=chat_disabled` |
|
||||
| SetChatEnabled true | 发言守卫恢复正常 |
|
||||
|
||||
### Admin And Host Tests
|
||||
|
||||
| Case | Expected |
|
||||
| --- | --- |
|
||||
| owner 添加 admin | `admin_user_ids` 包含 target |
|
||||
| owner 移除 admin | `admin_user_ids` 不包含 target |
|
||||
| 移除 owner admin | 拒绝 |
|
||||
| TransferRoomHost | `host_user_id` 更新,新 host 在 admin 集合 |
|
||||
| 新 host 管理麦位 | success |
|
||||
| admin 离房后重进 | admin 权限保留 |
|
||||
| Kick admin | admin 身份被移除,Unban 后不自动恢复 |
|
||||
| Kick current host | `PERMISSION_DENIED`,必须先转 host |
|
||||
|
||||
### Ban And Unban Tests
|
||||
|
||||
| Case | Expected |
|
||||
| --- | --- |
|
||||
| KickUser 后 JoinRoom | `PERMISSION_DENIED` |
|
||||
| KickUser 后 VerifyRoomPresence | `present=false, reason=user_banned` |
|
||||
| KickUser 后 CheckSpeakPermission | `allowed=false, reason=user_banned` |
|
||||
| KickUser 后 IM side effect | outbox/IM bridge 触发群成员移除 |
|
||||
| IM side effect 失败 | Room Cell 不回滚,outbox 保留重试 |
|
||||
| 限时 KickUser 过期后 JoinRoom | success |
|
||||
| UnbanUser 后 JoinRoom | success |
|
||||
| 重复 UnbanUser | 幂等 |
|
||||
|
||||
### Idempotency Tests
|
||||
|
||||
| Case | Expected |
|
||||
| --- | --- |
|
||||
| 相同 command_id 相同 payload 重试 | 不重复 mutate,不重复 outbox,返回幂等结果 |
|
||||
| 相同 command_id 不同 payload | `CONFLICT` |
|
||||
| no-op 命令重试 | 不因未递增版本而丢失幂等保护 |
|
||||
|
||||
### Event Payload Tests
|
||||
|
||||
| Case | Expected |
|
||||
| --- | --- |
|
||||
| 每类 room outbox event | 包含文档 payload 矩阵要求字段 |
|
||||
| IM system event | 与 outbox payload 字段语义一致 |
|
||||
| 客户端重复收到同一 event_id | 去重后 UI 不重复变更 |
|
||||
|
||||
### Recovery Tests
|
||||
|
||||
| Case | Expected |
|
||||
| --- | --- |
|
||||
| snapshot 后回放 SetMicSeatLock | locked 状态恢复 |
|
||||
| snapshot 后回放 SetChatEnabled | chat_enabled 恢复 |
|
||||
| snapshot 后回放 SetRoomAdmin | admin 集合恢复 |
|
||||
| snapshot 后回放 TransferRoomHost | host 恢复 |
|
||||
| snapshot 后回放 UnbanUser | ban 集合恢复 |
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- 权限矩阵覆盖所有麦位和房间管理命令。
|
||||
- 普通用户不能管理他人麦位、禁言、踢人、锁麦或开关公屏。
|
||||
- owner 可以添加管理员、转 host、管理所有麦位。
|
||||
- 锁麦命令可用,locked 麦位对所有角色都不能上麦或换入。
|
||||
- 关闭公屏后腾讯 IM 发言回调被拒绝,打开后恢复。
|
||||
- 踢人后用户不能 JoinRoom、不能 IM 进群、不能发言、不能拿 RTC token。
|
||||
- 踢人后 outbox/IM bridge 会补偿执行腾讯 IM 群成员移除;副作用失败不回滚 Room Cell。
|
||||
- 解封后用户可以重新 JoinRoom,但不会自动恢复 presence。
|
||||
- 解封后不会自动恢复 admin、host、麦位或 RTC 推流状态。
|
||||
- 相同 command_id 不同 payload 会被拒绝,no-op 管理命令也具备幂等保护。
|
||||
- 新增管理动作全部写 command log、snapshot、outbox,并能恢复。
|
||||
- 同步 IM 失败不回滚房间状态,outbox worker 后续补投。
|
||||
- 客户端可通过系统消息和 snapshot 正确更新麦位、禁言、踢人、聊天开关和管理员 UI。
|
||||
|
||||
## Remaining Decisions
|
||||
|
||||
- 是否本阶段启用限时 mute/ban;如果不开启,就不要追加 `duration_ms` 和 `mute_states/ban_states`。
|
||||
- 上麦后 RTC role 首版由客户端自行切换;gateway RTC token 返回 `anchor` 需要后续读取 Room Cell 麦位事实后再做。
|
||||
480
docs/wallet-service-architecture.md
Normal file
480
docs/wallet-service-architecture.md
Normal file
@ -0,0 +1,480 @@
|
||||
# Wallet Service Architecture
|
||||
|
||||
本文定义钱包服务的目标架构、账务模型、核心流程和落地顺序。当前 `wallet-service` 只有单币种 `coin` 扣费能力,新需求需要升级为多资产账户、外部支付订单、币商充值、主播奖励和提现审核的一体化账务边界。
|
||||
|
||||
## Goals
|
||||
|
||||
- `wallet-service` 是所有用户资产余额、流水、幂等和提现冻结的唯一 owner。
|
||||
- `room-service` 只同步调用钱包完成送礼扣费和主播积分入账,不直接修改余额。
|
||||
- MQ 只消费钱包 outbox 事件,不作为余额事实来源。
|
||||
- Redis 只能做读缓存、风控计数或短期状态,不能作为余额、充值或提现恢复来源。
|
||||
- 所有资金和虚拟资产使用整数最小单位,不能使用浮点数。
|
||||
|
||||
## Asset Model
|
||||
|
||||
| Asset | Meaning | Spendable | Withdrawable | Owner |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `COIN` | 金币,用户送礼消费资产 | yes | no | wallet-service |
|
||||
| `DIAMOND` | 钻石,不可直接消费,只能按政策兑换金币或美元余额 | no | no | wallet-service |
|
||||
| `GIFT_POINT` | 主播礼物积分,达到政策后结算美元奖励 | no | no | wallet-service |
|
||||
| `USD_BALANCE` | 主播可提现美元余额,建议单位为 cent 或 micro cent | no | yes | wallet-service |
|
||||
|
||||
钻石和积分都不是直接消费资产。钻石兑换金币或美元余额时必须落兑换订单,记录政策版本、汇率和结果。主播积分达到政策后,由结算任务发起美元余额奖励入账,用户提现只能提现 `USD_BALANCE`。
|
||||
|
||||
## Current Implementation Snapshot
|
||||
|
||||
| Capability | Status | Current Evidence | Gap |
|
||||
| --- | --- | --- | --- |
|
||||
| `DebitGift` RPC | `DONE` | `DebitGift` 只接收礼物 ID、数量和可选价格版本,返回 `transaction_id/coin_spent/gift_point_added/heat_value/price_version` | 充值、兑换、提现仍需新增 RPC 和状态机 |
|
||||
| 多资产账户 | `PARTIAL` | `wallet_accounts(user_id,asset_type,available_amount,frozen_amount,version)` | 已能表达冻结余额;提现冻结/解冻/出账命令未落地 |
|
||||
| 交易和分录 | `DONE` | 交易写 `wallet_transactions`、`wallet_entries`,不保留旧流水表 | 充值、兑换、提现的分录类型仍需补齐 |
|
||||
| 命令幂等 | `DONE` | `wallet_transactions.command_id` + `request_hash` | 重复 provider receipt 也要幂等 |
|
||||
| 余额不足保护 | `DONE` | MySQL 事务锁 sender account,余额不足失败 | 需要并发压测覆盖多资产和冻结余额 |
|
||||
| 礼物价格 | `DONE` | `wallet_gift_prices` 提供服务端 `coin_price/gift_point_amount/heat_value`,客户端不提交礼物单价 | 后台礼物价格配置和上下架入口未落地 |
|
||||
| 充值 | `TODO` | 无 payment order 和 provider 校验 | Apple、Google、线下、币商库存发放都未实现 |
|
||||
| 币商转金币 | `TODO` | 无 merchant account/transfer | 需要币商余额、限额、审计和用户入账 |
|
||||
| 钻石兑换 | `TODO` | 无 diamond exchange order | 需要政策快照、兑换状态机和原子出入账 |
|
||||
| 主播积分/奖励 | `PARTIAL` | 送礼实时给收礼人加 `GIFT_POINT` | 周期结算任务和 `USD_BALANCE` 奖励入账未落地 |
|
||||
| 提现 | `TODO` | 无 withdraw request | 需要冻结、审核、人工打款、失败回滚 |
|
||||
| 钱包 outbox | `PARTIAL` | `wallet_outbox` 已和交易同事务写入 `WalletBalanceChanged/WalletGiftDebited` | outbox worker 和 MQ 投递适配未落地 |
|
||||
|
||||
这张表是需求边界,不是上线状态承诺。后续开发必须先补账务事实层,再补 HTTP 管理入口和活动联动。
|
||||
|
||||
## Requirement Scope
|
||||
|
||||
### P0: Gift Billing Foundation
|
||||
|
||||
- `DebitGift` 只接收服务端定价所需字段,不保留历史兼容字段。
|
||||
- 钱包按服务端礼物价格结算 `coin_spent`、`gift_point_added` 和 `heat_value`,客户端不能提交礼物单价。
|
||||
- 同一个事务内完成 sender `COIN` 扣减、target `GIFT_POINT` 入账、交易记录、分录和 outbox。
|
||||
- `room-service` 只消费钱包返回的已结算值更新房间热度、本地榜和房间 outbox。
|
||||
- 扣费失败时 `room-service` 不进入 Room Cell 状态变更。
|
||||
|
||||
### P1: Balance And Admin Operations
|
||||
|
||||
- 增加多资产余额查询,客户端只能看到自己的余额,后台可以按权限查询用户资产和流水。
|
||||
- 增加后台手动充值、扣减和调账命令,所有操作必须记录操作人、原因、凭证和审批来源。
|
||||
- 增加账务对账查询:按用户、资产、交易、外部订单和时间窗口查询 transaction/entry。
|
||||
- 任何后台命令都必须有独立 `command_id`,重复提交返回原结果,不允许重复入账。
|
||||
|
||||
### P2: Provider Recharge
|
||||
|
||||
- Apple/Google 充值必须落 `payment_orders`,订单状态和账务入账不能只依赖客户端回调。
|
||||
- provider receipt、transaction id、purchase token 必须全局唯一,重复通知只能返回原订单结果。
|
||||
- provider 校验失败不能产生资产分录,校验成功和资产入账必须在同一个 MySQL 事务提交。
|
||||
- 商品配置必须是服务端资产包配置,客户端传入的商品 ID 只能用于匹配,不决定到账金额。
|
||||
|
||||
### P3: Merchant And Exchange
|
||||
|
||||
- 币商库存是币商自己的 `COIN` 账户余额,币商给用户转币必须先扣币商再加用户。
|
||||
- 币商转账需要限额、风控状态、线下订单号、目标用户和审计日志。
|
||||
- 钻石兑换必须生成兑换订单,记录政策版本和汇率快照,兑换成功原子扣 `DIAMOND` 并加目标资产。
|
||||
- 兑换失败不能出现单边扣减,重复兑换命令必须返回同一兑换订单结果。
|
||||
|
||||
### P4: Anchor Reward And Withdraw
|
||||
|
||||
- 送礼实时加主播 `GIFT_POINT`,但美元奖励由结算任务按政策转换,不能写死在送礼链路。
|
||||
- 奖励结算生成 `anchor_reward_settlements`,确认后加主播 `USD_BALANCE`。
|
||||
- 提现申请必须先冻结 `USD_BALANCE`,审核拒绝解冻,审核通过后进入人工打款流程。
|
||||
- 人工打款完成后从冻结余额出账;打款失败回到可重试状态,不能自动解冻后丢失审核上下文。
|
||||
|
||||
## Component Diagram
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Client["Client"] --> Gateway["gateway-service"]
|
||||
Gateway --> Room["room-service"]
|
||||
Gateway --> Wallet["wallet-service"]
|
||||
Gateway --> Admin["admin/ops API"]
|
||||
|
||||
Room -->|"DebitGift gRPC"| Wallet
|
||||
Admin -->|"manual recharge / withdraw review"| Wallet
|
||||
Merchant["coin merchant"] -->|"merchant transfer API"| Gateway
|
||||
Apple["Apple IAP"] -->|"receipt / callback"| Gateway
|
||||
Google["Google Play Billing"] -->|"receipt / callback"| Gateway
|
||||
|
||||
Wallet --> MySQL[("wallet MySQL")]
|
||||
Wallet --> Outbox[("wallet_outbox")]
|
||||
Outbox --> MQ["MQ / event bus"]
|
||||
MQ --> Activity["activity-service / settlement jobs"]
|
||||
MQ --> BI["risk / audit / analytics"]
|
||||
|
||||
Wallet --> RoomEvent["billing result"]
|
||||
Room --> TencentIM["Tencent IM room message"]
|
||||
```
|
||||
|
||||
## Ledger Design
|
||||
|
||||
钱包不要继续使用“单账户余额表 + 单条流水”承载所有场景。目标模型是账户表记录余额,交易表记录业务命令,分录表记录每个账户的资产变化。
|
||||
|
||||
核心表建议:
|
||||
|
||||
```sql
|
||||
wallet_accounts(
|
||||
user_id BIGINT NOT NULL,
|
||||
asset_type VARCHAR(32) NOT NULL,
|
||||
available_amount BIGINT NOT NULL,
|
||||
frozen_amount BIGINT NOT NULL,
|
||||
version BIGINT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (user_id, asset_type)
|
||||
);
|
||||
|
||||
wallet_transactions(
|
||||
transaction_id VARCHAR(96) NOT NULL PRIMARY KEY,
|
||||
command_id VARCHAR(128) NOT NULL,
|
||||
biz_type VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
request_hash VARCHAR(128) NOT NULL,
|
||||
external_ref VARCHAR(128) NOT NULL DEFAULT '',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
UNIQUE KEY uk_wallet_tx_command (command_id)
|
||||
);
|
||||
|
||||
wallet_entries(
|
||||
entry_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
transaction_id VARCHAR(96) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
asset_type VARCHAR(32) NOT NULL,
|
||||
available_delta BIGINT NOT NULL,
|
||||
frozen_delta BIGINT NOT NULL,
|
||||
available_after BIGINT NOT NULL,
|
||||
frozen_after BIGINT NOT NULL,
|
||||
counterparty_user_id BIGINT NOT NULL DEFAULT 0,
|
||||
room_id VARCHAR(96) NOT NULL DEFAULT '',
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
KEY idx_wallet_entries_user_time (user_id, created_at_ms),
|
||||
KEY idx_wallet_entries_tx (transaction_id)
|
||||
);
|
||||
```
|
||||
|
||||
交易和分录的核心约束:
|
||||
|
||||
- `wallet_transactions.command_id` 是钱包命令幂等键,同一个命令重复提交必须返回同一结果。
|
||||
- `wallet_transactions.request_hash` 覆盖业务语义,同一个 `command_id` 携带不同 payload 必须返回冲突。
|
||||
- `wallet_entries` 是资产事实的审计分录,原则上只追加不修改;余额修正也要通过新交易和新分录表达。
|
||||
- `wallet_entries.available_delta` 和 `frozen_delta` 必须同时表达一笔资产变化,提现冻结是 available 负数、frozen 正数,打款完成是 frozen 负数。
|
||||
- `wallet_accounts.version` 用于并发更新保护,写路径仍以 MySQL 行锁和事务为准。
|
||||
- `available_amount + frozen_amount` 是用户某资产的总负债,不能出现负数,不能由 Redis 恢复。
|
||||
|
||||
支付、兑换、提现使用独立状态表承载业务状态机:
|
||||
|
||||
- `payment_orders`: Apple、Google、运营给币商发币和线下充值记录。
|
||||
- `merchant_coin_transfers`: 币商给用户转金币的交易申请和结果。
|
||||
- `diamond_exchange_orders`: 钻石兑换金币或美元余额的政策快照。
|
||||
- `anchor_reward_settlements`: 主播积分达到政策后的美元奖励结算单。
|
||||
- `withdraw_requests`: 主播提现申请、审核、驳回、人工打款和完成状态。
|
||||
- `wallet_outbox`: 余额变化、充值成功、提现状态变化等事件投递源。
|
||||
|
||||
业务状态表必须把外部唯一键落到数据库约束里。Apple/Google 使用 provider transaction id 或 purchase token 唯一约束,币商转账使用 merchant order id + merchant user id 唯一约束,提现使用 withdraw request id 唯一约束。
|
||||
|
||||
礼物价格需要单独的服务端账务配置,不能由房间命令携带价格决定:
|
||||
|
||||
```sql
|
||||
wallet_gift_prices(
|
||||
gift_id VARCHAR(96) NOT NULL,
|
||||
price_version VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
coin_price BIGINT NOT NULL,
|
||||
gift_point_amount BIGINT NOT NULL,
|
||||
heat_value BIGINT NOT NULL,
|
||||
effective_at_ms BIGINT NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
PRIMARY KEY (gift_id, price_version),
|
||||
KEY idx_wallet_gift_prices_effective (gift_id, status, effective_at_ms)
|
||||
);
|
||||
```
|
||||
|
||||
`wallet_gift_prices` 只承载账务结算字段。礼物名称、图标、动画、排序、客户端资源可以放在独立礼物配置或后台配置里,但钱包落账时必须记录 `gift_id`、`price_version`、`coin_price`、`gift_point_amount` 和 `heat_value` 快照。
|
||||
|
||||
## Send Gift Flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant G as gateway-service
|
||||
participant R as room-service
|
||||
participant W as wallet-service
|
||||
participant DB as wallet MySQL
|
||||
participant IM as Tencent IM
|
||||
|
||||
C->>G: send gift
|
||||
G->>R: SendGift(command_id, room_id, gift_id, count)
|
||||
R->>R: Room Cell validates room/user/target
|
||||
R->>W: DebitGift(command_id, payer, anchor, gift)
|
||||
W->>DB: tx lock payer COIN account
|
||||
W->>DB: debit COIN, credit GIFT_POINT, write tx/entries/outbox
|
||||
W-->>R: receipt, coin_spent, point_added, balance_after
|
||||
R->>R: commit heat/rank/contribution/snapshot/outbox
|
||||
R-->>G: room snapshot
|
||||
R->>IM: gift room message
|
||||
```
|
||||
|
||||
`DebitGift` 应该由钱包按服务端礼物配置计算最终金币扣费和主播积分。客户端不提交礼物单价,`room-service` 只能使用钱包返回的已结算值;房间恢复也只使用 command log 中保存的 `heat_value`,不能重新推导价格。
|
||||
|
||||
送礼账务要求:
|
||||
|
||||
- `sender_user_id` 和 `target_user_id` 必须不同;如果产品允许给自己送礼,需要单独产品规则和风控开关。
|
||||
- `gift_count` 必须设置单次上限,防止大整数溢出和单请求绕过风控。
|
||||
- `coin_spent = coin_price * gift_count`,计算前要检查乘法溢出。
|
||||
- `gift_point_added = gift_point_amount * gift_count`,`heat_value = heat_value_per_gift * gift_count`。
|
||||
- 余额不足时不能写 `wallet_transactions` 的成功状态,不能写 `wallet_entries`,也不能写 outbox。
|
||||
- 扣费成功后必须返回 `billing_receipt_id`、`transaction_id`、`coin_spent`、`gift_point_added`、`heat_value`、`price_version` 和 sender `COIN` 账后余额。
|
||||
|
||||
## Recharge Flows
|
||||
|
||||
Apple 和 Google:
|
||||
|
||||
1. 客户端创建或购买商品,商品映射到固定资产包。
|
||||
2. 客户端提交 provider receipt 或 purchase token。
|
||||
3. `wallet-service` 调 provider 校验接口,确认未消费、金额、商品、环境和用户绑定。
|
||||
4. MySQL 事务内写 `payment_orders`、`wallet_transactions`、`wallet_entries` 和 `wallet_outbox`。
|
||||
5. 同一个 provider transaction id 必须全局唯一,重复回调只能返回原结果。
|
||||
|
||||
币商:
|
||||
|
||||
1. 币商私下给平台转账后,运营后台创建 `merchant_recharge`,审核通过后给币商账户发放 `COIN`。
|
||||
2. 币商没有可提现余额入账,平台给币商发放的是可转给用户的金币库存。
|
||||
3. 用户私下给币商转账后,币商发起转金币命令,钱包在一个事务中扣币商 `COIN`,加用户 `COIN`。
|
||||
4. 币商给用户转多少 `COIN`,就代表这次用户通过币商完成了多少金币充值。
|
||||
5. 这类交易 `biz_type=merchant_transfer`,同时记录币商 ID、目标用户、线下订单号和操作来源。
|
||||
6. 币商不能直接改用户余额,所有发币和转币都必须走钱包命令和审计流水。
|
||||
|
||||
运营线下充值:
|
||||
|
||||
1. 用户线下给平台转账后,运营后台创建 `manual_recharge`。
|
||||
2. 审核通过后钱包给用户加 `COIN`,并记录操作人、凭证、金额和备注。
|
||||
3. 手动充值必须有权限、二次确认和审计日志。
|
||||
|
||||
充值订单状态机:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> created
|
||||
created --> verifying
|
||||
verifying --> verified
|
||||
verifying --> verify_failed
|
||||
verified --> credited
|
||||
credited --> [*]
|
||||
verify_failed --> [*]
|
||||
```
|
||||
|
||||
`credited` 是唯一代表资产已到账的终态。`verified` 只代表 provider 或后台审核通过,不代表余额已更新;从 `verified` 到 `credited` 必须和 `wallet_transactions`、`wallet_entries`、`wallet_outbox` 同事务提交。
|
||||
|
||||
## Diamond Exchange
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["User DIAMOND account"] -->|"debit diamonds"| X["diamond_exchange_order"]
|
||||
X -->|"policy: diamond to coin"| C["User COIN account"]
|
||||
X -->|"policy: diamond to USD"| U["User USD_BALANCE account"]
|
||||
```
|
||||
|
||||
钻石兑换必须冻结政策版本。订单里至少记录 `from_diamond_amount`、`to_asset_type`、`to_amount`、`policy_id`、`rate_snapshot` 和 `status`。兑换成功后写分录和 outbox;兑换失败不能出现钻石扣了但目标资产没到账。
|
||||
|
||||
## Anchor Reward And Withdraw
|
||||
|
||||
主播通过礼物获得 `GIFT_POINT`。积分政策不要写死在送礼链路里,建议由结算任务按周期计算奖励:
|
||||
|
||||
1. 送礼时钱包给主播增加 `GIFT_POINT`。
|
||||
2. 结算任务读取积分和政策,生成 `anchor_reward_settlements`。
|
||||
3. 结算单确认后调用钱包 `CreditRewardBalance`,增加主播 `USD_BALANCE`。
|
||||
4. 主播提现时先冻结 `USD_BALANCE`,审核拒绝解冻,审核通过后等待人工转账。
|
||||
5. 人工转账完成后把冻结余额出账,状态改为 `paid`。
|
||||
|
||||
提现状态机:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending_review
|
||||
pending_review --> rejected
|
||||
rejected --> refunded
|
||||
pending_review --> approved
|
||||
approved --> paying
|
||||
paying --> paid
|
||||
paying --> pay_failed
|
||||
pay_failed --> approved
|
||||
```
|
||||
|
||||
提现申请时必须从 `available_amount` 转到 `frozen_amount`,不能只写一个待审核单。否则审核期间用户可以重复提现同一笔余额。
|
||||
|
||||
## RPC Surface
|
||||
|
||||
当前开发阶段直接使用新的 wallet RPC 契约:
|
||||
|
||||
- `GetBalances`: 查询用户多资产余额。
|
||||
- `DebitGift`: 扣金币、加主播积分,返回已结算值。
|
||||
- `CreatePaymentOrder`: 创建 Apple/Google 商品订单。
|
||||
- `ConfirmProviderPayment`: 校验 provider receipt 并入账。
|
||||
- `AdminCreditAsset`: 运营手动充值或调账,只开放后台。
|
||||
- `MerchantTransferCoin`: 币商转金币给用户。
|
||||
- `ExchangeDiamond`: 钻石兑换金币或美元余额。
|
||||
- `CreditRewardBalance`: 结算任务给主播发美元余额奖励。
|
||||
- `CreateWithdrawRequest`: 主播提现申请并冻结余额。
|
||||
- `ReviewWithdrawRequest`: 后台审核提现。
|
||||
- `MarkWithdrawPaid`: 人工打款后确认出账。
|
||||
|
||||
外部 HTTP 入口仍在 `gateway-service`,内部统一 gRPC + protobuf。当前处于开发阶段,不做历史兼容保留;修改 `api/proto` 后必须运行 `make proto` 和 `go test ./...`。
|
||||
|
||||
### RPC Field Requirements
|
||||
|
||||
`DebitGift` 字段要求:
|
||||
|
||||
- Request: `price_version` 可选;为空时钱包选择当前生效价格;传入时必须命中有效价格。
|
||||
- Response: `transaction_id`,用于内部账务查询。
|
||||
- Response: `coin_spent`,实际扣减的 `COIN`。
|
||||
- Response: `gift_point_added`,实际增加给收礼人的 `GIFT_POINT`。
|
||||
- Response: `heat_value`,room-service 更新房间热度的唯一可信值。
|
||||
- Response: `price_version`,这次账务使用的礼物价格版本。
|
||||
- Response: `balance_after`,语义限定为 sender `COIN available_amount`。
|
||||
|
||||
新 RPC 的共同字段要求:
|
||||
|
||||
- Request 必须包含 `command_id`,后台命令还必须包含 `operator_user_id`、`reason`、`evidence_ref`。
|
||||
- Response 必须包含稳定业务 ID,例如 `transaction_id`、`payment_order_id`、`withdraw_request_id`。
|
||||
- 查询 RPC 可以不需要 `command_id`,但必须透传 gateway 的 `request_id` 用于追踪。
|
||||
- 错误码必须能被 gateway 映射成稳定 HTTP envelope,不能只返回自由文本。
|
||||
|
||||
## HTTP And Admin Requirements
|
||||
|
||||
外部 HTTP 仍只放在 `gateway-service`,wallet-service 不直接暴露公网 HTTP。
|
||||
|
||||
客户端接口:
|
||||
|
||||
- `GET /api/v1/wallet/balances`: 查询当前用户多资产余额。
|
||||
- `GET /api/v1/wallet/transactions`: 查询当前用户流水,支持 asset、biz_type、时间和游标分页。
|
||||
- `POST /api/v1/wallet/payments/apple/confirm`: 提交 Apple receipt 或 transaction id。
|
||||
- `POST /api/v1/wallet/payments/google/confirm`: 提交 Google purchase token。
|
||||
- `POST /api/v1/wallet/diamond/exchange`: 发起钻石兑换。
|
||||
- `POST /api/v1/wallet/withdrawals`: 主播发起提现。
|
||||
- `GET /api/v1/wallet/withdrawals`: 查询提现记录。
|
||||
|
||||
后台接口:
|
||||
|
||||
- 用户资产查询和流水查询。
|
||||
- 手动充值、扣减和调账。
|
||||
- 币商账户开通、禁用、库存发放、转账查询。
|
||||
- 提现审核、打款标记、打款失败重试。
|
||||
- 礼物账务价格配置和上下架。
|
||||
- 兑换政策、奖励政策和限额配置。
|
||||
|
||||
后台所有写接口必须记录操作人、来源 IP、原因、工单或凭证引用。没有审计字段的后台写操作不允许落地。
|
||||
|
||||
## Error Semantics
|
||||
|
||||
钱包错误要区分“用户可处理”和“系统需重试”:
|
||||
|
||||
| Error | Meaning | Retry |
|
||||
| --- | --- | --- |
|
||||
| `INSUFFICIENT_BALANCE` | 可用余额不足 | no |
|
||||
| `ACCOUNT_FROZEN` | 账户被风控冻结 | no |
|
||||
| `GIFT_NOT_FOUND` | 礼物账务配置不存在 | no |
|
||||
| `GIFT_OFF_SHELF` | 礼物不可购买 | no |
|
||||
| `REQUEST_CONFLICT` | 同 `command_id` 不同 payload | no |
|
||||
| `PAYMENT_VERIFY_FAILED` | provider 校验失败 | no |
|
||||
| `PROVIDER_DUPLICATE` | provider 交易已处理 | return original |
|
||||
| `WITHDRAW_AMOUNT_INVALID` | 提现金额或门槛不满足 | no |
|
||||
| `LEDGER_BUSY` | 同账户并发冲突或锁等待超时 | yes |
|
||||
| `LEDGER_INTERNAL` | 数据库或内部未知错误 | yes |
|
||||
|
||||
`room-service` 调 `DebitGift` 时,只能在明确可重试错误上按上层策略重试。余额不足、礼物下架、请求冲突不能重试成重复扣费。
|
||||
|
||||
## Outbox Events
|
||||
|
||||
`wallet_outbox` 必须和账务交易同事务写入。事件消费者失败不能回滚账务事实,只能通过 outbox worker 补投。
|
||||
|
||||
建议事件:
|
||||
|
||||
- `WalletBalanceChanged`: 任意资产 available/frozen 变化。
|
||||
- `WalletGiftDebited`: 送礼扣费成功,包含 sender、target、room、gift、price_version、coin_spent、gift_point_added。
|
||||
- `PaymentCredited`: provider 或后台充值到账。
|
||||
- `MerchantCoinTransferred`: 币商给用户转金币成功。
|
||||
- `DiamondExchanged`: 钻石兑换成功。
|
||||
- `RewardBalanceCredited`: 主播奖励美元余额入账。
|
||||
- `WithdrawFrozen`: 提现申请冻结成功。
|
||||
- `WithdrawPaid`: 人工打款完成并出账。
|
||||
- `WithdrawRejected`: 审核拒绝并解冻。
|
||||
|
||||
事件 payload 必须包含 `event_id`、`transaction_id`、`command_id`、`user_id`、`asset_type`、`available_delta`、`frozen_delta`、`created_at_ms`。消费者必须按 `event_id` 或 `transaction_id + event_type` 幂等。
|
||||
|
||||
## Reconciliation And Observability
|
||||
|
||||
- 每日对账任务按 `wallet_entries` 汇总校验 `wallet_accounts.available_amount/frozen_amount`。
|
||||
- Provider 充值需要按 provider 后台交易和本地 `payment_orders` 做差异对账。
|
||||
- 提现需要按 `withdraw_requests` 状态、冻结余额和人工打款记录做对账。
|
||||
- 关键指标:扣费成功率、余额不足数、幂等命中数、请求冲突数、provider 校验失败数、outbox pending 数、outbox 重试次数、提现冻结金额。
|
||||
- 关键日志必须带 `request_id`、`command_id`、`transaction_id`、`user_id`、`biz_type`,但不能输出 provider receipt、支付凭证全文和敏感证件信息。
|
||||
|
||||
## Development Plan
|
||||
|
||||
1. 保持当前干净账本表:`wallet_accounts`、`wallet_transactions`、`wallet_entries`、`wallet_outbox`、`wallet_gift_prices`。
|
||||
2. `DebitGift` 只走 v2 账本和服务端礼物价格,不保留客户端单价字段。
|
||||
3. 增加后台礼物价格配置、上下架和审计入口。
|
||||
4. 增加充值、兑换、提现订单表和状态机。
|
||||
5. 增加钱包 outbox worker 和 MQ 投递适配。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
账务基础:
|
||||
|
||||
- 同一 `command_id`、同一 payload 重复调用 `DebitGift`,余额只变化一次,返回同一 `billing_receipt_id`。
|
||||
- 同一 `command_id`、不同 payload 调用 `DebitGift`,返回 `REQUEST_CONFLICT`,余额不变。
|
||||
- 并发多个扣费请求打同一个 sender 账户,不出现负数余额。
|
||||
- 余额不足时没有成功交易、没有分录、没有 outbox。
|
||||
|
||||
送礼:
|
||||
|
||||
- 钱包按 `wallet_gift_prices` 计算扣费、积分和热度,客户端没有礼物单价输入。
|
||||
- 扣费成功后 sender `COIN` 减少,target `GIFT_POINT` 增加,room-service 使用钱包返回 `heat_value` 更新房间热度。
|
||||
- 腾讯 IM 或 room outbox 投递失败不影响钱包交易事实。
|
||||
|
||||
充值和兑换:
|
||||
|
||||
- 同一个 Apple/Google provider transaction 重复确认,只返回原到账结果。
|
||||
- provider 校验失败不会产生资产分录。
|
||||
- 币商转金币在一个事务中扣币商、加用户,任一侧失败则整体失败。
|
||||
- 钻石兑换成功后扣 `DIAMOND` 和加目标资产是原子结果。
|
||||
|
||||
提现:
|
||||
|
||||
- 提现申请成功后 `USD_BALANCE.available_amount` 减少,`frozen_amount` 增加。
|
||||
- 审核拒绝后冻结金额回到 available。
|
||||
- 打款完成后 frozen 减少并写出账分录。
|
||||
- 打款失败后状态可重试,不丢失冻结关系和审核上下文。
|
||||
|
||||
验证命令:
|
||||
|
||||
```bash
|
||||
make proto
|
||||
go test ./services/wallet-service/...
|
||||
go test ./services/room-service/...
|
||||
go test ./services/gateway-service/...
|
||||
docker compose config
|
||||
```
|
||||
|
||||
涉及 Docker、配置或 provider mock 时再补 `docker compose build` 和实际 `docker compose up -d` 冒烟。
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. 新增 v2 账本表:`wallet_accounts` 扩展为多资产 available/frozen,新增 `wallet_transactions`、`wallet_entries`、`wallet_outbox`。
|
||||
2. 重构 repository:提供通用 `ApplyTransaction`,在一个 MySQL 事务中完成幂等、锁账户、写分录、更新余额。
|
||||
3. 保持现有 `DebitGift` RPC 可用,内部改为 `COIN` 扣费和 `GIFT_POINT` 入账。
|
||||
4. 增加余额查询和管理端手动充值,先打通运营可测链路。
|
||||
5. 增加 Apple/Google 订单和 receipt 校验,provider transaction id 做唯一约束。
|
||||
6. 增加币商账户、币商转账和限额审计。
|
||||
7. 增加钻石兑换订单。
|
||||
8. 增加主播奖励结算和提现冻结/审核/人工打款闭环。
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- 任何入账、扣款、冻结、解冻、出账都必须写 transaction 和 entry,不能直接 update balance。
|
||||
- 幂等键必须覆盖业务语义,同一个 command_id 带不同 payload 必须返回冲突。
|
||||
- Apple/Google 充值以 provider 真实交易号为最终幂等源,不能只信客户端 request id。
|
||||
- 币商充值分两段:平台给币商发 `COIN` 库存,币商再把 `COIN` 转给用户;必须有币商金币余额、限额和审计。
|
||||
- `USD_BALANCE` 是可提现负债,调账和奖励发放需要更高权限和审计。
|
||||
- 兑换汇率和奖励政策必须记录快照,不能只存当前 policy id。
|
||||
- 提现人工打款前必须冻结余额,打款失败要回到可重新处理状态。
|
||||
- 钱包事件投递使用 outbox,MQ 投递失败不回滚账务事实。
|
||||
- 礼物价格和积分比例必须来自服务端配置,不能由客户端决定。
|
||||
@ -51,7 +51,7 @@ func (s *Server) Check(ctx context.Context, req *healthpb.HealthCheckRequest) (*
|
||||
return &healthpb.HealthCheckResponse{Status: s.status(ctx)}, nil
|
||||
}
|
||||
|
||||
// Watch 定时推送 ready 状态变化,兼容支持 gRPC health watch 的服务发现系统。
|
||||
// Watch 定时推送 ready 状态变化,供支持 gRPC health watch 的服务发现系统消费。
|
||||
func (s *Server) Watch(req *healthpb.HealthCheckRequest, stream healthgrpc.Health_WatchServer) error {
|
||||
if !s.supports(req.GetService()) {
|
||||
if err := stream.Send(&healthpb.HealthCheckResponse{Status: healthpb.HealthCheckResponse_SERVICE_UNKNOWN}); err != nil {
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
package roomid
|
||||
|
||||
const (
|
||||
// MaxStringIDLength keeps room_id compatible with Tencent IM GroupID and RTC strRoomId.
|
||||
// MaxStringIDLength keeps room_id within both Tencent IM GroupID and RTC strRoomId limits.
|
||||
MaxStringIDLength = 48
|
||||
)
|
||||
|
||||
// ValidStringID reports whether value can be used as both internal room_id and RTC strRoomId.
|
||||
// ValidStringID reports whether value can be used as the internal room_id and Tencent room identifier.
|
||||
func ValidStringID(value string) bool {
|
||||
if len(value) == 0 || len(value) > MaxStringIDLength {
|
||||
return false
|
||||
|
||||
@ -24,6 +24,8 @@ const (
|
||||
createGroupCommand = "v4/group_open_http_svc/create_group"
|
||||
// sendGroupMsgCommand is Tencent Cloud Chat REST command for group message sending.
|
||||
sendGroupMsgCommand = "v4/group_open_http_svc/send_group_msg"
|
||||
// deleteGroupMemberCommand removes users from a Tencent Cloud Chat group.
|
||||
deleteGroupMemberCommand = "v4/group_open_http_svc/delete_group_member"
|
||||
)
|
||||
|
||||
// RESTConfig contains Tencent Cloud Chat server-side REST API settings.
|
||||
@ -156,6 +158,27 @@ func (c *RESTClient) PublishRoomEvent(ctx context.Context, event RoomEvent) erro
|
||||
return response.err()
|
||||
}
|
||||
|
||||
// DeleteRoomGroupMember removes a room user from the Tencent Cloud Chat group.
|
||||
func (c *RESTClient) DeleteRoomGroupMember(ctx context.Context, roomID string, userID int64) error {
|
||||
roomID = strings.TrimSpace(roomID)
|
||||
if roomID == "" || userID <= 0 {
|
||||
return fmt.Errorf("room group member removal is incomplete")
|
||||
}
|
||||
|
||||
request := deleteGroupMemberRequest{
|
||||
GroupID: roomID,
|
||||
MemberToDelAccount: []string{FormatUserID(userID)},
|
||||
Silence: 1,
|
||||
}
|
||||
|
||||
var response restResponse
|
||||
if err := c.post(ctx, deleteGroupMemberCommand, request, &response); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.err()
|
||||
}
|
||||
|
||||
// FormatUserID converts the internal immutable user_id to the Tencent Chat identifier.
|
||||
func FormatUserID(userID int64) string {
|
||||
// Decimal user_id is stable across services and avoids exposing mutable display_user_id.
|
||||
@ -256,6 +279,13 @@ type sendGroupMsgRequest struct {
|
||||
FromAccount string `json:"From_Account,omitempty"`
|
||||
}
|
||||
|
||||
type deleteGroupMemberRequest struct {
|
||||
GroupID string `json:"GroupId"`
|
||||
MemberToDelAccount []string `json:"MemberToDel_Account"`
|
||||
Silence int `json:"Silence,omitempty"`
|
||||
Reason string `json:"Reason,omitempty"`
|
||||
}
|
||||
|
||||
type messageElement struct {
|
||||
MsgType string `json:"MsgType"`
|
||||
MsgContent customMsgContent `json:"MsgContent"`
|
||||
|
||||
@ -93,6 +93,35 @@ func TestRESTClientPublishRoomEventBuildsCustomMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRESTClientDeleteRoomGroupMemberBuildsTencentRequest 锁定踢出 IM 群成员的 REST 请求。
|
||||
func TestRESTClientDeleteRoomGroupMemberBuildsTencentRequest(t *testing.T) {
|
||||
var capturedPath string
|
||||
var capturedBody map[string]any
|
||||
client := newTestRESTClient(t, func(request *http.Request) (*http.Response, error) {
|
||||
capturedPath = request.URL.Path
|
||||
if err := json.NewDecoder(request.Body).Decode(&capturedBody); err != nil {
|
||||
t.Fatalf("decode request body failed: %v", err)
|
||||
}
|
||||
|
||||
return okRESTResponse(), nil
|
||||
})
|
||||
|
||||
if err := client.DeleteRoomGroupMember(context.Background(), "room-1001", 10002); err != nil {
|
||||
t.Fatalf("DeleteRoomGroupMember failed: %v", err)
|
||||
}
|
||||
|
||||
if capturedPath != "/"+deleteGroupMemberCommand {
|
||||
t.Fatalf("path mismatch: got %q", capturedPath)
|
||||
}
|
||||
members, ok := capturedBody["MemberToDel_Account"].([]any)
|
||||
if !ok || len(members) != 1 || members[0] != "10002" {
|
||||
t.Fatalf("unexpected member removal body: %+v", capturedBody)
|
||||
}
|
||||
if capturedBody["GroupId"] != "room-1001" || capturedBody["Silence"].(float64) != 1 {
|
||||
t.Fatalf("unexpected group removal body: %+v", capturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestRESTClient(t *testing.T, roundTrip roundTripFunc) *RESTClient {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@ -67,7 +67,7 @@ func GenerateToken(cfg TokenConfig, userID int64, targetRoomID string, now time.
|
||||
}
|
||||
if !roomid.ValidStringID(targetRoomID) {
|
||||
// This guard protects package callers; gateway maps this case to INVALID_ARGUMENT first.
|
||||
return TokenResult{}, fmt.Errorf("room_id is incompatible with tencent rtc")
|
||||
return TokenResult{}, fmt.Errorf("room_id is invalid for tencent rtc")
|
||||
}
|
||||
|
||||
rtcUserID := FormatUserID(userID)
|
||||
|
||||
@ -62,8 +62,7 @@ func TestGenerateTokenFailsClosed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerateTokenRejectsIncompatibleRoomID keeps historical dirty room IDs fail-closed.
|
||||
func TestGenerateTokenRejectsIncompatibleRoomID(t *testing.T) {
|
||||
func TestGenerateTokenRejectsInvalidRoomID(t *testing.T) {
|
||||
_, err := GenerateToken(TokenConfig{
|
||||
Enabled: true,
|
||||
SDKAppID: 1400000000,
|
||||
@ -73,6 +72,6 @@ func TestGenerateTokenRejectsIncompatibleRoomID(t *testing.T) {
|
||||
AppScene: AppSceneVoiceChatRoom,
|
||||
}, 10001, "room:1001", time.Unix(1_700_000_000, 0))
|
||||
if err == nil {
|
||||
t.Fatalf("GenerateToken should reject incompatible room_id")
|
||||
t.Fatalf("GenerateToken should reject invalid room_id")
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,17 +9,17 @@ PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
||||
# Docker Desktop and Linux Docker expose published ports differently. The
|
||||
# fallback keeps the output useful before containers are fully healthy or when
|
||||
# default keeps the output useful before containers are fully healthy or when
|
||||
# Docker is not running yet.
|
||||
published_port() {
|
||||
local service="$1"
|
||||
local container_port="$2"
|
||||
local fallback_port="$3"
|
||||
local default_port="$3"
|
||||
local published=""
|
||||
|
||||
published="$(docker compose port "${service}" "${container_port}" 2>/dev/null | head -n 1 || true)"
|
||||
if [[ -z "${published}" ]]; then
|
||||
printf '%s' "${fallback_port}"
|
||||
printf '%s' "${default_port}"
|
||||
return
|
||||
fi
|
||||
|
||||
@ -71,14 +71,14 @@ print_row() {
|
||||
local scheme="$3"
|
||||
local container_host="$4"
|
||||
local container_port="$5"
|
||||
local fallback_host_port="$6"
|
||||
local default_host_port="$6"
|
||||
local path="${7:-}"
|
||||
local host_port=""
|
||||
local local_addr=""
|
||||
local lan_addr=""
|
||||
local docker_addr=""
|
||||
|
||||
host_port="$(published_port "${service}" "${container_port}" "${fallback_host_port}")"
|
||||
host_port="$(published_port "${service}" "${container_port}" "${default_host_port}")"
|
||||
local_addr="$(format_address "${scheme}" "127.0.0.1" "${host_port}" "${path}")"
|
||||
lan_addr="$(format_address "${scheme}" "${LAN_IP}" "${host_port}" "${path}")"
|
||||
docker_addr="$(format_address "${scheme}" "${container_host}" "${container_port}" "${path}")"
|
||||
@ -97,7 +97,7 @@ print_row "room-service" "gRPC" "grpc" "room-service" "13001" "13001"
|
||||
print_row "wallet-service" "gRPC" "grpc" "wallet-service" "13004" "13004"
|
||||
print_row "user-service" "gRPC" "grpc" "user-service" "13005" "13005"
|
||||
print_row "activity-service" "gRPC" "grpc" "activity-service" "13006" "13006"
|
||||
print_row "mysql" "MySQL" "tcp" "mysql" "3306" "13306"
|
||||
print_row "mysql" "MySQL" "tcp" "mysql" "3306" "23306"
|
||||
print_row "redis" "Redis" "tcp" "redis" "6379" "13379"
|
||||
|
||||
printf '\nUse LOCAL from the host machine, HOST_LAN from another device on the same LAN, and DOCKER_NETWORK only between compose containers.\n'
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
service_name: activity-service
|
||||
node_id: activity-local
|
||||
grpc_addr: ":13006"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:13306)/hy_activity?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_activity?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
consumer:
|
||||
room_outbox_poll_interval_ms: 1000
|
||||
|
||||
@ -26,7 +26,7 @@ func Default() Config {
|
||||
ServiceName: "activity-service",
|
||||
NodeID: "activity-local",
|
||||
GRPCAddr: ":13006",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:13306)/hy_activity?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_activity?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLAutoMigrate: false,
|
||||
Consumer: ConsumerConfig{
|
||||
RoomOutboxPollIntervalMs: 1000,
|
||||
|
||||
@ -15,8 +15,14 @@ type RoomClient interface {
|
||||
MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.MicUpResponse, error)
|
||||
MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roomv1.MicDownResponse, error)
|
||||
ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRequest) (*roomv1.ChangeMicSeatResponse, error)
|
||||
ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error)
|
||||
SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error)
|
||||
SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error)
|
||||
SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error)
|
||||
TransferRoomHost(ctx context.Context, req *roomv1.TransferRoomHostRequest) (*roomv1.TransferRoomHostResponse, error)
|
||||
MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error)
|
||||
KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*roomv1.KickUserResponse, error)
|
||||
UnbanUser(ctx context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error)
|
||||
SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error)
|
||||
}
|
||||
|
||||
@ -73,6 +79,26 @@ func (c *grpcRoomClient) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMi
|
||||
return c.client.ChangeMicSeat(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomClient) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error) {
|
||||
return c.client.ConfirmMicPublishing(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomClient) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
|
||||
return c.client.SetMicSeatLock(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomClient) SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error) {
|
||||
return c.client.SetChatEnabled(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomClient) SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error) {
|
||||
return c.client.SetRoomAdmin(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomClient) TransferRoomHost(ctx context.Context, req *roomv1.TransferRoomHostRequest) (*roomv1.TransferRoomHostResponse, error) {
|
||||
return c.client.TransferRoomHost(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomClient) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) {
|
||||
return c.client.MuteUser(ctx, req)
|
||||
}
|
||||
@ -81,6 +107,10 @@ func (c *grpcRoomClient) KickUser(ctx context.Context, req *roomv1.KickUserReque
|
||||
return c.client.KickUser(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomClient) UnbanUser(ctx context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error) {
|
||||
return c.client.UnbanUser(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcRoomClient) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
|
||||
return c.client.SendGift(ctx, req)
|
||||
}
|
||||
|
||||
@ -75,7 +75,6 @@ func (h *Handler) loginThirdParty(writer http.ResponseWriter, request *http.Requ
|
||||
Avatar string `json:"avatar"`
|
||||
Birth string `json:"birth"`
|
||||
AppVersion string `json:"app_version"`
|
||||
AppVersionTypo string `json:"app_verison"`
|
||||
BuildNumber string `json:"build_number"`
|
||||
Source string `json:"source"`
|
||||
InstallChannel string `json:"install_channel"`
|
||||
@ -112,7 +111,7 @@ func (h *Handler) loginThirdParty(writer http.ResponseWriter, request *http.Requ
|
||||
OsVersion: body.OSVersion,
|
||||
Avatar: body.Avatar,
|
||||
Birth: body.Birth,
|
||||
AppVersion: firstNonEmptyString(body.AppVersion, body.AppVersionTypo),
|
||||
AppVersion: body.AppVersion,
|
||||
BuildNumber: body.BuildNumber,
|
||||
Source: body.Source,
|
||||
InstallChannel: body.InstallChannel,
|
||||
@ -235,17 +234,6 @@ func authRequestMeta(request *http.Request, deviceID string) *userv1.RequestMeta
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
// 只用于兼容 app_verison 拼写错误,进入 user-service 前仍使用规范 app_version 字段。
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// authData 把 user-service 的 token response 转成外部 HTTP 扁平 data。
|
||||
func authData(token *userv1.AuthToken, isNewUser *bool) authTokenData {
|
||||
if token == nil {
|
||||
|
||||
@ -55,8 +55,7 @@ func NewHandler(roomClient client.RoomClient, userClient ...client.UserAuthClien
|
||||
return handler
|
||||
}
|
||||
|
||||
// NewHandlerWithClients 装配完整 gateway HTTP handler。
|
||||
// 保留 NewHandler 是为了不破坏只测 room/auth transport 的旧测试构造。
|
||||
// NewHandlerWithClients 装配需要用户资料接口的 gateway HTTP handler。
|
||||
func NewHandlerWithClients(roomClient client.RoomClient, userClient client.UserAuthClient, userIdentityClient client.UserIdentityClient, userProfileClient ...client.UserProfileClient) *Handler {
|
||||
handler := &Handler{
|
||||
roomClient: roomClient,
|
||||
|
||||
@ -68,7 +68,6 @@ func decodeJSON(reader io.Reader, out any) error {
|
||||
}
|
||||
|
||||
// writeOK 写成功 envelope。
|
||||
// request 允许为空,兼容少量历史 helper 调用;正常业务路径应始终传入 request。
|
||||
func writeOK(writer http.ResponseWriter, request *http.Request, data any) {
|
||||
writeEnvelope(writer, http.StatusOK, responseEnvelope{
|
||||
Code: codeOK,
|
||||
@ -111,7 +110,7 @@ func mapReasonToHTTP(reason xerr.Code) (int, string, string) {
|
||||
return http.StatusUnauthorized, string(reason), "authentication failed"
|
||||
case xerr.Unauthorized, xerr.SessionExpired, xerr.SessionRevoked:
|
||||
return http.StatusUnauthorized, string(reason), "unauthorized"
|
||||
case xerr.PasswordAlreadySet, xerr.DisplayUserIDExists:
|
||||
case xerr.Conflict, xerr.PasswordAlreadySet, xerr.DisplayUserIDExists:
|
||||
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"
|
||||
@ -139,9 +138,5 @@ func writeEnvelope(writer http.ResponseWriter, statusCode int, response response
|
||||
|
||||
// responseRequestID 统一确定响应中的 request_id。
|
||||
func responseRequestID(request *http.Request) string {
|
||||
if request == nil {
|
||||
return idgen.New("req")
|
||||
}
|
||||
|
||||
return requestIDFromContext(request.Context())
|
||||
}
|
||||
|
||||
@ -26,8 +26,14 @@ type fakeRoomClient struct {
|
||||
lastMicUp *roomv1.MicUpRequest
|
||||
lastMicDown *roomv1.MicDownRequest
|
||||
lastChangeMicSeat *roomv1.ChangeMicSeatRequest
|
||||
lastConfirmMic *roomv1.ConfirmMicPublishingRequest
|
||||
lastMicSeatLock *roomv1.SetMicSeatLockRequest
|
||||
lastChatEnabled *roomv1.SetChatEnabledRequest
|
||||
lastSetAdmin *roomv1.SetRoomAdminRequest
|
||||
lastTransferHost *roomv1.TransferRoomHostRequest
|
||||
lastMute *roomv1.MuteUserRequest
|
||||
lastKick *roomv1.KickUserRequest
|
||||
lastUnban *roomv1.UnbanUserRequest
|
||||
lastGift *roomv1.SendGiftRequest
|
||||
createErr error
|
||||
}
|
||||
@ -68,19 +74,49 @@ func (f *fakeRoomClient) ChangeMicSeat(_ context.Context, req *roomv1.ChangeMicS
|
||||
return &roomv1.ChangeMicSeatResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 6}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) ConfirmMicPublishing(_ context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error) {
|
||||
f.lastConfirmMic = req
|
||||
return &roomv1.ConfirmMicPublishingResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) SetMicSeatLock(_ context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
|
||||
f.lastMicSeatLock = req
|
||||
return &roomv1.SetMicSeatLockResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) SetChatEnabled(_ context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error) {
|
||||
f.lastChatEnabled = req
|
||||
return &roomv1.SetChatEnabledResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) SetRoomAdmin(_ context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error) {
|
||||
f.lastSetAdmin = req
|
||||
return &roomv1.SetRoomAdminResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 9}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) TransferRoomHost(_ context.Context, req *roomv1.TransferRoomHostRequest) (*roomv1.TransferRoomHostResponse, error) {
|
||||
f.lastTransferHost = req
|
||||
return &roomv1.TransferRoomHostResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 10}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) MuteUser(_ context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) {
|
||||
f.lastMute = req
|
||||
return &roomv1.MuteUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 7}}, nil
|
||||
return &roomv1.MuteUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 11}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) KickUser(_ context.Context, req *roomv1.KickUserRequest) (*roomv1.KickUserResponse, error) {
|
||||
f.lastKick = req
|
||||
return &roomv1.KickUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 8}}, nil
|
||||
return &roomv1.KickUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 12}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) UnbanUser(_ context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error) {
|
||||
f.lastUnban = req
|
||||
return &roomv1.UnbanUserResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 13}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeRoomClient) SendGift(_ context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
|
||||
f.lastGift = req
|
||||
return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 9}}, nil
|
||||
return &roomv1.SendGiftResponse{Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14}}, nil
|
||||
}
|
||||
|
||||
type fakeUserAuthClient struct {
|
||||
@ -215,7 +251,7 @@ func (f *fakeRoomGuardClient) VerifyRoomPresence(_ context.Context, req *roomv1.
|
||||
func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
|
||||
client := &fakeRoomClient{}
|
||||
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"room_id":"room-1","owner_user_id":999,"host_user_id":1000,"seat_count":8,"mode":"voice"}`)
|
||||
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))
|
||||
request.Header.Set("X-Request-ID", "req-test")
|
||||
@ -243,9 +279,6 @@ func TestRoutesWriteUnifiedEnvelopeAndReuseRequestID(t *testing.T) {
|
||||
if client.lastCreate.GetMeta().GetActorUserId() != 42 {
|
||||
t.Fatalf("actor user mismatch: got %d", client.lastCreate.GetMeta().GetActorUserId())
|
||||
}
|
||||
if client.lastCreate.GetOwnerUserId() != 0 || client.lastCreate.GetHostUserId() != 0 {
|
||||
t.Fatalf("gateway must not pass body owner/host to room-service, got owner=%d host=%d", client.lastCreate.GetOwnerUserId(), client.lastCreate.GetHostUserId())
|
||||
}
|
||||
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())
|
||||
}
|
||||
@ -313,6 +346,51 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
return client.lastChangeMicSeat.GetMeta()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "confirm_mic_publishing",
|
||||
path: "/api/v1/rooms/mic/publishing/confirm",
|
||||
body: `{"room_id":"room-1","command_id":"cmd-confirm-mic-1","mic_session_id":"mic-1","room_version":4,"event_time_ms":1700000001000,"source":"client"}`,
|
||||
commandID: "cmd-confirm-mic-1",
|
||||
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
|
||||
return client.lastConfirmMic.GetMeta()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mic_lock",
|
||||
path: "/api/v1/rooms/mic/lock",
|
||||
body: `{"room_id":"room-1","command_id":"cmd-mic-lock-1","seat_no":2,"locked":true}`,
|
||||
commandID: "cmd-mic-lock-1",
|
||||
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
|
||||
return client.lastMicSeatLock.GetMeta()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chat_enabled",
|
||||
path: "/api/v1/rooms/chat/enabled",
|
||||
body: `{"room_id":"room-1","command_id":"cmd-chat-enabled-1","enabled":false}`,
|
||||
commandID: "cmd-chat-enabled-1",
|
||||
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
|
||||
return client.lastChatEnabled.GetMeta()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "set_admin",
|
||||
path: "/api/v1/rooms/admin/set",
|
||||
body: `{"room_id":"room-1","command_id":"cmd-admin-1","target_user_id":43,"enabled":true}`,
|
||||
commandID: "cmd-admin-1",
|
||||
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
|
||||
return client.lastSetAdmin.GetMeta()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "transfer_host",
|
||||
path: "/api/v1/rooms/host/transfer",
|
||||
body: `{"room_id":"room-1","command_id":"cmd-host-1","target_user_id":43}`,
|
||||
commandID: "cmd-host-1",
|
||||
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
|
||||
return client.lastTransferHost.GetMeta()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mute",
|
||||
path: "/api/v1/rooms/user/mute",
|
||||
@ -331,10 +409,19 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
return client.lastKick.GetMeta()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unban",
|
||||
path: "/api/v1/rooms/user/unban",
|
||||
body: `{"room_id":"room-1","command_id":"cmd-unban-1","target_user_id":43}`,
|
||||
commandID: "cmd-unban-1",
|
||||
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
|
||||
return client.lastUnban.GetMeta()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "gift",
|
||||
path: "/api/v1/rooms/gift/send",
|
||||
body: `{"room_id":"room-1","command_id":"cmd-gift-1","target_user_id":43,"gift_id":"rose","gift_count":1,"gift_unit_value":10}`,
|
||||
body: `{"room_id":"room-1","command_id":"cmd-gift-1","target_user_id":43,"gift_id":"rose","gift_count":1}`,
|
||||
commandID: "cmd-gift-1",
|
||||
extractMeta: func(client *fakeRoomClient) *roomv1.RequestMeta {
|
||||
return client.lastGift.GetMeta()
|
||||
@ -374,10 +461,34 @@ func TestRoomCommandsPropagateClientCommandID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmMicPublishingForwardsSessionVersionAndEventTime(t *testing.T) {
|
||||
client := &fakeRoomClient{}
|
||||
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
|
||||
body := []byte(`{"room_id":"room-1","command_id":"cmd-confirm","target_user_id":43,"mic_session_id":"mic-session-1","room_version":12,"event_time_ms":1700000001234,"source":"client"}`)
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/mic/publishing/confirm", bytes.NewReader(body))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-confirm-mic")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
assertEnvelope(t, recorder, http.StatusOK, codeOK, "req-confirm-mic")
|
||||
if client.lastConfirmMic == nil {
|
||||
t.Fatal("ConfirmMicPublishing request was not sent")
|
||||
}
|
||||
if client.lastConfirmMic.GetTargetUserId() != 43 ||
|
||||
client.lastConfirmMic.GetMicSessionId() != "mic-session-1" ||
|
||||
client.lastConfirmMic.GetRoomVersion() != 12 ||
|
||||
client.lastConfirmMic.GetEventTimeMs() != 1700000001234 ||
|
||||
client.lastConfirmMic.GetSource() != "client" {
|
||||
t.Fatalf("confirm fields mismatch: %+v", client.lastConfirmMic)
|
||||
}
|
||||
}
|
||||
|
||||
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","ip":"198.51.100.9","device_id":"dev-1","device":"iPhone 15","os_version":"iOS 18.1","avatar":"https://cdn.example/avatar.png","birth":"2000-01-02","app_verison":"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":"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"}`)
|
||||
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")
|
||||
@ -405,7 +516,7 @@ func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
|
||||
if req.GetMeta().GetRequestId() != "req-auth-third" || req.GetMeta().GetClientIp() != "203.0.113.10" || req.GetMeta().GetUserAgent() != "hyapp-test/1.0" || req.GetMeta().GetCountryByIp() != "SG" {
|
||||
t.Fatalf("auth meta was not propagated from gateway context: %+v", req.GetMeta())
|
||||
}
|
||||
if req.GetUsername() != "hy" || req.GetInviteCode() != "INV-1" || req.GetIp() != "" || req.GetDeviceId() != "dev-1" {
|
||||
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.GetDevice() != "iPhone 15" || req.GetOsVersion() != "iOS 18.1" || req.GetAvatar() == "" || req.GetBirth() != "2000-01-02" || req.GetAppVersion() != "1.2.3" || req.GetBuildNumber() != "123" {
|
||||
@ -619,6 +730,14 @@ func TestAuthLoginMapsGRPCReason(t *testing.T) {
|
||||
assertEnvelope(t, recorder, http.StatusUnauthorized, string(xerr.AuthFailed), "req-auth-failed")
|
||||
}
|
||||
|
||||
func TestMapReasonToHTTPMapsGenericConflict(t *testing.T) {
|
||||
// room-service 的麦位占用、幂等 payload 冲突等通用业务冲突必须稳定透出 409。
|
||||
statusCode, code, message := mapReasonToHTTP(xerr.Conflict)
|
||||
if statusCode != http.StatusConflict || code != string(xerr.Conflict) || message != "conflict" {
|
||||
t.Fatalf("generic conflict mapping mismatch: status=%d code=%s message=%s", statusCode, code, message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoutesWriteUnauthorizedEnvelope(t *testing.T) {
|
||||
router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{}`)))
|
||||
@ -642,7 +761,7 @@ func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) {
|
||||
assertEnvelope(t, recorder, http.StatusBadRequest, codeInvalidJSON, "req-json")
|
||||
}
|
||||
|
||||
func TestCreateRoomRejectsIncompatibleRoomIDBeforeGRPC(t *testing.T) {
|
||||
func TestCreateRoomRejectsInvalidRoomIDBeforeGRPC(t *testing.T) {
|
||||
client := &fakeRoomClient{}
|
||||
router := NewHandler(client).Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{"room_id":"room:1","seat_count":8,"mode":"voice"}`)))
|
||||
|
||||
@ -101,6 +101,7 @@ func (h *Handler) micDown(writer http.ResponseWriter, request *http.Request) {
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
if !decode(writer, request, &body) {
|
||||
@ -110,6 +111,7 @@ func (h *Handler) micDown(writer http.ResponseWriter, request *http.Request) {
|
||||
resp, err := h.roomClient.MicDown(request.Context(), &roomv1.MicDownRequest{
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
TargetUserId: body.TargetUserID,
|
||||
Reason: body.Reason,
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
@ -135,6 +137,113 @@ func (h *Handler) changeMicSeat(writer http.ResponseWriter, request *http.Reques
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// confirmMicPublishing 把客户端 SDK 发流成功回调转换为 room-service 发流确认命令。
|
||||
func (h *Handler) confirmMicPublishing(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
MicSessionID string `json:"mic_session_id"`
|
||||
RoomVersion int64 `json:"room_version"`
|
||||
EventTimeMS int64 `json:"event_time_ms"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
if !decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.roomClient.ConfirmMicPublishing(request.Context(), &roomv1.ConfirmMicPublishingRequest{
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
TargetUserId: body.TargetUserID,
|
||||
MicSessionId: body.MicSessionID,
|
||||
RoomVersion: body.RoomVersion,
|
||||
EventTimeMs: body.EventTimeMS,
|
||||
Source: body.Source,
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// setMicSeatLock 把锁麦 HTTP JSON 请求转换为 room-service SetMicSeatLock 命令。
|
||||
func (h *Handler) setMicSeatLock(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
SeatNo int32 `json:"seat_no"`
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
|
||||
if !decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.roomClient.SetMicSeatLock(request.Context(), &roomv1.SetMicSeatLockRequest{
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
SeatNo: body.SeatNo,
|
||||
Locked: body.Locked,
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// setChatEnabled 把公屏开关 HTTP JSON 请求转换为 room-service SetChatEnabled 命令。
|
||||
func (h *Handler) setChatEnabled(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
if !decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.roomClient.SetChatEnabled(request.Context(), &roomv1.SetChatEnabledRequest{
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
Enabled: body.Enabled,
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// setRoomAdmin 把管理员增删 HTTP JSON 请求转换为 room-service SetRoomAdmin 命令。
|
||||
func (h *Handler) setRoomAdmin(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
if !decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.roomClient.SetRoomAdmin(request.Context(), &roomv1.SetRoomAdminRequest{
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
TargetUserId: body.TargetUserID,
|
||||
Enabled: body.Enabled,
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// transferRoomHost 把主持转移 HTTP JSON 请求转换为 room-service TransferRoomHost 命令。
|
||||
func (h *Handler) transferRoomHost(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
}
|
||||
|
||||
if !decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.roomClient.TransferRoomHost(request.Context(), &roomv1.TransferRoomHostRequest{
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
TargetUserId: body.TargetUserID,
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// muteUser 把禁言 HTTP JSON 请求转换为 room-service MuteUser 命令。
|
||||
func (h *Handler) muteUser(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
@ -175,16 +284,34 @@ func (h *Handler) kickUser(writer http.ResponseWriter, request *http.Request) {
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// unbanUser 把解封 HTTP JSON 请求转换为 room-service UnbanUser 命令。
|
||||
func (h *Handler) unbanUser(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
}
|
||||
|
||||
if !decode(writer, request, &body) {
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.roomClient.UnbanUser(request.Context(), &roomv1.UnbanUserRequest{
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
TargetUserId: body.TargetUserID,
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
// sendGift 把送礼 HTTP JSON 请求转换为 room-service SendGift 命令。
|
||||
// 扣费、幂等、房间表现和 outbox 仍由 room-service 的命令链路统一处理。
|
||||
func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
var body struct {
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
GiftID string `json:"gift_id"`
|
||||
GiftCount int32 `json:"gift_count"`
|
||||
GiftUnitValue int64 `json:"gift_unit_value"`
|
||||
RoomID string `json:"room_id"`
|
||||
CommandID string `json:"command_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
GiftID string `json:"gift_id"`
|
||||
GiftCount int32 `json:"gift_count"`
|
||||
}
|
||||
|
||||
if !decode(writer, request, &body) {
|
||||
@ -192,11 +319,10 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
}
|
||||
|
||||
resp, err := h.roomClient.SendGift(request.Context(), &roomv1.SendGiftRequest{
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
TargetUserId: body.TargetUserID,
|
||||
GiftId: body.GiftID,
|
||||
GiftCount: body.GiftCount,
|
||||
GiftUnitValue: body.GiftUnitValue,
|
||||
Meta: meta(request, body.RoomID, body.CommandID),
|
||||
TargetUserId: body.TargetUserID,
|
||||
GiftId: body.GiftID,
|
||||
GiftCount: body.GiftCount,
|
||||
})
|
||||
write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
@ -36,8 +36,14 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
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))
|
||||
|
||||
return mux
|
||||
|
||||
@ -145,9 +145,6 @@ func (h *Handler) tencentIMSDKAppIDMatched(request *http.Request) bool {
|
||||
}
|
||||
|
||||
raw := strings.TrimSpace(request.URL.Query().Get("SdkAppid"))
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(request.URL.Query().Get("SDKAppID"))
|
||||
}
|
||||
value, err := strconv.ParseInt(raw, 10, 64)
|
||||
return err == nil && value == h.tencentIM.SDKAppID
|
||||
}
|
||||
|
||||
@ -23,6 +23,8 @@ redis_password: ""
|
||||
redis_db: 0
|
||||
presence_stale_after: "2m"
|
||||
presence_stale_scan_interval: "30s"
|
||||
mic_publish_timeout: "15s"
|
||||
mic_publish_scan_interval: "1s"
|
||||
outbox_worker:
|
||||
# Docker 本地默认启动补偿 worker,验证同步 IM 失败后的 room_outbox 补投闭环。
|
||||
enabled: true
|
||||
|
||||
@ -30,6 +30,8 @@ redis_password: "TENCENT_REDIS_PASSWORD"
|
||||
redis_db: 0
|
||||
presence_stale_after: "2m"
|
||||
presence_stale_scan_interval: "30s"
|
||||
mic_publish_timeout: "15s"
|
||||
mic_publish_scan_interval: "1s"
|
||||
outbox_worker:
|
||||
# 线上默认启动补偿 worker;腾讯云 IM 抖动时不回滚 Room Cell 状态。
|
||||
enabled: true
|
||||
|
||||
@ -17,7 +17,7 @@ tencent_im:
|
||||
group_type: "ChatRoom"
|
||||
# room-service 同步建群和发送房间系统消息的单次 REST 超时。
|
||||
request_timeout: "5s"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:13306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_max_open_conns: 20
|
||||
mysql_max_idle_conns: 10
|
||||
mysql_auto_migrate: true
|
||||
@ -26,6 +26,8 @@ redis_password: ""
|
||||
redis_db: 0
|
||||
presence_stale_after: "2m"
|
||||
presence_stale_scan_interval: "30s"
|
||||
mic_publish_timeout: "15s"
|
||||
mic_publish_scan_interval: "1s"
|
||||
outbox_worker:
|
||||
# room_outbox 是腾讯云 IM 系统消息补偿通道;失败保持 pending 并固定间隔重试。
|
||||
enabled: true
|
||||
|
||||
@ -116,6 +116,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
RankLimit: cfg.RankLimit,
|
||||
SnapshotEveryN: cfg.SnapshotEveryN,
|
||||
PresenceStaleAfter: cfg.PresenceStaleAfter,
|
||||
MicPublishTimeout: cfg.MicPublishTimeout,
|
||||
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher)
|
||||
|
||||
server := grpc.NewServer()
|
||||
@ -167,6 +168,14 @@ func (a *App) Run() error {
|
||||
a.service.RunPresenceStaleWorker(a.workerCtx, a.cfg.PresenceStaleScanInterval)
|
||||
}()
|
||||
}
|
||||
if a.cfg.MicPublishScanInterval > 0 {
|
||||
// MicUp 只代表业务占麦;后台 worker 负责释放未确认 RTC 发流的 pending_publish 麦位。
|
||||
a.workerWG.Add(1)
|
||||
go func() {
|
||||
defer a.workerWG.Done()
|
||||
a.service.RunMicPublishTimeoutWorker(a.workerCtx, a.cfg.MicPublishScanInterval)
|
||||
}()
|
||||
}
|
||||
if a.cfg.OutboxWorker.Enabled {
|
||||
a.workerWG.Add(1)
|
||||
go func() {
|
||||
|
||||
@ -45,6 +45,10 @@ type Config struct {
|
||||
PresenceStaleAfter time.Duration `yaml:"presence_stale_after"`
|
||||
// PresenceStaleScanInterval 是本节点扫描已装载 Room Cell 的周期。
|
||||
PresenceStaleScanInterval time.Duration `yaml:"presence_stale_scan_interval"`
|
||||
// MicPublishTimeout 是 MicUp 后等待客户端/RTC 发流确认的最长时间。
|
||||
MicPublishTimeout time.Duration `yaml:"mic_publish_timeout"`
|
||||
// MicPublishScanInterval 是本节点扫描 pending_publish 超时麦位的周期。
|
||||
MicPublishScanInterval time.Duration `yaml:"mic_publish_scan_interval"`
|
||||
// OutboxWorker 控制 room_outbox 到腾讯云 IM 补偿投递 worker。
|
||||
OutboxWorker OutboxWorkerConfig `yaml:"outbox_worker"`
|
||||
}
|
||||
@ -120,7 +124,7 @@ func Default() Config {
|
||||
GroupType: tencentim.DefaultGroupType,
|
||||
RequestTimeout: 5 * time.Second,
|
||||
},
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:13306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hyapp_room?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLMaxOpenConns: 20,
|
||||
MySQLMaxIdleConns: 10,
|
||||
MySQLAutoMigrate: true,
|
||||
@ -128,11 +132,14 @@ func Default() Config {
|
||||
RedisDB: 0,
|
||||
PresenceStaleAfter: 2 * time.Minute,
|
||||
PresenceStaleScanInterval: 30 * time.Second,
|
||||
MicPublishTimeout: 15 * time.Second,
|
||||
MicPublishScanInterval: time.Second,
|
||||
OutboxWorker: defaultOutboxWorkerConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
func defaultOutboxWorkerConfig() OutboxWorkerConfig {
|
||||
// 默认启动补偿 worker,保证同步腾讯云 IM 失败后 pending outbox 能自动重试。
|
||||
return OutboxWorkerConfig{
|
||||
Enabled: true,
|
||||
PollInterval: time.Second,
|
||||
@ -144,6 +151,7 @@ func defaultOutboxWorkerConfig() OutboxWorkerConfig {
|
||||
|
||||
// Normalize 补齐动态配置默认值,并拒绝会改变补偿语义的未知策略。
|
||||
func Normalize(cfg Config) (Config, error) {
|
||||
// 配置归一化只处理需要跨环境保持语义的字段,其他默认值由 Default 提供。
|
||||
outboxWorker, err := normalizeOutboxWorkerConfig(cfg.OutboxWorker)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
@ -154,6 +162,7 @@ func Normalize(cfg Config) (Config, error) {
|
||||
}
|
||||
|
||||
func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, error) {
|
||||
// Outbox worker 的所有时间和批量参数都必须有安全下限,避免配置缺省导致 busy loop。
|
||||
defaults := defaultOutboxWorkerConfig()
|
||||
if cfg.PollInterval <= 0 {
|
||||
cfg.PollInterval = defaults.PollInterval
|
||||
@ -169,6 +178,7 @@ func normalizeOutboxWorkerConfig(cfg OutboxWorkerConfig) (OutboxWorkerConfig, er
|
||||
cfg.RetryStrategy = defaults.RetryStrategy
|
||||
}
|
||||
if cfg.RetryStrategy != defaults.RetryStrategy {
|
||||
// V1 只实现 fixed_interval;未知策略直接启动失败,避免误以为退避或死信已生效。
|
||||
return OutboxWorkerConfig{}, fmt.Errorf("unsupported outbox_worker retry_strategy %q", cfg.RetryStrategy)
|
||||
}
|
||||
|
||||
|
||||
@ -50,6 +50,12 @@ func (p *TencentIMPublisher) PublishOutboxEvent(ctx context.Context, envelope *r
|
||||
if err != nil || !publish {
|
||||
return err
|
||||
}
|
||||
if envelope.GetEventType() == "RoomUserKicked" {
|
||||
// KickUser 的安全边界除了系统消息,还必须补偿移除腾讯云 IM 群成员。
|
||||
if err := p.client.DeleteRoomGroupMember(ctx, envelope.GetRoomId(), event.TargetUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return p.client.PublishRoomEvent(ctx, event)
|
||||
}
|
||||
@ -88,6 +94,7 @@ func (noopOutboxPublisher) PublishOutboxEvent(context.Context, *roomeventsv1.Eve
|
||||
}
|
||||
|
||||
func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.RoomEvent, bool, error) {
|
||||
// EventEnvelope 是 room_outbox 的稳定信封,补偿投递时再按 event_type 解出客户端字段。
|
||||
base := tencentim.RoomEvent{
|
||||
EventID: envelope.GetEventId(),
|
||||
RoomID: envelope.GetRoomId(),
|
||||
@ -97,6 +104,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
|
||||
switch envelope.GetEventType() {
|
||||
case "RoomUserJoined":
|
||||
// 进房事件需要带 role,客户端可用于展示进房身份,但权限仍以后续 snapshot 为准。
|
||||
var body roomeventsv1.RoomUserJoined
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
@ -106,6 +114,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
base.Attributes = map[string]string{"role": body.GetRole()}
|
||||
return base, true, nil
|
||||
case "RoomUserLeft":
|
||||
// 离房事件只表达业务 presence 移除,不声明腾讯云 IM 连接是否已断开。
|
||||
var body roomeventsv1.RoomUserLeft
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
@ -114,6 +123,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
base.TargetUserID = body.GetUserId()
|
||||
return base, true, nil
|
||||
case "RoomMicChanged":
|
||||
// 麦位变更把 from/to 合并成一个客户端事件,避免换麦出现中间空位状态。
|
||||
var body roomeventsv1.RoomMicChanged
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
@ -124,9 +134,59 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
if base.SeatNo == 0 {
|
||||
base.SeatNo = body.GetFromSeat()
|
||||
}
|
||||
base.Attributes = map[string]string{"action": body.GetAction()}
|
||||
base.Attributes = map[string]string{
|
||||
"action": body.GetAction(),
|
||||
"mic_session_id": body.GetMicSessionId(),
|
||||
"publish_state": body.GetPublishState(),
|
||||
"reason": body.GetReason(),
|
||||
"publish_deadline_ms": fmt.Sprintf("%d", body.GetPublishDeadlineMs()),
|
||||
"publish_event_time_ms": fmt.Sprintf("%d", body.GetPublishEventTimeMs()),
|
||||
}
|
||||
return base, true, nil
|
||||
case "RoomMicSeatLocked":
|
||||
// 锁麦事件只广播锁状态;首版锁麦不会自动下掉已占用用户。
|
||||
var body roomeventsv1.RoomMicSeatLocked
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
base.ActorUserID = body.GetActorUserId()
|
||||
base.SeatNo = body.GetSeatNo()
|
||||
base.Attributes = map[string]string{"locked": fmt.Sprintf("%t", body.GetLocked())}
|
||||
return base, true, nil
|
||||
case "RoomChatEnabledChanged":
|
||||
// 公屏开关事件提示客户端更新输入态,真正发言仍由 CheckSpeakPermission 拦截。
|
||||
var body roomeventsv1.RoomChatEnabledChanged
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
base.ActorUserID = body.GetActorUserId()
|
||||
base.Attributes = map[string]string{"enabled": fmt.Sprintf("%t", body.GetEnabled())}
|
||||
return base, true, nil
|
||||
case "RoomAdminChanged":
|
||||
// 管理员变更进入系统消息,客户端收到后仍应以最新 snapshot 修正本地权限 UI。
|
||||
var body roomeventsv1.RoomAdminChanged
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
base.ActorUserID = body.GetActorUserId()
|
||||
base.TargetUserID = body.GetTargetUserId()
|
||||
base.Attributes = map[string]string{"enabled": fmt.Sprintf("%t", body.GetEnabled())}
|
||||
return base, true, nil
|
||||
case "RoomHostTransferred":
|
||||
// 主持人转移同时携带新旧 host,便于客户端展示交接提示。
|
||||
var body roomeventsv1.RoomHostTransferred
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
base.ActorUserID = body.GetActorUserId()
|
||||
base.TargetUserID = body.GetNewHostUserId()
|
||||
base.Attributes = map[string]string{
|
||||
"old_host_user_id": fmt.Sprintf("%d", body.GetOldHostUserId()),
|
||||
"new_host_user_id": fmt.Sprintf("%d", body.GetNewHostUserId()),
|
||||
}
|
||||
return base, true, nil
|
||||
case "RoomUserMuted":
|
||||
// 禁言事件只影响公屏发言权限,不改变 presence、麦位或收消息资格。
|
||||
var body roomeventsv1.RoomUserMuted
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
@ -136,6 +196,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
base.Attributes = map[string]string{"muted": fmt.Sprintf("%t", body.GetMuted())}
|
||||
return base, true, nil
|
||||
case "RoomUserKicked":
|
||||
// KickUser 的即时系统消息和补偿踢群共用同一个 target_user_id。
|
||||
var body roomeventsv1.RoomUserKicked
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
@ -143,7 +204,17 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
base.ActorUserID = body.GetActorUserId()
|
||||
base.TargetUserID = body.GetTargetUserId()
|
||||
return base, true, nil
|
||||
case "RoomUserUnbanned":
|
||||
// 解封只恢复再次 JoinRoom 的资格,不代表目标已经回到房间 presence。
|
||||
var body roomeventsv1.RoomUserUnbanned
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
}
|
||||
base.ActorUserID = body.GetActorUserId()
|
||||
base.TargetUserID = body.GetTargetUserId()
|
||||
return base, true, nil
|
||||
case "RoomGiftSent":
|
||||
// 送礼客户端消息只用 GiftSent 主事件承载,Heat/Rank 变化作为字段随同一条消息展示。
|
||||
var body roomeventsv1.RoomGiftSent
|
||||
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
|
||||
return tencentim.RoomEvent{}, false, err
|
||||
@ -164,6 +235,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
|
||||
}
|
||||
|
||||
func eventTypeForClient(eventType string) string {
|
||||
// protobuf 事件名使用 PascalCase,客户端 TIMCustomElem 使用 snake_case 作为稳定协议字段。
|
||||
switch eventType {
|
||||
case "RoomUserJoined":
|
||||
return "room_user_joined"
|
||||
@ -171,10 +243,20 @@ func eventTypeForClient(eventType string) string {
|
||||
return "room_user_left"
|
||||
case "RoomMicChanged":
|
||||
return "room_mic_changed"
|
||||
case "RoomMicSeatLocked":
|
||||
return "room_mic_seat_locked"
|
||||
case "RoomChatEnabledChanged":
|
||||
return "room_chat_enabled_changed"
|
||||
case "RoomAdminChanged":
|
||||
return "room_admin_changed"
|
||||
case "RoomHostTransferred":
|
||||
return "room_host_transferred"
|
||||
case "RoomUserMuted":
|
||||
return "room_user_muted"
|
||||
case "RoomUserKicked":
|
||||
return "room_user_kicked"
|
||||
case "RoomUserUnbanned":
|
||||
return "room_user_unbanned"
|
||||
case "RoomGiftSent":
|
||||
return "room_gift_sent"
|
||||
default:
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
roomeventsv1 "hyapp/api/proto/events/room/v1"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/services/room-service/internal/room/outbox"
|
||||
)
|
||||
|
||||
@ -27,3 +32,46 @@ func TestRoomEventFromEnvelopeSkipsNonIMEvents(t *testing.T) {
|
||||
t.Fatalf("RoomCreated must not publish Tencent IM message: %+v", event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTencentIMPublisherRemovesGroupMemberBeforeKickMessage(t *testing.T) {
|
||||
paths := make([]string, 0, 2)
|
||||
client, err := tencentim.NewRESTClient(tencentim.RESTConfig{
|
||||
SDKAppID: 1400000000,
|
||||
SecretKey: "secret",
|
||||
AdminIdentifier: "administrator",
|
||||
AdminUserSigTTL: time.Hour,
|
||||
HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
|
||||
paths = append(paths, request.URL.Path)
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"ActionStatus":"OK","ErrorCode":0,"ErrorInfo":""}`))),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRESTClient failed: %v", err)
|
||||
}
|
||||
|
||||
record, err := outbox.Build("room-kick", "RoomUserKicked", 3, time.Now(), &roomeventsv1.RoomUserKicked{
|
||||
ActorUserId: 1,
|
||||
TargetUserId: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Build RoomUserKicked envelope failed: %v", err)
|
||||
}
|
||||
publisher := NewTencentIMPublisher(client)
|
||||
if err := publisher.PublishOutboxEvent(context.Background(), record.Envelope); err != nil {
|
||||
t.Fatalf("PublishOutboxEvent failed: %v", err)
|
||||
}
|
||||
|
||||
if len(paths) != 2 || paths[0] != "/v4/group_open_http_svc/delete_group_member" || paths[1] != "/v4/group_open_http_svc/send_group_msg" {
|
||||
t.Fatalf("kick outbox should delete member before system message, paths=%+v", paths)
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
||||
return f(request)
|
||||
}
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
// Package command 定义 room-service 可持久化、可回放的房间命令模型。
|
||||
package command
|
||||
|
||||
import "encoding/json"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Command 统一表达可写入 command log 的房间命令。
|
||||
type Command interface {
|
||||
@ -87,6 +90,10 @@ type MicUp struct {
|
||||
Base
|
||||
// SeatNo 是目标麦位编号,从 RoomState 初始化的麦位集合中查找。
|
||||
SeatNo int32 `json:"seat_no"`
|
||||
// MicSessionID 是服务端为本次上麦生成的发流会话 ID,确认和超时释放都必须匹配它。
|
||||
MicSessionID string `json:"mic_session_id"`
|
||||
// PublishDeadlineMS 是客户端必须确认 RTC 发流成功的截止时间。
|
||||
PublishDeadlineMS int64 `json:"publish_deadline_ms"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
@ -97,11 +104,33 @@ type MicDown struct {
|
||||
Base
|
||||
// TargetUserID 是被下麦用户;操作者和目标用户可以不同。
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
// MicSessionID 非空时只允许释放同一发流会话,防止旧超时任务清掉新上麦。
|
||||
MicSessionID string `json:"mic_session_id,omitempty"`
|
||||
// Reason 记录系统触发下麦原因,例如 publish_timeout;主动下麦保持为空。
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
func (MicDown) Type() string { return "mic_down" }
|
||||
|
||||
// ConfirmMicPublishing 定义 RTC 发流成功确认请求。
|
||||
type ConfirmMicPublishing struct {
|
||||
Base
|
||||
// TargetUserID 是被确认发布音频的用户;为空时由服务层使用 ActorUserID。
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
// MicSessionID 必须匹配当前麦位上的会话。
|
||||
MicSessionID string `json:"mic_session_id"`
|
||||
// RoomVersion 是触发客户端/RTC 事件时看到的房间版本,旧版本事件会被忽略。
|
||||
RoomVersion int64 `json:"room_version"`
|
||||
// EventTimeMS 是客户端 SDK 或 RTC webhook 的事件时间,必须晚于已接受事件。
|
||||
EventTimeMS int64 `json:"event_time_ms"`
|
||||
// Source 记录确认来源,例如 client 或 rtc_webhook。
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
func (ConfirmMicPublishing) Type() string { return "confirm_mic_publishing" }
|
||||
|
||||
// ChangeMicSeat 定义换麦位请求。
|
||||
type ChangeMicSeat struct {
|
||||
Base
|
||||
@ -114,6 +143,50 @@ type ChangeMicSeat struct {
|
||||
// Type 返回命令类型。
|
||||
func (ChangeMicSeat) Type() string { return "change_mic_seat" }
|
||||
|
||||
// SetMicSeatLock 定义锁定或解锁麦位请求。
|
||||
type SetMicSeatLock struct {
|
||||
Base
|
||||
// SeatNo 是目标麦位编号。
|
||||
SeatNo int32 `json:"seat_no"`
|
||||
// Locked 为 true 表示锁定,false 表示解锁。
|
||||
Locked bool `json:"locked"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
func (SetMicSeatLock) Type() string { return "set_mic_seat_lock" }
|
||||
|
||||
// SetChatEnabled 定义房间公屏开关请求。
|
||||
type SetChatEnabled struct {
|
||||
Base
|
||||
// Enabled 为 false 时 CheckSpeakPermission 会拒绝所有普通公屏发言。
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
func (SetChatEnabled) Type() string { return "set_chat_enabled" }
|
||||
|
||||
// SetRoomAdmin 定义添加或移除管理员请求。
|
||||
type SetRoomAdmin struct {
|
||||
Base
|
||||
// TargetUserID 是被添加或移除管理员身份的用户。
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
// Enabled 为 true 表示添加管理员,false 表示移除管理员。
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
func (SetRoomAdmin) Type() string { return "set_room_admin" }
|
||||
|
||||
// TransferRoomHost 定义主持人转移请求。
|
||||
type TransferRoomHost struct {
|
||||
Base
|
||||
// TargetUserID 是新的主持人,必须在当前房间 presence 中。
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
func (TransferRoomHost) Type() string { return "transfer_room_host" }
|
||||
|
||||
// MuteUser 定义禁言请求。
|
||||
type MuteUser struct {
|
||||
Base
|
||||
@ -136,6 +209,16 @@ type KickUser struct {
|
||||
// Type 返回命令类型。
|
||||
func (KickUser) Type() string { return "kick_user" }
|
||||
|
||||
// UnbanUser 定义解除房间 ban 请求。
|
||||
type UnbanUser struct {
|
||||
Base
|
||||
// TargetUserID 是被解除 ban 的用户。
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
func (UnbanUser) Type() string { return "unban_user" }
|
||||
|
||||
// SendGift 定义送礼请求。
|
||||
type SendGift struct {
|
||||
Base
|
||||
@ -145,8 +228,16 @@ type SendGift struct {
|
||||
GiftID string `json:"gift_id"`
|
||||
// GiftCount 是礼物数量,必须为正数。
|
||||
GiftCount int32 `json:"gift_count"`
|
||||
// GiftUnitValue 是单个礼物价值,必须为正数,账务扣费由 wallet-service 执行。
|
||||
GiftUnitValue int64 `json:"gift_unit_value"`
|
||||
// BillingReceiptID 是 wallet-service 成功落账后的回执,用于排障和恢复审计。
|
||||
BillingReceiptID string `json:"billing_receipt_id,omitempty"`
|
||||
// CoinSpent 是 sender COIN 实际扣减值,来自 wallet-service 服务端价格。
|
||||
CoinSpent int64 `json:"coin_spent,omitempty"`
|
||||
// GiftPointAdded 是 target GIFT_POINT 实际入账值,来自 wallet-service。
|
||||
GiftPointAdded int64 `json:"gift_point_added,omitempty"`
|
||||
// HeatValue 是 Room Cell 恢复时唯一可信的热度增量,不能重新用客户端价格推导。
|
||||
HeatValue int64 `json:"heat_value,omitempty"`
|
||||
// PriceVersion 是本次钱包结算使用的服务端礼物价格版本。
|
||||
PriceVersion string `json:"price_version,omitempty"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
@ -158,11 +249,42 @@ func Serialize(cmd Command) ([]byte, error) {
|
||||
return json.Marshal(cmd)
|
||||
}
|
||||
|
||||
// IdempotencyPayloadForCommand 生成用于 command_id 冲突判定的业务载荷。
|
||||
func IdempotencyPayloadForCommand(cmd Command) ([]byte, error) {
|
||||
payload, err := Serialize(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return IdempotencyPayload(payload)
|
||||
}
|
||||
|
||||
// IdempotencyPayload 去掉请求追踪和入口节点等易变字段,只保留业务命令语义。
|
||||
func IdempotencyPayload(payload []byte) ([]byte, error) {
|
||||
var values map[string]any
|
||||
if err := json.Unmarshal(payload, &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delete(values, "request_id")
|
||||
delete(values, "gateway_node_id")
|
||||
delete(values, "session_id")
|
||||
delete(values, "sent_at_ms")
|
||||
// SendGift 的以下字段由 wallet-service 结算后写入 command log,不属于客户端请求幂等语义。
|
||||
delete(values, "billing_receipt_id")
|
||||
delete(values, "coin_spent")
|
||||
delete(values, "gift_point_added")
|
||||
delete(values, "heat_value")
|
||||
delete(values, "price_version")
|
||||
// MicUp 的发流确认 deadline 由 room-service 生成,客户端重试同一 command_id 时不能因此冲突。
|
||||
delete(values, "publish_deadline_ms")
|
||||
|
||||
return json.Marshal(values)
|
||||
}
|
||||
|
||||
// Deserialize 按命令类型恢复命令载荷。
|
||||
func Deserialize(commandType string, payload []byte) (Command, error) {
|
||||
var cmd Command
|
||||
|
||||
// command_type 是恢复时的分发键,未知命令返回 nil 让恢复流程安全跳过。
|
||||
// command_type 是恢复时的分发键;未知类型表示当前代码无法保证恢复语义。
|
||||
switch commandType {
|
||||
case CreateRoom{}.Type():
|
||||
cmd = &CreateRoom{}
|
||||
@ -174,16 +296,28 @@ func Deserialize(commandType string, payload []byte) (Command, error) {
|
||||
cmd = &MicUp{}
|
||||
case MicDown{}.Type():
|
||||
cmd = &MicDown{}
|
||||
case ConfirmMicPublishing{}.Type():
|
||||
cmd = &ConfirmMicPublishing{}
|
||||
case ChangeMicSeat{}.Type():
|
||||
cmd = &ChangeMicSeat{}
|
||||
case SetMicSeatLock{}.Type():
|
||||
cmd = &SetMicSeatLock{}
|
||||
case SetChatEnabled{}.Type():
|
||||
cmd = &SetChatEnabled{}
|
||||
case SetRoomAdmin{}.Type():
|
||||
cmd = &SetRoomAdmin{}
|
||||
case TransferRoomHost{}.Type():
|
||||
cmd = &TransferRoomHost{}
|
||||
case MuteUser{}.Type():
|
||||
cmd = &MuteUser{}
|
||||
case KickUser{}.Type():
|
||||
cmd = &KickUser{}
|
||||
case UnbanUser{}.Type():
|
||||
cmd = &UnbanUser{}
|
||||
case SendGift{}.Type():
|
||||
cmd = &SendGift{}
|
||||
default:
|
||||
return nil, nil
|
||||
return nil, fmt.Errorf("unknown room command type: %s", commandType)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(payload, cmd); err != nil {
|
||||
|
||||
117
services/room-service/internal/room/service/authorization.go
Normal file
117
services/room-service/internal/room/service/authorization.go
Normal file
@ -0,0 +1,117 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
|
||||
// roomRole 是权限矩阵内部使用的收敛角色,不直接信任客户端 JoinRoom.role。
|
||||
type roomRole string
|
||||
|
||||
const (
|
||||
// roleOwner 来自 RoomState.OwnerUserID,拥有房间内最高管理权限。
|
||||
roleOwner roomRole = "owner"
|
||||
// roleHost 来自 RoomState.HostUserID,负责现场管理但不能越过 owner。
|
||||
roleHost roomRole = "host"
|
||||
// roleAdmin 来自 RoomState.AdminUsers,权限低于 owner/host。
|
||||
roleAdmin roomRole = "admin"
|
||||
// roleAudience 是默认兜底角色,包含未登录、未在房间和普通观众。
|
||||
roleAudience roomRole = "audience"
|
||||
)
|
||||
|
||||
func actorRole(current *state.RoomState, actorUserID int64) roomRole {
|
||||
// 角色权威顺序按文档固定:owner > host > admin > audience,不能只看 RoomUser.role 展示字段。
|
||||
switch {
|
||||
case current == nil || actorUserID <= 0:
|
||||
return roleAudience
|
||||
case actorUserID == current.OwnerUserID:
|
||||
return roleOwner
|
||||
case actorUserID == current.HostUserID:
|
||||
return roleHost
|
||||
case current.AdminUsers[actorUserID]:
|
||||
return roleAdmin
|
||||
default:
|
||||
return roleAudience
|
||||
}
|
||||
}
|
||||
|
||||
func requireActiveRoom(current *state.RoomState) error {
|
||||
// 当前只有 active 房间可执行管理和麦位命令;CloseRoom 语义后续单独扩展。
|
||||
if current == nil || current.Status != "active" {
|
||||
return xerr.New(xerr.Conflict, "room is not active")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireActorPresent(current *state.RoomState, actorUserID int64) error {
|
||||
// 管理动作必须来自房间业务 presence;离房后的 host/admin 不能远程管理房间。
|
||||
if _, exists := current.OnlineUsers[actorUserID]; !exists {
|
||||
return xerr.New(xerr.PermissionDenied, "permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireManagerPresent(current *state.RoomState, actorUserID int64) error {
|
||||
// 先校验 presence,再校验角色,避免房间外用户凭持久 admin 身份直接操作。
|
||||
if err := requireActorPresent(current, actorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if !isManagerRole(actorRole(current, actorUserID)) {
|
||||
return xerr.New(xerr.PermissionDenied, "permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isManagerRole(role roomRole) bool {
|
||||
// 管理角色集合只包含 owner/host/admin,普通观众只能做自助动作。
|
||||
return role == roleOwner || role == roleHost || role == roleAdmin
|
||||
}
|
||||
|
||||
func canManageTarget(current *state.RoomState, actorUserID int64, targetUserID int64) error {
|
||||
// 目标用户同样按权威状态判定角色,防止客户端伪造 role 绕过管理层级。
|
||||
actor := actorRole(current, actorUserID)
|
||||
target := actorRole(current, targetUserID)
|
||||
if !isManagerRole(actor) {
|
||||
return xerr.New(xerr.PermissionDenied, "permission denied")
|
||||
}
|
||||
if target == roleOwner && actor != roleOwner {
|
||||
// host/admin 不能管理 owner,owner 自操作由具体命令的 self check 再收紧。
|
||||
return xerr.New(xerr.PermissionDenied, "permission denied")
|
||||
}
|
||||
if target == roleHost && actor == roleAdmin {
|
||||
// 普通 admin 不能管理 host;host 变更必须走 owner 的 TransferRoomHost。
|
||||
return xerr.New(xerr.PermissionDenied, "permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func canSelfOrManageTarget(current *state.RoomState, actorUserID int64, targetUserID int64) error {
|
||||
// 自助动作例如自己下麦允许 audience 执行,代操作才进入管理权限矩阵。
|
||||
if actorUserID == targetUserID {
|
||||
return nil
|
||||
}
|
||||
return canManageTarget(current, actorUserID, targetUserID)
|
||||
}
|
||||
|
||||
func requireOwnerPresent(current *state.RoomState, actorUserID int64) error {
|
||||
// owner 专属命令仍要求 owner 当前在房间业务 presence 内。
|
||||
if err := requireActorPresent(current, actorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
if actorRole(current, actorUserID) != roleOwner {
|
||||
return xerr.New(xerr.PermissionDenied, "permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireOwnerOrHostPresent(current *state.RoomState, actorUserID int64) error {
|
||||
// 解封等高风险操作限制在 owner/host,普通 admin 不能恢复被 ban 用户。
|
||||
if err := requireActorPresent(current, actorUserID); err != nil {
|
||||
return err
|
||||
}
|
||||
role := actorRole(current, actorUserID)
|
||||
if role != roleOwner && role != roleHost {
|
||||
return xerr.New(xerr.PermissionDenied, "permission denied")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -24,34 +24,26 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
}
|
||||
if !roomid.ValidStringID(req.GetMeta().GetRoomId()) {
|
||||
// 新房间必须能同时映射到腾讯云 IM GroupID 和 RTC strRoomId,避免创建后无法进语音。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room_id format is incompatible")
|
||||
return nil, xerr.New(xerr.InvalidArgument, "room_id format is invalid")
|
||||
}
|
||||
mode := strings.TrimSpace(req.GetMode())
|
||||
if mode == "" {
|
||||
// mode 是房间状态对客户端和后续策略分发的基础维度,不能创建空模式房。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "mode is required")
|
||||
}
|
||||
|
||||
// protobuf 请求先收敛为 command,后续幂等、持久化和恢复都只认识 command 模型。
|
||||
cmd := command.CreateRoom{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
OwnerUserID: req.GetOwnerUserId(),
|
||||
HostUserID: req.GetHostUserId(),
|
||||
SeatCount: req.GetSeatCount(),
|
||||
Mode: mode,
|
||||
}
|
||||
actorUserID := cmd.ActorUserID()
|
||||
actorUserID := req.GetMeta().GetActorUserId()
|
||||
if actorUserID == 0 {
|
||||
// CreateRoom 的 owner/host 默认来自已鉴权调用方,不允许匿名内部命令造房间。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "actor_user_id is required")
|
||||
}
|
||||
if cmd.OwnerUserID == 0 {
|
||||
// gateway 不需要理解房主分配语义;缺省 owner 由 room-service 从 actor 收敛。
|
||||
cmd.OwnerUserID = actorUserID
|
||||
}
|
||||
if cmd.HostUserID == 0 {
|
||||
// v1 创建房间默认房主即主持,后续如需转主持应走独立房间管理命令。
|
||||
cmd.HostUserID = cmd.OwnerUserID
|
||||
|
||||
// protobuf 请求先收敛为 command,后续幂等、持久化和恢复都只认识 command 模型。
|
||||
cmd := command.CreateRoom{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
OwnerUserID: actorUserID,
|
||||
HostUserID: actorUserID,
|
||||
SeatCount: req.GetSeatCount(),
|
||||
Mode: mode,
|
||||
}
|
||||
|
||||
if cmd.SeatCount <= 0 {
|
||||
@ -59,18 +51,11 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
return nil, xerr.New(xerr.InvalidArgument, "seat_count must be positive")
|
||||
}
|
||||
|
||||
if cmd.OwnerUserID == 0 || cmd.HostUserID == 0 {
|
||||
// owner/host 是默认管理员集合来源,不能缺失。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "owner and host are required")
|
||||
}
|
||||
if cmd.OwnerUserID != actorUserID || cmd.HostUserID != actorUserID {
|
||||
// 外部创建路径不能代替其他用户开房,显式内部字段也不能偏离 actor。
|
||||
return nil, xerr.New(xerr.PermissionDenied, "owner and host must match actor")
|
||||
}
|
||||
|
||||
if seen, err := s.repository.HasCommand(ctx, cmd.RoomID(), cmd.ID()); err != nil {
|
||||
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
} else if seen {
|
||||
}
|
||||
if seen {
|
||||
// CreateRoom 幂等重试也要确保腾讯云 IM 群组存在,避免上次创建在外部桥接处失败。
|
||||
if err := s.syncPublisher.EnsureRoomGroup(ctx, cmd.RoomID(), cmd.OwnerUserID); err != nil {
|
||||
return nil, err
|
||||
@ -122,11 +107,6 @@ func (s *Service) CreateRoom(ctx context.Context, req *roomv1.CreateRoomRequest)
|
||||
LastSeenAtMS: now.UnixMilli(),
|
||||
}
|
||||
|
||||
payload, err := command.Serialize(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// RoomCreated 进入 outbox,供 activity/audit 等房间外系统异步消费。
|
||||
createdEvent, err := outbox.Build(cmd.RoomID(), "RoomCreated", roomState.Version, now, &roomeventsv1.RoomCreated{
|
||||
OwnerUserId: cmd.OwnerUserID,
|
||||
|
||||
16
services/room-service/internal/room/service/event_attrs.go
Normal file
16
services/room-service/internal/room/service/event_attrs.go
Normal file
@ -0,0 +1,16 @@
|
||||
package service
|
||||
|
||||
import "strconv"
|
||||
|
||||
func boolString(value bool) string {
|
||||
// 腾讯云 IM 自定义消息 attributes 只放字符串,bool 在这里统一转成小写字面量。
|
||||
if value {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func int64String(value int64) string {
|
||||
// 用户 ID 等数值进入 attributes 时保持十进制字符串,和 IM identifier 映射一致。
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
@ -20,11 +20,10 @@ import (
|
||||
func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
|
||||
// SendGift 的账务不在 room-service 内结算,但房间表现、热度和榜单由 Room Cell 同步更新。
|
||||
cmd := command.SendGift{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
GiftID: req.GetGiftId(),
|
||||
GiftCount: req.GetGiftCount(),
|
||||
GiftUnitValue: req.GetGiftUnitValue(),
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
GiftID: req.GetGiftId(),
|
||||
GiftCount: req.GetGiftCount(),
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, localRank *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
@ -38,28 +37,41 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not in room")
|
||||
}
|
||||
|
||||
if cmd.GiftCount <= 0 || cmd.GiftUnitValue <= 0 {
|
||||
// 非正数礼物会破坏账务和榜单累计语义。
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "gift_count and gift_unit_value must be positive")
|
||||
if cmd.GiftID == "" || cmd.GiftCount <= 0 {
|
||||
// 礼物 ID 和数量是钱包查服务端价格的最小输入,客户端不再提交礼物单价。
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "gift_id and gift_count must be valid")
|
||||
}
|
||||
|
||||
// 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。
|
||||
billing, err := s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{
|
||||
CommandId: cmd.ID(),
|
||||
RoomId: cmd.RoomID(),
|
||||
SenderUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
GiftId: cmd.GiftID,
|
||||
GiftCount: cmd.GiftCount,
|
||||
GiftUnitValue: cmd.GiftUnitValue,
|
||||
CommandId: cmd.ID(),
|
||||
RoomId: cmd.RoomID(),
|
||||
SenderUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
GiftId: cmd.GiftID,
|
||||
GiftCount: cmd.GiftCount,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if billing == nil {
|
||||
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "wallet debit response is empty")
|
||||
}
|
||||
heatValue := billing.GetHeatValue()
|
||||
settledCommand := cmd
|
||||
settledCommand.BillingReceiptID = billing.GetBillingReceiptId()
|
||||
settledCommand.CoinSpent = billing.GetCoinSpent()
|
||||
settledCommand.GiftPointAdded = billing.GetGiftPointAdded()
|
||||
settledCommand.HeatValue = heatValue
|
||||
settledCommand.PriceVersion = billing.GetPriceVersion()
|
||||
commandPayload, err := command.Serialize(settledCommand)
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
// 扣费成功后,Room Cell 同步更新热度和本地礼物榜。
|
||||
current.Heat += billing.GetTotalGiftValue()
|
||||
current.GiftRank = localRank.ApplyGift(cmd.ActorUserID(), billing.GetTotalGiftValue(), now)
|
||||
current.Heat += heatValue
|
||||
current.GiftRank = localRank.ApplyGift(cmd.ActorUserID(), heatValue, now)
|
||||
current.Version++
|
||||
|
||||
// 送礼事件、热度事件和榜单事件使用同一个房间版本,方便消费者关联同一次命令。
|
||||
@ -68,7 +80,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
GiftId: cmd.GiftID,
|
||||
GiftCount: cmd.GiftCount,
|
||||
GiftValue: billing.GetTotalGiftValue(),
|
||||
GiftValue: heatValue,
|
||||
BillingReceiptId: billing.GetBillingReceiptId(),
|
||||
})
|
||||
if err != nil {
|
||||
@ -76,7 +88,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
}
|
||||
|
||||
heatEvent, err := outbox.Build(current.RoomID, "RoomHeatChanged", current.Version, now, &roomeventsv1.RoomHeatChanged{
|
||||
Delta: billing.GetTotalGiftValue(),
|
||||
Delta: heatValue,
|
||||
CurrentHeat: current.Heat,
|
||||
})
|
||||
if err != nil {
|
||||
@ -98,6 +110,7 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
billingReceiptID: billing.GetBillingReceiptId(),
|
||||
roomHeat: current.Heat,
|
||||
giftRank: cloneProtoRank(current.GiftRank),
|
||||
commandPayload: commandPayload,
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
// 同步广播只选 GiftSent 主事件作为客户端房间系统消息,Heat/Rank 通过字段携带。
|
||||
EventID: giftEvent.EventID,
|
||||
@ -105,13 +118,16 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
EventType: "room_gift_sent",
|
||||
ActorUserID: cmd.ActorUserID(),
|
||||
TargetUserID: cmd.TargetUserID,
|
||||
GiftValue: billing.GetTotalGiftValue(),
|
||||
GiftValue: heatValue,
|
||||
RoomHeat: current.Heat,
|
||||
RoomVersion: current.Version,
|
||||
Attributes: map[string]string{
|
||||
"gift_id": cmd.GiftID,
|
||||
"gift_count": fmt.Sprintf("%d", cmd.GiftCount),
|
||||
"billing_receipt_id": billing.GetBillingReceiptId(),
|
||||
"coin_spent": fmt.Sprintf("%d", settledCommand.CoinSpent),
|
||||
"gift_point_added": fmt.Sprintf("%d", settledCommand.GiftPointAdded),
|
||||
"price_version": settledCommand.PriceVersion,
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{giftEvent, heatEvent, rankEvent}, nil
|
||||
|
||||
288
services/room-service/internal/room/service/management.go
Normal file
288
services/room-service/internal/room/service/management.go
Normal file
@ -0,0 +1,288 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
roomeventsv1 "hyapp/api/proto/events/room/v1"
|
||||
roomv1 "hyapp/api/proto/room/v1"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"hyapp/services/room-service/internal/room/outbox"
|
||||
"hyapp/services/room-service/internal/room/rank"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
|
||||
// SetMicSeatLock 锁定或解锁指定麦位;首版只允许锁空麦位。
|
||||
func (s *Service) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
|
||||
// 锁麦是房间管理命令,客户端不能通过麦位字段直接绕过 Room Cell 权限矩阵。
|
||||
cmd := command.SetMicSeatLock{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
SeatNo: req.GetSeatNo(),
|
||||
Locked: req.GetLocked(),
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if err := requireActiveRoom(current); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if err := requireManagerPresent(current, cmd.ActorUserID()); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
index := current.SeatIndex(cmd.SeatNo)
|
||||
if index < 0 {
|
||||
// 只能锁定房间初始化时存在的麦位编号。
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "seat does not exist")
|
||||
}
|
||||
if cmd.Locked && current.MicSeats[index].UserID != 0 {
|
||||
// 首版不把“下麦”和“锁麦”合并成一个命令,避免客户端处理复合事件。
|
||||
return mutationResult{}, nil, xerr.New(xerr.Conflict, "seat is occupied")
|
||||
}
|
||||
if current.MicSeats[index].Locked == cmd.Locked {
|
||||
// 重复锁定或重复解锁是 no-op,只写 command log 做幂等保护,不广播事件。
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
|
||||
// 锁状态属于 Room Cell 核心态,必须和版本递增一起提交。
|
||||
current.MicSeats[index].Locked = cmd.Locked
|
||||
current.Version++
|
||||
|
||||
// 锁麦事件进入 outbox,客户端通过系统消息修正麦位 UI。
|
||||
lockEvent, err := outbox.Build(current.RoomID, "RoomMicSeatLocked", current.Version, now, &roomeventsv1.RoomMicSeatLocked{
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
SeatNo: cmd.SeatNo,
|
||||
Locked: cmd.Locked,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: lockEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
EventType: "room_mic_seat_locked",
|
||||
ActorUserID: cmd.ActorUserID(),
|
||||
SeatNo: cmd.SeatNo,
|
||||
RoomVersion: current.Version,
|
||||
Attributes: map[string]string{
|
||||
"locked": boolString(cmd.Locked),
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{lockEvent}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &roomv1.SetMicSeatLockResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
Room: result.snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetChatEnabled 开启或关闭公屏发言守卫。
|
||||
func (s *Service) SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error) {
|
||||
// 公屏开关只影响普通客户端发言守卫,不阻断 room-service 发送系统消息。
|
||||
cmd := command.SetChatEnabled{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
Enabled: req.GetEnabled(),
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if err := requireActiveRoom(current); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if err := requireManagerPresent(current, cmd.ActorUserID()); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if current.ChatEnabled == cmd.Enabled {
|
||||
// 重复设置同一个开关值不产生事件,避免客户端收到无意义的状态变更。
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
|
||||
// CheckSpeakPermission 后续会以该字段作为 chat_disabled 的权威来源。
|
||||
current.ChatEnabled = cmd.Enabled
|
||||
current.Version++
|
||||
|
||||
// 公屏开关变化需要进入 outbox,便于 IM 系统消息和审计消费。
|
||||
chatEvent, err := outbox.Build(current.RoomID, "RoomChatEnabledChanged", current.Version, now, &roomeventsv1.RoomChatEnabledChanged{
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
Enabled: cmd.Enabled,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: chatEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
EventType: "room_chat_enabled_changed",
|
||||
ActorUserID: cmd.ActorUserID(),
|
||||
RoomVersion: current.Version,
|
||||
Attributes: map[string]string{
|
||||
"enabled": boolString(cmd.Enabled),
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{chatEvent}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &roomv1.SetChatEnabledResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
Room: result.snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetRoomAdmin 添加或移除房间管理员;只有 owner 可以执行。
|
||||
func (s *Service) SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error) {
|
||||
// 管理员身份是 RoomState 持久态,离房后保留,重新进房后继续生效。
|
||||
cmd := command.SetRoomAdmin{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
Enabled: req.GetEnabled(),
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if err := requireActiveRoom(current); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if cmd.TargetUserID <= 0 {
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
|
||||
}
|
||||
if err := requireOwnerPresent(current, cmd.ActorUserID()); err != nil {
|
||||
// 只有 owner 可以增删 admin,host/admin 不能扩张自己的权限。
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if _, exists := current.OnlineUsers[cmd.TargetUserID]; !exists {
|
||||
// 首版不支持预设管理员,避免把不存在或已离房用户加入管理集合。
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not in room")
|
||||
}
|
||||
if !cmd.Enabled && (cmd.TargetUserID == current.OwnerUserID || cmd.TargetUserID == current.HostUserID) {
|
||||
// owner/host 的管理身份由专门字段决定,不能通过 admin 集合移除。
|
||||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "permission denied")
|
||||
}
|
||||
if current.AdminUsers[cmd.TargetUserID] == cmd.Enabled {
|
||||
// 重复添加或重复移除是 no-op,避免重复发送 RoomAdminChanged。
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
|
||||
if cmd.Enabled {
|
||||
// map set 是管理员集合的恢复来源,快照会导出为 admin_user_ids。
|
||||
current.AdminUsers[cmd.TargetUserID] = true
|
||||
} else {
|
||||
delete(current.AdminUsers, cmd.TargetUserID)
|
||||
}
|
||||
current.Version++
|
||||
|
||||
// 管理员变更事件同时服务客户端提示和后续房间审计。
|
||||
adminEvent, err := outbox.Build(current.RoomID, "RoomAdminChanged", current.Version, now, &roomeventsv1.RoomAdminChanged{
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
Enabled: cmd.Enabled,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: adminEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
EventType: "room_admin_changed",
|
||||
ActorUserID: cmd.ActorUserID(),
|
||||
TargetUserID: cmd.TargetUserID,
|
||||
RoomVersion: current.Version,
|
||||
Attributes: map[string]string{
|
||||
"enabled": boolString(cmd.Enabled),
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{adminEvent}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &roomv1.SetRoomAdminResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
Room: result.snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TransferRoomHost 转移主持人;新 host 自动进入管理员集合。
|
||||
func (s *Service) TransferRoomHost(ctx context.Context, req *roomv1.TransferRoomHostRequest) (*roomv1.TransferRoomHostResponse, error) {
|
||||
// 主持人是独立于 admin 集合的强角色,转移必须由 owner 发起。
|
||||
cmd := command.TransferRoomHost{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if err := requireActiveRoom(current); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if cmd.TargetUserID <= 0 {
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
|
||||
}
|
||||
if err := requireOwnerPresent(current, cmd.ActorUserID()); err != nil {
|
||||
// host 不能自转移或把主持权限授予他人。
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if _, exists := current.OnlineUsers[cmd.TargetUserID]; !exists {
|
||||
// 新 host 必须当前在房间 presence 内,保证交接对象可立即管理现场。
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not in room")
|
||||
}
|
||||
if current.HostUserID == cmd.TargetUserID {
|
||||
// 转给当前 host 不改变状态,也不重复广播。
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
|
||||
oldHostUserID := current.HostUserID
|
||||
// 新 host 自动进入 AdminUsers;旧 host 是否保留 admin 由当前实现保持不变。
|
||||
current.HostUserID = cmd.TargetUserID
|
||||
current.AdminUsers[cmd.TargetUserID] = true
|
||||
current.Version++
|
||||
|
||||
// 主持人转移是房间管理强事件,必须进入 outbox 便于客户端和审计同步。
|
||||
hostEvent, err := outbox.Build(current.RoomID, "RoomHostTransferred", current.Version, now, &roomeventsv1.RoomHostTransferred{
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
OldHostUserId: oldHostUserID,
|
||||
NewHostUserId: cmd.TargetUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: hostEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
EventType: "room_host_transferred",
|
||||
ActorUserID: cmd.ActorUserID(),
|
||||
TargetUserID: cmd.TargetUserID,
|
||||
RoomVersion: current.Version,
|
||||
Attributes: map[string]string{
|
||||
"old_host_user_id": int64String(oldHostUserID),
|
||||
"new_host_user_id": int64String(cmd.TargetUserID),
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{hostEvent}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &roomv1.TransferRoomHostResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
Room: result.snapshot,
|
||||
}, nil
|
||||
}
|
||||
@ -2,6 +2,8 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"time"
|
||||
|
||||
roomeventsv1 "hyapp/api/proto/events/room/v1"
|
||||
@ -21,8 +23,13 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
SeatNo: req.GetSeatNo(),
|
||||
}
|
||||
cmd.MicSessionID = micSessionIDForCommand(cmd.RoomID(), cmd.ID())
|
||||
cmd.PublishDeadlineMS = s.clock.Now().Add(s.micPublishTimeout).UnixMilli()
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if err := requireActiveRoom(current); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if _, exists := current.OnlineUsers[cmd.ActorUserID()]; !exists {
|
||||
// 不在 room-service presence 的用户不能上麦。
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room")
|
||||
@ -49,25 +56,37 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.
|
||||
return mutationResult{}, nil, xerr.New(xerr.Conflict, "seat is occupied")
|
||||
}
|
||||
|
||||
// 麦位占用和房间版本必须在同一个 Room Cell 任务里更新。
|
||||
current.MicSeats[index].UserID = cmd.ActorUserID()
|
||||
// MicUp 只代表业务占麦成功;RTC 发流必须再通过 ConfirmMicPublishing 确认。
|
||||
current.Version++
|
||||
current.MicSeats[index].UserID = cmd.ActorUserID()
|
||||
current.MicSeats[index].PublishState = state.MicPublishPending
|
||||
current.MicSeats[index].MicSessionID = cmd.MicSessionID
|
||||
current.MicSeats[index].PublishDeadlineMS = cmd.PublishDeadlineMS
|
||||
current.MicSeats[index].MicSessionRoomVersion = current.Version
|
||||
// 新 session 尚未接受任何 RTC/客户端事件;不能用服务端 MicUp 时间初始化,
|
||||
// 否则客户端设备时间略慢时,合法的首次确认会被误判为旧事件。
|
||||
current.MicSeats[index].LastPublishEventTimeMS = 0
|
||||
|
||||
// RoomMicChanged 是房间外消费者和腾讯云 IM 共同使用的麦位事件源。
|
||||
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.ActorUserID(),
|
||||
FromSeat: 0,
|
||||
ToSeat: cmd.SeatNo,
|
||||
Action: "up",
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.ActorUserID(),
|
||||
FromSeat: 0,
|
||||
ToSeat: cmd.SeatNo,
|
||||
Action: "up",
|
||||
MicSessionId: cmd.MicSessionID,
|
||||
PublishState: state.MicPublishPending,
|
||||
PublishDeadlineMs: cmd.PublishDeadlineMS,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
seatNo: cmd.SeatNo,
|
||||
snapshot: current.ToProto(),
|
||||
seatNo: cmd.SeatNo,
|
||||
micSessionID: cmd.MicSessionID,
|
||||
publishDeadlineMS: cmd.PublishDeadlineMS,
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: micEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
@ -76,17 +95,33 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.
|
||||
TargetUserID: cmd.ActorUserID(),
|
||||
SeatNo: cmd.SeatNo,
|
||||
RoomVersion: current.Version,
|
||||
Attributes: map[string]string{
|
||||
"action": "up",
|
||||
"publish_state": state.MicPublishPending,
|
||||
"mic_session_id": cmd.MicSessionID,
|
||||
"publish_deadline_ms": int64String(cmd.PublishDeadlineMS),
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{micEvent}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.micSessionID == "" {
|
||||
// 幂等重试不会重新执行 mutate,需要从当前快照补回首次 MicUp 生成的会话信息。
|
||||
if seat := protoSeatByUser(result.snapshot, cmd.ActorUserID()); seat != nil {
|
||||
result.seatNo = seat.GetSeatNo()
|
||||
result.micSessionID = seat.GetMicSessionId()
|
||||
result.publishDeadlineMS = seat.GetPublishDeadlineMs()
|
||||
}
|
||||
}
|
||||
|
||||
return &roomv1.MicUpResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
SeatNo: result.seatNo,
|
||||
Room: result.snapshot,
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
SeatNo: result.seatNo,
|
||||
Room: result.snapshot,
|
||||
MicSessionId: result.micSessionID,
|
||||
PublishDeadlineMs: result.publishDeadlineMS,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -96,18 +131,55 @@ func (s *Service) MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roo
|
||||
cmd := command.MicDown{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
Reason: req.GetReason(),
|
||||
}
|
||||
if cmd.TargetUserID == 0 {
|
||||
// target 缺省时按主动下麦处理;管理别人下麦必须显式传 target_user_id。
|
||||
cmd.TargetUserID = cmd.ActorUserID()
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
result, err := s.micDown(ctx, cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &roomv1.MicDownResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
SeatNo: result.seatNo,
|
||||
Room: result.snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) micDown(ctx context.Context, cmd command.MicDown) (mutationResult, error) {
|
||||
return s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if err := requireActiveRoom(current); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if cmd.TargetUserID <= 0 {
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
|
||||
}
|
||||
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if err := canSelfOrManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
seat, exists := current.SeatByUser(cmd.TargetUserID)
|
||||
if !exists {
|
||||
// 目标用户不在麦上时不能生成下麦事件。
|
||||
return mutationResult{}, nil, xerr.New(xerr.NotFound, "target not on seat")
|
||||
}
|
||||
|
||||
if cmd.MicSessionID != "" && seat.MicSessionID != cmd.MicSessionID {
|
||||
// 超时任务携带旧 mic_session_id 时只能成为 no-op,不能清掉用户新的上麦会话。
|
||||
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
|
||||
}
|
||||
|
||||
// SeatByUser 返回的是值拷贝,真正修改需要再定位切片下标。
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].UserID = 0
|
||||
micSessionID := current.MicSeats[index].MicSessionID
|
||||
current.ClearMicSession(index)
|
||||
current.Version++
|
||||
|
||||
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{
|
||||
@ -116,6 +188,9 @@ func (s *Service) MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roo
|
||||
FromSeat: seat.SeatNo,
|
||||
ToSeat: 0,
|
||||
Action: "down",
|
||||
MicSessionId: micSessionID,
|
||||
PublishState: state.MicPublishIdle,
|
||||
Reason: cmd.Reason,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
@ -132,18 +207,15 @@ func (s *Service) MicDown(ctx context.Context, req *roomv1.MicDownRequest) (*roo
|
||||
TargetUserID: cmd.TargetUserID,
|
||||
SeatNo: seat.SeatNo,
|
||||
RoomVersion: current.Version,
|
||||
Attributes: map[string]string{
|
||||
"action": "down",
|
||||
"publish_state": state.MicPublishIdle,
|
||||
"mic_session_id": micSessionID,
|
||||
"reason": cmd.Reason,
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{micEvent}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &roomv1.MicDownResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
SeatNo: result.seatNo,
|
||||
Room: result.snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ChangeMicSeat 调整指定用户所在麦位。
|
||||
@ -156,6 +228,19 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if err := requireActiveRoom(current); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if cmd.TargetUserID <= 0 {
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
|
||||
}
|
||||
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
fromSeat, exists := current.SeatByUser(cmd.TargetUserID)
|
||||
if !exists {
|
||||
// 未上麦用户没有可换出的源麦位。
|
||||
@ -180,16 +265,30 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe
|
||||
|
||||
// 清空源麦位和占用目标麦位在同一 Cell 任务内完成,客户端不会看到中间态。
|
||||
fromIndex := current.SeatIndex(fromSeat.SeatNo)
|
||||
movedSeat := current.MicSeats[fromIndex]
|
||||
current.MicSeats[fromIndex].UserID = 0
|
||||
current.MicSeats[fromIndex].PublishState = ""
|
||||
current.MicSeats[fromIndex].MicSessionID = ""
|
||||
current.MicSeats[fromIndex].PublishDeadlineMS = 0
|
||||
current.MicSeats[fromIndex].MicSessionRoomVersion = 0
|
||||
current.MicSeats[fromIndex].LastPublishEventTimeMS = 0
|
||||
current.MicSeats[toIndex].UserID = cmd.TargetUserID
|
||||
current.MicSeats[toIndex].PublishState = movedSeat.PublishState
|
||||
current.MicSeats[toIndex].MicSessionID = movedSeat.MicSessionID
|
||||
current.MicSeats[toIndex].PublishDeadlineMS = movedSeat.PublishDeadlineMS
|
||||
current.MicSeats[toIndex].MicSessionRoomVersion = movedSeat.MicSessionRoomVersion
|
||||
current.MicSeats[toIndex].LastPublishEventTimeMS = movedSeat.LastPublishEventTimeMS
|
||||
current.Version++
|
||||
|
||||
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
FromSeat: fromSeat.SeatNo,
|
||||
ToSeat: cmd.SeatNo,
|
||||
Action: "change",
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
FromSeat: fromSeat.SeatNo,
|
||||
ToSeat: cmd.SeatNo,
|
||||
Action: "change",
|
||||
MicSessionId: movedSeat.MicSessionID,
|
||||
PublishState: movedSeat.PublishState,
|
||||
PublishDeadlineMs: movedSeat.PublishDeadlineMS,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
@ -205,6 +304,12 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe
|
||||
TargetUserID: cmd.TargetUserID,
|
||||
SeatNo: cmd.SeatNo,
|
||||
RoomVersion: current.Version,
|
||||
Attributes: map[string]string{
|
||||
"action": "change",
|
||||
"publish_state": movedSeat.PublishState,
|
||||
"mic_session_id": movedSeat.MicSessionID,
|
||||
"publish_deadline_ms": int64String(movedSeat.PublishDeadlineMS),
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{micEvent}, nil
|
||||
})
|
||||
@ -217,3 +322,124 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe
|
||||
Room: result.snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ConfirmMicPublishing 把 pending_publish 麦位推进到 publishing。
|
||||
func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error) {
|
||||
cmd := command.ConfirmMicPublishing{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
MicSessionID: req.GetMicSessionId(),
|
||||
RoomVersion: req.GetRoomVersion(),
|
||||
EventTimeMS: req.GetEventTimeMs(),
|
||||
Source: req.GetSource(),
|
||||
}
|
||||
if cmd.TargetUserID == 0 {
|
||||
cmd.TargetUserID = cmd.ActorUserID()
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if err := requireActiveRoom(current); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if cmd.TargetUserID <= 0 || cmd.MicSessionID == "" || cmd.RoomVersion <= 0 || cmd.EventTimeMS <= 0 {
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "mic publish confirmation is incomplete")
|
||||
}
|
||||
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if cmd.ActorUserID() != cmd.TargetUserID {
|
||||
if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
seat, exists := current.SeatByUser(cmd.TargetUserID)
|
||||
if !exists {
|
||||
// RTC webhook 可能晚于用户下麦到达;旧事件只忽略,不返回错误清掉新状态。
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
if seat.MicSessionID != cmd.MicSessionID ||
|
||||
cmd.RoomVersion < seat.MicSessionRoomVersion ||
|
||||
cmd.EventTimeMS <= seat.LastPublishEventTimeMS ||
|
||||
cmd.EventTimeMS > seat.PublishDeadlineMS {
|
||||
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
|
||||
}
|
||||
if seat.PublishState == state.MicPublishPublishing {
|
||||
return mutationResult{snapshot: current.ToProto(), seatNo: seat.SeatNo}, nil, nil
|
||||
}
|
||||
if seat.PublishState != state.MicPublishPending {
|
||||
return mutationResult{}, nil, xerr.New(xerr.Conflict, "mic is not waiting for publish confirmation")
|
||||
}
|
||||
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].PublishState = state.MicPublishPublishing
|
||||
current.MicSeats[index].LastPublishEventTimeMS = cmd.EventTimeMS
|
||||
current.Version++
|
||||
|
||||
micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, &roomeventsv1.RoomMicChanged{
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
FromSeat: seat.SeatNo,
|
||||
ToSeat: seat.SeatNo,
|
||||
Action: "publish_confirmed",
|
||||
MicSessionId: cmd.MicSessionID,
|
||||
PublishState: state.MicPublishPublishing,
|
||||
PublishDeadlineMs: seat.PublishDeadlineMS,
|
||||
PublishEventTimeMs: cmd.EventTimeMS,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
seatNo: seat.SeatNo,
|
||||
micSessionID: cmd.MicSessionID,
|
||||
publishDeadlineMS: seat.PublishDeadlineMS,
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: micEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
EventType: "room_mic_publish_confirmed",
|
||||
ActorUserID: cmd.ActorUserID(),
|
||||
TargetUserID: cmd.TargetUserID,
|
||||
SeatNo: seat.SeatNo,
|
||||
RoomVersion: current.Version,
|
||||
Attributes: map[string]string{
|
||||
"action": "publish_confirmed",
|
||||
"publish_state": state.MicPublishPublishing,
|
||||
"mic_session_id": cmd.MicSessionID,
|
||||
"publish_event_time_ms": int64String(cmd.EventTimeMS),
|
||||
"source": cmd.Source,
|
||||
},
|
||||
},
|
||||
}, []outbox.Record{micEvent}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &roomv1.ConfirmMicPublishingResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
SeatNo: result.seatNo,
|
||||
Room: result.snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func micSessionIDForCommand(roomID string, commandID string) string {
|
||||
sum := sha256.Sum256([]byte(roomID + "\x00" + commandID))
|
||||
return "mic_" + hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func protoSeatByUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.SeatState {
|
||||
if snapshot == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, seat := range snapshot.GetMicSeats() {
|
||||
if seat.GetUserId() == userID {
|
||||
return seat
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -0,0 +1,90 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
|
||||
const micDownReasonPublishTimeout = "publish_timeout"
|
||||
|
||||
// RunMicPublishTimeoutWorker 周期性释放超过 deadline 仍未确认 RTC 发流的麦位。
|
||||
func (s *Service) RunMicPublishTimeoutWorker(ctx context.Context, interval time.Duration) {
|
||||
if interval <= 0 || s.micPublishTimeout <= 0 {
|
||||
// 非正数表示显式关闭,便于测试或临时止血。
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = s.SweepMicPublishTimeouts(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SweepMicPublishTimeouts 扫描本节点已装载 Room Cell,自动下掉未确认发流的 pending_publish 麦位。
|
||||
func (s *Service) SweepMicPublishTimeouts(ctx context.Context) error {
|
||||
now := s.clock.Now()
|
||||
nowMs := now.UnixMilli()
|
||||
for _, roomID := range s.loadedRoomIDs() {
|
||||
roomCell := s.loadCell(roomID)
|
||||
if roomCell == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
currentState, _, err := roomCell.Snapshot(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, seat := range pendingPublishTimedOutSeats(currentState, nowMs) {
|
||||
cmd := command.MicDown{
|
||||
Base: command.Base{
|
||||
RequestID: idgen.New("req_mic_publish_timeout"),
|
||||
CommandID: idgen.New("cmd_mic_publish_timeout"),
|
||||
ActorID: seat.UserID,
|
||||
Room: roomID,
|
||||
GatewayNodeID: s.nodeID,
|
||||
SessionID: "mic-publish-timeout",
|
||||
SentAtMS: nowMs,
|
||||
},
|
||||
TargetUserID: seat.UserID,
|
||||
MicSessionID: seat.MicSessionID,
|
||||
Reason: micDownReasonPublishTimeout,
|
||||
}
|
||||
if _, err := s.micDown(ctx, cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pendingPublishTimedOutSeats(current *state.RoomState, nowMs int64) []state.MicSeat {
|
||||
if current == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
seats := make([]state.MicSeat, 0)
|
||||
for _, seat := range current.MicSeats {
|
||||
if seat.UserID <= 0 || seat.MicSessionID == "" {
|
||||
continue
|
||||
}
|
||||
if seat.PublishState == state.MicPublishPending && seat.PublishDeadlineMS > 0 && seat.PublishDeadlineMS <= nowMs {
|
||||
// 返回值拷贝,后续 MicDown 还会用 mic_session_id 在当前 Cell 状态中二次校验。
|
||||
seats = append(seats, seat)
|
||||
}
|
||||
}
|
||||
|
||||
return seats
|
||||
}
|
||||
@ -7,6 +7,7 @@ import (
|
||||
roomeventsv1 "hyapp/api/proto/events/room/v1"
|
||||
roomv1 "hyapp/api/proto/room/v1"
|
||||
"hyapp/pkg/tencentim"
|
||||
"hyapp/pkg/xerr"
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"hyapp/services/room-service/internal/room/outbox"
|
||||
"hyapp/services/room-service/internal/room/rank"
|
||||
@ -23,6 +24,26 @@ func (s *Service) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*r
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if err := requireActiveRoom(current); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if cmd.TargetUserID <= 0 {
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
|
||||
}
|
||||
if cmd.TargetUserID == cmd.ActorUserID() {
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "cannot mute self")
|
||||
}
|
||||
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if current.MuteUsers[cmd.TargetUserID] == cmd.Muted {
|
||||
// 重复设置同一禁言状态是业务 no-op,仍由 pipeline 写 command log 做幂等保护。
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
|
||||
if cmd.Muted {
|
||||
// map set 表达禁言集合,true 才会被快照导出。
|
||||
current.MuteUsers[cmd.TargetUserID] = true
|
||||
@ -74,14 +95,41 @@ func (s *Service) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*r
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if err := requireActiveRoom(current); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if cmd.TargetUserID <= 0 {
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
|
||||
}
|
||||
if cmd.TargetUserID == cmd.ActorUserID() {
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "cannot kick self")
|
||||
}
|
||||
if cmd.TargetUserID == current.HostUserID {
|
||||
return mutationResult{}, nil, xerr.New(xerr.PermissionDenied, "permission denied")
|
||||
}
|
||||
if err := requireActorPresent(current, cmd.ActorUserID()); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
_, inRoom := current.OnlineUsers[cmd.TargetUserID]
|
||||
_, onSeat := current.SeatByUser(cmd.TargetUserID)
|
||||
if current.BanUsers[cmd.TargetUserID] && !inRoom && !onSeat && !current.AdminUsers[cmd.TargetUserID] {
|
||||
// 已处于 ban 且没有残留 presence、麦位或管理员身份时,重复 KickUser 不产生新事件。
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
|
||||
if seat, exists := current.SeatByUser(cmd.TargetUserID); exists {
|
||||
// 被踢用户如果在麦上,必须先释放麦位,避免恢复后出现幽灵占位。
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].UserID = 0
|
||||
current.ClearMicSession(index)
|
||||
}
|
||||
|
||||
// ban 集合会让后续 JoinRoom 和 VerifyRoomPresence 都失败。
|
||||
delete(current.OnlineUsers, cmd.TargetUserID)
|
||||
delete(current.AdminUsers, cmd.TargetUserID)
|
||||
current.BanUsers[cmd.TargetUserID] = true
|
||||
current.Version++
|
||||
|
||||
@ -114,3 +162,58 @@ func (s *Service) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*r
|
||||
Room: result.snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UnbanUser 解除房间 ban;不会恢复 presence、管理员、麦位或 RTC 推流事实。
|
||||
func (s *Service) UnbanUser(ctx context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error) {
|
||||
cmd := command.UnbanUser{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
}
|
||||
|
||||
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
|
||||
if err := requireActiveRoom(current); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if cmd.TargetUserID <= 0 {
|
||||
return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required")
|
||||
}
|
||||
if err := requireOwnerOrHostPresent(current, cmd.ActorUserID()); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if !current.BanUsers[cmd.TargetUserID] {
|
||||
// 重复 unban 是 no-op,仍写 command log 确保 command_id 幂等可判定。
|
||||
return mutationResult{snapshot: current.ToProto()}, nil, nil
|
||||
}
|
||||
|
||||
delete(current.BanUsers, cmd.TargetUserID)
|
||||
current.Version++
|
||||
|
||||
unbanEvent, err := outbox.Build(current.RoomID, "RoomUserUnbanned", current.Version, now, &roomeventsv1.RoomUserUnbanned{
|
||||
ActorUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
return mutationResult{
|
||||
snapshot: current.ToProto(),
|
||||
syncEvent: &tencentim.RoomEvent{
|
||||
EventID: unbanEvent.EventID,
|
||||
RoomID: current.RoomID,
|
||||
EventType: "room_user_unbanned",
|
||||
ActorUserID: cmd.ActorUserID(),
|
||||
TargetUserID: cmd.TargetUserID,
|
||||
RoomVersion: current.Version,
|
||||
},
|
||||
}, []outbox.Record{unbanEvent}, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &roomv1.UnbanUserResponse{
|
||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||
Room: result.snapshot,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ type OutboxWorkerOptions struct {
|
||||
}
|
||||
|
||||
func defaultOutboxWorkerOptions() OutboxWorkerOptions {
|
||||
// 默认值对应文档里的 V1 固定间隔补偿策略,优先避免 busy loop 和无限外部调用。
|
||||
return OutboxWorkerOptions{
|
||||
PollInterval: time.Second,
|
||||
BatchSize: 100,
|
||||
@ -37,18 +38,23 @@ func defaultOutboxWorkerOptions() OutboxWorkerOptions {
|
||||
}
|
||||
|
||||
func normalizeOutboxWorkerOptions(options OutboxWorkerOptions) OutboxWorkerOptions {
|
||||
// worker 层再次归一化,防止测试或手动构造绕过 config.Normalize。
|
||||
defaults := defaultOutboxWorkerOptions()
|
||||
if options.PollInterval <= 0 {
|
||||
// 非正间隔不能直接传给 ticker,否则会 panic 或形成忙循环。
|
||||
options.PollInterval = defaults.PollInterval
|
||||
}
|
||||
if options.BatchSize <= 0 {
|
||||
// 非正批量按安全默认值处理,避免 repository 扫描不受控。
|
||||
options.BatchSize = defaults.BatchSize
|
||||
}
|
||||
if options.PublishTimeout <= 0 {
|
||||
// 单条外部投递必须有 deadline,shutdown 才能等待有界。
|
||||
options.PublishTimeout = defaults.PublishTimeout
|
||||
}
|
||||
options.RetryStrategy = strings.TrimSpace(options.RetryStrategy)
|
||||
if options.RetryStrategy == "" {
|
||||
// 空策略按 fixed_interval 处理,保持和配置默认值一致。
|
||||
options.RetryStrategy = defaults.RetryStrategy
|
||||
}
|
||||
|
||||
@ -134,10 +140,12 @@ func (s *Service) ProcessPendingOutbox(ctx context.Context, options OutboxWorker
|
||||
}
|
||||
|
||||
func trimOutboxError(value string) string {
|
||||
// last_error 会写入 MySQL 和日志,先去掉首尾空白避免不可见差异影响排障。
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) <= 512 {
|
||||
return value
|
||||
}
|
||||
|
||||
// V1 只保留错误前缀,避免外部响应过大撑爆日志或 TEXT 字段查询成本。
|
||||
return value[:512]
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
@ -18,9 +19,11 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
return mutationResult{}, xerr.New(xerr.InvalidArgument, "command meta is incomplete")
|
||||
}
|
||||
|
||||
if seen, err := s.repository.HasCommand(ctx, cmd.RoomID(), cmd.ID()); err != nil {
|
||||
payload, seen, err := s.commandPayloadForIdempotency(ctx, cmd)
|
||||
if err != nil {
|
||||
return mutationResult{}, err
|
||||
} else if seen {
|
||||
}
|
||||
if seen {
|
||||
// 幂等命令不重新执行 mutate,也不会再次调用 wallet 或写 outbox。
|
||||
snapshot, err := s.currentSnapshot(ctx, cmd.RoomID())
|
||||
if err != nil {
|
||||
@ -55,17 +58,12 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
|
||||
if result.snapshot.GetVersion() != current.Version {
|
||||
// 版本变化代表本命令产生实际状态变更,需要持久化命令和事件。
|
||||
payload, err := command.Serialize(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := s.repository.SaveCommand(ctx, CommandRecord{
|
||||
RoomID: cmd.RoomID(),
|
||||
RoomVersion: nextState.Version,
|
||||
CommandID: cmd.ID(),
|
||||
CommandType: cmd.Type(),
|
||||
Payload: payload,
|
||||
Payload: commandPayloadForRecord(payload, result),
|
||||
Replayable: replayable,
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
@ -84,6 +82,20 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
currentRank.ReplaceWith(nextRank)
|
||||
result.snapshot = current.ToProto()
|
||||
result.applied = true
|
||||
} else {
|
||||
// no-op 命令仍写入 command log,保证相同 command_id 重试可命中幂等,
|
||||
// 不同 payload 复用 command_id 会被后续入口拒绝为 CONFLICT。
|
||||
if err := s.repository.SaveCommand(ctx, CommandRecord{
|
||||
RoomID: cmd.RoomID(),
|
||||
RoomVersion: current.Version,
|
||||
CommandID: cmd.ID(),
|
||||
CommandType: cmd.Type(),
|
||||
Payload: payload,
|
||||
Replayable: false,
|
||||
CreatedAt: now,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@ -116,3 +128,38 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) commandPayloadForIdempotency(ctx context.Context, cmd command.Command) ([]byte, bool, error) {
|
||||
payload, err := command.Serialize(cmd)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
record, exists, err := s.repository.GetCommand(ctx, cmd.RoomID(), cmd.ID())
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !exists {
|
||||
return payload, false, nil
|
||||
}
|
||||
currentIDPayload, err := command.IdempotencyPayload(payload)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
storedIDPayload, err := command.IdempotencyPayload(record.Payload)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if record.CommandType != cmd.Type() || !bytes.Equal(storedIDPayload, currentIDPayload) {
|
||||
return nil, true, xerr.New(xerr.Conflict, "command_id payload conflict")
|
||||
}
|
||||
|
||||
return payload, true, nil
|
||||
}
|
||||
|
||||
func commandPayloadForRecord(defaultPayload []byte, result mutationResult) []byte {
|
||||
if len(result.commandPayload) > 0 {
|
||||
return result.commandPayload
|
||||
}
|
||||
return defaultPayload
|
||||
}
|
||||
|
||||
@ -121,7 +121,7 @@ func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason s
|
||||
// 离房必须同步释放麦位,但对外只发 RoomUserLeft 主事件,避免客户端处理双事件竞态。
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
if index >= 0 {
|
||||
current.MicSeats[index].UserID = 0
|
||||
current.ClearMicSession(index)
|
||||
}
|
||||
}
|
||||
current.Version++
|
||||
|
||||
@ -13,6 +13,7 @@ import (
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
|
||||
// ensureCell 确保当前节点拥有房间执行权,并在本地缺少 Cell 时从 MySQL 恢复。
|
||||
func (s *Service) ensureCell(ctx context.Context, roomID string) (*cell.RoomCell, error) {
|
||||
// 每次写命令先刷新或接管 lease,确保同一房间同一时刻只有一个执行节点。
|
||||
lease, err := s.directory.EnsureOwner(ctx, roomID, s.nodeID, s.clock.Now(), s.leaseTTL)
|
||||
@ -54,6 +55,7 @@ func (s *Service) ensureCell(ctx context.Context, roomID string) (*cell.RoomCell
|
||||
return roomCell, nil
|
||||
}
|
||||
|
||||
// recoverRoom 按“最新快照 + 之后的可回放命令”重建 Room Cell 状态。
|
||||
func (s *Service) recoverRoom(ctx context.Context, roomMeta RoomMeta) (*state.RoomState, error) {
|
||||
// 恢复优先从最新快照开始,再回放快照版本之后的 replayable command log。
|
||||
snapshotRecord, hasSnapshot, err := s.repository.GetLatestSnapshot(ctx, roomMeta.RoomID)
|
||||
@ -96,13 +98,8 @@ func (s *Service) recoverRoom(ctx context.Context, roomMeta RoomMeta) (*state.Ro
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cmd == nil {
|
||||
// 未知命令类型跳过,保留兼容未来命令的恢复容错。
|
||||
continue
|
||||
}
|
||||
|
||||
if err := replay(recovered, cmd); err != nil {
|
||||
// replay 失败说明命令日志和当前状态不兼容,不能继续装载错误 Cell。
|
||||
// replay 失败说明命令日志无法被当前状态机解释,不能继续装载错误 Cell。
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@ -110,6 +107,7 @@ func (s *Service) recoverRoom(ctx context.Context, roomMeta RoomMeta) (*state.Ro
|
||||
return recovered, nil
|
||||
}
|
||||
|
||||
// replay 只重放房间内确定性状态变更,不重复执行钱包扣费或腾讯云 IM 投递。
|
||||
func replay(current *state.RoomState, cmd command.Command) error {
|
||||
// replay 只重建房间内状态,不重复写 outbox、不重复调用 wallet、不重复同步腾讯云 IM。
|
||||
switch typed := cmd.(type) {
|
||||
@ -136,34 +134,89 @@ func replay(current *state.RoomState, cmd command.Command) error {
|
||||
if seat, exists := current.SeatByUser(typed.ActorUserID()); exists {
|
||||
// 离房恢复也必须释放麦位,避免 snapshot 落后时重建出幽灵占位。
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].UserID = 0
|
||||
current.ClearMicSession(index)
|
||||
}
|
||||
current.Version++
|
||||
case *command.MicUp:
|
||||
index := current.SeatIndex(typed.SeatNo)
|
||||
if index >= 0 {
|
||||
// 回放对不存在麦位保持容错,避免旧命令阻塞新结构恢复。
|
||||
current.MicSeats[index].UserID = typed.ActorUserID()
|
||||
current.Version++
|
||||
if index < 0 {
|
||||
return fmt.Errorf("mic_up replay references missing seat: %d", typed.SeatNo)
|
||||
}
|
||||
current.Version++
|
||||
current.MicSeats[index].UserID = typed.ActorUserID()
|
||||
current.MicSeats[index].PublishState = state.MicPublishPending
|
||||
current.MicSeats[index].MicSessionID = typed.MicSessionID
|
||||
current.MicSeats[index].PublishDeadlineMS = typed.PublishDeadlineMS
|
||||
current.MicSeats[index].MicSessionRoomVersion = current.Version
|
||||
// pending_publish 还没有接受 RTC/客户端事件,恢复时保持 0,避免客户端时间慢于服务端命令时间时无法确认。
|
||||
current.MicSeats[index].LastPublishEventTimeMS = 0
|
||||
case *command.MicDown:
|
||||
if seat, exists := current.SeatByUser(typed.TargetUserID); exists {
|
||||
// 只有目标仍在麦上时才清空,重复或乱序异常不会制造负状态。
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].UserID = 0
|
||||
current.Version++
|
||||
seat, exists := current.SeatByUser(typed.TargetUserID)
|
||||
if !exists {
|
||||
return fmt.Errorf("mic_down replay target is not on seat: %d", typed.TargetUserID)
|
||||
}
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.ClearMicSession(index)
|
||||
current.Version++
|
||||
case *command.ConfirmMicPublishing:
|
||||
seat, exists := current.SeatByUser(typed.TargetUserID)
|
||||
if !exists {
|
||||
return fmt.Errorf("confirm_mic_publishing replay target is not on seat: %d", typed.TargetUserID)
|
||||
}
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].PublishState = state.MicPublishPublishing
|
||||
current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS
|
||||
current.Version++
|
||||
case *command.ChangeMicSeat:
|
||||
if from, exists := current.SeatByUser(typed.TargetUserID); exists {
|
||||
// 换位回放必须同时找到源麦位和目标麦位。
|
||||
fromIndex := current.SeatIndex(from.SeatNo)
|
||||
toIndex := current.SeatIndex(typed.SeatNo)
|
||||
if fromIndex >= 0 && toIndex >= 0 {
|
||||
current.MicSeats[fromIndex].UserID = 0
|
||||
current.MicSeats[toIndex].UserID = typed.TargetUserID
|
||||
current.Version++
|
||||
}
|
||||
from, exists := current.SeatByUser(typed.TargetUserID)
|
||||
if !exists {
|
||||
return fmt.Errorf("change_mic_seat replay target is not on seat: %d", typed.TargetUserID)
|
||||
}
|
||||
fromIndex := current.SeatIndex(from.SeatNo)
|
||||
toIndex := current.SeatIndex(typed.SeatNo)
|
||||
if fromIndex < 0 || toIndex < 0 {
|
||||
return fmt.Errorf("change_mic_seat replay references missing seat: from=%d to=%d", from.SeatNo, typed.SeatNo)
|
||||
}
|
||||
current.MicSeats[fromIndex].UserID = 0
|
||||
movedSeat := current.MicSeats[fromIndex]
|
||||
current.MicSeats[fromIndex].PublishState = ""
|
||||
current.MicSeats[fromIndex].MicSessionID = ""
|
||||
current.MicSeats[fromIndex].PublishDeadlineMS = 0
|
||||
current.MicSeats[fromIndex].MicSessionRoomVersion = 0
|
||||
current.MicSeats[fromIndex].LastPublishEventTimeMS = 0
|
||||
current.MicSeats[toIndex].UserID = typed.TargetUserID
|
||||
current.MicSeats[toIndex].PublishState = movedSeat.PublishState
|
||||
current.MicSeats[toIndex].MicSessionID = movedSeat.MicSessionID
|
||||
current.MicSeats[toIndex].PublishDeadlineMS = movedSeat.PublishDeadlineMS
|
||||
current.MicSeats[toIndex].MicSessionRoomVersion = movedSeat.MicSessionRoomVersion
|
||||
current.MicSeats[toIndex].LastPublishEventTimeMS = movedSeat.LastPublishEventTimeMS
|
||||
current.Version++
|
||||
case *command.SetMicSeatLock:
|
||||
index := current.SeatIndex(typed.SeatNo)
|
||||
if index < 0 {
|
||||
return fmt.Errorf("set_mic_seat_lock replay references missing seat: %d", typed.SeatNo)
|
||||
}
|
||||
// 锁麦恢复只还原锁状态,不隐式清空已占用麦位。
|
||||
current.MicSeats[index].Locked = typed.Locked
|
||||
current.Version++
|
||||
case *command.SetChatEnabled:
|
||||
// 公屏开关恢复后直接影响 CheckSpeakPermission 的 chat_disabled 判断。
|
||||
current.ChatEnabled = typed.Enabled
|
||||
current.Version++
|
||||
case *command.SetRoomAdmin:
|
||||
if typed.Enabled {
|
||||
// 管理员集合是持久权限,恢复后用户重新进房即可继续具备 admin 身份。
|
||||
current.AdminUsers[typed.TargetUserID] = true
|
||||
} else if typed.TargetUserID != current.OwnerUserID && typed.TargetUserID != current.HostUserID {
|
||||
// owner/host 的管理身份不能被 admin 删除命令移除。
|
||||
delete(current.AdminUsers, typed.TargetUserID)
|
||||
}
|
||||
current.Version++
|
||||
case *command.TransferRoomHost:
|
||||
// 新 host 自动进入管理员集合,和在线命令路径保持一致。
|
||||
current.HostUserID = typed.TargetUserID
|
||||
current.AdminUsers[typed.TargetUserID] = true
|
||||
current.Version++
|
||||
case *command.MuteUser:
|
||||
if typed.Muted {
|
||||
// 回放禁言集合,供 CheckSpeakPermission 使用。
|
||||
@ -176,14 +229,19 @@ func replay(current *state.RoomState, cmd command.Command) error {
|
||||
if seat, exists := current.SeatByUser(typed.TargetUserID); exists {
|
||||
// 被踢用户恢复后不能继续占麦位。
|
||||
index := current.SeatIndex(seat.SeatNo)
|
||||
current.MicSeats[index].UserID = 0
|
||||
current.ClearMicSession(index)
|
||||
}
|
||||
delete(current.OnlineUsers, typed.TargetUserID)
|
||||
delete(current.AdminUsers, typed.TargetUserID)
|
||||
current.BanUsers[typed.TargetUserID] = true
|
||||
current.Version++
|
||||
case *command.UnbanUser:
|
||||
// Unban 只解除 ban,不恢复被 KickUser 删除的 presence、麦位或管理员身份。
|
||||
delete(current.BanUsers, typed.TargetUserID)
|
||||
current.Version++
|
||||
case *command.SendGift:
|
||||
// 送礼回放不能再次调用 wallet,只使用命令中记录的礼物价值重建展示态。
|
||||
total := typed.GiftUnitValue * int64(typed.GiftCount)
|
||||
// 送礼回放不能再次调用 wallet,只使用命令中记录的钱包结算热度重建展示态。
|
||||
total := typed.HeatValue
|
||||
current.Heat += total
|
||||
applied := false
|
||||
for index := range current.GiftRank {
|
||||
|
||||
@ -60,13 +60,13 @@ type Repository interface {
|
||||
SaveRoomMeta(ctx context.Context, meta RoomMeta) error
|
||||
// GetRoomMeta 读取房间基础元数据,恢复无内存 Cell 的房间依赖它。
|
||||
GetRoomMeta(ctx context.Context, roomID string) (RoomMeta, bool, error)
|
||||
// HasCommand 判断命令是否已经成功提交,用于所有写命令幂等。
|
||||
HasCommand(ctx context.Context, roomID string, commandID string) (bool, error)
|
||||
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
|
||||
GetCommand(ctx context.Context, roomID string, commandID string) (CommandRecord, bool, error)
|
||||
// SaveCommand 追加成功命令日志,关键命令恢复必须依赖它。
|
||||
SaveCommand(ctx context.Context, record CommandRecord) error
|
||||
// ListCommandsAfter 读取快照版本之后的命令,恢复时按版本顺序回放。
|
||||
ListCommandsAfter(ctx context.Context, roomID string, roomVersion int64) ([]CommandRecord, error)
|
||||
// SaveSnapshot 保存最新房间快照,允许覆盖旧版本但不能倒退。
|
||||
// SaveSnapshot 保存最新房间快照,允许覆盖较低版本号快照但不能倒退。
|
||||
SaveSnapshot(ctx context.Context, snapshot SnapshotRecord) error
|
||||
// GetLatestSnapshot 读取当前最新快照,恢复时优先使用它减少回放成本。
|
||||
GetLatestSnapshot(ctx context.Context, roomID string) (SnapshotRecord, bool, error)
|
||||
|
||||
@ -26,6 +26,8 @@ type Config struct {
|
||||
SnapshotEveryN int64
|
||||
// PresenceStaleAfter 控制业务 presence 多久未刷新后由后台 worker 清理。
|
||||
PresenceStaleAfter time.Duration
|
||||
// MicPublishTimeout 控制 MicUp 后等待 RTC 发流确认的最长时间。
|
||||
MicPublishTimeout time.Duration
|
||||
// Clock 允许测试注入稳定时间,生产默认使用系统时钟。
|
||||
Clock clock.Clock
|
||||
}
|
||||
@ -42,6 +44,8 @@ type Service struct {
|
||||
snapshotEveryN int64
|
||||
// presenceStaleAfter 是断线或无心跳用户从 room-service presence 中移除的阈值。
|
||||
presenceStaleAfter time.Duration
|
||||
// micPublishTimeout 是 pending_publish 自动下麦的确认窗口。
|
||||
micPublishTimeout time.Duration
|
||||
// clock 是房间命令、事件和快照的统一时间来源。
|
||||
clock clock.Clock
|
||||
// directory 负责 room_id -> node_id lease,生产路径是 Redis。
|
||||
@ -71,12 +75,18 @@ type mutationResult struct {
|
||||
user *roomv1.RoomUser
|
||||
// seatNo 是麦位命令返回的目标或原麦位编号。
|
||||
seatNo int32
|
||||
// micSessionID 是 MicUp 创建的发流会话 ID,客户端确认发流时必须带回。
|
||||
micSessionID string
|
||||
// publishDeadlineMS 是 pending_publish 自动释放的截止时间。
|
||||
publishDeadlineMS int64
|
||||
// billingReceiptID 是 SendGift 从 wallet-service 得到的账务回执。
|
||||
billingReceiptID string
|
||||
// roomHeat 是 SendGift 后的房间热度。
|
||||
roomHeat int64
|
||||
// giftRank 是 SendGift 后的本地礼物榜投影。
|
||||
giftRank []*roomv1.RankItem
|
||||
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log,用于恢复。
|
||||
commandPayload []byte
|
||||
// syncEvent 是需要同步推给腾讯云 IM 的低时延房间系统事件。
|
||||
syncEvent *tencentim.RoomEvent
|
||||
}
|
||||
@ -107,12 +117,19 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
|
||||
presenceStaleAfter = 2 * time.Minute
|
||||
}
|
||||
|
||||
micPublishTimeout := cfg.MicPublishTimeout
|
||||
if micPublishTimeout <= 0 {
|
||||
// MicUp 只是业务占麦,客户端必须在短窗口内确认 RTC 发流成功。
|
||||
micPublishTimeout = 15 * time.Second
|
||||
}
|
||||
|
||||
return &Service{
|
||||
nodeID: cfg.NodeID,
|
||||
leaseTTL: cfg.LeaseTTL,
|
||||
rankLimit: rankLimit,
|
||||
snapshotEveryN: snapshotEveryN,
|
||||
presenceStaleAfter: presenceStaleAfter,
|
||||
micPublishTimeout: micPublishTimeout,
|
||||
clock: usedClock,
|
||||
directory: directory,
|
||||
repository: repository,
|
||||
|
||||
@ -22,12 +22,17 @@ import (
|
||||
// fakeWallet 模拟 wallet-service 扣费成功路径。
|
||||
type fakeWallet struct{}
|
||||
|
||||
// DebitGift 返回可预测账单回执和礼物总价值,测试只关注 room-service 状态变更。
|
||||
// DebitGift 返回可预测账单回执和钱包结算值,测试只关注 room-service 状态变更。
|
||||
func (fakeWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
||||
heatValue := int64(req.GetGiftCount()) * 10
|
||||
return &walletv1.DebitGiftResponse{
|
||||
BillingReceiptId: "receipt_" + req.GetCommandId(),
|
||||
TotalGiftValue: int64(req.GetGiftCount()) * req.GetGiftUnitValue(),
|
||||
BalanceAfter: 1000,
|
||||
TransactionId: "wtx_" + req.GetCommandId(),
|
||||
CoinSpent: heatValue,
|
||||
GiftPointAdded: heatValue,
|
||||
HeatValue: heatValue,
|
||||
PriceVersion: "test-v1",
|
||||
BalanceAfter: 1000 - heatValue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -143,23 +148,7 @@ func TestCreateRoomDefaultsOwnerAndHostFromActor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoomRejectsOwnerHostMismatchWithActor(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), mysqltest.NewRepository(t), &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||
|
||||
_, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta("room-owner-mismatch", 42),
|
||||
OwnerUserId: 99,
|
||||
HostUserId: 99,
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
})
|
||||
if !xerr.IsCode(err, xerr.PermissionDenied) {
|
||||
t.Fatalf("CreateRoom should reject owner/host mismatch, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRoomRejectsIncompatibleRoomID(t *testing.T) {
|
||||
func TestCreateRoomRejectsInvalidRoomID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||
@ -170,7 +159,7 @@ func TestCreateRoomRejectsIncompatibleRoomID(t *testing.T) {
|
||||
Mode: "social",
|
||||
})
|
||||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("CreateRoom should reject incompatible room_id, got %v", err)
|
||||
t.Fatalf("CreateRoom should reject invalid room_id, got %v", err)
|
||||
}
|
||||
if _, exists, getErr := repository.GetRoomMeta(ctx, "room:1001"); getErr != nil || exists {
|
||||
t.Fatalf("invalid room_id must not create room meta: exists=%v err=%v", exists, getErr)
|
||||
@ -212,11 +201,9 @@ func TestRoomLifecycleGiftAndGuards(t *testing.T) {
|
||||
|
||||
// 创建房间会初始化 owner presence、麦位、snapshot、command log 和 RoomCreated outbox。
|
||||
createResp, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
OwnerUserId: 1,
|
||||
HostUserId: 1,
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
@ -249,11 +236,10 @@ func TestRoomLifecycleGiftAndGuards(t *testing.T) {
|
||||
|
||||
// SendGift 先走 fakeWallet,再更新热度和房间内本地礼物榜。
|
||||
giftResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
TargetUserId: 2,
|
||||
GiftId: "rose",
|
||||
GiftCount: 2,
|
||||
GiftUnitValue: 10,
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
TargetUserId: 2,
|
||||
GiftId: "rose",
|
||||
GiftCount: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SendGift failed: %v", err)
|
||||
@ -313,6 +299,430 @@ func TestRoomLifecycleGiftAndGuards(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomManagementPermissionsAndHostFlow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||
roomID := "room-management-flow"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom user2 failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
|
||||
t.Fatalf("JoinRoom user3 failed: %v", err)
|
||||
}
|
||||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||||
t.Fatalf("MicUp user2 failed: %v", err)
|
||||
}
|
||||
|
||||
_, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2})
|
||||
if !xerr.IsCode(err, xerr.PermissionDenied) {
|
||||
t.Fatalf("audience should not mic down others, got %v", err)
|
||||
}
|
||||
|
||||
adminResp, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
TargetUserId: 3,
|
||||
Enabled: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetRoomAdmin failed: %v", err)
|
||||
}
|
||||
if !containsInt64(adminResp.GetRoom().GetAdminUserIds(), 3) {
|
||||
t.Fatalf("admin ids should contain user3: %+v", adminResp.GetRoom().GetAdminUserIds())
|
||||
}
|
||||
|
||||
if _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2}); err != nil {
|
||||
t.Fatalf("admin MicDown failed: %v", err)
|
||||
}
|
||||
hostResp, err := svc.TransferRoomHost(ctx, &roomv1.TransferRoomHostRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
TargetUserId: 2,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("TransferRoomHost failed: %v", err)
|
||||
}
|
||||
if hostResp.GetRoom().GetHostUserId() != 2 || !containsInt64(hostResp.GetRoom().GetAdminUserIds(), 2) {
|
||||
t.Fatalf("host transfer should update host and admin set: %+v", hostResp.GetRoom())
|
||||
}
|
||||
|
||||
_, err = svc.MuteUser(ctx, &roomv1.MuteUserRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 3),
|
||||
TargetUserId: 2,
|
||||
Muted: true,
|
||||
})
|
||||
if !xerr.IsCode(err, xerr.PermissionDenied) {
|
||||
t.Fatalf("admin should not mute current host, got %v", err)
|
||||
}
|
||||
if _, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: roomservice.NewRequestMeta(roomID, 2), Enabled: false}); err != nil {
|
||||
t.Fatalf("host SetChatEnabled failed: %v", err)
|
||||
}
|
||||
speakResp, err := svc.CheckSpeakPermission(ctx, &roomv1.CheckSpeakPermissionRequest{RoomId: roomID, UserId: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("CheckSpeakPermission failed: %v", err)
|
||||
}
|
||||
if speakResp.GetAllowed() || speakResp.GetReason() != "chat_disabled" {
|
||||
t.Fatalf("chat disabled should block all users: %+v", speakResp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMicPublishingConfirmAndStaleEvents(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-mic-publish-confirm"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom failed: %v", err)
|
||||
}
|
||||
|
||||
micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("MicUp failed: %v", err)
|
||||
}
|
||||
seat := findSeat(micResp.GetRoom(), 1)
|
||||
if seat == nil || seat.GetUserId() != 2 || seat.GetPublishState() != "pending_publish" || seat.GetMicSessionId() == "" {
|
||||
t.Fatalf("MicUp should create pending_publish session: seat=%+v", seat)
|
||||
}
|
||||
if micResp.GetMicSessionId() != seat.GetMicSessionId() || micResp.GetPublishDeadlineMs() != seat.GetPublishDeadlineMs() {
|
||||
t.Fatalf("MicUp response should expose current mic session: resp=%+v seat=%+v", micResp, seat)
|
||||
}
|
||||
if seat.GetLastPublishEventTimeMs() != 0 {
|
||||
t.Fatalf("new pending session should not pre-fill publish event time: seat=%+v", seat)
|
||||
}
|
||||
|
||||
confirmEventTime := usedClock.now.Add(-time.Second).UnixMilli()
|
||||
confirmResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||||
MicSessionId: micResp.GetMicSessionId(),
|
||||
RoomVersion: micResp.GetResult().GetRoomVersion(),
|
||||
EventTimeMs: confirmEventTime,
|
||||
Source: "client",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ConfirmMicPublishing failed: %v", err)
|
||||
}
|
||||
seat = findSeat(confirmResp.GetRoom(), 1)
|
||||
if seat == nil || seat.GetPublishState() != "publishing" || seat.GetLastPublishEventTimeMs() != confirmEventTime {
|
||||
t.Fatalf("confirm should move seat to publishing: seat=%+v", seat)
|
||||
}
|
||||
|
||||
staleResp, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||||
MicSessionId: micResp.GetMicSessionId(),
|
||||
RoomVersion: micResp.GetResult().GetRoomVersion(),
|
||||
EventTimeMs: confirmEventTime,
|
||||
Source: "rtc_webhook",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("stale ConfirmMicPublishing should be ignored without error: %v", err)
|
||||
}
|
||||
if staleResp.GetResult().GetApplied() {
|
||||
t.Fatalf("stale confirmation must not advance room version")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMicPublishTimeoutMicDownsPendingSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
usedClock := &mutableClock{now: time.UnixMilli(1_700_000_000_000)}
|
||||
syncPublisher := &fakeSyncPublisher{}
|
||||
svc := newRoomServiceWithClock("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{}, usedClock, time.Minute)
|
||||
roomID := "room-mic-publish-timeout"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom failed: %v", err)
|
||||
}
|
||||
micResp, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("MicUp failed: %v", err)
|
||||
}
|
||||
usedClock.now = time.UnixMilli(micResp.GetPublishDeadlineMs())
|
||||
if err := svc.SweepMicPublishTimeouts(ctx); err != nil {
|
||||
t.Fatalf("SweepMicPublishTimeouts failed: %v", err)
|
||||
}
|
||||
|
||||
joinResp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1)})
|
||||
if err != nil {
|
||||
t.Fatalf("JoinRoom refresh failed: %v", err)
|
||||
}
|
||||
if seat := findSeat(joinResp.GetRoom(), 1); seat == nil || seat.GetUserId() != 0 || seat.GetMicSessionId() != "" {
|
||||
t.Fatalf("publish timeout should release seat and session: %+v", seat)
|
||||
}
|
||||
if len(syncPublisher.events) == 0 {
|
||||
t.Fatalf("expected timeout mic_down event")
|
||||
}
|
||||
last := syncPublisher.events[len(syncPublisher.events)-1]
|
||||
if last.EventType != "room_mic_down" || last.Attributes["reason"] != "publish_timeout" || last.Attributes["mic_session_id"] != micResp.GetMicSessionId() {
|
||||
t.Fatalf("timeout should publish mic_down with session reason: %+v", last)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOldMicSessionEventsDoNotClearNewMicUp(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-mic-old-session"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "voice"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom failed: %v", err)
|
||||
}
|
||||
oldMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("first MicUp failed: %v", err)
|
||||
}
|
||||
if _, err := svc.MicDown(ctx, &roomv1.MicDownRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("MicDown failed: %v", err)
|
||||
}
|
||||
|
||||
usedClock.now = usedClock.now.Add(time.Second)
|
||||
newMic, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("second MicUp failed: %v", err)
|
||||
}
|
||||
if newMic.GetMicSessionId() == oldMic.GetMicSessionId() {
|
||||
t.Fatalf("new MicUp must create a new session: old=%s new=%s", oldMic.GetMicSessionId(), newMic.GetMicSessionId())
|
||||
}
|
||||
|
||||
usedClock.now = time.UnixMilli(oldMic.GetPublishDeadlineMs())
|
||||
if err := svc.SweepMicPublishTimeouts(ctx); err != nil {
|
||||
t.Fatalf("old timeout sweep failed: %v", err)
|
||||
}
|
||||
staleConfirm, err := svc.ConfirmMicPublishing(ctx, &roomv1.ConfirmMicPublishingRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 2),
|
||||
MicSessionId: oldMic.GetMicSessionId(),
|
||||
RoomVersion: oldMic.GetResult().GetRoomVersion(),
|
||||
EventTimeMs: usedClock.now.Add(time.Second).UnixMilli(),
|
||||
Source: "rtc_webhook",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("old session confirm should be ignored without error: %v", err)
|
||||
}
|
||||
seat := findSeat(staleConfirm.GetRoom(), 1)
|
||||
if seat == nil || seat.GetUserId() != 2 || seat.GetMicSessionId() != newMic.GetMicSessionId() || seat.GetPublishState() != "pending_publish" {
|
||||
t.Fatalf("old session event must not clear new MicUp: seat=%+v new=%+v old=%+v", seat, newMic, oldMic)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMicSeatLockAndNoopIdempotency(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
syncPublisher := &fakeSyncPublisher{fail: true}
|
||||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, syncPublisher, &fakeOutboxPublisher{})
|
||||
roomID := "room-mic-lock"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 3, Mode: "social"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom failed: %v", err)
|
||||
}
|
||||
|
||||
lockResp, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
SeatNo: 2,
|
||||
Locked: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SetMicSeatLock failed: %v", err)
|
||||
}
|
||||
if seat := findSeat(lockResp.GetRoom(), 2); seat == nil || !seat.GetLocked() {
|
||||
t.Fatalf("seat should be locked: %+v", seat)
|
||||
}
|
||||
if got := countPendingOutboxType(t, repository, "RoomMicSeatLocked"); got != 1 {
|
||||
t.Fatalf("lock should write one outbox event, got %d", got)
|
||||
}
|
||||
|
||||
repeatResp, err := svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
SeatNo: 2,
|
||||
Locked: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("repeat SetMicSeatLock failed: %v", err)
|
||||
}
|
||||
if repeatResp.GetResult().GetApplied() {
|
||||
t.Fatalf("repeat lock should be a no-op")
|
||||
}
|
||||
if got := countPendingOutboxType(t, repository, "RoomMicSeatLocked"); got != 1 {
|
||||
t.Fatalf("repeat lock should not write duplicate outbox, got %d", got)
|
||||
}
|
||||
|
||||
_, err = svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 2})
|
||||
if !xerr.IsCode(err, xerr.PermissionDenied) {
|
||||
t.Fatalf("locked seat MicUp should be denied, got %v", err)
|
||||
}
|
||||
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{Meta: roomservice.NewRequestMeta(roomID, 2), SeatNo: 1}); err != nil {
|
||||
t.Fatalf("MicUp unlocked seat failed: %v", err)
|
||||
}
|
||||
_, err = svc.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 1, Locked: true})
|
||||
if !xerr.IsCode(err, xerr.Conflict) {
|
||||
t.Fatalf("locking occupied seat should conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKickUnbanAndAdminRemoval(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||
roomID := "room-ban-unban"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom user2 failed: %v", err)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
|
||||
t.Fatalf("JoinRoom user3 failed: %v", err)
|
||||
}
|
||||
if _, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2, Enabled: true}); err != nil {
|
||||
t.Fatalf("SetRoomAdmin user2 failed: %v", err)
|
||||
}
|
||||
if _, err := svc.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3, Enabled: true}); err != nil {
|
||||
t.Fatalf("SetRoomAdmin user3 failed: %v", err)
|
||||
}
|
||||
|
||||
kickResp, err := svc.KickUser(ctx, &roomv1.KickUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("KickUser failed: %v", err)
|
||||
}
|
||||
if !containsInt64(kickResp.GetRoom().GetBanUserIds(), 2) || containsInt64(kickResp.GetRoom().GetAdminUserIds(), 2) {
|
||||
t.Fatalf("kick should ban user2 and remove admin: %+v", kickResp.GetRoom())
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); !xerr.IsCode(err, xerr.PermissionDenied) {
|
||||
t.Fatalf("banned user should not join, got %v", err)
|
||||
}
|
||||
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{RoomId: roomID, UserId: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("VerifyRoomPresence failed: %v", err)
|
||||
}
|
||||
if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
|
||||
t.Fatalf("banned user should fail presence guard: %+v", presenceResp)
|
||||
}
|
||||
|
||||
_, err = svc.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 3), TargetUserId: 2})
|
||||
if !xerr.IsCode(err, xerr.PermissionDenied) {
|
||||
t.Fatalf("admin should not unban, got %v", err)
|
||||
}
|
||||
unbanResp, err := svc.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("owner UnbanUser failed: %v", err)
|
||||
}
|
||||
if containsInt64(unbanResp.GetRoom().GetBanUserIds(), 2) || containsInt64(unbanResp.GetRoom().GetAdminUserIds(), 2) {
|
||||
t.Fatalf("unban should not restore ban or admin state: %+v", unbanResp.GetRoom())
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("unbanned user should join again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandIDPayloadConflictIgnoresTraceFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := newRoomService("node-a", 1, router.NewMemoryDirectory(), repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||
roomID := "room-command-conflict"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
|
||||
firstMeta := roomservice.NewRequestMeta(roomID, 1)
|
||||
firstMeta.CommandId = "cmd-chat-disable-fixed"
|
||||
firstMeta.RequestId = "req-first"
|
||||
if _, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: firstMeta, Enabled: false}); err != nil {
|
||||
t.Fatalf("first SetChatEnabled failed: %v", err)
|
||||
}
|
||||
|
||||
retryMeta := roomservice.NewRequestMeta(roomID, 1)
|
||||
retryMeta.CommandId = firstMeta.GetCommandId()
|
||||
retryMeta.RequestId = "req-retry"
|
||||
retryMeta.SessionId = "different-session"
|
||||
retryMeta.SentAtMs = firstMeta.GetSentAtMs() + 1000
|
||||
retryResp, err := svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: retryMeta, Enabled: false})
|
||||
if err != nil {
|
||||
t.Fatalf("same command with different trace fields should be idempotent: %v", err)
|
||||
}
|
||||
if retryResp.GetResult().GetApplied() {
|
||||
t.Fatalf("idempotent retry should not apply again")
|
||||
}
|
||||
|
||||
_, err = svc.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: retryMeta, Enabled: true})
|
||||
if !xerr.IsCode(err, xerr.Conflict) {
|
||||
t.Fatalf("same command_id with different business payload should conflict, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomManagementRecoveryReplay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
directory := router.NewMemoryDirectory()
|
||||
serviceA := newRoomService("node-a", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||
roomID := "room-management-replay"
|
||||
|
||||
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatCount: 4, Mode: "social"}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 2)}); err != nil {
|
||||
t.Fatalf("JoinRoom user2 failed: %v", err)
|
||||
}
|
||||
if _, err := serviceA.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 3)}); err != nil {
|
||||
t.Fatalf("JoinRoom user3 failed: %v", err)
|
||||
}
|
||||
if _, err := serviceA.SetMicSeatLock(ctx, &roomv1.SetMicSeatLockRequest{Meta: roomservice.NewRequestMeta(roomID, 1), SeatNo: 4, Locked: true}); err != nil {
|
||||
t.Fatalf("SetMicSeatLock failed: %v", err)
|
||||
}
|
||||
if _, err := serviceA.SetChatEnabled(ctx, &roomv1.SetChatEnabledRequest{Meta: roomservice.NewRequestMeta(roomID, 1), Enabled: false}); err != nil {
|
||||
t.Fatalf("SetChatEnabled failed: %v", err)
|
||||
}
|
||||
if _, err := serviceA.SetRoomAdmin(ctx, &roomv1.SetRoomAdminRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2, Enabled: true}); err != nil {
|
||||
t.Fatalf("SetRoomAdmin failed: %v", err)
|
||||
}
|
||||
if _, err := serviceA.TransferRoomHost(ctx, &roomv1.TransferRoomHostRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 2}); err != nil {
|
||||
t.Fatalf("TransferRoomHost failed: %v", err)
|
||||
}
|
||||
if _, err := serviceA.KickUser(ctx, &roomv1.KickUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3}); err != nil {
|
||||
t.Fatalf("KickUser failed: %v", err)
|
||||
}
|
||||
if _, err := serviceA.UnbanUser(ctx, &roomv1.UnbanUserRequest{Meta: roomservice.NewRequestMeta(roomID, 1), TargetUserId: 3}); err != nil {
|
||||
t.Fatalf("UnbanUser failed: %v", err)
|
||||
}
|
||||
|
||||
directory.ForceExpire(roomID)
|
||||
serviceB := newRoomService("node-b", 100, directory, repository, &fakeSyncPublisher{}, &fakeOutboxPublisher{})
|
||||
joinResp, err := serviceB.JoinRoom(ctx, &roomv1.JoinRoomRequest{Meta: roomservice.NewRequestMeta(roomID, 4)})
|
||||
if err != nil {
|
||||
t.Fatalf("JoinRoom on recovered service failed: %v", err)
|
||||
}
|
||||
snapshot := joinResp.GetRoom()
|
||||
if snapshot.GetHostUserId() != 2 || !containsInt64(snapshot.GetAdminUserIds(), 2) {
|
||||
t.Fatalf("host/admin state did not replay: %+v", snapshot)
|
||||
}
|
||||
if snapshot.GetChatEnabled() {
|
||||
t.Fatalf("chat_enabled should replay as false")
|
||||
}
|
||||
if seat := findSeat(snapshot, 4); seat == nil || !seat.GetLocked() {
|
||||
t.Fatalf("locked seat did not replay: %+v", seat)
|
||||
}
|
||||
if containsInt64(snapshot.GetBanUserIds(), 3) {
|
||||
t.Fatalf("unban should replay after kick: %+v", snapshot.GetBanUserIds())
|
||||
}
|
||||
}
|
||||
|
||||
// TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent 验证重连只刷新 presence,不重复广播进房。
|
||||
func TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
@ -323,11 +733,9 @@ func TestRepeatedJoinRefreshesPresenceWithoutDuplicateJoinEvent(t *testing.T) {
|
||||
roomID := "room-repeat-join"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
OwnerUserId: 1,
|
||||
HostUserId: 1,
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
@ -367,11 +775,9 @@ func TestLeaveRoomReleasesSeatAndBlocksGuards(t *testing.T) {
|
||||
roomID := "room-leave-seat"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
OwnerUserId: 1,
|
||||
HostUserId: 1,
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
@ -415,11 +821,9 @@ func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) {
|
||||
|
||||
// node-a 先创建并推进房间状态,snapshotEveryN=2 会让恢复需要结合快照和命令日志。
|
||||
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
OwnerUserId: 1,
|
||||
HostUserId: 1,
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
@ -432,11 +836,10 @@ func TestRoomRecoveryAfterLeaseTakeover(t *testing.T) {
|
||||
}
|
||||
|
||||
if _, err := serviceA.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
TargetUserId: 2,
|
||||
GiftId: "rose",
|
||||
GiftCount: 1,
|
||||
GiftUnitValue: 10,
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
TargetUserId: 2,
|
||||
GiftId: "rose",
|
||||
GiftCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("SendGift failed: %v", err)
|
||||
}
|
||||
@ -482,11 +885,9 @@ func TestGuardRecoveryReplaysCommandsAfterSnapshot(t *testing.T) {
|
||||
roomID := "room-guard-recover"
|
||||
|
||||
if _, err := serviceA.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
OwnerUserId: 1,
|
||||
HostUserId: 1,
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
@ -523,11 +924,9 @@ func TestSweepStalePresenceRemovesUserAndReleasesSeat(t *testing.T) {
|
||||
roomID := "room-stale"
|
||||
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
OwnerUserId: 1,
|
||||
HostUserId: 1,
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
@ -581,11 +980,9 @@ func TestRoomOutboxCompensationWhenSyncPublishFails(t *testing.T) {
|
||||
|
||||
// 创建和进房先建立可送礼的基本房间态。
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
OwnerUserId: 1,
|
||||
HostUserId: 1,
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
SeatCount: 4,
|
||||
Mode: "social",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateRoom failed: %v", err)
|
||||
}
|
||||
@ -598,11 +995,10 @@ func TestRoomOutboxCompensationWhenSyncPublishFails(t *testing.T) {
|
||||
}
|
||||
|
||||
if _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
TargetUserId: 2,
|
||||
GiftId: "car",
|
||||
GiftCount: 1,
|
||||
GiftUnitValue: 100,
|
||||
Meta: roomservice.NewRequestMeta(roomID, 1),
|
||||
TargetUserId: 2,
|
||||
GiftId: "car",
|
||||
GiftCount: 1,
|
||||
}); err != nil {
|
||||
t.Fatalf("SendGift failed: %v", err)
|
||||
}
|
||||
@ -770,6 +1166,15 @@ func findSeat(snapshot *roomv1.RoomSnapshot, seatNo int32) *roomv1.SeatState {
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsInt64(values []int64, target int64) bool {
|
||||
for _, value := range values {
|
||||
if value == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasSyncEventReason(events []tencentim.RoomEvent, reason string) bool {
|
||||
for _, event := range events {
|
||||
if event.EventType == "room_user_left" && event.Attributes["reason"] == reason {
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
"hyapp/services/room-service/internal/room/rank"
|
||||
)
|
||||
|
||||
// currentSnapshot 返回当前房间的最新快照;没有内存 Cell 时会从持久化恢复后再返回。
|
||||
func (s *Service) currentSnapshot(ctx context.Context, roomID string) (*roomv1.RoomSnapshot, error) {
|
||||
if roomCell := s.loadCell(roomID); roomCell != nil {
|
||||
// 内存 Cell 是最新状态来源,guard 查询优先读它。
|
||||
@ -38,6 +39,7 @@ func (s *Service) currentSnapshot(ctx context.Context, roomID string) (*roomv1.R
|
||||
return restoredState.ToProto(), nil
|
||||
}
|
||||
|
||||
// persistSnapshot 持久化 protobuf 快照,作为后续恢复和 guard 查询的快速入口。
|
||||
func (s *Service) persistSnapshot(ctx context.Context, snapshot *roomv1.RoomSnapshot) error {
|
||||
// 快照统一使用 protobuf,保持和跨服务契约一致。
|
||||
payload, err := proto.Marshal(snapshot)
|
||||
@ -53,6 +55,7 @@ func (s *Service) persistSnapshot(ctx context.Context, snapshot *roomv1.RoomSnap
|
||||
})
|
||||
}
|
||||
|
||||
// shouldSnapshot 判定当前命令结果是否需要落快照。
|
||||
func shouldSnapshot(snapshot *roomv1.RoomSnapshot, every int64) bool {
|
||||
if snapshot == nil || every <= 1 {
|
||||
// every<=1 表示每次有快照结果都保存,nil 快照永远不保存。
|
||||
|
||||
@ -7,6 +7,15 @@ import (
|
||||
roomv1 "hyapp/api/proto/room/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
// MicPublishIdle 表示麦位没有正在确认或已确认的 RTC 发流会话。
|
||||
MicPublishIdle = "idle"
|
||||
// MicPublishPending 表示业务上麦成功,但客户端/RTC 尚未确认音频发布成功。
|
||||
MicPublishPending = "pending_publish"
|
||||
// MicPublishPublishing 表示当前 mic_session 已确认在 RTC 侧发布音频。
|
||||
MicPublishPublishing = "publishing"
|
||||
)
|
||||
|
||||
// MicSeat 表达房间内单个麦位的占用状态。
|
||||
type MicSeat struct {
|
||||
// SeatNo 是房间内稳定麦位编号,不使用切片下标作为对外标识。
|
||||
@ -15,6 +24,16 @@ type MicSeat struct {
|
||||
UserID int64
|
||||
// Locked 表示麦位被锁定,锁定麦位不能上麦或换入。
|
||||
Locked bool
|
||||
// PublishState 区分业务占麦和 RTC 发流确认状态,避免把 MicUp 当作已发流。
|
||||
PublishState string
|
||||
// MicSessionID 标识单次上麦发流会话,确认和超时清理都必须匹配它。
|
||||
MicSessionID string
|
||||
// PublishDeadlineMS 是 pending_publish 自动释放麦位的截止时间。
|
||||
PublishDeadlineMS int64
|
||||
// MicSessionRoomVersion 是创建该会话的房间版本,用于丢弃旧版本 RTC 事件。
|
||||
MicSessionRoomVersion int64
|
||||
// LastPublishEventTimeMS 是已接受的最新 RTC/客户端发布事件时间。
|
||||
LastPublishEventTimeMS int64
|
||||
}
|
||||
|
||||
// RoomUserState 只保存 room-service 真正需要的轻量用户态。
|
||||
@ -175,6 +194,20 @@ func (s *RoomState) SeatByUser(userID int64) (MicSeat, bool) {
|
||||
return MicSeat{}, false
|
||||
}
|
||||
|
||||
// ClearMicSession 清空麦位占用和 RTC 发流会话字段。
|
||||
func (s *RoomState) ClearMicSession(index int) {
|
||||
if index < 0 || index >= len(s.MicSeats) {
|
||||
return
|
||||
}
|
||||
|
||||
seatNo := s.MicSeats[index].SeatNo
|
||||
locked := s.MicSeats[index].Locked
|
||||
s.MicSeats[index] = MicSeat{
|
||||
SeatNo: seatNo,
|
||||
Locked: locked,
|
||||
}
|
||||
}
|
||||
|
||||
// SeatIndex 返回指定麦位在切片中的下标。
|
||||
func (s *RoomState) SeatIndex(seatNo int32) int {
|
||||
// 返回 -1 表示外部传入的麦位编号不存在。
|
||||
@ -222,9 +255,14 @@ func (s *RoomState) ToProto() *roomv1.RoomSnapshot {
|
||||
for _, seat := range s.MicSeats {
|
||||
// 麦位顺序沿用内部切片顺序,即初始化的 SeatNo 升序和后续原位修改。
|
||||
seats = append(seats, &roomv1.SeatState{
|
||||
SeatNo: seat.SeatNo,
|
||||
UserId: seat.UserID,
|
||||
Locked: seat.Locked,
|
||||
SeatNo: seat.SeatNo,
|
||||
UserId: seat.UserID,
|
||||
Locked: seat.Locked,
|
||||
PublishState: seat.PublishState,
|
||||
MicSessionId: seat.MicSessionID,
|
||||
PublishDeadlineMs: seat.PublishDeadlineMS,
|
||||
MicSessionRoomVersion: seat.MicSessionRoomVersion,
|
||||
LastPublishEventTimeMs: seat.LastPublishEventTimeMS,
|
||||
})
|
||||
}
|
||||
|
||||
@ -288,9 +326,14 @@ func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState {
|
||||
for _, seat := range snapshot.GetMicSeats() {
|
||||
// protobuf 空字段会映射成零值,UserID=0 仍表示空麦位。
|
||||
restored.MicSeats = append(restored.MicSeats, MicSeat{
|
||||
SeatNo: seat.GetSeatNo(),
|
||||
UserID: seat.GetUserId(),
|
||||
Locked: seat.GetLocked(),
|
||||
SeatNo: seat.GetSeatNo(),
|
||||
UserID: seat.GetUserId(),
|
||||
Locked: seat.GetLocked(),
|
||||
PublishState: seat.GetPublishState(),
|
||||
MicSessionID: seat.GetMicSessionId(),
|
||||
PublishDeadlineMS: seat.GetPublishDeadlineMs(),
|
||||
MicSessionRoomVersion: seat.GetMicSessionRoomVersion(),
|
||||
LastPublishEventTimeMS: seat.GetLastPublishEventTimeMs(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -308,6 +351,9 @@ func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState {
|
||||
// set 在 protobuf 中用数组表达,恢复时重新转为 map。
|
||||
restored.AdminUsers[userID] = true
|
||||
}
|
||||
// owner 和 host 是派生管理员身份,快照恢复后必须重新压实该不变量。
|
||||
restored.AdminUsers[restored.OwnerUserID] = true
|
||||
restored.AdminUsers[restored.HostUserID] = true
|
||||
|
||||
for _, userID := range snapshot.GetBanUserIds() {
|
||||
restored.BanUsers[userID] = true
|
||||
|
||||
@ -160,11 +160,11 @@ func (r *Repository) GetRoomMeta(ctx context.Context, roomID string) (roomservic
|
||||
return meta, true, nil
|
||||
}
|
||||
|
||||
// HasCommand 判断命令是否已经成功落盘。
|
||||
func (r *Repository) HasCommand(ctx context.Context, roomID string, commandID string) (bool, error) {
|
||||
// GetCommand 读取已提交命令,用于幂等重试和 command_id 冲突判定。
|
||||
func (r *Repository) GetCommand(ctx context.Context, roomID string, commandID string) (roomservice.CommandRecord, bool, error) {
|
||||
// 幂等检查只看 command log,只有成功提交过的命令才算 seen。
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT 1
|
||||
`SELECT room_id, room_version, command_id, command_type, payload, replayable, created_at
|
||||
FROM room_command_log
|
||||
WHERE room_id = ? AND command_id = ?
|
||||
LIMIT 1`,
|
||||
@ -172,17 +172,17 @@ func (r *Repository) HasCommand(ctx context.Context, roomID string, commandID st
|
||||
commandID,
|
||||
)
|
||||
|
||||
var marker int
|
||||
if err := row.Scan(&marker); err != nil {
|
||||
var record roomservice.CommandRecord
|
||||
if err := row.Scan(&record.RoomID, &record.RoomVersion, &record.CommandID, &record.CommandType, &record.Payload, &record.Replayable, &record.CreatedAt); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
// 没有命令日志表示本命令尚未成功提交。
|
||||
return false, nil
|
||||
return roomservice.CommandRecord{}, false, nil
|
||||
}
|
||||
|
||||
return false, err
|
||||
return roomservice.CommandRecord{}, false, err
|
||||
}
|
||||
|
||||
return true, nil
|
||||
return record, true, nil
|
||||
}
|
||||
|
||||
// SaveCommand 追加一条成功命令日志。
|
||||
|
||||
@ -11,9 +11,9 @@ import (
|
||||
|
||||
// Server 把 room-service 领域实现挂到 gRPC 接口上。
|
||||
type Server struct {
|
||||
// UnimplementedRoomCommandServiceServer 保持新增 RPC 时的向前兼容默认行为。
|
||||
// UnimplementedRoomCommandServiceServer 提供未实现 RPC 的标准 gRPC 返回。
|
||||
roomv1.UnimplementedRoomCommandServiceServer
|
||||
// UnimplementedRoomGuardServiceServer 保持守卫服务新增 RPC 时的向前兼容默认行为。
|
||||
// UnimplementedRoomGuardServiceServer 提供守卫服务未实现 RPC 的标准 gRPC 返回。
|
||||
roomv1.UnimplementedRoomGuardServiceServer
|
||||
|
||||
// svc 是领域服务入口,gRPC 层不保存任何房间状态。
|
||||
@ -73,6 +73,36 @@ func (s *Server) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatReq
|
||||
return mapServiceResult(s.svc.ChangeMicSeat(ctx, req))
|
||||
}
|
||||
|
||||
// ConfirmMicPublishing 代理到领域服务。
|
||||
func (s *Server) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmMicPublishingRequest) (*roomv1.ConfirmMicPublishingResponse, error) {
|
||||
// 发流确认必须进入 Room Cell 串行状态机,避免旧 RTC 事件覆盖新 mic_session。
|
||||
return mapServiceResult(s.svc.ConfirmMicPublishing(ctx, req))
|
||||
}
|
||||
|
||||
// SetMicSeatLock 代理到领域服务。
|
||||
func (s *Server) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLockRequest) (*roomv1.SetMicSeatLockResponse, error) {
|
||||
// 锁麦权限和目标麦位状态由 Room Cell 判定。
|
||||
return mapServiceResult(s.svc.SetMicSeatLock(ctx, req))
|
||||
}
|
||||
|
||||
// SetChatEnabled 代理到领域服务。
|
||||
func (s *Server) SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabledRequest) (*roomv1.SetChatEnabledResponse, error) {
|
||||
// 公屏开关是 room-service 发言守卫的权威状态。
|
||||
return mapServiceResult(s.svc.SetChatEnabled(ctx, req))
|
||||
}
|
||||
|
||||
// SetRoomAdmin 代理到领域服务。
|
||||
func (s *Server) SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequest) (*roomv1.SetRoomAdminResponse, error) {
|
||||
// 管理员集合只能由 room-service 权限矩阵修改。
|
||||
return mapServiceResult(s.svc.SetRoomAdmin(ctx, req))
|
||||
}
|
||||
|
||||
// TransferRoomHost 代理到领域服务。
|
||||
func (s *Server) TransferRoomHost(ctx context.Context, req *roomv1.TransferRoomHostRequest) (*roomv1.TransferRoomHostResponse, error) {
|
||||
// 主持人转移由 Room Cell 串行提交,并进入 command log/outbox。
|
||||
return mapServiceResult(s.svc.TransferRoomHost(ctx, req))
|
||||
}
|
||||
|
||||
// MuteUser 代理到领域服务。
|
||||
func (s *Server) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) {
|
||||
// 禁言状态是腾讯云 IM 发言回调的权威来源。
|
||||
@ -85,6 +115,12 @@ func (s *Server) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*ro
|
||||
return mapServiceResult(s.svc.KickUser(ctx, req))
|
||||
}
|
||||
|
||||
// UnbanUser 代理到领域服务。
|
||||
func (s *Server) UnbanUser(ctx context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error) {
|
||||
// 解封只解除 ban,不恢复 presence、管理员身份或麦位。
|
||||
return mapServiceResult(s.svc.UnbanUser(ctx, req))
|
||||
}
|
||||
|
||||
// SendGift 代理到领域服务。
|
||||
func (s *Server) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*roomv1.SendGiftResponse, error) {
|
||||
// SendGift 先同步扣费,成功后才更新房间热度和本地榜。
|
||||
|
||||
@ -44,10 +44,8 @@ func TestCreateRoomMapsDomainErrorToGRPCReason(t *testing.T) {
|
||||
|
||||
_, err := server.CreateRoom(context.Background(), &roomv1.CreateRoomRequest{
|
||||
// room_id 缺失属于客户端参数错误,不能在 gRPC 边界丢失 reason 后变成 INTERNAL_ERROR。
|
||||
OwnerUserId: 1,
|
||||
HostUserId: 1,
|
||||
SeatCount: 8,
|
||||
Mode: "voice",
|
||||
SeatCount: 8,
|
||||
Mode: "voice",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("CreateRoom without room_id should fail")
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
service_name: user-service
|
||||
node_id: user-local
|
||||
grpc_addr: ":13005"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:13306)/hy_user?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_user?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
id_generator:
|
||||
node_id: 1
|
||||
|
||||
@ -54,14 +54,6 @@ CREATE TABLE IF NOT EXISTS countries (
|
||||
KEY idx_countries_status_sort (status, sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS country_aliases (
|
||||
alias_code VARCHAR(3) NOT NULL PRIMARY KEY,
|
||||
country_code VARCHAR(3) NOT NULL,
|
||||
created_at_ms BIGINT NOT NULL,
|
||||
updated_at_ms BIGINT NOT NULL,
|
||||
KEY idx_country_aliases_country (country_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS regions (
|
||||
region_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
region_code VARCHAR(64) NOT NULL,
|
||||
@ -140,9 +132,6 @@ INSERT IGNORE INTO countries (country_name, country_code, country_display_name,
|
||||
('Macao', 'MO', '中国澳门', 'active', 110, 0, 0),
|
||||
('Taiwan', 'TW', '中国台湾', 'active', 120, 0, 0);
|
||||
|
||||
INSERT IGNORE INTO country_aliases (alias_code, country_code, created_at_ms, updated_at_ms) VALUES
|
||||
('USA', 'US', 0, 0);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_display_user_ids (
|
||||
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
display_user_id VARCHAR(32) NOT NULL,
|
||||
|
||||
@ -56,9 +56,6 @@ func New(cfg config.Config) (*App, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var userRepository userservice.Repository = mysqlRepo
|
||||
var authRepository authservice.Repository = mysqlRepo
|
||||
|
||||
// listener 在 New 阶段创建,端口占用会作为启动失败返回。
|
||||
listener, err := net.Listen("tcp", cfg.GRPCAddr)
|
||||
if err != nil {
|
||||
@ -77,13 +74,21 @@ func New(cfg config.Config) (*App, error) {
|
||||
SigningAlg: cfg.JWT.SigningAlg,
|
||||
SigningSecret: cfg.JWT.SigningSecret,
|
||||
},
|
||||
authservice.WithRepository(authRepository),
|
||||
authservice.WithAuthRepository(mysqlRepo),
|
||||
authservice.WithUserRepository(mysqlRepo),
|
||||
authservice.WithIdentityRepository(mysqlRepo),
|
||||
authservice.WithCountryRegionRepository(mysqlRepo),
|
||||
authservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)),
|
||||
authservice.WithDisplayUserIDAllocateMaxAttempts(cfg.DisplayUserID.AllocateMaxAttempts),
|
||||
authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier(cfg.ThirdParty.AllowedProviders)),
|
||||
)
|
||||
// user service 负责用户主状态和 display_user_id/靓号用例,不进入房间高频流程。
|
||||
userSvc := userservice.New(userRepository,
|
||||
userSvc := userservice.New(mysqlRepo,
|
||||
userservice.WithIdentityRepository(mysqlRepo),
|
||||
userservice.WithCountryRegionRepository(mysqlRepo),
|
||||
userservice.WithCountryAdminRepository(mysqlRepo),
|
||||
userservice.WithRegionAdminRepository(mysqlRepo),
|
||||
userservice.WithRegionRebuildRepository(mysqlRepo),
|
||||
userservice.WithIDGenerator(idgen.NewInt64Generator(cfg.IDGenerator.NodeID)),
|
||||
userservice.WithDisplayUserIDPolicy(cfg.DisplayUserID.AllocateMaxAttempts, time.Duration(cfg.DisplayUserID.ChangeCooldownSec)*time.Second),
|
||||
)
|
||||
|
||||
@ -80,7 +80,7 @@ func Default() Config {
|
||||
ServiceName: "user-service",
|
||||
NodeID: "user-local",
|
||||
GRPCAddr: ":13005",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:13306)/hy_user?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLDSN: "hyapp:hyapp@tcp(127.0.0.1:23306)/hy_user?parseTime=true&charset=utf8mb4&loc=Local",
|
||||
MySQLAutoMigrate: false,
|
||||
IDGenerator: IDGeneratorConfig{
|
||||
NodeID: 1,
|
||||
|
||||
@ -33,11 +33,6 @@ var (
|
||||
regionCodePattern = regexp.MustCompile(`^[A-Z0-9_]{2,32}$`)
|
||||
)
|
||||
|
||||
var defaultCountryAliases = map[string]string{
|
||||
// USA 是当前 seed 中唯一需要兼容的 alpha-3 输入;canonical 仍统一写 US,避免美国被配置到两个区域。
|
||||
"USA": "US",
|
||||
}
|
||||
|
||||
// Country 是国家主数据;外部只提交 CountryCode,CountryID 只用于管理和审计。
|
||||
type Country struct {
|
||||
CountryID int64
|
||||
@ -203,16 +198,6 @@ func ValidCountryCode(code string) bool {
|
||||
return countryCodePattern.MatchString(code)
|
||||
}
|
||||
|
||||
// DefaultCountryAliases 返回内置 alias -> canonical 映射;生产 MySQL 通过 country_aliases 表持久化同一规则。
|
||||
func DefaultCountryAliases() map[string]string {
|
||||
aliases := make(map[string]string, len(defaultCountryAliases))
|
||||
for alias, canonical := range defaultCountryAliases {
|
||||
aliases[alias] = canonical
|
||||
}
|
||||
|
||||
return aliases
|
||||
}
|
||||
|
||||
// NormalizeRegionCode 统一区域业务编码形态。
|
||||
func NormalizeRegionCode(code string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(code))
|
||||
|
||||
@ -294,28 +294,6 @@ func (u User) EffectiveIdentity() Identity {
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeDisplayUserIDState 补齐老测试数据或迁移窗口中的默认短号字段。
|
||||
func (u User) NormalizeDisplayUserIDState() User {
|
||||
if u.DefaultDisplayUserID == "" {
|
||||
// 兼容旧测试或迁移窗口中只有 current_display_user_id 的用户。
|
||||
u.DefaultDisplayUserID = u.CurrentDisplayUserID
|
||||
}
|
||||
if u.CurrentDisplayUserID == "" {
|
||||
// 当前展示号缺失时回退到默认短号,避免返回空短号。
|
||||
u.CurrentDisplayUserID = u.DefaultDisplayUserID
|
||||
}
|
||||
if u.CurrentDisplayUserIDKind == "" {
|
||||
// 未写 kind 的老数据按默认短号处理。
|
||||
u.CurrentDisplayUserIDKind = DisplayUserIDKindDefault
|
||||
}
|
||||
if u.CurrentDisplayUserIDKind == DisplayUserIDKindDefault {
|
||||
// 默认短号没有过期时间,清理脏数据中的 expires_at。
|
||||
u.CurrentDisplayUserIDExpiresAtMs = 0
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
// ValidDisplayUserID 校验短号格式。
|
||||
func ValidDisplayUserID(value string) bool {
|
||||
// 格式校验只判断形态,唯一性和可用性由 repository 事务决定。
|
||||
|
||||
@ -67,14 +67,6 @@ type IdentityRepository interface {
|
||||
ExpirePrettyDisplayUserID(ctx context.Context, command userdomain.ExpirePrettyDisplayUserIDCommand) (userdomain.Identity, error)
|
||||
}
|
||||
|
||||
// Repository 是兼容旧 WithRepository 调用的组合接口。
|
||||
// 新增实现可以分别注入 AuthRepository、UserRepository 和 IdentityRepository,避免继续扩大单一接口。
|
||||
type Repository interface {
|
||||
AuthRepository
|
||||
UserRepository
|
||||
IdentityRepository
|
||||
}
|
||||
|
||||
// ThirdPartyVerifier 把 provider credential 校验成稳定 provider_subject。
|
||||
// gateway 不接触 provider secret,三方校验必须停留在 user-service。
|
||||
type ThirdPartyVerifier interface {
|
||||
@ -143,20 +135,6 @@ func New(cfg Config, options ...Option) *Service {
|
||||
return svc
|
||||
}
|
||||
|
||||
// WithRepository 注入认证持久化实现。
|
||||
func WithRepository(repository Repository) Option {
|
||||
return func(s *Service) {
|
||||
// 兼容旧构造方式:同一个 repository 同时承载 auth/user/identity 能力。
|
||||
s.authRepository = repository
|
||||
s.userRepository = repository
|
||||
s.identityRepository = repository
|
||||
if countryRegionRepository, ok := repository.(userservice.CountryRegionRepository); ok {
|
||||
// 国家主数据校验是注册写 users.country 前的强约束。
|
||||
s.countryRegionRepository = countryRegionRepository
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithAuthRepository 注入纯认证持久化实现。
|
||||
func WithAuthRepository(repository AuthRepository) Option {
|
||||
return func(s *Service) {
|
||||
|
||||
@ -46,6 +46,17 @@ func (a sequenceDisplayUserIDAllocator) NextDisplayUserID(_ int64, attempt int)
|
||||
return a.values[attempt]
|
||||
}
|
||||
|
||||
func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service {
|
||||
base := []userservice.Option{
|
||||
userservice.WithIdentityRepository(repository),
|
||||
userservice.WithCountryRegionRepository(repository),
|
||||
userservice.WithCountryAdminRepository(repository),
|
||||
userservice.WithRegionAdminRepository(repository),
|
||||
userservice.WithRegionRebuildRepository(repository),
|
||||
}
|
||||
return userservice.New(repository, append(base, options...)...)
|
||||
}
|
||||
|
||||
func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int64, displayIDs []string) *authservice.Service {
|
||||
// 测试服务固定 JWT、发号器、短号候选和时钟,保证 token/session 语义可断言。
|
||||
return authservice.New(authservice.Config{
|
||||
@ -55,7 +66,10 @@ func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int6
|
||||
SigningAlg: "HS256",
|
||||
SigningSecret: "test-secret",
|
||||
},
|
||||
authservice.WithRepository(repository),
|
||||
authservice.WithAuthRepository(repository),
|
||||
authservice.WithUserRepository(repository),
|
||||
authservice.WithIdentityRepository(repository),
|
||||
authservice.WithCountryRegionRepository(repository),
|
||||
authservice.WithIDGenerator(&sequenceIDGenerator{values: ids}),
|
||||
authservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: displayIDs}),
|
||||
authservice.WithClock(func() time.Time { return *now }),
|
||||
@ -75,7 +89,7 @@ func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) us
|
||||
} else if ok {
|
||||
return country
|
||||
}
|
||||
svc := userservice.New(repository)
|
||||
svc := newUserService(repository)
|
||||
country, err := svc.CreateCountry(context.Background(), code+" Name", code, code+" Display", 0, 1, "seed-country-"+code)
|
||||
if err != nil {
|
||||
t.Fatalf("seed country %s failed: %v", code, err)
|
||||
@ -85,7 +99,7 @@ func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) us
|
||||
|
||||
func seedRegion(t *testing.T, repository *mysqltest.Repository, code string, countries []string) userdomain.Region {
|
||||
t.Helper()
|
||||
svc := userservice.New(repository)
|
||||
svc := newUserService(repository)
|
||||
region, err := svc.CreateRegion(context.Background(), code, code+" Region", countries, 0, 1, "seed-region-"+code)
|
||||
if err != nil {
|
||||
t.Fatalf("seed region %s failed: %v", code, err)
|
||||
@ -315,40 +329,12 @@ func TestThirdPartyRegisterAssignsRegionAndExistingLoginKeepsOriginalCountry(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestThirdPartyRegisterCanonicalizesCountryAlias(t *testing.T) {
|
||||
// 注册允许客户端传 USA alias,但 users.country 和区域快照必须落 canonical US。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "US")
|
||||
region := seedRegion(t, repository, "NA", []string{"USA"})
|
||||
now := time.UnixMilli(1000)
|
||||
svc := newAuthService(repository, &now, []int64{900009}, []string{"100009"})
|
||||
|
||||
token, isNew, err := svc.LoginThirdParty(ctx, "wechat", "openid-us-alias", authdomain.ThirdPartyRegistration{
|
||||
DeviceID: "ios",
|
||||
Country: "usa",
|
||||
}, authservice.Meta{RequestID: "req-us-alias"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginThirdParty failed: %v", err)
|
||||
}
|
||||
if !isNew {
|
||||
t.Fatalf("first third-party login must create user")
|
||||
}
|
||||
user, err := repository.GetUser(ctx, token.UserID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser failed: %v", err)
|
||||
}
|
||||
if user.Country != "US" || user.RegionID != region.RegionID || user.RegionCode != "NA" {
|
||||
t.Fatalf("country alias should canonicalize to US: %+v", user)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThirdPartyRegisterRejectsUnknownOrDisabledCountry(t *testing.T) {
|
||||
// 国家格式合法但未命中 active countries 时,也必须返回稳定 INVALID_ARGUMENT。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
country := seedCountry(t, repository, "MY")
|
||||
userSvc := userservice.New(repository)
|
||||
userSvc := newUserService(repository)
|
||||
if _, err := userSvc.DisableCountry(ctx, country.CountryID, 1, "disable-my"); err != nil {
|
||||
t.Fatalf("DisableCountry failed: %v", err)
|
||||
}
|
||||
@ -447,7 +433,7 @@ func TestThirdPartyRegisterRetriesDisplayUserIDConflict(t *testing.T) {
|
||||
t.Fatalf("LoginThirdParty should retry display_user_id conflict: %v", err)
|
||||
}
|
||||
|
||||
userSvc := userservice.New(repository)
|
||||
userSvc := newUserService(repository)
|
||||
identity, err := userSvc.GetUserIdentity(ctx, token.UserID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserIdentity failed: %v", err)
|
||||
@ -463,7 +449,7 @@ func TestPasswordLoginUsesCurrentDisplayUserIDWithPrettyLease(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := time.UnixMilli(1000)
|
||||
authSvc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})
|
||||
userSvc := userservice.New(repository, userservice.WithClock(func() time.Time { return now }))
|
||||
userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
|
||||
|
||||
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-pretty", thirdPartyRegistration("ios"), authservice.Meta{})
|
||||
if err != nil {
|
||||
|
||||
@ -120,11 +120,9 @@ func (s *Service) newSession(userID int64, deviceID string) (authdomain.Session,
|
||||
}
|
||||
|
||||
func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToken string) (authdomain.Token, error) {
|
||||
// 签发前补齐 display_user_id 兼容字段,避免 token claim 出现空短号。
|
||||
user = user.NormalizeDisplayUserIDState()
|
||||
now := s.now()
|
||||
expiresAt := now.Add(time.Duration(s.cfg.AccessTokenTTLSec) * time.Second)
|
||||
// claim 同时包含 sub 字符串和 user_id 数值,兼容不同消费方。
|
||||
// claim 同时包含 JWT subject 和网关鉴权需要的数值 user_id。
|
||||
claims := jwt.MapClaims{
|
||||
"iss": s.cfg.Issuer,
|
||||
"sub": strconv.FormatInt(user.UserID, 10),
|
||||
@ -170,7 +168,6 @@ func (s *Service) freshUser(ctx context.Context, userID int64, nowMs int64, requ
|
||||
if err != nil {
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
user = user.NormalizeDisplayUserIDState()
|
||||
if !user.DisplayUserIDExpired(nowMs) {
|
||||
// 当前展示号仍有效,直接返回规范化用户。
|
||||
return user, nil
|
||||
|
||||
@ -66,8 +66,6 @@ func (s *Service) ExpirePrettyDisplayUserID(ctx context.Context, userID int64, l
|
||||
}
|
||||
|
||||
func (s *Service) refreshExpiredUser(ctx context.Context, user userdomain.User, requestID string) (userdomain.User, error) {
|
||||
// 先补齐迁移窗口字段,避免老数据因为 kind/expires 缺失误判。
|
||||
user = user.NormalizeDisplayUserIDState()
|
||||
nowMs := s.now().UnixMilli()
|
||||
if !user.DisplayUserIDExpired(nowMs) {
|
||||
// 当前展示号仍有效时直接返回用户快照。
|
||||
|
||||
@ -74,13 +74,6 @@ type IdentityRepository interface {
|
||||
ExpirePrettyDisplayUserID(ctx context.Context, command userdomain.ExpirePrettyDisplayUserIDCommand) (userdomain.Identity, error)
|
||||
}
|
||||
|
||||
// Repository 是兼容旧构造方式的组合接口。
|
||||
// 新代码应优先让 Service 字段依赖 UserRepository 和 IdentityRepository,而不是把所有方法塞回一个贫血大接口。
|
||||
type Repository interface {
|
||||
UserRepository
|
||||
IdentityRepository
|
||||
}
|
||||
|
||||
// IDGenerator 生成系统内部不可变 user_id。
|
||||
type IDGenerator interface {
|
||||
// NewInt64 返回全局唯一 user_id。
|
||||
@ -138,27 +131,6 @@ func New(userRepository UserRepository, options ...Option) *Service {
|
||||
displayUserIDCooldown: 30 * 24 * time.Hour,
|
||||
countryChangeCooldown: 30 * 24 * time.Hour,
|
||||
}
|
||||
if identityRepository, ok := userRepository.(IdentityRepository); ok {
|
||||
// MySQL repository 同时实现短号接口时自动启用短号用例。
|
||||
svc.identityRepository = identityRepository
|
||||
}
|
||||
if countryRegionRepository, ok := userRepository.(CountryRegionRepository); ok {
|
||||
// 国家和区域主数据通常与 users 同库,自动接入注册和改国家校验。
|
||||
svc.countryRegionRepository = countryRegionRepository
|
||||
}
|
||||
if countryAdminRepository, ok := userRepository.(CountryAdminRepository); ok {
|
||||
// 管理 RPC 只在 repository 明确实现完整事务能力时启用。
|
||||
svc.countryAdminRepository = countryAdminRepository
|
||||
}
|
||||
if regionAdminRepository, ok := userRepository.(RegionAdminRepository); ok {
|
||||
// 区域管理包含国家映射和 rebuild task,必须由同一 repository 保证事务。
|
||||
svc.regionAdminRepository = regionAdminRepository
|
||||
}
|
||||
if regionRebuildRepository, ok := userRepository.(RegionRebuildRepository); ok {
|
||||
// rebuild worker 只消费持久化 task,不参与注册或改国家主链路。
|
||||
svc.regionRebuildRepository = regionRebuildRepository
|
||||
}
|
||||
|
||||
for _, option := range options {
|
||||
// option 只改依赖或策略,不做 IO。
|
||||
option(svc)
|
||||
|
||||
@ -48,6 +48,17 @@ func strptr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service {
|
||||
base := []userservice.Option{
|
||||
userservice.WithIdentityRepository(repository),
|
||||
userservice.WithCountryRegionRepository(repository),
|
||||
userservice.WithCountryAdminRepository(repository),
|
||||
userservice.WithRegionAdminRepository(repository),
|
||||
userservice.WithRegionRebuildRepository(repository),
|
||||
}
|
||||
return userservice.New(repository, append(base, options...)...)
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -55,7 +66,7 @@ func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) us
|
||||
} else if ok {
|
||||
return country
|
||||
}
|
||||
svc := userservice.New(repository)
|
||||
svc := newUserService(repository)
|
||||
country, err := svc.CreateCountry(context.Background(), code+" Name", code, code+" Display", 0, 1, "seed-country-"+code)
|
||||
if err != nil {
|
||||
t.Fatalf("seed country %s failed: %v", code, err)
|
||||
@ -65,7 +76,7 @@ func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) us
|
||||
|
||||
func seedRegion(t *testing.T, repository *mysqltest.Repository, code string, countries []string) userdomain.Region {
|
||||
t.Helper()
|
||||
svc := userservice.New(repository)
|
||||
svc := newUserService(repository)
|
||||
region, err := svc.CreateRegion(context.Background(), code, code+" Region", countries, 0, 1, "seed-region-"+code)
|
||||
if err != nil {
|
||||
t.Fatalf("seed region %s failed: %v", code, err)
|
||||
@ -79,7 +90,7 @@ func TestGetUserUsesRepository(t *testing.T) {
|
||||
// PutUser 是测试辅助入口,直接准备用户主记录。
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})
|
||||
|
||||
svc := userservice.New(repository)
|
||||
svc := newUserService(repository)
|
||||
user, err := svc.GetUser(context.Background(), 10001)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUser failed: %v", err)
|
||||
@ -101,7 +112,7 @@ func TestUpdateUserProfile(t *testing.T) {
|
||||
Status: userdomain.StatusActive,
|
||||
})
|
||||
now := time.UnixMilli(2000)
|
||||
svc := userservice.New(repository, userservice.WithClock(func() time.Time { return now }))
|
||||
svc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
|
||||
|
||||
user, err := svc.UpdateUserProfile(context.Background(), 10001, strptr(" new-name "), strptr(" https://cdn.example/a.png "), strptr("2000-01-02"))
|
||||
if err != nil {
|
||||
@ -164,7 +175,7 @@ func TestChangeUserCountryWritesCooldown(t *testing.T) {
|
||||
seedCountry(t, repository, "JP")
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive})
|
||||
current := time.UnixMilli(1000)
|
||||
svc := userservice.New(repository,
|
||||
svc := newUserService(repository,
|
||||
userservice.WithClock(func() time.Time { return current }),
|
||||
userservice.WithCountryChangeCooldown(time.Hour),
|
||||
)
|
||||
@ -197,10 +208,10 @@ func TestChangeUserCountryWritesCooldown(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCountryAndRegionAdminValidation(t *testing.T) {
|
||||
// 国家管理要接受小写归一,同时拒绝非法国家码、alias 国家码和重复区域归属。
|
||||
// 国家管理要接受小写归一,同时拒绝非法国家码、重复国家码和重复区域归属。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := userservice.New(repository)
|
||||
svc := newUserService(repository)
|
||||
|
||||
aa, err := svc.CreateCountry(ctx, "Alpha Area", " aa ", "测试国家", 10, 1, "req-aa")
|
||||
if err != nil {
|
||||
@ -216,8 +227,8 @@ func TestCountryAndRegionAdminValidation(t *testing.T) {
|
||||
if tla.CountryCode != "TLA" {
|
||||
t.Fatalf("three-letter canonical country code mismatch: %+v", tla)
|
||||
}
|
||||
if _, err := svc.CreateCountry(ctx, "United States", "usa", "美国", 21, 1, "req-usa"); !xerr.IsCode(err, xerr.Conflict) {
|
||||
t.Fatalf("expected alias country conflict, got %v", err)
|
||||
if _, err := svc.CreateCountry(ctx, "Alpha Area Duplicate", "AA", "重复国家", 11, 1, "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) {
|
||||
t.Fatalf("expected invalid country code, got %v", err)
|
||||
@ -235,37 +246,6 @@ func TestCountryAndRegionAdminValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountryAliasCanonicalizesUserAndRegionWrites(t *testing.T) {
|
||||
// alpha-3 alias 只用于输入兼容,users.country 和 region_countries 都必须写 canonical alpha-2。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "US")
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})
|
||||
current := time.UnixMilli(1000)
|
||||
svc := userservice.New(repository,
|
||||
userservice.WithClock(func() time.Time { return current }),
|
||||
userservice.WithCountryChangeCooldown(0),
|
||||
)
|
||||
|
||||
region, err := svc.CreateRegion(ctx, "NA", "North America", []string{"usa"}, 0, 1, "req-na")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegion with USA alias failed: %v", err)
|
||||
}
|
||||
if len(region.Countries) != 1 || region.Countries[0] != "US" {
|
||||
t.Fatalf("region countries should store canonical code: %+v", region)
|
||||
}
|
||||
user, _, err := svc.ChangeUserCountry(ctx, 10001, "USA", "req-country-usa")
|
||||
if err != nil {
|
||||
t.Fatalf("ChangeUserCountry with USA alias failed: %v", err)
|
||||
}
|
||||
if user.Country != "US" || user.RegionID != region.RegionID || user.RegionCode != "NA" {
|
||||
t.Fatalf("user country alias was not canonicalized: %+v", user)
|
||||
}
|
||||
if _, err := svc.ReplaceRegionCountries(ctx, region.RegionID, []string{"US", "USA"}, 1, "req-dup-alias"); !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("expected duplicate canonical countries to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChangeUserCountryUpdatesRegionAndSameCountryRepairsStaleRegion(t *testing.T) {
|
||||
// 改国家同步重算 region_id;相同国家不写冷却日志,但允许修正历史 stale region_id。
|
||||
ctx := context.Background()
|
||||
@ -275,7 +255,7 @@ func TestChangeUserCountryUpdatesRegionAndSameCountryRepairsStaleRegion(t *testi
|
||||
region := seedRegion(t, repository, "SEA", []string{"SG"})
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "US", Status: userdomain.StatusActive})
|
||||
current := time.UnixMilli(1000)
|
||||
svc := userservice.New(repository,
|
||||
svc := newUserService(repository,
|
||||
userservice.WithClock(func() time.Time { return current }),
|
||||
userservice.WithCountryChangeCooldown(time.Hour),
|
||||
)
|
||||
@ -312,7 +292,7 @@ func TestChangeUserCountryRejectsUnknownOrDisabledCountry(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
country := seedCountry(t, repository, "MY")
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "", Status: userdomain.StatusActive})
|
||||
svc := userservice.New(repository, userservice.WithCountryChangeCooldown(0))
|
||||
svc := newUserService(repository, userservice.WithCountryChangeCooldown(0))
|
||||
if _, err := svc.DisableCountry(ctx, country.CountryID, 1, "disable-my"); err != nil {
|
||||
t.Fatalf("DisableCountry failed: %v", err)
|
||||
}
|
||||
@ -332,7 +312,7 @@ func TestRegionRebuildWorkerUpdatesHistoricalUsers(t *testing.T) {
|
||||
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})
|
||||
current := time.UnixMilli(1000)
|
||||
svc := userservice.New(repository, userservice.WithClock(func() time.Time { return current }))
|
||||
svc := newUserService(repository, userservice.WithClock(func() time.Time { return current }))
|
||||
region, err := svc.CreateRegion(ctx, "SEA", "Southeast Asia", []string{"SG"}, 0, 1, "req-sea")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegion failed: %v", err)
|
||||
@ -363,7 +343,7 @@ func TestRegionRebuildWorkerSkipsStaleRevision(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
current := time.UnixMilli(1000)
|
||||
svc := userservice.New(repository, userservice.WithClock(func() time.Time { return current }))
|
||||
svc := newUserService(repository, userservice.WithClock(func() time.Time { return current }))
|
||||
region, err := svc.CreateRegion(ctx, "SEA", "Southeast Asia", []string{"SG"}, 0, 1, "req-sea")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegion failed: %v", err)
|
||||
@ -402,7 +382,7 @@ func TestRegionRebuildWorkerSkipsStaleRevision(t *testing.T) {
|
||||
|
||||
// TestGetUserValidatesID 锁定 service 层的领域错误,不让 transport 层承担参数校验。
|
||||
func TestGetUserValidatesID(t *testing.T) {
|
||||
svc := userservice.New(mysqltest.NewRepository(t))
|
||||
svc := newUserService(mysqltest.NewRepository(t))
|
||||
|
||||
_, err := svc.GetUser(context.Background(), 0)
|
||||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
@ -413,7 +393,7 @@ func TestGetUserValidatesID(t *testing.T) {
|
||||
// TestCreateUserAllocatesDisplayUserID 验证新用户创建时同时生成 user_id 和 active display_user_id。
|
||||
func TestCreateUserAllocatesDisplayUserID(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := userservice.New(repository,
|
||||
svc := newUserService(repository,
|
||||
userservice.WithIDGenerator(&sequenceIDGenerator{values: []int64{900001}}),
|
||||
userservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: []string{"100001"}}),
|
||||
userservice.WithClock(func() time.Time { return time.UnixMilli(1000) }),
|
||||
@ -443,7 +423,7 @@ func TestCreateUserRetriesDisplayUserIDConflict(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
// 先占用 100001,强制 CreateUser 走第二个候选。
|
||||
repository.PutUser(userdomain.User{UserID: 1, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})
|
||||
svc := userservice.New(repository,
|
||||
svc := newUserService(repository,
|
||||
userservice.WithIDGenerator(&sequenceIDGenerator{values: []int64{900001, 900002}}),
|
||||
userservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: []string{"100001", "100002"}}),
|
||||
)
|
||||
@ -460,7 +440,7 @@ func TestCreateUserRetriesDisplayUserIDConflict(t *testing.T) {
|
||||
|
||||
// TestResolveDisplayUserIDNotFound 锁定短号不存在的专用 reason。
|
||||
func TestResolveDisplayUserIDNotFound(t *testing.T) {
|
||||
svc := userservice.New(mysqltest.NewRepository(t))
|
||||
svc := newUserService(mysqltest.NewRepository(t))
|
||||
|
||||
_, err := svc.ResolveDisplayUserID(context.Background(), "100001")
|
||||
if !xerr.IsCode(err, xerr.DisplayUserIDNotFound) {
|
||||
@ -474,7 +454,7 @@ func TestChangeDisplayUserID(t *testing.T) {
|
||||
// 当前默认短号为 100001,修改后旧短号应释放。
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})
|
||||
now := time.UnixMilli(2000)
|
||||
svc := userservice.New(repository,
|
||||
svc := newUserService(repository,
|
||||
userservice.WithClock(func() time.Time { return now }),
|
||||
userservice.WithDisplayUserIDPolicy(8, 30*24*time.Hour),
|
||||
)
|
||||
@ -501,7 +481,7 @@ func TestChangeDisplayUserIDConflictAndCooldown(t *testing.T) {
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Status: userdomain.StatusActive})
|
||||
repository.PutUser(userdomain.User{UserID: 10002, CurrentDisplayUserID: "100002", Status: userdomain.StatusActive})
|
||||
current := time.UnixMilli(3000)
|
||||
svc := userservice.New(repository,
|
||||
svc := newUserService(repository,
|
||||
userservice.WithClock(func() time.Time { return current }),
|
||||
userservice.WithDisplayUserIDPolicy(8, time.Hour),
|
||||
)
|
||||
@ -528,7 +508,7 @@ func TestApplyPrettyDisplayUserIDOverridesAndExpires(t *testing.T) {
|
||||
// 覆盖靓号申请、默认号 held、靓号 active 解析、过期后默认号恢复。
|
||||
repository := mysqltest.NewRepository(t)
|
||||
current := time.UnixMilli(1000)
|
||||
svc := userservice.New(repository,
|
||||
svc := newUserService(repository,
|
||||
userservice.WithIDGenerator(&sequenceIDGenerator{values: []int64{900001}}),
|
||||
userservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: []string{"100001"}}),
|
||||
userservice.WithClock(func() time.Time { return current }),
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"hyapp/pkg/xerr"
|
||||
userdomain "hyapp/services/user-service/internal/domain/user"
|
||||
"hyapp/services/user-service/internal/testutil/mysqlschema"
|
||||
)
|
||||
|
||||
const (
|
||||
countryAliasIntegrationCountry = "ZQ"
|
||||
countryAliasIntegrationAlias = "ZQQ"
|
||||
countryAliasIntegrationRegionCode = "TEST_ALIAS_CANONICAL"
|
||||
countryAliasIntegrationDisplayName = "Alias Country"
|
||||
)
|
||||
|
||||
func TestCountryAliasCanonicalizesRegionWrites(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() })
|
||||
|
||||
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 (?, ?, ?, ?, 998, 1000, 1000)
|
||||
`, "Alias Country", countryAliasIntegrationCountry, countryAliasIntegrationDisplayName, userdomain.CountryStatusActive); err != nil {
|
||||
t.Fatalf("insert country failed: %v", err)
|
||||
}
|
||||
if _, err := repository.db.ExecContext(ctx, `
|
||||
INSERT INTO country_aliases (alias_code, country_code, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, 1000, 1000)
|
||||
`, countryAliasIntegrationAlias, countryAliasIntegrationCountry); err != nil {
|
||||
t.Fatalf("insert country alias failed: %v", err)
|
||||
}
|
||||
|
||||
country, ok, err := repository.ResolveActiveCountryByCode(ctx, countryAliasIntegrationAlias)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveActiveCountryByCode failed: %v", err)
|
||||
}
|
||||
if !ok || country.CountryCode != countryAliasIntegrationCountry {
|
||||
t.Fatalf("alias should resolve to canonical country: ok=%v country=%+v", ok, country)
|
||||
}
|
||||
|
||||
region, err := repository.CreateRegion(ctx, userdomain.CreateRegionCommand{
|
||||
RegionCode: countryAliasIntegrationRegionCode,
|
||||
Name: "Alias Region",
|
||||
Countries: []string{countryAliasIntegrationAlias},
|
||||
OperatorUserID: 1,
|
||||
RequestID: "req-alias-region",
|
||||
NowMs: 2000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRegion failed: %v", err)
|
||||
}
|
||||
if len(region.Countries) != 1 || region.Countries[0] != countryAliasIntegrationCountry {
|
||||
t.Fatalf("region should store canonical country code: %+v", region)
|
||||
}
|
||||
|
||||
var storedCountry string
|
||||
if err := repository.db.QueryRowContext(ctx, `
|
||||
SELECT country_code
|
||||
FROM region_countries
|
||||
WHERE region_id = ? AND status = ?
|
||||
`, region.RegionID, userdomain.RegionStatusActive).Scan(&storedCountry); err != nil {
|
||||
t.Fatalf("query region country failed: %v", err)
|
||||
}
|
||||
if storedCountry != countryAliasIntegrationCountry {
|
||||
t.Fatalf("region_countries should store canonical country code: %s", storedCountry)
|
||||
}
|
||||
|
||||
_, err = repository.ReplaceRegionCountries(ctx, userdomain.ReplaceRegionCountriesCommand{
|
||||
RegionID: region.RegionID,
|
||||
Countries: []string{countryAliasIntegrationCountry, countryAliasIntegrationAlias},
|
||||
OperatorUserID: 1,
|
||||
RequestID: "req-alias-dup",
|
||||
NowMs: 3000,
|
||||
})
|
||||
if !xerr.IsCode(err, xerr.InvalidArgument) {
|
||||
t.Fatalf("expected duplicate canonical countries to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
@ -316,7 +316,6 @@ func (r *Repository) expirePrettyByDisplayUserID(ctx context.Context, displayUse
|
||||
|
||||
func (r *Repository) expirePrettyInTx(ctx context.Context, tx *sql.Tx, user userdomain.User, leaseID string, nowMs int64, requestID string) (userdomain.Identity, error) {
|
||||
// 调用方必须已经锁住 users 行;本函数继续锁 active lease 行。
|
||||
user = user.NormalizeDisplayUserIDState()
|
||||
if user.CurrentDisplayUserIDKind != userdomain.DisplayUserIDKindPretty {
|
||||
// 非 pretty 状态重复过期按幂等成功返回当前身份。
|
||||
return user.EffectiveIdentity(), nil
|
||||
|
||||
@ -63,12 +63,6 @@ func (r *Repository) ResolveActiveRegionByCountry(ctx context.Context, countryCo
|
||||
|
||||
// CreateCountry 创建 active 国家主数据。
|
||||
func (r *Repository) CreateCountry(ctx context.Context, command userdomain.CreateCountryCommand) (userdomain.Country, error) {
|
||||
if canonicalCode, exists, err := countryAliasTargetCode(ctx, r.db, command.CountryCode); err != nil {
|
||||
return userdomain.Country{}, err
|
||||
} else if exists {
|
||||
// alias code 不能再作为 countries 主键,避免同一国家出现两套区域归属。
|
||||
return userdomain.Country{}, xerr.New(xerr.Conflict, "country_code is an alias of "+canonicalCode)
|
||||
}
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@ -628,7 +622,7 @@ func canonicalActiveCountryCodes(ctx context.Context, q queryer, countries []str
|
||||
return nil, xerr.New(xerr.InvalidArgument, "country is not supported")
|
||||
}
|
||||
if _, exists := seen[country.CountryCode]; exists {
|
||||
// alias 解析后仍要查重,防止 US 和 USA 同时配置成同一区域的两个国家。
|
||||
// 区域国家列表不能重复,避免重复写入 active 国家归属。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "countries must not contain duplicate canonical codes")
|
||||
}
|
||||
seen[country.CountryCode] = struct{}{}
|
||||
@ -640,20 +634,6 @@ func canonicalActiveCountryCodes(ctx context.Context, q queryer, countries []str
|
||||
|
||||
func resolveActiveCountryByCode(ctx context.Context, q queryer, countryCode string) (userdomain.Country, bool, error) {
|
||||
countryCode = userdomain.NormalizeCountryCode(countryCode)
|
||||
if canonicalCode, exists, err := countryAliasTargetCode(ctx, q, countryCode); err != nil {
|
||||
return userdomain.Country{}, false, err
|
||||
} else if exists {
|
||||
country, err := queryCountry(ctx, q, "WHERE country_code = ? AND status = ?", canonicalCode, userdomain.CountryStatusActive)
|
||||
if err == sql.ErrNoRows {
|
||||
return userdomain.Country{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return userdomain.Country{}, false, err
|
||||
}
|
||||
|
||||
return country, true, nil
|
||||
}
|
||||
|
||||
country, err := queryCountry(ctx, q, "WHERE country_code = ? AND status = ?", countryCode, userdomain.CountryStatusActive)
|
||||
if err == sql.ErrNoRows {
|
||||
return userdomain.Country{}, false, nil
|
||||
@ -665,24 +645,6 @@ func resolveActiveCountryByCode(ctx context.Context, q queryer, countryCode stri
|
||||
return country, true, nil
|
||||
}
|
||||
|
||||
func countryAliasTargetCode(ctx context.Context, q queryer, aliasCode string) (string, bool, error) {
|
||||
var canonicalCode string
|
||||
err := q.QueryRowContext(ctx, `
|
||||
SELECT country_code
|
||||
FROM country_aliases
|
||||
WHERE alias_code = ?
|
||||
LIMIT 1
|
||||
`, userdomain.NormalizeCountryCode(aliasCode)).Scan(&canonicalCode)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
|
||||
return canonicalCode, true, nil
|
||||
}
|
||||
|
||||
func ensureCountriesAssignableToRegion(ctx context.Context, q queryer, countries []string, regionID int64) error {
|
||||
for _, country := range countries {
|
||||
mappedRegionID, mapped, err := activeRegionIDByCountry(ctx, q, country)
|
||||
|
||||
@ -21,9 +21,9 @@ const userSelectColumns = `
|
||||
COALESCE(gender, ''),
|
||||
COALESCE(country, ''),
|
||||
COALESCE(region_id, 0),
|
||||
COALESCE((SELECT c.country_id FROM countries c LEFT JOIN country_aliases ca ON ca.country_code = c.country_code AND ca.alias_code = users.country WHERE c.country_code = users.country OR ca.alias_code = users.country ORDER BY CASE WHEN ca.alias_code = users.country THEN 0 ELSE 1 END LIMIT 1), 0),
|
||||
COALESCE((SELECT c.country_name FROM countries c LEFT JOIN country_aliases ca ON ca.country_code = c.country_code AND ca.alias_code = users.country WHERE c.country_code = users.country OR ca.alias_code = users.country ORDER BY CASE WHEN ca.alias_code = users.country THEN 0 ELSE 1 END LIMIT 1), ''),
|
||||
COALESCE((SELECT c.country_display_name FROM countries c LEFT JOIN country_aliases ca ON ca.country_code = c.country_code AND ca.alias_code = users.country WHERE c.country_code = users.country OR ca.alias_code = users.country ORDER BY CASE WHEN ca.alias_code = users.country THEN 0 ELSE 1 END LIMIT 1), ''),
|
||||
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 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, ''),
|
||||
@ -152,7 +152,7 @@ func (r *Repository) UpdateUserProfile(ctx context.Context, command userdomain.P
|
||||
return userdomain.User{}, err
|
||||
}
|
||||
|
||||
return user.NormalizeDisplayUserIDState(), nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ChangeUserCountry 修改国家并写日志;冷却期检查和日志写入必须在同一事务内完成。
|
||||
@ -239,7 +239,7 @@ func (r *Repository) queryUser(ctx context.Context, q queryer, clause string, ar
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
// scanUser 固定 users 投影到领域模型的转换,并补齐迁移窗口中的默认值。
|
||||
// scanUser 固定 users 投影到领域模型的转换。
|
||||
func scanUser(scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}) (userdomain.User, error) {
|
||||
@ -289,17 +289,15 @@ func scanUser(scanner interface {
|
||||
|
||||
user.Status = userdomain.Status(status)
|
||||
user.CurrentDisplayUserIDKind = userdomain.DisplayUserIDKind(kind)
|
||||
// Normalize 兼容迁移窗口中默认短号或 kind 为空的历史数据。
|
||||
return user.NormalizeDisplayUserIDState(), nil
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// insertUserIdentity 是跨 auth/user 创建用户用例共享的事务片段。
|
||||
// 它只在调用方已经开启事务后执行,避免 service 层手动拼跨表事务。
|
||||
func insertUserIdentity(ctx context.Context, tx *sql.Tx, user userdomain.User, identity userdomain.Identity) error {
|
||||
// 调用方已开启事务,本函数只负责插入 users 和默认 active display_user_id。
|
||||
user = user.NormalizeDisplayUserIDState()
|
||||
if user.DefaultDisplayUserID == "" {
|
||||
// 创建路径允许 user 中未预填默认短号,以 identity 为准。
|
||||
// 创建路径允许 user 中未预填默认短号,以同事务创建的 identity 为准。
|
||||
user.DefaultDisplayUserID = identity.DisplayUserID
|
||||
}
|
||||
if user.CurrentDisplayUserID == "" {
|
||||
|
||||
@ -52,7 +52,15 @@ func (r *Repository) Close() {
|
||||
func (r *Repository) PutUser(user userdomain.User) {
|
||||
r.t.Helper()
|
||||
|
||||
user = user.NormalizeDisplayUserIDState()
|
||||
if user.DefaultDisplayUserID == "" {
|
||||
user.DefaultDisplayUserID = user.CurrentDisplayUserID
|
||||
}
|
||||
if user.CurrentDisplayUserID == "" {
|
||||
user.CurrentDisplayUserID = user.DefaultDisplayUserID
|
||||
}
|
||||
if user.CurrentDisplayUserIDKind == "" {
|
||||
user.CurrentDisplayUserIDKind = userdomain.DisplayUserIDKindDefault
|
||||
}
|
||||
if user.Status == "" {
|
||||
user.Status = userdomain.StatusActive
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
userv1 "hyapp/api/proto/user/v1"
|
||||
"hyapp/pkg/xerr"
|
||||
@ -14,15 +13,15 @@ import (
|
||||
|
||||
// Server 把 user-service 用例层适配为 protobuf gRPC 接口。
|
||||
type Server struct {
|
||||
// UnimplementedAuthServiceServer 保持新增 RPC 时的向前兼容默认行为。
|
||||
// UnimplementedAuthServiceServer 提供未实现 RPC 的默认返回。
|
||||
userv1.UnimplementedAuthServiceServer
|
||||
// UnimplementedUserServiceServer 保持用户服务新增 RPC 时的向前兼容默认行为。
|
||||
// UnimplementedUserServiceServer 提供未实现 RPC 的默认返回。
|
||||
userv1.UnimplementedUserServiceServer
|
||||
// UnimplementedUserIdentityServiceServer 保持短号服务新增 RPC 时的向前兼容默认行为。
|
||||
// UnimplementedUserIdentityServiceServer 提供未实现 RPC 的默认返回。
|
||||
userv1.UnimplementedUserIdentityServiceServer
|
||||
// UnimplementedCountryAdminServiceServer 保持国家管理 RPC 新增时的默认兼容行为。
|
||||
// UnimplementedCountryAdminServiceServer 提供未实现 RPC 的默认返回。
|
||||
userv1.UnimplementedCountryAdminServiceServer
|
||||
// UnimplementedRegionAdminServiceServer 保持区域管理 RPC 新增时的默认兼容行为。
|
||||
// UnimplementedRegionAdminServiceServer 提供未实现 RPC 的默认返回。
|
||||
userv1.UnimplementedRegionAdminServiceServer
|
||||
|
||||
// authSvc 承载登录、设置密码、refresh 和 logout。
|
||||
@ -59,7 +58,7 @@ func (s *Server) LoginThirdParty(ctx context.Context, req *userv1.LoginThirdPart
|
||||
Country: req.GetCountry(),
|
||||
InviteCode: req.GetInviteCode(),
|
||||
IP: req.GetMeta().GetClientIp(),
|
||||
DeviceID: firstNonEmpty(req.GetDeviceId(), req.GetMeta().GetDeviceId()),
|
||||
DeviceID: req.GetDeviceId(),
|
||||
Device: req.GetDevice(),
|
||||
OSVersion: req.GetOsVersion(),
|
||||
Avatar: req.GetAvatar(),
|
||||
@ -82,17 +81,6 @@ func (s *Server) LoginThirdParty(ctx context.Context, req *userv1.LoginThirdPart
|
||||
return &userv1.AuthResponse{Token: toProtoToken(token), IsNewUser: isNewUser}, nil
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
// gRPC request fields win over meta; meta is only a gateway fallback.
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// SetPassword 为已登录用户首次写入密码身份。
|
||||
func (s *Server) SetPassword(ctx context.Context, req *userv1.SetPasswordRequest) (*userv1.SetPasswordResponse, error) {
|
||||
// user_id 必须由 gateway 鉴权后传入,service 层会拒绝非法值。
|
||||
|
||||
@ -15,6 +15,17 @@ import (
|
||||
grpcserver "hyapp/services/user-service/internal/transport/grpc"
|
||||
)
|
||||
|
||||
func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service {
|
||||
base := []userservice.Option{
|
||||
userservice.WithIdentityRepository(repository),
|
||||
userservice.WithCountryRegionRepository(repository),
|
||||
userservice.WithCountryAdminRepository(repository),
|
||||
userservice.WithRegionAdminRepository(repository),
|
||||
userservice.WithRegionRebuildRepository(repository),
|
||||
}
|
||||
return userservice.New(repository, append(base, options...)...)
|
||||
}
|
||||
|
||||
func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) {
|
||||
t.Helper()
|
||||
if _, ok, err := repository.ResolveActiveCountryByCode(context.Background(), code); err != nil {
|
||||
@ -22,7 +33,7 @@ func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) {
|
||||
} else if ok {
|
||||
return
|
||||
}
|
||||
svc := userservice.New(repository)
|
||||
svc := newUserService(repository)
|
||||
if _, err := svc.CreateCountry(context.Background(), code+" Name", code, code+" Display", 0, 1, "seed-country-"+code); err != nil {
|
||||
t.Fatalf("seed country %s failed: %v", code, err)
|
||||
}
|
||||
@ -31,7 +42,7 @@ func seedCountry(t *testing.T, repository *mysqltest.Repository, code string) {
|
||||
// TestLoginPasswordErrorCarriesReason 验证 transport 层把领域错误转换为 ErrorInfo.reason。
|
||||
func TestLoginPasswordErrorCarriesReason(t *testing.T) {
|
||||
// Auth service 没有完整输入时返回 INVALID_ARGUMENT,transport 必须转成 gRPC ErrorInfo.reason。
|
||||
server := grpcserver.NewServer(authservice.New(authservice.Config{}), userservice.New(mysqltest.NewRepository(t)))
|
||||
server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(mysqltest.NewRepository(t)))
|
||||
|
||||
_, err := server.LoginPassword(context.Background(), &userv1.LoginPasswordRequest{
|
||||
Meta: &userv1.RequestMeta{DeviceId: "ios"},
|
||||
@ -42,7 +53,7 @@ func TestLoginPasswordErrorCarriesReason(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) {
|
||||
// gRPC 层负责把 request 字段和 meta fallback 收敛成 auth service 的注册快照。
|
||||
// gRPC 层负责把 request 字段和服务端 meta 收敛成 auth service 的注册快照。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "CN")
|
||||
@ -53,8 +64,11 @@ func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) {
|
||||
RefreshTokenTTLSec: 2592000,
|
||||
SigningAlg: "HS256",
|
||||
SigningSecret: "test-secret",
|
||||
}, authservice.WithRepository(repository), authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"})))
|
||||
server := grpcserver.NewServer(authSvc, userservice.New(repository))
|
||||
}, authservice.WithAuthRepository(repository),
|
||||
authservice.WithUserRepository(repository),
|
||||
authservice.WithIdentityRepository(repository),
|
||||
authservice.WithCountryRegionRepository(repository), authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"})))
|
||||
server := grpcserver.NewServer(authSvc, newUserService(repository))
|
||||
|
||||
resp, err := server.LoginThirdParty(ctx, &userv1.LoginThirdPartyRequest{
|
||||
Meta: &userv1.RequestMeta{RequestId: "req-third", DeviceId: "meta-device", ClientIp: "203.0.113.10", UserAgent: "hyapp-test/1.0", CountryByIp: "US"},
|
||||
@ -64,7 +78,6 @@ func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) {
|
||||
Gender: "male",
|
||||
Country: "CN",
|
||||
InviteCode: "INV-1",
|
||||
Ip: "198.51.100.9",
|
||||
DeviceId: "req-device",
|
||||
Device: "iPhone 15",
|
||||
OsVersion: "iOS 18.1",
|
||||
@ -102,7 +115,7 @@ func TestUpdateUserProfileAndCountry(t *testing.T) {
|
||||
seedCountry(t, repository, "CN")
|
||||
seedCountry(t, repository, "US")
|
||||
repository.PutUser(userdomain.User{UserID: 10001, CurrentDisplayUserID: "100001", Country: "CN", Status: userdomain.StatusActive})
|
||||
server := grpcserver.NewServer(authservice.New(authservice.Config{}), userservice.New(repository, userservice.WithCountryChangeCooldown(0)))
|
||||
server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(repository, userservice.WithCountryChangeCooldown(0)))
|
||||
username := "hy"
|
||||
avatar := "https://cdn.example/a.png"
|
||||
birth := "2000-01-02"
|
||||
@ -138,7 +151,7 @@ func TestCountryAndRegionAdminRPC(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := time.UnixMilli(1000)
|
||||
userSvc := userservice.New(repository, userservice.WithClock(func() time.Time { return now }))
|
||||
userSvc := newUserService(repository, userservice.WithClock(func() time.Time { return now }))
|
||||
server := grpcserver.NewServer(authservice.New(authservice.Config{}), userSvc)
|
||||
|
||||
countryResp, err := server.CreateCountry(ctx, &userv1.CreateCountryRequest{
|
||||
@ -174,7 +187,7 @@ func TestCountryAndRegionAdminRPC(t *testing.T) {
|
||||
// TestResolveDisplayUserIDErrorCarriesReason 验证短号接口也使用 gRPC ErrorInfo.reason。
|
||||
func TestResolveDisplayUserIDErrorCarriesReason(t *testing.T) {
|
||||
// 短号格式错误也必须通过同一 gRPC 错误映射路径暴露稳定 reason。
|
||||
server := grpcserver.NewServer(authservice.New(authservice.Config{}), userservice.New(mysqltest.NewRepository(t)))
|
||||
server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(mysqltest.NewRepository(t)))
|
||||
|
||||
_, err := server.ResolveDisplayUserID(context.Background(), &userv1.ResolveDisplayUserIDRequest{
|
||||
DisplayUserId: "bad",
|
||||
|
||||
@ -3,6 +3,3 @@ node_id: wallet-docker
|
||||
grpc_addr: ":13004"
|
||||
mysql_dsn: "hyapp:hyapp@tcp(mysql:3306)/hy_wallet?parseTime=true&charset=utf8mb4&loc=Local"
|
||||
mysql_auto_migrate: false
|
||||
ledger:
|
||||
currency: "coin"
|
||||
idempotency_ttl_sec: 86400
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user