# taskService 实现计划 > **面向智能体工程师:** 必需子技能:使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 来逐任务实现本计划。步骤使用复选框(`- [ ]`)语法进行跟踪。 **目标:** 实现 taskService(Go Dubbo-go)后端,包含移动端 API 和移动端前端页面(每日任务、引导流程、展示收益)。 **架构:** - taskService:Go Dubbo-go 服务,端口 20005,暴露 HTTP/Triple 移动端 API 和内部 RPC(TaskInternalService) - 移动端前端:Vue/uni-app 页面,通过 gateway 调用 taskService HTTP API - 共享 PostgreSQL 数据库存储任务数据 - taskService 调用 userService RPC 发放水晶/经验奖励 **技术栈:** Go (dubbo-go v3)、GORM、PostgreSQL、Vue/uni-app --- ## 文件结构 ### 后端 (taskService) ``` backend/ ├── proto/ │ └── task.proto # 新增:TaskMobileService + TaskInternalService 定义 ├── pkg/ │ └── proto/ │ └── task/ │ ├── task.pb.go # 新增:proto 编译生成 │ └── task.triple.go # 新增:proto 编译生成 ├── services/ │ └── taskService/ # 新增 │ ├── main.go # 新增:服务入口 │ ├── config/ │ │ └── task_config.go # 新增:配置 │ ├── model/ │ │ └── task_models.go # 新增:所有任务表对应的 GORM 模型 │ ├── repository/ │ │ ├── daily_task_repo.go # 新增 │ │ ├── onboarding_repo.go # 新增 │ │ └── revenue_repo.go # 新增 │ ├── service/ │ │ ├── daily_task_service.go # 新增 │ │ ├── onboarding_service.go # 新增 │ │ └── revenue_service.go # 新增 │ ├── provider/ │ │ ├── task_mobile_provider.go # 新增:移动端 API(HTTP/Triple) │ │ └── task_internal_provider.go # 新增:内部 RPC(TaskInternalService) │ ├── worker/ │ │ └── daily_reset_worker.go # 新增:每日 05:00 重置 + 自动发放 │ └── client/ │ └── user_rpc_client.go # 新增:调用 userService 的 UpdateCrystalBalance、AddExperience ``` ### 前端(移动端) ``` frontend/ ├── pages/ │ └── tasks/ # 新增:任务页面目录 │ ├── daily-tasks.vue # 新增:每日任务页面 │ ├── guide.vue # 新增:引导任务页面 │ └── revenue.vue # 新增:展示收益页面 ├── utils/ │ └── task-api.js # 新增:taskService API 调用封装 └── pages.json # 修改:新增页面路由 ``` ### 数据库迁移 ``` backend/ └── scripts/ └── v001_init_task_tables.sql # 新增:7 张任务相关表的 DDL ``` --- ## 关键前提:必须先在 user.proto 中添加 AddExperience 设计文档(设计文档第 101 行)要求在 `UserSocialService` 上添加 `AddExperience` RPC,但当前 `backend/proto/user.proto` 中没有该 RPC。**taskService 编译依赖此 RPC,必须先完成。** **修改:`backend/proto/user.proto`** — 在 `UserSocialService` 中添加: ```protobuf // 更新经验值请求(内部RPC调用,用于taskService发放经验奖励) message AddExperienceRequest { int64 user_id = 1; int64 star_id = 2; int64 delta = 3; } message AddExperienceResponse { topfans.common.BaseResponse base = 1; int64 new_experience = 2; } // 在 service UserSocialService 中添加: rpc AddExperience(AddExperienceRequest) returns (AddExperienceResponse); ``` 编辑完 `user.proto` 后,运行:`cd backend && sh scripts/compile-proto.sh` --- ## 阶段一:taskService 后端基础设施 ### 任务 1:Proto 定义 **文件:** - 新增:`backend/proto/task.proto` ```protobuf 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); } ``` ### 任务 2:数据库模型 **文件:** - 新增:`backend/services/taskService/model/task_models.go` ```go 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" } ``` ### 任务 3:配置文件 **文件:** - 新增:`backend/services/taskService/config/task_config.go` ```go 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) } ``` ### 任务 4:userService RPC 客户端 **文件:** - 新增:`backend/services/taskService/client/user_rpc_client.go` 模式参考 `assetService/client/user_rpc_client.go`。客户端封装体接收的是服务接口(而非 `*client.Client`)。服务接口创建(在 `main.go` 中调用 `pbUser.NewUserSocialService`)在 main.go 中完成。 ```go 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 } ``` --- ## 阶段二:Repository 层 ### 任务 5:每日任务 Repository **文件:** - 新增:`backend/services/taskService/repository/daily_task_repo.go` 主要方法: - `GetUserDailyProgress(userID, starID, taskKey) (*model.UserDailyTaskProgress, error)` - `GetOrCreateDailyProgress(userID, starID, taskKey, def *model.TaskDefinition) (*model.UserDailyTaskProgress, error)` - `ListDailyTasksByUser(userID, starID) ([]*model.UserDailyTaskProgress, error)` - `ListActiveDailyTaskDefinitions(starID) ([]*model.TaskDefinition, error)` — 返回 star_id 相关 + 全局默认 - `UpdateDailyProgress(progress *model.UserDailyTaskProgress) error` - `ResetAllDailyTasks() (int64, error)` — 重置所有非 pending 状态为 pending - `InitDailyTasksForUser(userID, starID) error` — InitUserTasks RPC 调用,为该用户创建该 star_id 下所有每日任务进度 - `AcquireAdvisoryLock(lockID int64) bool` — `SELECT pg_try_advisory_lock($1)`,返回是否获取成功 - `ReleaseAdvisoryLock(lockID int64)` — `SELECT pg_advisory_unlock($1)` ### 任务 6:引导任务 Repository **文件:** - 新增:`backend/services/taskService/repository/onboarding_repo.go` 主要方法: - `GetOnboardingStatus(userID int64) (*model.UserOnboardingStatus, error)` - `GetOrCreateOnboardingStatus(userID int64) (*model.UserOnboardingStatus, error)` — 获取或新建(current_stage=0, status=pending) - `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)` ### 任务 7:收益 Repository **文件:** - 新增:`backend/services/taskService/repository/revenue_repo.go` 主要方法: - `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)` — 乐观锁:仅在 status='claimable' 时更新,返回是否更新成功 - `UpdateRevenueStatus(id int64, status string) error` — Worker 自动发放后调用(claimed/failed) - `ListClaimableRevenue(limit int) ([]*model.ExhibitionRevenueRecord, error)` — Worker 自动发放用,`LIMIT batchSize` --- ## 阶段三:Service 层 ### 任务 8:每日任务 Service **文件:** - 新增:`backend/services/taskService/service/daily_task_service.go` 主要方法: - `GetDailyTasks(ctx, userID, starID) (*pb.GetDailyTasksResponse, error)` — 合并任务定义与用户进度,can_claim=(status==completed) - `ReportEvent(ctx, userID, starID, eventType) (*pb.ReportEventResponse, error)` — eventType 映射到 task_key,标记任务完成 - `ClaimDailyTask(ctx, userID, starID, taskKey) (*pb.ClaimDailyTaskResponse, error)` — 验证 completed,调用水晶和经验奖励,标记 claimed - `ClaimAllDailyTasks(ctx, userID, starID) (*pb.ClaimAllDailyTasksResponse, error)` — 查找所有已完成任务,批量领取 ### 任务 9:引导任务 Service **文件:** - 新增:`backend/services/taskService/service/onboarding_service.go` 主要方法: - `GetOnboardingStatus(ctx, userID) (*pb.GetOnboardingStatusResponse, error)` - `CompleteGuide(ctx, userID, taskKey) (*pb.CompleteGuideResponse, error)` — 标记引导进度完成(不是每日任务),返回完整阶段列表 - `AdvanceStage(ctx, userID, targetStage) (*pb.AdvanceStageResponse, error)` — 验证当前阶段所有任务已完成,再推进阶段 - `ClaimOnboardingReward(ctx, userID, stage) (*pb.ClaimOnboardingRewardResponse, error)` — 验证阶段已完成,发放奖励,标记 claimed - `InitUserTasks(ctx, userID, starID) error` — TaskInternalService.InitUserTasks RPC 调用:创建 user_onboarding_status(若不存在)+ 调用 dailyRepo.InitDailyTasksForUser ### 任务 10:收益 Service **文件:** - 新增:`backend/services/taskService/service/revenue_service.go` 主要方法: - `GetExhibitionRevenue(ctx, userID, starID, status string, page, pageSize) (*pb.GetExhibitionRevenueResponse, error)` - `ClaimExhibitionRevenue(ctx, userID, starID, revenueID) (*pb.ClaimExhibitionRevenueResponse, error)` — 使用 repo.ClaimRevenueRecord 乐观锁 - `ClaimAllExhibitionRevenue(ctx, userID, starID) (*pb.ClaimAllExhibitionRevenueResponse, error)` — 查找所有可领取收益,批量领取 - `OnExhibitionCompleted(ctx, req) (*pb.OnExhibitionCompletedResponse, error)` — **关键:仅在 slot_owner_uid != occupier_uid 时创建收益记录**(自己展位不产生收益),status='claimable' --- ## 阶段四:Provider 层(API 处理器) ### 任务 11:移动端 API Provider **文件:** - 新增:`backend/services/taskService/provider/task_mobile_provider.go` 实现 `TaskMobileService` 接口(proto 生成)。通过 Dubbo context attachments 提取 user_id(gateway 认证中间件设置)。参考 `galleryService/provider/gallery_provider.go`。 辅助函数(定义在文件顶部): ```go func getUserIDFromCtx(ctx context.Context) int64 { // Dubbo 通过 context attachments 从 gateway 认证中间件传递 user_id // Triple 协议通过 rpcCtx 或 attachments 获取 if attachments := ctx.Value("attachments").(map[string]string); attachments != nil { if uid := attachments["user_id"]; uid != "" { if id, _ := strconv.ParseInt(uid, 10, 64); id > 0 { return id } } } return 0 } ``` 每个 handler:提取 userID → 调用 service 方法 → 返回响应。 ### 任务 12:内部 RPC Provider **文件:** - 新增:`backend/services/taskService/provider/task_internal_provider.go` 实现 `TaskInternalService`: - `InitUserTasks(ctx, req) (*pb.InitUserTasksResponse, error)` — 提取 `req.UserId` 和 `req.StarId`,调用 `onboardingService.InitUserTasks(ctx, userID, starID)` - `OnExhibitionCompleted(ctx, req) (*pb.OnExhibitionCompletedResponse, error)` — 调用 `revenueService.OnExhibitionCompleted` 内部 RPC 不需要认证中间件(由其他服务调用)。 --- ## 阶段五:Worker ### 任务 13:每日重置 Worker **文件:** - 新增:`backend/services/taskService/worker/daily_reset_worker.go` ```go 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 { 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("Next daily reset at %s (in %v)", next.Format(time.RFC3339), waitDuration)) select { case <-time.After(waitDuration): w.doResetAndAutoClaim() case <-w.stopCh: return } } } func (w *DailyResetWorker) doResetAndAutoClaim() { lockKey := time.Now().Format("20060102") lockID, _ := strconv.ParseInt(lockKey, 10, 64) acquired := w.dailyRepo.AcquireAdvisoryLock(lockID) if !acquired { logger.Logger.Info("Another instance is running daily reset, skipping") return } defer w.dailyRepo.ReleaseAdvisoryLock(lockID) // 1. 重置每日任务 resetCount, err := w.dailyRepo.ResetAllDailyTasks() if err != nil { logger.Logger.Error("Failed to reset daily tasks", zap.Error(err)) } else { logger.Logger.Info(fmt.Sprintf("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("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 { w.revenueRepo.UpdateRevenueStatus(record.ID, "claimed") totalClaimed++ break } lastErr = err time.Sleep(100 * time.Millisecond) } if lastErr != nil { w.revenueRepo.UpdateRevenueStatus(record.ID, "failed") logger.Logger.Error("Failed to auto-claim revenue after retries", zap.Int64("record_id", record.ID), zap.Error(lastErr)) } } } logger.Logger.Info(fmt.Sprintf("Auto-claim completed: %d records claimed", totalClaimed)) } ``` --- ## 阶段六:main.go ### 任务 14:服务入口 **文件:** - 新增:`backend/services/taskService/main.go` ```go 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" "github.com/topfans/backend/pkg/proto/task" pbUser "github.com/topfans/backend/pkg/proto/user" "github.com/topfans/backend/services/taskService/client" "github.com/topfans/backend/services/taskService/config" "github.com/topfans/backend/services/taskService/model" "github.com/topfans/backend/services/taskService/provider" "github.com/topfans/backend/services/taskService/repository" "github.com/topfans/backend/services/taskService/service" "github.com/topfans/backend/services/taskService/worker" ) var port = flag.Int("port", 20005, "Dubbo service port") func main() { flag.Parse() // 1. Init logger(必须最前) env := os.Getenv("ENV") if env == "" { env = "development" } if err := logger.Init(logger.Config{ServiceName: "task-service", Environment: env, LogLevel: os.Getenv("LOG_LEVEL")}); err != nil { panic(fmt.Sprintf("Failed to init logger: %v", err)) } defer logger.Sync() logger.Logger.Info("Starting taskService...") // 2. Init config(读取 flags/env) config.InitConfig() // 3. Init database + auto-migrate dbConfig := database.Config{ Host: config.DBConfig.Host, Port: config.DBConfig.Port, User: config.DBConfig.User, Password: config.DBConfig.Password, DBName: config.DBConfig.DBName, SSLMode: config.DBConfig.SSLMode, TimeZone: "Asia/Shanghai", } if err := database.Init(dbConfig); err != nil { logger.Logger.Fatal(fmt.Sprintf("Failed to init DB: %v", err)) } db := database.GetDB() if err := db.AutoMigrate( &model.TaskDefinition{}, &model.UserDailyTaskProgress{}, &model.UserOnboardingProgress{}, &model.UserOnboardingStatus{}, &model.OnboardingStageConfig{}, &model.ExhibitionRevenueRecord{}, &model.TaskResetLog{}, ); err != nil { logger.Logger.Fatal(fmt.Sprintf("Failed to migrate tables: %v", err)) } logger.Logger.Info("Database initialized") // 4. Init repositories dailyRepo := repository.NewDailyTaskRepository(db) onboardingRepo := repository.NewOnboardingRepository(db) revenueRepo := repository.NewRevenueRepository(db) logger.Logger.Info("Repositories initialized") // 5. Init userService Dubbo client + RPC client userCli, err := dubboclient.NewClient(dubboclient.WithClientURL(config.ServiceURLsConfig.UserService)) if err != nil { logger.Logger.Fatal(fmt.Sprintf("Failed to create userService client: %v", err)) } userServiceClient, err := pbUser.NewUserSocialService(userCli) if err != nil { logger.Logger.Fatal(fmt.Sprintf("Failed to get userService: %v", err)) } userRPCClient := client.NewUserServiceClient(userServiceClient) logger.Logger.Info("User RPC client initialized") // 6. Init services dailySvc := service.NewDailyTaskService(dailyRepo, userRPCClient) onboardingSvc := service.NewOnboardingService(onboardingRepo, dailyRepo, userRPCClient) revenueSvc := service.NewRevenueService(revenueRepo, userRPCClient) logger.Logger.Info("Services initialized") // 7. Init worker(goroutine 中启动) resetWorker := worker.NewDailyResetWorker(dailyRepo, revenueRepo, userRPCClient) go resetWorker.Start() logger.Logger.Info("Reset worker started") // 8. Init providers mobileProvider := provider.NewTaskMobileProvider(dailySvc, onboardingSvc, revenueSvc) internalProvider := provider.NewTaskInternalProvider(onboardingSvc, revenueSvc) logger.Logger.Info("Providers initialized") // 9. Create Dubbo server on port 20005 srv, err := server.NewServer( server.WithServerProtocol(protocol.WithPort(*port), protocol.WithTriple()), ) if err != nil { logger.Logger.Fatal(fmt.Sprintf("Failed to create server: %v", err)) } if err := task.RegisterTaskMobileServiceHandler(srv, mobileProvider); err != nil { logger.Logger.Fatal(fmt.Sprintf("Failed to register TaskMobileService: %v", err)) } if err := task.RegisterTaskInternalServiceHandler(srv, internalProvider); err != nil { logger.Logger.Fatal(fmt.Sprintf("Failed to register TaskInternalService: %v", err)) } if err := srv.Serve(); err != nil { logger.Logger.Fatal(fmt.Sprintf("Failed to serve: %v", err)) } logger.Logger.Info(fmt.Sprintf("taskService started on port %d", *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() } ``` --- ## 阶段七:Proto 编译 ### 任务 15:Proto 编译 **步骤 1:** 修改 `backend/scripts/compile-proto.sh`: - 在循环(第 56 行左右)中添加 `task` - 在清理块(第 159 行左右)中添加 `task` - 在 activity.proto 编译块之后添加 task.proto 编译块 mkdir 循环中: ```bash for name in common user social asset gallery ranking activity task; do ``` mkdir 创建目录: ```bash mkdir -p pkg/proto/task ``` activity.proto 编译块之后添加: ```bash # 编译 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 编译完成" ``` 清理循环中也需要添加 `task`。 **步骤 2:** 运行:`cd backend && sh scripts/compile-proto.sh` --- ## 阶段九:移动端前端 ### 任务 16:任务 API 工具函数 **文件:** - 新增:`frontend/utils/task-api.js` 复用 `frontend/utils/api.js` 中的 `request` 封装: ```javascript import { request } from './api.js' // baseURL 已配置在 api.js 中,指向 gateway(端口 8080) // 所有 task API 通过 gateway 路由到 taskService(Triple/HTTP) export const getDailyTasks = (starId) => request({ url: '/api/tasks/daily', data: { star_id: starId } }) export const reportEvent = (eventType, starId) => request({ url: '/api/tasks/report-event', method: 'POST', data: { event_type: eventType, star_id: starId } }) export const claimDailyTask = (taskKey, starId) => request({ url: '/api/tasks/daily/claim', method: 'POST', data: { task_key: taskKey, star_id: starId } }) export const claimAllDailyTasks = (starId) => request({ url: '/api/tasks/daily/claim-all', method: 'POST', data: { star_id: starId } }) export const completeGuide = (taskKey) => request({ url: '/api/tasks/guide/complete', method: 'POST', data: { task_key: taskKey } }) export const getOnboardingStatus = () => request({ url: '/api/tasks/onboarding/status' }) export const advanceStage = (targetStage) => request({ url: '/api/tasks/onboarding/advance-stage', method: 'POST', data: { target_stage: targetStage } }) export const claimOnboardingReward = (stage) => request({ url: '/api/tasks/onboarding/claim-reward', method: 'POST', data: { stage } }) export const getExhibitionRevenue = (starId, status, page, pageSize) => request({ url: '/api/tasks/exhibition-revenue', data: { star_id: starId, status, page, page_size: pageSize } }) export const claimExhibitionRevenue = (revenueId, starId) => request({ url: '/api/tasks/exhibition-revenue/claim', method: 'POST', data: { revenue_id: revenueId, star_id: starId } }) export const claimAllExhibitionRevenue = (starId) => request({ url: '/api/tasks/exhibition-revenue/claim-all', method: 'POST', data: { star_id: starId } }) ``` ### 任务 17:每日任务页面 **文件:** - 新增:`frontend/pages/tasks/daily-tasks.vue` 功能: - 通过 `uni.getStorageSync('fan_profile')` 获取当前 star_id - 挂载时调用 `getDailyTasks(starId)` - 展示任务列表:名称、描述、水晶奖励、经验奖励、状态标签(pending=蓝色, completed=绿色可领取, claimed=灰色) - "领取"按钮仅在 `can_claim=true` 时可点击 - 底部有"一键领取"按钮(有待领取任务时显示) - 调用 `claimDailyTask` / `claimAllDailyTasks`,带加载状态 - 支持下拉刷新 `uni.startPullDownRefresh` - 任务列表为空时显示空状态 ### 任务 18:引导任务页面 **文件:** - 新增:`frontend/pages/tasks/guide.vue` 功能: - 挂载时调用 `getOnboardingStatus()` - 展示阶段列表:当前阶段高亮 - 每阶段显示:名称、所需任务 keys(含已完成/未完成标记) - "进入下一阶段"按钮 — 仅当当前阶段所有任务完成时才可点击,调用 `advanceStage(targetStage)` - 当前阶段"领取奖励"按钮 — 调用 `claimOnboardingReward(currentStage)` - 用户完成每个引导步骤后(如进入 square_home)调用 `completeGuide` ### 任务 19:收益页面 **文件:** - 新增:`frontend/pages/tasks/revenue.vue` 功能: - Tab 切换:"可领取" / "已领取",使用 `uni segmented-control` 或 Tab 样式按钮 - 默认 Tab = "可领取",显示 `can_claim=true` 的项目 - 每条记录:展品缩略图(无可用则占位)、展位类型标签("自己的展位"/"好友展位")、水晶数量、周期时间范围 - 单条"领取"按钮和"一键领取"按钮 - 分页加载 `uni.loadMore` - 下拉刷新 ### 任务 20:页面路由 **文件:** - 修改:`frontend/pages.json` 在 `pages` 数组中添加: ```json { "path": "pages/tasks/daily-tasks", "style": { "navigationStyle": "custom" } }, { "path": "pages/tasks/guide", "style": { "navigationStyle": "custom" } }, { "path": "pages/tasks/revenue", "style": { "navigationStyle": "custom" } } ``` --- ## 阶段十:集成与测试 ### 任务 21:手动 API 测试 通过 gateway 测试(假设 taskService 已在 Dubbo 注册,或通过 gateway HTTP 路由到 20005): 1. `GET /api/tasks/daily?star_id=1` → 返回任务列表 2. `POST /api/tasks/report-event {event_type:"daily_login",star_id:1}` → task_completed=true 3. `GET /api/tasks/onboarding/status` → 返回 current_stage:0 4. `POST /api/tasks/guide/complete {task_key:"square_home"}` → 创建进度,返回阶段信息 5. `GET /api/tasks/exhibition-revenue?star_id=1&page=1&page_size=20` → 返回收益列表 6. `POST /api/tasks/exhibition-revenue/claim {revenue_id:1,star_id:1}` → 领取单个 7. `POST /api/tasks/exhibition-revenue/claim-all {star_id:1}` → 一键领取