feat: add user report management

This commit is contained in:
zhx 2026-05-29 16:48:00 +08:00
parent 11e0ec2f75
commit 7433b24411
29 changed files with 1908 additions and 312 deletions

File diff suppressed because it is too large Load Diff

View File

@ -360,6 +360,35 @@ message ListFriendApplicationsResponse {
int64 total = 2;
}
// UserReport App
message UserReport {
string report_id = 1;
int64 reporter_user_id = 2;
string target_type = 3;
int64 user_id = 4;
string room_id = 5;
string report_type = 6;
string reason = 7;
repeated string image_urls = 8;
string status = 9;
int64 created_at_ms = 10;
}
// SubmitReportRequest user_id room_id
message SubmitReportRequest {
RequestMeta meta = 1;
int64 reporter_user_id = 2;
int64 user_id = 3;
string room_id = 4;
string report_type = 5;
string reason = 6;
repeated string image_urls = 7;
}
message SubmitReportResponse {
UserReport report = 1;
}
// BatchGetUsersRequest
message BatchGetUsersRequest {
RequestMeta meta = 1;
@ -716,6 +745,7 @@ service UserSocialService {
rpc DeleteFriend(DeleteFriendRequest) returns (DeleteFriendResponse);
rpc ListFriends(ListFriendsRequest) returns (ListFriendsResponse);
rpc ListFriendApplications(ListFriendApplicationsRequest) returns (ListFriendApplicationsResponse);
rpc SubmitReport(SubmitReportRequest) returns (SubmitReportResponse);
}
// UserCronService cron-service user-service owner

View File

@ -477,6 +477,7 @@ const (
UserSocialService_DeleteFriend_FullMethodName = "/hyapp.user.v1.UserSocialService/DeleteFriend"
UserSocialService_ListFriends_FullMethodName = "/hyapp.user.v1.UserSocialService/ListFriends"
UserSocialService_ListFriendApplications_FullMethodName = "/hyapp.user.v1.UserSocialService/ListFriendApplications"
UserSocialService_SubmitReport_FullMethodName = "/hyapp.user.v1.UserSocialService/SubmitReport"
)
// UserSocialServiceClient is the client API for UserSocialService service.
@ -495,6 +496,7 @@ type UserSocialServiceClient interface {
DeleteFriend(ctx context.Context, in *DeleteFriendRequest, opts ...grpc.CallOption) (*DeleteFriendResponse, error)
ListFriends(ctx context.Context, in *ListFriendsRequest, opts ...grpc.CallOption) (*ListFriendsResponse, error)
ListFriendApplications(ctx context.Context, in *ListFriendApplicationsRequest, opts ...grpc.CallOption) (*ListFriendApplicationsResponse, error)
SubmitReport(ctx context.Context, in *SubmitReportRequest, opts ...grpc.CallOption) (*SubmitReportResponse, error)
}
type userSocialServiceClient struct {
@ -605,6 +607,16 @@ func (c *userSocialServiceClient) ListFriendApplications(ctx context.Context, in
return out, nil
}
func (c *userSocialServiceClient) SubmitReport(ctx context.Context, in *SubmitReportRequest, opts ...grpc.CallOption) (*SubmitReportResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(SubmitReportResponse)
err := c.cc.Invoke(ctx, UserSocialService_SubmitReport_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// UserSocialServiceServer is the server API for UserSocialService service.
// All implementations must embed UnimplementedUserSocialServiceServer
// for forward compatibility.
@ -621,6 +633,7 @@ type UserSocialServiceServer interface {
DeleteFriend(context.Context, *DeleteFriendRequest) (*DeleteFriendResponse, error)
ListFriends(context.Context, *ListFriendsRequest) (*ListFriendsResponse, error)
ListFriendApplications(context.Context, *ListFriendApplicationsRequest) (*ListFriendApplicationsResponse, error)
SubmitReport(context.Context, *SubmitReportRequest) (*SubmitReportResponse, error)
mustEmbedUnimplementedUserSocialServiceServer()
}
@ -661,6 +674,9 @@ func (UnimplementedUserSocialServiceServer) ListFriends(context.Context, *ListFr
func (UnimplementedUserSocialServiceServer) ListFriendApplications(context.Context, *ListFriendApplicationsRequest) (*ListFriendApplicationsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListFriendApplications not implemented")
}
func (UnimplementedUserSocialServiceServer) SubmitReport(context.Context, *SubmitReportRequest) (*SubmitReportResponse, error) {
return nil, status.Error(codes.Unimplemented, "method SubmitReport not implemented")
}
func (UnimplementedUserSocialServiceServer) mustEmbedUnimplementedUserSocialServiceServer() {}
func (UnimplementedUserSocialServiceServer) testEmbeddedByValue() {}
@ -862,6 +878,24 @@ func _UserSocialService_ListFriendApplications_Handler(srv interface{}, ctx cont
return interceptor(ctx, in, info, handler)
}
func _UserSocialService_SubmitReport_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(SubmitReportRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserSocialServiceServer).SubmitReport(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserSocialService_SubmitReport_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserSocialServiceServer).SubmitReport(ctx, req.(*SubmitReportRequest))
}
return interceptor(ctx, in, info, handler)
}
// UserSocialService_ServiceDesc is the grpc.ServiceDesc for UserSocialService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@ -909,6 +943,10 @@ var UserSocialService_ServiceDesc = grpc.ServiceDesc{
MethodName: "ListFriendApplications",
Handler: _UserSocialService_ListFriendApplications_Handler,
},
{
MethodName: "SubmitReport",
Handler: _UserSocialService_SubmitReport_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "proto/user/v1/user.proto",

View File

@ -0,0 +1,272 @@
# 用户举报 Flutter 对接
本文描述 Flutter App 对接用户举报和房间举报的 HTTP 契约。通用文件上传接口用于上传举报图片并拿到 URL提交举报接口只接收 JSON。
## 基础地址
本地开发地址:
```text
http://127.0.0.1:13000
```
线上环境使用当前 App 配置里的 gateway base URL。下面所有路径都拼在 gateway base URL 后。
## 请求头
| Header | 必填 | 示例 | 说明 |
| --- | --- | --- | --- |
| `Authorization` | 是 | `Bearer <access_token>` | 登录 access token举报发起人只从 token 读取Flutter 不传举报发起人 ID。 |
| `X-App-Code` | 否 | `lalu` | App 编码;登录态接口最终以 token 内的 `app_code` 为准。 |
| `Content-Type` | 提交举报必填 | `application/json` | 提交举报使用 JSON。 |
| `Content-Type` | 上传图片必填 | `multipart/form-data` | 上传举报图片使用 multipart。 |
统一响应 envelope
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_abc",
"data": {}
}
```
Flutter 只在 `code == "OK"` 时读取 `data`。失败时记录 `request_id`,用于后端排查。
## 调用顺序
```text
1. 用户在举报弹窗选择举报类型。
2. 如有举报图,逐张调用 POST /api/v1/files/upload收集每次返回的 data.url。
3. 调用 POST /api/v1/reports 提交举报。
4. 提交成功后关闭弹窗,并用 data.report_id 写本地日志。
```
## 上传举报图片
上传接口只负责把文件写入对象存储并返回 URL不创建举报单。
```http
POST /api/v1/files/upload
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: multipart/form-data
file=<binary>
```
请求字段:
| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| `file` | 是 | multipart file | 单文件最大 20MB。多图逐张上传Flutter 把每张图返回的 `data.url` 放进提交举报接口的 `image_urls`。 |
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_upload_report_image",
"data": {
"url": "https://cdn.example.com/app/files/10001/20260529/file_xxx.jpg",
"object_key": "app/files/10001/20260529/file_xxx.jpg",
"content_type": "image/jpeg",
"size_bytes": 345678
}
}
```
Flutter 提交举报时只需要使用 `data.url`
## 提交举报
```http
POST /api/v1/reports
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"user_id": "10002",
"report_type": "pornography",
"reason": "用户在房间内发布违规内容",
"image_urls": [
"https://cdn.example.com/app/files/10001/20260529/file_xxx.jpg"
]
}
```
举报房间示例:
```http
POST /api/v1/reports
Authorization: Bearer <access_token>
X-App-Code: lalu
Content-Type: application/json
{
"room_id": "lalu_abc123",
"report_type": "fraud",
"reason": "房间引导线下交易",
"image_urls": []
}
```
请求字段:
| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| `user_id` | 条件必填 | string | 被举报用户 ID。和 `room_id` 二选一,只能传一个。 |
| `room_id` | 条件必填 | string | 被举报房间 ID。和 `user_id` 二选一,只能传一个。 |
| `report_type` | 是 | string | 举报类型,取值见下方枚举。 |
| `reason` | 否 | string | 举报理由Flutter 可允许用户不填。 |
| `image_urls` | 否 | string[] | 举报图 URL 数组;无图可传 `[]` 或不传。 |
目标校验规则:
| 场景 | 结果 |
| --- | --- |
| 只传 `user_id` | 举报用户。 |
| 只传 `room_id` | 举报房间。 |
| `user_id``room_id` 都不传 | `400 INVALID_ARGUMENT`。 |
| `user_id``room_id` 同时传 | `400 INVALID_ARGUMENT`Flutter 需要拆成一次明确的用户举报或房间举报。 |
举报发起人不在请求体里传。gateway 必须从 `Authorization` 对应的 access token 读取当前登录用户,避免客户端伪造举报发起人。
## 举报类型
| UI 文案 | `report_type` |
| --- | --- |
| Politics | `politics` |
| Pornography | `pornography` |
| Graphic Violence | `graphic_violence` |
| Verbal Abuse | `verbal_abuse` |
| Fraud | `fraud` |
| Illegal Content | `illegal_content` |
| Minors | `minors` |
| In-Person Transactions | `in_person_transactions` |
| Other | `other` |
## 返回体模拟数据
举报用户成功:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_report_user",
"data": {
"report_id": "rpt_10001_1778000000000",
"target_type": "user",
"user_id": "10002",
"room_id": "",
"report_type": "pornography",
"status": "submitted",
"created_at_ms": 1778000000000
}
}
```
举报房间成功:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_report_room",
"data": {
"report_id": "rpt_10001_1778000001000",
"target_type": "room",
"user_id": "",
"room_id": "lalu_abc123",
"report_type": "fraud",
"status": "submitted",
"created_at_ms": 1778000001000
}
}
```
字段说明:
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| `report_id` | string | 举报单 IDFlutter 可用于本地日志和客服排查。 |
| `target_type` | string | `user``room`,由服务端按本次传入目标生成。 |
| `user_id` | string | 被举报用户 ID房间举报返回空字符串。 |
| `room_id` | string | 被举报房间 ID用户举报返回空字符串。 |
| `report_type` | string | 本次提交的举报类型。 |
| `status` | string | 首次提交固定返回 `submitted`。 |
| `created_at_ms` | int64 | 举报创建时间Unix epoch milliseconds。 |
## 错误处理
失败返回仍使用统一 envelope
```json
{
"code": "INVALID_ARGUMENT",
"message": "invalid argument",
"request_id": "req_abc"
}
```
| HTTP 状态码 | `code` | Flutter 处理 |
| --- | --- | --- |
| `400` | `INVALID_JSON` | JSON 格式错误,检查本地序列化逻辑。 |
| `400` | `INVALID_ARGUMENT` | `user_id/room_id` 二选一不满足、`report_type` 非法、图片 URL 格式非法或字段超长。 |
| `401` | `UNAUTHORIZED` | token 缺失、无效、过期或会话失效,重新登录。 |
| `403` | `PROFILE_REQUIRED` | 用户资料未完成,跳转资料补全流程。 |
| `404` | `NOT_FOUND` | 被举报用户不存在,刷新页面状态。 |
| `502` | `UPSTREAM_ERROR` | 内部服务暂不可用,可提示稍后重试。 |
## Flutter 数据模型建议
```dart
class SubmitReportRequest {
SubmitReportRequest({
this.userId,
this.roomId,
required this.reportType,
this.reason,
this.imageUrls = const [],
});
final String? userId;
final String? roomId;
final String reportType;
final String? reason;
final List<String> imageUrls;
Map<String, dynamic> toJson() => {
if (userId != null && userId!.isNotEmpty) 'user_id': userId,
if (roomId != null && roomId!.isNotEmpty) 'room_id': roomId,
'report_type': reportType,
if (reason != null && reason!.trim().isNotEmpty) 'reason': reason!.trim(),
if (imageUrls.isNotEmpty) 'image_urls': imageUrls,
};
}
```
提交前本地校验:
```dart
void validateReportTarget({String? userId, String? roomId}) {
final hasUser = userId != null && userId.trim().isNotEmpty;
final hasRoom = roomId != null && roomId.trim().isNotEmpty;
if (hasUser == hasRoom) {
throw ArgumentError('user_id and room_id must be exactly one');
}
}
```
## 实现边界
- Flutter 不传举报发起人 ID服务端只认 token 中的当前用户。
- `user_id` 表示被举报用户,不是举报发起人。
- `room_id` 表示被举报房间,不要求同时传房间内某个用户。
- `user_id``room_id` 必须二选一,不能同时提交一个混合举报。
- 举报图片先上传再提交 URL提交举报接口不接收 multipart 文件。
- `message` 不作为业务分支依据Flutter 只按 `code` 和 HTTP 状态处理。

View File

@ -47,6 +47,7 @@ import (
redpacketmodule "hyapp-admin-server/internal/modules/redpacket"
regionblockmodule "hyapp-admin-server/internal/modules/regionblock"
registrationrewardmodule "hyapp-admin-server/internal/modules/registrationreward"
reportmodule "hyapp-admin-server/internal/modules/report"
resourcemodule "hyapp-admin-server/internal/modules/resource"
roomadminmodule "hyapp-admin-server/internal/modules/roomadmin"
roomtreasuremodule "hyapp-admin-server/internal/modules/roomtreasure"
@ -227,6 +228,7 @@ func main() {
Payment: paymentmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
RBAC: rbacmodule.New(store, auditHandler),
RedPacket: redpacketmodule.New(walletclient.NewGRPC(walletConn), auditHandler),
Report: reportmodule.New(userDB, roomClient),
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
RegionBlock: regionblockmodule.New(userDB, auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, auditHandler),

View File

@ -0,0 +1,83 @@
package report
import (
"database/sql"
"strconv"
"strings"
"hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/roomclient"
"hyapp-admin-server/internal/modules/shared"
"hyapp-admin-server/internal/response"
"github.com/gin-gonic/gin"
)
type Handler struct {
service *Service
}
func New(userDB *sql.DB, roomClient roomclient.Client) *Handler {
return &Handler{service: NewService(userDB, roomClient)}
}
func (h *Handler) ListReports(c *gin.Context) {
query, ok := parseListQuery(c)
if !ok {
return
}
items, total, err := h.service.ListReports(c.Request.Context(), appctx.FromContext(c.Request.Context()), query)
if err != nil {
response.ServerError(c, "获取举报列表失败")
return
}
response.OK(c, response.Page{Items: items, Page: query.Page, PageSize: query.PageSize, Total: total})
}
func parseListQuery(c *gin.Context) (listQuery, bool) {
options := shared.ListOptions(c)
startAtMS, ok := optionalInt64(c, "start_at_ms", "startAtMs")
if !ok {
response.BadRequest(c, "开始时间不正确")
return listQuery{}, false
}
endAtMS, ok := optionalInt64(c, "end_at_ms", "endAtMs")
if !ok {
response.BadRequest(c, "结束时间不正确")
return listQuery{}, false
}
if startAtMS > 0 && endAtMS > 0 && startAtMS >= endAtMS {
response.BadRequest(c, "时间区间不正确")
return listQuery{}, false
}
return normalizeListQuery(listQuery{
Page: options.Page,
PageSize: options.PageSize,
Keyword: options.Keyword,
TargetType: firstQuery(c, "target_type", "targetType"),
ReportType: firstQuery(c, "report_type", "reportType"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}), true
}
func optionalInt64(c *gin.Context, keys ...string) (int64, bool) {
value := strings.TrimSpace(firstQuery(c, keys...))
if value == "" {
return 0, true
}
parsed, err := strconv.ParseInt(value, 10, 64)
if err != nil || parsed < 0 {
return 0, false
}
return parsed, true
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(c.Query(key)); value != "" {
return value
}
}
return ""
}

View File

@ -0,0 +1,11 @@
package report
type listQuery struct {
Page int
PageSize int
Keyword string
TargetType string
ReportType string
StartAtMS int64
EndAtMS int64
}

View File

@ -0,0 +1,38 @@
package report
type reportUserDTO struct {
Avatar string `json:"avatar"`
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
DisplayUserID string `json:"displayUserId"`
UserID string `json:"userId"`
Username string `json:"username"`
}
type reportRoomDTO struct {
CoverURL string `json:"coverUrl"`
RoomID string `json:"roomId"`
RoomShortID string `json:"roomShortId"`
Title string `json:"title"`
}
type reportTargetDTO struct {
Type string `json:"type"`
Room *reportRoomDTO `json:"room,omitempty"`
User *reportUserDTO `json:"user,omitempty"`
}
type reportDTO struct {
CreatedAtMS int64 `json:"createdAtMs"`
ImageURLs []string `json:"imageUrls"`
Reason string `json:"reason"`
ReportID string `json:"reportId"`
ReporterUserID string `json:"reporterUserId"`
ReportType string `json:"reportType"`
RequestID string `json:"requestId"`
RoomID string `json:"roomId"`
Status string `json:"status"`
Target reportTargetDTO `json:"target"`
TargetType string `json:"targetType"`
UpdatedAtMS int64 `json:"updatedAtMs"`
UserID string `json:"userId"`
}

View File

@ -0,0 +1,15 @@
package report
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/operations/reports", middleware.RequirePermission("report:view"), h.ListReports)
}

View File

@ -0,0 +1,274 @@
package report
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strconv"
"strings"
"hyapp-admin-server/internal/integration/roomclient"
)
const (
targetTypeUser = "user"
targetTypeRoom = "room"
)
type Service struct {
roomClient roomclient.Client
userDB *sql.DB
}
func NewService(userDB *sql.DB, roomClient roomclient.Client) *Service {
return &Service{userDB: userDB, roomClient: roomClient}
}
func (s *Service) ListReports(ctx context.Context, appCode string, query listQuery) ([]reportDTO, int64, error) {
query = normalizeListQuery(query)
if s == nil || s.userDB == nil {
return nil, 0, fmt.Errorf("user mysql is not configured")
}
whereSQL, args := reportWhere(appCode, query)
var total int64
if err := s.userDB.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM user_reports r
LEFT JOIN users u ON u.app_code = r.app_code AND u.user_id = r.target_user_id
`+whereSQL,
args...,
).Scan(&total); err != nil {
return nil, 0, err
}
rows, err := s.userDB.QueryContext(ctx, `
SELECT r.report_id, r.reporter_user_id, r.target_type, r.target_user_id, r.room_id,
r.report_type, r.reason, CAST(r.image_urls_json AS CHAR), r.status, r.request_id,
r.created_at_ms, r.updated_at_ms,
COALESCE(u.current_display_user_id, ''), COALESCE(u.default_display_user_id, ''),
COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM user_reports r
LEFT JOIN users u ON u.app_code = r.app_code AND u.user_id = r.target_user_id
`+whereSQL+`
ORDER BY r.created_at_ms DESC, r.report_id DESC
LIMIT ? OFFSET ?`,
append(args, query.PageSize, offset(query.Page, query.PageSize))...,
)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items := make([]reportDTO, 0, query.PageSize)
for rows.Next() {
var item reportDTO
var reporterUserID int64
var targetUserID sql.NullInt64
var imageURLsJSON string
var displayUserID string
var defaultDisplayUserID string
var username string
var avatar string
if err := rows.Scan(
&item.ReportID,
&reporterUserID,
&item.TargetType,
&targetUserID,
&item.RoomID,
&item.ReportType,
&item.Reason,
&imageURLsJSON,
&item.Status,
&item.RequestID,
&item.CreatedAtMS,
&item.UpdatedAtMS,
&displayUserID,
&defaultDisplayUserID,
&username,
&avatar,
); err != nil {
return nil, 0, err
}
imageURLs, err := parseImageURLs(imageURLsJSON)
if err != nil {
return nil, 0, err
}
item.ReporterUserID = formatID(reporterUserID)
item.ImageURLs = imageURLs
item.Target = reportTargetDTO{Type: item.TargetType}
if targetUserID.Valid && targetUserID.Int64 > 0 {
item.UserID = formatID(targetUserID.Int64)
item.Target.User = &reportUserDTO{
Avatar: avatar,
DefaultDisplayUserID: defaultDisplayUserID,
DisplayUserID: displayUserID,
UserID: item.UserID,
Username: username,
}
}
if item.TargetType == targetTypeRoom {
item.Target.Room = &reportRoomDTO{RoomID: item.RoomID}
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if err := s.fillRoomTargets(ctx, items); err != nil {
return nil, 0, err
}
return items, total, nil
}
func normalizeListQuery(query listQuery) listQuery {
if query.Page < 1 {
query.Page = 1
}
if query.PageSize < 1 {
query.PageSize = 20
}
if query.PageSize > 100 {
query.PageSize = 100
}
query.Keyword = strings.TrimSpace(query.Keyword)
query.TargetType = normalizeTargetType(query.TargetType)
query.ReportType = strings.ToLower(strings.TrimSpace(query.ReportType))
if query.StartAtMS < 0 {
query.StartAtMS = 0
}
if query.EndAtMS < 0 {
query.EndAtMS = 0
}
return query
}
func normalizeTargetType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case targetTypeUser, "用户":
return targetTypeUser
case targetTypeRoom, "房间":
return targetTypeRoom
default:
return ""
}
}
func reportWhere(appCode string, query listQuery) (string, []any) {
conditions := []string{"r.app_code = ?"}
args := []any{appCode}
if query.TargetType != "" {
conditions = append(conditions, "r.target_type = ?")
args = append(args, query.TargetType)
}
if query.ReportType != "" {
conditions = append(conditions, "r.report_type = ?")
args = append(args, query.ReportType)
}
if query.StartAtMS > 0 {
conditions = append(conditions, "r.created_at_ms >= ?")
args = append(args, query.StartAtMS)
}
if query.EndAtMS > 0 {
conditions = append(conditions, "r.created_at_ms < ?")
args = append(args, query.EndAtMS)
}
if query.Keyword != "" {
like := "%" + query.Keyword + "%"
conditions = append(conditions, `(r.report_id LIKE ? OR r.room_id LIKE ? OR CAST(r.target_user_id AS CHAR) LIKE ? OR CAST(r.reporter_user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.default_display_user_id LIKE ? OR u.username LIKE ?)`)
args = append(args, like, like, like, like, like, like, like)
}
return "WHERE " + strings.Join(conditions, " AND "), args
}
func parseImageURLs(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return []string{}, nil
}
var urls []string
if err := json.Unmarshal([]byte(raw), &urls); err != nil {
return nil, err
}
if urls == nil {
return []string{}, nil
}
return urls, nil
}
func (s *Service) fillRoomTargets(ctx context.Context, items []reportDTO) error {
if s == nil || s.roomClient == nil || len(items) == 0 {
return nil
}
roomIDs := make([]string, 0, len(items))
for _, item := range items {
if item.TargetType == targetTypeRoom && strings.TrimSpace(item.RoomID) != "" {
roomIDs = append(roomIDs, item.RoomID)
}
}
rooms, err := s.queryRooms(ctx, uniqueStrings(roomIDs))
if err != nil {
return err
}
for index := range items {
if items[index].TargetType != targetTypeRoom {
continue
}
room := reportRoomDTO{RoomID: items[index].RoomID}
if found, ok := rooms[items[index].RoomID]; ok {
room = found
}
items[index].Target.Room = &room
}
return nil
}
func (s *Service) queryRooms(ctx context.Context, roomIDs []string) (map[string]reportRoomDTO, error) {
rooms := make(map[string]reportRoomDTO, len(roomIDs))
for _, roomID := range roomIDs {
room, err := s.roomClient.GetRoom(ctx, roomclient.GetRoomRequest{RoomID: roomID})
if err != nil {
// 举报列表是治理入口,房间被删或房间服务短暂不可读时仍保留举报事实本身。
if ctx.Err() != nil {
return nil, ctx.Err()
}
continue
}
rooms[roomID] = reportRoomDTO{
CoverURL: room.CoverURL,
RoomID: room.RoomID,
RoomShortID: room.RoomShortID,
Title: room.Title,
}
}
return rooms, nil
}
func formatID(value int64) string {
if value <= 0 {
return ""
}
return strconv.FormatInt(value, 10)
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{}, len(values))
unique := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
unique = append(unique, value)
}
return unique
}
func offset(page int, pageSize int) int {
return (page - 1) * pageSize
}

View File

@ -0,0 +1,38 @@
package report
import "testing"
func TestNormalizeListQueryCapsPageSizeAndFilters(t *testing.T) {
query := normalizeListQuery(listQuery{Page: -1, PageSize: 1000, Keyword: " abc ", TargetType: "用户", ReportType: " Fraud "})
if query.Page != 1 || query.PageSize != 100 || query.Keyword != "abc" || query.TargetType != targetTypeUser || query.ReportType != "fraud" {
t.Fatalf("normalized query mismatch: %+v", query)
}
}
func TestReportWhereUsesStableFilters(t *testing.T) {
query := listQuery{
Keyword: "10001",
TargetType: targetTypeRoom,
ReportType: "fraud",
StartAtMS: 100,
EndAtMS: 200,
}
where, args := reportWhere("lalu", query)
want := "WHERE r.app_code = ? AND r.target_type = ? AND r.report_type = ? AND r.created_at_ms >= ? AND r.created_at_ms < ? AND (r.report_id LIKE ? OR r.room_id LIKE ? OR CAST(r.target_user_id AS CHAR) LIKE ? OR CAST(r.reporter_user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.default_display_user_id LIKE ? OR u.username LIKE ?)"
if where != want {
t.Fatalf("where mismatch:\nwant %s\n got %s", want, where)
}
if len(args) != 12 || args[0] != "lalu" || args[1] != targetTypeRoom || args[2] != "fraud" || args[3] != int64(100) || args[4] != int64(200) || args[5] != "%10001%" || args[11] != "%10001%" {
t.Fatalf("args mismatch: %#v", args)
}
}
func TestParseImageURLsNormalizesEmptyJSON(t *testing.T) {
urls, err := parseImageURLs("null")
if err != nil {
t.Fatalf("parse images failed: %v", err)
}
if len(urls) != 0 {
t.Fatalf("empty images mismatch: %#v", urls)
}
}

View File

@ -76,6 +76,7 @@ var defaultPermissions = []model.Permission{
{Name: "金币流水查看", Code: "coin-ledger:view", Kind: "menu"},
{Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"},
{Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"},
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
@ -238,6 +239,7 @@ func (s *Store) seedMenus() error {
{ParentID: &operationsID, Title: "金币流水", Code: "operation-coin-ledger", Path: "/operations/coin-ledger", Icon: "receipt", PermissionCode: "coin-ledger:view", Sort: 68, Visible: true},
{ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 69, Visible: true},
{ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 70, Visible: true},
{ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 71, Visible: true},
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
{ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true},
{ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true},
@ -482,7 +484,7 @@ func defaultRolePermissionCodes(code string) []string {
"agency:view", "agency:create", "agency:status",
"bd:view", "bd:create", "bd:update",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -496,7 +498,7 @@ func defaultRolePermissionCodes(code string) []string {
"upload:create",
}
case "auditor":
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-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config: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-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"}
case "readonly":
return []string{
"overview:view",
@ -523,6 +525,7 @@ func defaultRolePermissionCodes(code string) []string {
"coin-seller:view",
"coin-ledger:view",
"coin-adjustment:view",
"report:view",
"lucky-gift:view",
"payment-bill:view",
"payment-product:view",
@ -564,7 +567,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"country:view", "country:create", "country:update", "country:status",
"region:view", "region:create", "region:update", "region:status",
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
"lucky-gift:view", "lucky-gift:update",
"game:view", "game:create", "game:update", "game:status", "game:delete",
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
@ -575,7 +578,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
"vip-config:view", "vip-config:update", "vip-config:grant",
}
case "auditor", "readonly":
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-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config: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-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"}
default:
return nil
}

View File

@ -27,6 +27,7 @@ import (
"hyapp-admin-server/internal/modules/redpacket"
"hyapp-admin-server/internal/modules/regionblock"
"hyapp-admin-server/internal/modules/registrationreward"
reportmodule "hyapp-admin-server/internal/modules/report"
resourcemodule "hyapp-admin-server/internal/modules/resource"
"hyapp-admin-server/internal/modules/roomadmin"
"hyapp-admin-server/internal/modules/roomtreasure"
@ -64,6 +65,7 @@ type Handlers struct {
Payment *payment.Handler
RBAC *rbac.Handler
RedPacket *redpacket.Handler
Report *reportmodule.Handler
RegistrationReward *registrationreward.Handler
RegionBlock *regionblock.Handler
Resource *resourcemodule.Handler
@ -93,6 +95,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
menu.RegisterRoutes(protected, h.Menu)
rbac.RegisterRoutes(protected, h.RBAC)
redpacket.RegisterRoutes(protected, h.RedPacket)
reportmodule.RegisterRoutes(protected, h.Report)
resourcemodule.RegisterRoutes(protected, h.Resource)
adminuser.RegisterRoutes(protected, h.AdminUser)
appuser.RegisterRoutes(protected, h.AppUser)

View File

@ -0,0 +1,43 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
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
('举报列表查看', 'report:view', 'menu', '', @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, '运营管理', 'operations', '', 'operations', '', 68, 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, '举报列表', 'operation-reports', '/operations/reports', 'flag', 'report:view', 71, TRUE, @now_ms, @now_ms
FROM admin_menus parent
WHERE parent.code = 'operations'
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 IN ('platform-admin', 'ops-admin', 'auditor', 'readonly')
AND admin_permission.code = 'report:view';

View File

@ -50,6 +50,7 @@ type UserSocialClient interface {
DeleteFriend(ctx context.Context, req *userv1.DeleteFriendRequest) (*userv1.DeleteFriendResponse, error)
ListFriends(ctx context.Context, req *userv1.ListFriendsRequest) (*userv1.ListFriendsResponse, error)
ListFriendApplications(ctx context.Context, req *userv1.ListFriendApplicationsRequest) (*userv1.ListFriendApplicationsResponse, error)
SubmitReport(ctx context.Context, req *userv1.SubmitReportRequest) (*userv1.SubmitReportResponse, error)
}
// UserDeviceClient 抽象 gateway 对 user-service 设备 push token 的依赖。
@ -281,6 +282,10 @@ func (c *grpcUserSocialClient) ListFriendApplications(ctx context.Context, req *
return c.client.ListFriendApplications(ctx, req)
}
func (c *grpcUserSocialClient) SubmitReport(ctx context.Context, req *userv1.SubmitReportRequest) (*userv1.SubmitReportResponse, error) {
return c.client.SubmitReport(ctx, req)
}
func (c *grpcUserDeviceClient) BindPushToken(ctx context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) {
return c.client.BindPushToken(ctx, req)
}

View File

@ -84,6 +84,7 @@ type UserHandlers struct {
ListMyResources http.HandlerFunc
EquipMyResource http.HandlerFunc
UserSocialAction http.HandlerFunc
SubmitReport http.HandlerFunc
}
type ManagerHandlers struct {
@ -294,6 +295,7 @@ func (r routes) registerUserRoutes() {
r.profile("/users/me", "", h.GetMyProfile)
r.profile("/users/me/resources", "", h.ListMyResources)
r.profile("/users/me/resources/{resource_id}/equip", "", h.EquipMyResource)
r.profile("/reports", http.MethodPost, h.SubmitReport)
r.profile("/users/{user_id}/{action...}", "", h.UserSocialAction)
}

View File

@ -6,6 +6,7 @@ import (
"hyapp/services/gateway-service/internal/transport/http/httpkit"
"net/http"
"net/http/httptest"
"strings"
"testing"
userv1 "hyapp.local/api/proto/user/v1"
@ -23,6 +24,7 @@ type fakeUserSocialClient struct {
lastDelete *userv1.DeleteFriendRequest
lastFriends *userv1.ListFriendsRequest
lastFriendApps *userv1.ListFriendApplicationsRequest
lastReport *userv1.SubmitReportRequest
}
func (f *fakeUserSocialClient) RecordProfileVisit(_ context.Context, req *userv1.RecordProfileVisitRequest) (*userv1.RecordProfileVisitResponse, error) {
@ -75,6 +77,26 @@ func (f *fakeUserSocialClient) ListFriendApplications(_ context.Context, req *us
return &userv1.ListFriendApplicationsResponse{Applications: []*userv1.FriendApplication{{RequesterUserId: 10002, TargetUserId: req.GetUserId(), Status: "pending", CreatedAtMs: 9000, UpdatedAtMs: 9000}}, Total: 1}, nil
}
func (f *fakeUserSocialClient) SubmitReport(_ context.Context, req *userv1.SubmitReportRequest) (*userv1.SubmitReportResponse, error) {
f.lastReport = req
targetType := "user"
if req.GetRoomId() != "" {
targetType = "room"
}
return &userv1.SubmitReportResponse{Report: &userv1.UserReport{
ReportId: "rpt-test",
ReporterUserId: req.GetReporterUserId(),
TargetType: targetType,
UserId: req.GetUserId(),
RoomId: req.GetRoomId(),
ReportType: req.GetReportType(),
Reason: req.GetReason(),
ImageUrls: req.GetImageUrls(),
Status: "submitted",
CreatedAtMs: 1778000000000,
}}, nil
}
func TestFriendApplicationFlowRoutesUseBackendThenIMOwnedReminder(t *testing.T) {
socialClient := &fakeUserSocialClient{}
handler := NewHandler(&fakeRoomClient{})
@ -104,6 +126,28 @@ func TestFriendApplicationFlowRoutesUseBackendThenIMOwnedReminder(t *testing.T)
}
}
func TestSubmitReportRoutePassesAuthenticatedReporterAndSingleTarget(t *testing.T) {
socialClient := &fakeUserSocialClient{}
handler := NewHandler(&fakeRoomClient{})
handler.SetUserSocialClient(socialClient)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodPost, "/api/v1/reports", strings.NewReader(`{"user_id":"10002","report_type":"pornography","reason":"bad","image_urls":["https://cdn.example.com/a.png"]}`))
request.Header.Set("Authorization", "Bearer "+signGatewayTokenWithProfile(t, "secret", 10001, true))
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("submit report status = %d body=%s", response.Code, response.Body.String())
}
if socialClient.lastReport == nil || socialClient.lastReport.GetReporterUserId() != 10001 || socialClient.lastReport.GetUserId() != 10002 || socialClient.lastReport.GetRoomId() != "" || socialClient.lastReport.GetReportType() != "pornography" {
t.Fatalf("submit report request mismatch: %+v", socialClient.lastReport)
}
var envelope httpkit.ResponseEnvelope
if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil || envelope.Code != httpkit.CodeOK {
t.Fatalf("invalid envelope: %+v err=%v", envelope, err)
}
}
func TestSocialListRoutesPassPagingAndDirection(t *testing.T) {
socialClient := &fakeUserSocialClient{}
handler := NewHandler(&fakeRoomClient{})

View File

@ -61,6 +61,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers {
ListMyFriendApplications: h.listMyFriendApplications,
GetMyProfile: h.getMyProfile,
UserSocialAction: h.userSocialAction,
SubmitReport: h.submitReport,
}
}

View File

@ -0,0 +1,94 @@
package userapi
import (
"encoding/json"
"net/http"
"strconv"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type submitReportData struct {
ReportID string `json:"report_id"`
TargetType string `json:"target_type"`
UserID string `json:"user_id"`
RoomID string `json:"room_id"`
ReportType string `json:"report_type"`
Status string `json:"status"`
CreatedAtMs int64 `json:"created_at_ms"`
}
// submitReport 只把当前登录用户和举报目标透传到 user-service举报发起人不能来自客户端 body。
func (h *Handler) submitReport(writer http.ResponseWriter, request *http.Request) {
if h.userSocialClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body struct {
UserID json.RawMessage `json:"user_id"`
RoomID string `json:"room_id"`
ReportType string `json:"report_type"`
Reason string `json:"reason"`
ImageURLs []string `json:"image_urls"`
}
if !httpkit.Decode(writer, request, &body) {
return
}
userID, ok := optionalReportUserID(body.UserID)
if !ok {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
resp, err := h.userSocialClient.SubmitReport(request.Context(), &userv1.SubmitReportRequest{
Meta: httpkit.UserMeta(request, ""),
ReporterUserId: auth.UserIDFromContext(request.Context()),
UserId: userID,
RoomId: body.RoomID,
ReportType: body.ReportType,
Reason: body.Reason,
ImageUrls: body.ImageURLs,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
httpkit.WriteOK(writer, request, reportData(resp.GetReport()))
}
func optionalReportUserID(raw json.RawMessage) (int64, bool) {
if len(raw) == 0 || string(raw) == "null" {
return 0, true
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
text = strings.TrimSpace(text)
if text == "" {
return 0, true
}
value, err := strconv.ParseInt(text, 10, 64)
return value, err == nil && value > 0
}
var value int64
if err := json.Unmarshal(raw, &value); err != nil {
return 0, false
}
return value, value > 0
}
func reportData(report *userv1.UserReport) submitReportData {
if report == nil {
return submitReportData{}
}
return submitReportData{
ReportID: report.GetReportId(),
TargetType: report.GetTargetType(),
UserID: httpkit.UserIDString(report.GetUserId()),
RoomID: report.GetRoomId(),
ReportType: report.GetReportType(),
Status: report.GetStatus(),
CreatedAtMs: report.GetCreatedAtMs(),
}
}

View File

@ -146,6 +146,28 @@ CREATE TABLE IF NOT EXISTS user_status_change_logs (
KEY idx_user_status_logs_request (app_code, request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户状态变更日志表';
CREATE TABLE IF NOT EXISTS user_reports (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
report_id VARCHAR(96) NOT NULL COMMENT '举报单业务 ID',
reporter_user_id BIGINT NOT NULL COMMENT '举报发起人用户 ID',
target_type VARCHAR(16) NOT NULL COMMENT '举报目标类型user/room',
target_user_id BIGINT NULL COMMENT '被举报用户 ID',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '被举报房间 ID',
report_type VARCHAR(64) NOT NULL COMMENT '举报类型',
reason VARCHAR(512) NOT NULL DEFAULT '' COMMENT '举报理由',
image_urls_json JSON NOT NULL COMMENT '举报图片 URL 数组',
status VARCHAR(32) NOT NULL COMMENT '处理状态',
request_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '链路请求 ID用于排查问题',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, report_id),
KEY idx_user_reports_reporter (app_code, reporter_user_id, created_at_ms),
KEY idx_user_reports_target_user (app_code, target_user_id, created_at_ms),
KEY idx_user_reports_room (app_code, room_id, created_at_ms),
KEY idx_user_reports_status (app_code, status, created_at_ms),
KEY idx_user_reports_request (app_code, request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户举报表';
CREATE TABLE IF NOT EXISTS display_user_id_sequences (
app_code VARCHAR(32) NOT NULL PRIMARY KEY COMMENT '应用编码,用于多租户隔离',
next_value BIGINT NOT NULL COMMENT '下一个值',

View File

@ -192,6 +192,12 @@ const (
FriendApplicationStatusPending = "pending"
// FriendApplicationStatusAccepted 表示好友申请已同意。
FriendApplicationStatusAccepted = "accepted"
// ReportTargetUser 表示举报目标是单个用户。
ReportTargetUser = "user"
// ReportTargetRoom 表示举报目标是单个房间。
ReportTargetRoom = "room"
// ReportStatusSubmitted 表示举报单已提交,等待后台治理流程处理。
ReportStatusSubmitted = "submitted"
)
// ProfileVisitRecord 是访问某个用户主页的去重记录。
@ -225,6 +231,36 @@ type FriendApplication struct {
UpdatedAtMs int64
}
// Report 是 App 用户提交的治理举报事实。
type Report struct {
// AppCode 是举报所属 App后台治理只在同 App 范围内处理。
AppCode string
// ReportID 是举报单不可变业务 ID。
ReportID string
// ReporterUserID 是举报发起人,只能来自已认证 token。
ReporterUserID int64
// TargetType 区分 user/room避免靠空字段猜测举报对象。
TargetType string
// UserID 是被举报用户 ID房间举报时为 0。
UserID int64
// RoomID 是被举报房间 ID用户举报时为空。
RoomID string
// ReportType 是客户端选择的稳定举报类型枚举。
ReportType string
// Reason 是用户补充的举报理由。
Reason string
// ImageURLs 是用户上传后的举报图片 URL 快照。
ImageURLs []string
// Status 是举报单当前处理状态。
Status string
// RequestID 是提交举报时的 gateway 链路追踪 ID。
RequestID string
// CreatedAtMs 是举报创建时间UTC epoch ms。
CreatedAtMs int64
// UpdatedAtMs 是举报最后更新时间UTC epoch ms。
UpdatedAtMs int64
}
// BusinessUserLookupItem 是带业务场景权限裁剪后的用户查询结果。
// 它只包含选择目标用户所需字段,不能扩展手机号、登录身份或钱包资产。
type BusinessUserLookupItem struct {

View File

@ -105,6 +105,7 @@ type fakeModerationRepository struct {
user userdomain.User
sessionIDs []string
lastCommand UserStatusCommand
lastReport userdomain.Report
}
func (r *fakeModerationRepository) SetUserStatus(_ context.Context, command UserStatusCommand) (UserStatusPersistenceResult, error) {
@ -161,6 +162,11 @@ func (r *fakeModerationRepository) ListFriendApplications(context.Context, int64
return nil, 0, nil
}
func (r *fakeModerationRepository) SubmitReport(_ context.Context, report userdomain.Report) (userdomain.Report, error) {
r.lastReport = report
return report, nil
}
func (r *fakeModerationRepository) BusinessUserLookup(context.Context, string, bool, int32) ([]userdomain.BusinessUserLookupItem, error) {
return nil, nil
}

View File

@ -0,0 +1,117 @@
package user
import (
"context"
"net/url"
"strings"
"unicode/utf8"
"hyapp/pkg/idgen"
"hyapp/pkg/roomid"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
)
const (
maxReportReasonRunes = 512
maxReportImages = 9
maxReportImageURLLen = 1024
)
var validReportTypes = map[string]struct{}{
"politics": {},
"pornography": {},
"graphic_violence": {},
"verbal_abuse": {},
"fraud": {},
"illegal_content": {},
"minors": {},
"in_person_transactions": {},
"other": {},
}
// SubmitReport 校验 App 举报目标和证据 URL并把不可变举报事实交给 user repository 持久化。
func (s *Service) SubmitReport(ctx context.Context, reporterUserID int64, userID int64, roomID string, reportType string, reason string, imageURLs []string, requestID string) (userdomain.Report, error) {
if s.userRepository == nil {
return userdomain.Report{}, xerr.New(xerr.Unavailable, "user repository is not configured")
}
reporterUserID, userID, roomID, reportType, reason, imageURLs, targetType, err := normalizeReportInput(reporterUserID, userID, roomID, reportType, reason, imageURLs)
if err != nil {
return userdomain.Report{}, err
}
nowMs := s.now().UnixMilli()
report := userdomain.Report{
ReportID: idgen.New("rpt"),
ReporterUserID: reporterUserID,
TargetType: targetType,
UserID: userID,
RoomID: roomID,
ReportType: reportType,
Reason: reason,
ImageURLs: imageURLs,
Status: userdomain.ReportStatusSubmitted,
RequestID: strings.TrimSpace(requestID),
CreatedAtMs: nowMs,
UpdatedAtMs: nowMs,
}
return s.userRepository.SubmitReport(ctx, report)
}
func normalizeReportInput(reporterUserID int64, userID int64, roomID string, reportType string, reason string, imageURLs []string) (int64, int64, string, string, string, []string, string, error) {
roomID = strings.TrimSpace(roomID)
reportType = strings.ToLower(strings.TrimSpace(reportType))
reason = strings.TrimSpace(reason)
if reporterUserID <= 0 {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "reporter_user_id is required")
}
hasUser := userID > 0
hasRoom := roomID != ""
if hasUser == hasRoom {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "user_id and room_id must be exactly one")
}
if hasUser && userID == reporterUserID {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "cannot report self")
}
targetType := userdomain.ReportTargetUser
if hasRoom {
if !roomid.ValidStringID(roomID) {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "room_id is invalid")
}
targetType = userdomain.ReportTargetRoom
}
if _, ok := validReportTypes[reportType]; !ok {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "report_type is invalid")
}
if utf8.RuneCountInString(reason) > maxReportReasonRunes {
return 0, 0, "", "", "", nil, "", xerr.New(xerr.InvalidArgument, "reason is too long")
}
normalizedImages, err := normalizeReportImageURLs(imageURLs)
if err != nil {
return 0, 0, "", "", "", nil, "", err
}
return reporterUserID, userID, roomID, reportType, reason, normalizedImages, targetType, nil
}
func normalizeReportImageURLs(imageURLs []string) ([]string, error) {
if len(imageURLs) > maxReportImages {
return nil, xerr.New(xerr.InvalidArgument, "too many report images")
}
normalized := make([]string, 0, len(imageURLs))
for _, raw := range imageURLs {
value := strings.TrimSpace(raw)
if value == "" {
continue
}
if len(value) > maxReportImageURLLen {
return nil, xerr.New(xerr.InvalidArgument, "image url is too long")
}
parsed, err := url.Parse(value)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, xerr.New(xerr.InvalidArgument, "image url is invalid")
}
normalized = append(normalized, value)
}
return normalized, nil
}

View File

@ -0,0 +1,47 @@
package user
import (
"context"
"testing"
"time"
"hyapp/pkg/appcode"
userdomain "hyapp/services/user-service/internal/domain/user"
)
func TestSubmitReportAcceptsExactlyOneTarget(t *testing.T) {
repository := &fakeModerationRepository{}
svc := New(repository, WithClock(func() time.Time { return time.UnixMilli(1_778_000_000_000).UTC() }))
ctx := appcode.WithContext(context.Background(), "lalu")
report, err := svc.SubmitReport(ctx, 10001, 10002, "", " Pornography ", " reason ", []string{" https://cdn.example.com/report.png "}, "req-report")
if err != nil {
t.Fatalf("SubmitReport failed: %v", err)
}
if report.ReportID == "" || report.TargetType != userdomain.ReportTargetUser || report.ReportType != "pornography" || report.Reason != "reason" || len(report.ImageURLs) != 1 {
t.Fatalf("report normalization mismatch: %+v", report)
}
if repository.lastReport.ReporterUserID != 10001 || repository.lastReport.UserID != 10002 || repository.lastReport.RequestID != "req-report" || repository.lastReport.CreatedAtMs != 1_778_000_000_000 {
t.Fatalf("repository report mismatch: %+v", repository.lastReport)
}
}
func TestSubmitReportRejectsMissingOrMixedTarget(t *testing.T) {
svc := New(&fakeModerationRepository{})
ctx := appcode.WithContext(context.Background(), "lalu")
tests := []struct {
name string
userID int64
roomID string
}{
{name: "missing target"},
{name: "mixed target", userID: 10002, roomID: "room_1"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, err := svc.SubmitReport(ctx, 10001, test.userID, test.roomID, "fraud", "", nil, "req-report"); err == nil {
t.Fatalf("SubmitReport must reject target combination user=%d room=%q", test.userID, test.roomID)
}
})
}
}

View File

@ -37,6 +37,8 @@ type UserRepository interface {
ListFriends(ctx context.Context, userID int64, page int32, pageSize int32, cursorUpdatedAtMS int64, cursorUserID int64) ([]userdomain.FriendRecord, int64, error)
// ListFriendApplications 分页读取好友申请。
ListFriendApplications(ctx context.Context, userID int64, direction string, page int32, pageSize int32) ([]userdomain.FriendApplication, int64, error)
// SubmitReport 持久化 App 用户提交的治理举报单。
SubmitReport(ctx context.Context, report userdomain.Report) (userdomain.Report, error)
// BusinessUserLookup 按已授权业务场景查询目标用户,返回字段必须保持最小化。
BusinessUserLookup(ctx context.Context, keyword string, numericKeyword bool, pageSize int32) ([]userdomain.BusinessUserLookupItem, error)
// BatchGetUsers 批量查询用户主状态,缺失用户不应返回占位对象。

View File

@ -0,0 +1,51 @@
package user
import (
"context"
"encoding/json"
"hyapp/pkg/appcode"
userdomain "hyapp/services/user-service/internal/domain/user"
)
// SubmitReport 持久化举报事实。用户目标会校验同 App 存在;房间目标只记录 room_id不回查 room-service 明细表。
func (r *Repository) SubmitReport(ctx context.Context, report userdomain.Report) (userdomain.Report, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return userdomain.Report{}, err
}
defer tx.Rollback()
appCode := appcode.FromContext(ctx)
if err := ensureSocialUser(ctx, tx, appCode, report.ReporterUserID); err != nil {
return userdomain.Report{}, err
}
if report.UserID > 0 {
if err := ensureSocialUser(ctx, tx, appCode, report.UserID); err != nil {
return userdomain.Report{}, err
}
}
imageURLsJSON, err := json.Marshal(report.ImageURLs)
if err != nil {
return userdomain.Report{}, err
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_reports (
app_code, report_id, reporter_user_id, target_type, target_user_id, room_id,
report_type, reason, image_urls_json, status, request_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appCode, report.ReportID, report.ReporterUserID, report.TargetType, nullableReportUserID(report.UserID), report.RoomID,
report.ReportType, report.Reason, string(imageURLsJSON), report.Status, report.RequestID, report.CreatedAtMs, report.UpdatedAtMs,
); err != nil {
return userdomain.Report{}, err
}
report.AppCode = appCode
return report, tx.Commit()
}
func nullableReportUserID(userID int64) any {
if userID <= 0 {
return nil
}
return userID
}

View File

@ -111,6 +111,10 @@ func (r *Repository) ListFriendApplications(ctx context.Context, userID int64, d
return r.Repository.UserRepository().ListFriendApplications(ctx, userID, direction, page, pageSize)
}
func (r *Repository) SubmitReport(ctx context.Context, report userdomain.Report) (userdomain.Report, error) {
return r.Repository.UserRepository().SubmitReport(ctx, report)
}
// BusinessUserLookup 让测试 wrapper 继续满足业务场景用户查询接口。
func (r *Repository) BusinessUserLookup(ctx context.Context, keyword string, numericKeyword bool, pageSize int32) ([]userdomain.BusinessUserLookupItem, error) {
return r.Repository.UserRepository().BusinessUserLookup(ctx, keyword, numericKeyword, pageSize)

View File

@ -176,6 +176,21 @@ func toProtoFriendApplication(application userdomain.FriendApplication) *userv1.
}
}
func toProtoUserReport(report userdomain.Report) *userv1.UserReport {
return &userv1.UserReport{
ReportId: report.ReportID,
ReporterUserId: report.ReporterUserID,
TargetType: report.TargetType,
UserId: report.UserID,
RoomId: report.RoomID,
ReportType: report.ReportType,
Reason: report.Reason,
ImageUrls: append([]string{}, report.ImageURLs...),
Status: report.Status,
CreatedAtMs: report.CreatedAtMs,
}
}
// toProtoCountry 把国家领域对象转换为管理 RPC 响应。
func toProtoCountry(country userdomain.Country) *userv1.Country {
return &userv1.Country{

View File

@ -408,6 +408,16 @@ func (s *Server) ListFriendApplications(ctx context.Context, req *userv1.ListFri
return resp, nil
}
// SubmitReport 记录 App 用户提交的用户或房间举报。
func (s *Server) SubmitReport(ctx context.Context, req *userv1.SubmitReportRequest) (*userv1.SubmitReportResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
report, err := s.userSvc.SubmitReport(ctx, req.GetReporterUserId(), req.GetUserId(), req.GetRoomId(), req.GetReportType(), req.GetReason(), req.GetImageUrls(), req.GetMeta().GetRequestId())
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.SubmitReportResponse{Report: toProtoUserReport(report)}, nil
}
// BatchGetUsers 批量返回用户主状态,缺失用户不填充占位对象。
func (s *Server) BatchGetUsers(ctx context.Context, req *userv1.BatchGetUsersRequest) (*userv1.BatchGetUsersResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())