Implement ops operational closure

This commit is contained in:
2569718930@qq.com
2026-06-23 17:07:25 +08:00
parent 24415ee427
commit 0c76874418
27 changed files with 2196 additions and 24 deletions
+47
View File
@@ -0,0 +1,47 @@
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 GET(req: NextRequest) {
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 upstream = new URL(`${API_BASE}/api/ops/audit-log`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch ops audit log",
});
}
}
@@ -22,8 +22,9 @@ export async function GET(req: NextRequest) {
if (authError) return authError; if (authError) return authError;
const url = new URL(`${API_BASE}/api/ops/payments/incidents`); const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
const limit = req.nextUrl.searchParams.get("limit"); req.nextUrl.searchParams.forEach((value, key) => {
if (limit) url.searchParams.set("limit", limit); url.searchParams.set(key, value);
});
const res = await fetch(url.toString(), { const res = await fetch(url.toString(), {
headers: auth.headers, headers: auth.headers,
@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
buildJsonBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ caseId: 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 resolved = await params;
const body = await req.text();
const res = await fetch(
`${API_BASE}/api/ops/refunds/${encodeURIComponent(resolved.caseId)}`,
{
method: "PATCH",
cache: "no-store",
headers: buildJsonBackendRequestHeaders(auth.headers),
body,
},
);
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to update refund case",
});
}
}
+84
View File
@@ -0,0 +1,84 @@
import { NextRequest, NextResponse } from "next/server";
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
buildJsonBackendRequestHeaders,
} from "@/lib/backend-auth";
import { requireOpsProxyAuth } from "@/lib/ops-proxy-auth";
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
export async function GET(req: NextRequest) {
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 upstream = new URL(`${API_BASE}/api/ops/refunds`);
req.nextUrl.searchParams.forEach((value, key) => {
upstream.searchParams.set(key, value);
});
const res = await fetch(upstream.toString(), {
cache: "no-store",
headers: auth.headers,
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to fetch refund cases",
});
}
}
export async function POST(req: NextRequest) {
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 body = await req.text();
const res = await fetch(`${API_BASE}/api/ops/refunds`, {
method: "POST",
cache: "no-store",
headers: buildJsonBackendRequestHeaders(auth.headers),
body,
});
const raw = await res.text();
const response = new NextResponse(raw, {
status: res.status,
headers: {
"Cache-Control": "no-store",
"Content-Type": res.headers.get("content-type") || "application/json",
},
});
return applyAuthResponseCookies(response, auth.response);
} catch (error) {
return buildProxyExceptionResponse(error, {
publicMessage: "Failed to create refund case",
});
}
}
+5
View File
@@ -0,0 +1,5 @@
import { AuditLogPageClient } from "@/components/ops/audit/AuditLogPageClient";
export default function OpsAuditLogPage() {
return <AuditLogPageClient />;
}
@@ -70,6 +70,17 @@ import { getTurnstileTokenForAction } from "@/lib/turnstile-client";
// --- Main Component --- // --- Main Component ---
function pointSourceLabel(source?: string, isEn = false) {
const key = String(source || "").trim().toLowerCase();
if (key === "feedback_reward") return isEn ? "Feedback reward" : "反馈奖励";
if (key === "ops_manual_grant") return isEn ? "Ops manual grant" : "后台补发";
if (key === "paid_referral") return isEn ? "Paid referral" : "有效付费邀请";
if (key === "growth_milestone_reward") return isEn ? "Growth reward" : "增长奖励";
if (key === "points_redemption") return isEn ? "Payment redemption" : "支付抵扣";
if (key === "ops_subscription_deduction") return isEn ? "Ops deduction" : "后台订阅扣分";
return key || (isEn ? "Unknown source" : "未知来源");
}
export function AccountCenter() { export function AccountCenter() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@@ -535,6 +546,9 @@ export function AccountCenter() {
const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw) const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw)
? Math.max(0, monthlyReferralPointsLimitRaw) ? Math.max(0, monthlyReferralPointsLimitRaw)
: monthlyReferralLimit * referralRewardPoints; : monthlyReferralLimit * referralRewardPoints;
const pointsLedger = backend?.points_ledger;
const pointSourceRows = Object.entries(pointsLedger?.by_source ?? {});
const recentPointEvents = pointsLedger?.recent ?? [];
// ── Telegram bind command ────────────────────────────── // ── Telegram bind command ──────────────────────────────
const bindCommand = telegramBindCommand || copy.telegramBindCommandPlaceholder; const bindCommand = telegramBindCommand || copy.telegramBindCommandPlaceholder;
@@ -897,6 +911,64 @@ export function AccountCenter() {
</div> </div>
)} )}
<section className="lg:col-span-12 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<h3 className="flex items-center gap-2 text-lg font-bold text-slate-950">
<Coins size={20} className="text-yellow-500" />
{isEn ? "Point Sources" : "积分来源"}
</h3>
<span className="text-xs font-semibold text-slate-500">
{isEn ? "Current balance" : "当前余额"} {Number(pointsLedger?.balance ?? totalPoints).toLocaleString()}
</span>
</div>
{pointSourceRows.length === 0 && recentPointEvents.length === 0 ? (
<p className="mt-3 rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-xs text-slate-500">
{copy.pointsRule}
</p>
) : (
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
<div className="grid gap-2 sm:grid-cols-2">
{pointSourceRows.map(([source, item]) => (
<div key={source} className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div className="text-xs font-bold text-slate-600">
{pointSourceLabel(source, isEn)}
</div>
<div className="mt-1 text-lg font-black text-slate-950">
{Number(item?.points ?? 0).toLocaleString()}
</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{Number(item?.count ?? 0).toLocaleString()} {isEn ? "events" : "笔记录"} · {source}
</div>
</div>
))}
</div>
<div className="rounded-xl border border-slate-200">
<div className="border-b border-slate-200 px-4 py-2 text-xs font-bold uppercase text-slate-500">
{isEn ? "Recent point ledger" : "最近积分流水"}
</div>
<div className="divide-y divide-slate-100">
{recentPointEvents.slice(0, 5).map((event, index) => (
<div key={`${event.id ?? index}-${event.source}`} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-2 text-sm">
<div className="min-w-0">
<div className="truncate font-semibold text-slate-800">
{pointSourceLabel(event.source, isEn)}
</div>
<div className="mt-0.5 truncate text-[11px] text-slate-500">
{event.reference_type || "ledger"} · {event.created_at ? formatTime(event.created_at, locale) : "--"}
</div>
</div>
<div className={`font-mono font-bold ${Number(event.delta_points ?? 0) >= 0 ? "text-emerald-600" : "text-red-600"}`}>
{Number(event.delta_points ?? 0) >= 0 ? "+" : ""}
{Number(event.delta_points ?? 0).toLocaleString()}
</div>
</div>
))}
</div>
</div>
</div>
)}
</section>
{/* Subscription Info & Paywall */} {/* Subscription Info & Paywall */}
<div className="lg:col-span-12 relative"> <div className="lg:col-span-12 relative">
<div <div
@@ -0,0 +1,29 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const types = fs.readFileSync(path.join(projectRoot, "components", "account", "types.ts"), "utf8");
const accountCenter = fs.readFileSync(
path.join(projectRoot, "components", "account", "AccountCenter.tsx"),
"utf8",
);
assert(
types.includes("points_ledger") &&
types.includes("PointsLedgerSummary") &&
types.includes("PointsLedgerEntry"),
"auth/me account types must include points ledger summary and entries",
);
assert(
accountCenter.includes("points_ledger") &&
accountCenter.includes("积分来源") &&
accountCenter.includes("feedback_reward") &&
accountCenter.includes("ops_manual_grant"),
"account center must display point source explainability from auth/me",
);
}
+19
View File
@@ -3,6 +3,7 @@ export type AuthMeResponse = {
user_id?: string | null; user_id?: string | null;
email?: string | null; email?: string | null;
points?: number; points?: number;
points_ledger?: PointsLedgerSummary;
weekly_points?: number; weekly_points?: number;
weekly_rank?: number | string | null; weekly_rank?: number | string | null;
entitlement_mode?: string | null; entitlement_mode?: string | null;
@@ -25,6 +26,24 @@ export type AuthMeResponse = {
entitlement_snapshot_reason?: string | null; entitlement_snapshot_reason?: string | null;
}; };
export type PointsLedgerEntry = {
id?: number;
source?: string;
delta_points?: number;
balance_after?: number;
actor_email?: string;
reference_type?: string;
reference_id?: string;
metadata?: Record<string, unknown>;
created_at?: string;
};
export type PointsLedgerSummary = {
balance?: number;
recent?: PointsLedgerEntry[];
by_source?: Record<string, { points?: number; count?: number }>;
};
export type ReferralSummary = { export type ReferralSummary = {
code?: string; code?: string;
discount_usdc?: string; discount_usdc?: string;
@@ -239,6 +239,72 @@
line-height: 1.75; line-height: 1.75;
} }
.searchWrap {
display: flex;
align-items: center;
gap: 10px;
max-width: 620px;
min-height: 42px;
margin-top: 18px;
padding: 0 12px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #ffffff;
color: #64748b;
}
.searchInput {
min-width: 0;
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: #0f172a;
font-size: 0.94rem;
font-weight: 650;
}
.searchInput::placeholder {
color: #94a3b8;
}
.searchResults {
display: grid;
gap: 8px;
max-width: 720px;
margin-top: 10px;
}
.searchResult,
.searchEmpty {
display: grid;
gap: 3px;
padding: 10px 12px;
border: 1px solid #dbeafe;
border-radius: 8px;
background: #f8fbff;
color: #1d4ed8;
text-decoration: none;
}
.searchResult span {
color: #0f172a;
font-size: 0.9rem;
font-weight: 850;
}
.searchResult small,
.searchEmpty {
color: #64748b;
font-size: 0.78rem;
line-height: 1.45;
}
.searchResult:hover {
border-color: #93c5fd;
background: #eff6ff;
}
.pageMeta { .pageMeta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
+72 -1
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import clsx from "clsx"; import clsx from "clsx";
import { ArrowLeft, BookOpen, Menu } from "lucide-react"; import { ArrowLeft, BookOpen, Menu, Search } from "lucide-react";
import styles from "./DocsLayout.module.css"; import styles from "./DocsLayout.module.css";
import { import {
DocsLocale, DocsLocale,
@@ -171,8 +171,49 @@ export function DocsScreen({ page }: { page: DocsPage }) {
const pathname = usePathname(); const pathname = usePathname();
const { locale } = useI18n(); const { locale } = useI18n();
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const localizedPage = useMemo(() => page.content[locale], [locale, page]); const localizedPage = useMemo(() => page.content[locale], [locale, page]);
const currentSlug = pathname?.split("/").filter(Boolean).at(-1) || page.slug; const currentSlug = pathname?.split("/").filter(Boolean).at(-1) || page.slug;
const searchResults = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) return [];
return DOCS_PAGES.flatMap((doc) => {
const content = doc.content[locale];
const sectionText = content.sections
.flatMap((section) => [
section.title,
...section.blocks.flatMap((block) => {
if (block.type === "paragraph") return [block.text];
if (block.type === "callout") return [block.title || "", block.text];
if (block.type === "bullets" || block.type === "steps") return block.items;
if (block.type === "link") return [block.label, block.caption || ""];
if (block.type === "image") return [block.alt, block.caption || ""];
return [];
}),
])
.join(" ");
const haystack = `${content.title} ${content.description} ${sectionText}`.toLowerCase();
if (!haystack.includes(query)) return [];
const matchedSection =
content.sections.find((section) => section.title.toLowerCase().includes(query)) ||
content.sections.find((section) =>
section.blocks.some((block) => {
if (block.type === "paragraph") return block.text.toLowerCase().includes(query);
if (block.type === "callout") return `${block.title || ""} ${block.text}`.toLowerCase().includes(query);
if (block.type === "bullets" || block.type === "steps") return block.items.join(" ").toLowerCase().includes(query);
return false;
}),
) ||
content.sections[0];
return [{
slug: doc.slug,
title: content.title,
description: content.description,
sectionId: matchedSection?.id,
sectionTitle: matchedSection?.title,
}];
}).slice(0, 8);
}, [locale, searchQuery]);
return ( return (
<div className={styles.docsShell}> <div className={styles.docsShell}>
@@ -198,6 +239,36 @@ export function DocsScreen({ page }: { page: DocsPage }) {
</div> </div>
<h1 className={styles.pageTitle}>{localizedPage.title}</h1> <h1 className={styles.pageTitle}>{localizedPage.title}</h1>
<p className={styles.pageDescription}>{localizedPage.description}</p> <p className={styles.pageDescription}>{localizedPage.description}</p>
<div className={styles.searchWrap}>
<Search size={16} aria-hidden="true" />
<input
className={styles.searchInput}
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
placeholder={locale === "zh-CN" ? "搜索文档、章节或关键词" : "Search docs, sections, or keywords"}
aria-label={locale === "zh-CN" ? "搜索文档" : "Search docs"}
/>
</div>
{searchQuery.trim() ? (
<div className={styles.searchResults}>
{searchResults.length === 0 ? (
<div className={styles.searchEmpty}>
{locale === "zh-CN" ? "没有匹配文档" : "No matching docs"}
</div>
) : (
searchResults.map((result) => (
<Link
key={`${result.slug}-${result.sectionId || "top"}`}
href={`/docs/${result.slug}${result.sectionId ? `#${result.sectionId}` : ""}`}
className={styles.searchResult}
>
<span>{result.title}</span>
<small>{result.sectionTitle || result.description}</small>
</Link>
))
)}
</div>
) : null}
<div className={styles.pageMeta} aria-label={locale === "zh-CN" ? "文档范围" : "Document scope"}> <div className={styles.pageMeta} aria-label={locale === "zh-CN" ? "文档范围" : "Document scope"}>
<span>{locale === "zh-CN" ? "当前工作台" : "Current terminal"}</span> <span>{locale === "zh-CN" ? "当前工作台" : "Current terminal"}</span>
<span>{locale === "zh-CN" ? `${DOCS_PAGES.length} 篇文档` : `${DOCS_PAGES.length} docs`}</span> <span>{locale === "zh-CN" ? `${DOCS_PAGES.length} 篇文档` : `${DOCS_PAGES.length} docs`}</span>
@@ -0,0 +1,33 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const source = fs.readFileSync(
path.join(projectRoot, "components", "docs", "DocsScreen.tsx"),
"utf8",
);
const css = fs.readFileSync(
path.join(projectRoot, "components", "docs", "DocsLayout.module.css"),
"utf8",
);
assert(
source.includes("Search") &&
source.includes("searchQuery") &&
source.includes("searchResults") &&
source.includes("DOCS_PAGES.flatMap") &&
source.includes("block.type === \"paragraph\""),
"docs screen must build a local search index from page titles, sections, and paragraph text",
);
assert(
css.includes(".searchWrap") &&
css.includes(".searchInput") &&
css.includes(".searchResults"),
"docs layout must style the search input and result list",
);
}
@@ -0,0 +1,58 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const opsApi = fs.readFileSync(path.join(projectRoot, "lib", "ops-api.ts"), "utf8");
const opsTypes = fs.readFileSync(path.join(projectRoot, "types", "ops.ts"), "utf8");
const sidebar = fs.readFileSync(
path.join(projectRoot, "components", "ops", "layout", "AdminSidebar.tsx"),
"utf8",
);
const paymentsPage = fs.readFileSync(
path.join(projectRoot, "components", "ops", "payments", "PaymentsPageClient.tsx"),
"utf8",
);
assert(
opsApi.includes("auditLog(") &&
opsApi.includes("/api/ops/audit-log") &&
opsApi.includes("refunds(") &&
opsApi.includes("/api/ops/refunds") &&
opsApi.includes("updateRefund("),
"ops API client must expose audit log and refund case endpoints",
);
assert(
opsTypes.includes("OpsAuditEvent") &&
opsTypes.includes("RefundCase") &&
opsTypes.includes("refund_case_id") &&
opsTypes.includes("refund_status"),
"ops types must model audit events, refund cases, and incident refund metadata",
);
assert(
sidebar.includes("/ops/audit-log") && sidebar.includes("审计日志"),
"ops sidebar must expose the unified audit log page",
);
assert(
paymentsPage.includes("退款工单") &&
paymentsPage.includes("refund_case_id") &&
paymentsPage.includes("refund_status") &&
paymentsPage.includes("opsApi.refunds"),
"ops payment page must surface refund cases next to payment incidents",
);
const auditRoute = path.join(projectRoot, "app", "api", "ops", "audit-log", "route.ts");
const refundsRoute = path.join(projectRoot, "app", "api", "ops", "refunds", "route.ts");
const refundUpdateRoute = path.join(projectRoot, "app", "api", "ops", "refunds", "[caseId]", "route.ts");
for (const route of [auditRoute, refundsRoute, refundUpdateRoute]) {
const source = fs.readFileSync(route, "utf8");
assert(
source.includes("requireOpsProxyAuth") && source.includes("no-store"),
`${path.basename(path.dirname(route))} ops proxy route must be admin-protected and uncached`,
);
}
}
@@ -0,0 +1,110 @@
"use client";
import { useEffect, useState } from "react";
import { RefreshCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { opsApi } from "@/lib/ops-api";
import type { OpsAuditEvent } from "@/types/ops";
function compactDate(value?: string) {
if (!value) return "--";
return value.slice(0, 19).replace("T", " ");
}
function actionLabel(action?: string) {
const key = String(action || "").trim().toLowerCase();
if (key === "manual_points_grant") return "手动补分";
if (key === "feedback_reward_grant") return "反馈奖励";
if (key === "subscription_manual_grant") return "手动开通";
if (key === "subscription_manual_extend") return "会员延期";
if (key === "refund_case_create") return "创建退款工单";
if (key === "refund_case_update") return "更新退款工单";
return key || "未知操作";
}
export function AuditLogPageClient() {
const [loading, setLoading] = useState(true);
const [events, setEvents] = useState<OpsAuditEvent[]>([]);
const load = async () => {
setLoading(true);
try {
const payload = (await opsApi.auditLog(150)) as { events?: OpsAuditEvent[] };
setEvents(payload.events ?? []);
} catch {
setEvents([]);
}
setLoading(false);
};
useEffect(() => {
void load();
}, []);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white"></h1>
<Button variant="outline" size="sm" onClick={load} className="gap-1.5">
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<Card>
<CardHeader>
<CardTitle> ({events.length})</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="text-sm text-slate-500">...</div>
) : events.length === 0 ? (
<div className="text-sm text-slate-500"></div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-slate-500">
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
<th className="py-2 pr-4 font-bold"></th>
</tr>
</thead>
<tbody>
{events.map((event) => (
<tr key={event.id} className="border-b border-slate-100">
<td className="whitespace-nowrap py-2 pr-4 font-mono text-xs text-slate-500">
{compactDate(event.created_at)}
</td>
<td className="py-2 pr-4 font-bold text-slate-900">
{actionLabel(event.action)}
</td>
<td className="py-2 pr-4 text-xs text-slate-600">
{event.actor_email || "--"}
</td>
<td className="py-2 pr-4">
<div className="font-mono text-xs text-blue-700">
{event.target_email || event.target_user_id || event.target_id || "--"}
</div>
<div className="mt-0.5 text-[11px] text-slate-500">
{event.target_type || "--"}
</div>
</td>
<td className="max-w-md py-2 pr-4">
<code className="block truncate rounded bg-slate-100 px-2 py-1 text-[11px] text-slate-600">
{JSON.stringify(event.payload ?? {})}
</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -44,6 +44,7 @@ const navGroups = [
items: [ items: [
{ href: "/ops/config", icon: Settings, label: "系统配置" }, { href: "/ops/config", icon: Settings, label: "系统配置" },
{ href: "/ops/subscriptions", icon: ScrollText, label: "订阅操作" }, { href: "/ops/subscriptions", icon: ScrollText, label: "订阅操作" },
{ href: "/ops/audit-log", icon: FileText, label: "审计日志" },
{ href: "/ops/view-logs", icon: FileText, label: "日志查看" }, { href: "/ops/view-logs", icon: FileText, label: "日志查看" },
], ],
}, },
@@ -12,6 +12,7 @@ import type {
PaymentRuntimePayload, PaymentRuntimePayload,
PaymentIncident, PaymentIncident,
PaymentRecord, PaymentRecord,
RefundCase,
} from "@/types/ops"; } from "@/types/ops";
const PaymentIncidentPieChart = dynamic( const PaymentIncidentPieChart = dynamic(
@@ -60,6 +61,8 @@ function paymentReasonLabel(reason?: string) {
if (key === "expired") return "订单已过期"; if (key === "expired") return "订单已过期";
if (key === "event_mismatch") return "支付事件不匹配"; if (key === "event_mismatch") return "支付事件不匹配";
if (key === "direct_transfer_mismatch") return "直接转账不匹配"; if (key === "direct_transfer_mismatch") return "直接转账不匹配";
if (key === "refund_required") return "需要退款处理";
if (key === "duplicate_payment") return "重复付款";
if (key === "unknown") return "未知原因"; if (key === "unknown") return "未知原因";
return key || "未知原因"; return key || "未知原因";
} }
@@ -96,21 +99,24 @@ export function PaymentsPageClient() {
const [runtime, setRuntime] = useState<PaymentRuntimePayload | null>(null); const [runtime, setRuntime] = useState<PaymentRuntimePayload | null>(null);
const [incidents, setIncidents] = useState<PaymentIncident[]>([]); const [incidents, setIncidents] = useState<PaymentIncident[]>([]);
const [payments, setPayments] = useState<PaymentRecord[]>([]); const [payments, setPayments] = useState<PaymentRecord[]>([]);
const [refunds, setRefunds] = useState<RefundCase[]>([]);
const [risk, setRisk] = useState<BillingRiskPayload | null>(null); const [risk, setRisk] = useState<BillingRiskPayload | null>(null);
const [resolving, setResolving] = useState<Set<number>>(new Set()); const [resolving, setResolving] = useState<Set<number>>(new Set());
const load = async () => { const load = async () => {
setLoading(true); setLoading(true);
try { try {
const [rt, inc, pay, riskPayload] = await Promise.all([ const [rt, inc, pay, refundPayload, riskPayload] = await Promise.all([
opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>, opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>,
opsApi.incidents(50), opsApi.incidents(50),
opsApi.listPayments(50), opsApi.listPayments(50),
opsApi.refunds(50),
opsApi.billingRisk(30, 80) as Promise<BillingRiskPayload>, opsApi.billingRisk(30, 80) as Promise<BillingRiskPayload>,
]); ]);
setRuntime(rt); setRuntime(rt);
setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []); setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []);
setPayments((pay as unknown as { payments?: PaymentRecord[] }).payments ?? []); setPayments((pay as unknown as { payments?: PaymentRecord[] }).payments ?? []);
setRefunds((refundPayload as unknown as { refunds?: RefundCase[] }).refunds ?? []);
setRisk(riskPayload); setRisk(riskPayload);
} catch { /* */ } } catch { /* */ }
setLoading(false); setLoading(false);
@@ -328,6 +334,11 @@ export function PaymentsPageClient() {
<div className="mt-0.5 max-w-xl truncate text-xs text-slate-500" title={inc.detail || inc.reason || ""}> <div className="mt-0.5 max-w-xl truncate text-xs text-slate-500" title={inc.detail || inc.reason || ""}>
{inc.detail || inc.reason || "—"} {inc.detail || inc.reason || "—"}
</div> </div>
{inc.refund_case_id ? (
<div className="mt-1 text-[11px] font-semibold text-blue-700">
退 #{inc.refund_case_id} · {inc.refund_status || "open"}
</div>
) : null}
</td> </td>
<td className="py-2 pr-4 text-xs text-slate-500"> <td className="py-2 pr-4 text-xs text-slate-500">
<div className="font-mono" title={inc.user_id || ""}>{compactMono(inc.user_id)}</div> <div className="font-mono" title={inc.user_id || ""}>{compactMono(inc.user_id)}</div>
@@ -355,6 +366,54 @@ export function PaymentsPageClient() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader><CardTitle>退 ({refunds.length})</CardTitle></CardHeader>
<CardContent>
{refunds.length === 0 ? (
<span className="text-sm text-slate-500">退</span>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/10 text-left text-slate-400">
<th className="py-2 pr-4 font-medium">ID</th>
<th className="py-2 pr-4 font-medium"> / </th>
<th className="py-2 pr-4 font-medium"> / Intent</th>
<th className="py-2 pr-4 font-medium">Tx Hash</th>
<th className="py-2 pr-4 font-medium"></th>
<th className="py-2 pr-4 font-medium"></th>
</tr>
</thead>
<tbody>
{refunds.map((refund) => (
<tr key={refund.id} className="border-b border-white/5">
<td className="py-2 pr-4 font-mono text-xs text-slate-500">{refund.id}</td>
<td className="py-2 pr-4">
<div className="font-bold text-slate-900">{refund.status || "open"}</div>
<div className="text-xs text-amber-600">{paymentReasonLabel(refund.reason)}</div>
</td>
<td className="py-2 pr-4 text-xs text-slate-500">
<div className="font-mono" title={refund.user_id || ""}>{compactMono(refund.user_id)}</div>
<div className="mt-0.5 font-mono text-blue-700" title={refund.intent_id || ""}>{compactMono(refund.intent_id, 12, 6)}</div>
</td>
<td className="py-2 pr-4 font-mono text-xs text-slate-500" title={refund.tx_hash || ""}>
{compactMono(refund.tx_hash)}
</td>
<td className="py-2 pr-4 text-xs text-slate-500">
{refund.handled_by || refund.created_by || "—"}
</td>
<td className="py-2 pr-4 whitespace-nowrap text-xs text-slate-500">
{compactDate(refund.updated_at || refund.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
<Card> <Card>
<CardHeader><CardTitle> ({payments.length})</CardTitle></CardHeader> <CardHeader><CardTitle> ({payments.length})</CardTitle></CardHeader>
<CardContent> <CardContent>
+31
View File
@@ -109,6 +109,37 @@ export const opsApi = {
if (reason) params.set("reason", reason); if (reason) params.set("reason", reason);
return opsFetch<Record<string, unknown>>(`/api/ops/payments/incidents?${params}`); return opsFetch<Record<string, unknown>>(`/api/ops/payments/incidents?${params}`);
}, },
auditLog(limit = 100, action?: string) {
const params = new URLSearchParams({ limit: String(limit) });
if (action) params.set("action", action);
return opsFetch<Record<string, unknown>>(`/api/ops/audit-log?${params}`);
},
refunds(limit = 50, status?: string) {
const params = new URLSearchParams({ limit: String(limit) });
if (status) params.set("status", status);
return opsFetch<Record<string, unknown>>(`/api/ops/refunds?${params}`);
},
createRefund(input: {
reason: string;
intent_id?: string;
tx_hash?: string;
user_id?: string;
amount_usdc?: string;
note?: string;
}) {
return opsFetch<Record<string, unknown>>("/api/ops/refunds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
},
updateRefund(caseId: string | number, input: { status: string; note?: string }) {
return opsFetch<Record<string, unknown>>(`/api/ops/refunds/${caseId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
},
feedback(limit = 100, status?: string) { feedback(limit = 100, status?: string) {
const params = new URLSearchParams({ limit: String(limit) }); const params = new URLSearchParams({ limit: String(limit) });
if (status) params.set("status", status); if (status) params.set("status", status);
+38
View File
@@ -155,6 +155,8 @@ export type PaymentIncident = {
intent_id?: string; intent_id?: string;
user_id?: string; user_id?: string;
tx_hash?: string; tx_hash?: string;
refund_case_id?: number | string | null;
refund_status?: string;
payload_json?: string; payload_json?: string;
created_at?: string; created_at?: string;
resolved?: boolean; resolved?: boolean;
@@ -166,6 +168,42 @@ export type PaymentIncident = {
last_seen_at?: string; last_seen_at?: string;
}; };
export type RefundCase = {
id: number;
status?: string;
reason?: string;
intent_id?: string;
tx_hash?: string;
user_id?: string;
amount_usdc?: string;
created_by?: string;
handled_by?: string;
notes?: Array<{ note?: string; by?: string; at?: string }>;
created_at?: string;
updated_at?: string;
};
export type RefundCasesPayload = {
refunds?: RefundCase[];
};
export type OpsAuditEvent = {
id: number;
action?: string;
actor_email?: string;
target_user_id?: string;
target_email?: string;
target_type?: string;
target_id?: string;
payload?: Record<string, unknown>;
created_at?: string;
};
export type OpsAuditPayload = {
events?: OpsAuditEvent[];
total?: number;
};
export type IncidentsPayload = { export type IncidentsPayload = {
incidents?: PaymentIncident[]; incidents?: PaymentIncident[];
total?: number; total?: number;
+618 -4
View File
@@ -596,6 +596,75 @@ class DBManager:
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_payment_audit_events_created_at ON payment_audit_events(created_at DESC)" "CREATE INDEX IF NOT EXISTS idx_payment_audit_events_created_at ON payment_audit_events(created_at DESC)"
) )
conn.execute("""
CREATE TABLE IF NOT EXISTS ops_audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL,
actor_email TEXT NOT NULL DEFAULT '',
target_user_id TEXT NOT NULL DEFAULT '',
target_email TEXT NOT NULL DEFAULT '',
target_type TEXT NOT NULL DEFAULT '',
target_id TEXT NOT NULL DEFAULT '',
payload_json TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_ops_audit_events_created_at
ON ops_audit_events(created_at DESC)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_ops_audit_events_action_created_at
ON ops_audit_events(action, created_at DESC)
"""
)
conn.execute("""
CREATE TABLE IF NOT EXISTS points_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_id INTEGER,
supabase_user_id TEXT NOT NULL DEFAULT '',
supabase_email TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL,
delta_points INTEGER NOT NULL,
balance_after INTEGER NOT NULL,
actor_email TEXT NOT NULL DEFAULT '',
reference_type TEXT NOT NULL DEFAULT '',
reference_id TEXT NOT NULL DEFAULT '',
metadata_json TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_points_ledger_user_created_at
ON points_ledger(supabase_user_id, supabase_email, created_at DESC)
"""
)
conn.execute("""
CREATE TABLE IF NOT EXISTS payment_refund_cases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
status TEXT NOT NULL DEFAULT 'open',
reason TEXT NOT NULL,
intent_id TEXT NOT NULL DEFAULT '',
tx_hash TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL DEFAULT '',
amount_usdc TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL DEFAULT '',
handled_by TEXT NOT NULL DEFAULT '',
notes_json TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_payment_refund_cases_status_created_at
ON payment_refund_cases(status, created_at DESC)
"""
)
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS observation_patch_events ( CREATE TABLE IF NOT EXISTS observation_patch_events (
revision INTEGER PRIMARY KEY AUTOINCREMENT, revision INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1673,6 +1742,479 @@ class DBManager:
) )
conn.commit() conn.commit()
def append_ops_audit_event(
self,
*,
action: str,
actor_email: str = "",
target_user_id: str = "",
target_email: str = "",
target_type: str = "",
target_id: str = "",
payload: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
normalized_action = str(action or "").strip().lower()
if not normalized_action:
return {"ok": False, "reason": "invalid_action"}
body = payload if isinstance(payload, dict) else {}
now = datetime.now().isoformat()
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"""
INSERT INTO ops_audit_events (
action,
actor_email,
target_user_id,
target_email,
target_type,
target_id,
payload_json,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
normalized_action,
str(actor_email or "").strip().lower(),
str(target_user_id or "").strip().lower(),
str(target_email or "").strip().lower(),
str(target_type or "").strip().lower(),
str(target_id or "").strip(),
json.dumps(body, ensure_ascii=False, default=str),
now,
),
)
event_id = int(cursor.lastrowid)
conn.commit()
return {
"id": event_id,
"action": normalized_action,
"actor_email": str(actor_email or "").strip().lower(),
"target_user_id": str(target_user_id or "").strip().lower(),
"target_email": str(target_email or "").strip().lower(),
"target_type": str(target_type or "").strip().lower(),
"target_id": str(target_id or "").strip(),
"payload": body,
"created_at": now,
}
def list_ops_audit_events(
self,
*,
limit: int = 100,
action: str = "",
actor_email: str = "",
target_user_id: str = "",
) -> List[Dict[str, Any]]:
safe_limit = max(1, min(int(limit or 100), 500))
clauses: List[str] = []
params: List[Any] = []
normalized_action = str(action or "").strip().lower()
normalized_actor = str(actor_email or "").strip().lower()
normalized_target_user = str(target_user_id or "").strip().lower()
if normalized_action:
clauses.append("action = ?")
params.append(normalized_action)
if normalized_actor:
clauses.append("actor_email = ?")
params.append(normalized_actor)
if normalized_target_user:
clauses.append("target_user_id = ?")
params.append(normalized_target_user)
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
params.append(safe_limit)
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
f"""
SELECT id, action, actor_email, target_user_id, target_email,
target_type, target_id, payload_json, created_at
FROM ops_audit_events
{where_sql}
ORDER BY id DESC
LIMIT ?
""",
tuple(params),
).fetchall()
events: List[Dict[str, Any]] = []
for row in rows:
try:
payload = json.loads(str(row["payload_json"] or "{}"))
except Exception:
payload = {}
events.append(
{
"id": int(row["id"]),
"action": str(row["action"] or ""),
"actor_email": str(row["actor_email"] or ""),
"target_user_id": str(row["target_user_id"] or ""),
"target_email": str(row["target_email"] or ""),
"target_type": str(row["target_type"] or ""),
"target_id": str(row["target_id"] or ""),
"payload": payload if isinstance(payload, dict) else {},
"created_at": row["created_at"],
}
)
return events
def _append_points_ledger_entry_conn(
self,
conn: sqlite3.Connection,
*,
telegram_id: Optional[int],
supabase_user_id: str = "",
supabase_email: str = "",
source: str,
delta_points: int,
balance_after: int,
actor_email: str = "",
reference_type: str = "",
reference_id: str = "",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
normalized_source = str(source or "").strip().lower()
if not normalized_source or int(delta_points or 0) == 0:
return
conn.execute(
"""
INSERT INTO points_ledger (
telegram_id,
supabase_user_id,
supabase_email,
source,
delta_points,
balance_after,
actor_email,
reference_type,
reference_id,
metadata_json,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
int(telegram_id) if telegram_id is not None else None,
str(supabase_user_id or "").strip().lower(),
str(supabase_email or "").strip().lower(),
normalized_source,
int(delta_points),
int(balance_after),
str(actor_email or "").strip().lower(),
str(reference_type or "").strip().lower(),
str(reference_id or "").strip(),
json.dumps(metadata if isinstance(metadata, dict) else {}, ensure_ascii=False, default=str),
datetime.now().isoformat(),
),
)
def append_points_ledger_entry(
self,
*,
telegram_id: Optional[int] = None,
supabase_user_id: str = "",
supabase_email: str = "",
source: str,
delta_points: int,
balance_after: int,
actor_email: str = "",
reference_type: str = "",
reference_id: str = "",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
with self._get_connection() as conn:
self._append_points_ledger_entry_conn(
conn,
telegram_id=telegram_id,
supabase_user_id=supabase_user_id,
supabase_email=supabase_email,
source=source,
delta_points=delta_points,
balance_after=balance_after,
actor_email=actor_email,
reference_type=reference_type,
reference_id=reference_id,
metadata=metadata,
)
conn.commit()
def list_points_ledger_entries(
self,
*,
limit: int = 20,
supabase_user_id: str = "",
supabase_email: str = "",
) -> List[Dict[str, Any]]:
safe_limit = max(1, min(int(limit or 20), 200))
normalized_user_id = str(supabase_user_id or "").strip().lower()
normalized_email = str(supabase_email or "").strip().lower()
if not normalized_user_id and not normalized_email:
return []
clauses: List[str] = []
params: List[Any] = []
if normalized_user_id:
clauses.append("supabase_user_id = ?")
params.append(normalized_user_id)
if normalized_email:
clauses.append("supabase_email = ?")
params.append(normalized_email)
where_sql = f"WHERE {' OR '.join(clauses)}" if clauses else ""
params.append(safe_limit)
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
f"""
SELECT id, telegram_id, supabase_user_id, supabase_email, source,
delta_points, balance_after, actor_email, reference_type,
reference_id, metadata_json, created_at
FROM points_ledger
{where_sql}
ORDER BY id DESC
LIMIT ?
""",
tuple(params),
).fetchall()
out: List[Dict[str, Any]] = []
for row in rows:
try:
metadata = json.loads(str(row["metadata_json"] or "{}"))
except Exception:
metadata = {}
out.append(
{
"id": int(row["id"]),
"telegram_id": row["telegram_id"],
"supabase_user_id": str(row["supabase_user_id"] or ""),
"supabase_email": str(row["supabase_email"] or ""),
"source": str(row["source"] or ""),
"delta_points": int(row["delta_points"] or 0),
"balance_after": int(row["balance_after"] or 0),
"actor_email": str(row["actor_email"] or ""),
"reference_type": str(row["reference_type"] or ""),
"reference_id": str(row["reference_id"] or ""),
"metadata": metadata if isinstance(metadata, dict) else {},
"created_at": row["created_at"],
}
)
return out
def get_points_ledger_summary(
self,
*,
supabase_user_id: str = "",
supabase_email: str = "",
limit: int = 20,
) -> Dict[str, Any]:
recent = self.list_points_ledger_entries(
limit=limit,
supabase_user_id=supabase_user_id,
supabase_email=supabase_email,
)
by_source: Dict[str, Dict[str, int]] = {}
for row in recent:
source = str(row.get("source") or "unknown")
bucket = by_source.setdefault(source, {"points": 0, "count": 0})
bucket["points"] += int(row.get("delta_points") or 0)
bucket["count"] += 1
balance = int(recent[0]["balance_after"]) if recent else (
self.get_points_by_supabase_user_id(supabase_user_id)
if supabase_user_id
else self.get_points_by_supabase_email(supabase_email)
)
return {
"balance": max(0, balance),
"recent": recent,
"by_source": by_source,
}
@staticmethod
def _refund_case_row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
try:
notes = json.loads(str(row["notes_json"] or "[]"))
except Exception:
notes = []
return {
"id": int(row["id"]),
"status": str(row["status"] or ""),
"reason": str(row["reason"] or ""),
"intent_id": str(row["intent_id"] or ""),
"tx_hash": str(row["tx_hash"] or ""),
"user_id": str(row["user_id"] or ""),
"amount_usdc": str(row["amount_usdc"] or ""),
"created_by": str(row["created_by"] or ""),
"handled_by": str(row["handled_by"] or ""),
"notes": notes if isinstance(notes, list) else [],
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def create_refund_case(
self,
*,
reason: str,
intent_id: str = "",
tx_hash: str = "",
user_id: str = "",
amount_usdc: str = "",
created_by: str = "",
note: str = "",
) -> Dict[str, Any]:
normalized_reason = str(reason or "").strip().lower()
if not normalized_reason:
return {"ok": False, "reason": "invalid_refund_reason"}
now = datetime.now().isoformat()
notes = []
note_text = str(note or "").strip()
if note_text:
notes.append(
{
"note": note_text,
"by": str(created_by or "").strip().lower(),
"at": now,
}
)
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
cursor = conn.execute(
"""
INSERT INTO payment_refund_cases (
status,
reason,
intent_id,
tx_hash,
user_id,
amount_usdc,
created_by,
handled_by,
notes_json,
created_at,
updated_at
)
VALUES ('open', ?, ?, ?, ?, ?, ?, '', ?, ?, ?)
""",
(
normalized_reason,
str(intent_id or "").strip(),
str(tx_hash or "").strip().lower(),
str(user_id or "").strip().lower(),
str(amount_usdc or "").strip(),
str(created_by or "").strip().lower(),
json.dumps(notes, ensure_ascii=False, default=str),
now,
now,
),
)
case_id = int(cursor.lastrowid)
row = conn.execute(
"""
SELECT id, status, reason, intent_id, tx_hash, user_id,
amount_usdc, created_by, handled_by, notes_json,
created_at, updated_at
FROM payment_refund_cases
WHERE id = ?
""",
(case_id,),
).fetchone()
conn.commit()
return self._refund_case_row_to_dict(row)
def update_refund_case(
self,
case_id: int,
*,
status: str,
handled_by: str = "",
note: str = "",
) -> Optional[Dict[str, Any]]:
safe_id = int(case_id or 0)
normalized_status = str(status or "").strip().lower()
allowed = {"open", "processing", "refunded", "rejected", "closed"}
if safe_id <= 0 or normalized_status not in allowed:
return None
now = datetime.now().isoformat()
actor = str(handled_by or "").strip().lower()
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
row = conn.execute(
"""
SELECT notes_json
FROM payment_refund_cases
WHERE id = ?
LIMIT 1
""",
(safe_id,),
).fetchone()
if not row:
return None
try:
notes = json.loads(str(row["notes_json"] or "[]"))
except Exception:
notes = []
if not isinstance(notes, list):
notes = []
note_text = str(note or "").strip()
if note_text:
notes.append({"note": note_text, "by": actor, "at": now})
conn.execute(
"""
UPDATE payment_refund_cases
SET status = ?,
handled_by = ?,
notes_json = ?,
updated_at = ?
WHERE id = ?
""",
(
normalized_status,
actor,
json.dumps(notes, ensure_ascii=False, default=str),
now,
safe_id,
),
)
updated = conn.execute(
"""
SELECT id, status, reason, intent_id, tx_hash, user_id,
amount_usdc, created_by, handled_by, notes_json,
created_at, updated_at
FROM payment_refund_cases
WHERE id = ?
""",
(safe_id,),
).fetchone()
conn.commit()
return self._refund_case_row_to_dict(updated)
def list_refund_cases(
self,
*,
limit: int = 50,
status: Optional[str] = None,
) -> List[Dict[str, Any]]:
safe_limit = max(1, min(int(limit or 50), 200))
normalized_status = str(status or "").strip().lower()
params: List[Any] = []
where_sql = ""
if normalized_status:
where_sql = "WHERE status = ?"
params.append(normalized_status)
params.append(safe_limit)
with self._get_connection() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
f"""
SELECT id, status, reason, intent_id, tx_hash, user_id,
amount_usdc, created_by, handled_by, notes_json,
created_at, updated_at
FROM payment_refund_cases
{where_sql}
ORDER BY id DESC
LIMIT ?
""",
tuple(params),
).fetchall()
return [self._refund_case_row_to_dict(row) for row in rows]
def append_app_analytics_event( def append_app_analytics_event(
self, self,
event_type: str, event_type: str,
@@ -1981,6 +2523,7 @@ class DBManager:
*, *,
points: int, points: int,
reason: str = "", reason: str = "",
actor_email: str = "",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
safe_points = int(points or 0) safe_points = int(points or 0)
if safe_points <= 0: if safe_points <= 0:
@@ -2017,7 +2560,7 @@ class DBManager:
user_row = conn.execute( user_row = conn.execute(
""" """
SELECT telegram_id, username, points, supabase_email SELECT telegram_id, username, points, supabase_email, supabase_user_id
FROM users FROM users
WHERE lower(trim(COALESCE(supabase_email, ''))) = ? WHERE lower(trim(COALESCE(supabase_email, ''))) = ?
LIMIT 1 LIMIT 1
@@ -2027,7 +2570,8 @@ class DBManager:
if not user_row: if not user_row:
user_row = conn.execute( user_row = conn.execute(
""" """
SELECT u.telegram_id, u.username, u.points, b.supabase_email SELECT u.telegram_id, u.username, u.points, b.supabase_email,
b.supabase_user_id
FROM users u FROM users u
JOIN supabase_bindings b ON b.telegram_id = u.telegram_id JOIN supabase_bindings b ON b.telegram_id = u.telegram_id
WHERE lower(trim(COALESCE(b.supabase_email, ''))) = ? WHERE lower(trim(COALESCE(b.supabase_email, ''))) = ?
@@ -2074,6 +2618,19 @@ class DBManager:
""", """,
(safe_points, normalized_reason, now, now, int(feedback_id)), (safe_points, normalized_reason, now, now, int(feedback_id)),
) )
self._append_points_ledger_entry_conn(
conn,
telegram_id=telegram_id,
supabase_user_id=str(user_row["supabase_user_id"] or "").strip().lower(),
supabase_email=str(user_row["supabase_email"] or email),
source="feedback_reward",
delta_points=safe_points,
balance_after=after,
actor_email=actor_email,
reference_type="feedback",
reference_id=str(feedback_id),
metadata={"reason": normalized_reason},
)
updated_feedback_row = conn.execute( updated_feedback_row = conn.execute(
""" """
SELECT id, category, message, source, status, contact, user_id, SELECT id, category, message, source, status, contact, user_id,
@@ -2764,6 +3321,12 @@ class DBManager:
self, self,
supabase_email: str, supabase_email: str,
amount: int, amount: int,
*,
source: str = "manual_adjustment",
actor_email: str = "",
reference_type: str = "",
reference_id: str = "",
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
email = str(supabase_email or "").strip().lower() email = str(supabase_email or "").strip().lower()
points = int(amount or 0) points = int(amount or 0)
@@ -2776,7 +3339,7 @@ class DBManager:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
row = conn.execute( row = conn.execute(
""" """
SELECT telegram_id, username, points, supabase_email SELECT telegram_id, username, points, supabase_email, supabase_user_id
FROM users FROM users
WHERE lower(trim(COALESCE(supabase_email, ''))) = ? WHERE lower(trim(COALESCE(supabase_email, ''))) = ?
LIMIT 1 LIMIT 1
@@ -2797,6 +3360,19 @@ class DBManager:
""", """,
(after, telegram_id), (after, telegram_id),
) )
self._append_points_ledger_entry_conn(
conn,
telegram_id=telegram_id,
supabase_user_id=str(row["supabase_user_id"] or "").strip().lower(),
supabase_email=str(row["supabase_email"] or email),
source=source,
delta_points=points,
balance_after=after,
actor_email=actor_email,
reference_type=reference_type,
reference_id=reference_id,
metadata=metadata,
)
conn.commit() conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id, force=True) self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
return { return {
@@ -2813,6 +3389,12 @@ class DBManager:
self, self,
supabase_user_id: str, supabase_user_id: str,
amount: int, amount: int,
*,
source: str = "manual_adjustment",
actor_email: str = "",
reference_type: str = "",
reference_id: str = "",
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
key = str(supabase_user_id or "").strip().lower() key = str(supabase_user_id or "").strip().lower()
points = int(amount or 0) points = int(amount or 0)
@@ -2849,6 +3431,19 @@ class DBManager:
""", """,
(after, telegram_id), (after, telegram_id),
) )
self._append_points_ledger_entry_conn(
conn,
telegram_id=telegram_id,
supabase_user_id=key,
supabase_email=str(row["supabase_email"] or ""),
source=source,
delta_points=points,
balance_after=after,
actor_email=actor_email,
reference_type=reference_type,
reference_id=reference_id,
metadata=metadata,
)
conn.commit() conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id, force=True) self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
return { return {
@@ -2866,6 +3461,12 @@ class DBManager:
self, self,
supabase_email: str, supabase_email: str,
amount: int, amount: int,
*,
source: str = "points_redemption",
actor_email: str = "",
reference_type: str = "",
reference_id: str = "",
metadata: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
email = str(supabase_email or "").strip().lower() email = str(supabase_email or "").strip().lower()
points = int(amount or 0) points = int(amount or 0)
@@ -2878,7 +3479,7 @@ class DBManager:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
row = conn.execute( row = conn.execute(
""" """
SELECT telegram_id, username, points, supabase_email SELECT telegram_id, username, points, supabase_email, supabase_user_id
FROM users FROM users
WHERE lower(trim(COALESCE(supabase_email, ''))) = ? WHERE lower(trim(COALESCE(supabase_email, ''))) = ?
LIMIT 1 LIMIT 1
@@ -2902,6 +3503,19 @@ class DBManager:
"UPDATE users SET points = ? WHERE telegram_id = ?", "UPDATE users SET points = ? WHERE telegram_id = ?",
(after, telegram_id), (after, telegram_id),
) )
self._append_points_ledger_entry_conn(
conn,
telegram_id=telegram_id,
supabase_user_id=str(row["supabase_user_id"] or "").strip().lower(),
supabase_email=str(row["supabase_email"] or email),
source=source,
delta_points=-points,
balance_after=after,
actor_email=actor_email,
reference_type=reference_type,
reference_id=reference_id,
metadata=metadata,
)
conn.commit() conn.commit()
self._sync_points_to_supabase_user_metadata(telegram_id, force=True) self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
return { return {
+14
View File
@@ -2201,6 +2201,20 @@ class PaymentContractCheckoutService:
prefer="resolution=merge-duplicates,return=minimal", prefer="resolution=merge-duplicates,return=minimal",
allowed_status=[200, 201], allowed_status=[200, 201],
) )
if str(status or "").strip().lower() == "refund_required":
self._db.append_payment_audit_event(
"payment_refund_required",
{
"reason": "refund_required",
"detail": detail,
"intent_id": intent.intent_id,
"user_id": getattr(intent, "user_id", "") or "",
"tx_hash": tx_hash_text,
"chain_id": int(intent.chain_id),
"from_address": _normalize_address(from_address) or "",
"receiver_expected": intent.receiver_address,
},
)
return {} return {}
except Exception: except Exception:
return {} return {}
+354
View File
@@ -0,0 +1,354 @@
from src.database.db_manager import DBManager
from web.schemas.auth import GrantPointsRequest
import web.services.auth_api as auth_api
import web.services.ops.payments as ops_payments
import web.services.ops.users as ops_users
import web.services.system_api as system_api
def test_ops_audit_log_records_admin_action_roundtrip(tmp_path):
db = DBManager(str(tmp_path / "ops-audit.db"))
event = db.append_ops_audit_event(
action="manual_points_grant",
actor_email="ops@example.com",
target_user_id="user-1",
target_email="pilot@example.com",
payload={"points": 300},
)
rows = db.list_ops_audit_events(limit=10)
assert event["id"] > 0
assert rows[0]["action"] == "manual_points_grant"
assert rows[0]["actor_email"] == "ops@example.com"
assert rows[0]["target_user_id"] == "user-1"
assert rows[0]["target_email"] == "pilot@example.com"
assert rows[0]["payload"]["points"] == 300
def test_points_ledger_explains_manual_and_feedback_sources(tmp_path):
db = DBManager(str(tmp_path / "points-ledger.db"))
db.upsert_user(1001, "pilot")
with db._get_connection() as conn: # noqa: SLF001
conn.execute(
"""
UPDATE users
SET points = ?, supabase_user_id = ?, supabase_email = ?
WHERE telegram_id = ?
""",
(50, "user-1", "pilot@example.com", 1001),
)
conn.commit()
grant = db.grant_points_by_supabase_email(
"pilot@example.com",
300,
source="ops_manual_grant",
actor_email="ops@example.com",
reference_type="ops_audit",
reference_id="audit-1",
)
feedback = db.append_user_feedback(
category="data",
message="METAR was stale.",
user_id="user-1",
user_email="pilot@example.com",
)
reward = db.grant_feedback_reward(
feedback["id"],
points=500,
reason="valid stale-data report",
actor_email="ops@example.com",
)
summary = db.get_points_ledger_summary(
supabase_user_id="user-1",
supabase_email="pilot@example.com",
limit=10,
)
assert grant["ok"] is True
assert reward["ok"] is True
assert summary["balance"] == 850
assert summary["by_source"]["ops_manual_grant"]["points"] == 300
assert summary["by_source"]["feedback_reward"]["points"] == 500
assert summary["recent"][0]["source"] == "feedback_reward"
assert summary["recent"][0]["metadata"]["reason"] == "valid stale-data report"
def test_points_ledger_requires_user_identity(tmp_path):
db = DBManager(str(tmp_path / "points-ledger-identity.db"))
db.append_points_ledger_entry(
supabase_user_id="user-1",
supabase_email="pilot@example.com",
source="ops_manual_grant",
delta_points=100,
balance_after=100,
)
summary = db.get_points_ledger_summary(limit=10)
assert summary["balance"] == 0
assert summary["recent"] == []
assert summary["by_source"] == {}
def test_refund_case_state_machine_roundtrip(tmp_path):
db = DBManager(str(tmp_path / "refund-cases.db"))
created = db.create_refund_case(
reason="duplicate_payment",
intent_id="intent-1",
tx_hash="0x" + "1" * 64,
user_id="user-1",
amount_usdc="29.9",
created_by="ops@example.com",
note="User submitted tx after order already paid.",
)
updated = db.update_refund_case(
created["id"],
status="processing",
handled_by="ops2@example.com",
note="Refund tx prepared.",
)
rows = db.list_refund_cases(limit=10)
assert created["status"] == "open"
assert updated["status"] == "processing"
assert updated["handled_by"] == "ops2@example.com"
assert updated["notes"][-1]["note"] == "Refund tx prepared."
assert rows[0]["id"] == created["id"]
assert rows[0]["reason"] == "duplicate_payment"
def test_payment_incidents_include_refund_required_cases(monkeypatch):
class FakeDB:
def list_payment_audit_events(self, limit=50, event_type=None):
if event_type == "payment_intent_failed":
return [
{
"id": 1,
"event_type": "payment_intent_failed",
"payload": {
"reason": "receiver_mismatch",
"intent_id": "intent-1",
"user_id": "user-1",
"tx_hash": "0x" + "1" * 64,
},
"created_at": "2026-06-01T00:00:00",
}
]
if event_type == "payment_refund_required":
return [
{
"id": 2,
"event_type": "payment_refund_required",
"payload": {
"reason": "refund_required",
"intent_id": "intent-2",
"user_id": "user-2",
"tx_hash": "0x" + "2" * 64,
},
"created_at": "2026-06-01T00:01:00",
}
]
return []
def list_refund_cases(self, limit=50, status=None):
return [
{
"id": 10,
"status": "open",
"reason": "duplicate_payment",
"intent_id": "intent-3",
"user_id": "user-3",
"tx_hash": "0x" + "3" * 64,
"created_at": "2026-06-01T00:02:00",
}
]
monkeypatch.setattr(ops_payments, "_require_ops", lambda request: {"email": "ops@example.com"})
monkeypatch.setattr(ops_payments, "_get_db", lambda: FakeDB())
payload = ops_payments.list_ops_payment_incidents(object(), limit=20)
reasons = {item["reason"] for item in payload["incidents"]}
assert {"receiver_mismatch", "refund_required", "duplicate_payment"}.issubset(
reasons
)
assert any(item.get("refund_case_id") == 10 for item in payload["incidents"])
def test_ops_points_grant_writes_audit_and_ledger(monkeypatch):
calls = []
class FakeDB:
def grant_points_by_supabase_email(self, email, amount, **kwargs):
calls.append(("grant", email, amount, kwargs))
return {
"ok": True,
"supabase_user_id": "user-1",
"supabase_email": email,
"points_added": amount,
"points_after": 350,
}
def append_ops_audit_event(self, **kwargs):
calls.append(("audit", kwargs))
return {"id": 9, **kwargs}
monkeypatch.setattr(ops_users, "_require_ops", lambda request: {"email": "ops@example.com"})
monkeypatch.setattr(ops_users, "_get_db", lambda: FakeDB())
payload = ops_users.grant_ops_points(
object(),
GrantPointsRequest(email="pilot@example.com", points=300),
)
assert payload["ok"] is True
assert calls[0][0] == "grant"
assert calls[0][3]["source"] == "ops_manual_grant"
assert calls[0][3]["actor_email"] == "ops@example.com"
assert calls[1][0] == "audit"
assert calls[1][1]["action"] == "manual_points_grant"
assert payload["audit_event_id"] == 9
def test_auth_me_payload_includes_points_ledger_summary(monkeypatch):
class FakeEntitlement:
enabled = True
require_subscription = False
@staticmethod
def ensure_signup_trial(user_id, email):
return None
@staticmethod
def get_subscription_window(*args, **kwargs):
return {"current": None, "rows": [], "total_expires_at": None}
@staticmethod
def get_latest_subscription_any_status(user_id):
return None
@staticmethod
def get_referral_summary(user_id):
return {"reward_points": 3500}
class FakeDB:
def get_points_ledger_summary(self, **kwargs):
assert kwargs["supabase_user_id"] == "user-1"
assert kwargs["supabase_email"] == "pilot@example.com"
return {
"balance": 850,
"by_source": {"feedback_reward": {"points": 500, "count": 1}},
"recent": [
{
"source": "feedback_reward",
"delta_points": 500,
"balance_after": 850,
}
],
}
class RequestStub:
query_params = {}
def __init__(self):
self.state = type(
"State",
(),
{
"auth_user_id": "user-1",
"auth_email": "pilot@example.com",
},
)()
monkeypatch.setattr(auth_api.legacy_routes, "_assert_entitlement", lambda request: None)
monkeypatch.setattr(auth_api.legacy_routes, "_bind_optional_supabase_identity", lambda request: None)
monkeypatch.setattr(auth_api.legacy_routes, "_resolve_auth_points", lambda request: 850)
monkeypatch.setattr(
auth_api.legacy_routes,
"_resolve_weekly_profile",
lambda request: {"weekly_points": 0, "weekly_rank": None},
)
monkeypatch.setattr(auth_api.legacy_routes, "SUPABASE_ENTITLEMENT", FakeEntitlement())
monkeypatch.setattr(auth_api.legacy_routes, "_SUPABASE_AUTH_REQUIRED", False, raising=False)
monkeypatch.setattr(auth_api, "DBManager", lambda: FakeDB())
monkeypatch.setattr(auth_api.TelegramGroupPricing, "configured", False, raising=False)
payload = auth_api.get_auth_me_payload(RequestStub())
assert payload["points"] == 850
assert payload["points_ledger"]["balance"] == 850
assert payload["points_ledger"]["by_source"]["feedback_reward"]["points"] == 500
def test_prometheus_exports_operational_closure_metrics(monkeypatch, tmp_path):
db_path = tmp_path / "metrics.db"
db_path.write_bytes(b"x" * 2048)
db_path_text = str(db_path)
class FakeDB:
db_path = db_path_text
def list_payment_audit_events(self, limit=500, event_type=None):
if event_type == "payment_intent_failed":
return [
{
"id": 1,
"event_type": "payment_intent_failed",
"payload": {"reason": "receiver_mismatch"},
"created_at": "2026-06-01T00:00:00",
},
{
"id": 2,
"event_type": "payment_intent_failed",
"payload": {
"reason": "event_mismatch",
"resolved_at": "2026-06-01T00:05:00",
},
"created_at": "2026-06-01T00:01:00",
},
]
if event_type == "payment_refund_required":
return [
{
"id": 3,
"event_type": "payment_refund_required",
"payload": {"reason": "refund_required"},
"created_at": "2026-06-01T00:02:00",
}
]
return []
def list_refund_cases(self, limit=500, status=None):
return [
{"id": 10, "status": "open", "reason": "duplicate_payment"},
{"id": 11, "status": "refunded", "reason": "receiver_mismatch"},
]
monkeypatch.setattr(system_api.legacy_routes, "_require_ops_admin", lambda request: {"email": "ops@example.com"})
monkeypatch.setattr(system_api, "DBManager", lambda: FakeDB())
monkeypatch.setattr(
system_api,
"_realtime_status_payload",
lambda: {
"store": "degraded_sqlite",
"latest_revision": 42,
"sse_connections": 3,
"degraded_from": "redis",
},
)
response = system_api.get_prometheus_metrics_response(object())
text = response.body.decode("utf-8")
assert "polyweather_payment_incidents_open 2" in text
assert 'polyweather_payment_incidents_by_reason{reason="receiver_mismatch"} 1' in text
assert "polyweather_refund_cases_open 1" in text
assert "polyweather_sse_connections 3" in text
assert "polyweather_realtime_latest_revision 42" in text
assert "polyweather_realtime_redis_fallback 1" in text
assert "polyweather_sqlite_db_size_bytes 2048" in text
+55
View File
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Request, Response
from web.core import FeedbackRewardRequest, GrantPointsRequest from web.core import FeedbackRewardRequest, GrantPointsRequest
from web.services.ops_api import ( from web.services.ops_api import (
create_ops_refund_case,
extend_ops_subscription, extend_ops_subscription,
get_ops_analytics_funnel, get_ops_analytics_funnel,
get_ops_billing_risk, get_ops_billing_risk,
@@ -17,7 +18,9 @@ from web.services.ops_api import (
get_ops_source_health, get_ops_source_health,
get_ops_truth_history, get_ops_truth_history,
get_ops_weekly_leaderboard, get_ops_weekly_leaderboard,
list_ops_audit_log,
list_ops_feedback, list_ops_feedback,
list_ops_refund_cases,
get_ops_user_subscriptions, get_ops_user_subscriptions,
grant_ops_points, grant_ops_points,
transfer_ops_points, transfer_ops_points,
@@ -25,6 +28,7 @@ from web.services.ops_api import (
list_ops_memberships, list_ops_memberships,
list_ops_payment_incidents, list_ops_payment_incidents,
resolve_ops_payment_incident, resolve_ops_payment_incident,
update_ops_refund_case,
list_ops_payments, list_ops_payments,
search_ops_users, search_ops_users,
update_ops_config, update_ops_config,
@@ -75,6 +79,23 @@ async def ops_weekly_leaderboard(request: Request, limit: int = 20):
return get_ops_weekly_leaderboard(request, limit=limit) return get_ops_weekly_leaderboard(request, limit=limit)
@router.get("/api/ops/audit-log")
async def ops_audit_log(
request: Request,
limit: int = 100,
action: str = "",
actor_email: str = "",
target_user_id: str = "",
):
return list_ops_audit_log(
request,
limit=limit,
action=action,
actor_email=actor_email,
target_user_id=target_user_id,
)
@router.get("/api/ops/feedback") @router.get("/api/ops/feedback")
async def ops_feedback(request: Request, limit: int = 100, status: str = ""): async def ops_feedback(request: Request, limit: int = 100, status: str = ""):
return list_ops_feedback(request, limit=limit, status=status) return list_ops_feedback(request, limit=limit, status=status)
@@ -147,6 +168,40 @@ async def ops_payments(request: Request, limit: int = 50):
return list_ops_payments(request, limit=limit) return list_ops_payments(request, limit=limit)
@router.get("/api/ops/refunds")
async def ops_refunds(request: Request, limit: int = 50, status: str = ""):
return list_ops_refund_cases(request, limit=limit, status=status)
@router.post("/api/ops/refunds")
async def ops_refund_create(request: Request):
import json as _json
body_bytes = await request.body()
body = _json.loads(body_bytes.decode("utf-8") or "{}")
return create_ops_refund_case(
request,
reason=str(body.get("reason") or "").strip(),
intent_id=str(body.get("intent_id") or "").strip(),
tx_hash=str(body.get("tx_hash") or "").strip(),
user_id=str(body.get("user_id") or "").strip(),
amount_usdc=str(body.get("amount_usdc") or "").strip(),
note=str(body.get("note") or "").strip(),
)
@router.patch("/api/ops/refunds/{case_id}")
async def ops_refund_update(request: Request, case_id: int):
import json as _json
body_bytes = await request.body()
body = _json.loads(body_bytes.decode("utf-8") or "{}")
return update_ops_refund_case(
request,
case_id=case_id,
status=str(body.get("status") or "").strip(),
note=str(body.get("note") or "").strip(),
)
@router.get("/api/ops/billing-risk") @router.get("/api/ops/billing-risk")
async def ops_billing_risk(request: Request, days: int = 30, limit: int = 80): async def ops_billing_risk(request: Request, days: int = 30, limit: int = 80):
return get_ops_billing_risk(request, days=days, limit=limit) return get_ops_billing_risk(request, days=days, limit=limit)
+22
View File
@@ -381,6 +381,27 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
"weekly_profile", "weekly_profile",
lambda: legacy_routes._resolve_weekly_profile(request), lambda: legacy_routes._resolve_weekly_profile(request),
) )
points_ledger = {
"balance": points,
"recent": [],
"by_source": {},
}
if user_id and not entitlement_scope:
try:
points_ledger = timer.measure(
"points_ledger",
lambda: DBManager().get_points_ledger_summary(
supabase_user_id=str(user_id or ""),
supabase_email=str(email or ""),
limit=8,
),
)
except Exception:
points_ledger = {
"balance": points,
"recent": [],
"by_source": {},
}
def resolve_telegram_pricing() -> Any: def resolve_telegram_pricing() -> Any:
if not user_id: if not user_id:
@@ -409,6 +430,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
"user_id": user_id, "user_id": user_id,
"email": email, "email": email,
"points": points, "points": points,
"points_ledger": points_ledger,
"weekly_points": weekly_profile["weekly_points"], "weekly_points": weekly_profile["weekly_points"],
"weekly_rank": weekly_profile["weekly_rank"], "weekly_rank": weekly_profile["weekly_rank"],
"entitlement_mode": ( "entitlement_mode": (
+44 -3
View File
@@ -274,7 +274,8 @@ def grant_ops_subscription(
days: int = 30, days: int = 30,
deduct_points: int = 0, deduct_points: int = 0,
) -> dict[str, Any]: ) -> dict[str, Any]:
_require_ops(request) admin = _require_ops(request) or {}
actor_email = str(admin.get("email") or "").strip().lower()
from datetime import datetime from datetime import datetime
import web.routes as legacy_routes # lazy avoid circular import import web.routes as legacy_routes # lazy avoid circular import
@@ -344,11 +345,34 @@ def grant_ops_subscription(
if safe_deduct > 0: if safe_deduct > 0:
db = _get_db() db = _get_db()
deduct_result = db.deduct_points_by_supabase_email( deduct_result = db.deduct_points_by_supabase_email(
normalized_email, safe_deduct normalized_email,
safe_deduct,
source="ops_subscription_deduction",
actor_email=actor_email,
reference_type="subscription",
reference_id=user_id,
metadata={"plan_code": plan_code, "days": safe_days},
) )
result["points_deducted"] = safe_deduct result["points_deducted"] = safe_deduct
result["points_result"] = deduct_result result["points_result"] = deduct_result
try:
_get_db().append_ops_audit_event(
action="subscription_manual_grant",
actor_email=actor_email,
target_user_id=user_id,
target_email=normalized_email,
target_type="subscription",
payload={
"plan_code": plan_code,
"days": safe_days,
"expires_at": expires_at,
"deduct_points": safe_deduct,
},
)
except Exception:
pass
return result return result
@@ -357,7 +381,8 @@ def extend_ops_subscription(
email: str, email: str,
additional_days: int = 30, additional_days: int = 30,
) -> dict[str, Any]: ) -> dict[str, Any]:
_require_ops(request) admin = _require_ops(request) or {}
actor_email = str(admin.get("email") or "").strip().lower()
from datetime import datetime from datetime import datetime
import web.routes as legacy_routes # lazy avoid circular import import web.routes as legacy_routes # lazy avoid circular import
@@ -419,6 +444,22 @@ def extend_ops_subscription(
) )
if patch_resp.ok: if patch_resp.ok:
legacy_routes.SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id) legacy_routes.SUPABASE_ENTITLEMENT.invalidate_subscription_cache(user_id)
try:
_get_db().append_ops_audit_event(
action="subscription_manual_extend",
actor_email=actor_email,
target_user_id=user_id,
target_email=normalized_email,
target_type="subscription",
target_id=str(sub.get("id") or ""),
payload={
"additional_days": safe_days,
"previous_expires_at": current_expiry,
"new_expires_at": new_expiry,
},
)
except Exception:
pass
return { return {
"ok": True, "ok": True,
"email": normalized_email, "email": normalized_email,
+143 -4
View File
@@ -146,6 +146,8 @@ def _normalize_payment_incident(item: Dict[str, Any]) -> Dict[str, Any]:
or confirm_failure.get("tx_hash") or confirm_failure.get("tx_hash")
or "" or ""
).strip(), ).strip(),
"refund_case_id": payload.get("refund_case_id"),
"refund_status": str(payload.get("refund_status") or "").strip(),
"resolved": bool(resolved_at), "resolved": bool(resolved_at),
"resolved_at": resolved_at, "resolved_at": resolved_at,
"resolved_by": str(payload.get("resolved_by") or "").strip(), "resolved_by": str(payload.get("resolved_by") or "").strip(),
@@ -235,10 +237,47 @@ def list_ops_payment_incidents(
_require_ops(request) _require_ops(request)
db = _get_db() db = _get_db()
safe_limit = max(1, min(int(limit or 50), 200)) safe_limit = max(1, min(int(limit or 50), 200))
incidents = db.list_payment_audit_events( incidents: List[Dict[str, Any]] = []
limit=max(safe_limit, 500), for event_type in ("payment_intent_failed", "payment_refund_required"):
event_type="payment_intent_failed", try:
) rows = db.list_payment_audit_events(
limit=max(safe_limit, 500),
event_type=event_type,
)
incidents.extend(
row for row in rows
if str(row.get("event_type") or "").strip().lower() == event_type
)
except Exception:
continue
terminal_refund_statuses = {"refunded", "rejected", "closed"}
list_refund_cases = getattr(db, "list_refund_cases", None)
if callable(list_refund_cases):
try:
refund_cases = list_refund_cases(limit=max(safe_limit, 500))
except Exception:
refund_cases = []
for case in refund_cases:
if not isinstance(case, dict):
continue
status = str(case.get("status") or "").strip().lower()
if not include_resolved and status in terminal_refund_statuses:
continue
incidents.append(
{
"id": int(case.get("id") or 0),
"event_type": "payment_refund_case",
"payload": {
"reason": str(case.get("reason") or "refund_required"),
"intent_id": case.get("intent_id"),
"user_id": case.get("user_id"),
"tx_hash": case.get("tx_hash"),
"refund_case_id": case.get("id"),
"refund_status": status,
},
"created_at": case.get("created_at"),
}
)
grouped = _group_payment_incidents( grouped = _group_payment_incidents(
incidents, incidents,
reason=reason, reason=reason,
@@ -250,6 +289,106 @@ def list_ops_payment_incidents(
} }
def list_ops_refund_cases(
request: Request,
limit: int = 50,
status: str = "",
) -> Dict[str, Any]:
_require_ops(request)
db = _get_db()
safe_limit = max(1, min(int(limit or 50), 200))
return {
"refunds": db.list_refund_cases(
limit=safe_limit,
status=str(status or "").strip().lower() or None,
)
}
def create_ops_refund_case(
request: Request,
*,
reason: str,
intent_id: str = "",
tx_hash: str = "",
user_id: str = "",
amount_usdc: str = "",
note: str = "",
) -> Dict[str, Any]:
admin = _require_ops(request) or {}
actor_email = str(admin.get("email") or "").strip().lower()
db = _get_db()
created = db.create_refund_case(
reason=reason,
intent_id=intent_id,
tx_hash=tx_hash,
user_id=user_id,
amount_usdc=amount_usdc,
created_by=actor_email,
note=note,
)
if not created or created.get("ok") is False:
raise HTTPException(status_code=400, detail=created or "refund_case_failed")
db.append_ops_audit_event(
action="refund_case_create",
actor_email=actor_email,
target_user_id=str(user_id or ""),
target_type="refund_case",
target_id=str(created.get("id") or ""),
payload={
"reason": reason,
"intent_id": intent_id,
"tx_hash": tx_hash,
"amount_usdc": amount_usdc,
},
)
db.append_payment_audit_event(
"payment_refund_required",
{
"reason": str(reason or "refund_required").strip().lower(),
"intent_id": intent_id,
"user_id": user_id,
"tx_hash": tx_hash,
"refund_case_id": created.get("id"),
},
)
return {"ok": True, "refund": created}
def update_ops_refund_case(
request: Request,
*,
case_id: int,
status: str,
note: str = "",
) -> Dict[str, Any]:
admin = _require_ops(request) or {}
actor_email = str(admin.get("email") or "").strip().lower()
db = _get_db()
updated = db.update_refund_case(
case_id,
status=status,
handled_by=actor_email,
note=note,
)
if not updated:
raise HTTPException(status_code=404, detail="refund_case_not_found")
db.append_ops_audit_event(
action="refund_case_update",
actor_email=actor_email,
target_user_id=str(updated.get("user_id") or ""),
target_type="refund_case",
target_id=str(case_id),
payload={
"status": status,
"note": note,
"intent_id": updated.get("intent_id"),
"tx_hash": updated.get("tx_hash"),
},
)
return {"ok": True, "refund": updated}
def resolve_ops_payment_incident(request: Request, event_id: int) -> Dict[str, Any]: def resolve_ops_payment_incident(request: Request, event_id: int) -> Dict[str, Any]:
admin = _require_ops(request) or {} admin = _require_ops(request) or {}
db = _get_db() db = _get_db()
+81 -8
View File
@@ -63,6 +63,25 @@ def search_ops_users(request: Request, q: str = "", limit: int = 20) -> Dict[str
return {"users": db.search_users(q, limit=limit)} return {"users": db.search_users(q, limit=limit)}
def list_ops_audit_log(
request: Request,
*,
limit: int = 100,
action: str = "",
actor_email: str = "",
target_user_id: str = "",
) -> Dict[str, Any]:
_require_ops(request)
db = _get_db()
rows = db.list_ops_audit_events(
limit=limit,
action=action,
actor_email=actor_email,
target_user_id=target_user_id,
)
return {"events": rows, "total": len(rows)}
def get_ops_weekly_leaderboard(request: Request, limit: int = 20) -> Dict[str, Any]: def get_ops_weekly_leaderboard(request: Request, limit: int = 20) -> Dict[str, Any]:
_require_ops(request) _require_ops(request)
db = _get_db() db = _get_db()
@@ -76,12 +95,34 @@ def get_ops_weekly_leaderboard(request: Request, limit: int = 20) -> Dict[str, A
def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, Any]: def grant_ops_points(request: Request, body: GrantPointsRequest) -> Dict[str, Any]:
admin = _require_ops(request) or {} admin = _require_ops(request) or {}
db = _get_db() db = _get_db()
result = db.grant_points_by_supabase_email(body.email, body.points) actor_email = str(admin.get("email") or "").strip().lower()
result["operator_email"] = admin.get("email") result = db.grant_points_by_supabase_email(
body.email,
body.points,
source="ops_manual_grant",
actor_email=actor_email,
reference_type="ops_action",
metadata={"action": "manual_points_grant"},
)
result["operator_email"] = actor_email
if not result.get("ok"): if not result.get("ok"):
reason = str(result.get("reason") or "grant_points_failed") reason = str(result.get("reason") or "grant_points_failed")
status_code = 404 if reason == "user_not_found" else 400 status_code = 404 if reason == "user_not_found" else 400
raise HTTPException(status_code=status_code, detail=result) raise HTTPException(status_code=status_code, detail=result)
append_audit = getattr(db, "append_ops_audit_event", None)
if callable(append_audit):
audit = append_audit(
action="manual_points_grant",
actor_email=actor_email,
target_user_id=str(result.get("supabase_user_id") or ""),
target_email=str(result.get("supabase_email") or body.email),
target_type="user",
payload={
"points_added": int(result.get("points_added") or body.points),
"points_after": int(result.get("points_after") or 0),
},
)
result["audit_event_id"] = audit.get("id")
return result return result
@@ -170,12 +211,23 @@ def grant_ops_feedback_reward(
reason: str = "", reason: str = "",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
admin = _require_ops(request) or {} admin = _require_ops(request) or {}
actor_email = str(admin.get("email") or "").strip().lower()
db = _get_db() db = _get_db()
result = db.grant_feedback_reward( try:
feedback_id, result = db.grant_feedback_reward(
points=points, feedback_id,
reason=reason, points=points,
) reason=reason,
actor_email=actor_email,
)
except TypeError as exc:
if "actor_email" not in str(exc):
raise
result = db.grant_feedback_reward(
feedback_id,
points=points,
reason=reason,
)
if not result.get("ok") and str(result.get("reason") or "") == "user_not_found": 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 {} feedback = result.get("feedback") if isinstance(result.get("feedback"), dict) else {}
reward_status = str(feedback.get("reward_status") or "").strip().lower() reward_status = str(feedback.get("reward_status") or "").strip().lower()
@@ -203,11 +255,32 @@ def grant_ops_feedback_reward(
"supabase_user_id": supabase_user_id, "supabase_user_id": supabase_user_id,
"feedback": updated_feedback, "feedback": updated_feedback,
} }
result["operator_email"] = admin.get("email") result["operator_email"] = actor_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")
status_code = 404 if reason_code in {"feedback_not_found", "user_not_found"} else 400 status_code = 404 if reason_code in {"feedback_not_found", "user_not_found"} else 400
if reason_code == "already_rewarded": if reason_code == "already_rewarded":
status_code = 409 status_code = 409
raise HTTPException(status_code=status_code, detail=result) raise HTTPException(status_code=status_code, detail=result)
feedback = result.get("feedback") if isinstance(result.get("feedback"), dict) else {}
append_audit = getattr(db, "append_ops_audit_event", None)
if callable(append_audit):
audit = append_audit(
action="feedback_reward_grant",
actor_email=actor_email,
target_user_id=str(
result.get("supabase_user_id")
or feedback.get("user_id")
or ""
),
target_email=str(result.get("supabase_email") or feedback.get("user_email") or ""),
target_type="feedback",
target_id=str(feedback_id),
payload={
"points_added": int(points or 0),
"reason": str(reason or ""),
"points_after": int(result.get("points_after") or 0),
},
)
result["audit_event_id"] = audit.get("id")
return result return result
+4
View File
@@ -35,6 +35,7 @@ from web.services.ops.users import ( # noqa: E402, F401
get_ops_weekly_leaderboard, get_ops_weekly_leaderboard,
grant_ops_feedback_reward, grant_ops_feedback_reward,
grant_ops_points, grant_ops_points,
list_ops_audit_log,
list_ops_feedback, list_ops_feedback,
search_ops_users, search_ops_users,
transfer_ops_points, transfer_ops_points,
@@ -45,13 +46,16 @@ from web.services.ops.users import ( # noqa: E402, F401
# Payments / Billing / Memberships # Payments / Billing / Memberships
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
from web.services.ops.payments import ( # noqa: E402, F401 from web.services.ops.payments import ( # noqa: E402, F401
create_ops_refund_case,
get_ops_billing_risk, get_ops_billing_risk,
get_ops_memberships_growth, get_ops_memberships_growth,
get_ops_memberships_overview, get_ops_memberships_overview,
list_ops_refund_cases,
list_ops_memberships, list_ops_memberships,
list_ops_payment_incidents, list_ops_payment_incidents,
list_ops_payments, list_ops_payments,
resolve_ops_payment_incident, resolve_ops_payment_incident,
update_ops_refund_case,
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+80 -1
View File
@@ -3,6 +3,8 @@
from __future__ import annotations from __future__ import annotations
import time import time
import os
from collections import Counter
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from fastapi import BackgroundTasks, Request from fastapi import BackgroundTasks, Request
@@ -10,7 +12,8 @@ from fastapi.concurrency import run_in_threadpool
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse
from loguru import logger from loguru import logger
from src.utils.metrics import export_prometheus_metrics from src.database.db_manager import DBManager
from src.utils.metrics import export_prometheus_metrics, gauge_set
from web.core import build_health_payload, build_system_status_payload from web.core import build_health_payload, build_system_status_payload
import web.routes as legacy_routes import web.routes as legacy_routes
@@ -128,7 +131,83 @@ def run_system_priority_warm(
def get_prometheus_metrics_response(request: Request) -> PlainTextResponse: def get_prometheus_metrics_response(request: Request) -> PlainTextResponse:
legacy_routes._require_ops_admin(request) legacy_routes._require_ops_admin(request)
_refresh_operational_metrics()
return PlainTextResponse( return PlainTextResponse(
export_prometheus_metrics(), export_prometheus_metrics(),
media_type="text/plain; version=0.0.4; charset=utf-8", media_type="text/plain; version=0.0.4; charset=utf-8",
) )
def _payment_event_reason(row: Dict[str, Any]) -> str:
payload = row.get("payload") if isinstance(row, dict) else {}
payload = payload if isinstance(payload, dict) else {}
confirm_failure = (
payload.get("confirm_failure")
if isinstance(payload.get("confirm_failure"), dict)
else {}
)
return str(
payload.get("reason")
or confirm_failure.get("reason")
or payload.get("error")
or "unknown"
).strip().lower() or "unknown"
def _payment_event_is_resolved(row: Dict[str, Any]) -> bool:
payload = row.get("payload") if isinstance(row, dict) else {}
payload = payload if isinstance(payload, dict) else {}
return bool(str(payload.get("resolved_at") or "").strip())
def _refresh_operational_metrics() -> None:
try:
db = DBManager()
except Exception:
return
payment_events = []
for event_type in ("payment_intent_failed", "payment_refund_required"):
try:
payment_events.extend(
db.list_payment_audit_events(limit=500, event_type=event_type)
)
except Exception:
continue
open_events = [row for row in payment_events if not _payment_event_is_resolved(row)]
gauge_set("polyweather_payment_incidents_open", len(open_events))
for reason, count in Counter(_payment_event_reason(row) for row in open_events).items():
gauge_set("polyweather_payment_incidents_by_reason", count, reason=reason)
try:
refund_cases = db.list_refund_cases(limit=500)
except Exception:
refund_cases = []
terminal_statuses = {"refunded", "rejected", "closed"}
open_refunds = [
case
for case in refund_cases
if str(case.get("status") or "").strip().lower() not in terminal_statuses
]
gauge_set("polyweather_refund_cases_open", len(open_refunds))
realtime = _realtime_status_payload()
gauge_set("polyweather_sse_connections", int(realtime.get("sse_connections") or 0))
gauge_set(
"polyweather_realtime_latest_revision",
int(realtime.get("latest_revision") or 0),
)
degraded_from = str(realtime.get("degraded_from") or "").strip().lower()
store = str(realtime.get("store") or "").strip().lower()
gauge_set(
"polyweather_realtime_redis_fallback",
1 if degraded_from == "redis" or store == "degraded_sqlite" else 0,
)
db_path = str(getattr(db, "db_path", "") or "").strip()
if db_path:
try:
gauge_set("polyweather_sqlite_db_size_bytes", os.path.getsize(db_path))
except OSError:
pass