From 4b817b320c2f79c3671431c4b45b7bd0b5dae934 Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 13 Apr 2026 16:08:52 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E8=BF=90=E8=90=A5?= =?UTF-8?q?=E6=B4=BB=E5=8A=A8=E8=B4=AD=E4=B9=B0=E9=81=93=E5=85=B7=E6=8F=90?= =?UTF-8?q?=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gateway/controller/activity_controller.go | 108 ++++++++++++------ backend/pkg/models/activity.go | 5 +- backend/pkg/proto/activity/activity.pb.go | 13 ++- backend/proto/activity.proto | 1 + frontend/pages/components/RankingModal.vue | 2 +- frontend/pages/profile/profile.vue | 18 ++- .../support-activity/components/ActionBar.vue | 72 +++++++++--- frontend/utils/activity-config.js | 18 ++- frontend/utils/api.js | 6 +- 9 files changed, 178 insertions(+), 65 deletions(-) diff --git a/backend/gateway/controller/activity_controller.go b/backend/gateway/controller/activity_controller.go index 25197bc..7cefc38 100644 --- a/backend/gateway/controller/activity_controller.go +++ b/backend/gateway/controller/activity_controller.go @@ -315,7 +315,30 @@ func (ctrl *ActivityController) PurchaseItem(c *gin.Context) { return } - // 转换响应 + // 非 active 阶段:通过 message 信号返回 200 + activity_status + activityStatusMap := map[string]string{ + "activity:expired": "expired", + "activity:pending": "pending", + "activity:completed": "completed", + "activity:incomplete": "incomplete", + "activity:active": "active", + } + activityMessageMap := map[string]string{ + "expired": "活动已结束", + "pending": "活动未开始", + "completed": "活动已完成", + "incomplete": "活动未完成", + "active": "活动进行中", + } + if status, ok := activityStatusMap[resp.Base.Message]; ok { + response.Success(c, map[string]interface{}{ + "activity_status": status, + "message": activityMessageMap[status], + }) + return + } + + // 正常购买成功 data := convertPurchaseResponse(resp) response.Success(c, data) } @@ -415,27 +438,41 @@ func (ctrl *ActivityController) GetContributionRanking(c *gin.Context) { response.Success(c, data) } +// formatTimestamp 将时间戳转换为 YYYY-MM-DD HH:mm:ss 格式 +// 毫秒级时间戳除以1000转换为秒级 +func formatTimestamp(timestamp int64) string { + if timestamp <= 0 { + return "" + } + // 毫秒级时间戳(13位以上,如1773411298000)除以1000转为秒级 + if timestamp >= 1e12 { + timestamp = timestamp / 1000 + } + return time.Unix(timestamp, 0).Format("2006-01-02 15:04:05") +} + // convertActivityListResponse 转换活动列表响应 func convertActivityListResponse(resp *pbActivity.GetActivityListResponse) map[string]interface{} { activities := make([]map[string]interface{}, 0, len(resp.Activities)) for _, activity := range resp.Activities { activities = append(activities, map[string]interface{}{ - "id": activity.Id, - "activity_type": activity.ActivityType, - "title": activity.Title, - "theme": activity.Theme, - "description": activity.Description, - "star_id": activity.StarId, - "start_time": activity.StartTime, - "end_time": activity.EndTime, - "target_progress": activity.TargetProgress, - "current_progress": activity.CurrentProgress, - "status": activity.Status, - "current_stage": activity.CurrentStage, - "cover_image": activity.CoverImage, - "banner_image": activity.BannerImage, - "current_stage_background": activity.CurrentStageBackground, - "current_stage_title": activity.CurrentStageTitle, + "id": activity.Id, + "activity_type": activity.ActivityType, + "title": activity.Title, + "theme": activity.Theme, + "description": activity.Description, + "star_id": activity.StarId, + "start_time": formatTimestamp(activity.StartTime), + "end_time": formatTimestamp(activity.EndTime), + "overall_end_time": formatTimestamp(activity.OverallEndTime), + "target_progress": activity.TargetProgress, + "current_progress": activity.CurrentProgress, + "status": activity.Status, + "current_stage": activity.CurrentStage, + "cover_image": activity.CoverImage, + "banner_image": activity.BannerImage, + "current_stage_background": activity.CurrentStageBackground, + "current_stage_title": activity.CurrentStageTitle, }) } @@ -462,23 +499,24 @@ func convertActivityResponse(activity *pbActivity.Activity) map[string]interface } return map[string]interface{}{ - "id": activity.Id, - "activity_type": activity.ActivityType, - "title": activity.Title, - "theme": activity.Theme, - "description": activity.Description, - "star_id": activity.StarId, - "start_time": activity.StartTime, - "end_time": activity.EndTime, - "target_progress": activity.TargetProgress, - "current_progress": activity.CurrentProgress, - "status": activity.Status, - "current_stage": activity.CurrentStage, - "cover_image": activity.CoverImage, - "banner_image": activity.BannerImage, - "current_stage_background": activity.CurrentStageBackground, - "current_stage_title": activity.CurrentStageTitle, - "items": items, + "id": activity.Id, + "activity_type": activity.ActivityType, + "title": activity.Title, + "theme": activity.Theme, + "description": activity.Description, + "star_id": activity.StarId, + "start_time": formatTimestamp(activity.StartTime), + "end_time": formatTimestamp(activity.EndTime), + "overall_end_time": formatTimestamp(activity.OverallEndTime), + "target_progress": activity.TargetProgress, + "current_progress": activity.CurrentProgress, + "status": activity.Status, + "current_stage": activity.CurrentStage, + "cover_image": activity.CoverImage, + "banner_image": activity.BannerImage, + "current_stage_background": activity.CurrentStageBackground, + "current_stage_title": activity.CurrentStageTitle, + "items": items, } } @@ -509,7 +547,7 @@ func convertProgressResponse(resp *pbActivity.GetProgressResponse) map[string]in "current_progress": resp.CurrentProgress, "target_progress": resp.TargetProgress, "current_stage": resp.CurrentStage, - "end_time": resp.EndTime, + "end_time": formatTimestamp(resp.EndTime), "status": resp.Status, } } diff --git a/backend/pkg/models/activity.go b/backend/pkg/models/activity.go index 50f1178..4a8d5b7 100644 --- a/backend/pkg/models/activity.go +++ b/backend/pkg/models/activity.go @@ -13,8 +13,9 @@ type Activity struct { Theme string `json:"theme" gorm:"size:100"` // 中文主题 Description string `json:"description" gorm:"type:text"` StarID int64 `json:"star_id" gorm:"not null"` - StartTime int64 `json:"start_time" gorm:"not null"` // 毫秒时间戳 - EndTime int64 `json:"end_time" gorm:"not null"` // 毫秒时间戳 + StartTime int64 `json:"start_time" gorm:"not null"` // 毫秒时间戳 + EndTime int64 `json:"end_time" gorm:"not null"` // 毫秒时间戳 + OverallEndTime int64 `json:"overall_end_time" gorm:"default:0"` // 整体活动结束时间(毫秒时间戳) TargetProgress int64 `json:"target_progress" gorm:"default:1000"` CurrentProgress int64 `json:"current_progress" gorm:"default:0"` Status string `json:"status" gorm:"size:20;default:pending"` // pending/active/completed/expired diff --git a/backend/pkg/proto/activity/activity.pb.go b/backend/pkg/proto/activity/activity.pb.go index fdd0bb7..9329517 100644 --- a/backend/pkg/proto/activity/activity.pb.go +++ b/backend/pkg/proto/activity/activity.pb.go @@ -43,6 +43,7 @@ type Activity struct { BannerImage string `protobuf:"bytes,14,opt,name=banner_image,json=bannerImage,proto3" json:"banner_image,omitempty"` // 横幅图 CurrentStageBackground string `protobuf:"bytes,15,opt,name=current_stage_background,json=currentStageBackground,proto3" json:"current_stage_background,omitempty"` // 当前阶段背景图 CurrentStageTitle string `protobuf:"bytes,16,opt,name=current_stage_title,json=currentStageTitle,proto3" json:"current_stage_title,omitempty"` // 当前阶段标题 + OverallEndTime int64 `protobuf:"varint,18,opt,name=overall_end_time,json=overallEndTime,proto3" json:"overall_end_time,omitempty"` // 整体活动结束时间 unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -196,6 +197,13 @@ func (x *Activity) GetCurrentStageTitle() string { return "" } +func (x *Activity) GetOverallEndTime() int64 { + if x != nil { + return x.OverallEndTime + } + return 0 +} + // 活动道具 type ActivityItem struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1100,7 +1108,7 @@ var File_activity_proto protoreflect.FileDescriptor const file_activity_proto_rawDesc = "" + "\n" + - "\x0eactivity.proto\x12\x10topfans.activity\x1a\x12proto/common.proto\x1a\x1cgoogle/api/annotations.proto\"\xd5\x04\n" + + "\x0eactivity.proto\x12\x10topfans.activity\x1a\x12proto/common.proto\x1a\x1cgoogle/api/annotations.proto\"\xff\x04\n" + "\bActivity\x12\x0e\n" + "\x02id\x18\x01 \x01(\x03R\x02id\x12#\n" + "\ractivity_type\x18\x02 \x01(\tR\factivityType\x12\x14\n" + @@ -1121,7 +1129,8 @@ const file_activity_proto_rawDesc = "" + "coverImage\x12!\n" + "\fbanner_image\x18\x0e \x01(\tR\vbannerImage\x128\n" + "\x18current_stage_background\x18\x0f \x01(\tR\x16currentStageBackground\x12.\n" + - "\x13current_stage_title\x18\x10 \x01(\tR\x11currentStageTitle\"\xc7\x01\n" + + "\x13current_stage_title\x18\x10 \x01(\tR\x11currentStageTitle\x12(\n" + + "\x10overall_end_time\x18\x12 \x01(\x03R\x0eoverallEndTime\"\xc7\x01\n" + "\fActivityItem\x12\x0e\n" + "\x02id\x18\x01 \x01(\x03R\x02id\x12\x1b\n" + "\titem_type\x18\x02 \x01(\tR\bitemType\x12\x1b\n" + diff --git a/backend/proto/activity.proto b/backend/proto/activity.proto index 855344b..56045f0 100644 --- a/backend/proto/activity.proto +++ b/backend/proto/activity.proto @@ -28,6 +28,7 @@ message Activity { string banner_image = 14; // 横幅图 string current_stage_background = 15; // 当前阶段背景图 string current_stage_title = 16; // 当前阶段标题 + int64 overall_end_time = 18; // 整体活动结束时间 } // 活动道具 diff --git a/frontend/pages/components/RankingModal.vue b/frontend/pages/components/RankingModal.vue index 51d0f45..587dbc1 100644 --- a/frontend/pages/components/RankingModal.vue +++ b/frontend/pages/components/RankingModal.vue @@ -340,7 +340,7 @@ const fetchActivityList = async () => { try { isLoadingActivities.value = true; activityLoadError.value = null; - const response = await getActivityListApi(starId, 'active', 1, 20); + const response = await getActivityListApi(starId, 1, 20); if (response && response.code === 200 && response.data) { activityList.value = response.data.activities || []; diff --git a/frontend/pages/profile/profile.vue b/frontend/pages/profile/profile.vue index d5c4be6..7fcbe18 100644 --- a/frontend/pages/profile/profile.vue +++ b/frontend/pages/profile/profile.vue @@ -257,7 +257,7 @@ diff --git a/frontend/pages/support-activity/components/ActionBar.vue b/frontend/pages/support-activity/components/ActionBar.vue index 8e9556b..8279d0b 100644 --- a/frontend/pages/support-activity/components/ActionBar.vue +++ b/frontend/pages/support-activity/components/ActionBar.vue @@ -242,9 +242,11 @@ async function validateBalance(cost) { try { if (!userInfo.value) { const userStr = uni.getStorageSync('user') - if (userStr) userInfo.value = JSON.parse(userStr) + if (userStr) { + userInfo.value = typeof userStr === 'string' ? JSON.parse(userStr) : { ...userStr } + } } - const balance = userInfo.value?.crystal_balance || 0 + const balance = Number(userInfo.value?.crystal_balance) || 0 return balance >= cost } catch (error) { console.error('验证余额失败:', error) @@ -257,31 +259,37 @@ async function contributeItem(item, isRetry = false) { try { // 使用 activity-config.js 中的 purchaseItem 函数 const result = await purchaseItem(props.activityId, item.type, 1) - + + // 检查购买结果 + if (!result.success) { + showResultToast('', result.message || '活动不在进行中,无法购买') + return false + } + // 成功:触发反馈动画(重试时静默,不触发) if (!isRetry) { feedbackItem.value = item.type setTimeout(() => { feedbackItem.value = null }, 800) } - + // 更新本地用户余额:非重试时直接用服务端余额;重试时由 syncPendingActions 统一处理 if (!isRetry) { await updateLocalBalanceFromResult(result.remainingBalance) } - + // 通知父组件更新进度(使用返回的当前进度) emit('contribute', item.type, result.currentProgress) - + // 如果是重试成功,不单独弹 toast,由 syncPendingActions 统一汇总提示 if (!isRetry) { showResultToast('✅', `贡献值 +${result.totalContribution}`) } - + // 重试时返回完整结果供 syncPendingActions 汇总;正常时返回 true return isRetry ? { contribution: result.totalContribution, remainingBalance: result.remainingBalance } : true } catch (error) { console.error('贡献失败:', error) - + // 如果不是重试操作,先乐观扣除本地余额再入队 if (!isRetry) { deductLocalBalance(item.cost) @@ -293,7 +301,7 @@ async function contributeItem(item, isRetry = false) { } showResultToast('', '网络异常,已加入队列') } - + return false } } @@ -324,8 +332,18 @@ function deductLocalBalance(cost) { try { const userStr = uni.getStorageSync('user') if (userStr) { - const user = typeof userStr === 'string' ? JSON.parse(userStr) : userStr - user.crystal_balance = Math.max(0, (user.crystal_balance || 0) - cost) + // 确保正确解析用户对象 + let user + if (typeof userStr === 'string') { + user = JSON.parse(userStr) + } else { + user = { ...userStr } + } + + // 扣除余额,确保不为负数 + user.crystal_balance = Math.max(0, (Number(user.crystal_balance) || 0) - cost) + + // 保存回存储 uni.setStorageSync('user', JSON.stringify(user)) userInfo.value = user uni.$emit('balanceUpdated', { crystal_balance: user.crystal_balance }) @@ -340,10 +358,21 @@ function refundLocalBalance(cost) { try { const userStr = uni.getStorageSync('user') if (userStr) { - const user = typeof userStr === 'string' ? JSON.parse(userStr) : userStr - user.crystal_balance = (user.crystal_balance || 0) + cost + // 确保正确解析用户对象 + let user + if (typeof userStr === 'string') { + user = JSON.parse(userStr) + } else { + user = { ...userStr } + } + + // 退还余额 + user.crystal_balance = (Number(user.crystal_balance) || 0) + cost + + // 保存回存储 uni.setStorageSync('user', JSON.stringify(user)) userInfo.value = user + uni.$emit('balanceUpdated', { crystal_balance: user.crystal_balance }) } } catch (error) { console.error('退还本地余额失败:', error) @@ -353,11 +382,22 @@ async function updateLocalBalanceFromResult(newBalance) { try { const userStr = uni.getStorageSync('user') if (userStr) { - const user = typeof userStr === 'string' ? JSON.parse(userStr) : userStr - user.crystal_balance = newBalance + // 确保正确解析用户对象 + let user + if (typeof userStr === 'string') { + user = JSON.parse(userStr) + } else { + // 如果已经是对象,创建一个新副本避免引用问题 + user = { ...userStr } + } + + // 更新余额,确保是数字类型 + user.crystal_balance = Number(newBalance) || 0 + + // 保存回存储,确保是字符串格式 uni.setStorageSync('user', JSON.stringify(user)) userInfo.value = user - uni.$emit('balanceUpdated', { crystal_balance: newBalance }) + uni.$emit('balanceUpdated', { crystal_balance: user.crystal_balance }) } } catch (error) { console.error('更新本地余额失败:', error) diff --git a/frontend/utils/activity-config.js b/frontend/utils/activity-config.js index 92e0e5e..8768e6d 100644 --- a/frontend/utils/activity-config.js +++ b/frontend/utils/activity-config.js @@ -122,18 +122,32 @@ export async function fetchActivityProgress(activityId) { export async function purchaseItem(activityId, itemType, quantity = 1) { try { const response = await purchaseActivityItemApi(activityId, itemType, quantity) + // 检查活动状态,只有 active 才能购买 if (response.code === 200 && response.data) { + if (response.data.activity_status && response.data.activity_status !== 'active') { + return { + success: false, + message: response.data.message || '活动不在进行中,无法购买' + } + } return { + success: true, totalCrystalSpent: response.data.total_crystal_spent, totalContribution: response.data.total_contribution, currentProgress: response.data.current_progress, remainingBalance: response.data.remaining_balance } } - throw new Error(response.message || '购买道具失败') + return { + success: false, + message: response.message || '购买道具失败' + } } catch (error) { console.error('purchaseItem error:', error) - throw error + return { + success: false, + message: error.message || '购买道具失败' + } } } diff --git a/frontend/utils/api.js b/frontend/utils/api.js index 890c7e9..c670b0b 100644 --- a/frontend/utils/api.js +++ b/frontend/utils/api.js @@ -486,9 +486,9 @@ export function getOriginalRankingApi(dimension = 'total', starId = null, page = // 获取活动列表 export function getActivityListApi(starId, status = '', page = 1, pageSize = 10) { let url = `/api/v1/activities?star_id=${starId}&page=${page}&page_size=${pageSize}` - if (status) { - url += `&status=${status}` - } + // if (status) { + // url += `&status=${status}` + // } return request({ url: url, method: 'GET'