From 33fbf17c18b263e8060bee41204a8517e4219e42 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Fri, 12 Jun 2026 23:17:34 +0800 Subject: [PATCH] support feedback rewards for unbound users --- .../__tests__/opsFeedbackRewardGrant.test.ts | 6 ++ .../ops/feedback/FeedbackPageClient.tsx | 25 +++++--- src/auth/supabase_entitlement.py | 7 +++ tests/test_user_feedback.py | 58 +++++++++++++++++++ web/services/ops_api.py | 27 +++++++++ 5 files changed, 114 insertions(+), 9 deletions(-) diff --git a/frontend/components/ops/__tests__/opsFeedbackRewardGrant.test.ts b/frontend/components/ops/__tests__/opsFeedbackRewardGrant.test.ts index eda70687..aebe5c6e 100644 --- a/frontend/components/ops/__tests__/opsFeedbackRewardGrant.test.ts +++ b/frontend/components/ops/__tests__/opsFeedbackRewardGrant.test.ts @@ -56,4 +56,10 @@ export function runTests() { feedbackPageSource.includes("reward_points"), "ops feedback page must show existing feedback reward details", ); + assert( + feedbackPageSource.includes("actionError") && + feedbackPageSource.includes("奖励发放失败:") && + feedbackPageSource.includes("加载失败:"), + "ops feedback page must distinguish reward action failures from list loading failures", + ); } diff --git a/frontend/components/ops/feedback/FeedbackPageClient.tsx b/frontend/components/ops/feedback/FeedbackPageClient.tsx index 4876e708..5d7ec950 100644 --- a/frontend/components/ops/feedback/FeedbackPageClient.tsx +++ b/frontend/components/ops/feedback/FeedbackPageClient.tsx @@ -185,7 +185,8 @@ function feedbackFreshnessBadges(context?: Record) { export function FeedbackPageClient() { const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); + const [loadError, setLoadError] = useState(""); + const [actionError, setActionError] = useState(""); const [filter, setFilter] = useState(""); const [payload, setPayload] = useState(null); const [updatingId, setUpdatingId] = useState(null); @@ -194,12 +195,12 @@ export function FeedbackPageClient() { const load = async () => { setLoading(true); - setError(""); + setLoadError(""); try { const data = (await opsApi.feedback(120, filter)) as UserFeedbackPayload; setPayload(data); } catch (err) { - setError(String(err).slice(0, 220)); + setLoadError(String(err).slice(0, 220)); } finally { setLoading(false); } @@ -246,15 +247,15 @@ export function FeedbackPageClient() { const selectedPoints = rewardPointsById[row.id] || String(REWARD_POINT_OPTIONS[1].value); const points = Number.parseInt(selectedPoints, 10); if (!row.user_email) { - setError("这条反馈没有绑定用户邮箱,不能从反馈页直接发放积分。"); + setActionError("这条反馈没有绑定用户邮箱,不能从反馈页直接发放积分。"); return; } if (!Number.isFinite(points) || points <= 0) { - setError("请输入有效的奖励积分。"); + setActionError("请输入有效的奖励积分。"); return; } setRewardingId(row.id); - setError(""); + setActionError(""); try { await opsApi.grantFeedbackReward(row.id, points); setRewardPointsById((prev) => { @@ -264,7 +265,7 @@ export function FeedbackPageClient() { }); await load(); } catch (err) { - setError(String(err).slice(0, 220)); + setActionError(String(err).slice(0, 220)); } finally { setRewardingId(null); } @@ -283,9 +284,15 @@ export function FeedbackPageClient() { - {error && ( + {loadError && (
- 加载失败:{error} + 加载失败:{loadError} +
+ )} + + {actionError && ( +
+ 奖励发放失败:{actionError}
)} diff --git a/src/auth/supabase_entitlement.py b/src/auth/supabase_entitlement.py index acb90d18..eb5e9e96 100644 --- a/src/auth/supabase_entitlement.py +++ b/src/auth/supabase_entitlement.py @@ -1101,6 +1101,13 @@ class SupabaseEntitlementService: "points_after": after, } + def grant_points_to_user( + self, + user_id: str, + points: int, + ) -> Dict[str, object]: + return self._grant_referral_points(user_id, points) + def _record_referral_resolution_event( self, *, diff --git a/tests/test_user_feedback.py b/tests/test_user_feedback.py index 266fe348..9b02ac55 100644 --- a/tests/test_user_feedback.py +++ b/tests/test_user_feedback.py @@ -170,6 +170,64 @@ def test_ops_feedback_reward_service_returns_operator(monkeypatch): assert payload["feedback"]["reward_reason"] == "" +def test_ops_feedback_reward_falls_back_to_supabase_user_without_telegram_binding(monkeypatch): + updated_feedback = { + "id": 19, + "user_id": "supabase-user-19", + "user_email": "927794117@qq.com", + "reward_points": 500, + "reward_reason": "", + "reward_status": "granted", + } + + class FakeDB: + def grant_feedback_reward(self, feedback_id, *, points, reason=""): + return { + "ok": False, + "reason": "user_not_found", + "feedback": { + "id": feedback_id, + "user_id": "supabase-user-19", + "user_email": "927794117@qq.com", + }, + } + + def update_user_feedback_reward(self, feedback_id, *, points, reason="", status="granted"): + assert feedback_id == 19 + assert points == 500 + assert reason == "" + assert status == "granted" + return updated_feedback + + class FakeEntitlement: + def grant_points_to_user(self, user_id, points): + assert user_id == "supabase-user-19" + assert points == 500 + return { + "ok": True, + "source": "supabase_metadata", + "points_before": 100, + "points_added": 500, + "points_after": 600, + } + + monkeypatch.setattr(ops_api, "_require_ops", lambda request: {"email": "ops@example.com"}) + monkeypatch.setattr(ops_api, "DBManager", lambda: FakeDB()) + monkeypatch.setattr(ops_api.legacy_routes, "SUPABASE_ENTITLEMENT", FakeEntitlement()) + + payload = ops_api.grant_ops_feedback_reward( + object(), + feedback_id=19, + points=500, + ) + + assert payload["ok"] is True + assert payload["source"] == "supabase_metadata" + assert payload["points_after"] == 600 + assert payload["feedback"] == updated_feedback + assert payload["operator_email"] == "ops@example.com" + + 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/services/ops_api.py b/web/services/ops_api.py index 035250c8..f625e56c 100644 --- a/web/services/ops_api.py +++ b/web/services/ops_api.py @@ -203,6 +203,33 @@ def grant_ops_feedback_reward( points=points, reason=reason, ) + if not result.get("ok") and str(result.get("reason") or "") == "user_not_found": + feedback = result.get("feedback") if isinstance(result.get("feedback"), dict) else {} + reward_status = str(feedback.get("reward_status") or "").strip().lower() + reward_points = int(feedback.get("reward_points") or 0) + supabase_user_id = str(feedback.get("user_id") or "").strip().lower() + if supabase_user_id and not (reward_status == "granted" and reward_points > 0): + try: + fallback = legacy_routes.SUPABASE_ENTITLEMENT.grant_points_to_user( + supabase_user_id, + points, + ) + except Exception as exc: + fallback = {"ok": False, "reason": f"supabase_points_grant_failed:{exc}"} + if fallback.get("ok"): + updated_feedback = db.update_user_feedback_reward( + feedback_id, + points=points, + reason=reason, + status="granted", + ) + result = { + **fallback, + "ok": True, + "feedback_id": int(feedback_id), + "supabase_user_id": supabase_user_id, + "feedback": updated_feedback, + } result["operator_email"] = admin.get("email") if not result.get("ok"): reason_code = str(result.get("reason") or "feedback_reward_failed")