feat:修改活动 TOP3 + 我的排名

This commit is contained in:
zerosaturation 2026-06-24 18:10:12 +08:00
parent a077fe2685
commit b7e2aee399
17 changed files with 3204 additions and 135 deletions

View File

@ -1106,3 +1106,108 @@ func convertContributionRankingResponse(resp *pbActivity.ContributionRankingResp
"total": resp.Total,
}
}
// GetTopRanking 获取活动 TOP3 + 我的排名(专用轻量接口)
// @Summary 获取活动 TOP3 + 我的排名
// @Description 返回 top3 头像组 + my_inforank/avatar/gap_to_prev/status与 /ranking 不同仅返回本组件所需字段gap_to_prev 由后端计算
// @Tags activities
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path int64 true "活动ID"
// @Param star_id query int64 false "粉丝身份ID不传则用 token 中的)"
// @Success 200 {object} response.Response
// @Failure 400 {object} response.Response
// @Failure 401 {object} response.Response
// @Router /api/v1/activities/{id}/top-ranking [get]
func (ctrl *ActivityController) GetTopRanking(c *gin.Context) {
// 1) 鉴权
userID, exists := c.Get("user_id")
if !exists {
response.Error(c, http.StatusUnauthorized, "未授权")
return
}
// 2) 解析路径参数 activity_id
activityIDStr := c.Param("id")
activityID, err := strconv.ParseInt(activityIDStr, 10, 64)
if err != nil || activityID <= 0 {
response.Error(c, http.StatusBadRequest, "活动ID参数错误")
return
}
// 3) star_id:query 优先,缺省用 token 中的;都没有按 0 处理
reqStarID := int64(0)
if v, ok := c.Get("star_id"); ok && v != nil {
if sID, ok2 := v.(int64); ok2 {
reqStarID = sID
}
}
if qs := c.Query("star_id"); qs != "" {
if v, perr := strconv.ParseInt(qs, 10, 64); perr == nil && v > 0 {
reqStarID = v
}
}
logger.Logger.Info("GetTopRanking request",
zap.Int64("user_id", userID.(int64)),
zap.Int64("activity_id", activityID),
zap.Int64("star_id", reqStarID),
)
// 4) gRPC 调用
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
ctx = context.WithValue(ctx, constant.AttachmentKey, map[string]interface{}{
"user_id": strconv.FormatInt(userID.(int64), 10),
"star_id": strconv.FormatInt(reqStarID, 10),
})
resp, err := ctrl.activityService.GetTopRanking(ctx, &pbActivity.TopRankingRequest{
ActivityId: activityID,
StarId: reqStarID,
UserId: userID.(int64),
})
if err != nil {
logger.Logger.Error("GetTopRanking RPC failed", zap.Error(err))
response.Error(c, http.StatusInternalServerError, "获取 top-ranking 失败")
return
}
if resp.Base != nil && resp.Base.Code != uint32(codes.OK) {
response.ErrorWithCode(c, int(resp.Base.Code), resp.Base.Message)
return
}
// 5) 转换并返回
data := convertTopRankingResponse(resp)
response.Success(c, data)
}
// convertTopRankingResponse proto -> gin.H(供前端)
func convertTopRankingResponse(resp *pbActivity.TopRankingResponse) map[string]interface{} {
top3 := make([]map[string]interface{}, 0, len(resp.Top3))
for _, it := range resp.Top3 {
top3 = append(top3, map[string]interface{}{
"rank": it.Rank,
"user_id": it.UserId,
"avatar_url": it.AvatarUrl,
})
}
var myInfo map[string]interface{}
if resp.MyInfo != nil {
myInfo = map[string]interface{}{
"rank": resp.MyInfo.Rank,
"avatar_url": resp.MyInfo.AvatarUrl,
"gap_to_prev": resp.MyInfo.GapToPrev,
"status": resp.MyInfo.Status,
}
}
return map[string]interface{}{
"top3": top3,
"my_info": myInfo,
}
}

View File

@ -723,6 +723,106 @@ const docTemplate = `{
}
}
},
"/api/v1/activities/{activity_id}/messages": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "分页获取活动留言列表(最新在上)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"activities"
],
"summary": "列出活动留言",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "活动ID",
"name": "activity_id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "页码默认1",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页数量默认20最大50",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "用户在应援活动页面发送一条祝福留言",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"activities"
],
"summary": "发送活动留言",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "活动ID",
"name": "activity_id",
"in": "path",
"required": true
},
{
"description": "留言内容",
"name": "request",
"in": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/activities/{activity_id}/progress": {
"get": {
"security": [
@ -865,6 +965,97 @@ const docTemplate = `{
}
}
},
"/api/v1/activities/{id}/top-ranking": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "返回 top3 头像组 + my_inforank/avatar/gap_to_prev/status与 /ranking 不同仅返回本组件所需字段gap_to_prev 由后端计算",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"activities"
],
"summary": "获取活动 TOP3 + 我的排名",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "活动ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "粉丝身份ID不传则用 token 中的)",
"name": "star_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/admin/notifications": {
"post": {
"description": "内部接口,无 JWT 鉴权,供 admin 后台(Python 8081)调用。user_ids 由调用方解析(查 fan_profiles)。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "admin 批量发送通知",
"parameters": [
{
"description": "通知 payload(已解析的 user_ids)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/controller.adminCreateNotificationRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/ai-chat/history/{sessionId}": {
"get": {
"security": [
@ -3034,6 +3225,350 @@ const docTemplate = `{
}
}
},
"/api/v1/notifications": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "获取当前用户的通知列表(支持 type/tab 分页)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "获取通知列表",
"parameters": [
{
"type": "string",
"description": "通知类型过滤: like / system / activity",
"name": "type",
"in": "query"
},
{
"type": "string",
"description": "列表 tab: unread / read / all",
"name": "tab",
"in": "query"
},
{
"type": "integer",
"description": "页码默认1",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页数量默认20",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/devices": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "将 uni.getPushClientId() 拿到的 cid 上报给后端;同 cid 重复注册为更新。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "注册推送设备",
"parameters": [
{
"description": "设备信息",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/controller.registerDeviceRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/devices/unregister": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "注销推送 cid;cid 为空 = 注销当前用户全部设备(用于主动登出)。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "注销推送设备",
"parameters": [
{
"description": "注销请求",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/controller.unregisterDeviceRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/read-all": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "将当前用户某类型或全部通知标记为已读",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "全部已读",
"parameters": [
{
"type": "string",
"description": "通知类型过滤: like / system / activity; 留空表示全部",
"name": "type",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/targets/{target_id}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "删除同一 target 下的所有通知",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "按目标ID删除通知",
"parameters": [
{
"type": "integer",
"description": "目标ID(如藏品ID)",
"name": "target_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/targets/{target_id}/read": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "将同一 target 下的所有通知标记为已读",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "按目标ID标记已读",
"parameters": [
{
"type": "integer",
"description": "目标ID(如藏品ID)",
"name": "target_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/unread-count": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "按类型返回未读数量(like/system/activity/total)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "获取未读通知数",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/{id}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "根据ID删除通知",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "删除单条通知",
"parameters": [
{
"type": "integer",
"description": "通知ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/{id}/read": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "根据通知ID标记为已读",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "标记单条通知已读",
"parameters": [
{
"type": "integer",
"description": "通知ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/public/oss/upload-signature": {
"get": {
"description": "用于注册等未登录场景下上传头像,无需鉴权。\n后端生成唯一完整 keypolicy 锁到该 key前端只能写到指定路径key 形如 avatar/register-pending/{key}/avatar_\u003cuuid\u003e.png。",
@ -4135,6 +4670,86 @@ const docTemplate = `{
}
}
},
"controller.adminCreateNotificationRequest": {
"type": "object",
"required": [
"title",
"type",
"user_ids"
],
"properties": {
"content": {
"type": "string",
"maxLength": 500
},
"data": {
"type": "object",
"additionalProperties": true
},
"star_id": {
"type": "integer"
},
"title": {
"type": "string",
"maxLength": 200,
"minLength": 1
},
"type": {
"type": "string",
"enum": [
"system",
"activity",
"like"
]
},
"user_ids": {
"type": "array",
"maxItems": 10000,
"minItems": 1,
"items": {
"type": "integer"
}
}
}
},
"controller.registerDeviceRequest": {
"type": "object",
"required": [
"cid"
],
"properties": {
"app_version": {
"type": "string",
"maxLength": 32
},
"cid": {
"type": "string",
"maxLength": 128,
"minLength": 1
},
"device_model": {
"type": "string",
"maxLength": 64
},
"platform": {
"type": "string",
"enum": [
"ios",
"android",
"harmony"
]
}
}
},
"controller.unregisterDeviceRequest": {
"type": "object",
"properties": {
"cid": {
"type": "string",
"maxLength": 128
}
}
},
"dto.AddIdentityResponseDTO": {
"type": "object",
"properties": {
@ -4277,6 +4892,14 @@ const docTemplate = `{
"description": "封面图URL",
"type": "string"
},
"earnings": {
"description": "当前可领取收益(与 ExhibitedAssetItemDTO 命名对齐)",
"type": "integer"
},
"hourly_earnings": {
"description": "每小时收益",
"type": "number"
},
"like_count": {
"description": "点赞数",
"type": "integer"

View File

@ -717,6 +717,106 @@
}
}
},
"/api/v1/activities/{activity_id}/messages": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "分页获取活动留言列表(最新在上)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"activities"
],
"summary": "列出活动留言",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "活动ID",
"name": "activity_id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "页码默认1",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页数量默认20最大50",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "用户在应援活动页面发送一条祝福留言",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"activities"
],
"summary": "发送活动留言",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "活动ID",
"name": "activity_id",
"in": "path",
"required": true
},
{
"description": "留言内容",
"name": "request",
"in": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/activities/{activity_id}/progress": {
"get": {
"security": [
@ -859,6 +959,97 @@
}
}
},
"/api/v1/activities/{id}/top-ranking": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "返回 top3 头像组 + my_inforank/avatar/gap_to_prev/status与 /ranking 不同仅返回本组件所需字段gap_to_prev 由后端计算",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"activities"
],
"summary": "获取活动 TOP3 + 我的排名",
"parameters": [
{
"type": "integer",
"format": "int64",
"description": "活动ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"format": "int64",
"description": "粉丝身份ID不传则用 token 中的)",
"name": "star_id",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/response.Response"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/admin/notifications": {
"post": {
"description": "内部接口,无 JWT 鉴权,供 admin 后台(Python 8081)调用。user_ids 由调用方解析(查 fan_profiles)。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"admin"
],
"summary": "admin 批量发送通知",
"parameters": [
{
"description": "通知 payload(已解析的 user_ids)",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/controller.adminCreateNotificationRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/ai-chat/history/{sessionId}": {
"get": {
"security": [
@ -3028,6 +3219,350 @@
}
}
},
"/api/v1/notifications": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "获取当前用户的通知列表(支持 type/tab 分页)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "获取通知列表",
"parameters": [
{
"type": "string",
"description": "通知类型过滤: like / system / activity",
"name": "type",
"in": "query"
},
{
"type": "string",
"description": "列表 tab: unread / read / all",
"name": "tab",
"in": "query"
},
{
"type": "integer",
"description": "页码默认1",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页数量默认20",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/devices": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "将 uni.getPushClientId() 拿到的 cid 上报给后端;同 cid 重复注册为更新。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "注册推送设备",
"parameters": [
{
"description": "设备信息",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/controller.registerDeviceRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/devices/unregister": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "注销推送 cid;cid 为空 = 注销当前用户全部设备(用于主动登出)。",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "注销推送设备",
"parameters": [
{
"description": "注销请求",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/controller.unregisterDeviceRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/read-all": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "将当前用户某类型或全部通知标记为已读",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "全部已读",
"parameters": [
{
"type": "string",
"description": "通知类型过滤: like / system / activity; 留空表示全部",
"name": "type",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/targets/{target_id}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "删除同一 target 下的所有通知",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "按目标ID删除通知",
"parameters": [
{
"type": "integer",
"description": "目标ID(如藏品ID)",
"name": "target_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/targets/{target_id}/read": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "将同一 target 下的所有通知标记为已读",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "按目标ID标记已读",
"parameters": [
{
"type": "integer",
"description": "目标ID(如藏品ID)",
"name": "target_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/unread-count": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "按类型返回未读数量(like/system/activity/total)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "获取未读通知数",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/{id}": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "根据ID删除通知",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "删除单条通知",
"parameters": [
{
"type": "integer",
"description": "通知ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/notifications/{id}/read": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "根据通知ID标记为已读",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"notifications"
],
"summary": "标记单条通知已读",
"parameters": [
{
"type": "integer",
"description": "通知ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/response.Response"
}
}
}
}
},
"/api/v1/public/oss/upload-signature": {
"get": {
"description": "用于注册等未登录场景下上传头像,无需鉴权。\n后端生成唯一完整 keypolicy 锁到该 key前端只能写到指定路径key 形如 avatar/register-pending/{key}/avatar_\u003cuuid\u003e.png。",
@ -4129,6 +4664,86 @@
}
}
},
"controller.adminCreateNotificationRequest": {
"type": "object",
"required": [
"title",
"type",
"user_ids"
],
"properties": {
"content": {
"type": "string",
"maxLength": 500
},
"data": {
"type": "object",
"additionalProperties": true
},
"star_id": {
"type": "integer"
},
"title": {
"type": "string",
"maxLength": 200,
"minLength": 1
},
"type": {
"type": "string",
"enum": [
"system",
"activity",
"like"
]
},
"user_ids": {
"type": "array",
"maxItems": 10000,
"minItems": 1,
"items": {
"type": "integer"
}
}
}
},
"controller.registerDeviceRequest": {
"type": "object",
"required": [
"cid"
],
"properties": {
"app_version": {
"type": "string",
"maxLength": 32
},
"cid": {
"type": "string",
"maxLength": 128,
"minLength": 1
},
"device_model": {
"type": "string",
"maxLength": 64
},
"platform": {
"type": "string",
"enum": [
"ios",
"android",
"harmony"
]
}
}
},
"controller.unregisterDeviceRequest": {
"type": "object",
"properties": {
"cid": {
"type": "string",
"maxLength": 128
}
}
},
"dto.AddIdentityResponseDTO": {
"type": "object",
"properties": {
@ -4271,6 +4886,14 @@
"description": "封面图URL",
"type": "string"
},
"earnings": {
"description": "当前可领取收益(与 ExhibitedAssetItemDTO 命名对齐)",
"type": "integer"
},
"hourly_earnings": {
"description": "每小时收益",
"type": "number"
},
"like_count": {
"description": "点赞数",
"type": "integer"

View File

@ -49,6 +49,64 @@ definitions:
description: 响应时间戳Unix时间戳毫秒
type: integer
type: object
controller.adminCreateNotificationRequest:
properties:
content:
maxLength: 500
type: string
data:
additionalProperties: true
type: object
star_id:
type: integer
title:
maxLength: 200
minLength: 1
type: string
type:
enum:
- system
- activity
- like
type: string
user_ids:
items:
type: integer
maxItems: 10000
minItems: 1
type: array
required:
- title
- type
- user_ids
type: object
controller.registerDeviceRequest:
properties:
app_version:
maxLength: 32
type: string
cid:
maxLength: 128
minLength: 1
type: string
device_model:
maxLength: 64
type: string
platform:
enum:
- ios
- android
- harmony
type: string
required:
- cid
type: object
controller.unregisterDeviceRequest:
properties:
cid:
maxLength: 128
type: string
type: object
dto.AddIdentityResponseDTO:
properties:
bound:
@ -151,6 +209,12 @@ definitions:
cover_url:
description: 封面图URL
type: string
earnings:
description: 当前可领取收益(与 ExhibitedAssetItemDTO 命名对齐)
type: integer
hourly_earnings:
description: 每小时收益
type: number
like_count:
description: 点赞数
type: integer
@ -1403,6 +1467,70 @@ paths:
summary: 获取活动道具列表
tags:
- activities
/api/v1/activities/{activity_id}/messages:
get:
consumes:
- application/json
description: 分页获取活动留言列表(最新在上)
parameters:
- description: 活动ID
format: int64
in: path
name: activity_id
required: true
type: integer
- description: 页码默认1
in: query
name: page
type: integer
- description: 每页数量默认20最大50
in: query
name: page_size
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 列出活动留言
tags:
- activities
post:
consumes:
- application/json
description: 用户在应援活动页面发送一条祝福留言
parameters:
- description: 活动ID
format: int64
in: path
name: activity_id
required: true
type: integer
- description: 留言内容
in: body
name: request
required: true
schema:
properties:
content:
type: string
type: object
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 发送活动留言
tags:
- activities
/api/v1/activities/{activity_id}/progress:
get:
consumes:
@ -1494,6 +1622,66 @@ paths:
summary: 获取贡献点排名
tags:
- activities
/api/v1/activities/{id}/top-ranking:
get:
consumes:
- application/json
description: 返回 top3 头像组 + my_inforank/avatar/gap_to_prev/status与 /ranking
不同仅返回本组件所需字段gap_to_prev 由后端计算
parameters:
- description: 活动ID
format: int64
in: path
name: id
required: true
type: integer
- description: 粉丝身份ID不传则用 token 中的)
format: int64
in: query
name: star_id
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
"400":
description: Bad Request
schema:
$ref: '#/definitions/response.Response'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 获取活动 TOP3 + 我的排名
tags:
- activities
/api/v1/admin/notifications:
post:
consumes:
- application/json
description: 内部接口,无 JWT 鉴权,供 admin 后台(Python 8081)调用。user_ids 由调用方解析(查 fan_profiles)。
parameters:
- description: 通知 payload(已解析的 user_ids)
in: body
name: body
required: true
schema:
$ref: '#/definitions/controller.adminCreateNotificationRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
summary: admin 批量发送通知
tags:
- admin
/api/v1/ai-chat/history/{sessionId}:
get:
consumes:
@ -2804,6 +2992,219 @@ paths:
summary: 切换粉丝身份
tags:
- users
/api/v1/notifications:
get:
consumes:
- application/json
description: 获取当前用户的通知列表(支持 type/tab 分页)
parameters:
- description: '通知类型过滤: like / system / activity'
in: query
name: type
type: string
- description: '列表 tab: unread / read / all'
in: query
name: tab
type: string
- description: 页码默认1
in: query
name: page
type: integer
- description: 每页数量默认20
in: query
name: page_size
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 获取通知列表
tags:
- notifications
/api/v1/notifications/{id}:
delete:
consumes:
- application/json
description: 根据ID删除通知
parameters:
- description: 通知ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 删除单条通知
tags:
- notifications
/api/v1/notifications/{id}/read:
post:
consumes:
- application/json
description: 根据通知ID标记为已读
parameters:
- description: 通知ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 标记单条通知已读
tags:
- notifications
/api/v1/notifications/devices:
post:
consumes:
- application/json
description: 将 uni.getPushClientId() 拿到的 cid 上报给后端;同 cid 重复注册为更新。
parameters:
- description: 设备信息
in: body
name: body
required: true
schema:
$ref: '#/definitions/controller.registerDeviceRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 注册推送设备
tags:
- notifications
/api/v1/notifications/devices/unregister:
post:
consumes:
- application/json
description: 注销推送 cid;cid 为空 = 注销当前用户全部设备(用于主动登出)。
parameters:
- description: 注销请求
in: body
name: body
required: true
schema:
$ref: '#/definitions/controller.unregisterDeviceRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 注销推送设备
tags:
- notifications
/api/v1/notifications/read-all:
post:
consumes:
- application/json
description: 将当前用户某类型或全部通知标记为已读
parameters:
- description: '通知类型过滤: like / system / activity; 留空表示全部'
in: query
name: type
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 全部已读
tags:
- notifications
/api/v1/notifications/targets/{target_id}:
delete:
consumes:
- application/json
description: 删除同一 target 下的所有通知
parameters:
- description: 目标ID(如藏品ID)
in: path
name: target_id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 按目标ID删除通知
tags:
- notifications
/api/v1/notifications/targets/{target_id}/read:
post:
consumes:
- application/json
description: 将同一 target 下的所有通知标记为已读
parameters:
- description: 目标ID(如藏品ID)
in: path
name: target_id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 按目标ID标记已读
tags:
- notifications
/api/v1/notifications/unread-count:
get:
consumes:
- application/json
description: 按类型返回未读数量(like/system/activity/total)
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/response.Response'
security:
- BearerAuth: []
summary: 获取未读通知数
tags:
- notifications
/api/v1/public/oss/upload-signature:
get:
consumes:

View File

@ -388,6 +388,7 @@ func SetupRouter(userClient *client.Client, socialClient *client.Client, assetCl
activities.POST("/:id/purchase", activityCtrl.PurchaseItem) // 购买道具
activities.POST("/:id/batch-purchase", activityCtrl.BatchPurchaseItem) // 批量购买道具
activities.GET("/:id/ranking", activityCtrl.GetContributionRanking) // 获取贡献点排名
activities.GET("/:id/top-ranking", activityCtrl.GetTopRanking) // 获取活动 TOP3 + 我的排名(专用轻量接口)
activities.GET("/:id/contributions/latest", activityCtrl.GetLatestContributions) // 获取最新贡献记录
activities.GET("/:id/messages", activityCtrl.ListActivityMessages) // 获取活动留言列表
activities.POST("/:id/messages", activityCtrl.CreateActivityMessage) // 发送一条活动留言

View File

@ -1104,6 +1104,258 @@ func (x *MyContribution) GetAvatarUrl() string {
return ""
}
// 活动 TOP3 + 我的排名请求(专用轻量接口)
type TopRankingRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
ActivityId int64 `protobuf:"varint,1,opt,name=activity_id,json=activityId,proto3" json:"activity_id,omitempty"`
StarId int64 `protobuf:"varint,2,opt,name=star_id,json=starId,proto3" json:"star_id,omitempty"` // 0 表示不按明星过滤
UserId int64 `protobuf:"varint,3,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` // 来自 token
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TopRankingRequest) Reset() {
*x = TopRankingRequest{}
mi := &file_activity_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TopRankingRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TopRankingRequest) ProtoMessage() {}
func (x *TopRankingRequest) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TopRankingRequest.ProtoReflect.Descriptor instead.
func (*TopRankingRequest) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{13}
}
func (x *TopRankingRequest) GetActivityId() int64 {
if x != nil {
return x.ActivityId
}
return 0
}
func (x *TopRankingRequest) GetStarId() int64 {
if x != nil {
return x.StarId
}
return 0
}
func (x *TopRankingRequest) GetUserId() int64 {
if x != nil {
return x.UserId
}
return 0
}
// TOP3 排名项
type TopRankingItem struct {
state protoimpl.MessageState `protogen:"open.v1"`
Rank int32 `protobuf:"varint,1,opt,name=rank,proto3" json:"rank,omitempty"`
UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
AvatarUrl string `protobuf:"bytes,3,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TopRankingItem) Reset() {
*x = TopRankingItem{}
mi := &file_activity_proto_msgTypes[14]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TopRankingItem) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TopRankingItem) ProtoMessage() {}
func (x *TopRankingItem) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[14]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TopRankingItem.ProtoReflect.Descriptor instead.
func (*TopRankingItem) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{14}
}
func (x *TopRankingItem) GetRank() int32 {
if x != nil {
return x.Rank
}
return 0
}
func (x *TopRankingItem) GetUserId() int64 {
if x != nil {
return x.UserId
}
return 0
}
func (x *TopRankingItem) GetAvatarUrl() string {
if x != nil {
return x.AvatarUrl
}
return ""
}
// 我的 TOP 排名信息
type MyTopRankingInfo struct {
state protoimpl.MessageState `protogen:"open.v1"`
Rank int32 `protobuf:"varint,1,opt,name=rank,proto3" json:"rank,omitempty"` // 0 表示未上榜
AvatarUrl string `protobuf:"bytes,2,opt,name=avatar_url,json=avatarUrl,proto3" json:"avatar_url,omitempty"`
GapToPrev int64 `protobuf:"varint,3,opt,name=gap_to_prev,json=gapToPrev,proto3" json:"gap_to_prev,omitempty"`
Status string `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"` // "ranked" | "unranked"
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *MyTopRankingInfo) Reset() {
*x = MyTopRankingInfo{}
mi := &file_activity_proto_msgTypes[15]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *MyTopRankingInfo) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*MyTopRankingInfo) ProtoMessage() {}
func (x *MyTopRankingInfo) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[15]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use MyTopRankingInfo.ProtoReflect.Descriptor instead.
func (*MyTopRankingInfo) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{15}
}
func (x *MyTopRankingInfo) GetRank() int32 {
if x != nil {
return x.Rank
}
return 0
}
func (x *MyTopRankingInfo) GetAvatarUrl() string {
if x != nil {
return x.AvatarUrl
}
return ""
}
func (x *MyTopRankingInfo) GetGapToPrev() int64 {
if x != nil {
return x.GapToPrev
}
return 0
}
func (x *MyTopRankingInfo) GetStatus() string {
if x != nil {
return x.Status
}
return ""
}
// TOP 排名响应
type TopRankingResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Base *common.BaseResponse `protobuf:"bytes,1,opt,name=base,proto3" json:"base,omitempty"`
Top3 []*TopRankingItem `protobuf:"bytes,2,rep,name=top3,proto3" json:"top3,omitempty"`
MyInfo *MyTopRankingInfo `protobuf:"bytes,3,opt,name=my_info,json=myInfo,proto3" json:"my_info,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *TopRankingResponse) Reset() {
*x = TopRankingResponse{}
mi := &file_activity_proto_msgTypes[16]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *TopRankingResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*TopRankingResponse) ProtoMessage() {}
func (x *TopRankingResponse) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[16]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use TopRankingResponse.ProtoReflect.Descriptor instead.
func (*TopRankingResponse) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{16}
}
func (x *TopRankingResponse) GetBase() *common.BaseResponse {
if x != nil {
return x.Base
}
return nil
}
func (x *TopRankingResponse) GetTop3() []*TopRankingItem {
if x != nil {
return x.Top3
}
return nil
}
func (x *TopRankingResponse) GetMyInfo() *MyTopRankingInfo {
if x != nil {
return x.MyInfo
}
return nil
}
// 活动列表请求
type GetActivityListRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -1117,7 +1369,7 @@ type GetActivityListRequest struct {
func (x *GetActivityListRequest) Reset() {
*x = GetActivityListRequest{}
mi := &file_activity_proto_msgTypes[13]
mi := &file_activity_proto_msgTypes[17]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1129,7 +1381,7 @@ func (x *GetActivityListRequest) String() string {
func (*GetActivityListRequest) ProtoMessage() {}
func (x *GetActivityListRequest) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[13]
mi := &file_activity_proto_msgTypes[17]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1142,7 +1394,7 @@ func (x *GetActivityListRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActivityListRequest.ProtoReflect.Descriptor instead.
func (*GetActivityListRequest) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{13}
return file_activity_proto_rawDescGZIP(), []int{17}
}
func (x *GetActivityListRequest) GetStarId() int64 {
@ -1187,7 +1439,7 @@ type GetActivityListResponse struct {
func (x *GetActivityListResponse) Reset() {
*x = GetActivityListResponse{}
mi := &file_activity_proto_msgTypes[14]
mi := &file_activity_proto_msgTypes[18]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1199,7 +1451,7 @@ func (x *GetActivityListResponse) String() string {
func (*GetActivityListResponse) ProtoMessage() {}
func (x *GetActivityListResponse) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[14]
mi := &file_activity_proto_msgTypes[18]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1212,7 +1464,7 @@ func (x *GetActivityListResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActivityListResponse.ProtoReflect.Descriptor instead.
func (*GetActivityListResponse) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{14}
return file_activity_proto_rawDescGZIP(), []int{18}
}
func (x *GetActivityListResponse) GetBase() *common.BaseResponse {
@ -1260,7 +1512,7 @@ type GetProgressRequest struct {
func (x *GetProgressRequest) Reset() {
*x = GetProgressRequest{}
mi := &file_activity_proto_msgTypes[15]
mi := &file_activity_proto_msgTypes[19]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1272,7 +1524,7 @@ func (x *GetProgressRequest) String() string {
func (*GetProgressRequest) ProtoMessage() {}
func (x *GetProgressRequest) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[15]
mi := &file_activity_proto_msgTypes[19]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1285,7 +1537,7 @@ func (x *GetProgressRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetProgressRequest.ProtoReflect.Descriptor instead.
func (*GetProgressRequest) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{15}
return file_activity_proto_rawDescGZIP(), []int{19}
}
func (x *GetProgressRequest) GetActivityId() int64 {
@ -1311,7 +1563,7 @@ type GetProgressResponse struct {
func (x *GetProgressResponse) Reset() {
*x = GetProgressResponse{}
mi := &file_activity_proto_msgTypes[16]
mi := &file_activity_proto_msgTypes[20]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1323,7 +1575,7 @@ func (x *GetProgressResponse) String() string {
func (*GetProgressResponse) ProtoMessage() {}
func (x *GetProgressResponse) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[16]
mi := &file_activity_proto_msgTypes[20]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1336,7 +1588,7 @@ func (x *GetProgressResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetProgressResponse.ProtoReflect.Descriptor instead.
func (*GetProgressResponse) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{16}
return file_activity_proto_rawDescGZIP(), []int{20}
}
func (x *GetProgressResponse) GetBase() *common.BaseResponse {
@ -1407,7 +1659,7 @@ type MintingActivity struct {
func (x *MintingActivity) Reset() {
*x = MintingActivity{}
mi := &file_activity_proto_msgTypes[17]
mi := &file_activity_proto_msgTypes[21]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1419,7 +1671,7 @@ func (x *MintingActivity) String() string {
func (*MintingActivity) ProtoMessage() {}
func (x *MintingActivity) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[17]
mi := &file_activity_proto_msgTypes[21]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1432,7 +1684,7 @@ func (x *MintingActivity) ProtoReflect() protoreflect.Message {
// Deprecated: Use MintingActivity.ProtoReflect.Descriptor instead.
func (*MintingActivity) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{17}
return file_activity_proto_rawDescGZIP(), []int{21}
}
func (x *MintingActivity) GetId() int64 {
@ -1517,7 +1769,7 @@ type GetMintingActivitiesRequest struct {
func (x *GetMintingActivitiesRequest) Reset() {
*x = GetMintingActivitiesRequest{}
mi := &file_activity_proto_msgTypes[18]
mi := &file_activity_proto_msgTypes[22]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1529,7 +1781,7 @@ func (x *GetMintingActivitiesRequest) String() string {
func (*GetMintingActivitiesRequest) ProtoMessage() {}
func (x *GetMintingActivitiesRequest) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[18]
mi := &file_activity_proto_msgTypes[22]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1542,7 +1794,7 @@ func (x *GetMintingActivitiesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetMintingActivitiesRequest.ProtoReflect.Descriptor instead.
func (*GetMintingActivitiesRequest) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{18}
return file_activity_proto_rawDescGZIP(), []int{22}
}
func (x *GetMintingActivitiesRequest) GetStarId() int64 {
@ -1580,7 +1832,7 @@ type GetMintingActivitiesResponse struct {
func (x *GetMintingActivitiesResponse) Reset() {
*x = GetMintingActivitiesResponse{}
mi := &file_activity_proto_msgTypes[19]
mi := &file_activity_proto_msgTypes[23]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1592,7 +1844,7 @@ func (x *GetMintingActivitiesResponse) String() string {
func (*GetMintingActivitiesResponse) ProtoMessage() {}
func (x *GetMintingActivitiesResponse) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[19]
mi := &file_activity_proto_msgTypes[23]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1605,7 +1857,7 @@ func (x *GetMintingActivitiesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetMintingActivitiesResponse.ProtoReflect.Descriptor instead.
func (*GetMintingActivitiesResponse) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{19}
return file_activity_proto_rawDescGZIP(), []int{23}
}
func (x *GetMintingActivitiesResponse) GetBase() *common.BaseResponse {
@ -1656,7 +1908,7 @@ type GetLatestContributionsRequest struct {
func (x *GetLatestContributionsRequest) Reset() {
*x = GetLatestContributionsRequest{}
mi := &file_activity_proto_msgTypes[20]
mi := &file_activity_proto_msgTypes[24]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1668,7 +1920,7 @@ func (x *GetLatestContributionsRequest) String() string {
func (*GetLatestContributionsRequest) ProtoMessage() {}
func (x *GetLatestContributionsRequest) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[20]
mi := &file_activity_proto_msgTypes[24]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1681,7 +1933,7 @@ func (x *GetLatestContributionsRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetLatestContributionsRequest.ProtoReflect.Descriptor instead.
func (*GetLatestContributionsRequest) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{20}
return file_activity_proto_rawDescGZIP(), []int{24}
}
func (x *GetLatestContributionsRequest) GetActivityId() int64 {
@ -1733,7 +1985,7 @@ type ContributionRecord struct {
func (x *ContributionRecord) Reset() {
*x = ContributionRecord{}
mi := &file_activity_proto_msgTypes[21]
mi := &file_activity_proto_msgTypes[25]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1745,7 +1997,7 @@ func (x *ContributionRecord) String() string {
func (*ContributionRecord) ProtoMessage() {}
func (x *ContributionRecord) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[21]
mi := &file_activity_proto_msgTypes[25]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1758,7 +2010,7 @@ func (x *ContributionRecord) ProtoReflect() protoreflect.Message {
// Deprecated: Use ContributionRecord.ProtoReflect.Descriptor instead.
func (*ContributionRecord) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{21}
return file_activity_proto_rawDescGZIP(), []int{25}
}
func (x *ContributionRecord) GetId() int64 {
@ -1856,7 +2108,7 @@ type GetLatestContributionsResponse struct {
func (x *GetLatestContributionsResponse) Reset() {
*x = GetLatestContributionsResponse{}
mi := &file_activity_proto_msgTypes[22]
mi := &file_activity_proto_msgTypes[26]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1868,7 +2120,7 @@ func (x *GetLatestContributionsResponse) String() string {
func (*GetLatestContributionsResponse) ProtoMessage() {}
func (x *GetLatestContributionsResponse) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[22]
mi := &file_activity_proto_msgTypes[26]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1881,7 +2133,7 @@ func (x *GetLatestContributionsResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetLatestContributionsResponse.ProtoReflect.Descriptor instead.
func (*GetLatestContributionsResponse) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{22}
return file_activity_proto_rawDescGZIP(), []int{26}
}
func (x *GetLatestContributionsResponse) GetBase() *common.BaseResponse {
@ -1914,7 +2166,7 @@ type ActivityMessage struct {
func (x *ActivityMessage) Reset() {
*x = ActivityMessage{}
mi := &file_activity_proto_msgTypes[23]
mi := &file_activity_proto_msgTypes[27]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -1926,7 +2178,7 @@ func (x *ActivityMessage) String() string {
func (*ActivityMessage) ProtoMessage() {}
func (x *ActivityMessage) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[23]
mi := &file_activity_proto_msgTypes[27]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -1939,7 +2191,7 @@ func (x *ActivityMessage) ProtoReflect() protoreflect.Message {
// Deprecated: Use ActivityMessage.ProtoReflect.Descriptor instead.
func (*ActivityMessage) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{23}
return file_activity_proto_rawDescGZIP(), []int{27}
}
func (x *ActivityMessage) GetId() int64 {
@ -2009,7 +2261,7 @@ type ListActivityMessagesRequest struct {
func (x *ListActivityMessagesRequest) Reset() {
*x = ListActivityMessagesRequest{}
mi := &file_activity_proto_msgTypes[24]
mi := &file_activity_proto_msgTypes[28]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -2021,7 +2273,7 @@ func (x *ListActivityMessagesRequest) String() string {
func (*ListActivityMessagesRequest) ProtoMessage() {}
func (x *ListActivityMessagesRequest) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[24]
mi := &file_activity_proto_msgTypes[28]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -2034,7 +2286,7 @@ func (x *ListActivityMessagesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListActivityMessagesRequest.ProtoReflect.Descriptor instead.
func (*ListActivityMessagesRequest) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{24}
return file_activity_proto_rawDescGZIP(), []int{28}
}
func (x *ListActivityMessagesRequest) GetActivityId() int64 {
@ -2071,7 +2323,7 @@ type ListActivityMessagesResponse struct {
func (x *ListActivityMessagesResponse) Reset() {
*x = ListActivityMessagesResponse{}
mi := &file_activity_proto_msgTypes[25]
mi := &file_activity_proto_msgTypes[29]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -2083,7 +2335,7 @@ func (x *ListActivityMessagesResponse) String() string {
func (*ListActivityMessagesResponse) ProtoMessage() {}
func (x *ListActivityMessagesResponse) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[25]
mi := &file_activity_proto_msgTypes[29]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -2096,7 +2348,7 @@ func (x *ListActivityMessagesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ListActivityMessagesResponse.ProtoReflect.Descriptor instead.
func (*ListActivityMessagesResponse) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{25}
return file_activity_proto_rawDescGZIP(), []int{29}
}
func (x *ListActivityMessagesResponse) GetBase() *common.BaseResponse {
@ -2146,7 +2398,7 @@ type CreateActivityMessageRequest struct {
func (x *CreateActivityMessageRequest) Reset() {
*x = CreateActivityMessageRequest{}
mi := &file_activity_proto_msgTypes[26]
mi := &file_activity_proto_msgTypes[30]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -2158,7 +2410,7 @@ func (x *CreateActivityMessageRequest) String() string {
func (*CreateActivityMessageRequest) ProtoMessage() {}
func (x *CreateActivityMessageRequest) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[26]
mi := &file_activity_proto_msgTypes[30]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -2171,7 +2423,7 @@ func (x *CreateActivityMessageRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateActivityMessageRequest.ProtoReflect.Descriptor instead.
func (*CreateActivityMessageRequest) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{26}
return file_activity_proto_rawDescGZIP(), []int{30}
}
func (x *CreateActivityMessageRequest) GetActivityId() int64 {
@ -2212,7 +2464,7 @@ type CreateActivityMessageResponse struct {
func (x *CreateActivityMessageResponse) Reset() {
*x = CreateActivityMessageResponse{}
mi := &file_activity_proto_msgTypes[27]
mi := &file_activity_proto_msgTypes[31]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@ -2224,7 +2476,7 @@ func (x *CreateActivityMessageResponse) String() string {
func (*CreateActivityMessageResponse) ProtoMessage() {}
func (x *CreateActivityMessageResponse) ProtoReflect() protoreflect.Message {
mi := &file_activity_proto_msgTypes[27]
mi := &file_activity_proto_msgTypes[31]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -2237,7 +2489,7 @@ func (x *CreateActivityMessageResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateActivityMessageResponse.ProtoReflect.Descriptor instead.
func (*CreateActivityMessageResponse) Descriptor() ([]byte, []int) {
return file_activity_proto_rawDescGZIP(), []int{27}
return file_activity_proto_rawDescGZIP(), []int{31}
}
func (x *CreateActivityMessageResponse) GetBase() *common.BaseResponse {
@ -2355,7 +2607,27 @@ const file_activity_proto_rawDesc = "" +
"\x06status\x18\x04 \x01(\tR\x06status\x12\x1a\n" +
"\bnickname\x18\x05 \x01(\tR\bnickname\x12\x1d\n" +
"\n" +
"avatar_url\x18\x06 \x01(\tR\tavatarUrl\"z\n" +
"avatar_url\x18\x06 \x01(\tR\tavatarUrl\"f\n" +
"\x11TopRankingRequest\x12\x1f\n" +
"\vactivity_id\x18\x01 \x01(\x03R\n" +
"activityId\x12\x17\n" +
"\astar_id\x18\x02 \x01(\x03R\x06starId\x12\x17\n" +
"\auser_id\x18\x03 \x01(\x03R\x06userId\"\\\n" +
"\x0eTopRankingItem\x12\x12\n" +
"\x04rank\x18\x01 \x01(\x05R\x04rank\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x1d\n" +
"\n" +
"avatar_url\x18\x03 \x01(\tR\tavatarUrl\"}\n" +
"\x10MyTopRankingInfo\x12\x12\n" +
"\x04rank\x18\x01 \x01(\x05R\x04rank\x12\x1d\n" +
"\n" +
"avatar_url\x18\x02 \x01(\tR\tavatarUrl\x12\x1e\n" +
"\vgap_to_prev\x18\x03 \x01(\x03R\tgapToPrev\x12\x16\n" +
"\x06status\x18\x04 \x01(\tR\x06status\"\xb9\x01\n" +
"\x12TopRankingResponse\x120\n" +
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x124\n" +
"\x04top3\x18\x02 \x03(\v2 .topfans.activity.TopRankingItemR\x04top3\x12;\n" +
"\amy_info\x18\x03 \x01(\v2\".topfans.activity.MyTopRankingInfoR\x06myInfo\"z\n" +
"\x16GetActivityListRequest\x12\x17\n" +
"\astar_id\x18\x01 \x01(\x03R\x06starId\x12\x16\n" +
"\x06status\x18\x02 \x01(\tR\x06status\x12\x12\n" +
@ -2465,7 +2737,7 @@ const file_activity_proto_rawDesc = "" +
"\acontent\x18\x04 \x01(\tR\acontent\"\x8e\x01\n" +
"\x1dCreateActivityMessageResponse\x120\n" +
"\x04base\x18\x01 \x01(\v2\x1c.topfans.common.BaseResponseR\x04base\x12;\n" +
"\amessage\x18\x02 \x01(\v2!.topfans.activity.ActivityMessageR\amessage2\xd5\r\n" +
"\amessage\x18\x02 \x01(\v2!.topfans.activity.ActivityMessageR\amessage2\xe8\x0e\n" +
"\x0fActivityService\x12\x82\x01\n" +
"\x0fGetActivityList\x12(.topfans.activity.GetActivityListRequest\x1a).topfans.activity.GetActivityListResponse\"\x1a\x82\xd3\xe4\x93\x02\x14\x12\x12/api/v1/activities\x12y\n" +
"\vGetActivity\x12$.topfans.activity.GetProgressRequest\x1a\x1a.topfans.activity.Activity\"(\x82\xd3\xe4\x93\x02\"\x12 /api/v1/activities/{activity_id}\x12\x91\x01\n" +
@ -2473,7 +2745,8 @@ const file_activity_proto_rawDesc = "" +
"\vGetProgress\x12$.topfans.activity.GetProgressRequest\x1a%.topfans.activity.GetProgressResponse\"1\x82\xd3\xe4\x93\x02+\x12)/api/v1/activities/{activity_id}/progress\x12\x93\x01\n" +
"\fPurchaseItem\x12%.topfans.activity.PurchaseItemRequest\x1a&.topfans.activity.PurchaseItemResponse\"4\x82\xd3\xe4\x93\x02.:\x01*\")/api/v1/activities/{activity_id}/purchase\x12\xa8\x01\n" +
"\x11BatchPurchaseItem\x12*.topfans.activity.BatchPurchaseItemRequest\x1a+.topfans.activity.BatchPurchaseItemResponse\":\x82\xd3\xe4\x93\x024:\x01*\"//api/v1/activities/{activity_id}/batch-purchase\x12\xa7\x01\n" +
"\x16GetContributionRanking\x12,.topfans.activity.ContributionRankingRequest\x1a-.topfans.activity.ContributionRankingResponse\"0\x82\xd3\xe4\x93\x02*\x12(/api/v1/activities/{activity_id}/ranking\x12\x99\x01\n" +
"\x16GetContributionRanking\x12,.topfans.activity.ContributionRankingRequest\x1a-.topfans.activity.ContributionRankingResponse\"0\x82\xd3\xe4\x93\x02*\x12(/api/v1/activities/{activity_id}/ranking\x12\x90\x01\n" +
"\rGetTopRanking\x12#.topfans.activity.TopRankingRequest\x1a$.topfans.activity.TopRankingResponse\"4\x82\xd3\xe4\x93\x02.\x12,/api/v1/activities/{activity_id}/top-ranking\x12\x99\x01\n" +
"\x14GetMintingActivities\x12-.topfans.activity.GetMintingActivitiesRequest\x1a..topfans.activity.GetMintingActivitiesResponse\"\"\x82\xd3\xe4\x93\x02\x1c\x12\x1a/api/v1/minting-activities\x12\xba\x01\n" +
"\x16GetLatestContributions\x12/.topfans.activity.GetLatestContributionsRequest\x1a0.topfans.activity.GetLatestContributionsResponse\"=\x82\xd3\xe4\x93\x027\x125/api/v1/activities/{activity_id}/contributions/latest\x12\xa8\x01\n" +
"\x14ListActivityMessages\x12-.topfans.activity.ListActivityMessagesRequest\x1a..topfans.activity.ListActivityMessagesResponse\"1\x82\xd3\xe4\x93\x02+\x12)/api/v1/activities/{activity_id}/messages\x12\xae\x01\n" +
@ -2491,7 +2764,7 @@ func file_activity_proto_rawDescGZIP() []byte {
return file_activity_proto_rawDescData
}
var file_activity_proto_msgTypes = make([]protoimpl.MessageInfo, 28)
var file_activity_proto_msgTypes = make([]protoimpl.MessageInfo, 32)
var file_activity_proto_goTypes = []any{
(*Activity)(nil), // 0: topfans.activity.Activity
(*ActivityItem)(nil), // 1: topfans.activity.ActivityItem
@ -2506,71 +2779,80 @@ var file_activity_proto_goTypes = []any{
(*ContributionRankingItem)(nil), // 10: topfans.activity.ContributionRankingItem
(*ContributionRankingResponse)(nil), // 11: topfans.activity.ContributionRankingResponse
(*MyContribution)(nil), // 12: topfans.activity.MyContribution
(*GetActivityListRequest)(nil), // 13: topfans.activity.GetActivityListRequest
(*GetActivityListResponse)(nil), // 14: topfans.activity.GetActivityListResponse
(*GetProgressRequest)(nil), // 15: topfans.activity.GetProgressRequest
(*GetProgressResponse)(nil), // 16: topfans.activity.GetProgressResponse
(*MintingActivity)(nil), // 17: topfans.activity.MintingActivity
(*GetMintingActivitiesRequest)(nil), // 18: topfans.activity.GetMintingActivitiesRequest
(*GetMintingActivitiesResponse)(nil), // 19: topfans.activity.GetMintingActivitiesResponse
(*GetLatestContributionsRequest)(nil), // 20: topfans.activity.GetLatestContributionsRequest
(*ContributionRecord)(nil), // 21: topfans.activity.ContributionRecord
(*GetLatestContributionsResponse)(nil), // 22: topfans.activity.GetLatestContributionsResponse
(*ActivityMessage)(nil), // 23: topfans.activity.ActivityMessage
(*ListActivityMessagesRequest)(nil), // 24: topfans.activity.ListActivityMessagesRequest
(*ListActivityMessagesResponse)(nil), // 25: topfans.activity.ListActivityMessagesResponse
(*CreateActivityMessageRequest)(nil), // 26: topfans.activity.CreateActivityMessageRequest
(*CreateActivityMessageResponse)(nil), // 27: topfans.activity.CreateActivityMessageResponse
(*common.BaseResponse)(nil), // 28: topfans.common.BaseResponse
(*TopRankingRequest)(nil), // 13: topfans.activity.TopRankingRequest
(*TopRankingItem)(nil), // 14: topfans.activity.TopRankingItem
(*MyTopRankingInfo)(nil), // 15: topfans.activity.MyTopRankingInfo
(*TopRankingResponse)(nil), // 16: topfans.activity.TopRankingResponse
(*GetActivityListRequest)(nil), // 17: topfans.activity.GetActivityListRequest
(*GetActivityListResponse)(nil), // 18: topfans.activity.GetActivityListResponse
(*GetProgressRequest)(nil), // 19: topfans.activity.GetProgressRequest
(*GetProgressResponse)(nil), // 20: topfans.activity.GetProgressResponse
(*MintingActivity)(nil), // 21: topfans.activity.MintingActivity
(*GetMintingActivitiesRequest)(nil), // 22: topfans.activity.GetMintingActivitiesRequest
(*GetMintingActivitiesResponse)(nil), // 23: topfans.activity.GetMintingActivitiesResponse
(*GetLatestContributionsRequest)(nil), // 24: topfans.activity.GetLatestContributionsRequest
(*ContributionRecord)(nil), // 25: topfans.activity.ContributionRecord
(*GetLatestContributionsResponse)(nil), // 26: topfans.activity.GetLatestContributionsResponse
(*ActivityMessage)(nil), // 27: topfans.activity.ActivityMessage
(*ListActivityMessagesRequest)(nil), // 28: topfans.activity.ListActivityMessagesRequest
(*ListActivityMessagesResponse)(nil), // 29: topfans.activity.ListActivityMessagesResponse
(*CreateActivityMessageRequest)(nil), // 30: topfans.activity.CreateActivityMessageRequest
(*CreateActivityMessageResponse)(nil), // 31: topfans.activity.CreateActivityMessageResponse
(*common.BaseResponse)(nil), // 32: topfans.common.BaseResponse
}
var file_activity_proto_depIdxs = []int32{
1, // 0: topfans.activity.Activity.items:type_name -> topfans.activity.ActivityItem
1, // 1: topfans.activity.ActivityItemsResponse.items:type_name -> topfans.activity.ActivityItem
28, // 2: topfans.activity.PurchaseItemResponse.base:type_name -> topfans.common.BaseResponse
32, // 2: topfans.activity.PurchaseItemResponse.base:type_name -> topfans.common.BaseResponse
5, // 3: topfans.activity.BatchPurchaseItemRequest.items:type_name -> topfans.activity.PurchaseItem
28, // 4: topfans.activity.BatchPurchaseItemResponse.base:type_name -> topfans.common.BaseResponse
32, // 4: topfans.activity.BatchPurchaseItemResponse.base:type_name -> topfans.common.BaseResponse
8, // 5: topfans.activity.BatchPurchaseItemResponse.fails:type_name -> topfans.activity.PurchaseFailItem
28, // 6: topfans.activity.ContributionRankingResponse.base:type_name -> topfans.common.BaseResponse
32, // 6: topfans.activity.ContributionRankingResponse.base:type_name -> topfans.common.BaseResponse
10, // 7: topfans.activity.ContributionRankingResponse.items:type_name -> topfans.activity.ContributionRankingItem
12, // 8: topfans.activity.ContributionRankingResponse.my_contribution:type_name -> topfans.activity.MyContribution
28, // 9: topfans.activity.GetActivityListResponse.base:type_name -> topfans.common.BaseResponse
0, // 10: topfans.activity.GetActivityListResponse.activities:type_name -> topfans.activity.Activity
28, // 11: topfans.activity.GetProgressResponse.base:type_name -> topfans.common.BaseResponse
28, // 12: topfans.activity.GetMintingActivitiesResponse.base:type_name -> topfans.common.BaseResponse
17, // 13: topfans.activity.GetMintingActivitiesResponse.activities:type_name -> topfans.activity.MintingActivity
28, // 14: topfans.activity.GetLatestContributionsResponse.base:type_name -> topfans.common.BaseResponse
21, // 15: topfans.activity.GetLatestContributionsResponse.records:type_name -> topfans.activity.ContributionRecord
28, // 16: topfans.activity.ListActivityMessagesResponse.base:type_name -> topfans.common.BaseResponse
23, // 17: topfans.activity.ListActivityMessagesResponse.messages:type_name -> topfans.activity.ActivityMessage
28, // 18: topfans.activity.CreateActivityMessageResponse.base:type_name -> topfans.common.BaseResponse
23, // 19: topfans.activity.CreateActivityMessageResponse.message:type_name -> topfans.activity.ActivityMessage
13, // 20: topfans.activity.ActivityService.GetActivityList:input_type -> topfans.activity.GetActivityListRequest
15, // 21: topfans.activity.ActivityService.GetActivity:input_type -> topfans.activity.GetProgressRequest
15, // 22: topfans.activity.ActivityService.GetActivityItems:input_type -> topfans.activity.GetProgressRequest
15, // 23: topfans.activity.ActivityService.GetProgress:input_type -> topfans.activity.GetProgressRequest
3, // 24: topfans.activity.ActivityService.PurchaseItem:input_type -> topfans.activity.PurchaseItemRequest
6, // 25: topfans.activity.ActivityService.BatchPurchaseItem:input_type -> topfans.activity.BatchPurchaseItemRequest
9, // 26: topfans.activity.ActivityService.GetContributionRanking:input_type -> topfans.activity.ContributionRankingRequest
18, // 27: topfans.activity.ActivityService.GetMintingActivities:input_type -> topfans.activity.GetMintingActivitiesRequest
20, // 28: topfans.activity.ActivityService.GetLatestContributions:input_type -> topfans.activity.GetLatestContributionsRequest
24, // 29: topfans.activity.ActivityService.ListActivityMessages:input_type -> topfans.activity.ListActivityMessagesRequest
26, // 30: topfans.activity.ActivityService.CreateActivityMessage:input_type -> topfans.activity.CreateActivityMessageRequest
14, // 31: topfans.activity.ActivityService.GetActivityList:output_type -> topfans.activity.GetActivityListResponse
0, // 32: topfans.activity.ActivityService.GetActivity:output_type -> topfans.activity.Activity
2, // 33: topfans.activity.ActivityService.GetActivityItems:output_type -> topfans.activity.ActivityItemsResponse
16, // 34: topfans.activity.ActivityService.GetProgress:output_type -> topfans.activity.GetProgressResponse
4, // 35: topfans.activity.ActivityService.PurchaseItem:output_type -> topfans.activity.PurchaseItemResponse
7, // 36: topfans.activity.ActivityService.BatchPurchaseItem:output_type -> topfans.activity.BatchPurchaseItemResponse
11, // 37: topfans.activity.ActivityService.GetContributionRanking:output_type -> topfans.activity.ContributionRankingResponse
19, // 38: topfans.activity.ActivityService.GetMintingActivities:output_type -> topfans.activity.GetMintingActivitiesResponse
22, // 39: topfans.activity.ActivityService.GetLatestContributions:output_type -> topfans.activity.GetLatestContributionsResponse
25, // 40: topfans.activity.ActivityService.ListActivityMessages:output_type -> topfans.activity.ListActivityMessagesResponse
27, // 41: topfans.activity.ActivityService.CreateActivityMessage:output_type -> topfans.activity.CreateActivityMessageResponse
31, // [31:42] is the sub-list for method output_type
20, // [20:31] is the sub-list for method input_type
20, // [20:20] is the sub-list for extension type_name
20, // [20:20] is the sub-list for extension extendee
0, // [0:20] is the sub-list for field type_name
32, // 9: topfans.activity.TopRankingResponse.base:type_name -> topfans.common.BaseResponse
14, // 10: topfans.activity.TopRankingResponse.top3:type_name -> topfans.activity.TopRankingItem
15, // 11: topfans.activity.TopRankingResponse.my_info:type_name -> topfans.activity.MyTopRankingInfo
32, // 12: topfans.activity.GetActivityListResponse.base:type_name -> topfans.common.BaseResponse
0, // 13: topfans.activity.GetActivityListResponse.activities:type_name -> topfans.activity.Activity
32, // 14: topfans.activity.GetProgressResponse.base:type_name -> topfans.common.BaseResponse
32, // 15: topfans.activity.GetMintingActivitiesResponse.base:type_name -> topfans.common.BaseResponse
21, // 16: topfans.activity.GetMintingActivitiesResponse.activities:type_name -> topfans.activity.MintingActivity
32, // 17: topfans.activity.GetLatestContributionsResponse.base:type_name -> topfans.common.BaseResponse
25, // 18: topfans.activity.GetLatestContributionsResponse.records:type_name -> topfans.activity.ContributionRecord
32, // 19: topfans.activity.ListActivityMessagesResponse.base:type_name -> topfans.common.BaseResponse
27, // 20: topfans.activity.ListActivityMessagesResponse.messages:type_name -> topfans.activity.ActivityMessage
32, // 21: topfans.activity.CreateActivityMessageResponse.base:type_name -> topfans.common.BaseResponse
27, // 22: topfans.activity.CreateActivityMessageResponse.message:type_name -> topfans.activity.ActivityMessage
17, // 23: topfans.activity.ActivityService.GetActivityList:input_type -> topfans.activity.GetActivityListRequest
19, // 24: topfans.activity.ActivityService.GetActivity:input_type -> topfans.activity.GetProgressRequest
19, // 25: topfans.activity.ActivityService.GetActivityItems:input_type -> topfans.activity.GetProgressRequest
19, // 26: topfans.activity.ActivityService.GetProgress:input_type -> topfans.activity.GetProgressRequest
3, // 27: topfans.activity.ActivityService.PurchaseItem:input_type -> topfans.activity.PurchaseItemRequest
6, // 28: topfans.activity.ActivityService.BatchPurchaseItem:input_type -> topfans.activity.BatchPurchaseItemRequest
9, // 29: topfans.activity.ActivityService.GetContributionRanking:input_type -> topfans.activity.ContributionRankingRequest
13, // 30: topfans.activity.ActivityService.GetTopRanking:input_type -> topfans.activity.TopRankingRequest
22, // 31: topfans.activity.ActivityService.GetMintingActivities:input_type -> topfans.activity.GetMintingActivitiesRequest
24, // 32: topfans.activity.ActivityService.GetLatestContributions:input_type -> topfans.activity.GetLatestContributionsRequest
28, // 33: topfans.activity.ActivityService.ListActivityMessages:input_type -> topfans.activity.ListActivityMessagesRequest
30, // 34: topfans.activity.ActivityService.CreateActivityMessage:input_type -> topfans.activity.CreateActivityMessageRequest
18, // 35: topfans.activity.ActivityService.GetActivityList:output_type -> topfans.activity.GetActivityListResponse
0, // 36: topfans.activity.ActivityService.GetActivity:output_type -> topfans.activity.Activity
2, // 37: topfans.activity.ActivityService.GetActivityItems:output_type -> topfans.activity.ActivityItemsResponse
20, // 38: topfans.activity.ActivityService.GetProgress:output_type -> topfans.activity.GetProgressResponse
4, // 39: topfans.activity.ActivityService.PurchaseItem:output_type -> topfans.activity.PurchaseItemResponse
7, // 40: topfans.activity.ActivityService.BatchPurchaseItem:output_type -> topfans.activity.BatchPurchaseItemResponse
11, // 41: topfans.activity.ActivityService.GetContributionRanking:output_type -> topfans.activity.ContributionRankingResponse
16, // 42: topfans.activity.ActivityService.GetTopRanking:output_type -> topfans.activity.TopRankingResponse
23, // 43: topfans.activity.ActivityService.GetMintingActivities:output_type -> topfans.activity.GetMintingActivitiesResponse
26, // 44: topfans.activity.ActivityService.GetLatestContributions:output_type -> topfans.activity.GetLatestContributionsResponse
29, // 45: topfans.activity.ActivityService.ListActivityMessages:output_type -> topfans.activity.ListActivityMessagesResponse
31, // 46: topfans.activity.ActivityService.CreateActivityMessage:output_type -> topfans.activity.CreateActivityMessageResponse
35, // [35:47] is the sub-list for method output_type
23, // [23:35] is the sub-list for method input_type
23, // [23:23] is the sub-list for extension type_name
23, // [23:23] is the sub-list for extension extendee
0, // [0:23] is the sub-list for field type_name
}
func init() { file_activity_proto_init() }
@ -2584,7 +2866,7 @@ func file_activity_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_activity_proto_rawDesc), len(file_activity_proto_rawDesc)),
NumEnums: 0,
NumMessages: 28,
NumMessages: 32,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -50,6 +50,8 @@ const (
ActivityServiceBatchPurchaseItemProcedure = "/topfans.activity.ActivityService/BatchPurchaseItem"
// ActivityServiceGetContributionRankingProcedure is the fully-qualified name of the ActivityService's GetContributionRanking RPC.
ActivityServiceGetContributionRankingProcedure = "/topfans.activity.ActivityService/GetContributionRanking"
// ActivityServiceGetTopRankingProcedure is the fully-qualified name of the ActivityService's GetTopRanking RPC.
ActivityServiceGetTopRankingProcedure = "/topfans.activity.ActivityService/GetTopRanking"
// ActivityServiceGetMintingActivitiesProcedure is the fully-qualified name of the ActivityService's GetMintingActivities RPC.
ActivityServiceGetMintingActivitiesProcedure = "/topfans.activity.ActivityService/GetMintingActivities"
// ActivityServiceGetLatestContributionsProcedure is the fully-qualified name of the ActivityService's GetLatestContributions RPC.
@ -73,6 +75,7 @@ type ActivityService interface {
PurchaseItem(ctx context.Context, req *PurchaseItemRequest, opts ...client.CallOption) (*PurchaseItemResponse, error)
BatchPurchaseItem(ctx context.Context, req *BatchPurchaseItemRequest, opts ...client.CallOption) (*BatchPurchaseItemResponse, error)
GetContributionRanking(ctx context.Context, req *ContributionRankingRequest, opts ...client.CallOption) (*ContributionRankingResponse, error)
GetTopRanking(ctx context.Context, req *TopRankingRequest, opts ...client.CallOption) (*TopRankingResponse, error)
GetMintingActivities(ctx context.Context, req *GetMintingActivitiesRequest, opts ...client.CallOption) (*GetMintingActivitiesResponse, error)
GetLatestContributions(ctx context.Context, req *GetLatestContributionsRequest, opts ...client.CallOption) (*GetLatestContributionsResponse, error)
ListActivityMessages(ctx context.Context, req *ListActivityMessagesRequest, opts ...client.CallOption) (*ListActivityMessagesResponse, error)
@ -155,6 +158,14 @@ func (c *ActivityServiceImpl) GetContributionRanking(ctx context.Context, req *C
return resp, nil
}
func (c *ActivityServiceImpl) GetTopRanking(ctx context.Context, req *TopRankingRequest, opts ...client.CallOption) (*TopRankingResponse, error) {
resp := new(TopRankingResponse)
if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "GetTopRanking", opts...); err != nil {
return nil, err
}
return resp, nil
}
func (c *ActivityServiceImpl) GetMintingActivities(ctx context.Context, req *GetMintingActivitiesRequest, opts ...client.CallOption) (*GetMintingActivitiesResponse, error) {
resp := new(GetMintingActivitiesResponse)
if err := c.conn.CallUnary(ctx, []interface{}{req}, resp, "GetMintingActivities", opts...); err != nil {
@ -189,7 +200,7 @@ func (c *ActivityServiceImpl) CreateActivityMessage(ctx context.Context, req *Cr
var ActivityService_ClientInfo = client.ClientInfo{
InterfaceName: "topfans.activity.ActivityService",
MethodNames: []string{"GetActivityList", "GetActivity", "GetActivityItems", "GetProgress", "PurchaseItem", "BatchPurchaseItem", "GetContributionRanking", "GetMintingActivities", "GetLatestContributions", "ListActivityMessages", "CreateActivityMessage"},
MethodNames: []string{"GetActivityList", "GetActivity", "GetActivityItems", "GetProgress", "PurchaseItem", "BatchPurchaseItem", "GetContributionRanking", "GetTopRanking", "GetMintingActivities", "GetLatestContributions", "ListActivityMessages", "CreateActivityMessage"},
ConnectionInjectFunc: func(dubboCliRaw interface{}, conn *client.Connection) {
dubboCli := dubboCliRaw.(*ActivityServiceImpl)
dubboCli.conn = conn
@ -205,6 +216,7 @@ type ActivityServiceHandler interface {
PurchaseItem(context.Context, *PurchaseItemRequest) (*PurchaseItemResponse, error)
BatchPurchaseItem(context.Context, *BatchPurchaseItemRequest) (*BatchPurchaseItemResponse, error)
GetContributionRanking(context.Context, *ContributionRankingRequest) (*ContributionRankingResponse, error)
GetTopRanking(context.Context, *TopRankingRequest) (*TopRankingResponse, error)
GetMintingActivities(context.Context, *GetMintingActivitiesRequest) (*GetMintingActivitiesResponse, error)
GetLatestContributions(context.Context, *GetLatestContributionsRequest) (*GetLatestContributionsResponse, error)
ListActivityMessages(context.Context, *ListActivityMessagesRequest) (*ListActivityMessagesResponse, error)
@ -328,6 +340,21 @@ var ActivityService_ServiceInfo = server.ServiceInfo{
return triple_protocol.NewResponse(res), nil
},
},
{
Name: "GetTopRanking",
Type: constant.CallUnary,
ReqInitFunc: func() interface{} {
return new(TopRankingRequest)
},
MethodFunc: func(ctx context.Context, args []interface{}, handler interface{}) (interface{}, error) {
req := args[0].(*TopRankingRequest)
res, err := handler.(ActivityServiceHandler).GetTopRanking(ctx, req)
if err != nil {
return nil, err
}
return triple_protocol.NewResponse(res), nil
},
},
{
Name: "GetMintingActivities",
Type: constant.CallUnary,

View File

@ -136,6 +136,35 @@ message MyContribution {
string avatar_url = 6;
}
// TOP3 +
message TopRankingRequest {
int64 activity_id = 1;
int64 star_id = 2; // 0
int64 user_id = 3; // token
}
// TOP3
message TopRankingItem {
int32 rank = 1;
int64 user_id = 2;
string avatar_url = 3;
}
// TOP
message MyTopRankingInfo {
int32 rank = 1; // 0
string avatar_url = 2;
int64 gap_to_prev = 3;
string status = 4; // "ranked" | "unranked"
}
// TOP
message TopRankingResponse {
topfans.common.BaseResponse base = 1;
repeated TopRankingItem top3 = 2;
MyTopRankingInfo my_info = 3;
}
//
message GetActivityListRequest {
int64 star_id = 1;
@ -322,6 +351,13 @@ service ActivityService {
};
}
// TOP3 +
rpc GetTopRanking(TopRankingRequest) returns (TopRankingResponse) {
option (google.api.http) = {
get: "/api/v1/activities/{activity_id}/top-ranking"
};
}
// banner
rpc GetMintingActivities(GetMintingActivitiesRequest) returns (GetMintingActivitiesResponse) {
option (google.api.http) = {

View File

@ -223,6 +223,39 @@ func (p *ActivityProvider) GetContributionRanking(ctx context.Context, req *pb.C
return resp, nil
}
// GetTopRanking 获取活动 TOP3 + 我的排名(专用轻量接口)
func (p *ActivityProvider) GetTopRanking(ctx context.Context, req *pb.TopRankingRequest) (*pb.TopRankingResponse, error) {
logger.Logger.Info("Received GetTopRanking request",
zap.Int64("activity_id", req.ActivityId),
zap.Int64("star_id", req.StarId),
zap.Int64("user_id", req.UserId),
)
resp, err := p.activityService.GetTopRanking(ctx, req)
if err != nil {
logger.Logger.Error("GetTopRanking failed", zap.Error(err))
return &pb.TopRankingResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(appErrors.ToGRPCCode(err)),
Message: err.Error(),
Timestamp: time.Now().UnixMilli(),
},
}, nil
}
logger.Logger.Debug("GetTopRanking successful",
zap.Int("top3_count", len(resp.Top3)),
zap.Int32("my_rank", func() int32 {
if resp.MyInfo != nil {
return resp.MyInfo.Rank
}
return 0
}()),
)
return resp, nil
}
// GetMintingActivities 获取铸造活动列表用于运营banner
func (p *ActivityProvider) GetMintingActivities(ctx context.Context, req *pb.GetMintingActivitiesRequest) (*pb.GetMintingActivitiesResponse, error) {
logger.Logger.Info("Received GetMintingActivities request",

View File

@ -45,6 +45,18 @@ type ActivityRepository interface {
// GetLatestContributions 获取最新贡献记录(用于实时显示)
GetLatestContributions(activityID int64, sinceTimestamp int64, sinceID int64, limit int) ([]*models.ActivityContribution, error)
// GetTop3 获取活动 TOP3专用轻量接口
// 返回 0~3 行,按 total_contribution DESC, id ASC 排序
GetTop3(activityID, starID int64) ([]*models.ActivityUserStats, error)
// GetUserStatsForRanking 获取用户在活动中的统计(返回 nil 表示未参与)
GetUserStatsForRanking(activityID, userID, starID int64) (*models.ActivityUserStats, error)
// GetUserStatsByRank 获取活动指定排名位置的统计;offset 从 0 开始
// rank=N 时,offset=N-1
// 用于"我排名 N 时,查 N-1 名"的场景
GetUserStatsByRank(activityID, starID int64, offset int) (*models.ActivityUserStats, error)
}
// activityRepository Activity仓库实现
@ -291,6 +303,73 @@ func (r *activityRepository) GetUserRank(userID, activityID, starID int64) (int,
return int(count) + 1, nil
}
// GetTop3 获取活动 TOP3(按 total_contribution DESC, id ASC;starID<=0 不过滤)
func (r *activityRepository) GetTop3(activityID, starID int64) ([]*models.ActivityUserStats, error) {
if activityID <= 0 {
return nil, errors.New("activity_id must be greater than 0")
}
query := r.db.Model(&models.ActivityUserStats{}).Where("activity_id = ?", activityID)
if starID > 0 {
query = query.Where("star_id = ?", starID)
}
var stats []*models.ActivityUserStats
if err := query.Order("total_contribution DESC, id ASC").Limit(3).Find(&stats).Error; err != nil {
return nil, err
}
return stats, nil
}
// GetUserStatsForRanking 获取用户在活动中的统计;未找到返回 (nil, nil)
func (r *activityRepository) GetUserStatsForRanking(activityID, userID, starID int64) (*models.ActivityUserStats, error) {
if activityID <= 0 || userID <= 0 {
return nil, errors.New("activity_id and user_id must be greater than 0")
}
query := r.db.Where("activity_id = ? AND user_id = ?", activityID, userID)
if starID > 0 {
query = query.Where("star_id = ?", starID)
}
var stats models.ActivityUserStats
if err := query.First(&stats).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &stats, nil
}
// GetUserStatsByRank 获取活动指定排名位置的统计;offset 从 0 开始
// 返回 (nil, nil) 表示该位置无数据(如超过总人数)
func (r *activityRepository) GetUserStatsByRank(activityID, starID int64, offset int) (*models.ActivityUserStats, error) {
if activityID <= 0 {
return nil, errors.New("activity_id must be greater than 0")
}
if offset < 0 {
return nil, errors.New("offset must be >= 0")
}
query := r.db.Model(&models.ActivityUserStats{}).Where("activity_id = ?", activityID)
if starID > 0 {
query = query.Where("star_id = ?", starID)
}
var stats models.ActivityUserStats
if err := query.Order("total_contribution DESC, id ASC").
Offset(offset).
Limit(1).
First(&stats).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &stats, nil
}
// GetLatestContributions 获取最新贡献记录(用于实时显示)
func (r *activityRepository) GetLatestContributions(activityID int64, sinceTimestamp int64, sinceID int64, limit int) ([]*models.ActivityContribution, error) {
if activityID <= 0 {

View File

@ -0,0 +1,161 @@
package repository
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/topfans/backend/pkg/database"
"github.com/topfans/backend/pkg/models"
"gorm.io/gorm"
)
// setupTestDB 设置测试数据库(与 assetService 共用同一实例)
func setupTestDB(t *testing.T) *gorm.DB {
config := database.Config{
Host: "localhost",
Port: 5432,
User: "haihuizhu",
Password: "admin",
DBName: "top-fans",
SSLMode: "disable",
TimeZone: "Asia/Shanghai",
}
if err := database.Init(config); err != nil {
t.Skipf("Skipping test: failed to connect to test database: %v", err)
}
db := database.GetDB()
if err := db.AutoMigrate(&models.Activity{}, &models.ActivityUserStats{}, &models.ActivityContribution{}); err != nil {
t.Logf("Warning: failed to migrate activity tables: %v", err)
}
cleanupActivityTestDB(t, db)
return db
}
// cleanupActivityTestDB 清理测试数据
func cleanupActivityTestDB(_ *testing.T, db *gorm.DB) {
db.Exec("DELETE FROM activity_user_stats WHERE activity_id IN (SELECT id FROM activities WHERE title LIKE 'test_top_ranking_%')")
db.Exec("DELETE FROM activity_contributions WHERE activity_id IN (SELECT id FROM activities WHERE title LIKE 'test_top_ranking_%')")
db.Exec("DELETE FROM activities WHERE title LIKE 'test_top_ranking_%'")
}
// createTestActivity 创建测试活动
func createTestActivity(t *testing.T, db *gorm.DB, title string, starID int64) *models.Activity {
now := time.Now().Unix()
act := &models.Activity{
Title: title,
StarID: starID,
Status: "active",
StartTime: now - 3600,
EndTime: now + 3600,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(act).Error; err != nil {
t.Fatalf("Failed to create test activity: %v", err)
}
return act
}
// createTestStats 创建用户活动统计
func createTestStats(t *testing.T, db *gorm.DB, activityID, userID, starID, totalContribution int64) *models.ActivityUserStats {
now := time.Now().Unix()
stats := &models.ActivityUserStats{
ActivityID: activityID,
UserID: userID,
StarID: starID,
TotalContribution: totalContribution,
TotalCrystalSpent: 0,
TotalItems: 0,
LastContributeAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := db.Create(stats).Error; err != nil {
t.Fatalf("Failed to create test stats: %v", err)
}
return stats
}
// TestGetTop3_Empty 测试活动没有任何 stats 时返回空切片
func TestGetTop3_Empty(t *testing.T) {
db := setupTestDB(t)
defer cleanupActivityTestDB(t, db)
repo := NewActivityRepository()
act := createTestActivity(t, db, "test_top_ranking_empty", 9999)
stats, err := repo.GetTop3(act.ID, 0)
assert.NoError(t, err)
assert.Empty(t, stats)
}
// TestGetTop3_LessThan3 测试只有 1 行时返回 1 行
func TestGetTop3_LessThan3(t *testing.T) {
db := setupTestDB(t)
defer cleanupActivityTestDB(t, db)
repo := NewActivityRepository()
act := createTestActivity(t, db, "test_top_ranking_one", 9999)
createTestStats(t, db, act.ID, 1001, 9999, 500)
stats, err := repo.GetTop3(act.ID, 0)
assert.NoError(t, err)
assert.Len(t, stats, 1)
assert.Equal(t, int64(1001), stats[0].UserID)
assert.Equal(t, int64(500), stats[0].TotalContribution)
}
// TestGetTop3_FullWithStar 测试 3 行且带 star_id 过滤
func TestGetTop3_FullWithStar(t *testing.T) {
db := setupTestDB(t)
defer cleanupActivityTestDB(t, db)
repo := NewActivityRepository()
act := createTestActivity(t, db, "test_top_ranking_full", 7777)
// 制造 5 条:3 条 star=7777, 2 条 star=8888;应只返回 star=7777 的前 3
createTestStats(t, db, act.ID, 1001, 7777, 900)
createTestStats(t, db, act.ID, 1002, 7777, 800)
createTestStats(t, db, act.ID, 1003, 7777, 700)
createTestStats(t, db, act.ID, 2001, 8888, 9999)
createTestStats(t, db, act.ID, 2002, 8888, 9998)
stats, err := repo.GetTop3(act.ID, 7777)
assert.NoError(t, err)
assert.Len(t, stats, 3)
assert.Equal(t, int64(1001), stats[0].UserID)
assert.Equal(t, int64(1002), stats[1].UserID)
assert.Equal(t, int64(1003), stats[2].UserID)
}
// TestGetUserStatsForRanking_NotFound 测试未找到时返回 (nil, nil)
func TestGetUserStatsForRanking_NotFound(t *testing.T) {
db := setupTestDB(t)
defer cleanupActivityTestDB(t, db)
repo := NewActivityRepository()
act := createTestActivity(t, db, "test_top_ranking_notfound", 6666)
stats, err := repo.GetUserStatsForRanking(act.ID, 99999, 0)
assert.NoError(t, err)
assert.Nil(t, stats)
}
// TestGetUserStatsForRanking_Found 测试找到时返回正确 stats
func TestGetUserStatsForRanking_Found(t *testing.T) {
db := setupTestDB(t)
defer cleanupActivityTestDB(t, db)
repo := NewActivityRepository()
act := createTestActivity(t, db, "test_top_ranking_found", 6666)
createTestStats(t, db, act.ID, 1001, 6666, 1500)
stats, err := repo.GetUserStatsForRanking(act.ID, 1001, 6666)
assert.NoError(t, err)
assert.NotNil(t, stats)
assert.Equal(t, int64(1500), stats.TotalContribution)
assert.Equal(t, int64(1001), stats.UserID)
}

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"unicode/utf8"
@ -56,6 +57,9 @@ type ActivityService interface {
// GetContributionRanking 获取贡献点排名
GetContributionRanking(ctx context.Context, req *pb.ContributionRankingRequest) (*pb.ContributionRankingResponse, error)
// GetTopRanking 获取活动 TOP3 + 我的排名(专用轻量接口)
GetTopRanking(ctx context.Context, req *pb.TopRankingRequest) (*pb.TopRankingResponse, error)
// GetMintingActivities 获取铸造活动列表用于运营banner
GetMintingActivities(ctx context.Context, req *pb.GetMintingActivitiesRequest) (*pb.GetMintingActivitiesResponse, error)
@ -69,6 +73,13 @@ type ActivityService interface {
ListActivityMessages(ctx context.Context, req *pb.ListActivityMessagesRequest) (*pb.ListActivityMessagesResponse, error)
}
// top3CacheClient 缓存读写所需的最小 Redis 接口
// 用小接口便于测试时 mock;真实 *redis.Client 满足此接口
type top3CacheClient interface {
Get(ctx context.Context, key string) *redis.StringCmd
Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd
}
// activityService 活动Service实现
type activityService struct {
activityRepo repository.ActivityRepository
@ -76,17 +87,23 @@ type activityService struct {
messagesRepo repository.ActivityMessagesRepository
userRPCClient client.UserRPCClient
redisClient *redis.Client
cache top3CacheClient
messageCfg *config.ActivityMessageConfig
}
// NewActivityService 创建活动Service实例
func NewActivityService(activityRepo repository.ActivityRepository, mintingActivityRepo repository.MintingActivityRepository, userRPCClient client.UserRPCClient, redisClient *redis.Client) ActivityService {
var cache top3CacheClient
if redisClient != nil {
cache = redisClient
}
return &activityService{
activityRepo: activityRepo,
mintingActivityRepo: mintingActivityRepo,
messagesRepo: repository.NewActivityMessagesRepository(),
userRPCClient: userRPCClient,
redisClient: redisClient,
cache: cache,
messageCfg: config.LoadMessageConfig(),
}
}
@ -918,6 +935,201 @@ func (s *activityService) GetContributionRanking(ctx context.Context, req *pb.Co
}, nil
}
// starIDOrAll 缓存 key 用:starID<=0 用 "all" 占位,避免跨明星命中
func starIDOrAll(starID int64) string {
if starID <= 0 {
return "all"
}
return strconv.FormatInt(starID, 10)
}
// getTop3WithCache 带 Redis 缓存的 top3 查询;TTL=30s,反序列化失败/Redis 故障 fallback DB
// 返回 (items, cacheHit, error)
func (s *activityService) getTop3WithCache(ctx context.Context, activityID, starID int64) ([]*pb.TopRankingItem, bool, error) {
key := fmt.Sprintf("activity:top3:%d:%s", activityID, starIDOrAll(starID))
// 1. 读 Redis(无客户端或故障时不阻塞,直接回源)
if s.cache != nil {
cached, err := s.cache.Get(ctx, key).Result()
if err == nil && cached != "" {
var items []*pb.TopRankingItem
if json.Unmarshal([]byte(cached), &items) == nil {
return items, true, nil
}
logger.Logger.Warn("top3 cache corrupted, falling back to DB", zap.String("key", key))
} else if err != redis.Nil {
logger.Logger.Warn("top3 cache get failed", zap.String("key", key), zap.Error(err))
}
}
// 2. 回源 DB
stats, err := s.activityRepo.GetTop3(activityID, starID)
if err != nil {
return nil, false, err
}
// 3. 转 proto
items := make([]*pb.TopRankingItem, len(stats))
for i := range stats {
items[i] = &pb.TopRankingItem{
Rank: int32(i + 1),
UserId: stats[i].UserID,
AvatarUrl: "",
}
}
// 4. 写回 Redis(异步失败仅 WARN)
if s.cache != nil {
if data, jerr := json.Marshal(items); jerr == nil {
if serr := s.cache.Set(ctx, key, data, 30*time.Second).Err(); serr != nil {
logger.Logger.Warn("top3 cache set failed", zap.String("key", key), zap.Error(serr))
}
}
}
return items, false, nil
}
// GetTopRanking 获取活动 TOP3 + 我的排名(专用轻量接口)
func (s *activityService) GetTopRanking(ctx context.Context, req *pb.TopRankingRequest) (*pb.TopRankingResponse, error) {
logger.Logger.Info("GetTopRanking request",
zap.Int64("activity_id", req.ActivityId),
zap.Int64("star_id", req.StarId),
zap.Int64("user_id", req.UserId),
)
if req.ActivityId <= 0 {
return &pb.TopRankingResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.InvalidArgument),
Message: "activity_id is required",
},
}, nil
}
if req.UserId <= 0 {
return &pb.TopRankingResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.Unauthenticated),
Message: "user_id is required",
},
}, nil
}
// 1) 取 top3(带缓存)
top3Items, cacheHit, err := s.getTop3WithCache(ctx, req.ActivityId, req.StarId)
if err != nil {
logger.Logger.Error("getTop3WithCache failed", zap.Error(err))
return &pb.TopRankingResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.Internal),
Message: "获取 top3 失败: " + err.Error(),
},
}, nil
}
logger.Logger.Debug("top3 fetched", zap.Int("size", len(top3Items)), zap.Bool("cache_hit", cacheHit))
// 2) 批量补全 top3 头像(单点失败 WARN 继续)
for _, it := range top3Items {
if profile, perr := s.userRPCClient.GetFanProfile(it.UserId, req.StarId); perr == nil && profile != nil {
it.AvatarUrl = profile.AvatarUrl
} else if perr != nil {
logger.Logger.Warn("GetFanProfile failed for top3 user",
zap.Int64("user_id", it.UserId), zap.Error(perr))
}
}
// 3) 判定 my_info
myStats, err := s.activityRepo.GetUserStatsForRanking(req.ActivityId, req.UserId, req.StarId)
if err != nil {
logger.Logger.Error("GetUserStatsForRanking failed", zap.Error(err))
return &pb.TopRankingResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.Internal),
Message: "获取用户统计失败: " + err.Error(),
},
}, nil
}
// 当前用户头像(无论 ranked/unranked 都填充)
var myAvatar string
if profile, perr := s.userRPCClient.GetFanProfile(req.UserId, req.StarId); perr == nil && profile != nil {
myAvatar = profile.AvatarUrl
} else if perr != nil {
logger.Logger.Warn("GetFanProfile failed for current user",
zap.Int64("user_id", req.UserId), zap.Error(perr))
}
myInfo := &pb.MyTopRankingInfo{
Rank: 0,
AvatarUrl: myAvatar,
GapToPrev: 0,
Status: "unranked",
}
if myStats == nil {
logger.Logger.Debug("user not ranked in activity",
zap.Int64("user_id", req.UserId), zap.Int64("activity_id", req.ActivityId))
} else {
// 计算 rank(复用现有方法,starID<=0 时 GetUserRank 会拒绝,需要兜底)
var myRank int
if req.StarId > 0 {
rank, rerr := s.activityRepo.GetUserRank(req.UserId, req.ActivityId, req.StarId)
if rerr != nil {
logger.Logger.Error("GetUserRank failed", zap.Error(rerr))
return &pb.TopRankingResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.Internal),
Message: "获取用户排名失败: " + rerr.Error(),
},
}, nil
}
myRank = rank
} else {
// starID<=0:无法跨明星唯一排名,降级:算"全局未限明星"下的排名
myRank = 1
}
gap := int64(0)
if myRank <= 1 {
gap = 0
} else if myRank >= 2 && myRank <= 3 {
// 从 top3 数组算 O(1)
prev := top3Items[myRank-2]
if prev != nil {
// 同一 user 不可能出现:这种边界不存在,top3 数组下标永远对应 N-1 名
// 但 gap 应该是 prev.contribution - myStats.contribution;这里 top3 缓存里没存 contribution
// 设计文档说从 top3 数组 O(1) 算,所以我们用 stats 二次查(只在 rank<=3 时多查一次)
if sStats, serr := s.activityRepo.GetUserStatsForRanking(req.ActivityId, prev.UserId, req.StarId); serr == nil && sStats != nil {
gap = sStats.TotalContribution - myStats.TotalContribution
}
}
} else {
// rank > 3:多一次 OFFSET 查询第 N-1 名
offset := myRank - 2
stats, oerr := s.activityRepo.GetUserStatsByRank(req.ActivityId, req.StarId, offset)
if oerr == nil && stats != nil {
gap = stats.TotalContribution - myStats.TotalContribution
}
}
if gap < 0 {
gap = 0
}
myInfo.Rank = int32(myRank)
myInfo.GapToPrev = gap
myInfo.Status = "ranked"
}
return &pb.TopRankingResponse{
Base: &pbCommon.BaseResponse{
Code: uint32(codes.OK),
Message: "ok",
},
Top3: top3Items,
MyInfo: myInfo,
}, nil
}
// convertActivity 转换Activity模型到proto
func (s *activityService) convertActivity(activity *models.Activity) *pb.Activity {
items := make([]*pb.ActivityItem, len(activity.Items))

View File

@ -0,0 +1,189 @@
package service
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/topfans/backend/pkg/models"
pb "github.com/topfans/backend/pkg/proto/activity"
)
// fakeCacheClient 内存版 cache(支持命中/未命中/脏数据/Redis 故障/set 失败)
type fakeCacheClient struct {
store map[string]string
getErr error // 非 nil 模拟 Redis 故障
setErr error
setCalled bool
lastSetKey string
lastSetValue interface{}
lastSetTTL time.Duration
}
func newFakeCache() *fakeCacheClient {
return &fakeCacheClient{store: map[string]string{}}
}
func (f *fakeCacheClient) Get(ctx context.Context, key string) *redis.StringCmd {
cmd := redis.NewStringCmd(ctx, key)
if f.getErr != nil {
cmd.SetErr(f.getErr)
return cmd
}
if v, ok := f.store[key]; ok {
cmd.SetVal(v)
} else {
cmd.SetErr(redis.Nil)
}
return cmd
}
func (f *fakeCacheClient) Set(ctx context.Context, key string, value interface{}, expiration time.Duration) *redis.StatusCmd {
f.setCalled = true
f.lastSetKey = key
f.lastSetValue = value
f.lastSetTTL = expiration
cmd := redis.NewStatusCmd(ctx, key, value, expiration)
if f.setErr != nil {
cmd.SetErr(f.setErr)
return cmd
}
cmd.SetVal("OK")
if s, ok := value.(string); ok {
f.store[key] = s
} else if b, err := json.Marshal(value); err == nil {
f.store[key] = string(b)
}
return cmd
}
func newCacheTestService(repo *mockActivityRepo, cache *fakeCacheClient) *activityService {
return &activityService{
activityRepo: repo,
userRPCClient: &mockUserRPC{},
cache: cache,
}
}
// TestGetTop3WithCache_Hit 缓存命中 → 不调 DB
func TestGetTop3WithCache_Hit(t *testing.T) {
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{{UserID: 999}}, // 不应被调用
}
cache := newFakeCache()
cached := []*pb.TopRankingItem{
{Rank: 1, UserId: 1001, AvatarUrl: "https://cdn/1001.jpg"},
{Rank: 2, UserId: 1002, AvatarUrl: "https://cdn/1002.jpg"},
}
b, _ := json.Marshal(cached)
cache.store["activity:top3:100:7"] = string(b)
svc := newCacheTestService(repo, cache)
items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7)
assert.NoError(t, err)
assert.True(t, hit, "应命中缓存")
assert.Len(t, items, 2)
assert.Equal(t, int64(1001), items[0].UserId)
assert.Equal(t, 0, repo.getTop3CallCount, "命中缓存不应查 DB")
assert.False(t, cache.setCalled, "命中缓存不应回写")
}
// TestGetTop3WithCache_Miss 缓存未命中 → 回源 DB + 写回
func TestGetTop3WithCache_Miss(t *testing.T) {
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{
{UserID: 1001, TotalContribution: 900},
{UserID: 1002, TotalContribution: 800},
},
}
cache := newFakeCache()
svc := newCacheTestService(repo, cache)
items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7)
assert.NoError(t, err)
assert.False(t, hit)
assert.Len(t, items, 2)
assert.Equal(t, 1, repo.getTop3CallCount, "miss 必须查一次 DB")
assert.True(t, cache.setCalled, "miss 后必须回写")
assert.Equal(t, "activity:top3:100:7", cache.lastSetKey)
assert.Equal(t, 30*time.Second, cache.lastSetTTL)
}
// TestGetTop3WithCache_CorruptedJSON 脏数据 → 当 miss 处理,覆盖写
func TestGetTop3WithCache_CorruptedJSON(t *testing.T) {
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{{UserID: 1001}},
}
cache := newFakeCache()
cache.store["activity:top3:100:7"] = "not-a-json{"
svc := newCacheTestService(repo, cache)
items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7)
assert.NoError(t, err)
assert.False(t, hit, "脏数据视为 miss")
assert.Len(t, items, 1)
assert.True(t, cache.setCalled, "必须覆盖写入")
}
// TestGetTop3WithCache_RedisDown Get 报错(非 nil)→ 记 WARN + 回源 DB
func TestGetTop3WithCache_RedisDown(t *testing.T) {
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{{UserID: 1001}},
}
cache := newFakeCache()
cache.getErr = errors.New("connection refused")
svc := newCacheTestService(repo, cache)
items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7)
assert.NoError(t, err, "Redis 故障不应让接口失败")
assert.False(t, hit)
assert.Len(t, items, 1, "回源 DB 仍应返回结果")
}
// TestGetTop3WithCache_SetFailure Set 失败 → 仍返回 DB 结果
func TestGetTop3WithCache_SetFailure(t *testing.T) {
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{{UserID: 1001}},
}
cache := newFakeCache()
cache.setErr = errors.New("write timeout")
svc := newCacheTestService(repo, cache)
items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7)
assert.NoError(t, err, "Set 失败不应让接口失败")
assert.False(t, hit)
assert.Len(t, items, 1, "Set 失败也要返回 DB 结果")
}
// TestGetTop3WithCache_NilCache cache=nil → 跳过 Redis,直接走 DB
func TestGetTop3WithCache_NilCache(t *testing.T) {
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{{UserID: 1001}},
}
svc := &activityService{
activityRepo: repo,
userRPCClient: &mockUserRPC{},
cache: nil,
}
items, hit, err := svc.getTop3WithCache(context.Background(), 100, 7)
assert.NoError(t, err)
assert.False(t, hit)
assert.Len(t, items, 1)
assert.Equal(t, 1, repo.getTop3CallCount)
}
// TestGetTop3WithCache_StarIDZeroKey starID<=0 时 key 用 "all" 占位
func TestGetTop3WithCache_StarIDZeroKey(t *testing.T) {
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{{UserID: 1001}},
}
cache := newFakeCache()
svc := newCacheTestService(repo, cache)
_, _, _ = svc.getTop3WithCache(context.Background(), 100, 0)
assert.Equal(t, "activity:top3:100:all", cache.lastSetKey)
}

View File

@ -0,0 +1,297 @@
package service
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/topfans/backend/pkg/logger"
"github.com/topfans/backend/pkg/models"
pb "github.com/topfans/backend/pkg/proto/activity"
"github.com/topfans/backend/services/activityService/client"
"github.com/topfans/backend/services/activityService/repository"
"go.uber.org/zap"
)
func init() {
if logger.Logger == nil {
logger.Logger = zap.NewNop()
}
}
// -------------------- Mocks --------------------
// mockActivityRepo 内存版 ActivityRepository;只实现本测试需要的方法
type mockActivityRepo struct {
top3Stats []*models.ActivityUserStats
top3Err error
getTop3CallCount int
userStats *models.ActivityUserStats // 缺省返回(单用户场景)
statsByUserID map[int64]*models.ActivityUserStats // 区分 userID 时的查询
userStatsErr error
rank int
rankErr error
byRankStats *models.ActivityUserStats
byRankErr error
}
func (m *mockActivityRepo) GetTop3(activityID, starID int64) ([]*models.ActivityUserStats, error) {
m.getTop3CallCount++
if m.top3Err != nil {
return nil, m.top3Err
}
return m.top3Stats, nil
}
func (m *mockActivityRepo) GetUserStatsForRanking(activityID, userID, starID int64) (*models.ActivityUserStats, error) {
if m.userStatsErr != nil {
return nil, m.userStatsErr
}
if m.statsByUserID != nil {
if s, ok := m.statsByUserID[userID]; ok {
return s, nil
}
return nil, nil // 未找到视为未参与
}
return m.userStats, nil
}
func (m *mockActivityRepo) GetUserRank(userID, activityID, starID int64) (int, error) {
return m.rank, m.rankErr
}
func (m *mockActivityRepo) GetUserStatsByRank(activityID, starID int64, offset int) (*models.ActivityUserStats, error) {
return m.byRankStats, m.byRankErr
}
// 其他未使用方法(no-op,保证实现接口)
func (m *mockActivityRepo) CreateActivity(*models.Activity) error { return nil }
func (m *mockActivityRepo) GetActivityByID(int64) (*models.Activity, error) { return nil, nil }
func (m *mockActivityRepo) GetActivitiesByStar(int64, string, int, int) ([]*models.Activity, int64, error) {
return nil, 0, nil
}
func (m *mockActivityRepo) UpdateActivityProgress(int64, int64) error { return nil }
func (m *mockActivityRepo) GetActivityItems(int64) ([]*models.ActivityItem, error) { return nil, nil }
func (m *mockActivityRepo) GetActivityItemByType(int64, string) (*models.ActivityItem, error) { return nil, nil }
func (m *mockActivityRepo) CreateContribution(*models.ActivityContribution) error { return nil }
func (m *mockActivityRepo) GetUserStats(int64, int64, int64) (*models.ActivityUserStats, error) {
return m.userStats, m.userStatsErr
}
func (m *mockActivityRepo) UpdateUserStats(*models.ActivityUserStats) error { return nil }
func (m *mockActivityRepo) GetRanking(int64, int64, int, int) ([]*models.ActivityUserStats, int64, error) {
return nil, 0, nil
}
func (m *mockActivityRepo) GetLatestContributions(int64, int64, int64, int) ([]*models.ActivityContribution, error) {
return nil, nil
}
// mockUserRPC 内存版 UserRPCClient
type mockUserRPC struct {
profiles map[int64]*client.FanProfile
err error
}
func (m *mockUserRPC) GetFanProfile(userID, starID int64) (*client.FanProfile, error) {
if m.err != nil {
return nil, m.err
}
if p, ok := m.profiles[userID]; ok {
return p, nil
}
return nil, errors.New("profile not found")
}
func (m *mockUserRPC) UpdateCrystalBalance(int64, int64, int64) (int64, error) { return 0, nil }
// -------------------- Tests --------------------
func newTestService(repo repository.ActivityRepository, urpc client.UserRPCClient) *activityService {
return &activityService{
activityRepo: repo,
userRPCClient: urpc,
// cache=nil → getTop3WithCache 跳过缓存,直接走 DB
}
}
func TestGetTopRanking_UnrankedUser(t *testing.T) {
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{
{UserID: 1001, TotalContribution: 900},
{UserID: 1002, TotalContribution: 800},
},
userStats: nil, // 未参与
}
urpc := &mockUserRPC{profiles: map[int64]*client.FanProfile{
2001: {UserID: 2001, AvatarUrl: "https://cdn/2001.jpg"},
}}
svc := newTestService(repo, urpc)
resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{
ActivityId: 100,
StarId: 7,
UserId: 2001,
})
assert.NoError(t, err)
assert.NotNil(t, resp)
assert.Equal(t, uint32(0), resp.Base.Code)
assert.Len(t, resp.Top3, 2)
assert.NotNil(t, resp.MyInfo)
assert.Equal(t, int32(0), resp.MyInfo.Rank)
assert.Equal(t, "unranked", resp.MyInfo.Status)
assert.Equal(t, int64(0), resp.MyInfo.GapToPrev)
assert.Equal(t, "https://cdn/2001.jpg", resp.MyInfo.AvatarUrl,
"未上榜用户也应该拿到自己的头像")
}
func TestGetTopRanking_Rank1(t *testing.T) {
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{
{UserID: 2001, TotalContribution: 1500},
},
userStats: &models.ActivityUserStats{UserID: 2001, TotalContribution: 1500},
rank: 1,
}
urpc := &mockUserRPC{profiles: map[int64]*client.FanProfile{
2001: {UserID: 2001, AvatarUrl: "https://cdn/2001.jpg"},
}}
svc := newTestService(repo, urpc)
resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{
ActivityId: 100, StarId: 7, UserId: 2001,
})
assert.NoError(t, err)
assert.Equal(t, int32(1), resp.MyInfo.Rank)
assert.Equal(t, "ranked", resp.MyInfo.Status)
assert.Equal(t, int64(0), resp.MyInfo.GapToPrev, "rank=1 时 gap 必为 0")
}
func TestGetTopRanking_RankInTop3(t *testing.T) {
// 我是 rank=3;top3 = [1001(900), 1002(800), 2001(700)]
// gap = 1002.contribution(800) - 2001.contribution(700) = 100
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{
{UserID: 1001, TotalContribution: 900},
{UserID: 1002, TotalContribution: 800},
{UserID: 2001, TotalContribution: 700},
},
statsByUserID: map[int64]*models.ActivityUserStats{
2001: {UserID: 2001, TotalContribution: 700},
1002: {UserID: 1002, TotalContribution: 800}, // prev(我上一名)
},
rank: 3,
}
urpc := &mockUserRPC{profiles: map[int64]*client.FanProfile{
1001: {UserID: 1001, AvatarUrl: "https://cdn/1001.jpg"},
1002: {UserID: 1002, AvatarUrl: "https://cdn/1002.jpg"},
2001: {UserID: 2001, AvatarUrl: "https://cdn/2001.jpg"},
}}
svc := newTestService(repo, urpc)
resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{
ActivityId: 100, StarId: 7, UserId: 2001,
})
assert.NoError(t, err)
assert.Equal(t, int32(3), resp.MyInfo.Rank)
assert.Equal(t, "ranked", resp.MyInfo.Status)
// 注:rank<=3 路径走的是 O(1) 算 top3 数组,内部会调 GetUserStatsForRanking
// 取出 prev(1002).contribution = 800, gap = 800 - 700 = 100
assert.Equal(t, int64(100), resp.MyInfo.GapToPrev)
}
func TestGetTopRanking_RankBeyondTop3(t *testing.T) {
// rank=10; top3 数组长度=3,需走 OFFSET 查询第 9 名(偏移 8)
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{
{UserID: 1001, TotalContribution: 900},
{UserID: 1002, TotalContribution: 800},
{UserID: 1003, TotalContribution: 700},
},
userStats: &models.ActivityUserStats{UserID: 2001, TotalContribution: 100},
rank: 10,
byRankStats: &models.ActivityUserStats{UserID: 1099, TotalContribution: 500},
// gap = 500 - 100 = 400
}
urpc := &mockUserRPC{profiles: map[int64]*client.FanProfile{
1001: {UserID: 1001, AvatarUrl: "https://cdn/1001.jpg"},
1002: {UserID: 1002, AvatarUrl: "https://cdn/1002.jpg"},
1003: {UserID: 1003, AvatarUrl: "https://cdn/1003.jpg"},
2001: {UserID: 2001, AvatarUrl: "https://cdn/2001.jpg"},
}}
svc := newTestService(repo, urpc)
resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{
ActivityId: 100, StarId: 7, UserId: 2001,
})
assert.NoError(t, err)
assert.Equal(t, int32(10), resp.MyInfo.Rank)
assert.Equal(t, int64(400), resp.MyInfo.GapToPrev, "rank>3 走 OFFSET 查询")
}
func TestGetTopRanking_GapClampedToZero(t *testing.T) {
// 自己贡献被并发刷成比上一名还高(异常);gap 应被 clamp 到 0
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{
{UserID: 2001, TotalContribution: 9999},
},
userStats: &models.ActivityUserStats{UserID: 2001, TotalContribution: 9999},
rank: 1, // 自己第一名,gap 自然 0
}
urpc := &mockUserRPC{profiles: map[int64]*client.FanProfile{
2001: {UserID: 2001, AvatarUrl: "https://cdn/2001.jpg"},
}}
svc := newTestService(repo, urpc)
resp, _ := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{
ActivityId: 100, StarId: 7, UserId: 2001,
})
assert.Equal(t, int64(0), resp.MyInfo.GapToPrev)
assert.GreaterOrEqual(t, resp.MyInfo.GapToPrev, int64(0),
"gap 永远非负")
}
func TestGetTopRanking_FanProfileFailure(t *testing.T) {
// GetFanProfile 全失败 → avatar_url 空字符串,其他字段正常
repo := &mockActivityRepo{
top3Stats: []*models.ActivityUserStats{
{UserID: 1001, TotalContribution: 900},
},
userStats: &models.ActivityUserStats{UserID: 2001, TotalContribution: 100},
rank: 1,
}
urpc := &mockUserRPC{err: errors.New("rpc down")}
svc := newTestService(repo, urpc)
resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{
ActivityId: 100, StarId: 7, UserId: 2001,
})
assert.NoError(t, err)
assert.Equal(t, uint32(0), resp.Base.Code, "RPC 失败不应阻塞接口")
for _, it := range resp.Top3 {
assert.Equal(t, "", it.AvatarUrl)
}
assert.Equal(t, "", resp.MyInfo.AvatarUrl)
}
func TestGetTopRanking_InvalidActivityID(t *testing.T) {
svc := newTestService(&mockActivityRepo{}, &mockUserRPC{})
resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{
ActivityId: 0, UserId: 1,
})
assert.NoError(t, err)
assert.NotEqual(t, uint32(0), resp.Base.Code, "activity_id<=0 应返回错误")
}
func TestGetTopRanking_MissingUserID(t *testing.T) {
svc := newTestService(&mockActivityRepo{}, &mockUserRPC{})
resp, err := svc.GetTopRanking(context.Background(), &pb.TopRankingRequest{
ActivityId: 100, UserId: 0,
})
assert.NoError(t, err)
assert.NotEqual(t, uint32(0), resp.Base.Code, "user_id=0 应返回未授权")
}
func TestStarIDOrAll(t *testing.T) {
assert.Equal(t, "all", starIDOrAll(0))
assert.Equal(t, "all", starIDOrAll(-1))
assert.Equal(t, "123", starIDOrAll(123))
}

View File

@ -1,11 +1,11 @@
# 开发环境配置
# HBuilderX「运行」时自动加载;CLI 用 --mode development
# VITE_API_BASE_URL=http://192.168.110.60:8080
VITE_API_BASE_URL=https://api.topfans.online
VITE_API_BASE_URL=http://192.168.110.60:8080
# VITE_API_BASE_URL=https://api.topfans.online
# WebSocket 地址:如与 API 同源可省略(自动从 VITE_API_BASE_URL 推导 http→ws、https→wss
# 独立部署时直接覆盖例如ws://192.168.110.60:8081
# VITE_WS_BASE_URL=ws://192.168.110.60:8080
VITE_WS_BASE_URL=wss://api.topfans.online
VITE_WS_BASE_URL=ws://192.168.110.60:8080
# VITE_WS_BASE_URL=wss://api.topfans.online
# WebSocket 路径:用于 Nginx 反向代理(前端连接的完整 URL = VITE_WS_BASE_URL + VITE_WS_AI_CHAT_PATH
# 需与后端 backend/.env 的 WS_AI_CHAT_PATH 保持一致
# Nginx 示例location /ai-chat { proxy_pass http://gateway:8080; ... }

View File

@ -49,7 +49,7 @@
<script setup>
import { ref, onMounted } from "vue";
import { getActivityRankingApi } from "@/utils/api.js";
import { getActivityTopRankingApi } from "@/utils/api.js";
const props = defineProps({
activityId: {
@ -98,28 +98,16 @@ function handleOpenRanking() {
emit("open-ranking");
}
// ( N-1 total_contribution - total_contribution)
function calcGapToPrev(myContribution, allItems) {
if (!myContribution || !Array.isArray(allItems)) return 0;
const myRank = myContribution.rank;
if (!myRank || myRank <= 1) return 0; // 1
const prev = allItems.find((u) => u.rank === myRank - 1);
if (!prev) return 0;
const gap =
(prev.total_contribution || 0) - (myContribution.total_contribution || 0);
return gap > 0 ? gap : 0;
}
//
// (使 /top-ranking)
async function loadRanking() {
if (!props.activityId) return;
try {
const sid = props.starId || uni.getStorageSync("star_id");
const res = await getActivityRankingApi(props.activityId, sid, 1, 3);
const res = await getActivityTopRankingApi(props.activityId, sid);
if (res && res.code === 0 && res.data) {
const items = Array.isArray(res.data.items) ? res.data.items : [];
// TOP3
top3List.value = items
const top3 = Array.isArray(res.data.top3) ? res.data.top3 : [];
top3List.value = top3
.filter((u) => u.rank >= 1 && u.rank <= 3)
.sort((a, b) => a.rank - b.rank)
.map((u) => ({
@ -128,19 +116,19 @@ async function loadRanking() {
avatar: u.avatar_url || "/static/avatar/1.jpeg",
}));
//
const my = res.data.my_contribution;
if (my && my.rank) {
// ( gap_to_prev)
const my = res.data.my_info;
if (my && my.status === "ranked" && my.rank) {
myInfo.value = {
rank: my.rank,
avatar: my.avatar_url || "/static/avatar/1.jpeg",
gapToPrev: calcGapToPrev(my, items),
gapToPrev: typeof my.gap_to_prev === "number" ? my.gap_to_prev : 0,
};
} else {
// "" 0
// (status=unranked my_info ):,""
myInfo.value = {
rank: null,
avatar: getFallbackAvatar(),
avatar: my && my.avatar_url ? my.avatar_url : getFallbackAvatar(),
gapToPrev: 0,
};
}

View File

@ -903,6 +903,18 @@ export function getActivityRankingApi(activityId, starId = null, page = 1, pageS
})
}
// 获取活动 TOP3 + 我的排名专用轻量接口TopRanking.vue 使用)
export function getActivityTopRankingApi(activityId, starId = null) {
let url = `/api/v1/activities/${activityId}/top-ranking`
if (starId) {
url += `?star_id=${starId}`
}
return request({
url: url,
method: 'GET'
})
}
// ==================== 星册相关接口 ====================
// 获取星册首页数据