feat: 每日任务集成
This commit is contained in:
parent
8284999b3a
commit
ad20197352
241
backend/proto/task.proto
Normal file
241
backend/proto/task.proto
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
|
||||||
|
package topfans.task;
|
||||||
|
|
||||||
|
option go_package = "github.com/topfans/backend/pkg/proto/task;task";
|
||||||
|
|
||||||
|
import "proto/common.proto";
|
||||||
|
import "google/api/annotations.proto";
|
||||||
|
|
||||||
|
// ==================== 每日任务 ====================
|
||||||
|
|
||||||
|
message DailyTaskItem {
|
||||||
|
string task_key = 1;
|
||||||
|
int64 star_id = 2;
|
||||||
|
string name = 3;
|
||||||
|
string description = 4;
|
||||||
|
int64 crystal_reward = 5;
|
||||||
|
int64 exp_reward = 6;
|
||||||
|
string status = 7; // pending/completed/claimed
|
||||||
|
bool can_claim = 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetDailyTasksRequest {
|
||||||
|
int64 star_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetDailyTasksResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
int64 star_id = 2;
|
||||||
|
repeated DailyTaskItem tasks = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReportEventRequest {
|
||||||
|
string event_type = 1; // 如 "daily_browse_asset", "daily_login"
|
||||||
|
int64 star_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ReportEventResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
bool success = 2;
|
||||||
|
string task_key = 3;
|
||||||
|
bool task_completed = 4;
|
||||||
|
string message = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClaimDailyTaskRequest {
|
||||||
|
string task_key = 1;
|
||||||
|
int64 star_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClaimDailyTaskResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
bool success = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClaimAllDailyTasksRequest {
|
||||||
|
int64 star_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClaimAllDailyTasksResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
int32 claimed_count = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 引导任务 ====================
|
||||||
|
|
||||||
|
message OnboardingStage {
|
||||||
|
int32 stage = 1;
|
||||||
|
string name = 2;
|
||||||
|
repeated string required_task_keys = 3;
|
||||||
|
int64 crystal_reward = 4;
|
||||||
|
int64 exp_reward = 5;
|
||||||
|
string status = 6; // pending/completed/in_progress
|
||||||
|
bool is_current = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CompleteGuideRequest {
|
||||||
|
string task_key = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message CompleteGuideResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
int64 user_id = 2;
|
||||||
|
int32 current_stage = 3;
|
||||||
|
string status = 4; // pending/in_progress/completed
|
||||||
|
repeated OnboardingStage stages = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetOnboardingStatusRequest {}
|
||||||
|
|
||||||
|
message GetOnboardingStatusResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
int64 user_id = 2;
|
||||||
|
int32 current_stage = 3;
|
||||||
|
string status = 4;
|
||||||
|
repeated OnboardingStage stages = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdvanceStageRequest {
|
||||||
|
int32 target_stage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message AdvanceStageResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
int32 current_stage = 2;
|
||||||
|
string status = 3;
|
||||||
|
repeated OnboardingStage stages = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClaimOnboardingRewardRequest {
|
||||||
|
int32 stage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClaimOnboardingRewardResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
bool success = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 展示收益 ====================
|
||||||
|
|
||||||
|
message ExhibitionRevenueItem {
|
||||||
|
int64 id = 1;
|
||||||
|
int64 star_id = 2;
|
||||||
|
int64 exhibition_id = 3;
|
||||||
|
int64 asset_id = 4;
|
||||||
|
int64 slot_id = 5;
|
||||||
|
string slot_type = 6; // own/friend
|
||||||
|
int64 crystal_amount = 7;
|
||||||
|
int64 cycle_start_time = 8;
|
||||||
|
int64 cycle_end_time = 9;
|
||||||
|
string status = 10; // claimable/claimed/failed
|
||||||
|
bool can_claim = 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetExhibitionRevenueRequest {
|
||||||
|
int64 star_id = 1;
|
||||||
|
string status = 2; // 可选筛选
|
||||||
|
int32 page = 3;
|
||||||
|
int32 page_size = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message GetExhibitionRevenueResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
repeated ExhibitionRevenueItem items = 2;
|
||||||
|
int64 total = 3;
|
||||||
|
int32 page = 4;
|
||||||
|
int32 page_size = 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClaimExhibitionRevenueRequest {
|
||||||
|
int64 revenue_id = 1;
|
||||||
|
int64 star_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClaimExhibitionRevenueResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
bool success = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClaimAllExhibitionRevenueRequest {
|
||||||
|
int64 star_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ClaimAllExhibitionRevenueResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
int32 claimed_count = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 内部RPC服务 ====================
|
||||||
|
|
||||||
|
message InitUserTasksRequest {
|
||||||
|
int64 user_id = 1;
|
||||||
|
int64 star_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message InitUserTasksResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
bool success = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OnExhibitionCompletedRequest {
|
||||||
|
int64 exhibition_id = 1;
|
||||||
|
int64 asset_id = 2;
|
||||||
|
int64 slot_id = 3;
|
||||||
|
int64 occupier_uid = 4;
|
||||||
|
int64 occupier_star_id = 5;
|
||||||
|
int64 slot_owner_uid = 6;
|
||||||
|
int64 crystal_amount = 7;
|
||||||
|
int64 start_time = 8;
|
||||||
|
int64 expire_at = 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
message OnExhibitionCompletedResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
int64 revenue_record_id = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Mobile API Service ====================
|
||||||
|
|
||||||
|
service TaskMobileService {
|
||||||
|
rpc GetDailyTasks(GetDailyTasksRequest) returns (GetDailyTasksResponse) {
|
||||||
|
option (google.api.http) = { get: "/api/tasks/daily"; };
|
||||||
|
}
|
||||||
|
rpc ReportEvent(ReportEventRequest) returns (ReportEventResponse) {
|
||||||
|
option (google.api.http) = { post: "/api/tasks/report-event"; body: "*"; };
|
||||||
|
}
|
||||||
|
rpc ClaimDailyTask(ClaimDailyTaskRequest) returns (ClaimDailyTaskResponse) {
|
||||||
|
option (google.api.http) = { post: "/api/tasks/daily/claim"; body: "*"; };
|
||||||
|
}
|
||||||
|
rpc ClaimAllDailyTasks(ClaimAllDailyTasksRequest) returns (ClaimAllDailyTasksResponse) {
|
||||||
|
option (google.api.http) = { post: "/api/tasks/daily/claim-all"; body: "*"; };
|
||||||
|
}
|
||||||
|
rpc CompleteGuide(CompleteGuideRequest) returns (CompleteGuideResponse) {
|
||||||
|
option (google.api.http) = { post: "/api/tasks/guide/complete"; body: "*"; };
|
||||||
|
}
|
||||||
|
rpc GetOnboardingStatus(GetOnboardingStatusRequest) returns (GetOnboardingStatusResponse) {
|
||||||
|
option (google.api.http) = { get: "/api/tasks/onboarding/status"; };
|
||||||
|
}
|
||||||
|
rpc AdvanceStage(AdvanceStageRequest) returns (AdvanceStageResponse) {
|
||||||
|
option (google.api.http) = { post: "/api/tasks/onboarding/advance-stage"; body: "*"; };
|
||||||
|
}
|
||||||
|
rpc ClaimOnboardingReward(ClaimOnboardingRewardRequest) returns (ClaimOnboardingRewardResponse) {
|
||||||
|
option (google.api.http) = { post: "/api/tasks/onboarding/claim-reward"; body: "*"; };
|
||||||
|
}
|
||||||
|
rpc GetExhibitionRevenue(GetExhibitionRevenueRequest) returns (GetExhibitionRevenueResponse) {
|
||||||
|
option (google.api.http) = { get: "/api/tasks/exhibition-revenue"; };
|
||||||
|
}
|
||||||
|
rpc ClaimExhibitionRevenue(ClaimExhibitionRevenueRequest) returns (ClaimExhibitionRevenueResponse) {
|
||||||
|
option (google.api.http) = { post: "/api/tasks/exhibition-revenue/claim"; body: "*"; };
|
||||||
|
}
|
||||||
|
rpc ClaimAllExhibitionRevenue(ClaimAllExhibitionRevenueRequest) returns (ClaimAllExhibitionRevenueResponse) {
|
||||||
|
option (google.api.http) = { post: "/api/tasks/exhibition-revenue/claim-all"; body: "*"; };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Internal RPC Service ====================
|
||||||
|
|
||||||
|
service TaskInternalService {
|
||||||
|
rpc InitUserTasks(InitUserTasksRequest) returns (InitUserTasksResponse);
|
||||||
|
rpc OnExhibitionCompleted(OnExhibitionCompletedRequest) returns (OnExhibitionCompletedResponse);
|
||||||
|
}
|
||||||
@ -211,6 +211,19 @@ message UpdateAssetsCountResponse {
|
|||||||
int32 new_count = 2; // 更新后的资产数量
|
int32 new_count = 2; // 更新后的资产数量
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 增加经验值请求(内部RPC调用,用于taskService增加经验)
|
||||||
|
message AddExperienceRequest {
|
||||||
|
int64 user_id = 1; // 用户ID
|
||||||
|
int64 star_id = 2; // 明星ID
|
||||||
|
int64 delta = 3; // 变化量(正数增加,负数减少)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加经验值响应
|
||||||
|
message AddExperienceResponse {
|
||||||
|
topfans.common.BaseResponse base = 1;
|
||||||
|
int64 new_experience = 2; // 更新后的经验值
|
||||||
|
}
|
||||||
|
|
||||||
// 获取当前登录用户信息请求
|
// 获取当前登录用户信息请求
|
||||||
message GetCurrentUserRequest {
|
message GetCurrentUserRequest {
|
||||||
// 空请求,从Token中获取user_id和star_id
|
// 空请求,从Token中获取user_id和star_id
|
||||||
@ -401,6 +414,9 @@ service UserSocialService {
|
|||||||
// 内部RPC:更新资产数量(仅供assetService调用)
|
// 内部RPC:更新资产数量(仅供assetService调用)
|
||||||
rpc UpdateAssetsCount(UpdateAssetsCountRequest) returns (UpdateAssetsCountResponse);
|
rpc UpdateAssetsCount(UpdateAssetsCountRequest) returns (UpdateAssetsCountResponse);
|
||||||
|
|
||||||
|
// 内部RPC:增加经验值(仅供taskService调用)
|
||||||
|
rpc AddExperience(AddExperienceRequest) returns (AddExperienceResponse);
|
||||||
|
|
||||||
rpc GetCurrentUser(GetCurrentUserRequest) returns (GetCurrentUserResponse) {
|
rpc GetCurrentUser(GetCurrentUserRequest) returns (GetCurrentUserResponse) {
|
||||||
option (google.api.http) = {
|
option (google.api.http) = {
|
||||||
get: "/api/v1/auth/me"
|
get: "/api/v1/auth/me"
|
||||||
|
|||||||
@ -53,7 +53,7 @@ echo ""
|
|||||||
|
|
||||||
# 预先创建目标目录
|
# 预先创建目标目录
|
||||||
echo "📁 创建目标目录..."
|
echo "📁 创建目标目录..."
|
||||||
for name in common user social asset gallery ranking activity; do
|
for name in common user social asset gallery ranking activity task; do
|
||||||
mkdir -p "pkg/proto/$name"
|
mkdir -p "pkg/proto/$name"
|
||||||
done
|
done
|
||||||
echo ""
|
echo ""
|
||||||
@ -146,6 +146,19 @@ protoc --proto_path=proto \
|
|||||||
echo "✅ activity.proto 编译完成"
|
echo "✅ activity.proto 编译完成"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
# 编译 task.proto
|
||||||
|
echo "📦 编译 task.proto ..."
|
||||||
|
protoc --proto_path=proto \
|
||||||
|
--proto_path=. \
|
||||||
|
--go_out=pkg/proto/task \
|
||||||
|
--go_opt=paths=source_relative \
|
||||||
|
--go-triple_out=pkg/proto/task \
|
||||||
|
--go-triple_opt=paths=source_relative \
|
||||||
|
task.proto
|
||||||
|
|
||||||
|
echo "✅ task.proto 编译完成"
|
||||||
|
echo ""
|
||||||
|
|
||||||
# 清理可能存在的冗余目录和文件
|
# 清理可能存在的冗余目录和文件
|
||||||
echo "🔄 清理冗余文件..."
|
echo "🔄 清理冗余文件..."
|
||||||
|
|
||||||
@ -156,7 +169,7 @@ if [ -d "github.com" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# 删除 proto 目录下的生成文件(如果存在)
|
# 删除 proto 目录下的生成文件(如果存在)
|
||||||
for name in common user social asset gallery ranking activity; do
|
for name in common user social asset gallery ranking activity task; do
|
||||||
if [ -f "proto/$name.pb.go" ]; then
|
if [ -f "proto/$name.pb.go" ]; then
|
||||||
rm "proto/$name.pb.go"
|
rm "proto/$name.pb.go"
|
||||||
echo " ✅ proto/$name.pb.go 已清理"
|
echo " ✅ proto/$name.pb.go 已清理"
|
||||||
|
|||||||
55
backend/services/taskService/client/user_rpc_client.go
Normal file
55
backend/services/taskService/client/user_rpc_client.go
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
pbUser "github.com/topfans/backend/pkg/proto/user"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserServiceClient interface {
|
||||||
|
UpdateCrystalBalance(ctx context.Context, userID, starID int64, delta int64) (int64, error)
|
||||||
|
AddExperience(ctx context.Context, userID, starID int64, delta int64) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type userServiceClient struct {
|
||||||
|
client pbUser.UserSocialService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserServiceClient(client pbUser.UserSocialService) UserServiceClient {
|
||||||
|
return &userServiceClient{client: client}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *userServiceClient) UpdateCrystalBalance(ctx context.Context, userID, starID int64, delta int64) (int64, error) {
|
||||||
|
logger.Logger.Debug("Calling UserService.UpdateCrystalBalance",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int64("delta", delta))
|
||||||
|
resp, err := c.client.UpdateCrystalBalance(ctx, &pbUser.UpdateCrystalBalanceRequest{
|
||||||
|
UserId: userID, StarId: starID, Delta: delta,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("UserService.UpdateCrystalBalance failed", zap.Error(err))
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if resp.Base.Code != 0 {
|
||||||
|
logger.Logger.Warn("UpdateCrystalBalance non-zero code", zap.Int32("code", int32(resp.Base.Code)))
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return resp.NewBalance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *userServiceClient) AddExperience(ctx context.Context, userID, starID int64, delta int64) (int64, error) {
|
||||||
|
logger.Logger.Debug("Calling UserService.AddExperience",
|
||||||
|
zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Int64("delta", delta))
|
||||||
|
resp, err := c.client.AddExperience(ctx, &pbUser.AddExperienceRequest{
|
||||||
|
UserId: userID, StarId: starID, Delta: delta,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("UserService.AddExperience failed", zap.Error(err))
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if resp.Base.Code != 0 {
|
||||||
|
logger.Logger.Warn("AddExperience non-zero code", zap.Int32("code", int32(resp.Base.Code)))
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return resp.NewExperience, nil
|
||||||
|
}
|
||||||
64
backend/services/taskService/config/task_config.go
Normal file
64
backend/services/taskService/config/task_config.go
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DatabaseConfig struct {
|
||||||
|
Host, Password, DBName, SSLMode, TimeZone string
|
||||||
|
Port int
|
||||||
|
User string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServiceURLs struct {
|
||||||
|
UserService string
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkerConfig struct {
|
||||||
|
ResetHour, ResetMinute int
|
||||||
|
RevenueBatchSize int
|
||||||
|
RevenueMaxRetries int
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
DBConfig = &DatabaseConfig{}
|
||||||
|
ServiceURLsConfig = &ServiceURLs{UserService: "tri://localhost:20000"}
|
||||||
|
WorkerConfigData = &WorkerConfig{
|
||||||
|
ResetHour: 5, ResetMinute: 0,
|
||||||
|
RevenueBatchSize: 100, RevenueMaxRetries: 3,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func getEnv(key, fallback string) string {
|
||||||
|
if v := os.Getenv(key); v != "" { return v }
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnvInt(key string, fallback int) int {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil { return n }
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitConfig() {
|
||||||
|
flag.StringVar(&DBConfig.Host, "db-host", getEnv("DB_HOST", "localhost"), "数据库主机")
|
||||||
|
flag.IntVar(&DBConfig.Port, "db-port", getEnvInt("DB_PORT", 5432), "数据库端口")
|
||||||
|
flag.StringVar(&DBConfig.User, "db-user", getEnv("DB_USER", "postgres"), "数据库用户名")
|
||||||
|
flag.StringVar(&DBConfig.Password, "db-password", getEnv("DB_PASSWORD", ""), "数据库密码")
|
||||||
|
flag.StringVar(&DBConfig.DBName, "db-name", getEnv("DB_NAME", "topfans"), "数据库名称")
|
||||||
|
flag.StringVar(&DBConfig.SSLMode, "db-sslmode", "disable", "数据库 SSL 模式")
|
||||||
|
flag.StringVar(&ServiceURLsConfig.UserService, "user-service-url", getEnv("USER_SERVICE_URL", "tri://localhost:20000"), "User Service RPC 地址")
|
||||||
|
flag.IntVar(&WorkerConfigData.ResetHour, "reset-hour", getEnvInt("RESET_HOUR", 5), "每日重置小时(Asia/Shanghai)")
|
||||||
|
flag.IntVar(&WorkerConfigData.ResetMinute, "reset-minute", getEnvInt("RESET_MINUTE", 0), "每日重置分钟")
|
||||||
|
flag.IntVar(&WorkerConfigData.RevenueBatchSize, "revenue-batch-size", getEnvInt("REVENUE_BATCH_SIZE", 100), "收益自动发放批次大小")
|
||||||
|
flag.IntVar(&WorkerConfigData.RevenueMaxRetries, "revenue-max-retries", getEnvInt("REVENUE_MAX_RETRIES", 3), "收益自动发放最大重试次数")
|
||||||
|
flag.Parse()
|
||||||
|
log.Println("taskService 配置初始化完成")
|
||||||
|
log.Printf(" 数据库: %s:%d/%s", DBConfig.Host, DBConfig.Port, DBConfig.DBName)
|
||||||
|
log.Printf(" User Service: %s", ServiceURLsConfig.UserService)
|
||||||
|
log.Printf(" 重置时间: %02d:%02d Asia/Shanghai", WorkerConfigData.ResetHour, WorkerConfigData.ResetMinute)
|
||||||
|
}
|
||||||
132
backend/services/taskService/main.go
Normal file
132
backend/services/taskService/main.go
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
dubboclient "dubbo.apache.org/dubbo-go/v3/client"
|
||||||
|
_ "dubbo.apache.org/dubbo-go/v3/imports"
|
||||||
|
"dubbo.apache.org/dubbo-go/v3/protocol"
|
||||||
|
"dubbo.apache.org/dubbo-go/v3/server"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/pkg/database"
|
||||||
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
pbUser "github.com/topfans/backend/pkg/proto/user"
|
||||||
|
"github.com/topfans/backend/services/taskService/client"
|
||||||
|
"github.com/topfans/backend/services/taskService/config"
|
||||||
|
"github.com/topfans/backend/services/taskService/model"
|
||||||
|
"github.com/topfans/backend/services/taskService/provider"
|
||||||
|
"github.com/topfans/backend/services/taskService/repository"
|
||||||
|
"github.com/topfans/backend/services/taskService/service"
|
||||||
|
"github.com/topfans/backend/services/taskService/worker"
|
||||||
|
)
|
||||||
|
|
||||||
|
var port = flag.Int("port", 20005, "Dubbo service port")
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 1. Init logger(必须最前)
|
||||||
|
env := os.Getenv("ENV")
|
||||||
|
if env == "" {
|
||||||
|
env = "development"
|
||||||
|
}
|
||||||
|
if err := logger.Init(logger.Config{ServiceName: "task-service", Environment: env, LogLevel: os.Getenv("LOG_LEVEL")}); err != nil {
|
||||||
|
panic(fmt.Sprintf("Failed to init logger: %v", err))
|
||||||
|
}
|
||||||
|
defer logger.Sync()
|
||||||
|
logger.Logger.Info("Starting taskService...")
|
||||||
|
|
||||||
|
// 2. Init config(读取 flags/env)
|
||||||
|
config.InitConfig()
|
||||||
|
|
||||||
|
// 3. Init database + auto-migrate
|
||||||
|
dbConfig := database.Config{
|
||||||
|
Host: config.DBConfig.Host,
|
||||||
|
Port: config.DBConfig.Port,
|
||||||
|
User: config.DBConfig.User,
|
||||||
|
Password: config.DBConfig.Password,
|
||||||
|
DBName: config.DBConfig.DBName,
|
||||||
|
SSLMode: config.DBConfig.SSLMode,
|
||||||
|
TimeZone: "Asia/Shanghai",
|
||||||
|
}
|
||||||
|
if err := database.Init(dbConfig); err != nil {
|
||||||
|
logger.Logger.Fatal(fmt.Sprintf("Failed to init DB: %v", err))
|
||||||
|
}
|
||||||
|
db := database.GetDB()
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.TaskDefinition{},
|
||||||
|
&model.UserDailyTaskProgress{},
|
||||||
|
&model.UserOnboardingProgress{},
|
||||||
|
&model.UserOnboardingStatus{},
|
||||||
|
&model.OnboardingStageConfig{},
|
||||||
|
&model.ExhibitionRevenueRecord{},
|
||||||
|
&model.TaskResetLog{},
|
||||||
|
); err != nil {
|
||||||
|
logger.Logger.Fatal(fmt.Sprintf("Failed to migrate tables: %v", err))
|
||||||
|
}
|
||||||
|
logger.Logger.Info("Database initialized")
|
||||||
|
|
||||||
|
// 4. Init repositories
|
||||||
|
dailyRepo := repository.NewDailyTaskRepository(db)
|
||||||
|
onboardingRepo := repository.NewOnboardingRepository(db)
|
||||||
|
revenueRepo := repository.NewRevenueRepository(db)
|
||||||
|
logger.Logger.Info("Repositories initialized")
|
||||||
|
|
||||||
|
// 5. Init userService Dubbo client + RPC client
|
||||||
|
userCli, err := dubboclient.NewClient(
|
||||||
|
dubboclient.WithClientURL(config.ServiceURLsConfig.UserService),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Fatal(fmt.Sprintf("Failed to create userService client: %v", err))
|
||||||
|
}
|
||||||
|
userServiceClient, err := pbUser.NewUserSocialService(userCli)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Fatal(fmt.Sprintf("Failed to get userService: %v", err))
|
||||||
|
}
|
||||||
|
userRPCClient := client.NewUserServiceClient(userServiceClient)
|
||||||
|
logger.Logger.Info("User RPC client initialized")
|
||||||
|
|
||||||
|
// 6. Init services
|
||||||
|
dailySvc := service.NewDailyTaskService(dailyRepo, userRPCClient)
|
||||||
|
onboardingSvc := service.NewOnboardingService(onboardingRepo, dailyRepo, userRPCClient)
|
||||||
|
revenueSvc := service.NewRevenueService(revenueRepo, userRPCClient)
|
||||||
|
logger.Logger.Info("Services initialized")
|
||||||
|
|
||||||
|
// 7. Init worker(goroutine 中启动)
|
||||||
|
resetWorker := worker.NewDailyResetWorker(dailyRepo, revenueRepo, userRPCClient)
|
||||||
|
go resetWorker.Start()
|
||||||
|
logger.Logger.Info("Reset worker started")
|
||||||
|
|
||||||
|
// 8. Init providers
|
||||||
|
mobileProvider := provider.NewTaskMobileProvider(dailySvc, onboardingSvc, revenueSvc)
|
||||||
|
internalProvider := provider.NewTaskInternalProvider(onboardingSvc, revenueSvc)
|
||||||
|
logger.Logger.Info("Providers initialized")
|
||||||
|
|
||||||
|
// 9. Create Dubbo server on port 20005
|
||||||
|
srv, err := server.NewServer(
|
||||||
|
server.WithServerProtocol(
|
||||||
|
protocol.WithPort(*port),
|
||||||
|
protocol.WithTriple(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Fatal(fmt.Sprintf("Failed to create server: %v", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:这里需要 TaskMobileService 和 TaskInternalService 的 RegisterHandler
|
||||||
|
// 这些会在 proto 编译后生成。在 proto 编译之前,这里会报编译错误。
|
||||||
|
// Task 15 完成 proto 编译后,需要将下面的注释替换为实际的注册代码。
|
||||||
|
|
||||||
|
logger.Logger.Info(fmt.Sprintf("taskService configured on port %d, awaiting proto registration", *port))
|
||||||
|
|
||||||
|
// 10. Graceful shutdown
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
logger.Logger.Info("Shutting down taskService...")
|
||||||
|
resetWorker.Stop()
|
||||||
|
}
|
||||||
110
backend/services/taskService/model/task_models.go
Normal file
110
backend/services/taskService/model/task_models.go
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// TaskDefinition 任务定义表
|
||||||
|
type TaskDefinition struct {
|
||||||
|
ID int64 `gorm:"primaryKey;column:id;autoIncrement"`
|
||||||
|
StarID *int64 `gorm:"column:star_id;index"` // NULL=全局默认
|
||||||
|
TaskKey string `gorm:"column:task_key;size:50;not null"`
|
||||||
|
TaskType string `gorm:"column:task_type;size:20;not null"` // daily/onboarding
|
||||||
|
Name string `gorm:"column:name;size:100;not null"`
|
||||||
|
Description string `gorm:"column:description;type:text"`
|
||||||
|
CrystalReward int64 `gorm:"column:crystal_reward;default:0"`
|
||||||
|
ExpReward int64 `gorm:"column:exp_reward;default:0"`
|
||||||
|
SortOrder int `gorm:"column:sort_order;default:0"`
|
||||||
|
IsActive bool `gorm:"column:is_active;default:true"`
|
||||||
|
CreatedAt int64 `gorm:"column:created_at"`
|
||||||
|
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TaskDefinition) TableName() string { return "task_definitions" }
|
||||||
|
|
||||||
|
// UserDailyTaskProgress 每日任务进度表
|
||||||
|
type UserDailyTaskProgress struct {
|
||||||
|
ID int64 `gorm:"primaryKey;column:id;autoIncrement"`
|
||||||
|
UserID int64 `gorm:"column:user_id;not null;index:idx_daily_user_star_key"`
|
||||||
|
StarID int64 `gorm:"column:star_id;not null;index:idx_daily_user_star_key"`
|
||||||
|
TaskKey string `gorm:"column:task_key;size:50;not null;index:idx_daily_user_star_key"`
|
||||||
|
Status string `gorm:"column:status;size:20;default:pending"` // pending/completed/claimed
|
||||||
|
CompletedAt *int64 `gorm:"column:completed_at"`
|
||||||
|
ClaimedAt *int64 `gorm:"column:claimed_at"`
|
||||||
|
CreatedAt int64 `gorm:"column:created_at"`
|
||||||
|
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserDailyTaskProgress) TableName() string { return "user_daily_task_progress" }
|
||||||
|
|
||||||
|
// UserOnboardingProgress 引导任务进度表
|
||||||
|
type UserOnboardingProgress struct {
|
||||||
|
ID int64 `gorm:"primaryKey;column:id;autoIncrement"`
|
||||||
|
UserID int64 `gorm:"column:user_id;not null;index:idx_onboard_user_key"`
|
||||||
|
TaskKey string `gorm:"column:task_key;size:50;not null;index:idx_onboard_user_key"`
|
||||||
|
Status string `gorm:"column:status;size:20;default:pending"`
|
||||||
|
CompletedAt *int64 `gorm:"column:completed_at"`
|
||||||
|
ClaimedAt *int64 `gorm:"column:claimed_at"`
|
||||||
|
CreatedAt int64 `gorm:"column:created_at"`
|
||||||
|
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserOnboardingProgress) TableName() string { return "user_onboarding_progress" }
|
||||||
|
|
||||||
|
// UserOnboardingStatus 引导流程状态表(per-user,非 per-star)
|
||||||
|
type UserOnboardingStatus struct {
|
||||||
|
UserID int64 `gorm:"primaryKey;column:user_id"`
|
||||||
|
CurrentStage int `gorm:"column:current_stage;default:0"`
|
||||||
|
Status string `gorm:"column:status;size:20;default:pending"`
|
||||||
|
IsFirstLoginBonusClaimed bool `gorm:"column:is_first_login_bonus_claimed;default:false"` // 废弃字段
|
||||||
|
HasFriendDisplayBonus bool `gorm:"column:has_friend_display_bonus;default:false"` // 废弃字段
|
||||||
|
CompletedAt *int64 `gorm:"column:completed_at"`
|
||||||
|
ClaimedAt *int64 `gorm:"column:claimed_at"`
|
||||||
|
CreatedAt int64 `gorm:"column:created_at"`
|
||||||
|
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserOnboardingStatus) TableName() string { return "user_onboarding_status" }
|
||||||
|
|
||||||
|
// OnboardingStageConfig 引导阶段配置表
|
||||||
|
type OnboardingStageConfig struct {
|
||||||
|
ID int64 `gorm:"primaryKey;column:id;autoIncrement"`
|
||||||
|
Stage int `gorm:"column:stage;not null;uniqueIndex"`
|
||||||
|
Name string `gorm:"column:name;size:100;not null"`
|
||||||
|
Description string `gorm:"column:description;type:text"`
|
||||||
|
RequiredTaskKeys []string `gorm:"column:required_task_keys;text[]"` // PostgreSQL 数组
|
||||||
|
CrystalReward int64 `gorm:"column:crystal_reward;default:0"`
|
||||||
|
ExpReward int64 `gorm:"column:exp_reward;default:0"`
|
||||||
|
SortOrder int `gorm:"column:sort_order;default:0"`
|
||||||
|
IsActive bool `gorm:"column:is_active;default:true"`
|
||||||
|
CreatedAt int64 `gorm:"column:created_at"`
|
||||||
|
UpdatedAt int64 `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (OnboardingStageConfig) TableName() string { return "onboarding_stage_config" }
|
||||||
|
|
||||||
|
// ExhibitionRevenueRecord 展示收益记录表
|
||||||
|
type ExhibitionRevenueRecord struct {
|
||||||
|
ID int64 `gorm:"primaryKey;column:id;autoIncrement"`
|
||||||
|
UserID int64 `gorm:"column:user_id;not null;index:idx_revenue_user_star"`
|
||||||
|
StarID int64 `gorm:"column:star_id;not null;index:idx_revenue_user_star"`
|
||||||
|
ExhibitionID int64 `gorm:"column:exhibition_id;not null"`
|
||||||
|
AssetID int64 `gorm:"column:asset_id;not null"`
|
||||||
|
SlotID int64 `gorm:"column:slot_id;not null"`
|
||||||
|
SlotOwnerUID int64 `gorm:"column:slot_owner_uid;not null"`
|
||||||
|
SlotType string `gorm:"column:slot_type;size:20;not null"` // own/friend
|
||||||
|
CrystalAmount int64 `gorm:"column:crystal_amount;not null"`
|
||||||
|
CycleStartTime int64 `gorm:"column:cycle_start_time;not null"`
|
||||||
|
CycleEndTime int64 `gorm:"column:cycle_end_time;not null"`
|
||||||
|
Status string `gorm:"column:status;size:20;default:claimable"` // claimable/claimed/failed
|
||||||
|
ClaimedAt *int64 `gorm:"column:claimed_at"`
|
||||||
|
CreatedAt int64 `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ExhibitionRevenueRecord) TableName() string { return "exhibition_revenue_records" }
|
||||||
|
|
||||||
|
// TaskResetLog 重置日志表
|
||||||
|
type TaskResetLog struct {
|
||||||
|
ID int64 `gorm:"primaryKey;column:id;autoIncrement"`
|
||||||
|
ResetType string `gorm:"column:reset_type;size:20;not null"` // daily
|
||||||
|
LastResetAt int64 `gorm:"column:last_reset_at;not null"`
|
||||||
|
CreatedAt int64 `gorm:"column:created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (TaskResetLog) TableName() string { return "task_reset_log" }
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
pb "github.com/topfans/backend/pkg/proto/task"
|
||||||
|
"github.com/topfans/backend/services/taskService/service"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskInternalProvider 实现 TaskInternalService 接口(内部 RPC)
|
||||||
|
type TaskInternalProvider struct {
|
||||||
|
onboardingSvc service.OnboardingService
|
||||||
|
revenueSvc service.RevenueService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskInternalProvider(
|
||||||
|
onboardingSvc service.OnboardingService,
|
||||||
|
revenueSvc service.RevenueService,
|
||||||
|
) *TaskInternalProvider {
|
||||||
|
return &TaskInternalProvider{
|
||||||
|
onboardingSvc: onboardingSvc,
|
||||||
|
revenueSvc: revenueSvc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitUserTasks 创建用户的 onboarding status 和每日任务进度
|
||||||
|
// 由 userService 或其他服务在用户注册/新增粉丝身份时调用
|
||||||
|
func (p *TaskInternalProvider) InitUserTasks(ctx context.Context, req *pb.InitUserTasksRequest) (*pb.InitUserTasksResponse, error) {
|
||||||
|
logger.Logger.Info("TaskInternalProvider.InitUserTasks called",
|
||||||
|
zap.Int64("user_id", req.UserId),
|
||||||
|
zap.Int64("star_id", req.StarId))
|
||||||
|
|
||||||
|
err := p.onboardingSvc.InitUserTasks(ctx, req.UserId, req.StarId)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("TaskInternalProvider.InitUserTasks failed",
|
||||||
|
zap.Int64("user_id", req.UserId),
|
||||||
|
zap.Int64("star_id", req.StarId),
|
||||||
|
zap.Error(err))
|
||||||
|
return &pb.InitUserTasksResponse{
|
||||||
|
Success: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info("TaskInternalProvider.InitUserTasks succeeded",
|
||||||
|
zap.Int64("user_id", req.UserId),
|
||||||
|
zap.Int64("star_id", req.StarId))
|
||||||
|
|
||||||
|
return &pb.InitUserTasksResponse{
|
||||||
|
Success: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnExhibitionCompleted 当展位到期完成时由 galleryService 调用
|
||||||
|
// 创建展示收益记录(仅在 slot_owner_uid != occupier_uid 时)
|
||||||
|
func (p *TaskInternalProvider) OnExhibitionCompleted(ctx context.Context, req *pb.OnExhibitionCompletedRequest) (*pb.OnExhibitionCompletedResponse, error) {
|
||||||
|
logger.Logger.Info("TaskInternalProvider.OnExhibitionCompleted called",
|
||||||
|
zap.Int64("exhibition_id", req.ExhibitionId),
|
||||||
|
zap.Int64("asset_id", req.AssetId),
|
||||||
|
zap.Int64("slot_id", req.SlotId),
|
||||||
|
zap.Int64("occupier_uid", req.OccupierUid),
|
||||||
|
zap.Int64("occupier_star_id", req.OccupierStarId),
|
||||||
|
zap.Int64("slot_owner_uid", req.SlotOwnerUid),
|
||||||
|
zap.Int64("crystal_amount", req.CrystalAmount))
|
||||||
|
|
||||||
|
resp, err := p.revenueSvc.OnExhibitionCompleted(ctx, req)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("TaskInternalProvider.OnExhibitionCompleted failed",
|
||||||
|
zap.Int64("exhibition_id", req.ExhibitionId),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info("TaskInternalProvider.OnExhibitionCompleted succeeded",
|
||||||
|
zap.Int64("exhibition_id", req.ExhibitionId),
|
||||||
|
zap.Int64("revenue_record_id", resp.RevenueRecordId))
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
242
backend/services/taskService/provider/task_mobile_provider.go
Normal file
242
backend/services/taskService/provider/task_mobile_provider.go
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
package provider
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
pb "github.com/topfans/backend/pkg/proto/task"
|
||||||
|
"github.com/topfans/backend/services/taskService/service"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TaskMobileProvider 实现 TaskMobileService 接口
|
||||||
|
type TaskMobileProvider struct {
|
||||||
|
dailySvc service.DailyTaskService
|
||||||
|
onboardingSvc service.OnboardingService
|
||||||
|
revenueSvc service.RevenueService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTaskMobileProvider(
|
||||||
|
dailySvc service.DailyTaskService,
|
||||||
|
onboardingSvc service.OnboardingService,
|
||||||
|
revenueSvc service.RevenueService,
|
||||||
|
) *TaskMobileProvider {
|
||||||
|
return &TaskMobileProvider{
|
||||||
|
dailySvc: dailySvc,
|
||||||
|
onboardingSvc: onboardingSvc,
|
||||||
|
revenueSvc: revenueSvc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractUserInfoFromDubboAttachments 从 Dubbo attachments 提取用户信息
|
||||||
|
func extractUserInfoFromDubboAttachments(ctx context.Context) (int64, int64, error) {
|
||||||
|
// Dubbo-go 使用 constant.AttachmentKey 获取 attachments
|
||||||
|
// 但这里使用通用的 context.Value 方式
|
||||||
|
if attachments := ctx.Value("attachments"); attachments != nil {
|
||||||
|
if attMap, ok := attachments.(map[string]interface{}); ok {
|
||||||
|
userID, starID := extractUserInfoFromMap(attMap)
|
||||||
|
if userID > 0 && starID > 0 {
|
||||||
|
return userID, starID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, 0, fmt.Errorf("failed to extract user info from Dubbo attachments")
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractUserInfoFromMap 从 map 中提取用户信息
|
||||||
|
func extractUserInfoFromMap(attMap map[string]interface{}) (int64, int64) {
|
||||||
|
var userID, starID int64
|
||||||
|
|
||||||
|
if uid, ok := attMap["user_id"]; ok {
|
||||||
|
switch v := uid.(type) {
|
||||||
|
case int64:
|
||||||
|
userID = v
|
||||||
|
case float64:
|
||||||
|
userID = int64(v)
|
||||||
|
case string:
|
||||||
|
if parsed, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
userID = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if sid, ok := attMap["star_id"]; ok {
|
||||||
|
switch v := sid.(type) {
|
||||||
|
case int64:
|
||||||
|
starID = v
|
||||||
|
case float64:
|
||||||
|
starID = int64(v)
|
||||||
|
case string:
|
||||||
|
if parsed, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
starID = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, starID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskMobileProvider) GetDailyTasks(ctx context.Context, req *pb.GetDailyTasksRequest) (*pb.GetDailyTasksResponse, error) {
|
||||||
|
userID, starID, err := extractUserInfoFromDubboAttachments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("GetDailyTasks: failed to extract user", zap.Error(err))
|
||||||
|
return &pb.GetDailyTasksResponse{
|
||||||
|
Tasks: []*pb.DailyTaskItem{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("GetDailyTasks",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int64("star_id", starID))
|
||||||
|
|
||||||
|
return p.dailySvc.GetDailyTasks(ctx, userID, starID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskMobileProvider) ReportEvent(ctx context.Context, req *pb.ReportEventRequest) (*pb.ReportEventResponse, error) {
|
||||||
|
userID, _, err := extractUserInfoFromDubboAttachments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("ReportEvent: failed to extract user", zap.Error(err))
|
||||||
|
return &pb.ReportEventResponse{
|
||||||
|
Success: false,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("ReportEvent",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("event_type", req.EventType),
|
||||||
|
zap.Int64("star_id", req.StarId))
|
||||||
|
|
||||||
|
return p.dailySvc.ReportEvent(ctx, userID, req.StarId, req.EventType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskMobileProvider) ClaimDailyTask(ctx context.Context, req *pb.ClaimDailyTaskRequest) (*pb.ClaimDailyTaskResponse, error) {
|
||||||
|
userID, _, err := extractUserInfoFromDubboAttachments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("ClaimDailyTask: failed to extract user", zap.Error(err))
|
||||||
|
return &pb.ClaimDailyTaskResponse{Success: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("ClaimDailyTask",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("task_key", req.TaskKey),
|
||||||
|
zap.Int64("star_id", req.StarId))
|
||||||
|
|
||||||
|
return p.dailySvc.ClaimDailyTask(ctx, userID, req.StarId, req.TaskKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskMobileProvider) ClaimAllDailyTasks(ctx context.Context, req *pb.ClaimAllDailyTasksRequest) (*pb.ClaimAllDailyTasksResponse, error) {
|
||||||
|
userID, _, err := extractUserInfoFromDubboAttachments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("ClaimAllDailyTasks: failed to extract user", zap.Error(err))
|
||||||
|
return &pb.ClaimAllDailyTasksResponse{ClaimedCount: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("ClaimAllDailyTasks",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int64("star_id", req.StarId))
|
||||||
|
|
||||||
|
return p.dailySvc.ClaimAllDailyTasks(ctx, userID, req.StarId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskMobileProvider) CompleteGuide(ctx context.Context, req *pb.CompleteGuideRequest) (*pb.CompleteGuideResponse, error) {
|
||||||
|
userID, _, err := extractUserInfoFromDubboAttachments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("CompleteGuide: failed to extract user", zap.Error(err))
|
||||||
|
return &pb.CompleteGuideResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("CompleteGuide",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.String("task_key", req.TaskKey))
|
||||||
|
|
||||||
|
return p.onboardingSvc.CompleteGuide(ctx, userID, req.TaskKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskMobileProvider) GetOnboardingStatus(ctx context.Context, req *pb.GetOnboardingStatusRequest) (*pb.GetOnboardingStatusResponse, error) {
|
||||||
|
userID, _, err := extractUserInfoFromDubboAttachments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("GetOnboardingStatus: failed to extract user", zap.Error(err))
|
||||||
|
return &pb.GetOnboardingStatusResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("GetOnboardingStatus",
|
||||||
|
zap.Int64("user_id", userID))
|
||||||
|
|
||||||
|
return p.onboardingSvc.GetOnboardingStatus(ctx, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskMobileProvider) AdvanceStage(ctx context.Context, req *pb.AdvanceStageRequest) (*pb.AdvanceStageResponse, error) {
|
||||||
|
userID, _, err := extractUserInfoFromDubboAttachments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("AdvanceStage: failed to extract user", zap.Error(err))
|
||||||
|
return &pb.AdvanceStageResponse{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("AdvanceStage",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int32("target_stage", req.TargetStage))
|
||||||
|
|
||||||
|
return p.onboardingSvc.AdvanceStage(ctx, userID, req.TargetStage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskMobileProvider) ClaimOnboardingReward(ctx context.Context, req *pb.ClaimOnboardingRewardRequest) (*pb.ClaimOnboardingRewardResponse, error) {
|
||||||
|
userID, _, err := extractUserInfoFromDubboAttachments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("ClaimOnboardingReward: failed to extract user", zap.Error(err))
|
||||||
|
return &pb.ClaimOnboardingRewardResponse{Success: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("ClaimOnboardingReward",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int32("stage", req.Stage))
|
||||||
|
|
||||||
|
return p.onboardingSvc.ClaimOnboardingReward(ctx, userID, req.Stage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskMobileProvider) GetExhibitionRevenue(ctx context.Context, req *pb.GetExhibitionRevenueRequest) (*pb.GetExhibitionRevenueResponse, error) {
|
||||||
|
userID, starID, err := extractUserInfoFromDubboAttachments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("GetExhibitionRevenue: failed to extract user", zap.Error(err))
|
||||||
|
return &pb.GetExhibitionRevenueResponse{
|
||||||
|
Items: []*pb.ExhibitionRevenueItem{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("GetExhibitionRevenue",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int64("star_id", starID),
|
||||||
|
zap.String("status", req.Status))
|
||||||
|
|
||||||
|
return p.revenueSvc.GetExhibitionRevenue(ctx, userID, starID, req.Status, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskMobileProvider) ClaimExhibitionRevenue(ctx context.Context, req *pb.ClaimExhibitionRevenueRequest) (*pb.ClaimExhibitionRevenueResponse, error) {
|
||||||
|
userID, starID, err := extractUserInfoFromDubboAttachments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("ClaimExhibitionRevenue: failed to extract user", zap.Error(err))
|
||||||
|
return &pb.ClaimExhibitionRevenueResponse{Success: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("ClaimExhibitionRevenue",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int64("star_id", starID),
|
||||||
|
zap.Int64("revenue_id", req.RevenueId))
|
||||||
|
|
||||||
|
return p.revenueSvc.ClaimExhibitionRevenue(ctx, userID, starID, req.RevenueId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TaskMobileProvider) ClaimAllExhibitionRevenue(ctx context.Context, req *pb.ClaimAllExhibitionRevenueRequest) (*pb.ClaimAllExhibitionRevenueResponse, error) {
|
||||||
|
userID, starID, err := extractUserInfoFromDubboAttachments(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("ClaimAllExhibitionRevenue: failed to extract user", zap.Error(err))
|
||||||
|
return &pb.ClaimAllExhibitionRevenueResponse{ClaimedCount: 0}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Debug("ClaimAllExhibitionRevenue",
|
||||||
|
zap.Int64("user_id", userID),
|
||||||
|
zap.Int64("star_id", starID))
|
||||||
|
|
||||||
|
return p.revenueSvc.ClaimAllExhibitionRevenue(ctx, userID, starID)
|
||||||
|
}
|
||||||
189
backend/services/taskService/repository/daily_task_repo.go
Normal file
189
backend/services/taskService/repository/daily_task_repo.go
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/services/taskService/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DailyTaskRepository 每日任务数据访问层接口
|
||||||
|
type DailyTaskRepository interface {
|
||||||
|
// GetUserDailyProgress 获取用户指定每日任务的进度
|
||||||
|
GetUserDailyProgress(userID, starID int64, taskKey string) (*model.UserDailyTaskProgress, error)
|
||||||
|
// GetOrCreateDailyProgress 获取或创建用户每日任务进度
|
||||||
|
GetOrCreateDailyProgress(userID, starID int64, taskKey string, def *model.TaskDefinition) (*model.UserDailyTaskProgress, error)
|
||||||
|
// ListDailyTasksByUser 获取用户的所有每日任务进度
|
||||||
|
ListDailyTasksByUser(userID, starID int64) ([]*model.UserDailyTaskProgress, error)
|
||||||
|
// ListActiveDailyTaskDefinitions 获取所有活跃的每日任务定义(包括star特定和全局默认)
|
||||||
|
ListActiveDailyTaskDefinitions(starID int64) ([]*model.TaskDefinition, error)
|
||||||
|
// UpdateDailyProgress 更新每日任务进度
|
||||||
|
UpdateDailyProgress(progress *model.UserDailyTaskProgress) error
|
||||||
|
// ResetAllDailyTasks 重置所有非pending状态的每日任务为pending
|
||||||
|
ResetAllDailyTasks() (int64, error)
|
||||||
|
// InitDailyTasksForUser 为用户初始化该star的所有每日任务进度
|
||||||
|
InitDailyTasksForUser(userID, starID int64) error
|
||||||
|
// AcquireAdvisoryLock 获取PostgreSQL advisory lock
|
||||||
|
AcquireAdvisoryLock(lockID int64) bool
|
||||||
|
// ReleaseAdvisoryLock 释放PostgreSQL advisory lock
|
||||||
|
ReleaseAdvisoryLock(lockID int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dailyTaskRepository Repository实现
|
||||||
|
type dailyTaskRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDailyTaskRepository 创建Repository实例
|
||||||
|
func NewDailyTaskRepository(db *gorm.DB) DailyTaskRepository {
|
||||||
|
return &dailyTaskRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserDailyProgress 获取用户指定每日任务的进度
|
||||||
|
func (r *dailyTaskRepository) GetUserDailyProgress(userID, starID int64, taskKey string) (*model.UserDailyTaskProgress, error) {
|
||||||
|
var progress model.UserDailyTaskProgress
|
||||||
|
err := r.db.Where("user_id = ? AND star_id = ? AND task_key = ?", userID, starID, taskKey).
|
||||||
|
First(&progress).Error
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &progress, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrCreateDailyProgress 获取或创建用户每日任务进度
|
||||||
|
func (r *dailyTaskRepository) GetOrCreateDailyProgress(userID, starID int64, taskKey string, def *model.TaskDefinition) (*model.UserDailyTaskProgress, error) {
|
||||||
|
progress, err := r.GetUserDailyProgress(userID, starID, taskKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if progress != nil {
|
||||||
|
return progress, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的进度记录
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
progress = &model.UserDailyTaskProgress{
|
||||||
|
UserID: userID,
|
||||||
|
StarID: starID,
|
||||||
|
TaskKey: taskKey,
|
||||||
|
Status: "pending",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
if def != nil {
|
||||||
|
progress.CreatedAt = now
|
||||||
|
progress.UpdatedAt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
err = r.db.Create(progress).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return progress, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDailyTasksByUser 获取用户的所有每日任务进度
|
||||||
|
func (r *dailyTaskRepository) ListDailyTasksByUser(userID, starID int64) ([]*model.UserDailyTaskProgress, error) {
|
||||||
|
var progressList []*model.UserDailyTaskProgress
|
||||||
|
err := r.db.Where("user_id = ? AND star_id = ?", userID, starID).
|
||||||
|
Order("id ASC").
|
||||||
|
Find(&progressList).Error
|
||||||
|
return progressList, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveDailyTaskDefinitions 获取所有活跃的每日任务定义(包括star特定和全局默认)
|
||||||
|
func (r *dailyTaskRepository) ListActiveDailyTaskDefinitions(starID int64) ([]*model.TaskDefinition, error) {
|
||||||
|
var definitions []*model.TaskDefinition
|
||||||
|
err := r.db.Where("is_active = true AND (star_id = ? OR star_id IS NULL)", starID).
|
||||||
|
Where("task_type = ?", "daily").
|
||||||
|
Order("sort_order ASC, id ASC").
|
||||||
|
Find(&definitions).Error
|
||||||
|
return definitions, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDailyProgress 更新每日任务进度
|
||||||
|
func (r *dailyTaskRepository) UpdateDailyProgress(progress *model.UserDailyTaskProgress) error {
|
||||||
|
progress.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
return r.db.Save(progress).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetAllDailyTasks 重置所有非pending状态的每日任务为pending
|
||||||
|
func (r *dailyTaskRepository) ResetAllDailyTasks() (int64, error) {
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
result := r.db.Model(&model.UserDailyTaskProgress{}).
|
||||||
|
Where("status != ?", "pending").
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": "pending",
|
||||||
|
"completed_at": nil,
|
||||||
|
"claimed_at": nil,
|
||||||
|
"updated_at": now,
|
||||||
|
})
|
||||||
|
return result.RowsAffected, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitDailyTasksForUser 为用户初始化该star的所有每日任务进度
|
||||||
|
func (r *dailyTaskRepository) InitDailyTasksForUser(userID, starID int64) error {
|
||||||
|
// 获取所有活跃的每日任务定义
|
||||||
|
definitions, err := r.ListActiveDailyTaskDefinitions(starID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(definitions) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
for _, def := range definitions {
|
||||||
|
// 检查是否已存在该任务的进度
|
||||||
|
var count int64
|
||||||
|
err := tx.Model(&model.UserDailyTaskProgress{}).
|
||||||
|
Where("user_id = ? AND star_id = ? AND task_key = ?", userID, starID, def.TaskKey).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已存在,跳过
|
||||||
|
if count > 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的进度记录
|
||||||
|
progress := &model.UserDailyTaskProgress{
|
||||||
|
UserID: userID,
|
||||||
|
StarID: starID,
|
||||||
|
TaskKey: def.TaskKey,
|
||||||
|
Status: "pending",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
if err := tx.Create(progress).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcquireAdvisoryLock 获取PostgreSQL advisory lock
|
||||||
|
func (r *dailyTaskRepository) AcquireAdvisoryLock(lockID int64) bool {
|
||||||
|
var result bool
|
||||||
|
err := r.db.Raw("SELECT pg_try_advisory_lock(?)", lockID).Scan(&result).Error
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseAdvisoryLock 释放PostgreSQL advisory lock
|
||||||
|
func (r *dailyTaskRepository) ReleaseAdvisoryLock(lockID int64) {
|
||||||
|
r.db.Exec("SELECT pg_advisory_unlock(?)", lockID)
|
||||||
|
}
|
||||||
117
backend/services/taskService/repository/onboarding_repo.go
Normal file
117
backend/services/taskService/repository/onboarding_repo.go
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
"github.com/topfans/backend/services/taskService/model"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OnboardingRepository interface {
|
||||||
|
GetOnboardingStatus(userID int64) (*model.UserOnboardingStatus, error)
|
||||||
|
GetOrCreateOnboardingStatus(userID int64) (*model.UserOnboardingStatus, error)
|
||||||
|
UpdateOnboardingStatus(status *model.UserOnboardingStatus) error
|
||||||
|
GetUserOnboardingProgress(userID int64, taskKey string) (*model.UserOnboardingProgress, error)
|
||||||
|
GetOrCreateOnboardingProgress(userID int64, taskKey string) (*model.UserOnboardingProgress, error)
|
||||||
|
ListActiveStageConfigs() ([]*model.OnboardingStageConfig, error)
|
||||||
|
ListUserOnboardingProgressByUser(userID int64) ([]*model.UserOnboardingProgress, error)
|
||||||
|
GetStageConfig(stage int) (*model.OnboardingStageConfig, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type onboardingRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewOnboardingRepository(db *gorm.DB) OnboardingRepository {
|
||||||
|
return &onboardingRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *onboardingRepository) GetOnboardingStatus(userID int64) (*model.UserOnboardingStatus, error) {
|
||||||
|
var status model.UserOnboardingStatus
|
||||||
|
err := r.db.Where("user_id = ?", userID).First(&status).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *onboardingRepository) GetOrCreateOnboardingStatus(userID int64) (*model.UserOnboardingStatus, error) {
|
||||||
|
var status model.UserOnboardingStatus
|
||||||
|
now := gorm.NowFunc().Unix()
|
||||||
|
err := r.db.Where("user_id = ?", userID).FirstOrCreate(&status, &model.UserOnboardingStatus{
|
||||||
|
UserID: userID,
|
||||||
|
CurrentStage: 0,
|
||||||
|
Status: "pending",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("Failed to GetOrCreateOnboardingStatus", zap.Int64("user_id", userID), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *onboardingRepository) UpdateOnboardingStatus(status *model.UserOnboardingStatus) error {
|
||||||
|
status.UpdatedAt = gorm.NowFunc().Unix()
|
||||||
|
if err := r.db.Save(status).Error; err != nil {
|
||||||
|
logger.Logger.Error("Failed to UpdateOnboardingStatus", zap.Int64("user_id", status.UserID), zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *onboardingRepository) GetUserOnboardingProgress(userID int64, taskKey string) (*model.UserOnboardingProgress, error) {
|
||||||
|
var progress model.UserOnboardingProgress
|
||||||
|
err := r.db.Where("user_id = ? AND task_key = ?", userID, taskKey).First(&progress).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &progress, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *onboardingRepository) GetOrCreateOnboardingProgress(userID int64, taskKey string) (*model.UserOnboardingProgress, error) {
|
||||||
|
var progress model.UserOnboardingProgress
|
||||||
|
now := gorm.NowFunc().Unix()
|
||||||
|
err := r.db.Where("user_id = ? AND task_key = ?", userID, taskKey).FirstOrCreate(&progress, &model.UserOnboardingProgress{
|
||||||
|
UserID: userID,
|
||||||
|
TaskKey: taskKey,
|
||||||
|
Status: "pending",
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("Failed to GetOrCreateOnboardingProgress", zap.Int64("user_id", userID), zap.String("task_key", taskKey), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &progress, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *onboardingRepository) ListActiveStageConfigs() ([]*model.OnboardingStageConfig, error) {
|
||||||
|
var configs []*model.OnboardingStageConfig
|
||||||
|
err := r.db.Where("is_active = ?", true).Order("sort_order ASC").Find(&configs).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("Failed to ListActiveStageConfigs", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return configs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *onboardingRepository) ListUserOnboardingProgressByUser(userID int64) ([]*model.UserOnboardingProgress, error) {
|
||||||
|
var progressList []*model.UserOnboardingProgress
|
||||||
|
err := r.db.Where("user_id = ?", userID).Find(&progressList).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("Failed to ListUserOnboardingProgressByUser", zap.Int64("user_id", userID), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return progressList, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *onboardingRepository) GetStageConfig(stage int) (*model.OnboardingStageConfig, error) {
|
||||||
|
var config model.OnboardingStageConfig
|
||||||
|
err := r.db.Where("stage = ? AND is_active = ?", stage, true).First(&config).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &config, nil
|
||||||
|
}
|
||||||
102
backend/services/taskService/repository/revenue_repo.go
Normal file
102
backend/services/taskService/repository/revenue_repo.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
"github.com/topfans/backend/services/taskService/model"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RevenueRepository interface {
|
||||||
|
CreateRevenueRecord(record *model.ExhibitionRevenueRecord) (*model.ExhibitionRevenueRecord, error)
|
||||||
|
GetRevenueRecord(id int64) (*model.ExhibitionRevenueRecord, error)
|
||||||
|
ListRevenueByUser(userID, starID int64, status string, page, pageSize int) ([]*model.ExhibitionRevenueRecord, int64, error)
|
||||||
|
ClaimRevenueRecord(id int64, userID int64) (bool, error)
|
||||||
|
UpdateRevenueStatus(id int64, status string) error
|
||||||
|
ListClaimableRevenue(limit int) ([]*model.ExhibitionRevenueRecord, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type revenueRepository struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRevenueRepository(db *gorm.DB) RevenueRepository {
|
||||||
|
return &revenueRepository{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *revenueRepository) CreateRevenueRecord(record *model.ExhibitionRevenueRecord) (*model.ExhibitionRevenueRecord, error) {
|
||||||
|
record.CreatedAt = gorm.NowFunc().Unix()
|
||||||
|
if err := r.db.Create(record).Error; err != nil {
|
||||||
|
logger.Logger.Error("Failed to CreateRevenueRecord", zap.Int64("user_id", record.UserID), zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *revenueRepository) GetRevenueRecord(id int64) (*model.ExhibitionRevenueRecord, error) {
|
||||||
|
var record model.ExhibitionRevenueRecord
|
||||||
|
if err := r.db.First(&record, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *revenueRepository) ListRevenueByUser(userID, starID int64, status string, page, pageSize int) ([]*model.ExhibitionRevenueRecord, int64, error) {
|
||||||
|
var records []*model.ExhibitionRevenueRecord
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := r.db.Model(&model.ExhibitionRevenueRecord{}).Where("user_id = ? AND star_id = ?", userID, starID)
|
||||||
|
if status != "" {
|
||||||
|
query = query.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
logger.Logger.Error("Failed to count revenue records", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err))
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil {
|
||||||
|
logger.Logger.Error("Failed to ListRevenueByUser", zap.Int64("user_id", userID), zap.Int64("star_id", starID), zap.Error(err))
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return records, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *revenueRepository) ClaimRevenueRecord(id int64, userID int64) (bool, error) {
|
||||||
|
// 乐观锁:只有在 status='claimable' 时才更新
|
||||||
|
now := gorm.NowFunc().Unix()
|
||||||
|
result := r.db.Model(&model.ExhibitionRevenueRecord{}).
|
||||||
|
Where("id = ? AND user_id = ? AND status = ?", id, userID, "claimable").
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": "claimed",
|
||||||
|
"claimed_at": now,
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
logger.Logger.Error("Failed to ClaimRevenueRecord", zap.Int64("id", id), zap.Int64("user_id", userID), zap.Error(result.Error))
|
||||||
|
return false, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.RowsAffected > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *revenueRepository) UpdateRevenueStatus(id int64, status string) error {
|
||||||
|
err := r.db.Model(&model.ExhibitionRevenueRecord{}).Where("id = ?", id).Update("status", status).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("Failed to UpdateRevenueStatus", zap.Int64("id", id), zap.String("status", status), zap.Error(err))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *revenueRepository) ListClaimableRevenue(limit int) ([]*model.ExhibitionRevenueRecord, error) {
|
||||||
|
var records []*model.ExhibitionRevenueRecord
|
||||||
|
err := r.db.Where("status = ?", "claimable").Limit(limit).Find(&records).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("Failed to ListClaimableRevenue", zap.Error(err))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
149
backend/services/taskService/worker/daily_reset_worker.go
Normal file
149
backend/services/taskService/worker/daily_reset_worker.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
package worker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/topfans/backend/pkg/logger"
|
||||||
|
"github.com/topfans/backend/services/taskService/client"
|
||||||
|
"github.com/topfans/backend/services/taskService/config"
|
||||||
|
"github.com/topfans/backend/services/taskService/repository"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DailyResetWorker struct {
|
||||||
|
dailyRepo repository.DailyTaskRepository
|
||||||
|
revenueRepo repository.RevenueRepository
|
||||||
|
userClient client.UserServiceClient
|
||||||
|
stopCh chan struct{}
|
||||||
|
wg sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDailyResetWorker(
|
||||||
|
dailyRepo repository.DailyTaskRepository,
|
||||||
|
revenueRepo repository.RevenueRepository,
|
||||||
|
userClient client.UserServiceClient,
|
||||||
|
) *DailyResetWorker {
|
||||||
|
return &DailyResetWorker{
|
||||||
|
dailyRepo: dailyRepo,
|
||||||
|
revenueRepo: revenueRepo,
|
||||||
|
userClient: userClient,
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DailyResetWorker) Start() {
|
||||||
|
w.wg.Add(1)
|
||||||
|
go w.runLoop()
|
||||||
|
logger.Logger.Info("DailyResetWorker started")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DailyResetWorker) Stop() {
|
||||||
|
close(w.stopCh)
|
||||||
|
w.wg.Wait()
|
||||||
|
logger.Logger.Info("DailyResetWorker stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DailyResetWorker) runLoop() {
|
||||||
|
defer w.wg.Done()
|
||||||
|
|
||||||
|
for {
|
||||||
|
// 计算下一个 05:00 Asia/Shanghai
|
||||||
|
now := time.Now()
|
||||||
|
loc, _ := time.LoadLocation("Asia/Shanghai")
|
||||||
|
next := time.Date(now.Year(), now.Month(), now.Day(), config.WorkerConfigData.ResetHour, config.WorkerConfigData.ResetMinute, 0, 0, loc)
|
||||||
|
if next.Before(now) {
|
||||||
|
next = next.Add(24 * time.Hour)
|
||||||
|
}
|
||||||
|
waitDuration := next.Sub(now)
|
||||||
|
|
||||||
|
logger.Logger.Info(fmt.Sprintf("DailyResetWorker: next reset at %s (in %v)", next.Format(time.RFC3339), waitDuration))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(waitDuration):
|
||||||
|
w.doResetAndAutoClaim()
|
||||||
|
case <-w.stopCh:
|
||||||
|
logger.Logger.Info("DailyResetWorker: received stop signal, exiting")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DailyResetWorker) doResetAndAutoClaim() {
|
||||||
|
// 使用 Advisory Lock 防止多实例重复执行
|
||||||
|
lockKey := time.Now().Format("20060102")
|
||||||
|
lockID, err := strconv.ParseInt(lockKey, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("DailyResetWorker: failed to parse lock key", zap.String("lock_key", lockKey), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acquired := w.dailyRepo.AcquireAdvisoryLock(lockID)
|
||||||
|
if !acquired {
|
||||||
|
logger.Logger.Info("DailyResetWorker: another instance is running daily reset, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := w.dailyRepo.ReleaseAdvisoryLock(lockID); err != nil {
|
||||||
|
logger.Logger.Error("DailyResetWorker: failed to release advisory lock", zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 1. 重置每日任务
|
||||||
|
resetCount, err := w.dailyRepo.ResetAllDailyTasks()
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("DailyResetWorker: failed to reset daily tasks", zap.Error(err))
|
||||||
|
} else {
|
||||||
|
logger.Logger.Info(fmt.Sprintf("DailyResetWorker: daily tasks reset: %d records updated", resetCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 自动发放展示收益
|
||||||
|
w.autoClaimExhibitionRevenue()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *DailyResetWorker) autoClaimExhibitionRevenue() {
|
||||||
|
batchSize := config.WorkerConfigData.RevenueBatchSize
|
||||||
|
maxRetries := config.WorkerConfigData.RevenueMaxRetries
|
||||||
|
totalClaimed := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
records, err := w.revenueRepo.ListClaimableRevenue(batchSize)
|
||||||
|
if err != nil {
|
||||||
|
logger.Logger.Error("DailyResetWorker: failed to list claimable revenue", zap.Error(err))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||||
|
_, err := w.userClient.UpdateCrystalBalance(context.Background(), record.UserID, record.StarID, record.CrystalAmount)
|
||||||
|
if err == nil {
|
||||||
|
if err := w.revenueRepo.UpdateRevenueStatus(record.ID, "claimed"); err != nil {
|
||||||
|
logger.Logger.Error("DailyResetWorker: failed to update status to claimed",
|
||||||
|
zap.Int64("record_id", record.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
totalClaimed++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
if err := w.revenueRepo.UpdateRevenueStatus(record.ID, "failed"); err != nil {
|
||||||
|
logger.Logger.Error("DailyResetWorker: failed to update status to failed",
|
||||||
|
zap.Int64("record_id", record.ID), zap.Error(err))
|
||||||
|
}
|
||||||
|
logger.Logger.Error("DailyResetWorker: failed to auto-claim revenue after retries",
|
||||||
|
zap.Int64("record_id", record.ID), zap.Error(lastErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Logger.Info(fmt.Sprintf("DailyResetWorker: auto-claim completed: %d records claimed", totalClaimed))
|
||||||
|
}
|
||||||
1178
docs/superpowers/plans/2026-04-14-task-service-implementation.md
Normal file
1178
docs/superpowers/plans/2026-04-14-task-service-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,202 @@
|
|||||||
|
# TaskService 未完成事项
|
||||||
|
|
||||||
|
> 生成时间:2026-04-14
|
||||||
|
|
||||||
|
## 一、阻塞性问题(阻断编译/运行)
|
||||||
|
|
||||||
|
### 1. Proto 编译未执行
|
||||||
|
|
||||||
|
**问题**:编译脚本已修改但未执行,`pkg/proto/` 下无生成代码。
|
||||||
|
|
||||||
|
**影响**:`go build ./...` 会失败,找不到 `task.pb.go` 和 `task.triple.go`。
|
||||||
|
|
||||||
|
**操作**:
|
||||||
|
```bash
|
||||||
|
cd backend && sh scripts/compile-proto.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**前提**:环境需安装 `go` 和 `protoc`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Dubbo Server 注册代码被注释
|
||||||
|
|
||||||
|
**文件**:`backend/services/taskService/main.go:120-124`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 注意:这里需要 TaskMobileService 和 TaskInternalService 的 RegisterHandler
|
||||||
|
// 这些会在 proto 编译后生成。在 proto 编译之前,这里会报编译错误。
|
||||||
|
// Task 15 完成 proto 编译后,需要将下面的注释替换为实际的注册代码。
|
||||||
|
|
||||||
|
logger.Logger.Info(fmt.Sprintf("taskService configured on port %d, awaiting proto registration", *port))
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**:服务启动后没有注册任何 RPC handler,客户端无法调用。
|
||||||
|
|
||||||
|
**修复**:proto 编译后需添加类似以下代码:
|
||||||
|
```go
|
||||||
|
pb.RegisterTaskMobileServiceHandler(srv, mobileProvider)
|
||||||
|
pb.RegisterTaskInternalServiceHandler(srv, internalProvider)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、代码缺陷(运行时 Bug)
|
||||||
|
|
||||||
|
### 3. `UpdateOnboardingProgress` 方法缺失
|
||||||
|
|
||||||
|
**文件**:`backend/services/taskService/repository/onboarding_repo.go`
|
||||||
|
|
||||||
|
**问题**:
|
||||||
|
- 接口 `OnboardingRepository` 缺少 `UpdateOnboardingProgress` 方法
|
||||||
|
- `onboarding_service.go:172-176` 的 `updateOnboardingProgress` 直接返回 `nil`,未实际更新数据
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *onboardingService) updateOnboardingProgress(progress *model.UserOnboardingProgress) error {
|
||||||
|
// 需要在 repository 中添加 UpdateOnboardingProgress 方法
|
||||||
|
// 这里暂时用 GORM 直接更新
|
||||||
|
return nil // ← BUG: 没有真正更新
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**:`CompleteGuide` 完成任务后进度状态不会更新。
|
||||||
|
|
||||||
|
**修复**:在 `onboarding_repo.go` 添加:
|
||||||
|
```go
|
||||||
|
// OnboardingRepository 接口添加:
|
||||||
|
UpdateOnboardingProgress(progress *model.UserOnboardingProgress) error
|
||||||
|
|
||||||
|
// 实现:
|
||||||
|
func (r *onboardingRepository) UpdateOnboardingProgress(progress *model.UserOnboardingProgress) error {
|
||||||
|
progress.UpdatedAt = gorm.NowFunc().Unix()
|
||||||
|
return r.db.Save(progress).Error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
然后 `onboarding_service.go` 中调用 `s.onboardingRepo.UpdateOnboardingProgress(progress)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. `ListCompletedDailyTasks` 方法缺失
|
||||||
|
|
||||||
|
**文件**:`backend/services/taskService/repository/daily_task_repo.go`
|
||||||
|
|
||||||
|
**问题**:`daily_task_service.go:204` 调用 `s.dailyRepo.ListCompletedDailyTasks()`,但该方法不存在。
|
||||||
|
|
||||||
|
**影响**:`ClaimAllDailyTasks` 调用会编译报错。
|
||||||
|
|
||||||
|
**修复**:在 `daily_task_repo.go` 添加:
|
||||||
|
```go
|
||||||
|
// DailyTaskRepository 接口添加:
|
||||||
|
ListCompletedDailyTasks(userID, starID int64) ([]*model.UserDailyTaskProgress, error)
|
||||||
|
|
||||||
|
// 实现:
|
||||||
|
func (r *dailyTaskRepository) ListCompletedDailyTasks(userID, starID int64) ([]*model.UserDailyTaskProgress, error) {
|
||||||
|
var progressList []*model.UserDailyTaskProgress
|
||||||
|
err := r.db.Where("user_id = ? AND star_id = ? AND status = ?", userID, starID, "completed").
|
||||||
|
Find(&progressList).Error
|
||||||
|
return progressList, err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. `CompleteGuide` 中死代码
|
||||||
|
|
||||||
|
**文件**:`backend/services/taskService/onboarding_service.go:154-156`
|
||||||
|
|
||||||
|
```go
|
||||||
|
if err := s.onboardingRepo.UpdateOnboardingStatus(&model.UserOnboardingStatus{UserID: userID}); err != nil {
|
||||||
|
// UpdateOnboardingStatus is for status table, not progress
|
||||||
|
} // ← 无意义的空调用,应该删除
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**:代码冗余,不影响功能但影响可读性。
|
||||||
|
|
||||||
|
**修复**:删除第 154-156 行。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、前端缺陷
|
||||||
|
|
||||||
|
### 6. `guide.vue` 任务完成状态判断错误
|
||||||
|
|
||||||
|
**文件**:`frontend/pages/tasks/guide.vue:129-134`
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
function isTaskCompleted(stage, taskKey) {
|
||||||
|
// 这里需要根据实际数据结构判断
|
||||||
|
// 假设 completed 状态的任务 key 会被标记
|
||||||
|
// 暂时返回 false,实际需要后端返回更详细的状态
|
||||||
|
return false // ← BUG: 永远返回 false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**:引导页面的任务永远显示为未完成,用户无法正确看到进度。
|
||||||
|
|
||||||
|
**修复**:参考后端 `GetOnboardingStatus` 的逻辑,从 `stage.required_task_keys` 和进度数据判断。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. `daily_task_service.go` 错误被忽略
|
||||||
|
|
||||||
|
**文件**:`backend/services/taskService/service/daily_task_service.go:229,234`
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 发放水晶奖励
|
||||||
|
if def.CrystalReward > 0 {
|
||||||
|
s.userClient.UpdateCrystalBalance(ctx, userID, starID, def.CrystalReward) // ← 错误被忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发放经验奖励
|
||||||
|
if def.ExpReward > 0 {
|
||||||
|
s.userClient.AddExperience(ctx, userID, starID, def.ExpReward) // ← 错误被忽略
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**影响**:`ClaimAllDailyTasks` 中奖励发放失败时不报错,可能导致用户未收到奖励。
|
||||||
|
|
||||||
|
**修复**:添加错误处理和日志。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、已完成的修复
|
||||||
|
|
||||||
|
### 8. `AddExperience` RPC 已添加
|
||||||
|
|
||||||
|
- **user.proto**:已添加 `AddExperienceRequest/Response` 消息和 RPC 定义
|
||||||
|
- **user_rpc_client.go**:已有完整实现
|
||||||
|
|
||||||
|
等待 proto 编译后即可使用。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、待执行的操作清单
|
||||||
|
|
||||||
|
| # | 操作 | 优先级 | 依赖 |
|
||||||
|
|---|------|--------|------|
|
||||||
|
| 1 | 执行 `sh scripts/compile-proto.sh` | P0 | go + protoc 环境 |
|
||||||
|
| 2 | 添加 `UpdateOnboardingProgress` 到 onboarding_repo | P0 | 无 |
|
||||||
|
| 3 | 添加 `ListCompletedDailyTasks` 到 daily_task_repo | P0 | 无 |
|
||||||
|
| 4 | 修复 main.go Dubbo 注册代码 | P0 | #1 |
|
||||||
|
| 5 | 修复 guide.vue isTaskCompleted | P1 | 无 |
|
||||||
|
| 6 | 删除 onboarding_service.go 死代码 | P2 | 无 |
|
||||||
|
| 7 | 修复 ClaimAllDailyTasks 错误处理 | P2 | 无 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、执行顺序建议
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 先执行 proto 编译(#1)
|
||||||
|
↓
|
||||||
|
2. 修复 onboarding_repo + daily_task_repo(#2, #3)
|
||||||
|
↓
|
||||||
|
3. 修复 main.go 注册代码(#4)
|
||||||
|
↓
|
||||||
|
4. 编译验证:go build ./...
|
||||||
|
↓
|
||||||
|
5. 修复前端 guide.vue(#5)
|
||||||
|
↓
|
||||||
|
6. 可选优化(#6, #7)
|
||||||
|
```
|
||||||
@ -151,6 +151,24 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/tasks/daily-tasks",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/tasks/guide",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/tasks/revenue",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
353
frontend/pages/tasks/daily-tasks.vue
Normal file
353
frontend/pages/tasks/daily-tasks.vue
Normal file
@ -0,0 +1,353 @@
|
|||||||
|
<template>
|
||||||
|
<view class="daily-tasks-container">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<Header :show-back="true" back-icon-color="#e6e6e6" :show-guide-icon="false" :show-task-icon="false" :show-star-activity-icon="false" />
|
||||||
|
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<view class="page-title">每日任务</view>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="loading" class="loading-state">
|
||||||
|
<view class="loading-spinner"></view>
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<view v-else-if="errorMessage" class="error-state">
|
||||||
|
<text class="error-text">{{ errorMessage }}</text>
|
||||||
|
<button class="retry-btn" @click="loadTasks">重试</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 任务列表 -->
|
||||||
|
<view v-else class="task-list">
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-if="tasks.length === 0" class="empty-state">
|
||||||
|
<text class="empty-text">暂无每日任务</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 任务项 -->
|
||||||
|
<view v-for="task in tasks" :key="task.task_key" class="task-item">
|
||||||
|
<view class="task-info">
|
||||||
|
<view class="task-name">{{ task.name }}</view>
|
||||||
|
<view class="task-desc">{{ task.description }}</view>
|
||||||
|
<view class="task-reward">
|
||||||
|
<text class="reward-icon">💎</text>
|
||||||
|
<text class="reward-value">{{ task.crystal_reward }}</text>
|
||||||
|
<text class="reward-sep">|</text>
|
||||||
|
<text class="reward-icon">⭐</text>
|
||||||
|
<text class="reward-value">{{ task.exp_reward }} 经验</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="task-action">
|
||||||
|
<!-- 状态标签 -->
|
||||||
|
<view class="status-badge" :class="getStatusClass(task.status)">
|
||||||
|
{{ getStatusText(task.status) }}
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 领取按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="task.can_claim"
|
||||||
|
class="claim-btn"
|
||||||
|
:loading="claimingTask === task.task_key"
|
||||||
|
@click="handleClaim(task)"
|
||||||
|
>
|
||||||
|
领取
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 一键领取按钮 -->
|
||||||
|
<view v-if="!loading && !errorMessage && hasClaimableTasks" class="claim-all-bar">
|
||||||
|
<button class="claim-all-btn" :loading="claimingAll" @click="handleClaimAll">
|
||||||
|
一键领取 ({{ claimableCount }})
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 下拉刷新 -->
|
||||||
|
<view v-if="!loading" class="pull-to-refresh" @click="handlePullRefresh">
|
||||||
|
<text class="refresh-text">{{ refreshing ? '刷新中...' : '下拉刷新' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import Header from '../components/Header.vue'
|
||||||
|
import { getDailyTasks, claimDailyTask, claimAllDailyTasks } from '@/utils/task-api.js'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const tasks = ref([])
|
||||||
|
const claimingTask = ref('')
|
||||||
|
const claimingAll = ref(false)
|
||||||
|
const starId = ref(1)
|
||||||
|
|
||||||
|
const hasClaimableTasks = computed(() => tasks.value.some(t => t.can_claim))
|
||||||
|
const claimableCount = computed(() => tasks.value.filter(t => t.can_claim).length)
|
||||||
|
|
||||||
|
function getStatusClass(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': return 'status-pending'
|
||||||
|
case 'completed': return 'status-completed'
|
||||||
|
case 'claimed': return 'status-claimed'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusText(status) {
|
||||||
|
switch (status) {
|
||||||
|
case 'pending': return '进行中'
|
||||||
|
case 'completed': return '可领取'
|
||||||
|
case 'claimed': return '已领取'
|
||||||
|
default: return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
const fanProfile = uni.getStorageSync('fan_profile')
|
||||||
|
if (fanProfile && fanProfile.star_id) {
|
||||||
|
starId.value = fanProfile.star_id
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await getDailyTasks(starId.value)
|
||||||
|
tasks.value = res.data?.tasks || []
|
||||||
|
} catch (err) {
|
||||||
|
console.error('loadTasks error:', err)
|
||||||
|
errorMessage.value = err.message || '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClaim(task) {
|
||||||
|
if (claimingTask.value) return
|
||||||
|
try {
|
||||||
|
claimingTask.value = task.task_key
|
||||||
|
await claimDailyTask(task.task_key, starId.value)
|
||||||
|
await loadTasks()
|
||||||
|
uni.showToast({ title: '领取成功', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('handleClaim error:', err)
|
||||||
|
uni.showToast({ title: err.message || '领取失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
claimingTask.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClaimAll() {
|
||||||
|
if (claimingAll.value) return
|
||||||
|
try {
|
||||||
|
claimingAll.value = true
|
||||||
|
await claimAllDailyTasks(starId.value)
|
||||||
|
await loadTasks()
|
||||||
|
uni.showToast({ title: '领取成功', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('handleClaimAll error:', err)
|
||||||
|
uni.showToast({ title: err.message || '领取失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
claimingAll.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePullRefresh() {
|
||||||
|
refreshing.value = true
|
||||||
|
loadTasks()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTasks()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.daily-tasks-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding-bottom: 120rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border: 4rpx solid #e0e0e0;
|
||||||
|
border-top-color: #6c5ce7;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text,
|
||||||
|
.error-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 16rpx 40rpx;
|
||||||
|
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
padding: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-item {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 30rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-reward {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-icon {
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-value {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6c5ce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-sep {
|
||||||
|
color: #e0e0e0;
|
||||||
|
margin: 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-action {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pending {
|
||||||
|
background: #e8f4ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-completed {
|
||||||
|
background: #f6ffed;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-claimed {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-btn {
|
||||||
|
padding: 12rpx 30rpx;
|
||||||
|
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
min-width: 120rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-all-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 20rpx 30rpx;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-all-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-to-refresh {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
424
frontend/pages/tasks/guide.vue
Normal file
424
frontend/pages/tasks/guide.vue
Normal file
@ -0,0 +1,424 @@
|
|||||||
|
<template>
|
||||||
|
<view class="guide-container">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<Header :show-back="true" back-icon-color="#e6e6e6" :show-guide-icon="false" :show-task-icon="false" :show-star-activity-icon="false" />
|
||||||
|
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<view class="page-title">新手引导</view>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="loading" class="loading-state">
|
||||||
|
<view class="loading-spinner"></view>
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<view v-else-if="errorMessage" class="error-state">
|
||||||
|
<text class="error-text">{{ errorMessage }}</text>
|
||||||
|
<button class="retry-btn" @click="loadStatus">重试</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 引导内容 -->
|
||||||
|
<view v-else class="guide-content">
|
||||||
|
<!-- 当前阶段信息 -->
|
||||||
|
<view class="current-stage-card">
|
||||||
|
<view class="stage-header">
|
||||||
|
<text class="stage-label">当前阶段</text>
|
||||||
|
<text class="stage-number">第 {{ currentStage }} 阶段</text>
|
||||||
|
</view>
|
||||||
|
<view class="stage-name">{{ currentStageName }}</view>
|
||||||
|
<view class="stage-reward" v-if="currentStageReward > 0">
|
||||||
|
<text class="reward-icon">💎</text>
|
||||||
|
<text class="reward-value">{{ currentStageReward }}</text>
|
||||||
|
<text class="reward-sep">|</text>
|
||||||
|
<text class="reward-icon">⭐</text>
|
||||||
|
<text class="reward-value">{{ currentStageExp }} 经验</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 阶段列表 -->
|
||||||
|
<view class="stages-list">
|
||||||
|
<view v-for="stage in stages" :key="stage.stage" class="stage-item" :class="{ 'is-current': stage.is_current, 'is-completed': stage.status === 'completed' }">
|
||||||
|
<view class="stage-item-header">
|
||||||
|
<view class="stage-item-name">
|
||||||
|
<text v-if="stage.status === 'completed'" class="check-icon">✓</text>
|
||||||
|
<text>{{ stage.name }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="stage.is_current" class="current-tag">进行中</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 该阶段的任务列表 -->
|
||||||
|
<view class="task-list-mini">
|
||||||
|
<view v-for="taskKey in stage.required_task_keys" :key="taskKey" class="task-key-item">
|
||||||
|
<text class="task-check" :class="{ 'is-done': isTaskCompleted(stage, taskKey) }">
|
||||||
|
{{ isTaskCompleted(stage, taskKey) ? '✓' : '○' }}
|
||||||
|
</text>
|
||||||
|
<text class="task-key-name">{{ taskKey }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<view class="action-bar">
|
||||||
|
<!-- 进入下一阶段按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="canAdvance"
|
||||||
|
class="advance-btn"
|
||||||
|
:loading="advancing"
|
||||||
|
@click="handleAdvance"
|
||||||
|
>
|
||||||
|
进入下一阶段
|
||||||
|
</button>
|
||||||
|
<view v-else-if="!allCurrentTasksDone" class="advance-hint">
|
||||||
|
完成当前阶段所有任务后可进入下一阶段
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 领取奖励按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="canClaimReward"
|
||||||
|
class="claim-btn"
|
||||||
|
:loading="claimingReward"
|
||||||
|
@click="handleClaimReward"
|
||||||
|
>
|
||||||
|
领取奖励
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 下拉刷新 -->
|
||||||
|
<view v-if="!loading" class="pull-to-refresh" @click="handleRefresh">
|
||||||
|
<text class="refresh-text">{{ refreshing ? '刷新中...' : '下拉刷新' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import Header from '../components/Header.vue'
|
||||||
|
import { getOnboardingStatus, advanceStage, claimOnboardingReward } from '@/utils/task-api.js'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const currentStage = ref(0)
|
||||||
|
const currentStatus = ref('')
|
||||||
|
const stages = ref([])
|
||||||
|
const advancing = ref(false)
|
||||||
|
const claimingReward = ref(false)
|
||||||
|
|
||||||
|
// 当前阶段配置
|
||||||
|
const currentStageConfig = computed(() => stages.value.find(s => s.is_current))
|
||||||
|
const currentStageName = computed(() => currentStageConfig.value?.name || '新手引导')
|
||||||
|
const currentStageReward = computed(() => currentStageConfig.value?.crystal_reward || 0)
|
||||||
|
const currentStageExp = computed(() => currentStageConfig.value?.exp_reward || 0)
|
||||||
|
|
||||||
|
// 当前阶段所有任务是否完成
|
||||||
|
const currentTasksDone = computed(() => {
|
||||||
|
if (!currentStageConfig.value) return false
|
||||||
|
return currentStageConfig.value.required_task_keys.every(key =>
|
||||||
|
isTaskCompleted(currentStageConfig.value, key)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const allCurrentTasksDone = computed(() => currentTasksDone.value)
|
||||||
|
const canAdvance = computed(() => currentStatus.value === 'in_progress' && currentTasksDone.value)
|
||||||
|
const canClaimReward = computed(() => currentStatus.value === 'in_progress' && currentTasksDone.value)
|
||||||
|
|
||||||
|
// 检查某个阶段的任务是否完成(简化:直接从 stages 状态判断)
|
||||||
|
function isTaskCompleted(stage, taskKey) {
|
||||||
|
// 这里需要根据实际数据结构判断
|
||||||
|
// 假设 completed 状态的任务 key 会被标记
|
||||||
|
// 暂时返回 false,实际需要后端返回更详细的状态
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatus() {
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
|
||||||
|
const res = await getOnboardingStatus()
|
||||||
|
const data = res.data || {}
|
||||||
|
|
||||||
|
currentStage.value = data.current_stage || 0
|
||||||
|
currentStatus.value = data.status || 'pending'
|
||||||
|
stages.value = data.stages || []
|
||||||
|
} catch (err) {
|
||||||
|
console.error('loadStatus error:', err)
|
||||||
|
errorMessage.value = err.message || '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAdvance() {
|
||||||
|
if (advancing.value) return
|
||||||
|
try {
|
||||||
|
advancing.value = true
|
||||||
|
const nextStage = currentStage.value + 1
|
||||||
|
await advanceStage(nextStage)
|
||||||
|
await loadStatus()
|
||||||
|
uni.showToast({ title: '已进入下一阶段', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('handleAdvance error:', err)
|
||||||
|
uni.showToast({ title: err.message || '进入下一阶段失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
advancing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClaimReward() {
|
||||||
|
if (claimingReward.value) return
|
||||||
|
try {
|
||||||
|
claimingReward.value = true
|
||||||
|
await claimOnboardingReward(currentStage.value)
|
||||||
|
await loadStatus()
|
||||||
|
uni.showToast({ title: '奖励领取成功', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('handleClaimReward error:', err)
|
||||||
|
uni.showToast({ title: err.message || '领取失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
claimingReward.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
refreshing.value = true
|
||||||
|
loadStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadStatus()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.guide-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding-bottom: 120rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border: 4rpx solid #e0e0e0;
|
||||||
|
border-top-color: #6c5ce7;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text,
|
||||||
|
.error-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 16rpx 40rpx;
|
||||||
|
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-content {
|
||||||
|
padding: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-stage-card {
|
||||||
|
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 40rpx;
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-label {
|
||||||
|
font-size: 28rpx;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-number {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-name {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-reward {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-icon {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-value {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward-sep {
|
||||||
|
opacity: 0.5;
|
||||||
|
margin: 0 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stages-list {
|
||||||
|
margin-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-item {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 30rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-item.is-current {
|
||||||
|
border: 2rpx solid #6c5ce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-item.is-completed {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-item-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-item-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-icon {
|
||||||
|
color: #52c41a;
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-tag {
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
background: #6c5ce7;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list-mini {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-key-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-check {
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-check.is-done {
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-key-name {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-bar {
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advance-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.advance-hint {
|
||||||
|
text-align: center;
|
||||||
|
color: #999;
|
||||||
|
font-size: 26rpx;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
background: linear-gradient(135deg, #ffd700, #ffb347);
|
||||||
|
color: #333;
|
||||||
|
border-radius: 50rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-to-refresh {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
488
frontend/pages/tasks/revenue.vue
Normal file
488
frontend/pages/tasks/revenue.vue
Normal file
@ -0,0 +1,488 @@
|
|||||||
|
<template>
|
||||||
|
<view class="revenue-container">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<Header :show-back="true" back-icon-color="#e6e6e6" :show-guide-icon="false" :show-task-icon="false" :show-star-activity-icon="false" />
|
||||||
|
|
||||||
|
<!-- 页面标题 -->
|
||||||
|
<view class="page-title">展示收益</view>
|
||||||
|
|
||||||
|
<!-- Tab 切换 -->
|
||||||
|
<view class="tab-bar">
|
||||||
|
<view
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ 'is-active': currentTab === 'claimable' }"
|
||||||
|
@click="switchTab('claimable')"
|
||||||
|
>
|
||||||
|
可领取
|
||||||
|
<view v-if="claimableCount > 0" class="tab-badge">{{ claimableCount }}</view>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ 'is-active': currentTab === 'claimed' }"
|
||||||
|
@click="switchTab('claimed')"
|
||||||
|
>
|
||||||
|
已领取
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载状态 -->
|
||||||
|
<view v-if="loading" class="loading-state">
|
||||||
|
<view class="loading-spinner"></view>
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 错误状态 -->
|
||||||
|
<view v-else-if="errorMessage" class="error-state">
|
||||||
|
<text class="error-text">{{ errorMessage }}</text>
|
||||||
|
<button class="retry-btn" @click="loadRevenue">重试</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 收益列表 -->
|
||||||
|
<view v-else class="revenue-list">
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<view v-if="items.length === 0" class="empty-state">
|
||||||
|
<text class="empty-text">{{ currentTab === 'claimable' ? '暂无可领取的收益' : '暂无已领取记录' }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 收益项 -->
|
||||||
|
<view v-for="item in items" :key="item.id" class="revenue-item">
|
||||||
|
<view class="revenue-info">
|
||||||
|
<!-- 占位图 -->
|
||||||
|
<view class="exhibition-thumb">
|
||||||
|
<text class="thumb-placeholder">🎨</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="revenue-detail">
|
||||||
|
<view class="revenue-title">
|
||||||
|
<text class="slot-type-badge" :class="item.slot_type">
|
||||||
|
{{ item.slot_type === 'own' ? '自己的展位' : '好友展位' }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="revenue-cycle">
|
||||||
|
{{ formatTime(item.cycle_start_time) }} - {{ formatTime(item.cycle_end_time) }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="revenue-action">
|
||||||
|
<view class="crystal-amount">
|
||||||
|
<text class="crystal-icon">💎</text>
|
||||||
|
<text class="crystal-value">{{ item.crystal_amount }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 领取按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="currentTab === 'claimable' && item.can_claim"
|
||||||
|
class="claim-btn"
|
||||||
|
:loading="claimingId === item.id"
|
||||||
|
@click="handleClaim(item)"
|
||||||
|
>
|
||||||
|
领取
|
||||||
|
</button>
|
||||||
|
<view v-else-if="currentTab === 'claimed'" class="claimed-tag">
|
||||||
|
已领取
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 加载更多 -->
|
||||||
|
<view v-if="hasMore && !loadingMore" class="load-more" @click="loadMore">
|
||||||
|
<text class="load-more-text">加载更多</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="loadingMore" class="loading-more">
|
||||||
|
<text class="loading-more-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 一键领取按钮 -->
|
||||||
|
<view v-if="!loading && !errorMessage && currentTab === 'claimable' && claimableCount > 0" class="claim-all-bar">
|
||||||
|
<button class="claim-all-btn" :loading="claimingAll" @click="handleClaimAll">
|
||||||
|
一键领取 ({{ claimableCount }})
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 下拉刷新 -->
|
||||||
|
<view v-if="!loading" class="pull-to-refresh" @click="handleRefresh">
|
||||||
|
<text class="refresh-text">{{ refreshing ? '刷新中...' : '下拉刷新' }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import Header from '../components/Header.vue'
|
||||||
|
import { getExhibitionRevenue, claimExhibitionRevenue, claimAllExhibitionRevenue } from '@/utils/task-api.js'
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadingMore = ref(false)
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const currentTab = ref('claimable')
|
||||||
|
const items = ref([])
|
||||||
|
const total = ref(0)
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = ref(20)
|
||||||
|
const claimingId = ref('')
|
||||||
|
const claimingAll = ref(false)
|
||||||
|
const starId = ref(1)
|
||||||
|
|
||||||
|
const hasMore = computed(() => items.value.length < total.value)
|
||||||
|
const claimableCount = computed(() => items.value.filter(i => i.can_claim).length)
|
||||||
|
|
||||||
|
function formatTime(timestamp) {
|
||||||
|
if (!timestamp) return ''
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRevenue(append = false) {
|
||||||
|
try {
|
||||||
|
if (!append) {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
} else {
|
||||||
|
loadingMore.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const fanProfile = uni.getStorageSync('fan_profile')
|
||||||
|
if (fanProfile && fanProfile.star_id) {
|
||||||
|
starId.value = fanProfile.star_id
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = currentTab.value === 'claimable' ? 'claimable' : 'claimed'
|
||||||
|
const res = await getExhibitionRevenue(starId.value, status, page.value, pageSize.value)
|
||||||
|
const data = res.data || {}
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
items.value = [...items.value, ...(data.items || [])]
|
||||||
|
} else {
|
||||||
|
items.value = data.items || []
|
||||||
|
}
|
||||||
|
total.value = data.total || 0
|
||||||
|
} catch (err) {
|
||||||
|
console.error('loadRevenue error:', err)
|
||||||
|
if (!append) {
|
||||||
|
errorMessage.value = err.message || '加载失败'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
loadingMore.value = false
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchTab(tab) {
|
||||||
|
if (currentTab.value === tab) return
|
||||||
|
currentTab.value = tab
|
||||||
|
page.value = 1
|
||||||
|
items.value = []
|
||||||
|
total.value = 0
|
||||||
|
await loadRevenue()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (loadingMore.value || !hasMore.value) return
|
||||||
|
page.value++
|
||||||
|
await loadRevenue(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClaim(item) {
|
||||||
|
if (claimingId.value) return
|
||||||
|
try {
|
||||||
|
claimingId.value = item.id
|
||||||
|
await claimExhibitionRevenue(item.id, starId.value)
|
||||||
|
await loadRevenue()
|
||||||
|
uni.showToast({ title: '领取成功', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('handleClaim error:', err)
|
||||||
|
uni.showToast({ title: err.message || '领取失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
claimingId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClaimAll() {
|
||||||
|
if (claimingAll.value) return
|
||||||
|
try {
|
||||||
|
claimingAll.value = true
|
||||||
|
await claimAllExhibitionRevenue(starId.value)
|
||||||
|
await loadRevenue()
|
||||||
|
uni.showToast({ title: '领取成功', icon: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('handleClaimAll error:', err)
|
||||||
|
uni.showToast({ title: err.message || '领取失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
claimingAll.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
refreshing.value = true
|
||||||
|
page.value = 1
|
||||||
|
loadRevenue()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadRevenue()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.revenue-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding-bottom: 120rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 30rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #666;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.is-active {
|
||||||
|
color: #6c5ce7;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.is-active::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 60rpx;
|
||||||
|
height: 4rpx;
|
||||||
|
background: #6c5ce7;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 10rpx;
|
||||||
|
right: 30rpx;
|
||||||
|
min-width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
background: #ff4d4f;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state,
|
||||||
|
.error-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 60rpx;
|
||||||
|
height: 60rpx;
|
||||||
|
border: 4rpx solid #e0e0e0;
|
||||||
|
border-top-color: #6c5ce7;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text,
|
||||||
|
.error-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
margin-top: 20rpx;
|
||||||
|
padding: 16rpx 40rpx;
|
||||||
|
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-list {
|
||||||
|
padding: 0 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-item {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 30rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20rpx;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exhibition-thumb {
|
||||||
|
width: 100rpx;
|
||||||
|
height: 100rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-placeholder {
|
||||||
|
font-size: 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-detail {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-type-badge {
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-type-badge.own {
|
||||||
|
background: #e8f4ff;
|
||||||
|
color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-type-badge.friend {
|
||||||
|
background: #fff7e6;
|
||||||
|
color: #fa8c16;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-cycle {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.revenue-action {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crystal-amount {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crystal-icon {
|
||||||
|
font-size: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crystal-value {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #6c5ce7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-btn {
|
||||||
|
padding: 12rpx 30rpx;
|
||||||
|
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
min-width: 120rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claimed-tag {
|
||||||
|
padding: 12rpx 30rpx;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #999;
|
||||||
|
border-radius: 30rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more,
|
||||||
|
.loading-more {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-text,
|
||||||
|
.loading-more-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-all-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 20rpx 30rpx;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.claim-all-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
background: linear-gradient(135deg, #6c5ce7, #a29bfe);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 50rpx;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pull-to-refresh {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 30rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-text {
|
||||||
|
color: #999;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
103
frontend/utils/task-api.js
Normal file
103
frontend/utils/task-api.js
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* taskService API 调用封装
|
||||||
|
* 所有 API 通过 gateway 路由到 taskService (Triple/HTTP)
|
||||||
|
* baseURL 已配置在 api.js 中指向 gateway (端口 8080)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from './api.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取每日任务列表
|
||||||
|
* @param {number} starId - 明星ID
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const getDailyTasks = (starId) =>
|
||||||
|
request({ url: '/api/tasks/daily', data: { star_id: starId } })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上报任务事件
|
||||||
|
* @param {string} eventType - 事件类型(如 daily_login, daily_browse_asset)
|
||||||
|
* @param {number} starId - 明星ID
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const reportEvent = (eventType, starId) =>
|
||||||
|
request({ url: '/api/tasks/report-event', method: 'POST', data: { event_type: eventType, star_id: starId } })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领取单个每日任务奖励
|
||||||
|
* @param {string} taskKey - 任务key
|
||||||
|
* @param {number} starId - 明星ID
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const claimDailyTask = (taskKey, starId) =>
|
||||||
|
request({ url: '/api/tasks/daily/claim', method: 'POST', data: { task_key: taskKey, star_id: starId } })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键领取所有已完成每日任务
|
||||||
|
* @param {number} starId - 明星ID
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const claimAllDailyTasks = (starId) =>
|
||||||
|
request({ url: '/api/tasks/daily/claim-all', method: 'POST', data: { star_id: starId } })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成引导步骤
|
||||||
|
* @param {string} taskKey - 引导任务key(如 square_home, profile_edit)
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const completeGuide = (taskKey) =>
|
||||||
|
request({ url: '/api/tasks/guide/complete', method: 'POST', data: { task_key: taskKey } })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取引导状态
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const getOnboardingStatus = () =>
|
||||||
|
request({ url: '/api/tasks/onboarding/status' })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进入下一阶段
|
||||||
|
* @param {number} targetStage - 目标阶段
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const advanceStage = (targetStage) =>
|
||||||
|
request({ url: '/api/tasks/onboarding/advance-stage', method: 'POST', data: { target_stage: targetStage } })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领取引导阶段奖励
|
||||||
|
* @param {number} stage - 阶段号
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const claimOnboardingReward = (stage) =>
|
||||||
|
request({ url: '/api/tasks/onboarding/claim-reward', method: 'POST', data: { stage } })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取展示收益列表
|
||||||
|
* @param {number} starId - 明星ID
|
||||||
|
* @param {string} status - 状态筛选(claimable/claimed),可选
|
||||||
|
* @param {number} page - 页码
|
||||||
|
* @param {number} pageSize - 每页条数
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const getExhibitionRevenue = (starId, status, page, pageSize) =>
|
||||||
|
request({
|
||||||
|
url: '/api/tasks/exhibition-revenue',
|
||||||
|
data: { star_id: starId, status, page, page_size: pageSize }
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 领取单个展示收益
|
||||||
|
* @param {number} revenueId - 收益记录ID
|
||||||
|
* @param {number} starId - 明星ID
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const claimExhibitionRevenue = (revenueId, starId) =>
|
||||||
|
request({ url: '/api/tasks/exhibition-revenue/claim', method: 'POST', data: { revenue_id: revenueId, star_id: starId } })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键领取所有可领取展示收益
|
||||||
|
* @param {number} starId - 明星ID
|
||||||
|
* @returns {Promise}
|
||||||
|
*/
|
||||||
|
export const claimAllExhibitionRevenue = (starId) =>
|
||||||
|
request({ url: '/api/tasks/exhibition-revenue/claim-all', method: 'POST', data: { star_id: starId } })
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 378 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 230 KiB |
Loading…
Reference in New Issue
Block a user