Merge pull request #52 from ChurchTao/codex/afadian

Optimize display effects and stamp functionality for功德簿
This commit is contained in:
桃树夭 authored and GitHub committed 2026-08-17 19:32:58 +08:00
commit 75ce3dc334
54 files changed
+12627 -83

No files matched your search

+7
View File
@@ -58,6 +58,13 @@ SPONSORKIT_AFDIAN_USER_ID=
SPONSORKIT_AFDIAN_TOKEN=
SPONSORKIT_AFDIAN_EXCHANGE_RATE=6.5
# In-game 功德簿 / Afdian Webhook
# Set provider to afdian only after the migration, Redis and NATS are ready.
SPONSORSHIP_PROVIDER=disabled
SPONSORSHIP_FULFILLMENT_ENABLED=false
AFDIAN_USER_ID=
AFDIAN_TOKEN=
# Frontend public build variables
# Configure these in GitHub Actions Environment/Repository vars, not in the
# server runtime env file passed to `docker run --env-file`.
+32
View File
@@ -0,0 +1,32 @@
# 爱发电功德簿运行手册
## 能力边界
当前实现不依赖 OAuth2。爱发电 Webhook 负责低延迟通知,开放 API 负责二次核验、定时补单与私信认领码。游戏不会发放数值奖励,也不会根据爱发电昵称猜测游戏账号。
`SponsorshipProvider` 是编译期接口,内置 `afdian` 适配器;开源部署者可以替换适配器代码,但服务端不会加载任意运行时 JavaScript。四个档位、名称和主题固定,管理端只允许调整 `plan_id` 和按分计的最低金额。
档位解析遵循爱发电官方订单字段语义:非空 `plan_id` 必须命中管理端映射,固定方案即使发生折扣也按映射档位;只有官方定义的空 `plan_id` 自选金额订单才按 `total_amount` 命中金额门槛。未映射的非空方案不会降级成自选金额订单。
## 上线顺序
1. 执行主库 Drizzle migration,确认新增的 `wanjiedaoyou_sponsorship_*` 表和唯一索引存在。
2. 确认 Redis 与 NATS JetStream 健康;订单按 Redis 单订单锁串行,数据库唯一约束是最终幂等防线,不使用 PostgreSQL advisory/task lock。
3. 配置 `AFDIAN_USER_ID`、`AFDIAN_TOKEN`。
4. 先设置 `SPONSORSHIP_PROVIDER=afdian`、`SPONSORSHIP_FULFILLMENT_ENABLED=false` 启动应用,在 `/admin/sponsorship` 填写真实方案 ID 和金额并测试连接。首次保存有效方案时,服务端会自动固化“自动处理起始时间”;管理员不能改写它。
5. 在爱发电配置 Webhook 地址:`https://<API域名>/api/sponsorship/providers/afdian/webhook`。该入口限制 256 KiB,请勿经代理改写 JSON。
6. 观察签名校验、API 二次核验和 NATS consumer 正常后,将 `SPONSORSHIP_FULFILLMENT_ENABLED=true` 并重启。此前已核验订单会由十分钟补偿任务继续履约。
## 数据与任务
- 每个成功订单按 `provider + provider_order_id` 唯一;每个订单最多生成一条功德记录和一枚认领码。
- Webhook 和定时对账只会自动处理起始时间之后的订单;更早或缺少创建时间的订单不会生成认领码,历史订单继续由管理员手动发放。
- 原始 Webhook、查询和对账响应以 JSON 保存,默认两年清理;订单身份与金额字段同步脱敏。认领码保留哈希用于一次性校验,原码用于私信失败后的重试。
- NATS WorkQueue 每 10 分钟运行近期对账与失败补偿;每天运行深度对账和敏感数据清理。Bun cron 只发布命令,Redis job lock 防止多实例并发。
- Webhook、NATS 和 API 都可能重复到达;消费者、Redis 单订单锁、状态条件更新和数据库唯一约束共同保证幂等。
## 排障
管理端可查看订单校验/履约状态,重试订单、轮换并重发认领码、撤销错误留名、手动发放历史功德。原始快照只通过带管理员鉴权的审计接口查看,每次查看都会写入 `sponsorship_admin_actions`。
若爱发电 API、Redis 或 NATS 不可用,保持 `SPONSORSHIP_FULFILLMENT_ENABLED=false`;恢复后先执行 `/internal/cron/sponsorship-deep-reconcile`(Bearer `CRON_SECRET`),核对订单再重新开启自动履约。
+134
View File
@@ -0,0 +1,134 @@
CREATE TABLE "wanjiedaoyou_sponsorship_admin_actions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"admin_user_id" uuid NOT NULL,
"action" varchar(64) NOT NULL,
"order_id" uuid,
"metadata" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sponsorship_checkout_intents" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"provider" varchar(32) NOT NULL,
"user_id" uuid,
"cultivator_id" uuid,
"tier" varchar(32) NOT NULL,
"expected_plan_id" varchar(80),
"public_listing" boolean DEFAULT true NOT NULL,
"status" varchar(24) DEFAULT 'pending' NOT NULL,
"provider_order_id" varchar(80),
"config_snapshot" jsonb NOT NULL,
"expires_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sponsorship_claims" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"order_id" uuid NOT NULL,
"code_hash" varchar(64) NOT NULL,
"code" text NOT NULL,
"public_listing" boolean DEFAULT true NOT NULL,
"version" integer DEFAULT 1 NOT NULL,
"status" varchar(24) DEFAULT 'active' NOT NULL,
"expires_at" timestamp NOT NULL,
"cultivator_id" uuid,
"claimed_at" timestamp,
"message_status" varchar(24) DEFAULT 'pending' NOT NULL,
"message_attempts" integer DEFAULT 0 NOT NULL,
"last_message_error" text,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sponsorship_merit_profiles" (
"cultivator_id" uuid PRIMARY KEY NOT NULL,
"is_public" boolean DEFAULT true NOT NULL,
"highest_tier" varchar(32) NOT NULL,
"merit_count" integer DEFAULT 0 NOT NULL,
"first_supported_at" timestamp NOT NULL,
"last_supported_at" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sponsorship_merit_records" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"order_id" uuid,
"cultivator_id" uuid NOT NULL,
"tier" varchar(32) NOT NULL,
"source" varchar(32) NOT NULL,
"supported_at" timestamp NOT NULL,
"mail_id" uuid,
"created_by" uuid,
"revoked_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sponsorship_order_snapshots" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"order_id" uuid NOT NULL,
"source" varchar(24) NOT NULL,
"payload" jsonb NOT NULL,
"purge_after" timestamp NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "wanjiedaoyou_sponsorship_orders" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"provider" varchar(32) NOT NULL,
"provider_order_id" varchar(80) NOT NULL,
"custom_order_id" varchar(128),
"provider_user_id" varchar(80),
"plan_id" varchar(80),
"sku_id" varchar(80),
"product_type" integer,
"total_amount_fen" integer,
"show_amount_fen" integer,
"month" integer,
"provider_status" integer,
"provider_created_at" timestamp,
"verification_status" varchar(24) DEFAULT 'received' NOT NULL,
"fulfillment_status" varchar(24) DEFAULT 'pending' NOT NULL,
"resolved_tier" varchar(32),
"checkout_intent_id" uuid,
"config_snapshot" jsonb,
"retry_count" integer DEFAULT 0 NOT NULL,
"last_error_code" varchar(80),
"last_error_message" text,
"signature_verified_at" timestamp,
"verified_at" timestamp,
"fulfilled_at" timestamp,
"revoked_at" timestamp,
"sensitive_purged_at" timestamp,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sponsorship_admin_actions" ADD CONSTRAINT "wanjiedaoyou_sponsorship_admin_actions_order_id_wanjiedaoyou_sponsorship_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."wanjiedaoyou_sponsorship_orders"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sponsorship_checkout_intents" ADD CONSTRAINT "wanjiedaoyou_sponsorship_checkout_intents_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sponsorship_claims" ADD CONSTRAINT "wanjiedaoyou_sponsorship_claims_order_id_wanjiedaoyou_sponsorship_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."wanjiedaoyou_sponsorship_orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sponsorship_claims" ADD CONSTRAINT "wanjiedaoyou_sponsorship_claims_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sponsorship_merit_profiles" ADD CONSTRAINT "wanjiedaoyou_sponsorship_merit_profiles_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sponsorship_merit_records" ADD CONSTRAINT "wanjiedaoyou_sponsorship_merit_records_order_id_wanjiedaoyou_sponsorship_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."wanjiedaoyou_sponsorship_orders"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sponsorship_merit_records" ADD CONSTRAINT "wanjiedaoyou_sponsorship_merit_records_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sponsorship_merit_records" ADD CONSTRAINT "wanjiedaoyou_sponsorship_merit_records_mail_id_wanjiedaoyou_mails_id_fk" FOREIGN KEY ("mail_id") REFERENCES "public"."wanjiedaoyou_mails"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sponsorship_order_snapshots" ADD CONSTRAINT "wanjiedaoyou_sponsorship_order_snapshots_order_id_wanjiedaoyou_sponsorship_orders_id_fk" FOREIGN KEY ("order_id") REFERENCES "public"."wanjiedaoyou_sponsorship_orders"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "wanjiedaoyou_sponsorship_orders" ADD CONSTRAINT "wanjiedaoyou_sponsorship_orders_checkout_intent_id_wanjiedaoyou_sponsorship_checkout_intents_id_fk" FOREIGN KEY ("checkout_intent_id") REFERENCES "public"."wanjiedaoyou_sponsorship_checkout_intents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "sponsorship_admin_actions_admin_created_idx" ON "wanjiedaoyou_sponsorship_admin_actions" USING btree ("admin_user_id","created_at");--> statement-breakpoint
CREATE INDEX "sponsorship_admin_actions_order_created_idx" ON "wanjiedaoyou_sponsorship_admin_actions" USING btree ("order_id","created_at");--> statement-breakpoint
CREATE UNIQUE INDEX "sponsorship_checkout_provider_order_uidx" ON "wanjiedaoyou_sponsorship_checkout_intents" USING btree ("provider","provider_order_id");--> statement-breakpoint
CREATE INDEX "sponsorship_checkout_user_created_idx" ON "wanjiedaoyou_sponsorship_checkout_intents" USING btree ("user_id","created_at");--> statement-breakpoint
CREATE INDEX "sponsorship_checkout_status_expires_idx" ON "wanjiedaoyou_sponsorship_checkout_intents" USING btree ("status","expires_at");--> statement-breakpoint
CREATE UNIQUE INDEX "sponsorship_claims_order_uidx" ON "wanjiedaoyou_sponsorship_claims" USING btree ("order_id");--> statement-breakpoint
CREATE UNIQUE INDEX "sponsorship_claims_code_hash_uidx" ON "wanjiedaoyou_sponsorship_claims" USING btree ("code_hash");--> statement-breakpoint
CREATE INDEX "sponsorship_claims_status_expires_idx" ON "wanjiedaoyou_sponsorship_claims" USING btree ("status","expires_at");--> statement-breakpoint
CREATE INDEX "sponsorship_merit_public_tier_first_idx" ON "wanjiedaoyou_sponsorship_merit_profiles" USING btree ("is_public","highest_tier","first_supported_at");--> statement-breakpoint
CREATE UNIQUE INDEX "sponsorship_merit_order_uidx" ON "wanjiedaoyou_sponsorship_merit_records" USING btree ("order_id");--> statement-breakpoint
CREATE INDEX "sponsorship_merit_cultivator_supported_idx" ON "wanjiedaoyou_sponsorship_merit_records" USING btree ("cultivator_id","supported_at");--> statement-breakpoint
CREATE INDEX "sponsorship_snapshots_order_created_idx" ON "wanjiedaoyou_sponsorship_order_snapshots" USING btree ("order_id","created_at");--> statement-breakpoint
CREATE INDEX "sponsorship_snapshots_purge_idx" ON "wanjiedaoyou_sponsorship_order_snapshots" USING btree ("purge_after");--> statement-breakpoint
CREATE UNIQUE INDEX "sponsorship_orders_provider_order_uidx" ON "wanjiedaoyou_sponsorship_orders" USING btree ("provider","provider_order_id");--> statement-breakpoint
CREATE INDEX "sponsorship_orders_verification_created_idx" ON "wanjiedaoyou_sponsorship_orders" USING btree ("verification_status","created_at");--> statement-breakpoint
CREATE INDEX "sponsorship_orders_fulfillment_created_idx" ON "wanjiedaoyou_sponsorship_orders" USING btree ("fulfillment_status","created_at");--> statement-breakpoint
CREATE INDEX "sponsorship_orders_provider_user_idx" ON "wanjiedaoyou_sponsorship_orders" USING btree ("provider_user_id");
File diff suppressed because it is too large. Load diff
+9 -2
View File
@@ -215,9 +215,16 @@
{
"idx": 30,
"version": "7",
"when": 1786608971546,
"when": 1786549787086,
"tag": "0030_smart_harpoon",
"breakpoints": true
},
{
"idx": 31,
"version": "7",
"when": 1786931826016,
"tag": "0031_damp_karnak",
"breakpoints": true
}
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

@@ -0,0 +1,51 @@
import { cn } from '@shared/lib/cn';
import type { SponsorshipTierId } from '@shared/lib/sponsorship';
import { SPONSORSHIP_TIER_META } from '@shared/lib/sponsorship';
type MeritStampPresentation = {
stampSrc: string;
stampClassName: string;
};
const MERIT_STAMP_PRESENTATION: Record<
SponsorshipTierId,
MeritStampPresentation
> = {
faint_light: {
stampSrc: '/assets/sponsors/yidengweiguang.webp',
stampClassName: 'rotate-[1.5deg]',
},
fellow_traveler: {
stampSrc: '/assets/sponsors/shanshuitongcheng.webp',
stampClassName: '-rotate-[1deg]',
},
night_guardian: {
stampSrc: '/assets/sponsors/changyehudao.webp',
stampClassName: 'rotate-[0.5deg]',
},
immortality_witness: {
stampSrc: '/assets/sponsors/gongzhengchangsheng.webp',
stampClassName: '-rotate-[0.5deg]',
},
};
export function MeritStamp({
tier,
className,
}: {
tier: SponsorshipTierId;
className?: string;
}) {
const presentation = MERIT_STAMP_PRESENTATION[tier];
return (
<img
src={presentation.stampSrc}
alt={`${SPONSORSHIP_TIER_META[tier].name}印戳`}
className={cn(
'pointer-events-none block aspect-square size-8 object-contain drop-shadow-[0_1px_1px_rgba(91,15,10,0.18)]',
presentation.stampClassName,
className,
)}
/>
);
}
@@ -0,0 +1,54 @@
import { cn } from '@shared/lib/utils';
import { format } from 'd3-format';
import type { BreakthroughChancePresentation } from './breakthroughChancePresentation';
function formatPercent(value: number): string {
return format('.1%')(value);
}
export function BreakthroughChanceDetails({
presentation,
className,
}: {
presentation: BreakthroughChancePresentation;
className?: string;
}) {
return (
<div className={cn('space-y-3', className)}>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-ink-secondary text-xs leading-5">综合基础率</p>
<p className="text-ink font-mono font-semibold">
{formatPercent(presentation.adjustedBaseChance)}
</p>
</div>
<div>
<p className="text-ink-secondary text-xs leading-5">最终成功率</p>
<p className="text-emerald-800 font-mono font-semibold">
{formatPercent(presentation.finalChance)}
</p>
</div>
</div>
{presentation.factors.length > 0 ? (
<div className="flex flex-wrap items-center gap-x-2 gap-y-1.5">
{presentation.factors.map((factor) => (
<span
key={factor.key}
className={cn(
'border-ink/15 bg-bgpaper/70 inline-flex max-w-full items-center gap-1.5 border border-dashed px-2 py-1 text-xs leading-5',
factor.tone === 'positive' &&
'border-emerald-700/25 text-emerald-800',
factor.tone === 'warning' && 'border-wood/35 text-wood',
factor.tone === 'neutral' && 'text-ink-secondary',
)}
>
<span>{factor.label}</span>
<span className="font-mono">{factor.value}</span>
</span>
))}
</div>
) : null}
</div>
);
}
@@ -1,6 +1,6 @@
import { InkModal } from '@app/components/layout';
import { InkButton } from '@app/components/ui/InkButton';
import { format } from 'd3-format';
import { BreakthroughChanceDetails } from './BreakthroughChanceDetails';
import type { BreakthroughChancePreviewData } from './useRetreatViewModel';
interface BreakthroughConfirmModalProps {
@@ -77,19 +77,9 @@ export function BreakthroughConfirmModal({
</div>
{chancePreview && (
<div className="border-teal/35 bg-bgpaper space-y-1 border border-dashed p-3">
<div className="border-teal/35 bg-bgpaper space-y-3 border border-dashed p-3">
<p className="text-teal font-medium">【当前成功率推演】</p>
<p className="text-teal text-xs">
基础成功率:{format('.1%')(Math.min(chancePreview.baseChance, 1))}
</p>
{chancePreview.buffBonus > 0 && (
<p className="text-xs text-emerald-800">
外物与机缘加成:+{format('.1%')(chancePreview.buffBonus)}
</p>
)}
<p className="text-xs text-emerald-800">
最终成功率:{format('.1%')(Math.min(chancePreview.finalChance, 1))}
</p>
<BreakthroughChanceDetails presentation={chancePreview} />
</div>
)}
@@ -10,6 +10,8 @@ import type { Attributes } from '@shared/types/cultivator';
import { getAttributeInfo } from '@shared/lib/gameConceptDisplay';
import { format } from 'd3-format';
import { useMemo } from 'react';
import { BreakthroughChanceDetails } from './BreakthroughChanceDetails';
import { buildBreakthroughChancePresentation } from './breakthroughChancePresentation';
import { isSuccessfulBreakthrough } from './retreatStream';
const COMPREHENSION_LABEL = getGameConceptLabel('comprehension_insight');
@@ -147,14 +149,10 @@ function BreakthroughResultContent({
retreatResult: RetreatResultData;
}) {
const summary = retreatResult.summary as BreakthroughResult['summary'];
const buffBonus = Math.max(
0,
(summary.modifiers?.pillBonus ?? 0) + (summary.modifiers?.fateBonus ?? 0),
);
const displayedBaseChance = Math.max(
0,
Math.min(1, summary.chance - buffBonus),
);
const chancePresentation = buildBreakthroughChancePresentation({
modifiers: summary.modifiers,
finalChance: summary.chance,
});
const realmChangeText =
summary.success && summary.toRealm && summary.toStage
? `${summary.fromRealm}${summary.fromStage} → ${summary.toRealm}${summary.toStage}`
@@ -185,14 +183,10 @@ function BreakthroughResultContent({
{summary.success ? '🌅 突破成功!' : '☁️ 冲关失败'}
</p>
<p>成功率 {format('.1%')(Math.min(summary.chance, 1))}</p>
{buffBonus > 0 ? (
<p className="text-emerald-700">
机缘加成:+{format('.1%')(buffBonus)}(
{format('.1%')(displayedBaseChance)} →{' '}
{format('.1%')(Math.min(summary.chance, 1))})
</p>
) : null}
<div className="border-teal/25 bg-bgpaper/60 space-y-3 border border-dashed p-3">
<p className="text-teal font-medium">【本次成功率推演】</p>
<BreakthroughChanceDetails presentation={chancePresentation} />
</div>
{realmChangeText ? <p>境界突破:{realmChangeText}</p> : null}
@@ -14,6 +14,7 @@ import { cn } from '@shared/lib/utils';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { BreakthroughConfirmModal } from './BreakthroughConfirmModal';
import { BreakthroughChanceDetails } from './BreakthroughChanceDetails';
import type {
RetreatBuffTag,
RetreatEfficiencyModel,
@@ -21,6 +22,7 @@ import type {
import { RetreatResultModal } from './RetreatResultModal';
import {
useRetreatViewModel,
type BreakthroughChancePreviewData,
type CultivationProgressData,
} from './useRetreatViewModel';
@@ -319,6 +321,7 @@ function BreakthroughPanel({
cultivationProgress,
canAttemptBreakthrough,
guidanceText,
breakthroughPreview,
retreatEfficiency,
isMajorBreakthrough,
majorBreakthroughBlocked,
@@ -332,6 +335,7 @@ function BreakthroughPanel({
cultivationProgress: CultivationProgressData | null;
canAttemptBreakthrough: boolean;
guidanceText: string;
breakthroughPreview: BreakthroughChancePreviewData | null;
retreatEfficiency: RetreatEfficiencyModel | null;
isMajorBreakthrough: boolean;
majorBreakthroughBlocked: boolean;
@@ -361,7 +365,21 @@ function BreakthroughPanel({
) : null}
<span className="text-ink-secondary">{guidanceText}</span>
</div>
<RetreatBuffTags tags={retreatEfficiency?.breakthroughTags ?? []} />
{breakthroughPreview ? (
<div className="border-teal/25 bg-bgpaper/60 space-y-3 border border-dashed px-3 py-3">
<p className="text-teal text-sm font-medium">成功率推演</p>
<BreakthroughChanceDetails presentation={breakthroughPreview} />
</div>
) : null}
{(retreatEfficiency?.breakthroughTags.length ?? 0) > 0 ? (
<div className="space-y-2">
<p className="text-ink-secondary text-xs leading-5">破境准备</p>
<RetreatBuffTags
tags={retreatEfficiency?.breakthroughTags ?? []}
/>
</div>
) : null}
</div>
{isMajorBreakthrough ? (
@@ -610,6 +628,7 @@ export function RetreatView({ sectContext }: RetreatViewProps) {
cultivationProgress={cultivationProgress}
canAttemptBreakthrough={canAttemptBreakthrough}
guidanceText={guidanceText}
breakthroughPreview={breakthroughPreview}
retreatEfficiency={retreatEfficiency}
isMajorBreakthrough={isMajorBreakthrough}
majorBreakthroughBlocked={majorBreakthroughBlocked}
@@ -0,0 +1,109 @@
import type { BreakthroughModifiers } from '@server/utils/breakthroughCalculator';
import { format } from 'd3-format';
export type BreakthroughFactorTone = 'positive' | 'warning' | 'neutral';
export interface BreakthroughChanceFactor {
key: string;
label: string;
value: string;
tone: BreakthroughFactorTone;
}
export interface BreakthroughChancePresentation {
adjustedBaseChance: number;
finalChance: number;
factors: BreakthroughChanceFactor[];
}
function formatPercent(value: number): string {
return format('.1%')(value);
}
function formatSignedPercent(value: number): string {
if (value === 0) return '+0.0%';
return `${value > 0 ? '+' : '-'}${formatPercent(Math.abs(value))}`;
}
function getDeltaTone(value: number): BreakthroughFactorTone {
if (value > 0) return 'positive';
if (value < 0) return 'warning';
return 'neutral';
}
function getMultiplierTone(value: number): BreakthroughFactorTone {
if (value > 1) return 'positive';
if (value < 1) return 'warning';
return 'neutral';
}
export function buildBreakthroughChancePresentation(input: {
modifiers: BreakthroughModifiers;
finalChance?: number;
}): BreakthroughChancePresentation {
const { modifiers } = input;
const factors: BreakthroughChanceFactor[] = [];
if (modifiers.realmDifficulty !== 1) {
factors.push({
key: 'realm',
label: '境界难度',
value: `×${formatPercent(modifiers.realmDifficulty)}`,
tone: getMultiplierTone(modifiers.realmDifficulty),
});
}
if (modifiers.progressMultiplier !== 1) {
factors.push({
key: 'progress',
label: '修为进度',
value: `×${formatPercent(modifiers.progressMultiplier)}`,
tone: getMultiplierTone(modifiers.progressMultiplier),
});
}
if (modifiers.insightMultiplier !== 1) {
factors.push({
key: 'insight',
label: '道行感悟',
value: `×${formatPercent(modifiers.insightMultiplier)}`,
tone: getMultiplierTone(modifiers.insightMultiplier),
});
}
if (modifiers.demonPenalty !== 1) {
factors.push({
key: 'inner-demon',
label: '心魔影响',
value: `×${formatPercent(modifiers.demonPenalty)}`,
tone: getMultiplierTone(modifiers.demonPenalty),
});
}
if (modifiers.fateBonus !== 0) {
factors.push({
key: 'fate',
label: '命格机缘',
value: formatSignedPercent(modifiers.fateBonus),
tone: getDeltaTone(modifiers.fateBonus),
});
}
if (modifiers.pillBonus !== 0) {
factors.push({
key: 'pill',
label: '破境药力',
value: formatSignedPercent(modifiers.pillBonus),
tone: getDeltaTone(modifiers.pillBonus),
});
}
if (modifiers.toxicityPenalty !== 0) {
factors.push({
key: 'toxicity',
label: '丹毒惩罚',
value: `-${formatPercent(modifiers.toxicityPenalty)}`,
tone: 'warning',
});
}
return {
adjustedBaseChance: modifiers.adjustedBaseChance,
finalChance: input.finalChance ?? modifiers.finalChance,
factors,
};
}
@@ -34,6 +34,10 @@ import {
buildRetreatEfficiencyModel,
type RetreatEfficiencyModel,
} from './retreatEfficiency';
import {
buildBreakthroughChancePresentation,
type BreakthroughChancePresentation,
} from './breakthroughChancePresentation';
export interface CultivationProgressData {
cultivation_exp: number;
@@ -58,10 +62,8 @@ type RetreatCultivatorView = Pick<
cultivation_progress: NonNullable<Cultivator['cultivation_progress']>;
};
export interface BreakthroughChancePreviewData {
baseChance: number;
finalChance: number;
buffBonus: number;
export interface BreakthroughChancePreviewData
extends BreakthroughChancePresentation {
recommendation: string;
}
@@ -227,31 +229,19 @@ export function useRetreatViewModel(): UseRetreatViewModelReturn {
useMemo((): BreakthroughChancePreviewData | null => {
if (
!cultivator ||
!cultivationProgress?.canBreakthrough ||
(isMajorBreakthrough && majorBreakthroughBlocked)
!cultivationProgress?.canBreakthrough
) {
return null;
}
try {
const result = calculateBreakthroughChance(cultivator);
const baseChance = Math.min(
1,
Math.max(
0.05,
result.modifiers.baseChance *
result.modifiers.realmDifficulty *
result.modifiers.progressMultiplier *
result.modifiers.insightMultiplier *
result.modifiers.demonPenalty +
result.modifiers.toxicityPenalty,
),
);
return {
baseChance,
finalChance: result.chance,
buffBonus: result.modifiers.pillBonus + result.modifiers.fateBonus,
...buildBreakthroughChancePresentation({
modifiers: result.modifiers,
finalChance: result.chance,
}),
recommendation: result.recommendation,
};
} catch {
@@ -260,8 +250,6 @@ export function useRetreatViewModel(): UseRetreatViewModelReturn {
}, [
cultivator,
cultivationProgress?.canBreakthrough,
isMajorBreakthrough,
majorBreakthroughBlocked,
]);
const handleRetreatYearsChange = useCallback((value: string) => {
@@ -3,6 +3,7 @@ import {
MarrowWashSummaryContent,
} from '@app/components/feature/cultivator/BodyCultivationPanels';
import { useQiState } from '@app/components/feature/cultivator/useQiState';
import { MeritStamp } from '@app/components/feature/merit/MeritStamp';
import { getSectIdentityLabels } from '@app/components/feature/sect/sectIdentityDisplay';
import { useActiveSectContextQuery } from '@app/components/feature/sect/sectResources';
import { useSectIdentityDialog } from '@app/components/feature/sect/useSectIdentityDialog';
@@ -24,10 +25,49 @@ import {
} from '@shared/config/qiSystem';
import { cn } from '@shared/lib/cn';
import { getGameConceptInfo } from '@shared/lib/gameConceptDisplay';
import type { SponsorshipTierId } from '@shared/lib/sponsorship';
import type { ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import type { GameHudSnapshot } from './useGameHudModel';
type MeritProfileResponse = {
profile: { highestTier: SponsorshipTierId } | null;
};
function usePlayerMeritTier(
cultivatorId: string | undefined,
): SponsorshipTierId | null {
const [tier, setTier] = useState<SponsorshipTierId | null>(null);
useEffect(() => {
if (!cultivatorId) return;
let cancelled = false;
fetch('/api/sponsorship/me')
.then(async (response) => {
const data = (await response.json()) as
MeritProfileResponse | { error?: string };
if (!response.ok) {
throw new Error('error' in data ? data.error : '请求失败');
}
return data as MeritProfileResponse;
})
.then((data) => {
if (!cancelled) setTier(data.profile?.highestTier ?? null);
})
.catch(() => {
if (!cancelled) setTier(null);
});
return () => {
cancelled = true;
};
}, [cultivatorId]);
return tier;
}
function HudMeter({
label,
display,
@@ -295,6 +335,7 @@ export function GameTopHud({ snapshot }: { snapshot: GameHudSnapshot | null }) {
} = useQiState({
cultivatorId: snapshot?.cultivatorId ?? '',
});
const meritTier = usePlayerMeritTier(snapshot?.cultivatorId);
if (!snapshot) return <GameTopHudPlaceholder />;
@@ -764,6 +805,12 @@ export function GameTopHud({ snapshot }: { snapshot: GameHudSnapshot | null }) {
>
{snapshot.name}
</Link>
{meritTier ? (
<MeritStamp
tier={meritTier}
className="size-5 shrink-0 opacity-90 md:size-6"
/>
) : null}
{snapshot.title ? (
<div className="text-crimson hidden min-w-0 text-xs md:inline-block md:text-sm">
<span className="block truncate">「{snapshot.title}」</span>
@@ -326,6 +326,12 @@ export const gameDockGroups: GameNavGroup[] = [
href: '/game/redeem',
expandedDockLabel: '🎁 兑换码',
},
{
id: 'merit-ledger',
sceneLabel: '功德簿',
href: '/game/merit-ledger',
expandedDockLabel: '📜 功德簿',
},
{
id: 'community',
sceneLabel: '玩家交流群',
+5 -1
View File
@@ -37,10 +37,14 @@ export function usePlayerLoadout(enabled = true) {
}
export function useUnreadMailCount() {
const query = useSingletonResource(playerMailSummaryResource);
const query = usePlayerMailSummary();
return query.data?.unreadCount;
}
export function usePlayerMailSummary() {
return useSingletonResource(playerMailSummaryResource);
}
export function useTaskSummary() {
return useSingletonResource(playerTaskSummaryResource);
}
+13
View File
@@ -909,6 +909,14 @@ export const router = createBrowserRouter(
'兑换码',
)}
/>
<Route
path="merit-ledger"
lazy={lazyRoute(() => import('@app/routes/game/merit-ledger/route'))}
handle={scene(
{ id: 'merit-ledger', presentation: 'service', summary: '记同行之缘,不录金额与次数。' },
'功德簿',
)}
/>
<Route
path="settings"
lazy={lazyRoute(() => import('@app/routes/game/settings/route'))}
@@ -1244,6 +1252,11 @@ export const router = createBrowserRouter(
)}
handle={title('新建兑换码')}
/>
<Route
path="sponsorship"
lazy={lazyRoute(() => import('@app/routes/admin/sponsorship/route'))}
handle={title('功德簿管理')}
/>
<Route
path="llm-metrics"
lazy={lazyRoute(() => import('@app/routes/admin/llm-metrics/route'))}
@@ -60,6 +60,11 @@ export const adminNavItems: AdminNavItem[] = [
description: '活动兑换码创建与停用',
href: '/admin/redeem-codes',
},
{
title: '功德簿管理',
description: '爱发电映射、订单与认领处理',
href: '/admin/sponsorship',
},
{
title: 'LLM 观测',
description: '查看场景体积、usage 与缓存迹象',
@@ -0,0 +1,495 @@
import { InkButton } from '@app/components/ui/InkButton';
import {
SPONSORSHIP_TIER_IDS,
SPONSORSHIP_TIER_META,
type SponsorshipTierId,
} from '@shared/lib/sponsorship';
import { useCallback, useEffect, useState } from 'react';
type TierConfig = { planId: string; minimumAmountFen: number };
type Config = {
ordersAcceptedAfter: string | null;
tiers: Record<SponsorshipTierId, TierConfig>;
};
type Order = {
id: string;
providerOrderId: string;
resolvedTier: SponsorshipTierId | null;
verificationStatus: string;
fulfillmentStatus: string;
lastErrorMessage: string | null;
createdAt: string;
};
type OrderDetail = {
order: Order;
claims: {
id: string;
status: string;
messageStatus: string;
expiresAt: string;
}[];
records: {
id: string;
cultivatorId: string;
tier: SponsorshipTierId;
revokedAt: string | null;
}[];
snapshots: {
id: string;
source: string;
createdAt: string;
purgeAfter: string;
}[];
};
type OrderFilter =
'all' | 'attention' | 'awaiting_claim' | 'fulfilled' | 'revoked';
async function request<T>(url: string, init?: RequestInit): Promise<T> {
const response = await fetch(url, init);
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? '请求失败');
return data as T;
}
export default function SponsorshipAdminPage() {
const [config, setConfig] = useState<Config | null>(null);
const [orders, setOrders] = useState<Order[]>([]);
const [message, setMessage] = useState('');
const [cultivatorId, setCultivatorId] = useState('');
const [manualTier, setManualTier] =
useState<SponsorshipTierId>('faint_light');
const [manualSupportedAt, setManualSupportedAt] = useState('');
const [manualPublic, setManualPublic] = useState(true);
const [manualSendMail, setManualSendMail] = useState(true);
const [orderFilter, setOrderFilter] = useState<OrderFilter>('all');
const [orderPage, setOrderPage] = useState(1);
const [orderTotal, setOrderTotal] = useState(0);
const [busy, setBusy] = useState(false);
const [detail, setDetail] = useState<OrderDetail | null>(null);
const [revealedSnapshot, setRevealedSnapshot] = useState<unknown>(null);
const load = useCallback(async () => {
const [nextConfig, nextOrders] = await Promise.all([
request<Config>('/api/admin/sponsorship/config'),
request<{ orders: Order[]; total: number }>(
`/api/admin/sponsorship/orders?page=${orderPage}&pageSize=50&filter=${orderFilter}`,
),
]);
setConfig(nextConfig);
setOrders(nextOrders.orders);
setOrderTotal(nextOrders.total);
}, [orderFilter, orderPage]);
useEffect(() => {
void (async () => {
try {
await load();
} catch (error) {
setMessage(error instanceof Error ? error.message : '加载失败');
}
})();
}, [load]);
const save = async () => {
if (!config) return;
setBusy(true);
try {
await request('/api/admin/sponsorship/config', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ tiers: config.tiers }),
});
await load();
setMessage('档位映射已保存');
} finally {
setBusy(false);
}
};
const act = async (
orderId: string,
action: 'retry' | 'revoke' | 'rotate-claim',
) => {
if (
action === 'revoke' &&
!window.confirm(
'确认撤销该订单的功德记录?此操作会重新计算角色最高档位。',
)
)
return;
setBusy(true);
try {
await request(`/api/admin/sponsorship/orders/${orderId}/${action}`, {
method: 'POST',
});
await load();
setMessage('操作完成');
} finally {
setBusy(false);
}
};
const manualGrant = async () => {
if (!window.confirm('确认向该角色手动写入历史功德?')) return;
setBusy(true);
try {
await request('/api/admin/sponsorship/manual-grants', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
cultivatorId,
tier: manualTier,
supportedAt: manualSupportedAt
? new Date(manualSupportedAt).toISOString()
: undefined,
publicListing: manualPublic,
sendMail: manualSendMail,
}),
});
setCultivatorId('');
setManualSupportedAt('');
setMessage('历史功德已手动发放');
} finally {
setBusy(false);
}
};
const showDetail = async (orderId: string) => {
setRevealedSnapshot(null);
setDetail(
await request<OrderDetail>(`/api/admin/sponsorship/orders/${orderId}`),
);
};
const revealSnapshot = async (snapshotId: string) => {
if (!window.confirm('原始快照可能包含支付相关敏感信息,确认审计查看?'))
return;
const result = await request<{ snapshot: unknown }>(
`/api/admin/sponsorship/snapshots/${snapshotId}/reveal`,
{ method: 'POST' },
);
setRevealedSnapshot(result.snapshot);
};
return (
<div className="space-y-5">
<header className="border-ink/15 bg-bgpaper/90 border border-dashed p-6">
<p className="text-ink-secondary text-xs tracking-[0.2em]">
SPONSORSHIP
</p>
<h2 className="font-heading text-ink mt-2 text-4xl">功德簿管理</h2>
<p className="text-ink-secondary mt-2 text-sm">
配置爱发电方案映射,核查订单、重试认领与手动处理历史订单。
</p>
</header>
{message && <p className="border-ink/15 border p-3 text-sm">{message}</p>}
<section className="border-ink/15 bg-bgpaper/90 border border-dashed p-6">
<h3 className="text-xl">档位映射</h3>
<p className="text-ink-secondary mt-1 text-sm">
仅方案 ID 与最低金额(分)可修改,档位名称和主题固定在代码中。
</p>
{config?.ordersAcceptedAfter && (
<p className="text-ink-secondary mt-1 text-xs">
自动处理起始:
{new Date(config.ordersAcceptedAfter).toLocaleString('zh-CN')}
(更早订单仅手动发放)
</p>
)}
<div className="mt-4 grid gap-3 md:grid-cols-2">
{config &&
SPONSORSHIP_TIER_IDS.map((tier) => (
<label key={tier} className="border-ink/15 border p-3 text-sm">
<span className="block font-medium">
{SPONSORSHIP_TIER_META[tier].name}
</span>
<input
className="mt-2 w-full border p-2"
value={config.tiers[tier].planId}
placeholder="爱发电 plan_id"
onChange={(event) =>
setConfig({
...config,
tiers: {
...config.tiers,
[tier]: {
...config.tiers[tier],
planId: event.target.value,
},
},
})
}
/>
<span className="text-ink-secondary mt-2 block text-xs">
最低金额(分)
</span>
<input
className="mt-1 w-full border p-2"
type="number"
min={1}
value={config.tiers[tier].minimumAmountFen}
onChange={(event) =>
setConfig({
...config,
tiers: {
...config.tiers,
[tier]: {
...config.tiers[tier],
minimumAmountFen: Number(event.target.value),
},
},
})
}
/>
</label>
))}
</div>
<div className="mt-4 flex gap-3">
<InkButton
variant="primary"
disabled={busy}
onClick={() =>
void save().catch((error) => setMessage(error.message))
}
>
保存映射
</InkButton>
<InkButton
variant="secondary"
disabled={busy}
onClick={() =>
void request('/api/admin/sponsorship/ping', { method: 'POST' })
.then(() => setMessage('爱发电连接正常'))
.catch((error) => setMessage(error.message))
}
>
测试连接
</InkButton>
</div>
</section>
<section className="border-ink/15 bg-bgpaper/90 border border-dashed p-6">
<h3 className="text-xl">历史手动发放</h3>
<div className="mt-3 flex flex-wrap items-center gap-2">
<input
className="min-w-72 border p-2"
value={cultivatorId}
onChange={(event) => setCultivatorId(event.target.value)}
placeholder="角色 UUID"
/>
<select
className="border p-2"
value={manualTier}
onChange={(event) =>
setManualTier(event.target.value as SponsorshipTierId)
}
>
{SPONSORSHIP_TIER_IDS.map((tier) => (
<option key={tier} value={tier}>
{SPONSORSHIP_TIER_META[tier].name}
</option>
))}
</select>
<input
className="border p-2"
type="datetime-local"
value={manualSupportedAt}
onChange={(event) => setManualSupportedAt(event.target.value)}
/>
<label className="text-sm">
<input
type="checkbox"
checked={manualPublic}
onChange={(event) => setManualPublic(event.target.checked)}
/>{' '}
公开留名
</label>
<label className="text-sm">
<input
type="checkbox"
checked={manualSendMail}
onChange={(event) => setManualSendMail(event.target.checked)}
/>{' '}
寄送谢信
</label>
<InkButton
variant="primary"
disabled={!cultivatorId || busy}
onClick={() =>
void manualGrant().catch((error) => setMessage(error.message))
}
>
手动发放
</InkButton>
</div>
</section>
<section className="border-ink/15 bg-bgpaper/90 overflow-x-auto border border-dashed p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-xl">订单</h3>
<select
className="border p-2 text-sm"
value={orderFilter}
onChange={(event) => {
setOrderFilter(event.target.value as OrderFilter);
setOrderPage(1);
}}
>
<option value="all">全部</option>
<option value="attention">需人工关注</option>
<option value="awaiting_claim">待认领</option>
<option value="fulfilled">已履约</option>
<option value="revoked">已撤销</option>
</select>
</div>
<table className="mt-4 w-full min-w-[760px] text-left text-sm">
<thead>
<tr>
<th className="p-2">订单</th>
<th>档位</th>
<th>校验</th>
<th>履约</th>
<th>错误</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{orders.map((order) => (
<tr key={order.id} className="border-ink/10 border-t">
<td className="p-2 font-mono">{order.providerOrderId}</td>
<td>
{order.resolvedTier
? SPONSORSHIP_TIER_META[order.resolvedTier].name
: '-'}
</td>
<td>{order.verificationStatus}</td>
<td>{order.fulfillmentStatus}</td>
<td className="max-w-52 truncate">
{order.lastErrorMessage ?? '-'}
</td>
<td className="space-x-2">
<button
disabled={busy}
onClick={() =>
void showDetail(order.id).catch((error) =>
setMessage(error.message),
)
}
>
详情
</button>
<button
disabled={busy || order.fulfillmentStatus === 'revoked'}
onClick={() =>
void act(order.id, 'retry').catch((error) =>
setMessage(error.message),
)
}
>
重试
</button>
<button
disabled={
busy ||
order.fulfillmentStatus === 'fulfilled' ||
order.fulfillmentStatus === 'revoked'
}
onClick={() =>
void act(order.id, 'rotate-claim').catch((error) =>
setMessage(error.message),
)
}
>
轮换认领码
</button>
<button
disabled={busy || order.fulfillmentStatus === 'revoked'}
onClick={() =>
void act(order.id, 'revoke').catch((error) =>
setMessage(error.message),
)
}
>
撤销
</button>
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-4 flex items-center justify-center gap-3">
<InkButton
variant="secondary"
disabled={orderPage <= 1 || busy}
onClick={() => setOrderPage((page) => page - 1)}
>
上一页
</InkButton>
<span className="text-sm">
第 {orderPage} 页 · 共 {orderTotal} 条
</span>
<InkButton
variant="secondary"
disabled={orderPage * 50 >= orderTotal || busy}
onClick={() => setOrderPage((page) => page + 1)}
>
下一页
</InkButton>
</div>
</section>
{detail && (
<section className="border-ink/15 bg-bgpaper/90 border border-dashed p-6">
<h3 className="text-xl">订单详情 · {detail.order.providerOrderId}</h3>
<div className="mt-3 grid gap-4 md:grid-cols-2">
<div>
<h4 className="font-medium">认领</h4>
{detail.claims.length ? (
detail.claims.map((claim) => (
<p key={claim.id} className="mt-1 text-sm">
{claim.status} · 私信 {claim.messageStatus} ·{' '}
{new Date(claim.expiresAt).toLocaleString('zh-CN')}
</p>
))
) : (
<p className="text-ink-secondary mt-1 text-sm">无认领码</p>
)}
</div>
<div>
<h4 className="font-medium">功德记录</h4>
{detail.records.map((record) => (
<p key={record.id} className="mt-1 text-sm">
{record.cultivatorId} ·{' '}
{SPONSORSHIP_TIER_META[record.tier].name}
{record.revokedAt ? ' · 已撤销' : ''}
</p>
))}
</div>
</div>
<div className="mt-5">
<h4 className="font-medium">原始快照</h4>
<p className="text-ink-secondary mt-1 text-xs">
查看会写入管理员审计日志,请仅在排障时使用。
</p>
{detail.snapshots.map((snapshot) => (
<div
key={snapshot.id}
className="mt-2 flex items-center justify-between border-b py-2 text-sm"
>
<span>
{snapshot.source} ·{' '}
{new Date(snapshot.createdAt).toLocaleString('zh-CN')}
</span>
<button
onClick={() =>
void revealSnapshot(snapshot.id).catch((error) =>
setMessage(error.message),
)
}
>
审计查看
</button>
</div>
))}
{revealedSnapshot !== null && (
<pre className="mt-3 max-h-96 overflow-auto border p-3 text-xs whitespace-pre-wrap">
{JSON.stringify(revealedSnapshot, null, 2)}
</pre>
)}
</div>
</section>
)}
</div>
);
}
@@ -0,0 +1,104 @@
import { MeritStamp } from '@app/components/feature/merit/MeritStamp';
import { cn } from '@shared/lib/cn';
import type { SponsorshipTierId } from '@shared/lib/sponsorship';
import { SPONSORSHIP_TIER_META } from '@shared/lib/sponsorship';
import type { ReactNode } from 'react';
type TierPresentation = {
cardClassName: string;
ruleClassName: string;
};
const MERIT_TIER_PRESENTATION: Record<SponsorshipTierId, TierPresentation> = {
faint_light: {
cardClassName:
'border-[#9a7954]/55 bg-[#ede0c5] text-[#2a1a12] shadow-[0_6px_18px_rgba(72,45,24,0.06)]',
ruleClassName: 'bg-[#7c5b39]/55',
},
fellow_traveler: {
cardClassName:
'border-[#718084]/55 bg-[#dce2e0] text-[#253034] shadow-[0_6px_18px_rgba(45,62,64,0.06)]',
ruleClassName: 'bg-[#596a6e]/55',
},
night_guardian: {
cardClassName:
'border-[#b69649]/70 bg-[#27241e] text-[#d7b666] shadow-[0_8px_20px_rgba(21,17,11,0.16)]',
ruleClassName: 'bg-[#c6a653]/65',
},
immortality_witness: {
cardClassName:
'border-[#9e9a64]/55 bg-[#e4e4d2] text-[#292719] shadow-[0_6px_18px_rgba(64,61,34,0.06)]',
ruleClassName: 'bg-[#77733f]/55',
},
};
function CardCorner({ className }: { className: string }) {
return (
<span
aria-hidden="true"
className={cn(
'pointer-events-none absolute h-2.5 w-2.5 border-current opacity-20',
className,
)}
/>
);
}
export function MeritTierCard({
tier,
eyebrow,
children,
action,
className,
}: {
tier: SponsorshipTierId;
eyebrow?: ReactNode;
children?: ReactNode;
action?: ReactNode;
className?: string;
}) {
const presentation = MERIT_TIER_PRESENTATION[tier];
const meta = SPONSORSHIP_TIER_META[tier];
return (
<article
className={cn(
'group relative overflow-hidden border p-4 bg-blend-multiply transition-[transform,box-shadow] duration-200 hover:-translate-y-0.5 sm:p-5',
presentation.cardClassName,
className,
)}
style={{
backgroundImage: "url('/assets/paper.webp')",
backgroundSize: '300px',
}}
>
<CardCorner className="top-2 left-2 border-t border-l" />
<CardCorner className="top-2 right-2 border-t border-r" />
<CardCorner className="bottom-2 left-2 border-b border-l" />
<CardCorner className="right-2 bottom-2 border-r border-b" />
<div className="relative z-10 flex items-start gap-4">
<div className="min-w-0 flex-1">
{eyebrow ? (
<div className="mb-2 text-[0.7rem] tracking-[0.16em] opacity-60">
{eyebrow}
</div>
) : null}
<h3 className="text-lg leading-none font-semibold tracking-[0.08em]">
{meta.name}
</h3>
<span
aria-hidden="true"
className={cn('mt-3 block h-px w-6', presentation.ruleClassName)}
/>
{children ? (
<div className="mt-3 text-sm leading-6">{children}</div>
) : null}
{action ? <div className="mt-3">{action}</div> : null}
</div>
<MeritStamp tier={tier} className="size-9 shrink-0 opacity-90" />
</div>
</article>
);
}
@@ -0,0 +1,77 @@
import { MeritStamp } from '@app/components/feature/merit/MeritStamp';
import {
SPONSORSHIP_TIER_IDS,
SPONSORSHIP_TIER_META,
type SponsorshipTierId,
} from '@shared/lib/sponsorship';
export type MeritPublicRow = {
cultivatorId: string;
name: string;
title: string | null;
realm: string;
realmStage: string;
highestTier: SponsorshipTierId;
firstSupportedMonth: string;
};
const DISPLAY_TIER_IDS = [...SPONSORSHIP_TIER_IDS].reverse();
export function MeritWall({ rows }: { rows: MeritPublicRow[] }) {
const groups = DISPLAY_TIER_IDS.map((tier) => ({
tier,
members: rows.filter((row) => row.highestTier === tier),
})).filter((group) => group.members.length > 0);
if (groups.length === 0) {
return (
<p className="text-ink-secondary py-10 text-center text-sm">
此间尚无公开留名。
</p>
);
}
return (
<div className="space-y-8">
{groups.map(({ tier, members }) => (
<section key={tier}>
<header className="border-ink/20 mb-4 flex items-center gap-3 border-b border-dashed pb-3">
<MeritStamp tier={tier} className="size-10 shrink-0 opacity-90" />
<div>
<h3 className="text-lg font-medium tracking-[0.1em]">
{SPONSORSHIP_TIER_META[tier].name}
</h3>
<p className="text-ink-secondary mt-0.5 text-xs tracking-[0.12em]">
共 {members.length} 位道友
</p>
</div>
</header>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{members.map((row) => (
<article
key={row.cultivatorId}
className="border-ink/15 bg-paper/70 min-w-0 border p-3"
>
<p className="truncate text-sm font-medium">
{row.name}
{row.title ? (
<span className="text-ink-secondary text-xs">
{' '}
· {row.title}
</span>
) : null}
</p>
<p className="text-ink-secondary mt-1 truncate text-xs leading-5">
{row.realm} · {row.realmStage}
</p>
<p className="text-ink-secondary mt-1 text-xs opacity-70">
初录于 {row.firstSupportedMonth}
</p>
</article>
))}
</div>
</section>
))}
</div>
);
}
@@ -0,0 +1,404 @@
import { MeritStamp } from '@app/components/feature/merit/MeritStamp';
import { GameSceneFrame } from '@app/components/game-shell';
import { useInkUI } from '@app/components/providers/InkUIProvider';
import { InkButton } from '@app/components/ui/InkButton';
import { InkInput } from '@app/components/ui/InkInput';
import { usePlayerMailSummary } from '@app/lib/resources/player';
import {
SPONSORSHIP_TIER_IDS,
SPONSORSHIP_TIER_META,
type SponsorshipTierId,
} from '@shared/lib/sponsorship';
import { useCallback, useEffect, useRef, useState } from 'react';
import { MeritTierCard } from './components/MeritTierCard';
import { MeritWall, type MeritPublicRow } from './components/MeritWall';
type Tab = 'mine' | 'world' | 'support';
type ClientConfig = {
enabled: boolean;
fulfillmentEnabled: boolean;
tiers: Record<
SponsorshipTierId,
{
name: string;
theme: string;
configured: boolean;
minimumAmountFen: number;
}
>;
};
type MeritState = {
profile: {
isPublic: boolean;
highestTier: SponsorshipTierId;
firstSupportedAt: string;
lastSupportedAt: string;
meritCount: number;
} | null;
records: { id: string; tier: SponsorshipTierId; supportedAt: string }[];
pending: {
id: string;
tier: SponsorshipTierId;
status: string;
expiresAt: string;
createdAt: string;
}[];
};
function formatMonth(value: string): string {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
}).format(new Date(value));
}
async function readJson<T>(response: Response): Promise<T> {
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? '请求失败');
return data as T;
}
async function fetchWorld(): Promise<MeritPublicRow[]> {
const pageSize = 50;
const first = await readJson<{ items: MeritPublicRow[]; total: number }>(
await fetch(`/api/sponsorship/public?page=1&pageSize=${pageSize}`),
);
const totalPages = Math.ceil(first.total / pageSize);
const remaining = await Promise.all(
Array.from({ length: totalPages - 1 }, async (_, index) =>
readJson<{ items: MeritPublicRow[]; total: number }>(
await fetch(
`/api/sponsorship/public?page=${index + 2}&pageSize=${pageSize}`,
),
),
),
);
return [first, ...remaining].flatMap((page) => page.items);
}
export default function MeritLedgerPage() {
const { pushToast } = useInkUI();
const mailSummary = usePlayerMailSummary();
const [tab, setTab] = useState<Tab>('mine');
const [config, setConfig] = useState<ClientConfig | null>(null);
const [mine, setMine] = useState<MeritState | null>(null);
const [world, setWorld] = useState<MeritPublicRow[]>([]);
const [publicListing, setPublicListing] = useState(true);
const [claimCode, setClaimCode] = useState('');
const [pendingCheckoutUrl, setPendingCheckoutUrl] = useState<string | null>(
null,
);
const [busy, setBusy] = useState(false);
const pollingGeneration = useRef(0);
const load = useCallback(async () => {
const [nextConfig, nextMine, nextWorld] = await Promise.all([
readJson<ClientConfig>(await fetch('/api/sponsorship/config')),
readJson<MeritState>(await fetch('/api/sponsorship/me')),
fetchWorld(),
]);
setConfig(nextConfig);
setMine(nextMine);
setWorld(nextWorld);
setPublicListing(nextMine.profile?.isPublic ?? true);
}, []);
useEffect(() => {
void (async () => {
try {
await load();
} catch (error) {
pushToast({
message: error instanceof Error ? error.message : '功德簿加载失败',
tone: 'danger',
});
}
})();
}, [load, pushToast]);
useEffect(
() => () => {
pollingGeneration.current += 1;
},
[],
);
const updateVisibility = async (checked: boolean) => {
setPublicListing(checked);
if (!mine?.profile) return;
try {
await readJson(
await fetch('/api/sponsorship/me/visibility', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ isPublic: checked }),
}),
);
await load();
} catch (error) {
setPublicListing(!checked);
pushToast({
message: error instanceof Error ? error.message : '公开设置更新失败',
tone: 'danger',
});
}
};
const claim = async () => {
if (!claimCode.trim()) return;
setBusy(true);
try {
await readJson(
await fetch('/api/sponsorship/claims', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: claimCode.trim(), publicListing }),
}),
);
setClaimCode('');
await load();
await mailSummary.reload();
setTab('mine');
pushToast({ message: '功德已归入当前角色,感谢同行', tone: 'success' });
} catch (error) {
pushToast({
message: error instanceof Error ? error.message : '认领失败',
tone: 'danger',
});
} finally {
setBusy(false);
}
};
const checkout = async (tier: SponsorshipTierId) => {
const generation = ++pollingGeneration.current;
setBusy(true);
try {
const intent = await readJson<{ id: string; checkoutUrl: string }>(
await fetch('/api/sponsorship/checkout-intents', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ tier, publicListing }),
}),
);
setPendingCheckoutUrl(intent.checkoutUrl);
window.open(intent.checkoutUrl, '_blank', 'noopener,noreferrer');
pushToast({
message: '已发起爱发电支付;若窗口未打开,请点击页面中的备用链接',
tone: 'success',
});
for (let attempt = 0; attempt < 60; attempt += 1) {
await new Promise((resolve) => window.setTimeout(resolve, 3_000));
if (pollingGeneration.current !== generation) return;
const status = await readJson<{ status: string }>(
await fetch(`/api/sponsorship/checkout-intents/${intent.id}`),
);
if (status.status === 'fulfilled') {
await load();
await mailSummary.reload();
setTab('mine');
pushToast({
message: '功德已记,谢信已送至传音玉简',
tone: 'success',
});
return;
}
}
pushToast({
message: '暂未收到支付结果;订单仍会由后台继续核对,无需重复支付',
tone: 'warning',
});
} catch (error) {
pushToast({
message: error instanceof Error ? error.message : '发起支持失败',
tone: 'danger',
});
} finally {
if (pollingGeneration.current === generation) setBusy(false);
}
};
return (
<GameSceneFrame
variant="default"
title="功德簿"
description="不记灵石多寡,只录同行之缘。每笔支持留下一页功德与一封无附件谢信。"
contentClassName="!mt-4"
>
<nav
aria-label="功德簿页签"
className="border-ink/20 mb-6 flex flex-wrap gap-x-5 gap-y-2 border-b border-dashed pb-3"
>
{(
[
['mine', '我的功德'],
['world', '天下功德'],
['support', '续添功德'],
] as const
).map(([id, label]) => (
<InkButton
key={id}
variant={tab === id ? 'primary' : 'ghost'}
className="text-base"
onClick={() => setTab(id)}
>
{label}
</InkButton>
))}
</nav>
{tab === 'mine' && (
<div className="space-y-6">
{mine?.profile ? (
<MeritTierCard
tier={mine.profile.highestTier}
eyebrow="当前角色功德总卡"
>
<p className="text-sm leading-6">
初录于 {formatMonth(mine.profile.firstSupportedAt)}
</p>
</MeritTierCard>
) : (
<div className="border-ink/20 bg-ink/[0.025] border-l-2 px-5 py-8">
<p className="text-lg">此页尚待落印</p>
<p className="text-ink-secondary mt-2 text-sm leading-7">
当前角色尚未在功德簿留名,同行之缘自下一笔开始记述。
</p>
</div>
)}
<label className="border-ink/15 flex items-start gap-3 border-y border-dashed py-4 text-sm leading-7">
<input
type="checkbox"
checked={publicListing}
className="mt-1.5 size-4 accent-[#a51f18]"
onChange={(event) => void updateVisibility(event.target.checked)}
/>
<span className="min-w-0">
<span className="block text-base">向天下道友公开此页留名</span>
<span className="text-ink-secondary block text-xs leading-6">
仅展示角色名、称号、境界、最高档位与首次支持月份。
</span>
</span>
</label>
{mine?.pending.length ? (
<section className="border-crimson/35 bg-crimson/[0.025] border-l-2 px-4 py-3">
<p className="text-sm font-medium">尚有功德正在核验</p>
{mine.pending.map((item) => (
<p key={item.id} className="text-ink-secondary mt-1 text-xs">
{SPONSORSHIP_TIER_META[item.tier].name} ·
请勿重复支付,结果会自动续入此页
</p>
))}
</section>
) : null}
<section>
<div className="mb-2 flex items-baseline justify-between gap-4">
<h3 className="text-base font-medium tracking-[0.12em]">册页</h3>
<span className="text-ink-secondary text-xs">按时间由近及远</span>
</div>
{mine?.records.map((record) => (
<div
key={record.id}
className="border-ink/15 group flex items-center justify-between gap-4 border-b py-3 text-sm"
>
<span className="flex min-w-0 items-center gap-3">
<MeritStamp
tier={record.tier}
className="size-8 shrink-0 opacity-75"
/>
<span>{SPONSORSHIP_TIER_META[record.tier].name}</span>
</span>
<span className="text-ink-secondary shrink-0 text-xs">
{new Date(record.supportedAt).toLocaleDateString('zh-CN')}
</span>
</div>
))}
</section>
</div>
)}
{tab === 'world' && <MeritWall rows={world} />}
{tab === 'support' && (
<div className="space-y-7">
{pendingCheckoutUrl && (
<p className="border-crimson/35 bg-crimson/[0.025] border-l-2 px-4 py-3 text-sm">
支付窗口未打开?
<a
className="ml-2 underline"
href={pendingCheckoutUrl}
target="_blank"
rel="noreferrer"
>
点击此处继续前往爱发电
</a>
</p>
)}
{!config?.enabled ? (
<p className="border-ink/15 text-ink-secondary border-y border-dashed py-3 text-sm">
续添功德暂未开放;已有功德与站外订单认领不受影响。
</p>
) : null}
<label className="flex items-start gap-3 text-sm leading-7">
<input
type="checkbox"
checked={publicListing}
className="mt-1.5 size-4 accent-[#a51f18]"
onChange={(event) => setPublicListing(event.target.checked)}
/>
<span>
<span className="block text-base">本次功德完成后公开留名</span>
<span className="text-ink-secondary block text-xs leading-6">
默认开启,只公开角色基础信息与最高档位,不公开金额和次数。
</span>
</span>
</label>
<div className="grid gap-4 md:grid-cols-2">
{SPONSORSHIP_TIER_IDS.map((tier) => {
const item = config?.tiers[tier];
return (
<MeritTierCard
key={tier}
tier={tier}
action={
<InkButton
variant="outline"
className="!px-0 !text-current"
disabled={busy || !config?.enabled || !item?.configured}
onClick={() => void checkout(tier)}
>
落印结缘
</InkButton>
}
>
<p className="max-w-48 opacity-65">
一纸落印,只记同行,不添战力与数值。
</p>
</MeritTierCard>
);
})}
</div>
<section className="border-ink/20 grid items-end gap-4 border-t border-dashed pt-6 md:grid-cols-[minmax(0,1fr)_auto]">
<InkInput
label="站外订单认领码"
value={claimCode}
onChange={(value) => setClaimCode(value.toUpperCase())}
placeholder="请输入爱发电私信中的功德认领码"
disabled={busy}
/>
<InkButton
variant="outline"
pending={busy}
disabled={!claimCode.trim()}
onClick={() => void claim()}
>
认领至当前角色
</InkButton>
</section>
</div>
)}
</GameSceneFrame>
);
}
+256 -2
View File
@@ -1,3 +1,4 @@
import type { BattleReplayV1 } from '@shared/contracts/battleReplay';
import type {
ResourceChangeOperation,
ResourceScopeKind,
@@ -7,9 +8,9 @@ import type {
ItemLibraryEditorConfig,
ItemLibraryPayload,
} from '@shared/lib/itemLibrary';
import type { SponsorshipTierId } from '@shared/lib/sponsorship';
import type { TowerPreparedEnemy } from '@shared/lib/tower';
import type { BattleRecordV3 } from '@shared/types/battle';
import type { BattleReplayV1 } from '@shared/contracts/battleReplay';
import type {
AlchemyFormulaBlueprint,
AlchemyFormulaMastery,
@@ -636,7 +637,13 @@ export const consumables = pgTable(
table.stackKey,
),
uniqueIndex('consumables_cultivator_stack_unique')
.on(table.cultivatorId, table.name, table.quality, table.type, table.stackKey)
.on(
table.cultivatorId,
table.name,
table.quality,
table.type,
table.stackKey,
)
.where(sql`${table.stackKey} is not null`),
index('consumables_score_idx').on(table.score),
],
@@ -964,6 +971,253 @@ export const appSettings = pgTable('wanjiedaoyou_app_settings', {
updatedBy: uuid('updated_by'),
});
export type SponsorshipVerificationStatus =
| 'received'
| 'signature_verified'
| 'api_verifying'
| 'verified'
| 'rejected'
| 'needs_attention';
export type SponsorshipFulfillmentStatus =
| 'pending'
| 'linked'
| 'awaiting_claim'
| 'fulfilling'
| 'fulfilled'
| 'retry_wait'
| 'needs_attention'
| 'revoked';
export const sponsorshipCheckoutIntents = pgTable(
'wanjiedaoyou_sponsorship_checkout_intents',
{
id: uuid('id').primaryKey().defaultRandom(),
provider: varchar('provider', { length: 32 }).notNull(),
userId: uuid('user_id'),
cultivatorId: uuid('cultivator_id').references(() => cultivators.id, {
onDelete: 'set null',
}),
tier: varchar('tier', { length: 32 }).$type<SponsorshipTierId>().notNull(),
expectedPlanId: varchar('expected_plan_id', { length: 80 }),
publicListing: boolean('public_listing').notNull().default(true),
status: varchar('status', { length: 24 }).notNull().default('pending'),
providerOrderId: varchar('provider_order_id', { length: 80 }),
configSnapshot: jsonb('config_snapshot').notNull(),
expiresAt: timestamp('expires_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => [
uniqueIndex('sponsorship_checkout_provider_order_uidx').on(
table.provider,
table.providerOrderId,
),
index('sponsorship_checkout_user_created_idx').on(
table.userId,
table.createdAt,
),
index('sponsorship_checkout_status_expires_idx').on(
table.status,
table.expiresAt,
),
],
);
export const sponsorshipOrders = pgTable(
'wanjiedaoyou_sponsorship_orders',
{
id: uuid('id').primaryKey().defaultRandom(),
provider: varchar('provider', { length: 32 }).notNull(),
providerOrderId: varchar('provider_order_id', { length: 80 }).notNull(),
customOrderId: varchar('custom_order_id', { length: 128 }),
providerUserId: varchar('provider_user_id', { length: 80 }),
planId: varchar('plan_id', { length: 80 }),
skuId: varchar('sku_id', { length: 80 }),
productType: integer('product_type'),
totalAmountFen: integer('total_amount_fen'),
showAmountFen: integer('show_amount_fen'),
month: integer('month'),
providerStatus: integer('provider_status'),
providerCreatedAt: timestamp('provider_created_at'),
verificationStatus: varchar('verification_status', { length: 24 })
.$type<SponsorshipVerificationStatus>()
.notNull()
.default('received'),
fulfillmentStatus: varchar('fulfillment_status', { length: 24 })
.$type<SponsorshipFulfillmentStatus>()
.notNull()
.default('pending'),
resolvedTier: varchar('resolved_tier', {
length: 32,
}).$type<SponsorshipTierId>(),
checkoutIntentId: uuid('checkout_intent_id').references(
() => sponsorshipCheckoutIntents.id,
{ onDelete: 'set null' },
),
configSnapshot: jsonb('config_snapshot'),
retryCount: integer('retry_count').notNull().default(0),
lastErrorCode: varchar('last_error_code', { length: 80 }),
lastErrorMessage: text('last_error_message'),
signatureVerifiedAt: timestamp('signature_verified_at'),
verifiedAt: timestamp('verified_at'),
fulfilledAt: timestamp('fulfilled_at'),
revokedAt: timestamp('revoked_at'),
sensitivePurgedAt: timestamp('sensitive_purged_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => [
uniqueIndex('sponsorship_orders_provider_order_uidx').on(
table.provider,
table.providerOrderId,
),
index('sponsorship_orders_verification_created_idx').on(
table.verificationStatus,
table.createdAt,
),
index('sponsorship_orders_fulfillment_created_idx').on(
table.fulfillmentStatus,
table.createdAt,
),
index('sponsorship_orders_provider_user_idx').on(table.providerUserId),
],
);
export const sponsorshipOrderSnapshots = pgTable(
'wanjiedaoyou_sponsorship_order_snapshots',
{
id: uuid('id').primaryKey().defaultRandom(),
orderId: uuid('order_id')
.references(() => sponsorshipOrders.id, { onDelete: 'cascade' })
.notNull(),
source: varchar('source', { length: 24 }).notNull(),
payload: jsonb('payload').notNull(),
purgeAfter: timestamp('purge_after').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => [
index('sponsorship_snapshots_order_created_idx').on(
table.orderId,
table.createdAt,
),
index('sponsorship_snapshots_purge_idx').on(table.purgeAfter),
],
);
export const sponsorshipMeritProfiles = pgTable(
'wanjiedaoyou_sponsorship_merit_profiles',
{
cultivatorId: uuid('cultivator_id')
.primaryKey()
.references(() => cultivators.id, { onDelete: 'cascade' }),
isPublic: boolean('is_public').notNull().default(true),
highestTier: varchar('highest_tier', { length: 32 })
.$type<SponsorshipTierId>()
.notNull(),
meritCount: integer('merit_count').notNull().default(0),
firstSupportedAt: timestamp('first_supported_at').notNull(),
lastSupportedAt: timestamp('last_supported_at').notNull(),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => [
index('sponsorship_merit_public_tier_first_idx').on(
table.isPublic,
table.highestTier,
table.firstSupportedAt,
),
],
);
export const sponsorshipMeritRecords = pgTable(
'wanjiedaoyou_sponsorship_merit_records',
{
id: uuid('id').primaryKey().defaultRandom(),
orderId: uuid('order_id').references(() => sponsorshipOrders.id, {
onDelete: 'set null',
}),
cultivatorId: uuid('cultivator_id')
.references(() => cultivators.id, { onDelete: 'cascade' })
.notNull(),
tier: varchar('tier', { length: 32 }).$type<SponsorshipTierId>().notNull(),
source: varchar('source', { length: 32 }).notNull(),
supportedAt: timestamp('supported_at').notNull(),
mailId: uuid('mail_id').references(() => mails.id, {
onDelete: 'set null',
}),
createdBy: uuid('created_by'),
revokedAt: timestamp('revoked_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => [
uniqueIndex('sponsorship_merit_order_uidx').on(table.orderId),
index('sponsorship_merit_cultivator_supported_idx').on(
table.cultivatorId,
table.supportedAt,
),
],
);
export const sponsorshipClaims = pgTable(
'wanjiedaoyou_sponsorship_claims',
{
id: uuid('id').primaryKey().defaultRandom(),
orderId: uuid('order_id')
.references(() => sponsorshipOrders.id, { onDelete: 'cascade' })
.notNull(),
codeHash: varchar('code_hash', { length: 64 }).notNull(),
code: text('code').notNull(),
publicListing: boolean('public_listing').notNull().default(true),
version: integer('version').notNull().default(1),
status: varchar('status', { length: 24 }).notNull().default('active'),
expiresAt: timestamp('expires_at').notNull(),
cultivatorId: uuid('cultivator_id').references(() => cultivators.id, {
onDelete: 'set null',
}),
claimedAt: timestamp('claimed_at'),
messageStatus: varchar('message_status', { length: 24 })
.notNull()
.default('pending'),
messageAttempts: integer('message_attempts').notNull().default(0),
lastMessageError: text('last_message_error'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
},
(table) => [
uniqueIndex('sponsorship_claims_order_uidx').on(table.orderId),
uniqueIndex('sponsorship_claims_code_hash_uidx').on(table.codeHash),
index('sponsorship_claims_status_expires_idx').on(
table.status,
table.expiresAt,
),
],
);
export const sponsorshipAdminActions = pgTable(
'wanjiedaoyou_sponsorship_admin_actions',
{
id: uuid('id').primaryKey().defaultRandom(),
adminUserId: uuid('admin_user_id').notNull(),
action: varchar('action', { length: 64 }).notNull(),
orderId: uuid('order_id').references(() => sponsorshipOrders.id, {
onDelete: 'set null',
}),
metadata: jsonb('metadata').notNull().default({}),
createdAt: timestamp('created_at').notNull().defaultNow(),
},
(table) => [
index('sponsorship_admin_actions_admin_created_idx').on(
table.adminUserId,
table.createdAt,
),
index('sponsorship_admin_actions_order_created_idx').on(
table.orderId,
table.createdAt,
),
],
);
export const itemLibrary = pgTable(
'wanjiedaoyou_item_library',
{
+58 -6
View File
@@ -27,6 +27,13 @@ import {
ITEM_LIBRARY_SYSTEM_USER_ID,
} from '@server/lib/services/MaterialLibraryService';
import { sendWeeklyRankingRewardCommand } from '@server/lib/services/RankingApplicationService';
import {
cleanupSponsorshipSensitiveData,
reconcileAfdianOrders,
retryPendingSponsorshipWork,
sendSponsorshipAdminDigest,
} from '@server/lib/services/SponsorshipApplicationService';
import { getSponsorshipProvider } from '@server/lib/sponsorship/providerRegistry';
import { towerEnemySetService } from '@server/lib/tower/enemySets';
import { RANKING_REWARDS, REALM_VALUES } from '@shared/types/constants';
import { eq } from 'drizzle-orm';
@@ -57,6 +64,55 @@ export type CronJobResult = {
reason?: string;
};
export async function runSponsorshipReconcileJob(
deep = false,
): Promise<CronJobResult> {
return withJobLock(
deep ? 'sponsorship-deep-reconcile' : 'sponsorship-reconcile',
async () => {
const provider = getSponsorshipProvider();
if (!provider?.isConfigured()) {
return {
success: true,
processed: 0,
skipped: true,
reason: 'sponsorship_provider_disabled',
};
}
const result = await reconcileAfdianOrders({ pages: deep ? 50 : 2 });
const retried = await retryPendingSponsorshipWork();
return {
success: true,
processed: result.scanned + retried,
skipped: false,
};
},
);
}
export async function runSponsorshipCleanupJob(): Promise<CronJobResult> {
return withJobLock('sponsorship-cleanup', async () => {
const result = await cleanupSponsorshipSensitiveData();
return {
success: true,
processed:
result.snapshots +
result.orders +
result.claims +
result.checkoutIntents,
skipped: false,
};
});
}
export async function runSponsorshipAdminDigestJob(): Promise<CronJobResult> {
return withJobLock('sponsorship-admin-digest', async () => ({
success: true,
processed: await sendSponsorshipAdminDigest(),
skipped: false,
}));
}
export type RankRewardsJobResult = CronJobResult & {
settlementDate?: string;
logs?: string[];
@@ -402,12 +458,8 @@ export async function runExpiredDataCleanupJob(): Promise<
reputationShopPurchases: new Date(
now - REPUTATION_SHOP_PURCHASE_RETENTION_MS,
),
sectShopPurchases: new Date(
now - SECT_SHOP_PURCHASE_RETENTION_MS,
),
sectStipendClaims: new Date(
now - SECT_STIPEND_CLAIM_RETENTION_MS,
),
sectShopPurchases: new Date(now - SECT_SHOP_PURCHASE_RETENTION_MS),
sectStipendClaims: new Date(now - SECT_STIPEND_CLAIM_RETENTION_MS),
auctionListings: new Date(now - AUCTION_LISTING_RETENTION_MS),
},
tx,
@@ -12,6 +12,10 @@ const RESOURCE_REPLAY_CLEANUP_SCHEDULE = '30 18 * * *';
const EXPIRED_DATA_CLEANUP_SCHEDULE = '45 18 * * *';
// 17:00 UTC equals 01:00 Asia/Shanghai.
const MATERIAL_LIBRARY_DAILY_GENERATION_SCHEDULE = '0 17 * * *';
const SPONSORSHIP_RECONCILE_SCHEDULE = '*/10 * * * *';
const SPONSORSHIP_DEEP_RECONCILE_SCHEDULE = '15 19 * * *';
const SPONSORSHIP_CLEANUP_SCHEDULE = '30 19 * * *';
const SPONSORSHIP_ADMIN_DIGEST_SCHEDULE = '0 1 * * *';
let schedulerRegistered = false;
let scheduledTasks: Bun.CronJob[] = [];
@@ -58,6 +62,18 @@ export function registerInternalCronJobs(
Bun.cron(MATERIAL_LIBRARY_DAILY_GENERATION_SCHEDULE, () =>
runScheduledJob('material-library.generate'),
),
Bun.cron(SPONSORSHIP_RECONCILE_SCHEDULE, () =>
runScheduledJob('sponsorship.reconcile'),
),
Bun.cron(SPONSORSHIP_DEEP_RECONCILE_SCHEDULE, () =>
runScheduledJob('sponsorship.deep-reconcile'),
),
Bun.cron(SPONSORSHIP_CLEANUP_SCHEDULE, () =>
runScheduledJob('sponsorship.cleanup'),
),
Bun.cron(SPONSORSHIP_ADMIN_DIGEST_SCHEDULE, () =>
runScheduledJob('sponsorship.admin-digest'),
),
];
schedulerRegistered = true;
@@ -73,6 +89,10 @@ export function registerInternalCronJobs(
materialLibraryDailyGenerationUtc:
MATERIAL_LIBRARY_DAILY_GENERATION_SCHEDULE,
materialLibraryDailyGenerationLocal: '01:00 Asia/Shanghai',
sponsorshipReconcile: SPONSORSHIP_RECONCILE_SCHEDULE,
sponsorshipDeepReconcileUtc: SPONSORSHIP_DEEP_RECONCILE_SCHEDULE,
sponsorshipCleanupUtc: SPONSORSHIP_CLEANUP_SCHEDULE,
sponsorshipAdminDigestUtc: SPONSORSHIP_ADMIN_DIGEST_SCHEDULE,
});
return scheduledTasks;
+10 -1
View File
@@ -13,6 +13,9 @@ import {
runMaterialLibraryDailyGenerationJob,
runRankRewardsJob,
runResourceReplayCleanupJob,
runSponsorshipAdminDigestJob,
runSponsorshipCleanupJob,
runSponsorshipReconcileJob,
runTowerEnemySetRefreshJob,
} from '../jobs/internalCron';
import {
@@ -24,7 +27,9 @@ import {
const MAX_PROCESSING_ATTEMPTS = 10;
const WORKING_INTERVAL_MS = 30_000;
const CONSUMER_RESTART_DELAYS_MS = [1_000, 2_000, 5_000, 10_000, 30_000] as const;
const CONSUMER_RESTART_DELAYS_MS = [
1_000, 2_000, 5_000, 10_000, 30_000,
] as const;
const codec = JSONCodec();
let runningMessages: ConsumerMessages | undefined;
let consumerTask: Promise<void> | undefined;
@@ -44,6 +49,10 @@ const handlers = {
'expired-data.cleanup': () => runExpiredDataCleanupJob(),
'material-library.generate': (command) =>
runMaterialLibraryDailyGenerationJob(new Date(command.requestedAt)),
'sponsorship.reconcile': () => runSponsorshipReconcileJob(false),
'sponsorship.deep-reconcile': () => runSponsorshipReconcileJob(true),
'sponsorship.cleanup': () => runSponsorshipCleanupJob(),
'sponsorship.admin-digest': () => runSponsorshipAdminDigestJob(),
} satisfies Record<
BackgroundCommandType,
(command: BackgroundCommandEnvelope) => Promise<unknown>
+38
View File
@@ -8,6 +8,9 @@ import {
import { projectSectConstructionDonation } from '@server/lib/services/sect-organization/SectConstructionSettlementService';
import { projectTaskDomainEvent } from '@server/lib/services/TaskDomainEventProjector';
import { projectWorldRumorDomainEvent } from '@server/lib/services/WorldRumorDomainEventProjector';
import { processSponsorshipOrder } from '@server/lib/services/SponsorshipApplicationService';
import { db } from '@server/lib/drizzle/db';
import { claimMessageForConsumer } from '@server/lib/repositories/messageConsumptionRepository';
import {
generateYieldRewardAttachments,
projectYieldReward,
@@ -108,6 +111,12 @@ export async function registerMessageInfrastructure(): Promise<void> {
acceptedTypes: ['mail.created'],
handle: handleMailCreatedEvent,
}),
startDomainEventConsumer({
consumerName: DOMAIN_EVENT_CONSUMERS.sponsorshipOrderProjector.name,
concurrency: DOMAIN_EVENT_CONSUMERS.sponsorshipOrderProjector.concurrency,
acceptedTypes: ['sponsorship.order.received'],
handle: handleSponsorshipOrderEvent,
}),
]);
startTransactionalMessageRelay();
registered = true;
@@ -180,6 +189,35 @@ async function handleMailCreatedEvent(event: DomainEventEnvelope) {
});
}
async function handleSponsorshipOrderEvent(event: DomainEventEnvelope) {
if (!isDomainEventType(event, 'sponsorship.order.received')) {
throw new Error(`功德订单投影不支持领域事件: ${event.type}`);
}
const alreadyProcessed = await db.query.messageConsumptions.findFirst({
columns: { messageId: true },
where: (rows, { and, eq }) =>
and(
eq(
rows.consumerName,
DOMAIN_EVENT_CONSUMERS.sponsorshipOrderProjector.name,
),
eq(rows.messageId, event.id),
),
});
if (alreadyProcessed) return;
await processSponsorshipOrder(event.data.orderId);
await db.transaction(async (tx) => {
await claimMessageForConsumer(
{
consumerName: DOMAIN_EVENT_CONSUMERS.sponsorshipOrderProjector.name,
messageId: event.id,
messageKey: event.type,
},
tx,
);
});
}
export async function shutdownMessageInfrastructure(): Promise<void> {
if (!registered) return;
registered = false;
+5
View File
@@ -96,6 +96,11 @@ export const DOMAIN_EVENT_CONSUMERS = {
filterSubject: `${DOMAIN_EVENT_SUBJECT_PREFIX}.communication.mail-created.v1`,
concurrency: 8,
},
sponsorshipOrderProjector: {
name: 'sponsorship-order-projector-v1',
filterSubject: `${DOMAIN_EVENT_SUBJECT_PREFIX}.sponsorship.order-received.v1`,
concurrency: 4,
},
} as const;
const DOMAIN_EVENT_STREAM_CONFIG: Partial<StreamConfig> = {
+3
View File
@@ -28,6 +28,9 @@ export const redisLockKeys = {
`lock:black-market:session:${sessionId}`,
dungeonCommand: (cultivatorId: string) =>
`lock:dungeon:command:${cultivatorId}`,
sponsorshipOrder: (orderId: string) => `lock:sponsorship:order:${orderId}`,
sponsorshipCultivator: (cultivatorId: string) =>
`lock:sponsorship:cultivator:${cultivatorId}`,
} as const;
export class RedisLeaseLostError extends Error {
@@ -1,4 +1,4 @@
import { getExecutor } from '@server/lib/drizzle/db';
import { getExecutor, type DbTransaction } from '@server/lib/drizzle/db';
import { appSettings } from '@server/lib/drizzle/schema';
import {
APP_SETTING_KEYS,
@@ -7,6 +7,11 @@ import {
ItemLibraryDailyMaterialGenerationSettingsSchema,
type ItemLibraryDailyMaterialGenerationSettings,
} from '@shared/lib/constants/appSettings';
import {
AfdianSponsorshipConfigSchema,
DEFAULT_AFDIAN_SPONSORSHIP_CONFIG,
type AfdianSponsorshipConfig,
} from '@shared/lib/sponsorship';
import { eq } from 'drizzle-orm';
export async function getAppSetting(key: string): Promise<string | null> {
@@ -20,12 +25,15 @@ export async function getAppSetting(key: string): Promise<string | null> {
return v ? v : null;
}
export async function upsertAppSetting(params: {
key: string;
value: string;
updatedBy: string;
}): Promise<void> {
const q = getExecutor();
export async function upsertAppSetting(
params: {
key: string;
value: string;
updatedBy: string;
},
tx?: DbTransaction,
): Promise<void> {
const q = getExecutor(tx);
await q
.insert(appSettings)
.values({
@@ -83,3 +91,31 @@ export async function upsertItemLibraryDailyMaterialGenerationSettings(params: {
updatedBy: params.updatedBy,
});
}
export async function getAfdianSponsorshipConfig(): Promise<AfdianSponsorshipConfig> {
const raw = await getAppSetting(APP_SETTING_KEYS.afdianSponsorship);
if (!raw) return structuredClone(DEFAULT_AFDIAN_SPONSORSHIP_CONFIG);
try {
return AfdianSponsorshipConfigSchema.parse(JSON.parse(raw));
} catch {
return structuredClone(DEFAULT_AFDIAN_SPONSORSHIP_CONFIG);
}
}
export async function upsertAfdianSponsorshipConfig(
params: {
config: AfdianSponsorshipConfig;
updatedBy: string;
},
tx?: DbTransaction,
): Promise<void> {
const config = AfdianSponsorshipConfigSchema.parse(params.config);
await upsertAppSetting(
{
key: APP_SETTING_KEYS.afdianSponsorship,
value: JSON.stringify(config),
updatedBy: params.updatedBy,
},
tx,
);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,192 @@
import {
buildAfdianCheckoutUrl,
buildAfdianWebhookSignedText,
createAfdianApiEnvelope,
} from '@shared/lib/afdianProtocol';
import { parseCnyAmountToFen } from '@shared/lib/sponsorship';
import { createPublicKey, verify as verifySignature } from 'node:crypto';
import { z } from 'zod';
import type {
ProviderOrder,
SponsorshipCheckoutRequest,
SponsorshipProvider,
VerifiedWebhook,
} from './types';
const AFDIAN_API_BASE_URL = 'https://ifdian.net';
const AFDIAN_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwwdaCg1Bt+UKZKs0R54y
lYnuANma49IpgoOwNmk3a0rhg/PQuhUJ0EOZSowIC44l0K3+fqGns3Ygi4AfmEfS
4EKbdk1ahSxu7Zkp2rHMt+R9GarQFQkwSS/5x1dYiHNVMiR8oIXDgjmvxuNes2Cr
8fw9dEF0xNBKdkKgG2qAawcN1nZrdyaKWtPVT9m2Hl0ddOO9thZmVLFOb9NVzgYf
jEgI+KWX6aY19Ka/ghv/L4t1IXmz9pctablN5S0CRWpJW3Cn0k6zSXgjVdKm4uN7
jRlgSRaf/Ind46vMCm3N2sgwxu/g3bnooW+db0iLo13zzuvyn727Q3UDQ0MmZcEW
MQIDAQAB
-----END PUBLIC KEY-----`;
const AfdianSkuSchema = z.object({
sku_id: z.string().optional().default(''),
});
const AfdianOrderSchema = z
.object({
out_trade_no: z.string().min(1).max(80),
custom_order_id: z.string().max(128).optional().default(''),
user_id: z.string().min(1).max(80),
plan_id: z.string().max(80).optional().default(''),
month: z.coerce.number().int().nonnegative().optional(),
total_amount: z.string(),
show_amount: z.string(),
status: z.coerce.number().int(),
product_type: z.coerce.number().int().optional(),
create_time: z.coerce.number().int().positive().optional(),
sku_detail: z.array(AfdianSkuSchema).optional().default([]),
})
.passthrough();
const AfdianWebhookSchema = z.object({
data: z.object({
type: z.literal('order'),
order: AfdianOrderSchema,
sign: z.string().min(1),
}),
});
const AfdianApiResponseSchema = z.object({
ec: z.number().int(),
em: z.string().optional(),
data: z.unknown().optional(),
});
function mapOrder(
input: z.infer<typeof AfdianOrderSchema>,
raw: unknown,
): ProviderOrder {
const totalAmountFen = parseCnyAmountToFen(input.total_amount);
const showAmountFen = parseCnyAmountToFen(input.show_amount);
if (totalAmountFen === null || showAmountFen === null) {
throw new Error('爱发电订单金额格式无效');
}
return {
provider: 'afdian',
providerOrderId: input.out_trade_no,
customOrderId: input.custom_order_id || null,
providerUserId: input.user_id,
planId: input.plan_id || null,
skuId: input.sku_detail[0]?.sku_id || null,
productType: input.product_type ?? null,
totalAmountFen,
showAmountFen,
month: input.month ?? null,
status: input.status,
createdAt: input.create_time ? new Date(input.create_time * 1_000) : null,
raw,
};
}
export class AfdianSponsorshipProvider implements SponsorshipProvider {
readonly id = 'afdian' as const;
isConfigured(): boolean {
return Boolean(
process.env.AFDIAN_USER_ID?.trim() && process.env.AFDIAN_TOKEN?.trim(),
);
}
verifyWebhook(payload: unknown): VerifiedWebhook {
const parsed = AfdianWebhookSchema.parse(payload);
const order = parsed.data.order;
const signed = buildAfdianWebhookSignedText({
outTradeNo: order.out_trade_no,
userId: order.user_id,
planId: order.plan_id,
totalAmount: order.total_amount,
});
const valid = verifySignature(
'sha256',
Buffer.from(signed, 'utf8'),
createPublicKey(AFDIAN_PUBLIC_KEY),
Buffer.from(parsed.data.sign, 'base64'),
);
if (!valid) throw new Error('爱发电 Webhook 签名无效');
return { order: mapOrder(order, payload), raw: payload };
}
async queryOrder(providerOrderId: string): Promise<ProviderOrder | null> {
const data = await this.request('/api/open/query-order', {
out_trade_no: providerOrderId,
});
const list = z
.object({ list: z.array(AfdianOrderSchema) })
.parse(data).list;
const row = list.find((item) => item.out_trade_no === providerOrderId);
return row ? mapOrder(row, row) : null;
}
async listOrders(page: number, perPage: number): Promise<ProviderOrder[]> {
const data = await this.request('/api/open/query-order', {
page,
per_page: perPage,
});
return z
.object({ list: z.array(AfdianOrderSchema) })
.parse(data)
.list.map((row) => mapOrder(row, row));
}
async sendMessage(recipient: string, content: string): Promise<void> {
await this.request('/api/open/send-msg', { recipient, content });
}
async ping(): Promise<void> {
await this.request('/api/open/ping', { ping: 'daoyou' });
}
buildCheckoutUrl(input: SponsorshipCheckoutRequest): URL {
return buildAfdianCheckoutUrl({
intentId: input.intentId,
planId: input.planId,
customAmountFen: input.customAmountFen,
creatorUserId: input.planId
? undefined
: this.requireCredentials().userId,
});
}
private requireCredentials(): { userId: string; token: string } {
const userId = process.env.AFDIAN_USER_ID?.trim();
const token = process.env.AFDIAN_TOKEN?.trim();
if (!userId || !token) throw new Error('爱发电 Provider 未配置');
return { userId, token };
}
private async request(
path: string,
params: Record<string, unknown>,
): Promise<unknown> {
const credentials = this.requireCredentials();
const ts = Math.floor(Date.now() / 1_000);
const envelope = createAfdianApiEnvelope({
userId: credentials.userId,
token: credentials.token,
params,
timestampSeconds: ts,
});
const response = await fetch(`${AFDIAN_API_BASE_URL}${path}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(envelope),
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`爱发电 API HTTP ${response.status}`);
const parsed = AfdianApiResponseSchema.parse(await response.json());
if (parsed.ec !== 200) {
throw new Error(
`爱发电 API ${parsed.ec}: ${parsed.em ?? 'unknown error'}`,
);
}
return parsed.data ?? {};
}
}
export const afdianSponsorshipProvider = new AfdianSponsorshipProvider();
+5
View File
@@ -0,0 +1,5 @@
import { createHash } from 'node:crypto';
export function hashSponsorshipClaimCode(code: string): string {
return createHash('sha256').update(code.trim().toUpperCase()).digest('hex');
}
@@ -0,0 +1,17 @@
import { afdianSponsorshipProvider } from './afdianProvider';
import type { SponsorshipProvider } from './types';
export function getSponsorshipProvider(): SponsorshipProvider | null {
const provider = process.env.SPONSORSHIP_PROVIDER?.trim() || 'disabled';
if (provider === 'disabled') return null;
if (provider === 'afdian') return afdianSponsorshipProvider;
throw new Error(`Unsupported SPONSORSHIP_PROVIDER: ${provider}`);
}
export function requireSponsorshipProvider(): SponsorshipProvider {
const provider = getSponsorshipProvider();
if (!provider || !provider.isConfigured()) {
throw new Error('赞助 Provider 未启用或配置不完整');
}
return provider;
}
+40
View File
@@ -0,0 +1,40 @@
import type { SponsorshipTierId } from '@shared/lib/sponsorship';
export type ProviderOrder = {
provider: 'afdian';
providerOrderId: string;
customOrderId: string | null;
providerUserId: string;
planId: string | null;
skuId: string | null;
productType: number | null;
totalAmountFen: number;
showAmountFen: number;
month: number | null;
status: number;
createdAt: Date | null;
raw: unknown;
};
export type VerifiedWebhook = {
order: ProviderOrder;
raw: unknown;
};
export type SponsorshipCheckoutRequest = {
intentId: string;
tier: SponsorshipTierId;
planId: string | null;
customAmountFen?: number;
};
export interface SponsorshipProvider {
readonly id: 'afdian';
isConfigured(): boolean;
verifyWebhook(payload: unknown): VerifiedWebhook;
queryOrder(providerOrderId: string): Promise<ProviderOrder | null>;
listOrders(page: number, perPage: number): Promise<ProviderOrder[]>;
sendMessage(recipient: string, content: string): Promise<void>;
ping(): Promise<void>;
buildCheckoutUrl(input: SponsorshipCheckoutRequest): URL;
}
+2
View File
@@ -13,6 +13,7 @@ import onlineBattlesRouter from '@server/routes/api/admin/online-battles.router'
import redeemCodesRouter from '@server/routes/api/admin/redeem-codes.router';
import reputationShopRouter from '@server/routes/api/admin/reputation-shop.router';
import sectShopRouter from '@server/routes/api/admin/sect-shop.router';
import sponsorshipRouter from '@server/routes/api/admin/sponsorship.router';
import templatesRouter from '@server/routes/api/admin/templates.router';
import towerEnemySetsRouter from '@server/routes/api/admin/tower-enemy-sets.router';
import { Hono } from 'hono';
@@ -37,6 +38,7 @@ router.route('/item-library', itemLibraryRouter);
router.route('/redeem-codes', redeemCodesRouter);
router.route('/reputation-shop', reputationShopRouter);
router.route('/sect-shop', sectShopRouter);
router.route('/sponsorship', sponsorshipRouter);
router.route('/community-group', communityGroupRouter);
router.route('/llm-metrics', llmMetricsRouter);
router.route('/online-users', onlineUsersRouter);
@@ -0,0 +1,199 @@
import { requireAdmin } from '@server/lib/hono/middleware';
import type { AppEnv } from '@server/lib/hono/types';
import { getAfdianSponsorshipConfig } from '@server/lib/repositories/appSettingsRepository';
import {
getSponsorshipOrderForAdmin,
grantManualSponsorshipMerit,
listSponsorshipOrdersForAdmin,
retrySponsorshipOrderAsAdmin,
revealSponsorshipSnapshot,
revokeSponsorshipOrderAsAdmin,
rotateSponsorshipClaimAsAdmin,
SponsorshipApplicationError,
updateSponsorshipConfigAsAdmin,
} from '@server/lib/services/SponsorshipApplicationService';
import { requireSponsorshipProvider } from '@server/lib/sponsorship/providerRegistry';
import {
AfdianSponsorshipConfigSchema,
SPONSORSHIP_TIER_IDS,
} from '@shared/lib/sponsorship';
import { Hono, type Context } from 'hono';
import { z } from 'zod';
const TierConfigSchema = z
.object({
planId: z.string().trim().max(80),
minimumAmountFen: z.number().int().min(1).max(100_000_000),
})
.strict();
const ConfigUpdateSchema = z
.object({
tiers: z.object({
faint_light: TierConfigSchema,
fellow_traveler: TierConfigSchema,
night_guardian: TierConfigSchema,
immortality_witness: TierConfigSchema,
}),
})
.strict();
const ManualGrantSchema = z
.object({
cultivatorId: z.uuid(),
tier: z.enum(SPONSORSHIP_TIER_IDS),
supportedAt: z.string().datetime().optional(),
publicListing: z.boolean().default(true),
sendMail: z.boolean().default(true),
})
.strict();
const router = new Hono<AppEnv>();
router.use('*', requireAdmin());
function sponsorshipErrorResponse(c: Context<AppEnv>, error: unknown) {
if (error instanceof SponsorshipApplicationError) {
return c.json({ error: error.message, code: error.code }, error.status);
}
throw error;
}
router.get('/config', async (c) => c.json(await getAfdianSponsorshipConfig()));
router.put('/config', async (c) => {
const user = c.get('user')!;
const parsed = ConfigUpdateSchema.safeParse(
await c.req.json().catch(() => null),
);
if (!parsed.success)
return c.json({ error: '参数错误', details: parsed.error.flatten() }, 400);
const current = await getAfdianSponsorshipConfig();
const acceptingCheckout = Object.values(parsed.data.tiers).some(
(tier) => tier.planId.length > 0,
);
const ordersAcceptedAfter = acceptingCheckout
? current.acceptingCheckout && current.ordersAcceptedAfter
? current.ordersAcceptedAfter
: new Date().toISOString()
: current.ordersAcceptedAfter;
const nextConfig = AfdianSponsorshipConfigSchema.safeParse({
...current,
acceptingCheckout,
ordersAcceptedAfter,
tiers: parsed.data.tiers,
});
if (!nextConfig.success) {
return c.json(
{ error: '档位配置错误', details: nextConfig.error.flatten() },
400,
);
}
await updateSponsorshipConfigAsAdmin({
config: nextConfig.data,
adminUserId: user.id,
});
return c.json({ success: true });
});
router.post('/ping', async (c) => {
try {
await requireSponsorshipProvider().ping();
return c.json({ success: true });
} catch {
return c.json({ error: '爱发电连接测试失败,请检查服务端配置' }, 502);
}
});
router.get('/orders', async (c) => {
const parsed = z
.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
filter: z
.enum(['all', 'attention', 'awaiting_claim', 'fulfilled', 'revoked'])
.default('all'),
})
.safeParse(c.req.query());
if (!parsed.success) return c.json({ error: '查询参数错误' }, 400);
const result = await listSponsorshipOrdersForAdmin(parsed.data);
return c.json({
orders: result.items,
total: result.total,
page: result.page,
pageSize: result.pageSize,
});
});
router.get('/orders/:id', async (c) => {
const id = z.uuid().safeParse(c.req.param('id'));
if (!id.success) return c.json({ error: '订单不存在' }, 404);
const detail = await getSponsorshipOrderForAdmin(id.data);
return detail ? c.json(detail) : c.json({ error: '订单不存在' }, 404);
});
router.post('/orders/:id/retry', async (c) => {
const id = z.uuid().safeParse(c.req.param('id'));
if (!id.success) return c.json({ error: '订单不存在' }, 404);
try {
await retrySponsorshipOrderAsAdmin(id.data, c.get('user')!.id);
return c.json({ success: true });
} catch (error) {
return sponsorshipErrorResponse(c, error);
}
});
router.post('/orders/:id/revoke', async (c) => {
const id = z.uuid().safeParse(c.req.param('id'));
if (!id.success) return c.json({ error: '订单不存在' }, 404);
try {
await revokeSponsorshipOrderAsAdmin(id.data, c.get('user')!.id);
return c.json({ success: true });
} catch (error) {
return sponsorshipErrorResponse(c, error);
}
});
router.post('/orders/:id/rotate-claim', async (c) => {
const id = z.uuid().safeParse(c.req.param('id'));
if (!id.success) return c.json({ error: '订单不存在' }, 404);
try {
await rotateSponsorshipClaimAsAdmin(id.data, c.get('user')!.id);
return c.json({ success: true });
} catch (error) {
return sponsorshipErrorResponse(c, error);
}
});
router.post('/snapshots/:id/reveal', async (c) => {
const id = z.uuid().safeParse(c.req.param('id'));
if (!id.success) return c.json({ error: '快照不存在' }, 404);
const snapshot = await revealSponsorshipSnapshot({
snapshotId: id.data,
adminUserId: c.get('user')!.id,
});
return snapshot === null
? c.json({ error: '快照不存在' }, 404)
: c.json({ snapshot });
});
router.post('/manual-grants', async (c) => {
const parsed = ManualGrantSchema.safeParse(
await c.req.json().catch(() => null),
);
if (!parsed.success)
return c.json({ error: '参数错误', details: parsed.error.flatten() }, 400);
try {
await grantManualSponsorshipMerit({
...parsed.data,
supportedAt: parsed.data.supportedAt
? new Date(parsed.data.supportedAt)
: new Date(),
adminUserId: c.get('user')!.id,
});
return c.json({ success: true }, 201);
} catch (error) {
return sponsorshipErrorResponse(c, error);
}
});
export default router;
+2
View File
@@ -33,6 +33,7 @@ import realtimeRouter from '@server/routes/api/realtime.router';
import reputationShopRouter from '@server/routes/api/reputation-shop.router';
import saveCharacterRouter from '@server/routes/api/save-character.router';
import sectsRouter from '@server/routes/api/sects.router';
import sponsorshipRouter from '@server/routes/api/sponsorship.router';
import tasksRouter from '@server/routes/api/tasks.router';
import towerRouter from '@server/routes/api/tower.router';
import worldChatRouter from '@server/routes/api/world-chat.router';
@@ -107,6 +108,7 @@ apiRouter.route('/save-character', saveCharacterRouter);
apiRouter.route('/tasks', tasksRouter);
apiRouter.route('/tower', towerRouter);
apiRouter.route('/sects', sectsRouter);
apiRouter.route('/sponsorship', sponsorshipRouter);
apiRouter.route('/v2/products', productsRouter);
apiRouter.route('/world-chat', worldChatRouter);
+143
View File
@@ -0,0 +1,143 @@
import {
getValidatedJson,
getValidatedQuery,
requireActiveCultivatorRef,
validateJson,
validateQuery,
} from '@server/lib/hono/middleware';
import type { AppEnv } from '@server/lib/hono/types';
import {
claimSponsorshipOrder,
createSponsorshipCheckoutIntent,
getCheckoutIntentStatus,
getCultivatorMerit,
getSponsorshipClientConfig,
listPublicMeritProfiles,
recordAfdianWebhook,
SponsorshipApplicationError,
updateMeritVisibility,
} from '@server/lib/services/SponsorshipApplicationService';
import {
SponsorshipCheckoutRequestSchema,
SponsorshipClaimRequestSchema,
SponsorshipPublicQuerySchema,
SponsorshipVisibilityRequestSchema,
type SponsorshipCheckoutRequest,
type SponsorshipClaimRequest,
} from '@shared/contracts/sponsorship';
import { Hono, type Context } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import { z } from 'zod';
const router = new Hono<AppEnv>();
function sponsorshipErrorResponse(c: Context<AppEnv>, error: unknown) {
if (error instanceof SponsorshipApplicationError) {
return c.json({ error: error.message, code: error.code }, error.status);
}
throw error;
}
router.post(
'/providers/afdian/webhook',
bodyLimit({ maxSize: 256 * 1_024 }),
async (c) => {
try {
await recordAfdianWebhook(await c.req.json());
return c.json({ ec: 200, em: '' });
} catch (error) {
console.warn('[sponsorship-webhook] rejected', {
error: error instanceof Error ? error.message : 'unknown',
});
return c.json({ ec: 400, em: 'invalid webhook' }, 400);
}
},
);
router.get('/config', async (c) => c.json(await getSponsorshipClientConfig()));
router.get(
'/public',
validateQuery(SponsorshipPublicQuerySchema),
async (c) => {
const query = getValidatedQuery<{ page: number; pageSize: number }>(c);
return c.json(await listPublicMeritProfiles(query.page, query.pageSize));
},
);
router.get('/me', requireActiveCultivatorRef(), async (c) => {
const ref = c.get('activeCultivatorRef')!;
return c.json(await getCultivatorMerit(ref.cultivatorId));
});
router.patch(
'/me/visibility',
requireActiveCultivatorRef(),
validateJson(SponsorshipVisibilityRequestSchema),
async (c) => {
const ref = c.get('activeCultivatorRef')!;
const body = getValidatedJson<{ isPublic: boolean }>(c);
const updated = await updateMeritVisibility(
ref.cultivatorId,
body.isPublic,
);
return c.json({ success: true, updated });
},
);
router.post(
'/checkout-intents',
requireActiveCultivatorRef(),
validateJson(SponsorshipCheckoutRequestSchema),
async (c) => {
const user = c.get('user')!;
const ref = c.get('activeCultivatorRef')!;
const body = getValidatedJson<SponsorshipCheckoutRequest>(c);
try {
return c.json(
await createSponsorshipCheckoutIntent({
userId: user.id,
cultivatorId: ref.cultivatorId,
...body,
}),
201,
);
} catch (error) {
return sponsorshipErrorResponse(c, error);
}
},
);
router.get('/checkout-intents/:id', requireActiveCultivatorRef(), async (c) => {
const user = c.get('user')!;
const id = z.uuid().safeParse(c.req.param('id'));
if (!id.success) return c.json({ error: '下单意图不存在' }, 404);
const status = await getCheckoutIntentStatus({
id: id.data,
userId: user.id,
});
return status ? c.json(status) : c.json({ error: '下单意图不存在' }, 404);
});
router.post(
'/claims',
requireActiveCultivatorRef(),
validateJson(SponsorshipClaimRequestSchema),
async (c) => {
const user = c.get('user')!;
const ref = c.get('activeCultivatorRef')!;
const body = getValidatedJson<SponsorshipClaimRequest>(c);
try {
await claimSponsorshipOrder({
userId: user.id,
cultivatorId: ref.cultivatorId,
...body,
});
return c.json({ success: true });
} catch (error) {
return sponsorshipErrorResponse(c, error);
}
},
);
export default router;
+19
View File
@@ -7,6 +7,9 @@ import {
runResourceReplayCleanupJob,
runRankRewardsJob,
runTowerEnemySetRefreshJob,
runSponsorshipCleanupJob,
runSponsorshipReconcileJob,
runSponsorshipAdminDigestJob,
} from '@server/lib/jobs/internalCron';
import type { AppEnv } from '@server/lib/hono/types';
import { Hono } from 'hono';
@@ -92,4 +95,20 @@ router.get('/material-library-daily-generation', (c) =>
handleCronRequest(c.req.raw, runMaterialLibraryDailyGenerationJob),
);
router.get('/sponsorship-reconcile', (c) =>
handleCronRequest(c.req.raw, () => runSponsorshipReconcileJob(false)),
);
router.get('/sponsorship-deep-reconcile', (c) =>
handleCronRequest(c.req.raw, () => runSponsorshipReconcileJob(true)),
);
router.get('/sponsorship-cleanup', (c) =>
handleCronRequest(c.req.raw, runSponsorshipCleanupJob),
);
router.get('/sponsorship-admin-digest', (c) =>
handleCronRequest(c.req.raw, runSponsorshipAdminDigestJob),
);
export default router;
+16 -9
View File
@@ -64,6 +64,7 @@ export interface BreakthroughModifiers {
progressMultiplier: number; // 修为进度系数
insightMultiplier: number; // 感悟系数
demonPenalty: number; // 心魔惩罚
adjustedBaseChance: number; // 乘算修正后的综合基础成功率
fateBonus: number; // 命格加成
pillBonus: number; // 破境丹残留加成
toxicityPenalty: number; // 丹毒惩罚
@@ -164,20 +165,19 @@ export function calculateBreakthroughChance(
cultivator.condition,
fateContext.toxicityPenaltyMultiplier,
);
const adjustedBaseChance =
baseChance *
realmDifficulty *
progressMultiplier *
insightMultiplier *
demonPenalty;
// 计算最终成功率
const finalChance = Math.min(
1.0,
0.95,
Math.max(
0.05,
baseChance *
realmDifficulty *
progressMultiplier *
insightMultiplier *
demonPenalty +
fateBonus +
pillBonus +
-toxicityPenalty,
adjustedBaseChance + fateBonus + pillBonus - toxicityPenalty,
),
);
@@ -198,6 +198,7 @@ export function calculateBreakthroughChance(
progressMultiplier,
insightMultiplier,
demonPenalty,
adjustedBaseChance,
fateBonus,
pillBonus,
toxicityPenalty,
@@ -369,6 +370,7 @@ function createInsufficientExpResult(
progressMultiplier: 0,
insightMultiplier: 0,
demonPenalty: 0,
adjustedBaseChance: 0,
fateBonus: 0,
pillBonus: 0,
toxicityPenalty: 0,
@@ -393,6 +395,7 @@ function createMaxRealmResult(): BreakthroughChanceResult {
progressMultiplier: 0,
insightMultiplier: 0,
demonPenalty: 0,
adjustedBaseChance: 0,
fateBonus: 0,
pillBonus: 0,
toxicityPenalty: 0,
@@ -432,6 +435,10 @@ export function getBreakthroughChanceExplanation(
• 修为进度:×${format('.2f')(modifiers.progressMultiplier)}
• 感悟加成:×${format('.2f')(modifiers.insightMultiplier)}
${modifiers.demonPenalty < 1.0 ? `• 心魔惩罚:×${format('.2f')(modifiers.demonPenalty)}\n` : ''}
• 综合基础成功率:${format('.1%')(modifiers.adjustedBaseChance)}
• 命格机缘:${format('+.1%')(modifiers.fateBonus)}
• 破境药力:${format('+.1%')(modifiers.pillBonus)}
• 丹毒惩罚:-${format('.1%')(modifiers.toxicityPenalty)}
【建议】${result.recommendation}
`.trim();
}
@@ -12,6 +12,10 @@ export const BACKGROUND_COMMAND_TYPES = [
'resource-replay.cleanup',
'expired-data.cleanup',
'material-library.generate',
'sponsorship.reconcile',
'sponsorship.deep-reconcile',
'sponsorship.cleanup',
'sponsorship.admin-digest',
] as const;
export type BackgroundCommandType = (typeof BACKGROUND_COMMAND_TYPES)[number];
@@ -57,6 +61,26 @@ export const BACKGROUND_COMMAND_DEFINITIONS = {
subject: `${BACKGROUND_COMMAND_SUBJECT_PREFIX}.material-library-generate.v1`,
scheduleBucketMs: 24 * 60 * 60_000,
},
'sponsorship.reconcile': {
version: 1,
subject: `${BACKGROUND_COMMAND_SUBJECT_PREFIX}.sponsorship-reconcile.v1`,
scheduleBucketMs: 10 * 60_000,
},
'sponsorship.deep-reconcile': {
version: 1,
subject: `${BACKGROUND_COMMAND_SUBJECT_PREFIX}.sponsorship-deep-reconcile.v1`,
scheduleBucketMs: 24 * 60 * 60_000,
},
'sponsorship.cleanup': {
version: 1,
subject: `${BACKGROUND_COMMAND_SUBJECT_PREFIX}.sponsorship-cleanup.v1`,
scheduleBucketMs: 24 * 60 * 60_000,
},
'sponsorship.admin-digest': {
version: 1,
subject: `${BACKGROUND_COMMAND_SUBJECT_PREFIX}.sponsorship-admin-digest.v1`,
scheduleBucketMs: 24 * 60 * 60_000,
},
} as const satisfies Record<
BackgroundCommandType,
{ version: number; subject: string; scheduleBucketMs: number }
+12
View File
@@ -22,6 +22,7 @@ export const DOMAIN_EVENT_TYPES = [
'bet-battle.created',
'bet-battle.settled',
'ranking.position.changed',
'sponsorship.order.received',
] as const;
export type DomainEventType = (typeof DOMAIN_EVENT_TYPES)[number];
@@ -147,6 +148,13 @@ export const DomainEventDataSchemas = {
changeType: z.enum(['direct_entry', 'challenge_win', 'vacancy_entry']),
})
.strict(),
'sponsorship.order.received': z
.object({
orderId: z.uuid(),
provider: z.literal('afdian'),
providerOrderId: z.string().min(1).max(80),
})
.strict(),
} as const;
export type DomainEventData<TType extends DomainEventType> = z.infer<
@@ -202,6 +210,10 @@ export const DOMAIN_EVENT_DEFINITIONS = {
version: 1,
subject: `${DOMAIN_EVENT_SUBJECT_PREFIX}.gameplay.ranking-position-changed.v1`,
},
'sponsorship.order.received': {
version: 1,
subject: `${DOMAIN_EVENT_SUBJECT_PREFIX}.sponsorship.order-received.v1`,
},
} as const satisfies Record<
DomainEventType,
{ version: number; subject: string }
+33
View File
@@ -0,0 +1,33 @@
import { SPONSORSHIP_TIER_IDS } from '@shared/lib/sponsorship';
import { z } from 'zod';
export const SponsorshipCheckoutRequestSchema = z
.object({
tier: z.enum(SPONSORSHIP_TIER_IDS),
publicListing: z.boolean().default(true),
customAmountFen: z.number().int().min(1).max(100_000_000).optional(),
})
.strict();
export const SponsorshipClaimRequestSchema = z
.object({
code: z.string().trim().min(8).max(64),
publicListing: z.boolean().default(true),
})
.strict();
export const SponsorshipVisibilityRequestSchema = z
.object({ isPublic: z.boolean() })
.strict();
export const SponsorshipPublicQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(20),
});
export type SponsorshipCheckoutRequest = z.infer<
typeof SponsorshipCheckoutRequestSchema
>;
export type SponsorshipClaimRequest = z.infer<
typeof SponsorshipClaimRequestSchema
>;
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import type { Cultivator } from '@shared/types/cultivator';
import type { Cultivator, FateEffectEntry } from '@shared/types/cultivator';
import { resolveLiveExpCap } from '@server/utils/cultivationUtils';
import { attemptBreakthrough, performCultivation } from './CultivationEngine';
@@ -113,6 +113,31 @@ function withStatus(
} satisfies Cultivator;
}
function withBreakthroughFate(cultivator: Cultivator, value: number) {
const effect: FateEffectEntry = {
id: `breakthrough-${value}`,
effectId: 'breakthrough-bonus',
scope: value >= 0 ? 'daily' : 'drawback',
polarity: value >= 0 ? 'boon' : 'burden',
effectType: 'breakthrough_bonus',
value,
label: '突破成功率变化',
description: '测试用突破命格',
rollMeta: {
qualityAnchor: '凡品',
minValue: value,
maxValue: value,
rolledPercentile: 0,
roundingStep: 0.01,
},
};
return {
...cultivator,
pre_heaven_fates: [{ name: '测试命格', effects: [effect] }],
} satisfies Cultivator;
}
function withBodyCultivation(
cultivator: Cultivator,
levels: {
@@ -339,6 +364,86 @@ describe('CultivationEngine cultivation boost', () => {
expect(boosted.summary.chance).toBeCloseTo(base.summary.chance + 0.12, 4);
});
it('keeps adjusted base chance stable while toxicity lowers final chance', () => {
const clean = createCultivator();
const currentCap = resolveLiveExpCap(clean.realm, clean.realm_stage);
clean.cultivation_progress!.cultivation_exp = currentCap * 0.8;
clean.cultivation_progress!.comprehension_insight = 0;
const toxic = structuredClone(clean);
toxic.condition!.gauges.pillToxicity = 170;
const cleanResult = attemptBreakthrough(clean, () => 0.99);
const toxicResult = attemptBreakthrough(toxic, () => 0.99);
expect(cleanResult.summary.modifiers.adjustedBaseChance).toBeCloseTo(
0.3876,
4,
);
expect(toxicResult.summary.modifiers.adjustedBaseChance).toBeCloseTo(
cleanResult.summary.modifiers.adjustedBaseChance,
8,
);
expect(toxicResult.summary.modifiers.toxicityPenalty).toBeCloseTo(
0.17,
5,
);
expect(toxicResult.summary.chance).toBeCloseTo(
cleanResult.summary.chance -
toxicResult.summary.modifiers.toxicityPenalty,
8,
);
});
it('applies signed fate bonuses and clear mind to the final chance', () => {
const base = createCultivator();
base.cultivation_progress!.cultivation_exp = 80_000;
base.cultivation_progress!.inner_demon = true;
const boon = withBreakthroughFate(structuredClone(base), 0.08);
const burden = withBreakthroughFate(structuredClone(base), -0.05);
const clearMind = withStatus(structuredClone(base), {
key: 'clear_mind',
stacks: 1,
source: 'pill',
duration: { kind: 'until_removed' },
usesRemaining: 1,
createdAt: '2026-05-25T12:00:00.000Z',
updatedAt: '2026-05-25T12:00:00.000Z',
});
const baseResult = attemptBreakthrough(base, () => 0.99);
const boonResult = attemptBreakthrough(boon, () => 0.99);
const burdenResult = attemptBreakthrough(burden, () => 0.99);
const clearMindResult = attemptBreakthrough(clearMind, () => 0.99);
expect(boonResult.summary.chance).toBeCloseTo(
baseResult.summary.chance + 0.08,
8,
);
expect(burdenResult.summary.chance).toBeCloseTo(
baseResult.summary.chance - 0.05,
8,
);
expect(baseResult.summary.modifiers.demonPenalty).toBe(0.95);
expect(clearMindResult.summary.modifiers.demonPenalty).toBe(1);
expect(clearMindResult.summary.chance).toBeGreaterThan(
baseResult.summary.chance,
);
});
it('uses the canonical 95 percent cap for modifiers and the actual roll', () => {
const cultivator = createCultivator();
cultivator.realm = '炼气';
cultivator.cultivation_progress!.cultivation_exp = 100_000;
cultivator.cultivation_progress!.comprehension_insight = 100;
const result = attemptBreakthrough(cultivator, () => 0.96);
expect(result.summary.modifiers.adjustedBaseChance).toBeGreaterThan(0.95);
expect(result.summary.modifiers.finalChance).toBe(0.95);
expect(result.summary.chance).toBe(0.95);
expect(result.summary.success).toBe(false);
});
it('uses protect meridians payload to reduce failed breakthrough exp loss', () => {
const baseCultivator = createCultivator();
baseCultivator.cultivation_progress!.cultivation_exp = 80_000;
@@ -309,7 +309,7 @@ export function attemptBreakthrough(
throw new Error(breakthroughResult.recommendation);
}
const finalChance = clamp(breakthroughResult.chance, 0.05, 0.95);
const finalChance = breakthroughResult.chance;
const modifiers = breakthroughResult.modifiers;
// roll突破
+59
View File
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import {
buildAfdianCheckoutUrl,
buildAfdianWebhookSignedText,
createAfdianApiEnvelope,
} from './afdianProtocol';
describe('Afdian official protocol', () => {
it('matches the official API signing example', () => {
expect(
createAfdianApiEnvelope({
userId: 'abc',
token: '123',
params: { a: 333 },
timestampSeconds: 1_624_339_905,
}),
).toEqual({
user_id: 'abc',
params: '{"a":333}',
ts: 1_624_339_905,
sign: 'a4acc28b81598b7e5d84ebdc3e91710c',
});
});
it('uses the documented webhook field order', () => {
expect(
buildAfdianWebhookSignedText({
outTradeNo: 'ORDER',
userId: 'USER',
planId: 'PLAN',
totalAmount: '38.00',
}),
).toBe('ORDERUSERPLAN38.00');
});
it('builds a fixed-plan checkout bound to the intent', () => {
const url = buildAfdianCheckoutUrl({
intentId: 'intent-id',
planId: 'plan-id',
});
expect(url.origin + url.pathname).toBe('https://afdian.com/order/create');
expect(Object.fromEntries(url.searchParams)).toEqual({
product_type: '0',
custom_order_id: 'intent-id',
plan_id: 'plan-id',
});
});
it('formats custom amounts as CNY with two decimals', () => {
const url = buildAfdianCheckoutUrl({
intentId: 'intent-id',
planId: null,
customAmountFen: 3_801,
creatorUserId: 'creator-id',
});
expect(url.searchParams.get('custom_price')).toBe('38.01');
expect(url.searchParams.get('user_id')).toBe('creator-id');
});
});
+57
View File
@@ -0,0 +1,57 @@
import { createHash } from 'node:crypto';
export type AfdianApiEnvelope = {
user_id: string;
params: string;
ts: number;
sign: string;
};
export function buildAfdianWebhookSignedText(order: {
outTradeNo: string;
userId: string;
planId: string;
totalAmount: string;
}): string {
return `${order.outTradeNo}${order.userId}${order.planId}${order.totalAmount}`;
}
export function createAfdianApiEnvelope(input: {
userId: string;
token: string;
params: Record<string, unknown>;
timestampSeconds: number;
}): AfdianApiEnvelope {
const params = JSON.stringify(input.params);
const signSource = `${input.token}params${params}ts${input.timestampSeconds}user_id${input.userId}`;
return {
user_id: input.userId,
params,
ts: input.timestampSeconds,
sign: createHash('md5').update(signSource).digest('hex'),
};
}
export function buildAfdianCheckoutUrl(input: {
intentId: string;
planId: string | null;
customAmountFen?: number;
creatorUserId?: string;
}): URL {
const url = new URL('https://afdian.com/order/create');
url.searchParams.set('product_type', '0');
url.searchParams.set('custom_order_id', input.intentId);
if (input.planId) {
url.searchParams.set('plan_id', input.planId);
return url;
}
if (input.customAmountFen && input.creatorUserId) {
url.searchParams.set('user_id', input.creatorUserId);
url.searchParams.set(
'custom_price',
(input.customAmountFen / 100).toFixed(2),
);
return url;
}
throw new Error('下单地址缺少方案或自选金额');
}
+1
View File
@@ -5,6 +5,7 @@ export const APP_SETTING_KEYS = {
communityQqGroupNumber: 'community_qq_group_number',
authPageAnnouncement: 'auth_page_announcement',
itemLibraryDailyMaterialGeneration: 'item_library_daily_material_generation',
afdianSponsorship: 'sponsorship_afdian_config_v1',
} as const;
/** Bundled fallback when DB has no row or row is empty. */
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import {
AfdianSponsorshipConfigSchema,
DEFAULT_AFDIAN_SPONSORSHIP_CONFIG,
formatSponsorshipMonth,
highestSponsorshipTier,
isSponsorshipOrderAccepted,
parseCnyAmountToFen,
resolveSponsorshipTier,
} from './sponsorship';
describe('sponsorship rules', () => {
it.each([
['0.01', 1],
['38', 3_800],
['188.5', 18_850],
['188.50', 18_850],
['1.234', null],
['-1', null],
])('parses %s yuan to fen', (input, expected) => {
expect(parseCnyAmountToFen(input)).toBe(expected);
});
it('uses an allowlisted plan before the discounted paid amount', () => {
const config = structuredClone(DEFAULT_AFDIAN_SPONSORSHIP_CONFIG);
config.acceptingCheckout = true;
config.tiers.night_guardian.planId = 'night-plan';
expect(
resolveSponsorshipTier(
{ planId: 'night-plan', totalAmountFen: 1 },
config,
),
).toBe('night_guardian');
});
it('does not treat an unmapped fixed plan as a custom-amount order', () => {
expect(
resolveSponsorshipTier(
{ planId: 'unknown-plan', totalAmountFen: 18_800 },
DEFAULT_AFDIAN_SPONSORSHIP_CONFIG,
),
).toBeNull();
});
it.each([
[1, 'faint_light'],
[3_799, 'faint_light'],
[3_800, 'fellow_traveler'],
[9_800, 'night_guardian'],
[18_800, 'immortality_witness'],
] as const)('resolves amount %i', (amount, expected) => {
expect(
resolveSponsorshipTier(
{ totalAmountFen: amount },
DEFAULT_AFDIAN_SPONSORSHIP_CONFIG,
),
).toBe(expected);
});
it('keeps the historical highest tier', () => {
expect(
highestSponsorshipTier([
'night_guardian',
'faint_light',
'fellow_traveler',
]),
).toBe('night_guardian');
});
it('accepts only orders at or after the server-managed activation time', () => {
const config = structuredClone(DEFAULT_AFDIAN_SPONSORSHIP_CONFIG);
config.acceptingCheckout = true;
config.ordersAcceptedAfter = '2026-08-17T00:00:00.000Z';
expect(
isSponsorshipOrderAccepted(new Date('2026-08-16T23:59:59.999Z'), config),
).toBe(false);
expect(
isSponsorshipOrderAccepted(new Date('2026-08-17T00:00:00.000Z'), config),
).toBe(true);
expect(isSponsorshipOrderAccepted(null, config)).toBe(false);
});
it('rejects automatic order handling until checkout is activated', () => {
const config = structuredClone(DEFAULT_AFDIAN_SPONSORSHIP_CONFIG);
config.ordersAcceptedAfter = '2026-08-17T00:00:00.000Z';
expect(
isSponsorshipOrderAccepted(new Date('2026-08-18T00:00:00.000Z'), config),
).toBe(false);
});
it('keeps persisted pre-cutoff configs backward compatible', () => {
const legacy = structuredClone(DEFAULT_AFDIAN_SPONSORSHIP_CONFIG) as Omit<
typeof DEFAULT_AFDIAN_SPONSORSHIP_CONFIG,
'ordersAcceptedAfter'
> & { ordersAcceptedAfter?: string | null };
delete legacy.ordersAcceptedAfter;
expect(
AfdianSponsorshipConfigSchema.parse(legacy).ordersAcceptedAfter,
).toBeNull();
});
it('rejects non-increasing thresholds', () => {
const config = structuredClone(DEFAULT_AFDIAN_SPONSORSHIP_CONFIG);
config.tiers.night_guardian.minimumAmountFen = 3_800;
expect(AfdianSponsorshipConfigSchema.safeParse(config).success).toBe(false);
});
it('rejects a plan id mapped to multiple tiers', () => {
const config = structuredClone(DEFAULT_AFDIAN_SPONSORSHIP_CONFIG);
config.tiers.faint_light.planId = 'same-plan';
config.tiers.fellow_traveler.planId = 'same-plan';
expect(AfdianSponsorshipConfigSchema.safeParse(config).success).toBe(false);
});
it('uses the China calendar month for public attribution', () => {
expect(formatSponsorshipMonth(new Date('2026-07-31T16:30:00.000Z'))).toBe(
'2026-08',
);
});
});
+156
View File
@@ -0,0 +1,156 @@
import { z } from 'zod';
export const SPONSORSHIP_TIER_IDS = [
'faint_light',
'fellow_traveler',
'night_guardian',
'immortality_witness',
] as const;
export type SponsorshipTierId = (typeof SPONSORSHIP_TIER_IDS)[number];
export const SPONSORSHIP_TIER_META: Record<
SponsorshipTierId,
{ name: string; rank: number; theme: string }
> = {
faint_light: { name: '一盏微光', rank: 0, theme: '微光印记' },
fellow_traveler: { name: '山水同程', rank: 1, theme: '山水纹章' },
night_guardian: { name: '长夜护道', rank: 2, theme: '护道金印' },
immortality_witness: { name: '共证长生', rank: 3, theme: '长生玉牒' },
};
const SponsorshipTierConfigSchema = z.object({
planId: z.string().trim().max(80).default(''),
minimumAmountFen: z.number().int().min(1).max(100_000_000),
});
export const AfdianSponsorshipConfigSchema = z
.object({
creatorUrl: z.url().refine((url) => new URL(url).protocol === 'https:', {
message: '爱发电创作者地址必须使用 HTTPS',
}),
acceptingCheckout: z.boolean(),
acceptingCustomAmount: z.boolean(),
ordersAcceptedAfter: z
.string()
.datetime({ offset: true })
.nullable()
.default(null),
tiers: z.object({
faint_light: SponsorshipTierConfigSchema,
fellow_traveler: SponsorshipTierConfigSchema,
night_guardian: SponsorshipTierConfigSchema,
immortality_witness: SponsorshipTierConfigSchema,
}),
})
.superRefine((value, ctx) => {
const thresholds = SPONSORSHIP_TIER_IDS.map(
(tier) => value.tiers[tier].minimumAmountFen,
);
for (let index = 1; index < thresholds.length; index += 1) {
if (thresholds[index]! <= thresholds[index - 1]!) {
ctx.addIssue({
code: 'custom',
path: ['tiers', SPONSORSHIP_TIER_IDS[index], 'minimumAmountFen'],
message: '档位最低金额必须严格递增',
});
}
}
const planIds = SPONSORSHIP_TIER_IDS.map(
(tier) => value.tiers[tier].planId,
).filter(Boolean);
if (new Set(planIds).size !== planIds.length) {
ctx.addIssue({
code: 'custom',
path: ['tiers'],
message: '不同档位不能使用相同的方案 ID',
});
}
});
export type AfdianSponsorshipConfig = z.infer<
typeof AfdianSponsorshipConfigSchema
>;
export const DEFAULT_AFDIAN_SPONSORSHIP_CONFIG: AfdianSponsorshipConfig = {
creatorUrl: 'https://afdian.com/a/afdian',
acceptingCheckout: false,
acceptingCustomAmount: true,
ordersAcceptedAfter: null,
tiers: {
faint_light: { planId: '', minimumAmountFen: 1 },
fellow_traveler: { planId: '', minimumAmountFen: 3_800 },
night_guardian: { planId: '', minimumAmountFen: 9_800 },
immortality_witness: { planId: '', minimumAmountFen: 18_800 },
},
};
export function parseCnyAmountToFen(value: string): number | null {
const normalized = value.trim();
const match = /^(0|[1-9]\d{0,9})(?:\.(\d{1,2}))?$/.exec(normalized);
if (!match) return null;
const yuan = Number(match[1]);
const decimals = (match[2] ?? '').padEnd(2, '0');
const fen = yuan * 100 + Number(decimals || '0');
return Number.isSafeInteger(fen) ? fen : null;
}
export function resolveSponsorshipTier(
input: { planId?: string | null; totalAmountFen: number },
config: AfdianSponsorshipConfig,
): SponsorshipTierId | null {
const planId = input.planId?.trim();
if (planId) {
for (const tierId of SPONSORSHIP_TIER_IDS) {
if (config.tiers[tierId].planId === planId) return tierId;
}
return null;
}
if (!config.acceptingCustomAmount) return null;
let resolved: SponsorshipTierId | null = null;
for (const tierId of SPONSORSHIP_TIER_IDS) {
if (input.totalAmountFen >= config.tiers[tierId].minimumAmountFen) {
resolved = tierId;
}
}
return resolved;
}
export function isSponsorshipOrderAccepted(
createdAt: Date | null,
config: AfdianSponsorshipConfig,
): boolean {
if (
!config.acceptingCheckout ||
!config.ordersAcceptedAfter ||
!createdAt ||
Number.isNaN(createdAt.getTime())
) {
return false;
}
return createdAt.getTime() >= Date.parse(config.ordersAcceptedAfter);
}
export function highestSponsorshipTier(
tiers: SponsorshipTierId[],
): SponsorshipTierId | null {
let result: SponsorshipTierId | null = null;
for (const tier of tiers) {
if (
result === null ||
SPONSORSHIP_TIER_META[tier].rank > SPONSORSHIP_TIER_META[result].rank
) {
result = tier;
}
}
return result;
}
export function formatSponsorshipMonth(date: Date): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
}).format(date);
}