feat: 每日任务集成

This commit is contained in:
zheng020 2026-04-14 14:32:47 +08:00
parent 8284999b3a
commit ad20197352
23 changed files with 4278 additions and 2 deletions

241
backend/proto/task.proto Normal file
View 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);
}

View File

@ -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 {
// RPCassetService调用 // RPCassetService调用
rpc UpdateAssetsCount(UpdateAssetsCountRequest) returns (UpdateAssetsCountResponse); rpc UpdateAssetsCount(UpdateAssetsCountRequest) returns (UpdateAssetsCountResponse);
// RPCtaskService调用
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"

View File

@ -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 已清理"

View 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
}

View 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)
}

View 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 workergoroutine 中启动)
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()
}

View 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" }

View File

@ -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
}

View 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)
}

View 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)
}

View 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
}

View 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
}

View 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))
}

File diff suppressed because it is too large Load Diff

View File

@ -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
```

View File

@ -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": {

View 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>

View 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>

View 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
View 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 } })

BIN
image.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB