diff --git a/QA_CHECKLIST.md b/QA_CHECKLIST.md new file mode 100644 index 0000000..db85ad2 --- /dev/null +++ b/QA_CHECKLIST.md @@ -0,0 +1,32 @@ +# 最小验收清单(功能自测) + +## 账号与权限 +- 注册新账号并登录,能获取 `/api/auth/me` 返回用户信息 +- 非管理员访问 `/admin` 自动跳回首页 +- 管理员访问 `/admin` 正常看到用户/悬赏/支付事件列表 + +## 悬赏流程 +- 发布悬赏 → 列表/详情可见 +- 其他用户申请接单 → 发布者在详情页接受申请 +- 接单者提交交付内容 → 发布者验收(通过/驳回) +- 验收通过后可完成悬赏 +- 完成后双方可互评 + +## 支付流程 +- 发布者创建托管支付(跳转 Stripe) +- 完成支付后悬赏状态为已托管 +- 发布者完成悬赏后释放赏金 +- 支付事件在管理后台可查看 + +## 收藏与价格监控 +- 收藏商品并设置监控(目标价/提醒开关) +- 刷新价格后产生价格历史记录 +- 达到目标价时产生通知 + +## 通知与偏好 +- 通知列表可查看、单条已读、全部已读 +- 通知偏好开关能控制对应类型通知是否创建 + +## 争议与延期 +- 接单者可提交延期申请,发布者可同意/拒绝 +- 争议可由任一方发起,管理员可处理 diff --git a/README.md b/README.md new file mode 100644 index 0000000..a5602bf --- /dev/null +++ b/README.md @@ -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 登录 +- 个人中心 +- 通知系统 diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..6bddd75 --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/apps/__init__.py b/backend/apps/__init__.py new file mode 100644 index 0000000..65b3225 --- /dev/null +++ b/backend/apps/__init__.py @@ -0,0 +1 @@ +# Django apps package diff --git a/backend/apps/admin/__init__.py b/backend/apps/admin/__init__.py new file mode 100644 index 0000000..4aa305f --- /dev/null +++ b/backend/apps/admin/__init__.py @@ -0,0 +1 @@ +"""Admin API package.""" diff --git a/backend/apps/admin/api.py b/backend/apps/admin/api.py new file mode 100644 index 0000000..1049dcb --- /dev/null +++ b/backend/apps/admin/api.py @@ -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 + ] diff --git a/backend/apps/admin/apps.py b/backend/apps/admin/apps.py new file mode 100644 index 0000000..540feed --- /dev/null +++ b/backend/apps/admin/apps.py @@ -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' diff --git a/backend/apps/bounties/__init__.py b/backend/apps/bounties/__init__.py new file mode 100644 index 0000000..f8e08b6 --- /dev/null +++ b/backend/apps/bounties/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.bounties.apps.BountiesConfig' diff --git a/backend/apps/bounties/admin.py b/backend/apps/bounties/admin.py new file mode 100644 index 0000000..bf11f9b --- /dev/null +++ b/backend/apps/bounties/admin.py @@ -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'] diff --git a/backend/apps/bounties/api.py b/backend/apps/bounties/api.py new file mode 100644 index 0000000..6704490 --- /dev/null +++ b/backend/apps/bounties/api.py @@ -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) diff --git a/backend/apps/bounties/apps.py b/backend/apps/bounties/apps.py new file mode 100644 index 0000000..cd395b1 --- /dev/null +++ b/backend/apps/bounties/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class BountiesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.bounties' + verbose_name = '悬赏管理' diff --git a/backend/apps/bounties/migrations/0001_initial.py b/backend/apps/bounties/migrations/0001_initial.py new file mode 100644 index 0000000..684c05f --- /dev/null +++ b/backend/apps/bounties/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/backend/apps/bounties/migrations/0002_initial.py b/backend/apps/bounties/migrations/0002_initial.py new file mode 100644 index 0000000..f5ff747 --- /dev/null +++ b/backend/apps/bounties/migrations/0002_initial.py @@ -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')}, + ), + ] diff --git a/backend/apps/bounties/migrations/0003_workflow_and_payments.py b/backend/apps/bounties/migrations/0003_workflow_and_payments.py new file mode 100644 index 0000000..7d82c88 --- /dev/null +++ b/backend/apps/bounties/migrations/0003_workflow_and_payments.py @@ -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'], + }, + ), + ] diff --git a/backend/apps/bounties/migrations/0004_extension_request.py b/backend/apps/bounties/migrations/0004_extension_request.py new file mode 100644 index 0000000..23b563a --- /dev/null +++ b/backend/apps/bounties/migrations/0004_extension_request.py @@ -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'], + }, + ), + ] diff --git a/client/public/.gitkeep b/backend/apps/bounties/migrations/__init__.py similarity index 100% rename from client/public/.gitkeep rename to backend/apps/bounties/migrations/__init__.py diff --git a/backend/apps/bounties/models.py b/backend/apps/bounties/models.py new file mode 100644 index 0000000..77ff2bf --- /dev/null +++ b/backend/apps/bounties/models.py @@ -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}" diff --git a/backend/apps/bounties/payments.py b/backend/apps/bounties/payments.py new file mode 100644 index 0000000..e7e2258 --- /dev/null +++ b/backend/apps/bounties/payments.py @@ -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) diff --git a/backend/apps/bounties/schemas.py b/backend/apps/bounties/schemas.py new file mode 100644 index 0000000..e61185e --- /dev/null +++ b/backend/apps/bounties/schemas.py @@ -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 diff --git a/backend/apps/favorites/__init__.py b/backend/apps/favorites/__init__.py new file mode 100644 index 0000000..8a39c0f --- /dev/null +++ b/backend/apps/favorites/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.favorites.apps.FavoritesConfig' diff --git a/backend/apps/favorites/admin.py b/backend/apps/favorites/admin.py new file mode 100644 index 0000000..75f6128 --- /dev/null +++ b/backend/apps/favorites/admin.py @@ -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'] diff --git a/backend/apps/favorites/api.py b/backend/apps/favorites/api.py new file mode 100644 index 0000000..f90a86d --- /dev/null +++ b/backend/apps/favorites/api.py @@ -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 + ] diff --git a/backend/apps/favorites/apps.py b/backend/apps/favorites/apps.py new file mode 100644 index 0000000..52bfae4 --- /dev/null +++ b/backend/apps/favorites/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class FavoritesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.favorites' + verbose_name = '收藏管理' diff --git a/backend/apps/favorites/migrations/0001_initial.py b/backend/apps/favorites/migrations/0001_initial.py new file mode 100644 index 0000000..ec34013 --- /dev/null +++ b/backend/apps/favorites/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/backend/apps/favorites/migrations/0002_initial.py b/backend/apps/favorites/migrations/0002_initial.py new file mode 100644 index 0000000..85134c0 --- /dev/null +++ b/backend/apps/favorites/migrations/0002_initial.py @@ -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')}, + ), + ] diff --git a/backend/apps/favorites/migrations/0003_price_monitor_notify.py b/backend/apps/favorites/migrations/0003_price_monitor_notify.py new file mode 100644 index 0000000..0a71e1f --- /dev/null +++ b/backend/apps/favorites/migrations/0003_price_monitor_notify.py @@ -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='上次通知价格'), + ), + ] diff --git a/drizzle/migrations/.gitkeep b/backend/apps/favorites/migrations/__init__.py similarity index 100% rename from drizzle/migrations/.gitkeep rename to backend/apps/favorites/migrations/__init__.py diff --git a/backend/apps/favorites/models.py b/backend/apps/favorites/models.py new file mode 100644 index 0000000..b7c615e --- /dev/null +++ b/backend/apps/favorites/models.py @@ -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}" diff --git a/backend/apps/favorites/schemas.py b/backend/apps/favorites/schemas.py new file mode 100644 index 0000000..1329be9 --- /dev/null +++ b/backend/apps/favorites/schemas.py @@ -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 diff --git a/backend/apps/notifications/__init__.py b/backend/apps/notifications/__init__.py new file mode 100644 index 0000000..4711a4e --- /dev/null +++ b/backend/apps/notifications/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.notifications.apps.NotificationsConfig' diff --git a/backend/apps/notifications/admin.py b/backend/apps/notifications/admin.py new file mode 100644 index 0000000..83b2ca7 --- /dev/null +++ b/backend/apps/notifications/admin.py @@ -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'] diff --git a/backend/apps/notifications/api.py b/backend/apps/notifications/api.py new file mode 100644 index 0000000..06647b1 --- /dev/null +++ b/backend/apps/notifications/api.py @@ -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) diff --git a/backend/apps/notifications/apps.py b/backend/apps/notifications/apps.py new file mode 100644 index 0000000..0508fc4 --- /dev/null +++ b/backend/apps/notifications/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.notifications' + verbose_name = '通知管理' diff --git a/backend/apps/notifications/migrations/0001_initial.py b/backend/apps/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..f24eec7 --- /dev/null +++ b/backend/apps/notifications/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/backend/apps/notifications/migrations/0002_initial.py b/backend/apps/notifications/migrations/0002_initial.py new file mode 100644 index 0000000..fcb2a1e --- /dev/null +++ b/backend/apps/notifications/migrations/0002_initial.py @@ -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='用户'), + ), + ] diff --git a/backend/apps/notifications/migrations/0003_notification_preferences.py b/backend/apps/notifications/migrations/0003_notification_preferences.py new file mode 100644 index 0000000..09704ac --- /dev/null +++ b/backend/apps/notifications/migrations/0003_notification_preferences.py @@ -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', + }, + ), + ] diff --git a/backend/apps/notifications/migrations/__init__.py b/backend/apps/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/notifications/models.py b/backend/apps/notifications/models.py new file mode 100644 index 0000000..9c9fd88 --- /dev/null +++ b/backend/apps/notifications/models.py @@ -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" diff --git a/backend/apps/notifications/schemas.py b/backend/apps/notifications/schemas.py new file mode 100644 index 0000000..fcc1af3 --- /dev/null +++ b/backend/apps/notifications/schemas.py @@ -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 diff --git a/backend/apps/products/__init__.py b/backend/apps/products/__init__.py new file mode 100644 index 0000000..8773be7 --- /dev/null +++ b/backend/apps/products/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.products.apps.ProductsConfig' diff --git a/backend/apps/products/admin.py b/backend/apps/products/admin.py new file mode 100644 index 0000000..3e76453 --- /dev/null +++ b/backend/apps/products/admin.py @@ -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'] diff --git a/backend/apps/products/api.py b/backend/apps/products/api.py new file mode 100644 index 0000000..afe4692 --- /dev/null +++ b/backend/apps/products/api.py @@ -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, + ) diff --git a/backend/apps/products/apps.py b/backend/apps/products/apps.py new file mode 100644 index 0000000..b9d2d60 --- /dev/null +++ b/backend/apps/products/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ProductsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.products' + verbose_name = '商品管理' diff --git a/backend/apps/products/migrations/0001_initial.py b/backend/apps/products/migrations/0001_initial.py new file mode 100644 index 0000000..6b71540 --- /dev/null +++ b/backend/apps/products/migrations/0001_initial.py @@ -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')}, + }, + ), + ] diff --git a/backend/apps/products/migrations/__init__.py b/backend/apps/products/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/products/models.py b/backend/apps/products/models.py new file mode 100644 index 0000000..76b137b --- /dev/null +++ b/backend/apps/products/models.py @@ -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}" diff --git a/backend/apps/products/schemas.py b/backend/apps/products/schemas.py new file mode 100644 index 0000000..f2d1340 --- /dev/null +++ b/backend/apps/products/schemas.py @@ -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 diff --git a/backend/apps/users/__init__.py b/backend/apps/users/__init__.py new file mode 100644 index 0000000..2791a18 --- /dev/null +++ b/backend/apps/users/__init__.py @@ -0,0 +1 @@ +default_app_config = 'apps.users.apps.UsersConfig' diff --git a/backend/apps/users/admin.py b/backend/apps/users/admin.py new file mode 100644 index 0000000..c0b725e --- /dev/null +++ b/backend/apps/users/admin.py @@ -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'), + }), + ) diff --git a/backend/apps/users/api.py b/backend/apps/users/api.py new file mode 100644 index 0000000..3a77521 --- /dev/null +++ b/backend/apps/users/api.py @@ -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), + ) diff --git a/backend/apps/users/apps.py b/backend/apps/users/apps.py new file mode 100644 index 0000000..4300380 --- /dev/null +++ b/backend/apps/users/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.users' + verbose_name = '用户管理' diff --git a/backend/apps/users/friends.py b/backend/apps/users/friends.py new file mode 100644 index 0000000..612b64a --- /dev/null +++ b/backend/apps/users/friends.py @@ -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] diff --git a/backend/apps/users/migrations/0001_initial.py b/backend/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..f6000bc --- /dev/null +++ b/backend/apps/users/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/backend/apps/users/migrations/0002_friend_request.py b/backend/apps/users/migrations/0002_friend_request.py new file mode 100644 index 0000000..edcf309 --- /dev/null +++ b/backend/apps/users/migrations/0002_friend_request.py @@ -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"), + ), + ] diff --git a/backend/apps/users/migrations/__init__.py b/backend/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py new file mode 100644 index 0000000..40716c0 --- /dev/null +++ b/backend/apps/users/models.py @@ -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})" diff --git a/backend/apps/users/schemas.py b/backend/apps/users/schemas.py new file mode 100644 index 0000000..ace30fe --- /dev/null +++ b/backend/apps/users/schemas.py @@ -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 diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..a1f62a5 --- /dev/null +++ b/backend/config/__init__.py @@ -0,0 +1 @@ +# Django config package diff --git a/backend/config/api.py b/backend/config/api.py new file mode 100644 index 0000000..c880470 --- /dev/null +++ b/backend/config/api.py @@ -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=["后台"]) diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..e7678db --- /dev/null +++ b/backend/config/asgi.py @@ -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() diff --git a/backend/config/search.py b/backend/config/search.py new file mode 100644 index 0000000..1cff8c6 --- /dev/null +++ b/backend/config/search.py @@ -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], + ) diff --git a/backend/config/settings.py b/backend/config/settings.py new file mode 100644 index 0000000..4dc07aa --- /dev/null +++ b/backend/config/settings.py @@ -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') diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..019181b --- /dev/null +++ b/backend/config/urls.py @@ -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'), +] diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..56de90d --- /dev/null +++ b/backend/config/wsgi.py @@ -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() diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..8e7ac79 --- /dev/null +++ b/backend/manage.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..ca6c3fc --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/client/src/const.ts b/client/src/const.ts deleted file mode 100644 index 9999063..0000000 --- a/client/src/const.ts +++ /dev/null @@ -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(); -}; diff --git a/client/src/lib/trpc.ts b/client/src/lib/trpc.ts deleted file mode 100644 index df38607..0000000 --- a/client/src/lib/trpc.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { createTRPCReact } from "@trpc/react-query"; -import type { AppRouter } from "../../../server/routers"; - -export const trpc = createTRPCReact(); diff --git a/client/src/main.tsx b/client/src/main.tsx deleted file mode 100644 index 8adf6f5..0000000 --- a/client/src/main.tsx +++ /dev/null @@ -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( - - - - - -); diff --git a/client/src/pages/BountyDetail.tsx b/client/src/pages/BountyDetail.tsx deleted file mode 100644 index 9743afa..0000000 --- a/client/src/pages/BountyDetail.tsx +++ /dev/null @@ -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 = { - 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 ( -
- -
- ); - } - - if (!bountyData) { - return ( -
- - - -

悬赏不存在

-

该悬赏可能已被删除或不存在

- - - -
-
-
- ); - } - - 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 ( -
- {/* Navigation */} - - - {/* Content */} -
-
- {/* Back button */} - - - - -
- {/* Main Content */} -
- {/* Bounty Info */} - - -
- - {statusMap[bounty.status]?.label || bounty.status} - -
- - ¥{bounty.reward} -
-
- - {bounty.title} - -
- -
-

{bounty.description}

-
- - - -
-
- - 发布者: {publisher?.name || "匿名用户"} -
-
- - - {formatDistanceToNow(new Date(bounty.createdAt), { - addSuffix: true, - locale: zhCN - })} - -
- {bounty.deadline && ( -
- - 截止: {format(new Date(bounty.deadline), "yyyy-MM-dd")} -
- )} -
-
-
- - {/* Comments */} - - - - - 评论 ({comments?.length || 0}) - - - - {isAuthenticated ? ( -
- - - {user?.name?.[0] || "U"} - -
-