feat(membership): replace purchase modal with membership page

This commit is contained in:
Jesse
2026-08-07 18:24:16 +08:00
parent 07223b6b4a
commit 280d3e7a35
20 changed files with 2901 additions and 179 deletions
@@ -0,0 +1,131 @@
begin;
-- Account-level, cross-instance rate limiting for consecutive failed
-- redemption attempts. The audit table records only who failed and when;
-- neither the plaintext code nor its hash is ever stored here. It is
-- failure-only: a successful redemption deletes the account's rows, so no
-- permanent success row is kept.
create table if not exists public.redemption_attempts (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users(id) on delete cascade,
created_at timestamptz not null default now()
);
create index if not exists redemption_attempts_user_created_idx
on public.redemption_attempts (user_id, created_at desc);
alter table public.redemption_attempts enable row level security;
revoke all on table public.redemption_attempts from anon, authenticated;
grant select on table public.redemption_attempts to service_role;
-- CREATE OR REPLACE cannot change a function's return type, so the previous
-- 3-column redeem_code(text) is dropped and rebuilt inside the same
-- transaction, then its revoke/grant ACLs are restored below.
drop function if exists public.redeem_code(text);
-- redeem_code keeps the redemption-code row lock and the credit_transactions
-- unique constraint. It additionally serializes per-account attempts, counts
-- business failures within a rolling 10-minute window, and returns
-- rate_limited once an account reaches 5 failures. Every business failure
-- writes an audit row; a successful redemption deletes the account's
-- attempts. account_not_eligible is intentionally not fabricated here: there
-- is no account-eligibility restriction model yet. The audit table's
-- identity sequence needs no authenticated USAGE grant because the
-- security-definer function runs as its owner.
create or replace function public.redeem_code(p_code_hash text)
returns table (success boolean, credits integer, awarded_credits integer, error_code text)
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_user_id uuid := auth.uid();
v_email text := auth.jwt() ->> 'email';
v_code public.redemption_codes%rowtype;
v_balance integer;
v_failed integer;
begin
if v_user_id is null then
return query select false, null::integer, null::integer, 'unauthorized'::text;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended('redeem:' || v_user_id::text, 0));
delete from public.redemption_attempts
where user_id = v_user_id and created_at < now() - interval '10 minutes';
select count(*) into v_failed
from public.redemption_attempts
where user_id = v_user_id;
if v_failed >= 5 then
return query select false, null::integer, null::integer, 'rate_limited'::text;
return;
end if;
if p_code_hash is null or p_code_hash !~ '^[0-9a-f]{64}$' then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'invalid_code'::text;
return;
end if;
select rc.* into v_code
from public.redemption_codes rc
where rc.code_hash = p_code_hash
for update;
if not found then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'invalid_code'::text;
return;
end if;
if v_code.redeemed_by is not null then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'already_redeemed'::text;
return;
end if;
if v_code.revoked_at is not null then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'revoked_code'::text;
return;
end if;
if v_code.expires_at is not null and v_code.expires_at <= now() then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'expired_code'::text;
return;
end if;
select p.credits into v_balance
from public.profiles p
where p.id = v_user_id
for update;
if not found then
return query select false, null::integer, null::integer, 'profile_missing'::text;
return;
end if;
update public.redemption_codes rc
set redeemed_by = v_user_id, redeemed_email = v_email, redeemed_at = now()
where rc.id = v_code.id;
update public.profiles p
set credits = p.credits + v_code.credits, updated_at = now()
where p.id = v_user_id
returning p.credits into v_balance;
insert into public.credit_transactions (
user_id, transaction_type, amount, balance_after, request_id, redemption_code_id
) values (
v_user_id, 'redeem', v_code.credits, v_balance, v_code.id::text, v_code.id
);
delete from public.redemption_attempts where user_id = v_user_id;
return query select true, v_balance, v_code.credits, null::text;
end;
$$;
revoke all on function public.redeem_code(text) from public, anon;
grant execute on function public.redeem_code(text) to authenticated;
commit;
@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
formatPaymentOrders,
PAYMENT_ORDERS_SELECT,
type PaymentOrderRow,
} from "@/lib/payment-orders";
export const runtime = "nodejs";
const MAX_ORDERS = 20;
export async function GET() {
try {
const supabase = await createServerSupabaseClient();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
const { data, error } = await supabase
.from("payment_orders")
.select(PAYMENT_ORDERS_SELECT.join(","))
.eq("user_id", user.id)
.order("created_at", { ascending: false })
.limit(MAX_ORDERS);
if (error) {
return NextResponse.json(
{ error: "暂时无法查询订单" },
{ status: 500 },
);
}
return NextResponse.json({
orders: formatPaymentOrders((data ?? []) as unknown as PaymentOrderRow[]),
});
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json(
{ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" },
{ status: 503 },
);
}
return NextResponse.json({ error: "订单服务暂时不可用" }, { status: 500 });
}
}
+51 -25
View File
@@ -1,36 +1,48 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { hashRedeemCode, normalizeRedeemCode } from "@/lib/supabase/codes";
import { hashRedeemCode } from "@/lib/supabase/codes";
import { isSupabaseConfigurationError } from "@/lib/supabase/config";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import {
redeemErrorResponse,
redeemInputErrorResponse,
} from "@/lib/redeem-response";
export const runtime = "nodejs";
const requestSchema = z.object({ code: z.string().max(100) });
const redeemErrors: Record<string, { status: number; message: string }> = {
unauthorized: { status: 401, message: "请先登录" },
invalid_code: { status: 404, message: "兑换码不存在" },
expired_code: { status: 410, message: "兑换码已过期" },
revoked_code: { status: 410, message: "兑换码已撤销" },
already_redeemed: { status: 409, message: "兑换码已被使用" },
profile_missing: { status: 500, message: "账户资料不存在,请稍后重试" },
};
export async function POST(request: Request) {
try {
const parsed = requestSchema.safeParse(await request.json().catch(() => null));
const parsed = requestSchema.safeParse(
await request.json().catch(() => null),
);
if (!parsed.success) {
return NextResponse.json({ error: "请输入有效兑换码" }, { status: 400 });
const inputError = redeemInputErrorResponse();
return NextResponse.json(
{ error: inputError.message, code: inputError.code },
{ status: inputError.status },
);
}
const code = normalizeRedeemCode(parsed.data.code);
if (!/^JYOTISH-[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(code)) {
return NextResponse.json({ error: "兑换码格式不正确" }, { status: 400 });
// Only surrounding whitespace is trimmed; case is never changed. Every
// non-empty value is hashed as-is and evaluated by the database, so
// lowercase or malformed attempts still hit the RPC failure rate limit
// instead of being short-circuited client-side.
const code = parsed.data.code.trim();
if (!code) {
const inputError = redeemInputErrorResponse();
return NextResponse.json(
{ error: inputError.message, code: inputError.code },
{ status: inputError.status },
);
}
const supabase = await createServerSupabaseClient();
const { data: { user }, error: authError } = await supabase.auth.getUser();
const {
data: { user },
error: authError,
} = await supabase.auth.getUser();
if (authError || !user) {
return NextResponse.json({ error: "请先登录" }, { status: 401 });
}
@@ -40,23 +52,37 @@ export async function POST(request: Request) {
});
if (error) {
return NextResponse.json({ error: "兑换失败,请稍后重试" }, { status: 500 });
const systemError = redeemErrorResponse("system_error");
return NextResponse.json(
{ error: systemError.message, code: systemError.code },
{ status: systemError.status },
);
}
const result = Array.isArray(data) ? data[0] : data;
if (!result?.success) {
const mapped = redeemErrors[result?.error_code] ?? {
status: 500,
message: "兑换失败,请稍后重试",
};
return NextResponse.json({ error: mapped.message }, { status: mapped.status });
const mapped = redeemErrorResponse(result?.error_code);
return NextResponse.json(
{ error: mapped.message, code: mapped.code },
{ status: mapped.status },
);
}
return NextResponse.json({ credits: result.credits });
return NextResponse.json({
awardedCredits: result.awarded_credits ?? null,
credits: result.credits,
});
} catch (error) {
if (isSupabaseConfigurationError(error)) {
return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
return NextResponse.json(
{ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" },
{ status: 503 },
);
}
return NextResponse.json({ error: "兑换服务暂时不可用" }, { status: 500 });
const systemError = redeemErrorResponse("system_error");
return NextResponse.json(
{ error: systemError.message, code: systemError.code },
{ status: systemError.status },
);
}
}
+718
View File
@@ -1867,3 +1867,721 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
max-width: 120mm;
}
}
/* ---- Membership page ---- */
.membership-page {
height: 100dvh;
min-height: 100dvh;
overflow-y: auto;
background: var(--color-canvas);
}
.membership-shell {
width: min(1160px, 100%);
margin: 0 auto;
padding: var(--space-6) var(--space-5) var(--space-16);
display: grid;
gap: var(--space-6);
}
.membership-loading {
padding: var(--space-16) var(--space-5);
color: var(--color-ink-secondary);
text-align: center;
}
.membership-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.membership-header-left {
display: flex;
align-items: center;
gap: var(--space-3);
min-width: 0;
}
.membership-back {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
color: var(--color-ink);
cursor: pointer;
font-size: 14px;
}
.membership-back svg {
width: 16px;
height: 16px;
}
.membership-title {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-display-sm);
font-weight: 400;
letter-spacing: -.03em;
}
.membership-redeem-entry {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: 0 14px;
border: 1px solid color-mix(in srgb, var(--color-action) 34%, transparent);
border-radius: var(--radius-md);
background: var(--color-action-soft);
color: var(--color-action-hover);
cursor: pointer;
font-size: 14px;
font-weight: 500;
}
.membership-redeem-entry svg {
width: 16px;
height: 16px;
}
.membership-notice {
margin: 0;
padding: var(--space-3) var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-action) 22%, var(--color-border));
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-action-soft) 62%, transparent);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.membership-hero {
position: relative;
min-height: 260px;
overflow: hidden;
display: grid;
grid-template-columns: minmax(0, 1fr) 240px;
align-items: center;
gap: var(--space-8);
padding: var(--space-10);
border: 1px solid color-mix(in srgb, var(--color-action) 14%, var(--color-border));
border-radius: var(--radius-xl);
background: radial-gradient(circle at 84% 32%, rgba(133, 67, 47, .12), transparent 34%), linear-gradient(135deg, var(--color-canvas) 0%, var(--color-canvas-soft) 100%);
}
.membership-hero::after {
position: absolute;
inset: 0;
opacity: .2;
pointer-events: none;
background-image: radial-gradient(rgba(29, 29, 31, .24) .45px, transparent .45px);
background-size: 6px 6px;
content: "";
mask-image: linear-gradient(to right, transparent 8%, #000 72%);
}
.membership-hero-copy {
position: relative;
z-index: 1;
max-width: 720px;
}
.membership-greeting {
margin: 0 0 var(--space-3);
color: var(--color-action);
font-size: var(--type-body-sm);
font-weight: 600;
letter-spacing: .02em;
}
.membership-hero h2 {
max-width: 640px;
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: clamp(32px, 4vw, 48px);
font-weight: 400;
letter-spacing: -.045em;
line-height: 1.05;
text-wrap: balance;
}
.membership-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
margin-top: var(--space-6);
}
.membership-summary-card {
display: grid;
gap: var(--space-1);
padding: var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-action) 18%, var(--color-border));
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-canvas) 86%, transparent);
}
.membership-summary-card span {
color: var(--color-ink-secondary);
font-size: var(--type-caption);
}
.membership-summary-card strong {
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-title-lg);
font-weight: 400;
font-variant-numeric: tabular-nums;
}
.membership-summary-card small {
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
line-height: 1.5;
}
.membership-celestial-visual {
position: relative;
z-index: 1;
width: 220px;
height: 220px;
justify-self: end;
}
.membership-orbit {
position: absolute;
border: 1px solid color-mix(in srgb, var(--color-action) 34%, transparent);
border-radius: 50%;
}
.membership-orbit-outer {
inset: 10px 14px;
transform: rotate(-12deg);
}
.membership-orbit-inner {
inset: 42px 54px;
border-color: color-mix(in srgb, var(--color-ink-tertiary) 42%, transparent);
transform: rotate(20deg);
}
.membership-orbit-mini {
inset: 76px 90px;
border-color: color-mix(in srgb, var(--color-action) 26%, transparent);
border-style: dashed;
}
.membership-orbit-dot {
position: absolute;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--color-action);
box-shadow: 0 0 0 5px color-mix(in srgb, var(--color-action-soft) 72%, transparent);
}
.membership-orbit-dot-primary {
top: 28px;
right: 38px;
}
.membership-orbit-dot-secondary {
bottom: 34px;
left: 34px;
width: 6px;
height: 6px;
background: var(--color-ink-tertiary);
box-shadow: none;
}
.membership-celestial-mark {
position: absolute;
top: 50%;
left: 50%;
width: 52px;
height: 52px;
border: 1px solid color-mix(in srgb, var(--color-action) 18%, var(--color-border));
border-radius: 50%;
background: color-mix(in srgb, var(--color-canvas) 88%, transparent) url("/jyotish-logo.png") center / 30px 30px no-repeat;
box-shadow: 0 14px 36px -24px rgba(29, 29, 31, .48);
transform: translate(-50%, -50%);
}
.membership-section-heading {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
}
.membership-section-heading h2 {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-title-lg);
font-weight: 400;
letter-spacing: -.02em;
}
.membership-section-heading p {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.membership-orders-refresh {
flex: 0 0 auto;
padding: 6px 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-canvas);
color: var(--color-ink-secondary);
cursor: pointer;
font-size: var(--type-caption);
}
.membership-order-list {
display: grid;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.membership-order-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-canvas);
}
.membership-order-row > div {
display: grid;
gap: 2px;
min-width: 0;
}
.membership-order-row strong {
color: var(--color-ink);
font-size: var(--type-body-sm);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.membership-order-row small {
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
font-variant-numeric: tabular-nums;
}
.membership-order-row b {
color: var(--color-ink);
font-size: var(--type-body-sm);
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.membership-order-status {
padding: 3px 9px;
border-radius: 999px;
font-size: var(--type-caption);
}
.membership-order-status.status-paid {
background: var(--color-success-muted);
color: var(--color-success);
}
.membership-order-status.status-pending {
background: var(--color-action-soft);
color: var(--color-action-hover);
}
.membership-order-status.status-failed {
background: var(--color-danger-muted);
color: var(--color-danger);
}
.membership-empty {
margin: 0;
padding: var(--space-5);
border: 1px dashed var(--color-border-strong);
border-radius: var(--radius-md);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
text-align: center;
}
.membership-plan-grid,
.membership-credit-grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-4);
}
.membership-plan-card,
.membership-credit-card {
position: relative;
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-6);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-canvas);
box-shadow: var(--shadow-soft);
overflow: hidden;
}
.membership-plan-card--recommended {
border-color: color-mix(in srgb, var(--color-action) 52%, var(--color-border));
background: color-mix(in srgb, var(--color-action-soft) 46%, var(--color-canvas));
box-shadow: 0 1px 2px rgba(29, 29, 31, .06), 0 14px 34px -20px rgba(133, 67, 47, .34);
}
.membership-plan-card--recommended::before {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--color-action);
content: "";
}
.membership-plan-card--current {
border-color: color-mix(in srgb, var(--color-success) 40%, var(--color-border));
}
.membership-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
}
.membership-card-kicker {
display: block;
margin-bottom: 4px;
color: var(--color-action);
font-size: var(--type-overline);
font-weight: 600;
letter-spacing: .05em;
}
.membership-card-header h3,
.membership-credit-copy h3 {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-title-md);
font-weight: 400;
letter-spacing: -.01em;
}
.membership-card-badges {
display: flex;
flex-wrap: wrap;
gap: var(--space-1);
justify-content: flex-end;
}
.membership-badge {
padding: 3px 9px;
border-radius: 999px;
font-size: var(--type-caption);
font-weight: 600;
}
.membership-badge--recommended {
background: var(--color-action);
color: var(--color-on-dark);
}
.membership-badge--current {
background: var(--color-success-muted);
color: var(--color-success);
}
.membership-card-price {
margin: 0;
color: var(--color-ink);
font-family: var(--font-display);
font-size: var(--type-display-sm);
font-weight: 400;
font-variant-numeric: tabular-nums;
letter-spacing: -.02em;
}
.membership-card-price small {
color: var(--color-ink-secondary);
font-size: var(--type-caption);
font-family: var(--font-body);
font-weight: 400;
letter-spacing: 0;
}
.membership-card-audience {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.6;
}
.membership-card-entitlements {
display: grid;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.membership-card-entitlements li {
position: relative;
padding-left: var(--space-4);
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.membership-card-entitlements li::before {
position: absolute;
top: .62em;
left: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: color-mix(in srgb, var(--color-action) 60%, transparent);
content: "";
}
.membership-rules {
margin-top: auto;
border-top: 1px solid var(--color-border);
padding-top: var(--space-3);
}
.membership-rules summary {
cursor: pointer;
color: var(--color-ink-secondary);
font-size: var(--type-caption);
font-weight: 600;
user-select: none;
}
.membership-rules ul {
display: grid;
gap: var(--space-1);
margin: var(--space-2) 0 0;
padding: 0 0 0 var(--space-4);
color: var(--color-ink-tertiary);
font-size: var(--type-caption);
line-height: 1.55;
}
.membership-buy {
width: 100%;
margin-top: var(--space-2);
}
.membership-credit-card {
gap: var(--space-2);
}
.membership-credit-copy p {
margin: var(--space-1) 0 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
}
.membership-payment-panel {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-4);
padding: var(--space-5) var(--space-6);
border: 1px solid color-mix(in srgb, var(--color-action) 30%, var(--color-border));
border-radius: var(--radius-lg);
background: var(--color-action-soft);
}
.membership-payment-icon {
display: grid;
place-items: center;
color: var(--color-action);
}
.membership-payment-icon svg {
width: 28px;
height: 28px;
}
.membership-payment-copy {
min-width: 0;
display: grid;
gap: var(--space-1);
}
.membership-payment-copy h3 {
margin: 0;
color: var(--color-ink);
font-size: var(--type-title-md);
font-weight: 600;
}
.membership-payment-copy p {
margin: 0;
color: var(--color-ink-secondary);
font-size: var(--type-body-sm);
line-height: 1.55;
font-variant-numeric: tabular-nums;
}
.membership-payment-panel .dialog-actions {
margin: 0;
}
.membership-redeem {
width: min(100%, 480px);
}
@media (max-width: 767px) {
.membership-hero {
grid-template-columns: 1fr;
padding: var(--space-6);
}
.membership-celestial-visual {
display: none;
}
.membership-summary {
grid-template-columns: 1fr;
}
.membership-order-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.membership-order-row b {
display: none;
}
.membership-payment-panel {
grid-template-columns: auto minmax(0, 1fr);
}
.membership-payment-panel .dialog-actions {
grid-column: 1 / -1;
}
}
@media (max-width: 639px) {
.membership-plan-card--recommended {
order: -1;
}
}
@media (min-width: 640px) {
.membership-plan-grid,
.membership-credit-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.membership-plan-grid,
.membership-credit-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) {
.membership-plan-card,
.membership-credit-card,
.membership-hero {
animation: none;
}
}
.membership-tabs {
display: flex;
gap: var(--space-2);
margin-bottom: var(--space-5);
border-bottom: 1px solid var(--color-border);
}
.membership-tab {
position: relative;
min-height: 44px;
padding: 0 16px;
border: 0;
border-bottom: 2px solid transparent;
background: none;
color: var(--color-ink-secondary);
cursor: pointer;
font-size: var(--type-body-sm);
font-weight: 600;
}
.membership-tab.is-active,
.membership-tab[aria-selected="true"] {
color: var(--color-action);
border-bottom-color: var(--color-action);
}
.membership-panel {
min-width: 0;
}
.membership-plan-card--highlighted {
border-color: var(--color-action);
outline: 2px solid color-mix(in srgb, var(--color-action) 38%, transparent);
outline-offset: 2px;
}
.membership-payment-error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
margin-bottom: var(--space-4);
padding: var(--space-3) var(--space-4);
border: 1px solid color-mix(in srgb, var(--color-danger) 42%, var(--color-border));
border-radius: var(--radius-md);
background: var(--color-danger-muted);
}
.membership-payment-error p {
margin: 0;
color: var(--color-danger);
font-size: var(--type-body-sm);
line-height: 1.55;
}
@media (max-width: 639px) {
.membership-payment-error {
align-items: flex-start;
flex-direction: column;
}
}
+702
View File
@@ -0,0 +1,702 @@
"use client";
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
import type { FormEvent } from "react";
import { useSearchParams } from "next/navigation";
import { ArrowLeft, CheckCircle2, Gift, Sparkles, X } from "lucide-react";
import { keepFocusWithin } from "@/lib/focus-trap";
import {
entitlementLabel,
formatMembershipDate,
formatPrice,
formatProductDuration,
membershipSourceNotice,
notifyBalanceChanged,
orderStatusLabel,
planAlias,
planDisplayLabel,
productAudience,
redeemErrorMessage,
selectMembershipPlans,
type MembershipPlanAlias,
type MembershipProduct,
} from "@/lib/membership";
type MembershipAccount = {
user: { id: string; email: string | null };
credits: number;
activeSubscription: {
id: string;
status: string;
startsAt: string;
endsAt: string;
productCode: string;
productVersion: number;
product: { name?: string; productType?: string } | null;
entitlements: unknown;
} | null;
};
type PaymentOrder = {
orderNo: string;
status: string;
credits: number;
moneyCents: number;
currency: string;
productName: string;
paidAt: string | null;
createdAt: string;
};
type PaymentOrderState = {
orderNo: string;
payUrl: string | null;
qrCode: string | null;
status: string;
productId: string;
};
function normalizeOrders(list: readonly unknown[]): PaymentOrder[] {
return list.map((raw) => {
const item = (raw ?? {}) as Record<string, unknown>;
const snapshot = (item.product_snapshot ?? item.product ?? {}) as Record<string, unknown>;
const price =
typeof item.price === "number"
? item.price
: typeof item.money_cents === "number"
? item.money_cents
: typeof item.moneyCents === "number"
? item.moneyCents
: 0;
return {
orderNo: String(item.order_no ?? item.orderNo ?? ""),
status: String(item.status ?? "unknown"),
credits: typeof item.credits === "number" ? item.credits : 0,
moneyCents: price,
currency: String(item.currency ?? "CNY"),
productName:
typeof snapshot.name === "string"
? snapshot.name
: typeof item.name === "string"
? item.name
: String(item.product_code ?? item.productCode ?? ""),
paidAt: item.paid_at ? String(item.paid_at) : item.paidAt ? String(item.paidAt) : null,
createdAt: String(item.created_at ?? item.createdAt ?? ""),
};
});
}
function goBack() {
if (window.history.length > 1) window.history.back();
else window.location.assign("/");
}
function MembershipContent() {
const searchParams = useSearchParams();
const source = searchParams.get("source");
const planHint = searchParams.get("plan");
const highlightedPlan: MembershipPlanAlias | null =
planHint === "trial" || planHint === "monthly" || planHint === "yearly" ? planHint : null;
const [account, setAccount] = useState<MembershipAccount | null>(null);
const [accountError, setAccountError] = useState("");
const [paymentPackages, setPaymentPackages] = useState<MembershipProduct[]>([]);
const [paymentEnabled, setPaymentEnabled] = useState(false);
const [packagesError, setPackagesError] = useState("");
const [orders, setOrders] = useState<PaymentOrder[]>([]);
const [ordersState, setOrdersState] = useState<"loading" | "ready" | "unavailable">("loading");
const [redeemOpen, setRedeemOpen] = useState(() => searchParams.get("redeem") === "1");
const [redeemCode, setRedeemCode] = useState("");
const [redeemError, setRedeemError] = useState("");
const [redeemMessage, setRedeemMessage] = useState("");
const [redeeming, setRedeeming] = useState(false);
const [paymentOrder, setPaymentOrder] = useState<PaymentOrderState | null>(null);
const [paymentError, setPaymentError] = useState("");
const [payingProductId, setPayingProductId] = useState<string | null>(null);
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
const [activeSegment, setActiveSegment] = useState<"membership" | "credits">("membership");
const [redeemDone, setRedeemDone] = useState(false);
const redeemDialogRef = useRef<HTMLElement>(null);
const redeemInputRef = useRef<HTMLInputElement>(null);
const highlightedCardRef = useRef<HTMLElement | null>(null);
const planHighlightScrolled = useRef(false);
const activeSubscription = account?.activeSubscription ?? null;
const currentProductCode = activeSubscription?.status === "active"
? activeSubscription.productCode
: null;
const fetchAccountData = useCallback(async (): Promise<MembershipAccount | null> => {
try {
const response = await fetch("/api/account", { cache: "no-store" });
if (response.status === 401) {
window.location.assign("/login");
return null;
}
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || "暂时无法读取账户信息");
setAccount(payload);
setAccountError("");
return payload as MembershipAccount;
} catch (caught) {
setAccountError(caught instanceof Error ? caught.message : "暂时无法读取账户信息");
return null;
}
}, []);
const resetPaymentState = useCallback(() => {
setPaymentEnabled(false);
setPaymentPackages([]);
setPaymentOrder(null);
setPaymentError("");
setPayingProductId(null);
setSelectedProductId(null);
}, []);
const fetchPackages = useCallback(async () => {
try {
const response = await fetch("/api/payment/packages", { cache: "no-store" });
const payload = await response.json().catch(() => null);
if (response.ok && payload?.enabled === true) {
setPaymentEnabled(true);
setPaymentPackages(payload.packages || []);
setPackagesError("");
return;
}
if (!response.ok) {
setPackagesError("套餐支付暂时不可用,请稍后重试");
resetPaymentState();
}
} catch {
setPackagesError("套餐支付暂时不可用,请稍后重试");
resetPaymentState();
}
}, [resetPaymentState]);
const fetchOrders = useCallback(async () => {
try {
const response = await fetch("/api/payment/orders", { cache: "no-store" });
if (!response.ok) {
setOrdersState("unavailable");
setOrders([]);
return;
}
const payload = await response.json().catch(() => null);
const list = Array.isArray(payload)
? payload
: payload && typeof payload === "object" && Array.isArray(payload.orders)
? payload.orders
: [];
setOrders(normalizeOrders(list));
setOrdersState("ready");
} catch {
setOrdersState("unavailable");
setOrders([]);
}
}, []);
useEffect(() => {
void (async () => {
await fetchAccountData();
await fetchPackages();
await fetchOrders();
})();
}, [fetchAccountData, fetchOrders, fetchPackages]);
useEffect(() => {
if (!redeemOpen) return;
window.requestAnimationFrame(() => redeemInputRef.current?.focus());
const onKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") closeRedeem();
else if (redeemDialogRef.current) keepFocusWithin(event, redeemDialogRef.current);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [redeemOpen]);
useEffect(() => {
if (!account) return;
const onBalanceStorage = (event: StorageEvent) => {
if (event.key === "jyotisha:balance:updated") void fetchAccountData();
};
const onBalanceChanged = () => void fetchAccountData();
window.addEventListener("storage", onBalanceStorage);
window.addEventListener("jyotisha:balance:changed", onBalanceChanged);
return () => {
window.removeEventListener("storage", onBalanceStorage);
window.removeEventListener("jyotisha:balance:changed", onBalanceChanged);
};
}, [account, fetchAccountData]);
useEffect(() => {
if (!highlightedPlan || planHighlightScrolled.current || paymentPackages.length === 0) return;
planHighlightScrolled.current = true;
const card = highlightedCardRef.current;
if (!card) return;
window.requestAnimationFrame(() => {
card.scrollIntoView({ behavior: "smooth", block: "center" });
card.focus({ preventScroll: true });
});
}, [highlightedPlan, paymentPackages.length]);
function closeRedeem() {
setRedeemOpen(false);
setRedeemError("");
const url = new URL(window.location.href);
url.searchParams.delete("redeem");
window.history.replaceState(null, "", `${url.pathname}${url.search}`);
}
async function redeem(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = redeemCode.trim();
if (!code || redeeming) return;
setRedeeming(true);
setRedeemError("");
setRedeemMessage("");
try {
const response = await fetch("/api/redeem", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code }),
});
const payload = await response.json().catch(() => null);
if (response.status === 401) {
window.location.assign("/login");
return;
}
if (!response.ok) throw new Error(redeemErrorMessage(response.status, payload));
const result = payload as { awardedCredits?: number; credits: number; message?: string };
const awarded = typeof result.awardedCredits === "number" ? result.awardedCredits : result.credits;
setRedeemCode("");
setRedeemDone(true);
setRedeemMessage(result.message || `本次到账 ${awarded} 点,最新余额 ${result.credits} 点。`);
const refreshed = await fetchAccountData();
notifyBalanceChanged(refreshed?.credits ?? result.credits);
void fetchOrders();
} catch (caught) {
setRedeemError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试");
} finally {
setRedeeming(false);
}
}
async function createPayment(productId: string) {
if (payingProductId) return;
setSelectedProductId(productId);
setPayingProductId(productId);
setPaymentError("");
try {
const response = await fetch("/api/payment/epay/create", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ productId }),
});
const payload = await response.json().catch(() => null);
if (response.status === 401) {
window.location.assign("/login");
return;
}
if (!response.ok) throw new Error(payload?.error || "创建支付失败");
if (typeof payload?.orderNo !== "string" || typeof payload?.payUrl !== "string") {
throw new Error("创建支付失败");
}
setPaymentOrder({
orderNo: payload.orderNo,
payUrl: payload.payUrl,
qrCode: payload.qrCode ?? null,
status: "pending",
productId,
});
window.open(payload.payUrl, "_blank", "noopener,noreferrer");
} catch (caught) {
setPaymentError(caught instanceof Error ? caught.message : "创建支付失败");
} finally {
setPayingProductId(null);
}
}
useEffect(() => {
if (!paymentOrder || paymentOrder.status !== "pending") return;
const timer = window.setInterval(() => {
void fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" }).then(async (response) => {
const payload = await response.json().catch(() => null);
if (!response.ok) return;
const failed = (typeof payload.status === "string"
&& ["failed", "closed", "cancelled"].includes(payload.status))
|| payload.grantStatus === "failed";
const paid = payload.status === "paid" && !failed;
setPaymentOrder((current) => current ? {
...current,
status: paid ? "paid" : failed ? "failed" : "pending",
} : current);
if (paid) {
const refreshed = await fetchAccountData();
notifyBalanceChanged(refreshed?.credits ?? 0);
void fetchOrders();
}
});
}, 3000);
return () => window.clearInterval(timer);
}, [fetchAccountData, fetchOrders, paymentOrder]);
const plans = selectMembershipPlans(paymentPackages);
const creditPacks = paymentPackages.filter((product) => product.productType === "credit_pack");
const sourceNotice = membershipSourceNotice(source);
return (
<main className="membership-page">
<div className="membership-shell">
<header className="membership-header">
<div className="membership-header-left">
<button className="membership-back" type="button" onClick={goBack} aria-label="返回">
<ArrowLeft aria-hidden="true" />
<span>返回</span>
</button>
<h1 className="membership-title">套餐与会员</h1>
</div>
<button className="membership-redeem-entry" type="button" onClick={() => setRedeemOpen(true)}>
<Gift aria-hidden="true" />
<span>兑换码</span>
</button>
</header>
{sourceNotice && <p className="membership-notice" role="status">{sourceNotice}</p>}
{highlightedPlan && (
<p className="membership-notice" role="status">已为你定位到{planDisplayLabel(highlightedPlan)}套餐。</p>
)}
<section className="membership-hero" aria-labelledby="membership-hero-title">
<div className="membership-hero-copy">
<p className="membership-greeting">{account?.user.email ? `账户:${account.user.email}` : "欢迎回来"}</p>
<h2 id="membership-hero-title">
{activeSubscription ? "保持会员状态,持续获得完整权益" : "开通会员,解锁完整咨询体验"}
</h2>
<div className="membership-summary">
<article className="membership-summary-card">
<span>会员状态</span>
<strong>{activeSubscription ? activeSubscription.product?.name || activeSubscription.productCode : "未开通会员"}</strong>
<small>{activeSubscription
? `${formatMembershipDate(activeSubscription.startsAt)} 至 ${formatMembershipDate(activeSubscription.endsAt)}`
: "体验 / 月卡 / 年卡见下方套餐"}</small>
</article>
<article className="membership-summary-card">
<span>当前余额</span>
<strong>{account ? `${account.credits} 点` : accountError || "正在读取…"}</strong>
<small>用于咨询与生时校正等服务</small>
</article>
</div>
</div>
<div className="membership-celestial-visual" aria-hidden="true">
<span className="membership-orbit membership-orbit-outer" />
<span className="membership-orbit membership-orbit-inner" />
<span className="membership-orbit membership-orbit-mini" />
<span className="membership-orbit-dot membership-orbit-dot-primary" />
<span className="membership-orbit-dot membership-orbit-dot-secondary" />
<span className="membership-celestial-mark" />
</div>
</section>
<section className="membership-orders" aria-labelledby="membership-orders-title">
<div className="membership-section-heading">
<h2 id="membership-orders-title">订单记录</h2>
<button className="membership-orders-refresh" type="button" onClick={() => void fetchOrders()}>刷新</button>
</div>
{ordersState === "loading" && <p className="membership-empty">正在加载订单…</p>}
{ordersState === "unavailable" && <p className="membership-empty">订单记录暂时不可用,请稍后刷新。</p>}
{ordersState === "ready" && orders.length === 0 && <p className="membership-empty">暂无订单。</p>}
{orders.length > 0 && (
<ul className="membership-order-list">
{orders.map((order) => (
<li className="membership-order-row" key={order.orderNo}>
<div>
<strong>{order.productName || "订单"}</strong>
<small>{order.orderNo}</small>
</div>
<span className={`membership-order-status status-${order.status}`}>
{orderStatusLabel(order.status)}
</span>
<b>{formatPrice(order.moneyCents, order.currency)}</b>
</li>
))}
</ul>
)}
</section>
<section className="membership-segment" aria-label="套餐与点数">
<div className="membership-tabs" role="tablist" aria-label="套餐分类">
<button
className={`membership-tab${activeSegment === "membership" ? " is-active" : ""}`}
id="membership-plans-tab"
role="tab"
type="button"
aria-selected={activeSegment === "membership"}
aria-controls="membership-plans-panel"
onClick={() => setActiveSegment("membership")}
>
会员套餐
</button>
<button
className={`membership-tab${activeSegment === "credits" ? " is-active" : ""}`}
id="membership-credits-tab"
role="tab"
type="button"
aria-selected={activeSegment === "credits"}
aria-controls="membership-credits-panel"
onClick={() => setActiveSegment("credits")}
>
点数充值
</button>
</div>
{paymentError && (
<div className="membership-payment-error" role="alert">
<p>{paymentError}</p>
{selectedProductId && (
<button
className="button-secondary"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(selectedProductId)}
>
{payingProductId === selectedProductId ? "创建中…" : "重新支付"}
</button>
)}
</div>
)}
{packagesError && <p className="form-error" role="alert">{packagesError}</p>}
{activeSegment === "membership" && (
<div
className="membership-panel"
id="membership-plans-panel"
role="tabpanel"
aria-labelledby="membership-plans-tab"
>
{!packagesError && !paymentEnabled && <p className="membership-empty">在线支付暂未开放。</p>}
{plans.length > 0 && (
<div className="membership-plan-grid">
{plans.map((product) => {
const alias = planAlias(product);
const recommended = alias === "monthly";
const isCurrent = currentProductCode === product.code;
const highlighted = alias === highlightedPlan;
return (
<article
key={product.id}
ref={highlighted ? highlightedCardRef : undefined}
tabIndex={highlighted ? -1 : undefined}
aria-current={highlighted ? "true" : undefined}
className={`membership-plan-card${recommended ? " membership-plan-card--recommended" : ""}${isCurrent ? " membership-plan-card--current" : ""}${highlighted ? " membership-plan-card--highlighted" : ""}`}
>
<header className="membership-card-header">
<div>
<span className="membership-card-kicker">{planDisplayLabel(alias ?? "monthly")}</span>
<h3>{product.name}</h3>
</div>
<div className="membership-card-badges">
{recommended && <span className="membership-badge membership-badge--recommended">推荐</span>}
{isCurrent && <span className="membership-badge membership-badge--current">当前套餐</span>}
</div>
</header>
<p className="membership-card-price">
{formatPrice(product.priceCents, product.currency)}
<small>/{formatProductDuration(product)}</small>
</p>
<p className="membership-card-audience">{productAudience(product)}</p>
<ul className="membership-card-entitlements">
{product.entitlements.map((entitlement, index) => (
<li key={index}>{entitlementLabel(entitlement)}</li>
))}
{product.entitlements.length === 0 && <li>购买后按页面说明到账</li>}
</ul>
<details className="membership-rules">
<summary>详细规则</summary>
<ul>
<li>{isCurrent
? `当前有效期至 ${formatMembershipDate(activeSubscription?.endsAt)},续费从到期后顺延,不会覆盖未到期权益。`
: "支付成功后权益立即生效。"}</li>
<li>订单与到账状态可在本页「订单记录」中查看。</li>
<li>支付由合作渠道处理;如长时间未到账,请联系支持并出示订单号。</li>
</ul>
</details>
<button
className={recommended ? "button-primary membership-buy" : "button-secondary membership-buy"}
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingProductId === product.id ? "创建中…" : isCurrent ? "续费" : product.productType === "trial" ? "立即开通" : "立即购买"}
</button>
</article>
);
})}
</div>
)}
{!packagesError && paymentEnabled && plans.length === 0 && <p className="membership-empty">会员套餐正在上架中。</p>}
</div>
)}
{activeSegment === "credits" && (
<div
className="membership-panel"
id="membership-credits-panel"
role="tabpanel"
aria-labelledby="membership-credits-tab"
>
{creditPacks.length > 0 && (
<div className="membership-credit-grid">
{creditPacks.map((product) => (
<article className="membership-credit-card" key={product.id}>
<div className="membership-credit-copy">
<span className="membership-card-kicker">{product.credits > 0 ? `${product.credits} 点` : "点数包"}</span>
<h3>{product.name}</h3>
<p>{product.description || "按需补充咨询点数"}</p>
</div>
<p className="membership-card-price">{formatPrice(product.priceCents, product.currency)}</p>
<ul className="membership-card-entitlements">
{product.entitlements.map((entitlement, index) => (
<li key={index}>{entitlementLabel(entitlement)}</li>
))}
</ul>
<details className="membership-rules">
<summary>详细规则</summary>
<ul>
<li>点数充值支付成功后立即到账。</li>
<li>点数用于咨询、报告与生时校正等服务。</li>
</ul>
</details>
<button
className="button-secondary membership-buy"
type="button"
disabled={!paymentEnabled || Boolean(payingProductId)}
onClick={() => void createPayment(product.id)}
>
{payingProductId === product.id ? "创建中…" : "立即购买"}
</button>
</article>
))}
</div>
)}
{!packagesError && paymentEnabled && creditPacks.length === 0 && <p className="membership-empty">点数包正在上架中。</p>}
</div>
)}
</section>
{paymentOrder && (
<section className="membership-payment-panel" role="status" aria-live="polite">
<span className="membership-payment-icon" aria-hidden="true">
{paymentOrder.status === "paid" ? <CheckCircle2 /> : paymentOrder.status === "failed" ? <X /> : <Sparkles />}
</span>
<div className="membership-payment-copy">
{paymentOrder.status === "paid" && (
<>
<h3>支付成功,权益已到账</h3>
<p>订单号:{paymentOrder.orderNo}</p>
<p>余额与会员状态已刷新,可以返回继续使用。</p>
</>
)}
{paymentOrder.status === "failed" && (
<>
<h3>支付失败</h3>
<p>订单号:{paymentOrder.orderNo}</p>
<p>订单未完成支付,可重新支付或稍后再试。</p>
</>
)}
{paymentOrder.status === "pending" && (
<>
<h3>等待完成支付</h3>
<p>订单号:{paymentOrder.orderNo}</p>
<p>请在已打开的收银台页面完成支付;本页会自动刷新余额与订单记录。</p>
</>
)}
</div>
<div className="dialog-actions">
{paymentOrder.status === "failed" && (
<button
className="button-secondary"
type="button"
disabled={Boolean(payingProductId)}
onClick={() => void createPayment(paymentOrder.productId)}
>
{payingProductId === paymentOrder.productId ? "创建中…" : "重新支付"}
</button>
)}
<button className="button-primary" type="button" onClick={goBack}>返回</button>
</div>
</section>
)}
</div>
{redeemOpen && (
<div className="account-modal-overlay" onMouseDown={closeRedeem}>
<section
className="account-modal membership-redeem"
ref={redeemDialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="membership-redeem-title"
onMouseDown={(event) => event.stopPropagation()}
>
<header className="account-modal-header">
<h2 id="membership-redeem-title">兑换点数</h2>
<button className="dialog-close" aria-label="关闭" type="button" onClick={closeRedeem}>
<X aria-hidden="true" />
</button>
</header>
<div className="redeem-balance">
<span>{activeSubscription ? "当前会员" : "当前余额"}</span>
<strong>{activeSubscription
? `${activeSubscription.product?.name || activeSubscription.productCode} · ${formatMembershipDate(activeSubscription.endsAt)} 到期`
: `${account?.credits ?? 0} 点`}</strong>
</div>
<form className="redeem-form" onSubmit={redeem}>
<label htmlFor="membership-redeem-code">兑换码</label>
<div>
<input
id="membership-redeem-code"
ref={redeemInputRef}
autoComplete="off"
value={redeemCode}
onChange={(event) => {
setRedeemCode(event.target.value);
setRedeemError("");
setRedeemMessage("");
setRedeemDone(false);
}}
placeholder="输入完整兑换码"
/>
<button className="button-primary" type="submit" disabled={!redeemCode.trim() || redeeming || redeemDone}>
{redeeming ? "兑换中" : "立即兑换"}
</button>
</div>
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
{redeemDone && (
<div className="dialog-actions">
<button className="button-primary" type="button" onClick={closeRedeem}>完成</button>
</div>
)}
</form>
<details className="membership-rules">
<summary>兑换规则</summary>
<ul>
<li>请完整输入兑换码,系统会按 JYOTISH-XXXX-XXXX 格式校验。</li>
<li>兑换成功后点数立即到账,余额实时更新。</li>
<li>每个兑换码仅可使用一次,无法转让或退换。</li>
</ul>
</details>
</section>
</div>
)}
</main>
);
}
export default function MembershipPage() {
return (
<Suspense fallback={<main className="membership-page"><div className="membership-loading">正在加载会员信息…</div></main>}>
<MembershipContent />
</Suspense>
);
}
+28 -129
View File
@@ -2,7 +2,7 @@
import Link from "next/link";
import dynamic from "next/dynamic";
import { ArrowUp, ArrowUpRight, ShieldCheck, Sparkles, Square, X } from "lucide-react";
import { ArrowUp, ArrowUpRight, Sparkles, Square, X } from "lucide-react";
import { useGSAP } from "@gsap/react";
import { gsap } from "gsap";
import { useEffect, useRef, useState } from "react";
@@ -74,6 +74,7 @@ import {
} from "@/lib/birth-time-guided-preview";
import { defaultGuidedJyotishTopics, generalGuidedJyotishTopics } from "@/lib/guided-jyotish-topics";
import { keepFocusWithin } from "@/lib/focus-trap";
import { BALANCE_SYNC_KEY, BALANCE_CHANGED_EVENT, membershipHref } from "@/lib/membership";
import { chatMessageViews, type ChatMessage } from "@/lib/chat-message-view";
import { writeChatSession } from "@/lib/chat-session-write-contract";
import { consultationReportMarkdown } from "@/lib/consultation-report-export";
@@ -202,7 +203,7 @@ type Account = {
profile: unknown;
};
type OnboardingStep = "name" | "birth" | "place" | "rectification";
type AccountDialog = "profile" | "redeem" | "logout";
type AccountDialog = "profile" | "logout";
type DailyStarlanguageCard = { trend: string; action: string; caution: string };
type DailyStarlanguageApiResponse = {
status?: "ok";
@@ -240,13 +241,11 @@ const rectifyBeforeConsultationSuggestion = "先完成生时校正";
const accountDialogTitles = {
profile: "个人资料",
redeem: "兑换点数",
logout: "退出登录?",
} as const satisfies Record<AccountDialog, string>;
const accountDialogClasses = {
profile: "profile-modal",
redeem: "redeem-modal",
logout: "logout-modal",
} as const satisfies Record<AccountDialog, string>;
@@ -926,15 +925,6 @@ export default function Home() {
const [avatarSaving, setAvatarSaving] = useState(false);
const [account, setAccount] = useState<Account | null>(null);
const [accountError, setAccountError] = useState("");
const [redeemCode, setRedeemCode] = useState("");
const [redeemError, setRedeemError] = useState("");
const [redeemMessage, setRedeemMessage] = useState("");
const [redeeming, setRedeeming] = useState(false);
const [paymentEnabled, setPaymentEnabled] = useState(false);
const [paymentPackages, setPaymentPackages] = useState<Array<{ id: string; name: string; description: string; productType: "credit_pack" | "trial" | "subscription"; billingPeriod: string; intervalCount: number; priceCents: number; credits: number }>>([]);
const [paymentOrder, setPaymentOrder] = useState<{ orderNo: string; payUrl: string | null; qrCode: string | null; status: string } | null>(null);
const [paymentError, setPaymentError] = useState("");
const [payingPackageId, setPayingPackageId] = useState<string | null>(null);
const [signingOut, setSigningOut] = useState(false);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [pinnedSessionIds, setPinnedSessionIds] = useState<string[]>([]);
@@ -976,10 +966,8 @@ export default function Home() {
const conversationEnd = useRef<HTMLDivElement>(null);
const accountTrigger = useRef<HTMLButtonElement>(null);
const accountDialog = useRef<HTMLElement>(null);
const creditTrigger = useRef<HTMLButtonElement>(null);
const dialogReturnTarget = useRef<HTMLButtonElement | null>(null);
const closeButton = useRef<HTMLButtonElement>(null);
const redeemInput = useRef<HTMLInputElement>(null);
const composerInput = useRef<HTMLTextAreaElement>(null);
const starterWorkbench = useRef<HTMLDivElement>(null);
const pendingConsultation = useRef<PendingConsultation | null>(null);
@@ -1446,8 +1434,7 @@ export default function Home() {
if (activeAccountDialog === null) return;
window.requestAnimationFrame(() => {
if (signingOut) return;
if (activeAccountDialog === "redeem") redeemInput.current?.focus();
else closeButton.current?.focus();
closeButton.current?.focus();
});
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
@@ -1464,6 +1451,25 @@ export default function Home() {
return () => window.removeEventListener("keydown", closeOnEscape);
}, [activeAccountDialog, signingOut]);
useEffect(() => {
if (!accountId || !hydrated) return;
const onBalanceStorage = (event: StorageEvent) => {
if (event.key === BALANCE_SYNC_KEY) void refreshAccount();
};
const onBalanceChanged = () => void refreshAccount();
const onPageShow = (event: PageTransitionEvent) => {
if (event.persisted) void refreshAccount();
};
window.addEventListener("storage", onBalanceStorage);
window.addEventListener(BALANCE_CHANGED_EVENT, onBalanceChanged);
window.addEventListener("pageshow", onPageShow);
return () => {
window.removeEventListener("storage", onBalanceStorage);
window.removeEventListener(BALANCE_CHANGED_EVENT, onBalanceChanged);
window.removeEventListener("pageshow", onPageShow);
};
}, [accountId, hydrated]);
async function refreshAccount() {
const requestIdentity = accountRefreshGuard.current.begin();
try {
@@ -1674,14 +1680,6 @@ export default function Home() {
setProfileNotice("");
setAvatarNotice("");
break;
case "redeem":
setRedeemError("");
setRedeemMessage("");
setPaymentEnabled(false);
setPaymentPackages([]);
setPaymentOrder(null);
setPaymentError("");
break;
case "logout":
break;
default: {
@@ -1977,77 +1975,6 @@ export default function Home() {
}
}
useEffect(() => {
if (activeAccountDialog !== "redeem") return;
void fetch("/api/payment/packages", { cache: "no-store" }).then(async (response) => {
const payload = await response.json().catch(() => null);
if (response.ok && payload?.enabled === true) {
setPaymentEnabled(true);
setPaymentPackages(payload.packages || []);
return;
}
if (!response.ok) setPaymentError("套餐支付暂时不可用,请稍后重试");
}).catch(() => {
setPaymentError("套餐支付暂时不可用,请稍后重试");
});
}, [activeAccountDialog]);
useEffect(() => {
if (!paymentOrder || paymentOrder.status === "paid") return;
const timer = window.setInterval(() => {
void fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" }).then(async (response) => {
const payload = await response.json().catch(() => null);
if (!response.ok) return;
setPaymentOrder((current) => current ? { ...current, status: payload.status } : current);
if (payload.status === "paid") void refreshAccount();
});
}, 3000);
return () => window.clearInterval(timer);
}, [paymentOrder]);
async function createPayment(packageId: string) {
if (payingPackageId) return;
setPayingPackageId(packageId); setPaymentError("");
try {
const response = await fetch("/api/payment/epay/create", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ packageId }) });
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || "创建支付失败");
if (typeof payload?.orderNo !== "string" || typeof payload?.payUrl !== "string") throw new Error("创建支付失败");
setPaymentOrder({ orderNo: payload.orderNo, payUrl: payload.payUrl, qrCode: payload.qrCode ?? null, status: "pending" });
window.open(payload.payUrl, "_blank", "noopener,noreferrer");
} catch (caught) { setPaymentError(caught instanceof Error ? caught.message : "创建支付失败"); } finally { setPayingPackageId(null); }
}
async function redeem(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = redeemCode.trim();
if (!code || redeeming) return;
setRedeeming(true);
setRedeemError("");
setRedeemMessage("");
try {
const response = await fetch("/api/redeem", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ code }),
});
const payload = await response.json().catch(() => null);
if (response.status === 401) {
window.location.assign("/login");
return;
}
if (!response.ok) throw new Error(payloadMessage(payload, "兑换码无效或已使用"));
const result = payload as { credits: number; message?: string };
setAccount((current) => current ? { ...current, credits: result.credits } : current);
setRedeemCode("");
setRedeemMessage(result.message || `兑换成功,当前余额 ${result.credits} 点。`);
} catch (caught) {
setRedeemError(caught instanceof Error ? caught.message : "兑换失败,请稍后重试");
} finally {
setRedeeming(false);
}
}
async function signOut() {
if (signingOut) return;
setSigningOut(true);
@@ -2419,7 +2346,7 @@ export default function Home() {
: initialConsultationRoute;
if (account.credits <= 0 && !account.activeSubscription) {
openAccountDialog("redeem", creditTrigger.current);
window.location.assign(membershipHref("insufficient-credits"));
return false;
}
@@ -2552,7 +2479,7 @@ export default function Home() {
const contentType = response.headers.get("content-type") ?? "";
const errorPayload = contentType.includes("application/json") ? await response.json() : { message: await response.text() };
if (response.status === 401) window.location.assign("/login");
if (response.status === 402) openAccountDialog("redeem", creditTrigger.current);
if (response.status === 402) window.location.assign(membershipHref("insufficient-credits"));
throw new Error(payloadMessage(errorPayload, "服务暂时不可用"));
}
if (!response.body) throw new Error("浏览器未收到可读取的回答流");
@@ -2754,8 +2681,6 @@ export default function Home() {
name: profile.name.trim() || account.user.email || "账户",
email: account.user.email || "尚未读取邮箱",
credits: account.credits,
isAdmin: account.isAdmin,
adminUrl: account.adminUrl,
avatar: account.avatar,
initial: profile.name.trim().slice(0, 1)
|| account.user.email?.slice(0, 1).toUpperCase()
@@ -2810,7 +2735,7 @@ export default function Home() {
onNewChat={() => void startNewChat()}
onSelectSession={selectSession}
onOpenProfile={() => openAccountDialog("profile")}
onOpenRedeem={() => openAccountDialog("redeem")}
onOpenRedeem={() => window.location.assign(membershipHref("account-menu", { redeem: true }))}
onOpenLogout={() => openAccountDialog("logout")}
/>
{pendingSessionDeletion ? (
@@ -2847,12 +2772,7 @@ export default function Home() {
evidenceState={reportEvidenceState}
/>
)}
{account.isAdmin && account.adminUrl ? (
<Link className="admin-button" href={account.adminUrl} aria-label="后台管理" title="后台管理">
<ShieldCheck aria-hidden="true" />
</Link>
) : null}
<button className="credit-button" ref={creditTrigger} type="button" onClick={() => openAccountDialog("redeem", creditTrigger.current)} aria-label={account ? `余额 ${account.credits} 点,兑换点数` : accountError || "读取余额中"}>
<button className="credit-button" type="button" onClick={() => window.location.assign(membershipHref("credits"))} aria-label={account ? `余额 ${account.credits} 点,会员与充值` : accountError || "读取余额中"}>
<Sparkles className="credit-icon" aria-hidden="true" />
<span>{account ? account.credits : "—"}</span>
</button>
@@ -3083,7 +3003,7 @@ export default function Home() {
? presetMessageFinished ? "输入你的称呼" : "Jyotisha 正在输入…"
: "请先完成上方资料"
: account.credits === 0 && !account.activeSubscription
? "余额不足,发送时将打开兑换码"
? "余额不足,可前往会员页充值点数或开通会员"
: "例如:未来半年是否适合换工作?"}
rows={1}
maxLength={!profileComplete && onboardingStep === "name" ? 80 : 500}
@@ -3272,27 +3192,6 @@ export default function Home() {
</>
)}
{activeAccountDialog === "redeem" && (
<>
<div className="redeem-balance"><span>{account.activeSubscription ? "当前会员" : "当前余额"}</span><strong>{account.activeSubscription ? `${account.activeSubscription.product?.name || account.activeSubscription.productCode} · ${new Date(account.activeSubscription.endsAt).toLocaleDateString("zh-CN")} 到期` : `${account.credits} 点`}</strong></div>
<form className="redeem-form account-redeem-form" onSubmit={redeem}>
<label htmlFor="redeem-code">兑换码</label>
<div>
<input id="redeem-code" ref={redeemInput} autoComplete="off" value={redeemCode} onChange={(event) => { setRedeemCode(event.target.value); setRedeemError(""); setRedeemMessage(""); }} placeholder="输入完整兑换码" />
<button className="button-primary" type="submit" disabled={!redeemCode.trim() || redeeming}>{redeeming ? "兑换中" : "兑换"}</button>
</div>
{redeemError && <p className="form-error" role="alert">{redeemError}</p>}
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
</form>
{paymentEnabled && <div className="payment-packages">
<h3>套餐与会员</h3>
{paymentPackages.map((item) => <div className="payment-package" key={item.id}><div><strong>{item.name}</strong><span>{item.description || (item.productType === "credit_pack" ? `${item.credits} 点` : "会员权益")}</span></div><b>¥{(item.priceCents / 100).toFixed(2)}</b><button className="button-secondary" type="button" onClick={() => void createPayment(item.id)} disabled={Boolean(payingPackageId)}>{payingPackageId === item.id ? "创建中" : "立即支付"}</button></div>)}
{paymentOrder && <div className="payment-status" role="status"><p>订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,权益已到账" : "等待支付"}</p></div>}
</div>}
{paymentError && <p className="form-error" role="alert">{paymentError}</p>}
</>
)}
{activeAccountDialog === "logout" && (
<>
<p className="logout-copy">退出后,需要重新登录才能继续查看对话。</p>
@@ -4,6 +4,7 @@ import { ArrowUp } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { parseAgentReply } from "@/lib/agent-reply";
import type { ChatMessage, ChatMessageView } from "@/lib/chat-message-view";
import { membershipHref } from "@/lib/membership";
import type { PublicLanguageModel } from "@/lib/public-models";
import { ChatMessageRow } from "./chat-message-row";
import { ModelSelector } from "./model-selector";
@@ -152,8 +153,11 @@ export function AgenticRectificationChat(props: AgenticRectificationChatProps) {
onProfileIncomplete?.();
return;
}
if (response.status === 402) setError(`咨询点数不足:${message}`);
else if (response.status === 401) setError("请先登录。");
if (response.status === 402) {
window.location.assign(membershipHref("rectification"));
return;
}
if (response.status === 401) setError("请先登录。");
else setError(message);
return;
}
+155
View File
@@ -0,0 +1,155 @@
export type MembershipProductType = "credit_pack" | "trial" | "subscription";
export type MembershipEntitlement = {
featureKey: string;
allowanceType: string;
allowanceCount: number | null;
resetPeriod: string;
modelTier: string | null;
fairUsePolicyId: string | null;
metadata: unknown;
};
export type MembershipProduct = {
id: string;
productId: string;
code: string;
version: number;
name: string;
description: string;
productType: MembershipProductType;
billingPeriod: "none" | "day" | "month" | "year";
intervalCount: number;
priceCents: number;
currency: string;
credits: number;
entitlements: MembershipEntitlement[];
};
export type MembershipPlanAlias = "trial" | "monthly" | "yearly";
export function membershipHref(source: string, options: { redeem?: boolean; plan?: MembershipPlanAlias } = {}) {
const params = new URLSearchParams({ source });
if (options.redeem) params.set("redeem", "1");
if (options.plan) params.set("plan", options.plan);
return `/membership?${params.toString()}`;
}
export function planDisplayLabel(alias: MembershipPlanAlias) {
const labels: Record<MembershipPlanAlias, string> = {
trial: "体验",
monthly: "月卡",
yearly: "年卡",
};
return labels[alias];
}
export function formatPrice(priceCents: number, currency = "CNY") {
return `${currency === "CNY" ? "¥" : `${currency} `}${(priceCents / 100).toFixed(2)}`;
}
export function formatMembershipDate(value: string | null | undefined) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString("zh-CN");
}
export function orderStatusLabel(status: string | null | undefined) {
const labels: Record<string, string> = {
paid: "已支付",
pending: "待支付",
failed: "支付失败",
closed: "已关闭",
cancelled: "已取消",
};
return (status && labels[status]) || status || "未知";
}
export function membershipSourceNotice(source: string | null | undefined) {
const notices: Record<string, string> = {
credits: "来自对话页余额入口",
"account-menu": "来自账户菜单",
"insufficient-credits": "余额不足,请先补充点数或开通会员后再发送问题",
rectification: "生时校正需要点数或会员权益",
};
return notices[source ?? ""] ?? "";
}
export const BALANCE_SYNC_KEY = "jyotisha:balance:updated";
export const BALANCE_CHANGED_EVENT = "jyotisha:balance:changed";
export function notifyBalanceChanged(credits: number) {
try {
localStorage.setItem(BALANCE_SYNC_KEY, JSON.stringify({ credits, at: Date.now() }));
} catch {
// Storage can be unavailable in privacy modes; the window event still fires.
}
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent(BALANCE_CHANGED_EVENT, { detail: { credits } }));
}
}
export function planAlias(product: Pick<MembershipProduct, "productType" | "billingPeriod">): MembershipPlanAlias | null {
if (product.productType === "trial") return "trial";
if (product.productType !== "subscription") return null;
if (product.billingPeriod === "month") return "monthly";
if (product.billingPeriod === "year") return "yearly";
return null;
}
export function selectMembershipPlans(products: readonly MembershipProduct[]) {
const aliases: MembershipPlanAlias[] = ["trial", "monthly", "yearly"];
return aliases.flatMap((alias) => {
const product = products.find((candidate) => planAlias(candidate) === alias);
return product ? [product] : [];
});
}
export function formatProductDuration(product: Pick<MembershipProduct, "productType" | "billingPeriod" | "intervalCount">) {
if (product.productType === "credit_pack") return "购买后立即到账";
const unit = product.billingPeriod === "day" ? "天" : product.billingPeriod === "month" ? "个月" : "年";
return `${product.intervalCount} ${unit}`;
}
export function productAudience(product: Pick<MembershipProduct, "productType" | "billingPeriod">) {
if (product.productType === "trial") return "适合初次体验完整咨询流程";
if (product.productType === "credit_pack") return "适合按需补充点数、灵活使用";
if (product.billingPeriod === "year") return "适合长期记录与持续复盘";
return "适合稳定使用咨询与报告功能";
}
const featureLabels: Record<string, string> = {
"chat.standard": "标准 AI 咨询",
"report.full": "完整个人报告",
rectification: "生时校正",
};
export function entitlementLabel(entitlement: MembershipEntitlement) {
const feature = featureLabels[entitlement.featureKey] ?? entitlement.featureKey;
if (entitlement.allowanceType === "unlimited") return `${feature}不限点数(遵循合理使用规则)`;
if (entitlement.allowanceType === "credits") return `${entitlement.allowanceCount ?? 0} 点咨询点数`;
if (typeof entitlement.allowanceCount === "number") return `${feature} ${entitlement.allowanceCount} 次`;
return feature;
}
export function redeemErrorMessage(status: number, payload: unknown) {
if (status === 429) return "请求过于频繁";
if (payload && typeof payload === "object" && "code" in payload) {
const code = String((payload as { code?: unknown }).code ?? "");
const messages: Record<string, string> = {
invalid_code: "兑换码不存在",
expired_code: "兑换码已过期",
already_redeemed: "兑换码已被使用",
account_not_eligible: "兑换码不适用于当前账户",
rate_limited: "请求过于频繁",
};
if (messages[code]) return messages[code];
}
if (status >= 500) return "系统异常,请稍后重试";
if (payload && typeof payload === "object" && "error" in payload) {
const error = (payload as { error?: unknown }).error;
if (typeof error === "string" && error) return error;
}
return "系统异常,请稍后重试";
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Allowlisted columns a user may read from their own payment orders.
* Sensitive settlement material (epay_trade_no, raw_notify_payload_hash,
* user_id, full snapshots) is deliberately excluded.
*/
export const PAYMENT_ORDERS_SELECT = [
"order_no",
"product_code",
"product_snapshot",
"money_cents",
"status",
"grant_status",
"created_at",
"paid_at",
] as const;
export type PaymentOrderRow = {
order_no: string;
product_code: string | null;
product_snapshot: { name?: string } | null;
money_cents: number;
status: string;
grant_status: string;
created_at: Date | string;
paid_at: Date | string | null;
};
export type PaymentOrderSummary = {
orderNo: string;
product: string | null;
name: string | null;
price: number;
status: string;
grantStatus: string;
createdAt: string;
paidAt: string | null;
};
function toIso(value: Date | string): string {
return value instanceof Date ? value.toISOString() : value;
}
export function formatPaymentOrders(
rows: readonly PaymentOrderRow[],
): PaymentOrderSummary[] {
return rows.map((row) => ({
orderNo: row.order_no,
product: row.product_code,
name: row.product_snapshot?.name ?? row.product_code,
price: row.money_cents,
status: row.status,
grantStatus: row.grant_status,
createdAt: toIso(row.created_at),
paidAt: row.paid_at ? toIso(row.paid_at) : null,
}));
}
+46
View File
@@ -0,0 +1,46 @@
export type RedeemErrorCode =
| "invalid_code"
| "expired_code"
| "already_redeemed"
| "account_not_eligible"
| "rate_limited"
| "system_error";
export type RedeemErrorResponse = {
status: number;
message: string;
code: RedeemErrorCode;
};
/**
* Maps a redeem_code RPC error_code to a stable API error contract.
* revoked_code is normalized to invalid_code (the stable code set has no
* revoked variant). Unknown or system-side error codes collapse to
* system_error. account_not_eligible is kept as a mapping only: there is no
* account-eligibility restriction model, so no check is fabricated.
*/
export function redeemErrorResponse(errorCode: string): RedeemErrorResponse {
switch (errorCode) {
case "expired_code":
return { status: 410, message: "兑换码已过期", code: "expired_code" };
case "already_redeemed":
return { status: 409, message: "兑换码已被使用", code: "already_redeemed" };
case "account_not_eligible":
return { status: 403, message: "该兑换码不适用于当前账户", code: "account_not_eligible" };
case "rate_limited":
return { status: 429, message: "请求过于频繁", code: "rate_limited" };
case "revoked_code":
return { status: 404, message: "兑换码已撤销", code: "invalid_code" };
case "invalid_code":
return { status: 404, message: "兑换码不存在", code: "invalid_code" };
case "profile_missing":
return { status: 500, message: "系统异常,请稍后重试", code: "system_error" };
default:
return { status: 500, message: "系统异常,请稍后重试", code: "system_error" };
}
}
export function redeemInputErrorResponse(): RedeemErrorResponse {
return { status: 400, message: "请输入有效兑换码", code: "invalid_code" };
}
@@ -0,0 +1,131 @@
begin;
-- Account-level, cross-instance rate limiting for consecutive failed
-- redemption attempts. The audit table records only who failed and when;
-- neither the plaintext code nor its hash is ever stored here. It is
-- failure-only: a successful redemption deletes the account's rows, so no
-- permanent success row is kept.
create table if not exists public.redemption_attempts (
id bigint generated always as identity primary key,
user_id uuid not null references auth.users(id) on delete cascade,
created_at timestamptz not null default now()
);
create index if not exists redemption_attempts_user_created_idx
on public.redemption_attempts (user_id, created_at desc);
alter table public.redemption_attempts enable row level security;
revoke all on table public.redemption_attempts from anon, authenticated;
grant select on table public.redemption_attempts to service_role;
-- CREATE OR REPLACE cannot change a function's return type, so the previous
-- 3-column redeem_code(text) is dropped and rebuilt inside the same
-- transaction, then its revoke/grant ACLs are restored below.
drop function if exists public.redeem_code(text);
-- redeem_code keeps the redemption-code row lock and the credit_transactions
-- unique constraint. It additionally serializes per-account attempts, counts
-- business failures within a rolling 10-minute window, and returns
-- rate_limited once an account reaches 5 failures. Every business failure
-- writes an audit row; a successful redemption deletes the account's
-- attempts. account_not_eligible is intentionally not fabricated here: there
-- is no account-eligibility restriction model yet. The audit table's
-- identity sequence needs no authenticated USAGE grant because the
-- security-definer function runs as its owner.
create or replace function public.redeem_code(p_code_hash text)
returns table (success boolean, credits integer, awarded_credits integer, error_code text)
language plpgsql
security definer
set search_path = public, pg_temp
as $$
declare
v_user_id uuid := auth.uid();
v_email text := auth.jwt() ->> 'email';
v_code public.redemption_codes%rowtype;
v_balance integer;
v_failed integer;
begin
if v_user_id is null then
return query select false, null::integer, null::integer, 'unauthorized'::text;
return;
end if;
perform pg_advisory_xact_lock(hashtextextended('redeem:' || v_user_id::text, 0));
delete from public.redemption_attempts
where user_id = v_user_id and created_at < now() - interval '10 minutes';
select count(*) into v_failed
from public.redemption_attempts
where user_id = v_user_id;
if v_failed >= 5 then
return query select false, null::integer, null::integer, 'rate_limited'::text;
return;
end if;
if p_code_hash is null or p_code_hash !~ '^[0-9a-f]{64}$' then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'invalid_code'::text;
return;
end if;
select rc.* into v_code
from public.redemption_codes rc
where rc.code_hash = p_code_hash
for update;
if not found then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'invalid_code'::text;
return;
end if;
if v_code.redeemed_by is not null then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'already_redeemed'::text;
return;
end if;
if v_code.revoked_at is not null then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'revoked_code'::text;
return;
end if;
if v_code.expires_at is not null and v_code.expires_at <= now() then
insert into public.redemption_attempts (user_id) values (v_user_id);
return query select false, null::integer, null::integer, 'expired_code'::text;
return;
end if;
select p.credits into v_balance
from public.profiles p
where p.id = v_user_id
for update;
if not found then
return query select false, null::integer, null::integer, 'profile_missing'::text;
return;
end if;
update public.redemption_codes rc
set redeemed_by = v_user_id, redeemed_email = v_email, redeemed_at = now()
where rc.id = v_code.id;
update public.profiles p
set credits = p.credits + v_code.credits, updated_at = now()
where p.id = v_user_id
returning p.credits into v_balance;
insert into public.credit_transactions (
user_id, transaction_type, amount, balance_after, request_id, redemption_code_id
) values (
v_user_id, 'redeem', v_code.credits, v_balance, v_code.id::text, v_code.id
);
delete from public.redemption_attempts where user_id = v_user_id;
return query select true, v_balance, v_code.credits, null::text;
end;
$$;
revoke all on function public.redeem_code(text) from public, anon;
grant execute on function public.redeem_code(text) to authenticated;
commit;
+1 -1
View File
@@ -83,7 +83,7 @@ test("admin code functions reject immutable codes, revoked redemption, and roll
{ id: userId, email: "admin@example.com" },
);
const redeemRevoked = await app.rpc("redeem_code", { p_code_hash: "a".repeat(64) });
assert.deepEqual(redeemRevoked.data, [{ success: false, credits: null, error_code: "revoked_code" }]);
assert.deepEqual(redeemRevoked.data, [{ success: false, credits: null, awarded_credits: null, error_code: "revoked_code" }]);
fixture.psql(`
insert into public.redemption_codes (id, code_hash, code_mask, credits, redeemed_by, redeemed_email, redeemed_at)
@@ -289,3 +289,17 @@ test("starter homepage stays editorial and hides technical chart parameters", ()
assert.match(styles, /\/\* Starter workbench \*\/[\s\S]*?\.starter-list \{[\s\S]*?grid-template-columns: minmax\(0, 1fr\);/);
assert.match(styles, /\.starter-hero,[\s\S]*?\.product-entrypoints,[\s\S]*?\.starter-themes \{[\s\S]*?width: 100%;/);
});
test("rectification 402 navigates to the membership page with the rectification source", () => {
// Given: the Agentic rectification chat owns the /api/rectification/agent call.
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
const membershipLib = readFileSync(new URL("../src/lib/membership.ts", import.meta.url), "utf8");
// Then: a 402 (insufficient credits) hands off to /membership with the
// rectification source instead of leaving a dead-end error.
assert.match(chat, /membershipHref\("rectification"\)/);
assert.match(chat, /if \(response\.status === 402\) \{[\s\S]*window\.location\.assign\(membershipHref\("rectification"\)\)/);
assert.doesNotMatch(chat, /咨询点数不足/);
assert.doesNotMatch(chat, /setError\(`咨询点数不足/);
assert.match(membershipLib, /rectification: "生时校正需要点数或会员权益"/);
});
@@ -144,6 +144,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
"pricing_experiment_events",
"product_entitlements",
"profiles",
"redemption_attempts",
"redemption_codes",
"synastry_reports",
"usage_events",
@@ -380,7 +381,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic
p_code_hash: "a".repeat(64),
});
assert.equal(redeemed.error, null);
assert.deepEqual(redeemed.data, [{ success: true, credits: 3, error_code: null }]);
assert.deepEqual(redeemed.data, [{ success: true, credits: 3, awarded_credits: 3, error_code: null }]);
} finally {
await closeLocalPostgresDataPools();
fixture.stop();
@@ -0,0 +1,283 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import test from "node:test";
import {
closeLocalPostgresDataPools,
createLocalPostgresDataClient,
} from "../src/lib/db/local-postgres-client-core.ts";
import { formatPaymentOrders, PAYMENT_ORDERS_SELECT } from "../src/lib/payment-orders.ts";
import { startPostgresFixture } from "./helpers/postgres-fixture.ts";
const runnerPath = fileURLToPath(
new URL("../scripts/db-migrate.mjs", import.meta.url),
);
function hash(code: string): string {
return createHash("sha256").update(code, "utf8").digest("hex");
}
test("redeem security: case-sensitive hashing, rate limiting, idempotency and order ownership", async () => {
const fixture = startPostgresFixture();
try {
const migration = spawnSync(process.execPath, [runnerPath], {
encoding: "utf8",
env: {
...process.env,
SCHEMA_DATABASE_URL: fixture.connectionUrl(
"schema_owner",
"schema-owner-test-password",
),
},
});
assert.equal(migration.status, 0, migration.stderr);
assert.match(migration.stdout, /applied 20260807020000_redeem_security\.sql/);
assert.match(migration.stdout, /applied 20260807030000_redeem_security\.sql/);
fixture.psqlAs("identity_runtime", "identity-runtime-test-password", `
insert into identity.users (name, email, email_verified, email_verified_at)
values ('User A', 'redeem-a@example.com', true, now()),
('User B', 'redeem-b@example.com', true, now())
`);
const userA = fixture.psql(
"select id from identity.users where email = 'redeem-a@example.com'",
);
const userB = fixture.psql(
"select id from identity.users where email = 'redeem-b@example.com'",
);
const validCodeA = "JYOTISH-TEST-ABCD";
const validCodeB = "JYOTISH-TEST-WXYZ";
fixture.psql(`
insert into public.redemption_codes (code_hash, code_mask, credits)
values ('${hash(validCodeA)}', 'JYOTISH-****-ABCD', 3),
('${hash(validCodeB)}', 'JYOTISH-****-WXYZ', 5)
`);
const local = createLocalPostgresDataClient(
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
{ id: userA, email: "redeem-a@example.com" },
);
// 1) Case-sensitive matching: the lowercase hash must not match.
const lowercase = await local.rpc("redeem_code", {
p_code_hash: hash("jyotish-test-abcd"),
});
assert.deepEqual(lowercase.data, [
{ success: false, credits: null, awarded_credits: null, error_code: "invalid_code" },
]);
// 2) The exact uppercase hash redeems: awarded credits and balance delta.
const redeemed = await local.rpc("redeem_code", {
p_code_hash: hash(validCodeA),
});
assert.deepEqual(redeemed.data, [
{ success: true, credits: 3, awarded_credits: 3, error_code: null },
]);
// A successful redemption deletes all of the account's attempts; no
// permanent success row is kept.
assert.equal(
fixture.psql(
`select count(*) from public.redemption_attempts where user_id='${userA}'`,
),
"0",
);
// 3) Idempotency: reusing the same code is already_redeemed and never
// double-credits (redemption row lock + credit_transactions unique).
const again = await local.rpc("redeem_code", {
p_code_hash: hash(validCodeA),
});
assert.deepEqual(again.data, [
{ success: false, credits: null, awarded_credits: null, error_code: "already_redeemed" },
]);
assert.equal(
fixture.psql(
`select count(*) from public.credit_transactions where user_id='${userA}' and transaction_type='redeem'`,
),
"1",
);
assert.equal(
fixture.psql(`select credits from public.profiles where id='${userA}'`),
"3",
);
// 4) Rate limiting: the 5th failure is recorded, the 6th attempt is
// blocked even with a valid unused code, and nothing is credited.
for (let index = 0; index < 4; index += 1) {
const failed = await local.rpc("redeem_code", {
p_code_hash: hash(`NO-SUCH-${index}`),
});
assert.deepEqual(failed.data, [
{ success: false, credits: null, awarded_credits: null, error_code: "invalid_code" },
]);
}
assert.equal(
fixture.psql(
`select count(*) from public.redemption_attempts where user_id='${userA}'`,
),
"5",
);
const blocked = await local.rpc("redeem_code", {
p_code_hash: hash(validCodeB),
});
assert.deepEqual(blocked.data, [
{ success: false, credits: null, awarded_credits: null, error_code: "rate_limited" },
]);
assert.equal(
fixture.psql(`select credits from public.profiles where id='${userA}'`),
"3",
);
// rate_limited itself is not a new business failure.
assert.equal(
fixture.psql(
`select count(*) from public.redemption_attempts where user_id='${userA}'`,
),
"5",
);
// 5) The window is rolling: aging failures past 10 minutes unblocks.
fixture.psql(
`update public.redemption_attempts set created_at = now() - interval '11 minutes' where user_id='${userA}'`,
);
const afterWindow = await local.rpc("redeem_code", {
p_code_hash: hash(validCodeB),
});
assert.deepEqual(afterWindow.data, [
{ success: true, credits: 8, awarded_credits: 5, error_code: null },
]);
assert.equal(
fixture.psql(
`select count(*) from public.redemption_attempts where user_id='${userA}'`,
),
"0",
);
// 6) Per-account isolation: user B is unaffected by user A's failures.
const userBClient = createLocalPostgresDataClient(
fixture.connectionUrl("app_runtime", "app-runtime-test-password"),
{ id: userB, email: "redeem-b@example.com" },
);
for (let index = 0; index < 2; index += 1) {
const bAttempt = await userBClient.rpc("redeem_code", {
p_code_hash: "f".repeat(64),
});
assert.equal(
(bAttempt.data as Array<{ error_code: string }>)[0]?.error_code,
"invalid_code",
);
}
assert.equal(
fixture.psql(
`select count(*) from public.redemption_attempts where user_id='${userB}'`,
),
"2",
);
// 7) Orders: the route's exact query returns only the current user's
// recent orders, most recent first.
fixture.psql(`
insert into public.payment_orders (
order_no, user_id, money_cents, product_code, product_snapshot,
status, grant_status, created_at, paid_at
) values
('${"JY" + "A1".padEnd(14, "0")}', '${userA}', 9900, 'standard_monthly', '{"name":"标准月卡"}'::jsonb, 'paid', 'granted', now() - interval '1 day', now() - interval '1 day'),
('${"JY" + "A2".padEnd(14, "0")}', '${userA}', 990, 'trial_7d', '{"name":"体验卡"}'::jsonb, 'paid', 'granted', now(), now()),
('${"JY" + "B1".padEnd(14, "0")}', '${userB}', 9900, 'standard_monthly', '{"name":"标准月卡"}'::jsonb, 'paid', 'granted', now(), now())
`);
const orders = await local
.from("payment_orders")
.select(PAYMENT_ORDERS_SELECT.join(","))
.eq("user_id", userA)
.order("created_at", { ascending: false })
.limit(20);
assert.equal(orders.error, null);
const orderRows = orders.data as Array<{ order_no: string }>;
assert.deepEqual(
orderRows.map((row) => row.order_no),
["JY" + "A2".padEnd(14, "0"), "JY" + "A1".padEnd(14, "0")],
);
assert.deepEqual(
formatPaymentOrders(orders.data as never).map((order) => order.orderNo),
["JY" + "A2".padEnd(14, "0"), "JY" + "A1".padEnd(14, "0")],
);
// RLS still filters direct selects to the authenticated account.
assert.equal(
fixture.psqlAs(
"app_runtime",
"app-runtime-test-password",
`set role authenticated;
select set_config('request.jwt.claim.sub', '${userA}', true);
select count(*) from public.payment_orders`,
),
`SET\n${userA}\n2`,
);
// 8) The audit table stores no code material and has least privilege.
assert.equal(
fixture.psql(`
select string_agg(column_name, ',' order by ordinal_position)
from information_schema.columns
where table_schema = 'public' and table_name = 'redemption_attempts'
`),
"id,user_id,created_at",
);
assert.equal(
fixture.psql(
`select relrowsecurity from pg_class where relname = 'redemption_attempts'`,
),
"t",
);
assert.equal(
fixture.psql(
`select has_table_privilege('authenticated', 'public.redemption_attempts', 'select')`,
),
"f",
);
assert.equal(
fixture.psql(
`select has_table_privilege('authenticated', 'public.redemption_attempts', 'insert')`,
),
"f",
);
assert.equal(
fixture.psql(
`select has_table_privilege('anon', 'public.redemption_attempts', 'select')`,
),
"f",
);
assert.equal(
fixture.psql(
`select has_table_privilege('service_role', 'public.redemption_attempts', 'select')`,
),
"t",
);
// The identity sequence needs no authenticated USAGE grant: inserts flow
// through the security-definer redeem_code which runs as its owner. The
// count assertions above already prove the definer can insert.
assert.equal(
fixture.psql(
`select has_sequence_privilege('authenticated', 'public.redemption_attempts_id_seq', 'USAGE')`,
),
"f",
);
assert.equal(
fixture.psql(
`select has_function_privilege('authenticated', 'public.redeem_code(text)', 'execute')`,
),
"t",
);
assert.equal(
fixture.psql(
`select has_function_privilege('anon', 'public.redeem_code(text)', 'execute')`,
),
"f",
);
} finally {
await closeLocalPostgresDataPools();
fixture.stop();
}
});
+13 -8
View File
@@ -18,6 +18,7 @@ const packagesRoute = readFileSync(new URL("src/app/api/payment/packages/route.t
const testRoute = readFileSync(new URL("src/app/api/admin/epay-settings/test/route.ts", root), "utf8");
const gatewayPolicy = readFileSync(new URL("src/lib/epay/gateway-policy.ts", root), "utf8");
const page = readFileSync(new URL("src/app/page.tsx", root), "utf8");
const membershipPage = readFileSync(new URL("src/app/membership/page.tsx", root), "utf8");
const migration = readFileSync(new URL("supabase/migrations/20260805020000_reconcile_payment_admin_schema.sql", root), "utf8");
const key = crypto.randomBytes(32).toString("base64");
@@ -166,7 +167,7 @@ test("统一支付页面含默认折叠的 Z-Pay 渠道配置与永不预填的
assert.doesNotMatch(management, /value=\{.*(?:key|secret)/i);
});
test("支付开关贯通迁移、公共套餐 API、创建订单和对话页", () => {
test("支付开关贯通迁移、公共套餐 API、创建订单和会员页", () => {
assert.match(migration, /chat_enabled boolean not null default false/);
assert.match(migration, /p_chat_enabled boolean/);
assert.match(migration, /'chatEnabled'/);
@@ -180,8 +181,11 @@ test("支付开关贯通迁移、公共套餐 API、创建订单和对话页", (
assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("await readEpayConfig()"));
assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("billing_products"));
assert.match(management, /在对话页开放支付/);
assert.match(page, /paymentEnabled && <div className="payment-packages">/);
assert.match(page, /setPaymentEnabled\(false\)[\s\S]*setPaymentPackages\(\[\]\)[\s\S]*setPaymentOrder\(null\)[\s\S]*setPaymentError\(""\)/);
assert.match(membershipPage, /fetch\("\/api\/payment\/packages"/);
assert.match(membershipPage, /payload\?\..*enabled === true/);
assert.match(membershipPage, /setPaymentEnabled\(true\)/);
assert.match(membershipPage, /setPaymentEnabled\(false\)[\s\S]*setPaymentPackages\(\[\]\)[\s\S]*setPaymentOrder\(null\)[\s\S]*setPaymentError\(""\)/);
assert.doesNotMatch(page, /paymentEnabled|paymentPackages|paymentOrder|payingPackageId/);
});
test("创建订单返回已签名收银台 URL 且不服务端请求网关", () => {
@@ -194,11 +198,12 @@ test("创建订单返回已签名收银台 URL 且不服务端请求网关", ()
assert.doesNotMatch(createRoute, /fetch\(submitUrl|document\.createElement\("form"\)|submitUrl:|fields[, }]/);
assert.doesNotMatch(createRoute, /NextResponse\.json\([^\n]*config\.key|searchParams\.set\([^\n]*config\.key/);
assert.match(page, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/);
assert.match(page, /<h3>套餐与会员<\/h3>/);
assert.match(page, /立即支付/);
assert.match(page, /套餐支付暂时不可用,请稍后重试/);
assert.doesNotMatch(page, /document\.createElement\("form"\)|payload\.submitUrl|payload\.fields/);
assert.match(membershipPage, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/);
assert.match(membershipPage, /id="membership-plans-tab"/);
assert.match(membershipPage, /id="membership-credits-tab"/);
assert.match(membershipPage, /立即购买/);
assert.match(membershipPage, /套餐支付暂时不可用,请稍后重试/);
assert.doesNotMatch(membershipPage, /document\.createElement\("form"\)|payload\.submitUrl|payload\.fields/);
});
test("网关探测固定已验证公网 IP、保留 TLS hostname 且限制重定向与响应", () => {
+283
View File
@@ -0,0 +1,283 @@
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
const projectFile = (path: string) => new URL(`../${path}`, import.meta.url);
const readProjectFile = (path: string) => readFileSync(projectFile(path), "utf8");
const globalStyles = readProjectFile("src/app/globals.css");
const pageSource = readProjectFile("src/app/membership/page.tsx");
const membershipLib = readProjectFile("src/lib/membership.ts");
function cssBlock(selector: string) {
const start = globalStyles.indexOf(`${selector} {`);
assert.notEqual(start, -1, `missing CSS selector: ${selector}`);
const end = globalStyles.indexOf("}", start);
assert.notEqual(end, -1, `unterminated CSS selector: ${selector}`);
return globalStyles.slice(start, end);
}
test("membership page exists as a client component with a loading boundary", () => {
assert.equal(existsSync(projectFile("src/app/membership/page.tsx")), true);
assert.match(pageSource, /"use client"/);
assert.match(pageSource, /<Suspense/);
assert.match(pageSource, /membership-loading/);
});
test("membership page loads account, packages, orders and redeem endpoints", () => {
assert.match(pageSource, /fetch\("\/api\/account"/);
assert.match(pageSource, /fetch\("\/api\/payment\/packages"/);
assert.match(pageSource, /fetch\("\/api\/payment\/orders"/);
assert.match(pageSource, /fetch\("\/api\/payment\/epay\/create"/);
assert.match(pageSource, /fetch\(`\/api\/payment\/epay\/status\?orderNo=/);
assert.match(pageSource, /fetch\("\/api\/redeem"/);
});
test("creates orders with productId and opens the signed cashier URL safely", () => {
assert.match(pageSource, /JSON\.stringify\(\{ productId \}\)/);
assert.match(pageSource, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/);
assert.doesNotMatch(pageSource, /document\.createElement\("form"\)|payload\.submitUrl|payload\.fields/);
assert.match(pageSource, /if \(!paymentOrder \|\| paymentOrder\.status !== "pending"\) return;/);
});
test("terminal payment failures stop polling and offer re-pay with the same product", () => {
assert.match(pageSource, /\["failed", "closed", "cancelled"\]\.includes\(payload\.status\)/);
assert.match(pageSource, /payload\.grantStatus === "failed"/);
assert.match(pageSource, /status !== "pending"\) return;/);
assert.match(pageSource, /status: paid \? "paid" : failed \? "failed" : "pending"/);
assert.match(pageSource, /paymentOrder\.status === "failed"/);
assert.match(pageSource, /支付失败/);
assert.match(pageSource, /createPayment\(paymentOrder\.productId\)/);
assert.match(pageSource, /重新支付/);
});
test("failed status beats paid and non-terminal statuses keep polling", () => {
assert.match(pageSource, /const paid = payload\.status === "paid" && !failed;/);
assert.match(pageSource, /status: paid \? "paid" : failed \? "failed" : "pending"/);
assert.match(pageSource, /if \(paid\) \{/);
assert.doesNotMatch(pageSource, /status: paid \? "paid" : failed \? "failed" : payload\.status/);
assert.doesNotMatch(pageSource, /payload\.status === "paid" \? "paid"/);
});
test("polls order status and refreshes balance on paid", () => {
assert.match(pageSource, /const paid = payload\.status === "paid" && !failed;/);
assert.match(pageSource, /if \(paid\) \{/);
assert.match(pageSource, /status: paid \? "paid" : failed \? "failed" : "pending"/);
assert.match(pageSource, /fetchAccountData\(\)/);
assert.match(pageSource, /notifyBalanceChanged\(refreshed\?\.credits/);
assert.match(pageSource, /void fetchOrders\(\)/);
});
test("keeps the selected product and allows retry on payment failure", () => {
assert.match(pageSource, /setSelectedProductId\(productId\)/);
assert.match(pageSource, /setPaymentError\(caught instanceof Error \? caught\.message : "创建支付失败"\)/);
assert.match(pageSource, /finally \{\s*setPayingProductId\(null\);/);
assert.match(pageSource, /if \(payingProductId\) return;/);
assert.match(pageSource, /createPayment\(selectedProductId\)/);
assert.match(pageSource, /membership-payment-error/);
assert.doesNotMatch(pageSource, /setPayingProductId\(null\)[\s\S]{0,40}setPaymentOrder\(null\)/);
});
test("redeem opens from redeem=1 and removes the param without new history", () => {
assert.match(pageSource, /searchParams\.get\("redeem"\) === "1"/);
assert.match(pageSource, /window\.history\.replaceState/);
assert.match(pageSource, /url\.searchParams\.delete\("redeem"\)/);
assert.doesNotMatch(pageSource, /pushState/);
});
test("returning prefers history.back and keeps browser back semantics", () => {
assert.match(pageSource, /window\.history\.back\(\)/);
assert.match(pageSource, /window\.location\.assign\("\/"\)/);
assert.match(pageSource, /onClick=\{goBack\}/);
});
test("trims the redeem code without changing its case", () => {
assert.match(pageSource, /const code = redeemCode\.trim\(\);/);
assert.doesNotMatch(pageSource, /redeemCode\.(?:toUpperCase|toLowerCase)\(\)/);
assert.doesNotMatch(pageSource, /normalizeRedeemCode/);
assert.match(pageSource, /JSON\.stringify\(\{ code \}\)/);
});
test("redeem success reports awarded credits, latest balance and completes", () => {
assert.match(pageSource, /awardedCredits/);
assert.match(pageSource, /本次到账/);
assert.match(pageSource, /最新余额/);
assert.match(pageSource, /setRedeemDone\(true\)/);
assert.match(pageSource, /setRedeemCode\(""\)/);
assert.match(pageSource, /const refreshed = await fetchAccountData\(\);/);
assert.match(pageSource, /notifyBalanceChanged\(refreshed\?\.credits \?\? result\.credits\)/);
assert.match(pageSource, /完成/);
assert.match(pageSource, /onClick=\{closeRedeem\}/);
});
test("supports plan and source query hints", () => {
assert.match(pageSource, /searchParams\.get\("plan"\)/);
assert.match(pageSource, /searchParams\.get\("source"\)/);
assert.match(pageSource, /membershipSourceNotice\(source\)/);
assert.match(membershipLib, /"insufficient-credits": "余额不足,请先补充点数或开通会员后再发送问题"/);
assert.match(membershipLib, /rectification: "生时校正需要点数或会员权益"/);
});
test("membership section shows fixed trial/monthly/yearly plans with monthly recommended", () => {
assert.match(pageSource, /selectMembershipPlans\(paymentPackages\)/);
assert.match(pageSource, /planAlias\(product\)/);
assert.match(pageSource, /alias === "monthly"/);
assert.match(pageSource, /membership-plan-card--recommended/);
assert.match(membershipLib, /trial: "体验"/);
assert.match(membershipLib, /monthly: "月卡"/);
assert.match(membershipLib, /yearly: "年卡"/);
assert.match(pageSource, /会员套餐/);
assert.match(pageSource, /点数充值/);
assert.match(pageSource, /id="membership-plans-tab"/);
assert.match(pageSource, /id="membership-credits-tab"/);
assert.match(pageSource, /role="tablist"/);
assert.match(pageSource, /role="tab"/);
assert.match(pageSource, /role="tabpanel"/);
assert.match(pageSource, /aria-controls="membership-plans-panel"/);
assert.match(pageSource, /aria-labelledby="membership-plans-tab"/);
});
test("plan cards carry price, duration, audience, entitlements, purchase and rules", () => {
assert.match(pageSource, /formatPrice\(product\.priceCents, product\.currency\)/);
assert.match(pageSource, /formatProductDuration\(product\)/);
assert.match(pageSource, /productAudience\(product\)/);
assert.match(pageSource, /entitlementLabel\(entitlement\)/);
assert.match(pageSource, /<details className="membership-rules">/);
assert.match(pageSource, /<summary>详细规则<\/summary>/);
assert.match(pageSource, /className=\{recommended \? "button-primary membership-buy" : "button-secondary membership-buy"\}/);
});
test("renewal semantics map the active subscription to the matching plan", () => {
assert.match(pageSource, /const currentProductCode = activeSubscription\?\.status === "active"/);
assert.match(pageSource, /currentProductCode === product\.code/);
assert.match(pageSource, /isCurrent \? "续费" : product\.productType === "trial" \? "立即开通" : "立即购买"/);
assert.match(pageSource, /当前套餐/);
});
test("membership and credits render as switchable tab panels, membership first", () => {
assert.match(pageSource, /const \[activeSegment, setActiveSegment\] = useState<"membership" \| "credits">\("membership"\)/);
assert.match(pageSource, /setActiveSegment\("membership"\)/);
assert.match(pageSource, /setActiveSegment\("credits"\)/);
assert.match(pageSource, /activeSegment === "membership" && \(/);
assert.match(pageSource, /activeSegment === "credits" && \(/);
assert.match(pageSource, /const plans = selectMembershipPlans\(paymentPackages\);/);
assert.match(pageSource, /const creditPacks = paymentPackages\.filter\(\(product\) => product\.productType === "credit_pack"\);/);
assert.match(pageSource, /className="membership-plan-grid"/);
assert.match(pageSource, /className="membership-credit-grid"/);
assert.doesNotMatch(pageSource, /creditPacks\.map[\.\s\S]{0,120}membership-plan-card/);
});
test("plan param forces the membership segment and highlights the matching card", () => {
assert.match(pageSource, /const highlighted = alias === highlightedPlan;/);
assert.match(pageSource, /membership-plan-card--highlighted/);
assert.match(pageSource, /aria-current=\{highlighted \? "true" : undefined\}/);
assert.match(pageSource, /highlightedCardRef/);
assert.match(pageSource, /scrollIntoView\(\{ behavior: "smooth", block: "center" \}\)/);
assert.match(pageSource, /card\.focus\(\{ preventScroll: true \}\)/);
assert.match(pageSource, /已为你定位到/);
assert.doesNotMatch(pageSource, /setActiveSegment\(\"credits\"\)[\s\S]{0,80}highlightedPlan/);
});
test("page main title is 套餐与会员", () => {
assert.match(pageSource, /<h1 className="membership-title">套餐与会员<\/h1>/);
assert.doesNotMatch(pageSource, /<h1 className="membership-title">会员与点数<\/h1>/);
});
test("payment errors sit in a common position above the visible segment", () => {
const errorAt = pageSource.indexOf("membership-payment-error");
const plansPanelAt = pageSource.indexOf('activeSegment === "membership" && (');
assert.notEqual(errorAt, -1, "missing common payment error surface");
assert.notEqual(plansPanelAt, -1, "missing plans panel");
assert.ok(errorAt < plansPanelAt, "payment error must render before the segment panels");
assert.doesNotMatch(pageSource, /<p className="form-error" role="alert">\{paymentError\}<\/p>/);
});
test("summary shows member status and balance; orders section lists records", () => {
assert.match(pageSource, /会员状态/);
assert.match(pageSource, /当前余额/);
assert.match(pageSource, /membership-summary-card/);
assert.match(pageSource, /订单记录/);
assert.match(pageSource, /membership-order-row/);
assert.match(pageSource, /orderStatusLabel\(order\.status\)/);
assert.match(pageSource, /orderStatusLabel/);
assert.match(membershipLib, /paid: "已支付"/);
});
test("orbit / ring guidance visual is present in the hero", () => {
assert.match(pageSource, /membership-celestial-visual/);
assert.match(pageSource, /membership-orbit membership-orbit-outer/);
assert.match(pageSource, /membership-orbit membership-orbit-inner/);
assert.match(pageSource, /membership-celestial-mark/);
assert.match(cssBlock(".membership-hero"), /overflow:\s*hidden/);
});
test("redeem modal surface contains title, balance, input, redeem, rules and close", () => {
assert.match(pageSource, /id="membership-redeem-title"/);
assert.match(pageSource, /<h2 id="membership-redeem-title">兑换点数<\/h2>/);
assert.match(pageSource, /className="redeem-balance"/);
assert.match(pageSource, /id="membership-redeem-code"/);
assert.match(pageSource, /立即兑换/);
assert.match(pageSource, /<summary>兑换规则<\/summary>/);
assert.match(pageSource, /aria-label="关闭"/);
});
test("redeem modal reuses the existing account dialog classes", () => {
assert.match(pageSource, /className="account-modal-overlay"/);
assert.match(pageSource, /className="account-modal membership-redeem"/);
assert.match(pageSource, /className="account-modal-header"/);
assert.match(pageSource, /className="dialog-close"/);
assert.match(pageSource, /className="redeem-form"/);
assert.match(pageSource, /className="form-error" role="alert"/);
assert.match(pageSource, /className="form-success" role="status"/);
assert.match(pageSource, /className="button-primary"/);
});
test("desktop uses three columns, tablet two and mobile one with monthly first", () => {
assert.match(globalStyles, /\.membership-plan-grid,[\s\S]*\.membership-credit-grid\s*\{[^}]*grid-template-columns:\s*1fr/);
assert.match(globalStyles, /@media \(min-width:\s*640px\)[\s\S]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\)/);
assert.match(globalStyles, /@media \(min-width:\s*1024px\)[\s\S]*grid-template-columns:\s*repeat\(3, minmax\(0, 1fr\)\)/);
assert.match(globalStyles, /@media \(max-width:\s*639px\)[\s\S]*\.membership-plan-card--recommended\s*\{[^}]*order:\s*-1/);
assert.doesNotMatch(cssBlock(".membership-plan-card--recommended"), /grid-column/);
});
test("segment tabs and highlight styles are defined", () => {
assert.match(cssBlock(".membership-tabs"), /border-bottom:\s*1px solid var\(--color-border\)/);
assert.match(globalStyles, /\.membership-tab\[aria-selected="true"\][^{]*\{[^}]*color:\s*var\(--color-action\)/);
assert.match(cssBlock(".membership-plan-card--highlighted"), /outline:\s*2px solid color-mix\(in srgb, var\(--color-action\)/);
assert.match(cssBlock(".membership-payment-error"), /background:\s*var\(--color-danger-muted\)/);
});
test("membership shell is capped within the 1120-1200 range", () => {
assert.match(cssBlock(".membership-shell"), /width:\s*min\(1160px, 100%\)/);
assert.match(cssBlock(".membership-shell"), /margin:\s*0 auto/);
});
test("membership page reuses the warm parchment / terracotta tokens", () => {
assert.match(cssBlock(".membership-hero"), /linear-gradient\(135deg, var\(--color-canvas\) 0%, var\(--color-canvas-soft\) 100%\)/);
assert.doesNotMatch(cssBlock(".membership-hero"), /#fbfaf7|#f5f1eb/);
assert.match(cssBlock(".membership-plan-card--recommended"), /color-mix\(in srgb, var\(--color-action\)/);
assert.match(cssBlock(".membership-title"), /var\(--color-ink\)/);
assert.match(cssBlock(".membership-badge--recommended"), /background: var\(--color-action\)/);
});
test("membership page scrolls within a fixed viewport and the recommended card has a terracotta top bar", () => {
assert.match(cssBlock(".membership-page"), /height:\s*100dvh/);
assert.match(cssBlock(".membership-page"), /overflow-y:\s*auto/);
assert.match(globalStyles, /\.membership-plan-card,[\s\S]*\.membership-credit-card\s*\{[^}]*position:\s*relative/);
assert.match(globalStyles, /\.membership-plan-card,[\s\S]*\.membership-credit-card\s*\{[^}]*overflow:\s*hidden/);
assert.match(cssBlock(".membership-plan-card--recommended::before"), /background:\s*var\(--color-action\)/);
assert.match(cssBlock(".membership-plan-card--recommended"), /background:\s*color-mix\(in srgb, var\(--color-action-soft\)/);
assert.match(cssBlock(".membership-plan-card--recommended"), /border-color:\s*color-mix\(in srgb, var\(--color-action\)/);
assert.match(cssBlock(".membership-plan-card--recommended"), /box-shadow:/);
});
test("membership lib keeps helpers and the balance sync contract", () => {
assert.match(membershipLib, /export const BALANCE_SYNC_KEY = "jyotisha:balance:updated"/);
assert.match(membershipLib, /export const BALANCE_CHANGED_EVENT = "jyotisha:balance:changed"/);
assert.match(membershipLib, /export function notifyBalanceChanged\(credits: number\)/);
assert.match(membershipLib, /localStorage\.setItem\(BALANCE_SYNC_KEY/);
assert.match(membershipLib, /new CustomEvent\(BALANCE_CHANGED_EVENT/);
assert.match(membershipLib, /export function formatPrice\(priceCents: number, currency = "CNY"\)/);
assert.match(membershipLib, /export function orderStatusLabel/);
assert.match(membershipLib, /export function planDisplayLabel/);
assert.match(membershipLib, /export function membershipHref\(source: string, options: \{ redeem\?: boolean; plan\?: MembershipPlanAlias \} = \{\}\)/);
});
@@ -0,0 +1,205 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import {
formatPaymentOrders,
PAYMENT_ORDERS_SELECT,
} from "../src/lib/payment-orders.ts";
import {
redeemErrorResponse,
redeemInputErrorResponse,
} from "../src/lib/redeem-response.ts";
const redeemRoute = readFileSync(
new URL("../src/app/api/redeem/route.ts", import.meta.url),
"utf8",
);
const ordersRoute = readFileSync(
new URL("../src/app/api/payment/orders/route.ts", import.meta.url),
"utf8",
);
const dbMigration = readFileSync(
new URL("../db/migrations/20260807020000_redeem_security.sql", import.meta.url),
"utf8",
);
const supabaseMigration = readFileSync(
new URL("../supabase/migrations/20260807030000_redeem_security.sql", import.meta.url),
"utf8",
);
test("redeem API trims surrounding whitespace only, never changes case, and never pre-validates format", () => {
assert.match(redeemRoute, /\.trim\(\)/);
assert.doesNotMatch(redeemRoute, /normalizeRedeemCode|\.toUpperCase\(\)/);
// No client-side format short-circuit: lowercase or malformed non-empty
// values must reach the RPC so they count against the failure rate limit.
assert.doesNotMatch(redeemRoute, /JYOTISH-\[A-Z0-9\]|codeFormat/);
assert.match(redeemRoute, /if \(!code\)/);
// The trimmed input is hashed as-is so a lowercase attempt cannot match.
assert.match(redeemRoute, /hashRedeemCode\(code\)/);
});
test("redeem API returns stable error codes with the 429 rate-limit copy", () => {
assert.deepEqual(redeemErrorResponse("rate_limited"), {
status: 429,
message: "请求过于频繁",
code: "rate_limited",
});
assert.deepEqual(redeemErrorResponse("invalid_code"), {
status: 404,
message: "兑换码不存在",
code: "invalid_code",
});
assert.deepEqual(redeemErrorResponse("revoked_code"), {
status: 404,
message: "兑换码已撤销",
code: "invalid_code",
});
assert.deepEqual(redeemErrorResponse("expired_code"), {
status: 410,
message: "兑换码已过期",
code: "expired_code",
});
assert.deepEqual(redeemErrorResponse("already_redeemed"), {
status: 409,
message: "兑换码已被使用",
code: "already_redeemed",
});
assert.deepEqual(redeemErrorResponse("account_not_eligible"), {
status: 403,
message: "该兑换码不适用于当前账户",
code: "account_not_eligible",
});
assert.deepEqual(redeemErrorResponse("profile_missing"), {
status: 500,
message: "系统异常,请稍后重试",
code: "system_error",
});
assert.deepEqual(redeemErrorResponse("unknown_future_code"), {
status: 500,
message: "系统异常,请稍后重试",
code: "system_error",
});
assert.deepEqual(redeemInputErrorResponse(), {
status: 400,
message: "请输入有效兑换码",
code: "invalid_code",
});
assert.match(redeemRoute, /redeemErrorResponse\(/);
assert.match(redeemRoute, /code: mapped\.code/);
assert.match(redeemRoute, /status: 401/);
});
test("redeem API success returns awardedCredits and the latest balance", () => {
assert.match(redeemRoute, /awardedCredits: result\.awarded_credits/);
assert.match(redeemRoute, /credits: result\.credits/);
assert.match(redeemRoute, /result\??\.error_code/);
});
test("payment orders API is authenticated, owner-filtered and limited", () => {
assert.match(ordersRoute, /请先登录/);
assert.match(ordersRoute, /status: 401/);
assert.match(ordersRoute, /\.eq\("user_id", user\.id\)/);
assert.match(ordersRoute, /\.order\("created_at", \{ ascending: false \}\)/);
assert.match(ordersRoute, /\.limit\(MAX_ORDERS\)/);
assert.doesNotMatch(ordersRoute, /epay_trade_no|raw_notify_payload_hash/);
});
test("payment orders summary is desensitized to the allowlist", () => {
assert.deepEqual(PAYMENT_ORDERS_SELECT, [
"order_no",
"product_code",
"product_snapshot",
"money_cents",
"status",
"grant_status",
"created_at",
"paid_at",
]);
assert.doesNotMatch(
PAYMENT_ORDERS_SELECT.join(","),
/user_id|epay_trade_no|raw_notify|entitlement/,
);
assert.deepEqual(
formatPaymentOrders([
{
order_no: "JY-ONE",
product_code: "standard_monthly",
product_snapshot: { name: "标准月卡" },
money_cents: 9900,
status: "paid",
grant_status: "granted",
created_at: new Date("2026-08-07T00:00:00.000Z"),
paid_at: new Date("2026-08-07T00:05:00.000Z"),
},
{
order_no: "JY-TWO",
product_code: null,
product_snapshot: null,
money_cents: 990,
status: "pending",
grant_status: "pending",
created_at: "2026-08-06T00:00:00.000Z",
paid_at: null,
},
]),
[
{
orderNo: "JY-ONE",
product: "standard_monthly",
name: "标准月卡",
price: 9900,
status: "paid",
grantStatus: "granted",
createdAt: "2026-08-07T00:00:00.000Z",
paidAt: "2026-08-07T00:05:00.000Z",
},
{
orderNo: "JY-TWO",
product: null,
name: null,
price: 990,
status: "pending",
grantStatus: "pending",
createdAt: "2026-08-06T00:00:00.000Z",
paidAt: null,
},
],
);
});
test("redeem security migration adds a hash-free failure-only audit table with least privilege", () => {
const tableBlock = dbMigration.match(
/create table if not exists public\.redemption_attempts[\s\S]*?\);/,
);
assert.ok(tableBlock, "redemption_attempts table must be created");
assert.match(tableBlock[0], /user_id uuid not null references auth\.users\(id\) on delete cascade/);
assert.match(tableBlock[0], /created_at timestamptz not null default now\(\)/);
assert.match(tableBlock[0], /generated always as identity/);
// Failure-only: no success column, no plaintext code and no code hash.
assert.doesNotMatch(tableBlock[0], /success|code|hash|mask/i);
assert.match(dbMigration, /drop function if exists public\.redeem_code\(text\);/);
assert.match(dbMigration, /alter table public\.redemption_attempts enable row level security/);
assert.match(dbMigration, /revoke all on table public\.redemption_attempts from anon, authenticated/);
assert.match(dbMigration, /grant select on table public\.redemption_attempts to service_role/);
assert.match(dbMigration, /pg_advisory_xact_lock/);
assert.match(dbMigration, /interval '10 minutes'/);
assert.match(dbMigration, /v_failed >= 5/);
// Business failures insert a single-column audit row; success deletes all
// of the account's attempts instead of inserting a permanent success row.
assert.match(dbMigration, /insert into public\.redemption_attempts \(user_id\) values \(v_user_id\)/);
assert.match(dbMigration, /delete from public\.redemption_attempts where user_id = v_user_id;/);
// The redemption-code row lock is kept and the credit_transactions
// unique constraint is preserved untouched (nothing is dropped).
assert.match(dbMigration, /from public\.redemption_codes rc\s+where rc\.code_hash = p_code_hash\s+for update/);
assert.doesNotMatch(dbMigration, /alter table public\.credit_transactions/);
assert.match(dbMigration, /grant execute on function public\.redeem_code\(text\) to authenticated/);
assert.match(dbMigration, /revoke all on function public\.redeem_code\(text\) from public, anon/);
});
test("supabase compatibility migration is byte-identical to the primary migration", () => {
assert.equal(supabaseMigration, dbMigration);
assert.match(supabaseMigration, /create table if not exists public\.redemption_attempts/);
});
+5 -6
View File
@@ -158,17 +158,16 @@ test("keeps app sidebar props as product data and callbacks", () => {
assert.doesNotMatch(appSidebar, /supabase|fetch\(|\/api\//i);
});
test("renders the server-provided admin entry beside the credit control", () => {
test("removes the user-facing admin entry and routes credit control to membership", () => {
const appSidebar = readProjectFile("src/components/app-sidebar.tsx");
const page = readProjectFile("src/app/page.tsx");
assert.doesNotMatch(appSidebar, /adminUrl|account\.isAdmin|后台管理|KeyRound/);
assert.doesNotMatch(page, /admin-button|ShieldCheck|后台管理/);
assert.doesNotMatch(page, /account\.isAdmin && account\.adminUrl/);
assert.match(page, /className="chat-header-actions"/);
assert.match(page, /account\.isAdmin && account\.adminUrl/);
assert.match(page, /className="admin-button" href=\{account\.adminUrl\}/);
assert.match(page, /aria-label="后台管理" title="后台管理"/);
assert.match(page, /<ShieldCheck aria-hidden="true" \/>/);
assert.match(page, /className="admin-button"[\s\S]*className="credit-button"/);
assert.match(page, /className="credit-button"/);
assert.match(page, /membershipHref\("credits"\)/);
});
test("composes the chat page with the app sidebar shell", () => {
+19 -7
View File
@@ -117,17 +117,29 @@ test("centers the credit value with its icon", () => {
test("routes account actions through a menu and focused dialogs", () => {
// Given: the account surface state and entry-point handlers.
// When: the page source is inspected for independent menu and dialog routes.
// Then: each account task has a focused destination instead of one combined sheet.
// Then: profile and logout stay focused dialogs; redeem leaves for /membership.
assert.match(pageSource, /const \[accountMenuOpen, setAccountMenuOpen\] = useState\(false\)/);
assert.match(pageSource, /const \[activeAccountDialog, setActiveAccountDialog\] = useState<AccountDialog \| null>\(null\)/);
assert.match(pageSource, /openAccountDialog\("profile"/);
assert.match(pageSource, /openAccountDialog\("redeem"/);
assert.match(pageSource, /openAccountDialog\("logout"/);
assert.doesNotMatch(pageSource, /type AccountDialog = "profile" \| "redeem" \| "logout"/);
assert.doesNotMatch(pageSource, /openAccountDialog\("redeem"/);
assert.match(appSidebarSource, /<Menu\.Popup className="account-menu-popup"/);
assert.doesNotMatch(appSidebarSource, /<Menu\.Popup className="account-menu"/);
assert.match(appSidebarSource, /<Menu\.Root open=\{accountMenuOpen\} onOpenChange=\{onAccountMenuOpenChange\} modal=\{false\}>/);
});
test("redeem and balance entries navigate to the membership page with sources", () => {
// Given: the account menu, credit control and insufficient-balance paths.
// Then: every redeem/purchase path lands on /membership with a source hint.
assert.match(pageSource, /membershipHref\("account-menu", \{ redeem: true \}\)/);
assert.match(pageSource, /membershipHref\("credits"\)/);
assert.match(pageSource, /membershipHref\("insufficient-credits"\)/);
assert.doesNotMatch(pageSource, /activeAccountDialog === "redeem"/);
assert.doesNotMatch(pageSource, /paymentEnabled|paymentPackages|paymentOrder|payingPackageId/);
assert.doesNotMatch(pageSource, /redeemCode|redeeming|redeemError|redeemMessage/);
});
test("removes the monolithic account sheet", () => {
// Given: the former sheet implementation names.
// When: the page and global styles are inspected.
@@ -136,13 +148,13 @@ test("removes the monolithic account sheet", () => {
assert.doesNotMatch(globalStyles, /\.profile-overlay|\.profile-dialog/);
});
test("keeps admin navigation separate from account task dialogs", () => {
test("removes the user-facing admin button from the chat page", () => {
// Given: the administrator-only route and the new account menu.
// When: their source relationship is inspected.
// Then: code management stays a guarded navigation action rather than a modal.
// Then: code management is no longer surfaced to users on the chat page.
assert.doesNotMatch(appSidebarSource, /adminUrl|account\.isAdmin|后台管理/);
assert.match(pageSource, /account\.isAdmin && account\.adminUrl/);
assert.match(pageSource, /className="admin-button" href=\{account\.adminUrl\}/);
assert.doesNotMatch(pageSource, /account\.isAdmin && account\.adminUrl/);
assert.doesNotMatch(pageSource, /className="admin-button"/);
assert.doesNotMatch(pageSource, /ShieldCheck/);
});
test("keeps the empty starter home at the top instead of auto-scrolling", () => {