增加钱包功能,去掉兼容

This commit is contained in:
170-carry 2026-04-29 23:07:50 +08:00
parent 4c731c9970
commit 1225a0c3a0
112 changed files with 9686 additions and 4800 deletions

View File

@ -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` 作为默认端口。

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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_idmeta.command_idmeta.actor_user_idseat_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_idroom_version event_time_ms
message ConfirmMicPublishingRequest {
RequestMeta meta = 1;
// target_user_id actor_user_idRTC 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);
}

View File

@ -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,

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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);
}

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -1,6 +1,6 @@
# Graceful Shutdown Development Guide
本文档定义服务下线、重启、滚动发布时必须遵守的进程生命周期规则。目标是让实例退出时不再接新流量,不截断关键请求,不留下错误 lease不制造重复消费。
本文档定义服务下线、重启和重新部署时必须遵守的进程生命周期规则。目标是让实例退出时不再接新流量,不截断关键请求,不留下错误 lease不制造重复消费。
## Scope

View File

@ -299,7 +299,7 @@ running -> draining -> stopped
5. lease 释放或自然过期。
6. 新节点接到请求后通过 snapshot + command log 恢复。
禁止在 `ready=false` 之后继续接管新房间。否则滚动发布时会出现旧节点持续获得新 room lease导致下线时间不可控。
禁止在 `ready=false` 之后继续接管新房间。否则节点下线期间会持续获得新 room lease导致退出时间不可控。
## Observability Fields

View File

@ -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)

View 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 nameactivity-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。

View File

@ -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"

View File

@ -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: 命令所属房间 IDCreateRoom 时必须兼容腾讯 IM GroupID 和 RTC strRoomId
description: 命令所属房间 IDCreateRoom 时必须同时满足腾讯 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_idRTC 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

View File

@ -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

View 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: 扣费回执 IDroom-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 namewallet-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。

View File

@ -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。

View File

@ -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
```

View File

@ -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` 这类地区码的中文展示名和产品文案口径。

View File

@ -264,7 +264,7 @@ room-service 发给腾讯云 IM 的房间系统消息使用 `TIMCustomElem`。
- `event_id` 是客户端去重键。
- `room_version` 是房间状态排序参考,不替代腾讯云 IM 消息序列。
- `event_type` 使用 snake_case不能直接暴露 protobuf 事件名。
- 新增字段必须兼容旧客户端,不能修改已有字段语义
- 系统事件字段按当前 OpenAPI/proto 契约输出;新增字段必须同步 gateway、room-service 和客户端测试
首版系统事件:

View File

@ -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 | 客户端去重和展示策略未验收 |

View 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 连接态属于腾讯 RTCRoom 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 Celloutbox 保留待处理记录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 | 首版不能直接 KickUserowner 需先转 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 麦位事实后再做。

View 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。
- 提现人工打款前必须冻结余额,打款失败要回到可重新处理状态。
- 钱包事件投递使用 outboxMQ 投递失败不回滚账务事实。
- 礼物价格和积分比例必须来自服务端配置,不能由客户端决定。

View File

@ -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 {

View File

@ -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

View File

@ -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"`

View File

@ -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()

View File

@ -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)

View File

@ -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")
}
}

View File

@ -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'

View File

@ -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

View File

@ -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,

View File

@ -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)
}

View File

@ -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 {

View File

@ -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,

View File

@ -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())
}

View File

@ -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"}`)))

View File

@ -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)
}

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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() {

View File

@ -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)
}

View File

@ -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:

View File

@ -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)
}

View File

@ -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 {

View 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 不能管理 ownerowner 自操作由具体命令的 self check 再收紧。
return xerr.New(xerr.PermissionDenied, "permission denied")
}
if target == roleHost && actor == roleAdmin {
// 普通 admin 不能管理 hosthost 变更必须走 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
}

View File

@ -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,

View 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)
}

View File

@ -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

View 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 可以增删 adminhost/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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {
// 单条外部投递必须有 deadlineshutdown 才能等待有界。
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]
}

View File

@ -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
}

View File

@ -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++

View File

@ -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 {

View File

@ -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)

View File

@ -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,

View File

@ -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 {

View File

@ -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 快照永远不保存。

View File

@ -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

View File

@ -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 追加一条成功命令日志。

View File

@ -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 先同步扣费,成功后才更新房间热度和本地榜。

View File

@ -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")

View File

@ -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

View File

@ -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,

View File

@ -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),
)

View File

@ -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,

View File

@ -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 是国家主数据;外部只提交 CountryCodeCountryID 只用于管理和审计。
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))

View File

@ -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 事务决定。

View File

@ -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) {

View File

@ -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 {

View File

@ -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

View File

@ -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) {
// 当前展示号仍有效时直接返回用户快照。

View File

@ -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)

View File

@ -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 }),

View File

@ -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)
}
}

View File

@ -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

View File

@ -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)

View File

@ -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 == "" {

View File

@ -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
}

View File

@ -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 层会拒绝非法值。

View File

@ -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_ARGUMENTtransport 必须转成 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",

View File

@ -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