表情包

This commit is contained in:
zhx 2026-05-16 08:12:42 +08:00
parent a5475f9957
commit b8ead156e6
60 changed files with 2896 additions and 2177 deletions

View File

@ -5,3 +5,14 @@ dist
bin
*.log
# These files do not participate in Go container builds. Keeping them out of the
# build context prevents documentation/deploy-only edits from invalidating COPY . .
AGENTS.md
README.md
go.work
docs
deploy
scripts
tools
server/admin/README.md
server/admin/docs

File diff suppressed because it is too large Load Diff

View File

@ -566,6 +566,21 @@ message ListRegistrationCountriesResponse {
repeated Country countries = 1;
}
message LoginRiskBlockedCountry {
string country_code = 1;
string keyword = 2;
int64 updated_at_ms = 3;
}
message ListLoginRiskBlockedCountriesRequest {
RequestMeta meta = 1;
}
message ListLoginRiskBlockedCountriesResponse {
repeated LoginRiskBlockedCountry countries = 1;
int64 updated_at_ms = 2;
}
message ListRegionsRequest {
RequestMeta meta = 1;
string status = 2;
@ -731,6 +746,7 @@ service CountryAdminService {
// CountryQueryService App
service CountryQueryService {
rpc ListRegistrationCountries(ListRegistrationCountriesRequest) returns (ListRegistrationCountriesResponse);
rpc ListLoginRiskBlockedCountries(ListLoginRiskBlockedCountriesRequest) returns (ListLoginRiskBlockedCountriesResponse);
}
// RegionAdminService App RPC

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 v4.25.3
// - protoc v5.28.3
// source: proto/user/v1/user.proto
package userv1
@ -1493,7 +1493,8 @@ var CountryAdminService_ServiceDesc = grpc.ServiceDesc{
}
const (
CountryQueryService_ListRegistrationCountries_FullMethodName = "/hyapp.user.v1.CountryQueryService/ListRegistrationCountries"
CountryQueryService_ListRegistrationCountries_FullMethodName = "/hyapp.user.v1.CountryQueryService/ListRegistrationCountries"
CountryQueryService_ListLoginRiskBlockedCountries_FullMethodName = "/hyapp.user.v1.CountryQueryService/ListLoginRiskBlockedCountries"
)
// CountryQueryServiceClient is the client API for CountryQueryService service.
@ -1503,6 +1504,7 @@ const (
// CountryQueryService 承载 App 注册页公开国家列表查询。
type CountryQueryServiceClient interface {
ListRegistrationCountries(ctx context.Context, in *ListRegistrationCountriesRequest, opts ...grpc.CallOption) (*ListRegistrationCountriesResponse, error)
ListLoginRiskBlockedCountries(ctx context.Context, in *ListLoginRiskBlockedCountriesRequest, opts ...grpc.CallOption) (*ListLoginRiskBlockedCountriesResponse, error)
}
type countryQueryServiceClient struct {
@ -1523,6 +1525,16 @@ func (c *countryQueryServiceClient) ListRegistrationCountries(ctx context.Contex
return out, nil
}
func (c *countryQueryServiceClient) ListLoginRiskBlockedCountries(ctx context.Context, in *ListLoginRiskBlockedCountriesRequest, opts ...grpc.CallOption) (*ListLoginRiskBlockedCountriesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListLoginRiskBlockedCountriesResponse)
err := c.cc.Invoke(ctx, CountryQueryService_ListLoginRiskBlockedCountries_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// CountryQueryServiceServer is the server API for CountryQueryService service.
// All implementations must embed UnimplementedCountryQueryServiceServer
// for forward compatibility.
@ -1530,6 +1542,7 @@ func (c *countryQueryServiceClient) ListRegistrationCountries(ctx context.Contex
// CountryQueryService 承载 App 注册页公开国家列表查询。
type CountryQueryServiceServer interface {
ListRegistrationCountries(context.Context, *ListRegistrationCountriesRequest) (*ListRegistrationCountriesResponse, error)
ListLoginRiskBlockedCountries(context.Context, *ListLoginRiskBlockedCountriesRequest) (*ListLoginRiskBlockedCountriesResponse, error)
mustEmbedUnimplementedCountryQueryServiceServer()
}
@ -1543,6 +1556,9 @@ type UnimplementedCountryQueryServiceServer struct{}
func (UnimplementedCountryQueryServiceServer) ListRegistrationCountries(context.Context, *ListRegistrationCountriesRequest) (*ListRegistrationCountriesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListRegistrationCountries not implemented")
}
func (UnimplementedCountryQueryServiceServer) ListLoginRiskBlockedCountries(context.Context, *ListLoginRiskBlockedCountriesRequest) (*ListLoginRiskBlockedCountriesResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ListLoginRiskBlockedCountries not implemented")
}
func (UnimplementedCountryQueryServiceServer) mustEmbedUnimplementedCountryQueryServiceServer() {}
func (UnimplementedCountryQueryServiceServer) testEmbeddedByValue() {}
@ -1582,6 +1598,24 @@ func _CountryQueryService_ListRegistrationCountries_Handler(srv interface{}, ctx
return interceptor(ctx, in, info, handler)
}
func _CountryQueryService_ListLoginRiskBlockedCountries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListLoginRiskBlockedCountriesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(CountryQueryServiceServer).ListLoginRiskBlockedCountries(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: CountryQueryService_ListLoginRiskBlockedCountries_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(CountryQueryServiceServer).ListLoginRiskBlockedCountries(ctx, req.(*ListLoginRiskBlockedCountriesRequest))
}
return interceptor(ctx, in, info, handler)
}
// CountryQueryService_ServiceDesc is the grpc.ServiceDesc for CountryQueryService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -1593,6 +1627,10 @@ var CountryQueryService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListRegistrationCountries",
Handler: _CountryQueryService_ListRegistrationCountries_Handler,
},
{
MethodName: "ListLoginRiskBlockedCountries",
Handler: _CountryQueryService_ListLoginRiskBlockedCountries_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",

View File

@ -0,0 +1,286 @@
# 表情包 Flutter 对接
本文描述 Flutter App 拉取表情包列表的 HTTP 接口。表情包由后台管理维护App 只读取 gateway 返回的已启用资源;免费/付费字段用于展示和后续业务分流,不在本接口内完成购买或授权判断。
## 接口
Base URL
```text
https://<gateway-host>
```
本地开发:
```text
http://127.0.0.1:13000
```
| 功能 | Method | Path | 鉴权 |
| --- | --- | --- | --- |
| 表情包列表 | `GET` | `/api/v1/emoji-packs` | 不需要登录 |
请求头:
| Header | 必填 | 说明 |
| --- | --- | --- |
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
| `X-App-Package` | 否 | 包名解析 App 时使用。 |
| `X-App-Platform` | 否 | `android` / `ios`。 |
示例:
```http
GET /api/v1/emoji-packs?page=1&page_size=20&region_id=1001
X-App-Code: lalu
X-App-Platform: ios
```
## 请求参数
Query
| 字段 | 必填 | 默认 | 说明 |
| --- | --- | --- | --- |
| `page` | 否 | `1` | 页码,从 1 开始;必须是正整数。 |
| `page_size` | 否 | `20` | 每页数量必须是正整数。Flutter 端统一使用该字段。 |
| `keyword` | 否 | 空 | 按表情包名称或资源编码检索。 |
| `region_id` | 否 | 不过滤 | 用户当前区域 ID传入后返回全局表情包和该区域可用表情包。缺省或传 `0` 返回全部已启用表情包。 |
## 返回值
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {
"items": [
{
"resource_id": 162,
"resource_code": "emoji_pack_1778828784753000000",
"name": "Rocket",
"category": "热门",
"pricing_type": "paid",
"cover_url": "https://cdn.example/emoji/rocket.png",
"animation_url": "https://cdn.example/emoji/rocket.svga",
"region_ids": [1001],
"is_global": false,
"sort_order": 10,
"updated_at_ms": 1778828784753
}
],
"total": 1,
"page": 1,
"page_size": 20
}
}
```
字段规则:
| 字段 | 说明 |
| --- | --- |
| `items` | 当前页表情包列表,只包含后台启用状态的表情包。 |
| `total` | 当前 query 条件下的总数,用于分页。 |
| `resource_id` | 表情包资源 ID后续发送、展示或授权判断应使用该 ID。 |
| `resource_code` | 资源编码,主要用于日志和排查,不建议作为业务主键。 |
| `name` | 表情包名称。 |
| `category` | 表情包分类,用于列表分组或筛选;历史数据缺省时返回 `默认`。 |
| `pricing_type` | `free` / `paid`;客户端展示为免费/付费标签。 |
| `cover_url` | 静态封面图,优先用于列表和面板入口。 |
| `animation_url` | 动态资源地址,文件类型由后台上传内容决定,可能是 `webp``svga``pag` 或其他客户端已支持格式。 |
| `region_ids` | 可用区域 ID空数组表示全局可用。 |
| `is_global` | `true` 表示全局可用。 |
| `sort_order` | 后台排序值,列表已经按服务端资源顺序返回,客户端不要重新反向排序。 |
| `updated_at_ms` | Unix epoch milliseconds可作为本地缓存刷新依据。 |
## Flutter 解析示例
```dart
class ApiEnvelope<T> {
ApiEnvelope({
required this.code,
required this.message,
required this.requestId,
this.data,
});
final String code;
final String message;
final String requestId;
final T? data;
factory ApiEnvelope.fromJson(
Map<String, dynamic> json,
T Function(Object? value) parseData,
) {
return ApiEnvelope<T>(
code: json['code'] as String? ?? '',
message: json['message'] as String? ?? '',
requestId: json['request_id'] as String? ?? '',
data: json.containsKey('data') ? parseData(json['data']) : null,
);
}
}
class EmojiPackPage {
EmojiPackPage({
required this.items,
required this.total,
required this.page,
required this.pageSize,
});
final List<EmojiPackItem> items;
final int total;
final int page;
final int pageSize;
factory EmojiPackPage.fromJson(Map<String, dynamic> json) {
return EmojiPackPage(
items: (json['items'] as List<dynamic>? ?? const [])
.whereType<Map<String, dynamic>>()
.map(EmojiPackItem.fromJson)
.toList(),
total: (json['total'] as num?)?.toInt() ?? 0,
page: (json['page'] as num?)?.toInt() ?? 1,
pageSize: (json['page_size'] as num?)?.toInt() ?? 20,
);
}
}
class EmojiPackItem {
EmojiPackItem({
required this.resourceId,
required this.resourceCode,
required this.name,
required this.category,
required this.pricingType,
required this.coverUrl,
required this.animationUrl,
required this.regionIds,
required this.isGlobal,
required this.sortOrder,
required this.updatedAtMs,
});
final int resourceId;
final String resourceCode;
final String name;
final String category;
final String pricingType;
final String coverUrl;
final String animationUrl;
final List<int> regionIds;
final bool isGlobal;
final int sortOrder;
final int updatedAtMs;
bool get isPaid => pricingType == 'paid';
String get pricingLabel => isPaid ? '付费' : '免费';
factory EmojiPackItem.fromJson(Map<String, dynamic> json) {
return EmojiPackItem(
resourceId: (json['resource_id'] as num?)?.toInt() ?? 0,
resourceCode: json['resource_code'] as String? ?? '',
name: json['name'] as String? ?? '',
category: json['category'] as String? ?? '默认',
pricingType: json['pricing_type'] as String? ?? 'free',
coverUrl: json['cover_url'] as String? ?? '',
animationUrl: json['animation_url'] as String? ?? '',
regionIds: (json['region_ids'] as List<dynamic>? ?? const [])
.whereType<num>()
.map((value) => value.toInt())
.toList(),
isGlobal: json['is_global'] as bool? ?? false,
sortOrder: (json['sort_order'] as num?)?.toInt() ?? 0,
updatedAtMs: (json['updated_at_ms'] as num?)?.toInt() ?? 0,
);
}
}
```
调用示例:
```dart
class ApiException implements Exception {
ApiException({
required this.code,
required this.message,
required this.requestId,
});
final String code;
final String message;
final String requestId;
@override
String toString() => 'ApiException($code, requestId: $requestId)';
}
Future<EmojiPackPage> fetchEmojiPacks({
required Dio dio,
String appCode = 'lalu',
int page = 1,
int pageSize = 20,
int? regionId,
String keyword = '',
}) async {
final response = await dio.get<Map<String, dynamic>>(
'/api/v1/emoji-packs',
queryParameters: <String, dynamic>{
'page': page,
'page_size': pageSize,
if (regionId != null) 'region_id': regionId,
if (keyword.trim().isNotEmpty) 'keyword': keyword.trim(),
},
options: Options(headers: <String, String>{'X-App-Code': appCode}),
);
final envelope = ApiEnvelope<EmojiPackPage>.fromJson(
response.data ?? const <String, dynamic>{},
(value) => EmojiPackPage.fromJson(value as Map<String, dynamic>),
);
if (envelope.code != 'OK' || envelope.data == null) {
throw ApiException(
code: envelope.code,
message: envelope.message,
requestId: envelope.requestId,
);
}
return envelope.data!;
}
```
## 错误处理
所有响应先解析外层 envelope。只有 HTTP status 为 `200``code == "OK"` 时读取 `data`;其他情况不要使用 `data`
| HTTP status | `code` | 处理方式 |
| --- | --- | --- |
| `400` | `INVALID_ARGUMENT` | 请求参数非法,例如 `page``page_size``region_id` 不是合法数字;客户端修正参数后再请求,不做自动重试。 |
| `502` | `UPSTREAM_ERROR` | 后端依赖服务不可用;展示上次缓存或空态,允许用户手动重试,可做指数退避。 |
| `500` | `INTERNAL_ERROR` | 服务端未知错误;展示通用错误,把 `request_id` 写入客户端日志用于排查。 |
| 网络错误 | 无 envelope | 超时、断网或 TLS 失败;优先展示缓存,恢复网络后重新拉取当前页。 |
失败响应示例:
```json
{
"code": "INVALID_ARGUMENT",
"message": "invalid argument",
"request_id": "req_bad"
}
```
客户端缓存建议:
| 场景 | 建议 |
| --- | --- |
| 首次进入表情面板 | 请求 `page=1&page_size=20`,有用户区域时带 `region_id`。 |
| 本地缓存 key | 至少包含 `app_code``region_id``keyword``page``page_size`。 |
| 刷新判断 | 同一个 `resource_id``updated_at_ms` 变大时刷新本地资源和封面缓存。 |
| 付费展示 | `pricing_type == "paid"` 只表示付费标签;能否发送该表情包仍以发送表情或用户资源授权接口的服务端结果为准。 |

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26.3-alpine AS builder
WORKDIR /src
@ -10,14 +11,18 @@ ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
COPY api/go.mod api/go.sum ./api/
COPY server/admin/go.mod server/admin/go.sum ./server/admin/
WORKDIR /src/server/admin
RUN GOWORK=off go mod download
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
GOWORK=off go mod download
WORKDIR /src
COPY api ./api
COPY server/admin ./server/admin
WORKDIR /src/server/admin
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./cmd/server
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./cmd/server
FROM alpine:3.20

View File

@ -18,6 +18,7 @@ import (
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/gameclient"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/integration/userclient"
"hyapp-admin-server/internal/integration/walletclient"
jobrunner "hyapp-admin-server/internal/job"
@ -41,6 +42,7 @@ import (
menumodule "hyapp-admin-server/internal/modules/menu"
paymentmodule "hyapp-admin-server/internal/modules/payment"
rbacmodule "hyapp-admin-server/internal/modules/rbac"
regionblockmodule "hyapp-admin-server/internal/modules/regionblock"
registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward"
resourcemodule "hyapp-admin-server/internal/modules/resource"
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
@ -160,6 +162,12 @@ func main() {
}
defer walletConn.Close()
roomConn, err := grpc.Dial(cfg.RoomService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
fatalRuntime("connect_room_service_failed", err)
}
defer roomConn.Close()
activityConn, err := grpc.Dial(cfg.ActivityService.Addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
fatalRuntime("connect_activity_service_failed", err)
@ -218,8 +226,9 @@ func main() {
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
RegionBlock: regionblockmodule.New(userDB, auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),
RoomAdmin: roomadminmodule.New(roomDB, userDB, auditHandler),
RoomAdmin: roomadminmodule.New(roomDB, userDB, roomclient.NewGRPC(roomConn), auditHandler),
Search: searchmodule.New(store),
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
}

View File

@ -40,6 +40,9 @@ user_service:
wallet_service:
addr: "10.2.1.15:13004"
request_timeout: "3s"
room_service:
addr: "10.2.1.15:13001"
request_timeout: "3s"
activity_service:
addr: "10.2.1.16:13006"
request_timeout: "3s"

View File

@ -40,6 +40,9 @@ user_service:
wallet_service:
addr: "127.0.0.1:13004"
request_timeout: "3s"
room_service:
addr: "127.0.0.1:13001"
request_timeout: "3s"
activity_service:
addr: "127.0.0.1:13006"
request_timeout: "3s"

View File

@ -36,6 +36,7 @@ type Config struct {
Redis RedisConfig `yaml:"redis"`
Jobs JobsConfig `yaml:"jobs"`
WalletService WalletServiceConfig `yaml:"wallet_service"`
RoomService RoomServiceConfig `yaml:"room_service"`
ActivityService ActivityServiceConfig `yaml:"activity_service"`
GameService GameServiceConfig `yaml:"game_service"`
}
@ -56,6 +57,11 @@ type WalletServiceConfig struct {
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type RoomServiceConfig struct {
Addr string `yaml:"addr"`
RequestTimeout time.Duration `yaml:"request_timeout"`
}
type ActivityServiceConfig struct {
Addr string `yaml:"addr"`
RequestTimeout time.Duration `yaml:"request_timeout"`
@ -143,6 +149,10 @@ func Default() Config {
Addr: "127.0.0.1:13004",
RequestTimeout: 3 * time.Second,
},
RoomService: RoomServiceConfig{
Addr: "127.0.0.1:13001",
RequestTimeout: 3 * time.Second,
},
ActivityService: ActivityServiceConfig{
Addr: "127.0.0.1:13006",
RequestTimeout: 3 * time.Second,
@ -241,6 +251,13 @@ func (cfg *Config) Normalize() {
if cfg.WalletService.RequestTimeout <= 0 {
cfg.WalletService.RequestTimeout = 3 * time.Second
}
cfg.RoomService.Addr = strings.TrimSpace(cfg.RoomService.Addr)
if cfg.RoomService.Addr == "" {
cfg.RoomService.Addr = "127.0.0.1:13001"
}
if cfg.RoomService.RequestTimeout <= 0 {
cfg.RoomService.RequestTimeout = 3 * time.Second
}
cfg.ActivityService.Addr = strings.TrimSpace(cfg.ActivityService.Addr)
if cfg.ActivityService.RequestTimeout <= 0 {
cfg.ActivityService.RequestTimeout = 3 * time.Second
@ -332,6 +349,12 @@ func (cfg Config) Validate() error {
if cfg.WalletService.RequestTimeout <= 0 {
return errors.New("wallet_service.request_timeout must be greater than 0")
}
if strings.TrimSpace(cfg.RoomService.Addr) == "" {
return errors.New("room_service.addr is required")
}
if cfg.RoomService.RequestTimeout <= 0 {
return errors.New("room_service.request_timeout must be greater than 0")
}
if strings.TrimSpace(cfg.ActivityService.Addr) == "" {
return errors.New("activity_service.addr is required")
}

View File

@ -0,0 +1,114 @@
package roomclient
import (
"context"
"fmt"
"strings"
"time"
"hyapp-admin-server/internal/appctx"
roomv1 "hyapp.local/api/proto/room/v1"
"google.golang.org/grpc"
)
type Client interface {
CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error)
ReopenRoom(ctx context.Context, req ReopenRoomRequest) (*CloseRoomResult, error)
}
type CloseRoomRequest struct {
RequestID string
RoomID string
ActorUserID int64
Reason string
}
type CloseRoomResult struct {
Applied bool
RoomVersion int64
ServerTimeMs int64
RoomStatus string
OnlineUserNum int
}
type ReopenRoomRequest struct {
RequestID string
RoomID string
ActorUserID int64
}
type GRPCClient struct {
client roomv1.RoomCommandServiceClient
}
func NewGRPC(conn grpc.ClientConnInterface) *GRPCClient {
return &GRPCClient{client: roomv1.NewRoomCommandServiceClient(conn)}
}
func (c *GRPCClient) CloseRoom(ctx context.Context, req CloseRoomRequest) (*CloseRoomResult, error) {
return c.setRoomLifecycle(ctx, req.RequestID, req.RoomID, req.ActorUserID, firstNonEmpty(req.Reason, "admin_closed"))
}
func (c *GRPCClient) ReopenRoom(ctx context.Context, req ReopenRoomRequest) (*CloseRoomResult, error) {
return c.setRoomLifecycle(ctx, req.RequestID, req.RoomID, req.ActorUserID, "admin_reopen")
}
func (c *GRPCClient) setRoomLifecycle(ctx context.Context, rawRequestID string, rawRoomID string, actorUserID int64, rawReason string) (*CloseRoomResult, error) {
if c == nil || c.client == nil {
return nil, fmt.Errorf("room service client is not configured")
}
roomID := strings.TrimSpace(rawRoomID)
reason := strings.TrimSpace(rawReason)
if roomID == "" || actorUserID <= 0 || reason == "" {
return nil, fmt.Errorf("room lifecycle request is incomplete")
}
requestID := strings.TrimSpace(rawRequestID)
if requestID == "" {
requestID = fmt.Sprintf("admin-room-lifecycle-%s-%d", roomID, time.Now().UnixMilli())
}
resp, err := c.client.CloseRoom(ctx, &roomv1.CloseRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: requestID,
CommandId: commandID(requestID, reason, roomID),
ActorUserId: actorUserID,
RoomId: roomID,
AppCode: appctx.FromContext(ctx),
SentAtMs: time.Now().UnixMilli(),
},
Reason: reason,
})
if err != nil {
return nil, err
}
result := resp.GetResult()
room := resp.GetRoom()
return &CloseRoomResult{
Applied: result.GetApplied(),
RoomVersion: result.GetRoomVersion(),
ServerTimeMs: result.GetServerTimeMs(),
RoomStatus: room.GetStatus(),
OnlineUserNum: len(room.GetOnlineUsers()),
}, nil
}
func commandID(requestID string, reason string, roomID string) string {
base := strings.TrimSpace(requestID)
if base == "" {
base = fmt.Sprintf("admin-room-lifecycle-%d", time.Now().UnixMilli())
}
value := base + ":" + strings.TrimSpace(reason) + ":" + strings.TrimSpace(roomID)
if len(value) <= 128 {
return value
}
return value[:128]
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,56 @@
package regionblock
import (
"database/sql"
"errors"
"fmt"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
audit shared.OperationLogger
}
type replaceRequest struct {
Keywords *[]string `json:"keywords"`
}
func New(userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(userDB), audit: audit}
}
// ListRegionBlocks 返回用户管理 > 地区屏蔽页面需要的当前屏蔽词列表。
func (h *Handler) ListRegionBlocks(c *gin.Context) {
items, err := h.service.List(c.Request.Context())
if err != nil {
response.ServerError(c, "获取地区屏蔽配置失败")
return
}
response.OK(c, gin.H{"items": items, "total": len(items)})
}
// ReplaceRegionBlocks 保存整页地区屏蔽词配置;空数组表示清空屏蔽词。
func (h *Handler) ReplaceRegionBlocks(c *gin.Context) {
var req replaceRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Keywords == nil {
response.BadRequest(c, "地区屏蔽词参数不正确")
return
}
items, err := h.service.Replace(c.Request.Context(), int64(shared.ActorFromContext(c).UserID), *req.Keywords)
if err != nil {
if errors.Is(err, errInvalidPayload) {
response.BadRequest(c, "地区屏蔽词参数不正确")
return
}
response.ServerError(c, "保存地区屏蔽配置失败")
return
}
shared.OperationLogWithResourceID(c, h.audit, "replace-region-blocks", "login_risk_country_blocks", middleware.CurrentRequestID(c), "success", fmt.Sprintf("keywords=%d", len(items)))
response.OK(c, gin.H{"items": items, "total": len(items)})
}

View File

@ -0,0 +1,16 @@
package regionblock
import (
"hyapp-admin-server/internal/middleware"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
if h == nil {
return
}
protected.GET("/admin/users/region-blocks", middleware.RequirePermission("region-block:view"), h.ListRegionBlocks)
protected.PUT("/admin/users/region-blocks", middleware.RequirePermission("region-block:update"), h.ReplaceRegionBlocks)
}

View File

@ -0,0 +1,194 @@
package regionblock
import (
"context"
"database/sql"
"errors"
"regexp"
"strings"
"time"
"unicode/utf8"
"hyapp-admin-server/internal/appctx"
)
const (
maxRegionBlockKeywords = 200
maxRegionBlockKeywordLength = 128
)
var (
countryCodePattern = regexp.MustCompile(`^[A-Za-z]{2,3}$`)
errInvalidPayload = errors.New("region block payload invalid")
)
type Service struct {
userDB *sql.DB
}
type RegionBlock struct {
BlockID int64 `json:"block_id"`
Keyword string `json:"keyword"`
CountryCode string `json:"country_code"`
Enabled bool `json:"enabled"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type normalizedKeyword struct {
Keyword string
CountryCode string
}
type countryCodeQuerier interface {
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
func NewService(userDB *sql.DB) *Service {
return &Service{userDB: userDB}
}
// List 返回当前 App 已启用的登录地区屏蔽词。
// 表属于 user-service 登录风控事实admin-server 只作为后台维护入口直接写 user DB。
func (s *Service) List(ctx context.Context) ([]RegionBlock, error) {
if s.userDB == nil {
return nil, errors.New("user db is not configured")
}
return queryEnabledBlocks(ctx, s.userDB, appctx.FromContext(ctx))
}
// Replace 用页面提交的词表整体替换当前 App 的生效配置。
// 整体替换比逐条增删更适合配置页保存语义,也能避免客户端和服务端出现半旧半新的屏蔽词集合。
func (s *Service) Replace(ctx context.Context, actorID int64, keywords []string) ([]RegionBlock, error) {
if s.userDB == nil {
return nil, errors.New("user db is not configured")
}
normalized, err := normalizeKeywords(ctx, s.userDB, appctx.FromContext(ctx), keywords)
if err != nil {
return nil, err
}
tx, err := s.userDB.BeginTx(ctx, nil)
if err != nil {
return nil, err
}
defer tx.Rollback()
nowMS := time.Now().UTC().UnixMilli()
appCode := appctx.FromContext(ctx)
if _, err := tx.ExecContext(ctx, `
UPDATE login_risk_country_blocks
SET enabled = 0, updated_by_user_id = ?, updated_at_ms = ?
WHERE app_code = ? AND enabled = 1
`, nullableActor(actorID), nowMS, appCode); err != nil {
return nil, err
}
for _, keyword := range normalized {
if _, err := tx.ExecContext(ctx, `
INSERT INTO login_risk_country_blocks (
app_code, keyword, country_code, enabled,
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
)
VALUES (?, ?, ?, 1, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
country_code = VALUES(country_code),
enabled = 1,
updated_by_user_id = VALUES(updated_by_user_id),
updated_at_ms = VALUES(updated_at_ms)
`, appCode, keyword.Keyword, keyword.CountryCode, nullableActor(actorID), nullableActor(actorID), nowMS, nowMS); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return s.List(ctx)
}
func normalizeKeywords(ctx context.Context, querier countryCodeQuerier, appCode string, raw []string) ([]normalizedKeyword, error) {
if len(raw) > maxRegionBlockKeywords {
return nil, errInvalidPayload
}
result := make([]normalizedKeyword, 0, len(raw))
seen := map[string]struct{}{}
for _, item := range raw {
keyword := strings.TrimSpace(item)
if keyword == "" || utf8.RuneCountInString(keyword) > maxRegionBlockKeywordLength {
return nil, errInvalidPayload
}
if countryCodePattern.MatchString(keyword) {
keyword = strings.ToUpper(keyword)
}
key := strings.ToLower(keyword)
if _, ok := seen[key]; ok {
continue
}
countryCode, err := resolveCountryCode(ctx, querier, appCode, keyword)
if err != nil {
return nil, err
}
seen[key] = struct{}{}
result = append(result, normalizedKeyword{Keyword: keyword, CountryCode: countryCode})
}
return result, nil
}
func resolveCountryCode(ctx context.Context, querier countryCodeQuerier, appCode string, keyword string) (string, error) {
if countryCodePattern.MatchString(keyword) {
return strings.ToUpper(keyword), nil
}
upperKeyword := strings.ToUpper(keyword)
var countryCode string
err := querier.QueryRowContext(ctx, `
SELECT country_code
FROM countries
WHERE app_code = ?
AND (
country_name = ?
OR country_display_name = ?
OR UPPER(country_code) = ?
OR UPPER(COALESCE(iso_alpha3, '')) = ?
)
ORDER BY enabled DESC, sort_order ASC, country_id ASC
LIMIT 1
`, appCode, keyword, keyword, upperKeyword, upperKeyword).Scan(&countryCode)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
if err != nil {
return "", err
}
return strings.ToUpper(strings.TrimSpace(countryCode)), nil
}
func queryEnabledBlocks(ctx context.Context, db *sql.DB, appCode string) ([]RegionBlock, error) {
rows, err := db.QueryContext(ctx, `
SELECT block_id, keyword, country_code, enabled, created_at_ms, updated_at_ms
FROM login_risk_country_blocks
WHERE app_code = ? AND enabled = 1
ORDER BY country_code ASC, keyword ASC, block_id ASC
`, appCode)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]RegionBlock, 0)
for rows.Next() {
var item RegionBlock
if err := rows.Scan(&item.BlockID, &item.Keyword, &item.CountryCode, &item.Enabled, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func nullableActor(actorID int64) any {
if actorID <= 0 {
return nil
}
return actorID
}

View File

@ -1,10 +1,6 @@
package resource
import (
"encoding/json"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
import walletv1 "hyapp.local/api/proto/wallet/v1"
type resourceDTO struct {
AppCode string `json:"appCode"`
@ -90,6 +86,8 @@ type emojiPackDTO struct {
ResourceCode string `json:"resourceCode"`
Name string `json:"name"`
Status string `json:"status"`
Category string `json:"category"`
PricingType string `json:"pricingType"`
CoverURL string `json:"coverUrl"`
AnimationURL string `json:"animationUrl"`
RegionIDs []int64 `json:"regionIds"`
@ -116,6 +114,7 @@ type grantDTO struct {
GrantID string `json:"grantId"`
CommandID string `json:"commandId"`
TargetUserID int64 `json:"targetUserId,string"`
TargetUser *grantUserDTO `json:"targetUser,omitempty"`
GrantSource string `json:"grantSource"`
GrantSubjectType string `json:"grantSubjectType"`
GrantSubjectID string `json:"grantSubjectId"`
@ -128,6 +127,13 @@ type grantDTO struct {
UpdatedAtMS int64 `json:"updatedAtMs"`
}
type grantUserDTO struct {
UserID int64 `json:"userId,string"`
DisplayUserID string `json:"displayUserId,omitempty"`
Username string `json:"username,omitempty"`
Avatar string `json:"avatar,omitempty"`
}
type operatorDTO struct {
Source string `json:"source"`
UserID int64 `json:"userId,string,omitempty"`
@ -244,15 +250,18 @@ func emojiPackFromProto(item *walletv1.Resource) emojiPackDTO {
if item == nil {
return emojiPackDTO{}
}
metadata := emojiPackMetadataFromJSON(item.GetMetadataJson())
return emojiPackDTO{
AppCode: item.GetAppCode(),
ResourceID: item.GetResourceId(),
ResourceCode: item.GetResourceCode(),
Name: item.GetName(),
Status: item.GetStatus(),
Category: metadata.Category,
PricingType: metadata.PricingType,
CoverURL: firstNonEmpty(item.GetPreviewUrl(), item.GetAssetUrl()),
AnimationURL: item.GetAnimationUrl(),
RegionIDs: regionIDsFromMetadata(item.GetMetadataJson()),
RegionIDs: metadata.RegionIDs,
SortOrder: item.GetSortOrder(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
@ -260,13 +269,7 @@ func emojiPackFromProto(item *walletv1.Resource) emojiPackDTO {
}
func regionIDsFromMetadata(metadataJSON string) []int64 {
var payload struct {
RegionIDs []int64 `json:"region_ids"`
}
if err := json.Unmarshal([]byte(metadataJSON), &payload); err != nil {
return nil
}
return normalizeRegionIDs(payload.RegionIDs)
return emojiPackMetadataFromJSON(metadataJSON).RegionIDs
}
func firstNonEmpty(values ...string) string {

View File

@ -3,6 +3,7 @@ package resource
import (
"database/sql"
"fmt"
"sort"
"strconv"
"strings"
@ -105,6 +106,39 @@ func (h *Handler) ListEmojiPacks(c *gin.Context) {
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}
func (h *Handler) ListEmojiPackCategories(c *gin.Context) {
const pageSize int32 = 100
categories := map[string]struct{}{}
for page := int32(1); ; page++ {
resp, err := h.wallet.ListResources(c.Request.Context(), &walletv1.ListResourcesRequest{
RequestId: middleware.CurrentRequestID(c),
AppCode: appctx.FromContext(c.Request.Context()),
ResourceType: resourceTypeEmojiPack,
Page: page,
PageSize: pageSize,
})
if err != nil {
response.ServerError(c, "获取表情包分类失败")
return
}
for _, item := range resp.GetResources() {
category := emojiPackMetadataFromJSON(item.GetMetadataJson()).Category
if category != "" {
categories[category] = struct{}{}
}
}
if len(resp.GetResources()) == 0 || int64(page)*int64(pageSize) >= resp.GetTotal() {
break
}
}
items := make([]string, 0, len(categories))
for category := range categories {
items = append(items, category)
}
sort.Strings(items)
response.OK(c, items)
}
func (h *Handler) CreateEmojiPack(c *gin.Context) {
var req emojiPackRequest
if err := c.ShouldBindJSON(&req); err != nil {
@ -385,6 +419,10 @@ func (h *Handler) ListResourceGrants(c *gin.Context) {
response.ServerError(c, "获取资源赠送操作人失败")
return
}
if err := h.enrichGrantTargets(c.Request.Context(), items); err != nil {
response.ServerError(c, "获取资源赠送用户信息失败")
return
}
response.OK(c, response.Page{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
}

View File

@ -26,6 +26,66 @@ type managerOperator struct {
Avatar string
}
func (h *Handler) enrichGrantTargets(ctx context.Context, grants []grantDTO) error {
ids := collectGrantTargetIDs(grants)
if len(ids) == 0 {
return nil
}
users := make(map[int64]grantUserDTO, len(ids))
if h.userDB != nil {
var err error
users, err = h.queryGrantTargetUsers(ctx, ids)
if err != nil {
return err
}
}
applyGrantTargetUsers(grants, users)
return nil
}
func (h *Handler) queryGrantTargetUsers(ctx context.Context, ids []int64) (map[int64]grantUserDTO, error) {
out := make(map[int64]grantUserDTO, len(ids))
if len(ids) == 0 || h.userDB == nil {
return out, nil
}
args := append([]any{appctx.FromContext(ctx)}, int64Args(ids)...)
rows, err := h.userDB.QueryContext(ctx, `
SELECT user_id,
current_display_user_id,
COALESCE(username, ''),
COALESCE(avatar, '')
FROM users
WHERE app_code = ? AND user_id IN (`+placeholders(len(ids))+`)
`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var user grantUserDTO
if err := rows.Scan(&user.UserID, &user.DisplayUserID, &user.Username, &user.Avatar); err != nil {
return nil, err
}
out[user.UserID] = user
}
return out, rows.Err()
}
func applyGrantTargetUsers(grants []grantDTO, users map[int64]grantUserDTO) {
for index := range grants {
targetUserID := grants[index].TargetUserID
if targetUserID <= 0 {
continue
}
if user, ok := users[targetUserID]; ok {
grants[index].TargetUser = &user
continue
}
grants[index].TargetUser = &grantUserDTO{UserID: targetUserID}
}
}
func (h *Handler) enrichGrantOperators(ctx context.Context, grants []grantDTO) error {
// operator_user_id 在 wallet 发放事实里只保存稳定 ID后台列表按来源补展示资料避免把 admin 用户和 app 用户混成同一张表。
adminIDs, managerIDs := collectGrantOperatorIDs(grants)
@ -140,6 +200,21 @@ func collectGrantOperatorIDs(grants []grantDTO) ([]uint, []int64) {
return adminIDs, managerIDs
}
func collectGrantTargetIDs(grants []grantDTO) []int64 {
targetSet := make(map[int64]struct{})
for _, grant := range grants {
if grant.TargetUserID <= 0 {
continue
}
targetSet[grant.TargetUserID] = struct{}{}
}
targetIDs := make([]int64, 0, len(targetSet))
for id := range targetSet {
targetIDs = append(targetIDs, id)
}
return targetIDs
}
func adminOperatorFromModel(user model.User) adminOperator {
return adminOperator{
UserID: user.ID,

View File

@ -1,6 +1,9 @@
package resource
import "testing"
import (
"encoding/json"
"testing"
)
func TestApplyGrantOperatorsUsesSourceSpecificProfiles(t *testing.T) {
grants := []grantDTO{
@ -22,22 +25,48 @@ func TestApplyGrantOperatorsUsesSourceSpecificProfiles(t *testing.T) {
}
}
func TestApplyGrantTargetUsersKeepsFallbackUserID(t *testing.T) {
grants := []grantDTO{
{GrantID: "grant-with-user", TargetUserID: 1001},
{GrantID: "grant-missing-user", TargetUserID: 1002},
}
applyGrantTargetUsers(grants, map[int64]grantUserDTO{
1001: {UserID: 1001, DisplayUserID: "900001", Username: "玩家 A", Avatar: "https://cdn.example/user.png"},
})
if grants[0].TargetUser == nil || grants[0].TargetUser.DisplayUserID != "900001" || grants[0].TargetUser.Username != "玩家 A" {
t.Fatalf("target user mismatch: %+v", grants[0].TargetUser)
}
if grants[1].TargetUser == nil || grants[1].TargetUser.UserID != 1002 || grants[1].TargetUser.DisplayUserID != "" {
t.Fatalf("target fallback mismatch: %+v", grants[1].TargetUser)
}
}
func TestEmojiPackMetadataDefaultsToAllRegions(t *testing.T) {
metadata, err := emojiPackMetadataJSON([]int64{0, 1001})
metadata, err := emojiPackMetadataJSON([]int64{0, 1001}, "", "")
if err != nil {
t.Fatalf("metadata build failed: %v", err)
}
if metadata != `{"region_scope":"all"}` {
t.Fatalf("unexpected all-region metadata: %s", metadata)
var payload emojiPackMetadataPayload
if err := json.Unmarshal([]byte(metadata), &payload); err != nil {
t.Fatalf("metadata json mismatch: %v", err)
}
if payload.RegionScope != "all" || len(payload.RegionIDs) != 0 || payload.Category != defaultEmojiPackCategory || payload.PricingType != emojiPackPricingFree {
t.Fatalf("unexpected all-region metadata: %+v", payload)
}
}
func TestEmojiPackMetadataKeepsLimitedRegions(t *testing.T) {
metadata, err := emojiPackMetadataJSON([]int64{1001, 1001, 1002})
metadata, err := emojiPackMetadataJSON([]int64{1001, 1001, 1002}, "会员", "paid")
if err != nil {
t.Fatalf("metadata build failed: %v", err)
}
if metadata != `{"region_ids":[1001,1002],"region_scope":"limited"}` {
t.Fatalf("unexpected limited-region metadata: %s", metadata)
payload := emojiPackMetadataFromJSON(metadata)
if payload.RegionScope != "limited" || payload.Category != "会员" || payload.PricingType != emojiPackPricingPaid {
t.Fatalf("unexpected limited-region metadata: %+v", payload)
}
if len(payload.RegionIDs) != 2 || payload.RegionIDs[0] != 1001 || payload.RegionIDs[1] != 1002 {
t.Fatalf("unexpected region ids: %+v", payload.RegionIDs)
}
}

View File

@ -5,6 +5,7 @@ import (
"fmt"
"strings"
"time"
"unicode/utf8"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/middleware"
@ -20,6 +21,12 @@ const (
resourceTypeEmojiPack = "emoji_pack"
)
const (
defaultEmojiPackCategory = "默认"
emojiPackPricingFree = "free"
emojiPackPricingPaid = "paid"
)
type resourceRequest struct {
ResourceCode string `json:"resourceCode"`
ResourceType string `json:"resourceType"`
@ -35,12 +42,21 @@ type resourceRequest struct {
type emojiPackRequest struct {
Name string `json:"name"`
Category string `json:"category"`
PricingType string `json:"pricingType"`
CoverURL string `json:"coverUrl"`
AnimationURL string `json:"animationUrl"`
RegionIDs []int64 `json:"regionIds"`
SortOrder int32 `json:"sortOrder"`
}
type emojiPackMetadataPayload struct {
Category string `json:"category"`
PricingType string `json:"pricing_type"`
RegionIDs []int64 `json:"region_ids,omitempty"`
RegionScope string `json:"region_scope"`
}
type resourceGroupRequest struct {
GroupCode string `json:"groupCode"`
Name string `json:"name"`
@ -153,7 +169,11 @@ func (r emojiPackRequest) createProto(c *gin.Context) (*walletv1.CreateResourceR
if name == "" || coverURL == "" || animationURL == "" {
return nil, fmt.Errorf("表情包参数不完整")
}
metadataJSON, err := emojiPackMetadataJSON(r.RegionIDs)
category := normalizeEmojiPackCategory(r.Category)
if utf8.RuneCountInString(category) > 64 {
return nil, fmt.Errorf("表情包分类不能超过 64 个字符")
}
metadataJSON, err := emojiPackMetadataJSON(r.RegionIDs, category, r.PricingType)
if err != nil {
return nil, err
}
@ -313,20 +333,62 @@ func resourceGrantStrategy(resourceType string) string {
}
}
func emojiPackMetadataJSON(regionIDs []int64) (string, error) {
func emojiPackMetadataJSON(regionIDs []int64, category string, pricingType string) (string, error) {
normalizedRegionIDs := normalizeRegionIDs(regionIDs)
metadata := map[string]any{"region_scope": "all"}
metadata := emojiPackMetadataPayload{
Category: normalizeEmojiPackCategory(category),
PricingType: normalizeEmojiPackPricingType(pricingType),
RegionScope: "all",
}
if utf8.RuneCountInString(metadata.Category) > 64 {
return "", fmt.Errorf("表情包分类不能超过 64 个字符")
}
if len(normalizedRegionIDs) > 0 {
metadata["region_scope"] = "limited"
metadata["region_ids"] = normalizedRegionIDs
metadata.RegionScope = "limited"
metadata.RegionIDs = normalizedRegionIDs
}
body, err := json.Marshal(metadata)
if err != nil {
return "", fmt.Errorf("表情包区域配置不正确")
return "", fmt.Errorf("表情包配置不正确")
}
return string(body), nil
}
func emojiPackMetadataFromJSON(metadataJSON string) emojiPackMetadataPayload {
payload := emojiPackMetadataPayload{
Category: defaultEmojiPackCategory,
PricingType: emojiPackPricingFree,
RegionScope: "all",
}
if err := json.Unmarshal([]byte(metadataJSON), &payload); err != nil {
return payload
}
payload.Category = normalizeEmojiPackCategory(payload.Category)
payload.PricingType = normalizeEmojiPackPricingType(payload.PricingType)
payload.RegionIDs = normalizeRegionIDs(payload.RegionIDs)
if len(payload.RegionIDs) > 0 {
payload.RegionScope = "limited"
} else {
payload.RegionScope = "all"
}
return payload
}
func normalizeEmojiPackCategory(value string) string {
category := strings.TrimSpace(value)
if category == "" {
return defaultEmojiPackCategory
}
return category
}
func normalizeEmojiPackPricingType(value string) string {
if strings.ToLower(strings.TrimSpace(value)) == emojiPackPricingPaid {
return emojiPackPricingPaid
}
return emojiPackPricingFree
}
func normalizeRegionIDs(regionIDs []int64) []int64 {
out := make([]int64, 0, len(regionIDs))
seen := make(map[int64]struct{}, len(regionIDs))

View File

@ -19,6 +19,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/resources/:resource_id/disable", middleware.RequirePermission("resource:update"), h.DisableResource)
protected.GET("/admin/emoji-packs", middleware.RequirePermission("emoji-pack:view"), h.ListEmojiPacks)
protected.GET("/admin/emoji-packs/categories", middleware.RequirePermission("emoji-pack:view"), h.ListEmojiPackCategories)
protected.POST("/admin/emoji-packs", middleware.RequirePermission("emoji-pack:create"), h.CreateEmojiPack)
protected.GET("/admin/resource-groups", middleware.RequirePermission("resource-group:view"), h.ListResourceGroups)

View File

@ -6,6 +6,8 @@ import (
"strconv"
"strings"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
@ -17,8 +19,8 @@ type Handler struct {
audit shared.OperationLogger
}
func New(roomDB *sql.DB, userDB *sql.DB, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(roomDB, userDB), audit: audit}
func New(roomDB *sql.DB, userDB *sql.DB, roomClient roomclient.Client, audit shared.OperationLogger) *Handler {
return &Handler{service: NewService(roomDB, userDB, roomClient), audit: audit}
}
func (h *Handler) ListRooms(c *gin.Context) {
@ -110,7 +112,7 @@ func (h *Handler) UpdateRoom(c *gin.Context) {
response.BadRequest(c, "房间参数不正确")
return
}
room, err := h.service.UpdateRoom(c.Request.Context(), roomID, req)
room, err := h.service.UpdateRoom(c.Request.Context(), roomID, req, shared.ActorFromContext(c), middleware.CurrentRequestID(c))
if err != nil {
writeMutationError(c, err, "更新房间失败")
return

View File

@ -26,6 +26,7 @@ type createRoomPinRequest struct {
type updateRoomRequest struct {
CoverURL *string `json:"coverUrl"`
CloseReason *string `json:"closeReason"`
Description *string `json:"description"`
Mode *string `json:"mode"`
Status *string `json:"status"`

View File

@ -9,8 +9,12 @@ import (
"strings"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/modules/shared"
roomv1 "hyapp.local/api/proto/room/v1"
)
@ -20,8 +24,9 @@ var (
)
type Service struct {
db *sql.DB
userDB *sql.DB
db *sql.DB
userDB *sql.DB
roomClient roomclient.Client
}
type RoomOwner struct {
@ -33,6 +38,10 @@ type RoomOwner struct {
type Room struct {
CoverURL string `json:"coverUrl"`
ClosedAtMs int64 `json:"closedAtMs,omitempty"`
ClosedByAdminID uint `json:"closedByAdminId,omitempty"`
ClosedByAdminName string `json:"closedByAdminName,omitempty"`
CloseReason string `json:"closeReason,omitempty"`
CreatedAtMs int64 `json:"createdAtMs"`
Heat int64 `json:"heat"`
Mode string `json:"mode"`
@ -52,8 +61,8 @@ type Room struct {
VisibleRegionID int64 `json:"visibleRegionId"`
}
func NewService(db *sql.DB, userDB *sql.DB) *Service {
return &Service{db: db, userDB: userDB}
func NewService(db *sql.DB, userDB *sql.DB, roomClient roomclient.Client) *Service {
return &Service{db: db, userDB: userDB, roomClient: roomClient}
}
func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64, error) {
@ -93,6 +102,10 @@ func (s *Service) ListRooms(ctx context.Context, query listQuery) ([]Room, int64
rle.cover_url,
rle.mode,
rle.status,
rle.close_reason,
rle.closed_by_admin_id,
rle.closed_by_admin_name,
rle.closed_at_ms,
rle.heat,
rle.online_count,
rle.seat_count,
@ -140,6 +153,10 @@ func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) {
cover_url,
mode,
status,
close_reason,
closed_by_admin_id,
closed_by_admin_name,
closed_at_ms,
heat,
online_count,
seat_count,
@ -164,7 +181,7 @@ func (s *Service) GetRoom(ctx context.Context, roomID string) (Room, error) {
return items[0], nil
}
func (s *Service) UpdateRoom(ctx context.Context, roomID string, req updateRoomRequest) (Room, error) {
func (s *Service) UpdateRoom(ctx context.Context, roomID string, req updateRoomRequest, actor shared.Actor, requestID string) (Room, error) {
if s.db == nil {
return Room{}, fmt.Errorf("room mysql is not configured")
}
@ -175,6 +192,15 @@ func (s *Service) UpdateRoom(ctx context.Context, roomID string, req updateRoomR
if len(updates) == 0 {
return s.GetRoom(ctx, roomID)
}
if snapshotPatch.statusSet && snapshotPatch.status == "closed" {
if err := s.closeRoomLifecycle(ctx, roomID, actor, requestID); err != nil {
return Room{}, err
}
} else if snapshotPatch.statusSet && snapshotPatch.status == "active" {
if err := s.reopenRoomLifecycle(ctx, roomID, actor, requestID); err != nil {
return Room{}, err
}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
@ -192,10 +218,10 @@ func (s *Service) UpdateRoom(ctx context.Context, roomID string, req updateRoomR
}
now := time.Now().UnixMilli()
listSets := make([]string, 0, len(updates)+1)
listArgs := make([]any, 0, len(updates)+2)
roomSets := make([]string, 0, 3)
roomArgs := make([]any, 0, 4)
listSets := make([]string, 0, len(updates)+5)
listArgs := make([]any, 0, len(updates)+6)
roomSets := make([]string, 0, len(updates)+4)
roomArgs := make([]any, 0, len(updates)+6)
for _, update := range updates {
if update.listColumn != "" {
listSets = append(listSets, update.listColumn+" = ?")
@ -206,6 +232,27 @@ func (s *Service) UpdateRoom(ctx context.Context, roomID string, req updateRoomR
roomArgs = append(roomArgs, update.value)
}
}
if snapshotPatch.statusSet {
if snapshotPatch.status == "closed" {
reason := strings.TrimSpace(valueOrEmpty(req.CloseReason))
operatorName := strings.TrimSpace(actor.Username)
closeUpdates := []roomUpdate{
{listColumn: "close_reason", roomColumn: "close_reason", value: reason},
{listColumn: "closed_by_admin_id", roomColumn: "closed_by_admin_id", value: actor.UserID},
{listColumn: "closed_by_admin_name", roomColumn: "closed_by_admin_name", value: operatorName},
{listColumn: "closed_at_ms", roomColumn: "closed_at_ms", value: now},
}
appendRoomUpdates(closeUpdates, &listSets, &listArgs, &roomSets, &roomArgs)
} else {
clearUpdates := []roomUpdate{
{listColumn: "close_reason", roomColumn: "close_reason", value: ""},
{listColumn: "closed_by_admin_id", roomColumn: "closed_by_admin_id", value: 0},
{listColumn: "closed_by_admin_name", roomColumn: "closed_by_admin_name", value: ""},
{listColumn: "closed_at_ms", roomColumn: "closed_at_ms", value: 0},
}
appendRoomUpdates(clearUpdates, &listSets, &listArgs, &roomSets, &roomArgs)
}
}
listSets = append(listSets, "updated_at_ms = ?")
listArgs = append(listArgs, now, appCode, roomID)
if _, err := tx.ExecContext(ctx, "UPDATE room_list_entries SET "+strings.Join(listSets, ", ")+" WHERE app_code = ? AND room_id = ?", listArgs...); err != nil {
@ -226,6 +273,72 @@ func (s *Service) UpdateRoom(ctx context.Context, roomID string, req updateRoomR
return s.GetRoom(ctx, roomID)
}
func (s *Service) closeRoomLifecycle(ctx context.Context, roomID string, actor shared.Actor, requestID string) error {
if s.roomClient == nil {
return fmt.Errorf("room service client is not configured")
}
if actor.UserID == 0 {
return fmt.Errorf("%w: 操作人不正确", ErrInvalidArgument)
}
_, err := s.roomClient.CloseRoom(ctx, roomclient.CloseRoomRequest{
RequestID: requestID,
RoomID: roomID,
ActorUserID: int64(actor.UserID),
Reason: "admin_closed",
})
if err == nil {
return nil
}
if st, ok := status.FromError(err); ok {
switch st.Code() {
case codes.NotFound:
return ErrNotFound
case codes.InvalidArgument, codes.FailedPrecondition:
return fmt.Errorf("%w: %s", ErrInvalidArgument, st.Message())
}
}
return err
}
func (s *Service) reopenRoomLifecycle(ctx context.Context, roomID string, actor shared.Actor, requestID string) error {
if s.roomClient == nil {
return fmt.Errorf("room service client is not configured")
}
if actor.UserID == 0 {
return fmt.Errorf("%w: 操作人不正确", ErrInvalidArgument)
}
_, err := s.roomClient.ReopenRoom(ctx, roomclient.ReopenRoomRequest{
RequestID: requestID,
RoomID: roomID,
ActorUserID: int64(actor.UserID),
})
if err == nil {
return nil
}
if st, ok := status.FromError(err); ok {
switch st.Code() {
case codes.NotFound:
return ErrNotFound
case codes.InvalidArgument, codes.FailedPrecondition:
return fmt.Errorf("%w: %s", ErrInvalidArgument, st.Message())
}
}
return err
}
func appendRoomUpdates(updates []roomUpdate, listSets *[]string, listArgs *[]any, roomSets *[]string, roomArgs *[]any) {
for _, update := range updates {
if update.listColumn != "" {
*listSets = append(*listSets, update.listColumn+" = ?")
*listArgs = append(*listArgs, update.value)
}
if update.roomColumn != "" {
*roomSets = append(*roomSets, update.roomColumn+" = ?")
*roomArgs = append(*roomArgs, update.value)
}
}
}
func (s *Service) DeleteRoom(ctx context.Context, roomID string) error {
if s.db == nil {
return fmt.Errorf("room mysql is not configured")
@ -327,6 +440,15 @@ func buildRoomUpdate(req updateRoomRequest) ([]roomUpdate, snapshotPatch, error)
if !ok {
return nil, patch, fmt.Errorf("%w: 房间状态不正确", ErrInvalidArgument)
}
if value == "closed" {
reason := strings.TrimSpace(valueOrEmpty(req.CloseReason))
if reason == "" {
return nil, patch, fmt.Errorf("%w: 请输入关闭原因", ErrInvalidArgument)
}
if len(reason) > 512 {
return nil, patch, fmt.Errorf("%w: 关闭原因不能超过 512 个字符", ErrInvalidArgument)
}
}
updates = append(updates, roomUpdate{listColumn: "status", roomColumn: "status", value: value})
patch.statusSet = true
patch.status = value
@ -390,6 +512,7 @@ type roomScanner interface {
func scanRoom(row roomScanner) (Room, error) {
var item Room
var ownerUserID int64
var closedByAdminID uint64
err := row.Scan(
&item.RoomID,
&item.RoomShortID,
@ -399,6 +522,10 @@ func scanRoom(row roomScanner) (Room, error) {
&item.CoverURL,
&item.Mode,
&item.Status,
&item.CloseReason,
&closedByAdminID,
&item.ClosedByAdminName,
&item.ClosedAtMs,
&item.Heat,
&item.OnlineCount,
&item.SeatCount,
@ -407,6 +534,7 @@ func scanRoom(row roomScanner) (Room, error) {
&item.CreatedAtMs,
&item.UpdatedAtMs,
)
item.ClosedByAdminID = uint(closedByAdminID)
item.OwnerUserID = strconv.FormatInt(ownerUserID, 10)
item.Owner = RoomOwner{UserID: item.OwnerUserID}
item.RoomContribution = item.Heat
@ -480,6 +608,13 @@ func normalizeRoomStatus(status string) (string, bool) {
}
}
func valueOrEmpty(value *string) string {
if value == nil {
return ""
}
return *value
}
func (s *Service) fillRoomDetails(ctx context.Context, items []Room) error {
if s.userDB == nil || len(items) == 0 {
return nil

View File

@ -22,6 +22,8 @@ var defaultPermissions = []model.Permission{
{Name: "App 用户设置密码", Code: "app-user:password", Kind: "button"},
{Name: "等级配置查看", Code: "level-config:view", Kind: "menu"},
{Name: "等级配置更新", Code: "level-config:update", Kind: "button"},
{Name: "地区屏蔽查看", Code: "region-block:view", Kind: "menu"},
{Name: "地区屏蔽更新", Code: "region-block:update", Kind: "button"},
{Name: "房间查看", Code: "room:view", Kind: "menu"},
{Name: "房间更新", Code: "room:update", Kind: "button"},
{Name: "房间删除", Code: "room:delete", Kind: "button"},
@ -204,6 +206,7 @@ func (s *Store) seedMenus() error {
{ParentID: &appUsersID, Title: "封禁列表", Code: "app-user-bans", Path: "/app/users/bans", Icon: "block", PermissionCode: "app-user:view", Sort: 61, Visible: true},
{ParentID: &appUsersID, Title: "登录日志", Code: "app-user-login-logs", Path: "/app/users/login-logs", Icon: "login", PermissionCode: "app-user:view", Sort: 62, Visible: true},
{ParentID: &appUsersID, Title: "等级配置", Code: "app-user-level-config", Path: "/app/users/level-config", Icon: "military_tech", PermissionCode: "level-config:view", Sort: 63, Visible: true},
{ParentID: &appUsersID, Title: "地区屏蔽", Code: "app-user-region-blocks", Path: "/app/users/region-blocks", Icon: "public", PermissionCode: "region-block:view", Sort: 64, Visible: true},
{ParentID: &roomsID, Title: "房间列表", Code: "room-list", Path: "/rooms", Icon: "room", PermissionCode: "room:view", Sort: 65, Visible: true},
{ParentID: &roomsID, Title: "房间置顶", Code: "room-pins", Path: "/rooms/pins", Icon: "push_pin", PermissionCode: "room-pin:view", Sort: 66, Visible: true},
{ParentID: &roomsID, Title: "房间配置", Code: "room-config", Path: "/rooms/config", Icon: "settings", PermissionCode: "room-config:view", Sort: 67, Visible: true},
@ -441,6 +444,7 @@ func defaultRolePermissionCodes(code string) []string {
"user:view", "user:status",
"app-user:view", "app-user:update", "app-user:status", "app-user:password",
"level-config:view", "level-config:update",
"region-block:view", "region-block:update",
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update",
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
@ -464,13 +468,14 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "role:view", "permission:view", "job:view"}
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
"user:view",
"app-user:view",
"level-config:view",
"region-block:view",
"room:view",
"room-pin:view",
"room-config:view",
@ -514,6 +519,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"app-config:view", "app-config:update",
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
"level-config:view", "level-config:update",
"region-block:view", "region-block:update",
"resource:view", "resource:create", "resource:update",
"resource-group:view", "resource-group:create", "resource-group:update",
"resource-grant:view", "resource-grant:create",
@ -529,7 +535,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"achievement:view", "achievement:create", "achievement:update",
}
case "auditor", "readonly":
return []string{"level-config:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view"}
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view"}
default:
return nil
}

View File

@ -22,6 +22,7 @@ import (
"hyapp-admin-server/internal/modules/menu"
"hyapp-admin-server/internal/modules/payment"
"hyapp-admin-server/internal/modules/rbac"
"hyapp-admin-server/internal/modules/regionblock"
"hyapp-admin-server/internal/modules/registrationreward"
resourcemodule "hyapp-admin-server/internal/modules/resource"
"hyapp-admin-server/internal/modules/roomadmin"
@ -54,6 +55,7 @@ type Handlers struct {
Payment *payment.Handler
RBAC *rbac.Handler
RegistrationReward *registrationreward.Handler
RegionBlock *regionblock.Handler
Resource *resourcemodule.Handler
RoomAdmin *roomadmin.Handler
Search *search.Handler
@ -84,6 +86,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
countryregion.RegisterRoutes(protected, h.CountryRegion)
dailytask.RegisterRoutes(protected, h.DailyTask)
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
regionblock.RegisterRoutes(protected, h.RegionBlock)
gamemanagement.RegisterRoutes(protected, h.Game)
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
dashboard.RegisterRoutes(protected, h.Dashboard)

View File

@ -0,0 +1,58 @@
-- Region block is a user-service login risk policy maintained from User Management.
-- The migration seeds menu and RBAC facts for environments that do not run bootstrap.
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
('地区屏蔽查看', 'region-block:view', 'menu', '', @now_ms, @now_ms),
('地区屏蔽更新', 'region-block:update', 'button', '', @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
name = VALUES(name),
kind = VALUES(kind),
description = VALUES(description),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES
(NULL, '用户管理', 'app-users', '', 'users', '', 60, TRUE, @now_ms, @now_ms)
ON DUPLICATE KEY UPDATE
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
SELECT parent.id, '地区屏蔽', 'app-user-region-blocks', '/app/users/region-blocks', 'public', 'region-block:view', 64, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'app-users'
ON DUPLICATE KEY UPDATE
parent_id = VALUES(parent_id),
title = VALUES(title),
path = VALUES(path),
icon = VALUES(icon),
permission_code = VALUES(permission_code),
sort = VALUES(sort),
visible = VALUES(visible),
updated_at_ms = @now_ms;
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'platform-admin'
AND admin_permission.code IN ('region-block:view', 'region-block:update');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code = 'ops-admin'
AND admin_permission.code IN ('region-block:view', 'region-block:update');
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
SELECT admin_role.id, admin_permission.id
FROM admin_roles admin_role
JOIN admin_permissions admin_permission
WHERE admin_role.code IN ('auditor', 'readonly')
AND admin_permission.code = 'region-block:view';

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26.3-alpine AS builder
WORKDIR /src
@ -9,12 +10,16 @@ ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
COPY go.mod go.sum ./
COPY api/go.mod api/go.sum ./api/
RUN GOWORK=off go mod download
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
GOWORK=off go mod download
COPY . .
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/activity-service/cmd/server
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-health-probe ./cmd/grpc-health-probe
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
mkdir -p /out && \
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/ ./services/activity-service/cmd/server ./cmd/grpc-health-probe
FROM alpine:3.20

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26.3-alpine AS builder
WORKDIR /src
@ -9,12 +10,16 @@ ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
COPY go.mod go.sum ./
COPY api/go.mod api/go.sum ./api/
RUN GOWORK=off go mod download
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
GOWORK=off go mod download
COPY . .
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/cron-service/cmd/server
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-health-probe ./cmd/grpc-health-probe
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
mkdir -p /out && \
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/ ./services/cron-service/cmd/server ./cmd/grpc-health-probe
FROM alpine:3.20

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26.3-alpine AS builder
WORKDIR /src
@ -7,10 +8,14 @@ ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
COPY go.mod go.sum ./
COPY api/go.mod api/go.sum ./api/
RUN GOWORK=off go mod download
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
GOWORK=off go mod download
COPY . .
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/game-service/cmd/server
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-health-probe ./cmd/grpc-health-probe
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
mkdir -p /out && \
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/ ./services/game-service/cmd/server ./cmd/grpc-health-probe
FROM alpine:3.20
ENV TZ=UTC

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26.3-alpine AS builder
WORKDIR /src
@ -9,11 +10,15 @@ ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
COPY go.mod go.sum ./
COPY api/go.mod api/go.sum ./api/
RUN GOWORK=off go mod download
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
GOWORK=off go mod download
COPY . .
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/gateway-service/cmd/server
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/gateway-service/cmd/server
FROM alpine:3.20

View File

@ -60,6 +60,7 @@ type UserDeviceClient interface {
// UserCountryQueryClient 抽象 gateway 对 App 注册页国家列表的公开读依赖。
type UserCountryQueryClient interface {
ListRegistrationCountries(ctx context.Context, req *userv1.ListRegistrationCountriesRequest) (*userv1.ListRegistrationCountriesResponse, error)
ListLoginRiskBlockedCountries(ctx context.Context, req *userv1.ListLoginRiskBlockedCountriesRequest) (*userv1.ListLoginRiskBlockedCountriesResponse, error)
}
// UserHostClient 抽象 gateway 对 user-service host domain 身份查询能力的依赖。
@ -284,6 +285,10 @@ func (c *grpcUserCountryQueryClient) ListRegistrationCountries(ctx context.Conte
return c.client.ListRegistrationCountries(ctx, req)
}
func (c *grpcUserCountryQueryClient) ListLoginRiskBlockedCountries(ctx context.Context, req *userv1.ListLoginRiskBlockedCountriesRequest) (*userv1.ListLoginRiskBlockedCountriesResponse, error) {
return c.client.ListLoginRiskBlockedCountries(ctx, req)
}
func (c *grpcUserHostClient) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) {
return c.client.GetHostProfile(ctx, req)
}

View File

@ -37,21 +37,22 @@ type AuthHandlers struct {
}
type AppHandlers struct {
ListRegistrationCountries http.HandlerFunc
GetAppBootstrap http.HandlerFunc
ListH5Links http.HandlerFunc
ListAppBanners http.HandlerFunc
GetAppVersion http.HandlerFunc
GetResourceGroup http.HandlerFunc
ListGifts http.HandlerFunc
ListEmojiPacks http.HandlerFunc
IssueTencentIMUserSig http.HandlerFunc
IssueTencentRTCToken http.HandlerFunc
HandleTencentIMCallback http.HandlerFunc
HandleTencentRTCCallback http.HandlerFunc
UploadFile http.HandlerFunc
UploadAvatar http.HandlerFunc
HandlePushToken http.HandlerFunc
ListRegistrationCountries http.HandlerFunc
ListLoginRiskBlockedCountries http.HandlerFunc
GetAppBootstrap http.HandlerFunc
ListH5Links http.HandlerFunc
ListAppBanners http.HandlerFunc
GetAppVersion http.HandlerFunc
GetResourceGroup http.HandlerFunc
ListGifts http.HandlerFunc
ListEmojiPacks http.HandlerFunc
IssueTencentIMUserSig http.HandlerFunc
IssueTencentRTCToken http.HandlerFunc
HandleTencentIMCallback http.HandlerFunc
HandleTencentRTCCallback http.HandlerFunc
UploadFile http.HandlerFunc
UploadAvatar http.HandlerFunc
HandlePushToken http.HandlerFunc
}
type UserHandlers struct {
@ -223,6 +224,7 @@ func (r routes) registerAuthRoutes() {
func (r routes) registerAppRoutes() {
h := r.config.App
r.public("/countries", "", h.ListRegistrationCountries)
r.public("/login-risk/blocked-countries", http.MethodGet, h.ListLoginRiskBlockedCountries)
r.public("/app/bootstrap", "", h.GetAppBootstrap)
r.public("/app/h5-links", "", h.ListH5Links)
r.public("/app/banners", "", h.ListAppBanners)

View File

@ -16,6 +16,12 @@ import (
const resourceTypeEmojiPack = "emoji_pack"
const (
defaultEmojiPackCategory = "默认"
emojiPackPricingFree = "free"
emojiPackPricingPaid = "paid"
)
type resourceData struct {
ResourceID int64 `json:"resource_id"`
ResourceCode string `json:"resource_code"`
@ -86,6 +92,8 @@ type emojiPackData struct {
ResourceID int64 `json:"resource_id"`
ResourceCode string `json:"resource_code"`
Name string `json:"name"`
Category string `json:"category"`
PricingType string `json:"pricing_type"`
CoverURL string `json:"cover_url"`
AnimationURL string `json:"animation_url"`
RegionIDs []int64 `json:"region_ids"`
@ -94,6 +102,12 @@ type emojiPackData struct {
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type emojiPackMetadata struct {
Category string `json:"category"`
PricingType string `json:"pricing_type"`
RegionIDs []int64 `json:"region_ids"`
}
type userResourceData struct {
EntitlementID string `json:"entitlement_id"`
ResourceID int64 `json:"resource_id"`
@ -414,30 +428,55 @@ func emojiPackFromProto(item *walletv1.Resource) emojiPackData {
if item == nil {
return emojiPackData{}
}
regionIDs := emojiRegionIDsFromMetadata(item.GetMetadataJson())
metadata := emojiPackMetadataFromJSON(item.GetMetadataJson())
return emojiPackData{
ResourceID: item.GetResourceId(),
ResourceCode: item.GetResourceCode(),
Name: item.GetName(),
Category: metadata.Category,
PricingType: metadata.PricingType,
CoverURL: firstNonEmpty(item.GetPreviewUrl(), item.GetAssetUrl()),
AnimationURL: item.GetAnimationUrl(),
RegionIDs: regionIDs,
IsGlobal: len(regionIDs) == 0,
RegionIDs: metadata.RegionIDs,
IsGlobal: len(metadata.RegionIDs) == 0,
SortOrder: item.GetSortOrder(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func emojiRegionIDsFromMetadata(metadataJSON string) []int64 {
var payload struct {
RegionIDs []int64 `json:"region_ids"`
func emojiPackMetadataFromJSON(metadataJSON string) emojiPackMetadata {
payload := emojiPackMetadata{
Category: defaultEmojiPackCategory,
PricingType: emojiPackPricingFree,
}
if err := json.Unmarshal([]byte(metadataJSON), &payload); err != nil {
return nil
return payload
}
out := make([]int64, 0, len(payload.RegionIDs))
seen := make(map[int64]struct{}, len(payload.RegionIDs))
for _, regionID := range payload.RegionIDs {
payload.Category = normalizeEmojiPackCategory(payload.Category)
payload.PricingType = normalizeEmojiPackPricingType(payload.PricingType)
payload.RegionIDs = normalizeEmojiPackRegionIDs(payload.RegionIDs)
return payload
}
func normalizeEmojiPackCategory(value string) string {
category := strings.TrimSpace(value)
if category == "" {
return defaultEmojiPackCategory
}
return category
}
func normalizeEmojiPackPricingType(value string) string {
if strings.ToLower(strings.TrimSpace(value)) == emojiPackPricingPaid {
return emojiPackPricingPaid
}
return emojiPackPricingFree
}
func normalizeEmojiPackRegionIDs(regionIDs []int64) []int64 {
out := make([]int64, 0, len(regionIDs))
seen := make(map[int64]struct{}, len(regionIDs))
for _, regionID := range regionIDs {
if regionID <= 0 {
return nil
}

View File

@ -302,9 +302,12 @@ type fakeUserDeviceClient struct {
}
type fakeUserCountryQueryClient struct {
last *userv1.ListRegistrationCountriesRequest
resp *userv1.ListRegistrationCountriesResponse
err error
last *userv1.ListRegistrationCountriesRequest
resp *userv1.ListRegistrationCountriesResponse
err error
lastBlocked *userv1.ListLoginRiskBlockedCountriesRequest
blockedResp *userv1.ListLoginRiskBlockedCountriesResponse
blockedErr error
}
type fakeUserHostClient struct {
@ -734,6 +737,18 @@ func (f *fakeUserCountryQueryClient) ListRegistrationCountries(_ context.Context
return &userv1.ListRegistrationCountriesResponse{}, nil
}
func (f *fakeUserCountryQueryClient) ListLoginRiskBlockedCountries(_ context.Context, req *userv1.ListLoginRiskBlockedCountriesRequest) (*userv1.ListLoginRiskBlockedCountriesResponse, error) {
f.lastBlocked = req
if f.blockedErr != nil {
return nil, f.blockedErr
}
if f.blockedResp != nil {
return f.blockedResp, nil
}
return &userv1.ListLoginRiskBlockedCountriesResponse{}, nil
}
func (f *fakeMessageInboxClient) ListMessageTabs(_ context.Context, req *activityv1.ListMessageTabsRequest) (*activityv1.ListMessageTabsResponse, error) {
f.lastTabs = req
if f.err != nil {
@ -2057,6 +2072,46 @@ func TestListRegistrationCountriesIsPublicAndReturnsCountryMetadata(t *testing.T
}
}
func TestListLoginRiskBlockedCountriesIsPublic(t *testing.T) {
countryClient := &fakeUserCountryQueryClient{blockedResp: &userv1.ListLoginRiskBlockedCountriesResponse{
Countries: []*userv1.LoginRiskBlockedCountry{
{CountryCode: "CN", Keyword: "中国", UpdatedAtMs: 1700000000000},
},
UpdatedAtMs: 1700000000000,
}}
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetUserCountryQueryClient(countryClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/login-risk/blocked-countries", nil)
request.Header.Set("X-Request-ID", "req-risk-blocks")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
if countryClient.lastBlocked == nil || countryClient.lastBlocked.GetMeta().GetRequestId() != assertGeneratedRequestID(t, recorder, "req-risk-blocks") {
t.Fatalf("login risk block request metadata mismatch: %+v", countryClient.lastBlocked)
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok {
t.Fatalf("unexpected envelope: %+v", response)
}
countries, ok := data["countries"].([]any)
if !ok || len(countries) != 1 || data["updated_at_ms"].(float64) != 1700000000000 {
t.Fatalf("blocked countries response shape mismatch: %+v", data)
}
country, ok := countries[0].(map[string]any)
if !ok || country["country_code"] != "CN" || country["keyword"] != "中国" {
t.Fatalf("blocked country metadata mismatch: %+v", country)
}
}
func TestListH5LinksReturnsAdminAppConfig(t *testing.T) {
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetAppConfigReader(appconfig.StaticReader{Links: []appconfig.H5Link{
@ -2947,7 +3002,7 @@ func TestListEmojiPacksFiltersRegionAndReturnsAssets(t *testing.T) {
Status: "active",
PreviewUrl: "https://cdn.example/emoji/global.png",
AnimationUrl: "https://cdn.example/emoji/global.svga",
MetadataJson: `{"region_scope":"all"}`,
MetadataJson: `{"category":"默认","pricing_type":"free","region_scope":"all"}`,
SortOrder: 10,
},
{
@ -2958,7 +3013,7 @@ func TestListEmojiPacksFiltersRegionAndReturnsAssets(t *testing.T) {
Status: "active",
AssetUrl: "https://cdn.example/emoji/saudi.png",
AnimationUrl: "https://cdn.example/emoji/saudi.pag",
MetadataJson: `{"region_ids":[1001]}`,
MetadataJson: `{"category":"热门","pricing_type":"paid","region_ids":[1001]}`,
SortOrder: 20,
},
{
@ -2991,6 +3046,8 @@ func TestListEmojiPacksFiltersRegionAndReturnsAssets(t *testing.T) {
Data struct {
Items []struct {
ResourceID int64 `json:"resource_id"`
Category string `json:"category"`
PricingType string `json:"pricing_type"`
CoverURL string `json:"cover_url"`
AnimationURL string `json:"animation_url"`
RegionIDs []int64 `json:"region_ids"`
@ -3008,10 +3065,10 @@ func TestListEmojiPacksFiltersRegionAndReturnsAssets(t *testing.T) {
if envelope.Code != httpkit.CodeOK || envelope.Data.Total != 2 || len(envelope.Data.Items) != 2 {
t.Fatalf("emoji pack envelope mismatch: %+v", envelope)
}
if !envelope.Data.Items[0].IsGlobal || envelope.Data.Items[0].CoverURL != "https://cdn.example/emoji/global.png" {
if !envelope.Data.Items[0].IsGlobal || envelope.Data.Items[0].CoverURL != "https://cdn.example/emoji/global.png" || envelope.Data.Items[0].Category != "默认" || envelope.Data.Items[0].PricingType != "free" {
t.Fatalf("global emoji pack mismatch: %+v", envelope.Data.Items[0])
}
if envelope.Data.Items[1].ResourceID != 7002 || envelope.Data.Items[1].AnimationURL != "https://cdn.example/emoji/saudi.pag" || len(envelope.Data.Items[1].RegionIDs) != 1 || envelope.Data.Items[1].RegionIDs[0] != 1001 {
if envelope.Data.Items[1].ResourceID != 7002 || envelope.Data.Items[1].AnimationURL != "https://cdn.example/emoji/saudi.pag" || envelope.Data.Items[1].Category != "热门" || envelope.Data.Items[1].PricingType != "paid" || len(envelope.Data.Items[1].RegionIDs) != 1 || envelope.Data.Items[1].RegionIDs[0] != 1001 {
t.Fatalf("regional emoji pack mismatch: %+v", envelope.Data.Items[1])
}
}

View File

@ -118,21 +118,22 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
Logout: h.logout,
},
App: httproutes.AppHandlers{
ListRegistrationCountries: userAPI.ListRegistrationCountries,
GetAppBootstrap: appAPI.GetAppBootstrap,
ListH5Links: appAPI.ListH5Links,
ListAppBanners: appAPI.ListAppBanners,
GetAppVersion: appAPI.GetAppVersion,
GetResourceGroup: resourceAPI.GetResourceGroup,
ListGifts: resourceAPI.ListGifts,
ListEmojiPacks: resourceAPI.ListEmojiPacks,
IssueTencentIMUserSig: h.issueTencentIMUserSig,
IssueTencentRTCToken: roomAPI.IssueTencentRTCToken,
HandleTencentIMCallback: callbackAPI.HandleTencentIMCallback,
HandleTencentRTCCallback: callbackAPI.HandleTencentRTCCallback,
UploadFile: appAPI.UploadFile,
UploadAvatar: appAPI.UploadAvatar,
HandlePushToken: appAPI.HandlePushToken,
ListRegistrationCountries: userAPI.ListRegistrationCountries,
ListLoginRiskBlockedCountries: userAPI.ListLoginRiskBlockedCountries,
GetAppBootstrap: appAPI.GetAppBootstrap,
ListH5Links: appAPI.ListH5Links,
ListAppBanners: appAPI.ListAppBanners,
GetAppVersion: appAPI.GetAppVersion,
GetResourceGroup: resourceAPI.GetResourceGroup,
ListGifts: resourceAPI.ListGifts,
ListEmojiPacks: resourceAPI.ListEmojiPacks,
IssueTencentIMUserSig: h.issueTencentIMUserSig,
IssueTencentRTCToken: roomAPI.IssueTencentRTCToken,
HandleTencentIMCallback: callbackAPI.HandleTencentIMCallback,
HandleTencentRTCCallback: callbackAPI.HandleTencentRTCCallback,
UploadFile: appAPI.UploadFile,
UploadAvatar: appAPI.UploadAvatar,
HandlePushToken: appAPI.HandlePushToken,
},
User: userHandlers,
Manager: managerAPI.ManagerHandlers(),

View File

@ -65,3 +65,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
func (h *Handler) ListRegistrationCountries(writer http.ResponseWriter, request *http.Request) {
h.listRegistrationCountries(writer, request)
}
func (h *Handler) ListLoginRiskBlockedCountries(writer http.ResponseWriter, request *http.Request) {
h.listLoginRiskBlockedCountries(writer, request)
}

View File

@ -84,6 +84,14 @@ type countryData struct {
SortOrder int32 `json:"sort_order"`
}
// loginRiskBlockedCountryData 是 App 登录后异步地区风控只读词表。
// keyword 保留后台录入词country_code 为空时表示该词只供客户端本地匹配。
type loginRiskBlockedCountryData struct {
CountryCode string `json:"country_code,omitempty"`
Keyword string `json:"keyword"`
UpdatedAtMs int64 `json:"updated_at_ms"`
}
// listRegistrationCountries 返回 App 注册页可选国家。
// 该接口公开读,不要求 access tokenuser-service 负责只返回 active 且 enabled 的国家。
func (h *Handler) listRegistrationCountries(writer http.ResponseWriter, request *http.Request) {
@ -107,6 +115,33 @@ func (h *Handler) listRegistrationCountries(writer http.ResponseWriter, request
httpkit.WriteOK(writer, request, map[string]any{"countries": countries})
}
// listLoginRiskBlockedCountries 返回 App 可异步拉取的地区屏蔽词表。
// 登录最终处置仍由 user-service 风控 worker 决定,客户端只做补充风险探测和上报。
func (h *Handler) listLoginRiskBlockedCountries(writer http.ResponseWriter, request *http.Request) {
if h.userCountryClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
resp, err := h.userCountryClient.ListLoginRiskBlockedCountries(request.Context(), &userv1.ListLoginRiskBlockedCountriesRequest{
Meta: httpkit.UserMeta(request, ""),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
countries := make([]loginRiskBlockedCountryData, 0, len(resp.GetCountries()))
for _, country := range resp.GetCountries() {
countries = append(countries, loginRiskBlockedCountryData{
CountryCode: country.GetCountryCode(),
Keyword: country.GetKeyword(),
UpdatedAtMs: country.GetUpdatedAtMs(),
})
}
httpkit.WriteOK(writer, request, map[string]any{"countries": countries, "updated_at_ms": resp.GetUpdatedAtMs()})
}
// getMyIdentity 返回当前登录用户的短号状态。
func (h *Handler) getMyIdentity(writer http.ResponseWriter, request *http.Request) {
if h.userIdentityClient == nil {

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26.3-alpine AS builder
WORKDIR /src
@ -9,12 +10,16 @@ ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
COPY go.mod go.sum ./
COPY api/go.mod api/go.sum ./api/
RUN GOWORK=off go mod download
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
GOWORK=off go mod download
COPY . .
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/notice-service/cmd/server
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-health-probe ./cmd/grpc-health-probe
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
mkdir -p /out && \
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/ ./services/notice-service/cmd/server ./cmd/grpc-health-probe
FROM alpine:3.20

View File

@ -9,12 +9,12 @@ room_database: "hyapp_room"
mysql_auto_migrate: false
tencent_im:
enabled: false
sdk_app_id: 0
secret_key: ""
admin_identifier: "administrator"
enabled: true
sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
admin_user_sig_ttl: 24h
endpoint: "console.tim.qq.com"
endpoint: "adminapisgp.im.qcloud.com"
request_timeout: 5s
wallet_notice_worker:
@ -28,7 +28,7 @@ wallet_notice_worker:
max_backoff: 5m
room_notice_worker:
enabled: false
enabled: true
poll_interval: 1s
batch_size: 100
lock_ttl: 30s

View File

@ -9,12 +9,12 @@ room_database: "hyapp_room"
mysql_auto_migrate: false
tencent_im:
enabled: false
sdk_app_id: 0
secret_key: ""
admin_identifier: "administrator"
enabled: true
sdk_app_id: 20036101
secret_key: "dcdf53135f27e7cf8541c4ae9798fb0cb2f0e4d6c5c20022dbaea053f35cbb8c"
admin_identifier: "900100100"
admin_user_sig_ttl: 24h
endpoint: "console.tim.qq.com"
endpoint: "adminapisgp.im.qcloud.com"
request_timeout: 5s
wallet_notice_worker:
@ -28,7 +28,7 @@ wallet_notice_worker:
max_backoff: 5m
room_notice_worker:
enabled: false
enabled: true
poll_interval: 1s
batch_size: 100
lock_ttl: 30s

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26.3-alpine AS builder
WORKDIR /src
@ -9,12 +10,16 @@ ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
COPY go.mod go.sum ./
COPY api/go.mod api/go.sum ./api/
RUN GOWORK=off go mod download
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
GOWORK=off go mod download
COPY . .
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/room-service/cmd/server
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-health-probe ./cmd/grpc-health-probe
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
mkdir -p /out && \
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/ ./services/room-service/cmd/server ./cmd/grpc-health-probe
FROM alpine:3.20

View File

@ -29,11 +29,11 @@ tencent_im:
group_type: "ChatRoom"
request_timeout: "5s"
tencent_rtc:
# Docker 本地默认关闭真实 TRTC 管理 API开启后封禁会同步调用 RemoveUserByStrRoomId。
enabled: false
sdk_app_id: 0
secret_id: ""
secret_key: ""
# Docker 本地联调真实 TRTC 管理 API关闭房间会同步调用 RemoveUserByStrRoomId。
enabled: true
sdk_app_id: 20036101
secret_id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
secret_key: "nMkbLsGRO6ZqulSyJQJ0UjjU0KSKxOgl"
region: "ap-guangzhou"
endpoint: "trtc.tencentcloudapi.com"
request_timeout: "5s"

View File

@ -32,11 +32,11 @@ tencent_im:
# outbox worker 建群和发送房间系统消息的单次 REST 超时。
request_timeout: "5s"
tencent_rtc:
# 本地默认关闭真实 TRTC 管理 API开启后封禁会同步调用 RemoveUserByStrRoomId。
enabled: false
sdk_app_id: 0
secret_id: ""
secret_key: ""
# 本地联调真实 TRTC 管理 API关闭房间会同步调用 RemoveUserByStrRoomId。
enabled: true
sdk_app_id: 20036101
secret_id: "IKIDMchhZEfrsiNo472DAtTpzzmLjttkOnyu"
secret_key: "nMkbLsGRO6ZqulSyJQJ0UjjU0KSKxOgl"
region: "ap-guangzhou"
endpoint: "trtc.tencentcloudapi.com"
request_timeout: "5s"

View File

@ -15,6 +15,10 @@ CREATE TABLE IF NOT EXISTS rooms (
mode VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
room_password_hash VARCHAR(128) NOT NULL DEFAULT '',
close_reason VARCHAR(512) NOT NULL DEFAULT '',
closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '',
closed_at_ms BIGINT NOT NULL DEFAULT 0,
visible_region_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
@ -35,6 +39,10 @@ CREATE TABLE IF NOT EXISTS room_list_entries (
mode VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
locked BOOLEAN NOT NULL DEFAULT FALSE,
close_reason VARCHAR(512) NOT NULL DEFAULT '',
closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '',
closed_at_ms BIGINT NOT NULL DEFAULT 0,
heat BIGINT NOT NULL DEFAULT 0,
online_count INT NOT NULL DEFAULT 0,
seat_count INT NOT NULL DEFAULT 0,

View File

@ -130,6 +130,15 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
base.ActorUserID = body.GetUserId()
base.TargetUserID = body.GetUserId()
return base, true, nil
case "RoomClosed":
// 关房事件提示仍在群内的客户端刷新房间终态;私有通知由 RoomUserKicked 事实另行投递。
var body roomeventsv1.RoomClosed
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.Attributes = map[string]string{"reason": body.GetReason()}
return base, true, nil
case "RoomMicChanged":
// 麦位变更把 from/to 合并成一个客户端事件,避免换麦出现中间空位状态。
var body roomeventsv1.RoomMicChanged
@ -260,6 +269,8 @@ func eventTypeForClient(eventType string) string {
return "room_user_joined"
case "RoomUserLeft":
return "room_user_left"
case "RoomClosed":
return "room_closed"
case "RoomMicChanged":
return "room_mic_changed"
case "RoomMicSeatLocked":

View File

@ -2,18 +2,28 @@ package service_test
import (
"context"
"fmt"
"testing"
"time"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/services/room-service/internal/integration"
roomoutbox "hyapp/services/room-service/internal/room/outbox"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
type fakeRTCUserRemover struct {
calls []rtcRemoveCall
roomID string
userID int64
}
type rtcRemoveCall struct {
roomID string
userID int64
}
@ -21,6 +31,7 @@ type fakeRTCUserRemover struct {
func (r *fakeRTCUserRemover) RemoveUserByStrRoomID(_ context.Context, roomID string, userID int64) error {
r.roomID = roomID
r.userID = userID
r.calls = append(r.calls, rtcRemoveCall{roomID: roomID, userID: userID})
return nil
}
@ -89,6 +100,95 @@ func TestKickUserRemovesPresenceBanAndRTCConnection(t *testing.T) {
}
}
func TestCloseRoomByAdminEvictsCurrentUsersThroughOutboxAndRTC(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
rtc := &fakeRTCUserRemover{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-close-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
RTCUserRemover: rtc,
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-close-flow"
ownerID := int64(8101)
viewerID := int64(8201)
adminID := int64(1)
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: 8101,
RoomName: "Close Flow",
RoomShortId: "close-flow",
}); err != nil {
t.Fatalf("create close room fixture failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("join close target failed: %v", err)
}
resp, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-close-admin",
CommandId: "cmd-close-admin",
ActorUserId: adminID,
RoomId: roomID,
AppCode: appcode.Default,
SentAtMs: time.Now().UnixMilli(),
},
Reason: "admin_closed",
})
if err != nil {
t.Fatalf("admin close room failed: %v", err)
}
if resp.GetRoom().GetStatus() != "closed" || len(resp.GetRoom().GetOnlineUsers()) != 0 {
t.Fatalf("admin close response must close room and clear users: %+v", resp.GetRoom())
}
if !hasRTCRemoveCall(rtc.calls, roomID, ownerID) || !hasRTCRemoveCall(rtc.calls, roomID, viewerID) {
t.Fatalf("admin close must remove all current RTC users: %+v", rtc.calls)
}
records, err := repository.ListPendingOutbox(ctx, 20)
if err != nil {
t.Fatalf("list pending outbox failed: %v", err)
}
if !hasOutboxEvent(records, "RoomClosed", 0) ||
!hasOutboxEvent(records, "RoomUserKicked", ownerID) ||
!hasOutboxEvent(records, "RoomUserKicked", viewerID) {
t.Fatalf("admin close must write room closed and per-user kick events: %+v", outboxSummary(records))
}
reopenResp, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{
Meta: &roomv1.RequestMeta{
RequestId: "req-reopen-admin",
CommandId: "cmd-reopen-admin",
ActorUserId: adminID,
RoomId: roomID,
AppCode: appcode.Default,
SentAtMs: time.Now().UnixMilli(),
},
Reason: "admin_reopen",
})
if err != nil {
t.Fatalf("admin reopen room failed: %v", err)
}
if reopenResp.GetRoom().GetStatus() != "active" {
t.Fatalf("admin reopen response must make room active: %+v", reopenResp.GetRoom())
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("viewer must join after admin reopen: %v", err)
}
}
func findRoomUser(snapshot *roomv1.RoomSnapshot, userID int64) *roomv1.RoomUser {
for _, user := range snapshot.GetOnlineUsers() {
if user.GetUserId() == userID {
@ -106,3 +206,39 @@ func containsInt64(values []int64, target int64) bool {
}
return false
}
func hasRTCRemoveCall(calls []rtcRemoveCall, roomID string, userID int64) bool {
for _, call := range calls {
if call.roomID == roomID && call.userID == userID {
return true
}
}
return false
}
func hasOutboxEvent(records []roomoutbox.Record, eventType string, targetUserID int64) bool {
for _, record := range records {
if record.EventType != eventType {
continue
}
if targetUserID == 0 {
return true
}
var body roomeventsv1.RoomUserKicked
if record.Envelope == nil || proto.Unmarshal(record.Envelope.GetBody(), &body) != nil {
continue
}
if body.GetTargetUserId() == targetUserID {
return true
}
}
return false
}
func outboxSummary(records []roomoutbox.Record) []string {
summary := make([]string, 0, len(records))
for _, record := range records {
summary = append(summary, fmt.Sprintf("%s:%s", record.EventType, record.EventID))
}
return summary
}

View File

@ -2,6 +2,7 @@ package service
import (
"context"
"sort"
"strings"
"time"
@ -49,7 +50,11 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
if cmd.Reason == "" {
cmd.Reason = "closed_by_owner"
}
if isAdminReopenReason(cmd.Reason) {
return s.reopenRoom(ctx, cmd)
}
rtcKickTargets := make([]int64, 0)
result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if current == nil {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
@ -62,8 +67,15 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
// creating 等非开放状态不允许用 CloseRoom 直接跳过创建收尾语义。
return mutationResult{}, nil, xerr.New(xerr.Conflict, "room is not active")
}
if err := requireOwnerPresent(current, cmd.ActorUserID()); err != nil {
return mutationResult{}, nil, err
if !isAdminCloseReason(cmd.Reason) {
if err := requireOwnerPresent(current, cmd.ActorUserID()); err != nil {
return mutationResult{}, nil, err
}
}
kickTargets := closeRoomKickTargets(current)
if len(kickTargets) > 0 {
rtcKickTargets = append(rtcKickTargets[:0], kickTargets...)
}
type occupiedMicSeat struct {
@ -91,6 +103,7 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
}
current.Version++
records := make([]outbox.Record, 0, len(occupiedSeats)+len(kickTargets)+1)
closedEvent, err := outbox.Build(current.RoomID, "RoomClosed", current.Version, now, &roomeventsv1.RoomClosed{
ActorUserId: cmd.ActorUserID(),
Reason: cmd.Reason,
@ -98,7 +111,20 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
if err != nil {
return mutationResult{}, nil, err
}
records := make([]outbox.Record, 0, len(occupiedSeats)+1)
records = append(records, closedEvent)
for _, targetUserID := range kickTargets {
// 关房清场复用 RoomUserKicked 事实notice-service 和腾讯 IM 群成员移除可沿用同一条消费链路。
kickEvent, err := outbox.Build(current.RoomID, "RoomUserKicked", current.Version, now, &roomeventsv1.RoomUserKicked{
ActorUserId: cmd.ActorUserID(),
TargetUserId: targetUserID,
})
if err != nil {
return mutationResult{}, nil, err
}
records = append(records, kickEvent)
}
for _, seat := range occupiedSeats {
// 关房是 Room Cell 统一清场,给每个占麦 session 单独写 down 事件,保证用户时长能闭合。
micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), seat.userID, seat.seatNo, seat.micSessionID, "room_closed")
@ -107,7 +133,6 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
}
records = append(records, micEvent)
}
records = append(records, closedEvent)
return mutationResult{
snapshot: current.ToProto(),
@ -128,8 +153,78 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
return nil, err
}
response := &roomv1.CloseRoomResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
Room: result.snapshot,
}
if result.applied && s.rtcUserRemover != nil {
for _, targetUserID := range rtcKickTargets {
// RTC 是房间外连接态:关房事实已提交后再逐个移除,失败不回滚 Room Cell。
_ = s.rtcUserRemover.RemoveUserByStrRoomID(ctx, cmd.RoomID(), targetUserID)
}
}
return response, nil
}
func isAdminCloseReason(reason string) bool {
return strings.EqualFold(strings.TrimSpace(reason), "admin_closed")
}
func isAdminReopenReason(reason string) bool {
return strings.EqualFold(strings.TrimSpace(reason), "admin_reopen")
}
func (s *Service) reopenRoom(ctx context.Context, cmd command.CloseRoom) (*roomv1.CloseRoomResponse, error) {
result, err := s.mutateRoom(ctx, cmd, true, func(_ time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) {
if current == nil {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found")
}
if current.Status == state.RoomStatusActive {
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
if current.Status != state.RoomStatusClosed {
return mutationResult{}, nil, xerr.New(xerr.Conflict, "room is not closed")
}
current.Status = state.RoomStatusActive
current.Version++
return mutationResult{
snapshot: current.ToProto(),
roomStatus: state.RoomStatusActive,
}, nil, nil
})
if err != nil {
return nil, err
}
return &roomv1.CloseRoomResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
Room: result.snapshot,
}, nil
}
func closeRoomKickTargets(current *state.RoomState) []int64 {
if current == nil {
return nil
}
seen := make(map[int64]struct{}, len(current.OnlineUsers)+len(current.MicSeats))
for userID := range current.OnlineUsers {
if userID > 0 {
seen[userID] = struct{}{}
}
}
for _, seat := range current.MicSeats {
if seat.UserID > 0 {
seen[seat.UserID] = struct{}{}
}
}
targets := make([]int64, 0, len(seen))
for userID := range seen {
targets = append(targets, userID)
}
sort.Slice(targets, func(left, right int) bool {
return targets[left] < targets[right]
})
return targets
}

View File

@ -80,6 +80,10 @@ func (r *Repository) Migrate(ctx context.Context) error {
mode VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
room_password_hash VARCHAR(128) NOT NULL DEFAULT '',
close_reason VARCHAR(512) NOT NULL DEFAULT '',
closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '',
closed_at_ms BIGINT NOT NULL DEFAULT 0,
visible_region_id BIGINT NOT NULL DEFAULT 0,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
@ -99,6 +103,10 @@ func (r *Repository) Migrate(ctx context.Context) error {
mode VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL,
locked BOOLEAN NOT NULL DEFAULT FALSE,
close_reason VARCHAR(512) NOT NULL DEFAULT '',
closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '',
closed_at_ms BIGINT NOT NULL DEFAULT 0,
heat BIGINT NOT NULL DEFAULT 0,
online_count INT NOT NULL DEFAULT 0,
seat_count INT NOT NULL DEFAULT 0,
@ -245,6 +253,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureRoomPasswordSchema(ctx); err != nil {
return err
}
if err := r.ensureRoomAdminCloseSchema(ctx); err != nil {
return err
}
return r.ensureOutboxRetrySchema(ctx)
}
@ -277,6 +288,35 @@ func (r *Repository) ensureRoomListLockSchema(ctx context.Context) error {
return nil
}
func (r *Repository) ensureRoomAdminCloseSchema(ctx context.Context) error {
columns := []struct {
table string
column string
statement string
}{
{"rooms", "close_reason", `ALTER TABLE rooms ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER room_password_hash`},
{"rooms", "closed_by_admin_id", `ALTER TABLE rooms ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`},
{"rooms", "closed_by_admin_name", `ALTER TABLE rooms ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`},
{"rooms", "closed_at_ms", `ALTER TABLE rooms ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`},
{"room_list_entries", "close_reason", `ALTER TABLE room_list_entries ADD COLUMN close_reason VARCHAR(512) NOT NULL DEFAULT '' AFTER locked`},
{"room_list_entries", "closed_by_admin_id", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0 AFTER close_reason`},
{"room_list_entries", "closed_by_admin_name", `ALTER TABLE room_list_entries ADD COLUMN closed_by_admin_name VARCHAR(128) NOT NULL DEFAULT '' AFTER closed_by_admin_id`},
{"room_list_entries", "closed_at_ms", `ALTER TABLE room_list_entries ADD COLUMN closed_at_ms BIGINT NOT NULL DEFAULT 0 AFTER closed_by_admin_name`},
}
for _, item := range columns {
hasColumn, err := r.columnExists(ctx, item.table, item.column)
if err != nil {
return err
}
if !hasColumn {
if _, err := r.db.ExecContext(ctx, item.statement); err != nil {
return err
}
}
}
return nil
}
func (r *Repository) ensureOutboxRetrySchema(ctx context.Context) error {
hasColumn, err := r.columnExists(ctx, "room_outbox", "next_retry_at_ms")
if err != nil {

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26.3-alpine AS builder
WORKDIR /src
@ -9,12 +10,16 @@ ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
COPY go.mod go.sum ./
COPY api/go.mod api/go.sum ./api/
RUN GOWORK=off go mod download
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
GOWORK=off go mod download
COPY . .
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/user-service/cmd/server
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-health-probe ./cmd/grpc-health-probe
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
mkdir -p /out && \
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/ ./services/user-service/cmd/server ./cmd/grpc-health-probe
FROM alpine:3.20

File diff suppressed because it is too large Load Diff

View File

@ -622,40 +622,24 @@ CREATE TABLE IF NOT EXISTS host_outbox (
-- Country bootstrap is generated from services/user-service/deploy/data/countries.json.
-- Region bootstrap uses product-owned business buckets instead of source geography.
INSERT INTO countries (country_name, country_code, iso_alpha3, iso_numeric, country_display_name, phone_country_code, flag, enabled, sort_order, created_at_ms, updated_at_ms) VALUES
('Andorra', 'AD', 'AND', '020', '安道尔', '+376', '🇦🇩', TRUE, 10, 0, 0),
('United Arab Emirates', 'AE', 'ARE', '784', '阿拉伯联合酋长国', '+971', '🇦🇪', TRUE, 20, 0, 0),
('Afghanistan', 'AF', 'AFG', '004', '阿富汗', '+93', '🇦🇫', TRUE, 30, 0, 0),
('Anguilla', 'AI', 'AIA', '660', '安圭拉', '+1', '🇦🇮', TRUE, 50, 0, 0),
('Albania', 'AL', 'ALB', '008', '阿尔巴尼亚', '+355', '🇦🇱', TRUE, 60, 0, 0),
('Armenia', 'AM', 'ARM', '051', '亚美尼亚', '+374', '🇦🇲', TRUE, 70, 0, 0),
('Angola', 'AO', 'AGO', '024', '安哥拉', '+244', '🇦🇴', TRUE, 80, 0, 0),
('Argentina', 'AR', 'ARG', '032', '阿根廷', '+54', '🇦🇷', TRUE, 100, 0, 0),
('Austria', 'AT', 'AUT', '040', '奥地利', '+43', '🇦🇹', TRUE, 120, 0, 0),
('Australia', 'AU', 'AUS', '036', '澳大利亚', '+61', '🇦🇺', TRUE, 130, 0, 0),
('Aruba', 'AW', 'ABW', '533', '阿鲁巴', '+297', '🇦🇼', TRUE, 140, 0, 0),
('Åland Islands', 'AX', 'ALA', '248', '奥兰群岛', '+358', '🇦🇽', TRUE, 150, 0, 0),
('Azerbaijan', 'AZ', 'AZE', '031', '阿塞拜疆', '+994', '🇦🇿', TRUE, 160, 0, 0),
('Barbados', 'BB', 'BRB', '052', '巴巴多斯', '+1', '🇧🇧', TRUE, 180, 0, 0),
('Bangladesh', 'BD', 'BGD', '050', '孟加拉国', '+880', '🇧🇩', TRUE, 190, 0, 0),
('Belgium', 'BE', 'BEL', '056', '比利时', '+32', '🇧🇪', TRUE, 200, 0, 0),
('Burkina Faso', 'BF', 'BFA', '854', '布基纳法索', '+226', '🇧🇫', TRUE, 210, 0, 0),
('Bulgaria', 'BG', 'BGR', '100', '保加利亚', '+359', '🇧🇬', TRUE, 220, 0, 0),
('Bahrain', 'BH', 'BHR', '048', '巴林', '+973', '🇧🇭', TRUE, 230, 0, 0),
('Burundi', 'BI', 'BDI', '108', '布隆迪', '+257', '🇧🇮', TRUE, 240, 0, 0),
('Benin', 'BJ', 'BEN', '204', '贝宁', '+229', '🇧🇯', TRUE, 250, 0, 0),
('Saint Barthélemy', 'BL', 'BLM', '652', '圣巴泰勒米', '+590', '🇧🇱', TRUE, 260, 0, 0),
('Bermuda', 'BM', 'BMU', '060', '百慕大', '+1', '🇧🇲', TRUE, 270, 0, 0),
('Brunei', 'BN', 'BRN', '096', '文莱', '+673', '🇧🇳', TRUE, 280, 0, 0),
('Bolivia', 'BO', 'BOL', '068', '玻利维亚', '+591', '🇧🇴', TRUE, 290, 0, 0),
('Brazil', 'BR', 'BRA', '076', '巴西', '+55', '🇧🇷', TRUE, 310, 0, 0),
('Bahamas', 'BS', 'BHS', '044', '巴哈马', '+1', '🇧🇸', TRUE, 320, 0, 0),
('Bhutan', 'BT', 'BTN', '064', '不丹', '+975', '🇧🇹', TRUE, 330, 0, 0),
('Bouvet Island', 'BV', 'BVT', '074', '布维岛', NULL, '🇧🇻', TRUE, 340, 0, 0),
('Botswana', 'BW', 'BWA', '072', '博茨瓦纳', '+267', '🇧🇼', TRUE, 350, 0, 0),
('Belarus', 'BY', 'BLR', '112', '白俄罗斯', '+375', '🇧🇾', TRUE, 360, 0, 0),
('Belize', 'BZ', 'BLZ', '084', '伯利兹', '+501', '🇧🇿', TRUE, 370, 0, 0),
('Canada', 'CA', 'CAN', '124', '加拿大', '+1', '🇨🇦', TRUE, 380, 0, 0),
('Cocos (Keeling) Islands', 'CC', 'CCK', '166', '科科斯', '+61', '🇨🇨', TRUE, 390, 0, 0),
('DR Congo', 'CD', 'COD', '180', '民主刚果', '+243', '🇨🇩', TRUE, 400, 0, 0),
('Central African Republic', 'CF', 'CAF', '140', '中非共和国', '+236', '🇨🇫', TRUE, 410, 0, 0),
('Congo', 'CG', 'COG', '178', '刚果', '+242', '🇨🇬', TRUE, 420, 0, 0),
@ -663,19 +647,14 @@ INSERT INTO countries (country_name, country_code, iso_alpha3, iso_numeric, coun
('Ivory Coast', 'CI', 'CIV', '384', '科特迪瓦', '+225', '🇨🇮', TRUE, 440, 0, 0),
('Chile', 'CL', 'CHL', '152', '智利', '+56', '🇨🇱', TRUE, 460, 0, 0),
('Cameroon', 'CM', 'CMR', '120', '喀麦隆', '+237', '🇨🇲', TRUE, 470, 0, 0),
('China', 'CN', 'CHN', '156', '中国', '+86', '🇨🇳', TRUE, 480, 0, 0),
('Colombia', 'CO', 'COL', '170', '哥伦比亚', '+57', '🇨🇴', TRUE, 490, 0, 0),
('Costa Rica', 'CR', 'CRI', '188', '哥斯达黎加', '+506', '🇨🇷', TRUE, 500, 0, 0),
('Cuba', 'CU', 'CUB', '192', '古巴', '+53', '🇨🇺', TRUE, 510, 0, 0),
('Cape Verde', 'CV', 'CPV', '132', '佛得角', '+238', '🇨🇻', TRUE, 520, 0, 0),
('Curaçao', 'CW', 'CUW', '531', '库拉索', '+599', '🇨🇼', TRUE, 530, 0, 0),
('Christmas Island', 'CX', 'CXR', '162', '圣诞岛', '+61', '🇨🇽', TRUE, 540, 0, 0),
('Cyprus', 'CY', 'CYP', '196', '塞浦路斯', '+357', '🇨🇾', TRUE, 550, 0, 0),
('Czechia', 'CZ', 'CZE', '203', '捷克', '+420', '🇨🇿', TRUE, 560, 0, 0),
('Germany', 'DE', 'DEU', '276', '德国', '+49', '🇩🇪', TRUE, 570, 0, 0),
('Djibouti', 'DJ', 'DJI', '262', '吉布提', '+253', '🇩🇯', TRUE, 580, 0, 0),
('Denmark', 'DK', 'DNK', '208', '丹麦', '+45', '🇩🇰', TRUE, 590, 0, 0),
('Dominica', 'DM', 'DMA', '212', '多米尼加', '+1', '🇩🇲', TRUE, 600, 0, 0),
('Dominican Republic', 'DO', 'DOM', '214', '多明尼加', '+1', '🇩🇴', TRUE, 610, 0, 0),
('Algeria', 'DZ', 'DZA', '012', '阿尔及利亚', '+213', '🇩🇿', TRUE, 620, 0, 0),
('Ecuador', 'EC', 'ECU', '218', '厄瓜多尔', '+593', '🇪🇨', TRUE, 630, 0, 0),
@ -685,26 +664,16 @@ INSERT INTO countries (country_name, country_code, iso_alpha3, iso_numeric, coun
('Spain', 'ES', 'ESP', '724', '西班牙', '+34', '🇪🇸', TRUE, 680, 0, 0),
('Ethiopia', 'ET', 'ETH', '231', '埃塞俄比亚', '+251', '🇪🇹', TRUE, 690, 0, 0),
('Finland', 'FI', 'FIN', '246', '芬兰', '+358', '🇫🇮', TRUE, 700, 0, 0),
('Fiji', 'FJ', 'FJI', '242', '斐济', '+679', '🇫🇯', TRUE, 710, 0, 0),
('Micronesia', 'FM', 'FSM', '583', '密克罗尼西亚', '+691', '🇫🇲', TRUE, 730, 0, 0),
('France', 'FR', 'FRA', '250', '法国', '+33', '🇫🇷', TRUE, 750, 0, 0),
('Gabon', 'GA', 'GAB', '266', '加蓬', '+241', '🇬🇦', TRUE, 760, 0, 0),
('United Kingdom', 'GB', 'GBR', '826', '英国', '+44', '🇬🇧', TRUE, 770, 0, 0),
('Grenada', 'GD', 'GRD', '308', '格林纳达', '+1', '🇬🇩', TRUE, 780, 0, 0),
('Georgia', 'GE', 'GEO', '268', '格鲁吉亚', '+995', '🇬🇪', TRUE, 790, 0, 0),
('French Guiana', 'GF', 'GUF', '254', '法属圭亚那', '+594', '🇬🇫', TRUE, 800, 0, 0),
('Guernsey', 'GG', 'GGY', '831', '根西岛', '+44', '🇬🇬', TRUE, 810, 0, 0),
('Ghana', 'GH', 'GHA', '288', '加纳', '+233', '🇬🇭', TRUE, 820, 0, 0),
('Gibraltar', 'GI', 'GIB', '292', '直布罗陀', '+350', '🇬🇮', TRUE, 830, 0, 0),
('Greenland', 'GL', 'GRL', '304', '格陵兰', '+299', '🇬🇱', TRUE, 840, 0, 0),
('Gambia', 'GM', 'GMB', '270', '冈比亚', '+220', '🇬🇲', TRUE, 850, 0, 0),
('Guinea', 'GN', 'GIN', '324', '几内亚', '+224', '🇬🇳', TRUE, 860, 0, 0),
('Equatorial Guinea', 'GQ', 'GNQ', '226', '赤道几内亚', '+240', '🇬🇶', TRUE, 880, 0, 0),
('Greece', 'GR', 'GRC', '300', '希腊', '+30', '🇬🇷', TRUE, 890, 0, 0),
('South Georgia', 'GS', 'SGS', '239', '南乔治亚', NULL, '🇬🇸', TRUE, 900, 0, 0),
('Guatemala', 'GT', 'GTM', '320', '危地马拉', '+502', '🇬🇹', TRUE, 910, 0, 0),
('Guam', 'GU', 'GUM', '316', '关岛', '+1', '🇬🇺', TRUE, 920, 0, 0),
('Guyana', 'GY', 'GUY', '328', '圭亚那', '+592', '🇬🇾', TRUE, 940, 0, 0),
('Honduras', 'HN', 'HND', '340', '洪都拉斯', '+504', '🇭🇳', TRUE, 970, 0, 0),
('Croatia', 'HR', 'HRV', '191', '克罗地亚', '+385', '🇭🇷', TRUE, 980, 0, 0),
('Haiti', 'HT', 'HTI', '332', '海地', '+509', '🇭🇹', TRUE, 990, 0, 0),
@ -712,68 +681,42 @@ INSERT INTO countries (country_name, country_code, iso_alpha3, iso_numeric, coun
('Indonesia', 'ID', 'IDN', '360', '印度尼西亚', '+62', '🇮🇩', TRUE, 1010, 0, 0),
('Ireland', 'IE', 'IRL', '372', '爱尔兰', '+353', '🇮🇪', TRUE, 1020, 0, 0),
('Israel', 'IL', 'ISR', '376', '以色列', '+972', '🇮🇱', TRUE, 1030, 0, 0),
('Isle of Man', 'IM', 'IMN', '833', '马恩岛', '+44', '🇮🇲', TRUE, 1040, 0, 0),
('India', 'IN', 'IND', '356', '印度', '+91', '🇮🇳', TRUE, 1050, 0, 0),
('Iraq', 'IQ', 'IRQ', '368', '伊拉克', '+964', '🇮🇶', TRUE, 1070, 0, 0),
('Iran', 'IR', 'IRN', '364', '伊朗', '+98', '🇮🇷', TRUE, 1080, 0, 0),
('Iceland', 'IS', 'ISL', '352', '冰岛', '+354', '🇮🇸', TRUE, 1090, 0, 0),
('Italy', 'IT', 'ITA', '380', '意大利', '+39', '🇮🇹', TRUE, 1100, 0, 0),
('Jersey', 'JE', 'JEY', '832', '泽西岛', '+44', '🇯🇪', TRUE, 1110, 0, 0),
('Jamaica', 'JM', 'JAM', '388', '牙买加', '+1', '🇯🇲', TRUE, 1120, 0, 0),
('Jordan', 'JO', 'JOR', '400', '约旦', '+962', '🇯🇴', TRUE, 1130, 0, 0),
('Japan', 'JP', 'JPN', '392', '日本', '+81', '🇯🇵', TRUE, 1140, 0, 0),
('Kenya', 'KE', 'KEN', '404', '肯尼亚', '+254', '🇰🇪', TRUE, 1150, 0, 0),
('Kyrgyzstan', 'KG', 'KGZ', '417', '吉尔吉斯斯坦', '+996', '🇰🇬', TRUE, 1160, 0, 0),
('Cambodia', 'KH', 'KHM', '116', '柬埔寨', '+855', '🇰🇭', TRUE, 1170, 0, 0),
('Kiribati', 'KI', 'KIR', '296', '基里巴斯', '+686', '🇰🇮', TRUE, 1180, 0, 0),
('Comoros', 'KM', 'COM', '174', '科摩罗', '+269', '🇰🇲', TRUE, 1190, 0, 0),
('North Korea', 'KP', 'PRK', '408', '朝鲜', '+850', '🇰🇵', TRUE, 1210, 0, 0),
('South Korea', 'KR', 'KOR', '410', '韩国', '+82', '🇰🇷', TRUE, 1220, 0, 0),
('Kuwait', 'KW', 'KWT', '414', '科威特', '+965', '🇰🇼', TRUE, 1230, 0, 0),
('Cayman Islands', 'KY', 'CYM', '136', '开曼群岛', '+1', '🇰🇾', TRUE, 1240, 0, 0),
('Kazakhstan', 'KZ', 'KAZ', '398', '哈萨克斯坦', '+7', '🇰🇿', TRUE, 1250, 0, 0),
('Laos', 'LA', 'LAO', '418', '老挝', '+856', '🇱🇦', TRUE, 1260, 0, 0),
('Lebanon', 'LB', 'LBN', '422', '黎巴嫩', '+961', '🇱🇧', TRUE, 1270, 0, 0),
('Saint Lucia', 'LC', 'LCA', '662', '圣卢西亚', '+1', '🇱🇨', TRUE, 1280, 0, 0),
('Liechtenstein', 'LI', 'LIE', '438', '列支敦士登', '+423', '🇱🇮', TRUE, 1290, 0, 0),
('Sri Lanka', 'LK', 'LKA', '144', '斯里兰卡', '+94', '🇱🇰', TRUE, 1300, 0, 0),
('Liberia', 'LR', 'LBR', '430', '利比里亚', '+231', '🇱🇷', TRUE, 1310, 0, 0),
('Lesotho', 'LS', 'LSO', '426', '莱索托', '+266', '🇱🇸', TRUE, 1320, 0, 0),
('Lithuania', 'LT', 'LTU', '440', '立陶宛', '+370', '🇱🇹', TRUE, 1330, 0, 0),
('Luxembourg', 'LU', 'LUX', '442', '卢森堡', '+352', '🇱🇺', TRUE, 1340, 0, 0),
('Latvia', 'LV', 'LVA', '428', '拉脱维亚', '+371', '🇱🇻', TRUE, 1350, 0, 0),
('Libya', 'LY', 'LBY', '434', '利比亚', '+218', '🇱🇾', TRUE, 1360, 0, 0),
('Morocco', 'MA', 'MAR', '504', '摩洛哥', '+212', '🇲🇦', TRUE, 1370, 0, 0),
('Monaco', 'MC', 'MCO', '492', '摩纳哥', '+377', '🇲🇨', TRUE, 1380, 0, 0),
('Moldova', 'MD', 'MDA', '498', '摩尔多瓦', '+373', '🇲🇩', TRUE, 1390, 0, 0),
('Montenegro', 'ME', 'MNE', '499', '黑山', '+382', '🇲🇪', TRUE, 1400, 0, 0),
('Saint Martin', 'MF', 'MAF', '663', '圣马丁', '+590', '🇲🇫', TRUE, 1410, 0, 0),
('Madagascar', 'MG', 'MDG', '450', '马达加斯加', '+261', '🇲🇬', TRUE, 1420, 0, 0),
('Mali', 'ML', 'MLI', '466', '马里', '+223', '🇲🇱', TRUE, 1450, 0, 0),
('Myanmar', 'MM', 'MMR', '104', '缅甸', '+95', '🇲🇲', TRUE, 1460, 0, 0),
('Mongolia', 'MN', 'MNG', '496', '蒙古', '+976', '🇲🇳', TRUE, 1470, 0, 0),
('Macao', 'MO', 'MAC', '446', '中国澳门', '+853', '🇲🇴', TRUE, 1480, 0, 0),
('Martinique', 'MQ', 'MTQ', '474', '马提尼克', '+596', '🇲🇶', TRUE, 1500, 0, 0),
('Mauritania', 'MR', 'MRT', '478', '毛里塔尼亚', '+222', '🇲🇷', TRUE, 1510, 0, 0),
('Montserrat', 'MS', 'MSR', '500', '蒙特塞拉特', '+1', '🇲🇸', TRUE, 1520, 0, 0),
('Malta', 'MT', 'MLT', '470', '马耳他', '+356', '🇲🇹', TRUE, 1530, 0, 0),
('Mauritius', 'MU', 'MUS', '480', '毛里求斯', '+230', '🇲🇺', TRUE, 1540, 0, 0),
('Maldives', 'MV', 'MDV', '462', '马尔代夫', '+960', '🇲🇻', TRUE, 1550, 0, 0),
('Malawi', 'MW', 'MWI', '454', '马拉维', '+265', '🇲🇼', TRUE, 1560, 0, 0),
('Mexico', 'MX', 'MEX', '484', '墨西哥', '+52', '🇲🇽', TRUE, 1570, 0, 0),
('Malaysia', 'MY', 'MYS', '458', '马来西亚', '+60', '🇲🇾', TRUE, 1580, 0, 0),
('Mozambique', 'MZ', 'MOZ', '508', '莫桑比克', '+258', '🇲🇿', TRUE, 1590, 0, 0),
('Namibia', 'NA', 'NAM', '516', '纳米比亚', '+264', '🇳🇦', TRUE, 1600, 0, 0),
('New Caledonia', 'NC', 'NCL', '540', '新喀里多尼亚', '+687', '🇳🇨', TRUE, 1610, 0, 0),
('Niger', 'NE', 'NER', '562', '尼日尔', '+227', '🇳🇪', TRUE, 1620, 0, 0),
('Norfolk Island', 'NF', 'NFK', '574', '诺福克岛', '+672', '🇳🇫', TRUE, 1630, 0, 0),
('Nigeria', 'NG', 'NGA', '566', '尼日利亚', '+234', '🇳🇬', TRUE, 1640, 0, 0),
('Nicaragua', 'NI', 'NIC', '558', '尼加拉瓜', '+505', '🇳🇮', TRUE, 1650, 0, 0),
('Netherlands', 'NL', 'NLD', '528', '荷兰', '+31', '🇳🇱', TRUE, 1660, 0, 0),
('Norway', 'NO', 'NOR', '578', '挪威', '+47', '🇳🇴', TRUE, 1670, 0, 0),
('Nepal', 'NP', 'NPL', '524', '尼泊尔', '+977', '🇳🇵', TRUE, 1680, 0, 0),
('Nauru', 'NR', 'NRU', '520', '瑙鲁', '+674', '🇳🇷', TRUE, 1690, 0, 0),
('Niue', 'NU', 'NIU', '570', '纽埃', '+683', '🇳🇺', TRUE, 1700, 0, 0),
('New Zealand', 'NZ', 'NZL', '554', '新西兰', '+64', '🇳🇿', TRUE, 1710, 0, 0),
('Oman', 'OM', 'OMN', '512', '阿曼', '+968', '🇴🇲', TRUE, 1720, 0, 0),
('Panama', 'PA', 'PAN', '591', '巴拿马', '+507', '🇵🇦', TRUE, 1730, 0, 0),
@ -784,59 +727,39 @@ INSERT INTO countries (country_name, country_code, iso_alpha3, iso_numeric, coun
('Puerto Rico', 'PR', 'PRI', '630', '波多黎各', '+1', '🇵🇷', TRUE, 1820, 0, 0),
('Palestine', 'PS', 'PSE', '275', '巴勒斯坦', '+970', '🇵🇸', TRUE, 1830, 0, 0),
('Portugal', 'PT', 'PRT', '620', '葡萄牙', '+351', '🇵🇹', TRUE, 1840, 0, 0),
('Palau', 'PW', 'PLW', '585', '帕劳', '+680', '🇵🇼', TRUE, 1850, 0, 0),
('Paraguay', 'PY', 'PRY', '600', '巴拉圭', '+595', '🇵🇾', TRUE, 1860, 0, 0),
('Qatar', 'QA', 'QAT', '634', '卡塔尔', '+974', '🇶🇦', TRUE, 1870, 0, 0),
('Réunion', 'RE', 'REU', '638', '留尼旺岛', '+262', '🇷🇪', TRUE, 1880, 0, 0),
('Romania', 'RO', 'ROU', '642', '罗马尼亚', '+40', '🇷🇴', TRUE, 1890, 0, 0),
('Serbia', 'RS', 'SRB', '688', '塞尔维亚', '+381', '🇷🇸', TRUE, 1900, 0, 0),
('Russia', 'RU', 'RUS', '643', '俄罗斯', '+7', '🇷🇺', TRUE, 1910, 0, 0),
('Rwanda', 'RW', 'RWA', '646', '卢旺达', '+250', '🇷🇼', TRUE, 1920, 0, 0),
('Saudi Arabia', 'SA', 'SAU', '682', '沙特阿拉伯', '+966', '🇸🇦', TRUE, 1930, 0, 0),
('Solomon Islands', 'SB', 'SLB', '090', '所罗门群岛', '+677', '🇸🇧', TRUE, 1940, 0, 0),
('Seychelles', 'SC', 'SYC', '690', '塞舌尔', '+248', '🇸🇨', TRUE, 1950, 0, 0),
('Sudan', 'SD', 'SDN', '729', '苏丹', '+249', '🇸🇩', TRUE, 1960, 0, 0),
('Sweden', 'SE', 'SWE', '752', '瑞典', '+46', '🇸🇪', TRUE, 1970, 0, 0),
('Singapore', 'SG', 'SGP', '702', '新加坡', '+65', '🇸🇬', TRUE, 1980, 0, 0),
('Slovenia', 'SI', 'SVN', '705', '斯洛文尼亚', '+386', '🇸🇮', TRUE, 2000, 0, 0),
('Svalbard and Jan Mayen', 'SJ', 'SJM', '744', '斯瓦尔巴特', '+47', '🇸🇯', TRUE, 2010, 0, 0),
('Slovakia', 'SK', 'SVK', '703', '斯洛伐克', '+421', '🇸🇰', TRUE, 2020, 0, 0),
('Sierra Leone', 'SL', 'SLE', '694', '塞拉利昂', '+232', '🇸🇱', TRUE, 2030, 0, 0),
('San Marino', 'SM', 'SMR', '674', '圣马力诺', '+378', '🇸🇲', TRUE, 2040, 0, 0),
('Senegal', 'SN', 'SEN', '686', '塞内加尔', '+221', '🇸🇳', TRUE, 2050, 0, 0),
('Somalia', 'SO', 'SOM', '706', '索马里', '+252', '🇸🇴', TRUE, 2060, 0, 0),
('Suriname', 'SR', 'SUR', '740', '苏里南', '+597', '🇸🇷', TRUE, 2070, 0, 0),
('South Sudan', 'SS', 'SSD', '728', '南苏丹', '+211', '🇸🇸', TRUE, 2080, 0, 0),
('El Salvador', 'SV', 'SLV', '222', '萨尔瓦多', '+503', '🇸🇻', TRUE, 2100, 0, 0),
('Sint Maarten', 'SX', 'SXM', '534', '圣马丁岛', '+1', '🇸🇽', TRUE, 2110, 0, 0),
('Syria', 'SY', 'SYR', '760', '叙利亚', '+963', '🇸🇾', TRUE, 2120, 0, 0),
('Eswatini', 'SZ', 'SWZ', '748', '斯威士兰', '+268', '🇸🇿', TRUE, 2130, 0, 0),
('Chad', 'TD', 'TCD', '148', '乍得', '+235', '🇹🇩', TRUE, 2150, 0, 0),
('Togo', 'TG', 'TGO', '768', '多哥', '+228', '🇹🇬', TRUE, 2170, 0, 0),
('Thailand', 'TH', 'THA', '764', '泰国', '+66', '🇹🇭', TRUE, 2180, 0, 0),
('Tajikistan', 'TJ', 'TJK', '762', '塔吉克斯坦', '+992', '🇹🇯', TRUE, 2190, 0, 0),
('Tokelau', 'TK', 'TKL', '772', '托克劳', '+690', '🇹🇰', TRUE, 2200, 0, 0),
('Timor-Leste', 'TL', 'TLS', '626', '东帝汶', '+670', '🇹🇱', TRUE, 2210, 0, 0),
('Turkmenistan', 'TM', 'TKM', '795', '土库曼斯坦', '+993', '🇹🇲', TRUE, 2220, 0, 0),
('Tunisia', 'TN', 'TUN', '788', '突尼斯', '+216', '🇹🇳', TRUE, 2230, 0, 0),
('Tonga', 'TO', 'TON', '776', '汤加', '+676', '🇹🇴', TRUE, 2240, 0, 0),
('Türkiye', 'TR', 'TUR', '792', '土耳其', '+90', '🇹🇷', TRUE, 2250, 0, 0),
('Tuvalu', 'TV', 'TUV', '798', '图瓦卢', '+688', '🇹🇻', TRUE, 2270, 0, 0),
('Tanzania', 'TZ', 'TZA', '834', '坦桑尼亚', '+255', '🇹🇿', TRUE, 2290, 0, 0),
('Ukraine', 'UA', 'UKR', '804', '乌克兰', '+380', '🇺🇦', TRUE, 2300, 0, 0),
('Uganda', 'UG', 'UGA', '800', '乌干达', '+256', '🇺🇬', TRUE, 2310, 0, 0),
('United States', 'US', 'USA', '840', '美国', '+1', '🇺🇸', TRUE, 2330, 0, 0),
('Uruguay', 'UY', 'URY', '858', '乌拉圭', '+598', '🇺🇾', TRUE, 2340, 0, 0),
('Uzbekistan', 'UZ', 'UZB', '860', '乌兹别克斯坦', '+998', '🇺🇿', TRUE, 2350, 0, 0),
('Vatican City', 'VA', 'VAT', '336', '梵蒂冈', '+39', '🇻🇦', TRUE, 2360, 0, 0),
('Venezuela', 'VE', 'VEN', '862', '委内瑞拉', '+58', '🇻🇪', TRUE, 2380, 0, 0),
('Vietnam', 'VN', 'VNM', '704', '越南', '+84', '🇻🇳', TRUE, 2410, 0, 0),
('Vanuatu', 'VU', 'VUT', '548', '瓦努阿图', '+678', '🇻🇺', TRUE, 2420, 0, 0),
('Samoa', 'WS', 'WSM', '882', '萨摩亚', '+685', '🇼🇸', TRUE, 2440, 0, 0),
('Kosovo', 'XK', 'UNK', NULL, '科索沃', '+383', '🇽🇰', TRUE, 2450, 0, 0),
('Yemen', 'YE', 'YEM', '887', '也门', '+967', '🇾🇪', TRUE, 2460, 0, 0),
('Mayotte', 'YT', 'MYT', '175', '马约特', '+262', '🇾🇹', TRUE, 2470, 0, 0),
('South Africa', 'ZA', 'ZAF', '710', '南非', '+27', '🇿🇦', TRUE, 2480, 0, 0),
('Zambia', 'ZM', 'ZMB', '894', '赞比亚', '+260', '🇿🇲', TRUE, 2490, 0, 0),
('Zimbabwe', 'ZW', 'ZWE', '716', '津巴布韦', '+263', '🇿🇼', TRUE, 2500, 0, 0)
ON DUPLICATE KEY UPDATE
@ -863,14 +786,10 @@ ON DUPLICATE KEY UPDATE
INSERT INTO region_countries (app_code, region_id, country_code, status, created_at_ms, updated_at_ms)
SELECT 'lalu', r.region_id, seed.country_code, 'active', 0, 0
FROM (
SELECT 'AD' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'AE' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'AF' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'AI' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'AL' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'AM' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -883,54 +802,26 @@ FROM (
UNION ALL
SELECT 'AU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'AW' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'AX' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'AZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BB' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BD' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'BE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BF' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BH' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'BI' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BJ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BL' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BS' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BT' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'BV' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BW' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'BZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CC' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CD' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CF' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -945,20 +836,12 @@ FROM (
UNION ALL
SELECT 'CM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CV' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CW' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CX' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'CZ' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -969,8 +852,6 @@ FROM (
UNION ALL
SELECT 'DK' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'DM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'DO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'DZ' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -989,30 +870,16 @@ FROM (
UNION ALL
SELECT 'FI' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'FJ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'FM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'FR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GB' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GD' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GF' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GH' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GI' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GL' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GN' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -1021,14 +888,8 @@ FROM (
UNION ALL
SELECT 'GR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GS' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GT' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'GY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'HN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'HR' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -1043,20 +904,14 @@ FROM (
UNION ALL
SELECT 'IL' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'IM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'IN' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'IQ' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'IR' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'IS' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'IT' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'JE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'JM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'JO' AS country_code, 'MIDDLE_EAST' AS region_code
@ -1069,52 +924,26 @@ FROM (
UNION ALL
SELECT 'KH' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KI' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KP' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KW' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'KY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'KZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LB' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'LC' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LI' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LK' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'LR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LS' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LT' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LV' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'LY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MC' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MD' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'ME' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MF' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'ML' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -1123,22 +952,10 @@ FROM (
UNION ALL
SELECT 'MN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MQ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MS' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MT' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MV' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'MW' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MX' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'MY' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -1147,12 +964,8 @@ FROM (
UNION ALL
SELECT 'NA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NC' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NF' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NI' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -1163,10 +976,6 @@ FROM (
UNION ALL
SELECT 'NP' AS country_code, 'INDIA' AS region_code
UNION ALL
SELECT 'NR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'NZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'OM' AS country_code, 'MIDDLE_EAST' AS region_code
@ -1187,16 +996,10 @@ FROM (
UNION ALL
SELECT 'PT' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'PW' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'PY' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'QA' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'RE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'RO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'RS' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'RU' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -1205,64 +1008,40 @@ FROM (
UNION ALL
SELECT 'SA' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'SB' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SC' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SD' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SI' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SJ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SK' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SL' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SR' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SS' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SV' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SX' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'SY' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'SZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TD' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TG' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TH' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TJ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TK' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TL' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TO' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TR' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'TV' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'TZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'UA' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -1275,24 +1054,14 @@ FROM (
UNION ALL
SELECT 'UZ' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'VA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'VE' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'VN' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'VU' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'WS' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'XK' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'YE' AS country_code, 'MIDDLE_EAST' AS region_code
UNION ALL
SELECT 'YT' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'ZA' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'ZM' AS country_code, 'EURASIA_AMERICA' AS region_code
UNION ALL
SELECT 'ZW' AS country_code, 'EURASIA_AMERICA' AS region_code
@ -1312,11 +1081,11 @@ WHERE app_code = 'lalu'
-- Product-excluded countries are hard removed so reapplying initdb updates existing local databases as well as fresh volumes.
DELETE FROM region_countries
WHERE app_code = 'lalu'
AND country_code IN ('AG', 'AQ', 'AS', 'BA', 'BQ', 'CK', 'EH', 'FK', 'FO', 'GP', 'GW', 'HK', 'HM', 'IO', 'KN', 'MH', 'MK', 'MP', 'PF', 'PG', 'PM', 'PN', 'SH', 'ST', 'TC', 'TF', 'TT', 'TW', 'UM', 'VC', 'VG', 'VI', 'WF');
AND country_code IN ('AD', 'AG', 'AI', 'AQ', 'AS', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BF', 'BI', 'BL', 'BM', 'BN', 'BQ', 'BS', 'BT', 'BV', 'BZ', 'CC', 'CK', 'CN', 'CV', 'CW', 'CX', 'DM', 'EH', 'FJ', 'FK', 'FM', 'FO', 'GD', 'GF', 'GG', 'GI', 'GL', 'GP', 'GS', 'GU', 'GW', 'GY', 'HK', 'HM', 'IM', 'IO', 'IS', 'JE', 'KI', 'KM', 'KN', 'KY', 'LC', 'LI', 'LS', 'LT', 'LU', 'LV', 'MC', 'MD', 'ME', 'MF', 'MH', 'MK', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MW', 'NC', 'NF', 'NR', 'NU', 'PF', 'PG', 'PM', 'PN', 'PW', 'RE', 'RO', 'SB', 'SC', 'SH', 'SI', 'SJ', 'SM', 'SR', 'ST', 'SX', 'TC', 'TF', 'TG', 'TK', 'TL', 'TO', 'TT', 'TV', 'TW', 'UM', 'VA', 'VC', 'VG', 'VI', 'VU', 'WF', 'WS', 'YT', 'ZA');
DELETE FROM countries
WHERE app_code = 'lalu'
AND country_code IN ('AG', 'AQ', 'AS', 'BA', 'BQ', 'CK', 'EH', 'FK', 'FO', 'GP', 'GW', 'HK', 'HM', 'IO', 'KN', 'MH', 'MK', 'MP', 'PF', 'PG', 'PM', 'PN', 'SH', 'ST', 'TC', 'TF', 'TT', 'TW', 'UM', 'VC', 'VG', 'VI', 'WF');
AND country_code IN ('AD', 'AG', 'AI', 'AQ', 'AS', 'AW', 'AX', 'AZ', 'BA', 'BB', 'BF', 'BI', 'BL', 'BM', 'BN', 'BQ', 'BS', 'BT', 'BV', 'BZ', 'CC', 'CK', 'CN', 'CV', 'CW', 'CX', 'DM', 'EH', 'FJ', 'FK', 'FM', 'FO', 'GD', 'GF', 'GG', 'GI', 'GL', 'GP', 'GS', 'GU', 'GW', 'GY', 'HK', 'HM', 'IM', 'IO', 'IS', 'JE', 'KI', 'KM', 'KN', 'KY', 'LC', 'LI', 'LS', 'LT', 'LU', 'LV', 'MC', 'MD', 'ME', 'MF', 'MH', 'MK', 'MO', 'MP', 'MQ', 'MR', 'MS', 'MT', 'MW', 'NC', 'NF', 'NR', 'NU', 'PF', 'PG', 'PM', 'PN', 'PW', 'RE', 'RO', 'SB', 'SC', 'SH', 'SI', 'SJ', 'SM', 'SR', 'ST', 'SX', 'TC', 'TF', 'TG', 'TK', 'TL', 'TO', 'TT', 'TV', 'TW', 'UM', 'VA', 'VC', 'VG', 'VI', 'VU', 'WF', 'WS', 'YT', 'ZA');
CREATE TABLE IF NOT EXISTS user_display_user_ids (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu',
@ -1517,6 +1286,22 @@ CREATE TABLE IF NOT EXISTS login_ip_risk_decisions (
KEY idx_login_ip_risk_decisions_expires (app_code, expires_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS login_risk_country_blocks (
app_code VARCHAR(32) NOT NULL,
block_id BIGINT NOT NULL AUTO_INCREMENT,
keyword VARCHAR(128) NOT NULL,
country_code VARCHAR(3) NOT NULL DEFAULT '',
enabled TINYINT(1) NOT NULL DEFAULT 1,
created_by_user_id BIGINT NULL,
updated_by_user_id BIGINT NULL,
created_at_ms BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (block_id),
UNIQUE KEY uk_login_risk_country_blocks_keyword (app_code, keyword),
KEY idx_login_risk_country_blocks_enabled (app_code, enabled, country_code),
KEY idx_login_risk_country_blocks_updated (app_code, updated_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 本地联调测试账号。
-- 密码登录仍以 users.current_display_user_id 为账号password_accounts 只保存 user_id 维度密码身份。
INSERT INTO users (

View File

@ -59,3 +59,15 @@ type LoginIPRiskDecision struct {
ExpiresAtMs int64
CreatedAtMs int64
}
// LoginRiskCountryBlock 是后台维护的国家屏蔽词事实。
// CountryCode 为空时仅作为客户端词表下发;服务端 IP 风控只能稳定消费 provider 返回的国家码。
type LoginRiskCountryBlock struct {
AppCode string
BlockID int64
Keyword string
CountryCode string
Enabled bool
CreatedAtMs int64
UpdatedAtMs int64
}

View File

@ -0,0 +1,91 @@
package auth
import (
"context"
"sort"
"strings"
"hyapp/pkg/appcode"
authdomain "hyapp/services/user-service/internal/domain/auth"
)
// ListLoginRiskBlockedCountries 返回当前 App 实际生效的登录地区风控词表。
// 静态配置和后台动态配置都下发给 AppApp 可异步做本地风险提示,最终封禁仍以服务端 worker 为准。
func (s *Service) ListLoginRiskBlockedCountries(ctx context.Context) ([]authdomain.LoginRiskCountryBlock, error) {
if !s.loginRiskPolicy.Enabled {
return nil, nil
}
appCode := appcode.FromContext(ctx)
blocks := make([]authdomain.LoginRiskCountryBlock, 0, len(s.loginRiskPolicy.UnsupportedCountries))
seen := map[string]struct{}{}
for _, country := range s.loginRiskPolicy.UnsupportedCountries {
countryCode := normalizeCountryCode(country)
if countryCode == "" {
continue
}
block := authdomain.LoginRiskCountryBlock{
AppCode: appCode,
Keyword: countryCode,
CountryCode: countryCode,
Enabled: true,
}
blocks = appendUniqueCountryBlock(blocks, seen, block)
}
if s.authRepository != nil {
dynamicBlocks, err := s.authRepository.ListEnabledLoginRiskCountryBlocks(ctx)
if err != nil {
return nil, err
}
for _, block := range dynamicBlocks {
block.AppCode = appcode.Normalize(block.AppCode)
block.Keyword = strings.TrimSpace(block.Keyword)
block.CountryCode = normalizeCountryCode(block.CountryCode)
block.Enabled = true
if block.Keyword == "" {
continue
}
blocks = appendUniqueCountryBlock(blocks, seen, block)
}
}
sort.SliceStable(blocks, func(i, j int) bool {
if blocks[i].CountryCode != blocks[j].CountryCode {
return blocks[i].CountryCode < blocks[j].CountryCode
}
return strings.ToLower(blocks[i].Keyword) < strings.ToLower(blocks[j].Keyword)
})
return blocks, nil
}
func appendUniqueCountryBlock(blocks []authdomain.LoginRiskCountryBlock, seen map[string]struct{}, block authdomain.LoginRiskCountryBlock) []authdomain.LoginRiskCountryBlock {
key := strings.ToLower(block.CountryCode + "\x00" + block.Keyword)
if _, ok := seen[key]; ok {
return blocks
}
seen[key] = struct{}{}
return append(blocks, block)
}
func (s *Service) countryBlockedByStaticPolicy(countryCode string) bool {
for _, country := range s.loginRiskPolicy.UnsupportedCountries {
if normalizeCountryCode(country) == countryCode {
return true
}
}
return false
}
func (s *Service) countryBlockedByDynamicPolicy(ctx context.Context, countryCode string) (bool, error) {
if s.authRepository == nil {
return false, nil
}
blocks, err := s.authRepository.ListEnabledLoginRiskCountryBlocks(ctx)
if err != nil {
return false, err
}
for _, block := range blocks {
if normalizeCountryCode(block.CountryCode) == countryCode || normalizeCountryCode(block.Keyword) == countryCode {
return true, nil
}
}
return false, nil
}

View File

@ -76,7 +76,7 @@ func (s *Service) processLoginIPRiskJob(ctx context.Context, job authdomain.Logi
}
result, attempts := s.resolveIPCountry(ctx, job, policy)
decision := s.decisionForCountry(result.CountryCode)
decision := s.decisionForCountry(ctx, result.CountryCode)
ttl := s.ttlForDecision(decision)
expiresAtMs := nowMs + ttl.Milliseconds()
ipDecision := IPDecision{
@ -174,15 +174,22 @@ func (s *Service) resolveIPCountry(ctx context.Context, job authdomain.LoginIPRi
return IPGeoResult{}, attempts
}
func (s *Service) decisionForCountry(countryCode string) string {
func (s *Service) decisionForCountry(ctx context.Context, countryCode string) string {
countryCode = normalizeCountryCode(countryCode)
if countryCode == "" {
return authdomain.LoginIPRiskDecisionUnknown
}
for _, country := range s.loginRiskPolicy.UnsupportedCountries {
if normalizeCountryCode(country) == countryCode {
return authdomain.LoginIPRiskDecisionBlocked
}
if s.countryBlockedByStaticPolicy(countryCode) {
return authdomain.LoginIPRiskDecisionBlocked
}
dynamicBlocked, err := s.countryBlockedByDynamicPolicy(ctx, countryCode)
if err != nil {
// 动态配置读取失败不能扩大误伤面;保留静态配置阻断,其余按 fail-open 放行并记录。
logx.Warn(ctx, "login_risk_country_blocks_read_failed", slog.String("component", "user_auth_risk"), slog.String("country", countryCode), slog.String("error", err.Error()))
return authdomain.LoginIPRiskDecisionAllowed
}
if dynamicBlocked {
return authdomain.LoginIPRiskDecisionBlocked
}
return authdomain.LoginIPRiskDecisionAllowed
}

View File

@ -68,6 +68,8 @@ type AuthRepository interface {
CompleteLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error
// CreateLoginIPRiskDecision 持久化 IP 风控决策审计。
CreateLoginIPRiskDecision(ctx context.Context, decision authdomain.LoginIPRiskDecision) error
// ListEnabledLoginRiskCountryBlocks 返回当前 App 的动态屏蔽国家词表。
ListEnabledLoginRiskCountryBlocks(ctx context.Context) ([]authdomain.LoginRiskCountryBlock, error)
// MarkLoginAuditBlocked 把登录成功后被异步风控撤销的审计行更新成阻断态。
MarkLoginAuditBlocked(ctx context.Context, requestID string, userID int64, countryCode string, blockReason string) error
}

View File

@ -326,6 +326,61 @@ func TestLoginIPRiskWorkerRevokesBlockedCountrySession(t *testing.T) {
}
}
func TestLoginIPRiskWorkerUsesDynamicCountryBlocks(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "MY")
now := time.UnixMilli(1000)
if _, err := repository.RawDB().ExecContext(ctx, `
INSERT INTO login_risk_country_blocks (
app_code, keyword, country_code, enabled, created_at_ms, updated_at_ms
)
VALUES ('lalu', 'Malaysia', 'MY', 1, 1000, 1000)
`); err != nil {
t.Fatalf("seed dynamic country block failed: %v", err)
}
authSvc := newAuthService(repository, &now, []int64{900021}, []string{"100021"},
authservice.WithLoginRiskPolicy(authservice.LoginRiskPolicy{
Enabled: true,
UnsupportedCountries: nil,
BlockedTTL: time.Hour,
AllowedTTL: time.Hour,
UnknownTTL: time.Minute,
ProviderTimeout: 50 * time.Millisecond,
ProviderNames: []string{"edge_country"},
DenylistTTL: time.Hour,
}),
authservice.WithIPGeoProviders(authservice.BuildIPGeoProviders([]string{"edge_country"})),
)
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-risk-dynamic", thirdPartyRegistration("ios"), authservice.Meta{
AppCode: "lalu",
RequestID: "req-risk-dynamic",
ClientIP: "203.0.113.88",
CountryByIP: "MY",
Platform: "ios",
})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
now = time.UnixMilli(2000)
processed, err := authSvc.ProcessLoginIPRiskBatch(ctx, authservice.LoginIPRiskWorkerOptions{WorkerID: "risk-test", LockTTL: time.Second, BatchSize: 10})
if err != nil || processed != 1 {
t.Fatalf("risk worker mismatch: processed=%d err=%v", processed, err)
}
if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-dynamic"}); !xerr.IsCode(err, xerr.SessionRevoked) {
t.Fatalf("expected revoked refresh session, got %v", err)
}
blocks, err := authSvc.ListLoginRiskBlockedCountries(ctx)
if err != nil {
t.Fatalf("ListLoginRiskBlockedCountries failed: %v", err)
}
if len(blocks) != 1 || blocks[0].CountryCode != "MY" || blocks[0].Keyword != "Malaysia" {
t.Fatalf("dynamic block list mismatch: %+v", blocks)
}
}
func TestIssueAccessTokenForSessionCarriesCompletedOnboardingStatusWithoutRotatingRefresh(t *testing.T) {
// CompleteOnboarding 后只需要替换 access tokenrefresh token 原文不可从数据库反查,也不应被静默轮换。
ctx := context.Background()

View File

@ -3,6 +3,7 @@ package auth
import (
"context"
"database/sql"
"strings"
"time"
"hyapp/pkg/appcode"
@ -103,10 +104,45 @@ func (r *Repository) CreateLoginIPRiskDecision(ctx context.Context, decision aut
return err
}
func (r *Repository) ListEnabledLoginRiskCountryBlocks(ctx context.Context) ([]authdomain.LoginRiskCountryBlock, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, block_id, keyword, country_code, enabled, created_at_ms, updated_at_ms
FROM login_risk_country_blocks
WHERE app_code = ? AND enabled = 1
ORDER BY country_code ASC, keyword ASC, block_id ASC
`, appcode.FromContext(ctx))
if err != nil {
return nil, err
}
defer rows.Close()
blocks := make([]authdomain.LoginRiskCountryBlock, 0)
for rows.Next() {
var block authdomain.LoginRiskCountryBlock
if err := rows.Scan(&block.AppCode, &block.BlockID, &block.Keyword, &block.CountryCode, &block.Enabled, &block.CreatedAtMs, &block.UpdatedAtMs); err != nil {
return nil, err
}
block.CountryCode = normalizeCountryCode(block.CountryCode)
blocks = append(blocks, block)
}
if err := rows.Err(); err != nil {
return nil, err
}
return blocks, nil
}
type loginIPRiskJobScanner interface {
Scan(dest ...any) error
}
func normalizeCountryCode(country string) string {
country = strings.ToUpper(strings.TrimSpace(country))
if len(country) < 2 || len(country) > 3 {
return ""
}
return country
}
func scanLoginIPRiskJob(scanner loginIPRiskJobScanner) (authdomain.LoginIPRiskJob, error) {
var job authdomain.LoginIPRiskJob
err := scanner.Scan(

View File

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

View File

@ -566,6 +566,29 @@ func (s *Server) ListRegistrationCountries(ctx context.Context, req *userv1.List
return &userv1.ListRegistrationCountriesResponse{Countries: result}, nil
}
// ListLoginRiskBlockedCountries 返回 App 登录后异步风控可消费的地区屏蔽词表。
func (s *Server) ListLoginRiskBlockedCountries(ctx context.Context, req *userv1.ListLoginRiskBlockedCountriesRequest) (*userv1.ListLoginRiskBlockedCountriesResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
blocks, err := s.authSvc.ListLoginRiskBlockedCountries(ctx)
if err != nil {
return nil, xerr.ToGRPCError(err)
}
result := make([]*userv1.LoginRiskBlockedCountry, 0, len(blocks))
var updatedAtMs int64
for _, block := range blocks {
if block.UpdatedAtMs > updatedAtMs {
updatedAtMs = block.UpdatedAtMs
}
result = append(result, &userv1.LoginRiskBlockedCountry{
CountryCode: block.CountryCode,
Keyword: block.Keyword,
UpdatedAtMs: block.UpdatedAtMs,
})
}
return &userv1.ListLoginRiskBlockedCountriesResponse{Countries: result, UpdatedAtMs: updatedAtMs}, nil
}
// ListRegions 返回区域列表。
func (s *Server) ListRegions(ctx context.Context, req *userv1.ListRegionsRequest) (*userv1.ListRegionsResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())

View File

@ -1,3 +1,4 @@
# syntax=docker/dockerfile:1.7
FROM golang:1.26.3-alpine AS builder
WORKDIR /src
@ -9,12 +10,16 @@ ENV GOPROXY=${GOPROXY} GOSUMDB=${GOSUMDB}
COPY go.mod go.sum ./
COPY api/go.mod api/go.sum ./api/
RUN GOWORK=off go mod download
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
GOWORK=off go mod download
COPY . .
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/server ./services/wallet-service/cmd/server
RUN GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/grpc-health-probe ./cmd/grpc-health-probe
RUN --mount=type=cache,id=hyapp-go-mod,target=/go/pkg/mod \
--mount=type=cache,id=hyapp-go-build,target=/root/.cache/go-build \
mkdir -p /out && \
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/ ./services/wallet-service/cmd/server ./cmd/grpc-health-probe
FROM alpine:3.20