2418 lines
73 KiB
Markdown
2418 lines
73 KiB
Markdown
# 任务管理系统实现计划
|
||
|
||
> **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. **移动端**:
|
||
- [ ] 每日任务列表显示正常
|
||
- [ ] 任务完成上报正常
|
||
- [ ] 奖励领取正常
|
||
- [ ] 展示收益显示和领取正常
|