Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions backend/apps/accounts/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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})
Expand Down Expand Up @@ -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)
Expand Down
7 changes: 4 additions & 3 deletions backend/apps/accounts/invitation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": "邀请码有效"}
20 changes: 19 additions & 1 deletion backend/apps/accounts/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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()
)
Expand Down
22 changes: 19 additions & 3 deletions backend/apps/bounties/api.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
64 changes: 56 additions & 8 deletions backend/apps/bounties/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"])
Expand Down Expand Up @@ -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

Expand Down
34 changes: 28 additions & 6 deletions backend/apps/skills/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -932,16 +944,25 @@ 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)

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", "")
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion backend/apps/workshop/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
12 changes: 12 additions & 0 deletions backend/common/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading