feat(membership): replace purchase modal with membership page
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 "系统异常,请稍后重试";
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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 且限制重定向与响应", () => {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user