This commit is contained in:
27942
2026-01-27 18:15:43 +08:00
parent 1fbe62c015
commit 03117098c7
209 changed files with 12770 additions and 15317 deletions

32
QA_CHECKLIST.md Normal file
View File

@@ -0,0 +1,32 @@
# 最小验收清单(功能自测)
## 账号与权限
- 注册新账号并登录,能获取 `/api/auth/me` 返回用户信息
- 非管理员访问 `/admin` 自动跳回首页
- 管理员访问 `/admin` 正常看到用户/悬赏/支付事件列表
## 悬赏流程
- 发布悬赏 → 列表/详情可见
- 其他用户申请接单 → 发布者在详情页接受申请
- 接单者提交交付内容 → 发布者验收(通过/驳回)
- 验收通过后可完成悬赏
- 完成后双方可互评
## 支付流程
- 发布者创建托管支付(跳转 Stripe
- 完成支付后悬赏状态为已托管
- 发布者完成悬赏后释放赏金
- 支付事件在管理后台可查看
## 收藏与价格监控
- 收藏商品并设置监控(目标价/提醒开关)
- 刷新价格后产生价格历史记录
- 达到目标价时产生通知
## 通知与偏好
- 通知列表可查看、单条已读、全部已读
- 通知偏好开关能控制对应类型通知是否创建
## 争议与延期
- 接单者可提交延期申请,发布者可同意/拒绝
- 争议可由任一方发起,管理员可处理

131
README.md Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
# Django apps package

View File

@@ -0,0 +1 @@
"""Admin API package."""

179
backend/apps/admin/api.py Normal file
View 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
]

View 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'

View File

@@ -0,0 +1 @@
default_app_config = 'apps.bounties.apps.BountiesConfig'

View 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']

View 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)

View 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 = '悬赏管理'

View 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'],
},
),
]

View 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')},
),
]

View File

@@ -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'],
},
),
]

View 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'],
},
),
]

View 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}"

View 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)

View 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

View File

@@ -0,0 +1 @@
default_app_config = 'apps.favorites.apps.FavoritesConfig'

View 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']

View 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
]

View 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 = '收藏管理'

View 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',
},
),
]

View 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')},
),
]

View File

@@ -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='上次通知价格'),
),
]

View 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}"

View 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

View File

@@ -0,0 +1 @@
default_app_config = 'apps.notifications.apps.NotificationsConfig'

View 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']

View 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)

View 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 = '通知管理'

View 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'],
},
),
]

View 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='用户'),
),
]

View File

@@ -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',
},
),
]

View 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"

View 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

View File

@@ -0,0 +1 @@
default_app_config = 'apps.products.apps.ProductsConfig'

View 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']

View 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,
)

View 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 = '商品管理'

View 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')},
},
),
]

View 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}"

View 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

View File

@@ -0,0 +1 @@
default_app_config = 'apps.users.apps.UsersConfig'

View 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
View 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),
)

View 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 = '用户管理'

View 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]

View 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',
},
),
]

View 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"),
),
]

View 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})"

View 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

View File

@@ -0,0 +1 @@
# Django config package

36
backend/config/api.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

View File

@@ -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();
};

View File

@@ -1,4 +0,0 @@
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../../../server/routers";
export const trpc = createTRPCReact<AppRouter>();

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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,
},
});

View File

@@ -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`)
);

View File

@@ -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);

View File

@@ -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`)
);

View File

@@ -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`)
);

View File

@@ -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`)
);

View File

@@ -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": {}
}
}

View File

@@ -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": {}
}
}

View File

@@ -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

View File

@@ -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
}
]
}

View File

@@ -1 +0,0 @@
import {} from "./schema";

View File

@@ -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
View 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

View File

@@ -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
View File

View 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>

View File

@@ -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(

View File

@@ -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>

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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