topfans/docs/superpowers/plans/2026-04-08-task-management-system-implementation-plan.md
2026-04-10 16:17:45 +08:00

2418 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 任务管理系统实现计划
> **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 迁移脚本**
```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: 创建默认任务数据脚本**
```sql
-- 插入默认每日任务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: 创建任务相关模型**
```python
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**
```python
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**
```python
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 函数**
```python
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**
```python
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**
```python
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: 创建任务定义列表页面**
```vue
<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: 创建任务进度查询页面**
```vue
<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: 创建任务统计页面**
```vue
<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: 创建手动重置页面**
```vue
<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: 创建展示收益页面**
```vue
<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**
```javascript
// 任务定义
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**
```javascript
{
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**
```protobuf
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**
```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**
```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更新经验值仅供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 方法**
```go
// 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 方法**
```go
// 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 方法**
```go
// 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**
```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**
```javascript
// 每日任务
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')
}
```
---
## 实现检查点
完成所有任务后,请验证以下功能:
1. **数据库**:所有表已创建,默认数据已插入
2. **activity-admin**
- [ ] 任务定义列表页面可访问
- [ ] 新建/编辑/删除任务定义正常
- [ ] 任务进度查询正常
- [ ] 任务统计页面显示图表
- [ ] 手动重置功能正常
- [ ] 展示收益记录查询正常
3. **Go taskService**
- [ ] 服务启动正常
- [ ] 每日重置 Worker 运行正常5:00 Asia/Shanghai
- [ ] 内部 RPC 可被 galleryService 调用
4. **userService**
- [ ] AddExperience RPC 可正常调用
5. **移动端**
- [ ] 每日任务列表显示正常
- [ ] 任务完成上报正常
- [ ] 奖励领取正常
- [ ] 展示收益显示和领取正常