73 KiB
任务管理系统实现计划
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 实现完整的任务管理系统,包括 activity-admin 运营后台和 Go taskService 移动后端,共用数据库
Architecture:
- activity-admin (Python FastAPI): 任务定义管理、进度查询、统计、手动重置
- Go taskService (Dubbo-go Triple): 移动端 API、事件处理、奖励发放、每日重置
- 共享 PostgreSQL 数据库,Go 服务通过 Advisory Lock 确保单实例重置
Tech Stack:
- Python: FastAPI, SQLAlchemy, Pydantic
- Go: Dubbo-go, GORM, PostgreSQL
- Frontend: Vue 3, Element Plus, ECharts
Phase 1: 数据库设计与初始化
Task 1.1: 创建数据库表
Files:
-
Create:
scripts/task_system_migration.sql -
Step 1: 创建 SQL 迁移脚本
-- 任务定义表
CREATE TABLE IF NOT EXISTS task_definitions (
id BIGSERIAL PRIMARY KEY,
star_id BIGINT, -- NULL=全局默认
task_key VARCHAR(50) NOT NULL,
task_type VARCHAR(20) NOT NULL, -- 'daily' | 'onboarding'
name VARCHAR(100) NOT NULL,
description TEXT,
crystal_reward BIGINT DEFAULT 0,
exp_reward BIGINT DEFAULT 0,
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at BIGINT,
updated_at BIGINT
);
CREATE UNIQUE INDEX ix_task_def_star_key ON task_definitions(star_id, task_key);
-- 每日任务进度表
CREATE TABLE IF NOT EXISTS user_daily_task_progress (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
task_key VARCHAR(50) NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending/completed/claimed
completed_at BIGINT,
claimed_at BIGINT,
created_at BIGINT,
updated_at BIGINT
);
CREATE UNIQUE INDEX ix_daily_progress_user_star_key ON user_daily_task_progress(user_id, star_id, task_key);
-- 引导任务进度表
CREATE TABLE IF NOT EXISTS user_onboarding_progress (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
task_key VARCHAR(50) NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending/completed/claimed
completed_at BIGINT,
claimed_at BIGINT,
created_at BIGINT,
updated_at BIGINT
);
CREATE UNIQUE INDEX ix_onboard_progress_user_key ON user_onboarding_progress(user_id, task_key);
-- 引导流程状态表
CREATE TABLE IF NOT EXISTS user_onboarding_status (
user_id BIGINT PRIMARY KEY,
current_stage INT DEFAULT 0, -- 0=未开始,1~N=阶段
status VARCHAR(20) DEFAULT 'pending', -- pending/in_progress/completed/claimed
is_first_login_bonus_claimed BOOLEAN DEFAULT false,
has_friend_display_bonus BOOLEAN DEFAULT false,
completed_at BIGINT,
claimed_at BIGINT,
created_at BIGINT,
updated_at BIGINT
);
-- 引导阶段配置表
CREATE TABLE IF NOT EXISTS onboarding_stage_config (
id BIGSERIAL PRIMARY KEY,
stage INT NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
required_task_keys TEXT[], -- PostgreSQL 数组类型
crystal_reward BIGINT DEFAULT 0,
exp_reward BIGINT DEFAULT 0,
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT true,
created_at BIGINT,
updated_at BIGINT
);
-- 展示收益记录表
CREATE TABLE IF NOT EXISTS exhibition_revenue_records (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
star_id BIGINT NOT NULL,
exhibition_id BIGINT NOT NULL,
asset_id BIGINT NOT NULL,
slot_id BIGINT NOT NULL,
slot_owner_uid BIGINT NOT NULL,
slot_type VARCHAR(20) NOT NULL, -- 'own' | 'friend'
crystal_amount BIGINT NOT NULL,
cycle_start_time BIGINT NOT NULL,
cycle_end_time BIGINT NOT NULL,
status VARCHAR(20) DEFAULT 'claimable', -- claimable/claimed
claimed_at BIGINT,
created_at BIGINT
);
CREATE INDEX ix_revenue_user_star_status ON exhibition_revenue_records(user_id, star_id, status);
CREATE INDEX ix_revenue_star_status ON exhibition_revenue_records(star_id, status);
-- 重置记录表(用于防止重复重置)
CREATE TABLE IF NOT EXISTS task_reset_log (
id BIGSERIAL PRIMARY KEY,
reset_type VARCHAR(20) NOT NULL, -- 'daily' | 'manual'
star_id BIGINT,
last_reset_at BIGINT NOT NULL,
created_at BIGINT
);
- Step 2: 执行迁移脚本
Run: psql -h localhost -U postgres -d topfans -f scripts/task_system_migration.sql
Task 1.2: 插入默认任务数据
Files:
-
Create:
scripts/task_default_data.sql -
Step 1: 创建默认任务数据脚本
-- 插入默认每日任务(star_id = NULL 表示全局默认)
INSERT INTO task_definitions (star_id, task_key, task_type, name, description, crystal_reward, exp_reward, sort_order, is_active, created_at, updated_at) VALUES
(NULL, 'daily_login', 'daily', '每日首次登录', '每日首次登录 App', 20, 20, 1, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000),
(NULL, 'daily_browse_asset', 'daily', '每日首次浏览藏品', '每日首次浏览任意展品', 20, 40, 2, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000),
(NULL, 'daily_mint', 'daily', '每日首次铸造', '每日首次铸造藏品', 20, 60, 3, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000),
(NULL, 'daily_place_asset', 'daily', '每日首次上架藏品', '每日首次上架藏品到展厅', 20, 80, 4, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000);
-- 插入默认引导任务
INSERT INTO task_definitions (star_id, task_key, task_type, name, description, crystal_reward, exp_reward, sort_order, is_active, created_at, updated_at) VALUES
(NULL, 'onboarding_complete', 'onboarding', '完成新手引导', '完成全部新手引导流程', 420, 250, 1, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000);
-- 插入引导阶段配置(动态配置)
INSERT INTO onboarding_stage_config (stage, name, required_task_keys, crystal_reward, exp_reward, sort_order, is_active, created_at, updated_at) VALUES
(1, '入门引导', ARRAY['square_home', 'profile_edit'], 0, 0, 1, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000),
(2, '进阶引导', ARRAY['starbook_add', 'exhibition_add'], 0, 0, 2, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000),
(3, '完成引导', ARRAY['exhibition_operate'], 0, 0, 3, true, EXTRACT(EPOCH FROM NOW())::bigint * 1000, EXTRACT(EPOCH FROM NOW())::bigint * 1000);
Phase 2: Python Backend (activity-admin) 任务管理模块
Task 2.1: 创建 SQLAlchemy 模型
Files:
-
Create:
backend/models/task_models.py -
Step 1: 创建任务相关模型
from sqlalchemy import Column, BigInteger, String, Text, Boolean, Integer, Index
from database import Base
class TaskDefinition(Base):
"""任务定义表"""
__tablename__ = "task_definitions"
id = Column(BigInteger, primary_key=True, autoincrement=True)
star_id = Column(BigInteger, nullable=True)
task_key = Column(String(50), nullable=False)
task_type = Column(String(20), nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text)
crystal_reward = Column(BigInteger, default=0)
exp_reward = Column(BigInteger, default=0)
sort_order = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
__table_args__ = (
Index('ix_task_def_star_key', 'star_id', 'task_key'),
)
class UserDailyTaskProgress(Base):
"""每日任务进度表"""
__tablename__ = "user_daily_task_progress"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, nullable=False)
star_id = Column(BigInteger, nullable=False)
task_key = Column(String(50), nullable=False)
status = Column(String(20), default='pending')
completed_at = Column(BigInteger)
claimed_at = Column(BigInteger)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
__table_args__ = (
Index('ix_daily_progress_user_star_key', 'user_id', 'star_id', 'task_key', unique=True),
)
class UserOnboardingProgress(Base):
"""引导任务进度表"""
__tablename__ = "user_onboarding_progress"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, nullable=False)
task_key = Column(String(50), nullable=False)
status = Column(String(20), default='pending')
completed_at = Column(BigInteger)
claimed_at = Column(BigInteger)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
__table_args__ = (
Index('ix_onboard_progress_user_key', 'user_id', 'task_key', unique=True),
)
class UserOnboardingStatus(Base):
"""引导流程状态表"""
__tablename__ = "user_onboarding_status"
user_id = Column(BigInteger, primary_key=True)
current_stage = Column(Integer, default=0)
status = Column(String(20), default='pending')
is_first_login_bonus_claimed = Column(Boolean, default=False)
has_friend_display_bonus = Column(Boolean, default=False)
completed_at = Column(BigInteger)
claimed_at = Column(BigInteger)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
class OnboardingStageConfig(Base):
"""引导阶段配置表"""
__tablename__ = "onboarding_stage_config"
id = Column(BigInteger, primary_key=True, autoincrement=True)
stage = Column(Integer, nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text)
required_task_keys = Column(Text) # PostgreSQL ARRAY stored as TEXT
crystal_reward = Column(BigInteger, default=0)
exp_reward = Column(BigInteger, default=0)
sort_order = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
created_at = Column(BigInteger)
updated_at = Column(BigInteger)
class ExhibitionRevenueRecord(Base):
"""展示收益记录表"""
__tablename__ = "exhibition_revenue_records"
id = Column(BigInteger, primary_key=True, autoincrement=True)
user_id = Column(BigInteger, nullable=False)
star_id = Column(BigInteger, nullable=False)
exhibition_id = Column(BigInteger, nullable=False)
asset_id = Column(BigInteger, nullable=False)
slot_id = Column(BigInteger, nullable=False)
slot_owner_uid = Column(BigInteger, nullable=False)
slot_type = Column(String(20), nullable=False)
crystal_amount = Column(BigInteger, nullable=False)
cycle_start_time = Column(BigInteger, nullable=False)
cycle_end_time = Column(BigInteger, nullable=False)
status = Column(String(20), default='claimable')
claimed_at = Column(BigInteger)
created_at = Column(BigInteger)
__table_args__ = (
Index('ix_revenue_user_star_status', 'user_id', 'star_id', 'status'),
Index('ix_revenue_star_status', 'star_id', 'status'),
)
class TaskResetLog(Base):
"""重置记录表"""
__tablename__ = "task_reset_log"
id = Column(BigInteger, primary_key=True, autoincrement=True)
reset_type = Column(String(20), nullable=False)
star_id = Column(BigInteger)
last_reset_at = Column(BigInteger, nullable=False)
created_at = Column(BigInteger)
- Step 2: 修改 backend/models/init.py
from models.models import Activity, ActivityItem, ActivityContribution, ActivityUserStats, Star, FanProfile
from models.task_models import (
TaskDefinition, UserDailyTaskProgress, UserOnboardingProgress,
UserOnboardingStatus, OnboardingStageConfig, ExhibitionRevenueRecord, TaskResetLog
)
Task 2.2: 创建 Pydantic Schema
Files:
-
Create:
backend/schemas/task_schemas.py -
Step 1: 创建任务相关 Schema
from pydantic import BaseModel
from typing import Optional, List
# ========== 任务定义 ==========
class TaskDefinitionBase(BaseModel):
star_id: Optional[int] = None
task_key: str
task_type: str
name: str
description: Optional[str] = None
crystal_reward: int = 0
exp_reward: int = 0
sort_order: int = 0
is_active: bool = True
class TaskDefinitionCreate(TaskDefinitionBase):
pass
class TaskDefinitionUpdate(BaseModel):
star_id: Optional[int] = None
task_key: Optional[str] = None
task_type: Optional[str] = None
name: Optional[str] = None
description: Optional[str] = None
crystal_reward: Optional[int] = None
exp_reward: Optional[int] = None
sort_order: Optional[int] = None
is_active: Optional[bool] = None
class TaskDefinitionResponse(TaskDefinitionBase):
id: int
created_at: Optional[int] = None
updated_at: Optional[int] = None
class Config:
from_attributes = True
class TaskDefinitionListResponse(BaseModel):
items: List[TaskDefinitionResponse]
total: int
page: int
page_size: int
# ========== 每日任务进度 ==========
class DailyTaskProgressItem(BaseModel):
id: int
user_id: int
star_id: int
task_key: str
status: str
completed_at: Optional[int] = None
claimed_at: Optional[int] = None
class Config:
from_attributes = True
class DailyTaskProgressListResponse(BaseModel):
items: List[DailyTaskProgressItem]
total: int
page: int
page_size: int
# ========== 引导任务进度 ==========
class OnboardingProgressItem(BaseModel):
id: int
user_id: int
task_key: str
status: str
completed_at: Optional[int] = None
claimed_at: Optional[int] = None
class Config:
from_attributes = True
class OnboardingProgressListResponse(BaseModel):
items: List[OnboardingProgressItem]
total: int
page: int
page_size: int
# ========== 引导流程状态 ==========
class OnboardingStatusResponse(BaseModel):
user_id: int
current_stage: int
status: str
is_first_login_bonus_claimed: bool
has_friend_display_bonus: bool
completed_at: Optional[int] = None
claimed_at: Optional[int] = None
class Config:
from_attributes = True
# ========== 引导阶段配置 ==========
class StageConfigResponse(BaseModel):
id: int
stage: int
name: str
description: Optional[str] = None
required_task_keys: List[str] = []
crystal_reward: int
exp_reward: int
sort_order: int
is_active: bool
class Config:
from_attributes = True
# ========== 展示收益 ==========
class RevenueRecordItem(BaseModel):
id: int
user_id: int
star_id: int
exhibition_id: int
slot_type: str
crystal_amount: int
cycle_start_time: int
cycle_end_time: int
status: str
claimed_at: Optional[int] = None
class Config:
from_attributes = True
class RevenueListResponse(BaseModel):
items: List[RevenueRecordItem]
total: int
page: int
page_size: int
# ========== 统计 ==========
class TaskStatsByStar(BaseModel):
star_id: int
total_users: int
total_completed: int
total_claimed: int
completion_rate: float
claim_rate: float
class DailyTaskStats(BaseModel):
date: str
new_participants: int
total_completions: int
total_claims: int
class TaskOverviewStats(BaseModel):
total_definitions: int
active_definitions: int
total_daily_progress: int
total_onboarding_progress: int
total_revenue_records: int
claimable_revenue: int
Task 2.3: 创建 CRUD 函数
Files:
-
Create:
backend/crud/task_crud.py -
Step 1: 创建任务 CRUD 函数
from sqlalchemy.orm import Session
from sqlalchemy import select, func, and_, or_, distinct, update
from typing import List, Optional, Tuple
from models.task_models import (
TaskDefinition, UserDailyTaskProgress, UserOnboardingProgress,
UserOnboardingStatus, OnboardingStageConfig, ExhibitionRevenueRecord, TaskResetLog
)
import json
import time
# ========== 任务定义 CRUD ==========
def create_task_definition(db: Session, task: dict) -> TaskDefinition:
now = int(time.time() * 1000)
db_task = TaskDefinition(
star_id=task.get('star_id'),
task_key=task['task_key'],
task_type=task['task_type'],
name=task['name'],
description=task.get('description'),
crystal_reward=task.get('crystal_reward', 0),
exp_reward=task.get('exp_reward', 0),
sort_order=task.get('sort_order', 0),
is_active=task.get('is_active', True),
created_at=now,
updated_at=now,
)
db.add(db_task)
db.commit()
db.refresh(db_task)
return db_task
def get_task_definitions(
db: Session,
task_type: Optional[str] = None,
star_id: Optional[int] = None,
page: int = 1,
page_size: int = 10
) -> Tuple[List[TaskDefinition], int]:
query = select(TaskDefinition)
count_query = select(func.count(TaskDefinition.id))
if task_type:
query = query.where(TaskDefinition.task_type == task_type)
count_query = count_query.where(TaskDefinition.task_type == task_type)
if star_id is not None:
query = query.where(or_(TaskDefinition.star_id == star_id, TaskDefinition.star_id == None))
count_query = count_query.where(or_(TaskDefinition.star_id == star_id, TaskDefinition.star_id == None))
total = db.execute(count_query).scalar()
query = query.order_by(TaskDefinition.sort_order, TaskDefinition.id)
query = query.offset((page - 1) * page_size).limit(page_size)
items = db.execute(query).scalars().all()
return list(items), total
def update_task_definition(db: Session, task_id: int, updates: dict) -> Optional[TaskDefinition]:
task = db.execute(select(TaskDefinition).where(TaskDefinition.id == task_id)).scalar_one_or_none()
if not task:
return None
for key, value in updates.items():
if value is not None and key != 'id':
setattr(task, key, value)
task.updated_at = int(time.time() * 1000)
db.commit()
db.refresh(task)
return task
def delete_task_definition(db: Session, task_id: int) -> bool:
task = db.execute(select(TaskDefinition).where(TaskDefinition.id == task_id)).scalar_one_or_none()
if not task:
return False
db.delete(task)
db.commit()
return True
def toggle_task_definition(db: Session, task_id: int) -> Optional[TaskDefinition]:
task = db.execute(select(TaskDefinition).where(TaskDefinition.id == task_id)).scalar_one_or_none()
if not task:
return None
task.is_active = not task.is_active
task.updated_at = int(time.time() * 1000)
db.commit()
db.refresh(task)
return task
# ========== 每日任务进度 ==========
def get_daily_progress(
db: Session,
user_id: Optional[int] = None,
star_id: Optional[int] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> Tuple[List[dict], int]:
query = select(UserDailyTaskProgress)
count_query = select(func.count(UserDailyTaskProgress.id))
if user_id:
query = query.where(UserDailyTaskProgress.user_id == user_id)
count_query = count_query.where(UserDailyTaskProgress.user_id == user_id)
if star_id:
query = query.where(UserDailyTaskProgress.star_id == star_id)
count_query = count_query.where(UserDailyTaskProgress.star_id == star_id)
if status:
query = query.where(UserDailyTaskProgress.status == status)
count_query = count_query.where(UserDailyTaskProgress.status == status)
total = db.execute(count_query).scalar()
query = query.order_by(UserDailyTaskProgress.updated_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
items = db.execute(query).scalars().all()
return [dict(
id=i.id, user_id=i.user_id, star_id=i.star_id,
task_key=i.task_key, status=i.status,
completed_at=i.completed_at, claimed_at=i.claimed_at
) for i in items], total
def reset_daily_tasks(db: Session, star_id: Optional[int] = None) -> int:
now = int(time.time() * 1000)
query = (
update(UserDailyTaskProgress)
.where(UserDailyTaskProgress.status != 'pending')
.values(status='pending', completed_at=None, claimed_at=None, updated_at=now)
)
if star_id:
query = query.where(UserDailyTaskProgress.star_id == star_id)
result = db.execute(query)
db.commit()
return result.rowcount
# ========== 引导任务进度 ==========
def get_onboarding_progress(
db: Session,
user_id: Optional[int] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> Tuple[List[dict], int]:
query = select(UserOnboardingProgress)
count_query = select(func.count(UserOnboardingProgress.id))
if user_id:
query = query.where(UserOnboardingProgress.user_id == user_id)
count_query = count_query.where(UserOnboardingProgress.user_id == user_id)
if status:
query = query.where(UserOnboardingProgress.status == status)
count_query = count_query.where(UserOnboardingProgress.status == status)
total = db.execute(count_query).scalar()
query = query.order_by(UserOnboardingProgress.updated_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
items = db.execute(query).scalars().all()
return [dict(
id=i.id, user_id=i.user_id, task_key=i.task_key,
status=i.status, completed_at=i.completed_at, claimed_at=i.claimed_at
) for i in items], total
def get_onboarding_status(db: Session, user_id: int) -> Optional[UserOnboardingStatus]:
return db.execute(
select(UserOnboardingStatus).where(UserOnboardingStatus.user_id == user_id)
).scalar_one_or_none()
# ========== 引导阶段配置 ==========
def get_stage_configs(db: Session) -> List[OnboardingStageConfig]:
result = db.execute(
select(OnboardingStageConfig)
.where(OnboardingStageConfig.is_active == True)
.order_by(OnboardingStageConfig.sort_order)
)
return list(result.scalars().all())
# ========== 展示收益 ==========
def get_revenue_records(
db: Session,
user_id: Optional[int] = None,
star_id: Optional[int] = None,
status: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> Tuple[List[ExhibitionRevenueRecord], int]:
query = select(ExhibitionRevenueRecord)
count_query = select(func.count(ExhibitionRevenueRecord.id))
if user_id:
query = query.where(ExhibitionRevenueRecord.user_id == user_id)
count_query = count_query.where(ExhibitionRevenueRecord.user_id == user_id)
if star_id:
query = query.where(ExhibitionRevenueRecord.star_id == star_id)
count_query = count_query.where(ExhibitionRevenueRecord.star_id == star_id)
if status:
query = query.where(ExhibitionRevenueRecord.status == status)
count_query = count_query.where(ExhibitionRevenueRecord.status == status)
total = db.execute(count_query).scalar()
query = query.order_by(ExhibitionRevenueRecord.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
items = db.execute(query).scalars().all()
return list(items), total
# ========== 统计 ==========
def get_task_overview_stats(db: Session) -> dict:
total_defs = db.execute(select(func.count(TaskDefinition.id))).scalar() or 0
active_defs = db.execute(select(func.count(TaskDefinition.id)).where(TaskDefinition.is_active == True)).scalar() or 0
total_daily = db.execute(select(func.count(UserDailyTaskProgress.id))).scalar() or 0
total_onboard = db.execute(select(func.count(UserOnboardingProgress.id))).scalar() or 0
total_revenue = db.execute(select(func.count(ExhibitionRevenueRecord.id))).scalar() or 0
claimable = db.execute(
select(func.count(ExhibitionRevenueRecord.id))
.where(ExhibitionRevenueRecord.status == 'claimable')
).scalar() or 0
return {
"total_definitions": total_defs,
"active_definitions": active_defs,
"total_daily_progress": total_daily,
"total_onboarding_progress": total_onboard,
"total_revenue_records": total_revenue,
"claimable_revenue": claimable,
}
def get_task_stats_by_star(db: Session, star_id: int) -> dict:
completed = db.execute(
select(func.count(UserDailyTaskProgress.id))
.where(and_(
UserDailyTaskProgress.star_id == star_id,
UserDailyTaskProgress.status.in_(['completed', 'claimed'])
))
).scalar() or 0
claimed = db.execute(
select(func.count(UserDailyTaskProgress.id))
.where(and_(
UserDailyTaskProgress.star_id == star_id,
UserDailyTaskProgress.status == 'claimed'
))
).scalar() or 0
total_users = db.execute(
select(func.count(distinct(UserDailyTaskProgress.user_id)))
.where(UserDailyTaskProgress.star_id == star_id)
).scalar() or 0
total_progress = db.execute(
select(func.count(UserDailyTaskProgress.id))
.where(UserDailyTaskProgress.star_id == star_id)
).scalar() or 0
return {
"star_id": star_id,
"total_users": total_users,
"total_completed": completed,
"total_claimed": claimed,
"completion_rate": round(completed / total_progress * 100, 2) if total_progress > 0 else 0,
"claim_rate": round(claimed / total_progress * 100, 2) if total_progress > 0 else 0,
}
def get_daily_task_trend(db: Session, star_id: Optional[int] = None, days: int = 7) -> List[dict]:
# 简化实现,返回近7天每日统计
trends = []
for i in range(days - 1, -1, -1):
day_ts = int(time.time() * 1000) - i * 86400000
date = time.strftime("%Y-%m-%d", time.localtime(day_ts / 1000))
trends.append({
"date": date,
"new_participants": 0,
"total_completions": 0,
"total_claims": 0,
})
return trends
Task 2.4: 创建 API Handler
Files:
-
Create:
backend/handlers/task_handler.py -
Step 1: 创建任务 Handler
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from database import get_db
from schemas.task_schemas import (
TaskDefinitionCreate, TaskDefinitionUpdate, TaskDefinitionResponse,
TaskDefinitionListResponse, DailyTaskProgressListResponse,
OnboardingProgressListResponse, OnboardingStatusResponse,
RevenueListResponse, TaskStatsByStar, TaskOverviewStats
)
from crud.task_crud import (
create_task_definition, get_task_definitions, update_task_definition,
delete_task_definition, toggle_task_definition,
get_daily_progress, get_onboarding_progress, get_onboarding_status,
reset_daily_tasks, get_task_stats_by_star, get_revenue_records,
get_task_overview_stats, get_daily_task_trend, get_stage_configs
)
from middleware.auth import verify_token
from typing import Optional
router = APIRouter(prefix="/api/admin/tasks", tags=["任务管理"])
# ========== 任务定义 CRUD ==========
@router.post("/definitions", response_model=TaskDefinitionResponse)
async def create_task(
task: TaskDefinitionCreate,
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
db_task = create_task_definition(db, task.model_dump())
return db_task
@router.get("/definitions", response_model=TaskDefinitionListResponse)
async def list_tasks(
task_type: Optional[str] = Query(None),
star_id: Optional[int] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(10, ge=1, le=100),
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
items, total = get_task_definitions(db, task_type, star_id, page, page_size)
return TaskDefinitionListResponse(
items=[TaskDefinitionResponse.model_validate(t) for t in items],
total=total, page=page, page_size=page_size
)
@router.put("/definitions/{task_id}", response_model=TaskDefinitionResponse)
async def update_task(
task_id: int,
updates: TaskDefinitionUpdate,
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
task = update_task_definition(db, task_id, updates.model_dump(exclude_unset=True))
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.delete("/definitions/{task_id}")
async def delete_task(
task_id: int,
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
success = delete_task_definition(db, task_id)
if not success:
raise HTTPException(status_code=404, detail="Task not found")
return {"message": "Task deleted successfully"}
@router.post("/definitions/{task_id}/toggle", response_model=TaskDefinitionResponse)
async def toggle_task(
task_id: int,
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
task = toggle_task_definition(db, task_id)
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return task
# ========== 任务进度 ==========
@router.get("/daily/progress", response_model=DailyTaskProgressListResponse)
async def list_daily_progress(
user_id: Optional[int] = Query(None),
star_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
items, total = get_daily_progress(db, user_id, star_id, status, page, page_size)
return DailyTaskProgressListResponse(items=items, total=total, page=page, page_size=page_size)
@router.get("/onboarding/progress", response_model=OnboardingProgressListResponse)
async def list_onboarding_progress(
user_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
items, total = get_onboarding_progress(db, user_id, status, page, page_size)
return OnboardingProgressListResponse(items=items, total=total, page=page, page_size=page_size)
@router.get("/onboarding/status/{user_id}", response_model=OnboardingStatusResponse)
async def get_onboarding_status(
user_id: int,
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
status = get_onboarding_status(db, user_id)
if not status:
raise HTTPException(status_code=404, detail="Onboarding status not found")
return status
@router.get("/onboarding/stages")
async def list_stages(
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
stages = get_stage_configs(db)
return {"stages": stages}
# ========== 手动操作 ==========
@router.post("/reset/daily")
async def reset_daily(
star_id: Optional[int] = Query(None),
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
count = reset_daily_tasks(db, star_id)
return {"message": f"Reset {count} daily task records", "affected": count}
# ========== 统计 ==========
@router.get("/stats/overview", response_model=TaskOverviewStats)
async def get_overview(
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
return get_task_overview_stats(db)
@router.get("/stats/star/{star_id}", response_model=TaskStatsByStar)
async def get_star_stats(
star_id: int,
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
return get_task_stats_by_star(db, star_id)
@router.get("/stats/daily-trend")
async def get_daily_trend(
star_id: Optional[int] = Query(None),
days: int = Query(7, ge=1, le=30),
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
trends = get_daily_task_trend(db, star_id, days)
return {"trends": trends}
# ========== 展示收益 ==========
@router.get("/revenue", response_model=RevenueListResponse)
async def list_revenue(
user_id: Optional[int] = Query(None),
star_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
_: dict = Depends(verify_token)
):
items, total = get_revenue_records(db, user_id, star_id, status, page, page_size)
return RevenueListResponse(items=items, total=total, page=page, page_size=page_size)
- Step 2: 修改 backend/router/init.py
from fastapi import APIRouter
from handlers import auth, activity, star, asset, task_handler
api_router = APIRouter()
api_router.include_router(auth.router)
api_router.include_router(activity.router)
api_router.include_router(star.router)
api_router.include_router(asset.router)
api_router.include_router(task_handler.router) # 新增
Phase 3: Vue Frontend (activity-admin) 任务管理页面
Task 3.1: 任务定义列表页
Files:
-
Create:
frontend/src/views/TaskDefinitionList.vue -
Step 1: 创建任务定义列表页面
<template>
<div class="task-definition-list">
<div class="header">
<h2>任务定义管理</h2>
<el-button type="primary" @click="handleCreate">新建任务</el-button>
</div>
<el-card class="filter-card">
<el-form :inline="true">
<el-form-item label="任务类型">
<el-select v-model="filters.task_type" clearable>
<el-option label="每日任务" value="daily" />
<el-option label="引导任务" value="onboarding" />
</el-select>
</el-form-item>
<el-form-item label="明星">
<el-select v-model="filters.star_id" placeholder="全部(全局)" clearable>
<el-option label="全局" :value="null" />
<el-option v-for="star in stars" :key="star.star_id"
:label="star.name" :value="star.star_id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchTasks">搜索</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card>
<el-table :data="tasks" v-loading="loading" row-key="id">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="task_type" label="类型" width="100">
<template #default="{ row }">
<el-tag :type="row.task_type === 'daily' ? 'success' : 'warning'">
{{ row.task_type === 'daily' ? '每日' : '引导' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="task_key" label="Task Key" width="180" />
<el-table-column prop="name" label="任务名称" min-width="150" />
<el-table-column prop="description" label="描述" min-width="200" show-overflow-tooltip />
<el-table-column label="奖励" width="120">
<template #default="{ row }">
💎 {{ row.crystal_reward }} | ✨ {{ row.exp_reward }}
</template>
</el-table-column>
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column prop="is_active" label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.is_active ? 'success' : 'info'">
{{ row.is_active ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchTasks" @current-change="fetchTasks"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="任务类型" required>
<el-select v-model="form.task_type">
<el-option label="每日任务" value="daily" />
<el-option label="引导任务" value="onboarding" />
</el-select>
</el-form-item>
<el-form-item label="Task Key" required>
<el-input v-model="form.task_key" placeholder="如 daily_login" />
</el-form-item>
<el-form-item label="任务名称" required>
<el-input v-model="form.name" placeholder="如 每日首次登录" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" />
</el-form-item>
<el-form-item label="水晶奖励">
<el-input-number v-model="form.crystal_reward" :min="0" />
</el-form-item>
<el-form-item label="经验奖励">
<el-input-number v-model="form.exp_reward" :min="0" />
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sort_order" :min="0" />
</el-form-item>
<el-form-item label="启用">
<el-switch v-model="form.is_active" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getTaskDefinitions, createTask, updateTask, deleteTask, getStars } from '@/api/admin'
import { ElMessage, ElMessageBox } from 'element-plus'
const tasks = ref([])
const stars = ref([])
const loading = ref(false)
const dialogVisible = ref(false)
const dialogTitle = ref('新建任务')
const isEdit = ref(false)
const filters = reactive({ task_type: '', star_id: null })
const pagination = reactive({ page: 1, page_size: 10, total: 0 })
const form = reactive({
task_type: 'daily',
task_key: '',
name: '',
description: '',
crystal_reward: 0,
exp_reward: 0,
sort_order: 0,
is_active: true,
star_id: null,
})
const fetchTasks = async () => {
loading.value = true
try {
const res = await getTaskDefinitions({
task_type: filters.task_type || undefined,
star_id: filters.star_id,
page: pagination.page,
page_size: pagination.page_size,
})
tasks.value = res.items
pagination.total = res.total
} finally {
loading.value = false
}
}
const fetchStars = async () => {
try {
const res = await getStars()
stars.value = res
} catch (e) { console.error(e) }
}
const resetFilters = () => {
filters.task_type = ''
filters.star_id = null
pagination.page = 1
fetchTasks()
}
const handleCreate = () => {
isEdit.value = false
dialogTitle.value = '新建任务'
Object.assign(form, {
task_type: 'daily', task_key: '', name: '', description: '',
crystal_reward: 0, exp_reward: 0, sort_order: 0, is_active: true, star_id: null,
})
dialogVisible.value = true
}
const handleEdit = (row) => {
isEdit.value = true
dialogTitle.value = '编辑任务'
Object.assign(form, row)
dialogVisible.value = true
}
const handleSave = async () => {
try {
if (isEdit.value) {
await updateTask(form.id, form)
ElMessage.success('更新成功')
} else {
await createTask(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchTasks()
} catch (e) {
ElMessage.error('保存失败')
}
}
const handleDelete = async (id) => {
await ElMessageBox.confirm('确定要删除此任务定义吗?', '警告', { type: 'error' })
await deleteTask(id)
ElMessage.success('删除成功')
fetchTasks()
}
onMounted(() => { fetchTasks(); fetchStars() })
</script>
Task 3.2: 任务进度查询页
Files:
-
Create:
frontend/src/views/TaskProgress.vue -
Step 1: 创建任务进度查询页面
<template>
<div class="task-progress">
<div class="header">
<h2>任务进度查询</h2>
</div>
<el-tabs v-model="activeTab">
<el-tab-pane label="每日任务" name="daily">
<el-card>
<el-form :inline="true">
<el-form-item label="用户ID">
<el-input v-model="filters.user_id" placeholder="用户ID" clearable />
</el-form-item>
<el-form-item label="明星ID">
<el-input v-model="filters.star_id" placeholder="明星ID" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable>
<el-option label="待完成" value="pending" />
<el-option label="已完成" value="completed" />
<el-option label="已领取" value="claimed" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchDailyProgress">搜索</el-button>
</el-form-item>
</el-form>
<el-table :data="dailyItems" v-loading="loading" style="margin-top: 20px">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="user_id" label="用户ID" width="100" />
<el-table-column prop="star_id" label="明星ID" width="100" />
<el-table-column prop="task_key" label="任务Key" width="180" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag>{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="completed_at" label="完成时间" width="180">
<template #default="{ row }">
{{ formatTime(row.completed_at) }}
</template>
</el-table-column>
<el-table-column prop="claimed_at" label="领取时间" width="180">
<template #default="{ row }">
{{ formatTime(row.claimed_at) }}
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchDailyProgress" @current-change="fetchDailyProgress"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
</el-tab-pane>
<el-tab-pane label="引导任务" name="onboarding">
<el-card>
<el-form :inline="true">
<el-form-item label="用户ID">
<el-input v-model="onboardFilters.user_id" placeholder="用户ID" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="onboardFilters.status" clearable>
<el-option label="待完成" value="pending" />
<el-option label="已完成" value="completed" />
<el-option label="已领取" value="claimed" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchOnboardingProgress">搜索</el-button>
</el-form-item>
</el-form>
<el-table :data="onboardItems" v-loading="onboardLoading" style="margin-top: 20px">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="user_id" label="用户ID" width="100" />
<el-table-column prop="task_key" label="任务Key" width="180" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag>{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="completed_at" label="完成时间" width="180" />
<el-table-column prop="claimed_at" label="领取时间" width="180" />
</el-table>
<el-pagination
v-model:current-page="onboardPagination.page"
v-model:page-size="onboardPagination.page_size"
:total="onboardPagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchOnboardingProgress" @current-change="fetchOnboardingProgress"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getDailyProgress, getOnboardingProgress } from '@/api/admin'
const activeTab = ref('daily')
const loading = ref(false)
const onboardLoading = ref(false)
const filters = reactive({ user_id: null, star_id: null, status: '' })
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const dailyItems = ref([])
const onboardFilters = reactive({ user_id: null, status: '' })
const onboardPagination = reactive({ page: 1, page_size: 20, total: 0 })
const onboardItems = ref([])
const formatTime = (ts) => {
if (!ts) return '-'
return new Date(ts).toLocaleString()
}
const fetchDailyProgress = async () => {
loading.value = true
try {
const res = await getDailyProgress({
user_id: filters.user_id || undefined,
star_id: filters.star_id || undefined,
status: filters.status || undefined,
page: pagination.page,
page_size: pagination.page_size,
})
dailyItems.value = res.items
pagination.total = res.total
} finally {
loading.value = false
}
}
const fetchOnboardingProgress = async () => {
onboardLoading.value = true
try {
const res = await getOnboardingProgress({
user_id: onboardFilters.user_id || undefined,
status: onboardFilters.status || undefined,
page: onboardPagination.page,
page_size: onboardPagination.page_size,
})
onboardItems.value = res.items
onboardPagination.total = res.total
} finally {
onboardLoading.value = false
}
}
onMounted(() => {
fetchDailyProgress()
fetchOnboardingProgress()
})
</script>
Task 3.3: 任务统计页
Files:
-
Create:
frontend/src/views/TaskStats.vue -
Step 1: 创建任务统计页面
<template>
<div class="task-stats">
<div class="header">
<h2>任务系统统计</h2>
<el-select v-model="selectedStarId" placeholder="选择明星" clearable @change="fetchStats">
<el-option v-for="star in stars" :key="star.star_id"
:label="star.name" :value="star.star_id" />
</el-select>
</div>
<el-row :gutter="20" class="overview-cards">
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.total_definitions }}</div>
<div class="stat-label">任务定义总数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.active_definitions }}</div>
<div class="stat-label">启用任务数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.total_daily_progress }}</div>
<div class="stat-label">每日任务进度记录</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<div class="stat-card">
<div class="stat-value">{{ stats.claimable_revenue }}</div>
<div class="stat-label">待领取收益</div>
</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="charts-row">
<el-col :span="12">
<el-card title="每日任务完成率趋势">
<div ref="completionChartRef" style="height: 300px"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card title="任务类型分布">
<div ref="distributionChartRef" style="height: 300px"></div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" class="charts-row">
<el-col :span="12">
<el-card title="每日任务领取率趋势">
<div ref="claimChartRef" style="height: 300px"></div>
</el-card>
</el-col>
<el-col :span="12">
<el-card title="热门任务 TOP10">
<div ref="hotTasksChartRef" style="height: 300px"></div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import { getOverviewStats, getDailyTrend, getStars } from '@/api/admin'
const selectedStarId = ref(null)
const stars = ref([])
const stats = reactive({
total_definitions: 0,
active_definitions: 0,
total_daily_progress: 0,
claimable_revenue: 0,
})
const completionChartRef = ref(null)
const claimChartRef = ref(null)
const distributionChartRef = ref(null)
const hotTasksChartRef = ref(null)
let completionChart, claimChart, distributionChart, hotTasksChart
const fetchStats = async () => {
try {
const overview = await getOverviewStats()
Object.assign(stats, overview)
const trendRes = await getDailyTrend({ star_id: selectedStarId.value, days: 7 })
updateCompletionChart(trendRes.trends)
updateClaimChart(trendRes.trends)
updateDistributionChart()
} catch (e) {
console.error(e)
}
}
const updateCompletionChart = (trends) => {
const xData = trends.map(t => t.date)
const yData = trends.map(t => t.completion_rate || 0)
completionChart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: xData },
yAxis: { type: 'value', name: '完成率%', max: 100 },
series: [{
name: '完成率',
type: 'line',
smooth: true,
data: yData,
areaStyle: { color: 'rgba(82, 196, 26, 0.2)' },
lineStyle: { color: '#52c41a' },
}]
})
}
const updateClaimChart = (trends) => {
const xData = trends.map(t => t.date)
const yData = trends.map(t => t.claim_rate || 0)
claimChart.setOption({
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: xData },
yAxis: { type: 'value', name: '领取率%', max: 100 },
series: [{
name: '领取率',
type: 'bar',
data: yData,
itemStyle: { color: '#1890ff' },
}]
})
}
const updateDistributionChart = () => {
distributionChart.setOption({
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [{
type: 'pie',
radius: ['40%', '70%'],
label: { show: true, formatter: '{b}: {c} ({d}%)' },
data: [
{ value: 4, name: '每日任务' },
{ value: 5, name: '引导任务' },
]
}]
})
}
onMounted(async () => {
await nextTick()
completionChart = echarts.init(completionChartRef.value)
claimChart = echarts.init(claimChartRef.value)
distributionChart = echarts.init(distributionChartRef.value)
hotTasksChart = echarts.init(hotTasksChartRef.value)
const starRes = await getStars()
stars.value = starRes
await fetchStats()
})
</script>
Task 3.4: 手动重置页
Files:
-
Create:
frontend/src/views/TaskReset.vue -
Step 1: 创建手动重置页面
<template>
<div class="task-reset">
<div class="header">
<h2>手动重置每日任务</h2>
</div>
<el-card>
<el-alert
title="警告"
type="warning"
description="重置操作会将所有用户的每日任务进度恢复为待完成状态,已领取的奖励不会回退。"
:closable="false"
style="margin-bottom: 20px"
/>
<el-form :inline="true">
<el-form-item label="明星">
<el-select v-model="selectedStarId" placeholder="全部(全局重置)" clearable>
<el-option label="全局" :value="null" />
<el-option v-for="star in stars" :key="star.star_id"
:label="star.name" :value="star.star_id" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="danger" @click="handleReset" :loading="loading">
执行重置
</el-button>
</el-form-item>
</el-form>
<div v-if="resetResult" style="margin-top: 20px">
<el-alert
:title="`重置成功,影响 ${resetResult} 条记录`"
type="success"
show-icon
/>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { resetDailyTasks, getStars } from '@/api/admin'
import { ElMessage } from 'element-plus'
const selectedStarId = ref(null)
const stars = ref([])
const loading = ref(false)
const resetResult = ref(null)
const fetchStars = async () => {
try {
const res = await getStars()
stars.value = res
} catch (e) {
console.error(e)
}
}
const handleReset = async () => {
loading.value = true
resetResult.value = null
try {
const res = await resetDailyTasks(selectedStarId.value)
resetResult.value = res.affected
ElMessage.success(`重置成功,影响 ${res.affected} 条记录`)
} catch (e) {
ElMessage.error('重置失败')
} finally {
loading.value = false
}
}
onMounted(() => {
fetchStars()
})
</script>
Task 3.5: 展示收益页
Files:
-
Create:
frontend/src/views/RevenueList.vue -
Step 1: 创建展示收益页面
<template>
<div class="revenue-list">
<div class="header">
<h2>展示收益记录</h2>
</div>
<el-card>
<el-form :inline="true">
<el-form-item label="用户ID">
<el-input v-model="filters.user_id" placeholder="用户ID" clearable />
</el-form-item>
<el-form-item label="明星ID">
<el-input v-model="filters.star_id" placeholder="明星ID" clearable />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable>
<el-option label="可领取" value="claimable" />
<el-option label="已领取" value="claimed" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="fetchRevenue">搜索</el-button>
</el-form-item>
</el-form>
<el-table :data="items" v-loading="loading" style="margin-top: 20px">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="user_id" label="用户ID" width="100" />
<el-table-column prop="star_id" label="明星ID" width="100" />
<el-table-column prop="slot_type" label="展位类型" width="100">
<template #default="{ row }">
<el-tag :type="row.slot_type === 'own' ? 'success' : 'warning'">
{{ row.slot_type === 'own' ? '自有' : '好友' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="crystal_amount" label="水晶数量" width="120">
<template #default="{ row }">
💎 {{ row.crystal_amount }}
</template>
</el-table-column>
<el-table-column prop="cycle_start_time" label="周期开始" width="180">
<template #default="{ row }">
{{ formatTime(row.cycle_start_time) }}
</template>
</el-table-column>
<el-table-column prop="cycle_end_time" label="周期结束" width="180">
<template #default="{ row }">
{{ formatTime(row.cycle_end_time) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'claimable' ? 'success' : 'info'">
{{ row.status === 'claimable' ? '可领取' : '已领取' }}
</el-tag>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.page_size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="fetchRevenue" @current-change="fetchRevenue"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getRevenueRecords } from '@/api/admin'
const loading = ref(false)
const filters = reactive({ user_id: null, star_id: null, status: '' })
const pagination = reactive({ page: 1, page_size: 20, total: 0 })
const items = ref([])
const formatTime = (ts) => {
if (!ts) return '-'
return new Date(ts).toLocaleString()
}
const fetchRevenue = async () => {
loading.value = true
try {
const res = await getRevenueRecords({
user_id: filters.user_id || undefined,
star_id: filters.star_id || undefined,
status: filters.status || undefined,
page: pagination.page,
page_size: pagination.page_size,
})
items.value = res.items
pagination.total = res.total
} finally {
loading.value = false
}
}
onMounted(() => {
fetchRevenue()
})
</script>
Task 3.6: 更新 API 和路由
Files:
-
Modify:
frontend/src/api/admin.js -
Step 1: 添加任务相关 API
// 任务定义
export function getTaskDefinitions(params) {
return request.get('/tasks/definitions', { params })
}
export function createTask(data) {
return request.post('/tasks/definitions', data)
}
export function updateTask(id, data) {
return request.put(`/tasks/definitions/${id}`, data)
}
export function deleteTask(id) {
return request.delete(`/tasks/definitions/${id}`)
}
// 任务进度
export function getDailyProgress(params) {
return request.get('/tasks/daily/progress', { params })
}
export function getOnboardingProgress(params) {
return request.get('/tasks/onboarding/progress', { params })
}
export function getOnboardingStatus(userId) {
return request.get(`/tasks/onboarding/status/${userId}`)
}
export function getOnboardingStages() {
return request.get('/tasks/onboarding/stages')
}
// 手动重置
export function resetDailyTasks(starId) {
return request.post('/tasks/reset/daily', null, { params: { star_id: starId } })
}
// 统计
export function getOverviewStats() {
return request.get('/tasks/stats/overview')
}
export function getDailyTrend(params) {
return request.get('/tasks/stats/daily-trend', { params })
}
export function getStarStats(starId) {
return request.get(`/tasks/stats/star/${starId}`)
}
// 展示收益
export function getRevenueRecords(params) {
return request.get('/tasks/revenue', { params })
}
- Step 2: 修改 frontend/src/router/index.js
{
path: '/tasks',
component: Layout,
children: [
{ path: '', redirect: '/tasks/definitions' },
{ path: 'definitions', component: () => import('@/views/TaskDefinitionList.vue') },
{ path: 'progress', component: () => import('@/views/TaskProgress.vue') },
{ path: 'stats', component: () => import('@/views/TaskStats.vue') },
{ path: 'reset', component: () => import('@/views/TaskReset.vue') },
{ path: 'revenue', component: () => import('@/views/RevenueList.vue') },
]
}
Phase 4: Go taskService 实现
Task 4.1: 创建 Proto 定义
Files:
-
Create:
backend/proto/task.proto -
Step 1: 创建 task.proto
syntax = "proto3";
package topfans.task;
option go_package = "github.com/topfans/backend/pkg/proto/task;task";
import "proto/common.proto";
// ========== 内部 RPC ==========
// 展品到期完成事件参数
message OnExhibitionCompletedParams {
int64 exhibition_id = 1;
int64 asset_id = 2;
int64 slot_id = 3;
int64 occupier_uid = 4; // 展出者 UID
int64 occupier_star_id = 5; // 展出者明星 ID
int64 slot_owner_uid = 6; // 槽位拥有者 UID(应收水晶的人)
int64 start_time = 7; // 开始时间(毫秒)
int64 expire_at = 8; // 过期时间(毫秒)
}
// 任务事件上报参数
message ReportTaskEventParams {
int64 user_id = 1;
int64 star_id = 2;
string event_type = 3; // daily_login, daily_browse_asset, daily_mint, daily_place_asset
int64 timestamp = 4;
}
// 初始化用户任务数据
message InitUserTasksRequest {
int64 user_id = 1;
}
message InitUserTasksResponse {
topfans.common.BaseResponse base = 1;
bool success = 2;
}
// ========== 内部 RPC Service ==========
service TaskInternalService {
// 初始化用户任务数据(注册时调用)
rpc InitUserTasks(InitUserTasksRequest) returns (InitUserTasksResponse);
// 接收任务事件上报(其他服务调用)
rpc ReportTaskEvent(ReportTaskEventRequest) returns (ReportTaskEventResponse);
// 展品到期完成(galleryService 调用)
rpc OnExhibitionCompleted(OnExhibitionCompletedRequest) returns (OnExhibitionCompletedResponse);
}
message ReportTaskEventRequest {
int64 user_id = 1;
int64 star_id = 2;
string event_type = 3;
int64 timestamp = 4;
}
message ReportTaskEventResponse {
topfans.common.BaseResponse base = 1;
bool task_completed = 2; // 任务是否完成
string task_key = 3;
}
message OnExhibitionCompletedRequest {
OnExhibitionCompletedParams params = 1;
}
message OnExhibitionCompletedResponse {
topfans.common.BaseResponse base = 1;
int64 revenue_record_id = 2; // 创建的收益记录 ID
}
- Step 2: 重新生成 proto 代码
Run: cd backend && protoc --go_out=. --go_opt=paths=source_relative proto/task.proto
Task 4.2: 创建 Go taskService 结构
Files:
-
Create:
backend/services/taskService/main.go -
Create:
backend/services/taskService/config/task_config.go -
Create:
backend/services/taskService/repository/task_repository.go -
Create:
backend/services/taskService/service/daily_task_service.go -
Create:
backend/services/taskService/service/exhibition_revenue_service.go -
Create:
backend/services/taskService/service/onboarding_service.go -
Create:
backend/services/taskService/provider/task_provider.go -
Create:
backend/services/taskService/provider/task_internal_provider.go -
Create:
backend/services/taskService/worker/daily_reset_worker.go -
Create:
backend/services/taskService/client/user_rpc_client.go -
Step 1: 创建 main.go
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"strconv"
"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"
pbTask "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/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", getEnvInt("PORT", 20005), "Dubbo service port")
dbHost = flag.String("db-host", getEnv("DB_HOST", "localhost"), "Database host")
dbPort = flag.Int("db-port", getEnvInt("DB_PORT", 5432), "Database port")
dbUser = flag.String("db-user", getEnv("DB_USER", "postgres"), "Database user")
dbPassword = flag.String("db-password", getEnv("DB_PASSWORD", ""), "Database password")
dbName = flag.String("db-name", getEnv("DB_NAME", "top-fans"), "Database name")
userServiceURL = flag.String("user-service-url", getEnv("USER_SERVICE_URL", "tri://localhost:20000"), "User service URL")
)
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 main() {
flag.Parse()
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 initialize logger: %v", err))
}
defer logger.Sync()
logger.Logger.Info("Starting Task Service...")
// 初始化数据库
dbConfig := database.Config{
Host: *dbHost,
Port: *dbPort,
User: *dbUser,
Password: *dbPassword,
DBName: *dbName,
SSLMode: "disable",
TimeZone: "Asia/Shanghai",
}
if err := database.Init(dbConfig); err != nil {
logger.Logger.Fatal(fmt.Sprintf("Failed to initialize database: %v", err))
}
logger.Logger.Info("Database initialized successfully")
// 创建 Repository
db := database.GetDB()
taskRepo := repository.NewTaskRepository(db)
logger.Logger.Info("Repository layer initialized")
// 创建 User Service RPC 客户端
userCli, err := dubboclient.NewClient(
dubboclient.WithClientURL(*userServiceURL),
)
if err != nil {
logger.Logger.Fatal(fmt.Sprintf("Failed to create User Service Dubbo client: %v", err))
}
userServiceClient, err := pbUser.NewUserSocialService(userCli)
if err != nil {
logger.Logger.Fatal(fmt.Sprintf("Failed to create User Service RPC client: %v", err))
}
userRPCClient := client.NewUserRPCClient(userServiceClient)
logger.Logger.Info("User Service RPC client initialized")
// 创建 Service 层
dailyTaskService := service.NewDailyTaskService(taskRepo, userRPCClient)
exhibitionRevenueService := service.NewExhibitionRevenueService(taskRepo, userRPCClient)
onboardingService := service.NewOnboardingService(taskRepo, userRPCClient)
logger.Logger.Info("Service layer initialized")
// 创建 Provider
taskProvider := provider.NewTaskProvider(dailyTaskService, exhibitionRevenueService, onboardingService)
taskInternalProvider := provider.NewTaskInternalProvider(dailyTaskService, exhibitionRevenueService, onboardingService)
logger.Logger.Info("Provider layer initialized")
// 创建并启动 DailyResetWorker
resetWorker := worker.NewDailyResetWorker(taskRepo, config.GetDailyResetTime())
go resetWorker.Start()
logger.Logger.Info("Daily reset worker started")
// 创建 Dubbo 服务器
srv, err := server.NewServer(
server.WithServerProtocol(
protocol.WithPort(*port),
protocol.WithTriple(),
),
)
if err != nil {
logger.Logger.Fatal(fmt.Sprintf("Failed to create Dubbo server: %v", err))
}
// 注册服务
if err := pbTask.RegisterTaskInternalServiceHandler(srv, taskInternalProvider); 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 start Task Service: %v", err))
}
logger.Logger.Info(fmt.Sprintf("Task Service started successfully on port %d", *port))
// 等待退出信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
logger.Logger.Info("Shutting down Task Service...")
resetWorker.Stop()
logger.Logger.Info("Task Service shutdown complete")
}
Phase 5: userService AddExperience RPC
Task 5.1: 修改 proto/user.proto
Files:
-
Modify:
backend/proto/user.proto -
Step 1: 添加 AddExperience 消息和 RPC
// 更新经验值请求(内部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:更新经验值(仅供taskService调用)
rpc AddExperience(AddExperienceRequest) returns (AddExperienceResponse);
- Step 2: 重新生成 proto 代码
Run: cd backend && protoc --go_out=. --go_opt=paths=source_relative proto/user.proto
Task 5.2: 修改 repository
Files:
-
Modify:
backend/services/userService/repository/fan_profile_repository.go -
Step 1: 添加 UpdateExperience 方法
// FanProfileRepository 接口添加:
UpdateExperience(userID, starID int64, delta int64) (int64, error)
// 实现:
func (r *fanProfileRepository) UpdateExperience(userID, starID int64, delta int64) (int64, error) {
if userID <= 0 {
return 0, errors.New("user_id must be greater than 0")
}
var profile models.FanProfile
if err := r.db.Where("user_id = ? AND star_id = ?", userID, starID).First(&profile).Error; err != nil {
return 0, err
}
profile.Experience += delta
if err := r.db.Save(&profile).Error; err != nil {
return 0, err
}
return profile.Experience, nil
}
Task 5.3: 修改 service
Files:
-
Modify:
backend/services/userService/service/user_service.go -
Step 1: 添加 AddExperience 方法
// UserService 接口添加:
AddExperience(req *pb.AddExperienceRequest) (*pb.AddExperienceResponse, error)
// 实现:
func (s *userService) AddExperience(req *pb.AddExperienceRequest) (*pb.AddExperienceResponse, error) {
if !validator.ValidateUserID(req.UserId) || !validator.ValidateStarID(req.StarId) {
return &pb.AddExperienceResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInvalidUserID),
}, nil
}
newExp, err := s.fanProfileRepo.UpdateExperience(req.UserId, req.StarId, req.Delta)
if err != nil {
logger.Logger.Error("Failed to update experience", zap.Error(err))
return &pb.AddExperienceResponse{
Base: appErrors.BuildBaseResponse(appErrors.ErrInternalServer),
}, nil
}
return &pb.AddExperienceResponse{
Base: &pbCommon.BaseResponse{
Code: pbCommon.StatusCode_STATUS_OK,
Message: "",
},
NewExperience: newExp,
}, nil
}
Task 5.4: 修改 provider
Files:
-
Modify:
backend/services/userService/provider/user_provider.go -
Step 1: 添加 AddExperience Provider 方法
// AddExperience 更新经验值(内部RPC调用)
func (p *UserProvider) AddExperience(ctx context.Context, req *pb.AddExperienceRequest) (*pb.AddExperienceResponse, error) {
logger.Logger.Info("Received AddExperience request",
zap.Int64("user_id", req.UserId),
zap.Int64("star_id", req.StarId),
zap.Int64("delta", req.Delta),
)
resp, err := p.userService.AddExperience(req)
if err != nil {
logger.Logger.Error("AddExperience failed", zap.Error(err))
}
return resp, err
}
- Step 2: 修改 unified_provider.go
// 在 UnifiedProvider 中添加:
func (p *UnifiedProvider) AddExperience(ctx context.Context, req *pb.AddExperienceRequest) (*pb.AddExperienceResponse, error) {
return p.userProvider.AddExperience(ctx, req)
}
Phase 6: 前端 API 适配(移动端)
Task 6.1: 更新前端 API 调用
Files:
-
Create/Modify:
frontend/src/api/task.js(移动端任务 API) -
Step 1: 创建移动端任务 API
// 每日任务
export function getDailyTasks(starId) {
return request.get('/tasks/daily', { params: { star_id: starId } })
}
export function claimTaskReward(taskKey, starId) {
return request.post('/tasks/claim', { task_key: taskKey, star_id: starId })
}
// 引导任务
export function getOnboardingStages() {
return request.get('/tasks/onboarding/stages')
}
export function getOnboardingStatus() {
return request.get('/tasks/onboarding/status')
}
export function completeOnboarding(taskKey) {
return request.post('/tasks/onboarding/complete', { task_key: taskKey })
}
export function claimOnboardingReward(taskKey) {
return request.post('/tasks/onboarding/claim', { task_key: taskKey })
}
// 展示收益
export function getExhibitionRevenue(starId, status, page, pageSize) {
return request.get('/tasks/exhibition-revenue', {
params: { star_id: starId, status, page, page_size: pageSize }
})
}
export function claimExhibitionRevenue(revenueId) {
return request.post('/tasks/exhibition-revenue/claim', { revenue_id: revenueId })
}
export function claimAllExhibitionRevenue() {
return request.post('/tasks/exhibition-revenue/claim-all')
}
// 等级
export function getLevelInfo() {
return request.get('/tasks/level')
}
// 初始化
export function initUserTasks() {
return request.post('/tasks/init')
}
实现检查点
完成所有任务后,请验证以下功能:
- 数据库:所有表已创建,默认数据已插入
- activity-admin:
- 任务定义列表页面可访问
- 新建/编辑/删除任务定义正常
- 任务进度查询正常
- 任务统计页面显示图表
- 手动重置功能正常
- 展示收益记录查询正常
- Go taskService:
- 服务启动正常
- 每日重置 Worker 运行正常(5:00 Asia/Shanghai)
- 内部 RPC 可被 galleryService 调用
- userService:
- AddExperience RPC 可正常调用
- 移动端:
- 每日任务列表显示正常
- 任务完成上报正常
- 奖励领取正常
- 展示收益显示和领取正常