From 859e0302195090d79fd965619ad0e9863defe0fc Mon Sep 17 00:00:00 2001 From: Administrator Date: Sun, 19 Apr 2026 22:36:41 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E6=88=90=20issue1-fix-prompts=20?= =?UTF-8?q?=E7=9A=84=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/apps/accounts/api.py | 7 +- backend/apps/accounts/invitation_api.py | 7 +- backend/apps/accounts/services.py | 20 +- backend/apps/bounties/api.py | 22 +- backend/apps/bounties/services.py | 64 +++- backend/apps/skills/services.py | 34 +- backend/apps/workshop/services.py | 4 +- backend/common/permissions.py | 12 + backend/config/settings/test.py | 56 +--- backend/tests/test_api_regression.py | 42 +++ .../test_issue1_acceptance_regressions.py | 304 ++++++++++++++++++ frontend/src/hooks/use-auth.tsx | 9 +- frontend/src/lib/api/client.ts | 4 +- frontend/src/lib/bounties.ts | 5 + .../src/pages/bounty/BountyDetailPage.tsx | 12 +- 15 files changed, 513 insertions(+), 89 deletions(-) create mode 100644 backend/tests/test_issue1_acceptance_regressions.py diff --git a/backend/apps/accounts/api.py b/backend/apps/accounts/api.py index 274e61d..006182d 100644 --- a/backend/apps/accounts/api.py +++ b/backend/apps/accounts/api.py @@ -107,7 +107,7 @@ def get_tokens_for_user(user): # API Endpoints # ============================================================================= -@router.post("/register", response={201: TokenOutput, 400: MessageOutput}) +@router.post("/register", response={201: MessageOutput, 400: MessageOutput}) def register(request, data: RegisterInput): """Register a new user with email and password.""" normalized_email = data.email.strip().lower() @@ -163,8 +163,7 @@ def register(request, data: RegisterInput): AuthService.send_verification_email(request, user, signup=True) - tokens = get_tokens_for_user(user) - return Status(201, tokens) + return Status(201, {"message": "注册成功,请查收验证邮件后登录"}) @router.get("/invite-codes/{code}/validate", response={200: InviteValidationOutput, 404: MessageOutput}) @@ -194,7 +193,7 @@ def login(request, data: LoginInput): if not user.check_password(data.password): return Status(401, {"message": "邮箱或密码错误"}) - if not user.is_active: + if not AuthService.is_email_verified(user): return 401, {"message": "请先验证您的邮箱"} tokens = get_tokens_for_user(user) diff --git a/backend/apps/accounts/invitation_api.py b/backend/apps/accounts/invitation_api.py index 27982ec..fb5603e 100644 --- a/backend/apps/accounts/invitation_api.py +++ b/backend/apps/accounts/invitation_api.py @@ -81,7 +81,8 @@ def list_invitations(request): auth=None) def validate_invite_code(request, data: ValidateCodeInput): """Validate an invitation code (public endpoint).""" - _, error = InvitationService.validate_code(data.code) - if error: - return 400, {"message": error} + try: + InvitationService.validate_code(data.code) + except ValueError as e: + return 400, {"message": str(e)} return 200, {"message": "邀请码有效"} diff --git a/backend/apps/accounts/services.py b/backend/apps/accounts/services.py index f2bf7e8..763a555 100644 --- a/backend/apps/accounts/services.py +++ b/backend/apps/accounts/services.py @@ -251,6 +251,11 @@ def get_or_create_shareable_invitation(cls, inviter: User) -> Invitation: return invitation return Invitation.objects.create(inviter=inviter, code=cls._generate_unique_code()) + @classmethod + def generate_code(cls, inviter: User) -> str: + """Compatibility wrapper for legacy invitation API.""" + return cls.get_or_create_shareable_invitation(inviter).code + @classmethod def validate_code(cls, code: str | None) -> Invitation: normalized_code = cls.normalize_code(code) @@ -266,6 +271,19 @@ def validate_code(cls, code: str | None) -> Invitation: raise InvitationError("邀请码已被使用") return invitation + @classmethod + def get_stats(cls, inviter: User) -> dict: + dashboard = cls.get_dashboard(inviter) + return { + "total_codes": dashboard["total_codes_generated"], + "used_codes": dashboard["registered_invites"], + "remaining_this_month": dashboard["monthly_credit_remaining"], + } + + @classmethod + def get_my_invitations(cls, inviter: User): + return Invitation.objects.filter(inviter=inviter).order_by("-used_at", "-created_at") + @classmethod @transaction.atomic def bind_invitation_for_registration(cls, *, invitee: User, code: str, request) -> InvitationRewardResult: @@ -373,7 +391,7 @@ def _lock_invitation(cls, code: str) -> Invitation: invitation = ( Invitation.objects.select_for_update() - .select_related("inviter", "used_by") + .select_related("inviter") .filter(code=normalized_code) .first() ) diff --git a/backend/apps/bounties/api.py b/backend/apps/bounties/api.py index 1b6b436..f3b8d84 100644 --- a/backend/apps/bounties/api.py +++ b/backend/apps/bounties/api.py @@ -1,9 +1,8 @@ """Bounties API routes.""" -from __future__ import annotations from django.db.models import Count, Q from django.shortcuts import get_object_or_404 -from ninja import Router +from ninja import Body, Router from ninja.errors import HttpError from apps.bounties.models import Bounty @@ -269,6 +268,23 @@ def accept_application(request, bounty_id: int, application_id: int): ) +@router.post("/{bounty_id}/reject/{application_id}", response={200: BountyDetailOut, 400: MessageOut}, auth=AuthBearer()) +def reject_application(request, bounty_id: int, application_id: int): + bounty = get_object_or_404(Bounty.objects.select_related("creator", "accepted_application__applicant"), id=bounty_id) + try: + BountyService.reject_application(request.auth, bounty, application_id) + except BountyError as exc: + return 400, {"message": str(exc)} + bounty.refresh_from_db() + return _detail_out( + Bounty.objects.select_related("creator", "accepted_application__applicant").prefetch_related( + "applications__applicant", + "comments__author", + ).get(id=bounty.id), + viewer=request.auth, + ) + + @router.post("/{bounty_id}/comments", response={201: MessageOut, 400: MessageOut}, auth=AuthBearer()) def add_comment(request, bounty_id: int, data: BountyCommentInput): bounty = get_object_or_404(Bounty, id=bounty_id) @@ -411,7 +427,7 @@ def appeal_arbitration(request, bounty_id: int, data: ArbitrationAppealInput): @router.post("/{bounty_id}/arbitration/admin-finalize", response={200: BountyDetailOut, 400: MessageOut}, auth=AuthBearer()) @admin_required -def admin_finalize_arbitration(request, bounty_id: int, data: AdminArbitrationDecisionInput): +def admin_finalize_arbitration(request, bounty_id: int, data: AdminArbitrationDecisionInput = Body(...)): bounty = get_object_or_404(Bounty.objects.select_related("creator", "accepted_application__applicant"), id=bounty_id) try: BountyService.admin_finalize(request.auth, bounty, data.result, data.hunter_ratio) diff --git a/backend/apps/bounties/services.py b/backend/apps/bounties/services.py index b852614..58e2390 100644 --- a/backend/apps/bounties/services.py +++ b/backend/apps/bounties/services.py @@ -62,6 +62,28 @@ def _accepted_user(bounty: Bounty): return None return bounty.accepted_application.applicant + @staticmethod + def _normalize_hunter_ratio( + result: str, + hunter_ratio: float | Decimal | None, + *, + default_partial: Decimal | None = None, + ) -> Decimal: + if result == "HUNTER_WIN": + return Decimal("1.00") + if result == "CREATOR_WIN": + return Decimal("0.00") + if result != "PARTIAL": + raise BountyError("仲裁结果无效") + if hunter_ratio is None: + if default_partial is not None: + return quantize_amount(default_partial) + raise BountyError("PARTIAL 仲裁必须指定 hunter_ratio") + normalized = quantize_amount(hunter_ratio) + if not (Decimal("0.00") <= normalized <= Decimal("1.00")): + raise BountyError("hunter_ratio 必须在 0 到 1 之间") + return normalized + @classmethod def process_automations(cls): now = timezone.now() @@ -238,6 +260,31 @@ def accept_application(cls, actor, bounty: Bounty, application_id: int) -> Bount bounty.save(update_fields=["accepted_application", "status"]) return bounty + @classmethod + @transaction.atomic + def reject_application(cls, actor, bounty: Bounty, application_id: int, *, reason: str = "") -> Bounty: + if bounty.creator_id != actor.id: + raise BountyError("只有发布者可以拒绝申请") + if bounty.status != BountyStatus.OPEN: + raise BountyError("当前状态下不能拒绝申请") + + application = BountyApplication.objects.filter( + id=application_id, + bounty=bounty, + ).select_related("applicant").first() + if not application: + raise BountyError("申请不存在") + + applicant_name = application.applicant.display_name or application.applicant.username + application.delete() + feedback = reason.strip() + if feedback: + content = f"已拒绝 {applicant_name} 的申请:{feedback[:940]}" + else: + content = f"已拒绝 {applicant_name} 的申请" + BountyComment.objects.create(bounty=bounty, author=actor, content=content) + return bounty + @classmethod @transaction.atomic def add_comment(cls, actor, bounty: Bounty, content: str) -> BountyComment: @@ -463,20 +510,22 @@ def admin_finalize(cls, actor, bounty: Bounty, result: str, hunter_ratio: float # Appealed case: community arbitration already settled (money moved). # Admin must confirm the existing settlement — contradictory results # would create inconsistent metadata since funds cannot be re-moved. - if result not in {"HUNTER_WIN", "CREATOR_WIN", "PARTIAL"}: - raise BountyError("仲裁结果无效") + normalized_ratio = cls._normalize_hunter_ratio( + result, + hunter_ratio, + default_partial=arbitration.hunter_ratio, + ) if result != arbitration.result: raise BountyError( f"仲裁资金已按 {arbitration.result} 结果分配," f"无法改判为 {result},如需变更请先撤销原结算" ) if result == "PARTIAL": - requested_ratio = quantize_amount(hunter_ratio or 0) - settled_ratio = arbitration.hunter_ratio or Decimal("0") - if requested_ratio != settled_ratio: + settled_ratio = arbitration.hunter_ratio or Decimal("0.00") + if normalized_ratio != settled_ratio: raise BountyError( f"仲裁资金已按比例 {settled_ratio} 分配," - f"无法变更为 {requested_ratio},如需变更请先撤销原结算" + f"无法变更为 {normalized_ratio},如需变更请先撤销原结算" ) arbitration.admin_final_result = result arbitration.save(update_fields=["admin_final_result"]) @@ -574,8 +623,7 @@ def _apply_arbitration_result(cls, arbitration: Arbitration, result: str, hunter if not accepted_user: raise BountyError("当前悬赏没有接单者,无法结算仲裁") - ratio = quantize_amount(hunter_ratio or 0) - ratio = min(max(ratio, Decimal("0.00")), Decimal("1.00")) + ratio = cls._normalize_hunter_ratio(result, hunter_ratio) payout = quantize_amount(bounty.reward * ratio) refund = bounty.reward - payout # derive from payout to guarantee payout + refund == reward diff --git a/backend/apps/skills/services.py b/backend/apps/skills/services.py index d69191c..2a75fba 100644 --- a/backend/apps/skills/services.py +++ b/backend/apps/skills/services.py @@ -143,6 +143,13 @@ class SkillService: TRENDING_CACHE_KEY = "skills:trending" RECOMMENDATION_CACHE_KEY = "skills:recommended:user:{user_id}" + @staticmethod + def _json_safe_metadata(data: dict) -> dict: + safe: dict = {} + for key, value in data.items(): + safe[key] = float(value) if isinstance(value, Decimal) else value + return safe + @classmethod def _clean_tags(cls, tags: list[str] | None) -> list[str]: if not tags: @@ -339,7 +346,7 @@ def update(cls, skill: Skill, data: dict) -> Skill: status=VersionStatus.REJECTED if skill.status == SkillStatus.APPROVED else VersionStatus.SCANNING, # Store deferred metadata so _promote_version can apply it when # this version is approved (approved skills only). - pending_metadata=payload if defer_metadata else {}, + pending_metadata=cls._json_safe_metadata(payload) if defer_metadata else {}, ) if skill.status != SkillStatus.APPROVED: @@ -596,7 +603,6 @@ def complete_scan(cls, skill: Skill, *, passed: bool, issues: list[str], warning # Promote the approved version to live pointers cls._promote_version(skill, latest_version) skill.save() - from apps.search.services import SearchService SearchService.sync_skill(skill) NotificationService.send( recipient=skill.creator, @@ -876,7 +882,13 @@ def call(cls, skill: Skill, caller, input_text: str) -> SkillCall: ).first() if not selected_version_obj: - # Default: latest approved version + selected_version_obj = skill.versions.filter( + version=skill.current_version, + status=VersionStatus.APPROVED, + ).first() + + if not selected_version_obj: + # Fallback: latest approved version for legacy data selected_version_obj = skill.versions.filter( status=VersionStatus.APPROVED, ).order_by("-created_at").first() @@ -932,7 +944,10 @@ def _execute_from_package( try: import zipfile from io import BytesIO + from pathlib import PurePosixPath + if hasattr(package_file, "open"): + package_file.open("rb") content = package_file.read() if hasattr(package_file, 'read') else package_file if hasattr(package_file, 'seek'): package_file.seek(0) @@ -940,8 +955,14 @@ def _execute_from_package( file_contents: dict[str, str] = {} with zipfile.ZipFile(BytesIO(content if isinstance(content, bytes) else content.read())) as zf: for name in zf.namelist(): - if name.startswith("prompts/") and name.endswith((".txt", ".md")): - file_contents[name] = zf.read(name).decode("utf-8", errors="replace") + normalized_parts = PurePosixPath(name).parts + if "prompts" not in normalized_parts: + continue + if not name.endswith((".txt", ".md")): + continue + prompts_index = normalized_parts.index("prompts") + relative_name = "/".join(normalized_parts[prompts_index:]) + file_contents[relative_name] = zf.read(name).decode("utf-8", errors="replace") system_prompt = file_contents.get("prompts/system.txt") or file_contents.get("prompts/system.md", "") user_template = file_contents.get("prompts/user_template.txt") or file_contents.get("prompts/user_template.md", "") @@ -957,6 +978,8 @@ def _execute_from_package( return f"[基于 Prompt 模板执行] {rendered[:200]}" + except FileNotFoundError: + raise ValueError("未找到文件包,该 Skill 当前无法在线调用") except zipfile.BadZipFile: raise ValueError("文件包解析失败") except ValueError: @@ -1324,7 +1347,6 @@ def report(skill: Skill, reporter, reason: str, detail: str = "") -> SkillReport # Promote the latest safe version to live pointers SkillService._promote_version(skill, fallback) skill.save() - from apps.search.services import SearchService SearchService.sync_skill(skill) else: # No safe versions left — archive the whole skill diff --git a/backend/apps/workshop/services.py b/backend/apps/workshop/services.py index 4558852..370aec9 100644 --- a/backend/apps/workshop/services.py +++ b/backend/apps/workshop/services.py @@ -89,12 +89,14 @@ def send_tip(cls, tipper, article_id: int, amount: Decimal) -> Tip: reference_id=str(article_id), ) - return Tip.objects.create( + tip = Tip.objects.create( article=article, tipper=tipper, recipient=recipient, amount=amount, ) + cache.delete("tip_leaderboard") + return tip @classmethod def get_article_tips(cls, article_id: int, limit: int = 20): diff --git a/backend/common/permissions.py b/backend/common/permissions.py index 5d07d46..ac9bc61 100644 --- a/backend/common/permissions.py +++ b/backend/common/permissions.py @@ -75,34 +75,46 @@ def login_required(func): the Authorization header. """ from functools import wraps + from inspect import signature + from typing import get_type_hints @wraps(func) def wrapper(request, *args, **kwargs): user = getattr(request, 'auth', None) if not user or user is _ANONYMOUS: raise HttpError(401, "需要登录") return func(request, *args, **kwargs) + wrapper.__signature__ = signature(func) + wrapper.__annotations__ = get_type_hints(func) return wrapper def moderator_required(func): """Requires moderator role or above.""" from functools import wraps + from inspect import signature + from typing import get_type_hints @wraps(func) def wrapper(request, *args, **kwargs): user = getattr(request, 'auth', None) if not user or user.role not in ('MODERATOR', 'ADMIN'): raise HttpError(403, "需要版主权限") return func(request, *args, **kwargs) + wrapper.__signature__ = signature(func) + wrapper.__annotations__ = get_type_hints(func) return wrapper def admin_required(func): """Requires admin role.""" from functools import wraps + from inspect import signature + from typing import get_type_hints @wraps(func) def wrapper(request, *args, **kwargs): user = getattr(request, 'auth', None) if not user or user.role != 'ADMIN': raise HttpError(403, "需要管理员权限") return func(request, *args, **kwargs) + wrapper.__signature__ = signature(func) + wrapper.__annotations__ = get_type_hints(func) return wrapper diff --git a/backend/config/settings/test.py b/backend/config/settings/test.py index 028153b..33fdc9b 100644 --- a/backend/config/settings/test.py +++ b/backend/config/settings/test.py @@ -1,19 +1,10 @@ -"""Test settings - uses SQLite to avoid PostgreSQL dependency.""" -import json +"""Test settings for local regression runs against the standard Postgres stack.""" from config.settings.base import * # noqa # Override SECRET_KEY for tests SECRET_KEY = 'test-secret-key-for-testing-only' -# Use SQLite for tests -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ':memory:', - } -} - # Disable cache for tests CACHES = { 'default': { @@ -21,53 +12,10 @@ } } -# Disable postgres-specific features -INSTALLED_APPS = [app for app in INSTALLED_APPS if app != 'django.contrib.postgres'] - EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend' PASSWORD_HASHERS = ['django.contrib.auth.hashers.MD5PasswordHasher'] +AUTH_EMAIL_SEND_ASYNC = False # Run Celery tasks synchronously in tests (no Redis broker needed) CELERY_TASK_ALWAYS_EAGER = True CELERY_TASK_EAGER_PROPAGATES = True - -from django.contrib.postgres.fields import ArrayField # noqa: E402 - - -def _sqlite_array_db_type(self, connection): - return "text" - - -ArrayField.db_type = _sqlite_array_db_type -ArrayField.cast_db_type = _sqlite_array_db_type - - -def _sqlite_array_placeholder(self, value, compiler, connection): - return "%s" - - -ArrayField.get_placeholder = _sqlite_array_placeholder - - -def _sqlite_array_get_db_prep_value(self, value, connection, prepared=False): - if value is None: - return None - if isinstance(value, str): - return value - return json.dumps(value) - - -def _sqlite_array_from_db_value(self, value, expression, connection): - if value in (None, ""): - return [] - if isinstance(value, list): - return value - try: - return json.loads(value) - except (TypeError, ValueError): - return [] - - -ArrayField.get_db_prep_value = _sqlite_array_get_db_prep_value -ArrayField.from_db_value = _sqlite_array_from_db_value -ArrayField.to_python = lambda self, value: _sqlite_array_from_db_value(self, value, None, None) diff --git a/backend/tests/test_api_regression.py b/backend/tests/test_api_regression.py index 025c76e..f2cbf6e 100644 --- a/backend/tests/test_api_regression.py +++ b/backend/tests/test_api_regression.py @@ -890,6 +890,48 @@ def test_call_download_only_skill_rejected(self, client, user, approved_skill): body = resp.json() assert "下载" in body.get("detail", "") + @pytest.mark.django_db + def test_call_skill_with_nested_prompts_succeeds(self, client, user): + """POST /api/skills/{id}/call supports prompts nested under a top-level directory.""" + package = _make_zip( + { + "bundle/SKILL.md": SKILL_MD.format(version="1.0.0"), + "bundle/prompts/system.txt": "You are a test skill.", + "bundle/prompts/user_template.txt": "Nested Echo: {{input}}", + } + ) + skill = Skill.objects.create( + creator=user, + name="Nested Prompt Skill", + slug="nested-prompt-skill", + description="A skill with prompts under a top-level directory", + category="CODE_DEV", + pricing_model=PricingModel.FREE, + status=SkillStatus.APPROVED, + current_version="1.0.0", + package_file=package, + package_sha256="n" * 64, + package_size=100, + ) + SkillVersion.objects.create( + skill=skill, + version="1.0.0", + package_file=package, + package_sha256="n" * 64, + status=VersionStatus.APPROVED, + ) + + resp = client.post( + f"/api/skills/{skill.id}/call", + data=json.dumps({"input_text": "hello nested"}), + content_type="application/json", + **_jwt_header(user), + ) + + assert resp.status_code == 200, resp.content + body = resp.json() + assert "hello nested" in body["output_text"] + class TestHTTPSkillSubmit: """HTTP-level tests for the submit-for-review endpoint.""" diff --git a/backend/tests/test_issue1_acceptance_regressions.py b/backend/tests/test_issue1_acceptance_regressions.py new file mode 100644 index 0000000..14ceffb --- /dev/null +++ b/backend/tests/test_issue1_acceptance_regressions.py @@ -0,0 +1,304 @@ +import json +from datetime import timedelta +from decimal import Decimal + +import pytest +from allauth.account.models import EmailAddress +from django.core.cache import cache +from django.test import Client as DjangoClient +from django.utils import timezone +from rest_framework_simplejwt.tokens import AccessToken + +from apps.accounts.models import Invitation, User, UserRole +from apps.bounties.models import Arbitration, Bounty, BountyApplication +from apps.workshop.models import Article, ArticleDifficulty, ArticleStatus, ArticleType +from apps.workshop.services import TipService + + +@pytest.fixture +def client(): + return DjangoClient() + + +def _jwt_header(user) -> dict: + token = str(AccessToken.for_user(user)) + return {"HTTP_AUTHORIZATION": f"Bearer {token}"} + + +def _mark_email_verified(user: User) -> None: + EmailAddress.objects.update_or_create( + user=user, + email=user.email, + defaults={"primary": True, "verified": True}, + ) + + +@pytest.mark.django_db +def test_register_requires_email_verification_before_login(client): + email = "issue1-auth@example.test" + password = "StrongPass123!" + + register_resp = client.post( + "/api/auth/register", + data=json.dumps( + { + "email": email, + "password": password, + "display_name": "Issue1 Auth", + } + ), + content_type="application/json", + ) + assert register_resp.status_code == 201, register_resp.content + register_body = register_resp.json() + assert "message" in register_body + assert "access" not in register_body + assert "refresh" not in register_body + + login_resp = client.post( + "/api/auth/login", + data=json.dumps({"email": email, "password": password}), + content_type="application/json", + ) + assert login_resp.status_code == 401 + assert "验证" in login_resp.json()["message"] + + user = User.objects.get(email=email) + _mark_email_verified(user) + + verified_login_resp = client.post( + "/api/auth/login", + data=json.dumps({"email": email, "password": password}), + content_type="application/json", + ) + assert verified_login_resp.status_code == 200, verified_login_resp.content + verified_login_body = verified_login_resp.json() + assert "access" in verified_login_body + assert "refresh" in verified_login_body + + +@pytest.mark.django_db +def test_legacy_invitation_endpoints_and_invite_registration_work(client): + inviter = User.objects.create_user( + username="inviter", + email="inviter@example.test", + password="pw", + display_name="Inviter", + ) + + generate_resp = client.post( + "/api/invitations/generate", + **_jwt_header(inviter), + ) + assert generate_resp.status_code == 200, generate_resp.content + code = generate_resp.json()["code"] + + stats_resp = client.get( + "/api/invitations/stats", + **_jwt_header(inviter), + ) + assert stats_resp.status_code == 200, stats_resp.content + stats_body = stats_resp.json() + assert stats_body["total_codes"] == 1 + assert stats_body["used_codes"] == 0 + + list_resp = client.get( + "/api/invitations/list", + **_jwt_header(inviter), + ) + assert list_resp.status_code == 200, list_resp.content + invitations = list_resp.json() + assert len(invitations) == 1 + assert invitations[0]["code"] == code + + register_resp = client.post( + "/api/auth/register", + data=json.dumps( + { + "email": "invitee@example.test", + "password": "InvitePass123!", + "display_name": "Invitee", + "invite_code": code, + } + ), + content_type="application/json", + ) + assert register_resp.status_code == 201, register_resp.content + invitee = User.objects.get(email="invitee@example.test") + assert invitee.invited_by_id == inviter.id + + invitation = Invitation.objects.get(code=code) + assert invitation.used_by_id == invitee.id + + used_list_resp = client.get( + "/api/invitations/list", + **_jwt_header(inviter), + ) + assert used_list_resp.status_code == 200 + used_invitations = used_list_resp.json() + assert used_invitations[0]["used_by_name"] == "Invitee" + + +@pytest.mark.django_db +def test_openapi_schema_and_admin_finalize_no_longer_500(client): + openapi_resp = client.get("/api/openapi.json") + assert openapi_resp.status_code == 200, openapi_resp.content + assert "arbitration/admin-finalize" in openapi_resp.content.decode() + + admin_user = User.objects.create_user( + username="issue1admin", + email="issue1admin@example.test", + password="pw", + role=UserRole.ADMIN, + ) + finalize_resp = client.post( + "/api/bounties/999999/arbitration/admin-finalize", + data=json.dumps({"result": "CREATOR_WIN", "hunter_ratio": 0}), + content_type="application/json", + **_jwt_header(admin_user), + ) + assert finalize_resp.status_code == 404 + + +@pytest.mark.django_db +def test_tip_leaderboard_cache_is_invalidated_after_new_tip(client): + cache.set( + "tip_leaderboard", + [{"rank": 1, "user_id": 999, "display_name": "stale", "avatar_url": "", "total_tips": 99.0}], + timeout=3600, + ) + + author = User.objects.create_user( + username="tipauthor", + email="tipauthor@example.test", + password="pw", + display_name="Tip Author", + ) + tipper = User.objects.create_user( + username="tipper", + email="tipper@example.test", + password="pw", + display_name="Tipper", + balance=Decimal("10.00"), + ) + article = Article.objects.create( + author=author, + title="Tip Cache Test", + slug="tip-cache-test", + content="

content

", + difficulty=ArticleDifficulty.BEGINNER, + article_type=ArticleType.TUTORIAL, + status=ArticleStatus.PUBLISHED, + published_at=timezone.now(), + ) + + TipService.send_tip(tipper, article.id, Decimal("0.50")) + + leaderboard = TipService.get_leaderboard() + assert leaderboard[0]["user_id"] == author.id + assert leaderboard[0]["total_tips"] == 0.5 + + +@pytest.mark.django_db +def test_bounty_creator_can_reject_application(client): + creator = User.objects.create_user( + username="bountycreator", + email="bountycreator@example.test", + password="pw", + display_name="Bounty Creator", + ) + applicant = User.objects.create_user( + username="bountyapplicant", + email="bountyapplicant@example.test", + password="pw", + display_name="Bounty Applicant", + ) + bounty = Bounty.objects.create( + creator=creator, + title="Reject Application Bounty", + description="Test rejecting applications", + bounty_type="GENERAL", + max_applicants=3, + workload_estimate="ONE_DAY", + reward=Decimal("1.00"), + deadline=timezone.now() + timedelta(days=7), + ) + application = BountyApplication.objects.create( + bounty=bounty, + applicant=applicant, + proposal="please accept", + estimated_days=2, + ) + + resp = client.post( + f"/api/bounties/{bounty.id}/reject/{application.id}", + **_jwt_header(creator), + ) + + assert resp.status_code == 200, resp.content + body = resp.json() + assert body["applications"] == [] + assert any("已拒绝" in comment["content"] for comment in body["comments"]) + assert not BountyApplication.objects.filter(id=application.id).exists() + + +@pytest.mark.django_db +def test_admin_finalize_hunter_win_defaults_to_full_payout(client): + creator = User.objects.create_user( + username="arbcreator", + email="arbcreator@example.test", + password="pw", + display_name="Arb Creator", + frozen_balance=Decimal("1.00"), + ) + hunter = User.objects.create_user( + username="arbhunter", + email="arbhunter@example.test", + password="pw", + display_name="Arb Hunter", + balance=Decimal("0.00"), + ) + admin_user = User.objects.create_user( + username="arbadmin", + email="arbadmin@example.test", + password="pw", + role=UserRole.ADMIN, + ) + bounty = Bounty.objects.create( + creator=creator, + title="Admin Finalize Ratio Test", + description="Test admin finalize default ratio", + bounty_type="GENERAL", + max_applicants=1, + workload_estimate="ONE_DAY", + reward=Decimal("1.00"), + deadline=timezone.now() + timedelta(days=7), + ) + application = BountyApplication.objects.create( + bounty=bounty, + applicant=hunter, + proposal="hunter proposal", + estimated_days=1, + ) + bounty.accepted_application = application + bounty.status = "ARBITRATING" + bounty.save(update_fields=["accepted_application", "status"]) + Arbitration.objects.create(bounty=bounty) + + resp = client.post( + f"/api/bounties/{bounty.id}/arbitration/admin-finalize", + data=json.dumps({"result": "HUNTER_WIN"}), + content_type="application/json", + **_jwt_header(admin_user), + ) + + assert resp.status_code == 200, resp.content + bounty.refresh_from_db() + creator.refresh_from_db() + hunter.refresh_from_db() + arbitration = bounty.arbitration + assert bounty.status == "COMPLETED" + assert arbitration.result == "HUNTER_WIN" + assert float(arbitration.hunter_ratio) == 1.0 + assert creator.frozen_balance == Decimal("0.00") + assert hunter.balance == Decimal("1.00") diff --git a/frontend/src/hooks/use-auth.tsx b/frontend/src/hooks/use-auth.tsx index bc484e9..c6a4a1e 100644 --- a/frontend/src/hooks/use-auth.tsx +++ b/frontend/src/hooks/use-auth.tsx @@ -40,6 +40,10 @@ interface AuthContextType { completeSocialLogin: (code: string) => Promise; } +interface MessageResponse { + message: string; +} + // Create context const AuthContext = createContext(undefined); @@ -193,15 +197,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { }; const register = async (email: string, password: string, displayName?: string, inviteCode?: string) => { - const response = await api.post('/auth/register', { + await api.post('/auth/register', { email, password, display_name: displayName, invite_code: inviteCode, }); - const tokens: AuthTokens = response.data; - setStoredTokens(tokens); - await refreshUser(); }; const loginWithTokens = async (tokens: AuthTokens) => { diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index b485b3d..b59bb46 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -227,8 +227,8 @@ export interface Notification { export const authApi = { login: (email: string, password: string) => api.post('/auth/login', { email, password }), - register: (email: string, password: string, display_name?: string) => - api.post('/auth/register', { email, password, display_name }), + register: (email: string, password: string, display_name?: string, invite_code?: string) => + api.post('/auth/register', { email, password, display_name, invite_code }), logout: (refresh: string) => api.post('/auth/logout', { refresh }), refresh: (refresh: string) => diff --git a/frontend/src/lib/bounties.ts b/frontend/src/lib/bounties.ts index bb66a02..3850232 100644 --- a/frontend/src/lib/bounties.ts +++ b/frontend/src/lib/bounties.ts @@ -141,6 +141,11 @@ export async function acceptBountyApplication(id: number, applicationId: number) return response.data } +export async function rejectBountyApplication(id: number, applicationId: number) { + const response = await api.post(`/bounties/${id}/reject/${applicationId}`) + return response.data +} + export async function addBountyComment(id: number, content: string) { return api.post(`/bounties/${id}/comments`, { content }) } diff --git a/frontend/src/pages/bounty/BountyDetailPage.tsx b/frontend/src/pages/bounty/BountyDetailPage.tsx index 51c5be5..47900c1 100644 --- a/frontend/src/pages/bounty/BountyDetailPage.tsx +++ b/frontend/src/pages/bounty/BountyDetailPage.tsx @@ -19,6 +19,7 @@ import { castArbitrationVote, createBountyDispute, getBounty, + rejectBountyApplication, requestBountyRevision, startArbitration, submitArbitrationStatement, @@ -183,9 +184,14 @@ export default function BountyDetailPage() { {isCreator && data.status === 'OPEN' ? ( - +
+ + +
) : null}

{application.proposal}