功能:私有化交易
This commit is contained in:
1 parent
b25ada0b41
commit
747191e33b
24 files changed
+6687
-70
No files matched your search
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE "wanjiedaoyou_cultivator_friends" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"cultivator_id" uuid NOT NULL,
|
||||
"friend_cultivator_id" uuid NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "wanjiedaoyou_auction_listings" ADD COLUMN "visibility" varchar(20) DEFAULT 'public' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "wanjiedaoyou_auction_listings" ADD COLUMN "target_cultivator_id" uuid;--> statement-breakpoint
|
||||
ALTER TABLE "wanjiedaoyou_auction_listings" ADD COLUMN "target_cultivator_name" varchar(100);--> statement-breakpoint
|
||||
ALTER TABLE "wanjiedaoyou_cultivator_friends" ADD CONSTRAINT "wanjiedaoyou_cultivator_friends_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_cultivator_friends" ADD CONSTRAINT "wanjiedaoyou_cultivator_friends_friend_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("friend_cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "cultivator_friends_pair_uidx" ON "wanjiedaoyou_cultivator_friends" USING btree ("cultivator_id","friend_cultivator_id");--> statement-breakpoint
|
||||
CREATE INDEX "cultivator_friends_friend_idx" ON "wanjiedaoyou_cultivator_friends" USING btree ("friend_cultivator_id");--> statement-breakpoint
|
||||
ALTER TABLE "wanjiedaoyou_auction_listings" ADD CONSTRAINT "wanjiedaoyou_auction_listings_target_cultivator_id_wanjiedaoyou_cultivators_id_fk" FOREIGN KEY ("target_cultivator_id") REFERENCES "public"."wanjiedaoyou_cultivators"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "auction_visibility_target_status_idx" ON "wanjiedaoyou_auction_listings" USING btree ("visibility","target_cultivator_id","status");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -15,6 +15,13 @@
|
||||
"when": 1782107240879,
|
||||
"tag": "0001_motionless_iron_patriot",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1782118374333,
|
||||
"tag": "0002_puzzling_wildside",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
InkInput,
|
||||
InkList,
|
||||
InkNotice,
|
||||
InkSelect,
|
||||
InkTabs,
|
||||
inkFieldVariants,
|
||||
} from '@app/components/ui';
|
||||
@@ -57,6 +58,16 @@ type SelectableItem = (Material | Artifact | Consumable) & {
|
||||
};
|
||||
|
||||
type InventoryApiType = 'materials' | 'artifacts' | 'consumables';
|
||||
type ListingVisibility = 'public' | 'private';
|
||||
|
||||
interface FriendSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
realm: string;
|
||||
realmStage: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface InventoryPagination {
|
||||
page: number;
|
||||
@@ -202,6 +213,10 @@ export function ListItemModal({
|
||||
const [selectedItem, setSelectedItem] = useState<SelectableItem | null>(null);
|
||||
const [price, setPrice] = useState('');
|
||||
const [quantity, setQuantity] = useState('1');
|
||||
const [visibility, setVisibility] = useState<ListingVisibility>('public');
|
||||
const [targetCultivatorId, setTargetCultivatorId] = useState('');
|
||||
const [friends, setFriends] = useState<FriendSummary[]>([]);
|
||||
const [friendsLoading, setFriendsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [listError, setListError] = useState('');
|
||||
@@ -231,6 +246,42 @@ export function ListItemModal({
|
||||
});
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cultivator?.id || step !== 'price') {
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const loadFriends = async () => {
|
||||
try {
|
||||
setFriendsLoading(true);
|
||||
const res = await fetch('/api/friends');
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '获取好友名录失败');
|
||||
}
|
||||
const nextFriends = (data.friends || []) as FriendSummary[];
|
||||
setFriends(nextFriends);
|
||||
setTargetCultivatorId((current) => current || nextFriends[0]?.id || '');
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setError(error instanceof Error ? error.message : '获取好友名录失败');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setFriendsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadFriends();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cultivator?.id, step]);
|
||||
|
||||
const fetchItemPage = useCallback(
|
||||
async (itemType: ItemType, page: number) => {
|
||||
if (!cultivator?.id) return;
|
||||
@@ -401,6 +452,8 @@ export function ListItemModal({
|
||||
setSelectedItem(null);
|
||||
setPrice('');
|
||||
setQuantity('1');
|
||||
setVisibility('public');
|
||||
setTargetCultivatorId('');
|
||||
setError('');
|
||||
};
|
||||
|
||||
@@ -451,6 +504,10 @@ export function ListItemModal({
|
||||
setError(`数量范围为 1 ~ ${selectedItem.quantity}`);
|
||||
return;
|
||||
}
|
||||
if (visibility === 'private' && !targetCultivatorId) {
|
||||
setError('专属交易必须指定好友');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError('');
|
||||
@@ -465,6 +522,9 @@ export function ListItemModal({
|
||||
itemId: selectedItem.id,
|
||||
price: priceNum,
|
||||
quantity: quantityNum,
|
||||
visibility,
|
||||
targetCultivatorId:
|
||||
visibility === 'private' ? targetCultivatorId : undefined,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
@@ -1147,6 +1207,44 @@ export function ListItemModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`grid gap-3 ${visibility === 'private' ? 'md:grid-cols-2' : ''}`}
|
||||
>
|
||||
<InkSelect
|
||||
label="交易范围"
|
||||
value={visibility}
|
||||
onChange={(value) => {
|
||||
const nextVisibility = value as ListingVisibility;
|
||||
setVisibility(nextVisibility);
|
||||
if (nextVisibility === 'public') {
|
||||
setTargetCultivatorId('');
|
||||
}
|
||||
setError('');
|
||||
}}
|
||||
>
|
||||
<option value="public">公开拍卖</option>
|
||||
<option value="private">专属交易</option>
|
||||
</InkSelect>
|
||||
{visibility === 'private' && (
|
||||
<InkSelect
|
||||
label="指定道友"
|
||||
value={targetCultivatorId}
|
||||
onChange={setTargetCultivatorId}
|
||||
disabled={friendsLoading}
|
||||
hint="专属交易上架会消耗拍卖行贵宾符,可在天骄宝阁购买"
|
||||
>
|
||||
<option value="">
|
||||
{friendsLoading ? '读取好友名录中...' : '选择好友'}
|
||||
</option>
|
||||
{friends.map((friend) => (
|
||||
<option key={friend.id} value={friend.id}>
|
||||
{friend.name} · {friend.realm}{friend.realmStage}
|
||||
</option>
|
||||
))}
|
||||
</InkSelect>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-crimson text-sm">{error}</p>}
|
||||
|
||||
<div className="text-ink-secondary text-xs">
|
||||
@@ -1154,6 +1252,7 @@ export function ListItemModal({
|
||||
<p>· 寄售后物品将从储物袋中扣除</p>
|
||||
<p>· 寄售时限为 48 小时</p>
|
||||
<p>· 交易成功后扣除 10% 手续费</p>
|
||||
<p>· 专属交易只向指定好友展示,并额外消耗拍卖行贵宾符,可在天骄宝阁购买</p>
|
||||
<p>· 未售出的物品将通过邮件返还</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import {
|
||||
AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO,
|
||||
FRIEND_MAIL_TALISMAN_SCENARIO,
|
||||
} from '@shared/config/socialConfig';
|
||||
import type { Consumable } from '@shared/types/cultivator';
|
||||
import {
|
||||
buildTalismanDetailText,
|
||||
getTalismanActionHref,
|
||||
getTalismanActionLabel,
|
||||
getTalismanUsageHint,
|
||||
isQiRestoreTalisman,
|
||||
} from './talismanDisplay';
|
||||
@@ -47,4 +53,30 @@ describe('talisman display helpers', () => {
|
||||
expect(buildTalismanDetailText(item)).not.toContain('qi_restore_medium');
|
||||
expect(buildTalismanDetailText(item)).not.toContain('consume_on_action');
|
||||
});
|
||||
|
||||
it('routes friend mail talismans to the mail scene with a purchase hint', () => {
|
||||
const item = talisman(FRIEND_MAIL_TALISMAN_SCENARIO);
|
||||
|
||||
expect(getTalismanActionHref(item)).toBe('/game/mail');
|
||||
expect(getTalismanActionLabel(item)).toBe('去传音');
|
||||
expect(getTalismanUsageHint(item)).toContain('传音玉简');
|
||||
expect(getTalismanUsageHint(item)).toContain('天骄宝阁');
|
||||
expect(buildTalismanDetailText(item)).toContain('传音玉简·好友传音');
|
||||
expect(buildTalismanDetailText(item)).not.toContain(
|
||||
FRIEND_MAIL_TALISMAN_SCENARIO,
|
||||
);
|
||||
});
|
||||
|
||||
it('routes private auction talismans to the auction scene with a purchase hint', () => {
|
||||
const item = talisman(AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO);
|
||||
|
||||
expect(getTalismanActionHref(item)).toBe('/game/auction');
|
||||
expect(getTalismanActionLabel(item)).toBe('去上架');
|
||||
expect(getTalismanUsageHint(item)).toContain('拍卖行');
|
||||
expect(getTalismanUsageHint(item)).toContain('天骄宝阁');
|
||||
expect(buildTalismanDetailText(item)).toContain('拍卖行·专属交易');
|
||||
expect(buildTalismanDetailText(item)).not.toContain(
|
||||
AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
QI_RESTORE_TALISMAN_SCENARIOS,
|
||||
isQiRestoreTalismanScenario,
|
||||
} from '@shared/config/qiSystem';
|
||||
import {
|
||||
AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO,
|
||||
FRIEND_MAIL_TALISMAN_SCENARIO,
|
||||
} from '@shared/config/socialConfig';
|
||||
import { isTalismanConsumable } from '@shared/lib/consumables';
|
||||
import type { Consumable } from '@shared/types/cultivator';
|
||||
import { buildManualDrawHref } from '@shared/types/manualDraw';
|
||||
@@ -10,24 +14,34 @@ const TALISMAN_SCENARIO_LABELS: Record<string, string> = {
|
||||
fate_reshape: '命格重塑',
|
||||
draw_gongfa: '问法寻卷·功法抽取',
|
||||
draw_skill: '问法寻卷·神通抽取',
|
||||
[FRIEND_MAIL_TALISMAN_SCENARIO]: '传音玉简·好友传音',
|
||||
[AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO]: '拍卖行·专属交易',
|
||||
};
|
||||
|
||||
const TALISMAN_SCENARIO_HREFS: Record<string, string> = {
|
||||
fate_reshape: '/game/fate-reshape',
|
||||
draw_gongfa: buildManualDrawHref('gongfa'),
|
||||
draw_skill: buildManualDrawHref('skill'),
|
||||
[FRIEND_MAIL_TALISMAN_SCENARIO]: '/game/mail',
|
||||
[AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO]: '/game/auction',
|
||||
};
|
||||
|
||||
const TALISMAN_SCENARIO_ACTION_LABELS: Record<string, string> = {
|
||||
fate_reshape: '前往重塑',
|
||||
draw_gongfa: '抽功法秘籍',
|
||||
draw_skill: '抽神通秘籍',
|
||||
[FRIEND_MAIL_TALISMAN_SCENARIO]: '去传音',
|
||||
[AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO]: '去上架',
|
||||
};
|
||||
|
||||
const TALISMAN_USAGE_HINTS: Record<string, string> = {
|
||||
fate_reshape: '【前往命格重塑功能页启封,开启时立即扣除】',
|
||||
draw_gongfa: '【前往问法寻卷,直接消耗符箓抽取功法秘籍】',
|
||||
draw_skill: '【前往问法寻卷,直接消耗符箓抽取神通秘籍】',
|
||||
[FRIEND_MAIL_TALISMAN_SCENARIO]:
|
||||
'【前往传音玉简,给好友发送传音时消耗;不足时可去天骄宝阁购买】',
|
||||
[AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO]:
|
||||
'【前往拍卖行,上架专属交易时消耗;不足时可去天骄宝阁购买】',
|
||||
};
|
||||
|
||||
function getQiRestoreEffectText(scenario: string): string | null {
|
||||
|
||||
@@ -41,6 +41,9 @@ type AuctionListing = {
|
||||
itemId: string;
|
||||
itemSnapshot: Material | Artifact | Consumable;
|
||||
price: number;
|
||||
visibility?: 'public' | 'private';
|
||||
targetCultivatorId?: string | null;
|
||||
targetCultivatorName?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
@@ -335,6 +338,14 @@ export default function AuctionPage() {
|
||||
{listing.sellerId === cultivator?.id ? ' (我)' : ''}
|
||||
</span>
|
||||
<span>数量: x{listedQuantity}</span>
|
||||
{listing.visibility === 'private' && (
|
||||
<span className="text-crimson">
|
||||
专属:
|
||||
{listing.targetCultivatorId === cultivator?.id
|
||||
? '指定给我'
|
||||
: listing.targetCultivatorName || '指定道友'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gold text-sm font-semibold">
|
||||
{SPIRIT_STONES_INFO.icon} {listing.price}{' '}
|
||||
{SPIRIT_STONES_INFO.label}
|
||||
|
||||
@@ -2,16 +2,126 @@ import {
|
||||
GameSceneAsideSection,
|
||||
GameSceneFrame,
|
||||
} from '@app/components/game-shell';
|
||||
import { InkModal } from '@app/components/layout';
|
||||
import { MailDetailModal } from '@app/components/mail/MailDetailModal';
|
||||
import { Mail, MailList } from '@app/components/mail/MailList';
|
||||
import { useInkUI } from '@app/components/providers/InkUIProvider';
|
||||
import { InkButton } from '@app/components/ui/InkButton';
|
||||
import { InkInput } from '@app/components/ui/InkInput';
|
||||
import { InkNotice } from '@app/components/ui/InkNotice';
|
||||
import { InkSelect } from '@app/components/ui/InkSelect';
|
||||
import { InkBadge, InkList, InkTabs } from '@app/components/ui';
|
||||
import { usePlayerStateView } from '@app/lib/player-state/selectors';
|
||||
import { usePlayerStateActions } from '@app/lib/player-state/store';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { isPillConsumable } from '@shared/lib/consumables';
|
||||
import { QUALITY_ORDER, type Quality } from '@shared/types/constants';
|
||||
import type { Artifact, Consumable, Material } from '@shared/types/cultivator';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const MIN_TRANSFER_QUALITY = '玄品';
|
||||
const TRANSFER_ALLOWED_QUALITIES = Object.keys(QUALITY_ORDER).filter(
|
||||
(quality) =>
|
||||
QUALITY_ORDER[quality as keyof typeof QUALITY_ORDER] >=
|
||||
QUALITY_ORDER[MIN_TRANSFER_QUALITY],
|
||||
) as Quality[];
|
||||
|
||||
type FriendSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
realm: string;
|
||||
realmStage: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type AttachmentOption = {
|
||||
key: string;
|
||||
itemType: 'material' | 'artifact' | 'consumable';
|
||||
itemId: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
qualityLabel: string;
|
||||
};
|
||||
|
||||
type AttachmentItemType = AttachmentOption['itemType'];
|
||||
type SelectableAttachment = (Material | Artifact | Consumable) & {
|
||||
itemType: AttachmentItemType;
|
||||
};
|
||||
|
||||
type InventoryApiPayload = {
|
||||
success: boolean;
|
||||
data?: {
|
||||
items?: Array<Material | Artifact | Consumable>;
|
||||
pagination?: InventoryPagination;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type InventoryPagination = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
const defaultAttachmentPagination: InventoryPagination = {
|
||||
page: 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasMore: false,
|
||||
};
|
||||
|
||||
function isTransferableQuality(quality: string | undefined): boolean {
|
||||
return Boolean(
|
||||
quality && quality in QUALITY_ORDER &&
|
||||
QUALITY_ORDER[quality as keyof typeof QUALITY_ORDER] >=
|
||||
QUALITY_ORDER[MIN_TRANSFER_QUALITY],
|
||||
);
|
||||
}
|
||||
|
||||
function getAttachmentQuality(item: SelectableAttachment): Quality {
|
||||
if (item.itemType === 'material') {
|
||||
return (item as Material).rank;
|
||||
}
|
||||
|
||||
const quality = (item as Artifact | Consumable).quality || '凡品';
|
||||
return quality in QUALITY_ORDER ? quality : '凡品';
|
||||
}
|
||||
|
||||
function getAttachmentUnsupportedReason(item: SelectableAttachment): string | null {
|
||||
if (item.itemType === 'artifact' && (item as Artifact).isEquipped) {
|
||||
return '已装备法宝不可附带';
|
||||
}
|
||||
if (item.itemType === 'consumable' && !isPillConsumable(item as Consumable)) {
|
||||
return '当前仅支持丹药附带';
|
||||
}
|
||||
if (!isTransferableQuality(getAttachmentQuality(item))) {
|
||||
return `仅玄品及以上物品可附带,当前为${getAttachmentQuality(item)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toAttachmentOption(item: SelectableAttachment): AttachmentOption | null {
|
||||
if (!item.id || getAttachmentUnsupportedReason(item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
key: `${item.itemType}:${item.id}`,
|
||||
itemType: item.itemType,
|
||||
itemId: item.id,
|
||||
name: item.name,
|
||||
quantity: item.itemType === 'artifact' ? 1 : (item as Material | Consumable).quantity,
|
||||
qualityLabel: getAttachmentQuality(item),
|
||||
};
|
||||
}
|
||||
|
||||
export default function MailPage() {
|
||||
const { cultivator } = usePlayerStateView();
|
||||
const [mails, setMails] = useState<Mail[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
@@ -20,9 +130,138 @@ export default function MailPage() {
|
||||
const [selectedMail, setSelectedMail] = useState<Mail | null>(null);
|
||||
const [batchClaiming, setBatchClaiming] = useState(false);
|
||||
const [batchReading, setBatchReading] = useState(false);
|
||||
const [friends, setFriends] = useState<FriendSummary[]>([]);
|
||||
const [friendsLoading, setFriendsLoading] = useState(true);
|
||||
const [inviteTarget, setInviteTarget] = useState<FriendSummary | null>(null);
|
||||
const [inviteAlreadyFriend, setInviteAlreadyFriend] = useState(false);
|
||||
const [inviteLoading, setInviteLoading] = useState(false);
|
||||
const [showSendModal, setShowSendModal] = useState(false);
|
||||
const [showAttachmentPicker, setShowAttachmentPicker] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [recipientId, setRecipientId] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [activeAttachmentType, setActiveAttachmentType] =
|
||||
useState<AttachmentItemType>('material');
|
||||
const [selectedAttachment, setSelectedAttachment] =
|
||||
useState<AttachmentOption | null>(null);
|
||||
const [attachmentQuantity, setAttachmentQuantity] = useState('1');
|
||||
const [attachmentItems, setAttachmentItems] = useState<
|
||||
Record<AttachmentItemType, SelectableAttachment[]>
|
||||
>({
|
||||
material: [],
|
||||
artifact: [],
|
||||
consumable: [],
|
||||
});
|
||||
const [attachmentPagination, setAttachmentPagination] = useState<
|
||||
Record<AttachmentItemType, InventoryPagination>
|
||||
>({
|
||||
material: defaultAttachmentPagination,
|
||||
artifact: defaultAttachmentPagination,
|
||||
consumable: defaultAttachmentPagination,
|
||||
});
|
||||
const [attachmentLoading, setAttachmentLoading] = useState(false);
|
||||
const [attachmentError, setAttachmentError] = useState('');
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const attachmentRequestIdRef = useRef(0);
|
||||
const { mutate } = usePlayerStateActions();
|
||||
const { pushToast } = useInkUI();
|
||||
|
||||
const clearInviteParam = useCallback(() => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('addFriend');
|
||||
return next;
|
||||
});
|
||||
}, [setSearchParams]);
|
||||
|
||||
const fetchFriends = useCallback(async (options: { showLoading?: boolean } = {}) => {
|
||||
try {
|
||||
if (options.showLoading) {
|
||||
setFriendsLoading(true);
|
||||
}
|
||||
const res = await fetch('/api/friends');
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '获取好友名录失败');
|
||||
}
|
||||
const nextFriends = (data.friends || []) as FriendSummary[];
|
||||
setFriends(nextFriends);
|
||||
setRecipientId((current) => current || nextFriends[0]?.id || '');
|
||||
} catch (error) {
|
||||
pushToast({
|
||||
message: error instanceof Error ? error.message : '获取好友名录失败',
|
||||
tone: 'warning',
|
||||
});
|
||||
} finally {
|
||||
if (options.showLoading) {
|
||||
setFriendsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [pushToast]);
|
||||
|
||||
const fetchAttachmentPage = useCallback(
|
||||
async (itemType: AttachmentItemType, pageNumber: number) => {
|
||||
if (!cultivator?.id) return;
|
||||
|
||||
const requestId = ++attachmentRequestIdRef.current;
|
||||
setAttachmentLoading(true);
|
||||
setAttachmentError('');
|
||||
|
||||
try {
|
||||
const apiType =
|
||||
itemType === 'material'
|
||||
? 'materials'
|
||||
: itemType === 'artifact'
|
||||
? 'artifacts'
|
||||
: 'consumables';
|
||||
const params = new URLSearchParams({
|
||||
type: apiType,
|
||||
page: String(Math.max(1, pageNumber)),
|
||||
pageSize: String(PAGE_SIZE),
|
||||
});
|
||||
|
||||
if (itemType === 'material') {
|
||||
params.set('materialRanks', TRANSFER_ALLOWED_QUALITIES.join(','));
|
||||
params.set('materialSortBy', 'rank');
|
||||
params.set('materialSortOrder', 'desc');
|
||||
}
|
||||
|
||||
const res = await fetch(
|
||||
`/api/cultivator/inventory?${params.toString()}`,
|
||||
);
|
||||
const result = (await res.json()) as InventoryApiPayload;
|
||||
if (!res.ok || !result.success) {
|
||||
throw new Error(result.error || '读取背包失败');
|
||||
}
|
||||
if (requestId !== attachmentRequestIdRef.current) return;
|
||||
|
||||
const mappedItems = (result.data?.items || []).map((item) => ({
|
||||
...item,
|
||||
itemType,
|
||||
})) as SelectableAttachment[];
|
||||
setAttachmentItems((prev) => ({
|
||||
...prev,
|
||||
[itemType]: mappedItems,
|
||||
}));
|
||||
setAttachmentPagination((prev) => ({
|
||||
...prev,
|
||||
[itemType]: result.data?.pagination || defaultAttachmentPagination,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (requestId === attachmentRequestIdRef.current) {
|
||||
setAttachmentError(
|
||||
error instanceof Error ? error.message : '读取背包失败',
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (requestId === attachmentRequestIdRef.current) {
|
||||
setAttachmentLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[cultivator?.id],
|
||||
);
|
||||
|
||||
const fetchMails = useCallback(
|
||||
async (targetPage: number, append: boolean) => {
|
||||
try {
|
||||
@@ -88,6 +327,73 @@ export default function MailPage() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/friends')
|
||||
.then(async (res) => {
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '获取好友名录失败');
|
||||
}
|
||||
const nextFriends = (data.friends || []) as FriendSummary[];
|
||||
setFriends(nextFriends);
|
||||
setRecipientId((current) => current || nextFriends[0]?.id || '');
|
||||
})
|
||||
.catch((error) => {
|
||||
pushToast({
|
||||
message: error instanceof Error ? error.message : '获取好友名录失败',
|
||||
tone: 'warning',
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setFriendsLoading(false);
|
||||
});
|
||||
}, [pushToast]);
|
||||
|
||||
useEffect(() => {
|
||||
const targetId = searchParams.get('addFriend');
|
||||
if (!targetId || !cultivator) {
|
||||
return;
|
||||
}
|
||||
if (targetId === cultivator.id) {
|
||||
pushToast({ message: '不能将自己加入好友名录', tone: 'warning' });
|
||||
clearInviteParam();
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const loadInvite = async () => {
|
||||
try {
|
||||
setInviteLoading(true);
|
||||
const res = await fetch(`/api/friends/invite/${targetId}`);
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '查询道友失败');
|
||||
}
|
||||
setInviteTarget(data.target);
|
||||
setInviteAlreadyFriend(Boolean(data.isFriend));
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
pushToast({
|
||||
message: error instanceof Error ? error.message : '查询道友失败',
|
||||
tone: 'warning',
|
||||
});
|
||||
clearInviteParam();
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setInviteLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void loadInvite();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [clearInviteParam, cultivator, pushToast, searchParams]);
|
||||
|
||||
const handleSelectMail = async (mail: Mail) => {
|
||||
setSelectedMail(mail);
|
||||
|
||||
@@ -203,10 +509,127 @@ export default function MailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyInviteLink = async () => {
|
||||
if (!cultivator) return;
|
||||
const link = `${window.location.origin}/game/mail?addFriend=${cultivator.id}`;
|
||||
await navigator.clipboard.writeText(link);
|
||||
pushToast({ message: '好友邀请链接已复制', tone: 'success' });
|
||||
};
|
||||
|
||||
const handleOpenAttachmentPicker = () => {
|
||||
setShowAttachmentPicker(true);
|
||||
void fetchAttachmentPage(activeAttachmentType, 1);
|
||||
};
|
||||
|
||||
const handleConfirmInvite = async () => {
|
||||
if (!inviteTarget) return;
|
||||
try {
|
||||
const res = await fetch(`/api/friends/${inviteTarget.id}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '添加道友失败');
|
||||
}
|
||||
pushToast({ message: '已加入好友名录', tone: 'success' });
|
||||
setInviteTarget(null);
|
||||
clearInviteParam();
|
||||
await fetchFriends();
|
||||
} catch (error) {
|
||||
pushToast({
|
||||
message: error instanceof Error ? error.message : '添加道友失败',
|
||||
tone: 'danger',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFriend = async (friendId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/friends/${friendId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || '移除道友失败');
|
||||
}
|
||||
setFriends((prev) => prev.filter((friend) => friend.id !== friendId));
|
||||
if (recipientId === friendId) {
|
||||
setRecipientId('');
|
||||
}
|
||||
pushToast({ message: '已移出好友名录', tone: 'success' });
|
||||
} catch (error) {
|
||||
pushToast({
|
||||
message: error instanceof Error ? error.message : '移除道友失败',
|
||||
tone: 'danger',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMail = async () => {
|
||||
if (!recipientId) {
|
||||
pushToast({ message: '请选择收信道友', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (!content.trim()) {
|
||||
pushToast({ message: '请写下传音内容', tone: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
const quantity = Math.max(1, Number(attachmentQuantity) || 1);
|
||||
const attachment =
|
||||
selectedAttachment
|
||||
? {
|
||||
itemType: selectedAttachment.itemType,
|
||||
itemId: selectedAttachment.itemId,
|
||||
quantity:
|
||||
selectedAttachment.itemType === 'artifact'
|
||||
? 1
|
||||
: Math.min(quantity, selectedAttachment.quantity),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
setSending(true);
|
||||
await mutate(
|
||||
fetch('/api/cultivator/mail/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipientCultivatorId: recipientId,
|
||||
content,
|
||||
attachment,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
pushToast({ message: '传音已发出', tone: 'success' });
|
||||
setShowSendModal(false);
|
||||
setContent('');
|
||||
setSelectedAttachment(null);
|
||||
setAttachmentQuantity('1');
|
||||
} catch (error) {
|
||||
pushToast({
|
||||
message: error instanceof Error ? error.message : '发送传音失败',
|
||||
tone: 'danger',
|
||||
});
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const unreadCount = mails.filter((mail) => !mail.isRead).length;
|
||||
const pendingAttachments = mails.filter(
|
||||
(mail) => mail.type === 'reward' && !mail.isClaimed,
|
||||
).length;
|
||||
const attachmentTabs = [
|
||||
{ label: '材料', value: 'material' },
|
||||
{ label: '法宝', value: 'artifact' },
|
||||
{ label: '丹药', value: 'consumable' },
|
||||
];
|
||||
const currentAttachmentItems = attachmentItems[activeAttachmentType]
|
||||
.map(toAttachmentOption)
|
||||
.filter((item): item is AttachmentOption => Boolean(item));
|
||||
const currentAttachmentPagination =
|
||||
attachmentPagination[activeAttachmentType];
|
||||
|
||||
return (
|
||||
<GameSceneFrame
|
||||
@@ -221,6 +644,50 @@ export default function MailPage() {
|
||||
<p>待领附件:{pendingAttachments} 封</p>
|
||||
</div>
|
||||
</GameSceneAsideSection>
|
||||
<GameSceneAsideSection title="好友名录">
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<InkButton onClick={handleCopyInviteLink} disabled={!cultivator}>
|
||||
复制邀请链接
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={() => setShowSendModal(true)}
|
||||
disabled={friends.length === 0}
|
||||
>
|
||||
发送传音
|
||||
</InkButton>
|
||||
</div>
|
||||
{friendsLoading ? (
|
||||
<p className="opacity-60">正在翻检名录...</p>
|
||||
) : friends.length === 0 ? (
|
||||
<p className="opacity-60">尚未收录道友。</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{friends.slice(0, 6).map((friend) => (
|
||||
<div
|
||||
key={friend.id}
|
||||
className="border-ink/10 flex items-center justify-between gap-2 border border-dashed p-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">{friend.name}</p>
|
||||
<p className="text-xs opacity-60">
|
||||
{friend.realm} {friend.realmStage}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs opacity-60 hover:opacity-100"
|
||||
onClick={() => void handleRemoveFriend(friend.id)}
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</GameSceneAsideSection>
|
||||
<GameSceneAsideSection
|
||||
title="操作说明"
|
||||
className="text-sm leading-7"
|
||||
@@ -239,6 +706,13 @@ export default function MailPage() {
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={() => setShowSendModal(true)}
|
||||
disabled={friends.length === 0}
|
||||
>
|
||||
发送传音
|
||||
</InkButton>
|
||||
<InkButton
|
||||
onClick={handleClaimAll}
|
||||
disabled={batchClaiming || batchReading || mails.length === 0}
|
||||
@@ -275,6 +749,243 @@ export default function MailPage() {
|
||||
onClose={() => setSelectedMail(null)}
|
||||
onUpdate={handleUpdate}
|
||||
/>
|
||||
<InkModal
|
||||
isOpen={showSendModal}
|
||||
onClose={() => setShowSendModal(false)}
|
||||
title="发送传音"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<InkSelect
|
||||
label="收信道友"
|
||||
value={recipientId}
|
||||
onChange={setRecipientId}
|
||||
disabled={friends.length === 0}
|
||||
>
|
||||
<option value="">选择好友</option>
|
||||
{friends.map((friend) => (
|
||||
<option key={friend.id} value={friend.id}>
|
||||
{friend.name} · {friend.realm}{friend.realmStage}
|
||||
</option>
|
||||
))}
|
||||
</InkSelect>
|
||||
<InkInput
|
||||
label="传音内容"
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
multiline
|
||||
rows={5}
|
||||
placeholder="写下要托玉简送达的话"
|
||||
hint="发送会消耗空白传音符,可在天骄宝阁购买"
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="text-ink font-semibold tracking-[0.08em]">
|
||||
随附物品
|
||||
</div>
|
||||
{selectedAttachment ? (
|
||||
<div className="border-ink/10 bg-paper-2 flex items-center justify-between gap-3 border border-dashed p-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate font-medium">
|
||||
{selectedAttachment.name}
|
||||
</p>
|
||||
<p className="text-xs opacity-60">
|
||||
{selectedAttachment.qualityLabel} · 可用{' '}
|
||||
{selectedAttachment.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
onClick={handleOpenAttachmentPicker}
|
||||
>
|
||||
更换
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSelectedAttachment(null);
|
||||
setAttachmentQuantity('1');
|
||||
}}
|
||||
>
|
||||
移除
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
onClick={handleOpenAttachmentPicker}
|
||||
>
|
||||
选择附件
|
||||
</InkButton>
|
||||
)}
|
||||
</div>
|
||||
{selectedAttachment && selectedAttachment.itemType !== 'artifact' ? (
|
||||
<InkInput
|
||||
label="数量"
|
||||
type="number"
|
||||
value={attachmentQuantity}
|
||||
onChange={setAttachmentQuantity}
|
||||
hint={`最多 ${selectedAttachment.quantity}`}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex justify-end gap-2">
|
||||
<InkButton onClick={() => setShowSendModal(false)}>取消</InkButton>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={handleSendMail}
|
||||
disabled={sending || friends.length === 0}
|
||||
>
|
||||
{sending ? '发送中...' : '发出'}
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
</InkModal>
|
||||
<InkModal
|
||||
isOpen={showAttachmentPicker}
|
||||
onClose={() => setShowAttachmentPicker(false)}
|
||||
title="选择附件"
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<InkTabs
|
||||
items={attachmentTabs}
|
||||
activeValue={activeAttachmentType}
|
||||
onChange={(value) => {
|
||||
const nextType = value as AttachmentItemType;
|
||||
setActiveAttachmentType(nextType);
|
||||
setAttachmentError('');
|
||||
void fetchAttachmentPage(nextType, 1);
|
||||
}}
|
||||
/>
|
||||
{attachmentError ? (
|
||||
<InkNotice tone="danger">{attachmentError}</InkNotice>
|
||||
) : null}
|
||||
{attachmentLoading ? (
|
||||
<div className="py-8 text-center text-sm opacity-60">
|
||||
正在翻检储物袋...
|
||||
</div>
|
||||
) : currentAttachmentItems.length > 0 ? (
|
||||
<InkList>
|
||||
{currentAttachmentItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="border-ink/10 bg-paper-2 flex items-center justify-between gap-3 border border-dashed p-3"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<InkBadge tier={item.qualityLabel as Quality} hideTierText>
|
||||
{item.name}
|
||||
</InkBadge>
|
||||
<p className="mt-1 text-xs opacity-60">
|
||||
{item.qualityLabel} · 可用 {item.quantity}
|
||||
</p>
|
||||
</div>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setSelectedAttachment(item);
|
||||
setAttachmentQuantity('1');
|
||||
setShowAttachmentPicker(false);
|
||||
}}
|
||||
>
|
||||
选择
|
||||
</InkButton>
|
||||
</div>
|
||||
))}
|
||||
</InkList>
|
||||
) : (
|
||||
<InkNotice>
|
||||
{activeAttachmentType === 'material'
|
||||
? '暂无可附带材料(仅限玄品及以上)。'
|
||||
: activeAttachmentType === 'artifact'
|
||||
? '暂无可附带法宝(仅限玄品及以上且未装备)。'
|
||||
: '暂无可附带丹药(仅限玄品及以上)。'}
|
||||
</InkNotice>
|
||||
)}
|
||||
{currentAttachmentPagination.totalPages > 1 ? (
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={
|
||||
attachmentLoading || currentAttachmentPagination.page <= 1
|
||||
}
|
||||
onClick={() =>
|
||||
void fetchAttachmentPage(
|
||||
activeAttachmentType,
|
||||
currentAttachmentPagination.page - 1,
|
||||
)
|
||||
}
|
||||
>
|
||||
上一页
|
||||
</InkButton>
|
||||
<span className="text-ink-secondary text-sm">
|
||||
{currentAttachmentPagination.page} /{' '}
|
||||
{currentAttachmentPagination.totalPages}
|
||||
</span>
|
||||
<InkButton
|
||||
variant="secondary"
|
||||
disabled={
|
||||
attachmentLoading ||
|
||||
currentAttachmentPagination.page >=
|
||||
currentAttachmentPagination.totalPages
|
||||
}
|
||||
onClick={() =>
|
||||
void fetchAttachmentPage(
|
||||
activeAttachmentType,
|
||||
currentAttachmentPagination.page + 1,
|
||||
)
|
||||
}
|
||||
>
|
||||
下一页
|
||||
</InkButton>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</InkModal>
|
||||
<InkModal
|
||||
isOpen={Boolean(inviteTarget) || inviteLoading}
|
||||
onClose={() => {
|
||||
setInviteTarget(null);
|
||||
clearInviteParam();
|
||||
}}
|
||||
title="收录道友"
|
||||
>
|
||||
{inviteLoading ? (
|
||||
<div className="py-6 text-center text-sm opacity-60">
|
||||
正在辨认玉简气息...
|
||||
</div>
|
||||
) : inviteTarget ? (
|
||||
<div className="space-y-4">
|
||||
<InkNotice tone="muted">
|
||||
<div className="space-y-1 text-sm">
|
||||
<p className="font-medium">{inviteTarget.name}</p>
|
||||
<p className="opacity-70">
|
||||
{inviteTarget.realm} {inviteTarget.realmStage}
|
||||
</p>
|
||||
{inviteTarget.title ? (
|
||||
<p className="opacity-70">称号:{inviteTarget.title}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</InkNotice>
|
||||
<div className="flex justify-end gap-2">
|
||||
<InkButton
|
||||
onClick={() => {
|
||||
setInviteTarget(null);
|
||||
clearInviteParam();
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</InkButton>
|
||||
<InkButton
|
||||
variant="primary"
|
||||
onClick={handleConfirmInvite}
|
||||
disabled={inviteAlreadyFriend}
|
||||
>
|
||||
{inviteAlreadyFriend ? '已在名录中' : '加入名录'}
|
||||
</InkButton>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</InkModal>
|
||||
</GameSceneFrame>
|
||||
);
|
||||
}
|
||||
@@ -511,6 +511,28 @@ export const mails = pgTable(
|
||||
],
|
||||
);
|
||||
|
||||
// 好友名录:双向好友会写入两条记录,便于按当前角色快速查询
|
||||
export const cultivatorFriends = pgTable(
|
||||
'wanjiedaoyou_cultivator_friends',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
cultivatorId: uuid('cultivator_id')
|
||||
.references(() => cultivators.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
friendCultivatorId: uuid('friend_cultivator_id')
|
||||
.references(() => cultivators.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('cultivator_friends_pair_uidx').on(
|
||||
table.cultivatorId,
|
||||
table.friendCultivatorId,
|
||||
),
|
||||
index('cultivator_friends_friend_idx').on(table.friendCultivatorId),
|
||||
],
|
||||
);
|
||||
|
||||
// 兑换码表
|
||||
export const redeemCodes = pgTable(
|
||||
'wanjiedaoyou_redeem_codes',
|
||||
@@ -824,6 +846,12 @@ export const auctionListings = pgTable(
|
||||
// 价格与状态
|
||||
price: integer('price').notNull(), // 一口价(灵石)
|
||||
status: varchar('status', { length: 20 }).notNull().default('active'), // active | sold | expired | cancelled
|
||||
visibility: varchar('visibility', { length: 20 }).notNull().default('public'), // public | private
|
||||
targetCultivatorId: uuid('target_cultivator_id').references(
|
||||
() => cultivators.id,
|
||||
{ onDelete: 'set null' },
|
||||
),
|
||||
targetCultivatorName: varchar('target_cultivator_name', { length: 100 }),
|
||||
|
||||
// 时间戳
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
@@ -851,6 +879,11 @@ export const auctionListings = pgTable(
|
||||
table.expiresAt,
|
||||
table.itemType,
|
||||
),
|
||||
index('auction_visibility_target_status_idx').on(
|
||||
table.visibility,
|
||||
table.targetCultivatorId,
|
||||
table.status,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, gte, lte, sql, type SQL } from 'drizzle-orm';
|
||||
import { and, asc, desc, eq, gte, lte, or, sql, type SQL } from 'drizzle-orm';
|
||||
import { getExecutor, type DbExecutor, type DbTransaction } from '../drizzle/db';
|
||||
import * as schema from '../drizzle/schema';
|
||||
|
||||
@@ -17,6 +17,9 @@ export async function createListing(data: {
|
||||
itemId: string;
|
||||
itemSnapshot: unknown;
|
||||
price: number;
|
||||
visibility?: 'public' | 'private';
|
||||
targetCultivatorId?: string;
|
||||
targetCultivatorName?: string;
|
||||
expiresAt: Date;
|
||||
tx?: DbTransaction;
|
||||
}): Promise<AuctionListing> {
|
||||
@@ -30,6 +33,9 @@ export async function createListing(data: {
|
||||
itemId: data.itemId,
|
||||
itemSnapshot: data.itemSnapshot,
|
||||
price: data.price,
|
||||
visibility: data.visibility ?? 'public',
|
||||
targetCultivatorId: data.targetCultivatorId,
|
||||
targetCultivatorName: data.targetCultivatorName,
|
||||
status: 'active',
|
||||
expiresAt: data.expiresAt,
|
||||
})
|
||||
@@ -63,6 +69,7 @@ export interface FindActiveListingsOptions {
|
||||
sortBy?: 'price_asc' | 'price_desc' | 'latest';
|
||||
page?: number;
|
||||
limit?: number;
|
||||
viewerCultivatorId?: string;
|
||||
}
|
||||
|
||||
export async function findActiveListings(
|
||||
@@ -76,6 +83,7 @@ export async function findActiveListings(
|
||||
sortBy = 'latest',
|
||||
page = 1,
|
||||
limit = 20,
|
||||
viewerCultivatorId,
|
||||
} = options;
|
||||
|
||||
// 构建筛选条件
|
||||
@@ -93,6 +101,15 @@ export async function findActiveListings(
|
||||
if (maxPrice !== undefined) {
|
||||
conditions.push(lte(schema.auctionListings.price, maxPrice));
|
||||
}
|
||||
conditions.push(
|
||||
viewerCultivatorId
|
||||
? or(
|
||||
eq(schema.auctionListings.visibility, 'public'),
|
||||
eq(schema.auctionListings.sellerId, viewerCultivatorId),
|
||||
eq(schema.auctionListings.targetCultivatorId, viewerCultivatorId),
|
||||
)!
|
||||
: eq(schema.auctionListings.visibility, 'public'),
|
||||
);
|
||||
|
||||
const whereClause = and(...conditions);
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@server/lib/drizzle/db', () => ({
|
||||
getExecutor: vi.fn(),
|
||||
}));
|
||||
|
||||
import { pruneExpiredData } from './retentionRepository';
|
||||
|
||||
function flattenSqlObject(value: unknown, seen = new Set<unknown>()): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (!value || typeof value !== 'object' || seen.has(value)) return '';
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => flattenSqlObject(item, seen)).join(' ');
|
||||
}
|
||||
return Object.values(value as Record<string, unknown>)
|
||||
.map((item) => flattenSqlObject(item, seen))
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
describe('retentionRepository', () => {
|
||||
it('keeps old mails with unclaimed attachments when pruning expired data', async () => {
|
||||
const returningMock = vi.fn().mockResolvedValue([]);
|
||||
const whereMock = vi.fn(() => ({ returning: returningMock }));
|
||||
const deleteMock = vi.fn(() => ({ where: whereMock }));
|
||||
const q = { delete: deleteMock };
|
||||
const cutoff = new Date('2026-06-01T00:00:00.000Z');
|
||||
|
||||
await pruneExpiredData(
|
||||
{
|
||||
mails: cutoff,
|
||||
qiLogs: cutoff,
|
||||
dungeonHistories: cutoff,
|
||||
dungeonRuns: cutoff,
|
||||
battleRecordsV2: cutoff,
|
||||
reputationShopPurchases: cutoff,
|
||||
auctionListings: cutoff,
|
||||
},
|
||||
q as any,
|
||||
);
|
||||
|
||||
const mailWhereSql = flattenSqlObject((whereMock.mock.calls as unknown[][])[0]?.[0]);
|
||||
expect(mailWhereSql).toContain('jsonb_array_length');
|
||||
expect(mailWhereSql).toContain('is_claimed');
|
||||
expect(mailWhereSql).toContain('attachments');
|
||||
});
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
qiLogs,
|
||||
reputationShopPurchases,
|
||||
} from '@server/lib/drizzle/schema';
|
||||
import { and, inArray, lt, ne } from 'drizzle-orm';
|
||||
import { and, inArray, lt, ne, sql } from 'drizzle-orm';
|
||||
|
||||
export type ExpiredDataCleanupCutoffs = {
|
||||
mails: Date;
|
||||
@@ -46,7 +46,12 @@ export async function pruneExpiredData(
|
||||
const mailsDeleted = await deleteExpiredRows(q, (executor) =>
|
||||
executor
|
||||
.delete(mails)
|
||||
.where(lt(mails.createdAt, cutoffs.mails))
|
||||
.where(
|
||||
and(
|
||||
lt(mails.createdAt, cutoffs.mails),
|
||||
sql`(${mails.isClaimed} = true OR ${mails.attachments} IS NULL OR jsonb_typeof(${mails.attachments}) <> 'array' OR jsonb_array_length(${mails.attachments}) = 0)`,
|
||||
),
|
||||
)
|
||||
.returning({ id: mails.id }),
|
||||
);
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ const {
|
||||
keysMock,
|
||||
sendMailMock,
|
||||
setMock,
|
||||
assertFriendMock,
|
||||
consumeFirstTalismanByScenarioMock,
|
||||
getInviteTargetMock,
|
||||
updateStatusMock,
|
||||
} = vi.hoisted(() => ({
|
||||
countActiveBySellerMock: vi.fn(),
|
||||
@@ -17,6 +20,9 @@ const {
|
||||
keysMock: vi.fn(),
|
||||
sendMailMock: vi.fn(),
|
||||
setMock: vi.fn(),
|
||||
assertFriendMock: vi.fn(),
|
||||
consumeFirstTalismanByScenarioMock: vi.fn(),
|
||||
getInviteTargetMock: vi.fn(),
|
||||
updateStatusMock: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -46,6 +52,24 @@ vi.mock('@server/lib/services/MailService', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@server/lib/services/FriendService', () => ({
|
||||
assertFriend: assertFriendMock,
|
||||
FriendServiceError: class FriendServiceError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
getInviteTarget: getInviteTargetMock,
|
||||
}));
|
||||
|
||||
vi.mock('@server/lib/services/TalismanScenarioService', () => ({
|
||||
consumeFirstTalismanByScenario: consumeFirstTalismanByScenarioMock,
|
||||
TalismanScenarioError: class TalismanScenarioError extends Error {},
|
||||
}));
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { buyItem, listItem } from './AuctionService';
|
||||
@@ -216,6 +240,19 @@ describe('AuctionService', () => {
|
||||
createListingMock.mockResolvedValue({ id: 'listing-1' });
|
||||
updateStatusMock.mockResolvedValue(undefined);
|
||||
sendMailMock.mockResolvedValue({ id: 'mail-1' });
|
||||
assertFriendMock.mockResolvedValue(undefined);
|
||||
consumeFirstTalismanByScenarioMock.mockResolvedValue(undefined);
|
||||
getInviteTargetMock.mockResolvedValue({
|
||||
isFriend: true,
|
||||
target: {
|
||||
id: 'target-1',
|
||||
name: '南宫婉',
|
||||
title: null,
|
||||
realm: '筑基',
|
||||
realmStage: '初期',
|
||||
status: 'active',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('allows listing pills and preserves their full spec in the auction snapshot', async () => {
|
||||
@@ -225,6 +262,7 @@ describe('AuctionService', () => {
|
||||
getExecutorMock.mockReturnValue(executor);
|
||||
|
||||
await listItem({
|
||||
userId: 'user-1',
|
||||
cultivatorId: 'cultivator-1',
|
||||
cultivatorName: '韩立',
|
||||
itemType: 'consumable',
|
||||
@@ -270,6 +308,7 @@ describe('AuctionService', () => {
|
||||
|
||||
await expect(
|
||||
listItem({
|
||||
userId: 'user-1',
|
||||
cultivatorId: 'cultivator-1',
|
||||
cultivatorName: '韩立',
|
||||
itemType: 'consumable',
|
||||
@@ -281,6 +320,48 @@ describe('AuctionService', () => {
|
||||
expect(createListingMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('consumes a private listing talisman and stores the target friend snapshot', async () => {
|
||||
const { executor } = createExecutor({
|
||||
consumableRow: createConsumableRow(),
|
||||
});
|
||||
getExecutorMock.mockReturnValue(executor);
|
||||
|
||||
await listItem({
|
||||
userId: 'user-1',
|
||||
cultivatorId: 'cultivator-1',
|
||||
cultivatorName: '韩立',
|
||||
itemType: 'consumable',
|
||||
itemId: 'consumable-1',
|
||||
price: 120,
|
||||
quantity: 1,
|
||||
visibility: 'private',
|
||||
targetCultivatorId: 'target-1',
|
||||
});
|
||||
|
||||
expect(getInviteTargetMock).toHaveBeenCalledWith(
|
||||
'cultivator-1',
|
||||
'target-1',
|
||||
executor,
|
||||
);
|
||||
expect(assertFriendMock).toHaveBeenCalledWith(
|
||||
'cultivator-1',
|
||||
'target-1',
|
||||
executor,
|
||||
);
|
||||
expect(consumeFirstTalismanByScenarioMock).toHaveBeenCalledWith(
|
||||
'cultivator-1',
|
||||
'auction_private_listing',
|
||||
executor,
|
||||
);
|
||||
expect(createListingMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
visibility: 'private',
|
||||
targetCultivatorId: 'target-1',
|
||||
targetCultivatorName: '南宫婉',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the buyer a consumable attachment whose pill spec matches the sold snapshot', async () => {
|
||||
const pillSnapshot = {
|
||||
id: 'consumable-1',
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
TEMP_DISABLED_MESSAGES,
|
||||
temporaryRestrictions,
|
||||
} from '@shared/config/temporaryRestrictions';
|
||||
import { AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO } from '@shared/config/socialConfig';
|
||||
import { isPillConsumable } from '@shared/lib/consumables';
|
||||
import { QUALITY_ORDER, type Quality } from '@shared/types/constants';
|
||||
import type { Artifact, Consumable, Material } from '@shared/types/cultivator';
|
||||
@@ -14,6 +15,15 @@ import * as schema from '../drizzle/schema';
|
||||
import { MailService } from './MailService';
|
||||
import { mapConsumableRow } from './consumablePersistence';
|
||||
import { toArtifactFromProduct } from './creationProductArtifactSupport';
|
||||
import {
|
||||
assertFriend,
|
||||
FriendServiceError,
|
||||
getInviteTarget,
|
||||
} from './FriendService';
|
||||
import {
|
||||
consumeFirstTalismanByScenario,
|
||||
TalismanScenarioError,
|
||||
} from './TalismanScenarioService';
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
@@ -59,10 +69,17 @@ export const AuctionError = {
|
||||
INVALID_ITEM_QUALITY: 'INVALID_ITEM_QUALITY',
|
||||
CONSUMABLE_LISTING_DISABLED: 'CONSUMABLE_LISTING_DISABLED',
|
||||
SAME_OWNER: 'SAME_OWNER',
|
||||
INVALID_VISIBILITY: 'INVALID_VISIBILITY',
|
||||
TARGET_NOT_FRIEND: 'TARGET_NOT_FRIEND',
|
||||
MISSING_TALISMAN: 'MISSING_TALISMAN',
|
||||
NOT_TARGET_BUYER: 'NOT_TARGET_BUYER',
|
||||
} as const;
|
||||
|
||||
export type AuctionErrorCode = (typeof AuctionError)[keyof typeof AuctionError];
|
||||
|
||||
export type AuctionItemType = 'material' | 'artifact' | 'consumable';
|
||||
export type AuctionListingVisibility = 'public' | 'private';
|
||||
|
||||
export class AuctionServiceError extends Error {
|
||||
constructor(
|
||||
public code: AuctionErrorCode,
|
||||
@@ -78,12 +95,15 @@ export class AuctionServiceError extends Error {
|
||||
// ============================================================================
|
||||
|
||||
export interface ListItemInput {
|
||||
userId: string;
|
||||
cultivatorId: string;
|
||||
cultivatorName: string;
|
||||
itemType: 'material' | 'artifact' | 'consumable';
|
||||
itemType: AuctionItemType;
|
||||
itemId: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
visibility?: AuctionListingVisibility;
|
||||
targetCultivatorId?: string;
|
||||
}
|
||||
|
||||
export interface ListItemResult {
|
||||
@@ -123,8 +143,8 @@ async function getArtifactProductSnapshot(
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function getItemSnapshot(
|
||||
itemType: 'material' | 'artifact' | 'consumable',
|
||||
export async function getAuctionItemSnapshot(
|
||||
itemType: AuctionItemType,
|
||||
itemId: string,
|
||||
cultivatorId: string,
|
||||
executor?: DbExecutor,
|
||||
@@ -183,8 +203,8 @@ export async function clearAuctionListingsCache(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeItemQuality(
|
||||
itemType: 'material' | 'artifact' | 'consumable',
|
||||
export function normalizeAuctionItemQuality(
|
||||
itemType: AuctionItemType,
|
||||
item: Material | Artifact | Consumable,
|
||||
): Quality {
|
||||
if (itemType === 'material') {
|
||||
@@ -195,10 +215,60 @@ function normalizeItemQuality(
|
||||
return quality in QUALITY_ORDER ? quality : '凡品';
|
||||
}
|
||||
|
||||
function isAuctionListableQuality(quality: Quality): boolean {
|
||||
export function isAuctionListableQuality(quality: Quality): boolean {
|
||||
return QUALITY_ORDER[quality] >= QUALITY_ORDER[AUCTION_MIN_QUALITY];
|
||||
}
|
||||
|
||||
export function assertAuctionListableItem(
|
||||
itemType: AuctionItemType,
|
||||
itemSnapshot: Material | Artifact | Consumable,
|
||||
quantity: number,
|
||||
): void {
|
||||
if ((itemSnapshot as Artifact).isEquipped) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_ITEM_TYPE,
|
||||
'已装备法宝不可寄售,请先卸下',
|
||||
);
|
||||
}
|
||||
if (
|
||||
itemType === 'consumable' &&
|
||||
!isPillConsumable(itemSnapshot as Consumable)
|
||||
) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_ITEM_TYPE,
|
||||
'当前仅支持丹药寄售',
|
||||
);
|
||||
}
|
||||
|
||||
const itemQuality = normalizeAuctionItemQuality(itemType, itemSnapshot);
|
||||
if (!isAuctionListableQuality(itemQuality)) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_ITEM_QUALITY,
|
||||
`仅玄品及以上物品可寄售,当前为${itemQuality}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (itemType === 'artifact' && quantity !== 1) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_QUANTITY,
|
||||
'法宝每次只能上架 1 件',
|
||||
);
|
||||
}
|
||||
|
||||
const availableQuantity =
|
||||
itemType === 'artifact'
|
||||
? 1
|
||||
: 'quantity' in itemSnapshot
|
||||
? itemSnapshot.quantity
|
||||
: 0;
|
||||
if (itemType !== 'artifact' && quantity > availableQuantity) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_QUANTITY,
|
||||
`上架数量不足,当前仅有 ${availableQuantity}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Service Methods
|
||||
// ============================================================================
|
||||
@@ -211,8 +281,17 @@ export async function listItem(
|
||||
options: AuctionMutationOptions = {},
|
||||
): Promise<ListItemResult> {
|
||||
const q = getExecutor(options.tx);
|
||||
const { cultivatorId, cultivatorName, itemType, itemId, price, quantity } =
|
||||
input;
|
||||
const {
|
||||
cultivatorId,
|
||||
cultivatorName,
|
||||
itemType,
|
||||
itemId,
|
||||
price,
|
||||
quantity,
|
||||
targetCultivatorId,
|
||||
} = input;
|
||||
const visibility = input.visibility ?? 'public';
|
||||
let targetCultivatorName: string | undefined;
|
||||
|
||||
// 1. 校验价格(全局上限)
|
||||
if (price < 1) {
|
||||
@@ -244,6 +323,41 @@ export async function listItem(
|
||||
);
|
||||
}
|
||||
|
||||
if (!['public', 'private'].includes(visibility)) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_VISIBILITY,
|
||||
'无效的拍卖可见范围',
|
||||
);
|
||||
}
|
||||
if (visibility === 'private') {
|
||||
if (!targetCultivatorId) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_VISIBILITY,
|
||||
'专属交易必须指定好友',
|
||||
);
|
||||
}
|
||||
try {
|
||||
const target = await getInviteTarget(cultivatorId, targetCultivatorId, q);
|
||||
if (!target.isFriend) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.TARGET_NOT_FRIEND,
|
||||
'专属交易只能指定好友名录中的道友',
|
||||
);
|
||||
}
|
||||
targetCultivatorName = target.target.name;
|
||||
} catch (error) {
|
||||
if (error instanceof FriendServiceError) {
|
||||
throw new AuctionServiceError(
|
||||
error.status === 403
|
||||
? AuctionError.TARGET_NOT_FRIEND
|
||||
: AuctionError.ITEM_NOT_FOUND,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
temporaryRestrictions.disableConsumableAuctionListing &&
|
||||
itemType === 'consumable'
|
||||
@@ -279,39 +393,20 @@ export async function listItem(
|
||||
}
|
||||
|
||||
// 6. 获取物品快照并校验所有权
|
||||
const artifactProduct =
|
||||
itemType === 'artifact'
|
||||
? await getArtifactProductSnapshot(itemId, cultivatorId, q)
|
||||
: null;
|
||||
const itemSnapshot = await getItemSnapshot(itemType, itemId, cultivatorId, q);
|
||||
const itemSnapshot = await getAuctionItemSnapshot(
|
||||
itemType,
|
||||
itemId,
|
||||
cultivatorId,
|
||||
q,
|
||||
);
|
||||
if (!itemSnapshot) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.ITEM_NOT_FOUND,
|
||||
'物品不存在或已消耗',
|
||||
);
|
||||
}
|
||||
if (artifactProduct?.isEquipped) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_ITEM_TYPE,
|
||||
'已装备法宝不可寄售,请先卸下',
|
||||
);
|
||||
}
|
||||
if (
|
||||
itemType === 'consumable' &&
|
||||
!isPillConsumable(itemSnapshot as Consumable)
|
||||
) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_ITEM_TYPE,
|
||||
'当前仅支持丹药寄售',
|
||||
);
|
||||
}
|
||||
const itemQuality = normalizeItemQuality(itemType, itemSnapshot);
|
||||
if (!isAuctionListableQuality(itemQuality)) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_ITEM_QUALITY,
|
||||
`仅玄品及以上物品可寄售,当前为${itemQuality}`,
|
||||
);
|
||||
}
|
||||
assertAuctionListableItem(itemType, itemSnapshot, quantity);
|
||||
const itemQuality = normalizeAuctionItemQuality(itemType, itemSnapshot);
|
||||
|
||||
// 按品质校验价格上限
|
||||
const qualityCap = QUALITY_PRICE_CAPS[itemQuality];
|
||||
@@ -322,32 +417,13 @@ export async function listItem(
|
||||
);
|
||||
}
|
||||
|
||||
const availableQuantity =
|
||||
itemType === 'artifact'
|
||||
? 1
|
||||
: 'quantity' in itemSnapshot
|
||||
? itemSnapshot.quantity
|
||||
: 0;
|
||||
if (itemType === 'artifact' && quantity !== 1) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_QUANTITY,
|
||||
'法宝每次只能上架 1 件',
|
||||
);
|
||||
}
|
||||
if (itemType !== 'artifact' && quantity > availableQuantity) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_QUANTITY,
|
||||
`上架数量不足,当前仅有 ${availableQuantity}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 7. 在事务中:扣减/删除物品 + 创建拍卖记录
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + LISTING_DURATION_HOURS);
|
||||
|
||||
const persistListing = async (tx: DbTransaction) => {
|
||||
// 事务内二次校验并按数量扣减
|
||||
const ownedItem = await getItemSnapshot(
|
||||
const ownedItem = await getAuctionItemSnapshot(
|
||||
itemType,
|
||||
itemId,
|
||||
cultivatorId,
|
||||
@@ -359,14 +435,37 @@ export async function listItem(
|
||||
'物品不存在或已被消耗',
|
||||
);
|
||||
}
|
||||
if (
|
||||
itemType === 'consumable' &&
|
||||
!isPillConsumable(ownedItem as Consumable)
|
||||
) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_ITEM_TYPE,
|
||||
'当前仅支持丹药寄售',
|
||||
);
|
||||
assertAuctionListableItem(itemType, ownedItem, quantity);
|
||||
|
||||
if (visibility === 'private') {
|
||||
if (!targetCultivatorId) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.INVALID_VISIBILITY,
|
||||
'专属交易必须指定好友',
|
||||
);
|
||||
}
|
||||
try {
|
||||
await assertFriend(cultivatorId, targetCultivatorId, tx);
|
||||
await consumeFirstTalismanByScenario(
|
||||
cultivatorId,
|
||||
AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO,
|
||||
tx,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof FriendServiceError) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.TARGET_NOT_FRIEND,
|
||||
error.message,
|
||||
);
|
||||
}
|
||||
if (error instanceof TalismanScenarioError) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.MISSING_TALISMAN,
|
||||
'缺少拍卖行贵宾符,可前往天骄宝阁购买后再上架专属交易',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const listingSnapshot =
|
||||
@@ -470,6 +569,11 @@ export async function listItem(
|
||||
itemId,
|
||||
itemSnapshot: listingSnapshot,
|
||||
price,
|
||||
visibility,
|
||||
targetCultivatorId:
|
||||
visibility === 'private' ? targetCultivatorId : undefined,
|
||||
targetCultivatorName:
|
||||
visibility === 'private' ? targetCultivatorName : undefined,
|
||||
expiresAt,
|
||||
tx,
|
||||
});
|
||||
@@ -547,6 +651,15 @@ export async function buyItem(
|
||||
'无法购买自己寄售的物品',
|
||||
);
|
||||
}
|
||||
if (
|
||||
listing.visibility === 'private' &&
|
||||
listing.targetCultivatorId !== buyerCultivatorId
|
||||
) {
|
||||
throw new AuctionServiceError(
|
||||
AuctionError.NOT_TARGET_BUYER,
|
||||
'此物为专属交易,不可购买',
|
||||
);
|
||||
}
|
||||
|
||||
const price = listing.price;
|
||||
const feeAmount = Math.floor(price * FEE_RATE);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@server/lib/drizzle/db', () => ({
|
||||
getExecutor: vi.fn(),
|
||||
}));
|
||||
|
||||
import { addFriendPair, FriendServiceError } from './FriendService';
|
||||
|
||||
function createFriendTx() {
|
||||
let selectCount = 0;
|
||||
const valuesMock = vi.fn(() => ({ onConflictDoNothing: vi.fn() }));
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: vi.fn(async () => {
|
||||
selectCount += 1;
|
||||
return [
|
||||
selectCount === 1
|
||||
? {
|
||||
id: 'cultivator-1',
|
||||
name: '韩立',
|
||||
title: null,
|
||||
realm: '筑基',
|
||||
realm_stage: '初期',
|
||||
status: 'active',
|
||||
}
|
||||
: {
|
||||
id: 'cultivator-2',
|
||||
name: '南宫婉',
|
||||
title: null,
|
||||
realm: '结丹',
|
||||
realm_stage: '中期',
|
||||
status: 'active',
|
||||
},
|
||||
];
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
insert: vi.fn(() => ({ values: valuesMock })),
|
||||
valuesMock,
|
||||
};
|
||||
}
|
||||
|
||||
describe('FriendService', () => {
|
||||
it('adds both directions for a friend pair', async () => {
|
||||
const tx = createFriendTx();
|
||||
|
||||
await expect(
|
||||
addFriendPair('cultivator-1', 'cultivator-2', tx as any),
|
||||
).resolves.toEqual(
|
||||
expect.objectContaining({ id: 'cultivator-2', name: '南宫婉' }),
|
||||
);
|
||||
|
||||
expect(tx.valuesMock).toHaveBeenCalledWith([
|
||||
{
|
||||
cultivatorId: 'cultivator-1',
|
||||
friendCultivatorId: 'cultivator-2',
|
||||
},
|
||||
{
|
||||
cultivatorId: 'cultivator-2',
|
||||
friendCultivatorId: 'cultivator-1',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects adding self', async () => {
|
||||
await expect(addFriendPair('cultivator-1', 'cultivator-1')).rejects.toBeInstanceOf(
|
||||
FriendServiceError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
getExecutor,
|
||||
type DbExecutor,
|
||||
type DbTransaction,
|
||||
} from '@server/lib/drizzle/db';
|
||||
import * as schema from '@server/lib/drizzle/schema';
|
||||
import { and, eq, inArray, or } from 'drizzle-orm';
|
||||
|
||||
export type FriendCultivatorSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string | null;
|
||||
realm: string;
|
||||
realmStage: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
export class FriendServiceError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'FriendServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
function toSummary(
|
||||
row: Pick<
|
||||
typeof schema.cultivators.$inferSelect,
|
||||
'id' | 'name' | 'title' | 'realm' | 'realm_stage' | 'status'
|
||||
>,
|
||||
): FriendCultivatorSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
title: row.title,
|
||||
realm: row.realm,
|
||||
realmStage: row.realm_stage,
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
|
||||
async function getActiveCultivatorSummary(
|
||||
cultivatorId: string,
|
||||
q: DbExecutor | DbTransaction,
|
||||
): Promise<FriendCultivatorSummary | null> {
|
||||
const [row] = await q
|
||||
.select({
|
||||
id: schema.cultivators.id,
|
||||
name: schema.cultivators.name,
|
||||
title: schema.cultivators.title,
|
||||
realm: schema.cultivators.realm,
|
||||
realm_stage: schema.cultivators.realm_stage,
|
||||
status: schema.cultivators.status,
|
||||
})
|
||||
.from(schema.cultivators)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.cultivators.id, cultivatorId),
|
||||
eq(schema.cultivators.status, 'active'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return row ? toSummary(row) : null;
|
||||
}
|
||||
|
||||
export async function areFriends(
|
||||
cultivatorId: string,
|
||||
friendCultivatorId: string,
|
||||
executor?: DbExecutor | DbTransaction,
|
||||
): Promise<boolean> {
|
||||
const q = executor ?? getExecutor();
|
||||
const [row] = await q
|
||||
.select({ id: schema.cultivatorFriends.id })
|
||||
.from(schema.cultivatorFriends)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.cultivatorFriends.cultivatorId, cultivatorId),
|
||||
eq(schema.cultivatorFriends.friendCultivatorId, friendCultivatorId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return Boolean(row);
|
||||
}
|
||||
|
||||
export async function listFriends(
|
||||
cultivatorId: string,
|
||||
executor?: DbExecutor | DbTransaction,
|
||||
): Promise<FriendCultivatorSummary[]> {
|
||||
const q = executor ?? getExecutor();
|
||||
const rows = await q
|
||||
.select({
|
||||
id: schema.cultivators.id,
|
||||
name: schema.cultivators.name,
|
||||
title: schema.cultivators.title,
|
||||
realm: schema.cultivators.realm,
|
||||
realm_stage: schema.cultivators.realm_stage,
|
||||
status: schema.cultivators.status,
|
||||
})
|
||||
.from(schema.cultivatorFriends)
|
||||
.innerJoin(
|
||||
schema.cultivators,
|
||||
eq(schema.cultivatorFriends.friendCultivatorId, schema.cultivators.id),
|
||||
)
|
||||
.where(eq(schema.cultivatorFriends.cultivatorId, cultivatorId));
|
||||
|
||||
return rows.map(toSummary);
|
||||
}
|
||||
|
||||
export async function getInviteTarget(
|
||||
currentCultivatorId: string,
|
||||
targetCultivatorId: string,
|
||||
executor?: DbExecutor | DbTransaction,
|
||||
): Promise<{ target: FriendCultivatorSummary; isFriend: boolean }> {
|
||||
const q = executor ?? getExecutor();
|
||||
if (currentCultivatorId === targetCultivatorId) {
|
||||
throw new FriendServiceError(400, '不能将自己加入好友名录');
|
||||
}
|
||||
|
||||
const target = await getActiveCultivatorSummary(targetCultivatorId, q);
|
||||
if (!target) {
|
||||
throw new FriendServiceError(404, '未找到该道友');
|
||||
}
|
||||
|
||||
return {
|
||||
target,
|
||||
isFriend: await areFriends(currentCultivatorId, targetCultivatorId, q),
|
||||
};
|
||||
}
|
||||
|
||||
export async function addFriendPair(
|
||||
cultivatorId: string,
|
||||
friendCultivatorId: string,
|
||||
executor?: DbExecutor | DbTransaction,
|
||||
): Promise<FriendCultivatorSummary> {
|
||||
if (cultivatorId === friendCultivatorId) {
|
||||
throw new FriendServiceError(400, '不能将自己加入好友名录');
|
||||
}
|
||||
|
||||
const persist = async (tx: DbTransaction) => {
|
||||
const [current, friend] = await Promise.all([
|
||||
getActiveCultivatorSummary(cultivatorId, tx),
|
||||
getActiveCultivatorSummary(friendCultivatorId, tx),
|
||||
]);
|
||||
|
||||
if (!current) {
|
||||
throw new FriendServiceError(404, '当前角色不存在');
|
||||
}
|
||||
if (!friend) {
|
||||
throw new FriendServiceError(404, '未找到该道友');
|
||||
}
|
||||
|
||||
await tx
|
||||
.insert(schema.cultivatorFriends)
|
||||
.values([
|
||||
{ cultivatorId, friendCultivatorId },
|
||||
{ cultivatorId: friendCultivatorId, friendCultivatorId: cultivatorId },
|
||||
])
|
||||
.onConflictDoNothing();
|
||||
|
||||
return friend;
|
||||
};
|
||||
|
||||
return executor
|
||||
? persist(executor as DbTransaction)
|
||||
: getExecutor().transaction(persist);
|
||||
}
|
||||
|
||||
export async function removeFriendPair(
|
||||
cultivatorId: string,
|
||||
friendCultivatorId: string,
|
||||
executor?: DbExecutor | DbTransaction,
|
||||
): Promise<void> {
|
||||
const q = executor ?? getExecutor();
|
||||
await q
|
||||
.delete(schema.cultivatorFriends)
|
||||
.where(
|
||||
or(
|
||||
and(
|
||||
eq(schema.cultivatorFriends.cultivatorId, cultivatorId),
|
||||
eq(schema.cultivatorFriends.friendCultivatorId, friendCultivatorId),
|
||||
),
|
||||
and(
|
||||
eq(schema.cultivatorFriends.cultivatorId, friendCultivatorId),
|
||||
eq(schema.cultivatorFriends.friendCultivatorId, cultivatorId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function assertFriend(
|
||||
cultivatorId: string,
|
||||
friendCultivatorId: string,
|
||||
executor?: DbExecutor | DbTransaction,
|
||||
): Promise<void> {
|
||||
if (!(await areFriends(cultivatorId, friendCultivatorId, executor))) {
|
||||
throw new FriendServiceError(403, '只能向好友名录中的道友发送');
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertFriends(
|
||||
cultivatorId: string,
|
||||
friendCultivatorIds: string[],
|
||||
executor?: DbExecutor | DbTransaction,
|
||||
): Promise<void> {
|
||||
if (friendCultivatorIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const q = executor ?? getExecutor();
|
||||
const rows = await q
|
||||
.select({ friendCultivatorId: schema.cultivatorFriends.friendCultivatorId })
|
||||
.from(schema.cultivatorFriends)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.cultivatorFriends.cultivatorId, cultivatorId),
|
||||
inArray(
|
||||
schema.cultivatorFriends.friendCultivatorId,
|
||||
friendCultivatorIds,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (rows.length !== new Set(friendCultivatorIds).size) {
|
||||
throw new FriendServiceError(403, '只能指定好友名录中的道友');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
const {
|
||||
assertAuctionListableItemMock,
|
||||
assertFriendMock,
|
||||
consumeFirstTalismanByScenarioMock,
|
||||
deleteArtifactsByIdsAndCultivatorMock,
|
||||
getAuctionItemSnapshotMock,
|
||||
sendMailMock,
|
||||
} = vi.hoisted(() => ({
|
||||
assertAuctionListableItemMock: vi.fn(),
|
||||
assertFriendMock: vi.fn(),
|
||||
consumeFirstTalismanByScenarioMock: vi.fn(),
|
||||
deleteArtifactsByIdsAndCultivatorMock: vi.fn(),
|
||||
getAuctionItemSnapshotMock: vi.fn(),
|
||||
sendMailMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./AuctionService', () => ({
|
||||
assertAuctionListableItem: assertAuctionListableItemMock,
|
||||
AuctionServiceError: class AuctionServiceError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
getAuctionItemSnapshot: getAuctionItemSnapshotMock,
|
||||
}));
|
||||
|
||||
vi.mock('./FriendService', () => ({
|
||||
assertFriend: assertFriendMock,
|
||||
FriendServiceError: class FriendServiceError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./TalismanScenarioService', () => ({
|
||||
consumeFirstTalismanByScenario: consumeFirstTalismanByScenarioMock,
|
||||
TalismanScenarioError: class TalismanScenarioError extends Error {},
|
||||
}));
|
||||
|
||||
vi.mock('./MailService', () => ({
|
||||
MailService: {
|
||||
sendMail: sendMailMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@server/lib/drizzle/db', () => ({
|
||||
getExecutor: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@server/lib/repositories/creationProductRepository', () => ({
|
||||
deleteArtifactsByIdsAndCultivator: deleteArtifactsByIdsAndCultivatorMock,
|
||||
}));
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as schema from '../drizzle/schema';
|
||||
import { sendPlayerMail } from './PlayerMailService';
|
||||
|
||||
function createTx() {
|
||||
const updateSetMock = vi.fn(() => ({ where: vi.fn() }));
|
||||
return {
|
||||
select: vi.fn(() => ({
|
||||
from: vi.fn(() => ({
|
||||
where: vi.fn(() => ({
|
||||
limit: vi.fn(async () => [{ id: 'recipient-1', name: '南宫婉' }]),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
delete: vi.fn(() => ({ where: vi.fn() })),
|
||||
update: vi.fn(() => ({ set: updateSetMock })),
|
||||
updateSetMock,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlayerMailService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
assertFriendMock.mockResolvedValue(undefined);
|
||||
consumeFirstTalismanByScenarioMock.mockResolvedValue(undefined);
|
||||
sendMailMock.mockResolvedValue({ id: 'mail-1' });
|
||||
deleteArtifactsByIdsAndCultivatorMock.mockResolvedValue([{ id: 'artifact-1' }]);
|
||||
getAuctionItemSnapshotMock.mockResolvedValue({
|
||||
id: 'material-1',
|
||||
name: '玄铁',
|
||||
type: 'ore',
|
||||
rank: '玄品',
|
||||
quantity: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('consumes the blank jade slip, detaches one transferable attachment, and sends mail', async () => {
|
||||
const tx = createTx();
|
||||
|
||||
await expect(
|
||||
sendPlayerMail({
|
||||
senderCultivatorId: 'sender-1',
|
||||
senderName: '韩立',
|
||||
recipientCultivatorId: 'recipient-1',
|
||||
content: '此物赠与道友。',
|
||||
attachment: {
|
||||
itemType: 'material',
|
||||
itemId: 'material-1',
|
||||
quantity: 2,
|
||||
},
|
||||
tx: tx as any,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
recipientName: '南宫婉',
|
||||
attachmentCount: 1,
|
||||
});
|
||||
|
||||
expect(assertFriendMock).toHaveBeenCalledWith(
|
||||
'sender-1',
|
||||
'recipient-1',
|
||||
tx,
|
||||
);
|
||||
expect(consumeFirstTalismanByScenarioMock).toHaveBeenCalledWith(
|
||||
'sender-1',
|
||||
'friend_mail_send',
|
||||
tx,
|
||||
);
|
||||
expect(assertAuctionListableItemMock).toHaveBeenCalledWith(
|
||||
'material',
|
||||
expect.objectContaining({ name: '玄铁', quantity: 3 }),
|
||||
2,
|
||||
);
|
||||
expect(tx.update).toHaveBeenCalledWith(schema.materials);
|
||||
expect(tx.updateSetMock).toHaveBeenCalledWith({ quantity: 1 });
|
||||
expect(sendMailMock).toHaveBeenCalledWith(
|
||||
'recipient-1',
|
||||
'来自韩立的传音',
|
||||
'此物赠与道友。',
|
||||
[
|
||||
{
|
||||
type: 'material',
|
||||
name: '玄铁',
|
||||
quantity: 2,
|
||||
data: expect.objectContaining({ name: '玄铁', quantity: 2 }),
|
||||
},
|
||||
],
|
||||
'reward',
|
||||
tx,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects sending to non-friends', async () => {
|
||||
const tx = createTx();
|
||||
assertFriendMock.mockRejectedValueOnce(new Error('只能向好友名录中的道友发送'));
|
||||
|
||||
await expect(
|
||||
sendPlayerMail({
|
||||
senderCultivatorId: 'sender-1',
|
||||
senderName: '韩立',
|
||||
recipientCultivatorId: 'recipient-1',
|
||||
content: '此物赠与道友。',
|
||||
tx: tx as any,
|
||||
}),
|
||||
).rejects.toThrow('只能向好友名录中的道友发送');
|
||||
expect(sendMailMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { getExecutor, type DbTransaction } from '@server/lib/drizzle/db';
|
||||
import * as creationProductRepository from '@server/lib/repositories/creationProductRepository';
|
||||
import * as schema from '@server/lib/drizzle/schema';
|
||||
import { FRIEND_MAIL_TALISMAN_SCENARIO } from '@shared/config/socialConfig';
|
||||
import type { Artifact, Consumable, Material } from '@shared/types/cultivator';
|
||||
import type { MailAttachment } from '@shared/types/mail';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import {
|
||||
assertAuctionListableItem,
|
||||
AuctionServiceError,
|
||||
getAuctionItemSnapshot,
|
||||
type AuctionItemType,
|
||||
} from './AuctionService';
|
||||
import { assertFriend, FriendServiceError } from './FriendService';
|
||||
import { MailService } from './MailService';
|
||||
import {
|
||||
consumeFirstTalismanByScenario,
|
||||
TalismanScenarioError,
|
||||
} from './TalismanScenarioService';
|
||||
|
||||
export type PlayerMailAttachmentInput = {
|
||||
itemType: AuctionItemType;
|
||||
itemId: string;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
export class PlayerMailServiceError extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PlayerMailServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
async function assertActiveRecipient(
|
||||
recipientCultivatorId: string,
|
||||
tx: DbTransaction,
|
||||
): Promise<{ id: string; name: string }> {
|
||||
const [recipient] = await tx
|
||||
.select({
|
||||
id: schema.cultivators.id,
|
||||
name: schema.cultivators.name,
|
||||
})
|
||||
.from(schema.cultivators)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.cultivators.id, recipientCultivatorId),
|
||||
eq(schema.cultivators.status, 'active'),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!recipient) {
|
||||
throw new PlayerMailServiceError(404, '未找到收信道友');
|
||||
}
|
||||
|
||||
return recipient;
|
||||
}
|
||||
|
||||
async function detachAttachment(
|
||||
senderCultivatorId: string,
|
||||
attachment: PlayerMailAttachmentInput,
|
||||
tx: DbTransaction,
|
||||
): Promise<MailAttachment> {
|
||||
if (attachment.quantity < 1) {
|
||||
throw new PlayerMailServiceError(400, '附件数量必须至少为 1');
|
||||
}
|
||||
|
||||
const item = await getAuctionItemSnapshot(
|
||||
attachment.itemType,
|
||||
attachment.itemId,
|
||||
senderCultivatorId,
|
||||
tx,
|
||||
);
|
||||
if (!item) {
|
||||
throw new PlayerMailServiceError(404, '附件物品不存在或已被消耗');
|
||||
}
|
||||
|
||||
try {
|
||||
assertAuctionListableItem(attachment.itemType, item, attachment.quantity);
|
||||
} catch (error) {
|
||||
if (error instanceof AuctionServiceError) {
|
||||
throw new PlayerMailServiceError(400, error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (attachment.itemType === 'artifact') {
|
||||
const deleted =
|
||||
await creationProductRepository.deleteArtifactsByIdsAndCultivator(
|
||||
senderCultivatorId,
|
||||
[attachment.itemId],
|
||||
tx,
|
||||
);
|
||||
if (deleted.length !== 1) {
|
||||
throw new PlayerMailServiceError(404, '附件物品不存在或已被消耗');
|
||||
}
|
||||
} else if (attachment.itemType === 'material') {
|
||||
const current = item as Material;
|
||||
if (attachment.quantity === current.quantity) {
|
||||
await tx
|
||||
.delete(schema.materials)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.materials.id, attachment.itemId),
|
||||
eq(schema.materials.cultivatorId, senderCultivatorId),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await tx
|
||||
.update(schema.materials)
|
||||
.set({ quantity: current.quantity - attachment.quantity })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.materials.id, attachment.itemId),
|
||||
eq(schema.materials.cultivatorId, senderCultivatorId),
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const current = item as Consumable;
|
||||
if (attachment.quantity === current.quantity) {
|
||||
await tx
|
||||
.delete(schema.consumables)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.consumables.id, attachment.itemId),
|
||||
eq(schema.consumables.cultivatorId, senderCultivatorId),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await tx
|
||||
.update(schema.consumables)
|
||||
.set({ quantity: current.quantity - attachment.quantity })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.consumables.id, attachment.itemId),
|
||||
eq(schema.consumables.cultivatorId, senderCultivatorId),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const snapshot =
|
||||
attachment.itemType === 'artifact'
|
||||
? (item as Artifact)
|
||||
: ({ ...item, quantity: attachment.quantity } as Material | Consumable);
|
||||
|
||||
return {
|
||||
type: attachment.itemType,
|
||||
name: snapshot.name,
|
||||
quantity: attachment.quantity,
|
||||
data: snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendPlayerMail(input: {
|
||||
senderCultivatorId: string;
|
||||
senderName: string;
|
||||
recipientCultivatorId: string;
|
||||
content: string;
|
||||
attachment?: PlayerMailAttachmentInput;
|
||||
tx?: DbTransaction;
|
||||
}): Promise<{ recipientName: string; attachmentCount: number }> {
|
||||
const persist = async (tx: DbTransaction) => {
|
||||
if (input.senderCultivatorId === input.recipientCultivatorId) {
|
||||
throw new PlayerMailServiceError(400, '不能给自己发送传音');
|
||||
}
|
||||
|
||||
try {
|
||||
await assertFriend(
|
||||
input.senderCultivatorId,
|
||||
input.recipientCultivatorId,
|
||||
tx,
|
||||
);
|
||||
await consumeFirstTalismanByScenario(
|
||||
input.senderCultivatorId,
|
||||
FRIEND_MAIL_TALISMAN_SCENARIO,
|
||||
tx,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof FriendServiceError) {
|
||||
throw new PlayerMailServiceError(403, error.message);
|
||||
}
|
||||
if (error instanceof TalismanScenarioError) {
|
||||
throw new PlayerMailServiceError(
|
||||
400,
|
||||
'缺少空白传音符,可前往天骄宝阁购买后再发送传音',
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const recipient = await assertActiveRecipient(input.recipientCultivatorId, tx);
|
||||
const attachments = input.attachment
|
||||
? [await detachAttachment(input.senderCultivatorId, input.attachment, tx)]
|
||||
: [];
|
||||
|
||||
await MailService.sendMail(
|
||||
input.recipientCultivatorId,
|
||||
`来自${input.senderName}的传音`,
|
||||
input.content,
|
||||
attachments,
|
||||
attachments.length > 0 ? 'reward' : 'system',
|
||||
tx,
|
||||
);
|
||||
|
||||
return {
|
||||
recipientName: recipient.name,
|
||||
attachmentCount: attachments.length,
|
||||
};
|
||||
};
|
||||
|
||||
return input.tx
|
||||
? persist(input.tx)
|
||||
: getExecutor().transaction((tx) => persist(tx));
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { getExecutor, type DbExecutor, type DbTransaction } from '@server/lib/drizzle/db';
|
||||
import * as schema from '@server/lib/drizzle/schema';
|
||||
import { isTalismanConsumable } from '@shared/lib/consumables';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { mapConsumableRow } from './consumablePersistence';
|
||||
|
||||
export class TalismanScenarioError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'TalismanScenarioError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function consumeFirstTalismanByScenario(
|
||||
cultivatorId: string,
|
||||
scenario: string,
|
||||
executor?: DbExecutor | DbTransaction,
|
||||
): Promise<void> {
|
||||
const q = executor ?? getExecutor();
|
||||
const rows = await q
|
||||
.select()
|
||||
.from(schema.consumables)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.consumables.cultivatorId, cultivatorId),
|
||||
eq(schema.consumables.type, '符箓'),
|
||||
),
|
||||
)
|
||||
.limit(200);
|
||||
|
||||
const row = rows
|
||||
.filter((candidate) => candidate.quantity > 0)
|
||||
.find((candidate) => {
|
||||
const consumable = mapConsumableRow(candidate);
|
||||
return (
|
||||
isTalismanConsumable(consumable) &&
|
||||
consumable.spec.scenario === scenario
|
||||
);
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
throw new TalismanScenarioError('缺少对应符箓,无法完成此操作');
|
||||
}
|
||||
|
||||
if (row.quantity <= 1) {
|
||||
await q
|
||||
.delete(schema.consumables)
|
||||
.where(eq(schema.consumables.id, row.id));
|
||||
return;
|
||||
}
|
||||
|
||||
await q
|
||||
.update(schema.consumables)
|
||||
.set({ quantity: row.quantity - 1 })
|
||||
.where(eq(schema.consumables.id, row.id));
|
||||
}
|
||||
@@ -37,6 +37,8 @@ const ListSchema = z.object({
|
||||
itemId: z.string().uuid(),
|
||||
price: z.number().int().min(1).max(9_999_999),
|
||||
quantity: z.number().int().min(1).default(1),
|
||||
visibility: z.enum(['public', 'private']).default('public'),
|
||||
targetCultivatorId: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
const statusMap: Record<string, number> = {
|
||||
@@ -53,6 +55,10 @@ const statusMap: Record<string, number> = {
|
||||
INVALID_ITEM_QUALITY: 400,
|
||||
CONSUMABLE_LISTING_DISABLED: 400,
|
||||
SAME_OWNER: 403,
|
||||
INVALID_VISIBILITY: 400,
|
||||
TARGET_NOT_FRIEND: 403,
|
||||
MISSING_TALISMAN: 400,
|
||||
NOT_TARGET_BUYER: 403,
|
||||
};
|
||||
|
||||
function getAuctionErrorStatus(error: AuctionServiceError): number {
|
||||
@@ -61,7 +67,12 @@ function getAuctionErrorStatus(error: AuctionServiceError): number {
|
||||
|
||||
const router = new Hono<AppEnv>();
|
||||
|
||||
router.get('/listings', async (c) => {
|
||||
router.get('/listings', requireActiveCultivator(), async (c) => {
|
||||
const cultivator = c.get('cultivator');
|
||||
if (!cultivator) {
|
||||
return c.json({ error: '未授权访问' }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const params = ListingsSchema.parse({
|
||||
itemType: c.req.query('itemType') || undefined,
|
||||
@@ -72,7 +83,10 @@ router.get('/listings', async (c) => {
|
||||
limit: c.req.query('limit') ? Number(c.req.query('limit')) : undefined,
|
||||
});
|
||||
|
||||
const result = await auctionRepository.findActiveListings(params);
|
||||
const result = await auctionRepository.findActiveListings({
|
||||
...params,
|
||||
viewerCultivatorId: cultivator.id,
|
||||
});
|
||||
const page = params.page || 1;
|
||||
const limit = params.limit || 20;
|
||||
const totalPages = Math.ceil(result.total / limit);
|
||||
@@ -165,7 +179,8 @@ router.post('/list', requireActiveCultivator(), async (c) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const { itemType, itemId, price, quantity } = ListSchema.parse(
|
||||
const { itemType, itemId, price, quantity, visibility, targetCultivatorId } =
|
||||
ListSchema.parse(
|
||||
await c.req.json(),
|
||||
);
|
||||
|
||||
@@ -176,12 +191,15 @@ router.post('/list', requireActiveCultivator(), async (c) => {
|
||||
run: async (tx) => {
|
||||
const result = await listItem(
|
||||
{
|
||||
userId: user.id,
|
||||
cultivatorId: cultivator.id,
|
||||
cultivatorName: cultivator.name,
|
||||
itemType,
|
||||
itemId,
|
||||
price,
|
||||
quantity,
|
||||
visibility,
|
||||
targetCultivatorId,
|
||||
},
|
||||
{ tx, deferCacheClear: true },
|
||||
);
|
||||
|
||||
@@ -52,6 +52,10 @@ import {
|
||||
identifyMysteryMaterial,
|
||||
MarketServiceError,
|
||||
} from '@server/lib/services/MarketService';
|
||||
import {
|
||||
PlayerMailServiceError,
|
||||
sendPlayerMail,
|
||||
} from '@server/lib/services/PlayerMailService';
|
||||
import { PillOperationExecutor } from '@server/lib/services/PillOperationExecutor';
|
||||
import {
|
||||
QiInsufficientError,
|
||||
@@ -159,6 +163,18 @@ const ClaimMailSchema = z.object({
|
||||
mailId: z.string(),
|
||||
});
|
||||
|
||||
const SendMailSchema = z.object({
|
||||
recipientCultivatorId: z.string().uuid(),
|
||||
content: z.string().trim().min(1).max(1000),
|
||||
attachment: z
|
||||
.object({
|
||||
itemType: z.enum(['material', 'artifact', 'consumable']),
|
||||
itemId: z.string().uuid(),
|
||||
quantity: z.number().int().min(1).default(1),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const ReadMailSchema = z.object({
|
||||
mailId: z.string(),
|
||||
});
|
||||
@@ -2210,6 +2226,67 @@ mailRouter.get('/', requireActiveCultivatorRef(), async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
mailRouter.post('/send', requireActiveCultivator(), async (c) => {
|
||||
const user = c.get('user');
|
||||
const cultivator = c.get('cultivator');
|
||||
if (!user || !cultivator) {
|
||||
return c.json({ error: '未授权访问' }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = SendMailSchema.parse(await c.req.json());
|
||||
const committed = await commitPlayerStateMutation({
|
||||
userId: user.id,
|
||||
cultivatorId: cultivator.id,
|
||||
source: 'player_mail_send',
|
||||
run: async (tx) => {
|
||||
const result = await sendPlayerMail({
|
||||
senderCultivatorId: cultivator.id,
|
||||
senderName: cultivator.name,
|
||||
recipientCultivatorId: parsed.recipientCultivatorId,
|
||||
content: parsed.content,
|
||||
attachment: parsed.attachment,
|
||||
tx,
|
||||
});
|
||||
|
||||
const changes: StateChangeDescriptor[] = [
|
||||
{
|
||||
domain: 'inventory',
|
||||
eventType: 'inventory.mail.sent',
|
||||
invalidates: ['inventory'],
|
||||
},
|
||||
];
|
||||
if (parsed.attachment?.itemType === 'artifact') {
|
||||
changes.push({
|
||||
domain: 'products',
|
||||
eventType: 'products.mail.sent',
|
||||
invalidates: ['products'],
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
message: `已向${result.recipientName}发出传音`,
|
||||
attachmentCount: result.attachmentCount,
|
||||
},
|
||||
changes,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return c.json(toPlayerStateMutationResponse(committed));
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: '参数错误', details: error.issues }, 400);
|
||||
}
|
||||
if (error instanceof PlayerMailServiceError) {
|
||||
return jsonWithStatus(c, { error: error.message }, error.status);
|
||||
}
|
||||
console.error('mail send api error:', error);
|
||||
return c.json({ error: '发送传音失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
mailRouter.post('/claim', requireActiveCultivator(), async (c) => {
|
||||
const user = c.get('user');
|
||||
const cultivator = c.get('cultivator');
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { requireActiveCultivator } from '@server/lib/hono/middleware';
|
||||
import { jsonWithStatus } from '@server/lib/hono/response';
|
||||
import type { AppEnv } from '@server/lib/hono/types';
|
||||
import {
|
||||
addFriendPair,
|
||||
FriendServiceError,
|
||||
getInviteTarget,
|
||||
listFriends,
|
||||
removeFriendPair,
|
||||
} from '@server/lib/services/FriendService';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CultivatorIdSchema = z.object({
|
||||
cultivatorId: z.string().uuid(),
|
||||
});
|
||||
|
||||
const router = new Hono<AppEnv>();
|
||||
|
||||
router.get('/', requireActiveCultivator(), async (c) => {
|
||||
const cultivator = c.get('cultivator');
|
||||
if (!cultivator) {
|
||||
return c.json({ error: '未授权访问' }, 401);
|
||||
}
|
||||
|
||||
const friends = await listFriends(cultivator.id);
|
||||
return c.json({ friends });
|
||||
});
|
||||
|
||||
router.get('/invite/:cultivatorId', requireActiveCultivator(), async (c) => {
|
||||
const cultivator = c.get('cultivator');
|
||||
if (!cultivator) {
|
||||
return c.json({ error: '未授权访问' }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const { cultivatorId } = CultivatorIdSchema.parse({
|
||||
cultivatorId: c.req.param('cultivatorId'),
|
||||
});
|
||||
const result = await getInviteTarget(cultivator.id, cultivatorId);
|
||||
return c.json(result);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: '参数错误', details: error.issues }, 400);
|
||||
}
|
||||
if (error instanceof FriendServiceError) {
|
||||
return jsonWithStatus(c, { error: error.message }, error.status);
|
||||
}
|
||||
console.error('Friend invite API error:', error);
|
||||
return c.json({ error: '查询道友失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:cultivatorId', requireActiveCultivator(), async (c) => {
|
||||
const cultivator = c.get('cultivator');
|
||||
if (!cultivator) {
|
||||
return c.json({ error: '未授权访问' }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const { cultivatorId } = CultivatorIdSchema.parse({
|
||||
cultivatorId: c.req.param('cultivatorId'),
|
||||
});
|
||||
const friend = await addFriendPair(cultivator.id, cultivatorId);
|
||||
return c.json({ friend, message: '已加入好友名录' });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: '参数错误', details: error.issues }, 400);
|
||||
}
|
||||
if (error instanceof FriendServiceError) {
|
||||
return jsonWithStatus(c, { error: error.message }, error.status);
|
||||
}
|
||||
console.error('Friend add API error:', error);
|
||||
return c.json({ error: '添加道友失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:cultivatorId', requireActiveCultivator(), async (c) => {
|
||||
const cultivator = c.get('cultivator');
|
||||
if (!cultivator) {
|
||||
return c.json({ error: '未授权访问' }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const { cultivatorId } = CultivatorIdSchema.parse({
|
||||
cultivatorId: c.req.param('cultivatorId'),
|
||||
});
|
||||
await removeFriendPair(cultivator.id, cultivatorId);
|
||||
return c.json({ message: '已移出好友名录' });
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return c.json({ error: '参数错误', details: error.issues }, 400);
|
||||
}
|
||||
console.error('Friend delete API error:', error);
|
||||
return c.json({ error: '移除道友失败' }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -13,6 +13,7 @@ import dungeonRouter from '@server/routes/api/dungeon.router';
|
||||
import enemiesRouter from '@server/routes/api/enemies.router';
|
||||
import fateReshapeRouter from '@server/routes/api/fate-reshape.router';
|
||||
import feedbackRouter from '@server/routes/api/feedback.router';
|
||||
import friendsRouter from '@server/routes/api/friends.router';
|
||||
import generateCharacterRouter from '@server/routes/api/generate-character.router';
|
||||
import generateFatesRouter from '@server/routes/api/generate-fates.router';
|
||||
import manualDrawRouter from '@server/routes/api/manual-draw.router';
|
||||
@@ -67,6 +68,7 @@ apiRouter.route('/dungeon', dungeonRouter);
|
||||
apiRouter.route('/enemies', enemiesRouter);
|
||||
apiRouter.route('/fate-reshape', fateReshapeRouter);
|
||||
apiRouter.route('/feedback', feedbackRouter);
|
||||
apiRouter.route('/friends', friendsRouter);
|
||||
apiRouter.route('/generate-character', generateCharacterRouter);
|
||||
apiRouter.route('/generate-fates', generateFatesRouter);
|
||||
apiRouter.route('/manual-draw', manualDrawRouter);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const FRIEND_MAIL_TALISMAN_SCENARIO = 'friend_mail_send';
|
||||
export const AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO =
|
||||
'auction_private_listing';
|
||||
Reference in new issue
Block a user