support feedback rewards for unbound users
This commit is contained in:
@@ -56,4 +56,10 @@ export function runTests() {
|
|||||||
feedbackPageSource.includes("reward_points"),
|
feedbackPageSource.includes("reward_points"),
|
||||||
"ops feedback page must show existing feedback reward details",
|
"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",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,8 @@ function feedbackFreshnessBadges(context?: Record<string, unknown>) {
|
|||||||
|
|
||||||
export function FeedbackPageClient() {
|
export function FeedbackPageClient() {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState("");
|
const [loadError, setLoadError] = useState("");
|
||||||
|
const [actionError, setActionError] = useState("");
|
||||||
const [filter, setFilter] = useState("");
|
const [filter, setFilter] = useState("");
|
||||||
const [payload, setPayload] = useState<UserFeedbackPayload | null>(null);
|
const [payload, setPayload] = useState<UserFeedbackPayload | null>(null);
|
||||||
const [updatingId, setUpdatingId] = useState<number | null>(null);
|
const [updatingId, setUpdatingId] = useState<number | null>(null);
|
||||||
@@ -194,12 +195,12 @@ export function FeedbackPageClient() {
|
|||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setLoadError("");
|
||||||
try {
|
try {
|
||||||
const data = (await opsApi.feedback(120, filter)) as UserFeedbackPayload;
|
const data = (await opsApi.feedback(120, filter)) as UserFeedbackPayload;
|
||||||
setPayload(data);
|
setPayload(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err).slice(0, 220));
|
setLoadError(String(err).slice(0, 220));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -246,15 +247,15 @@ export function FeedbackPageClient() {
|
|||||||
const selectedPoints = rewardPointsById[row.id] || String(REWARD_POINT_OPTIONS[1].value);
|
const selectedPoints = rewardPointsById[row.id] || String(REWARD_POINT_OPTIONS[1].value);
|
||||||
const points = Number.parseInt(selectedPoints, 10);
|
const points = Number.parseInt(selectedPoints, 10);
|
||||||
if (!row.user_email) {
|
if (!row.user_email) {
|
||||||
setError("这条反馈没有绑定用户邮箱,不能从反馈页直接发放积分。");
|
setActionError("这条反馈没有绑定用户邮箱,不能从反馈页直接发放积分。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(points) || points <= 0) {
|
if (!Number.isFinite(points) || points <= 0) {
|
||||||
setError("请输入有效的奖励积分。");
|
setActionError("请输入有效的奖励积分。");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setRewardingId(row.id);
|
setRewardingId(row.id);
|
||||||
setError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
await opsApi.grantFeedbackReward(row.id, points);
|
await opsApi.grantFeedbackReward(row.id, points);
|
||||||
setRewardPointsById((prev) => {
|
setRewardPointsById((prev) => {
|
||||||
@@ -264,7 +265,7 @@ export function FeedbackPageClient() {
|
|||||||
});
|
});
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(String(err).slice(0, 220));
|
setActionError(String(err).slice(0, 220));
|
||||||
} finally {
|
} finally {
|
||||||
setRewardingId(null);
|
setRewardingId(null);
|
||||||
}
|
}
|
||||||
@@ -283,9 +284,15 @@ export function FeedbackPageClient() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{loadError && (
|
||||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
加载失败:{error}
|
加载失败:{loadError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{actionError && (
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
奖励发放失败:{actionError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1101,6 +1101,13 @@ class SupabaseEntitlementService:
|
|||||||
"points_after": after,
|
"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(
|
def _record_referral_resolution_event(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -170,6 +170,64 @@ def test_ops_feedback_reward_service_returns_operator(monkeypatch):
|
|||||||
assert payload["feedback"]["reward_reason"] == ""
|
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):
|
def test_user_feedback_identity_filter_returns_only_matching_user(tmp_path):
|
||||||
db = DBManager(str(tmp_path / "polyweather-feedback-identity.db"))
|
db = DBManager(str(tmp_path / "polyweather-feedback-identity.db"))
|
||||||
mine_by_user_id = db.append_user_feedback(
|
mine_by_user_id = db.append_user_feedback(
|
||||||
|
|||||||
@@ -203,6 +203,33 @@ def grant_ops_feedback_reward(
|
|||||||
points=points,
|
points=points,
|
||||||
reason=reason,
|
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")
|
result["operator_email"] = admin.get("email")
|
||||||
if not result.get("ok"):
|
if not result.get("ok"):
|
||||||
reason_code = str(result.get("reason") or "feedback_reward_failed")
|
reason_code = str(result.get("reason") or "feedback_reward_failed")
|
||||||
|
|||||||
Reference in New Issue
Block a user