功能:私有化交易

This commit is contained in:
taojiacheng committed 2026-06-22 17:24:51 +08:00
1 parent b25ada0b41
commit 747191e33b
24 files changed
+6687 -70

No files matched your search

+16
View File
@@ -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
+7
View File
@@ -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}
+712 -1
View File
@@ -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>
);
}
+33
View File
@@ -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',
+176 -63
View File
@@ -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,
);
});
});
+228
View File
@@ -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));
}
+21 -3
View File
@@ -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');
+99
View File
@@ -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;
+2
View File
@@ -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);
+3
View File
@@ -0,0 +1,3 @@
export const FRIEND_MAIL_TALISMAN_SCENARIO = 'friend_mail_send';
export const AUCTION_PRIVATE_LISTING_TALISMAN_SCENARIO =
'auction_private_listing';