305 lines
11 KiB
Go
305 lines
11 KiB
Go
package activitytemplate
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"hyapp-admin-server/internal/appctx"
|
||
"hyapp-admin-server/internal/integration/statisticsclient"
|
||
"hyapp-admin-server/internal/integration/userclient"
|
||
"hyapp-admin-server/internal/middleware"
|
||
"hyapp-admin-server/internal/modules/shared"
|
||
"hyapp-admin-server/internal/response"
|
||
activityv1 "hyapp.local/api/proto/activity/v1"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
const maxActivityTemplateDataRange = 366 * 24 * time.Hour
|
||
|
||
func (h *Handler) Leaderboard(c *gin.Context) {
|
||
options := shared.ListOptions(c)
|
||
versionNo, ok := positiveInt64Query(c, "version_no", true)
|
||
if !ok {
|
||
response.BadRequest(c, "version_no 参数不正确")
|
||
return
|
||
}
|
||
boardType := strings.ToLower(strings.TrimSpace(c.Query("board_type")))
|
||
periodKey := strings.TrimSpace(c.Query("period_key"))
|
||
if boardType != "daily" && boardType != "total" {
|
||
response.BadRequest(c, "board_type 参数不正确")
|
||
return
|
||
}
|
||
if boardType == "daily" {
|
||
if parsed, err := time.Parse("2006-01-02", periodKey); err != nil || parsed.Format("2006-01-02") != periodKey {
|
||
response.BadRequest(c, "daily 榜单必须提供 YYYY-MM-DD 格式的 period_key")
|
||
return
|
||
}
|
||
} else {
|
||
periodKey = "total"
|
||
}
|
||
resp, err := h.activity.ListActivityTemplateLeaderboard(c.Request.Context(), &activityv1.AdminListActivityTemplateLeaderboardRequest{
|
||
Meta: h.meta(c), TemplateId: strings.TrimSpace(c.Param("template_id")), VersionNo: versionNo,
|
||
BoardType: boardType, PeriodKey: periodKey, Page: int32(options.Page), PageSize: int32(options.PageSize),
|
||
})
|
||
if err != nil {
|
||
h.writeGRPCError(c, err, "获取活动模版榜单失败")
|
||
return
|
||
}
|
||
entries := make([]leaderboardEntryDTO, 0, len(resp.GetEntries()))
|
||
users := h.activityTemplateUsers(c, leaderboardProtoUserIDs(resp.GetEntries()))
|
||
for _, item := range resp.GetEntries() {
|
||
entries = append(entries, leaderboardEntryFromProto(item, users))
|
||
}
|
||
response.OK(c, leaderboardPageDTO{
|
||
Period: rankPeriodFromProto(resp.GetPeriod()), Items: entries,
|
||
Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal(),
|
||
})
|
||
}
|
||
|
||
func (h *Handler) RewardJobs(c *gin.Context) {
|
||
options := shared.ListOptions(c)
|
||
versionNo, ok := positiveInt64Query(c, "version_no", true)
|
||
if !ok {
|
||
response.BadRequest(c, "version_no 参数不正确")
|
||
return
|
||
}
|
||
boardType := strings.ToLower(strings.TrimSpace(c.Query("board_type")))
|
||
if boardType != "" && boardType != "daily" && boardType != "total" {
|
||
response.BadRequest(c, "board_type 参数不正确")
|
||
return
|
||
}
|
||
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||
if status != "" && status != "pending" && status != "running" && status != "granted" && status != "failed" && status != "dead" {
|
||
response.BadRequest(c, "status 参数不正确")
|
||
return
|
||
}
|
||
periodKey := strings.TrimSpace(c.Query("period_key"))
|
||
if boardType == "total" && periodKey != "" {
|
||
periodKey = "total"
|
||
}
|
||
resp, err := h.activity.ListActivityTemplateRewardJobs(c.Request.Context(), &activityv1.ListActivityTemplateRewardJobsRequest{
|
||
Meta: h.meta(c), TemplateId: strings.TrimSpace(c.Param("template_id")), VersionNo: versionNo,
|
||
BoardType: boardType, PeriodKey: periodKey, Status: status, Page: int32(options.Page), PageSize: int32(options.PageSize),
|
||
})
|
||
if err != nil {
|
||
h.writeGRPCError(c, err, "获取活动模版奖励发放记录失败")
|
||
return
|
||
}
|
||
items := make([]rewardJobDTO, 0, len(resp.GetJobs()))
|
||
users := h.activityTemplateUsers(c, rewardJobProtoUserIDs(resp.GetJobs()))
|
||
for _, item := range resp.GetJobs() {
|
||
items = append(items, rewardJobFromProto(item, users))
|
||
}
|
||
response.OK(c, pageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||
}
|
||
|
||
func (h *Handler) RetryRewardJob(c *gin.Context) {
|
||
templateID := strings.TrimSpace(c.Param("template_id"))
|
||
jobID := strings.TrimSpace(c.Param("reward_job_id"))
|
||
if templateID == "" || jobID == "" {
|
||
response.BadRequest(c, "活动模版或奖励任务 ID 不正确")
|
||
return
|
||
}
|
||
resp, err := h.activity.RetryActivityTemplateRewardJob(c.Request.Context(), &activityv1.RetryActivityTemplateRewardJobRequest{
|
||
Meta: h.meta(c), TemplateId: templateID, RewardJobId: jobID, OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||
})
|
||
if err != nil {
|
||
h.writeGRPCError(c, err, "重试活动模版奖励发放失败")
|
||
return
|
||
}
|
||
users := h.activityTemplateUsers(c, []int64{resp.GetJob().GetUserId()})
|
||
item := rewardJobFromProto(resp.GetJob(), users)
|
||
shared.OperationLogWithResourceID(c, h.audit, "retry-activity-template-reward", "activity_template_reward_jobs", jobID, "success", "")
|
||
response.OK(c, item)
|
||
}
|
||
|
||
func (h *Handler) TaskClaims(c *gin.Context) {
|
||
options := shared.ListOptions(c)
|
||
versionNo, versionOK := positiveInt64Query(c, "version_no", false)
|
||
userID, userOK := positiveInt64Query(c, "user_id", false)
|
||
if !versionOK || !userOK {
|
||
response.BadRequest(c, "version_no 或 user_id 参数不正确")
|
||
return
|
||
}
|
||
taskDay := strings.TrimSpace(c.Query("task_day"))
|
||
if taskDay != "" {
|
||
parsed, err := time.Parse("2006-01-02", taskDay)
|
||
if err != nil || parsed.Format("2006-01-02") != taskDay {
|
||
response.BadRequest(c, "task_day 必须为 YYYY-MM-DD")
|
||
return
|
||
}
|
||
}
|
||
status := strings.ToLower(strings.TrimSpace(c.Query("status")))
|
||
if status != "" && status != "pending" && status != "running" && status != "granted" && status != "failed" {
|
||
response.BadRequest(c, "status 参数不正确")
|
||
return
|
||
}
|
||
resp, err := h.activity.ListActivityTemplateTaskClaims(c.Request.Context(), &activityv1.ListActivityTemplateTaskClaimsRequest{
|
||
Meta: h.meta(c), TemplateId: strings.TrimSpace(c.Param("template_id")), VersionNo: versionNo,
|
||
TaskDay: taskDay, TaskKey: strings.TrimSpace(c.Query("task_key")), UserId: userID, Status: status,
|
||
Page: int32(options.Page), PageSize: int32(options.PageSize),
|
||
})
|
||
if err != nil {
|
||
h.writeGRPCError(c, err, "获取活动模版任务领奖记录失败")
|
||
return
|
||
}
|
||
items := make([]taskClaimDTO, 0, len(resp.GetClaims()))
|
||
users := h.activityTemplateUsers(c, taskClaimProtoUserIDs(resp.GetClaims()))
|
||
for _, item := range resp.GetClaims() {
|
||
items = append(items, taskClaimFromProto(item, users))
|
||
}
|
||
response.OK(c, pageDTO{Items: items, Page: options.Page, PageSize: options.PageSize, Total: resp.GetTotal()})
|
||
}
|
||
|
||
func (h *Handler) RetryTaskClaim(c *gin.Context) {
|
||
templateID := strings.TrimSpace(c.Param("template_id"))
|
||
claimID := strings.TrimSpace(c.Param("claim_id"))
|
||
if templateID == "" || claimID == "" {
|
||
response.BadRequest(c, "活动模版或任务领奖记录 ID 不正确")
|
||
return
|
||
}
|
||
resp, err := h.activity.RetryActivityTemplateTaskClaim(c.Request.Context(), &activityv1.RetryActivityTemplateTaskClaimRequest{
|
||
Meta: h.meta(c), TemplateId: templateID, ClaimId: claimID, OperatorAdminId: int64(middleware.CurrentUserID(c)),
|
||
})
|
||
if err != nil {
|
||
h.writeGRPCError(c, err, "重试活动模版任务奖励发放失败")
|
||
return
|
||
}
|
||
users := h.activityTemplateUsers(c, []int64{resp.GetClaim().GetUserId()})
|
||
item := taskClaimFromProto(resp.GetClaim(), users)
|
||
shared.OperationLogWithResourceID(c, h.audit, "retry-activity-template-task-claim", "activity_template_task_claims", claimID, "success", "")
|
||
response.OK(c, item)
|
||
}
|
||
|
||
func (h *Handler) activityTemplateUsers(c *gin.Context, userIDs []int64) map[int64]*userclient.User {
|
||
if h.users == nil || len(userIDs) == 0 {
|
||
return nil
|
||
}
|
||
unique := make([]int64, 0, len(userIDs))
|
||
seen := make(map[int64]struct{}, len(userIDs))
|
||
for _, userID := range userIDs {
|
||
if userID <= 0 {
|
||
continue
|
||
}
|
||
if _, exists := seen[userID]; exists {
|
||
continue
|
||
}
|
||
seen[userID] = struct{}{}
|
||
unique = append(unique, userID)
|
||
}
|
||
if len(unique) == 0 {
|
||
return nil
|
||
}
|
||
users, err := h.users.BatchGetUsers(c.Request.Context(), userclient.BatchGetUsersRequest{
|
||
RequestID: middleware.CurrentRequestID(c), Caller: "admin-server", UserIDs: unique,
|
||
})
|
||
if err != nil {
|
||
// 用户资料是展示装饰,榜单和发奖事实仍必须返回;不能因 user-service 短暂不可用隐藏结算结果。
|
||
return nil
|
||
}
|
||
return users
|
||
}
|
||
|
||
func leaderboardProtoUserIDs(items []*activityv1.ActivityTemplateLeaderboardEntry) []int64 {
|
||
userIDs := make([]int64, 0, len(items))
|
||
for _, item := range items {
|
||
if item != nil {
|
||
userIDs = append(userIDs, item.GetUserId())
|
||
}
|
||
}
|
||
return userIDs
|
||
}
|
||
|
||
func rewardJobProtoUserIDs(items []*activityv1.ActivityTemplateRewardJob) []int64 {
|
||
userIDs := make([]int64, 0, len(items))
|
||
for _, item := range items {
|
||
if item != nil {
|
||
userIDs = append(userIDs, item.GetUserId())
|
||
}
|
||
}
|
||
return userIDs
|
||
}
|
||
|
||
func taskClaimProtoUserIDs(items []*activityv1.ActivityTemplateTaskClaim) []int64 {
|
||
userIDs := make([]int64, 0, len(items))
|
||
for _, item := range items {
|
||
if item != nil {
|
||
userIDs = append(userIDs, item.GetUserId())
|
||
}
|
||
}
|
||
return userIDs
|
||
}
|
||
|
||
func (h *Handler) Data(c *gin.Context) {
|
||
if h.stats == nil {
|
||
response.ServerError(c, "活动模版统计服务未配置")
|
||
return
|
||
}
|
||
versionNo, ok := positiveInt64Query(c, "version_no", true)
|
||
if !ok {
|
||
response.BadRequest(c, "version_no 参数不正确")
|
||
return
|
||
}
|
||
startMS, startOK := requiredPositiveInt64Query(c, "start_ms")
|
||
endMS, endOK := requiredPositiveInt64Query(c, "end_ms")
|
||
if !startOK || !endOK || endMS <= startMS {
|
||
response.BadRequest(c, "start_ms 或 end_ms 参数不正确")
|
||
return
|
||
}
|
||
regionID := int64(0)
|
||
if raw, exists := c.GetQuery("region_id"); exists {
|
||
var err error
|
||
regionID, err = strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||
if err != nil || regionID <= 0 {
|
||
response.BadRequest(c, "region_id 参数不正确")
|
||
return
|
||
}
|
||
}
|
||
// statistics-service 按 UTC 日聚合。把任意 UI 时间窗归一为覆盖它的完整 [UTC 00:00, UTC 00:00),
|
||
// 既避免半日过滤产生“看似精确但实际丢整日”的静默误差,也由响应 start_ms/end_ms 明确回显生效窗口。
|
||
effectiveStartMS := utcDayFloor(startMS)
|
||
effectiveEndMS := utcDayCeil(endMS)
|
||
if effectiveEndMS-effectiveStartMS > maxActivityTemplateDataRange.Milliseconds() {
|
||
response.BadRequest(c, "活动模版数据查询区间不能超过 366 天")
|
||
return
|
||
}
|
||
data, err := h.stats.ActivityTemplateData(c.Request.Context(), statisticsclient.ActivityTemplateDataQuery{
|
||
AppCode: appctx.FromContext(c.Request.Context()), TemplateID: strings.TrimSpace(c.Param("template_id")),
|
||
PublishedVersion: versionNo, RegionID: regionID, StartMS: effectiveStartMS, EndMS: effectiveEndMS,
|
||
})
|
||
if err != nil {
|
||
response.ServerError(c, "获取活动模版统计数据失败")
|
||
return
|
||
}
|
||
response.OK(c, data)
|
||
}
|
||
|
||
func positiveInt64Query(c *gin.Context, key string, required bool) (int64, bool) {
|
||
raw, exists := c.GetQuery(key)
|
||
if !exists || strings.TrimSpace(raw) == "" {
|
||
return 0, !required
|
||
}
|
||
value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
|
||
return value, err == nil && value > 0
|
||
}
|
||
|
||
func requiredPositiveInt64Query(c *gin.Context, key string) (int64, bool) {
|
||
value, ok := positiveInt64Query(c, key, true)
|
||
return value, ok
|
||
}
|
||
|
||
func utcDayFloor(ms int64) int64 {
|
||
value := time.UnixMilli(ms).UTC()
|
||
return time.Date(value.Year(), value.Month(), value.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
|
||
}
|
||
|
||
func utcDayCeil(ms int64) int64 {
|
||
floor := utcDayFloor(ms)
|
||
if floor == ms {
|
||
return floor
|
||
}
|
||
return floor + 24*time.Hour.Milliseconds()
|
||
}
|