From 6262a67f464fe1df29f94aa1a6db176c05d0eae8 Mon Sep 17 00:00:00 2001 From: 27942 Date: Wed, 28 Jan 2026 16:00:56 +0800 Subject: [PATCH] haha --- .gitignore | 3 + QA_CHECKLIST.md | 32 - README.md | 131 --- backend/apps/admin/api.py | 194 ++-- backend/apps/bounties/api.py | 225 ++-- .../bounties/migrations/0005_add_indexes.py | 70 ++ ...idx_bounties_status_ba7f3d_idx_and_more.py | 88 ++ backend/apps/bounties/models.py | 29 + backend/apps/bounties/payments.py | 50 +- backend/apps/common/__init__.py | 1 + backend/apps/common/errors.py | 36 + backend/apps/common/serializers.py | 53 + backend/apps/favorites/api.py | 31 +- .../favorites/migrations/0004_add_indexes.py | 30 + ...x_favorites_user_id_9cc509_idx_and_more.py | 38 + backend/apps/favorites/models.py | 15 + backend/apps/notifications/api.py | 7 +- .../0004_alter_notification_type.py | 18 + backend/apps/notifications/utils.py | 31 + backend/apps/products/api.py | 189 ++- .../apps/products/management/__init__.py | 0 .../products/management/commands/__init__.py | 0 .../products/management/commands/init_data.py | 114 ++ .../products/migrations/0002_add_indexes.py | 26 + ..._categories_parent__5c622c_idx_and_more.py | 33 + ...ect_reason_product_reviewed_at_and_more.py | 44 + backend/apps/products/models.py | 36 + backend/apps/products/schemas.py | 29 + backend/apps/users/api.py | 159 ++- backend/apps/users/management/__init__.py | 0 .../users/management/commands/__init__.py | 0 .../management/commands/createsuperadmin.py | 97 ++ .../apps/users/migrations/0003_add_indexes.py | 26 + ...friend_requ_receive_383c2c_idx_and_more.py | 38 + backend/apps/users/models.py | 8 + backend/apps/users/schemas.py | 10 +- backend/config/api.py | 35 + backend/config/middleware.py | 79 ++ backend/config/search.py | 51 +- backend/config/settings.py | 130 ++- backend/requirements-dev.txt | 5 + backend/requirements.txt | 3 - .prettierignore => frontend/.prettierignore | 0 .prettierrc => frontend/.prettierrc | 0 components.json => frontend/components.json | 2 +- package.json => frontend/package.json | 9 +- .../patches}/wouter@3.7.1.patch | 0 pnpm-lock.yaml => frontend/pnpm-lock.yaml | 0 {shared => frontend/shared}/_core/errors.ts | 0 {shared => frontend/shared}/const.ts | 0 {shared => frontend/shared}/types.ts | 1 - frontend/src/App.tsx | 28 +- frontend/src/_core/hooks/useAuth.ts | 71 -- frontend/src/components/DashboardLayout.tsx | 51 +- frontend/src/components/MobileNav.tsx | 2 +- frontend/src/components/Navbar.tsx | 2 +- frontend/src/features/admin/pages/Admin.tsx | 515 +++++++++ .../src/{ => features/auth}/pages/Login.tsx | 85 +- .../bounties/components/BountiesGrid.tsx | 77 ++ .../bounties/components/BountiesHeader.tsx | 180 +++ .../components/BountyActionsPanel.tsx | 177 +++ .../components/BountyApplicationsList.tsx | 70 ++ .../bounties/components/BountyComments.tsx | 110 ++ .../bounties/components/BountyDeliveries.tsx | 109 ++ .../bounties/components/BountyDisputes.tsx | 90 ++ .../bounties/components/BountyExtensions.tsx | 98 ++ .../bounties/components/BountyHeaderBar.tsx | 18 + .../bounties/components/BountyInfoCard.tsx | 69 ++ .../components/BountyPaymentTimeline.tsx | 49 + .../bounties/components/BountyReviews.tsx | 87 ++ .../src/features/bounties/pages/Bounties.tsx | 156 +++ .../features/bounties/pages/BountyDetail.tsx | 534 +++++++++ .../{ => features/common}/pages/NotFound.tsx | 0 .../features/dashboard/pages/Dashboard.tsx | 966 ++++++++++++++++ .../favorites}/pages/Favorites.tsx | 99 +- .../friends}/FriendPanel.tsx | 0 .../src/{ => features/home}/pages/Home.tsx | 2 +- .../products/components/ProductsHeader.tsx | 163 +++ .../components/RecommendedProducts.tsx | 56 + .../products/components/WebsitesSection.tsx | 102 ++ .../products}/pages/ProductComparison.tsx | 37 +- .../products}/pages/ProductDetail.tsx | 76 +- .../src/features/products/pages/Products.tsx | 732 ++++++++++++ .../{ => features/search}/pages/Search.tsx | 0 .../src/features/settings/pages/Settings.tsx | 298 +++++ frontend/src/hooks/useApi.ts | 84 +- frontend/src/hooks/useAuth.ts | 109 ++ frontend/src/hooks/useDebouncedValue.ts | 14 + frontend/src/lib/api.ts | 579 ---------- frontend/src/lib/api/admin.ts | 27 + frontend/src/lib/api/auth.ts | 30 + frontend/src/lib/api/bounties.ts | 70 ++ frontend/src/lib/api/categories.ts | 9 + frontend/src/lib/api/client.ts | 129 +++ frontend/src/lib/api/errors.ts | 52 + frontend/src/lib/api/favorites.ts | 55 + frontend/src/lib/api/friends.ts | 21 + frontend/src/lib/api/index.ts | 46 + frontend/src/lib/api/notifications.ts | 16 + frontend/src/lib/api/payments.ts | 19 + frontend/src/lib/api/products.ts | 33 + frontend/src/lib/api/search.ts | 7 + frontend/src/lib/api/websites.ts | 10 + frontend/src/lib/i18n/errorMessages.ts | 142 +++ frontend/src/lib/types.ts | 347 ++++++ frontend/src/pages/Admin.tsx | 273 ----- frontend/src/pages/Bounties.tsx | 346 ------ frontend/src/pages/BountyDetail.tsx | 1019 ----------------- frontend/src/pages/Dashboard.tsx | 443 ------- frontend/src/pages/Products.tsx | 808 ------------- tsconfig.json => frontend/tsconfig.json | 6 +- vite.config.ts => frontend/vite.config.ts | 8 +- todo.md | 144 --- 优化完成总结.md | 173 --- 前端优化建议.md | 620 ---------- 添加商品功能文档.md | 524 --------- 116 files changed, 7821 insertions(+), 5657 deletions(-) delete mode 100644 QA_CHECKLIST.md delete mode 100644 README.md create mode 100644 backend/apps/bounties/migrations/0005_add_indexes.py create mode 100644 backend/apps/bounties/migrations/0006_rename_bounty_status_created_idx_bounties_status_ba7f3d_idx_and_more.py create mode 100644 backend/apps/common/__init__.py create mode 100644 backend/apps/common/errors.py create mode 100644 backend/apps/common/serializers.py create mode 100644 backend/apps/favorites/migrations/0004_add_indexes.py create mode 100644 backend/apps/favorites/migrations/0005_rename_favorite_user_created_idx_favorites_user_id_9cc509_idx_and_more.py create mode 100644 backend/apps/notifications/migrations/0004_alter_notification_type.py create mode 100644 backend/apps/notifications/utils.py rename .gitkeep => backend/apps/products/management/__init__.py (100%) create mode 100644 backend/apps/products/management/commands/__init__.py create mode 100644 backend/apps/products/management/commands/init_data.py create mode 100644 backend/apps/products/migrations/0002_add_indexes.py create mode 100644 backend/apps/products/migrations/0003_rename_category_parent_sort_idx_categories_parent__5c622c_idx_and_more.py create mode 100644 backend/apps/products/migrations/0004_product_reject_reason_product_reviewed_at_and_more.py create mode 100644 backend/apps/users/management/__init__.py create mode 100644 backend/apps/users/management/commands/__init__.py create mode 100644 backend/apps/users/management/commands/createsuperadmin.py create mode 100644 backend/apps/users/migrations/0003_add_indexes.py create mode 100644 backend/apps/users/migrations/0004_rename_friendreq_receiver_status_idx_friend_requ_receive_383c2c_idx_and_more.py create mode 100644 backend/config/middleware.py create mode 100644 backend/requirements-dev.txt rename .prettierignore => frontend/.prettierignore (100%) rename .prettierrc => frontend/.prettierrc (100%) rename components.json => frontend/components.json (90%) rename package.json => frontend/package.json (95%) rename {patches => frontend/patches}/wouter@3.7.1.patch (100%) rename pnpm-lock.yaml => frontend/pnpm-lock.yaml (100%) rename {shared => frontend/shared}/_core/errors.ts (100%) rename {shared => frontend/shared}/const.ts (100%) rename {shared => frontend/shared}/types.ts (74%) delete mode 100644 frontend/src/_core/hooks/useAuth.ts create mode 100644 frontend/src/features/admin/pages/Admin.tsx rename frontend/src/{ => features/auth}/pages/Login.tsx (72%) create mode 100644 frontend/src/features/bounties/components/BountiesGrid.tsx create mode 100644 frontend/src/features/bounties/components/BountiesHeader.tsx create mode 100644 frontend/src/features/bounties/components/BountyActionsPanel.tsx create mode 100644 frontend/src/features/bounties/components/BountyApplicationsList.tsx create mode 100644 frontend/src/features/bounties/components/BountyComments.tsx create mode 100644 frontend/src/features/bounties/components/BountyDeliveries.tsx create mode 100644 frontend/src/features/bounties/components/BountyDisputes.tsx create mode 100644 frontend/src/features/bounties/components/BountyExtensions.tsx create mode 100644 frontend/src/features/bounties/components/BountyHeaderBar.tsx create mode 100644 frontend/src/features/bounties/components/BountyInfoCard.tsx create mode 100644 frontend/src/features/bounties/components/BountyPaymentTimeline.tsx create mode 100644 frontend/src/features/bounties/components/BountyReviews.tsx create mode 100644 frontend/src/features/bounties/pages/Bounties.tsx create mode 100644 frontend/src/features/bounties/pages/BountyDetail.tsx rename frontend/src/{ => features/common}/pages/NotFound.tsx (100%) create mode 100644 frontend/src/features/dashboard/pages/Dashboard.tsx rename frontend/src/{ => features/favorites}/pages/Favorites.tsx (87%) rename frontend/src/{components => features/friends}/FriendPanel.tsx (100%) rename frontend/src/{ => features/home}/pages/Home.tsx (99%) create mode 100644 frontend/src/features/products/components/ProductsHeader.tsx create mode 100644 frontend/src/features/products/components/RecommendedProducts.tsx create mode 100644 frontend/src/features/products/components/WebsitesSection.tsx rename frontend/src/{ => features/products}/pages/ProductComparison.tsx (94%) rename frontend/src/{ => features/products}/pages/ProductDetail.tsx (88%) create mode 100644 frontend/src/features/products/pages/Products.tsx rename frontend/src/{ => features/search}/pages/Search.tsx (100%) create mode 100644 frontend/src/features/settings/pages/Settings.tsx create mode 100644 frontend/src/hooks/useAuth.ts create mode 100644 frontend/src/hooks/useDebouncedValue.ts delete mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/api/admin.ts create mode 100644 frontend/src/lib/api/auth.ts create mode 100644 frontend/src/lib/api/bounties.ts create mode 100644 frontend/src/lib/api/categories.ts create mode 100644 frontend/src/lib/api/client.ts create mode 100644 frontend/src/lib/api/errors.ts create mode 100644 frontend/src/lib/api/favorites.ts create mode 100644 frontend/src/lib/api/friends.ts create mode 100644 frontend/src/lib/api/index.ts create mode 100644 frontend/src/lib/api/notifications.ts create mode 100644 frontend/src/lib/api/payments.ts create mode 100644 frontend/src/lib/api/products.ts create mode 100644 frontend/src/lib/api/search.ts create mode 100644 frontend/src/lib/api/websites.ts create mode 100644 frontend/src/lib/i18n/errorMessages.ts create mode 100644 frontend/src/lib/types.ts delete mode 100644 frontend/src/pages/Admin.tsx delete mode 100644 frontend/src/pages/Bounties.tsx delete mode 100644 frontend/src/pages/BountyDetail.tsx delete mode 100644 frontend/src/pages/Dashboard.tsx delete mode 100644 frontend/src/pages/Products.tsx rename tsconfig.json => frontend/tsconfig.json (81%) rename vite.config.ts => frontend/vite.config.ts (93%) delete mode 100644 todo.md delete mode 100644 优化完成总结.md delete mode 100644 前端优化建议.md delete mode 100644 添加商品功能文档.md diff --git a/.gitignore b/.gitignore index c1dbd8b..e76cfd3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ **/node_modules .pnpm-store/ +# Vite cache +.vite/ + # Build outputs dist/ build/ diff --git a/QA_CHECKLIST.md b/QA_CHECKLIST.md deleted file mode 100644 index db85ad2..0000000 --- a/QA_CHECKLIST.md +++ /dev/null @@ -1,32 +0,0 @@ -# 最小验收清单(功能自测) - -## 账号与权限 -- 注册新账号并登录,能获取 `/api/auth/me` 返回用户信息 -- 非管理员访问 `/admin` 自动跳回首页 -- 管理员访问 `/admin` 正常看到用户/悬赏/支付事件列表 - -## 悬赏流程 -- 发布悬赏 → 列表/详情可见 -- 其他用户申请接单 → 发布者在详情页接受申请 -- 接单者提交交付内容 → 发布者验收(通过/驳回) -- 验收通过后可完成悬赏 -- 完成后双方可互评 - -## 支付流程 -- 发布者创建托管支付(跳转 Stripe) -- 完成支付后悬赏状态为已托管 -- 发布者完成悬赏后释放赏金 -- 支付事件在管理后台可查看 - -## 收藏与价格监控 -- 收藏商品并设置监控(目标价/提醒开关) -- 刷新价格后产生价格历史记录 -- 达到目标价时产生通知 - -## 通知与偏好 -- 通知列表可查看、单条已读、全部已读 -- 通知偏好开关能控制对应类型通知是否创建 - -## 争议与延期 -- 接单者可提交延期申请,发布者可同意/拒绝 -- 争议可由任一方发起,管理员可处理 diff --git a/README.md b/README.md deleted file mode 100644 index a5602bf..0000000 --- a/README.md +++ /dev/null @@ -1,131 +0,0 @@ -# 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/apps/admin/api.py b/backend/apps/admin/api.py index 1049dcb..65c0317 100644 --- a/backend/apps/admin/api.py +++ b/backend/apps/admin/api.py @@ -2,9 +2,12 @@ Admin API routes for managing core data. """ from typing import List, Optional +from datetime import datetime +from django.utils import timezone from ninja import Router, Schema from ninja.errors import HttpError from ninja_jwt.authentication import JWTAuth +from ninja.pagination import paginate, PageNumberPagination from apps.users.models import User from apps.products.models import Product, Website, Category @@ -14,7 +17,7 @@ router = Router() def require_admin(user): - if not user or user.role != 'admin': + if not user or user.role != 'admin' or not user.is_active: raise HttpError(403, "仅管理员可访问") @@ -22,10 +25,9 @@ class UserOut(Schema): id: int open_id: str name: Optional[str] = None - email: Optional[str] = None role: str is_active: bool - created_at: str + created_at: datetime class UserUpdateIn(Schema): @@ -47,7 +49,14 @@ class BountyAdminOut(Schema): acceptor_id: Optional[int] = None is_escrowed: bool is_paid: bool - created_at: str + created_at: datetime + + class Config: + from_attributes = True + + @staticmethod + def resolve_reward(obj): + return str(obj.reward) class PaymentEventOut(Schema): @@ -56,7 +65,10 @@ class PaymentEventOut(Schema): event_type: str bounty_id: Optional[int] = None success: bool - processed_at: str + processed_at: datetime + + class Config: + from_attributes = True class DisputeOut(Schema): @@ -64,116 +76,160 @@ class DisputeOut(Schema): bounty_id: int initiator_id: int status: str - created_at: str + created_at: datetime + + class Config: + from_attributes = True @router.get("/users/", response=List[UserOut], auth=JWTAuth()) +@paginate(PageNumberPagination, page_size=20) 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 - ] + return User.objects.all().order_by('-created_at') @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) + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + raise HttpError(404, "用户不存在") 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(), - ) + return user @router.get("/categories/", response=List[SimpleOut], auth=JWTAuth()) +@paginate(PageNumberPagination, page_size=50) def list_categories(request): require_admin(request.auth) - return [SimpleOut(id=c.id, name=c.name) for c in Category.objects.all()] + return Category.objects.values('id', 'name').order_by('name') @router.get("/websites/", response=List[SimpleOut], auth=JWTAuth()) +@paginate(PageNumberPagination, page_size=50) def list_websites(request): require_admin(request.auth) - return [SimpleOut(id=w.id, name=w.name) for w in Website.objects.all()] + return Website.objects.values('id', 'name').order_by('name') @router.get("/products/", response=List[SimpleOut], auth=JWTAuth()) +@paginate(PageNumberPagination, page_size=50) def list_products(request): require_admin(request.auth) - return [SimpleOut(id=p.id, name=p.name) for p in Product.objects.all()] + return Product.objects.values('id', 'name').order_by('-created_at') @router.get("/bounties/", response=List[BountyAdminOut], auth=JWTAuth()) +@paginate(PageNumberPagination, page_size=20) def list_bounties(request, status: Optional[str] = None): require_admin(request.auth) - queryset = Bounty.objects.all().order_by('-created_at') + queryset = Bounty.objects.select_related("publisher", "acceptor").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 - ] + return queryset @router.get("/disputes/", response=List[DisputeOut], auth=JWTAuth()) +@paginate(PageNumberPagination, page_size=20) 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 - ] + return disputes @router.get("/payments/", response=List[PaymentEventOut], auth=JWTAuth()) +@paginate(PageNumberPagination, page_size=20) 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 - ] + return PaymentEvent.objects.all().order_by('-processed_at') + + +# ==================== Product Review ==================== + +class ProductAdminOut(Schema): + """Product admin output schema with all fields.""" + id: int + name: str + description: Optional[str] = None + image: Optional[str] = None + category_id: int + category_name: Optional[str] = None + status: str + submitted_by_id: Optional[int] = None + submitted_by_name: Optional[str] = None + reject_reason: Optional[str] = None + reviewed_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + @staticmethod + def resolve_category_name(obj): + return obj.category.name if obj.category else None + + @staticmethod + def resolve_submitted_by_name(obj): + if obj.submitted_by: + return obj.submitted_by.name or obj.submitted_by.open_id + return None + + +class ProductReviewIn(Schema): + """Product review input schema.""" + approved: bool + reject_reason: Optional[str] = None + + +@router.get("/products/pending/", response=List[ProductAdminOut], auth=JWTAuth()) +@paginate(PageNumberPagination, page_size=20) +def list_pending_products(request): + """List all pending products for review.""" + require_admin(request.auth) + return Product.objects.select_related("category", "submitted_by").filter( + status='pending' + ).order_by('-created_at') + + +@router.get("/products/all/", response=List[ProductAdminOut], auth=JWTAuth()) +@paginate(PageNumberPagination, page_size=20) +def list_all_products(request, status: Optional[str] = None): + """List all products with optional status filter.""" + require_admin(request.auth) + queryset = Product.objects.select_related("category", "submitted_by").order_by('-created_at') + if status: + queryset = queryset.filter(status=status) + return queryset + + +@router.post("/products/{product_id}/review/", response=ProductAdminOut, auth=JWTAuth()) +def review_product(request, product_id: int, data: ProductReviewIn): + """Approve or reject a product.""" + require_admin(request.auth) + + try: + product = Product.objects.select_related("category", "submitted_by").get(id=product_id) + except Product.DoesNotExist: + raise HttpError(404, "商品不存在") + + if product.status != 'pending': + raise HttpError(400, "只能审核待审核状态的商品") + + if data.approved: + product.status = 'approved' + product.reject_reason = None + else: + if not data.reject_reason: + raise HttpError(400, "拒绝时需要提供原因") + product.status = 'rejected' + product.reject_reason = data.reject_reason + + product.reviewed_at = timezone.now() + product.save() + + return product diff --git a/backend/apps/bounties/api.py b/backend/apps/bounties/api.py index 6704490..48d0c5d 100644 --- a/backend/apps/bounties/api.py +++ b/backend/apps/bounties/api.py @@ -8,6 +8,7 @@ from ninja import Router, Query from ninja.errors import HttpError from ninja_jwt.authentication import JWTAuth from ninja.pagination import paginate, PageNumberPagination +from django.conf import settings from django.db import transaction from django.db.models import Count, Q from django.shortcuts import get_object_or_404 @@ -32,8 +33,9 @@ from .schemas import ( BountyReviewOut, BountyReviewIn, BountyExtensionRequestOut, BountyExtensionRequestIn, BountyExtensionReviewIn, ) -from apps.users.schemas import UserOut -from apps.notifications.models import Notification, NotificationPreference +from apps.common.serializers import serialize_user, serialize_bounty +from apps.notifications.models import Notification +from apps.notifications.utils import should_notify router = Router() @@ -51,84 +53,17 @@ def parse_reward(raw_reward) -> Decimal: 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"): + min_reward = getattr(settings, "BOUNTY_MIN_REWARD", Decimal("0.01")) + max_reward = getattr(settings, "BOUNTY_MAX_REWARD", Decimal("99999999.99")) + if value < min_reward: + raise ValueError(f"reward must be at least {min_reward}") + if value > max_reward: 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]) @@ -150,8 +85,8 @@ def list_bounties(request, filters: BountyFilter = Query(...)): 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] + + return queryset @router.get("/search/", response=List[BountyWithDetailsOut]) @@ -166,7 +101,7 @@ def search_bounties(request, q: str): ) .filter(Q(title__icontains=q) | Q(description__icontains=q)) ) - return [serialize_bounty(b, include_counts=True) for b in queryset] + return queryset @router.get("/my-published/", response=List[BountyWithDetailsOut], auth=JWTAuth()) @@ -181,7 +116,7 @@ def my_published_bounties(request): ) .filter(publisher=request.auth) ) - return [serialize_bounty(b, include_counts=True) for b in queryset] + return queryset @router.get("/my-accepted/", response=List[BountyWithDetailsOut], auth=JWTAuth()) @@ -196,7 +131,7 @@ def my_accepted_bounties(request): ) .filter(acceptor=request.auth) ) - return [serialize_bounty(b, include_counts=True) for b in queryset] + return queryset @router.get("/{bounty_id}", response=BountyWithDetailsOut) @@ -215,6 +150,21 @@ def get_bounty(request, bounty_id: int): @router.post("/", response=BountyOut, auth=JWTAuth()) def create_bounty(request, data: BountyIn): """Create a new bounty.""" + # 标题和描述验证 + if not data.title or len(data.title.strip()) < 2: + raise HttpError(400, "标题至少需要2个字符") + if len(data.title) > 200: + raise HttpError(400, "标题不能超过200个字符") + if not data.description or len(data.description.strip()) < 10: + raise HttpError(400, "描述至少需要10个字符") + if len(data.description) > 5000: + raise HttpError(400, "描述不能超过5000个字符") + + # 截止时间验证 + if data.deadline: + if data.deadline <= timezone.now(): + raise HttpError(400, "截止时间必须是未来的时间") + payload = data.dict() try: payload["reward"] = parse_reward(payload.get("reward")) @@ -259,6 +209,13 @@ def cancel_bounty(request, bounty_id: int): if bounty.status not in [Bounty.Status.OPEN, Bounty.Status.IN_PROGRESS]: raise HttpError(400, "无法取消此悬赏") + # 如果已托管资金且处于进行中状态,需要处理退款 + if bounty.is_escrowed and bounty.status == Bounty.Status.IN_PROGRESS: + raise HttpError(400, "已托管资金的进行中悬赏无法直接取消,请联系客服处理退款") + + # 如果只是开放状态且已托管,标记需要退款 + refund_needed = bounty.is_escrowed and bounty.status == Bounty.Status.OPEN + bounty.status = Bounty.Status.CANCELLED bounty.save() @@ -273,7 +230,11 @@ def cancel_bounty(request, bounty_id: int): related_type="bounty", ) - return MessageOut(message="悬赏已取消", success=True) + message = "悬赏已取消" + if refund_needed: + message = "悬赏已取消,托管资金将在3-5个工作日内退回" + + return MessageOut(message=message, success=True) @router.post("/{bounty_id}/complete", response=MessageOut, auth=JWTAuth()) @@ -357,6 +318,10 @@ def my_application(request, bounty_id: int): @router.post("/{bounty_id}/applications/", response=BountyApplicationOut, auth=JWTAuth()) def submit_application(request, bounty_id: int, data: BountyApplicationIn): """Submit an application for a bounty.""" + # 申请消息长度验证 + if data.message and len(data.message) > 1000: + raise HttpError(400, "申请消息不能超过1000个字符") + bounty = get_object_or_404(Bounty, id=bounty_id) if bounty.status != Bounty.Status.OPEN: @@ -401,17 +366,27 @@ def submit_application(request, bounty_id: int, data: BountyApplicationIn): @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(): + # 使用 select_for_update 加锁防止并发 + bounty = Bounty.objects.select_for_update().filter(id=bounty_id).first() + if not bounty: + raise HttpError(404, "悬赏不存在") + + if bounty.publisher_id != request.auth.id: + raise HttpError(403, "只有发布者可以接受申请") + + if bounty.status != Bounty.Status.OPEN: + raise HttpError(400, "无法接受此悬赏的申请") + + app = BountyApplication.objects.select_for_update().filter( + id=application_id, bounty_id=bounty_id + ).first() + if not app: + raise HttpError(404, "申请不存在") + + if app.status != BountyApplication.Status.PENDING: + raise HttpError(400, "该申请已被处理") + # Accept this application app.status = BountyApplication.Status.ACCEPTED app.save() @@ -426,7 +401,7 @@ def accept_application(request, bounty_id: int, application_id: int): bounty.status = Bounty.Status.IN_PROGRESS bounty.save() - # Notify acceptor + # Notify acceptor (outside transaction for better performance) if should_notify(app.applicant, Notification.Type.BOUNTY_ACCEPTED): Notification.objects.create( user=app.applicant, @@ -469,8 +444,14 @@ def list_comments(request, bounty_id: int): @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) + # 评论内容验证 + if not data.content or len(data.content.strip()) < 1: + raise HttpError(400, "评论内容不能为空") + if len(data.content) > 2000: + raise HttpError(400, "评论内容不能超过2000个字符") + bounty = get_object_or_404(Bounty, id=bounty_id) + comment = BountyComment.objects.create( bounty=bounty, user=request.auth, @@ -491,7 +472,9 @@ def create_comment(request, bounty_id: int, data: BountyCommentIn): # Notify parent comment author (if replying) if data.parent_id: - parent = BountyComment.objects.get(id=data.parent_id) + parent = get_object_or_404( + BountyComment.objects.select_related("user"), 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, @@ -627,8 +610,28 @@ def list_disputes(request, bounty_id: int): 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]: + + # 检查悬赏状态是否允许创建争议 + if bounty.status not in [Bounty.Status.IN_PROGRESS, Bounty.Status.DISPUTED]: + raise HttpError(400, "只有进行中或已有争议的悬赏才能发起争议") + + # 检查权限(考虑acceptor可能为None) + allowed_users = [bounty.publisher_id] + if bounty.acceptor_id: + allowed_users.append(bounty.acceptor_id) + if request.auth.id not in allowed_users: raise HttpError(403, "无权限发起争议") + + # 检查是否已有未解决的争议 + if BountyDispute.objects.filter(bounty=bounty, status=BountyDispute.Status.OPEN).exists(): + raise HttpError(400, "该悬赏已有未解决的争议") + + # 争议原因验证 + if not data.reason or len(data.reason.strip()) < 10: + raise HttpError(400, "争议原因至少需要10个字符") + if len(data.reason) > 2000: + raise HttpError(400, "争议原因不能超过2000个字符") + dispute = BountyDispute.objects.create( bounty=bounty, initiator=request.auth, @@ -665,13 +668,47 @@ def resolve_dispute(request, bounty_id: int, dispute_id: int, data: BountyDisput """Resolve dispute (admin only).""" if request.auth.role != 'admin': raise HttpError(403, "仅管理员可处理争议") + + bounty = get_object_or_404(Bounty, id=bounty_id) 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() + + # 检查是否还有其他未解决的争议 + has_open_disputes = BountyDispute.objects.filter( + bounty=bounty, + status=BountyDispute.Status.OPEN + ).exists() + + # 如果没有其他未解决的争议,将悬赏状态恢复为进行中 + if not has_open_disputes and bounty.status == Bounty.Status.DISPUTED: + bounty.status = Bounty.Status.IN_PROGRESS + bounty.save() + + # 通知相关用户 + users_to_notify = [] + if bounty.publisher and bounty.publisher_id != request.auth.id: + users_to_notify.append(bounty.publisher) + if bounty.acceptor and bounty.acceptor_id != request.auth.id: + users_to_notify.append(bounty.acceptor) + + for user in users_to_notify: + if should_notify(user, Notification.Type.SYSTEM): + Notification.objects.create( + user=user, + 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/migrations/0005_add_indexes.py b/backend/apps/bounties/migrations/0005_add_indexes.py new file mode 100644 index 0000000..4fc0c7d --- /dev/null +++ b/backend/apps/bounties/migrations/0005_add_indexes.py @@ -0,0 +1,70 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bounties", "0004_extension_request"), + ] + + operations = [ + migrations.AddIndex( + model_name="bounty", + index=models.Index(fields=["status", "created_at"], name="bounty_status_created_idx"), + ), + migrations.AddIndex( + model_name="bounty", + index=models.Index(fields=["publisher", "created_at"], name="bounty_publisher_created_idx"), + ), + migrations.AddIndex( + model_name="bounty", + index=models.Index(fields=["acceptor", "created_at"], name="bounty_acceptor_created_idx"), + ), + migrations.AddIndex( + model_name="bountyapplication", + index=models.Index(fields=["bounty", "status"], name="bountyapp_bounty_status_idx"), + ), + migrations.AddIndex( + model_name="bountyapplication", + index=models.Index(fields=["applicant", "status"], name="bountyapp_applicant_status_idx"), + ), + migrations.AddIndex( + model_name="bountycomment", + index=models.Index(fields=["bounty", "created_at"], name="bountycomment_bounty_created_idx"), + ), + migrations.AddIndex( + model_name="bountycomment", + index=models.Index(fields=["parent", "created_at"], name="bountycomment_parent_created_idx"), + ), + migrations.AddIndex( + model_name="bountydelivery", + index=models.Index(fields=["bounty", "status"], name="bountydelivery_bounty_status_idx"), + ), + migrations.AddIndex( + model_name="bountydelivery", + index=models.Index(fields=["submitted_at"], name="bountydelivery_submitted_idx"), + ), + migrations.AddIndex( + model_name="bountydispute", + index=models.Index(fields=["bounty", "status"], name="bountydispute_bounty_status_idx"), + ), + migrations.AddIndex( + model_name="bountydispute", + index=models.Index(fields=["status", "created_at"], name="bountydispute_status_created_idx"), + ), + migrations.AddIndex( + model_name="bountyreview", + index=models.Index(fields=["bounty", "created_at"], name="bountyreview_bounty_created_idx"), + ), + migrations.AddIndex( + model_name="bountyreview", + index=models.Index(fields=["reviewee", "created_at"], name="bountyreview_reviewee_created_idx"), + ), + migrations.AddIndex( + model_name="bountyextensionrequest", + index=models.Index(fields=["bounty", "status"], name="bountyext_bounty_status_idx"), + ), + migrations.AddIndex( + model_name="bountyextensionrequest", + index=models.Index(fields=["requester", "status"], name="bountyext_requester_status_idx"), + ), + ] diff --git a/backend/apps/bounties/migrations/0006_rename_bounty_status_created_idx_bounties_status_ba7f3d_idx_and_more.py b/backend/apps/bounties/migrations/0006_rename_bounty_status_created_idx_bounties_status_ba7f3d_idx_and_more.py new file mode 100644 index 0000000..b1ada3b --- /dev/null +++ b/backend/apps/bounties/migrations/0006_rename_bounty_status_created_idx_bounties_status_ba7f3d_idx_and_more.py @@ -0,0 +1,88 @@ +# Generated by Django 4.2.27 on 2026-01-28 07:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('bounties', '0005_add_indexes'), + ] + + operations = [ + migrations.RenameIndex( + model_name='bounty', + new_name='bounties_status_ba7f3d_idx', + old_name='bounty_status_created_idx', + ), + migrations.RenameIndex( + model_name='bounty', + new_name='bounties_publish_1e0a79_idx', + old_name='bounty_publisher_created_idx', + ), + migrations.RenameIndex( + model_name='bounty', + new_name='bounties_accepto_d36c7a_idx', + old_name='bounty_acceptor_created_idx', + ), + migrations.RenameIndex( + model_name='bountyapplication', + new_name='bountyAppli_bounty__e03270_idx', + old_name='bountyapp_bounty_status_idx', + ), + migrations.RenameIndex( + model_name='bountyapplication', + new_name='bountyAppli_applica_1cc9cb_idx', + old_name='bountyapp_applicant_status_idx', + ), + migrations.RenameIndex( + model_name='bountycomment', + new_name='bountyComme_bounty__375c15_idx', + old_name='bountycomment_bounty_created_idx', + ), + migrations.RenameIndex( + model_name='bountycomment', + new_name='bountyComme_parent__e9d6ac_idx', + old_name='bountycomment_parent_created_idx', + ), + migrations.RenameIndex( + model_name='bountydelivery', + new_name='bountyDeliv_bounty__fe1a17_idx', + old_name='bountydelivery_bounty_status_idx', + ), + migrations.RenameIndex( + model_name='bountydelivery', + new_name='bountyDeliv_submitt_86ba61_idx', + old_name='bountydelivery_submitted_idx', + ), + migrations.RenameIndex( + model_name='bountydispute', + new_name='bountyDispu_bounty__fda581_idx', + old_name='bountydispute_bounty_status_idx', + ), + migrations.RenameIndex( + model_name='bountydispute', + new_name='bountyDispu_status_f1e0a9_idx', + old_name='bountydispute_status_created_idx', + ), + migrations.RenameIndex( + model_name='bountyextensionrequest', + new_name='bountyExten_bounty__79bd84_idx', + old_name='bountyext_bounty_status_idx', + ), + migrations.RenameIndex( + model_name='bountyextensionrequest', + new_name='bountyExten_request_a34cea_idx', + old_name='bountyext_requester_status_idx', + ), + migrations.RenameIndex( + model_name='bountyreview', + new_name='bountyRevie_bounty__2cfe16_idx', + old_name='bountyreview_bounty_created_idx', + ), + migrations.RenameIndex( + model_name='bountyreview', + new_name='bountyRevie_reviewe_72fa13_idx', + old_name='bountyreview_reviewee_created_idx', + ), + ] diff --git a/backend/apps/bounties/models.py b/backend/apps/bounties/models.py index 77ff2bf..0ed7a07 100644 --- a/backend/apps/bounties/models.py +++ b/backend/apps/bounties/models.py @@ -69,6 +69,11 @@ class Bounty(models.Model): verbose_name = '悬赏' verbose_name_plural = '悬赏' ordering = ['-created_at'] + indexes = [ + models.Index(fields=["status", "created_at"]), + models.Index(fields=["publisher", "created_at"]), + models.Index(fields=["acceptor", "created_at"]), + ] def __str__(self): return self.title @@ -110,6 +115,10 @@ class BountyApplication(models.Model): verbose_name = '悬赏申请' verbose_name_plural = '悬赏申请' unique_together = ['bounty', 'applicant'] + indexes = [ + models.Index(fields=["bounty", "status"]), + models.Index(fields=["applicant", "status"]), + ] def __str__(self): return f"{self.applicant} -> {self.bounty.title}" @@ -148,6 +157,10 @@ class BountyComment(models.Model): verbose_name = '悬赏评论' verbose_name_plural = '悬赏评论' ordering = ['created_at'] + indexes = [ + models.Index(fields=["bounty", "created_at"]), + models.Index(fields=["parent", "created_at"]), + ] def __str__(self): return f"{self.user} on {self.bounty.title}" @@ -190,6 +203,10 @@ class BountyDelivery(models.Model): verbose_name = '悬赏交付' verbose_name_plural = '悬赏交付' ordering = ['-submitted_at'] + indexes = [ + models.Index(fields=["bounty", "status"]), + models.Index(fields=["submitted_at"]), + ] def __str__(self): return f"{self.bounty.title} - {self.submitter}" @@ -233,6 +250,10 @@ class BountyDispute(models.Model): verbose_name = '悬赏争议' verbose_name_plural = '悬赏争议' ordering = ['-created_at'] + indexes = [ + models.Index(fields=["bounty", "status"]), + models.Index(fields=["status", "created_at"]), + ] def __str__(self): return f"{self.bounty.title} - {self.initiator}" @@ -270,6 +291,10 @@ class BountyReview(models.Model): verbose_name_plural = '悬赏评价' unique_together = ['bounty', 'reviewer'] ordering = ['-created_at'] + indexes = [ + models.Index(fields=["bounty", "created_at"]), + models.Index(fields=["reviewee", "created_at"]), + ] def __str__(self): return f"{self.bounty.title} - {self.reviewer}" @@ -341,6 +366,10 @@ class BountyExtensionRequest(models.Model): verbose_name = '延期申请' verbose_name_plural = '延期申请' ordering = ['-created_at'] + indexes = [ + models.Index(fields=["bounty", "status"]), + models.Index(fields=["requester", "status"]), + ] def __str__(self): return f"{self.bounty.title} - {self.requester}" diff --git a/backend/apps/bounties/payments.py b/backend/apps/bounties/payments.py index e7e2258..282b718 100644 --- a/backend/apps/bounties/payments.py +++ b/backend/apps/bounties/payments.py @@ -4,6 +4,7 @@ Stripe payment integration for bounties. from typing import Optional from decimal import Decimal from ninja import Router +from ninja.errors import HttpError from ninja_jwt.authentication import JWTAuth from django.conf import settings from django.shortcuts import get_object_or_404 @@ -15,7 +16,8 @@ import json from .models import Bounty, PaymentEvent from apps.users.models import User -from apps.notifications.models import Notification, NotificationPreference +from apps.notifications.models import Notification +from apps.notifications.utils import should_notify router = Router() @@ -23,24 +25,6 @@ router = Router() 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.""" @@ -81,13 +65,13 @@ def create_escrow(request, data: PaymentSchemas.EscrowIn): 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 + raise HttpError(403, "Only the publisher can create escrow") if bounty.is_escrowed: - return {"error": "Bounty is already escrowed"}, 400 + raise HttpError(400, "Bounty is already escrowed") if bounty.status != Bounty.Status.OPEN: - return {"error": "Can only escrow open bounties"}, 400 + raise HttpError(400, "Can only escrow open bounties") try: # Create or get Stripe customer @@ -137,7 +121,7 @@ def create_escrow(request, data: PaymentSchemas.EscrowIn): ) except stripe.error.StripeError as e: - return {"error": str(e)}, 400 + raise HttpError(400, str(e)) @router.get("/connect/status/", response=PaymentSchemas.ConnectStatusOut, auth=JWTAuth()) @@ -175,7 +159,7 @@ def get_connect_status(request): ) except stripe.error.StripeError as e: - return {"error": str(e)}, 400 + raise HttpError(400, str(e)) @router.post("/connect/setup/", response=PaymentSchemas.ConnectSetupOut, auth=JWTAuth()) @@ -215,7 +199,7 @@ def setup_connect_account(request, return_url: str, refresh_url: str): ) except stripe.error.StripeError as e: - return {"error": str(e)}, 400 + raise HttpError(400, str(e)) @router.post("/{bounty_id}/release/", response=PaymentSchemas.MessageOut, auth=JWTAuth()) @@ -224,23 +208,23 @@ def release_payout(request, bounty_id: int): 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 + raise HttpError(403, "Only the publisher can release payment") if bounty.status != Bounty.Status.COMPLETED: - return {"error": "Bounty must be completed to release payment"}, 400 + raise HttpError(400, "Bounty must be completed to release payment") if bounty.is_paid: - return {"error": "Payment has already been released"}, 400 + raise HttpError(400, "Payment has already been released") if not bounty.is_escrowed: - return {"error": "Bounty is not escrowed"}, 400 + raise HttpError(400, "Bounty is not escrowed") if not bounty.acceptor: - return {"error": "No acceptor to pay"}, 400 + raise HttpError(400, "No acceptor to pay") acceptor = bounty.acceptor if not acceptor.stripe_account_id: - return {"error": "Acceptor has not set up payment account"}, 400 + raise HttpError(400, "Acceptor has not set up payment account") try: with transaction.atomic(): @@ -249,7 +233,7 @@ def release_payout(request, bounty_id: int): 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 + platform_fee_percent = getattr(settings, "BOUNTY_PLATFORM_FEE_PERCENT", Decimal("0.05")) payout_amount = bounty.reward * (1 - platform_fee_percent) # Create transfer to acceptor @@ -282,7 +266,7 @@ def release_payout(request, bounty_id: int): return PaymentSchemas.MessageOut(message="赏金已释放", success=True) except stripe.error.StripeError as e: - return {"error": str(e)}, 400 + raise HttpError(400, str(e)) def handle_webhook(request: HttpRequest) -> HttpResponse: diff --git a/backend/apps/common/__init__.py b/backend/apps/common/__init__.py new file mode 100644 index 0000000..86c1312 --- /dev/null +++ b/backend/apps/common/__init__.py @@ -0,0 +1 @@ +"""Shared utilities for backend apps.""" diff --git a/backend/apps/common/errors.py b/backend/apps/common/errors.py new file mode 100644 index 0000000..21f9dc2 --- /dev/null +++ b/backend/apps/common/errors.py @@ -0,0 +1,36 @@ +from typing import Any, Optional + + +def map_status_to_code(status_code: int) -> str: + if status_code == 400: + return "bad_request" + if status_code == 401: + return "unauthorized" + if status_code == 403: + return "forbidden" + if status_code == 404: + return "not_found" + if status_code == 409: + return "conflict" + if status_code == 429: + return "rate_limited" + if status_code >= 500: + return "server_error" + return "error" + + +def build_error_payload( + *, + status_code: int, + message: str, + details: Optional[Any] = None, + code: Optional[str] = None, +) -> dict: + payload = { + "code": code or map_status_to_code(status_code), + "message": message, + "status": status_code, + } + if details is not None: + payload["details"] = details + return payload diff --git a/backend/apps/common/serializers.py b/backend/apps/common/serializers.py new file mode 100644 index 0000000..6ebeb56 --- /dev/null +++ b/backend/apps/common/serializers.py @@ -0,0 +1,53 @@ +from apps.users.schemas import UserOut +from apps.bounties.schemas import BountyOut, BountyWithDetailsOut + + +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, + created_at=user.created_at, + updated_at=user.updated_at, + ) + + +def serialize_bounty(bounty, include_counts: bool = 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) diff --git a/backend/apps/favorites/api.py b/backend/apps/favorites/api.py index f90a86d..dbb4987 100644 --- a/backend/apps/favorites/api.py +++ b/backend/apps/favorites/api.py @@ -22,7 +22,8 @@ from .schemas import ( MessageOut, ) from apps.products.models import Product, Website, ProductPrice -from apps.notifications.models import Notification, NotificationPreference +from apps.notifications.models import Notification +from apps.notifications.utils import should_notify router = Router() @@ -55,11 +56,6 @@ def serialize_favorite(favorite): ) -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 @@ -90,7 +86,7 @@ def record_price_for_monitor(monitor: PriceMonitor, price: Decimal): 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): + if should_alert and should_notify(monitor.user, Notification.Type.PRICE_ALERT): Notification.objects.create( user=monitor.user, type=Notification.Type.PRICE_ALERT, @@ -116,7 +112,7 @@ def list_favorites(request, tag_id: Optional[int] = None): ).prefetch_related('tag_mappings', 'tag_mappings__tag') if tag_id: - queryset = queryset.filter(tag_mappings__tag_id=tag_id) + queryset = queryset.filter(tag_mappings__tag_id=tag_id).distinct() return [serialize_favorite(f) for f in queryset] @@ -197,22 +193,13 @@ def get_favorite(request, favorite_id: int): @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( + favorite_id = 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} + ).values_list("id", flat=True).first() + + return {"is_favorited": bool(favorite_id), "favorite_id": favorite_id} @router.post("/", response=FavoriteOut, auth=JWTAuth()) @@ -274,7 +261,7 @@ 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 + raise HttpError(400, "Tag with this name already exists") tag = FavoriteTag.objects.create( user=request.auth, diff --git a/backend/apps/favorites/migrations/0004_add_indexes.py b/backend/apps/favorites/migrations/0004_add_indexes.py new file mode 100644 index 0000000..30c2a59 --- /dev/null +++ b/backend/apps/favorites/migrations/0004_add_indexes.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("favorites", "0003_price_monitor_notify"), + ] + + operations = [ + migrations.AddIndex( + model_name="favorite", + index=models.Index(fields=["user", "created_at"], name="favorite_user_created_idx"), + ), + migrations.AddIndex( + model_name="favoritetag", + index=models.Index(fields=["user", "created_at"], name="favoritetag_user_created_idx"), + ), + migrations.AddIndex( + model_name="favoritetagmapping", + index=models.Index(fields=["tag", "created_at"], name="favoritetag_tag_created_idx"), + ), + migrations.AddIndex( + model_name="pricemonitor", + index=models.Index(fields=["user", "is_active"], name="pricemonitor_user_active_idx"), + ), + migrations.AddIndex( + model_name="pricehistory", + index=models.Index(fields=["monitor", "recorded_at"], name="pricehistory_monitor_recorded_idx"), + ), + ] diff --git a/backend/apps/favorites/migrations/0005_rename_favorite_user_created_idx_favorites_user_id_9cc509_idx_and_more.py b/backend/apps/favorites/migrations/0005_rename_favorite_user_created_idx_favorites_user_id_9cc509_idx_and_more.py new file mode 100644 index 0000000..52a36d3 --- /dev/null +++ b/backend/apps/favorites/migrations/0005_rename_favorite_user_created_idx_favorites_user_id_9cc509_idx_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.27 on 2026-01-28 07:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('favorites', '0004_add_indexes'), + ] + + operations = [ + migrations.RenameIndex( + model_name='favorite', + new_name='favorites_user_id_9cc509_idx', + old_name='favorite_user_created_idx', + ), + migrations.RenameIndex( + model_name='favoritetag', + new_name='favoriteTag_user_id_b8d48c_idx', + old_name='favoritetag_user_created_idx', + ), + migrations.RenameIndex( + model_name='favoritetagmapping', + new_name='favoriteTag_tag_id_f111e4_idx', + old_name='favoritetag_tag_created_idx', + ), + migrations.RenameIndex( + model_name='pricehistory', + new_name='priceHistor_monitor_ca804f_idx', + old_name='pricehistory_monitor_recorded_idx', + ), + migrations.RenameIndex( + model_name='pricemonitor', + new_name='priceMonito_user_id_d5804f_idx', + old_name='pricemonitor_user_active_idx', + ), + ] diff --git a/backend/apps/favorites/models.py b/backend/apps/favorites/models.py index b7c615e..a77196a 100644 --- a/backend/apps/favorites/models.py +++ b/backend/apps/favorites/models.py @@ -34,6 +34,9 @@ class Favorite(models.Model): verbose_name = '收藏' verbose_name_plural = '收藏' unique_together = ['user', 'product', 'website'] + indexes = [ + models.Index(fields=["user", "created_at"]), + ] def __str__(self): return f"{self.user} - {self.product.name}" @@ -59,6 +62,9 @@ class FavoriteTag(models.Model): verbose_name = '收藏标签' verbose_name_plural = '收藏标签' unique_together = ['user', 'name'] + indexes = [ + models.Index(fields=["user", "created_at"]), + ] def __str__(self): return self.name @@ -87,6 +93,9 @@ class FavoriteTagMapping(models.Model): verbose_name = '收藏标签映射' verbose_name_plural = '收藏标签映射' unique_together = ['favorite', 'tag'] + indexes = [ + models.Index(fields=["tag", "created_at"]), + ] def __str__(self): return f"{self.favorite} - {self.tag.name}" @@ -153,6 +162,9 @@ class PriceMonitor(models.Model): db_table = 'priceMonitors' verbose_name = '价格监控' verbose_name_plural = '价格监控' + indexes = [ + models.Index(fields=["user", "is_active"]), + ] def __str__(self): return f"Monitor: {self.favorite}" @@ -190,6 +202,9 @@ class PriceHistory(models.Model): verbose_name = '价格历史' verbose_name_plural = '价格历史' ordering = ['-recorded_at'] + indexes = [ + models.Index(fields=["monitor", "recorded_at"]), + ] def __str__(self): return f"{self.monitor.favorite} - {self.price}" diff --git a/backend/apps/notifications/api.py b/backend/apps/notifications/api.py index 06647b1..a9748d6 100644 --- a/backend/apps/notifications/api.py +++ b/backend/apps/notifications/api.py @@ -11,7 +11,8 @@ from django.shortcuts import get_object_or_404 from django.http import HttpResponse from django.utils import timezone -from .models import Notification, NotificationPreference +from .models import Notification +from .utils import get_notification_preference from .schemas import ( NotificationOut, UnreadCountOut, @@ -63,7 +64,7 @@ def list_notifications( @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) + preference = get_notification_preference(request.auth) return NotificationPreferenceOut( user_id=preference.user_id, enable_bounty=preference.enable_bounty, @@ -76,7 +77,7 @@ def get_preferences(request): @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) + preference = get_notification_preference(request.auth) update_data = data.dict(exclude_unset=True) for key, value in update_data.items(): setattr(preference, key, value) diff --git a/backend/apps/notifications/migrations/0004_alter_notification_type.py b/backend/apps/notifications/migrations/0004_alter_notification_type.py new file mode 100644 index 0000000..07cb63a --- /dev/null +++ b/backend/apps/notifications/migrations/0004_alter_notification_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.27 on 2026-01-28 07:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0003_notification_preferences'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='type', + field=models.CharField(choices=[('bounty_accepted', '悬赏被接受'), ('bounty_completed', '悬赏已完成'), ('new_comment', '新评论'), ('payment_received', '收到付款'), ('price_alert', '价格提醒'), ('system', '系统通知')], max_length=30, verbose_name='类型'), + ), + ] diff --git a/backend/apps/notifications/utils.py b/backend/apps/notifications/utils.py new file mode 100644 index 0000000..3c868b5 --- /dev/null +++ b/backend/apps/notifications/utils.py @@ -0,0 +1,31 @@ +""" +Notification helpers for preference checks. +""" +from typing import Optional + +from .models import Notification, NotificationPreference + + +def get_notification_preference(user) -> Optional[NotificationPreference]: + if not user: + return None + preference, _ = NotificationPreference.objects.get_or_create(user=user) + return preference + + +def should_notify(user, notification_type: str) -> bool: + """Check if user has enabled notification type.""" + preference = get_notification_preference(user) + if not preference: + return False + 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 diff --git a/backend/apps/products/api.py b/backend/apps/products/api.py index afe4692..0564abc 100644 --- a/backend/apps/products/api.py +++ b/backend/apps/products/api.py @@ -2,6 +2,8 @@ Products API routes for categories, websites, products and prices. """ from typing import List, Optional +import re +import time from decimal import Decimal, InvalidOperation import csv import io @@ -9,9 +11,11 @@ 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.conf import settings +from django.db.models import Count, Min, Max, Q, Prefetch, F +from django.db import transaction, IntegrityError from django.shortcuts import get_object_or_404 +from django.views.decorators.cache import cache_page from .models import Category, Website, Product, ProductPrice from .schemas import ( @@ -19,7 +23,9 @@ from .schemas import ( WebsiteOut, WebsiteIn, WebsiteFilter, ProductOut, ProductIn, ProductWithPricesOut, ProductPriceOut, ProductPriceIn, ProductFilter, + ProductSearchFilter, ImportResultOut, + MyProductOut, ) from apps.favorites.models import Favorite @@ -31,21 +37,67 @@ website_router = Router() # ==================== Category Routes ==================== @category_router.get("/", response=List[CategoryOut]) +@cache_page(settings.CACHE_TTL_SECONDS) def list_categories(request): """Get all categories.""" return Category.objects.all() @category_router.get("/{slug}", response=CategoryOut) +@cache_page(settings.CACHE_TTL_SECONDS) def get_category_by_slug(request, slug: str): """Get category by slug.""" return get_object_or_404(Category, slug=slug) +def require_admin(user): + """Check if user is admin.""" + from ninja.errors import HttpError + if not user or user.role != 'admin' or not user.is_active: + raise HttpError(403, "仅管理员可执行此操作") + + +def normalize_category_slug(name: str, slug: str) -> str: + """Normalize category slug and ensure it's not empty.""" + raw_slug = (slug or "").strip() + if raw_slug: + return raw_slug + + base = re.sub(r"\s+", "-", name.strip().lower()) + base = re.sub(r"[^a-z0-9-]", "", base) + if not base: + base = f"category-{int(time.time())}" + + if Category.objects.filter(slug=base).exists(): + suffix = 1 + while Category.objects.filter(slug=f"{base}-{suffix}").exists(): + suffix += 1 + base = f"{base}-{suffix}" + return base + + @category_router.post("/", response=CategoryOut, auth=JWTAuth()) def create_category(request, data: CategoryIn): """Create a new category.""" - category = Category.objects.create(**data.dict()) + name = (data.name or "").strip() + if not name: + raise HttpError(400, "分类名称不能为空") + + slug = normalize_category_slug(name, data.slug) + if len(slug) > 100: + raise HttpError(400, "分类标识过长") + + try: + category = Category.objects.create( + name=name, + slug=slug, + description=data.description, + icon=data.icon, + parent_id=data.parent_id, + sort_order=data.sort_order or 0, + ) + except IntegrityError: + raise HttpError(400, "分类标识已存在") return category @@ -53,6 +105,7 @@ def create_category(request, data: CategoryIn): @website_router.get("/", response=List[WebsiteOut]) @paginate(PageNumberPagination, page_size=20) +@cache_page(settings.CACHE_TTL_SECONDS) def list_websites(request, filters: WebsiteFilter = Query(...)): """Get all websites with optional filters.""" queryset = Website.objects.all() @@ -66,6 +119,7 @@ def list_websites(request, filters: WebsiteFilter = Query(...)): @website_router.get("/{website_id}", response=WebsiteOut) +@cache_page(settings.CACHE_TTL_SECONDS) def get_website(request, website_id: int): """Get website by ID.""" return get_object_or_404(Website, id=website_id) @@ -73,7 +127,7 @@ def get_website(request, website_id: int): @website_router.post("/", response=WebsiteOut, auth=JWTAuth()) def create_website(request, data: WebsiteIn): - """Create a new website.""" + """Create a new website. Any authenticated user can create.""" website = Website.objects.create(**data.dict()) return website @@ -225,15 +279,22 @@ def import_products_csv(request, file: UploadedFile = File(...)): @router.get("/recommendations/", response=List[ProductOut]) def recommend_products(request, limit: int = 12): """Get recommended products based on favorites or popularity.""" + # 限制 limit 最大值 + if limit < 1: + limit = 1 + if limit > 100: + limit = 100 + user = getattr(request, "auth", None) - base_queryset = Product.objects.all() + # 只显示已审核通过的商品 + base_queryset = Product.objects.select_related("category").filter(status='approved') 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) + Product.objects.filter(id__in=favorite_product_ids, status='approved') .values_list("category_id", flat=True) .distinct() ) @@ -251,9 +312,11 @@ def recommend_products(request, limit: int = 12): @router.get("/", response=List[ProductOut]) @paginate(PageNumberPagination, page_size=20) +@cache_page(settings.CACHE_TTL_SECONDS) def list_products(request, filters: ProductFilter = Query(...)): - """Get all products with optional filters.""" - queryset = Product.objects.all() + """Get all approved products with optional filters.""" + # 只显示已审核通过的商品 + queryset = Product.objects.select_related("category").filter(status='approved') if filters.category_id: queryset = queryset.filter(category_id=filters.category_id) @@ -263,16 +326,40 @@ def list_products(request, filters: ProductFilter = Query(...)): Q(description__icontains=filters.search) ) + needs_price_stats = ( + filters.min_price is not None + or filters.max_price is not None + or (filters.sort_by or "").lower() in ("price_asc", "price_desc") + ) + if needs_price_stats: + queryset = queryset.annotate(lowest_price=Min("prices__price")) + if filters.min_price is not None: + queryset = queryset.filter(lowest_price__gte=filters.min_price) + if filters.max_price is not None: + queryset = queryset.filter(lowest_price__lte=filters.max_price) + + sort_by = (filters.sort_by or "newest").lower() + if sort_by == "oldest": + queryset = queryset.order_by("created_at") + elif sort_by == "price_asc": + queryset = queryset.order_by(F("lowest_price").asc(nulls_last=True), "-created_at") + elif sort_by == "price_desc": + queryset = queryset.order_by(F("lowest_price").desc(nulls_last=True), "-created_at") + else: + queryset = queryset.order_by("-created_at") + return queryset @router.get("/{product_id}", response=ProductOut) +@cache_page(settings.CACHE_TTL_SECONDS) 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) +@cache_page(settings.CACHE_TTL_SECONDS) 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) @@ -317,15 +404,48 @@ def get_product_with_prices(request, product_id: int): @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) +@cache_page(settings.CACHE_TTL_SECONDS) +def search_products(request, q: str, filters: ProductSearchFilter = Query(...)): + """Search approved products by name or description.""" + prices_prefetch = Prefetch( + "prices", + queryset=ProductPrice.objects.select_related("website"), ) + # 只搜索已审核通过的商品 + products = ( + Product.objects.select_related("category") + .filter(Q(name__icontains=q) | Q(description__icontains=q), status='approved') + ) + if filters.category_id: + products = products.filter(category_id=filters.category_id) + + needs_price_stats = ( + filters.min_price is not None + or filters.max_price is not None + or (filters.sort_by or "").lower() in ("price_asc", "price_desc") + ) + if needs_price_stats: + products = products.annotate(lowest_price=Min("prices__price")) + if filters.min_price is not None: + products = products.filter(lowest_price__gte=filters.min_price) + if filters.max_price is not None: + products = products.filter(lowest_price__lte=filters.max_price) + + sort_by = (filters.sort_by or "newest").lower() + if sort_by == "oldest": + products = products.order_by("created_at") + elif sort_by == "price_asc": + products = products.order_by(F("lowest_price").asc(nulls_last=True), "-created_at") + elif sort_by == "price_desc": + products = products.order_by(F("lowest_price").desc(nulls_last=True), "-created_at") + else: + products = products.order_by("-created_at") + + products = products.prefetch_related(prices_prefetch) result = [] for product in products: - prices = ProductPrice.objects.filter(product=product).select_related('website') + prices = list(product.prices.all()) price_list = [ ProductPriceOut( id=pp.id, @@ -342,8 +462,8 @@ def search_products(request, q: str): ) for pp in prices ] - - price_stats = prices.aggregate(lowest=Min('price'), highest=Max('price')) + lowest_price = min((pp.price for pp in prices), default=None) + highest_price = max((pp.price for pp in prices), default=None) result.append(ProductWithPricesOut( id=product.id, @@ -354,8 +474,8 @@ def search_products(request, q: str): created_at=product.created_at, updated_at=product.updated_at, prices=price_list, - lowest_price=price_stats['lowest'], - highest_price=price_stats['highest'], + lowest_price=lowest_price, + highest_price=highest_price, )) return result @@ -363,14 +483,43 @@ def search_products(request, q: str): @router.post("/", response=ProductOut, auth=JWTAuth()) def create_product(request, data: ProductIn): - """Create a new product.""" - product = Product.objects.create(**data.dict()) + """Create a new product. Admin creates approved, others create pending.""" + user = request.auth + is_admin = user and user.role == 'admin' and user.is_active + + product = Product.objects.create( + name=data.name, + description=data.description, + image=data.image, + category_id=data.category_id, + status='approved' if is_admin else 'pending', + submitted_by=user, + ) return product +@router.get("/my/", response=List[MyProductOut], auth=JWTAuth()) +@paginate(PageNumberPagination, page_size=20) +def my_products(request, status: Optional[str] = None): + """Get current user's submitted products.""" + user = request.auth + queryset = Product.objects.filter(submitted_by=user).order_by('-created_at') + if status: + queryset = queryset.filter(status=status) + return queryset + + @router.post("/prices/", response=ProductPriceOut, auth=JWTAuth()) def add_product_price(request, data: ProductPriceIn): - """Add a price for a product.""" + """Add a price for a product. Admin or product owner can add.""" + user = request.auth + is_admin = user and user.role == 'admin' and user.is_active + + # 检查商品是否存在并验证权限 + product = get_object_or_404(Product, id=data.product_id) + if not is_admin and product.submitted_by_id != user.id: + from ninja.errors import HttpError + raise HttpError(403, "只能为自己提交的商品添加价格") price = ProductPrice.objects.create(**data.dict()) website = price.website diff --git a/.gitkeep b/backend/apps/products/management/__init__.py similarity index 100% rename from .gitkeep rename to backend/apps/products/management/__init__.py diff --git a/backend/apps/products/management/commands/__init__.py b/backend/apps/products/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/products/management/commands/init_data.py b/backend/apps/products/management/commands/init_data.py new file mode 100644 index 0000000..9957e69 --- /dev/null +++ b/backend/apps/products/management/commands/init_data.py @@ -0,0 +1,114 @@ +""" +Management command to initialize sample categories and websites. +""" +from django.core.management.base import BaseCommand +from apps.products.models import Category, Website + + +class Command(BaseCommand): + help = "Initialize sample categories and websites" + + def handle(self, *args, **options): + # Create categories + categories_data = [ + {"name": "数码产品", "slug": "digital", "description": "手机、电脑、平板等数码产品", "icon": "💻"}, + {"name": "家用电器", "slug": "appliance", "description": "家电、厨房电器等", "icon": "🏠"}, + {"name": "服装鞋包", "slug": "fashion", "description": "服装、鞋子、箱包等", "icon": "👗"}, + {"name": "美妆护肤", "slug": "beauty", "description": "化妆品、护肤品等", "icon": "💄"}, + {"name": "食品饮料", "slug": "food", "description": "食品、零食、饮料等", "icon": "🍔"}, + {"name": "图书音像", "slug": "books", "description": "图书、音像制品等", "icon": "📚"}, + {"name": "运动户外", "slug": "sports", "description": "运动器材、户外装备等", "icon": "⚽"}, + {"name": "母婴用品", "slug": "baby", "description": "母婴、儿童用品等", "icon": "👶"}, + ] + + created_categories = 0 + for cat_data in categories_data: + category, created = Category.objects.get_or_create( + slug=cat_data["slug"], + defaults=cat_data + ) + if created: + created_categories += 1 + self.stdout.write(f" 创建分类: {category.name}") + + self.stdout.write(self.style.SUCCESS(f"分类: 新建 {created_categories} 个")) + + # Get digital category for websites + digital_category = Category.objects.filter(slug="digital").first() + appliance_category = Category.objects.filter(slug="appliance").first() + fashion_category = Category.objects.filter(slug="fashion").first() + + # Create websites + websites_data = [ + { + "name": "京东", + "url": "https://www.jd.com", + "description": "京东商城", + "category": digital_category, + "is_verified": True, + }, + { + "name": "淘宝", + "url": "https://www.taobao.com", + "description": "淘宝网", + "category": fashion_category, + "is_verified": True, + }, + { + "name": "天猫", + "url": "https://www.tmall.com", + "description": "天猫商城", + "category": fashion_category, + "is_verified": True, + }, + { + "name": "拼多多", + "url": "https://www.pinduoduo.com", + "description": "拼多多", + "category": digital_category, + "is_verified": True, + }, + { + "name": "苏宁易购", + "url": "https://www.suning.com", + "description": "苏宁易购", + "category": appliance_category, + "is_verified": True, + }, + { + "name": "国美", + "url": "https://www.gome.com.cn", + "description": "国美电器", + "category": appliance_category, + "is_verified": True, + }, + { + "name": "亚马逊中国", + "url": "https://www.amazon.cn", + "description": "亚马逊中国", + "category": digital_category, + "is_verified": True, + }, + { + "name": "当当网", + "url": "https://www.dangdang.com", + "description": "当当网", + "category": Category.objects.filter(slug="books").first(), + "is_verified": True, + }, + ] + + created_websites = 0 + for web_data in websites_data: + if web_data["category"] is None: + continue + website, created = Website.objects.get_or_create( + name=web_data["name"], + defaults=web_data + ) + if created: + created_websites += 1 + self.stdout.write(f" 创建网站: {website.name}") + + self.stdout.write(self.style.SUCCESS(f"网站: 新建 {created_websites} 个")) + self.stdout.write(self.style.SUCCESS("初始化完成!")) diff --git a/backend/apps/products/migrations/0002_add_indexes.py b/backend/apps/products/migrations/0002_add_indexes.py new file mode 100644 index 0000000..b192efe --- /dev/null +++ b/backend/apps/products/migrations/0002_add_indexes.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("products", "0001_initial"), + ] + + operations = [ + migrations.AddIndex( + model_name="category", + index=models.Index(fields=["parent", "sort_order"], name="category_parent_sort_idx"), + ), + migrations.AddIndex( + model_name="website", + index=models.Index(fields=["category", "is_verified"], name="website_category_verified_idx"), + ), + migrations.AddIndex( + model_name="product", + index=models.Index(fields=["category", "created_at"], name="product_category_created_idx"), + ), + migrations.AddIndex( + model_name="productprice", + index=models.Index(fields=["product", "website", "last_checked"], name="productprice_prod_web_checked_idx"), + ), + ] diff --git a/backend/apps/products/migrations/0003_rename_category_parent_sort_idx_categories_parent__5c622c_idx_and_more.py b/backend/apps/products/migrations/0003_rename_category_parent_sort_idx_categories_parent__5c622c_idx_and_more.py new file mode 100644 index 0000000..466ce4a --- /dev/null +++ b/backend/apps/products/migrations/0003_rename_category_parent_sort_idx_categories_parent__5c622c_idx_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.27 on 2026-01-28 07:35 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('products', '0002_add_indexes'), + ] + + operations = [ + migrations.RenameIndex( + model_name='category', + new_name='categories_parent__5c622c_idx', + old_name='category_parent_sort_idx', + ), + migrations.RenameIndex( + model_name='product', + new_name='products_categor_366566_idx', + old_name='product_category_created_idx', + ), + migrations.RenameIndex( + model_name='productprice', + new_name='productPric_product_7397d0_idx', + old_name='productprice_prod_web_checked_idx', + ), + migrations.RenameIndex( + model_name='website', + new_name='websites_categor_97d7c0_idx', + old_name='website_category_verified_idx', + ), + ] diff --git a/backend/apps/products/migrations/0004_product_reject_reason_product_reviewed_at_and_more.py b/backend/apps/products/migrations/0004_product_reject_reason_product_reviewed_at_and_more.py new file mode 100644 index 0000000..6f07d0d --- /dev/null +++ b/backend/apps/products/migrations/0004_product_reject_reason_product_reviewed_at_and_more.py @@ -0,0 +1,44 @@ +# Generated by Django 4.2.27 on 2026-01-28 07:53 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('products', '0003_rename_category_parent_sort_idx_categories_parent__5c622c_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='product', + name='reject_reason', + field=models.TextField(blank=True, null=True, verbose_name='拒绝原因'), + ), + migrations.AddField( + model_name='product', + name='reviewed_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='审核时间'), + ), + migrations.AddField( + model_name='product', + name='status', + field=models.CharField(choices=[('pending', '待审核'), ('approved', '已通过'), ('rejected', '已拒绝')], default='pending', max_length=20, verbose_name='审核状态'), + ), + migrations.AddField( + model_name='product', + name='submitted_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='submitted_products', to=settings.AUTH_USER_MODEL, verbose_name='提交者'), + ), + migrations.AddIndex( + model_name='product', + index=models.Index(fields=['status', 'created_at'], name='products_status_678497_idx'), + ), + migrations.AddIndex( + model_name='product', + index=models.Index(fields=['submitted_by', 'status'], name='products_submitt_1319f6_idx'), + ), + ] diff --git a/backend/apps/products/models.py b/backend/apps/products/models.py index 76b137b..eca2563 100644 --- a/backend/apps/products/models.py +++ b/backend/apps/products/models.py @@ -2,6 +2,7 @@ Product models for categories, websites, products and prices. """ from django.db import models +from django.conf import settings class Category(models.Model): @@ -28,6 +29,9 @@ class Category(models.Model): verbose_name = '分类' verbose_name_plural = '分类' ordering = ['sort_order', 'id'] + indexes = [ + models.Index(fields=["parent", "sort_order"]), + ] def __str__(self): return self.name @@ -58,6 +62,9 @@ class Website(models.Model): verbose_name = '网站' verbose_name_plural = '网站' ordering = ['sort_order', 'id'] + indexes = [ + models.Index(fields=["category", "is_verified"]), + ] def __str__(self): return self.name @@ -66,6 +73,11 @@ class Website(models.Model): class Product(models.Model): """Products for price comparison.""" + class Status(models.TextChoices): + PENDING = 'pending', '待审核' + APPROVED = 'approved', '已通过' + REJECTED = 'rejected', '已拒绝' + id = models.AutoField(primary_key=True) name = models.CharField('商品名称', max_length=300) description = models.TextField('描述', blank=True, null=True) @@ -76,6 +88,22 @@ class Product(models.Model): related_name='products', verbose_name='分类' ) + status = models.CharField( + '审核状态', + max_length=20, + choices=Status.choices, + default=Status.PENDING + ) + submitted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='submitted_products', + verbose_name='提交者' + ) + reject_reason = models.TextField('拒绝原因', blank=True, null=True) + reviewed_at = models.DateTimeField('审核时间', blank=True, null=True) created_at = models.DateTimeField('创建时间', auto_now_add=True) updated_at = models.DateTimeField('更新时间', auto_now=True) @@ -83,6 +111,11 @@ class Product(models.Model): db_table = 'products' verbose_name = '商品' verbose_name_plural = '商品' + indexes = [ + models.Index(fields=["category", "created_at"]), + models.Index(fields=["status", "created_at"]), + models.Index(fields=["submitted_by", "status"]), + ] def __str__(self): return self.name @@ -124,6 +157,9 @@ class ProductPrice(models.Model): verbose_name = '商品价格' verbose_name_plural = '商品价格' unique_together = ['product', 'website'] + indexes = [ + models.Index(fields=["product", "website", "last_checked"]), + ] 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 index f2d1340..dceb480 100644 --- a/backend/apps/products/schemas.py +++ b/backend/apps/products/schemas.py @@ -79,6 +79,10 @@ class ProductOut(Schema): description: Optional[str] = None image: Optional[str] = None category_id: int + status: str = "approved" + submitted_by_id: Optional[int] = None + reject_reason: Optional[str] = None + reviewed_at: Optional[datetime] = None created_at: datetime updated_at: datetime @@ -98,6 +102,20 @@ class ProductIn(Schema): category_id: int +class MyProductOut(Schema): + """User's product output schema.""" + id: int + name: str + description: Optional[str] = None + image: Optional[str] = None + category_id: int + status: str + reject_reason: Optional[str] = None + reviewed_at: Optional[datetime] = None + created_at: datetime + updated_at: datetime + + class ProductPriceIn(Schema): """Product price input schema.""" product_id: int @@ -123,6 +141,17 @@ class ProductFilter(FilterSchema): """Product filter schema.""" category_id: Optional[int] = None search: Optional[str] = None + min_price: Optional[Decimal] = None + max_price: Optional[Decimal] = None + sort_by: Optional[str] = None + + +class ProductSearchFilter(FilterSchema): + """Product search filter schema.""" + category_id: Optional[int] = None + min_price: Optional[Decimal] = None + max_price: Optional[Decimal] = None + sort_by: Optional[str] = None class WebsiteFilter(FilterSchema): diff --git a/backend/apps/users/api.py b/backend/apps/users/api.py index 3a77521..b4b26b8 100644 --- a/backend/apps/users/api.py +++ b/backend/apps/users/api.py @@ -2,15 +2,17 @@ User authentication API routes. """ from typing import Optional -from ninja import Router +from ninja import Router, Schema +from ninja.errors import HttpError from ninja_jwt.authentication import JWTAuth from ninja_jwt.tokens import RefreshToken from django.conf import settings from django.http import HttpRequest, HttpResponse +from urllib.parse import urlparse, urlencode import requests from .models import User -from .schemas import UserOut, UserUpdate, TokenOut, OAuthCallbackIn, MessageOut, RegisterIn, LoginIn +from .schemas import UserOut, UserPrivateOut, UserUpdate, TokenOut, OAuthCallbackIn, MessageOut, RegisterIn, LoginIn router = Router() @@ -22,18 +24,44 @@ def get_current_user(request: HttpRequest) -> Optional[User]: return None -@router.get("/me", response=UserOut, auth=JWTAuth()) +def _is_valid_url(value: str) -> bool: + if not value: + return False + parsed = urlparse(value) + return parsed.scheme in {"http", "https"} and bool(parsed.netloc) + + +def _require_oauth_config(): + if not settings.OAUTH_CLIENT_ID: + raise HttpError(500, "OAuth 未配置客户端 ID") + if not settings.OAUTH_AUTHORIZE_URL: + raise HttpError(500, "OAuth 未配置授权地址") + if not settings.OAUTH_TOKEN_URL: + raise HttpError(500, "OAuth 未配置令牌地址") + if not settings.OAUTH_USERINFO_URL: + raise HttpError(500, "OAuth 未配置用户信息地址") + if not _is_valid_url(settings.OAUTH_REDIRECT_URI): + raise HttpError(500, "OAuth 回调地址无效") + + +@router.get("/me", response=UserPrivateOut, auth=JWTAuth()) def get_me(request): """Get current user information.""" return request.auth -@router.patch("/me", response=UserOut, auth=JWTAuth()) +@router.patch("/me", response=UserPrivateOut, auth=JWTAuth()) def update_me(request, data: UserUpdate): """Update current user information.""" user = request.auth + # 验证邮箱格式 + if data.email is not None: + validate_email(data.email) + if data.name is not None: + if len(data.name) > 50: + raise HttpError(400, "名称不能超过50个字符") user.name = data.name if data.email is not None: user.email = data.email @@ -55,6 +83,36 @@ def logout(request): return MessageOut(message="已退出登录", success=True) +class ChangePasswordIn(Schema): + """Change password input schema.""" + current_password: str + new_password: str + + +@router.post("/change-password", response=MessageOut, auth=JWTAuth()) +def change_password(request, data: ChangePasswordIn): + """Change current user's password.""" + user = request.auth + + # 验证当前密码 + if not user.check_password(data.current_password): + raise HttpError(400, "当前密码错误") + + # 验证新密码 + if len(data.new_password) < 6: + raise HttpError(400, "新密码长度至少6位") + if len(data.new_password) > 128: + raise HttpError(400, "新密码长度不能超过128位") + if data.current_password == data.new_password: + raise HttpError(400, "新密码不能与当前密码相同") + + # 更新密码 + user.set_password(data.new_password) + user.save() + + return MessageOut(message="密码已更新", success=True) + + @router.post("/refresh", response=TokenOut) def refresh_token(request, refresh_token: str): """Refresh access token using refresh token.""" @@ -64,19 +122,50 @@ def refresh_token(request, refresh_token: str): access_token=str(refresh.access_token), refresh_token=str(refresh), ) - except Exception as e: - return {"error": str(e)}, 401 + except Exception: + raise HttpError(401, "刷新令牌无效或已过期") + + +def validate_password(password: str) -> None: + """Validate password strength.""" + if len(password) < 6: + raise HttpError(400, "密码长度至少6位") + if len(password) > 128: + raise HttpError(400, "密码长度不能超过128位") + + +def validate_email(email: Optional[str]) -> None: + """Validate email format.""" + if email: + import re + email_pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + if not re.match(email_pattern, email): + raise HttpError(400, "邮箱格式不正确") @router.post("/register", response=TokenOut) def register(request, data: RegisterIn): """Register new user with password.""" + # 输入验证 + validate_password(data.password) + + # 邮箱必填且格式验证 + if not data.email: + raise HttpError(400, "邮箱为必填项") + validate_email(data.email) + + # 检查用户名是否已存在 if User.objects.filter(open_id=data.open_id).exists(): - return {"error": "账号已存在"}, 400 + raise HttpError(400, "用户名已被使用") + + # 检查邮箱是否已存在 + if User.objects.filter(email=data.email).exists(): + raise HttpError(400, "邮箱已被注册") + user = User.objects.create_user( open_id=data.open_id, password=data.password, - name=data.name, + name=data.name or data.open_id, # 默认显示名称为用户名 email=data.email, login_method="password", ) @@ -89,13 +178,23 @@ def register(request, data: RegisterIn): @router.post("/login", response=TokenOut) def login(request, data: LoginIn): - """Login with open_id and password.""" + """Login with open_id or email and password.""" + from django.db.models import Q + + # 支持用户名或邮箱登录 try: - user = User.objects.get(open_id=data.open_id) + user = User.objects.get(Q(open_id=data.open_id) | Q(email=data.open_id)) except User.DoesNotExist: - return {"error": "账号或密码错误"}, 401 + raise HttpError(401, "账号或密码错误") + except User.MultipleObjectsReturned: + # 如果同时匹配多个用户,优先使用open_id匹配 + try: + user = User.objects.get(open_id=data.open_id) + except User.DoesNotExist: + raise HttpError(401, "账号或密码错误") + if not user.check_password(data.password): - return {"error": "账号或密码错误"}, 401 + raise HttpError(401, "账号或密码错误") refresh = RefreshToken.for_user(user) return TokenOut( access_token=str(refresh.access_token), @@ -106,27 +205,29 @@ def login(request, data: LoginIn): @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 + _require_oauth_config() 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" - + if not _is_valid_url(redirect): + raise HttpError(400, "回调地址无效") + query = urlencode( + { + "client_id": settings.OAUTH_CLIENT_ID, + "redirect_uri": redirect, + "response_type": "code", + } + ) + oauth_url = f"{settings.OAUTH_AUTHORIZE_URL}?{query}" 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: + _require_oauth_config() # Exchange code for access token token_response = requests.post( - "https://oauth.example.com/token", + settings.OAUTH_TOKEN_URL, data={ "client_id": settings.OAUTH_CLIENT_ID, "client_secret": settings.OAUTH_CLIENT_SECRET, @@ -137,18 +238,18 @@ def oauth_callback(request, data: OAuthCallbackIn): ) if token_response.status_code != 200: - return {"error": "OAuth token exchange failed"}, 400 + raise HttpError(400, "OAuth token exchange failed") oauth_data = token_response.json() # Get user info from OAuth provider user_response = requests.get( - "https://oauth.example.com/userinfo", + settings.OAUTH_USERINFO_URL, headers={"Authorization": f"Bearer {oauth_data['access_token']}"} ) if user_response.status_code != 200: - return {"error": "Failed to get user info"}, 400 + raise HttpError(400, "Failed to get user info") user_info = user_response.json() @@ -171,8 +272,8 @@ def oauth_callback(request, data: OAuthCallbackIn): refresh_token=str(refresh), ) - except Exception as e: - return {"error": str(e)}, 500 + except Exception: + raise HttpError(500, "OAuth 登录失败") # Development endpoint for testing without OAuth @@ -180,7 +281,7 @@ def oauth_callback(request, data: OAuthCallbackIn): 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 + raise HttpError(403, "Not available in production") user, created = User.objects.get_or_create( open_id=open_id, diff --git a/backend/apps/users/management/__init__.py b/backend/apps/users/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/users/management/commands/__init__.py b/backend/apps/users/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/users/management/commands/createsuperadmin.py b/backend/apps/users/management/commands/createsuperadmin.py new file mode 100644 index 0000000..37030d9 --- /dev/null +++ b/backend/apps/users/management/commands/createsuperadmin.py @@ -0,0 +1,97 @@ +""" +Management command to create a superadmin user. +""" +import getpass +from django.core.management.base import BaseCommand, CommandError +from apps.users.models import User + + +class Command(BaseCommand): + help = 'Create a superadmin user with admin role' + + def add_arguments(self, parser): + parser.add_argument( + '--username', + type=str, + help='Username for the admin account', + ) + parser.add_argument( + '--email', + type=str, + help='Email for the admin account', + ) + parser.add_argument( + '--password', + type=str, + help='Password for the admin account (not recommended, use interactive mode instead)', + ) + parser.add_argument( + '--name', + type=str, + help='Display name for the admin account', + ) + parser.add_argument( + '--noinput', + action='store_true', + help='Do not prompt for input (requires --username and --password)', + ) + + def handle(self, *args, **options): + username = options.get('username') + email = options.get('email') + password = options.get('password') + name = options.get('name') + noinput = options.get('noinput') + + if noinput: + if not username or not password: + raise CommandError('--username and --password are required when using --noinput') + else: + # Interactive mode + if not username: + username = input('Username: ').strip() + if not username: + raise CommandError('Username cannot be empty') + + if not email: + email = input('Email (optional): ').strip() or None + + if not name: + name = input('Display name (optional): ').strip() or None + + if not password: + password = getpass.getpass('Password: ') + password_confirm = getpass.getpass('Password (again): ') + if password != password_confirm: + raise CommandError('Passwords do not match') + + if not password: + raise CommandError('Password cannot be empty') + + if len(password) < 6: + raise CommandError('Password must be at least 6 characters') + + # Check if user already exists + if User.objects.filter(open_id=username).exists(): + raise CommandError(f'User with username "{username}" already exists') + + if email and User.objects.filter(email=email).exists(): + raise CommandError(f'User with email "{email}" already exists') + + # Create the admin user + user = User( + open_id=username, + email=email, + name=name or username, + role='admin', + is_active=True, + ) + user.set_password(password) + user.save() + + self.stdout.write( + self.style.SUCCESS(f'Successfully created superadmin user "{username}"') + ) + self.stdout.write(f' - Role: admin') + self.stdout.write(f' - Email: {email or "(not set)"}') + self.stdout.write(f' - Display name: {name or username}') diff --git a/backend/apps/users/migrations/0003_add_indexes.py b/backend/apps/users/migrations/0003_add_indexes.py new file mode 100644 index 0000000..f3043bb --- /dev/null +++ b/backend/apps/users/migrations/0003_add_indexes.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0002_friend_request"), + ] + + operations = [ + migrations.AddIndex( + model_name="user", + index=models.Index(fields=["role", "is_active"], name="user_role_active_idx"), + ), + migrations.AddIndex( + model_name="user", + index=models.Index(fields=["created_at"], name="user_created_idx"), + ), + migrations.AddIndex( + model_name="friendrequest", + index=models.Index(fields=["receiver", "status"], name="friendreq_receiver_status_idx"), + ), + migrations.AddIndex( + model_name="friendrequest", + index=models.Index(fields=["requester", "status"], name="friendreq_requester_status_idx"), + ), + ] diff --git a/backend/apps/users/migrations/0004_rename_friendreq_receiver_status_idx_friend_requ_receive_383c2c_idx_and_more.py b/backend/apps/users/migrations/0004_rename_friendreq_receiver_status_idx_friend_requ_receive_383c2c_idx_and_more.py new file mode 100644 index 0000000..1eebaaa --- /dev/null +++ b/backend/apps/users/migrations/0004_rename_friendreq_receiver_status_idx_friend_requ_receive_383c2c_idx_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.27 on 2026-01-28 07:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0003_add_indexes'), + ] + + operations = [ + migrations.RenameIndex( + model_name='friendrequest', + new_name='friend_requ_receive_383c2c_idx', + old_name='friendreq_receiver_status_idx', + ), + migrations.RenameIndex( + model_name='friendrequest', + new_name='friend_requ_request_97ff9a_idx', + old_name='friendreq_requester_status_idx', + ), + migrations.RenameIndex( + model_name='user', + new_name='users_role_a8f2ba_idx', + old_name='user_role_active_idx', + ), + migrations.RenameIndex( + model_name='user', + new_name='users_created_6541e9_idx', + old_name='user_created_idx', + ), + migrations.AlterField( + model_name='friendrequest', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), + ), + ] diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py index 40716c0..d3ee6df 100644 --- a/backend/apps/users/models.py +++ b/backend/apps/users/models.py @@ -67,6 +67,10 @@ class User(AbstractBaseUser, PermissionsMixin): db_table = 'users' verbose_name = '用户' verbose_name_plural = '用户' + indexes = [ + models.Index(fields=["role", "is_active"]), + models.Index(fields=["created_at"]), + ] def __str__(self): return self.name or self.open_id @@ -116,6 +120,10 @@ class FriendRequest(models.Model): name="no_self_friend_request", ) ] + indexes = [ + models.Index(fields=["receiver", "status"]), + models.Index(fields=["requester", "status"]), + ] 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 index ace30fe..a809aa3 100644 --- a/backend/apps/users/schemas.py +++ b/backend/apps/users/schemas.py @@ -7,19 +7,23 @@ from ninja import Schema class UserOut(Schema): - """User output schema.""" + """Public 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 UserPrivateOut(UserOut): + """Private user output schema (includes sensitive fields).""" + stripe_customer_id: Optional[str] = None + stripe_account_id: Optional[str] = None + + class UserBrief(Schema): """Minimal user info for social features.""" id: int diff --git a/backend/config/api.py b/backend/config/api.py index c880470..a4e6cda 100644 --- a/backend/config/api.py +++ b/backend/config/api.py @@ -2,6 +2,7 @@ Django Ninja API configuration. """ from ninja import NinjaAPI +from ninja.errors import HttpError, ValidationError from ninja_jwt.authentication import JWTAuth # Import routers from apps @@ -14,6 +15,7 @@ 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 +from apps.common.errors import build_error_payload # Create main API instance api = NinjaAPI( @@ -22,6 +24,39 @@ api = NinjaAPI( description="Backend API for AI Web application", ) + +@api.exception_handler(HttpError) +def on_http_error(request, exc: HttpError): + return api.create_response( + request, + build_error_payload(status_code=exc.status_code, message=str(exc)), + status=exc.status_code, + ) + + +@api.exception_handler(ValidationError) +def on_validation_error(request, exc: ValidationError): + details = getattr(exc, "errors", None) + return api.create_response( + request, + build_error_payload( + status_code=400, + message="请求参数校验失败", + details=details, + code="validation_error", + ), + status=400, + ) + + +@api.exception_handler(Exception) +def on_unhandled_error(request, exc: Exception): + return api.create_response( + request, + build_error_payload(status_code=500, message="服务器内部错误"), + status=500, + ) + # Register routers api.add_router("/auth/", auth_router, tags=["认证"]) api.add_router("/friends/", friends_router, tags=["好友"]) diff --git a/backend/config/middleware.py b/backend/config/middleware.py new file mode 100644 index 0000000..64c785f --- /dev/null +++ b/backend/config/middleware.py @@ -0,0 +1,79 @@ +import hashlib +import time +from django.conf import settings +from django.core.cache import cache +from django.http import JsonResponse + + +class RateLimitMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if not getattr(settings, "RATE_LIMIT_ENABLE", False): + return self.get_response(request) + + path = request.path or "" + rate_limit_paths = getattr(settings, "RATE_LIMIT_PATHS", ["/api/"]) + if not any(path.startswith(prefix) for prefix in rate_limit_paths): + return self.get_response(request) + + window = int(getattr(settings, "RATE_LIMIT_WINDOW_SECONDS", 60)) + max_requests = int(getattr(settings, "RATE_LIMIT_REQUESTS", 120)) + now = int(time.time()) + window_key = now // max(window, 1) + + ident = request.META.get("HTTP_X_FORWARDED_FOR", "").split(",")[0].strip() + if not ident: + ident = request.META.get("REMOTE_ADDR", "unknown") + cache_key = f"rate:{ident}:{window_key}" + + try: + current = cache.incr(cache_key) + except ValueError: + cache.add(cache_key, 1, timeout=window) + current = 1 + + if current > max_requests: + return JsonResponse( + {"message": "请求过于频繁,请稍后再试", "success": False}, + status=429, + ) + + return self.get_response(request) + + +class ETagMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + + if request.method not in {"GET", "HEAD"}: + return response + if response.status_code != 200: + return response + if response.has_header("ETag"): + return response + + content_type = response.get("Content-Type", "") + if "application/json" not in content_type: + return response + + content = getattr(response, "content", b"") or b"" + max_bytes = int(getattr(settings, "ETAG_MAX_BYTES", 2 * 1024 * 1024)) + if len(content) > max_bytes: + return response + + etag = hashlib.sha256(content).hexdigest() + etag_value = f'W/"{etag}"' + response["ETag"] = etag_value + response["Cache-Control"] = "private, max-age=0" + + if_none_match = request.META.get("HTTP_IF_NONE_MATCH") + if if_none_match and if_none_match == etag_value: + response.status_code = 304 + response.content = b"" + + return response diff --git a/backend/config/search.py b/backend/config/search.py index 1cff8c6..4f7b6e3 100644 --- a/backend/config/search.py +++ b/backend/config/search.py @@ -4,12 +4,14 @@ Global search API routes. from typing import List from ninja import Router, Schema from django.db.models import Count, Q +from django.conf import settings +from django.views.decorators.cache import cache_page 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 +from apps.common.serializers import serialize_bounty router = Router() @@ -20,47 +22,12 @@ class SearchResultsOut(Schema): 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), - ) +def _serialize_bounty_with_counts(bounty): + return serialize_bounty(bounty, include_counts=True) @router.get("/", response=SearchResultsOut) +@cache_page(settings.CACHE_TTL_SECONDS) def global_search(request, q: str, limit: int = 10): """Search products, websites and bounties by keyword.""" keyword = (q or "").strip() @@ -68,13 +35,13 @@ def global_search(request, q: str, limit: int = 10): return SearchResultsOut(products=[], websites=[], bounties=[]) products = list( - Product.objects.filter( + Product.objects.select_related("category").filter( Q(name__icontains=keyword) | Q(description__icontains=keyword) ).order_by("-created_at")[:limit] ) websites = list( - Website.objects.filter( + Website.objects.select_related("category").filter( Q(name__icontains=keyword) | Q(description__icontains=keyword) ).order_by("-created_at")[:limit] ) @@ -92,5 +59,5 @@ def global_search(request, q: str, limit: int = 10): return SearchResultsOut( products=products, websites=websites, - bounties=[serialize_bounty(b) for b in bounties], + bounties=[_serialize_bounty_with_counts(b) for b in bounties], ) diff --git a/backend/config/settings.py b/backend/config/settings.py index 4dc07aa..8bf28b3 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -2,6 +2,7 @@ Django settings for ai_web project. """ import os +from decimal import Decimal from pathlib import Path from datetime import timedelta from dotenv import load_dotenv @@ -9,16 +10,33 @@ from dotenv import load_dotenv # Load environment variables load_dotenv() +# Environment helpers +def _env_bool(key: str, default: bool = False) -> bool: + value = os.getenv(key, str(default)) + return str(value).strip().lower() in {"1", "true", "yes", "y", "on"} + + +def _env_csv(key: str, default: str) -> list[str]: + raw = os.getenv(key) + if raw is None: + raw = default + return [item.strip() for item in raw.split(",") if item.strip()] + # 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') +DEBUG = _env_bool("DEBUG", False) +SECRET_KEY = os.getenv("DJANGO_SECRET_KEY") +if not SECRET_KEY: + if DEBUG: + SECRET_KEY = "django-insecure-dev-key" + else: + raise RuntimeError("DJANGO_SECRET_KEY is required 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(',') +ALLOWED_HOSTS = _env_csv("ALLOWED_HOSTS", "localhost,127.0.0.1" if DEBUG else "") +if not DEBUG and not ALLOWED_HOSTS: + raise RuntimeError("ALLOWED_HOSTS must be configured in production") # Application definition @@ -46,9 +64,11 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', + 'config.middleware.RateLimitMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', + 'config.middleware.ETagMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] @@ -74,28 +94,28 @@ WSGI_APPLICATION = 'config.wsgi.application' # Database -# 使用 SQLite 数据库(开发环境) -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': BASE_DIR / 'db.sqlite3', +DB_ENGINE = os.getenv("DB_ENGINE", "sqlite").lower() +if DB_ENGINE == "mysql": + 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", + }, + } + } +else: + 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 @@ -136,11 +156,17 @@ 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_ALLOWED_ORIGINS = _env_csv( + "CORS_ALLOWED_ORIGINS", + "http://localhost:5173,http://127.0.0.1:5173" if DEBUG else "", +) +if not DEBUG and not CORS_ALLOWED_ORIGINS: + raise RuntimeError("CORS_ALLOWED_ORIGINS must be configured in production") CORS_ALLOW_CREDENTIALS = True +CSRF_TRUSTED_ORIGINS = _env_csv( + "CSRF_TRUSTED_ORIGINS", + "http://localhost:5173,http://127.0.0.1:5173" if DEBUG else "", +) # JWT settings @@ -156,14 +182,52 @@ NINJA_JWT = { 'AUTH_COOKIE_SAMESITE': 'Lax', } +# Cache settings +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "ai_web_cache", + "TIMEOUT": int(os.getenv("CACHE_DEFAULT_TIMEOUT", "300")), + } +} +CACHE_TTL_SECONDS = int(os.getenv("CACHE_TTL_SECONDS", "60")) +ETAG_MAX_BYTES = int(os.getenv("ETAG_MAX_BYTES", str(2 * 1024 * 1024))) + +# Security settings for production +if not DEBUG: + SECURE_SSL_REDIRECT = _env_bool("SECURE_SSL_REDIRECT", True) + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + SESSION_COOKIE_SECURE = True + CSRF_COOKIE_SECURE = True + SESSION_COOKIE_SAMESITE = "Lax" + CSRF_COOKIE_SAMESITE = "Lax" + SECURE_HSTS_SECONDS = int(os.getenv("SECURE_HSTS_SECONDS", "31536000")) + SECURE_HSTS_INCLUDE_SUBDOMAINS = _env_bool("SECURE_HSTS_INCLUDE_SUBDOMAINS", True) + SECURE_HSTS_PRELOAD = _env_bool("SECURE_HSTS_PRELOAD", True) + SECURE_REFERRER_POLICY = os.getenv("SECURE_REFERRER_POLICY", "same-origin") + # 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', '') +# Bounty settings +BOUNTY_MIN_REWARD = Decimal(os.getenv("BOUNTY_MIN_REWARD", "0.01")) +BOUNTY_MAX_REWARD = Decimal(os.getenv("BOUNTY_MAX_REWARD", "99999999.99")) +BOUNTY_PLATFORM_FEE_PERCENT = Decimal(os.getenv("BOUNTY_PLATFORM_FEE_PERCENT", "0.05")) + # 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') +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") +OAUTH_AUTHORIZE_URL = os.getenv("OAUTH_AUTHORIZE_URL", "") +OAUTH_TOKEN_URL = os.getenv("OAUTH_TOKEN_URL", "") +OAUTH_USERINFO_URL = os.getenv("OAUTH_USERINFO_URL", "") + +# Basic rate limiting +RATE_LIMIT_ENABLE = _env_bool("RATE_LIMIT_ENABLE", not DEBUG) +RATE_LIMIT_REQUESTS = int(os.getenv("RATE_LIMIT_REQUESTS", "120")) +RATE_LIMIT_WINDOW_SECONDS = int(os.getenv("RATE_LIMIT_WINDOW_SECONDS", "60")) +RATE_LIMIT_PATHS = _env_csv("RATE_LIMIT_PATHS", "/api/") diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..8909efc --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,5 @@ +-r requirements.txt + +# Development +pytest>=7.4.0 +pytest-django>=4.5.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index ca6c3fc..503e250 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -17,6 +17,3 @@ 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/.prettierignore b/frontend/.prettierignore similarity index 100% rename from .prettierignore rename to frontend/.prettierignore diff --git a/.prettierrc b/frontend/.prettierrc similarity index 100% rename from .prettierrc rename to frontend/.prettierrc diff --git a/components.json b/frontend/components.json similarity index 90% rename from components.json rename to frontend/components.json index c191978..d965835 100644 --- a/components.json +++ b/frontend/components.json @@ -4,7 +4,7 @@ "rsc": false, "tsx": true, "tailwind": { - "css": "client/src/index.css", + "css": "src/index.css", "baseColor": "neutral", "cssVariables": true, "prefix": "" diff --git a/package.json b/frontend/package.json similarity index 95% rename from package.json rename to frontend/package.json index edabf5d..b3709a8 100644 --- a/package.json +++ b/frontend/package.json @@ -87,6 +87,13 @@ }, "overrides": { "tailwindcss>nanoid": "3.3.7" - } + }, + "ignoredBuiltDependencies": [ + "@tailwindcss/oxide", + "esbuild" + ], + "onlyBuiltDependencies": [ + "@tailwindcss/oxide" + ] } } diff --git a/patches/wouter@3.7.1.patch b/frontend/patches/wouter@3.7.1.patch similarity index 100% rename from patches/wouter@3.7.1.patch rename to frontend/patches/wouter@3.7.1.patch diff --git a/pnpm-lock.yaml b/frontend/pnpm-lock.yaml similarity index 100% rename from pnpm-lock.yaml rename to frontend/pnpm-lock.yaml diff --git a/shared/_core/errors.ts b/frontend/shared/_core/errors.ts similarity index 100% rename from shared/_core/errors.ts rename to frontend/shared/_core/errors.ts diff --git a/shared/const.ts b/frontend/shared/const.ts similarity index 100% rename from shared/const.ts rename to frontend/shared/const.ts diff --git a/shared/types.ts b/frontend/shared/types.ts similarity index 74% rename from shared/types.ts rename to frontend/shared/types.ts index 47ca6e0..da424d6 100644 --- a/shared/types.ts +++ b/frontend/shared/types.ts @@ -3,5 +3,4 @@ * Import shared types from this single entry point. */ -export type * from "../drizzle/schema"; export * from "./_core/errors"; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2d0bbfb..e48bc4c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,21 +1,22 @@ import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import NotFound from "@/pages/NotFound"; +import NotFound from "@/features/common/pages/NotFound"; import { Route, Switch } from "wouter"; import ErrorBoundary from "./components/ErrorBoundary"; -import FriendPanel from "./components/FriendPanel"; +import FriendPanel from "@/features/friends/FriendPanel"; import { ThemeProvider } from "./contexts/ThemeContext"; -import Home from "./pages/Home"; -import Login from "./pages/Login"; -import Products from "./pages/Products"; -import ProductDetail from "./pages/ProductDetail"; -import Bounties from "./pages/Bounties"; -import BountyDetail from "./pages/BountyDetail"; -import Dashboard from "./pages/Dashboard"; -import Favorites from "./pages/Favorites"; -import ProductComparison from "./pages/ProductComparison"; -import Admin from "./pages/Admin"; -import Search from "./pages/Search"; +import Home from "@/features/home/pages/Home"; +import Login from "@/features/auth/pages/Login"; +import Products from "@/features/products/pages/Products"; +import ProductDetail from "@/features/products/pages/ProductDetail"; +import Bounties from "@/features/bounties/pages/Bounties"; +import BountyDetail from "@/features/bounties/pages/BountyDetail"; +import Dashboard from "@/features/dashboard/pages/Dashboard"; +import Favorites from "@/features/favorites/pages/Favorites"; +import ProductComparison from "@/features/products/pages/ProductComparison"; +import Admin from "@/features/admin/pages/Admin"; +import Search from "@/features/search/pages/Search"; +import Settings from "@/features/settings/pages/Settings"; function Router() { return ( @@ -31,6 +32,7 @@ function Router() { + diff --git a/frontend/src/_core/hooks/useAuth.ts b/frontend/src/_core/hooks/useAuth.ts deleted file mode 100644 index 733f8a7..0000000 --- a/frontend/src/_core/hooks/useAuth.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useMe, useLogout } from "@/hooks/useApi"; -import { useCallback, useEffect, useMemo } from "react"; -import { AxiosError } from "axios"; - -type UseAuthOptions = { - redirectOnUnauthenticated?: boolean; - redirectPath?: string; -}; - -export function useAuth(options?: UseAuthOptions) { - const { redirectOnUnauthenticated = false, redirectPath = "/login" } = - options ?? {}; - - const meQuery = useMe(); - const logoutMutation = useLogout(); - - const logout = useCallback(async () => { - try { - await logoutMutation.mutateAsync(); - } catch (error: unknown) { - if ( - error instanceof AxiosError && - error.response?.status === 401 - ) { - return; - } - throw error; - } - }, [logoutMutation]); - - const state = useMemo(() => { - localStorage.setItem( - "manus-runtime-user-info", - JSON.stringify(meQuery.data) - ); - return { - user: meQuery.data ?? null, - loading: meQuery.isLoading || logoutMutation.isPending, - error: meQuery.error ?? logoutMutation.error ?? null, - isAuthenticated: Boolean(meQuery.data), - }; - }, [ - meQuery.data, - meQuery.error, - meQuery.isLoading, - logoutMutation.error, - logoutMutation.isPending, - ]); - - useEffect(() => { - if (!redirectOnUnauthenticated) return; - if (meQuery.isLoading || logoutMutation.isPending) return; - if (state.user) return; - if (typeof window === "undefined") return; - if (window.location.pathname === redirectPath) return; - - window.location.href = redirectPath - }, [ - redirectOnUnauthenticated, - redirectPath, - logoutMutation.isPending, - meQuery.isLoading, - state.user, - ]); - - return { - ...state, - refresh: () => meQuery.refetch(), - logout, - }; -} diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index 2878b1b..37c10c5 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -1,4 +1,4 @@ -import { useAuth } from "@/_core/hooks/useAuth"; +import { useAuth } from "@/hooks/useAuth"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { DropdownMenu, @@ -20,15 +20,19 @@ import { useSidebar, } from "@/components/ui/sidebar"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users, Heart, ShieldCheck } from "lucide-react"; -import { CSSProperties, useEffect, useRef, useState } from "react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, Heart, ShieldCheck, Loader2, Settings, Home } from "lucide-react"; +import { CSSProperties, useCallback, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; import { Button } from "./ui/button"; +import { toast } from "sonner"; +import { getErrorCopy } from "@/lib/i18n/errorMessages"; const menuItems = [ + { icon: Home, label: "返回首页", path: "/" }, { icon: LayoutDashboard, label: "个人中心", path: "/dashboard" }, { icon: Heart, label: "我的收藏", path: "/favorites" }, + { icon: Settings, label: "账号设置", path: "/settings" }, ]; const adminMenuItems = [ @@ -45,11 +49,27 @@ export default function DashboardLayout({ }: { children: React.ReactNode; }) { + const [, navigate] = useLocation(); + const [isLoggingOut, setIsLoggingOut] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(() => { const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY); return saved ? parseInt(saved, 10) : DEFAULT_WIDTH; }); - const { loading, user } = useAuth(); + const { loading, user, logout } = useAuth(); + + const handleLogout = useCallback(async () => { + setIsLoggingOut(true); + try { + await logout(); + toast.success("已退出登录"); + navigate("/"); + } catch (error: unknown) { + const { title, description } = getErrorCopy(error, { context: "auth.logout" }); + toast.error(title, { description }); + } finally { + setIsLoggingOut(false); + } + }, [logout, navigate]); useEffect(() => { localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString()); @@ -93,7 +113,11 @@ export default function DashboardLayout({ } as CSSProperties } > - + {children} @@ -103,13 +127,17 @@ export default function DashboardLayout({ type DashboardLayoutContentProps = { children: React.ReactNode; setSidebarWidth: (width: number) => void; + handleLogout: () => void; + isLoggingOut: boolean; }; function DashboardLayoutContent({ children, setSidebarWidth, + handleLogout, + isLoggingOut, }: DashboardLayoutContentProps) { - const { user, logout } = useAuth(); + const { user } = useAuth(); const [location, setLocation] = useLocation(); const { state, toggleSidebar } = useSidebar(); const isCollapsed = state === "collapsed"; @@ -251,11 +279,16 @@ function DashboardLayoutContent({ - - Sign out + {isLoggingOut ? ( + + ) : ( + + )} + {isLoggingOut ? "退出中..." : "退出登录"} diff --git a/frontend/src/components/MobileNav.tsx b/frontend/src/components/MobileNav.tsx index f7bd343..450aa0e 100644 --- a/frontend/src/components/MobileNav.tsx +++ b/frontend/src/components/MobileNav.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { Link, useLocation } from "wouter"; import { Button } from "@/components/ui/button"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"; -import { useAuth } from "@/_core/hooks/useAuth"; +import { useAuth } from "@/hooks/useAuth"; import { Sparkles, Menu, X, ShoppingBag, Trophy, Search, User, Heart, LogOut } from "lucide-react"; import { useUnreadNotificationCount } from "@/hooks/useApi"; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 83fb68a..15e21fb 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -1,4 +1,4 @@ -import { useAuth } from "@/_core/hooks/useAuth"; +import { useAuth } from "@/hooks/useAuth"; import { Button } from "@/components/ui/button"; import { Link, useLocation } from "wouter"; import { Sparkles, Bell, LogOut } from "lucide-react"; diff --git a/frontend/src/features/admin/pages/Admin.tsx b/frontend/src/features/admin/pages/Admin.tsx new file mode 100644 index 0000000..cdbd1a8 --- /dev/null +++ b/frontend/src/features/admin/pages/Admin.tsx @@ -0,0 +1,515 @@ +import { useAuth } from "@/hooks/useAuth"; +import { useAdminUsers, useUpdateAdminUser, useAdminBounties, useAdminPayments, useAdminDisputes, useResolveDispute, useAdminPendingProducts, useReviewProduct } from "@/hooks/useApi"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Navbar } from "@/components/Navbar"; +import { Loader2, Package, Users, Trophy, CreditCard, AlertTriangle } from "lucide-react"; +import { useLocation } from "wouter"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { getErrorCopy } from "@/lib/i18n/errorMessages"; +import { formatDistanceToNow } from "date-fns"; +import { zhCN } from "date-fns/locale"; + +export default function Admin() { + const { user, isAuthenticated, loading } = useAuth(); + const [, navigate] = useLocation(); + const [rejectReason, setRejectReason] = useState(""); + const [rejectingProductId, setRejectingProductId] = useState(null); + + const { data: usersData, isLoading: usersLoading } = useAdminUsers(); + const { data: bountiesData, isLoading: bountiesLoading } = useAdminBounties(); + const { data: paymentsData, isLoading: paymentsLoading } = useAdminPayments(); + const { data: disputesData, isLoading: disputesLoading } = useAdminDisputes(); + const { data: pendingProductsData, isLoading: pendingProductsLoading } = useAdminPendingProducts(); + const updateUserMutation = useUpdateAdminUser(); + const resolveDisputeMutation = useResolveDispute(); + const reviewProductMutation = useReviewProduct(); + + // Extract items from paginated responses + const users = usersData?.items || []; + const bounties = bountiesData?.items || []; + const payments = paymentsData?.items || []; + const disputes = disputesData?.items || []; + const pendingProducts = pendingProductsData?.items || []; + + useEffect(() => { + if (!loading && (!isAuthenticated || user?.role !== "admin")) { + navigate("/"); + } + }, [loading, isAuthenticated, user, navigate]); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated || user?.role !== "admin") { + return null; + } + + const bountyStats = { + total: bounties?.length || 0, + escrowed: bounties?.filter((b) => b.is_escrowed).length || 0, + paid: bounties?.filter((b) => b.is_paid).length || 0, + disputed: bounties?.filter((b) => b.status === "disputed").length || 0, + }; + + const pendingProductsCount = pendingProducts?.length || 0; + + const handleApproveProduct = (productId: number) => { + reviewProductMutation.mutate( + { productId, data: { approved: true } }, + { + onSuccess: () => toast.success("商品已通过审核"), + onError: (error: unknown) => { + const { title, description } = getErrorCopy(error, { context: "admin.review_product" }); + toast.error(title, { description }); + }, + } + ); + }; + + const handleRejectProduct = (productId: number) => { + if (!rejectReason.trim()) { + toast.error("请输入拒绝原因"); + return; + } + reviewProductMutation.mutate( + { productId, data: { approved: false, reject_reason: rejectReason } }, + { + onSuccess: () => { + toast.success("商品已拒绝"); + setRejectingProductId(null); + setRejectReason(""); + }, + onError: (error: unknown) => { + const { title, description } = getErrorCopy(error, { context: "admin.review_product" }); + toast.error(title, { description }); + }, + } + ); + }; + + return ( +
+ + +
+
+
+ +
+
+

管理后台

+

审核商品、管理用户和悬赏

+
+
+ + {/* Stats */} +
+ + + 待审核商品 + + {pendingProductsCount} + + + + 总悬赏 + + {bountyStats.total} + + + + 已托管 + + {bountyStats.escrowed} + + + + 已结算 + + {bountyStats.paid} + + + + 争议中 + + {bountyStats.disputed} + +
+ + {/* Tabs */} + + + + + 商品审核 + {pendingProductsCount > 0 && ( + {pendingProductsCount} + )} + + + + 用户管理 + + + + 悬赏管理 + + + + 争议处理 + + + + 支付事件 + + + + {/* Products Review Tab */} + + + + 待审核商品 + 审核用户提交的商品,通过后将在商品列表显示 + + + {pendingProductsLoading ? ( +
+ +
+ ) : pendingProducts && pendingProducts.length > 0 ? ( + + + + 商品名称 + 分类 + 提交者 + 提交时间 + 操作 + + + + {pendingProducts.map((product) => ( + + +
+ {product.image && ( + {product.name} + )} +
+
{product.name}
+ {product.description && ( +
{product.description}
+ )} +
+
+
+ {product.category_name || "-"} + {product.submitted_by_name || "-"} + + {formatDistanceToNow(new Date(product.created_at), { addSuffix: true, locale: zhCN })} + + + {rejectingProductId === product.id ? ( +
+ setRejectReason(e.target.value)} + className="px-2 py-1 border rounded text-sm w-32" + /> + + +
+ ) : ( +
+ + +
+ )} +
+
+ ))} +
+
+ ) : ( +
+ +

暂无待审核商品

+
+ )} +
+
+
+ + {/* Users Tab */} + + + + 用户管理 + + + {usersLoading ? ( + + ) : ( + + + + 用户 + 角色 + 状态 + 操作 + + + + {users?.map((u) => ( + + {u.name || u.open_id} + + + {u.role === "admin" ? "管理员" : "普通用户"} + + + + + {u.is_active ? "正常" : "禁用"} + + + + + + + + ))} + +
+ )} +
+
+
+ + {/* Bounties Tab */} + + + + 悬赏管理 + + + {bountiesLoading ? ( + + ) : ( + + + + 标题 + 状态 + 金额 + 支付 + + + + {bounties?.map((b) => ( + + {b.title} + {b.status} + {b.reward} + + + {b.is_paid ? "已结算" : "未结算"} + + + + ))} + +
+ )} +
+
+
+ + {/* Disputes Tab */} + + + + 争议处理 + + + {disputesLoading ? ( + + ) : ( + + + + 争议ID + 悬赏ID + 状态 + 操作 + + + + {disputes?.map((d) => ( + + {d.id} + {d.bounty_id} + {d.status} + + + + + + ))} + +
+ )} +
+
+
+ + {/* Payments Tab */} + + + + 支付事件 + + + {paymentsLoading ? ( + + ) : ( + + + + 事件ID + 类型 + 状态 + + + + {payments?.map((p) => ( + + {p.event_id} + {p.event_type} + {p.success ? "成功" : "失败"} + + ))} + +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/frontend/src/pages/Login.tsx b/frontend/src/features/auth/pages/Login.tsx similarity index 72% rename from frontend/src/pages/Login.tsx rename to frontend/src/features/auth/pages/Login.tsx index f01064e..df60672 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/features/auth/pages/Login.tsx @@ -8,11 +8,12 @@ import { Sparkles, ArrowLeft, Loader2 } from "lucide-react"; import { Link } from "wouter"; import { useLogin, useRegister } from "@/hooks/useApi"; import { toast } from "sonner"; +import { getErrorCopy } from "@/lib/i18n/errorMessages"; +import { getAndClearRedirectPath } from "@/hooks/useAuth"; export default function Login() { const [, setLocation] = useLocation(); const [username, setUsername] = useState(""); - const [displayName, setDisplayName] = useState(""); const [password, setPassword] = useState(""); const [email, setEmail] = useState(""); const [isRegister, setIsRegister] = useState(false); @@ -20,6 +21,11 @@ export default function Login() { const loginMutation = useLogin(); const registerMutation = useRegister(); + const validateEmail = (email: string): boolean => { + const emailPattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + return emailPattern.test(email); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -27,20 +33,33 @@ export default function Login() { toast.error("请输入用户名"); return; } + if (isRegister && !email.trim()) { + toast.error("请输入邮箱"); + return; + } + if (isRegister && !validateEmail(email.trim())) { + toast.error("请输入正确的邮箱格式"); + return; + } if (!password.trim()) { toast.error("请输入密码"); return; } + if (password.length < 6) { + toast.error("密码长度至少6位"); + return; + } try { if (isRegister) { await registerMutation.mutateAsync({ openId: username.trim(), password: password.trim(), - name: displayName.trim() || undefined, - email: email.trim() || undefined, + name: username.trim(), // 显示名称默认为用户名 + email: email.trim(), }); } else { + // 登录时,用户名字段可以是用户名或邮箱 await loginMutation.mutateAsync({ openId: username.trim(), password: password.trim(), @@ -51,12 +70,14 @@ export default function Login() { description: isRegister ? "账号已创建" : "欢迎回来!", }); - // Redirect to home or dashboard - setLocation("/"); - } catch (error: any) { - toast.error(isRegister ? "注册失败" : "登录失败", { - description: error.response?.data?.error || "请稍后重试", + // 优先返回登录前的页面,否则跳转首页 + const redirectPath = getAndClearRedirectPath(); + setLocation(redirectPath || "/"); + } catch (error: unknown) { + const { title, description } = getErrorCopy(error, { + context: isRegister ? "auth.register" : "auth.login", }); + toast.error(title, { description }); } }; @@ -80,62 +101,50 @@ export default function Login() {
- 欢迎登录 + {isRegister ? "欢迎注册" : "欢迎登录"} - 登录资源聚合平台,享受更多功能 + {isRegister ? "创建账号,开始使用资源聚合平台" : "登录资源聚合平台,享受更多功能"}
- + setUsername(e.target.value)} disabled={loginMutation.isPending || registerMutation.isPending} />
- -
- - setPassword(e.target.value)} - disabled={loginMutation.isPending || registerMutation.isPending} - /> -
- -
- - setDisplayName(e.target.value)} - disabled={loginMutation.isPending || registerMutation.isPending} - /> -
{isRegister && (
- + setEmail(e.target.value)} disabled={loginMutation.isPending || registerMutation.isPending} />
)} + +
+ + setPassword(e.target.value)} + disabled={loginMutation.isPending || registerMutation.isPending} + /> +
+ + + + 发布新悬赏 + + 填写悬赏详情,发布后其他用户可以申请接单 + + +
+
+ + setNewBounty(prev => ({ ...prev, title: e.target.value }))} + /> +
+
+ +