haha
This commit is contained in:
32
QA_CHECKLIST.md
Normal file
32
QA_CHECKLIST.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 最小验收清单(功能自测)
|
||||
|
||||
## 账号与权限
|
||||
- 注册新账号并登录,能获取 `/api/auth/me` 返回用户信息
|
||||
- 非管理员访问 `/admin` 自动跳回首页
|
||||
- 管理员访问 `/admin` 正常看到用户/悬赏/支付事件列表
|
||||
|
||||
## 悬赏流程
|
||||
- 发布悬赏 → 列表/详情可见
|
||||
- 其他用户申请接单 → 发布者在详情页接受申请
|
||||
- 接单者提交交付内容 → 发布者验收(通过/驳回)
|
||||
- 验收通过后可完成悬赏
|
||||
- 完成后双方可互评
|
||||
|
||||
## 支付流程
|
||||
- 发布者创建托管支付(跳转 Stripe)
|
||||
- 完成支付后悬赏状态为已托管
|
||||
- 发布者完成悬赏后释放赏金
|
||||
- 支付事件在管理后台可查看
|
||||
|
||||
## 收藏与价格监控
|
||||
- 收藏商品并设置监控(目标价/提醒开关)
|
||||
- 刷新价格后产生价格历史记录
|
||||
- 达到目标价时产生通知
|
||||
|
||||
## 通知与偏好
|
||||
- 通知列表可查看、单条已读、全部已读
|
||||
- 通知偏好开关能控制对应类型通知是否创建
|
||||
|
||||
## 争议与延期
|
||||
- 接单者可提交延期申请,发布者可同意/拒绝
|
||||
- 争议可由任一方发起,管理员可处理
|
||||
131
README.md
Normal file
131
README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# AI Web 资源聚合平台
|
||||
|
||||
一个全栈 Web 应用,包含商品导航、悬赏任务系统、收藏管理等功能。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
ai_web/
|
||||
├── frontend/ # React 前端 (TypeScript + Vite)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # UI 组件
|
||||
│ │ ├── pages/ # 页面组件
|
||||
│ │ ├── hooks/ # React Hooks
|
||||
│ │ ├── lib/ # API 客户端和工具
|
||||
│ │ └── contexts/ # React Context
|
||||
│ └── index.html
|
||||
├── backend/ # Django 后端
|
||||
│ ├── config/ # Django 项目配置
|
||||
│ ├── apps/ # Django 应用模块
|
||||
│ │ ├── users/ # 用户认证
|
||||
│ │ ├── products/ # 商品和分类
|
||||
│ │ ├── bounties/ # 悬赏系统
|
||||
│ │ ├── favorites/ # 收藏管理
|
||||
│ │ └── notifications/ # 通知系统
|
||||
│ ├── requirements.txt
|
||||
│ └── manage.py
|
||||
└── shared/ # 共享类型定义
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
### 前端
|
||||
- React 18 + TypeScript
|
||||
- Vite
|
||||
- TanStack Query (React Query)
|
||||
- Tailwind CSS
|
||||
- Radix UI
|
||||
- Wouter (路由)
|
||||
|
||||
### 后端
|
||||
- Django 4.2
|
||||
- Django Ninja (API 框架)
|
||||
- MySQL
|
||||
- Stripe (支付)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 安装前端依赖
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 2. 安装后端依赖
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
|
||||
# 创建虚拟环境
|
||||
python -m venv venv
|
||||
|
||||
# 激活虚拟环境 (Windows)
|
||||
venv\Scripts\activate
|
||||
|
||||
# 激活虚拟环境 (Linux/Mac)
|
||||
source venv/bin/activate
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 3. 配置环境变量
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件,填入实际配置
|
||||
```
|
||||
|
||||
### 4. 初始化数据库
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python manage.py migrate
|
||||
python manage.py createsuperuser # 创建管理员账号
|
||||
```
|
||||
|
||||
### 5. 运行项目
|
||||
|
||||
**启动后端** (端口 8000):
|
||||
```bash
|
||||
cd backend
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
**启动前端** (端口 5173):
|
||||
```bash
|
||||
cd frontend
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问 http://localhost:5173 查看应用。
|
||||
|
||||
## API 文档
|
||||
|
||||
启动后端后,访问 http://localhost:8000/api/docs 查看 API 文档。
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 商品导航
|
||||
- 浏览购物网站和商品
|
||||
- 多平台价格对比
|
||||
- 商品搜索与筛选
|
||||
|
||||
### 悬赏系统
|
||||
- 发布悬赏任务
|
||||
- 申请接取任务
|
||||
- 赏金托管 (Stripe)
|
||||
- 任务完成确认与支付
|
||||
|
||||
### 收藏管理
|
||||
- 商品收藏
|
||||
- 标签分类
|
||||
- 价格监控
|
||||
- 降价提醒
|
||||
|
||||
### 用户系统
|
||||
- OAuth 登录
|
||||
- 个人中心
|
||||
- 通知系统
|
||||
24
backend/.env.example
Normal file
24
backend/.env.example
Normal file
@@ -0,0 +1,24 @@
|
||||
# Django settings
|
||||
DJANGO_SECRET_KEY=your-secret-key-here
|
||||
DEBUG=True
|
||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||
|
||||
# Database (MySQL)
|
||||
DB_NAME=ai_web
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your-password
|
||||
DB_HOST=localhost
|
||||
DB_PORT=3306
|
||||
|
||||
# CORS
|
||||
CORS_ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||
|
||||
# Stripe
|
||||
STRIPE_SECRET_KEY=sk_test_xxx
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_xxx
|
||||
STRIPE_WEBHOOK_SECRET=whsec_xxx
|
||||
|
||||
# OAuth (Manus SDK compatible)
|
||||
OAUTH_CLIENT_ID=your-oauth-client-id
|
||||
OAUTH_CLIENT_SECRET=your-oauth-client-secret
|
||||
OAUTH_REDIRECT_URI=http://localhost:8000/api/auth/callback
|
||||
1
backend/apps/__init__.py
Normal file
1
backend/apps/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Django apps package
|
||||
1
backend/apps/admin/__init__.py
Normal file
1
backend/apps/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Admin API package."""
|
||||
179
backend/apps/admin/api.py
Normal file
179
backend/apps/admin/api.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
Admin API routes for managing core data.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from ninja import Router, Schema
|
||||
from ninja.errors import HttpError
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
|
||||
from apps.users.models import User
|
||||
from apps.products.models import Product, Website, Category
|
||||
from apps.bounties.models import Bounty, BountyDispute, PaymentEvent
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def require_admin(user):
|
||||
if not user or user.role != 'admin':
|
||||
raise HttpError(403, "仅管理员可访问")
|
||||
|
||||
|
||||
class UserOut(Schema):
|
||||
id: int
|
||||
open_id: str
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
role: str
|
||||
is_active: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
class UserUpdateIn(Schema):
|
||||
role: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class SimpleOut(Schema):
|
||||
id: int
|
||||
name: str
|
||||
|
||||
|
||||
class BountyAdminOut(Schema):
|
||||
id: int
|
||||
title: str
|
||||
status: str
|
||||
reward: str
|
||||
publisher_id: int
|
||||
acceptor_id: Optional[int] = None
|
||||
is_escrowed: bool
|
||||
is_paid: bool
|
||||
created_at: str
|
||||
|
||||
|
||||
class PaymentEventOut(Schema):
|
||||
id: int
|
||||
event_id: str
|
||||
event_type: str
|
||||
bounty_id: Optional[int] = None
|
||||
success: bool
|
||||
processed_at: str
|
||||
|
||||
|
||||
class DisputeOut(Schema):
|
||||
id: int
|
||||
bounty_id: int
|
||||
initiator_id: int
|
||||
status: str
|
||||
created_at: str
|
||||
|
||||
|
||||
@router.get("/users/", response=List[UserOut], auth=JWTAuth())
|
||||
def list_users(request):
|
||||
require_admin(request.auth)
|
||||
users = User.objects.all().order_by('-created_at')
|
||||
return [
|
||||
UserOut(
|
||||
id=u.id,
|
||||
open_id=u.open_id,
|
||||
name=u.name,
|
||||
email=u.email,
|
||||
role=u.role,
|
||||
is_active=u.is_active,
|
||||
created_at=u.created_at.isoformat(),
|
||||
)
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response=UserOut, auth=JWTAuth())
|
||||
def update_user(request, user_id: int, data: UserUpdateIn):
|
||||
require_admin(request.auth)
|
||||
user = User.objects.get(id=user_id)
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(user, key, value)
|
||||
user.save()
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
open_id=user.open_id,
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
role=user.role,
|
||||
is_active=user.is_active,
|
||||
created_at=user.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/categories/", response=List[SimpleOut], auth=JWTAuth())
|
||||
def list_categories(request):
|
||||
require_admin(request.auth)
|
||||
return [SimpleOut(id=c.id, name=c.name) for c in Category.objects.all()]
|
||||
|
||||
|
||||
@router.get("/websites/", response=List[SimpleOut], auth=JWTAuth())
|
||||
def list_websites(request):
|
||||
require_admin(request.auth)
|
||||
return [SimpleOut(id=w.id, name=w.name) for w in Website.objects.all()]
|
||||
|
||||
|
||||
@router.get("/products/", response=List[SimpleOut], auth=JWTAuth())
|
||||
def list_products(request):
|
||||
require_admin(request.auth)
|
||||
return [SimpleOut(id=p.id, name=p.name) for p in Product.objects.all()]
|
||||
|
||||
|
||||
@router.get("/bounties/", response=List[BountyAdminOut], auth=JWTAuth())
|
||||
def list_bounties(request, status: Optional[str] = None):
|
||||
require_admin(request.auth)
|
||||
queryset = Bounty.objects.all().order_by('-created_at')
|
||||
if status:
|
||||
queryset = queryset.filter(status=status)
|
||||
return [
|
||||
BountyAdminOut(
|
||||
id=b.id,
|
||||
title=b.title,
|
||||
status=b.status,
|
||||
reward=str(b.reward),
|
||||
publisher_id=b.publisher_id,
|
||||
acceptor_id=b.acceptor_id,
|
||||
is_escrowed=b.is_escrowed,
|
||||
is_paid=b.is_paid,
|
||||
created_at=b.created_at.isoformat(),
|
||||
)
|
||||
for b in queryset
|
||||
]
|
||||
|
||||
|
||||
@router.get("/disputes/", response=List[DisputeOut], auth=JWTAuth())
|
||||
def list_disputes(request, status: Optional[str] = None):
|
||||
require_admin(request.auth)
|
||||
disputes = BountyDispute.objects.all().order_by('-created_at')
|
||||
if status:
|
||||
disputes = disputes.filter(status=status)
|
||||
return [
|
||||
DisputeOut(
|
||||
id=d.id,
|
||||
bounty_id=d.bounty_id,
|
||||
initiator_id=d.initiator_id,
|
||||
status=d.status,
|
||||
created_at=d.created_at.isoformat(),
|
||||
)
|
||||
for d in disputes
|
||||
]
|
||||
|
||||
|
||||
@router.get("/payments/", response=List[PaymentEventOut], auth=JWTAuth())
|
||||
def list_payment_events(request):
|
||||
require_admin(request.auth)
|
||||
events = PaymentEvent.objects.all().order_by('-processed_at')
|
||||
return [
|
||||
PaymentEventOut(
|
||||
id=e.id,
|
||||
event_id=e.event_id,
|
||||
event_type=e.event_type,
|
||||
bounty_id=e.bounty_id,
|
||||
success=e.success,
|
||||
processed_at=e.processed_at.isoformat(),
|
||||
)
|
||||
for e in events
|
||||
]
|
||||
7
backend/apps/admin/apps.py
Normal file
7
backend/apps/admin/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AdminApiConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.admin'
|
||||
label = 'admin_api'
|
||||
1
backend/apps/bounties/__init__.py
Normal file
1
backend/apps/bounties/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = 'apps.bounties.apps.BountiesConfig'
|
||||
29
backend/apps/bounties/admin.py
Normal file
29
backend/apps/bounties/admin.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.contrib import admin
|
||||
from .models import Bounty, BountyApplication, BountyComment
|
||||
|
||||
|
||||
@admin.register(Bounty)
|
||||
class BountyAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'title', 'reward', 'publisher', 'acceptor', 'status', 'is_paid', 'created_at']
|
||||
list_filter = ['status', 'is_paid', 'is_escrowed']
|
||||
search_fields = ['title', 'description']
|
||||
ordering = ['-created_at']
|
||||
raw_id_fields = ['publisher', 'acceptor']
|
||||
|
||||
|
||||
@admin.register(BountyApplication)
|
||||
class BountyApplicationAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'bounty', 'applicant', 'status', 'created_at']
|
||||
list_filter = ['status']
|
||||
search_fields = ['bounty__title', 'applicant__name']
|
||||
ordering = ['-created_at']
|
||||
raw_id_fields = ['bounty', 'applicant']
|
||||
|
||||
|
||||
@admin.register(BountyComment)
|
||||
class BountyCommentAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'bounty', 'user', 'parent', 'created_at']
|
||||
list_filter = ['bounty']
|
||||
search_fields = ['content', 'user__name']
|
||||
ordering = ['-created_at']
|
||||
raw_id_fields = ['bounty', 'user', 'parent']
|
||||
812
backend/apps/bounties/api.py
Normal file
812
backend/apps/bounties/api.py
Normal file
@@ -0,0 +1,812 @@
|
||||
"""
|
||||
Bounties API routes for tasks, applications and comments.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from ninja import Router, Query
|
||||
from ninja.errors import HttpError
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import (
|
||||
Bounty,
|
||||
BountyApplication,
|
||||
BountyComment,
|
||||
BountyDelivery,
|
||||
BountyDispute,
|
||||
BountyReview,
|
||||
BountyExtensionRequest,
|
||||
)
|
||||
from .schemas import (
|
||||
BountyOut, BountyIn, BountyUpdate, BountyWithDetailsOut,
|
||||
BountyApplicationOut, BountyApplicationIn,
|
||||
BountyCommentOut, BountyCommentIn,
|
||||
BountyFilter, MessageOut,
|
||||
BountyDeliveryOut, BountyDeliveryIn, BountyDeliveryReviewIn,
|
||||
BountyDisputeOut, BountyDisputeIn, BountyDisputeResolveIn,
|
||||
BountyReviewOut, BountyReviewIn,
|
||||
BountyExtensionRequestOut, BountyExtensionRequestIn, BountyExtensionReviewIn,
|
||||
)
|
||||
from apps.users.schemas import UserOut
|
||||
from apps.notifications.models import Notification, NotificationPreference
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def parse_reward(raw_reward) -> Decimal:
|
||||
"""Parse and normalize reward value."""
|
||||
if raw_reward is None:
|
||||
raise ValueError("reward is required")
|
||||
try:
|
||||
if isinstance(raw_reward, Decimal):
|
||||
value = raw_reward
|
||||
else:
|
||||
value = Decimal(str(raw_reward).replace(",", "").strip())
|
||||
if value.is_nan() or value.is_infinite():
|
||||
raise ValueError("reward must be a valid number")
|
||||
# Quantize to 2 decimal places
|
||||
value = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
# Validate range: max_digits=10, decimal_places=2 means max integer part is 8 digits
|
||||
# Max value: 99999999.99
|
||||
if value < Decimal("0.01"):
|
||||
raise ValueError("reward must be at least 0.01")
|
||||
if value > Decimal("99999999.99"):
|
||||
raise ValueError("reward exceeds maximum allowed value")
|
||||
return value
|
||||
except InvalidOperation:
|
||||
raise ValueError("reward must be a valid number")
|
||||
|
||||
|
||||
def serialize_user(user):
|
||||
"""Serialize user to UserOut."""
|
||||
if not user:
|
||||
return None
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
open_id=user.open_id,
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
avatar=user.avatar,
|
||||
role=user.role,
|
||||
stripe_customer_id=user.stripe_customer_id,
|
||||
stripe_account_id=user.stripe_account_id,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def serialize_bounty(bounty, include_counts=False):
|
||||
"""Serialize bounty to BountyOut or BountyWithDetailsOut."""
|
||||
data = {
|
||||
'id': bounty.id,
|
||||
'title': bounty.title,
|
||||
'description': bounty.description,
|
||||
'reward': bounty.reward,
|
||||
'currency': bounty.currency,
|
||||
'publisher_id': bounty.publisher_id,
|
||||
'publisher': serialize_user(bounty.publisher),
|
||||
'acceptor_id': bounty.acceptor_id,
|
||||
'acceptor': serialize_user(bounty.acceptor) if bounty.acceptor else None,
|
||||
'status': bounty.status,
|
||||
'deadline': bounty.deadline,
|
||||
'completed_at': bounty.completed_at,
|
||||
'is_paid': bounty.is_paid,
|
||||
'is_escrowed': bounty.is_escrowed,
|
||||
'created_at': bounty.created_at,
|
||||
'updated_at': bounty.updated_at,
|
||||
}
|
||||
|
||||
if include_counts:
|
||||
applications_count = getattr(bounty, "applications_count", None)
|
||||
comments_count = getattr(bounty, "comments_count", None)
|
||||
data['applications_count'] = applications_count if applications_count is not None else bounty.applications.count()
|
||||
data['comments_count'] = comments_count if comments_count is not None else bounty.comments.count()
|
||||
return BountyWithDetailsOut(**data)
|
||||
|
||||
return BountyOut(**data)
|
||||
|
||||
|
||||
def should_notify(user, notification_type: str) -> bool:
|
||||
"""Check if user has enabled notification type."""
|
||||
if not user:
|
||||
return False
|
||||
preference, _ = NotificationPreference.objects.get_or_create(user=user)
|
||||
if notification_type == Notification.Type.PRICE_ALERT:
|
||||
return preference.enable_price_alert
|
||||
if notification_type in (
|
||||
Notification.Type.BOUNTY_ACCEPTED,
|
||||
Notification.Type.BOUNTY_COMPLETED,
|
||||
Notification.Type.NEW_COMMENT,
|
||||
):
|
||||
return preference.enable_bounty
|
||||
if notification_type == Notification.Type.SYSTEM:
|
||||
return preference.enable_system
|
||||
return True
|
||||
|
||||
|
||||
# ==================== Bounty Routes ====================
|
||||
|
||||
@router.get("/", response=List[BountyWithDetailsOut])
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_bounties(request, filters: BountyFilter = Query(...)):
|
||||
"""Get all bounties with optional filters."""
|
||||
queryset = (
|
||||
Bounty.objects.select_related('publisher', 'acceptor')
|
||||
.annotate(
|
||||
applications_count=Count('applications', distinct=True),
|
||||
comments_count=Count('comments', distinct=True),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
if filters.status:
|
||||
queryset = queryset.filter(status=filters.status)
|
||||
if filters.publisher_id:
|
||||
queryset = queryset.filter(publisher_id=filters.publisher_id)
|
||||
if filters.acceptor_id:
|
||||
queryset = queryset.filter(acceptor_id=filters.acceptor_id)
|
||||
|
||||
return [serialize_bounty(b, include_counts=True) for b in queryset]
|
||||
|
||||
|
||||
@router.get("/search/", response=List[BountyWithDetailsOut])
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def search_bounties(request, q: str):
|
||||
"""Search bounties by title or description."""
|
||||
queryset = (
|
||||
Bounty.objects.select_related('publisher', 'acceptor')
|
||||
.annotate(
|
||||
applications_count=Count('applications', distinct=True),
|
||||
comments_count=Count('comments', distinct=True),
|
||||
)
|
||||
.filter(Q(title__icontains=q) | Q(description__icontains=q))
|
||||
)
|
||||
return [serialize_bounty(b, include_counts=True) for b in queryset]
|
||||
|
||||
|
||||
@router.get("/my-published/", response=List[BountyWithDetailsOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def my_published_bounties(request):
|
||||
"""Get bounties published by current user."""
|
||||
queryset = (
|
||||
Bounty.objects.select_related('publisher', 'acceptor')
|
||||
.annotate(
|
||||
applications_count=Count('applications', distinct=True),
|
||||
comments_count=Count('comments', distinct=True),
|
||||
)
|
||||
.filter(publisher=request.auth)
|
||||
)
|
||||
return [serialize_bounty(b, include_counts=True) for b in queryset]
|
||||
|
||||
|
||||
@router.get("/my-accepted/", response=List[BountyWithDetailsOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def my_accepted_bounties(request):
|
||||
"""Get bounties accepted by current user."""
|
||||
queryset = (
|
||||
Bounty.objects.select_related('publisher', 'acceptor')
|
||||
.annotate(
|
||||
applications_count=Count('applications', distinct=True),
|
||||
comments_count=Count('comments', distinct=True),
|
||||
)
|
||||
.filter(acceptor=request.auth)
|
||||
)
|
||||
return [serialize_bounty(b, include_counts=True) for b in queryset]
|
||||
|
||||
|
||||
@router.get("/{bounty_id}", response=BountyWithDetailsOut)
|
||||
def get_bounty(request, bounty_id: int):
|
||||
"""Get bounty by ID."""
|
||||
bounty = get_object_or_404(
|
||||
Bounty.objects.select_related('publisher', 'acceptor').annotate(
|
||||
applications_count=Count('applications', distinct=True),
|
||||
comments_count=Count('comments', distinct=True),
|
||||
),
|
||||
id=bounty_id
|
||||
)
|
||||
return serialize_bounty(bounty, include_counts=True)
|
||||
|
||||
|
||||
@router.post("/", response=BountyOut, auth=JWTAuth())
|
||||
def create_bounty(request, data: BountyIn):
|
||||
"""Create a new bounty."""
|
||||
payload = data.dict()
|
||||
try:
|
||||
payload["reward"] = parse_reward(payload.get("reward"))
|
||||
except (InvalidOperation, ValueError):
|
||||
raise HttpError(400, "赏金金额无效,请输入有效数字(最大 99999999.99)")
|
||||
bounty = Bounty.objects.create(**payload, publisher=request.auth)
|
||||
return serialize_bounty(bounty)
|
||||
|
||||
|
||||
@router.patch("/{bounty_id}", response=BountyOut, auth=JWTAuth())
|
||||
def update_bounty(request, bounty_id: int, data: BountyUpdate):
|
||||
"""Update a bounty (only by publisher)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
raise HttpError(403, "只有发布者可以更新此悬赏")
|
||||
|
||||
if bounty.status != Bounty.Status.OPEN:
|
||||
raise HttpError(400, "只能更新开放中的悬赏")
|
||||
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
if "reward" in update_data:
|
||||
try:
|
||||
update_data["reward"] = parse_reward(update_data.get("reward"))
|
||||
except (InvalidOperation, ValueError):
|
||||
raise HttpError(400, "赏金金额无效,请输入有效数字(最大 99999999.99)")
|
||||
for key, value in update_data.items():
|
||||
setattr(bounty, key, value)
|
||||
|
||||
bounty.save()
|
||||
return serialize_bounty(bounty)
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/cancel", response=MessageOut, auth=JWTAuth())
|
||||
def cancel_bounty(request, bounty_id: int):
|
||||
"""Cancel a bounty (only by publisher)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
raise HttpError(403, "只有发布者可以取消此悬赏")
|
||||
|
||||
if bounty.status not in [Bounty.Status.OPEN, Bounty.Status.IN_PROGRESS]:
|
||||
raise HttpError(400, "无法取消此悬赏")
|
||||
|
||||
bounty.status = Bounty.Status.CANCELLED
|
||||
bounty.save()
|
||||
|
||||
# Notify acceptor if exists
|
||||
if bounty.acceptor and should_notify(bounty.acceptor, Notification.Type.SYSTEM):
|
||||
Notification.objects.create(
|
||||
user=bounty.acceptor,
|
||||
type=Notification.Type.SYSTEM,
|
||||
title="悬赏已取消",
|
||||
content=f"您接取的悬赏 \"{bounty.title}\" 已被取消",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
|
||||
return MessageOut(message="悬赏已取消", success=True)
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/complete", response=MessageOut, auth=JWTAuth())
|
||||
def complete_bounty(request, bounty_id: int):
|
||||
"""Mark a bounty as completed (only by publisher)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
raise HttpError(403, "只有发布者可以完成此悬赏")
|
||||
|
||||
if bounty.status != Bounty.Status.IN_PROGRESS:
|
||||
raise HttpError(400, "悬赏必须处于进行中状态才能完成")
|
||||
|
||||
if not bounty.deliveries.filter(status=BountyDelivery.Status.ACCEPTED).exists():
|
||||
raise HttpError(400, "需先验收交付内容后才能完成")
|
||||
|
||||
bounty.status = Bounty.Status.COMPLETED
|
||||
bounty.completed_at = timezone.now()
|
||||
bounty.save()
|
||||
|
||||
# Notify acceptor
|
||||
if bounty.acceptor and should_notify(bounty.acceptor, Notification.Type.BOUNTY_COMPLETED):
|
||||
Notification.objects.create(
|
||||
user=bounty.acceptor,
|
||||
type=Notification.Type.BOUNTY_COMPLETED,
|
||||
title="悬赏已完成",
|
||||
content=f"您完成的悬赏 \"{bounty.title}\" 已被确认完成",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
|
||||
return MessageOut(message="悬赏已完成", success=True)
|
||||
|
||||
|
||||
# ==================== Application Routes ====================
|
||||
|
||||
@router.get("/{bounty_id}/applications/", response=List[BountyApplicationOut])
|
||||
def list_applications(request, bounty_id: int):
|
||||
"""Get all applications for a bounty."""
|
||||
applications = BountyApplication.objects.select_related('applicant').filter(
|
||||
bounty_id=bounty_id
|
||||
)
|
||||
|
||||
return [
|
||||
BountyApplicationOut(
|
||||
id=app.id,
|
||||
bounty_id=app.bounty_id,
|
||||
applicant_id=app.applicant_id,
|
||||
applicant=serialize_user(app.applicant),
|
||||
message=app.message,
|
||||
status=app.status,
|
||||
created_at=app.created_at,
|
||||
updated_at=app.updated_at,
|
||||
)
|
||||
for app in applications
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{bounty_id}/my-application/", response=Optional[BountyApplicationOut], auth=JWTAuth())
|
||||
def my_application(request, bounty_id: int):
|
||||
"""Get current user's application for a bounty."""
|
||||
try:
|
||||
app = BountyApplication.objects.select_related('applicant').get(
|
||||
bounty_id=bounty_id,
|
||||
applicant=request.auth
|
||||
)
|
||||
return BountyApplicationOut(
|
||||
id=app.id,
|
||||
bounty_id=app.bounty_id,
|
||||
applicant_id=app.applicant_id,
|
||||
applicant=serialize_user(app.applicant),
|
||||
message=app.message,
|
||||
status=app.status,
|
||||
created_at=app.created_at,
|
||||
updated_at=app.updated_at,
|
||||
)
|
||||
except BountyApplication.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/applications/", response=BountyApplicationOut, auth=JWTAuth())
|
||||
def submit_application(request, bounty_id: int, data: BountyApplicationIn):
|
||||
"""Submit an application for a bounty."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
|
||||
if bounty.status != Bounty.Status.OPEN:
|
||||
raise HttpError(400, "无法申请此悬赏")
|
||||
|
||||
if bounty.publisher_id == request.auth.id:
|
||||
raise HttpError(400, "不能申请自己发布的悬赏")
|
||||
|
||||
# Check if already applied
|
||||
if BountyApplication.objects.filter(bounty=bounty, applicant=request.auth).exists():
|
||||
raise HttpError(400, "您已经申请过了")
|
||||
|
||||
app = BountyApplication.objects.create(
|
||||
bounty=bounty,
|
||||
applicant=request.auth,
|
||||
message=data.message,
|
||||
)
|
||||
|
||||
# Notify publisher
|
||||
if should_notify(bounty.publisher, Notification.Type.SYSTEM):
|
||||
Notification.objects.create(
|
||||
user=bounty.publisher,
|
||||
type=Notification.Type.SYSTEM,
|
||||
title="收到新申请",
|
||||
content=f"您的悬赏 \"{bounty.title}\" 收到了新的申请",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
|
||||
return BountyApplicationOut(
|
||||
id=app.id,
|
||||
bounty_id=app.bounty_id,
|
||||
applicant_id=app.applicant_id,
|
||||
applicant=serialize_user(app.applicant),
|
||||
message=app.message,
|
||||
status=app.status,
|
||||
created_at=app.created_at,
|
||||
updated_at=app.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/applications/{application_id}/accept", response=MessageOut, auth=JWTAuth())
|
||||
def accept_application(request, bounty_id: int, application_id: int):
|
||||
"""Accept an application (only by bounty publisher)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
raise HttpError(403, "只有发布者可以接受申请")
|
||||
|
||||
if bounty.status != Bounty.Status.OPEN:
|
||||
raise HttpError(400, "无法接受此悬赏的申请")
|
||||
|
||||
app = get_object_or_404(BountyApplication, id=application_id, bounty_id=bounty_id)
|
||||
|
||||
with transaction.atomic():
|
||||
# Accept this application
|
||||
app.status = BountyApplication.Status.ACCEPTED
|
||||
app.save()
|
||||
|
||||
# Reject other applications
|
||||
BountyApplication.objects.filter(
|
||||
bounty=bounty
|
||||
).exclude(id=application_id).update(status=BountyApplication.Status.REJECTED)
|
||||
|
||||
# Update bounty
|
||||
bounty.acceptor = app.applicant
|
||||
bounty.status = Bounty.Status.IN_PROGRESS
|
||||
bounty.save()
|
||||
|
||||
# Notify acceptor
|
||||
if should_notify(app.applicant, Notification.Type.BOUNTY_ACCEPTED):
|
||||
Notification.objects.create(
|
||||
user=app.applicant,
|
||||
type=Notification.Type.BOUNTY_ACCEPTED,
|
||||
title="申请已被接受",
|
||||
content=f"您对悬赏 \"{bounty.title}\" 的申请已被接受",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
|
||||
return MessageOut(message="已接受申请", success=True)
|
||||
|
||||
|
||||
# ==================== Comment Routes ====================
|
||||
|
||||
@router.get("/{bounty_id}/comments/", response=List[BountyCommentOut])
|
||||
def list_comments(request, bounty_id: int):
|
||||
"""Get all comments for a bounty."""
|
||||
comments = BountyComment.objects.select_related('user').filter(
|
||||
bounty_id=bounty_id,
|
||||
parent__isnull=True # Only get top-level comments
|
||||
).prefetch_related('replies', 'replies__user')
|
||||
|
||||
def serialize_comment(comment):
|
||||
return BountyCommentOut(
|
||||
id=comment.id,
|
||||
bounty_id=comment.bounty_id,
|
||||
user_id=comment.user_id,
|
||||
user=serialize_user(comment.user),
|
||||
content=comment.content,
|
||||
parent_id=comment.parent_id,
|
||||
replies=[serialize_comment(r) for r in comment.replies.all()],
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
)
|
||||
|
||||
return [serialize_comment(c) for c in comments]
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/comments/", response=BountyCommentOut, auth=JWTAuth())
|
||||
def create_comment(request, bounty_id: int, data: BountyCommentIn):
|
||||
"""Create a comment on a bounty."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
|
||||
comment = BountyComment.objects.create(
|
||||
bounty=bounty,
|
||||
user=request.auth,
|
||||
content=data.content,
|
||||
parent_id=data.parent_id,
|
||||
)
|
||||
|
||||
# Notify bounty publisher (if not commenting on own bounty)
|
||||
if bounty.publisher_id != request.auth.id and should_notify(bounty.publisher, Notification.Type.NEW_COMMENT):
|
||||
Notification.objects.create(
|
||||
user=bounty.publisher,
|
||||
type=Notification.Type.NEW_COMMENT,
|
||||
title="收到新评论",
|
||||
content=f"您的悬赏 \"{bounty.title}\" 收到了新评论",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
|
||||
# Notify parent comment author (if replying)
|
||||
if data.parent_id:
|
||||
parent = BountyComment.objects.get(id=data.parent_id)
|
||||
if parent.user_id != request.auth.id and should_notify(parent.user, Notification.Type.NEW_COMMENT):
|
||||
Notification.objects.create(
|
||||
user=parent.user,
|
||||
type=Notification.Type.NEW_COMMENT,
|
||||
title="收到回复",
|
||||
content=f"您在悬赏 \"{bounty.title}\" 的评论收到了回复",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
|
||||
return BountyCommentOut(
|
||||
id=comment.id,
|
||||
bounty_id=comment.bounty_id,
|
||||
user_id=comment.user_id,
|
||||
user=serialize_user(comment.user),
|
||||
content=comment.content,
|
||||
parent_id=comment.parent_id,
|
||||
replies=[],
|
||||
created_at=comment.created_at,
|
||||
updated_at=comment.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ==================== Delivery Routes ====================
|
||||
|
||||
@router.get("/{bounty_id}/deliveries/", response=List[BountyDeliveryOut], auth=JWTAuth())
|
||||
def list_deliveries(request, bounty_id: int):
|
||||
"""List deliveries for a bounty (publisher or acceptor)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
if request.auth.id not in [bounty.publisher_id, bounty.acceptor_id]:
|
||||
raise HttpError(403, "无权限查看交付记录")
|
||||
deliveries = BountyDelivery.objects.filter(bounty=bounty).order_by('-submitted_at')
|
||||
return [
|
||||
BountyDeliveryOut(
|
||||
id=d.id,
|
||||
bounty_id=d.bounty_id,
|
||||
submitter_id=d.submitter_id,
|
||||
content=d.content,
|
||||
attachment_url=d.attachment_url,
|
||||
status=d.status,
|
||||
submitted_at=d.submitted_at,
|
||||
reviewed_at=d.reviewed_at,
|
||||
)
|
||||
for d in deliveries
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/deliveries/", response=BountyDeliveryOut, auth=JWTAuth())
|
||||
def submit_delivery(request, bounty_id: int, data: BountyDeliveryIn):
|
||||
"""Submit delivery (acceptor only)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
if bounty.acceptor_id != request.auth.id:
|
||||
raise HttpError(403, "只有接单者可以提交交付")
|
||||
if bounty.status != Bounty.Status.IN_PROGRESS:
|
||||
raise HttpError(400, "悬赏不在进行中状态")
|
||||
delivery = BountyDelivery.objects.create(
|
||||
bounty=bounty,
|
||||
submitter=request.auth,
|
||||
content=data.content,
|
||||
attachment_url=data.attachment_url,
|
||||
)
|
||||
if should_notify(bounty.publisher, Notification.Type.SYSTEM):
|
||||
Notification.objects.create(
|
||||
user=bounty.publisher,
|
||||
type=Notification.Type.SYSTEM,
|
||||
title="收到交付内容",
|
||||
content=f"悬赏 \"{bounty.title}\" 收到新的交付",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
return BountyDeliveryOut(
|
||||
id=delivery.id,
|
||||
bounty_id=delivery.bounty_id,
|
||||
submitter_id=delivery.submitter_id,
|
||||
content=delivery.content,
|
||||
attachment_url=delivery.attachment_url,
|
||||
status=delivery.status,
|
||||
submitted_at=delivery.submitted_at,
|
||||
reviewed_at=delivery.reviewed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/deliveries/{delivery_id}/review", response=MessageOut, auth=JWTAuth())
|
||||
def review_delivery(request, bounty_id: int, delivery_id: int, data: BountyDeliveryReviewIn):
|
||||
"""Accept or reject delivery (publisher only)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
raise HttpError(403, "只有发布者可以验收")
|
||||
delivery = get_object_or_404(BountyDelivery, id=delivery_id, bounty_id=bounty_id)
|
||||
if delivery.status != BountyDelivery.Status.SUBMITTED:
|
||||
raise HttpError(400, "该交付已处理")
|
||||
delivery.status = BountyDelivery.Status.ACCEPTED if data.accept else BountyDelivery.Status.REJECTED
|
||||
delivery.reviewed_at = timezone.now()
|
||||
delivery.save()
|
||||
if should_notify(delivery.submitter, Notification.Type.SYSTEM):
|
||||
Notification.objects.create(
|
||||
user=delivery.submitter,
|
||||
type=Notification.Type.SYSTEM,
|
||||
title="交付已处理",
|
||||
content=f"悬赏 \"{bounty.title}\" 的交付已被处理",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
return MessageOut(message="交付已处理", success=True)
|
||||
|
||||
|
||||
# ==================== Dispute Routes ====================
|
||||
|
||||
@router.get("/{bounty_id}/disputes/", response=List[BountyDisputeOut], auth=JWTAuth())
|
||||
def list_disputes(request, bounty_id: int):
|
||||
"""List disputes for a bounty."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
if request.auth.id not in [bounty.publisher_id, bounty.acceptor_id] and request.auth.role != 'admin':
|
||||
raise HttpError(403, "无权限查看争议")
|
||||
disputes = BountyDispute.objects.filter(bounty=bounty).order_by('-created_at')
|
||||
return [
|
||||
BountyDisputeOut(
|
||||
id=d.id,
|
||||
bounty_id=d.bounty_id,
|
||||
initiator_id=d.initiator_id,
|
||||
reason=d.reason,
|
||||
evidence_url=d.evidence_url,
|
||||
status=d.status,
|
||||
resolution=d.resolution,
|
||||
created_at=d.created_at,
|
||||
resolved_at=d.resolved_at,
|
||||
)
|
||||
for d in disputes
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/disputes/", response=BountyDisputeOut, auth=JWTAuth())
|
||||
def create_dispute(request, bounty_id: int, data: BountyDisputeIn):
|
||||
"""Create a dispute (publisher or acceptor)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
if request.auth.id not in [bounty.publisher_id, bounty.acceptor_id]:
|
||||
raise HttpError(403, "无权限发起争议")
|
||||
dispute = BountyDispute.objects.create(
|
||||
bounty=bounty,
|
||||
initiator=request.auth,
|
||||
reason=data.reason,
|
||||
evidence_url=data.evidence_url,
|
||||
)
|
||||
bounty.status = Bounty.Status.DISPUTED
|
||||
bounty.save()
|
||||
other_user = bounty.acceptor if bounty.publisher_id == request.auth.id else bounty.publisher
|
||||
if other_user and should_notify(other_user, Notification.Type.SYSTEM):
|
||||
Notification.objects.create(
|
||||
user=other_user,
|
||||
type=Notification.Type.SYSTEM,
|
||||
title="悬赏进入争议",
|
||||
content=f"悬赏 \"{bounty.title}\" 被发起争议",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
return BountyDisputeOut(
|
||||
id=dispute.id,
|
||||
bounty_id=dispute.bounty_id,
|
||||
initiator_id=dispute.initiator_id,
|
||||
reason=dispute.reason,
|
||||
evidence_url=dispute.evidence_url,
|
||||
status=dispute.status,
|
||||
resolution=dispute.resolution,
|
||||
created_at=dispute.created_at,
|
||||
resolved_at=dispute.resolved_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/disputes/{dispute_id}/resolve", response=MessageOut, auth=JWTAuth())
|
||||
def resolve_dispute(request, bounty_id: int, dispute_id: int, data: BountyDisputeResolveIn):
|
||||
"""Resolve dispute (admin only)."""
|
||||
if request.auth.role != 'admin':
|
||||
raise HttpError(403, "仅管理员可处理争议")
|
||||
dispute = get_object_or_404(BountyDispute, id=dispute_id, bounty_id=bounty_id)
|
||||
if dispute.status != BountyDispute.Status.OPEN:
|
||||
raise HttpError(400, "争议已处理")
|
||||
dispute.status = BountyDispute.Status.RESOLVED if data.accepted else BountyDispute.Status.REJECTED
|
||||
dispute.resolution = data.resolution
|
||||
dispute.resolved_at = timezone.now()
|
||||
dispute.save()
|
||||
return MessageOut(message="争议已处理", success=True)
|
||||
|
||||
|
||||
# ==================== Review Routes ====================
|
||||
|
||||
@router.get("/{bounty_id}/reviews/", response=List[BountyReviewOut])
|
||||
def list_reviews(request, bounty_id: int):
|
||||
"""List reviews for a bounty."""
|
||||
reviews = BountyReview.objects.filter(bounty_id=bounty_id).order_by('-created_at')
|
||||
return [
|
||||
BountyReviewOut(
|
||||
id=r.id,
|
||||
bounty_id=r.bounty_id,
|
||||
reviewer_id=r.reviewer_id,
|
||||
reviewee_id=r.reviewee_id,
|
||||
rating=r.rating,
|
||||
comment=r.comment,
|
||||
created_at=r.created_at,
|
||||
)
|
||||
for r in reviews
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/reviews/", response=BountyReviewOut, auth=JWTAuth())
|
||||
def create_review(request, bounty_id: int, data: BountyReviewIn):
|
||||
"""Create review after completion."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
if bounty.status != Bounty.Status.COMPLETED:
|
||||
raise HttpError(400, "悬赏未完成,无法评价")
|
||||
participants = {bounty.publisher_id, bounty.acceptor_id}
|
||||
if request.auth.id not in participants or data.reviewee_id not in participants:
|
||||
raise HttpError(403, "无权限评价")
|
||||
if request.auth.id == data.reviewee_id:
|
||||
raise HttpError(400, "无法评价自己")
|
||||
if not (1 <= data.rating <= 5):
|
||||
raise HttpError(400, "评分需在 1-5 之间")
|
||||
review = BountyReview.objects.create(
|
||||
bounty=bounty,
|
||||
reviewer=request.auth,
|
||||
reviewee_id=data.reviewee_id,
|
||||
rating=data.rating,
|
||||
comment=data.comment,
|
||||
)
|
||||
return BountyReviewOut(
|
||||
id=review.id,
|
||||
bounty_id=review.bounty_id,
|
||||
reviewer_id=review.reviewer_id,
|
||||
reviewee_id=review.reviewee_id,
|
||||
rating=review.rating,
|
||||
comment=review.comment,
|
||||
created_at=review.created_at,
|
||||
)
|
||||
|
||||
|
||||
# ==================== Extension Routes ====================
|
||||
|
||||
@router.get("/{bounty_id}/extension-requests/", response=List[BountyExtensionRequestOut], auth=JWTAuth())
|
||||
def list_extension_requests(request, bounty_id: int):
|
||||
"""List extension requests for a bounty."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
if request.auth.id not in [bounty.publisher_id, bounty.acceptor_id]:
|
||||
raise HttpError(403, "无权限查看延期申请")
|
||||
requests = BountyExtensionRequest.objects.filter(bounty=bounty).order_by('-created_at')
|
||||
return [
|
||||
BountyExtensionRequestOut(
|
||||
id=r.id,
|
||||
bounty_id=r.bounty_id,
|
||||
requester_id=r.requester_id,
|
||||
proposed_deadline=r.proposed_deadline,
|
||||
reason=r.reason,
|
||||
status=r.status,
|
||||
created_at=r.created_at,
|
||||
reviewed_at=r.reviewed_at,
|
||||
)
|
||||
for r in requests
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/extension-requests/", response=BountyExtensionRequestOut, auth=JWTAuth())
|
||||
def create_extension_request(request, bounty_id: int, data: BountyExtensionRequestIn):
|
||||
"""Request deadline extension (acceptor only)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
if bounty.acceptor_id != request.auth.id:
|
||||
raise HttpError(403, "只有接单者可以申请延期")
|
||||
if bounty.status != Bounty.Status.IN_PROGRESS:
|
||||
raise HttpError(400, "悬赏不在进行中状态")
|
||||
extension = BountyExtensionRequest.objects.create(
|
||||
bounty=bounty,
|
||||
requester=request.auth,
|
||||
proposed_deadline=data.proposed_deadline,
|
||||
reason=data.reason,
|
||||
)
|
||||
if should_notify(bounty.publisher, Notification.Type.SYSTEM):
|
||||
Notification.objects.create(
|
||||
user=bounty.publisher,
|
||||
type=Notification.Type.SYSTEM,
|
||||
title="收到延期申请",
|
||||
content=f"悬赏 \"{bounty.title}\" 收到延期申请",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
return BountyExtensionRequestOut(
|
||||
id=extension.id,
|
||||
bounty_id=extension.bounty_id,
|
||||
requester_id=extension.requester_id,
|
||||
proposed_deadline=extension.proposed_deadline,
|
||||
reason=extension.reason,
|
||||
status=extension.status,
|
||||
created_at=extension.created_at,
|
||||
reviewed_at=extension.reviewed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/extension-requests/{request_id}/review", response=MessageOut, auth=JWTAuth())
|
||||
def review_extension_request(request, bounty_id: int, request_id: int, data: BountyExtensionReviewIn):
|
||||
"""Approve or reject extension request (publisher only)."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
raise HttpError(403, "只有发布者可以处理延期申请")
|
||||
extension = get_object_or_404(BountyExtensionRequest, id=request_id, bounty_id=bounty_id)
|
||||
if extension.status != BountyExtensionRequest.Status.PENDING:
|
||||
raise HttpError(400, "延期申请已处理")
|
||||
extension.status = BountyExtensionRequest.Status.APPROVED if data.approve else BountyExtensionRequest.Status.REJECTED
|
||||
extension.reviewed_at = timezone.now()
|
||||
extension.save()
|
||||
if data.approve:
|
||||
bounty.deadline = extension.proposed_deadline
|
||||
bounty.save()
|
||||
if should_notify(extension.requester, Notification.Type.SYSTEM):
|
||||
Notification.objects.create(
|
||||
user=extension.requester,
|
||||
type=Notification.Type.SYSTEM,
|
||||
title="延期申请已处理",
|
||||
content=f"悬赏 \"{bounty.title}\" 的延期申请已处理",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
return MessageOut(message="延期申请已处理", success=True)
|
||||
7
backend/apps/bounties/apps.py
Normal file
7
backend/apps/bounties/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BountiesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.bounties'
|
||||
verbose_name = '悬赏管理'
|
||||
72
backend/apps/bounties/migrations/0001_initial.py
Normal file
72
backend/apps/bounties/migrations/0001_initial.py
Normal file
@@ -0,0 +1,72 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-27 07:39
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Bounty',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=300, verbose_name='标题')),
|
||||
('description', models.TextField(verbose_name='描述')),
|
||||
('reward', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='赏金')),
|
||||
('currency', models.CharField(default='CNY', max_length=10, verbose_name='货币')),
|
||||
('status', models.CharField(choices=[('open', '开放中'), ('in_progress', '进行中'), ('completed', '已完成'), ('cancelled', '已取消'), ('disputed', '争议中')], default='open', max_length=20, verbose_name='状态')),
|
||||
('deadline', models.DateTimeField(blank=True, null=True, verbose_name='截止时间')),
|
||||
('completed_at', models.DateTimeField(blank=True, null=True, verbose_name='完成时间')),
|
||||
('stripe_payment_intent_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Stripe支付意向ID')),
|
||||
('stripe_transfer_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Stripe转账ID')),
|
||||
('is_paid', models.BooleanField(default=False, verbose_name='是否已付款')),
|
||||
('is_escrowed', models.BooleanField(default=False, verbose_name='是否已托管')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '悬赏',
|
||||
'verbose_name_plural': '悬赏',
|
||||
'db_table': 'bounties',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BountyApplication',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('message', models.TextField(blank=True, null=True, verbose_name='申请消息')),
|
||||
('status', models.CharField(choices=[('pending', '待审核'), ('accepted', '已接受'), ('rejected', '已拒绝')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '悬赏申请',
|
||||
'verbose_name_plural': '悬赏申请',
|
||||
'db_table': 'bountyApplications',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BountyComment',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('content', models.TextField(verbose_name='内容')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('bounty', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='bounties.bounty', verbose_name='悬赏')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='bounties.bountycomment', verbose_name='父评论')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '悬赏评论',
|
||||
'verbose_name_plural': '悬赏评论',
|
||||
'db_table': 'bountyComments',
|
||||
'ordering': ['created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
47
backend/apps/bounties/migrations/0002_initial.py
Normal file
47
backend/apps/bounties/migrations/0002_initial.py
Normal file
@@ -0,0 +1,47 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-27 07:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('bounties', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='bountycomment',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounty_comments', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='bountyapplication',
|
||||
name='applicant',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounty_applications', to=settings.AUTH_USER_MODEL, verbose_name='申请者'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='bountyapplication',
|
||||
name='bounty',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='applications', to='bounties.bounty', verbose_name='悬赏'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='bounty',
|
||||
name='acceptor',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='accepted_bounties', to=settings.AUTH_USER_MODEL, verbose_name='接单者'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='bounty',
|
||||
name='publisher',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='published_bounties', to=settings.AUTH_USER_MODEL, verbose_name='发布者'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='bountyapplication',
|
||||
unique_together={('bounty', 'applicant')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bounties', '0002_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BountyDelivery',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('content', models.TextField(verbose_name='交付内容')),
|
||||
('attachment_url', models.TextField(blank=True, null=True, verbose_name='附件链接')),
|
||||
('status', models.CharField(choices=[('submitted', '已提交'), ('accepted', '已验收'), ('rejected', '已驳回')], default='submitted', max_length=20, verbose_name='状态')),
|
||||
('submitted_at', models.DateTimeField(auto_now_add=True, verbose_name='提交时间')),
|
||||
('reviewed_at', models.DateTimeField(blank=True, null=True, verbose_name='验收时间')),
|
||||
('bounty', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='deliveries', to='bounties.bounty', verbose_name='悬赏')),
|
||||
('submitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounty_deliveries', to=settings.AUTH_USER_MODEL, verbose_name='提交者')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '悬赏交付',
|
||||
'verbose_name_plural': '悬赏交付',
|
||||
'db_table': 'bountyDeliveries',
|
||||
'ordering': ['-submitted_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BountyDispute',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('reason', models.TextField(verbose_name='争议原因')),
|
||||
('evidence_url', models.TextField(blank=True, null=True, verbose_name='证据链接')),
|
||||
('status', models.CharField(choices=[('open', '处理中'), ('resolved', '已解决'), ('rejected', '已驳回')], default='open', max_length=20, verbose_name='状态')),
|
||||
('resolution', models.TextField(blank=True, null=True, verbose_name='处理结果')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('resolved_at', models.DateTimeField(blank=True, null=True, verbose_name='处理时间')),
|
||||
('bounty', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='disputes', to='bounties.bounty', verbose_name='悬赏')),
|
||||
('initiator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounty_disputes', to=settings.AUTH_USER_MODEL, verbose_name='发起人')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '悬赏争议',
|
||||
'verbose_name_plural': '悬赏争议',
|
||||
'db_table': 'bountyDisputes',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BountyReview',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('rating', models.PositiveSmallIntegerField(verbose_name='评分')),
|
||||
('comment', models.TextField(blank=True, null=True, verbose_name='评价内容')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('bounty', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reviews', to='bounties.bounty', verbose_name='悬赏')),
|
||||
('reviewee', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounty_reviews_received', to=settings.AUTH_USER_MODEL, verbose_name='被评价者')),
|
||||
('reviewer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounty_reviews_given', to=settings.AUTH_USER_MODEL, verbose_name='评价者')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '悬赏评价',
|
||||
'verbose_name_plural': '悬赏评价',
|
||||
'db_table': 'bountyReviews',
|
||||
'ordering': ['-created_at'],
|
||||
'unique_together': {('bounty', 'reviewer')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentEvent',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('event_id', models.CharField(max_length=255, unique=True, verbose_name='事件ID')),
|
||||
('event_type', models.CharField(max_length=100, verbose_name='事件类型')),
|
||||
('payload', models.TextField(verbose_name='事件内容')),
|
||||
('processed_at', models.DateTimeField(auto_now_add=True, verbose_name='处理时间')),
|
||||
('success', models.BooleanField(default=True, verbose_name='是否成功')),
|
||||
('error_message', models.TextField(blank=True, null=True, verbose_name='错误信息')),
|
||||
('bounty', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payment_events', to='bounties.bounty', verbose_name='悬赏')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '支付事件',
|
||||
'verbose_name_plural': '支付事件',
|
||||
'db_table': 'paymentEvents',
|
||||
'ordering': ['-processed_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
33
backend/apps/bounties/migrations/0004_extension_request.py
Normal file
33
backend/apps/bounties/migrations/0004_extension_request.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('bounties', '0003_workflow_and_payments'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BountyExtensionRequest',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('proposed_deadline', models.DateTimeField(verbose_name='申请截止时间')),
|
||||
('reason', models.TextField(blank=True, null=True, verbose_name='申请原因')),
|
||||
('status', models.CharField(choices=[('pending', '待处理'), ('approved', '已同意'), ('rejected', '已拒绝')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('reviewed_at', models.DateTimeField(blank=True, null=True, verbose_name='处理时间')),
|
||||
('bounty', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extension_requests', to='bounties.bounty', verbose_name='悬赏')),
|
||||
('requester', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bounty_extension_requests', to=settings.AUTH_USER_MODEL, verbose_name='申请人')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '延期申请',
|
||||
'verbose_name_plural': '延期申请',
|
||||
'db_table': 'bountyExtensionRequests',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
346
backend/apps/bounties/models.py
Normal file
346
backend/apps/bounties/models.py
Normal file
@@ -0,0 +1,346 @@
|
||||
"""
|
||||
Bounty models for task/reward system.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Bounty(models.Model):
|
||||
"""Bounty/Reward tasks."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
OPEN = 'open', '开放中'
|
||||
IN_PROGRESS = 'in_progress', '进行中'
|
||||
COMPLETED = 'completed', '已完成'
|
||||
CANCELLED = 'cancelled', '已取消'
|
||||
DISPUTED = 'disputed', '争议中'
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
title = models.CharField('标题', max_length=300)
|
||||
description = models.TextField('描述')
|
||||
reward = models.DecimalField('赏金', max_digits=10, decimal_places=2)
|
||||
currency = models.CharField('货币', max_length=10, default='CNY')
|
||||
|
||||
publisher = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='published_bounties',
|
||||
verbose_name='发布者'
|
||||
)
|
||||
acceptor = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='accepted_bounties',
|
||||
verbose_name='接单者'
|
||||
)
|
||||
|
||||
status = models.CharField(
|
||||
'状态',
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.OPEN
|
||||
)
|
||||
deadline = models.DateTimeField('截止时间', blank=True, null=True)
|
||||
completed_at = models.DateTimeField('完成时间', blank=True, null=True)
|
||||
|
||||
# Stripe payment fields
|
||||
stripe_payment_intent_id = models.CharField(
|
||||
'Stripe支付意向ID',
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
stripe_transfer_id = models.CharField(
|
||||
'Stripe转账ID',
|
||||
max_length=255,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
is_paid = models.BooleanField('是否已付款', default=False)
|
||||
is_escrowed = models.BooleanField('是否已托管', default=False)
|
||||
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'bounties'
|
||||
verbose_name = '悬赏'
|
||||
verbose_name_plural = '悬赏'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class BountyApplication(models.Model):
|
||||
"""Bounty applications/bids."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = 'pending', '待审核'
|
||||
ACCEPTED = 'accepted', '已接受'
|
||||
REJECTED = 'rejected', '已拒绝'
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
bounty = models.ForeignKey(
|
||||
Bounty,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='applications',
|
||||
verbose_name='悬赏'
|
||||
)
|
||||
applicant = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bounty_applications',
|
||||
verbose_name='申请者'
|
||||
)
|
||||
message = models.TextField('申请消息', blank=True, null=True)
|
||||
status = models.CharField(
|
||||
'状态',
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING
|
||||
)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'bountyApplications'
|
||||
verbose_name = '悬赏申请'
|
||||
verbose_name_plural = '悬赏申请'
|
||||
unique_together = ['bounty', 'applicant']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.applicant} -> {self.bounty.title}"
|
||||
|
||||
|
||||
class BountyComment(models.Model):
|
||||
"""Bounty comments."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
bounty = models.ForeignKey(
|
||||
Bounty,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='comments',
|
||||
verbose_name='悬赏'
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bounty_comments',
|
||||
verbose_name='用户'
|
||||
)
|
||||
content = models.TextField('内容')
|
||||
parent = models.ForeignKey(
|
||||
'self',
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='replies',
|
||||
verbose_name='父评论'
|
||||
)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'bountyComments'
|
||||
verbose_name = '悬赏评论'
|
||||
verbose_name_plural = '悬赏评论'
|
||||
ordering = ['created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} on {self.bounty.title}"
|
||||
|
||||
|
||||
class BountyDelivery(models.Model):
|
||||
"""Bounty delivery submissions."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
SUBMITTED = 'submitted', '已提交'
|
||||
ACCEPTED = 'accepted', '已验收'
|
||||
REJECTED = 'rejected', '已驳回'
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
bounty = models.ForeignKey(
|
||||
Bounty,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='deliveries',
|
||||
verbose_name='悬赏'
|
||||
)
|
||||
submitter = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bounty_deliveries',
|
||||
verbose_name='提交者'
|
||||
)
|
||||
content = models.TextField('交付内容')
|
||||
attachment_url = models.TextField('附件链接', blank=True, null=True)
|
||||
status = models.CharField(
|
||||
'状态',
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.SUBMITTED
|
||||
)
|
||||
submitted_at = models.DateTimeField('提交时间', auto_now_add=True)
|
||||
reviewed_at = models.DateTimeField('验收时间', blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'bountyDeliveries'
|
||||
verbose_name = '悬赏交付'
|
||||
verbose_name_plural = '悬赏交付'
|
||||
ordering = ['-submitted_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bounty.title} - {self.submitter}"
|
||||
|
||||
|
||||
class BountyDispute(models.Model):
|
||||
"""Dispute records for bounties."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
OPEN = 'open', '处理中'
|
||||
RESOLVED = 'resolved', '已解决'
|
||||
REJECTED = 'rejected', '已驳回'
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
bounty = models.ForeignKey(
|
||||
Bounty,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='disputes',
|
||||
verbose_name='悬赏'
|
||||
)
|
||||
initiator = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bounty_disputes',
|
||||
verbose_name='发起人'
|
||||
)
|
||||
reason = models.TextField('争议原因')
|
||||
evidence_url = models.TextField('证据链接', blank=True, null=True)
|
||||
status = models.CharField(
|
||||
'状态',
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.OPEN
|
||||
)
|
||||
resolution = models.TextField('处理结果', blank=True, null=True)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
resolved_at = models.DateTimeField('处理时间', blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'bountyDisputes'
|
||||
verbose_name = '悬赏争议'
|
||||
verbose_name_plural = '悬赏争议'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bounty.title} - {self.initiator}"
|
||||
|
||||
|
||||
class BountyReview(models.Model):
|
||||
"""Mutual reviews for completed bounties."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
bounty = models.ForeignKey(
|
||||
Bounty,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='reviews',
|
||||
verbose_name='悬赏'
|
||||
)
|
||||
reviewer = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bounty_reviews_given',
|
||||
verbose_name='评价者'
|
||||
)
|
||||
reviewee = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bounty_reviews_received',
|
||||
verbose_name='被评价者'
|
||||
)
|
||||
rating = models.PositiveSmallIntegerField('评分')
|
||||
comment = models.TextField('评价内容', blank=True, null=True)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'bountyReviews'
|
||||
verbose_name = '悬赏评价'
|
||||
verbose_name_plural = '悬赏评价'
|
||||
unique_together = ['bounty', 'reviewer']
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bounty.title} - {self.reviewer}"
|
||||
|
||||
|
||||
class PaymentEvent(models.Model):
|
||||
"""Stripe webhook event log for idempotency."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
event_id = models.CharField('事件ID', max_length=255, unique=True)
|
||||
event_type = models.CharField('事件类型', max_length=100)
|
||||
bounty = models.ForeignKey(
|
||||
Bounty,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='payment_events',
|
||||
verbose_name='悬赏'
|
||||
)
|
||||
payload = models.TextField('事件内容')
|
||||
processed_at = models.DateTimeField('处理时间', auto_now_add=True)
|
||||
success = models.BooleanField('是否成功', default=True)
|
||||
error_message = models.TextField('错误信息', blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'paymentEvents'
|
||||
verbose_name = '支付事件'
|
||||
verbose_name_plural = '支付事件'
|
||||
ordering = ['-processed_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event_type} - {self.event_id}"
|
||||
|
||||
|
||||
class BountyExtensionRequest(models.Model):
|
||||
"""Deadline extension request for bounty."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = 'pending', '待处理'
|
||||
APPROVED = 'approved', '已同意'
|
||||
REJECTED = 'rejected', '已拒绝'
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
bounty = models.ForeignKey(
|
||||
Bounty,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='extension_requests',
|
||||
verbose_name='悬赏'
|
||||
)
|
||||
requester = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='bounty_extension_requests',
|
||||
verbose_name='申请人'
|
||||
)
|
||||
proposed_deadline = models.DateTimeField('申请截止时间')
|
||||
reason = models.TextField('申请原因', blank=True, null=True)
|
||||
status = models.CharField(
|
||||
'状态',
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING
|
||||
)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
reviewed_at = models.DateTimeField('处理时间', blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'bountyExtensionRequests'
|
||||
verbose_name = '延期申请'
|
||||
verbose_name_plural = '延期申请'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.bounty.title} - {self.requester}"
|
||||
352
backend/apps/bounties/payments.py
Normal file
352
backend/apps/bounties/payments.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Stripe payment integration for bounties.
|
||||
"""
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from ninja import Router
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db import transaction
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
import stripe
|
||||
import json
|
||||
|
||||
from .models import Bounty, PaymentEvent
|
||||
from apps.users.models import User
|
||||
from apps.notifications.models import Notification, NotificationPreference
|
||||
|
||||
router = Router()
|
||||
|
||||
# Initialize Stripe
|
||||
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||
|
||||
|
||||
def should_notify(user, notification_type: str) -> bool:
|
||||
"""Check if user has enabled notification type."""
|
||||
if not user:
|
||||
return False
|
||||
preference, _ = NotificationPreference.objects.get_or_create(user=user)
|
||||
if notification_type == Notification.Type.PRICE_ALERT:
|
||||
return preference.enable_price_alert
|
||||
if notification_type in (
|
||||
Notification.Type.BOUNTY_ACCEPTED,
|
||||
Notification.Type.BOUNTY_COMPLETED,
|
||||
Notification.Type.NEW_COMMENT,
|
||||
):
|
||||
return preference.enable_bounty
|
||||
if notification_type == Notification.Type.SYSTEM:
|
||||
return preference.enable_system
|
||||
return True
|
||||
|
||||
|
||||
class PaymentSchemas:
|
||||
"""Payment related schemas."""
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
class EscrowIn(Schema):
|
||||
"""Create escrow input."""
|
||||
bounty_id: int
|
||||
success_url: str
|
||||
cancel_url: str
|
||||
|
||||
class EscrowOut(Schema):
|
||||
"""Create escrow output."""
|
||||
checkout_url: str
|
||||
session_id: str
|
||||
|
||||
class ConnectStatusOut(Schema):
|
||||
"""Connect account status."""
|
||||
has_account: bool
|
||||
account_id: Optional[str] = None
|
||||
is_complete: bool = False
|
||||
dashboard_url: Optional[str] = None
|
||||
|
||||
class ConnectSetupOut(Schema):
|
||||
"""Connect setup output."""
|
||||
onboarding_url: str
|
||||
account_id: str
|
||||
|
||||
class MessageOut(Schema):
|
||||
"""Simple message response."""
|
||||
message: str
|
||||
success: bool = True
|
||||
|
||||
|
||||
@router.post("/escrow/", response=PaymentSchemas.EscrowOut, auth=JWTAuth())
|
||||
def create_escrow(request, data: PaymentSchemas.EscrowIn):
|
||||
"""Create escrow payment for bounty using Stripe Checkout."""
|
||||
bounty = get_object_or_404(Bounty, id=data.bounty_id)
|
||||
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
return {"error": "Only the publisher can create escrow"}, 403
|
||||
|
||||
if bounty.is_escrowed:
|
||||
return {"error": "Bounty is already escrowed"}, 400
|
||||
|
||||
if bounty.status != Bounty.Status.OPEN:
|
||||
return {"error": "Can only escrow open bounties"}, 400
|
||||
|
||||
try:
|
||||
# Create or get Stripe customer
|
||||
user = request.auth
|
||||
if not user.stripe_customer_id:
|
||||
customer = stripe.Customer.create(
|
||||
email=user.email,
|
||||
name=user.name,
|
||||
metadata={'user_id': str(user.id)}
|
||||
)
|
||||
user.stripe_customer_id = customer.id
|
||||
user.save()
|
||||
|
||||
# Create Checkout Session
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=user.stripe_customer_id,
|
||||
payment_method_types=['card'],
|
||||
line_items=[{
|
||||
'price_data': {
|
||||
'currency': bounty.currency.lower(),
|
||||
'unit_amount': int(bounty.reward * 100), # Convert to cents
|
||||
'product_data': {
|
||||
'name': f'悬赏托管: {bounty.title}',
|
||||
'description': bounty.description[:500] if bounty.description else None,
|
||||
},
|
||||
},
|
||||
'quantity': 1,
|
||||
}],
|
||||
mode='payment',
|
||||
success_url=data.success_url,
|
||||
cancel_url=data.cancel_url,
|
||||
metadata={
|
||||
'bounty_id': str(bounty.id),
|
||||
'type': 'bounty_escrow',
|
||||
},
|
||||
payment_intent_data={
|
||||
'capture_method': 'manual', # Don't capture immediately, hold in escrow
|
||||
'metadata': {
|
||||
'bounty_id': str(bounty.id),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
return PaymentSchemas.EscrowOut(
|
||||
checkout_url=session.url,
|
||||
session_id=session.id,
|
||||
)
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
return {"error": str(e)}, 400
|
||||
|
||||
|
||||
@router.get("/connect/status/", response=PaymentSchemas.ConnectStatusOut, auth=JWTAuth())
|
||||
def get_connect_status(request):
|
||||
"""Get user's Stripe Connect account status."""
|
||||
user = request.auth
|
||||
|
||||
if not user.stripe_account_id:
|
||||
return PaymentSchemas.ConnectStatusOut(
|
||||
has_account=False,
|
||||
is_complete=False,
|
||||
)
|
||||
|
||||
try:
|
||||
account = stripe.Account.retrieve(user.stripe_account_id)
|
||||
|
||||
# Check if onboarding is complete
|
||||
is_complete = (
|
||||
account.charges_enabled and
|
||||
account.payouts_enabled and
|
||||
account.details_submitted
|
||||
)
|
||||
|
||||
# Create login link for dashboard
|
||||
dashboard_url = None
|
||||
if is_complete:
|
||||
login_link = stripe.Account.create_login_link(user.stripe_account_id)
|
||||
dashboard_url = login_link.url
|
||||
|
||||
return PaymentSchemas.ConnectStatusOut(
|
||||
has_account=True,
|
||||
account_id=user.stripe_account_id,
|
||||
is_complete=is_complete,
|
||||
dashboard_url=dashboard_url,
|
||||
)
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
return {"error": str(e)}, 400
|
||||
|
||||
|
||||
@router.post("/connect/setup/", response=PaymentSchemas.ConnectSetupOut, auth=JWTAuth())
|
||||
def setup_connect_account(request, return_url: str, refresh_url: str):
|
||||
"""Setup Stripe Connect account for receiving payments."""
|
||||
user = request.auth
|
||||
|
||||
try:
|
||||
# Create or retrieve account
|
||||
if not user.stripe_account_id:
|
||||
account = stripe.Account.create(
|
||||
type='express',
|
||||
country='CN', # Default to China, can be made dynamic
|
||||
email=user.email,
|
||||
metadata={'user_id': str(user.id)},
|
||||
capabilities={
|
||||
'card_payments': {'requested': True},
|
||||
'transfers': {'requested': True},
|
||||
},
|
||||
)
|
||||
user.stripe_account_id = account.id
|
||||
user.save()
|
||||
else:
|
||||
account = stripe.Account.retrieve(user.stripe_account_id)
|
||||
|
||||
# Create account link for onboarding
|
||||
account_link = stripe.AccountLink.create(
|
||||
account=user.stripe_account_id,
|
||||
refresh_url=refresh_url,
|
||||
return_url=return_url,
|
||||
type='account_onboarding',
|
||||
)
|
||||
|
||||
return PaymentSchemas.ConnectSetupOut(
|
||||
onboarding_url=account_link.url,
|
||||
account_id=user.stripe_account_id,
|
||||
)
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
return {"error": str(e)}, 400
|
||||
|
||||
|
||||
@router.post("/{bounty_id}/release/", response=PaymentSchemas.MessageOut, auth=JWTAuth())
|
||||
def release_payout(request, bounty_id: int):
|
||||
"""Release escrowed funds to bounty acceptor."""
|
||||
bounty = get_object_or_404(Bounty, id=bounty_id)
|
||||
|
||||
if bounty.publisher_id != request.auth.id:
|
||||
return {"error": "Only the publisher can release payment"}, 403
|
||||
|
||||
if bounty.status != Bounty.Status.COMPLETED:
|
||||
return {"error": "Bounty must be completed to release payment"}, 400
|
||||
|
||||
if bounty.is_paid:
|
||||
return {"error": "Payment has already been released"}, 400
|
||||
|
||||
if not bounty.is_escrowed:
|
||||
return {"error": "Bounty is not escrowed"}, 400
|
||||
|
||||
if not bounty.acceptor:
|
||||
return {"error": "No acceptor to pay"}, 400
|
||||
|
||||
acceptor = bounty.acceptor
|
||||
if not acceptor.stripe_account_id:
|
||||
return {"error": "Acceptor has not set up payment account"}, 400
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
# First, capture the payment intent
|
||||
if bounty.stripe_payment_intent_id:
|
||||
stripe.PaymentIntent.capture(bounty.stripe_payment_intent_id)
|
||||
|
||||
# Calculate payout amount (minus platform fee if any)
|
||||
platform_fee_percent = Decimal('0.05') # 5% platform fee
|
||||
payout_amount = bounty.reward * (1 - platform_fee_percent)
|
||||
|
||||
# Create transfer to acceptor
|
||||
transfer = stripe.Transfer.create(
|
||||
amount=int(payout_amount * 100), # Convert to cents
|
||||
currency=bounty.currency.lower(),
|
||||
destination=acceptor.stripe_account_id,
|
||||
metadata={
|
||||
'bounty_id': str(bounty.id),
|
||||
'type': 'bounty_payout',
|
||||
},
|
||||
)
|
||||
|
||||
# Update bounty
|
||||
bounty.stripe_transfer_id = transfer.id
|
||||
bounty.is_paid = True
|
||||
bounty.save()
|
||||
|
||||
# Notify acceptor
|
||||
if should_notify(acceptor, Notification.Type.PAYMENT_RECEIVED):
|
||||
Notification.objects.create(
|
||||
user=acceptor,
|
||||
type=Notification.Type.PAYMENT_RECEIVED,
|
||||
title="收到赏金",
|
||||
content=f"您已收到悬赏 \"{bounty.title}\" 的赏金 {payout_amount} {bounty.currency}",
|
||||
related_id=bounty.id,
|
||||
related_type="bounty",
|
||||
)
|
||||
|
||||
return PaymentSchemas.MessageOut(message="赏金已释放", success=True)
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
return {"error": str(e)}, 400
|
||||
|
||||
|
||||
def handle_webhook(request: HttpRequest) -> HttpResponse:
|
||||
"""Handle Stripe webhook events."""
|
||||
payload = request.body
|
||||
sig_header = request.META.get('HTTP_STRIPE_SIGNATURE')
|
||||
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except ValueError:
|
||||
return HttpResponse(status=400)
|
||||
except stripe.error.SignatureVerificationError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
# Idempotency check
|
||||
event_id = event.get('id')
|
||||
if event_id and PaymentEvent.objects.filter(event_id=event_id).exists():
|
||||
return HttpResponse(status=200)
|
||||
|
||||
# Handle the event
|
||||
bounty_id = None
|
||||
if event['type'] == 'checkout.session.completed':
|
||||
session = event['data']['object']
|
||||
|
||||
if session.get('metadata', {}).get('type') == 'bounty_escrow':
|
||||
bounty_id = int(session['metadata']['bounty_id'])
|
||||
payment_intent_id = session.get('payment_intent')
|
||||
|
||||
try:
|
||||
bounty = Bounty.objects.get(id=bounty_id)
|
||||
bounty.stripe_payment_intent_id = payment_intent_id
|
||||
bounty.is_escrowed = True
|
||||
bounty.save()
|
||||
except Bounty.DoesNotExist:
|
||||
pass
|
||||
|
||||
elif event['type'] == 'account.updated':
|
||||
account = event['data']['object']
|
||||
user_id = account.get('metadata', {}).get('user_id')
|
||||
|
||||
if user_id:
|
||||
try:
|
||||
user = User.objects.get(id=int(user_id))
|
||||
# Account status updated, could send notification
|
||||
if account.charges_enabled and account.payouts_enabled:
|
||||
if should_notify(user, Notification.Type.SYSTEM):
|
||||
Notification.objects.create(
|
||||
user=user,
|
||||
type=Notification.Type.SYSTEM,
|
||||
title="收款账户已激活",
|
||||
content="您的收款账户已完成设置,可以接收赏金了",
|
||||
)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
|
||||
if event_id:
|
||||
PaymentEvent.objects.create(
|
||||
event_id=event_id,
|
||||
event_type=event['type'],
|
||||
bounty_id=bounty_id,
|
||||
payload=payload.decode('utf-8', errors='ignore'),
|
||||
success=True,
|
||||
)
|
||||
|
||||
return HttpResponse(status=200)
|
||||
190
backend/apps/bounties/schemas.py
Normal file
190
backend/apps/bounties/schemas.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Pydantic schemas for bounties API.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from ninja import Schema, FilterSchema
|
||||
|
||||
from apps.users.schemas import UserOut
|
||||
|
||||
|
||||
class BountyOut(Schema):
|
||||
"""Bounty output schema."""
|
||||
id: int
|
||||
title: str
|
||||
description: str
|
||||
reward: Decimal
|
||||
currency: str
|
||||
publisher_id: int
|
||||
publisher: Optional[UserOut] = None
|
||||
acceptor_id: Optional[int] = None
|
||||
acceptor: Optional[UserOut] = None
|
||||
status: str
|
||||
deadline: Optional[datetime] = None
|
||||
completed_at: Optional[datetime] = None
|
||||
is_paid: bool
|
||||
is_escrowed: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BountyIn(Schema):
|
||||
"""Bounty input schema."""
|
||||
title: str
|
||||
description: str
|
||||
reward: Decimal
|
||||
currency: str = "CNY"
|
||||
deadline: Optional[datetime] = None
|
||||
|
||||
|
||||
class BountyUpdate(Schema):
|
||||
"""Bounty update schema."""
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
reward: Optional[Decimal] = None
|
||||
deadline: Optional[datetime] = None
|
||||
|
||||
|
||||
class BountyApplicationOut(Schema):
|
||||
"""Bounty application output schema."""
|
||||
id: int
|
||||
bounty_id: int
|
||||
applicant_id: int
|
||||
applicant: Optional[UserOut] = None
|
||||
message: Optional[str] = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BountyApplicationIn(Schema):
|
||||
"""Bounty application input schema."""
|
||||
message: Optional[str] = None
|
||||
|
||||
|
||||
class BountyCommentOut(Schema):
|
||||
"""Bounty comment output schema."""
|
||||
id: int
|
||||
bounty_id: int
|
||||
user_id: int
|
||||
user: Optional[UserOut] = None
|
||||
content: str
|
||||
parent_id: Optional[int] = None
|
||||
replies: List['BountyCommentOut'] = []
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BountyCommentIn(Schema):
|
||||
"""Bounty comment input schema."""
|
||||
content: str
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
|
||||
class BountyFilter(FilterSchema):
|
||||
"""Bounty filter schema."""
|
||||
status: Optional[str] = None
|
||||
publisher_id: Optional[int] = None
|
||||
acceptor_id: Optional[int] = None
|
||||
|
||||
|
||||
class BountyWithDetailsOut(BountyOut):
|
||||
"""Bounty with applications and comments."""
|
||||
applications_count: int = 0
|
||||
comments_count: int = 0
|
||||
|
||||
|
||||
class BountyDeliveryOut(Schema):
|
||||
"""Bounty delivery output schema."""
|
||||
id: int
|
||||
bounty_id: int
|
||||
submitter_id: int
|
||||
content: str
|
||||
attachment_url: Optional[str] = None
|
||||
status: str
|
||||
submitted_at: datetime
|
||||
reviewed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class BountyDeliveryIn(Schema):
|
||||
"""Bounty delivery input schema."""
|
||||
content: str
|
||||
attachment_url: Optional[str] = None
|
||||
|
||||
|
||||
class BountyDeliveryReviewIn(Schema):
|
||||
"""Bounty delivery review input schema."""
|
||||
accept: bool
|
||||
|
||||
|
||||
class BountyDisputeOut(Schema):
|
||||
"""Bounty dispute output schema."""
|
||||
id: int
|
||||
bounty_id: int
|
||||
initiator_id: int
|
||||
reason: str
|
||||
evidence_url: Optional[str] = None
|
||||
status: str
|
||||
resolution: Optional[str] = None
|
||||
created_at: datetime
|
||||
resolved_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class BountyDisputeIn(Schema):
|
||||
"""Bounty dispute input schema."""
|
||||
reason: str
|
||||
evidence_url: Optional[str] = None
|
||||
|
||||
|
||||
class BountyDisputeResolveIn(Schema):
|
||||
"""Bounty dispute resolve input schema (admin)."""
|
||||
resolution: str
|
||||
accepted: bool = True
|
||||
|
||||
|
||||
class BountyReviewOut(Schema):
|
||||
"""Bounty review output schema."""
|
||||
id: int
|
||||
bounty_id: int
|
||||
reviewer_id: int
|
||||
reviewee_id: int
|
||||
rating: int
|
||||
comment: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BountyReviewIn(Schema):
|
||||
"""Bounty review input schema."""
|
||||
reviewee_id: int
|
||||
rating: int
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
class BountyExtensionRequestOut(Schema):
|
||||
"""Bounty extension request output schema."""
|
||||
id: int
|
||||
bounty_id: int
|
||||
requester_id: int
|
||||
proposed_deadline: datetime
|
||||
reason: Optional[str] = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
reviewed_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class BountyExtensionRequestIn(Schema):
|
||||
"""Bounty extension request input schema."""
|
||||
proposed_deadline: datetime
|
||||
reason: Optional[str] = None
|
||||
|
||||
|
||||
class BountyExtensionReviewIn(Schema):
|
||||
"""Bounty extension request review input schema."""
|
||||
approve: bool
|
||||
|
||||
|
||||
class MessageOut(Schema):
|
||||
"""Simple message response."""
|
||||
message: str
|
||||
success: bool = True
|
||||
1
backend/apps/favorites/__init__.py
Normal file
1
backend/apps/favorites/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = 'apps.favorites.apps.FavoritesConfig'
|
||||
41
backend/apps/favorites/admin.py
Normal file
41
backend/apps/favorites/admin.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from django.contrib import admin
|
||||
from .models import Favorite, FavoriteTag, FavoriteTagMapping, PriceMonitor, PriceHistory
|
||||
|
||||
|
||||
@admin.register(Favorite)
|
||||
class FavoriteAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'user', 'product', 'website', 'created_at']
|
||||
list_filter = ['website']
|
||||
search_fields = ['user__name', 'product__name']
|
||||
ordering = ['-created_at']
|
||||
raw_id_fields = ['user', 'product', 'website']
|
||||
|
||||
|
||||
@admin.register(FavoriteTag)
|
||||
class FavoriteTagAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'user', 'name', 'color', 'created_at']
|
||||
search_fields = ['name', 'user__name']
|
||||
ordering = ['-created_at']
|
||||
raw_id_fields = ['user']
|
||||
|
||||
|
||||
@admin.register(FavoriteTagMapping)
|
||||
class FavoriteTagMappingAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'favorite', 'tag', 'created_at']
|
||||
ordering = ['-created_at']
|
||||
raw_id_fields = ['favorite', 'tag']
|
||||
|
||||
|
||||
@admin.register(PriceMonitor)
|
||||
class PriceMonitorAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'favorite', 'user', 'current_price', 'target_price', 'is_active', 'updated_at']
|
||||
list_filter = ['is_active']
|
||||
ordering = ['-updated_at']
|
||||
raw_id_fields = ['favorite', 'user']
|
||||
|
||||
|
||||
@admin.register(PriceHistory)
|
||||
class PriceHistoryAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'monitor', 'price', 'price_change', 'percent_change', 'recorded_at']
|
||||
ordering = ['-recorded_at']
|
||||
raw_id_fields = ['monitor']
|
||||
552
backend/apps/favorites/api.py
Normal file
552
backend/apps/favorites/api.py
Normal file
@@ -0,0 +1,552 @@
|
||||
"""
|
||||
Favorites API routes for collections, tags and price monitoring.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
import csv
|
||||
from decimal import Decimal
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
from django.db import transaction
|
||||
|
||||
from .models import Favorite, FavoriteTag, FavoriteTagMapping, PriceMonitor, PriceHistory
|
||||
from .schemas import (
|
||||
FavoriteOut, FavoriteIn,
|
||||
FavoriteTagOut, FavoriteTagIn, FavoriteTagUpdate, FavoriteTagMappingIn,
|
||||
PriceMonitorOut, PriceMonitorIn, PriceMonitorUpdate,
|
||||
PriceHistoryOut, RecordPriceIn,
|
||||
MessageOut,
|
||||
)
|
||||
from apps.products.models import Product, Website, ProductPrice
|
||||
from apps.notifications.models import Notification, NotificationPreference
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def serialize_favorite(favorite):
|
||||
"""Serialize favorite with related data."""
|
||||
tags = [
|
||||
FavoriteTagOut(
|
||||
id=mapping.tag.id,
|
||||
user_id=mapping.tag.user_id,
|
||||
name=mapping.tag.name,
|
||||
color=mapping.tag.color,
|
||||
description=mapping.tag.description,
|
||||
created_at=mapping.tag.created_at,
|
||||
)
|
||||
for mapping in favorite.tag_mappings.select_related('tag').all()
|
||||
]
|
||||
|
||||
return FavoriteOut(
|
||||
id=favorite.id,
|
||||
user_id=favorite.user_id,
|
||||
product_id=favorite.product_id,
|
||||
product_name=favorite.product.name if favorite.product else None,
|
||||
product_image=favorite.product.image if favorite.product else None,
|
||||
website_id=favorite.website_id,
|
||||
website_name=favorite.website.name if favorite.website else None,
|
||||
website_logo=favorite.website.logo if favorite.website else None,
|
||||
tags=tags,
|
||||
created_at=favorite.created_at,
|
||||
)
|
||||
|
||||
|
||||
def should_notify(user) -> bool:
|
||||
preference, _ = NotificationPreference.objects.get_or_create(user=user)
|
||||
return preference.enable_price_alert
|
||||
|
||||
|
||||
def record_price_for_monitor(monitor: PriceMonitor, price: Decimal):
|
||||
"""Record price history and update monitor stats."""
|
||||
price_change = None
|
||||
percent_change = None
|
||||
|
||||
if monitor.current_price:
|
||||
price_change = price - monitor.current_price
|
||||
if monitor.current_price > 0:
|
||||
percent_change = (price_change / monitor.current_price) * 100
|
||||
|
||||
history = PriceHistory.objects.create(
|
||||
monitor=monitor,
|
||||
price=price,
|
||||
price_change=price_change,
|
||||
percent_change=percent_change,
|
||||
)
|
||||
|
||||
monitor.current_price = price
|
||||
if monitor.lowest_price is None or price < monitor.lowest_price:
|
||||
monitor.lowest_price = price
|
||||
if monitor.highest_price is None or price > monitor.highest_price:
|
||||
monitor.highest_price = price
|
||||
|
||||
should_alert = (
|
||||
monitor.notify_enabled and
|
||||
monitor.notify_on_target and
|
||||
monitor.target_price is not None and
|
||||
price <= monitor.target_price and
|
||||
(monitor.last_notified_price is None or price < monitor.last_notified_price)
|
||||
)
|
||||
if should_alert and should_notify(monitor.user):
|
||||
Notification.objects.create(
|
||||
user=monitor.user,
|
||||
type=Notification.Type.PRICE_ALERT,
|
||||
title="价格已到达目标",
|
||||
content=f"您关注的商品价格已降至 {price}",
|
||||
related_id=monitor.favorite_id,
|
||||
related_type="favorite",
|
||||
)
|
||||
monitor.last_notified_price = price
|
||||
|
||||
monitor.save()
|
||||
return history
|
||||
|
||||
|
||||
# ==================== Favorite Routes ====================
|
||||
|
||||
@router.get("/", response=List[FavoriteOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_favorites(request, tag_id: Optional[int] = None):
|
||||
"""Get current user's favorites."""
|
||||
queryset = Favorite.objects.select_related('product', 'website').filter(
|
||||
user=request.auth
|
||||
).prefetch_related('tag_mappings', 'tag_mappings__tag')
|
||||
|
||||
if tag_id:
|
||||
queryset = queryset.filter(tag_mappings__tag_id=tag_id)
|
||||
|
||||
return [serialize_favorite(f) for f in queryset]
|
||||
|
||||
|
||||
@router.get("/export/", auth=JWTAuth())
|
||||
def export_favorites_csv(request):
|
||||
"""Export current user's favorites to CSV."""
|
||||
favorites = list(
|
||||
Favorite.objects.select_related("product", "website")
|
||||
.filter(user=request.auth)
|
||||
.prefetch_related("tag_mappings", "tag_mappings__tag")
|
||||
)
|
||||
|
||||
if not favorites:
|
||||
response = HttpResponse(content_type="text/csv; charset=utf-8")
|
||||
response["Content-Disposition"] = 'attachment; filename="favorites.csv"'
|
||||
response.write("\ufeff")
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(
|
||||
["product_id", "product_name", "website_id", "website_name", "price", "currency", "last_checked", "tags", "created_at"]
|
||||
)
|
||||
return response
|
||||
|
||||
product_ids = {f.product_id for f in favorites}
|
||||
website_ids = {f.website_id for f in favorites}
|
||||
price_map = {}
|
||||
for row in ProductPrice.objects.filter(
|
||||
product_id__in=product_ids, website_id__in=website_ids
|
||||
).values("product_id", "website_id", "price", "currency", "last_checked"):
|
||||
key = (row["product_id"], row["website_id"])
|
||||
existing = price_map.get(key)
|
||||
if not existing or row["last_checked"] > existing["last_checked"]:
|
||||
price_map[key] = row
|
||||
|
||||
response = HttpResponse(content_type="text/csv; charset=utf-8")
|
||||
filename = f'favorites_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv'
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
response.write("\ufeff")
|
||||
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(
|
||||
["product_id", "product_name", "website_id", "website_name", "price", "currency", "last_checked", "tags", "created_at"]
|
||||
)
|
||||
|
||||
for favorite in favorites:
|
||||
tags = ",".join([m.tag.name for m in favorite.tag_mappings.all()])
|
||||
price = price_map.get((favorite.product_id, favorite.website_id))
|
||||
writer.writerow(
|
||||
[
|
||||
favorite.product_id,
|
||||
favorite.product.name if favorite.product else "",
|
||||
favorite.website_id,
|
||||
favorite.website.name if favorite.website else "",
|
||||
price["price"] if price else "",
|
||||
price["currency"] if price else "",
|
||||
price["last_checked"].isoformat() if price else "",
|
||||
tags,
|
||||
favorite.created_at.isoformat(),
|
||||
]
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/{favorite_id}", response=FavoriteOut, auth=JWTAuth())
|
||||
def get_favorite(request, favorite_id: int):
|
||||
"""Get a specific favorite."""
|
||||
favorite = get_object_or_404(
|
||||
Favorite.objects.select_related('product', 'website').prefetch_related(
|
||||
'tag_mappings', 'tag_mappings__tag'
|
||||
),
|
||||
id=favorite_id,
|
||||
user=request.auth
|
||||
)
|
||||
return serialize_favorite(favorite)
|
||||
|
||||
|
||||
@router.get("/check/", auth=JWTAuth())
|
||||
def is_favorited(request, product_id: int, website_id: int):
|
||||
"""Check if a product is favorited."""
|
||||
exists = Favorite.objects.filter(
|
||||
user=request.auth,
|
||||
product_id=product_id,
|
||||
website_id=website_id
|
||||
).exists()
|
||||
|
||||
favorite_id = None
|
||||
if exists:
|
||||
favorite = Favorite.objects.get(
|
||||
user=request.auth,
|
||||
product_id=product_id,
|
||||
website_id=website_id
|
||||
)
|
||||
favorite_id = favorite.id
|
||||
|
||||
return {"is_favorited": exists, "favorite_id": favorite_id}
|
||||
|
||||
|
||||
@router.post("/", response=FavoriteOut, auth=JWTAuth())
|
||||
def add_favorite(request, data: FavoriteIn):
|
||||
"""Add a product to favorites."""
|
||||
# Check if already favorited
|
||||
existing = Favorite.objects.filter(
|
||||
user=request.auth,
|
||||
product_id=data.product_id,
|
||||
website_id=data.website_id
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
return serialize_favorite(existing)
|
||||
|
||||
favorite = Favorite.objects.create(
|
||||
user=request.auth,
|
||||
product_id=data.product_id,
|
||||
website_id=data.website_id,
|
||||
)
|
||||
|
||||
# Refresh with relations
|
||||
favorite = Favorite.objects.select_related('product', 'website').prefetch_related(
|
||||
'tag_mappings', 'tag_mappings__tag'
|
||||
).get(id=favorite.id)
|
||||
|
||||
return serialize_favorite(favorite)
|
||||
|
||||
|
||||
@router.delete("/{favorite_id}", response=MessageOut, auth=JWTAuth())
|
||||
def remove_favorite(request, favorite_id: int):
|
||||
"""Remove a product from favorites."""
|
||||
favorite = get_object_or_404(Favorite, id=favorite_id, user=request.auth)
|
||||
favorite.delete()
|
||||
return MessageOut(message="已取消收藏", success=True)
|
||||
|
||||
|
||||
# ==================== Tag Routes ====================
|
||||
|
||||
@router.get("/tags/", response=List[FavoriteTagOut], auth=JWTAuth())
|
||||
def list_tags(request):
|
||||
"""Get current user's tags."""
|
||||
tags = FavoriteTag.objects.filter(user=request.auth)
|
||||
return [
|
||||
FavoriteTagOut(
|
||||
id=t.id,
|
||||
user_id=t.user_id,
|
||||
name=t.name,
|
||||
color=t.color,
|
||||
description=t.description,
|
||||
created_at=t.created_at,
|
||||
)
|
||||
for t in tags
|
||||
]
|
||||
|
||||
|
||||
@router.post("/tags/", response=FavoriteTagOut, auth=JWTAuth())
|
||||
def create_tag(request, data: FavoriteTagIn):
|
||||
"""Create a new tag."""
|
||||
# Check if tag with same name exists
|
||||
if FavoriteTag.objects.filter(user=request.auth, name=data.name).exists():
|
||||
return {"error": "Tag with this name already exists"}, 400
|
||||
|
||||
tag = FavoriteTag.objects.create(
|
||||
user=request.auth,
|
||||
**data.dict()
|
||||
)
|
||||
|
||||
return FavoriteTagOut(
|
||||
id=tag.id,
|
||||
user_id=tag.user_id,
|
||||
name=tag.name,
|
||||
color=tag.color,
|
||||
description=tag.description,
|
||||
created_at=tag.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/tags/{tag_id}", response=FavoriteTagOut, auth=JWTAuth())
|
||||
def update_tag(request, tag_id: int, data: FavoriteTagUpdate):
|
||||
"""Update a tag."""
|
||||
tag = get_object_or_404(FavoriteTag, id=tag_id, user=request.auth)
|
||||
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(tag, key, value)
|
||||
|
||||
tag.save()
|
||||
|
||||
return FavoriteTagOut(
|
||||
id=tag.id,
|
||||
user_id=tag.user_id,
|
||||
name=tag.name,
|
||||
color=tag.color,
|
||||
description=tag.description,
|
||||
created_at=tag.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/tags/{tag_id}", response=MessageOut, auth=JWTAuth())
|
||||
def delete_tag(request, tag_id: int):
|
||||
"""Delete a tag."""
|
||||
tag = get_object_or_404(FavoriteTag, id=tag_id, user=request.auth)
|
||||
tag.delete()
|
||||
return MessageOut(message="标签已删除", success=True)
|
||||
|
||||
|
||||
@router.post("/{favorite_id}/tags/", response=MessageOut, auth=JWTAuth())
|
||||
def add_tag_to_favorite(request, favorite_id: int, data: FavoriteTagMappingIn):
|
||||
"""Add a tag to a favorite."""
|
||||
favorite = get_object_or_404(Favorite, id=favorite_id, user=request.auth)
|
||||
tag = get_object_or_404(FavoriteTag, id=data.tag_id, user=request.auth)
|
||||
|
||||
FavoriteTagMapping.objects.get_or_create(favorite=favorite, tag=tag)
|
||||
|
||||
return MessageOut(message="标签已添加", success=True)
|
||||
|
||||
|
||||
@router.delete("/{favorite_id}/tags/{tag_id}", response=MessageOut, auth=JWTAuth())
|
||||
def remove_tag_from_favorite(request, favorite_id: int, tag_id: int):
|
||||
"""Remove a tag from a favorite."""
|
||||
mapping = get_object_or_404(
|
||||
FavoriteTagMapping,
|
||||
favorite_id=favorite_id,
|
||||
tag_id=tag_id,
|
||||
favorite__user=request.auth
|
||||
)
|
||||
mapping.delete()
|
||||
return MessageOut(message="标签已移除", success=True)
|
||||
|
||||
|
||||
# ==================== Price Monitor Routes ====================
|
||||
|
||||
@router.get("/{favorite_id}/monitor/", response=Optional[PriceMonitorOut], auth=JWTAuth())
|
||||
def get_price_monitor(request, favorite_id: int):
|
||||
"""Get price monitor for a favorite."""
|
||||
favorite = get_object_or_404(Favorite, id=favorite_id, user=request.auth)
|
||||
|
||||
try:
|
||||
monitor = PriceMonitor.objects.get(favorite=favorite)
|
||||
return PriceMonitorOut(
|
||||
id=monitor.id,
|
||||
favorite_id=monitor.favorite_id,
|
||||
user_id=monitor.user_id,
|
||||
current_price=monitor.current_price,
|
||||
target_price=monitor.target_price,
|
||||
lowest_price=monitor.lowest_price,
|
||||
highest_price=monitor.highest_price,
|
||||
notify_enabled=monitor.notify_enabled,
|
||||
notify_on_target=monitor.notify_on_target,
|
||||
last_notified_price=monitor.last_notified_price,
|
||||
is_active=monitor.is_active,
|
||||
created_at=monitor.created_at,
|
||||
updated_at=monitor.updated_at,
|
||||
)
|
||||
except PriceMonitor.DoesNotExist:
|
||||
return None
|
||||
|
||||
|
||||
@router.post("/{favorite_id}/monitor/", response=PriceMonitorOut, auth=JWTAuth())
|
||||
def create_price_monitor(request, favorite_id: int, data: PriceMonitorIn):
|
||||
"""Create or update price monitor for a favorite."""
|
||||
favorite = get_object_or_404(Favorite, id=favorite_id, user=request.auth)
|
||||
|
||||
monitor, created = PriceMonitor.objects.update_or_create(
|
||||
favorite=favorite,
|
||||
defaults={
|
||||
'user': request.auth,
|
||||
'target_price': data.target_price,
|
||||
'is_active': data.is_active,
|
||||
'notify_enabled': data.notify_enabled,
|
||||
'notify_on_target': data.notify_on_target,
|
||||
}
|
||||
)
|
||||
|
||||
return PriceMonitorOut(
|
||||
id=monitor.id,
|
||||
favorite_id=monitor.favorite_id,
|
||||
user_id=monitor.user_id,
|
||||
current_price=monitor.current_price,
|
||||
target_price=monitor.target_price,
|
||||
lowest_price=monitor.lowest_price,
|
||||
highest_price=monitor.highest_price,
|
||||
notify_enabled=monitor.notify_enabled,
|
||||
notify_on_target=monitor.notify_on_target,
|
||||
last_notified_price=monitor.last_notified_price,
|
||||
is_active=monitor.is_active,
|
||||
created_at=monitor.created_at,
|
||||
updated_at=monitor.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{favorite_id}/monitor/", response=PriceMonitorOut, auth=JWTAuth())
|
||||
def update_price_monitor(request, favorite_id: int, data: PriceMonitorUpdate):
|
||||
"""Update price monitor for a favorite."""
|
||||
monitor = get_object_or_404(
|
||||
PriceMonitor,
|
||||
favorite_id=favorite_id,
|
||||
user=request.auth
|
||||
)
|
||||
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(monitor, key, value)
|
||||
|
||||
monitor.save()
|
||||
|
||||
return PriceMonitorOut(
|
||||
id=monitor.id,
|
||||
favorite_id=monitor.favorite_id,
|
||||
user_id=monitor.user_id,
|
||||
current_price=monitor.current_price,
|
||||
target_price=monitor.target_price,
|
||||
lowest_price=monitor.lowest_price,
|
||||
highest_price=monitor.highest_price,
|
||||
notify_enabled=monitor.notify_enabled,
|
||||
notify_on_target=monitor.notify_on_target,
|
||||
last_notified_price=monitor.last_notified_price,
|
||||
is_active=monitor.is_active,
|
||||
created_at=monitor.created_at,
|
||||
updated_at=monitor.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{favorite_id}/monitor/", response=MessageOut, auth=JWTAuth())
|
||||
def delete_price_monitor(request, favorite_id: int):
|
||||
"""Delete price monitor for a favorite."""
|
||||
monitor = get_object_or_404(
|
||||
PriceMonitor,
|
||||
favorite_id=favorite_id,
|
||||
user=request.auth
|
||||
)
|
||||
monitor.delete()
|
||||
return MessageOut(message="价格监控已删除", success=True)
|
||||
|
||||
|
||||
@router.get("/{favorite_id}/monitor/history/", response=List[PriceHistoryOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=50)
|
||||
def get_price_history(request, favorite_id: int):
|
||||
"""Get price history for a favorite's monitor."""
|
||||
monitor = get_object_or_404(
|
||||
PriceMonitor,
|
||||
favorite_id=favorite_id,
|
||||
user=request.auth
|
||||
)
|
||||
|
||||
history = PriceHistory.objects.filter(monitor=monitor)
|
||||
|
||||
return [
|
||||
PriceHistoryOut(
|
||||
id=h.id,
|
||||
monitor_id=h.monitor_id,
|
||||
price=h.price,
|
||||
price_change=h.price_change,
|
||||
percent_change=h.percent_change,
|
||||
recorded_at=h.recorded_at,
|
||||
)
|
||||
for h in history
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{favorite_id}/monitor/record/", response=PriceHistoryOut, auth=JWTAuth())
|
||||
def record_price(request, favorite_id: int, data: RecordPriceIn):
|
||||
"""Record a new price for monitoring."""
|
||||
monitor = get_object_or_404(
|
||||
PriceMonitor,
|
||||
favorite_id=favorite_id,
|
||||
user=request.auth
|
||||
)
|
||||
|
||||
history = record_price_for_monitor(monitor, data.price)
|
||||
|
||||
return PriceHistoryOut(
|
||||
id=history.id,
|
||||
monitor_id=history.monitor_id,
|
||||
price=history.price,
|
||||
price_change=history.price_change,
|
||||
percent_change=history.percent_change,
|
||||
recorded_at=history.recorded_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{favorite_id}/monitor/refresh/", response=PriceMonitorOut, auth=JWTAuth())
|
||||
def refresh_price_monitor(request, favorite_id: int):
|
||||
"""Refresh monitor price from product price data."""
|
||||
favorite = get_object_or_404(Favorite, id=favorite_id, user=request.auth)
|
||||
monitor = get_object_or_404(PriceMonitor, favorite=favorite)
|
||||
price_record = ProductPrice.objects.filter(
|
||||
product_id=favorite.product_id,
|
||||
website_id=favorite.website_id
|
||||
).order_by('-last_checked').first()
|
||||
if not price_record:
|
||||
raise HttpError(404, "未找到商品价格数据")
|
||||
record_price_for_monitor(monitor, price_record.price)
|
||||
return PriceMonitorOut(
|
||||
id=monitor.id,
|
||||
favorite_id=monitor.favorite_id,
|
||||
user_id=monitor.user_id,
|
||||
current_price=monitor.current_price,
|
||||
target_price=monitor.target_price,
|
||||
lowest_price=monitor.lowest_price,
|
||||
highest_price=monitor.highest_price,
|
||||
notify_enabled=monitor.notify_enabled,
|
||||
notify_on_target=monitor.notify_on_target,
|
||||
last_notified_price=monitor.last_notified_price,
|
||||
is_active=monitor.is_active,
|
||||
created_at=monitor.created_at,
|
||||
updated_at=monitor.updated_at,
|
||||
)
|
||||
|
||||
|
||||
# ==================== All Monitors Route ====================
|
||||
|
||||
@router.get("/monitors/all/", response=List[PriceMonitorOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_all_monitors(request):
|
||||
"""Get all price monitors for current user."""
|
||||
monitors = PriceMonitor.objects.filter(user=request.auth)
|
||||
|
||||
return [
|
||||
PriceMonitorOut(
|
||||
id=m.id,
|
||||
favorite_id=m.favorite_id,
|
||||
user_id=m.user_id,
|
||||
current_price=m.current_price,
|
||||
target_price=m.target_price,
|
||||
lowest_price=m.lowest_price,
|
||||
highest_price=m.highest_price,
|
||||
notify_enabled=m.notify_enabled,
|
||||
notify_on_target=m.notify_on_target,
|
||||
last_notified_price=m.last_notified_price,
|
||||
is_active=m.is_active,
|
||||
created_at=m.created_at,
|
||||
updated_at=m.updated_at,
|
||||
)
|
||||
for m in monitors
|
||||
]
|
||||
7
backend/apps/favorites/apps.py
Normal file
7
backend/apps/favorites/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class FavoritesConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.favorites'
|
||||
verbose_name = '收藏管理'
|
||||
89
backend/apps/favorites/migrations/0001_initial.py
Normal file
89
backend/apps/favorites/migrations/0001_initial.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-27 07:39
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Favorite',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '收藏',
|
||||
'verbose_name_plural': '收藏',
|
||||
'db_table': 'favorites',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FavoriteTag',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, verbose_name='标签名')),
|
||||
('color', models.CharField(default='#6366f1', max_length=20, verbose_name='颜色')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='描述')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '收藏标签',
|
||||
'verbose_name_plural': '收藏标签',
|
||||
'db_table': 'favoriteTags',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='FavoriteTagMapping',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '收藏标签映射',
|
||||
'verbose_name_plural': '收藏标签映射',
|
||||
'db_table': 'favoriteTagMappings',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PriceHistory',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='价格')),
|
||||
('price_change', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='价格变化')),
|
||||
('percent_change', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='变化百分比')),
|
||||
('recorded_at', models.DateTimeField(auto_now_add=True, verbose_name='记录时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '价格历史',
|
||||
'verbose_name_plural': '价格历史',
|
||||
'db_table': 'priceHistory',
|
||||
'ordering': ['-recorded_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PriceMonitor',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('current_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='当前价格')),
|
||||
('target_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='目标价格')),
|
||||
('lowest_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='最低价')),
|
||||
('highest_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='最高价')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否激活')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('favorite', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='price_monitor', to='favorites.favorite', verbose_name='收藏')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '价格监控',
|
||||
'verbose_name_plural': '价格监控',
|
||||
'db_table': 'priceMonitors',
|
||||
},
|
||||
),
|
||||
]
|
||||
71
backend/apps/favorites/migrations/0002_initial.py
Normal file
71
backend/apps/favorites/migrations/0002_initial.py
Normal file
@@ -0,0 +1,71 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-27 07:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('favorites', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('products', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='pricemonitor',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='price_monitors', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pricehistory',
|
||||
name='monitor',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='history', to='favorites.pricemonitor', verbose_name='监控'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='favoritetagmapping',
|
||||
name='favorite',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tag_mappings', to='favorites.favorite', verbose_name='收藏'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='favoritetagmapping',
|
||||
name='tag',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_mappings', to='favorites.favoritetag', verbose_name='标签'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='favoritetag',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorite_tags', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='favorite',
|
||||
name='product',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to='products.product', verbose_name='商品'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='favorite',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='favorite',
|
||||
name='website',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='favorites', to='products.website', verbose_name='网站'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='favoritetagmapping',
|
||||
unique_together={('favorite', 'tag')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='favoritetag',
|
||||
unique_together={('user', 'name')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='favorite',
|
||||
unique_together={('user', 'product', 'website')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('favorites', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='pricemonitor',
|
||||
name='notify_enabled',
|
||||
field=models.BooleanField(default=True, verbose_name='是否通知'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pricemonitor',
|
||||
name='notify_on_target',
|
||||
field=models.BooleanField(default=True, verbose_name='目标价提醒'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pricemonitor',
|
||||
name='last_notified_price',
|
||||
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='上次通知价格'),
|
||||
),
|
||||
]
|
||||
195
backend/apps/favorites/models.py
Normal file
195
backend/apps/favorites/models.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
Favorites models for collections, tags and price monitoring.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Favorite(models.Model):
|
||||
"""User product favorites/collections."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='favorites',
|
||||
verbose_name='用户'
|
||||
)
|
||||
product = models.ForeignKey(
|
||||
'products.Product',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='favorites',
|
||||
verbose_name='商品'
|
||||
)
|
||||
website = models.ForeignKey(
|
||||
'products.Website',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='favorites',
|
||||
verbose_name='网站'
|
||||
)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'favorites'
|
||||
verbose_name = '收藏'
|
||||
verbose_name_plural = '收藏'
|
||||
unique_together = ['user', 'product', 'website']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} - {self.product.name}"
|
||||
|
||||
|
||||
class FavoriteTag(models.Model):
|
||||
"""Favorite collection tags for organizing collections."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='favorite_tags',
|
||||
verbose_name='用户'
|
||||
)
|
||||
name = models.CharField('标签名', max_length=100)
|
||||
color = models.CharField('颜色', max_length=20, default='#6366f1')
|
||||
description = models.TextField('描述', blank=True, null=True)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'favoriteTags'
|
||||
verbose_name = '收藏标签'
|
||||
verbose_name_plural = '收藏标签'
|
||||
unique_together = ['user', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class FavoriteTagMapping(models.Model):
|
||||
"""Junction table for favorites and tags."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
favorite = models.ForeignKey(
|
||||
Favorite,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='tag_mappings',
|
||||
verbose_name='收藏'
|
||||
)
|
||||
tag = models.ForeignKey(
|
||||
FavoriteTag,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='favorite_mappings',
|
||||
verbose_name='标签'
|
||||
)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'favoriteTagMappings'
|
||||
verbose_name = '收藏标签映射'
|
||||
verbose_name_plural = '收藏标签映射'
|
||||
unique_together = ['favorite', 'tag']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.favorite} - {self.tag.name}"
|
||||
|
||||
|
||||
class PriceMonitor(models.Model):
|
||||
"""Price monitoring for favorites."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
favorite = models.OneToOneField(
|
||||
Favorite,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='price_monitor',
|
||||
verbose_name='收藏'
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='price_monitors',
|
||||
verbose_name='用户'
|
||||
)
|
||||
current_price = models.DecimalField(
|
||||
'当前价格',
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
target_price = models.DecimalField(
|
||||
'目标价格',
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
lowest_price = models.DecimalField(
|
||||
'最低价',
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
highest_price = models.DecimalField(
|
||||
'最高价',
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
notify_enabled = models.BooleanField('是否通知', default=True)
|
||||
notify_on_target = models.BooleanField('目标价提醒', default=True)
|
||||
last_notified_price = models.DecimalField(
|
||||
'上次通知价格',
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
is_active = models.BooleanField('是否激活', default=True)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'priceMonitors'
|
||||
verbose_name = '价格监控'
|
||||
verbose_name_plural = '价格监控'
|
||||
|
||||
def __str__(self):
|
||||
return f"Monitor: {self.favorite}"
|
||||
|
||||
|
||||
class PriceHistory(models.Model):
|
||||
"""Price history tracking."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
monitor = models.ForeignKey(
|
||||
PriceMonitor,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='history',
|
||||
verbose_name='监控'
|
||||
)
|
||||
price = models.DecimalField('价格', max_digits=10, decimal_places=2)
|
||||
price_change = models.DecimalField(
|
||||
'价格变化',
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
percent_change = models.DecimalField(
|
||||
'变化百分比',
|
||||
max_digits=5,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
recorded_at = models.DateTimeField('记录时间', auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'priceHistory'
|
||||
verbose_name = '价格历史'
|
||||
verbose_name_plural = '价格历史'
|
||||
ordering = ['-recorded_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.monitor.favorite} - {self.price}"
|
||||
110
backend/apps/favorites/schemas.py
Normal file
110
backend/apps/favorites/schemas.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Pydantic schemas for favorites API.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class FavoriteTagOut(Schema):
|
||||
"""Favorite tag output schema."""
|
||||
id: int
|
||||
user_id: int
|
||||
name: str
|
||||
color: str
|
||||
description: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FavoriteTagIn(Schema):
|
||||
"""Favorite tag input schema."""
|
||||
name: str
|
||||
color: str = "#6366f1"
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class FavoriteTagUpdate(Schema):
|
||||
"""Favorite tag update schema."""
|
||||
name: Optional[str] = None
|
||||
color: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class FavoriteOut(Schema):
|
||||
"""Favorite output schema."""
|
||||
id: int
|
||||
user_id: int
|
||||
product_id: int
|
||||
product_name: Optional[str] = None
|
||||
product_image: Optional[str] = None
|
||||
website_id: int
|
||||
website_name: Optional[str] = None
|
||||
website_logo: Optional[str] = None
|
||||
tags: List[FavoriteTagOut] = []
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FavoriteIn(Schema):
|
||||
"""Favorite input schema."""
|
||||
product_id: int
|
||||
website_id: int
|
||||
|
||||
|
||||
class FavoriteTagMappingIn(Schema):
|
||||
"""Add/remove tag from favorite."""
|
||||
tag_id: int
|
||||
|
||||
|
||||
class PriceHistoryOut(Schema):
|
||||
"""Price history output schema."""
|
||||
id: int
|
||||
monitor_id: int
|
||||
price: Decimal
|
||||
price_change: Optional[Decimal] = None
|
||||
percent_change: Optional[Decimal] = None
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class PriceMonitorOut(Schema):
|
||||
"""Price monitor output schema."""
|
||||
id: int
|
||||
favorite_id: int
|
||||
user_id: int
|
||||
current_price: Optional[Decimal] = None
|
||||
target_price: Optional[Decimal] = None
|
||||
lowest_price: Optional[Decimal] = None
|
||||
highest_price: Optional[Decimal] = None
|
||||
notify_enabled: bool
|
||||
notify_on_target: bool
|
||||
last_notified_price: Optional[Decimal] = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PriceMonitorIn(Schema):
|
||||
"""Price monitor input schema."""
|
||||
target_price: Optional[Decimal] = None
|
||||
is_active: bool = True
|
||||
notify_enabled: bool = True
|
||||
notify_on_target: bool = True
|
||||
|
||||
|
||||
class PriceMonitorUpdate(Schema):
|
||||
"""Price monitor update schema."""
|
||||
target_price: Optional[Decimal] = None
|
||||
is_active: Optional[bool] = None
|
||||
notify_enabled: Optional[bool] = None
|
||||
notify_on_target: Optional[bool] = None
|
||||
|
||||
|
||||
class RecordPriceIn(Schema):
|
||||
"""Record price input schema."""
|
||||
price: Decimal
|
||||
|
||||
|
||||
class MessageOut(Schema):
|
||||
"""Simple message response."""
|
||||
message: str
|
||||
success: bool = True
|
||||
1
backend/apps/notifications/__init__.py
Normal file
1
backend/apps/notifications/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = 'apps.notifications.apps.NotificationsConfig'
|
||||
11
backend/apps/notifications/admin.py
Normal file
11
backend/apps/notifications/admin.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.contrib import admin
|
||||
from .models import Notification
|
||||
|
||||
|
||||
@admin.register(Notification)
|
||||
class NotificationAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'user', 'type', 'title', 'is_read', 'created_at']
|
||||
list_filter = ['type', 'is_read']
|
||||
search_fields = ['title', 'content', 'user__name']
|
||||
ordering = ['-created_at']
|
||||
raw_id_fields = ['user']
|
||||
161
backend/apps/notifications/api.py
Normal file
161
backend/apps/notifications/api.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
Notifications API routes.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
import csv
|
||||
from datetime import datetime
|
||||
from ninja import Router
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import HttpResponse
|
||||
from django.utils import timezone
|
||||
|
||||
from .models import Notification, NotificationPreference
|
||||
from .schemas import (
|
||||
NotificationOut,
|
||||
UnreadCountOut,
|
||||
MessageOut,
|
||||
NotificationPreferenceOut,
|
||||
NotificationPreferenceIn,
|
||||
)
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@router.get("/", response=List[NotificationOut], auth=JWTAuth())
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_notifications(
|
||||
request,
|
||||
is_read: Optional[bool] = None,
|
||||
type: Optional[str] = None,
|
||||
start: Optional[datetime] = None,
|
||||
end: Optional[datetime] = None,
|
||||
):
|
||||
"""Get current user's notifications."""
|
||||
queryset = Notification.objects.filter(user=request.auth)
|
||||
|
||||
if is_read is not None:
|
||||
queryset = queryset.filter(is_read=is_read)
|
||||
if type:
|
||||
queryset = queryset.filter(type=type)
|
||||
if start:
|
||||
queryset = queryset.filter(created_at__gte=start)
|
||||
if end:
|
||||
queryset = queryset.filter(created_at__lte=end)
|
||||
|
||||
return [
|
||||
NotificationOut(
|
||||
id=n.id,
|
||||
user_id=n.user_id,
|
||||
type=n.type,
|
||||
title=n.title,
|
||||
content=n.content,
|
||||
related_id=n.related_id,
|
||||
related_type=n.related_type,
|
||||
is_read=n.is_read,
|
||||
created_at=n.created_at,
|
||||
)
|
||||
for n in queryset
|
||||
]
|
||||
|
||||
|
||||
@router.get("/preferences/", response=NotificationPreferenceOut, auth=JWTAuth())
|
||||
def get_preferences(request):
|
||||
"""Get current user's notification preferences."""
|
||||
preference, _ = NotificationPreference.objects.get_or_create(user=request.auth)
|
||||
return NotificationPreferenceOut(
|
||||
user_id=preference.user_id,
|
||||
enable_bounty=preference.enable_bounty,
|
||||
enable_price_alert=preference.enable_price_alert,
|
||||
enable_system=preference.enable_system,
|
||||
updated_at=preference.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/preferences/", response=NotificationPreferenceOut, auth=JWTAuth())
|
||||
def update_preferences(request, data: NotificationPreferenceIn):
|
||||
"""Update notification preferences."""
|
||||
preference, _ = NotificationPreference.objects.get_or_create(user=request.auth)
|
||||
update_data = data.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(preference, key, value)
|
||||
preference.save()
|
||||
return NotificationPreferenceOut(
|
||||
user_id=preference.user_id,
|
||||
enable_bounty=preference.enable_bounty,
|
||||
enable_price_alert=preference.enable_price_alert,
|
||||
enable_system=preference.enable_system,
|
||||
updated_at=preference.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count/", response=UnreadCountOut, auth=JWTAuth())
|
||||
def get_unread_count(request):
|
||||
"""Get count of unread notifications."""
|
||||
count = Notification.objects.filter(user=request.auth, is_read=False).count()
|
||||
return UnreadCountOut(count=count)
|
||||
|
||||
|
||||
@router.get("/export/", auth=JWTAuth())
|
||||
def export_notifications_csv(request):
|
||||
"""Export current user's notifications to CSV."""
|
||||
notifications = Notification.objects.filter(user=request.auth).order_by("-created_at")
|
||||
response = HttpResponse(content_type="text/csv; charset=utf-8")
|
||||
filename = f'notifications_{timezone.now().strftime("%Y%m%d_%H%M%S")}.csv'
|
||||
response["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||
response.write("\ufeff")
|
||||
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(["created_at", "type", "title", "content", "is_read"])
|
||||
for notification in notifications:
|
||||
writer.writerow(
|
||||
[
|
||||
notification.created_at.isoformat(),
|
||||
notification.type,
|
||||
notification.title,
|
||||
notification.content or "",
|
||||
"true" if notification.is_read else "false",
|
||||
]
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/{notification_id}/read/", response=MessageOut, auth=JWTAuth())
|
||||
def mark_as_read(request, notification_id: int):
|
||||
"""Mark a notification as read."""
|
||||
notification = get_object_or_404(
|
||||
Notification,
|
||||
id=notification_id,
|
||||
user=request.auth
|
||||
)
|
||||
notification.is_read = True
|
||||
notification.save()
|
||||
return MessageOut(message="已标记为已读", success=True)
|
||||
|
||||
|
||||
@router.post("/read-all/", response=MessageOut, auth=JWTAuth())
|
||||
def mark_all_as_read(request):
|
||||
"""Mark all notifications as read."""
|
||||
Notification.objects.filter(user=request.auth, is_read=False).update(is_read=True)
|
||||
return MessageOut(message="已全部标记为已读", success=True)
|
||||
|
||||
|
||||
@router.delete("/{notification_id}", response=MessageOut, auth=JWTAuth())
|
||||
def delete_notification(request, notification_id: int):
|
||||
"""Delete a notification."""
|
||||
notification = get_object_or_404(
|
||||
Notification,
|
||||
id=notification_id,
|
||||
user=request.auth
|
||||
)
|
||||
notification.delete()
|
||||
return MessageOut(message="通知已删除", success=True)
|
||||
|
||||
|
||||
@router.delete("/", response=MessageOut, auth=JWTAuth())
|
||||
def delete_all_read(request):
|
||||
"""Delete all read notifications."""
|
||||
Notification.objects.filter(user=request.auth, is_read=True).delete()
|
||||
return MessageOut(message="已删除所有已读通知", success=True)
|
||||
7
backend/apps/notifications/apps.py
Normal file
7
backend/apps/notifications/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.notifications'
|
||||
verbose_name = '通知管理'
|
||||
33
backend/apps/notifications/migrations/0001_initial.py
Normal file
33
backend/apps/notifications/migrations/0001_initial.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-27 07:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Notification',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('type', models.CharField(choices=[('bounty_accepted', '悬赏被接受'), ('bounty_completed', '悬赏已完成'), ('new_comment', '新评论'), ('payment_received', '收到付款'), ('system', '系统通知')], max_length=30, verbose_name='类型')),
|
||||
('title', models.CharField(max_length=200, verbose_name='标题')),
|
||||
('content', models.TextField(blank=True, null=True, verbose_name='内容')),
|
||||
('related_id', models.IntegerField(blank=True, null=True, verbose_name='关联ID')),
|
||||
('related_type', models.CharField(blank=True, max_length=50, null=True, verbose_name='关联类型')),
|
||||
('is_read', models.BooleanField(default=False, verbose_name='是否已读')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '通知',
|
||||
'verbose_name_plural': '通知',
|
||||
'db_table': 'notifications',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
23
backend/apps/notifications/migrations/0002_initial.py
Normal file
23
backend/apps/notifications/migrations/0002_initial.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-27 07:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('notifications', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='notification',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='用户'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0002_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='NotificationPreference',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('enable_bounty', models.BooleanField(default=True, verbose_name='悬赏通知')),
|
||||
('enable_price_alert', models.BooleanField(default=True, verbose_name='价格提醒')),
|
||||
('enable_system', models.BooleanField(default=True, verbose_name='系统通知')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='notification_preference', to=settings.AUTH_USER_MODEL, verbose_name='用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '通知偏好',
|
||||
'verbose_name_plural': '通知偏好',
|
||||
'db_table': 'notificationPreferences',
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/apps/notifications/migrations/__init__.py
Normal file
0
backend/apps/notifications/migrations/__init__.py
Normal file
69
backend/apps/notifications/models.py
Normal file
69
backend/apps/notifications/models.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Notification models for user notifications.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class Notification(models.Model):
|
||||
"""User notifications."""
|
||||
|
||||
class Type(models.TextChoices):
|
||||
BOUNTY_ACCEPTED = 'bounty_accepted', '悬赏被接受'
|
||||
BOUNTY_COMPLETED = 'bounty_completed', '悬赏已完成'
|
||||
NEW_COMMENT = 'new_comment', '新评论'
|
||||
PAYMENT_RECEIVED = 'payment_received', '收到付款'
|
||||
PRICE_ALERT = 'price_alert', '价格提醒'
|
||||
SYSTEM = 'system', '系统通知'
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='notifications',
|
||||
verbose_name='用户'
|
||||
)
|
||||
type = models.CharField(
|
||||
'类型',
|
||||
max_length=30,
|
||||
choices=Type.choices
|
||||
)
|
||||
title = models.CharField('标题', max_length=200)
|
||||
content = models.TextField('内容', blank=True, null=True)
|
||||
related_id = models.IntegerField('关联ID', blank=True, null=True)
|
||||
related_type = models.CharField('关联类型', max_length=50, blank=True, null=True)
|
||||
is_read = models.BooleanField('是否已读', default=False)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'notifications'
|
||||
verbose_name = '通知'
|
||||
verbose_name_plural = '通知'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} -> {self.user}"
|
||||
|
||||
|
||||
class NotificationPreference(models.Model):
|
||||
"""User notification preferences."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='notification_preference',
|
||||
verbose_name='用户'
|
||||
)
|
||||
enable_bounty = models.BooleanField('悬赏通知', default=True)
|
||||
enable_price_alert = models.BooleanField('价格提醒', default=True)
|
||||
enable_system = models.BooleanField('系统通知', default=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'notificationPreferences'
|
||||
verbose_name = '通知偏好'
|
||||
verbose_name_plural = '通知偏好'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user} preferences"
|
||||
46
backend/apps/notifications/schemas.py
Normal file
46
backend/apps/notifications/schemas.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Pydantic schemas for notifications API.
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class NotificationOut(Schema):
|
||||
"""Notification output schema."""
|
||||
id: int
|
||||
user_id: int
|
||||
type: str
|
||||
title: str
|
||||
content: Optional[str] = None
|
||||
related_id: Optional[int] = None
|
||||
related_type: Optional[str] = None
|
||||
is_read: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UnreadCountOut(Schema):
|
||||
"""Unread count output schema."""
|
||||
count: int
|
||||
|
||||
|
||||
class NotificationPreferenceOut(Schema):
|
||||
"""Notification preference output schema."""
|
||||
user_id: int
|
||||
enable_bounty: bool
|
||||
enable_price_alert: bool
|
||||
enable_system: bool
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class NotificationPreferenceIn(Schema):
|
||||
"""Notification preference update schema."""
|
||||
enable_bounty: Optional[bool] = None
|
||||
enable_price_alert: Optional[bool] = None
|
||||
enable_system: Optional[bool] = None
|
||||
|
||||
|
||||
class MessageOut(Schema):
|
||||
"""Simple message response."""
|
||||
message: str
|
||||
success: bool = True
|
||||
1
backend/apps/products/__init__.py
Normal file
1
backend/apps/products/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = 'apps.products.apps.ProductsConfig'
|
||||
35
backend/apps/products/admin.py
Normal file
35
backend/apps/products/admin.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from django.contrib import admin
|
||||
from .models import Category, Website, Product, ProductPrice
|
||||
|
||||
|
||||
@admin.register(Category)
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'name', 'slug', 'parent', 'sort_order', 'created_at']
|
||||
list_filter = ['parent']
|
||||
search_fields = ['name', 'slug']
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
ordering = ['sort_order', 'id']
|
||||
|
||||
|
||||
@admin.register(Website)
|
||||
class WebsiteAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'name', 'url', 'category', 'rating', 'is_verified', 'sort_order']
|
||||
list_filter = ['category', 'is_verified']
|
||||
search_fields = ['name', 'url']
|
||||
ordering = ['sort_order', 'id']
|
||||
|
||||
|
||||
@admin.register(Product)
|
||||
class ProductAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'name', 'category', 'created_at', 'updated_at']
|
||||
list_filter = ['category']
|
||||
search_fields = ['name', 'description']
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
@admin.register(ProductPrice)
|
||||
class ProductPriceAdmin(admin.ModelAdmin):
|
||||
list_display = ['id', 'product', 'website', 'price', 'original_price', 'in_stock', 'last_checked']
|
||||
list_filter = ['website', 'in_stock', 'currency']
|
||||
search_fields = ['product__name', 'website__name']
|
||||
ordering = ['-updated_at']
|
||||
389
backend/apps/products/api.py
Normal file
389
backend/apps/products/api.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""
|
||||
Products API routes for categories, websites, products and prices.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import csv
|
||||
import io
|
||||
from ninja import Router, Query, File
|
||||
from ninja.files import UploadedFile
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja.pagination import paginate, PageNumberPagination
|
||||
from django.db.models import Count, Min, Max, Q
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from .models import Category, Website, Product, ProductPrice
|
||||
from .schemas import (
|
||||
CategoryOut, CategoryIn,
|
||||
WebsiteOut, WebsiteIn, WebsiteFilter,
|
||||
ProductOut, ProductIn, ProductWithPricesOut, ProductPriceOut, ProductPriceIn,
|
||||
ProductFilter,
|
||||
ImportResultOut,
|
||||
)
|
||||
from apps.favorites.models import Favorite
|
||||
|
||||
router = Router()
|
||||
category_router = Router()
|
||||
website_router = Router()
|
||||
|
||||
|
||||
# ==================== Category Routes ====================
|
||||
|
||||
@category_router.get("/", response=List[CategoryOut])
|
||||
def list_categories(request):
|
||||
"""Get all categories."""
|
||||
return Category.objects.all()
|
||||
|
||||
|
||||
@category_router.get("/{slug}", response=CategoryOut)
|
||||
def get_category_by_slug(request, slug: str):
|
||||
"""Get category by slug."""
|
||||
return get_object_or_404(Category, slug=slug)
|
||||
|
||||
|
||||
@category_router.post("/", response=CategoryOut, auth=JWTAuth())
|
||||
def create_category(request, data: CategoryIn):
|
||||
"""Create a new category."""
|
||||
category = Category.objects.create(**data.dict())
|
||||
return category
|
||||
|
||||
|
||||
# ==================== Website Routes ====================
|
||||
|
||||
@website_router.get("/", response=List[WebsiteOut])
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_websites(request, filters: WebsiteFilter = Query(...)):
|
||||
"""Get all websites with optional filters."""
|
||||
queryset = Website.objects.all()
|
||||
|
||||
if filters.category_id:
|
||||
queryset = queryset.filter(category_id=filters.category_id)
|
||||
if filters.is_verified is not None:
|
||||
queryset = queryset.filter(is_verified=filters.is_verified)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@website_router.get("/{website_id}", response=WebsiteOut)
|
||||
def get_website(request, website_id: int):
|
||||
"""Get website by ID."""
|
||||
return get_object_or_404(Website, id=website_id)
|
||||
|
||||
|
||||
@website_router.post("/", response=WebsiteOut, auth=JWTAuth())
|
||||
def create_website(request, data: WebsiteIn):
|
||||
"""Create a new website."""
|
||||
website = Website.objects.create(**data.dict())
|
||||
return website
|
||||
|
||||
|
||||
# ==================== Product Routes ====================
|
||||
|
||||
@router.post("/import/", response=ImportResultOut, auth=JWTAuth())
|
||||
def import_products_csv(request, file: UploadedFile = File(...)):
|
||||
"""Import products/websites/prices from CSV."""
|
||||
content = file.read()
|
||||
try:
|
||||
text = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = content.decode("utf-8-sig", errors="ignore")
|
||||
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
result = ImportResultOut()
|
||||
|
||||
def parse_bool(value: Optional[str], default=False) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
val = str(value).strip().lower()
|
||||
if val in ("1", "true", "yes", "y", "on"):
|
||||
return True
|
||||
if val in ("0", "false", "no", "n", "off"):
|
||||
return False
|
||||
return default
|
||||
|
||||
def parse_decimal(value: Optional[str], field: str, line_no: int) -> Optional[Decimal]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return Decimal(str(value).strip())
|
||||
except (InvalidOperation, ValueError):
|
||||
result.errors.append(f"第{line_no}行字段{field}格式错误")
|
||||
return None
|
||||
|
||||
for idx, row in enumerate(reader, start=2):
|
||||
if not row:
|
||||
continue
|
||||
product_name = (row.get("product_name") or "").strip()
|
||||
category_slug = (row.get("category_slug") or "").strip()
|
||||
category_name = (row.get("category_name") or "").strip()
|
||||
website_name = (row.get("website_name") or "").strip()
|
||||
website_url = (row.get("website_url") or "").strip()
|
||||
price_value = (row.get("price") or "").strip()
|
||||
product_url = (row.get("url") or "").strip()
|
||||
|
||||
if not product_name or not (category_slug or category_name) or not website_name or not website_url or not price_value or not product_url:
|
||||
result.errors.append(f"第{idx}行缺少必填字段")
|
||||
continue
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
category = None
|
||||
if category_slug:
|
||||
category, created = Category.objects.get_or_create(
|
||||
slug=category_slug,
|
||||
defaults={
|
||||
"name": category_name or category_slug,
|
||||
"description": row.get("category_desc") or None,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
result.created_categories += 1
|
||||
if not category:
|
||||
category, created = Category.objects.get_or_create(
|
||||
name=category_name,
|
||||
defaults={
|
||||
"slug": category_slug or category_name,
|
||||
"description": row.get("category_desc") or None,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
result.created_categories += 1
|
||||
|
||||
website, created = Website.objects.get_or_create(
|
||||
name=website_name,
|
||||
defaults={
|
||||
"url": website_url,
|
||||
"logo": row.get("website_logo") or None,
|
||||
"description": row.get("website_desc") or None,
|
||||
"category": category,
|
||||
"is_verified": parse_bool(row.get("is_verified"), False),
|
||||
},
|
||||
)
|
||||
if created:
|
||||
result.created_websites += 1
|
||||
else:
|
||||
if website.url != website_url:
|
||||
website.url = website_url
|
||||
if row.get("website_logo"):
|
||||
website.logo = row.get("website_logo")
|
||||
if row.get("website_desc"):
|
||||
website.description = row.get("website_desc")
|
||||
if website.category_id != category.id:
|
||||
website.category = category
|
||||
website.save()
|
||||
|
||||
product, created = Product.objects.get_or_create(
|
||||
name=product_name,
|
||||
category=category,
|
||||
defaults={
|
||||
"description": row.get("product_desc") or None,
|
||||
"image": row.get("product_image") or None,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
result.created_products += 1
|
||||
else:
|
||||
updated = False
|
||||
if row.get("product_desc") and product.description != row.get("product_desc"):
|
||||
product.description = row.get("product_desc")
|
||||
updated = True
|
||||
if row.get("product_image") and product.image != row.get("product_image"):
|
||||
product.image = row.get("product_image")
|
||||
updated = True
|
||||
if updated:
|
||||
product.save()
|
||||
|
||||
price = parse_decimal(row.get("price"), "price", idx)
|
||||
if price is None:
|
||||
continue
|
||||
original_price = parse_decimal(row.get("original_price"), "original_price", idx)
|
||||
currency = (row.get("currency") or "CNY").strip() or "CNY"
|
||||
in_stock = parse_bool(row.get("in_stock"), True)
|
||||
|
||||
price_record, created = ProductPrice.objects.update_or_create(
|
||||
product=product,
|
||||
website=website,
|
||||
defaults={
|
||||
"price": price,
|
||||
"original_price": original_price,
|
||||
"currency": currency,
|
||||
"url": product_url,
|
||||
"in_stock": in_stock,
|
||||
},
|
||||
)
|
||||
if created:
|
||||
result.created_prices += 1
|
||||
else:
|
||||
result.updated_prices += 1
|
||||
except Exception:
|
||||
result.errors.append(f"第{idx}行处理失败")
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
@router.get("/recommendations/", response=List[ProductOut])
|
||||
def recommend_products(request, limit: int = 12):
|
||||
"""Get recommended products based on favorites or popularity."""
|
||||
user = getattr(request, "auth", None)
|
||||
base_queryset = Product.objects.all()
|
||||
|
||||
if user:
|
||||
favorite_product_ids = list(
|
||||
Favorite.objects.filter(user=user).values_list("product_id", flat=True)
|
||||
)
|
||||
category_ids = list(
|
||||
Product.objects.filter(id__in=favorite_product_ids)
|
||||
.values_list("category_id", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
if category_ids:
|
||||
base_queryset = base_queryset.filter(category_id__in=category_ids).exclude(
|
||||
id__in=favorite_product_ids
|
||||
)
|
||||
|
||||
queryset = (
|
||||
base_queryset.annotate(favorites_count=Count("favorites", distinct=True))
|
||||
.order_by("-favorites_count", "-created_at")[:limit]
|
||||
)
|
||||
return list(queryset)
|
||||
|
||||
@router.get("/", response=List[ProductOut])
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def list_products(request, filters: ProductFilter = Query(...)):
|
||||
"""Get all products with optional filters."""
|
||||
queryset = Product.objects.all()
|
||||
|
||||
if filters.category_id:
|
||||
queryset = queryset.filter(category_id=filters.category_id)
|
||||
if filters.search:
|
||||
queryset = queryset.filter(
|
||||
Q(name__icontains=filters.search) |
|
||||
Q(description__icontains=filters.search)
|
||||
)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
@router.get("/{product_id}", response=ProductOut)
|
||||
def get_product(request, product_id: int):
|
||||
"""Get product by ID."""
|
||||
return get_object_or_404(Product, id=product_id)
|
||||
|
||||
|
||||
@router.get("/{product_id}/with-prices", response=ProductWithPricesOut)
|
||||
def get_product_with_prices(request, product_id: int):
|
||||
"""Get product with all prices from different websites."""
|
||||
product = get_object_or_404(Product, id=product_id)
|
||||
|
||||
prices = ProductPrice.objects.filter(product=product).select_related('website')
|
||||
price_list = []
|
||||
|
||||
for pp in prices:
|
||||
price_list.append(ProductPriceOut(
|
||||
id=pp.id,
|
||||
product_id=pp.product_id,
|
||||
website_id=pp.website_id,
|
||||
website_name=pp.website.name,
|
||||
website_logo=pp.website.logo,
|
||||
price=pp.price,
|
||||
original_price=pp.original_price,
|
||||
currency=pp.currency,
|
||||
url=pp.url,
|
||||
in_stock=pp.in_stock,
|
||||
last_checked=pp.last_checked,
|
||||
))
|
||||
|
||||
# Calculate price range
|
||||
price_stats = prices.aggregate(
|
||||
lowest=Min('price'),
|
||||
highest=Max('price')
|
||||
)
|
||||
|
||||
return ProductWithPricesOut(
|
||||
id=product.id,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
image=product.image,
|
||||
category_id=product.category_id,
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
prices=price_list,
|
||||
lowest_price=price_stats['lowest'],
|
||||
highest_price=price_stats['highest'],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/search/", response=List[ProductWithPricesOut])
|
||||
@paginate(PageNumberPagination, page_size=20)
|
||||
def search_products(request, q: str):
|
||||
"""Search products by name or description."""
|
||||
products = Product.objects.filter(
|
||||
Q(name__icontains=q) | Q(description__icontains=q)
|
||||
)
|
||||
|
||||
result = []
|
||||
for product in products:
|
||||
prices = ProductPrice.objects.filter(product=product).select_related('website')
|
||||
price_list = [
|
||||
ProductPriceOut(
|
||||
id=pp.id,
|
||||
product_id=pp.product_id,
|
||||
website_id=pp.website_id,
|
||||
website_name=pp.website.name,
|
||||
website_logo=pp.website.logo,
|
||||
price=pp.price,
|
||||
original_price=pp.original_price,
|
||||
currency=pp.currency,
|
||||
url=pp.url,
|
||||
in_stock=pp.in_stock,
|
||||
last_checked=pp.last_checked,
|
||||
)
|
||||
for pp in prices
|
||||
]
|
||||
|
||||
price_stats = prices.aggregate(lowest=Min('price'), highest=Max('price'))
|
||||
|
||||
result.append(ProductWithPricesOut(
|
||||
id=product.id,
|
||||
name=product.name,
|
||||
description=product.description,
|
||||
image=product.image,
|
||||
category_id=product.category_id,
|
||||
created_at=product.created_at,
|
||||
updated_at=product.updated_at,
|
||||
prices=price_list,
|
||||
lowest_price=price_stats['lowest'],
|
||||
highest_price=price_stats['highest'],
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/", response=ProductOut, auth=JWTAuth())
|
||||
def create_product(request, data: ProductIn):
|
||||
"""Create a new product."""
|
||||
product = Product.objects.create(**data.dict())
|
||||
return product
|
||||
|
||||
|
||||
@router.post("/prices/", response=ProductPriceOut, auth=JWTAuth())
|
||||
def add_product_price(request, data: ProductPriceIn):
|
||||
"""Add a price for a product."""
|
||||
price = ProductPrice.objects.create(**data.dict())
|
||||
website = price.website
|
||||
|
||||
return ProductPriceOut(
|
||||
id=price.id,
|
||||
product_id=price.product_id,
|
||||
website_id=price.website_id,
|
||||
website_name=website.name,
|
||||
website_logo=website.logo,
|
||||
price=price.price,
|
||||
original_price=price.original_price,
|
||||
currency=price.currency,
|
||||
url=price.url,
|
||||
in_stock=price.in_stock,
|
||||
last_checked=price.last_checked,
|
||||
)
|
||||
7
backend/apps/products/apps.py
Normal file
7
backend/apps/products/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProductsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.products'
|
||||
verbose_name = '商品管理'
|
||||
95
backend/apps/products/migrations/0001_initial.py
Normal file
95
backend/apps/products/migrations/0001_initial.py
Normal file
@@ -0,0 +1,95 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-27 07:39
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Category',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, verbose_name='分类名称')),
|
||||
('slug', models.CharField(max_length=100, unique=True, verbose_name='Slug')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='描述')),
|
||||
('icon', models.CharField(blank=True, max_length=100, null=True, verbose_name='图标')),
|
||||
('sort_order', models.IntegerField(default=0, verbose_name='排序')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='products.category', verbose_name='父分类')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '分类',
|
||||
'verbose_name_plural': '分类',
|
||||
'db_table': 'categories',
|
||||
'ordering': ['sort_order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Product',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=300, verbose_name='商品名称')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='描述')),
|
||||
('image', models.TextField(blank=True, null=True, verbose_name='图片')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='products', to='products.category', verbose_name='分类')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '商品',
|
||||
'verbose_name_plural': '商品',
|
||||
'db_table': 'products',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Website',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=200, verbose_name='网站名称')),
|
||||
('url', models.CharField(max_length=500, verbose_name='网址')),
|
||||
('logo', models.TextField(blank=True, null=True, verbose_name='Logo')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='描述')),
|
||||
('rating', models.DecimalField(decimal_places=1, default=0, max_digits=2, verbose_name='评分')),
|
||||
('is_verified', models.BooleanField(default=False, verbose_name='是否认证')),
|
||||
('sort_order', models.IntegerField(default=0, verbose_name='排序')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='websites', to='products.category', verbose_name='分类')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '网站',
|
||||
'verbose_name_plural': '网站',
|
||||
'db_table': 'websites',
|
||||
'ordering': ['sort_order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProductPrice',
|
||||
fields=[
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='价格')),
|
||||
('original_price', models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='原价')),
|
||||
('currency', models.CharField(default='CNY', max_length=10, verbose_name='货币')),
|
||||
('url', models.CharField(max_length=500, verbose_name='商品链接')),
|
||||
('in_stock', models.BooleanField(default=True, verbose_name='是否有货')),
|
||||
('last_checked', models.DateTimeField(auto_now=True, verbose_name='最后检查时间')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='prices', to='products.product', verbose_name='商品')),
|
||||
('website', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='product_prices', to='products.website', verbose_name='网站')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '商品价格',
|
||||
'verbose_name_plural': '商品价格',
|
||||
'db_table': 'productPrices',
|
||||
'unique_together': {('product', 'website')},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
backend/apps/products/migrations/__init__.py
Normal file
0
backend/apps/products/migrations/__init__.py
Normal file
129
backend/apps/products/models.py
Normal file
129
backend/apps/products/models.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Product models for categories, websites, products and prices.
|
||||
"""
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
"""Product categories for navigation."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
name = models.CharField('分类名称', max_length=100)
|
||||
slug = models.CharField('Slug', max_length=100, unique=True)
|
||||
description = models.TextField('描述', blank=True, null=True)
|
||||
icon = models.CharField('图标', max_length=100, blank=True, null=True)
|
||||
parent = models.ForeignKey(
|
||||
'self',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='children',
|
||||
verbose_name='父分类'
|
||||
)
|
||||
sort_order = models.IntegerField('排序', default=0)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'categories'
|
||||
verbose_name = '分类'
|
||||
verbose_name_plural = '分类'
|
||||
ordering = ['sort_order', 'id']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Website(models.Model):
|
||||
"""External shopping/card websites."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
name = models.CharField('网站名称', max_length=200)
|
||||
url = models.CharField('网址', max_length=500)
|
||||
logo = models.TextField('Logo', blank=True, null=True)
|
||||
description = models.TextField('描述', blank=True, null=True)
|
||||
category = models.ForeignKey(
|
||||
Category,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='websites',
|
||||
verbose_name='分类'
|
||||
)
|
||||
rating = models.DecimalField('评分', max_digits=2, decimal_places=1, default=0)
|
||||
is_verified = models.BooleanField('是否认证', default=False)
|
||||
sort_order = models.IntegerField('排序', default=0)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'websites'
|
||||
verbose_name = '网站'
|
||||
verbose_name_plural = '网站'
|
||||
ordering = ['sort_order', 'id']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Product(models.Model):
|
||||
"""Products for price comparison."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
name = models.CharField('商品名称', max_length=300)
|
||||
description = models.TextField('描述', blank=True, null=True)
|
||||
image = models.TextField('图片', blank=True, null=True)
|
||||
category = models.ForeignKey(
|
||||
Category,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='products',
|
||||
verbose_name='分类'
|
||||
)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'products'
|
||||
verbose_name = '商品'
|
||||
verbose_name_plural = '商品'
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class ProductPrice(models.Model):
|
||||
"""Product prices from different websites."""
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
product = models.ForeignKey(
|
||||
Product,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='prices',
|
||||
verbose_name='商品'
|
||||
)
|
||||
website = models.ForeignKey(
|
||||
Website,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='product_prices',
|
||||
verbose_name='网站'
|
||||
)
|
||||
price = models.DecimalField('价格', max_digits=10, decimal_places=2)
|
||||
original_price = models.DecimalField(
|
||||
'原价',
|
||||
max_digits=10,
|
||||
decimal_places=2,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
currency = models.CharField('货币', max_length=10, default='CNY')
|
||||
url = models.CharField('商品链接', max_length=500)
|
||||
in_stock = models.BooleanField('是否有货', default=True)
|
||||
last_checked = models.DateTimeField('最后检查时间', auto_now=True)
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = 'productPrices'
|
||||
verbose_name = '商品价格'
|
||||
verbose_name_plural = '商品价格'
|
||||
unique_together = ['product', 'website']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.product.name} - {self.website.name}: {self.price}"
|
||||
131
backend/apps/products/schemas.py
Normal file
131
backend/apps/products/schemas.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Pydantic schemas for products API.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from ninja import Schema, FilterSchema
|
||||
from ninja.orm import create_schema
|
||||
|
||||
|
||||
class CategoryOut(Schema):
|
||||
"""Category output schema."""
|
||||
id: int
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CategoryIn(Schema):
|
||||
"""Category input schema."""
|
||||
name: str
|
||||
slug: str
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
parent_id: Optional[int] = None
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class WebsiteOut(Schema):
|
||||
"""Website output schema."""
|
||||
id: int
|
||||
name: str
|
||||
url: str
|
||||
logo: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category_id: int
|
||||
rating: Decimal
|
||||
is_verified: bool
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WebsiteIn(Schema):
|
||||
"""Website input schema."""
|
||||
name: str
|
||||
url: str
|
||||
logo: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
category_id: int
|
||||
rating: Decimal = Decimal("0")
|
||||
is_verified: bool = False
|
||||
sort_order: int = 0
|
||||
|
||||
|
||||
class ProductPriceOut(Schema):
|
||||
"""Product price output schema."""
|
||||
id: int
|
||||
product_id: int
|
||||
website_id: int
|
||||
website_name: Optional[str] = None
|
||||
website_logo: Optional[str] = None
|
||||
price: Decimal
|
||||
original_price: Optional[Decimal] = None
|
||||
currency: str
|
||||
url: str
|
||||
in_stock: bool
|
||||
last_checked: datetime
|
||||
|
||||
|
||||
class ProductOut(Schema):
|
||||
"""Product output schema."""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
category_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ProductWithPricesOut(ProductOut):
|
||||
"""Product with prices output schema."""
|
||||
prices: List[ProductPriceOut] = []
|
||||
lowest_price: Optional[Decimal] = None
|
||||
highest_price: Optional[Decimal] = None
|
||||
|
||||
|
||||
class ProductIn(Schema):
|
||||
"""Product input schema."""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
image: Optional[str] = None
|
||||
category_id: int
|
||||
|
||||
|
||||
class ProductPriceIn(Schema):
|
||||
"""Product price input schema."""
|
||||
product_id: int
|
||||
website_id: int
|
||||
price: Decimal
|
||||
original_price: Optional[Decimal] = None
|
||||
currency: str = "CNY"
|
||||
url: str
|
||||
in_stock: bool = True
|
||||
|
||||
|
||||
class ImportResultOut(Schema):
|
||||
"""CSV import result."""
|
||||
created_categories: int = 0
|
||||
created_websites: int = 0
|
||||
created_products: int = 0
|
||||
created_prices: int = 0
|
||||
updated_prices: int = 0
|
||||
errors: List[str] = []
|
||||
|
||||
|
||||
class ProductFilter(FilterSchema):
|
||||
"""Product filter schema."""
|
||||
category_id: Optional[int] = None
|
||||
search: Optional[str] = None
|
||||
|
||||
|
||||
class WebsiteFilter(FilterSchema):
|
||||
"""Website filter schema."""
|
||||
category_id: Optional[int] = None
|
||||
is_verified: Optional[bool] = None
|
||||
1
backend/apps/users/__init__.py
Normal file
1
backend/apps/users/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
default_app_config = 'apps.users.apps.UsersConfig'
|
||||
26
backend/apps/users/admin.py
Normal file
26
backend/apps/users/admin.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from .models import User
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
list_display = ['id', 'open_id', 'name', 'email', 'role', 'is_active', 'created_at']
|
||||
list_filter = ['role', 'is_active', 'is_staff']
|
||||
search_fields = ['open_id', 'name', 'email']
|
||||
ordering = ['-created_at']
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('open_id', 'password')}),
|
||||
('个人信息', {'fields': ('name', 'email', 'avatar', 'login_method')}),
|
||||
('权限', {'fields': ('role', 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
||||
('Stripe', {'fields': ('stripe_customer_id', 'stripe_account_id')}),
|
||||
('时间', {'fields': ('last_signed_in',)}),
|
||||
)
|
||||
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('open_id', 'name', 'email', 'role'),
|
||||
}),
|
||||
)
|
||||
198
backend/apps/users/api.py
Normal file
198
backend/apps/users/api.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""
|
||||
User authentication API routes.
|
||||
"""
|
||||
from typing import Optional
|
||||
from ninja import Router
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
from ninja_jwt.tokens import RefreshToken
|
||||
from django.conf import settings
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
import requests
|
||||
|
||||
from .models import User
|
||||
from .schemas import UserOut, UserUpdate, TokenOut, OAuthCallbackIn, MessageOut, RegisterIn, LoginIn
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def get_current_user(request: HttpRequest) -> Optional[User]:
|
||||
"""Get current authenticated user from request."""
|
||||
if hasattr(request, 'auth') and request.auth:
|
||||
return request.auth
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/me", response=UserOut, auth=JWTAuth())
|
||||
def get_me(request):
|
||||
"""Get current user information."""
|
||||
return request.auth
|
||||
|
||||
|
||||
@router.patch("/me", response=UserOut, auth=JWTAuth())
|
||||
def update_me(request, data: UserUpdate):
|
||||
"""Update current user information."""
|
||||
user = request.auth
|
||||
|
||||
if data.name is not None:
|
||||
user.name = data.name
|
||||
if data.email is not None:
|
||||
user.email = data.email
|
||||
if data.avatar is not None:
|
||||
user.avatar = data.avatar
|
||||
|
||||
user.save()
|
||||
return user
|
||||
|
||||
|
||||
@router.post("/logout", response=MessageOut, auth=JWTAuth())
|
||||
def logout(request):
|
||||
"""Logout current user (client should discard token)."""
|
||||
# JWT is stateless, so we just return success
|
||||
# Client should remove the token from storage
|
||||
response = HttpResponse()
|
||||
response.delete_cookie('access_token')
|
||||
response.delete_cookie('refresh_token')
|
||||
return MessageOut(message="已退出登录", success=True)
|
||||
|
||||
|
||||
@router.post("/refresh", response=TokenOut)
|
||||
def refresh_token(request, refresh_token: str):
|
||||
"""Refresh access token using refresh token."""
|
||||
try:
|
||||
refresh = RefreshToken(refresh_token)
|
||||
return TokenOut(
|
||||
access_token=str(refresh.access_token),
|
||||
refresh_token=str(refresh),
|
||||
)
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 401
|
||||
|
||||
|
||||
@router.post("/register", response=TokenOut)
|
||||
def register(request, data: RegisterIn):
|
||||
"""Register new user with password."""
|
||||
if User.objects.filter(open_id=data.open_id).exists():
|
||||
return {"error": "账号已存在"}, 400
|
||||
user = User.objects.create_user(
|
||||
open_id=data.open_id,
|
||||
password=data.password,
|
||||
name=data.name,
|
||||
email=data.email,
|
||||
login_method="password",
|
||||
)
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return TokenOut(
|
||||
access_token=str(refresh.access_token),
|
||||
refresh_token=str(refresh),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response=TokenOut)
|
||||
def login(request, data: LoginIn):
|
||||
"""Login with open_id and password."""
|
||||
try:
|
||||
user = User.objects.get(open_id=data.open_id)
|
||||
except User.DoesNotExist:
|
||||
return {"error": "账号或密码错误"}, 401
|
||||
if not user.check_password(data.password):
|
||||
return {"error": "账号或密码错误"}, 401
|
||||
refresh = RefreshToken.for_user(user)
|
||||
return TokenOut(
|
||||
access_token=str(refresh.access_token),
|
||||
refresh_token=str(refresh),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/oauth/url")
|
||||
def get_oauth_url(request, redirect_uri: Optional[str] = None):
|
||||
"""Get OAuth authorization URL."""
|
||||
# This would integrate with Manus SDK or other OAuth provider
|
||||
client_id = settings.OAUTH_CLIENT_ID
|
||||
redirect = redirect_uri or settings.OAUTH_REDIRECT_URI
|
||||
|
||||
# Example OAuth URL (adjust based on actual OAuth provider)
|
||||
oauth_url = f"https://oauth.example.com/authorize?client_id={client_id}&redirect_uri={redirect}&response_type=code"
|
||||
|
||||
return {"url": oauth_url}
|
||||
|
||||
|
||||
@router.post("/oauth/callback", response=TokenOut)
|
||||
def oauth_callback(request, data: OAuthCallbackIn):
|
||||
"""Handle OAuth callback and create/update user."""
|
||||
# This would exchange the code for tokens with the OAuth provider
|
||||
# and create or update the user in the database
|
||||
|
||||
# Example implementation (adjust based on actual OAuth provider)
|
||||
try:
|
||||
# Exchange code for access token
|
||||
token_response = requests.post(
|
||||
"https://oauth.example.com/token",
|
||||
data={
|
||||
"client_id": settings.OAUTH_CLIENT_ID,
|
||||
"client_secret": settings.OAUTH_CLIENT_SECRET,
|
||||
"code": data.code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": settings.OAUTH_REDIRECT_URI,
|
||||
}
|
||||
)
|
||||
|
||||
if token_response.status_code != 200:
|
||||
return {"error": "OAuth token exchange failed"}, 400
|
||||
|
||||
oauth_data = token_response.json()
|
||||
|
||||
# Get user info from OAuth provider
|
||||
user_response = requests.get(
|
||||
"https://oauth.example.com/userinfo",
|
||||
headers={"Authorization": f"Bearer {oauth_data['access_token']}"}
|
||||
)
|
||||
|
||||
if user_response.status_code != 200:
|
||||
return {"error": "Failed to get user info"}, 400
|
||||
|
||||
user_info = user_response.json()
|
||||
|
||||
# Create or update user
|
||||
user, created = User.objects.update_or_create(
|
||||
open_id=user_info.get("sub") or user_info.get("id"),
|
||||
defaults={
|
||||
"name": user_info.get("name"),
|
||||
"email": user_info.get("email"),
|
||||
"avatar": user_info.get("picture") or user_info.get("avatar"),
|
||||
"login_method": "oauth",
|
||||
}
|
||||
)
|
||||
|
||||
# Generate JWT tokens
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return TokenOut(
|
||||
access_token=str(refresh.access_token),
|
||||
refresh_token=str(refresh),
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return {"error": str(e)}, 500
|
||||
|
||||
|
||||
# Development endpoint for testing without OAuth
|
||||
@router.post("/dev/login", response=TokenOut)
|
||||
def dev_login(request, open_id: str, name: Optional[str] = None):
|
||||
"""Development login endpoint (disable in production)."""
|
||||
if not settings.DEBUG:
|
||||
return {"error": "Not available in production"}, 403
|
||||
|
||||
user, created = User.objects.get_or_create(
|
||||
open_id=open_id,
|
||||
defaults={
|
||||
"name": name or f"Dev User {open_id}",
|
||||
"login_method": "dev",
|
||||
}
|
||||
)
|
||||
|
||||
refresh = RefreshToken.for_user(user)
|
||||
|
||||
return TokenOut(
|
||||
access_token=str(refresh.access_token),
|
||||
refresh_token=str(refresh),
|
||||
)
|
||||
7
backend/apps/users/apps.py
Normal file
7
backend/apps/users/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UsersConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.users'
|
||||
verbose_name = '用户管理'
|
||||
183
backend/apps/users/friends.py
Normal file
183
backend/apps/users/friends.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Friendship and friend request API routes.
|
||||
"""
|
||||
from typing import List, Optional
|
||||
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
|
||||
from .models import User, FriendRequest
|
||||
from .schemas import FriendOut, FriendRequestIn, FriendRequestOut, MessageOut, UserBrief
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
def serialize_user_brief(user: User) -> UserBrief:
|
||||
return UserBrief(
|
||||
id=user.id,
|
||||
open_id=user.open_id,
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
avatar=user.avatar,
|
||||
)
|
||||
|
||||
|
||||
def serialize_request(request_obj: FriendRequest) -> FriendRequestOut:
|
||||
return FriendRequestOut(
|
||||
id=request_obj.id,
|
||||
requester=serialize_user_brief(request_obj.requester),
|
||||
receiver=serialize_user_brief(request_obj.receiver),
|
||||
status=request_obj.status,
|
||||
accepted_at=request_obj.accepted_at,
|
||||
created_at=request_obj.created_at,
|
||||
updated_at=request_obj.updated_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response=List[FriendOut], auth=JWTAuth())
|
||||
def list_friends(request):
|
||||
"""List accepted friends for current user."""
|
||||
relations = FriendRequest.objects.select_related("requester", "receiver").filter(
|
||||
status=FriendRequest.Status.ACCEPTED,
|
||||
).filter(Q(requester=request.auth) | Q(receiver=request.auth))
|
||||
|
||||
friends = []
|
||||
for relation in relations:
|
||||
friend_user = relation.receiver if relation.requester_id == request.auth.id else relation.requester
|
||||
friends.append(
|
||||
FriendOut(
|
||||
request_id=relation.id,
|
||||
user=serialize_user_brief(friend_user),
|
||||
since=relation.accepted_at,
|
||||
)
|
||||
)
|
||||
return friends
|
||||
|
||||
|
||||
@router.get("/requests/incoming", response=List[FriendRequestOut], auth=JWTAuth())
|
||||
def list_incoming_requests(request):
|
||||
"""List incoming friend requests."""
|
||||
requests = (
|
||||
FriendRequest.objects.select_related("requester", "receiver")
|
||||
.filter(receiver=request.auth, status=FriendRequest.Status.PENDING)
|
||||
.order_by("-created_at")
|
||||
)
|
||||
return [serialize_request(r) for r in requests]
|
||||
|
||||
|
||||
@router.get("/requests/outgoing", response=List[FriendRequestOut], auth=JWTAuth())
|
||||
def list_outgoing_requests(request):
|
||||
"""List outgoing friend requests."""
|
||||
requests = (
|
||||
FriendRequest.objects.select_related("requester", "receiver")
|
||||
.filter(requester=request.auth, status=FriendRequest.Status.PENDING)
|
||||
.order_by("-created_at")
|
||||
)
|
||||
return [serialize_request(r) for r in requests]
|
||||
|
||||
|
||||
@router.post("/requests", response=FriendRequestOut, auth=JWTAuth())
|
||||
@transaction.atomic
|
||||
def create_request(request, data: FriendRequestIn):
|
||||
"""Send a friend request."""
|
||||
if data.receiver_id == request.auth.id:
|
||||
raise HttpError(400, "不能添加自己为好友")
|
||||
|
||||
try:
|
||||
receiver = User.objects.get(id=data.receiver_id)
|
||||
except User.DoesNotExist:
|
||||
raise HttpError(404, "用户不存在")
|
||||
|
||||
existing = FriendRequest.objects.select_related("requester", "receiver").filter(
|
||||
Q(requester=request.auth, receiver=receiver) | Q(requester=receiver, receiver=request.auth)
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
if existing.status == FriendRequest.Status.ACCEPTED:
|
||||
raise HttpError(400, "你们已经是好友")
|
||||
if existing.status == FriendRequest.Status.PENDING:
|
||||
if existing.receiver_id == request.auth.id:
|
||||
raise HttpError(400, "对方已向你发送好友请求")
|
||||
return serialize_request(existing)
|
||||
|
||||
if existing.requester_id != request.auth.id:
|
||||
existing.requester = request.auth
|
||||
existing.receiver = receiver
|
||||
existing.status = FriendRequest.Status.PENDING
|
||||
existing.accepted_at = None
|
||||
existing.save(update_fields=["requester", "receiver", "status", "accepted_at", "updated_at"])
|
||||
return serialize_request(existing)
|
||||
|
||||
new_request = FriendRequest.objects.create(
|
||||
requester=request.auth,
|
||||
receiver=receiver,
|
||||
status=FriendRequest.Status.PENDING,
|
||||
)
|
||||
return serialize_request(new_request)
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/accept", response=FriendRequestOut, auth=JWTAuth())
|
||||
def accept_request(request, request_id: int):
|
||||
"""Accept incoming friend request."""
|
||||
friend_request = FriendRequest.objects.select_related("requester", "receiver").filter(
|
||||
id=request_id,
|
||||
receiver=request.auth,
|
||||
status=FriendRequest.Status.PENDING,
|
||||
).first()
|
||||
if not friend_request:
|
||||
raise HttpError(404, "未找到待处理好友请求")
|
||||
|
||||
friend_request.status = FriendRequest.Status.ACCEPTED
|
||||
friend_request.accepted_at = timezone.now()
|
||||
friend_request.save(update_fields=["status", "accepted_at", "updated_at"])
|
||||
return serialize_request(friend_request)
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/reject", response=FriendRequestOut, auth=JWTAuth())
|
||||
def reject_request(request, request_id: int):
|
||||
"""Reject incoming friend request."""
|
||||
friend_request = FriendRequest.objects.select_related("requester", "receiver").filter(
|
||||
id=request_id,
|
||||
receiver=request.auth,
|
||||
status=FriendRequest.Status.PENDING,
|
||||
).first()
|
||||
if not friend_request:
|
||||
raise HttpError(404, "未找到待处理好友请求")
|
||||
|
||||
friend_request.status = FriendRequest.Status.REJECTED
|
||||
friend_request.save(update_fields=["status", "updated_at"])
|
||||
return serialize_request(friend_request)
|
||||
|
||||
|
||||
@router.post("/requests/{request_id}/cancel", response=FriendRequestOut, auth=JWTAuth())
|
||||
def cancel_request(request, request_id: int):
|
||||
"""Cancel an outgoing friend request."""
|
||||
friend_request = FriendRequest.objects.select_related("requester", "receiver").filter(
|
||||
id=request_id,
|
||||
requester=request.auth,
|
||||
status=FriendRequest.Status.PENDING,
|
||||
).first()
|
||||
if not friend_request:
|
||||
raise HttpError(404, "未找到待处理好友请求")
|
||||
|
||||
friend_request.status = FriendRequest.Status.CANCELED
|
||||
friend_request.save(update_fields=["status", "updated_at"])
|
||||
return serialize_request(friend_request)
|
||||
|
||||
|
||||
@router.get("/search", response=List[UserBrief], auth=JWTAuth())
|
||||
def search_users(request, q: Optional[str] = None, limit: int = 10):
|
||||
"""Search users by open_id or name."""
|
||||
if not q:
|
||||
return []
|
||||
|
||||
queryset = (
|
||||
User.objects.filter(Q(open_id__icontains=q) | Q(name__icontains=q))
|
||||
.exclude(id=request.auth.id)
|
||||
.order_by("id")[: max(1, min(limit, 50))]
|
||||
)
|
||||
return [serialize_user_brief(user) for user in queryset]
|
||||
44
backend/apps/users/migrations/0001_initial.py
Normal file
44
backend/apps/users/migrations/0001_initial.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 4.2.27 on 2026-01-27 07:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('id', models.AutoField(primary_key=True, serialize=False)),
|
||||
('open_id', models.CharField(max_length=64, unique=True, verbose_name='OpenID')),
|
||||
('name', models.CharField(blank=True, max_length=255, null=True, verbose_name='用户名')),
|
||||
('email', models.EmailField(blank=True, max_length=320, null=True, verbose_name='邮箱')),
|
||||
('avatar', models.TextField(blank=True, null=True, verbose_name='头像')),
|
||||
('login_method', models.CharField(blank=True, max_length=64, null=True, verbose_name='登录方式')),
|
||||
('role', models.CharField(choices=[('user', '普通用户'), ('admin', '管理员')], default='user', max_length=10, verbose_name='角色')),
|
||||
('stripe_customer_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Stripe客户ID')),
|
||||
('stripe_account_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='Stripe账户ID')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否激活')),
|
||||
('is_staff', models.BooleanField(default=False, verbose_name='是否员工')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('last_signed_in', models.DateTimeField(auto_now=True, verbose_name='最后登录时间')),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '用户',
|
||||
'verbose_name_plural': '用户',
|
||||
'db_table': 'users',
|
||||
},
|
||||
),
|
||||
]
|
||||
33
backend/apps/users/migrations/0002_friend_request.py
Normal file
33
backend/apps/users/migrations/0002_friend_request.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("users", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FriendRequest",
|
||||
fields=[
|
||||
("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
|
||||
("status", models.CharField(choices=[("pending", "待处理"), ("accepted", "已通过"), ("rejected", "已拒绝"), ("canceled", "已取消")], default="pending", max_length=10, verbose_name="状态")),
|
||||
("accepted_at", models.DateTimeField(blank=True, null=True, verbose_name="通过时间")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
|
||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")),
|
||||
("receiver", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="received_friend_requests", to="users.user")),
|
||||
("requester", models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name="sent_friend_requests", to="users.user")),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "好友请求",
|
||||
"verbose_name_plural": "好友请求",
|
||||
"db_table": "friend_requests",
|
||||
"unique_together": {("requester", "receiver")},
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="friendrequest",
|
||||
constraint=models.CheckConstraint(check=models.Q(("requester", models.F("receiver")), _negated=True), name="no_self_friend_request"),
|
||||
),
|
||||
]
|
||||
0
backend/apps/users/migrations/__init__.py
Normal file
0
backend/apps/users/migrations/__init__.py
Normal file
121
backend/apps/users/models.py
Normal file
121
backend/apps/users/models.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
User models for authentication and profile management.
|
||||
"""
|
||||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, PermissionsMixin
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
"""Custom user manager."""
|
||||
|
||||
def create_user(self, open_id, password=None, **extra_fields):
|
||||
"""Create and save a regular user."""
|
||||
if not open_id:
|
||||
raise ValueError('The open_id must be set')
|
||||
user = self.model(open_id=open_id, **extra_fields)
|
||||
if password:
|
||||
user.set_password(password)
|
||||
else:
|
||||
user.set_unusable_password()
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, open_id, password=None, **extra_fields):
|
||||
"""Create and save a superuser."""
|
||||
extra_fields.setdefault('is_staff', True)
|
||||
extra_fields.setdefault('is_superuser', True)
|
||||
extra_fields.setdefault('role', 'admin')
|
||||
return self.create_user(open_id, password=password, **extra_fields)
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
"""Custom user model matching the original schema."""
|
||||
|
||||
class Role(models.TextChoices):
|
||||
USER = 'user', '普通用户'
|
||||
ADMIN = 'admin', '管理员'
|
||||
|
||||
id = models.AutoField(primary_key=True)
|
||||
open_id = models.CharField('OpenID', max_length=64, unique=True)
|
||||
name = models.CharField('用户名', max_length=255, blank=True, null=True)
|
||||
email = models.EmailField('邮箱', max_length=320, blank=True, null=True)
|
||||
avatar = models.TextField('头像', blank=True, null=True)
|
||||
login_method = models.CharField('登录方式', max_length=64, blank=True, null=True)
|
||||
role = models.CharField(
|
||||
'角色',
|
||||
max_length=10,
|
||||
choices=Role.choices,
|
||||
default=Role.USER
|
||||
)
|
||||
stripe_customer_id = models.CharField('Stripe客户ID', max_length=255, blank=True, null=True)
|
||||
stripe_account_id = models.CharField('Stripe账户ID', max_length=255, blank=True, null=True)
|
||||
|
||||
# Django auth fields
|
||||
is_active = models.BooleanField('是否激活', default=True)
|
||||
is_staff = models.BooleanField('是否员工', default=False)
|
||||
|
||||
created_at = models.DateTimeField('创建时间', auto_now_add=True)
|
||||
updated_at = models.DateTimeField('更新时间', auto_now=True)
|
||||
last_signed_in = models.DateTimeField('最后登录时间', auto_now=True)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = 'open_id'
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
class Meta:
|
||||
db_table = 'users'
|
||||
verbose_name = '用户'
|
||||
verbose_name_plural = '用户'
|
||||
|
||||
def __str__(self):
|
||||
return self.name or self.open_id
|
||||
|
||||
@property
|
||||
def is_admin(self):
|
||||
return self.role == self.Role.ADMIN
|
||||
|
||||
|
||||
class FriendRequest(models.Model):
|
||||
"""Friend request and relationship status."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
PENDING = "pending", "待处理"
|
||||
ACCEPTED = "accepted", "已通过"
|
||||
REJECTED = "rejected", "已拒绝"
|
||||
CANCELED = "canceled", "已取消"
|
||||
|
||||
requester = models.ForeignKey(
|
||||
User,
|
||||
related_name="sent_friend_requests",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
receiver = models.ForeignKey(
|
||||
User,
|
||||
related_name="received_friend_requests",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
status = models.CharField(
|
||||
"状态",
|
||||
max_length=10,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING,
|
||||
)
|
||||
accepted_at = models.DateTimeField("通过时间", blank=True, null=True)
|
||||
created_at = models.DateTimeField("创建时间", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("更新时间", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "friend_requests"
|
||||
verbose_name = "好友请求"
|
||||
verbose_name_plural = "好友请求"
|
||||
unique_together = ("requester", "receiver")
|
||||
constraints = [
|
||||
models.CheckConstraint(
|
||||
check=~models.Q(requester=models.F("receiver")),
|
||||
name="no_self_friend_request",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.requester_id}->{self.receiver_id} ({self.status})"
|
||||
89
backend/apps/users/schemas.py
Normal file
89
backend/apps/users/schemas.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""
|
||||
Pydantic schemas for user API.
|
||||
"""
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class UserOut(Schema):
|
||||
"""User output schema."""
|
||||
id: int
|
||||
open_id: str
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
role: str
|
||||
stripe_customer_id: Optional[str] = None
|
||||
stripe_account_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class UserBrief(Schema):
|
||||
"""Minimal user info for social features."""
|
||||
id: int
|
||||
open_id: str
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
|
||||
|
||||
class UserUpdate(Schema):
|
||||
"""User update schema."""
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
|
||||
|
||||
class TokenOut(Schema):
|
||||
"""JWT token output schema."""
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "Bearer"
|
||||
|
||||
|
||||
class OAuthCallbackIn(Schema):
|
||||
"""OAuth callback input schema."""
|
||||
code: str
|
||||
state: Optional[str] = None
|
||||
|
||||
|
||||
class RegisterIn(Schema):
|
||||
"""Register input schema."""
|
||||
open_id: str
|
||||
password: str
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
|
||||
|
||||
class LoginIn(Schema):
|
||||
"""Login input schema."""
|
||||
open_id: str
|
||||
password: str
|
||||
|
||||
|
||||
class MessageOut(Schema):
|
||||
"""Simple message response."""
|
||||
message: str
|
||||
success: bool = True
|
||||
|
||||
|
||||
class FriendRequestIn(Schema):
|
||||
receiver_id: int
|
||||
|
||||
|
||||
class FriendRequestOut(Schema):
|
||||
id: int
|
||||
requester: UserBrief
|
||||
receiver: UserBrief
|
||||
status: str
|
||||
accepted_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FriendOut(Schema):
|
||||
request_id: int
|
||||
user: UserBrief
|
||||
since: Optional[datetime] = None
|
||||
1
backend/config/__init__.py
Normal file
1
backend/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Django config package
|
||||
36
backend/config/api.py
Normal file
36
backend/config/api.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Django Ninja API configuration.
|
||||
"""
|
||||
from ninja import NinjaAPI
|
||||
from ninja_jwt.authentication import JWTAuth
|
||||
|
||||
# Import routers from apps
|
||||
from apps.users.api import router as auth_router
|
||||
from apps.users.friends import router as friends_router
|
||||
from apps.products.api import router as products_router, category_router, website_router
|
||||
from apps.bounties.api import router as bounties_router
|
||||
from apps.bounties.payments import router as payments_router
|
||||
from apps.favorites.api import router as favorites_router
|
||||
from apps.notifications.api import router as notifications_router
|
||||
from apps.admin.api import router as admin_router
|
||||
from config.search import router as search_router
|
||||
|
||||
# Create main API instance
|
||||
api = NinjaAPI(
|
||||
title="AI Web API",
|
||||
version="1.0.0",
|
||||
description="Backend API for AI Web application",
|
||||
)
|
||||
|
||||
# Register routers
|
||||
api.add_router("/auth/", auth_router, tags=["认证"])
|
||||
api.add_router("/friends/", friends_router, tags=["好友"])
|
||||
api.add_router("/categories/", category_router, tags=["分类"])
|
||||
api.add_router("/websites/", website_router, tags=["网站"])
|
||||
api.add_router("/products/", products_router, tags=["商品"])
|
||||
api.add_router("/bounties/", bounties_router, tags=["悬赏"])
|
||||
api.add_router("/payments/", payments_router, tags=["支付"])
|
||||
api.add_router("/favorites/", favorites_router, tags=["收藏"])
|
||||
api.add_router("/notifications/", notifications_router, tags=["通知"])
|
||||
api.add_router("/search/", search_router, tags=["搜索"])
|
||||
api.add_router("/admin/", admin_router, tags=["后台"])
|
||||
8
backend/config/asgi.py
Normal file
8
backend/config/asgi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
ASGI config for ai_web project.
|
||||
"""
|
||||
import os
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
application = get_asgi_application()
|
||||
96
backend/config/search.py
Normal file
96
backend/config/search.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Global search API routes.
|
||||
"""
|
||||
from typing import List
|
||||
from ninja import Router, Schema
|
||||
from django.db.models import Count, Q
|
||||
|
||||
from apps.products.models import Product, Website
|
||||
from apps.products.schemas import ProductOut, WebsiteOut
|
||||
from apps.bounties.models import Bounty
|
||||
from apps.bounties.schemas import BountyWithDetailsOut
|
||||
from apps.users.schemas import UserOut
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
class SearchResultsOut(Schema):
|
||||
products: List[ProductOut]
|
||||
websites: List[WebsiteOut]
|
||||
bounties: List[BountyWithDetailsOut]
|
||||
|
||||
|
||||
def serialize_user(user):
|
||||
if not user:
|
||||
return None
|
||||
return UserOut(
|
||||
id=user.id,
|
||||
open_id=user.open_id,
|
||||
name=user.name,
|
||||
email=user.email,
|
||||
avatar=user.avatar,
|
||||
role=user.role,
|
||||
stripe_customer_id=user.stripe_customer_id,
|
||||
stripe_account_id=user.stripe_account_id,
|
||||
created_at=user.created_at,
|
||||
updated_at=user.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def serialize_bounty(bounty):
|
||||
return BountyWithDetailsOut(
|
||||
id=bounty.id,
|
||||
title=bounty.title,
|
||||
description=bounty.description,
|
||||
reward=bounty.reward,
|
||||
currency=bounty.currency,
|
||||
publisher_id=bounty.publisher_id,
|
||||
publisher=serialize_user(bounty.publisher),
|
||||
acceptor_id=bounty.acceptor_id,
|
||||
acceptor=serialize_user(bounty.acceptor) if bounty.acceptor else None,
|
||||
status=bounty.status,
|
||||
deadline=bounty.deadline,
|
||||
completed_at=bounty.completed_at,
|
||||
is_paid=bounty.is_paid,
|
||||
is_escrowed=bounty.is_escrowed,
|
||||
created_at=bounty.created_at,
|
||||
updated_at=bounty.updated_at,
|
||||
applications_count=getattr(bounty, "applications_count", 0),
|
||||
comments_count=getattr(bounty, "comments_count", 0),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/", response=SearchResultsOut)
|
||||
def global_search(request, q: str, limit: int = 10):
|
||||
"""Search products, websites and bounties by keyword."""
|
||||
keyword = (q or "").strip()
|
||||
if not keyword:
|
||||
return SearchResultsOut(products=[], websites=[], bounties=[])
|
||||
|
||||
products = list(
|
||||
Product.objects.filter(
|
||||
Q(name__icontains=keyword) | Q(description__icontains=keyword)
|
||||
).order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
websites = list(
|
||||
Website.objects.filter(
|
||||
Q(name__icontains=keyword) | Q(description__icontains=keyword)
|
||||
).order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
bounties = list(
|
||||
Bounty.objects.select_related("publisher", "acceptor")
|
||||
.annotate(
|
||||
applications_count=Count("applications", distinct=True),
|
||||
comments_count=Count("comments", distinct=True),
|
||||
)
|
||||
.filter(Q(title__icontains=keyword) | Q(description__icontains=keyword))
|
||||
.order_by("-created_at")[:limit]
|
||||
)
|
||||
|
||||
return SearchResultsOut(
|
||||
products=products,
|
||||
websites=websites,
|
||||
bounties=[serialize_bounty(b) for b in bounties],
|
||||
)
|
||||
169
backend/config/settings.py
Normal file
169
backend/config/settings.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Django settings for ai_web project.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import timedelta
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.getenv('DJANGO_SECRET_KEY', 'django-insecure-change-this-in-production')
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.getenv('DEBUG', 'True').lower() == 'true'
|
||||
|
||||
ALLOWED_HOSTS = os.getenv('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',')
|
||||
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
# Third party
|
||||
'corsheaders',
|
||||
'ninja_jwt',
|
||||
# Local apps
|
||||
'apps.users',
|
||||
'apps.products',
|
||||
'apps.bounties',
|
||||
'apps.favorites',
|
||||
'apps.notifications',
|
||||
'apps.admin.apps.AdminApiConfig',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# 使用 SQLite 数据库(开发环境)
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
# 如需使用 MySQL,取消注释以下配置并注释上面的 SQLite 配置
|
||||
# DATABASES = {
|
||||
# 'default': {
|
||||
# 'ENGINE': 'django.db.backends.mysql',
|
||||
# 'NAME': os.getenv('DB_NAME', 'ai_web'),
|
||||
# 'USER': os.getenv('DB_USER', 'root'),
|
||||
# 'PASSWORD': os.getenv('DB_PASSWORD', ''),
|
||||
# 'HOST': os.getenv('DB_HOST', 'localhost'),
|
||||
# 'PORT': os.getenv('DB_PORT', '3306'),
|
||||
# 'OPTIONS': {
|
||||
# 'charset': 'utf8mb4',
|
||||
# },
|
||||
# }
|
||||
# }
|
||||
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
LANGUAGE_CODE = 'zh-hans'
|
||||
TIME_ZONE = 'Asia/Shanghai'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
|
||||
# Custom user model
|
||||
AUTH_USER_MODEL = 'users.User'
|
||||
|
||||
|
||||
# CORS settings
|
||||
CORS_ALLOWED_ORIGINS = os.getenv(
|
||||
'CORS_ALLOWED_ORIGINS',
|
||||
'http://localhost:5173,http://127.0.0.1:5173'
|
||||
).split(',')
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
|
||||
# JWT settings
|
||||
NINJA_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(hours=24),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
|
||||
'ALGORITHM': 'HS256',
|
||||
'SIGNING_KEY': SECRET_KEY,
|
||||
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||
'AUTH_COOKIE': 'access_token',
|
||||
'AUTH_COOKIE_HTTP_ONLY': True,
|
||||
'AUTH_COOKIE_SECURE': not DEBUG,
|
||||
'AUTH_COOKIE_SAMESITE': 'Lax',
|
||||
}
|
||||
|
||||
|
||||
# Stripe settings
|
||||
STRIPE_SECRET_KEY = os.getenv('STRIPE_SECRET_KEY', '')
|
||||
STRIPE_WEBHOOK_SECRET = os.getenv('STRIPE_WEBHOOK_SECRET', '')
|
||||
STRIPE_PUBLISHABLE_KEY = os.getenv('STRIPE_PUBLISHABLE_KEY', '')
|
||||
|
||||
|
||||
# OAuth settings (Manus SDK compatible)
|
||||
OAUTH_CLIENT_ID = os.getenv('OAUTH_CLIENT_ID', '')
|
||||
OAUTH_CLIENT_SECRET = os.getenv('OAUTH_CLIENT_SECRET', '')
|
||||
OAUTH_REDIRECT_URI = os.getenv('OAUTH_REDIRECT_URI', 'http://localhost:8000/api/auth/callback')
|
||||
14
backend/config/urls.py
Normal file
14
backend/config/urls.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
URL configuration for ai_web project.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from .api import api
|
||||
from apps.bounties.payments import handle_webhook
|
||||
|
||||
urlpatterns = [
|
||||
path('admin/', admin.site.urls),
|
||||
path('api/', api.urls),
|
||||
path('webhooks/stripe/', csrf_exempt(handle_webhook), name='stripe-webhook'),
|
||||
]
|
||||
8
backend/config/wsgi.py
Normal file
8
backend/config/wsgi.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
WSGI config for ai_web project.
|
||||
"""
|
||||
import os
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
application = get_wsgi_application()
|
||||
22
backend/manage.py
Normal file
22
backend/manage.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
22
backend/requirements.txt
Normal file
22
backend/requirements.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
# Django core
|
||||
django>=4.2,<5.0
|
||||
django-ninja>=1.0
|
||||
django-ninja-jwt>=5.2.0
|
||||
django-cors-headers>=4.3.0
|
||||
|
||||
# Database (SQLite 内置,无需额外安装)
|
||||
# mysqlclient>=2.2.0 # 如需使用 MySQL,取消此行注释
|
||||
|
||||
# Payment
|
||||
stripe>=7.0.0
|
||||
|
||||
# Utils
|
||||
python-dotenv>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
|
||||
# HTTP client (for OAuth)
|
||||
requests>=2.31.0
|
||||
|
||||
# Development
|
||||
pytest>=7.4.0
|
||||
pytest-django>=4.5.0
|
||||
@@ -1,17 +0,0 @@
|
||||
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
|
||||
|
||||
// Generate login URL at runtime so redirect URI reflects the current origin.
|
||||
export const getLoginUrl = () => {
|
||||
const oauthPortalUrl = import.meta.env.VITE_OAUTH_PORTAL_URL;
|
||||
const appId = import.meta.env.VITE_APP_ID;
|
||||
const redirectUri = `${window.location.origin}/api/oauth/callback`;
|
||||
const state = btoa(redirectUri);
|
||||
|
||||
const url = new URL(`${oauthPortalUrl}/app-auth`);
|
||||
url.searchParams.set("appId", appId);
|
||||
url.searchParams.set("redirectUri", redirectUri);
|
||||
url.searchParams.set("state", state);
|
||||
url.searchParams.set("type", "signIn");
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
import type { AppRouter } from "../../../server/routers";
|
||||
|
||||
export const trpc = createTRPCReact<AppRouter>();
|
||||
@@ -1,61 +0,0 @@
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { UNAUTHED_ERR_MSG } from '@shared/const';
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { httpBatchLink, TRPCClientError } from "@trpc/client";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import superjson from "superjson";
|
||||
import App from "./App";
|
||||
import { getLoginUrl } from "./const";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const redirectToLoginIfUnauthorized = (error: unknown) => {
|
||||
if (!(error instanceof TRPCClientError)) return;
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const isUnauthorized = error.message === UNAUTHED_ERR_MSG;
|
||||
|
||||
if (!isUnauthorized) return;
|
||||
|
||||
window.location.href = getLoginUrl();
|
||||
};
|
||||
|
||||
queryClient.getQueryCache().subscribe(event => {
|
||||
if (event.type === "updated" && event.action.type === "error") {
|
||||
const error = event.query.state.error;
|
||||
redirectToLoginIfUnauthorized(error);
|
||||
console.error("[API Query Error]", error);
|
||||
}
|
||||
});
|
||||
|
||||
queryClient.getMutationCache().subscribe(event => {
|
||||
if (event.type === "updated" && event.action.type === "error") {
|
||||
const error = event.mutation.state.error;
|
||||
redirectToLoginIfUnauthorized(error);
|
||||
console.error("[API Mutation Error]", error);
|
||||
}
|
||||
});
|
||||
|
||||
const trpcClient = trpc.createClient({
|
||||
links: [
|
||||
httpBatchLink({
|
||||
url: "/api/trpc",
|
||||
transformer: superjson,
|
||||
fetch(input, init) {
|
||||
return globalThis.fetch(input, {
|
||||
...(init ?? {}),
|
||||
credentials: "include",
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<trpc.Provider client={trpcClient} queryClient={queryClient}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</trpc.Provider>
|
||||
);
|
||||
@@ -1,611 +0,0 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Link, useParams, useLocation } from "wouter";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Trophy,
|
||||
Sparkles,
|
||||
Clock,
|
||||
User,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
CreditCard,
|
||||
Wallet,
|
||||
ShieldCheck
|
||||
} from "lucide-react";
|
||||
import { getLoginUrl } from "@/const";
|
||||
import { formatDistanceToNow, format } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
|
||||
const statusMap: Record<string, { label: string; class: string }> = {
|
||||
open: { label: "开放中", class: "badge-open" },
|
||||
in_progress: { label: "进行中", class: "badge-in-progress" },
|
||||
completed: { label: "已完成", class: "badge-completed" },
|
||||
cancelled: { label: "已取消", class: "badge-cancelled" },
|
||||
disputed: { label: "争议中", class: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400" },
|
||||
};
|
||||
|
||||
export default function BountyDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const [, navigate] = useLocation();
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const [applyMessage, setApplyMessage] = useState("");
|
||||
const [newComment, setNewComment] = useState("");
|
||||
const [isApplyOpen, setIsApplyOpen] = useState(false);
|
||||
|
||||
const bountyId = parseInt(id || "0");
|
||||
|
||||
const { data: bountyData, isLoading, refetch } = trpc.bounty.get.useQuery(
|
||||
{ id: bountyId },
|
||||
{ enabled: bountyId > 0 }
|
||||
);
|
||||
|
||||
const { data: applications } = trpc.bountyApplication.list.useQuery(
|
||||
{ bountyId },
|
||||
{ enabled: bountyId > 0 }
|
||||
);
|
||||
|
||||
const { data: comments, refetch: refetchComments } = trpc.comment.list.useQuery(
|
||||
{ bountyId },
|
||||
{ enabled: bountyId > 0 }
|
||||
);
|
||||
|
||||
const { data: myApplication } = trpc.bountyApplication.myApplication.useQuery(
|
||||
{ bountyId },
|
||||
{ enabled: bountyId > 0 && isAuthenticated }
|
||||
);
|
||||
|
||||
const applyMutation = trpc.bountyApplication.submit.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("申请已提交!");
|
||||
setIsApplyOpen(false);
|
||||
setApplyMessage("");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "申请失败,请重试");
|
||||
},
|
||||
});
|
||||
|
||||
const acceptMutation = trpc.bountyApplication.accept.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("已接受申请!");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "操作失败,请重试");
|
||||
},
|
||||
});
|
||||
|
||||
const completeMutation = trpc.bounty.complete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("悬赏已完成!");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "操作失败,请重试");
|
||||
},
|
||||
});
|
||||
|
||||
const cancelMutation = trpc.bounty.cancel.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("悬赏已取消");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "操作失败,请重试");
|
||||
},
|
||||
});
|
||||
|
||||
const escrowMutation = trpc.payment.createEscrow.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.checkoutUrl) {
|
||||
toast.info("正在跳转到支付页面...");
|
||||
window.open(data.checkoutUrl, "_blank");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "创建支付失败");
|
||||
},
|
||||
});
|
||||
|
||||
const releaseMutation = trpc.payment.releasePayout.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("赏金已释放给接单者!");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "释放赏金失败");
|
||||
},
|
||||
});
|
||||
|
||||
const commentMutation = trpc.comment.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("评论已发布!");
|
||||
setNewComment("");
|
||||
refetchComments();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || "评论失败,请重试");
|
||||
},
|
||||
});
|
||||
|
||||
const handleApply = () => {
|
||||
applyMutation.mutate({
|
||||
bountyId,
|
||||
message: applyMessage || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAccept = (applicationId: number) => {
|
||||
acceptMutation.mutate({ applicationId, bountyId });
|
||||
};
|
||||
|
||||
const handleComplete = () => {
|
||||
completeMutation.mutate({ id: bountyId });
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
cancelMutation.mutate({ id: bountyId });
|
||||
};
|
||||
|
||||
const handleComment = () => {
|
||||
if (!newComment.trim()) {
|
||||
toast.error("请输入评论内容");
|
||||
return;
|
||||
}
|
||||
commentMutation.mutate({
|
||||
bountyId,
|
||||
content: newComment,
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!bountyData) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex items-center justify-center">
|
||||
<Card className="card-elegant max-w-md">
|
||||
<CardContent className="py-12 text-center">
|
||||
<AlertCircle className="w-16 h-16 mx-auto text-muted-foreground mb-4" />
|
||||
<h3 className="text-xl font-semibold mb-2">悬赏不存在</h3>
|
||||
<p className="text-muted-foreground mb-6">该悬赏可能已被删除或不存在</p>
|
||||
<Link href="/bounties">
|
||||
<Button>返回悬赏大厅</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { bounty, publisher } = bountyData;
|
||||
const isPublisher = user?.id === bounty.publisherId;
|
||||
const isAcceptor = user?.id === bounty.acceptorId;
|
||||
const canApply = isAuthenticated && !isPublisher && bounty.status === "open" && !myApplication;
|
||||
const canComplete = isPublisher && bounty.status === "in_progress";
|
||||
const canCancel = isPublisher && bounty.status === "open";
|
||||
const canEscrow = isPublisher && bounty.status === "open" && !bounty.isEscrowed;
|
||||
const canRelease = isPublisher && bounty.status === "completed" && bounty.isEscrowed && !bounty.isPaid;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 glass border-b border-border/50">
|
||||
<div className="container flex items-center justify-between h-16">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="font-semibold text-lg">资源聚合</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<Link href="/products" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
商品导航
|
||||
</Link>
|
||||
<Link href="/bounties" className="text-foreground font-medium">
|
||||
悬赏大厅
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link href="/dashboard" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
个人中心
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isAuthenticated ? (
|
||||
<Link href="/dashboard">
|
||||
<Button variant="default" size="sm" className="gap-2">
|
||||
<span>{user?.name || '用户'}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<a href={getLoginUrl()}>
|
||||
<Button variant="default" size="sm">
|
||||
登录
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Content */}
|
||||
<section className="pt-24 pb-20">
|
||||
<div className="container max-w-4xl">
|
||||
{/* Back button */}
|
||||
<Link href="/bounties">
|
||||
<Button variant="ghost" className="mb-6 gap-2">
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
返回悬赏大厅
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
{/* Main Content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Bounty Info */}
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-4 mb-4">
|
||||
<Badge className={`${statusMap[bounty.status]?.class || "bg-muted"} text-sm px-3 py-1`}>
|
||||
{statusMap[bounty.status]?.label || bounty.status}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1 text-2xl font-bold text-primary">
|
||||
<DollarSign className="w-6 h-6" />
|
||||
<span>¥{bounty.reward}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
{bounty.title}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="prose prose-sm max-w-none text-muted-foreground">
|
||||
<p className="whitespace-pre-wrap">{bounty.description}</p>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>发布者: {publisher?.name || "匿名用户"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>
|
||||
{formatDistanceToNow(new Date(bounty.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: zhCN
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{bounty.deadline && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4" />
|
||||
<span>截止: {format(new Date(bounty.deadline), "yyyy-MM-dd")}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Comments */}
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
评论 ({comments?.length || 0})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isAuthenticated ? (
|
||||
<div className="flex gap-3 mb-6">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={user?.avatar || undefined} />
|
||||
<AvatarFallback>{user?.name?.[0] || "U"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<Textarea
|
||||
placeholder="发表评论..."
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleComment}
|
||||
disabled={commentMutation.isPending}
|
||||
>
|
||||
{commentMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
发送
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 mb-6 bg-muted/50 rounded-lg">
|
||||
<p className="text-muted-foreground mb-2">登录后可以发表评论</p>
|
||||
<a href={getLoginUrl()}>
|
||||
<Button size="sm">登录</Button>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{comments && comments.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{comments.map(({ comment, user: commentUser }) => (
|
||||
<div key={comment.id} className="flex gap-3">
|
||||
<Avatar className="w-10 h-10">
|
||||
<AvatarImage src={commentUser?.avatar || undefined} />
|
||||
<AvatarFallback>{commentUser?.name?.[0] || "U"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium">{commentUser?.name || "匿名用户"}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(comment.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: zhCN
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{comment.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground py-4">暂无评论</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Actions */}
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">操作</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{canApply && (
|
||||
<Dialog open={isApplyOpen} onOpenChange={setIsApplyOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="w-full gap-2">
|
||||
<Trophy className="w-4 h-4" />
|
||||
申请接单
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>申请接单</DialogTitle>
|
||||
<DialogDescription>
|
||||
向发布者说明您为什么适合完成这个任务
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Textarea
|
||||
placeholder="介绍您的经验和能力(可选)"
|
||||
value={applyMessage}
|
||||
onChange={(e) => setApplyMessage(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsApplyOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApply}
|
||||
disabled={applyMutation.isPending}
|
||||
>
|
||||
{applyMutation.isPending && (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
)}
|
||||
提交申请
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{myApplication && (
|
||||
<div className="p-3 bg-muted/50 rounded-lg text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
您已申请此悬赏
|
||||
<Badge className="ml-2" variant="secondary">
|
||||
{myApplication.status === "pending" ? "待审核" :
|
||||
myApplication.status === "accepted" ? "已接受" : "已拒绝"}
|
||||
</Badge>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Status */}
|
||||
{isPublisher && bounty.isEscrowed && (
|
||||
<div className="p-3 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-emerald-700 dark:text-emerald-400">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
<span className="font-medium">赏金已托管</span>
|
||||
</div>
|
||||
<p className="text-xs text-emerald-600 dark:text-emerald-500 mt-1">
|
||||
赏金已安全托管,任务完成后可释放给接单者
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bounty.isPaid && (
|
||||
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-purple-700 dark:text-purple-400">
|
||||
<Wallet className="w-5 h-5" />
|
||||
<span className="font-medium">赏金已结算</span>
|
||||
</div>
|
||||
<p className="text-xs text-purple-600 dark:text-purple-500 mt-1">
|
||||
赏金已转入接单者账户
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canEscrow && (
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
variant="default"
|
||||
onClick={() => escrowMutation.mutate({ bountyId })}
|
||||
disabled={escrowMutation.isPending}
|
||||
>
|
||||
{escrowMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CreditCard className="w-4 h-4" />
|
||||
)}
|
||||
托管赏金
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canRelease && (
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
variant="default"
|
||||
onClick={() => releaseMutation.mutate({ bountyId })}
|
||||
disabled={releaseMutation.isPending}
|
||||
>
|
||||
{releaseMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Wallet className="w-4 h-4" />
|
||||
)}
|
||||
释放赏金
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canComplete && (
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
variant="default"
|
||||
onClick={handleComplete}
|
||||
disabled={completeMutation.isPending}
|
||||
>
|
||||
{completeMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
)}
|
||||
确认完成
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{canCancel && (
|
||||
<Button
|
||||
className="w-full gap-2"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelMutation.isPending}
|
||||
>
|
||||
{cancelMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4" />
|
||||
)}
|
||||
取消悬赏
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isAuthenticated && (
|
||||
<a href={getLoginUrl()} className="block">
|
||||
<Button className="w-full">登录后操作</Button>
|
||||
</a>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Applications (for publisher) */}
|
||||
{isPublisher && bounty.status === "open" && applications && applications.length > 0 && (
|
||||
<Card className="card-elegant">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">申请列表 ({applications.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{applications.map(({ application, applicant }) => (
|
||||
<div key={application.id} className="p-3 bg-muted/50 rounded-lg">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage src={applicant?.avatar || undefined} />
|
||||
<AvatarFallback>{applicant?.name?.[0] || "U"}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{applicant?.name || "匿名用户"}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDistanceToNow(new Date(application.createdAt), {
|
||||
addSuffix: true,
|
||||
locale: zhCN
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{application.message && (
|
||||
<p className="text-sm text-muted-foreground mb-3">{application.message}</p>
|
||||
)}
|
||||
{application.status === "pending" && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => handleAccept(application.id)}
|
||||
disabled={acceptMutation.isPending}
|
||||
>
|
||||
{acceptMutation.isPending ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
"接受申请"
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{application.status !== "pending" && (
|
||||
<Badge variant="secondary" className="w-full justify-center">
|
||||
{application.status === "accepted" ? "已接受" : "已拒绝"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,634 +0,0 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Link } from "wouter";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Search,
|
||||
ExternalLink,
|
||||
Star,
|
||||
ShoppingBag,
|
||||
Sparkles,
|
||||
Grid3X3,
|
||||
List,
|
||||
ArrowUpDown,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Filter,
|
||||
X,
|
||||
Heart
|
||||
} from "lucide-react";
|
||||
import { getLoginUrl } from "@/const";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
|
||||
export default function Products() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>("all");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [sortBy, setSortBy] = useState<'newest' | 'oldest' | 'price_asc' | 'price_desc'>('newest');
|
||||
const [showAdvancedFilter, setShowAdvancedFilter] = useState(false);
|
||||
const [minPrice, setMinPrice] = useState<string>("");
|
||||
const [maxPrice, setMaxPrice] = useState<string>("");
|
||||
const [selectedWebsites, setSelectedWebsites] = useState<number[]>([]);
|
||||
const [searchHistory, setSearchHistory] = useState<string[]>([]);
|
||||
const [showSearchHistory, setShowSearchHistory] = useState(false);
|
||||
const [trendingSearches, setTrendingSearches] = useState<{query: string; count: number}[]>([]);
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
const { data: favoritesList } = trpc.favorite.list.useQuery(undefined, { enabled: isAuthenticated });
|
||||
const addFavoriteMutation = trpc.favorite.add.useMutation();
|
||||
const removeFavoriteMutation = trpc.favorite.remove.useMutation();
|
||||
|
||||
// Load favorites
|
||||
useEffect(() => {
|
||||
if (favoritesList) {
|
||||
const keys = new Set(favoritesList.map((f: any) => `${f.productId}-${f.websiteId}`));
|
||||
setFavorites(keys);
|
||||
}
|
||||
}, [favoritesList]);
|
||||
|
||||
// Load search history and trending searches from localStorage
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('searchHistory');
|
||||
if (saved) {
|
||||
setSearchHistory(JSON.parse(saved));
|
||||
}
|
||||
|
||||
const trending = localStorage.getItem('trendingSearches');
|
||||
if (trending) {
|
||||
setTrendingSearches(JSON.parse(trending));
|
||||
} else {
|
||||
const defaults = [
|
||||
{ query: 'iPhone', count: 125 },
|
||||
{ query: 'MacBook', count: 98 },
|
||||
{ query: 'AirPods', count: 87 },
|
||||
{ query: 'iPad', count: 76 },
|
||||
{ query: 'Apple Watch', count: 65 }
|
||||
];
|
||||
setTrendingSearches(defaults);
|
||||
localStorage.setItem('trendingSearches', JSON.stringify(defaults));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save search history and update trending searches
|
||||
const addToSearchHistory = (query: string) => {
|
||||
if (!query.trim()) return;
|
||||
const updated = [query, ...searchHistory.filter(h => h !== query)].slice(0, 10);
|
||||
setSearchHistory(updated);
|
||||
localStorage.setItem('searchHistory', JSON.stringify(updated));
|
||||
|
||||
const existing = trendingSearches.find(t => t.query === query);
|
||||
let newTrending;
|
||||
if (existing) {
|
||||
newTrending = trendingSearches.map(t =>
|
||||
t.query === query ? { ...t, count: t.count + 1 } : t
|
||||
).sort((a, b) => b.count - a.count);
|
||||
} else {
|
||||
newTrending = [{ query, count: 1 }, ...trendingSearches]
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
}
|
||||
setTrendingSearches(newTrending);
|
||||
localStorage.setItem('trendingSearches', JSON.stringify(newTrending));
|
||||
};
|
||||
|
||||
const clearSearchHistory = () => {
|
||||
setSearchHistory([]);
|
||||
localStorage.removeItem('searchHistory');
|
||||
setShowSearchHistory(false);
|
||||
};
|
||||
|
||||
const { data: categories, isLoading: categoriesLoading } = trpc.category.list.useQuery();
|
||||
const { data: websites, isLoading: websitesLoading } = trpc.website.list.useQuery();
|
||||
const { data: products, isLoading: productsLoading } = trpc.product.list.useQuery();
|
||||
|
||||
// Handle search with history
|
||||
const handleSearch = (query: string) => {
|
||||
setSearchQuery(query);
|
||||
if (query.trim()) {
|
||||
addToSearchHistory(query);
|
||||
}
|
||||
setShowSearchHistory(false);
|
||||
};
|
||||
|
||||
const filteredWebsites = useMemo(() => {
|
||||
if (!websites) return [];
|
||||
let filtered = websites;
|
||||
|
||||
if (selectedCategory !== "all") {
|
||||
const categoryId = parseInt(selectedCategory);
|
||||
filtered = filtered.filter(w => w.categoryId === categoryId);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(w =>
|
||||
w.name.toLowerCase().includes(query) ||
|
||||
w.description?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [websites, selectedCategory, searchQuery]);
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (!products) return [];
|
||||
let filtered: any[] = products;
|
||||
|
||||
if (selectedCategory !== "all") {
|
||||
const categoryId = parseInt(selectedCategory);
|
||||
filtered = filtered.filter((p: any) => p.categoryId === categoryId);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter((p: any) =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
p.description?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Apply price range filter
|
||||
if (minPrice || maxPrice) {
|
||||
const min = minPrice ? parseFloat(minPrice) : 0;
|
||||
const max = maxPrice ? parseFloat(maxPrice) : Infinity;
|
||||
filtered = filtered.filter((p: any) => {
|
||||
const price = p.minPrice ?? 0;
|
||||
return price >= min && price <= max;
|
||||
});
|
||||
}
|
||||
|
||||
// Apply website filter
|
||||
if (selectedWebsites.length > 0) {
|
||||
filtered = filtered.filter((p: any) => selectedWebsites.includes(p.id));
|
||||
}
|
||||
|
||||
// Apply sorting
|
||||
if (sortBy === 'price_asc') {
|
||||
filtered.sort((a: any, b: any) => (a.minPrice ?? 0) - (b.minPrice ?? 0));
|
||||
} else if (sortBy === 'price_desc') {
|
||||
filtered.sort((a: any, b: any) => (b.maxPrice ?? 0) - (a.maxPrice ?? 0));
|
||||
} else if (sortBy === 'newest') {
|
||||
filtered.sort((a: any, b: any) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
} else if (sortBy === 'oldest') {
|
||||
filtered.sort((a: any, b: any) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [products, selectedCategory, searchQuery, sortBy, minPrice, maxPrice, selectedWebsites]);
|
||||
|
||||
const isLoading = categoriesLoading || websitesLoading || productsLoading;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Navigation */}
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 glass border-b border-border/50">
|
||||
<div className="container flex items-center justify-between h-16">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="font-semibold text-lg">资源聚合</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<Link href="/products" className="text-foreground font-medium">
|
||||
商品导航
|
||||
</Link>
|
||||
<Link href="/bounties" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
悬赏大厅
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link href="/dashboard" className="text-muted-foreground hover:text-foreground transition-colors">
|
||||
个人中心
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isAuthenticated ? (
|
||||
<Link href="/dashboard">
|
||||
<Button variant="default" size="sm" className="gap-2">
|
||||
<span>{user?.name || '用户'}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<a href={getLoginUrl()}>
|
||||
<Button variant="default" size="sm">
|
||||
登录
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Advanced Filter Panel */}
|
||||
{showAdvancedFilter && (
|
||||
<div className="fixed inset-0 z-40 bg-black/50" onClick={() => setShowAdvancedFilter(false)} />
|
||||
)}
|
||||
|
||||
{showAdvancedFilter && (
|
||||
<div className="fixed right-0 top-16 z-50 w-80 bg-background border-l border-border shadow-lg">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold">高级筛选</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFilter(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Price Range Filter */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold mb-3">价格范围</h3>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="最低价格"
|
||||
value={minPrice}
|
||||
onChange={(e) => setMinPrice(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="最高价格"
|
||||
value={maxPrice}
|
||||
onChange={(e) => setMaxPrice(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Website Filter */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-semibold mb-3">网站分类</h3>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{websites?.map((website: any) => (
|
||||
<label key={website.id} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWebsites.includes(website.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedWebsites([...selectedWebsites, website.id]);
|
||||
} else {
|
||||
setSelectedWebsites(selectedWebsites.filter(id => id !== website.id));
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm">{website.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Reset Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
setMinPrice("");
|
||||
setMaxPrice("");
|
||||
setSelectedWebsites([]);
|
||||
}}
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<section className="pt-24 pb-8">
|
||||
<div className="container">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2" style={{ fontFamily: "'Playfair Display', serif" }}>
|
||||
商品导航
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
发现优质购物网站,比较商品价格
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索商品或网站..."
|
||||
className="pl-10 w-64"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onFocus={() => setShowSearchHistory(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch(searchQuery);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Search History Dropdown */}
|
||||
{showSearchHistory && searchHistory.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-lg shadow-lg z-50">
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between px-2 py-1 mb-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground">搜索历史</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={clearSearchHistory}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
{searchHistory.map((item, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSearch(item)}
|
||||
className="w-full text-left px-2 py-2 text-sm hover:bg-accent rounded transition-colors"
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trending Searches */}
|
||||
{!showSearchHistory && searchQuery === '' && trendingSearches.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-background border border-border rounded-lg shadow-lg z-50">
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between px-2 py-1 mb-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
热门搜索
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{trendingSearches.slice(0, 6).map((item, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => handleSearch(item.query)}
|
||||
className="px-3 py-1 text-xs bg-accent hover:bg-accent/80 rounded-full transition-colors"
|
||||
>
|
||||
{item.query}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="px-3 py-2 border rounded-lg bg-background text-foreground text-sm"
|
||||
>
|
||||
<option value="newest">最新发布</option>
|
||||
<option value="oldest">最早发布</option>
|
||||
<option value="price_asc">价格:低到高</option>
|
||||
<option value="price_desc">价格:高到低</option>
|
||||
</select>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFilter(!showAdvancedFilter)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
高级筛选
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center border rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<Grid3X3 className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<Tabs value={selectedCategory} onValueChange={setSelectedCategory} className="mb-8">
|
||||
<TabsList className="flex-wrap h-auto gap-2 bg-transparent p-0">
|
||||
<TabsTrigger
|
||||
value="all"
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-full px-4"
|
||||
>
|
||||
全部
|
||||
</TabsTrigger>
|
||||
{categories?.map(category => (
|
||||
<TabsTrigger
|
||||
key={category.id}
|
||||
value={category.id.toString()}
|
||||
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground rounded-full px-4"
|
||||
>
|
||||
{category.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Content */}
|
||||
<section className="pb-20">
|
||||
<div className="container">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Websites Section */}
|
||||
<div className="mb-12">
|
||||
<h2 className="text-xl font-semibold mb-6 flex items-center gap-2">
|
||||
<ShoppingBag className="w-5 h-5 text-primary" />
|
||||
购物网站
|
||||
<Badge variant="secondary" className="ml-2">{filteredWebsites.length}</Badge>
|
||||
</h2>
|
||||
|
||||
{filteredWebsites.length === 0 ? (
|
||||
<Card className="card-elegant">
|
||||
<CardContent className="py-12 text-center">
|
||||
<ShoppingBag className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">暂无网站数据</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">管理员可以在后台添加购物网站</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className={viewMode === "grid"
|
||||
? "grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||
: "space-y-3"
|
||||
}>
|
||||
{filteredWebsites.map(website => (
|
||||
<Card key={website.id} className="card-elegant group">
|
||||
<CardHeader className={viewMode === "list" ? "flex-row items-center gap-4 space-y-0" : ""}>
|
||||
<div className={`${viewMode === "list" ? "w-12 h-12" : "w-14 h-14"} rounded-xl bg-muted flex items-center justify-center overflow-hidden`}>
|
||||
{website.logo ? (
|
||||
<img src={website.logo} alt={website.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<ShoppingBag className="w-6 h-6 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-base">{website.name}</CardTitle>
|
||||
{website.isVerified && (
|
||||
<CheckCircle className="w-4 h-4 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2 mt-1">
|
||||
{website.description || "暂无描述"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{viewMode === "list" && (
|
||||
<a href={website.url} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="outline" size="sm" className="gap-2">
|
||||
访问
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
</CardHeader>
|
||||
{viewMode === "grid" && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center justify-between">
|
||||
{website.rating && parseFloat(website.rating) > 0 && (
|
||||
<div className="flex items-center gap-1 text-sm">
|
||||
<Star className="w-4 h-4 text-amber-500 fill-amber-500" />
|
||||
<span>{website.rating}</span>
|
||||
</div>
|
||||
)}
|
||||
<a href={website.url} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="ghost" size="sm" className="gap-1 text-primary">
|
||||
访问
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Products Section */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-6 flex items-center gap-2">
|
||||
<ArrowUpDown className="w-5 h-5 text-accent-foreground" />
|
||||
价格对比
|
||||
<Badge variant="secondary" className="ml-2">{filteredProducts.length}</Badge>
|
||||
</h2>
|
||||
|
||||
{filteredProducts.length === 0 ? (
|
||||
<Card className="card-elegant">
|
||||
<CardContent className="py-12 text-center">
|
||||
<ArrowUpDown className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">暂无商品数据</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">管理员可以在后台添加商品进行价格对比</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className={viewMode === "grid"
|
||||
? "grid sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"
|
||||
: "space-y-3"
|
||||
}>
|
||||
{filteredProducts.map((product: any) => {
|
||||
const isFav = favorites.has(`${product.id}-${product.id}`);
|
||||
return (
|
||||
<div key={product.id} className="relative">
|
||||
<Link href={`/products/${product.id}`}>
|
||||
<Card className="card-elegant group cursor-pointer">
|
||||
<CardHeader className={viewMode === "list" ? "flex-row items-center gap-4 space-y-0" : ""}>
|
||||
<div className={`${viewMode === "list" ? "w-16 h-16" : "w-full aspect-square"} rounded-xl bg-muted flex items-center justify-center overflow-hidden`}>
|
||||
{product.image ? (
|
||||
<img src={product.image} alt={product.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<ShoppingBag className="w-8 h-8 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-base line-clamp-2">{product.name}</CardTitle>
|
||||
<CardDescription className="line-clamp-2 mt-1">
|
||||
{product.description || "点击查看价格对比"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (isFav) {
|
||||
removeFavoriteMutation.mutate({ productId: product.id, websiteId: product.id });
|
||||
setFavorites(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(`${product.id}-${product.id}`);
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
addFavoriteMutation.mutate({ productId: product.id, websiteId: product.id });
|
||||
setFavorites(prev => new Set([...Array.from(prev), `${product.id}-${product.id}`]));
|
||||
}
|
||||
}}
|
||||
className="absolute top-2 right-2 p-2 rounded-lg bg-background/80 hover:bg-background transition-colors"
|
||||
>
|
||||
<Heart className={`w-5 h-5 ${isFav ? 'fill-red-500 text-red-500' : 'text-muted-foreground'}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-12 border-t border-border">
|
||||
<div className="container">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="font-semibold">资源聚合平台</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
© 2026 资源聚合平台. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is required to run drizzle commands");
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
schema: "./drizzle/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "mysql",
|
||||
dbCredentials: {
|
||||
url: connectionString,
|
||||
},
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
CREATE TABLE `users` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`openId` varchar(64) NOT NULL,
|
||||
`name` text,
|
||||
`email` varchar(320),
|
||||
`loginMethod` varchar(64),
|
||||
`role` enum('user','admin') NOT NULL DEFAULT 'user',
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
`lastSignedIn` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `users_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `users_openId_unique` UNIQUE(`openId`)
|
||||
);
|
||||
@@ -1,112 +0,0 @@
|
||||
CREATE TABLE `bounties` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`title` varchar(300) NOT NULL,
|
||||
`description` text NOT NULL,
|
||||
`reward` decimal(10,2) NOT NULL,
|
||||
`currency` varchar(10) DEFAULT 'CNY',
|
||||
`publisherId` int NOT NULL,
|
||||
`acceptorId` int,
|
||||
`status` enum('open','in_progress','completed','cancelled','disputed') NOT NULL DEFAULT 'open',
|
||||
`deadline` timestamp,
|
||||
`completedAt` timestamp,
|
||||
`stripePaymentIntentId` varchar(255),
|
||||
`stripeTransferId` varchar(255),
|
||||
`isPaid` boolean DEFAULT false,
|
||||
`isEscrowed` boolean DEFAULT false,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `bounties_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `bountyApplications` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`bountyId` int NOT NULL,
|
||||
`applicantId` int NOT NULL,
|
||||
`message` text,
|
||||
`status` enum('pending','accepted','rejected') NOT NULL DEFAULT 'pending',
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `bountyApplications_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `bountyComments` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`bountyId` int NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`parentId` int,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `bountyComments_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `categories` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`slug` varchar(100) NOT NULL,
|
||||
`description` text,
|
||||
`icon` varchar(100),
|
||||
`parentId` int,
|
||||
`sortOrder` int DEFAULT 0,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `categories_id` PRIMARY KEY(`id`),
|
||||
CONSTRAINT `categories_slug_unique` UNIQUE(`slug`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `notifications` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`type` enum('bounty_accepted','bounty_completed','new_comment','payment_received','system') NOT NULL,
|
||||
`title` varchar(200) NOT NULL,
|
||||
`content` text,
|
||||
`relatedId` int,
|
||||
`relatedType` varchar(50),
|
||||
`isRead` boolean DEFAULT false,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `notifications_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `productPrices` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`productId` int NOT NULL,
|
||||
`websiteId` int NOT NULL,
|
||||
`price` decimal(10,2) NOT NULL,
|
||||
`originalPrice` decimal(10,2),
|
||||
`currency` varchar(10) DEFAULT 'CNY',
|
||||
`url` varchar(500) NOT NULL,
|
||||
`inStock` boolean DEFAULT true,
|
||||
`lastChecked` timestamp NOT NULL DEFAULT (now()),
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `productPrices_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `products` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`name` varchar(300) NOT NULL,
|
||||
`description` text,
|
||||
`image` text,
|
||||
`categoryId` int NOT NULL,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `products_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `websites` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`name` varchar(200) NOT NULL,
|
||||
`url` varchar(500) NOT NULL,
|
||||
`logo` text,
|
||||
`description` text,
|
||||
`categoryId` int NOT NULL,
|
||||
`rating` decimal(2,1) DEFAULT '0',
|
||||
`isVerified` boolean DEFAULT false,
|
||||
`sortOrder` int DEFAULT 0,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `websites_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `avatar` text;--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `stripeCustomerId` varchar(255);--> statement-breakpoint
|
||||
ALTER TABLE `users` ADD `stripeAccountId` varchar(255);
|
||||
@@ -1,8 +0,0 @@
|
||||
CREATE TABLE `favorites` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`productId` int NOT NULL,
|
||||
`websiteId` int NOT NULL,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `favorites_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
@@ -1,17 +0,0 @@
|
||||
CREATE TABLE `favoriteTagMappings` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`favoriteId` int NOT NULL,
|
||||
`tagId` int NOT NULL,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `favoriteTagMappings_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `favoriteTags` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`name` varchar(100) NOT NULL,
|
||||
`color` varchar(20) DEFAULT '#6366f1',
|
||||
`description` text,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `favoriteTags_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
@@ -1,23 +0,0 @@
|
||||
CREATE TABLE `priceHistory` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`monitorId` int NOT NULL,
|
||||
`price` decimal(10,2) NOT NULL,
|
||||
`priceChange` decimal(10,2),
|
||||
`percentChange` decimal(5,2),
|
||||
`recordedAt` timestamp NOT NULL DEFAULT (now()),
|
||||
CONSTRAINT `priceHistory_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `priceMonitors` (
|
||||
`id` int AUTO_INCREMENT NOT NULL,
|
||||
`favoriteId` int NOT NULL,
|
||||
`userId` int NOT NULL,
|
||||
`currentPrice` decimal(10,2),
|
||||
`targetPrice` decimal(10,2),
|
||||
`lowestPrice` decimal(10,2),
|
||||
`highestPrice` decimal(10,2),
|
||||
`isActive` boolean NOT NULL DEFAULT true,
|
||||
`createdAt` timestamp NOT NULL DEFAULT (now()),
|
||||
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT `priceMonitors_id` PRIMARY KEY(`id`)
|
||||
);
|
||||
@@ -1,839 +0,0 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "9aae1b9d-7061-4515-92f5-3bb054372cfd",
|
||||
"prevId": "c1d56799-7b5e-454e-a01c-cdf939d7a804",
|
||||
"tables": {
|
||||
"bounties": {
|
||||
"name": "bounties",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(300)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reward": {
|
||||
"name": "reward",
|
||||
"type": "decimal(10,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'CNY'"
|
||||
},
|
||||
"publisherId": {
|
||||
"name": "publisherId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"acceptorId": {
|
||||
"name": "acceptorId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "enum('open','in_progress','completed','cancelled','disputed')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'open'"
|
||||
},
|
||||
"deadline": {
|
||||
"name": "deadline",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completedAt": {
|
||||
"name": "completedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripePaymentIntentId": {
|
||||
"name": "stripePaymentIntentId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripeTransferId": {
|
||||
"name": "stripeTransferId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"isPaid": {
|
||||
"name": "isPaid",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"isEscrowed": {
|
||||
"name": "isEscrowed",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"bounties_id": {
|
||||
"name": "bounties_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"bountyApplications": {
|
||||
"name": "bountyApplications",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"bountyId": {
|
||||
"name": "bountyId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"applicantId": {
|
||||
"name": "applicantId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "enum('pending','accepted','rejected')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"bountyApplications_id": {
|
||||
"name": "bountyApplications_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"bountyComments": {
|
||||
"name": "bountyComments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"bountyId": {
|
||||
"name": "bountyId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parentId": {
|
||||
"name": "parentId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"bountyComments_id": {
|
||||
"name": "bountyComments_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"categories": {
|
||||
"name": "categories",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parentId": {
|
||||
"name": "parentId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sortOrder": {
|
||||
"name": "sortOrder",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"categories_id": {
|
||||
"name": "categories_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"categories_slug_unique": {
|
||||
"name": "categories_slug_unique",
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"notifications": {
|
||||
"name": "notifications",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "enum('bounty_accepted','bounty_completed','new_comment','payment_received','system')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(200)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relatedId": {
|
||||
"name": "relatedId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relatedType": {
|
||||
"name": "relatedType",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"isRead": {
|
||||
"name": "isRead",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"notifications_id": {
|
||||
"name": "notifications_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"productPrices": {
|
||||
"name": "productPrices",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"productId": {
|
||||
"name": "productId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"websiteId": {
|
||||
"name": "websiteId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price": {
|
||||
"name": "price",
|
||||
"type": "decimal(10,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"originalPrice": {
|
||||
"name": "originalPrice",
|
||||
"type": "decimal(10,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'CNY'"
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"inStock": {
|
||||
"name": "inStock",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"lastChecked": {
|
||||
"name": "lastChecked",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"productPrices_id": {
|
||||
"name": "productPrices_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"products": {
|
||||
"name": "products",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(300)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"categoryId": {
|
||||
"name": "categoryId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"products_id": {
|
||||
"name": "products_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"openId": {
|
||||
"name": "openId",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(320)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"loginMethod": {
|
||||
"name": "loginMethod",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "enum('user','admin')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"stripeCustomerId": {
|
||||
"name": "stripeCustomerId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripeAccountId": {
|
||||
"name": "stripeAccountId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
},
|
||||
"lastSignedIn": {
|
||||
"name": "lastSignedIn",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"users_id": {
|
||||
"name": "users_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"users_openId_unique": {
|
||||
"name": "users_openId_unique",
|
||||
"columns": [
|
||||
"openId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"websites": {
|
||||
"name": "websites",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(200)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"logo": {
|
||||
"name": "logo",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"categoryId": {
|
||||
"name": "categoryId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "decimal(2,1)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'0'"
|
||||
},
|
||||
"isVerified": {
|
||||
"name": "isVerified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sortOrder": {
|
||||
"name": "sortOrder",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"websites_id": {
|
||||
"name": "websites_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -1,892 +0,0 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "f996fa15-db08-4ab1-ba12-611c23559a88",
|
||||
"prevId": "9aae1b9d-7061-4515-92f5-3bb054372cfd",
|
||||
"tables": {
|
||||
"bounties": {
|
||||
"name": "bounties",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(300)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reward": {
|
||||
"name": "reward",
|
||||
"type": "decimal(10,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'CNY'"
|
||||
},
|
||||
"publisherId": {
|
||||
"name": "publisherId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"acceptorId": {
|
||||
"name": "acceptorId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "enum('open','in_progress','completed','cancelled','disputed')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'open'"
|
||||
},
|
||||
"deadline": {
|
||||
"name": "deadline",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completedAt": {
|
||||
"name": "completedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripePaymentIntentId": {
|
||||
"name": "stripePaymentIntentId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripeTransferId": {
|
||||
"name": "stripeTransferId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"isPaid": {
|
||||
"name": "isPaid",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"isEscrowed": {
|
||||
"name": "isEscrowed",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"bounties_id": {
|
||||
"name": "bounties_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"bountyApplications": {
|
||||
"name": "bountyApplications",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"bountyId": {
|
||||
"name": "bountyId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"applicantId": {
|
||||
"name": "applicantId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "enum('pending','accepted','rejected')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"bountyApplications_id": {
|
||||
"name": "bountyApplications_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"bountyComments": {
|
||||
"name": "bountyComments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"bountyId": {
|
||||
"name": "bountyId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parentId": {
|
||||
"name": "parentId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"bountyComments_id": {
|
||||
"name": "bountyComments_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"categories": {
|
||||
"name": "categories",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parentId": {
|
||||
"name": "parentId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sortOrder": {
|
||||
"name": "sortOrder",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"categories_id": {
|
||||
"name": "categories_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"categories_slug_unique": {
|
||||
"name": "categories_slug_unique",
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"favorites": {
|
||||
"name": "favorites",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"productId": {
|
||||
"name": "productId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"websiteId": {
|
||||
"name": "websiteId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"favorites_id": {
|
||||
"name": "favorites_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"notifications": {
|
||||
"name": "notifications",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "enum('bounty_accepted','bounty_completed','new_comment','payment_received','system')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(200)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relatedId": {
|
||||
"name": "relatedId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relatedType": {
|
||||
"name": "relatedType",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"isRead": {
|
||||
"name": "isRead",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"notifications_id": {
|
||||
"name": "notifications_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"productPrices": {
|
||||
"name": "productPrices",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"productId": {
|
||||
"name": "productId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"websiteId": {
|
||||
"name": "websiteId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price": {
|
||||
"name": "price",
|
||||
"type": "decimal(10,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"originalPrice": {
|
||||
"name": "originalPrice",
|
||||
"type": "decimal(10,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'CNY'"
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"inStock": {
|
||||
"name": "inStock",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"lastChecked": {
|
||||
"name": "lastChecked",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"productPrices_id": {
|
||||
"name": "productPrices_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"products": {
|
||||
"name": "products",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(300)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"categoryId": {
|
||||
"name": "categoryId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"products_id": {
|
||||
"name": "products_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"openId": {
|
||||
"name": "openId",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(320)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"loginMethod": {
|
||||
"name": "loginMethod",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "enum('user','admin')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"stripeCustomerId": {
|
||||
"name": "stripeCustomerId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripeAccountId": {
|
||||
"name": "stripeAccountId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
},
|
||||
"lastSignedIn": {
|
||||
"name": "lastSignedIn",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"users_id": {
|
||||
"name": "users_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"users_openId_unique": {
|
||||
"name": "users_openId_unique",
|
||||
"columns": [
|
||||
"openId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"websites": {
|
||||
"name": "websites",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(200)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"logo": {
|
||||
"name": "logo",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"categoryId": {
|
||||
"name": "categoryId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "decimal(2,1)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'0'"
|
||||
},
|
||||
"isVerified": {
|
||||
"name": "isVerified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sortOrder": {
|
||||
"name": "sortOrder",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"websites_id": {
|
||||
"name": "websites_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -1,999 +0,0 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"id": "accfd741-8e72-4aae-a5f5-d02e533d44d8",
|
||||
"prevId": "f996fa15-db08-4ab1-ba12-611c23559a88",
|
||||
"tables": {
|
||||
"bounties": {
|
||||
"name": "bounties",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(300)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reward": {
|
||||
"name": "reward",
|
||||
"type": "decimal(10,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'CNY'"
|
||||
},
|
||||
"publisherId": {
|
||||
"name": "publisherId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"acceptorId": {
|
||||
"name": "acceptorId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "enum('open','in_progress','completed','cancelled','disputed')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'open'"
|
||||
},
|
||||
"deadline": {
|
||||
"name": "deadline",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completedAt": {
|
||||
"name": "completedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripePaymentIntentId": {
|
||||
"name": "stripePaymentIntentId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripeTransferId": {
|
||||
"name": "stripeTransferId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"isPaid": {
|
||||
"name": "isPaid",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"isEscrowed": {
|
||||
"name": "isEscrowed",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"bounties_id": {
|
||||
"name": "bounties_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"bountyApplications": {
|
||||
"name": "bountyApplications",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"bountyId": {
|
||||
"name": "bountyId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"applicantId": {
|
||||
"name": "applicantId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "enum('pending','accepted','rejected')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"bountyApplications_id": {
|
||||
"name": "bountyApplications_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"bountyComments": {
|
||||
"name": "bountyComments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"bountyId": {
|
||||
"name": "bountyId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parentId": {
|
||||
"name": "parentId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"bountyComments_id": {
|
||||
"name": "bountyComments_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"categories": {
|
||||
"name": "categories",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon": {
|
||||
"name": "icon",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parentId": {
|
||||
"name": "parentId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sortOrder": {
|
||||
"name": "sortOrder",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"categories_id": {
|
||||
"name": "categories_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"categories_slug_unique": {
|
||||
"name": "categories_slug_unique",
|
||||
"columns": [
|
||||
"slug"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"favoriteTagMappings": {
|
||||
"name": "favoriteTagMappings",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"favoriteId": {
|
||||
"name": "favoriteId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tagId": {
|
||||
"name": "tagId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"favoriteTagMappings_id": {
|
||||
"name": "favoriteTagMappings_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"favoriteTags": {
|
||||
"name": "favoriteTags",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(100)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"color": {
|
||||
"name": "color",
|
||||
"type": "varchar(20)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'#6366f1'"
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"favoriteTags_id": {
|
||||
"name": "favoriteTags_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"favorites": {
|
||||
"name": "favorites",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"productId": {
|
||||
"name": "productId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"websiteId": {
|
||||
"name": "websiteId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"favorites_id": {
|
||||
"name": "favorites_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"notifications": {
|
||||
"name": "notifications",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "enum('bounty_accepted','bounty_completed','new_comment','payment_received','system')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "varchar(200)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relatedId": {
|
||||
"name": "relatedId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relatedType": {
|
||||
"name": "relatedType",
|
||||
"type": "varchar(50)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"isRead": {
|
||||
"name": "isRead",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"notifications_id": {
|
||||
"name": "notifications_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"productPrices": {
|
||||
"name": "productPrices",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"productId": {
|
||||
"name": "productId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"websiteId": {
|
||||
"name": "websiteId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"price": {
|
||||
"name": "price",
|
||||
"type": "decimal(10,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"originalPrice": {
|
||||
"name": "originalPrice",
|
||||
"type": "decimal(10,2)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"currency": {
|
||||
"name": "currency",
|
||||
"type": "varchar(10)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'CNY'"
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"inStock": {
|
||||
"name": "inStock",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"lastChecked": {
|
||||
"name": "lastChecked",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"productPrices_id": {
|
||||
"name": "productPrices_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"products": {
|
||||
"name": "products",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(300)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"categoryId": {
|
||||
"name": "categoryId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"products_id": {
|
||||
"name": "products_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"openId": {
|
||||
"name": "openId",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "varchar(320)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"avatar": {
|
||||
"name": "avatar",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"loginMethod": {
|
||||
"name": "loginMethod",
|
||||
"type": "varchar(64)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "enum('user','admin')",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'user'"
|
||||
},
|
||||
"stripeCustomerId": {
|
||||
"name": "stripeCustomerId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripeAccountId": {
|
||||
"name": "stripeAccountId",
|
||||
"type": "varchar(255)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
},
|
||||
"lastSignedIn": {
|
||||
"name": "lastSignedIn",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"users_id": {
|
||||
"name": "users_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {
|
||||
"users_openId_unique": {
|
||||
"name": "users_openId_unique",
|
||||
"columns": [
|
||||
"openId"
|
||||
]
|
||||
}
|
||||
},
|
||||
"checkConstraint": {}
|
||||
},
|
||||
"websites": {
|
||||
"name": "websites",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "varchar(200)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "varchar(500)",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"logo": {
|
||||
"name": "logo",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"categoryId": {
|
||||
"name": "categoryId",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"rating": {
|
||||
"name": "rating",
|
||||
"type": "decimal(2,1)",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'0'"
|
||||
},
|
||||
"isVerified": {
|
||||
"name": "isVerified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"sortOrder": {
|
||||
"name": "sortOrder",
|
||||
"type": "int",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"createdAt": {
|
||||
"name": "createdAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(now())"
|
||||
},
|
||||
"updatedAt": {
|
||||
"name": "updatedAt",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"onUpdate": true,
|
||||
"default": "(now())"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"websites_id": {
|
||||
"name": "websites_id",
|
||||
"columns": [
|
||||
"id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraint": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"tables": {},
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "mysql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1769479044444,
|
||||
"tag": "0000_fearless_carlie_cooper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1769479164959,
|
||||
"tag": "0001_salty_scream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "5",
|
||||
"when": 1769484313009,
|
||||
"tag": "0002_exotic_cloak",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "5",
|
||||
"when": 1769484633846,
|
||||
"tag": "0003_messy_rachel_grey",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "5",
|
||||
"when": 1769484958903,
|
||||
"tag": "0004_whole_mandrill",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
import {} from "./schema";
|
||||
@@ -1,246 +0,0 @@
|
||||
import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, decimal, boolean } from "drizzle-orm/mysql-core";
|
||||
|
||||
/**
|
||||
* Core user table backing auth flow.
|
||||
*/
|
||||
export const users = mysqlTable("users", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
openId: varchar("openId", { length: 64 }).notNull().unique(),
|
||||
name: text("name"),
|
||||
email: varchar("email", { length: 320 }),
|
||||
avatar: text("avatar"),
|
||||
loginMethod: varchar("loginMethod", { length: 64 }),
|
||||
role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(),
|
||||
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
|
||||
stripeAccountId: varchar("stripeAccountId", { length: 255 }),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type InsertUser = typeof users.$inferInsert;
|
||||
|
||||
/**
|
||||
* Product categories for navigation
|
||||
*/
|
||||
export const categories = mysqlTable("categories", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
slug: varchar("slug", { length: 100 }).notNull().unique(),
|
||||
description: text("description"),
|
||||
icon: varchar("icon", { length: 100 }),
|
||||
parentId: int("parentId"),
|
||||
sortOrder: int("sortOrder").default(0),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Category = typeof categories.$inferSelect;
|
||||
export type InsertCategory = typeof categories.$inferInsert;
|
||||
|
||||
/**
|
||||
* External shopping/card websites
|
||||
*/
|
||||
export const websites = mysqlTable("websites", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
name: varchar("name", { length: 200 }).notNull(),
|
||||
url: varchar("url", { length: 500 }).notNull(),
|
||||
logo: text("logo"),
|
||||
description: text("description"),
|
||||
categoryId: int("categoryId").notNull(),
|
||||
rating: decimal("rating", { precision: 2, scale: 1 }).default("0"),
|
||||
isVerified: boolean("isVerified").default(false),
|
||||
sortOrder: int("sortOrder").default(0),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Website = typeof websites.$inferSelect;
|
||||
export type InsertWebsite = typeof websites.$inferInsert;
|
||||
|
||||
/**
|
||||
* Products for price comparison
|
||||
*/
|
||||
export const products = mysqlTable("products", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
name: varchar("name", { length: 300 }).notNull(),
|
||||
description: text("description"),
|
||||
image: text("image"),
|
||||
categoryId: int("categoryId").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Product = typeof products.$inferSelect;
|
||||
export type InsertProduct = typeof products.$inferInsert;
|
||||
|
||||
/**
|
||||
* Product prices from different websites
|
||||
*/
|
||||
export const productPrices = mysqlTable("productPrices", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
productId: int("productId").notNull(),
|
||||
websiteId: int("websiteId").notNull(),
|
||||
price: decimal("price", { precision: 10, scale: 2 }).notNull(),
|
||||
originalPrice: decimal("originalPrice", { precision: 10, scale: 2 }),
|
||||
currency: varchar("currency", { length: 10 }).default("CNY"),
|
||||
url: varchar("url", { length: 500 }).notNull(),
|
||||
inStock: boolean("inStock").default(true),
|
||||
lastChecked: timestamp("lastChecked").defaultNow().notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type ProductPrice = typeof productPrices.$inferSelect;
|
||||
export type InsertProductPrice = typeof productPrices.$inferInsert;
|
||||
|
||||
/**
|
||||
* Bounty/Reward tasks
|
||||
*/
|
||||
export const bounties = mysqlTable("bounties", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
title: varchar("title", { length: 300 }).notNull(),
|
||||
description: text("description").notNull(),
|
||||
reward: decimal("reward", { precision: 10, scale: 2 }).notNull(),
|
||||
currency: varchar("currency", { length: 10 }).default("CNY"),
|
||||
publisherId: int("publisherId").notNull(),
|
||||
acceptorId: int("acceptorId"),
|
||||
status: mysqlEnum("status", ["open", "in_progress", "completed", "cancelled", "disputed"]).default("open").notNull(),
|
||||
deadline: timestamp("deadline"),
|
||||
completedAt: timestamp("completedAt"),
|
||||
stripePaymentIntentId: varchar("stripePaymentIntentId", { length: 255 }),
|
||||
stripeTransferId: varchar("stripeTransferId", { length: 255 }),
|
||||
isPaid: boolean("isPaid").default(false),
|
||||
isEscrowed: boolean("isEscrowed").default(false),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type Bounty = typeof bounties.$inferSelect;
|
||||
export type InsertBounty = typeof bounties.$inferInsert;
|
||||
|
||||
/**
|
||||
* Bounty applications/bids
|
||||
*/
|
||||
export const bountyApplications = mysqlTable("bountyApplications", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
bountyId: int("bountyId").notNull(),
|
||||
applicantId: int("applicantId").notNull(),
|
||||
message: text("message"),
|
||||
status: mysqlEnum("status", ["pending", "accepted", "rejected"]).default("pending").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type BountyApplication = typeof bountyApplications.$inferSelect;
|
||||
export type InsertBountyApplication = typeof bountyApplications.$inferInsert;
|
||||
|
||||
/**
|
||||
* Bounty comments
|
||||
*/
|
||||
export const bountyComments = mysqlTable("bountyComments", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
bountyId: int("bountyId").notNull(),
|
||||
userId: int("userId").notNull(),
|
||||
content: text("content").notNull(),
|
||||
parentId: int("parentId"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type BountyComment = typeof bountyComments.$inferSelect;
|
||||
export type InsertBountyComment = typeof bountyComments.$inferInsert;
|
||||
|
||||
/**
|
||||
* User notifications
|
||||
*/
|
||||
export const notifications = mysqlTable("notifications", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
type: mysqlEnum("type", ["bounty_accepted", "bounty_completed", "new_comment", "payment_received", "system"]).notNull(),
|
||||
title: varchar("title", { length: 200 }).notNull(),
|
||||
content: text("content"),
|
||||
relatedId: int("relatedId"),
|
||||
relatedType: varchar("relatedType", { length: 50 }),
|
||||
isRead: boolean("isRead").default(false),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Notification = typeof notifications.$inferSelect;
|
||||
export type InsertNotification = typeof notifications.$inferInsert;
|
||||
|
||||
/**
|
||||
* User product favorites/collections
|
||||
*/
|
||||
export const favorites = mysqlTable("favorites", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
productId: int("productId").notNull(),
|
||||
websiteId: int("websiteId").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type Favorite = typeof favorites.$inferSelect;
|
||||
export type InsertFavorite = typeof favorites.$inferInsert;
|
||||
|
||||
/**
|
||||
* Favorite collection tags for organizing collections
|
||||
*/
|
||||
export const favoriteTags = mysqlTable("favoriteTags", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
color: varchar("color", { length: 20 }).default("#6366f1"),
|
||||
description: text("description"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type FavoriteTag = typeof favoriteTags.$inferSelect;
|
||||
export type InsertFavoriteTag = typeof favoriteTags.$inferInsert;
|
||||
|
||||
/**
|
||||
* Junction table for favorites and tags
|
||||
*/
|
||||
export const favoriteTagMappings = mysqlTable("favoriteTagMappings", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
favoriteId: int("favoriteId").notNull(),
|
||||
tagId: int("tagId").notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type FavoriteTagMapping = typeof favoriteTagMappings.$inferSelect;
|
||||
export type InsertFavoriteTagMapping = typeof favoriteTagMappings.$inferInsert;
|
||||
|
||||
/**
|
||||
* Price monitoring for favorites
|
||||
*/
|
||||
export const priceMonitors = mysqlTable("priceMonitors", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
favoriteId: int("favoriteId").notNull(),
|
||||
userId: int("userId").notNull(),
|
||||
currentPrice: decimal("currentPrice", { precision: 10, scale: 2 }),
|
||||
targetPrice: decimal("targetPrice", { precision: 10, scale: 2 }),
|
||||
lowestPrice: decimal("lowestPrice", { precision: 10, scale: 2 }),
|
||||
highestPrice: decimal("highestPrice", { precision: 10, scale: 2 }),
|
||||
isActive: boolean("isActive").default(true).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type PriceMonitor = typeof priceMonitors.$inferSelect;
|
||||
export type InsertPriceMonitor = typeof priceMonitors.$inferInsert;
|
||||
|
||||
/**
|
||||
* Price history tracking
|
||||
*/
|
||||
export const priceHistory = mysqlTable("priceHistory", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
monitorId: int("monitorId").notNull(),
|
||||
price: decimal("price", { precision: 10, scale: 2 }).notNull(),
|
||||
priceChange: decimal("priceChange", { precision: 10, scale: 2 }),
|
||||
percentChange: decimal("percentChange", { precision: 5, scale: 2 }),
|
||||
recordedAt: timestamp("recordedAt").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export type PriceHistory = typeof priceHistory.$inferSelect;
|
||||
export type InsertPriceHistory = typeof priceHistory.$inferInsert;
|
||||
7
frontend/.env.example
Normal file
7
frontend/.env.example
Normal file
@@ -0,0 +1,7 @@
|
||||
# OAuth 配置
|
||||
VITE_OAUTH_PORTAL_URL=https://your-oauth-portal.com
|
||||
VITE_APP_ID=your-app-id
|
||||
|
||||
# 分析服务配置(可选)
|
||||
VITE_ANALYTICS_ENDPOINT=https://analytics.example.com
|
||||
VITE_ANALYTICS_WEBSITE_ID=your-website-id
|
||||
@@ -15,10 +15,6 @@
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script
|
||||
defer
|
||||
src="%VITE_ANALYTICS_ENDPOINT%/umami"
|
||||
data-website-id="%VITE_ANALYTICS_WEBSITE_ID%"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
0
frontend/public/.gitkeep
Normal file
0
frontend/public/.gitkeep
Normal file
@@ -3,25 +3,34 @@ import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import NotFound from "@/pages/NotFound";
|
||||
import { Route, Switch } from "wouter";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import FriendPanel from "./components/FriendPanel";
|
||||
import { ThemeProvider } from "./contexts/ThemeContext";
|
||||
import Home from "./pages/Home";
|
||||
import Login from "./pages/Login";
|
||||
import Products from "./pages/Products";
|
||||
import ProductDetail from "./pages/ProductDetail";
|
||||
import Bounties from "./pages/Bounties";
|
||||
import BountyDetail from "./pages/BountyDetail";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Favorites from "./pages/Favorites";
|
||||
import ProductComparison from "./pages/ProductComparison";
|
||||
import Admin from "./pages/Admin";
|
||||
import Search from "./pages/Search";
|
||||
|
||||
function Router() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/login" component={Login} />
|
||||
<Route path="/products" component={Products} />
|
||||
<Route path="/products/:id" component={ProductDetail} />
|
||||
<Route path="/bounties" component={Bounties} />
|
||||
<Route path="/bounties/:id" component={BountyDetail} />
|
||||
<Route path="/dashboard" component={Dashboard} />
|
||||
<Route path="/favorites" component={Favorites} />
|
||||
<Route path="/comparison" component={ProductComparison} />
|
||||
<Route path="/search" component={Search} />
|
||||
<Route path="/admin" component={Admin} />
|
||||
<Route path="/404" component={NotFound} />
|
||||
<Route component={NotFound} />
|
||||
</Switch>
|
||||
@@ -35,6 +44,7 @@ function App() {
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Router />
|
||||
<FriendPanel />
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
@@ -1,7 +1,6 @@
|
||||
import { getLoginUrl } from "@/const";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { TRPCClientError } from "@trpc/client";
|
||||
import { useMe, useLogout } from "@/hooks/useApi";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
type UseAuthOptions = {
|
||||
redirectOnUnauthenticated?: boolean;
|
||||
@@ -9,37 +8,25 @@ type UseAuthOptions = {
|
||||
};
|
||||
|
||||
export function useAuth(options?: UseAuthOptions) {
|
||||
const { redirectOnUnauthenticated = false, redirectPath = getLoginUrl() } =
|
||||
const { redirectOnUnauthenticated = false, redirectPath = "/login" } =
|
||||
options ?? {};
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const meQuery = trpc.auth.me.useQuery(undefined, {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const logoutMutation = trpc.auth.logout.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.auth.me.setData(undefined, null);
|
||||
},
|
||||
});
|
||||
const meQuery = useMe();
|
||||
const logoutMutation = useLogout();
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await logoutMutation.mutateAsync();
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
error instanceof TRPCClientError &&
|
||||
error.data?.code === "UNAUTHORIZED"
|
||||
error instanceof AxiosError &&
|
||||
error.response?.status === 401
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
utils.auth.me.setData(undefined, null);
|
||||
await utils.auth.me.invalidate();
|
||||
}
|
||||
}, [logoutMutation, utils]);
|
||||
}, [logoutMutation]);
|
||||
|
||||
const state = useMemo(() => {
|
||||
localStorage.setItem(
|
||||
@@ -19,17 +19,20 @@ import {
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getLoginUrl } from "@/const";
|
||||
import { useIsMobile } from "@/hooks/useMobile";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
|
||||
import { LayoutDashboard, LogOut, PanelLeft, Users, Heart, ShieldCheck } from "lucide-react";
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "wouter";
|
||||
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
const menuItems = [
|
||||
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
|
||||
{ icon: Users, label: "Page 2", path: "/some-path" },
|
||||
{ icon: LayoutDashboard, label: "个人中心", path: "/dashboard" },
|
||||
{ icon: Heart, label: "我的收藏", path: "/favorites" },
|
||||
];
|
||||
|
||||
const adminMenuItems = [
|
||||
{ icon: ShieldCheck, label: "管理后台", path: "/admin" },
|
||||
];
|
||||
|
||||
const SIDEBAR_WIDTH_KEY = "sidebar-width";
|
||||
@@ -70,7 +73,7 @@ export default function DashboardLayout({
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
window.location.href = getLoginUrl();
|
||||
window.location.href = "/login";
|
||||
}}
|
||||
size="lg"
|
||||
className="w-full shadow-lg hover:shadow-xl transition-all"
|
||||
@@ -198,6 +201,32 @@ function DashboardLayoutContent({
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
{user?.role === "admin" && (
|
||||
<>
|
||||
<div className="px-3 py-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider mt-4">
|
||||
管理
|
||||
</div>
|
||||
{adminMenuItems.map(item => {
|
||||
const isActive = location === item.path;
|
||||
return (
|
||||
<SidebarMenuItem key={item.path}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
onClick={() => setLocation(item.path)}
|
||||
tooltip={item.label}
|
||||
className={`h-10 transition-all font-normal`}
|
||||
>
|
||||
<item.icon
|
||||
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
333
frontend/src/components/FriendPanel.tsx
Normal file
333
frontend/src/components/FriendPanel.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Check, UserPlus, Users, X } from "lucide-react";
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import {
|
||||
useAcceptFriendRequest,
|
||||
useCancelFriendRequest,
|
||||
useFriends,
|
||||
useIncomingFriendRequests,
|
||||
useMe,
|
||||
useOutgoingFriendRequests,
|
||||
useRejectFriendRequest,
|
||||
useSearchUsers,
|
||||
useSendFriendRequest,
|
||||
} from "@/hooks/useApi";
|
||||
|
||||
function getFallbackText(name?: string | null, openId?: string | null) {
|
||||
const seed = (name || openId || "?").trim();
|
||||
return seed ? seed[0].toUpperCase() : "?";
|
||||
}
|
||||
|
||||
export default function FriendPanel() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const debouncedQuery = useDebounce(query.trim(), 300);
|
||||
|
||||
const meQuery = useMe();
|
||||
const friendsQuery = useFriends();
|
||||
const incomingQuery = useIncomingFriendRequests();
|
||||
const outgoingQuery = useOutgoingFriendRequests();
|
||||
const searchQuery = useSearchUsers(debouncedQuery, 10);
|
||||
|
||||
const sendRequest = useSendFriendRequest();
|
||||
const acceptRequest = useAcceptFriendRequest();
|
||||
const rejectRequest = useRejectFriendRequest();
|
||||
const cancelRequest = useCancelFriendRequest();
|
||||
|
||||
const friends = friendsQuery.data ?? [];
|
||||
const incoming = incomingQuery.data ?? [];
|
||||
const outgoing = outgoingQuery.data ?? [];
|
||||
|
||||
const incomingCount = incoming.length;
|
||||
|
||||
const friendIdSet = useMemo(
|
||||
() => new Set(friends.map(item => item.user.id)),
|
||||
[friends]
|
||||
);
|
||||
const outgoingIdSet = useMemo(
|
||||
() => new Set(outgoing.map(item => item.receiver.id)),
|
||||
[outgoing]
|
||||
);
|
||||
const incomingIdSet = useMemo(
|
||||
() => new Set(incoming.map(item => item.requester.id)),
|
||||
[incoming]
|
||||
);
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
size="icon-lg"
|
||||
className="fixed right-5 bottom-24 z-50 rounded-full shadow-lg"
|
||||
aria-label="打开好友面板"
|
||||
>
|
||||
<Users />
|
||||
{incomingCount > 0 ? (
|
||||
<span className="absolute -top-1 -right-1 flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-red-500 px-1 text-xs font-semibold text-white">
|
||||
{incomingCount > 99 ? "99+" : incomingCount}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent className="p-0 sm:max-w-md">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetTitle>好友</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full flex-col gap-4 px-4 pb-4">
|
||||
{!meQuery.data && !meQuery.isLoading ? (
|
||||
<div className="pt-4 text-sm text-muted-foreground">
|
||||
登录后即可使用好友功能
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="friends" className="flex-1">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="friends">好友</TabsTrigger>
|
||||
<TabsTrigger value="requests">请求</TabsTrigger>
|
||||
<TabsTrigger value="search">搜索</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="friends" className="flex-1">
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-2">
|
||||
{friends.length === 0 ? (
|
||||
<div className="py-6 text-sm text-muted-foreground">
|
||||
暂无好友
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{friends.map(item => (
|
||||
<div
|
||||
key={item.request_id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={item.user.avatar || undefined} />
|
||||
<AvatarFallback>
|
||||
{getFallbackText(item.user.name, item.user.open_id)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{item.user.name || item.user.open_id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.user.open_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
已是好友
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="requests" className="flex-1">
|
||||
<ScrollArea className="h-[calc(100vh-260px)] pr-2">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold text-muted-foreground">
|
||||
收到的请求
|
||||
</div>
|
||||
{incoming.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
暂无请求
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{incoming.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={item.requester.avatar || undefined} />
|
||||
<AvatarFallback>
|
||||
{getFallbackText(
|
||||
item.requester.name,
|
||||
item.requester.open_id
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{item.requester.name || item.requester.open_id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.requester.open_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
onClick={() => acceptRequest.mutate(item.id)}
|
||||
disabled={acceptRequest.isPending}
|
||||
aria-label="接受好友请求"
|
||||
>
|
||||
<Check />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon-sm"
|
||||
onClick={() => rejectRequest.mutate(item.id)}
|
||||
disabled={rejectRequest.isPending}
|
||||
aria-label="拒绝好友请求"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold text-muted-foreground">
|
||||
我发出的请求
|
||||
</div>
|
||||
{outgoing.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
暂无请求
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{outgoing.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={item.receiver.avatar || undefined} />
|
||||
<AvatarFallback>
|
||||
{getFallbackText(
|
||||
item.receiver.name,
|
||||
item.receiver.open_id
|
||||
)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{item.receiver.name || item.receiver.open_id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.receiver.open_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => cancelRequest.mutate(item.id)}
|
||||
disabled={cancelRequest.isPending}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="search" className="flex-1">
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
placeholder="输入用户名或账号搜索"
|
||||
value={query}
|
||||
onChange={event => setQuery(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ScrollArea className="h-[calc(100vh-300px)] pr-2">
|
||||
{debouncedQuery.length === 0 ? (
|
||||
<div className="py-6 text-sm text-muted-foreground">
|
||||
请输入关键词搜索用户
|
||||
</div>
|
||||
) : searchQuery.isLoading ? (
|
||||
<div className="py-6 text-sm text-muted-foreground">
|
||||
搜索中...
|
||||
</div>
|
||||
) : (searchQuery.data ?? []).length === 0 ? (
|
||||
<div className="py-6 text-sm text-muted-foreground">
|
||||
没有找到匹配用户
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{(searchQuery.data ?? []).map(user => {
|
||||
const isFriend = friendIdSet.has(user.id);
|
||||
const isOutgoing = outgoingIdSet.has(user.id);
|
||||
const isIncoming = incomingIdSet.has(user.id);
|
||||
const disabled = isFriend || isOutgoing || isIncoming;
|
||||
const label = isFriend
|
||||
? "好友"
|
||||
: isIncoming
|
||||
? "待处理"
|
||||
: isOutgoing
|
||||
? "已发送"
|
||||
: "加好友";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center justify-between gap-3 rounded-md border px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar>
|
||||
<AvatarImage src={user.avatar || undefined} />
|
||||
<AvatarFallback>
|
||||
{getFallbackText(user.name, user.open_id)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{user.name || user.open_id}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{user.open_id}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={disabled ? "secondary" : "default"}
|
||||
onClick={() => sendRequest.mutate({ receiver_id: user.id })}
|
||||
disabled={disabled || sendRequest.isPending}
|
||||
>
|
||||
<UserPlus />
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
70
frontend/src/components/LazyImage.tsx
Normal file
70
frontend/src/components/LazyImage.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { ImageOff } from "lucide-react";
|
||||
|
||||
interface LazyImageProps {
|
||||
src?: string | null;
|
||||
alt: string;
|
||||
className?: string;
|
||||
fallback?: React.ReactNode;
|
||||
aspectRatio?: string;
|
||||
}
|
||||
|
||||
export function LazyImage({ src, alt, className = "", fallback, aspectRatio }: LazyImageProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!src) {
|
||||
setHasError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setIsLoaded(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ rootMargin: "50px" }
|
||||
);
|
||||
|
||||
if (imgRef.current) {
|
||||
observer.observe(imgRef.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [src]);
|
||||
|
||||
if (!src || hasError) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-muted ${className}`} style={{ aspectRatio }}>
|
||||
{fallback || <ImageOff className="w-8 h-8 text-muted-foreground" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${className}`} style={{ aspectRatio }}>
|
||||
{!isLoaded && (
|
||||
<Skeleton className="absolute inset-0 w-full h-full" />
|
||||
)}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`w-full h-full object-cover transition-opacity duration-300 ${
|
||||
isLoaded ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={() => setHasError(true)}
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/MobileNav.tsx
Normal file
113
frontend/src/components/MobileNav.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Sparkles, Menu, X, ShoppingBag, Trophy, Search, User, Heart, LogOut } from "lucide-react";
|
||||
import { useUnreadNotificationCount } from "@/hooks/useApi";
|
||||
|
||||
export function MobileNav() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [location] = useLocation();
|
||||
const { user, isAuthenticated, logout } = useAuth();
|
||||
const { data: unreadCountData } = useUnreadNotificationCount();
|
||||
const unreadCount = unreadCountData?.count || 0;
|
||||
|
||||
const navItems = [
|
||||
{ href: "/products", label: "商品导航", icon: ShoppingBag },
|
||||
{ href: "/bounties", label: "悬赏大厅", icon: Trophy },
|
||||
{ href: "/search", label: "全文搜索", icon: Search },
|
||||
];
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="md:hidden">
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[300px] sm:w-[400px]">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span>资源聚合</span>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="mt-8 space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location === item.href;
|
||||
return (
|
||||
<Link key={item.href} href={item.href} onClick={() => setIsOpen(false)}>
|
||||
<Button
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-3"
|
||||
>
|
||||
<Icon className="w-5 h-5" />
|
||||
{item.label}
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pt-4 border-t">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<Link href="/dashboard" onClick={() => setIsOpen(false)}>
|
||||
<Button variant="ghost" className="w-full justify-start gap-3">
|
||||
<User className="w-5 h-5" />
|
||||
个人中心
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/favorites" onClick={() => setIsOpen(false)}>
|
||||
<Button variant="ghost" className="w-full justify-start gap-3">
|
||||
<Heart className="w-5 h-5" />
|
||||
我的收藏
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-auto bg-destructive text-destructive-foreground text-xs rounded-full px-2 py-0.5">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
{user?.role === "admin" && (
|
||||
<Link href="/admin" onClick={() => setIsOpen(false)}>
|
||||
<Button variant="ghost" className="w-full justify-start gap-3">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
管理后台
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3 text-destructive"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
<LogOut className="w-5 h-5" />
|
||||
退出登录
|
||||
</Button>
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{user?.name || "用户"}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Link href="/login" onClick={() => setIsOpen(false)}>
|
||||
<Button className="w-full gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
登录
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/Navbar.tsx
Normal file
90
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useAuth } from "@/_core/hooks/useAuth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import { Sparkles, Bell, LogOut } from "lucide-react";
|
||||
import { MobileNav } from "./MobileNav";
|
||||
import { useUnreadNotificationCount } from "@/hooks/useApi";
|
||||
|
||||
interface NavbarProps {
|
||||
children?: React.ReactNode;
|
||||
showLinks?: boolean;
|
||||
}
|
||||
|
||||
export function Navbar({ children, showLinks = true }: NavbarProps) {
|
||||
const { user, isAuthenticated, logout } = useAuth();
|
||||
const [location] = useLocation();
|
||||
const { data: unreadCountData } = useUnreadNotificationCount();
|
||||
const unreadCount = unreadCountData?.count || 0;
|
||||
|
||||
return (
|
||||
<nav className="fixed top-0 left-0 right-0 z-50 glass border-b border-border/50">
|
||||
<div className="container flex items-center justify-between h-16">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-primary to-primary/60 flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-primary-foreground" />
|
||||
</div>
|
||||
<span className="font-semibold text-lg">资源聚合</span>
|
||||
</Link>
|
||||
|
||||
{showLinks && (
|
||||
<div className="hidden md:flex items-center gap-8">
|
||||
<Link href="/products" className={location === "/products" ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground transition-colors"}>
|
||||
商品导航
|
||||
</Link>
|
||||
<Link href="/bounties" className={location === "/bounties" ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground transition-colors"}>
|
||||
悬赏大厅
|
||||
</Link>
|
||||
<Link href="/search" className={location === "/search" ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground transition-colors"}>
|
||||
全文搜索
|
||||
</Link>
|
||||
{isAuthenticated && (
|
||||
<Link href="/dashboard" className={location === "/dashboard" ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground transition-colors"}>
|
||||
个人中心
|
||||
</Link>
|
||||
)}
|
||||
{isAuthenticated && user?.role === "admin" && (
|
||||
<Link href="/admin" className={location === "/admin" ? "text-foreground font-medium" : "text-muted-foreground hover:text-foreground transition-colors"}>
|
||||
管理后台
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<MobileNav />
|
||||
{children}
|
||||
{isAuthenticated ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/dashboard" className="hidden md:block">
|
||||
<Button variant="ghost" size="sm" className="gap-2">
|
||||
<span>{user?.name || '用户'}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href="/dashboard?tab=notifications">
|
||||
<Button variant="ghost" size="sm" className="relative">
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-destructive text-destructive-foreground text-xs rounded-full flex items-center justify-center">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" onClick={() => logout()}>
|
||||
<LogOut className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Link href="/login">
|
||||
<Button variant="default" size="sm">
|
||||
登录
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user