diff --git a/frontend/app/api/ops/feedback/[feedbackId]/reward/route.ts b/frontend/app/api/ops/feedback/[feedbackId]/reward/route.ts new file mode 100644 index 00000000..857932d8 --- /dev/null +++ b/frontend/app/api/ops/feedback/[feedbackId]/reward/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildProxyExceptionResponse } from "@/lib/api-proxy"; +import { + applyAuthResponseCookies, + buildBackendRequestHeaders, +} from "@/lib/backend-auth"; +import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth"; + +const API_BASE = process.env.POLYWEATHER_API_BASE_URL; + +export async function POST( + req: NextRequest, + context: { params: Promise<{ feedbackId: string }> }, +) { + if (!API_BASE) { + return NextResponse.json( + { error: "POLYWEATHER_API_BASE_URL is not configured" }, + { status: 500 }, + ); + } + + try { + const auth = await buildBackendRequestHeaders(req); + const authError = requireOpsProxyAuth(req, auth); + if (authError) return authError; + + const { feedbackId } = await context.params; + const body = await req.json(); + const res = await fetch( + `${API_BASE}/api/ops/feedback/${encodeURIComponent(feedbackId)}/reward`, + { + method: "POST", + cache: "no-store", + headers: { + ...Object.fromEntries(new Headers(auth.headers).entries()), + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, + ); + const raw = await res.text(); + const response = new NextResponse(raw, { + headers: { + "Cache-Control": "no-store", + "Content-Type": res.headers.get("content-type") || "application/json", + }, + status: res.status, + }); + return applyAuthResponseCookies(response, auth.response); + } catch (error) { + return buildProxyExceptionResponse(error, { + publicMessage: "Failed to grant feedback reward", + }); + } +} diff --git a/frontend/components/ops/__tests__/opsFeedbackRewardGrant.test.ts b/frontend/components/ops/__tests__/opsFeedbackRewardGrant.test.ts new file mode 100644 index 00000000..53909908 --- /dev/null +++ b/frontend/components/ops/__tests__/opsFeedbackRewardGrant.test.ts @@ -0,0 +1,47 @@ +import fs from "node:fs"; +import path from "node:path"; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +export function runTests() { + const projectRoot = process.cwd(); + const feedbackPageSource = fs.readFileSync( + path.join(projectRoot, "components", "ops", "feedback", "FeedbackPageClient.tsx"), + "utf8", + ); + const opsApiSource = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8"); + const rewardRoutePath = path.join( + projectRoot, + "app", + "api", + "ops", + "feedback", + "[feedbackId]", + "reward", + "route.ts", + ); + + assert(fs.existsSync(rewardRoutePath), "ops feedback reward proxy route must exist"); + assert( + opsApiSource.includes("grantFeedbackReward") && + opsApiSource.includes("/api/ops/feedback/${feedbackId}/reward") && + opsApiSource.includes("reason"), + "ops API client must expose grantFeedbackReward with reason", + ); + assert( + feedbackPageSource.includes("rewardDrafts") && + feedbackPageSource.includes("handleRewardGrant") && + feedbackPageSource.includes("发放奖励") && + feedbackPageSource.includes("奖励原因") && + feedbackPageSource.includes("opsApi.grantFeedbackReward"), + "ops feedback page must provide per-feedback reward grant controls", + ); + assert( + feedbackPageSource.includes("已发放") && + feedbackPageSource.includes("reward_points") && + feedbackPageSource.includes("reward_reason"), + "ops feedback page must show existing feedback reward details", + ); +} diff --git a/frontend/components/ops/feedback/FeedbackPageClient.tsx b/frontend/components/ops/feedback/FeedbackPageClient.tsx index f54df16c..66e90579 100644 --- a/frontend/components/ops/feedback/FeedbackPageClient.tsx +++ b/frontend/components/ops/feedback/FeedbackPageClient.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo, useState } from "react"; -import { Bug, CheckCircle2, MessageSquare, RefreshCcw } from "lucide-react"; +import { Bug, CheckCircle2, Coins, MessageSquare, RefreshCcw } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { opsApi } from "@/lib/ops-api"; @@ -27,6 +27,11 @@ const REWARD_GUIDELINES = [ { points: "1500+", title: "重大事故", detail: "大面积不可用或严重业务损失,谨慎使用" }, ] as const; +type RewardDraft = { + points: string; + reason: string; +}; + function compactDate(value?: string) { if (!value) return "—"; return value.slice(0, 19).replace("T", " "); @@ -76,6 +81,8 @@ export function FeedbackPageClient() { const [filter, setFilter] = useState(""); const [payload, setPayload] = useState(null); const [updatingId, setUpdatingId] = useState(null); + const [rewardingId, setRewardingId] = useState(null); + const [rewardDrafts, setRewardDrafts] = useState>({}); const load = async () => { setLoading(true); @@ -120,6 +127,50 @@ export function FeedbackPageClient() { } }; + const updateRewardDraft = (rowId: number, patch: Partial) => { + setRewardDrafts((prev) => ({ + ...prev, + [rowId]: { + points: prev[rowId]?.points || "", + reason: prev[rowId]?.reason || "", + ...patch, + }, + })); + }; + + const handleRewardGrant = async (row: UserFeedbackEntry) => { + const draft = rewardDrafts[row.id] || { points: "", reason: "" }; + const points = Number.parseInt(draft.points, 10); + const reason = draft.reason.trim(); + if (!row.user_email) { + setError("这条反馈没有绑定用户邮箱,不能从反馈页直接发放积分。"); + return; + } + if (!Number.isFinite(points) || points <= 0) { + setError("请输入有效的奖励积分。"); + return; + } + if (!reason) { + setError("请输入奖励原因,用户账户页会展示这条原因。"); + return; + } + setRewardingId(row.id); + setError(""); + try { + await opsApi.grantFeedbackReward(row.id, points, reason); + setRewardDrafts((prev) => { + const next = { ...prev }; + delete next[row.id]; + return next; + }); + await load(); + } catch (err) { + setError(String(err).slice(0, 220)); + } finally { + setRewardingId(null); + } + }; + if (loading && !payload) { return
加载中...
; } @@ -244,51 +295,109 @@ export function FeedbackPageClient() { 上下文 用户 时间 + 奖励 操作 - {rows.map((row) => ( - - - - {statusLabel(row.status)} - - - {categoryLabel(row.category)} - -
{row.message || "—"}
- {row.contact &&
联系:{row.contact}
} - - -
{contextSummary(row.context)}
- {Boolean(row.context?.detail_error) && ( -
- {String(row.context?.detail_error || "").slice(0, 120)} -
- )} - - - {row.user_email || row.user_id || "—"} - - {compactDate(row.created_at)} - - - - - ))} + {rows.map((row) => { + const draft = rewardDrafts[row.id] || { points: "", reason: "" }; + const rewardPoints = Number(row.reward_points || 0); + const rewardReason = String(row.reward_reason || "").trim(); + const rewardStatus = String(row.reward_status || "").toLowerCase(); + const hasReward = rewardStatus === "granted" && rewardPoints > 0; + return ( + + + + {statusLabel(row.status)} + + + {categoryLabel(row.category)} + +
{row.message || "—"}
+ {row.contact &&
联系:{row.contact}
} + + +
{contextSummary(row.context)}
+ {Boolean(row.context?.detail_error) && ( +
+ {String(row.context?.detail_error || "").slice(0, 120)} +
+ )} + + + {row.user_email || row.user_id || "—"} + + {compactDate(row.created_at)} + + {hasReward ? ( +
+
+ 已发放 +{rewardPoints.toLocaleString()} 分 +
+ {rewardReason && ( +
+ {rewardReason} +
+ )} +
+ ) : ( +
+
+ updateRewardDraft(row.id, { points: event.target.value })} + placeholder="积分" + aria-label="奖励积分" + className="h-8 w-20 rounded border border-slate-200 bg-white px-2 text-xs font-bold text-slate-700 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-100" + /> + updateRewardDraft(row.id, { reason: event.target.value })} + placeholder="奖励原因" + aria-label="奖励原因" + className="h-8 min-w-0 flex-1 rounded border border-slate-200 bg-white px-2 text-xs text-slate-700 outline-none focus:border-blue-300 focus:ring-2 focus:ring-blue-100" + /> +
+ + {!row.user_email && ( +
无用户邮箱,无法直接发放。
+ )} +
+ )} + + + + + + ); + })} diff --git a/frontend/lib/ops-api.ts b/frontend/lib/ops-api.ts index 662f4735..5b65ffac 100644 --- a/frontend/lib/ops-api.ts +++ b/frontend/lib/ops-api.ts @@ -121,6 +121,13 @@ export const opsApi = { body: JSON.stringify({ status }), }); }, + grantFeedbackReward(feedbackId: string | number, points: number, reason: string) { + return opsFetch>(`/api/ops/feedback/${feedbackId}/reward`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ points, reason }), + }); + }, resolveIncident(eventId: string | number) { return opsFetch>(`/api/ops/payments/incidents/${eventId}/resolve`, { method: "POST", diff --git a/src/database/db_manager.py b/src/database/db_manager.py index bf36d2a1..29e7f04c 100644 --- a/src/database/db_manager.py +++ b/src/database/db_manager.py @@ -1298,6 +1298,135 @@ class DBManager: conn.commit() return self._feedback_row_to_dict(row) if row else None + def grant_feedback_reward( + self, + feedback_id: int, + *, + points: int, + reason: str = "", + ) -> Dict[str, Any]: + safe_points = int(points or 0) + if safe_points <= 0: + return {"ok": False, "reason": "invalid_amount"} + normalized_reason = str(reason or "").strip()[:500] + if not normalized_reason: + return {"ok": False, "reason": "missing_reward_reason"} + + now = datetime.now().isoformat() + with self._get_connection() as conn: + conn.row_factory = sqlite3.Row + conn.execute("BEGIN IMMEDIATE") + feedback_row = conn.execute( + """ + SELECT id, category, message, source, status, contact, user_id, + user_email, context_json, reward_points, reward_reason, + rewarded_at, reward_status, created_at, updated_at + FROM user_feedback + WHERE id = ? + LIMIT 1 + """, + (int(feedback_id),), + ).fetchone() + if not feedback_row: + return {"ok": False, "reason": "feedback_not_found"} + + existing_points = int(feedback_row["reward_points"] or 0) + existing_status = str(feedback_row["reward_status"] or "").strip().lower() + email = str(feedback_row["user_email"] or "").strip().lower() + if not email: + return { + "ok": False, + "reason": "missing_feedback_user_email", + "feedback": self._feedback_row_to_dict(feedback_row), + } + + user_row = conn.execute( + """ + SELECT telegram_id, username, points, supabase_email + FROM users + WHERE lower(trim(COALESCE(supabase_email, ''))) = ? + LIMIT 1 + """, + (email,), + ).fetchone() + if not user_row: + user_row = conn.execute( + """ + SELECT u.telegram_id, u.username, u.points, b.supabase_email + FROM users u + JOIN supabase_bindings b ON b.telegram_id = u.telegram_id + WHERE lower(trim(COALESCE(b.supabase_email, ''))) = ? + LIMIT 1 + """, + (email,), + ).fetchone() + if not user_row: + return { + "ok": False, + "reason": "user_not_found", + "supabase_email": email, + "feedback": self._feedback_row_to_dict(feedback_row), + } + + before = int(user_row["points"] or 0) + if existing_status == "granted" and existing_points > 0: + return { + "ok": False, + "reason": "already_rewarded", + "feedback": self._feedback_row_to_dict(feedback_row), + "points_after": before, + } + + telegram_id = int(user_row["telegram_id"] or 0) + after = before + safe_points + conn.execute( + """ + UPDATE users + SET points = ? + WHERE telegram_id = ? + """, + (after, telegram_id), + ) + conn.execute( + """ + UPDATE user_feedback + SET reward_points = ?, + reward_reason = ?, + reward_status = 'granted', + rewarded_at = ?, + updated_at = ? + WHERE id = ? + """, + (safe_points, normalized_reason, now, now, int(feedback_id)), + ) + updated_feedback_row = conn.execute( + """ + SELECT id, category, message, source, status, contact, user_id, + user_email, context_json, reward_points, reward_reason, + rewarded_at, reward_status, created_at, updated_at + FROM user_feedback + WHERE id = ? + LIMIT 1 + """, + (int(feedback_id),), + ).fetchone() + conn.commit() + + self._sync_points_to_supabase_user_metadata(telegram_id, force=True) + return { + "ok": True, + "feedback_id": int(feedback_id), + "telegram_id": telegram_id, + "username": str(user_row["username"] or ""), + "supabase_email": str(user_row["supabase_email"] or email), + "points_before": before, + "points_added": safe_points, + "points_after": after, + "feedback": self._feedback_row_to_dict(updated_feedback_row) + if updated_feedback_row + else None, + } + def get_app_analytics_funnel_summary(self, *, days: int = 30) -> Dict[str, Any]: safe_days = max(1, min(int(days or 30), 365)) since_dt = datetime.now() - timedelta(days=safe_days) diff --git a/tests/test_user_feedback.py b/tests/test_user_feedback.py index 1fae2fc1..9f60454d 100644 --- a/tests/test_user_feedback.py +++ b/tests/test_user_feedback.py @@ -5,6 +5,7 @@ from fastapi import HTTPException from src.database.db_manager import DBManager from web.services import feedback_api +from web.services import ops_api def test_user_feedback_round_trip_includes_context_and_status(tmp_path): @@ -93,6 +94,85 @@ def test_user_feedback_reward_metadata_round_trip(tmp_path): assert row["rewarded_at"] == rewarded["rewarded_at"] +def test_feedback_reward_grant_adds_points_and_marks_feedback(tmp_path): + db = DBManager(str(tmp_path / "polyweather-feedback-grant.db")) + db.upsert_user(1001, "pilot") + with db._get_connection() as conn: # noqa: SLF001 + conn.execute( + """ + UPDATE users + SET points = ?, supabase_email = ? + WHERE telegram_id = ? + """, + (50, "pilot@example.com", 1001), + ) + conn.commit() + + created = db.append_user_feedback( + category="data", + message="Amsterdam METAR stale.", + user_id="user-1001", + user_email="pilot@example.com", + ) + + result = db.grant_feedback_reward( + created["id"], + points=300, + reason="Valid stale METAR report", + ) + + assert result["ok"] is True + assert result["points_before"] == 50 + assert result["points_added"] == 300 + assert result["points_after"] == 350 + assert result["feedback"]["reward_points"] == 300 + assert result["feedback"]["reward_reason"] == "Valid stale METAR report" + assert result["feedback"]["reward_status"] == "granted" + assert db.get_points_by_supabase_email("pilot@example.com") == 350 + + duplicate = db.grant_feedback_reward( + created["id"], + points=300, + reason="Duplicate grant should not apply", + ) + + assert duplicate["ok"] is False + assert duplicate["reason"] == "already_rewarded" + assert duplicate["points_after"] == 350 + + +def test_ops_feedback_reward_service_returns_operator(monkeypatch): + class FakeDB: + def grant_feedback_reward(self, feedback_id, *, points, reason): + return { + "ok": True, + "feedback_id": feedback_id, + "points_added": points, + "points_after": 900, + "feedback": { + "id": feedback_id, + "reward_points": points, + "reward_reason": reason, + "reward_status": "granted", + }, + } + + monkeypatch.setattr(ops_api, "_require_ops", lambda request: {"email": "ops@example.com"}) + monkeypatch.setattr(ops_api, "DBManager", lambda: FakeDB()) + + payload = ops_api.grant_ops_feedback_reward( + object(), + feedback_id=42, + points=500, + reason="High impact chart bug", + ) + + assert payload["ok"] is True + assert payload["operator_email"] == "ops@example.com" + assert payload["feedback"]["reward_points"] == 500 + assert payload["feedback"]["reward_reason"] == "High impact chart bug" + + def test_user_feedback_identity_filter_returns_only_matching_user(tmp_path): db = DBManager(str(tmp_path / "polyweather-feedback-identity.db")) mine_by_user_id = db.append_user_feedback( diff --git a/web/core.py b/web/core.py index bd7c4631..3d276a44 100644 --- a/web/core.py +++ b/web/core.py @@ -504,6 +504,11 @@ class GrantPointsRequest(BaseModel): points: int = Field(..., gt=0, le=100000) +class FeedbackRewardRequest(BaseModel): + points: int = Field(..., gt=0, le=100000) + reason: str = Field(..., min_length=2, max_length=500) + + def _sf(v) -> Optional[float]: if v is None: return None diff --git a/web/routers/ops.py b/web/routers/ops.py index 7f32a192..69bc3370 100644 --- a/web/routers/ops.py +++ b/web/routers/ops.py @@ -2,7 +2,7 @@ from fastapi import APIRouter, Request, Response -from web.core import GrantPointsRequest +from web.core import FeedbackRewardRequest, GrantPointsRequest from web.services.ops_api import ( extend_ops_subscription, get_ops_analytics_funnel, @@ -28,6 +28,7 @@ from web.services.ops_api import ( list_ops_payments, search_ops_users, update_ops_config, + grant_ops_feedback_reward, update_ops_sensitive_config, update_ops_feedback_status, get_ops_training_accuracy, @@ -88,6 +89,20 @@ async def ops_feedback_update_status(request: Request, feedback_id: int): return update_ops_feedback_status(request, feedback_id=feedback_id, status=status) +@router.post("/api/ops/feedback/{feedback_id}/reward") +async def ops_feedback_grant_reward( + request: Request, + feedback_id: int, + body: FeedbackRewardRequest, +): + return grant_ops_feedback_reward( + request, + feedback_id=feedback_id, + points=body.points, + reason=body.reason, + ) + + @router.get("/api/ops/memberships") async def ops_memberships(request: Request, limit: int = 200): return list_ops_memberships(request, limit=limit) diff --git a/web/services/ops_api.py b/web/services/ops_api.py index 97e1d226..bacad977 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -189,6 +189,30 @@ def update_ops_feedback_status( return {"ok": True, "feedback": updated} +def grant_ops_feedback_reward( + request: Request, + *, + feedback_id: int, + points: int, + reason: str, +) -> Dict[str, Any]: + admin = _require_ops(request) or {} + db = DBManager() + result = db.grant_feedback_reward( + feedback_id, + points=points, + reason=reason, + ) + result["operator_email"] = admin.get("email") + if not result.get("ok"): + reason_code = str(result.get("reason") or "feedback_reward_failed") + status_code = 404 if reason_code in {"feedback_not_found", "user_not_found"} else 400 + if reason_code == "already_rewarded": + status_code = 409 + raise HTTPException(status_code=status_code, detail=result) + return result + + def _list_active_subscriptions_with_windows( limit: int, ) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]], bool]: