Compare commits

...

4 Commits

Author SHA1 Message Date
ad20197352 feat: 每日任务集成 2026-04-14 14:32:56 +08:00
8284999b3a docs: change TypeScript to JavaScript in frontend types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 14:32:56 +08:00
4e2e867c06 docs: fix starbook refactor spec after review
- Fix category_name from "典藏" to "普通" for regular type
- Clarify 6 items max display (更多> not counted)
- Document cover_url_signed is generated on-the-fly, not stored
- Add star_id comes from JWT context note
- Document Gateway NULL→0 conversion for grade
- Add toChineseGrade helper function for frontend

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 14:32:56 +08:00
11bcfcd68b docs: update starbook refactor spec with slot table and dynamic total_slots
- Add starbook_slots table (new, separate from booth_slots)
- Change total_slots from hardcoded 15 to dynamic fan_profiles.starbook_limit
- Clarify same asset_id cannot be registered as multiple types
- Add placed_at to SlotAsset proto and TypeScript
- Fix grade=0 vs NULL explanation in DB/API contract
- Update affected scope (4 new tables, new StarbookSlotRepository)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 14:32:56 +08:00
24 changed files with 4980 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

@ -0,0 +1,702 @@
# 典藏/活动藏品体系 + 星册重构设计
> 日期2026-04-13
> 状态:已评审通过
> 负责人zheng020
---
## 零、重构后页面效果
### 星册主页StarbookContent.vue
```
┌─────────────────────────────────────────────┐
│ ✕ 返回 星册 │ ← Header
├─────────────────────────────────────────────┤
│ 背景图starbook.jpg满屏
│ │
│ ┌─────────────────────────────────────┐ │
│ │ │ │
│ │ [ 普通 ] [ 典藏 ] [ 活动 ] │ │ ← 类型Tab横向滚动
│ │ │ │
│ ├─────────────────────────────────────┤ │
│ │ │ │
│ │ 普通 · 等级五 │ │ ← 普通grade 大在上
│ │ ┌────────┐ ┌────────┐ ┌────────┐│ │
│ │ │ 封面 │ │ 封面 │ │ 封面 ││ │
│ │ └────────┘ └────────┘ └────────┘│ │
│ │ 铸爱-A 铸爱-B 铸爱-C ★820 │ │
│ │ │ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐│ │
│ │ │ 封面 │ │ 封面 │ │ 更多> ││ │
│ │ └────────┘ └────────┘ └────────┘│ │
│ │ 铸爱-D 铸爱-E [点击更多] │ │ ← 超出6个显示"更多>"
│ │ │ │
│ │ ───────────────────────────────── │ │
│ │ │ │
│ │ 普通 · 等级四 │ │
│ │ ┌────────┐ ┌────────┐ │ │
│ │ │ 封面 │ │ 封面 │ │ │
│ │ └────────┘ └────────┘ │ │
│ │ 铸爱-F 铸爱-G │ │
│ │ │ │
│ │ ...(等级三→等级一) │ │
│ │ │ │
│ └─────────────────────────────────────┘ │
│ │
├─────────────────────────────────────────────┤
│ [广场] [星册] [铸爱] [星际] [好友] │ ← BottomNav
└─────────────────────────────────────────────┘
```
**切换到"典藏"时:**
```
│ [ 普通 ] [ 典藏 ] [ 活动 ] │ ← 典藏Tab高亮
├─────────────────────────────────────────────┤
│ 典藏 · 限定典藏 │ ← 按category分组无grade
│ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ 封面 │ │ 封面 │ │ 更多> │ │
│ └────────┘ └────────┘ └────────┘ │
│ 典藏-A 典藏-B [点击更多] │
│ │
│ 典藏 · 经典典藏 │
│ ┌────────┐ ┌────────┐ │
│ │ 封面 │ │ 封面 │ │
│ └────────┘ └────────┘ │
│ 典藏-C 典藏-D │
│ │
│ ... │
```
**切换到"活动"时:**
```
│ [ 普通 ] [ 典藏 ] [ 活动 ] │ ← 活动Tab高亮
├─────────────────────────────────────────────┤
│ 活动 · 生日会 │ ← 按activity_type分组
│ ┌────────┐ ┌────────┐ │
│ │ 封面 │ │ 封面 │ │
│ └────────┘ └────────┘ │
│ 活动-A 活动-B │
│ │
│ 活动 · 演唱会 │
│ ┌────────┐ ┌────────┐ │
│ │ ... │ │ ... │ │
│ └────────┘ └────────┘ │
```
**页面说明:**
- **无槽位展示区**:新设计完全移除槽位概念,改为纯分组浏览
- **类型Tab**`[普通] [典藏] [活动]` 三选一,点击切换
- **默认选中"普通"**:进入页面默认展示普通藏品
- **普通**:按 grade 从等级五顶部到等级一底部排列grade 大的在上
- **典藏**:按 category 子分类分组,无 grade
- **活动**:按 activity_type 分组,无 grade
- **每组最多显示6个NftCard**超出6个显示"更多>"卡片("更多>"不计入6个之内
- **"更多>"卡片**:点击跳转 `/pages/starbook/items?type=regular&category=castlove&grade=5`
---
### 查看更多页面(/pages/starbook/items
```
┌─────────────────────────────────────────────┐
│ ← 返回 普通 · 等级五 │ ← Header
├─────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐│
│ │ 封面图 │ │ 封面图 │ │ 封面图 ││
│ └──────────┘ └──────────┘ └──────────┘│
│ 铸爱藏品-A 铸爱藏品-B 铸爱藏品-C │
│ ★820 ★450 ★120 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐│
│ │ 封面图 │ │ 封面图 │ │ 封面图 ││
│ └──────────┘ └──────────┘ └──────────┘│
│ 铸爱藏品-D 铸爱藏品-E 铸爱藏品-F │
│ ★98 ★45 ★30 │
│ │
│ ─────── 第 1/3 页 ─────── │
│ │
├─────────────────────────────────────────────┤
│ [广场] [星册] [铸爱] [星际] [好友] │
└─────────────────────────────────────────────┘
```
**页面说明:**
- **路由参数**`?type=regular&category=castlove&grade=5&page=1`
- **布局**3列网格每行3个 NftCard
- **分页**每次加载20条触底自动加载下一页
---
## 一、背景与目标
### 1.1 现状问题
- `StarbookContent.vue` 混用通用 `getMyAssetsApi`,无法区分藏品类型(普通/典藏/活动)
- `assets` 表只有一套,没有类型区分字段
- 典藏藏品有子分类category需求但目前表结构不支持
- 普通藏品有等级grade需求但目前表结构不支持
- 活动藏品需要独立的生命周期管理
- 前端封面 URL 逐个解析(`Promise.all` + 多次 OSS 预签名调用),性能差
- 前端多重生命周期触发(`onMounted` + `onActivated` + `onWatch` + `onShow`),存在重复请求
### 1.2 重构目标
1. 建立普通藏品、典藏藏品、活动藏品三套并行的数据表结构
2. 普通藏品按 grade1/2/3分组展示典藏按 category 子分类分组,活动按 activity_type 分组
3. 通过 `asset_registry` 统一索引表实现跨类型统一查询
4. 后端批量返回预签名封面 URL前端零额外 OSS 请求
5. 星册页面按类型 + 子分类/等级分组展示,每组最多展示 6 条
6. "查看更多" 跳转独立页面,支持分页
7. 普通藏品(铸爱流程)、典藏藏品、活动藏品均可放入星册
---
## 二、数据模型
### 2.1 现有表(不变动)
| 表名 | 说明 |
|------|------|
| `assets` | 普通藏品(铸爱流程),结构不变 |
**说明:** `cover_url_signed`预签名URL不在数据库中存储由 Service 层在响应时动态生成调用OSS生成预签名URL避免URL过期问题。
### 2.2 新建表
#### 2.2.1 典藏藏品表 `collection_assets`
```sql
CREATE TABLE collection_assets (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT UNIQUE NOT NULL, -- 关联 assets 表主键
owner_uid BIGINT NOT NULL,
star_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
cover_url VARCHAR(500) NOT NULL,
category VARCHAR(50) NOT NULL, -- 典藏子分类(动态值,示例:'limited', 'classic'
like_count INT NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 0, -- 0=Pending, 1=Active
metadata JSONB, -- 预留扩展字段
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_owner_star_name UNIQUE (owner_uid, star_id, name)
);
CREATE INDEX idx_collection_owner_star ON collection_assets (owner_uid, star_id);
CREATE INDEX idx_collection_category ON collection_assets (category);
CREATE INDEX idx_collection_asset_id ON collection_assets (asset_id);
```
#### 2.2.2 活动藏品表 `activity_assets`
```sql
CREATE TABLE activity_assets (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT UNIQUE NOT NULL,
owner_uid BIGINT NOT NULL,
star_id BIGINT NOT NULL,
activity_id BIGINT NOT NULL, -- 所属活动 ID
activity_type VARCHAR(50) NOT NULL, -- 活动类型(动态值,示例:'birthday', 'anniversary', 'concert'
name VARCHAR(100) NOT NULL,
cover_url VARCHAR(500) NOT NULL,
like_count INT NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 0,
metadata JSONB,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_owner_activity_name UNIQUE (owner_uid, activity_id, name)
);
CREATE INDEX idx_activity_owner ON activity_assets (owner_uid, activity_id);
CREATE INDEX idx_activity_star ON activity_assets (star_id, activity_id);
CREATE INDEX idx_activity_type ON activity_assets (activity_type);
CREATE INDEX idx_activity_asset_id ON activity_assets (asset_id);
```
#### 2.2.3 统一索引表 `asset_registry`
```sql
CREATE TABLE asset_registry (
id BIGSERIAL PRIMARY KEY,
asset_id BIGINT NOT NULL,
asset_type VARCHAR(20) NOT NULL, -- 'regular' | 'collection' | 'activity'
owner_uid BIGINT NOT NULL,
star_id BIGINT NOT NULL,
-- 普通藏品专属字段(其他类型时为 NULL
grade SMALLINT, -- 普通藏品等级1 / 2 / 3仅 regular 时有效)
-- 典藏专属字段(其他类型时为 NULL
collection_category VARCHAR(50), -- 典藏子分类(仅 collection 时有效)
-- 活动专属字段(其他类型时为 NULL
activity_id BIGINT,
activity_type VARCHAR(50),
-- 公共字段
status SMALLINT NOT NULL DEFAULT 0,
like_count INT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
CONSTRAINT uk_asset_type_id UNIQUE (asset_type, asset_id),
CONSTRAINT uk_owner_star_type_asset UNIQUE (owner_uid, star_id, asset_type, asset_id)
);
CREATE INDEX idx_registry_owner_star ON asset_registry (owner_uid, star_id);
CREATE INDEX idx_registry_type_star ON asset_registry (asset_type, star_id);
CREATE INDEX idx_registry_star_grade ON asset_registry (star_id, grade)
WHERE asset_type = 'regular';
CREATE INDEX idx_registry_star_activity ON asset_registry (star_id, activity_id)
WHERE asset_type = 'activity';
```
**说明:**
- `asset_type='regular'`(普通藏品):`grade` 有效1/2/3`collection_category` 为 NULL
- `asset_type='collection'`(典藏):`collection_category` 有效,`grade` 为 NULL
- `asset_type='activity'`(活动藏品):`activity_id` / `activity_type` 有效,`grade` 为 NULL
- **同一 `asset_id` 只能属于一种 type**,不能同时注册为 regular 和 collection
### 2.3 设计说明
1. **三种类型的区分方式:**
- `regular`(普通藏品):`grade` 有效1/2/3由点赞数计算得出预留升级接口
- `collection`(典藏):`collection_category` 有效(动态字符串),无 grade
- `activity`(活动藏品):`activity_id` / `activity_type` 有效,无 grade
2. **category / activity_type 为动态字符串**,不预设枚举值,应用层负责校验和展示名称映射
3. **registry 表不自增 asset_id**,由各类型表创建时写入,保证主从表一致性
4. **UNIQUE 约束**`owner_uid + star_id + asset_type + asset_id` 保证用户每种类型下无重复藏品
5. **同一 `asset_id` 只能属于一种 type**,普通藏品、典藏、活动藏品互斥,不能重复注册
6. **`grade` 字段在数据库中为 NULL 表示非普通藏品**,在 API 响应中统一转换为 `0`(由 Gateway 转换层处理)
---
## 三、藏品创建流程
### 3.1 普通藏品(铸爱流程)
```
用户上传素材 → 铸造订单创建 → assets 表写入
→ 同时写入 asset_registry (type='regular', grade=1) -- grade 初始为1升级规则预留
```
说明:普通藏品(铸爱流程)纳入星册体系,创建时同步写入 registry`grade` 初始为 1后续按点赞数升级规则待确认
### 3.2 典藏藏品
```
用户选择"典藏"类型 → 创建 assets 通用记录
→ 创建 collection_assets 专属记录category
→ 同时写入 asset_registry (type='collection')
```
### 3.3 活动藏品
```
用户参与活动并获得奖励 → 创建 assets 通用记录
→ 创建 activity_assets 专属记录activity_id, activity_type
→ 同时写入 asset_registry (type='activity')
```
### 3.4 写入一致性保障
建议在同一个事务内完成 assets 写入和 registry 写入,确保一致性。
---
## 四、普通藏品等级升级机制(预留接口)
`grade` 初始值为 1升级规则待定由团队讨论确认后实现。设计上预留扩展接口
```go
// AssetService 或独立的 GradeService 预留方法
UpgradeGrade(assetID int64, fromGrade, toGrade int) error
// 升级条件判断后期实现,支持:
// - 点赞数阈值触发(如 grade1: ≥0, grade2: ≥100, grade3: ≥500
// - 全网排名触发
// - 手动升级(运营操作)
// 升级后同步更新 asset_registry.grade
```
典藏藏品collection无 grade 字段,按 `category` 子分类区分。
---
## 五、API 设计
### 5.1 核心原则
- **后端批量返回预签名封面 URL**,前端不单独调用 OSS 预签名接口
- **单次请求返回首页全量数据**,避免前端多次请求
### 5.2 接口列表
| 接口 | 方法 | 说明 |
|------|------|------|
| `/api/v1/starbook/home` | GET | 星册首页,按 type → category/grade 分组,含普通/典藏/活动三种类型 |
| `/api/v1/starbook/items` | GET | 某分组的藏品列表(分页) |
### 5.3 详细接口定义
#### GET /api/v1/starbook/home
**说明:** 获取当前用户在当前 star 下的星册首页数据
**响应示例:**
```json
{
"code": 200,
"data": {
"groups": [
{
"type": "regular",
"category": "castlove",
"category_name": "普通",
"grades": [
{
"grade": 5,
"items": [
{ "asset_id": 201, "name": "铸爱藏品-A", "cover_url_signed": "https://...", "like_count": 820, "category": "castlove", "grade": 5 }
],
"total_count": 3,
"has_more": false
},
{
"grade": 4,
"items": [...],
"total_count": 8,
"has_more": true
}
]
},
{
"type": "collection",
"category": "limited",
"category_name": "限定典藏",
"items": [
{ "asset_id": 101, "name": "限定典藏-1", "cover_url_signed": "https://...", "like_count": 820, "category": "limited" }
],
"total_count": 5,
"has_more": true
},
{
"type": "activity",
"category": "birthday",
"category_name": "生日会",
"items": [...],
"total_count": 2,
"has_more": false
}
]
}
}
```
**说明:**
- `star_id` 从认证上下文JWT Token获取不在请求参数中显式传递
- `groups`:三种类型的分组逻辑不同
- `type='regular'`(普通藏品):按 `grade` 分组grade=1/2/3...`category='castlove'` 固定
- `type='collection'`(典藏):按 `category` 子分类分组,无 grade
- `type='activity'`(活动藏品):按 `activity_type` 分组,无 grade
- 每组最多返回 6 条,`has_more=true` 表示还有更多(点击"查看更多"跳转分页页)
- **Gateway 转换层**:数据库中 `grade` 为 NULL 时,转换为 `0` 返回(非 regular 类型)
#### GET /api/v1/starbook/items
**Query 参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| type | string | 是 | `regular` / `collection` / `activity` |
| category | string | 否 | 子分类不传则返回该类型全部regular 时传 `castlove` |
| grade | int | 否 | 等级(仅 regular 时有效1/2/3... |
| page | int | 否 | 默认 1 |
| page_size | int | 否 | 默认 20 |
**响应示例:**
```json
{
"code": 200,
"data": {
"items": [
{
"asset_id": 201,
"asset_type": "regular",
"name": "铸爱藏品-A",
"cover_url_signed": "https://oss-cn-shanghai.../signed",
"like_count": 120,
"category": "castlove",
"grade": 5,
"created_at": 1743004800000
}
],
"total": 15,
"page": 1,
"page_size": 20,
"has_more": false
}
}
```
**说明:**
- `type='regular'``grade` 取值 1/2/3...`category='castlove'` 固定
- `type='collection'` 时无 grade 字段,按 `category` 筛选
- `type='activity'` 时无 grade 字段,按 `activity_type`(即 category 参数)筛选
---
## 六、后端模块划分
### 6.1 Service 层
| Service | 职责 |
|---------|------|
| `AssetService` | 现有流程改造:创建普通藏品时同步写 asset_registry (type='regular', grade=1);预留 grade 升级接口 |
| `CollectionService`(新建) | 管理 `collection_assets`,典藏专属逻辑(含 category 子分类) |
| `ActivityAssetService`(新建) | 管理 `activity_assets`,活动藏品专属逻辑 |
| `StarbookService`(新建) | 星册业务编排,调用各 Service整合数据统一生成预签名 URL |
### 6.2 StarbookService 核心逻辑
1. 从 `asset_registry` 查询当前用户 + 当前 star 下的所有藏品regular + collection + activity
2. 按 type → 各自维度分组regular 按 gradecollection 按 categoryactivity 按 activity_type
3. 批量调用 OSS 生成预签名 URL已有 `generatePresignedURL` 逻辑复用)
4. 返回结构化数据
### 6.3 Repository 层
| Repository | 表 |
|------------|-----|
| `CollectionRepository` | `collection_assets` |
| `ActivityAssetRepository` | `activity_assets` |
| `AssetRegistryRepository` | `asset_registry` |
---
## 七、Proto 文件变更
### 7.1 新建 starbook.proto
```protobuf
syntax = "proto3";
package topfans.starbook;
option go_package = "github.com/topfans/backend/pkg/proto/starbook;starbook";
import "proto/common.proto";
import "google/api/annotations.proto";
service StarbookService {
// 星册首页
rpc GetStarbookHome(GetStarbookHomeRequest) returns (GetStarbookHomeResponse) {
option (google.api.http) = {
get: "/api/v1/starbook/home"
};
}
// 藏品列表(分页)
rpc GetStarbookItems(GetStarbookItemsRequest) returns (GetStarbookItemsResponse) {
option (google.api.http) = {
get: "/api/v1/starbook/items"
};
}
}
message GetStarbookHomeRequest {}
message GetStarbookHomeResponse {
topfans.common.BaseResponse base = 1;
StarbookHomeData data = 2;
}
message StarbookHomeData {
repeated AssetGroup groups = 1;
}
message AssetGroup {
string type = 1; // 'regular' / 'collection' / 'activity'
string category = 2; // 'castlove'(regular) / collection_category / activity_type
string category_name = 3;
// regular 使用 grades 分组collection/activity 使用 flat items 列表
repeated GradeSection grades = 4; // 仅 regular 时有效
repeated AssetItem items = 5; // collection / activity 时有效
int32 total_count = 6;
bool has_more = 7;
}
message GradeSection {
int32 grade = 1; // 仅 regular 时使用
repeated AssetItem items = 2;
int32 total_count = 3;
bool has_more = 4;
}
message AssetItem {
int64 asset_id = 1;
string name = 2;
string cover_url_signed = 3;
int32 like_count = 4;
int64 created_at = 5;
string category = 6; // regular: 'castlove' / collection: category / activity: activity_type
int32 grade = 7; // 仅 regular 时有效1/2/3...),其他类型为 0
}
message GetStarbookItemsRequest {
string type = 1; // 'regular' / 'collection' / 'activity'
string category = 2; // regular 时固定传 'castlove'
int32 grade = 3; // 仅 regular 时有效1/2/3...
int32 page = 4;
int32 page_size = 5;
}
message GetStarbookItemsResponse {
topfans.common.BaseResponse base = 1;
AssetListData data = 2;
}
message AssetListData {
repeated AssetItem items = 1;
int64 total = 2;
int32 page = 3;
int32 page_size = 4;
bool has_more = 5;
}
```
---
## 八、前端重构
### 8.1 生命周期优化
**问题:** 当前 `onMounted` + `onActivated` + `onShow` + `watch(isActive)` 四处触发 `loadAssetsList()`
**解决:**
```js
// 只保留 onShow + watch isActive合并为单一加载逻辑
let lastLoadedAt = 0;
onShow(() => {
if (props.isActive) {
loadStarbookData();
}
});
watch(() => props.isActive, (newVal) => {
if (newVal) loadStarbookData();
});
// 限制频繁刷新:距离上次加载不足 1 秒则跳过
function loadStarbookData() {
const now = Date.now();
if (now - lastLoadedAt < 1000) return;
lastLoadedAt = now;
// ... 实际加载逻辑
}
```
### 8.2 数据流变更
| 变更前 | 变更后 |
|--------|--------|
| 调用 `getMyAssetsApi`(普通藏品) | 调用 `getStarbookHomeApi`(统一,含 regular/collection/activity |
| 手动解析封面 URL`Promise.all` 逐个调 OSS | 后端直接返回 `cover_url_signed` |
| 前端按 index 硬分组 | 后端按 type → category → grade 分组返回 |
| 普通藏品不展示在星册 | 普通藏品type='regular')纳入星册体系 |
### 8.3 页面路由
| 页面 | 路由 | 说明 |
|------|------|------|
| 星册主页 | `/pages/starbook/index` | 不变 |
| 查看更多 | `/pages/starbook/items?type=regular&category=castlove&grade=5` | 新页面,接分页数据 |
### 8.4 数据结构(前端 JavaScript
```ts
interface StarbookHomeData {
groups: AssetGroup[]
}
interface AssetGroup {
type: 'regular' | 'collection' | 'activity'
category: string // regular: 'castlove' / collection: 子分类 / activity: activity_type
category_name: string
// regular 按 grade 分组collection/activity 直接用 items 列表
grades?: GradeSection[] // 仅 regular 时有效
items?: AssetItem[] // collection / activity 时有效
total_count: number
has_more: boolean
}
interface GradeSection {
grade: number // regular: 1/2/3...
items: AssetItem[]
total_count: number
has_more: boolean
}
interface AssetItem {
asset_id: number
name: string
cover_url_signed: string
like_count: number
created_at: number
category: string // regular: 'castlove' / collection: 子分类 / activity: activity_type
grade: number // regular: 1/2/3...,其他类型为 0
}
// grade 中文转换函数(前端使用)
function toChineseGrade(grade) {
const map = { 1: '一', 2: '二', 3: '三', 4: '四', 5: '五' };
return `等级${map[grade] || grade}`;
}
```
**说明:**
- `type='regular'``grades[]` 分组(按 grade`grade` 取值 1/2/3...`category='castlove'` 固定
- `type='collection'``items[]` 列表(按 category 分组),`grade=0`(数据库存 NULLAPI 转换层统一返回 0`category` 为典藏子分类
- `type='activity'``items[]` 列表(按 activity_type 分组),`grade=0`(数据库存 NULLAPI 转换层统一返回 0`category` 为活动类型
---
## 九、性能保障
1. **后端批量预签名**:单次请求一次性生成所有封面 URL 的预签名,前端零额外请求
2. **Registry 单表索引**三条索引覆盖所有查询路径owner+star, type+star, star+grade[regular]
3. **首页按组截断**:每 grade 组最多返回 6 条,`has_more` 标识是否还有更多
4. **前端防抖**1 秒内重复触发只执行一次加载
5. **分类动态扩展**category / activity_type 不写死枚举,应用层通过配置或枚举接口获取展示名
---
## 十、待确认事项
| 事项 | 状态 | 说明 |
|------|------|------|
| 典藏子分类category枚举值 | 待确认 | 动态字符串,设计已支持 |
| 活动类型activity_type枚举值 | 待确认 | 动态字符串,设计已支持 |
| 普通藏品 grade 升级规则 | 待确认 | 设计已预留 `UpgradeGrade` 接口 |
| 普通藏品 category 固定值 | 待确认 | 建议 `category='castlove'` |
---
## 十一、影响范围
| 范围 | 变更内容 |
|------|----------|
| 数据库 | 新建 3 张表collection_assets, activity_assets, asset_registry |
| Proto | 新建 starbook.proto |
| Gateway | 新建 StarbookController新增 2 个路由 |
| Service 层 | 新建 CollectionService, ActivityAssetService, StarbookService |
| Repository 层 | 新建 CollectionRepository, ActivityAssetRepository, AssetRegistryRepository |
| 前端 | StarbookContent.vue 重构,新增 /pages/starbook/items.vue |
| 铸爱流程 | **受影响**,普通藏品创建时需同步写入 asset_registrytype='regular', grade=1|

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