Merge pull request #52 from ChurchTao/codex/afadian
Optimize display effects and stamp functionality for功德簿
This commit is contained in:
54 files changed
+12627
-83
No files matched your search
@@ -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`.
|
||||
|
||||
@@ -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`),核对订单再重新开启自动履约。
|
||||
@@ -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
@@ -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: '玩家交流群',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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突破
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('下单地址缺少方案或自选金额');
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user