Implement ops operational closure
This commit is contained in:
@@ -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;
|
||||
|
||||
const url = new URL(`${API_BASE}/api/ops/payments/incidents`);
|
||||
const limit = req.nextUrl.searchParams.get("limit");
|
||||
if (limit) url.searchParams.set("limit", limit);
|
||||
req.nextUrl.searchParams.forEach((value, key) => {
|
||||
url.searchParams.set(key, value);
|
||||
});
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -535,6 +546,9 @@ export function AccountCenter() {
|
||||
const monthlyReferralPointsLimit = Number.isFinite(monthlyReferralPointsLimitRaw)
|
||||
? Math.max(0, monthlyReferralPointsLimitRaw)
|
||||
: monthlyReferralLimit * referralRewardPoints;
|
||||
const pointsLedger = backend?.points_ledger;
|
||||
const pointSourceRows = Object.entries(pointsLedger?.by_source ?? {});
|
||||
const recentPointEvents = pointsLedger?.recent ?? [];
|
||||
|
||||
// ── Telegram bind command ──────────────────────────────
|
||||
const bindCommand = telegramBindCommand || copy.telegramBindCommandPlaceholder;
|
||||
@@ -897,6 +911,64 @@ export function AccountCenter() {
|
||||
</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 */}
|
||||
<div className="lg:col-span-12 relative">
|
||||
<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",
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export type AuthMeResponse = {
|
||||
user_id?: string | null;
|
||||
email?: string | null;
|
||||
points?: number;
|
||||
points_ledger?: PointsLedgerSummary;
|
||||
weekly_points?: number;
|
||||
weekly_rank?: number | string | null;
|
||||
entitlement_mode?: string | null;
|
||||
@@ -25,6 +26,24 @@ export type AuthMeResponse = {
|
||||
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 = {
|
||||
code?: string;
|
||||
discount_usdc?: string;
|
||||
|
||||
@@ -239,6 +239,72 @@
|
||||
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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
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 {
|
||||
DocsLocale,
|
||||
@@ -171,8 +171,49 @@ export function DocsScreen({ page }: { page: DocsPage }) {
|
||||
const pathname = usePathname();
|
||||
const { locale } = useI18n();
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const localizedPage = useMemo(() => page.content[locale], [locale, page]);
|
||||
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 (
|
||||
<div className={styles.docsShell}>
|
||||
@@ -198,6 +239,36 @@ export function DocsScreen({ page }: { page: DocsPage }) {
|
||||
</div>
|
||||
<h1 className={styles.pageTitle}>{localizedPage.title}</h1>
|
||||
<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"}>
|
||||
<span>{locale === "zh-CN" ? "当前工作台" : "Current terminal"}</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: [
|
||||
{ href: "/ops/config", icon: Settings, label: "系统配置" },
|
||||
{ href: "/ops/subscriptions", icon: ScrollText, label: "订阅操作" },
|
||||
{ href: "/ops/audit-log", icon: FileText, label: "审计日志" },
|
||||
{ href: "/ops/view-logs", icon: FileText, label: "日志查看" },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
PaymentRuntimePayload,
|
||||
PaymentIncident,
|
||||
PaymentRecord,
|
||||
RefundCase,
|
||||
} from "@/types/ops";
|
||||
|
||||
const PaymentIncidentPieChart = dynamic(
|
||||
@@ -60,6 +61,8 @@ function paymentReasonLabel(reason?: string) {
|
||||
if (key === "expired") return "订单已过期";
|
||||
if (key === "event_mismatch") return "支付事件不匹配";
|
||||
if (key === "direct_transfer_mismatch") return "直接转账不匹配";
|
||||
if (key === "refund_required") return "需要退款处理";
|
||||
if (key === "duplicate_payment") return "重复付款";
|
||||
if (key === "unknown") return "未知原因";
|
||||
return key || "未知原因";
|
||||
}
|
||||
@@ -96,21 +99,24 @@ export function PaymentsPageClient() {
|
||||
const [runtime, setRuntime] = useState<PaymentRuntimePayload | null>(null);
|
||||
const [incidents, setIncidents] = useState<PaymentIncident[]>([]);
|
||||
const [payments, setPayments] = useState<PaymentRecord[]>([]);
|
||||
const [refunds, setRefunds] = useState<RefundCase[]>([]);
|
||||
const [risk, setRisk] = useState<BillingRiskPayload | null>(null);
|
||||
const [resolving, setResolving] = useState<Set<number>>(new Set());
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rt, inc, pay, riskPayload] = await Promise.all([
|
||||
const [rt, inc, pay, refundPayload, riskPayload] = await Promise.all([
|
||||
opsApi.paymentRuntime() as Promise<PaymentRuntimePayload>,
|
||||
opsApi.incidents(50),
|
||||
opsApi.listPayments(50),
|
||||
opsApi.refunds(50),
|
||||
opsApi.billingRisk(30, 80) as Promise<BillingRiskPayload>,
|
||||
]);
|
||||
setRuntime(rt);
|
||||
setIncidents((inc as unknown as { incidents?: PaymentIncident[] }).incidents ?? []);
|
||||
setPayments((pay as unknown as { payments?: PaymentRecord[] }).payments ?? []);
|
||||
setRefunds((refundPayload as unknown as { refunds?: RefundCase[] }).refunds ?? []);
|
||||
setRisk(riskPayload);
|
||||
} catch { /* */ }
|
||||
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 || ""}>
|
||||
{inc.detail || inc.reason || "—"}
|
||||
</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 className="py-2 pr-4 text-xs text-slate-500">
|
||||
<div className="font-mono" title={inc.user_id || ""}>{compactMono(inc.user_id)}</div>
|
||||
@@ -355,6 +366,54 @@ export function PaymentsPageClient() {
|
||||
</CardContent>
|
||||
</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>
|
||||
<CardHeader><CardTitle>成功支付记录 ({payments.length})</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -109,6 +109,37 @@ export const opsApi = {
|
||||
if (reason) params.set("reason", reason);
|
||||
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) {
|
||||
const params = new URLSearchParams({ limit: String(limit) });
|
||||
if (status) params.set("status", status);
|
||||
|
||||
@@ -155,6 +155,8 @@ export type PaymentIncident = {
|
||||
intent_id?: string;
|
||||
user_id?: string;
|
||||
tx_hash?: string;
|
||||
refund_case_id?: number | string | null;
|
||||
refund_status?: string;
|
||||
payload_json?: string;
|
||||
created_at?: string;
|
||||
resolved?: boolean;
|
||||
@@ -166,6 +168,42 @@ export type PaymentIncident = {
|
||||
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 = {
|
||||
incidents?: PaymentIncident[];
|
||||
total?: number;
|
||||
|
||||
+618
-4
@@ -596,6 +596,75 @@ class DBManager:
|
||||
conn.execute(
|
||||
"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("""
|
||||
CREATE TABLE IF NOT EXISTS observation_patch_events (
|
||||
revision INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -1673,6 +1742,479 @@ class DBManager:
|
||||
)
|
||||
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(
|
||||
self,
|
||||
event_type: str,
|
||||
@@ -1981,6 +2523,7 @@ class DBManager:
|
||||
*,
|
||||
points: int,
|
||||
reason: str = "",
|
||||
actor_email: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
safe_points = int(points or 0)
|
||||
if safe_points <= 0:
|
||||
@@ -2017,7 +2560,7 @@ class DBManager:
|
||||
|
||||
user_row = conn.execute(
|
||||
"""
|
||||
SELECT telegram_id, username, points, supabase_email
|
||||
SELECT telegram_id, username, points, supabase_email, supabase_user_id
|
||||
FROM users
|
||||
WHERE lower(trim(COALESCE(supabase_email, ''))) = ?
|
||||
LIMIT 1
|
||||
@@ -2027,7 +2570,8 @@ class DBManager:
|
||||
if not user_row:
|
||||
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
|
||||
JOIN supabase_bindings b ON b.telegram_id = u.telegram_id
|
||||
WHERE lower(trim(COALESCE(b.supabase_email, ''))) = ?
|
||||
@@ -2074,6 +2618,19 @@ class DBManager:
|
||||
""",
|
||||
(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(
|
||||
"""
|
||||
SELECT id, category, message, source, status, contact, user_id,
|
||||
@@ -2764,6 +3321,12 @@ class DBManager:
|
||||
self,
|
||||
supabase_email: str,
|
||||
amount: int,
|
||||
*,
|
||||
source: str = "manual_adjustment",
|
||||
actor_email: str = "",
|
||||
reference_type: str = "",
|
||||
reference_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
email = str(supabase_email or "").strip().lower()
|
||||
points = int(amount or 0)
|
||||
@@ -2776,7 +3339,7 @@ class DBManager:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT telegram_id, username, points, supabase_email
|
||||
SELECT telegram_id, username, points, supabase_email, supabase_user_id
|
||||
FROM users
|
||||
WHERE lower(trim(COALESCE(supabase_email, ''))) = ?
|
||||
LIMIT 1
|
||||
@@ -2797,6 +3360,19 @@ class DBManager:
|
||||
""",
|
||||
(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()
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
|
||||
return {
|
||||
@@ -2813,6 +3389,12 @@ class DBManager:
|
||||
self,
|
||||
supabase_user_id: str,
|
||||
amount: int,
|
||||
*,
|
||||
source: str = "manual_adjustment",
|
||||
actor_email: str = "",
|
||||
reference_type: str = "",
|
||||
reference_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
key = str(supabase_user_id or "").strip().lower()
|
||||
points = int(amount or 0)
|
||||
@@ -2849,6 +3431,19 @@ class DBManager:
|
||||
""",
|
||||
(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()
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
|
||||
return {
|
||||
@@ -2866,6 +3461,12 @@ class DBManager:
|
||||
self,
|
||||
supabase_email: str,
|
||||
amount: int,
|
||||
*,
|
||||
source: str = "points_redemption",
|
||||
actor_email: str = "",
|
||||
reference_type: str = "",
|
||||
reference_id: str = "",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
email = str(supabase_email or "").strip().lower()
|
||||
points = int(amount or 0)
|
||||
@@ -2878,7 +3479,7 @@ class DBManager:
|
||||
conn.row_factory = sqlite3.Row
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT telegram_id, username, points, supabase_email
|
||||
SELECT telegram_id, username, points, supabase_email, supabase_user_id
|
||||
FROM users
|
||||
WHERE lower(trim(COALESCE(supabase_email, ''))) = ?
|
||||
LIMIT 1
|
||||
@@ -2902,6 +3503,19 @@ class DBManager:
|
||||
"UPDATE users SET points = ? WHERE 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()
|
||||
self._sync_points_to_supabase_user_metadata(telegram_id, force=True)
|
||||
return {
|
||||
|
||||
@@ -2201,6 +2201,20 @@ class PaymentContractCheckoutService:
|
||||
prefer="resolution=merge-duplicates,return=minimal",
|
||||
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 {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@@ -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
|
||||
@@ -4,6 +4,7 @@ from fastapi import APIRouter, Request, Response
|
||||
|
||||
from web.core import FeedbackRewardRequest, GrantPointsRequest
|
||||
from web.services.ops_api import (
|
||||
create_ops_refund_case,
|
||||
extend_ops_subscription,
|
||||
get_ops_analytics_funnel,
|
||||
get_ops_billing_risk,
|
||||
@@ -17,7 +18,9 @@ from web.services.ops_api import (
|
||||
get_ops_source_health,
|
||||
get_ops_truth_history,
|
||||
get_ops_weekly_leaderboard,
|
||||
list_ops_audit_log,
|
||||
list_ops_feedback,
|
||||
list_ops_refund_cases,
|
||||
get_ops_user_subscriptions,
|
||||
grant_ops_points,
|
||||
transfer_ops_points,
|
||||
@@ -25,6 +28,7 @@ from web.services.ops_api import (
|
||||
list_ops_memberships,
|
||||
list_ops_payment_incidents,
|
||||
resolve_ops_payment_incident,
|
||||
update_ops_refund_case,
|
||||
list_ops_payments,
|
||||
search_ops_users,
|
||||
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)
|
||||
|
||||
|
||||
@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")
|
||||
async def ops_feedback(request: Request, limit: int = 100, status: str = ""):
|
||||
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)
|
||||
|
||||
|
||||
@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")
|
||||
async def ops_billing_risk(request: Request, days: int = 30, limit: int = 80):
|
||||
return get_ops_billing_risk(request, days=days, limit=limit)
|
||||
|
||||
@@ -381,6 +381,27 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
|
||||
"weekly_profile",
|
||||
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:
|
||||
if not user_id:
|
||||
@@ -409,6 +430,7 @@ def get_auth_me_payload(request: Request) -> Dict[str, Any]:
|
||||
"user_id": user_id,
|
||||
"email": email,
|
||||
"points": points,
|
||||
"points_ledger": points_ledger,
|
||||
"weekly_points": weekly_profile["weekly_points"],
|
||||
"weekly_rank": weekly_profile["weekly_rank"],
|
||||
"entitlement_mode": (
|
||||
|
||||
@@ -274,7 +274,8 @@ def grant_ops_subscription(
|
||||
days: int = 30,
|
||||
deduct_points: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
admin = _require_ops(request) or {}
|
||||
actor_email = str(admin.get("email") or "").strip().lower()
|
||||
from datetime import datetime
|
||||
|
||||
import web.routes as legacy_routes # lazy – avoid circular import
|
||||
@@ -344,11 +345,34 @@ def grant_ops_subscription(
|
||||
if safe_deduct > 0:
|
||||
db = _get_db()
|
||||
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_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
|
||||
|
||||
|
||||
@@ -357,7 +381,8 @@ def extend_ops_subscription(
|
||||
email: str,
|
||||
additional_days: int = 30,
|
||||
) -> dict[str, Any]:
|
||||
_require_ops(request)
|
||||
admin = _require_ops(request) or {}
|
||||
actor_email = str(admin.get("email") or "").strip().lower()
|
||||
from datetime import datetime
|
||||
|
||||
import web.routes as legacy_routes # lazy – avoid circular import
|
||||
@@ -419,6 +444,22 @@ def extend_ops_subscription(
|
||||
)
|
||||
if patch_resp.ok:
|
||||
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 {
|
||||
"ok": True,
|
||||
"email": normalized_email,
|
||||
|
||||
@@ -146,6 +146,8 @@ def _normalize_payment_incident(item: Dict[str, Any]) -> Dict[str, Any]:
|
||||
or confirm_failure.get("tx_hash")
|
||||
or ""
|
||||
).strip(),
|
||||
"refund_case_id": payload.get("refund_case_id"),
|
||||
"refund_status": str(payload.get("refund_status") or "").strip(),
|
||||
"resolved": bool(resolved_at),
|
||||
"resolved_at": resolved_at,
|
||||
"resolved_by": str(payload.get("resolved_by") or "").strip(),
|
||||
@@ -235,10 +237,47 @@ def list_ops_payment_incidents(
|
||||
_require_ops(request)
|
||||
db = _get_db()
|
||||
safe_limit = max(1, min(int(limit or 50), 200))
|
||||
incidents = db.list_payment_audit_events(
|
||||
limit=max(safe_limit, 500),
|
||||
event_type="payment_intent_failed",
|
||||
)
|
||||
incidents: List[Dict[str, Any]] = []
|
||||
for event_type in ("payment_intent_failed", "payment_refund_required"):
|
||||
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(
|
||||
incidents,
|
||||
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]:
|
||||
admin = _require_ops(request) or {}
|
||||
db = _get_db()
|
||||
|
||||
@@ -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)}
|
||||
|
||||
|
||||
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]:
|
||||
_require_ops(request)
|
||||
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]:
|
||||
admin = _require_ops(request) or {}
|
||||
db = _get_db()
|
||||
result = db.grant_points_by_supabase_email(body.email, body.points)
|
||||
result["operator_email"] = admin.get("email")
|
||||
actor_email = str(admin.get("email") or "").strip().lower()
|
||||
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"):
|
||||
reason = str(result.get("reason") or "grant_points_failed")
|
||||
status_code = 404 if reason == "user_not_found" else 400
|
||||
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
|
||||
|
||||
|
||||
@@ -170,12 +211,23 @@ def grant_ops_feedback_reward(
|
||||
reason: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
admin = _require_ops(request) or {}
|
||||
actor_email = str(admin.get("email") or "").strip().lower()
|
||||
db = _get_db()
|
||||
result = db.grant_feedback_reward(
|
||||
feedback_id,
|
||||
points=points,
|
||||
reason=reason,
|
||||
)
|
||||
try:
|
||||
result = db.grant_feedback_reward(
|
||||
feedback_id,
|
||||
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":
|
||||
feedback = result.get("feedback") if isinstance(result.get("feedback"), dict) else {}
|
||||
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,
|
||||
"feedback": updated_feedback,
|
||||
}
|
||||
result["operator_email"] = admin.get("email")
|
||||
result["operator_email"] = actor_email
|
||||
if not result.get("ok"):
|
||||
reason_code = str(result.get("reason") or "feedback_reward_failed")
|
||||
status_code = 404 if reason_code in {"feedback_not_found", "user_not_found"} else 400
|
||||
if reason_code == "already_rewarded":
|
||||
status_code = 409
|
||||
raise HTTPException(status_code=status_code, detail=result)
|
||||
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
|
||||
|
||||
@@ -35,6 +35,7 @@ from web.services.ops.users import ( # noqa: E402, F401
|
||||
get_ops_weekly_leaderboard,
|
||||
grant_ops_feedback_reward,
|
||||
grant_ops_points,
|
||||
list_ops_audit_log,
|
||||
list_ops_feedback,
|
||||
search_ops_users,
|
||||
transfer_ops_points,
|
||||
@@ -45,13 +46,16 @@ from web.services.ops.users import ( # noqa: E402, F401
|
||||
# Payments / Billing / Memberships
|
||||
# ---------------------------------------------------------------------------
|
||||
from web.services.ops.payments import ( # noqa: E402, F401
|
||||
create_ops_refund_case,
|
||||
get_ops_billing_risk,
|
||||
get_ops_memberships_growth,
|
||||
get_ops_memberships_overview,
|
||||
list_ops_refund_cases,
|
||||
list_ops_memberships,
|
||||
list_ops_payment_incidents,
|
||||
list_ops_payments,
|
||||
resolve_ops_payment_incident,
|
||||
update_ops_refund_case,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import os
|
||||
from collections import Counter
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import BackgroundTasks, Request
|
||||
@@ -10,7 +12,8 @@ from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import PlainTextResponse
|
||||
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
|
||||
import web.routes as legacy_routes
|
||||
|
||||
@@ -128,7 +131,83 @@ def run_system_priority_warm(
|
||||
|
||||
def get_prometheus_metrics_response(request: Request) -> PlainTextResponse:
|
||||
legacy_routes._require_ops_admin(request)
|
||||
_refresh_operational_metrics()
|
||||
return PlainTextResponse(
|
||||
export_prometheus_metrics(),
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user