Add market watch and match hub workflows
这个提交包含在:
@@ -39,10 +39,12 @@ export function getSessionCookieOptions(
|
||||
// ? hostname
|
||||
// : undefined;
|
||||
|
||||
const secure = isSecureRequest(req);
|
||||
|
||||
return {
|
||||
httpOnly: true,
|
||||
path: "/",
|
||||
sameSite: "none",
|
||||
secure: isSecureRequest(req),
|
||||
sameSite: secure ? "none" : "lax",
|
||||
secure,
|
||||
};
|
||||
}
|
||||
|
||||
29
server/_core/fetch.test.ts
普通文件
29
server/_core/fetch.test.ts
普通文件
@@ -0,0 +1,29 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchWithTimeout } from "./fetch";
|
||||
|
||||
describe("fetchWithTimeout", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("retries timeout-like errors for allowed methods", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockRejectedValueOnce(new Error("Request timed out after 100ms"))
|
||||
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
|
||||
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const response = await fetchWithTimeout("https://example.com", {
|
||||
method: "POST",
|
||||
}, {
|
||||
timeoutMs: 100,
|
||||
retries: 1,
|
||||
retryMethods: ["POST"],
|
||||
baseDelayMs: 1,
|
||||
});
|
||||
|
||||
expect(response.ok).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,12 @@ function shouldRetryError(method: string, error: unknown, options: FetchRetryOpt
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.name === "AbortError" || error.name === "TimeoutError" || error.message.includes("fetch");
|
||||
return (
|
||||
error.name === "AbortError" ||
|
||||
error.name === "TimeoutError" ||
|
||||
error.message.startsWith("Request timed out after ") ||
|
||||
error.message.includes("fetch")
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { appRouter } from "../routers";
|
||||
import { createContext } from "./context";
|
||||
import { registerMediaProxy } from "./mediaProxy";
|
||||
import { serveStatic } from "./static";
|
||||
import { createBackgroundTask, getAdminUserId, hasRecentBackgroundTaskOfType, seedAchievementDefinitions, seedAppSettings, seedTutorials, seedVisionReferenceImages } from "../db";
|
||||
import { createBackgroundTask, getAdminUserId, getAppSettingValue, hasRecentBackgroundTaskOfType, seedAchievementDefinitions, seedAppSettings, seedTutorials, seedVisionReferenceImages } from "../db";
|
||||
import { nanoid } from "nanoid";
|
||||
import { syncTutorialImages } from "../tutorialImages";
|
||||
|
||||
@@ -64,6 +64,32 @@ async function scheduleDailyNtrpRefresh() {
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleMarketWatchRefresh() {
|
||||
const intervalMinutes = Math.max(5, await getAppSettingValue("market_watch_refresh_interval_minutes", 30));
|
||||
const since = new Date(Date.now() - intervalMinutes * 60_000);
|
||||
const exists = await hasRecentBackgroundTaskOfType("market_watch_refresh", since);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const adminUserId = await getAdminUserId();
|
||||
if (!adminUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = nanoid();
|
||||
await createBackgroundTask({
|
||||
id: taskId,
|
||||
userId: adminUserId,
|
||||
type: "market_watch_refresh",
|
||||
title: "全网球拍行情刷新",
|
||||
message: "系统已自动创建球拍行情刷新任务",
|
||||
payload: { scope: "all_users", trigger: "scheduler" },
|
||||
progress: 0,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
}
|
||||
|
||||
function isPortAvailable(port: number): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
const server = net.createServer();
|
||||
@@ -129,6 +155,9 @@ async function startServer() {
|
||||
void scheduleDailyNtrpRefresh().catch((error) => {
|
||||
console.error("[scheduler] failed to schedule NTRP refresh", error);
|
||||
});
|
||||
void scheduleMarketWatchRefresh().catch((error) => {
|
||||
console.error("[scheduler] failed to schedule market refresh", error);
|
||||
});
|
||||
}, 60_000);
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ export type InvokeParams = {
|
||||
output_schema?: OutputSchema;
|
||||
responseFormat?: ResponseFormat;
|
||||
response_format?: ResponseFormat;
|
||||
timeoutMs?: number;
|
||||
retryCount?: number;
|
||||
};
|
||||
|
||||
export type ToolCall = {
|
||||
@@ -286,6 +288,8 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||
output_schema,
|
||||
responseFormat,
|
||||
response_format,
|
||||
timeoutMs,
|
||||
retryCount,
|
||||
} = params;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
@@ -332,8 +336,8 @@ export async function invokeLLM(params: InvokeParams): Promise<InvokeResult> {
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}, {
|
||||
timeoutMs: ENV.llmTimeoutMs,
|
||||
retries: ENV.llmRetryCount,
|
||||
timeoutMs: timeoutMs ?? ENV.llmTimeoutMs,
|
||||
retries: retryCount ?? ENV.llmRetryCount,
|
||||
retryMethods: ["POST"],
|
||||
});
|
||||
|
||||
|
||||
376
server/db.ts
376
server/db.ts
@@ -1,4 +1,4 @@
|
||||
import { eq, desc, and, asc, lte, gte, or, sql } from "drizzle-orm";
|
||||
import { eq, desc, and, asc, lte, gte, or, sql, like, inArray } from "drizzle-orm";
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import {
|
||||
InsertUser, users,
|
||||
@@ -21,6 +21,9 @@ import {
|
||||
tutorialProgress, InsertTutorialProgress,
|
||||
trainingReminders, InsertTrainingReminder,
|
||||
notificationLog, InsertNotificationLog,
|
||||
racketListings, InsertRacketListing,
|
||||
racketWatchRules, InsertRacketWatchRule,
|
||||
racketWatchHits, InsertRacketWatchHit,
|
||||
backgroundTasks, InsertBackgroundTask,
|
||||
adminAuditLogs, InsertAdminAuditLog,
|
||||
appSettings, InsertAppSetting,
|
||||
@@ -77,6 +80,93 @@ export const DEFAULT_APP_SETTINGS: Omit<InsertAppSetting, "id" | "createdAt" | "
|
||||
description: "每天异步刷新 NTRP 的小时数。",
|
||||
value: { value: 0, type: "number" },
|
||||
},
|
||||
{
|
||||
settingKey: "market_default_feishu_webhook",
|
||||
label: "球拍行情默认飞书 Webhook",
|
||||
description: "低价监控命中后默认推送到这个飞书机器人地址。",
|
||||
value: {
|
||||
value: "https://open.larksuite.com/open-apis/bot/v2/hook/9acc398a-1380-43e4-9291-3e2b94016dfe",
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
{
|
||||
settingKey: "market_watch_refresh_interval_minutes",
|
||||
label: "球拍行情刷新间隔",
|
||||
description: "系统按该分钟间隔自动入队一次全量球拍行情刷新任务。",
|
||||
value: { value: 30, type: "number" },
|
||||
},
|
||||
{
|
||||
settingKey: "market_price_repush_delta",
|
||||
label: "球拍行情再次推送降价阈值",
|
||||
description: "同一商品相较上次推送再降到该金额以上时,允许再次推送。",
|
||||
value: { value: 20, type: "number" },
|
||||
},
|
||||
{
|
||||
settingKey: "market_source_timeout_ms",
|
||||
label: "球拍行情抓取超时",
|
||||
description: "单次来源抓取请求的超时时间,单位毫秒。",
|
||||
value: { value: 12000, type: "number" },
|
||||
},
|
||||
{
|
||||
settingKey: "market_source_retry_count",
|
||||
label: "球拍行情抓取重试次数",
|
||||
description: "来源抓取发生超时或网关错误后的自动重试次数。",
|
||||
value: { value: 1, type: "number" },
|
||||
},
|
||||
{
|
||||
settingKey: "market_xianyu_cookie",
|
||||
label: "闲鱼抓取 Cookie",
|
||||
description: "可选。填写后可用于提升闲鱼搜索接口的可用性并降低风控触发概率。",
|
||||
value: { value: "", type: "string" },
|
||||
},
|
||||
{
|
||||
settingKey: "market_xianyu_user_agent",
|
||||
label: "闲鱼抓取 User-Agent",
|
||||
description: "闲鱼来源抓取时使用的默认 User-Agent。",
|
||||
value: {
|
||||
value:
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
{
|
||||
settingKey: "market_jd_cookie",
|
||||
label: "京东抓取 Cookie",
|
||||
description: "可选。用于减少京东搜索接口返回风控页的概率。",
|
||||
value: { value: "", type: "string" },
|
||||
},
|
||||
{
|
||||
settingKey: "market_jd_user_agent",
|
||||
label: "京东抓取 User-Agent",
|
||||
description: "京东来源抓取时使用的默认移动端 User-Agent。",
|
||||
value: {
|
||||
value:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
{
|
||||
settingKey: "market_zhuanzhuan_cookie",
|
||||
label: "转转抓取 Cookie",
|
||||
description: "可选。用于配置转转抓取请求时附带的 Cookie。",
|
||||
value: { value: "", type: "string" },
|
||||
},
|
||||
{
|
||||
settingKey: "market_zhuanzhuan_user_agent",
|
||||
label: "转转抓取 User-Agent",
|
||||
description: "转转来源抓取时使用的默认移动端 User-Agent。",
|
||||
value: {
|
||||
value:
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
{
|
||||
settingKey: "market_zhuanzhuan_search_url_template",
|
||||
label: "转转搜索 URL 模板",
|
||||
description: "当公开搜索页路径调整时,可在这里配置转转搜索模板,使用 {query} 作为占位符。",
|
||||
value: { value: "", type: "string" },
|
||||
},
|
||||
];
|
||||
|
||||
export const ACHIEVEMENT_DEFINITION_SEED_DATA: Omit<InsertAchievementDefinition, "id" | "createdAt" | "updatedAt">[] = [
|
||||
@@ -134,6 +224,22 @@ export async function listAppSettings() {
|
||||
return db.select().from(appSettings).orderBy(asc(appSettings.settingKey));
|
||||
}
|
||||
|
||||
export async function getAppSettingByKey(settingKey: string) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(appSettings).where(eq(appSettings.settingKey, settingKey)).limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getAppSettingValue<T>(settingKey: string, fallback: T): Promise<T> {
|
||||
const setting = await getAppSettingByKey(settingKey);
|
||||
const value = setting?.value as { value?: T } | T | null | undefined;
|
||||
if (value && typeof value === "object" && "value" in value) {
|
||||
return (value.value ?? fallback) as T;
|
||||
}
|
||||
return (value ?? fallback) as T;
|
||||
}
|
||||
|
||||
export async function updateAppSetting(settingKey: string, value: unknown) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
@@ -244,7 +350,7 @@ export async function listAllBackgroundTasks(limit = 100) {
|
||||
}
|
||||
|
||||
export async function hasRecentBackgroundTaskOfType(
|
||||
type: "ntrp_refresh_user" | "ntrp_refresh_all",
|
||||
type: "ntrp_refresh_user" | "ntrp_refresh_all" | "market_watch_refresh",
|
||||
since: Date,
|
||||
) {
|
||||
const db = await getDb();
|
||||
@@ -2006,6 +2112,272 @@ export async function getUnreadNotificationCount(userId: number) {
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
// ===== RACKET MARKET OPERATIONS =====
|
||||
|
||||
export async function listRacketListings(params?: {
|
||||
source?: "xianyu" | "jd" | "zhuanzhuan";
|
||||
brand?: string;
|
||||
category?: string;
|
||||
keyword?: string;
|
||||
lowPriceOnly?: boolean;
|
||||
limit?: number;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const conditions = [];
|
||||
if (params?.source) conditions.push(eq(racketListings.source, params.source));
|
||||
if (params?.brand) conditions.push(eq(racketListings.brand, params.brand));
|
||||
if (params?.category) conditions.push(eq(racketListings.category, params.category));
|
||||
if (params?.lowPriceOnly) conditions.push(eq(racketListings.isLowPriceCandidate, 1));
|
||||
if (params?.keyword) {
|
||||
const keyword = `%${params.keyword.trim()}%`;
|
||||
conditions.push(or(
|
||||
like(racketListings.title, keyword),
|
||||
like(racketListings.model, keyword),
|
||||
like(racketListings.series, keyword),
|
||||
like(racketListings.brand, keyword),
|
||||
)!);
|
||||
}
|
||||
|
||||
const limit = params?.limit ?? 50;
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const baseQuery = db.select().from(racketListings);
|
||||
return whereClause
|
||||
? baseQuery.where(whereClause).orderBy(desc(racketListings.fetchedAt), desc(racketListings.id)).limit(limit)
|
||||
: baseQuery.orderBy(desc(racketListings.fetchedAt), desc(racketListings.id)).limit(limit);
|
||||
}
|
||||
|
||||
export async function countRecentRacketListings(hours = 24) {
|
||||
const db = await getDb();
|
||||
if (!db) return 0;
|
||||
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
const result = await db.select({ count: sql<number>`count(*)` }).from(racketListings)
|
||||
.where(gte(racketListings.fetchedAt, since));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
export async function listRecentComparableRacketListings(params: {
|
||||
brand?: string | null;
|
||||
model?: string | null;
|
||||
excludeId?: number | null;
|
||||
limit?: number;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db || !params.brand || !params.model) return [];
|
||||
const conditions = [
|
||||
eq(racketListings.brand, params.brand),
|
||||
eq(racketListings.model, params.model),
|
||||
];
|
||||
if (params.excludeId != null) {
|
||||
conditions.push(sql`${racketListings.id} <> ${params.excludeId}`);
|
||||
}
|
||||
|
||||
return db.select().from(racketListings)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(racketListings.fetchedAt), desc(racketListings.id))
|
||||
.limit(params.limit ?? 8);
|
||||
}
|
||||
|
||||
export async function getRacketListingById(listingId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(racketListings).where(eq(racketListings.id, listingId)).limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function upsertRacketListing(listing: InsertRacketListing) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const existingBySource = await db.select().from(racketListings)
|
||||
.where(and(eq(racketListings.source, listing.source!), eq(racketListings.sourceListingId, listing.sourceListingId!)))
|
||||
.limit(1);
|
||||
const existing = existingBySource[0] ?? (await db.select().from(racketListings)
|
||||
.where(eq(racketListings.fingerprint, listing.fingerprint!))
|
||||
.limit(1))[0];
|
||||
|
||||
if (existing) {
|
||||
await db.update(racketListings).set({
|
||||
...listing,
|
||||
fetchedAt: listing.fetchedAt ?? new Date(),
|
||||
}).where(eq(racketListings.id, existing.id));
|
||||
return getRacketListingById(existing.id);
|
||||
}
|
||||
|
||||
const result = await db.insert(racketListings).values({
|
||||
...listing,
|
||||
fetchedAt: listing.fetchedAt ?? new Date(),
|
||||
});
|
||||
return getRacketListingById(result[0].insertId);
|
||||
}
|
||||
|
||||
export async function updateRacketListing(listingId: number, data: Partial<InsertRacketListing>) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(racketListings).set(data).where(eq(racketListings.id, listingId));
|
||||
}
|
||||
|
||||
export async function listUserRacketWatchRules(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(racketWatchRules)
|
||||
.where(eq(racketWatchRules.userId, userId))
|
||||
.orderBy(desc(racketWatchRules.updatedAt), desc(racketWatchRules.id));
|
||||
}
|
||||
|
||||
export async function listActiveRacketWatchRules(params?: {
|
||||
userId?: number;
|
||||
ruleIds?: number[];
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
const conditions = [eq(racketWatchRules.isActive, 1)];
|
||||
if (params?.userId != null) conditions.push(eq(racketWatchRules.userId, params.userId));
|
||||
if (params?.ruleIds?.length) conditions.push(inArray(racketWatchRules.id, params.ruleIds));
|
||||
return db.select().from(racketWatchRules)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(racketWatchRules.updatedAt), desc(racketWatchRules.id));
|
||||
}
|
||||
|
||||
export async function getUserRacketWatchRuleById(userId: number, ruleId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(racketWatchRules)
|
||||
.where(and(eq(racketWatchRules.id, ruleId), eq(racketWatchRules.userId, userId)))
|
||||
.limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function createRacketWatchRule(rule: InsertRacketWatchRule) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(racketWatchRules).values(rule);
|
||||
return result[0].insertId;
|
||||
}
|
||||
|
||||
export async function updateRacketWatchRule(userId: number, ruleId: number, data: Partial<InsertRacketWatchRule>) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(racketWatchRules).set(data)
|
||||
.where(and(eq(racketWatchRules.id, ruleId), eq(racketWatchRules.userId, userId)));
|
||||
}
|
||||
|
||||
export async function deleteRacketWatchRule(userId: number, ruleId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.delete(racketWatchRules)
|
||||
.where(and(eq(racketWatchRules.id, ruleId), eq(racketWatchRules.userId, userId)));
|
||||
}
|
||||
|
||||
export async function toggleRacketWatchRule(userId: number, ruleId: number, isActive: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(racketWatchRules).set({ isActive })
|
||||
.where(and(eq(racketWatchRules.id, ruleId), eq(racketWatchRules.userId, userId)));
|
||||
}
|
||||
|
||||
export async function getRacketWatchHitByRuleAndListing(watchRuleId: number, listingId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(racketWatchHits)
|
||||
.where(and(eq(racketWatchHits.watchRuleId, watchRuleId), eq(racketWatchHits.listingId, listingId)))
|
||||
.limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function createRacketWatchHit(hit: InsertRacketWatchHit) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
const result = await db.insert(racketWatchHits).values(hit);
|
||||
const inserted = await db.select().from(racketWatchHits).where(eq(racketWatchHits.id, result[0].insertId)).limit(1);
|
||||
return inserted[0];
|
||||
}
|
||||
|
||||
export async function updateRacketWatchHit(hitId: number, data: Partial<InsertRacketWatchHit>) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(racketWatchHits).set(data).where(eq(racketWatchHits.id, hitId));
|
||||
}
|
||||
|
||||
export async function listUserRacketWatchHits(userId: number, limit = 50) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select({
|
||||
id: racketWatchHits.id,
|
||||
userId: racketWatchHits.userId,
|
||||
watchRuleId: racketWatchHits.watchRuleId,
|
||||
listingId: racketWatchHits.listingId,
|
||||
matchedPrice: racketWatchHits.matchedPrice,
|
||||
status: racketWatchHits.status,
|
||||
firstMatchedAt: racketWatchHits.firstMatchedAt,
|
||||
lastMatchedAt: racketWatchHits.lastMatchedAt,
|
||||
lastPushPrice: racketWatchHits.lastPushPrice,
|
||||
pushedAt: racketWatchHits.pushedAt,
|
||||
pushCount: racketWatchHits.pushCount,
|
||||
ruleTitle: racketWatchRules.title,
|
||||
ruleBrand: racketWatchRules.brand,
|
||||
listingTitle: racketListings.title,
|
||||
listingSource: racketListings.source,
|
||||
listingUrl: racketListings.listingUrl,
|
||||
listingBrand: racketListings.brand,
|
||||
listingModel: racketListings.model,
|
||||
listingCategory: racketListings.category,
|
||||
listingWeightGram: racketListings.weightGram,
|
||||
listingGradeLevel: racketListings.gradeLevel,
|
||||
listingPrice: racketListings.price,
|
||||
listingFetchedAt: racketListings.fetchedAt,
|
||||
}).from(racketWatchHits)
|
||||
.innerJoin(racketWatchRules, eq(racketWatchRules.id, racketWatchHits.watchRuleId))
|
||||
.innerJoin(racketListings, eq(racketListings.id, racketWatchHits.listingId))
|
||||
.where(eq(racketWatchHits.userId, userId))
|
||||
.orderBy(desc(racketWatchHits.lastMatchedAt), desc(racketWatchHits.id))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
export async function getRacketWatchHitDeliveryPayload(hitId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select({
|
||||
id: racketWatchHits.id,
|
||||
userId: racketWatchHits.userId,
|
||||
matchedPrice: racketWatchHits.matchedPrice,
|
||||
status: racketWatchHits.status,
|
||||
lastPushPrice: racketWatchHits.lastPushPrice,
|
||||
pushCount: racketWatchHits.pushCount,
|
||||
ruleId: racketWatchRules.id,
|
||||
ruleTitle: racketWatchRules.title,
|
||||
ruleBrand: racketWatchRules.brand,
|
||||
ruleModelKeyword: racketWatchRules.modelKeyword,
|
||||
ruleTargetPrice: racketWatchRules.targetPrice,
|
||||
listingId: racketListings.id,
|
||||
listingSource: racketListings.source,
|
||||
listingTitle: racketListings.title,
|
||||
listingUrl: racketListings.listingUrl,
|
||||
listingBrand: racketListings.brand,
|
||||
listingModel: racketListings.model,
|
||||
listingCategory: racketListings.category,
|
||||
listingWeightGram: racketListings.weightGram,
|
||||
listingGradeLevel: racketListings.gradeLevel,
|
||||
listingGradeReason: racketListings.gradeReason,
|
||||
listingPrice: racketListings.price,
|
||||
listingFetchedAt: racketListings.fetchedAt,
|
||||
}).from(racketWatchHits)
|
||||
.innerJoin(racketWatchRules, eq(racketWatchRules.id, racketWatchHits.watchRuleId))
|
||||
.innerJoin(racketListings, eq(racketListings.id, racketWatchHits.listingId))
|
||||
.where(eq(racketWatchHits.id, hitId))
|
||||
.limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function countUserActiveRacketWatchRules(userId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return 0;
|
||||
const result = await db.select({ count: sql<number>`count(*)` }).from(racketWatchRules)
|
||||
.where(and(eq(racketWatchRules.userId, userId), eq(racketWatchRules.isActive, 1)));
|
||||
return result[0]?.count || 0;
|
||||
}
|
||||
|
||||
// ===== BACKGROUND TASK OPERATIONS =====
|
||||
|
||||
export async function createBackgroundTask(task: InsertBackgroundTask) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { appRouter } from "./routers";
|
||||
import { COOKIE_NAME } from "../shared/const";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
import * as db from "./db";
|
||||
import * as matchStore from "./matchStore";
|
||||
import * as trainingAutomation from "./trainingAutomation";
|
||||
import { ENV } from "./_core/env";
|
||||
import { sdk } from "./_core/sdk";
|
||||
@@ -831,14 +832,28 @@ describe("leaderboard.get", () => {
|
||||
await expect(caller.leaderboard.get()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("accepts sortBy parameter", async () => {
|
||||
it("accepts training sortBy parameter", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
for (const sortBy of ["ntrpRating", "totalMinutes", "totalSessions", "totalShots"] as const) {
|
||||
try {
|
||||
await caller.leaderboard.get({ sortBy, limit: 10 });
|
||||
await caller.leaderboard.get({ scope: "training", sortBy, limit: 10 });
|
||||
} catch (e: any) {
|
||||
expect(e.message).not.toContain("invalid_enum_value");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts competitive sortBy parameter", async () => {
|
||||
const user = createTestUser();
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
for (const sortBy of ["wins", "winRate", "setsWon", "pointsWon", "matches"] as const) {
|
||||
try {
|
||||
await caller.leaderboard.get({ scope: "competitive", sortBy, limit: 10 });
|
||||
} catch (e: any) {
|
||||
expect(e.message).not.toContain("invalid_enum_value");
|
||||
}
|
||||
@@ -856,6 +871,94 @@ describe("leaderboard.get", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("match router", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("requires authentication for listing matches", async () => {
|
||||
const { ctx } = createMockContext(null);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
await expect(caller.match.list()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects creating a match that does not include the current user when not admin", async () => {
|
||||
const user = createTestUser({ id: 99, name: "Viewer" });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(db, "getUserById")
|
||||
.mockResolvedValueOnce(createTestUser({ id: 1, name: "PlayerA" }))
|
||||
.mockResolvedValueOnce(createTestUser({ id: 2, name: "PlayerB" }));
|
||||
|
||||
await expect(caller.match.create({
|
||||
title: "League Match",
|
||||
matchMode: "competitive",
|
||||
playerAUserId: 1,
|
||||
playerBUserId: 2,
|
||||
durationMinutes: 90,
|
||||
})).rejects.toThrow("只能创建包含自己的比赛");
|
||||
});
|
||||
|
||||
it("rejects reading a match when the user is not an admin or participant", async () => {
|
||||
const user = createTestUser({ id: 77, name: "Outside" });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
vi.spyOn(matchStore, "getMatchDetail").mockResolvedValueOnce({
|
||||
id: 12,
|
||||
createdByUserId: 1,
|
||||
matchMode: "daily",
|
||||
workflowStatus: "review_pending",
|
||||
title: "Morning Match",
|
||||
courtName: "Court 1",
|
||||
notes: null,
|
||||
durationMinutes: 90,
|
||||
scheduledAt: new Date(),
|
||||
startedAt: null,
|
||||
endedAt: null,
|
||||
suggestionStatus: "ready",
|
||||
suggestionTaskId: "task-1",
|
||||
suggestedScore: null,
|
||||
suggestedMetrics: null,
|
||||
finalScore: null,
|
||||
finalMetrics: null,
|
||||
reviewNotes: null,
|
||||
reviewSubmittedAt: null,
|
||||
reviewedByUserId: null,
|
||||
reviewedAt: null,
|
||||
finalizedByUserId: null,
|
||||
finalizedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
participants: [
|
||||
{ userId: 1, playerSlot: "player_a" },
|
||||
{ userId: 2, playerSlot: "player_b" },
|
||||
] as any,
|
||||
events: [],
|
||||
eventCount: 0,
|
||||
} as any);
|
||||
|
||||
await expect(caller.match.get({ matchId: 12 })).rejects.toThrow("当前账号不能访问这场比赛");
|
||||
});
|
||||
|
||||
it("requires admin permission for review submission", async () => {
|
||||
const user = createTestUser({ id: 7, role: "user" });
|
||||
const { ctx } = createMockContext(user);
|
||||
const caller = appRouter.createCaller(ctx);
|
||||
|
||||
await expect(caller.match.reviewSubmit({
|
||||
matchId: 1,
|
||||
reviewNotes: "ok",
|
||||
finalScore: {
|
||||
sets: { player_a: 2, player_b: 0 },
|
||||
games: { player_a: 12, player_b: 6 },
|
||||
points: { player_a: 60, player_b: 42 },
|
||||
},
|
||||
})).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ===== BADGE DEFINITIONS UNIT TESTS =====
|
||||
|
||||
describe("BADGE_DEFINITIONS via badge.definitions endpoint", () => {
|
||||
|
||||
133
server/market.routes.test.ts
普通文件
133
server/market.routes.test.ts
普通文件
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { appRouter } from "./routers";
|
||||
import type { TrpcContext } from "./_core/context";
|
||||
import * as db from "./db";
|
||||
|
||||
type AuthenticatedUser = NonNullable<TrpcContext["user"]>;
|
||||
|
||||
function createTestUser(overrides?: Partial<AuthenticatedUser>): AuthenticatedUser {
|
||||
return {
|
||||
id: 7,
|
||||
openId: "market-user-7",
|
||||
email: "market@example.com",
|
||||
name: "MarketTester",
|
||||
loginMethod: "username",
|
||||
role: "user",
|
||||
skillLevel: "beginner",
|
||||
trainingGoals: null,
|
||||
ntrpRating: 1.5,
|
||||
manualNtrpRating: null,
|
||||
manualNtrpCapturedAt: null,
|
||||
heightCm: null,
|
||||
weightKg: null,
|
||||
sprintSpeedScore: null,
|
||||
explosivePowerScore: null,
|
||||
agilityScore: null,
|
||||
enduranceScore: null,
|
||||
flexibilityScore: null,
|
||||
coreStabilityScore: null,
|
||||
shoulderMobilityScore: null,
|
||||
hipMobilityScore: null,
|
||||
assessmentNotes: null,
|
||||
totalSessions: 0,
|
||||
totalMinutes: 0,
|
||||
totalShots: 0,
|
||||
currentStreak: 0,
|
||||
longestStreak: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
lastSignedIn: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockContext(user: AuthenticatedUser | null): TrpcContext {
|
||||
return {
|
||||
user,
|
||||
sessionSid: user ? "market-session" : null,
|
||||
req: {
|
||||
protocol: "https",
|
||||
headers: {},
|
||||
} as TrpcContext["req"],
|
||||
res: {
|
||||
clearCookie: vi.fn(),
|
||||
cookie: vi.fn(),
|
||||
} as TrpcContext["res"],
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("market.watchRuleCreate", () => {
|
||||
it("creates a rule and queues a refresh task", async () => {
|
||||
const user = createTestUser();
|
||||
const caller = appRouter.createCaller(createMockContext(user));
|
||||
|
||||
vi.spyOn(db, "createRacketWatchRule").mockResolvedValueOnce(88);
|
||||
vi.spyOn(db, "createBackgroundTask").mockResolvedValueOnce("task-market-1");
|
||||
vi.spyOn(db, "getBackgroundTaskById").mockResolvedValueOnce({
|
||||
id: "task-market-1",
|
||||
userId: user.id,
|
||||
type: "market_watch_refresh",
|
||||
status: "queued",
|
||||
title: "Yonex ≤ ¥500 刷新",
|
||||
message: "监控规则已创建,后台开始抓取对应平台价格",
|
||||
progress: 0,
|
||||
payload: {},
|
||||
result: null,
|
||||
error: null,
|
||||
attempts: 0,
|
||||
maxAttempts: 3,
|
||||
workerId: null,
|
||||
runAfter: new Date(),
|
||||
lockedAt: null,
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as Awaited<ReturnType<typeof db.getBackgroundTaskById>>);
|
||||
|
||||
const result = await caller.market.watchRuleCreate({
|
||||
brand: "Yonex",
|
||||
targetPrice: 500,
|
||||
modelKeyword: "98",
|
||||
pushEnabled: true,
|
||||
});
|
||||
|
||||
expect(result.ruleId).toBe(88);
|
||||
expect(result.taskId).toBeTruthy();
|
||||
expect(db.createRacketWatchRule).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: user.id,
|
||||
brand: "Yonex",
|
||||
targetPrice: 500,
|
||||
pushEnabled: 1,
|
||||
isActive: 1,
|
||||
}));
|
||||
expect(db.createBackgroundTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
userId: user.id,
|
||||
type: "market_watch_refresh",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe("market.pushConfigUpdate", () => {
|
||||
it("updates the default webhook for admins", async () => {
|
||||
const admin = createTestUser({ role: "admin", id: 1, name: "Admin" });
|
||||
const caller = appRouter.createCaller(createMockContext(admin));
|
||||
|
||||
vi.spyOn(db, "updateAppSetting").mockResolvedValueOnce(undefined);
|
||||
vi.spyOn(db, "createAdminAuditLog").mockResolvedValueOnce(undefined);
|
||||
|
||||
const result = await caller.market.pushConfigUpdate({
|
||||
webhookUrl: "https://open.larksuite.com/open-apis/bot/v2/hook/demo",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(db.updateAppSetting).toHaveBeenCalledWith("market_default_feishu_webhook", {
|
||||
value: "https://open.larksuite.com/open-apis/bot/v2/hook/demo",
|
||||
type: "string",
|
||||
});
|
||||
});
|
||||
});
|
||||
93
server/market.test.ts
普通文件
93
server/market.test.ts
普通文件
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildMarketSearchQuery,
|
||||
enrichRacketListing,
|
||||
formatMarketPushText,
|
||||
listingMatchesWatchRule,
|
||||
maskWebhookUrl,
|
||||
} from "./market";
|
||||
|
||||
describe("market enrichment", () => {
|
||||
it("extracts brand, model, weight, category and grade from a racket listing", () => {
|
||||
const listing = enrichRacketListing({
|
||||
source: "xianyu",
|
||||
sourceListingId: "xy-1",
|
||||
title: "Yonex Ezone 98 305g 95新 网球拍",
|
||||
listingUrl: "https://www.goofish.com/item?id=xy-1",
|
||||
price: 480,
|
||||
originalPrice: 1200,
|
||||
description: "尤尼克斯 Ezone 98 裸拍约305g,正常使用,无裂纹",
|
||||
imageUrl: null,
|
||||
sellerName: "seller-a",
|
||||
location: "上海",
|
||||
publishedAtRaw: "1小时前",
|
||||
extra: null,
|
||||
});
|
||||
|
||||
expect(listing.brand).toBe("Yonex");
|
||||
expect(listing.series).toBe("Ezone");
|
||||
expect(listing.model).toContain("98");
|
||||
expect(listing.weightGram).toBe(305);
|
||||
expect(listing.category).toBe("competitive");
|
||||
expect(listing.gradeLevel).toBe("high_value");
|
||||
expect(listing.isLowPriceCandidate).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("market watch matching", () => {
|
||||
it("matches a listing when price and keywords satisfy the rule", () => {
|
||||
const matched = listingMatchesWatchRule({
|
||||
price: 520,
|
||||
brand: "Wilson",
|
||||
title: "Wilson Blade 98 V8 网球拍",
|
||||
model: "98 V8",
|
||||
series: "Blade",
|
||||
category: "competitive",
|
||||
weightGram: 305,
|
||||
}, {
|
||||
brand: "Wilson",
|
||||
modelKeyword: "98",
|
||||
seriesKeyword: "Blade",
|
||||
category: "competitive",
|
||||
weightMinGram: 300,
|
||||
weightMaxGram: 310,
|
||||
targetPrice: 550,
|
||||
});
|
||||
|
||||
expect(matched).toBe(true);
|
||||
expect(buildMarketSearchQuery({
|
||||
brand: "Wilson",
|
||||
modelKeyword: "98",
|
||||
seriesKeyword: "Blade",
|
||||
category: "competitive",
|
||||
})).toContain("Wilson");
|
||||
});
|
||||
});
|
||||
|
||||
describe("market push formatting", () => {
|
||||
it("masks webhook urls and formats notification text", () => {
|
||||
expect(maskWebhookUrl("https://open.larksuite.com/open-apis/bot/v2/hook/1234567890abcdef"))
|
||||
.toContain("...");
|
||||
|
||||
const text = formatMarketPushText({
|
||||
ruleTitle: "Yonex Ezone ≤ ¥500",
|
||||
source: "xianyu",
|
||||
title: "Yonex Ezone 98 305g",
|
||||
price: 480,
|
||||
targetPrice: 500,
|
||||
brand: "Yonex",
|
||||
model: "98",
|
||||
category: "competitive",
|
||||
weightGram: 305,
|
||||
gradeLevel: "high_value",
|
||||
gradeReason: "品牌 Yonex · 当前价格落在低价区间",
|
||||
listingUrl: "https://www.goofish.com/item?id=1",
|
||||
fetchedAt: "2026-03-23T08:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(text).toContain("命中监控");
|
||||
expect(text).toContain("闲鱼");
|
||||
expect(text).toContain("Yonex");
|
||||
expect(text).toContain("https://www.goofish.com/item?id=1");
|
||||
});
|
||||
});
|
||||
777
server/market.ts
普通文件
777
server/market.ts
普通文件
@@ -0,0 +1,777 @@
|
||||
import crypto from "node:crypto";
|
||||
import type { InsertRacketListing, RacketWatchRule } from "../drizzle/schema";
|
||||
import * as db from "./db";
|
||||
import { fetchWithTimeout } from "./_core/fetch";
|
||||
|
||||
export const MARKET_SOURCES = ["xianyu", "jd", "zhuanzhuan"] as const;
|
||||
export type MarketSource = typeof MARKET_SOURCES[number];
|
||||
|
||||
export const MARKET_SOURCE_LABELS: Record<MarketSource, string> = {
|
||||
xianyu: "闲鱼",
|
||||
jd: "京东",
|
||||
zhuanzhuan: "转转",
|
||||
};
|
||||
|
||||
export const MARKET_CATEGORY_LABELS = {
|
||||
adult: "成人球拍",
|
||||
junior: "儿童球拍",
|
||||
competitive: "比赛拍",
|
||||
recreational: "娱乐拍",
|
||||
unknown: "未知",
|
||||
} as const;
|
||||
|
||||
export type MarketCategory = keyof typeof MARKET_CATEGORY_LABELS;
|
||||
|
||||
export const MARKET_CONDITION_LABELS = {
|
||||
brand_new: "全新",
|
||||
almost_new: "几乎全新",
|
||||
used_good: "正常使用",
|
||||
used_fair: "磕碰明显",
|
||||
unknown: "未知",
|
||||
} as const;
|
||||
|
||||
export type MarketConditionLevel = keyof typeof MARKET_CONDITION_LABELS;
|
||||
|
||||
export const MARKET_GRADE_LABELS = {
|
||||
high_value: "高性价比",
|
||||
standard: "标准价",
|
||||
overpriced: "偏高",
|
||||
pending_review: "待确认",
|
||||
} as const;
|
||||
|
||||
export type MarketGradeLevel = keyof typeof MARKET_GRADE_LABELS;
|
||||
|
||||
export type RawSourceListing = {
|
||||
source: MarketSource;
|
||||
sourceListingId: string;
|
||||
title: string;
|
||||
listingUrl: string;
|
||||
description?: string | null;
|
||||
imageUrl?: string | null;
|
||||
price: number;
|
||||
originalPrice?: number | null;
|
||||
sellerName?: string | null;
|
||||
location?: string | null;
|
||||
publishedAtRaw?: string | null;
|
||||
extra?: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export type SearchSourceResult = {
|
||||
source: MarketSource;
|
||||
query: string;
|
||||
ok: boolean;
|
||||
blocked: boolean;
|
||||
message: string;
|
||||
listings: RawSourceListing[];
|
||||
};
|
||||
|
||||
export type MarketConfig = {
|
||||
defaultFeishuWebhook: string;
|
||||
refreshIntervalMinutes: number;
|
||||
repushDelta: number;
|
||||
sourceTimeoutMs: number;
|
||||
sourceRetryCount: number;
|
||||
xianyuCookie: string;
|
||||
xianyuUserAgent: string;
|
||||
jdCookie: string;
|
||||
jdUserAgent: string;
|
||||
zhuanzhuanCookie: string;
|
||||
zhuanzhuanUserAgent: string;
|
||||
zhuanzhuanSearchUrlTemplate: string;
|
||||
};
|
||||
|
||||
const XIANYU_API = "mtop.taobao.idlemtopsearch.pc.search";
|
||||
const XIANYU_APP_KEY = "34839810";
|
||||
|
||||
const BRAND_ALIASES: Array<{ canonical: string; patterns: RegExp[] }> = [
|
||||
{ canonical: "Yonex", patterns: [/\byonex\b/i, /尤尼克斯/i, /yonex/i, /yy\b/i] },
|
||||
{ canonical: "Wilson", patterns: [/\bwilson\b/i, /威尔胜/i] },
|
||||
{ canonical: "Babolat", patterns: [/\bbabolat\b/i, /百保力/i] },
|
||||
{ canonical: "Head", patterns: [/\bhead\b/i, /海德/i] },
|
||||
{ canonical: "Tecnifibre", patterns: [/\btecnifibre\b/i, /泰克尼纤维/i, /tfight/i] },
|
||||
{ canonical: "Prince", patterns: [/\bprince\b/i, /王子/i] },
|
||||
{ canonical: "Dunlop", patterns: [/\bdunlop\b/i, /登禄普/i] },
|
||||
{ canonical: "Li-Ning", patterns: [/\bli-ning\b/i, /李宁/i] },
|
||||
{ canonical: "Kawasaki", patterns: [/\bkawasaki\b/i, /川崎/i] },
|
||||
{ canonical: "Teloon", patterns: [/\bteloon\b/i, /天龙/i] },
|
||||
];
|
||||
|
||||
const SERIES_PATTERNS: Array<{ series: string; pattern: RegExp }> = [
|
||||
{ series: "Ezone", pattern: /\bezone\b/i },
|
||||
{ series: "Vcore", pattern: /\bv-?core\b/i },
|
||||
{ series: "Percept", pattern: /\bpercept\b/i },
|
||||
{ series: "Blade", pattern: /\bblade\b/i },
|
||||
{ series: "Clash", pattern: /\bclash\b/i },
|
||||
{ series: "Ultra", pattern: /\bultra\b/i },
|
||||
{ series: "Pro Staff", pattern: /\bpro\s?staff\b/i },
|
||||
{ series: "Burn", pattern: /\bburn\b/i },
|
||||
{ series: "Pure Drive", pattern: /\bpure\s?drive\b/i },
|
||||
{ series: "Pure Aero", pattern: /\bpure\s?aero\b/i },
|
||||
{ series: "Pure Strike", pattern: /\bpure\s?strike\b/i },
|
||||
{ series: "Boom", pattern: /\bboom\b/i },
|
||||
{ series: "Radical", pattern: /\bradical\b/i },
|
||||
{ series: "Speed", pattern: /\bspeed\b/i },
|
||||
{ series: "Gravity", pattern: /\bgravity\b/i },
|
||||
{ series: "Extreme", pattern: /\bextreme\b/i },
|
||||
{ series: "TFight", pattern: /\btfight\b/i },
|
||||
];
|
||||
|
||||
const BRAND_TIER: Record<string, "elite" | "mainstream" | "value"> = {
|
||||
Yonex: "elite",
|
||||
Wilson: "elite",
|
||||
Babolat: "elite",
|
||||
Head: "elite",
|
||||
Tecnifibre: "elite",
|
||||
Prince: "mainstream",
|
||||
Dunlop: "mainstream",
|
||||
"Li-Ning": "mainstream",
|
||||
Kawasaki: "value",
|
||||
Teloon: "value",
|
||||
};
|
||||
|
||||
const POSITIVE_RACKET_PATTERN = /(网球拍|球拍|tennis|racquet|racket)/i;
|
||||
const NEGATIVE_ACCESSORY_PATTERN = /(拍包|背包|线|拍线|手胶|减震器|护线|包邮补差|网球包|球线)/i;
|
||||
|
||||
function normalizeWhitespace(value: string) {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function stripHtml(value: string) {
|
||||
return normalizeWhitespace(value.replace(/<[^>]+>/g, " "));
|
||||
}
|
||||
|
||||
function toNumber(value: unknown): number | null {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const match = value.replace(/,/g, "").match(/(\d+(?:\.\d+)?)/);
|
||||
if (!match) return null;
|
||||
const parsed = Number.parseFloat(match[1]);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
const obj = value as Record<string, unknown>;
|
||||
return toNumber(obj.value ?? obj.price ?? obj.amount ?? obj.priceText);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstString(values: unknown[]) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return normalizeWhitespace(value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findValuesByKey(
|
||||
node: unknown,
|
||||
matcher: RegExp,
|
||||
depth = 0,
|
||||
seen = new Set<unknown>(),
|
||||
): unknown[] {
|
||||
if (node == null || depth > 6) return [];
|
||||
if (typeof node !== "object") return [];
|
||||
if (seen.has(node)) return [];
|
||||
seen.add(node);
|
||||
|
||||
const results: unknown[] = [];
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
results.push(...findValuesByKey(item, matcher, depth + 1, seen));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
||||
if (matcher.test(key)) {
|
||||
results.push(value);
|
||||
}
|
||||
results.push(...findValuesByKey(value, matcher, depth + 1, seen));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
function collectObjectArrays(node: unknown, depth = 0, seen = new Set<unknown>()): Array<Record<string, unknown>[]> {
|
||||
if (node == null || depth > 6) return [];
|
||||
if (typeof node !== "object") return [];
|
||||
if (seen.has(node)) return [];
|
||||
seen.add(node);
|
||||
|
||||
const arrays: Array<Record<string, unknown>[]> = [];
|
||||
if (Array.isArray(node)) {
|
||||
if (node.length > 0 && node.every((item) => item && typeof item === "object" && !Array.isArray(item))) {
|
||||
arrays.push(node as Array<Record<string, unknown>>);
|
||||
}
|
||||
for (const item of node) {
|
||||
arrays.push(...collectObjectArrays(item, depth + 1, seen));
|
||||
}
|
||||
return arrays;
|
||||
}
|
||||
|
||||
for (const value of Object.values(node as Record<string, unknown>)) {
|
||||
arrays.push(...collectObjectArrays(value, depth + 1, seen));
|
||||
}
|
||||
return arrays;
|
||||
}
|
||||
|
||||
function guessUrlFromValue(value: string | null, source: MarketSource, sourceListingId: string) {
|
||||
if (value && /^https?:\/\//i.test(value)) return value;
|
||||
if (value && value.startsWith("/")) {
|
||||
switch (source) {
|
||||
case "xianyu":
|
||||
return `https://www.goofish.com${value}`;
|
||||
case "jd":
|
||||
return `https://item.jd.com${value}`;
|
||||
case "zhuanzhuan":
|
||||
return `https://www.zhuanzhuan.com${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
switch (source) {
|
||||
case "xianyu":
|
||||
return `https://www.goofish.com/item?id=${encodeURIComponent(sourceListingId)}`;
|
||||
case "jd":
|
||||
return `https://item.jd.com/${encodeURIComponent(sourceListingId)}.html`;
|
||||
case "zhuanzhuan":
|
||||
return `https://www.zhuanzhuan.com/`;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeRacketTitle(title: string) {
|
||||
const normalized = normalizeWhitespace(title);
|
||||
return POSITIVE_RACKET_PATTERN.test(normalized) && !NEGATIVE_ACCESSORY_PATTERN.test(normalized);
|
||||
}
|
||||
|
||||
export function detectBrand(text: string) {
|
||||
for (const item of BRAND_ALIASES) {
|
||||
if (item.patterns.some((pattern) => pattern.test(text))) {
|
||||
return item.canonical;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectSeries(text: string) {
|
||||
const hit = SERIES_PATTERNS.find((item) => item.pattern.test(text));
|
||||
return hit?.series ?? null;
|
||||
}
|
||||
|
||||
export function detectWeightGram(text: string) {
|
||||
const direct = text.match(/(?:^|[^0-9])([23]\d{2})(?:g|克)\b/i);
|
||||
if (direct) {
|
||||
const weight = Number.parseInt(direct[1], 10);
|
||||
if (weight >= 230 && weight <= 340) return weight;
|
||||
}
|
||||
|
||||
const unstrung = text.match(/([23]\d{2})\s*\/?\s*(?:unstrung|裸拍)/i);
|
||||
if (unstrung) {
|
||||
const weight = Number.parseInt(unstrung[1], 10);
|
||||
if (weight >= 230 && weight <= 340) return weight;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectConditionLevel(text: string): MarketConditionLevel {
|
||||
if (/(全新未拆|全新|仅拆封|吊牌)/i.test(text)) return "brand_new";
|
||||
if (/(99新|98新|95新|几乎全新|微瑕|试打|试用)/i.test(text)) return "almost_new";
|
||||
if (/(正常使用|二手|使用痕迹|轻微磕碰|轻微掉漆)/i.test(text)) return "used_good";
|
||||
if (/(磕碰|掉漆|裂纹|修补|明显使用痕迹)/i.test(text)) return "used_fair";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function detectCategory(text: string, weightGram: number | null): MarketCategory {
|
||||
if (/(junior|jr|儿童|青少年|25寸|26寸)/i.test(text)) return "junior";
|
||||
if (/(pro|tour|比赛|竞技|98\b|97\b|95\b)/i.test(text) || (weightGram != null && weightGram >= 295)) {
|
||||
return "competitive";
|
||||
}
|
||||
if (/(初学|入门|娱乐|练习)/i.test(text) || (weightGram != null && weightGram <= 285)) {
|
||||
return "recreational";
|
||||
}
|
||||
if (POSITIVE_RACKET_PATTERN.test(text)) return "adult";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function deriveModel(text: string, brand: string | null, series: string | null) {
|
||||
let value = text;
|
||||
if (brand) {
|
||||
value = value.replace(new RegExp(brand, "ig"), " ");
|
||||
}
|
||||
if (series) {
|
||||
value = value.replace(new RegExp(series.replace(/\s+/g, "\\s*"), "ig"), " ");
|
||||
}
|
||||
value = value.replace(/(网球拍|球拍|tennis|racquet|racket|全新|二手|99新|95新|98新)/ig, " ");
|
||||
value = value.replace(/([23]\d{2})(?:g|克)/ig, " ");
|
||||
value = normalizeWhitespace(value);
|
||||
return value ? value.slice(0, 96) : null;
|
||||
}
|
||||
|
||||
function fingerprintForListing(source: MarketSource, sourceListingId: string, title: string, price: number) {
|
||||
return crypto
|
||||
.createHash("sha1")
|
||||
.update(`${source}:${sourceListingId}:${title}:${price}`)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function gradeRacketListing(input: {
|
||||
brand: string | null;
|
||||
price: number;
|
||||
conditionLevel: MarketConditionLevel;
|
||||
category: MarketCategory;
|
||||
weightGram: number | null;
|
||||
}) {
|
||||
const tier = input.brand ? (BRAND_TIER[input.brand] ?? "value") : "value";
|
||||
const conditionBand = {
|
||||
brand_new: tier === "elite" ? 1200 : tier === "mainstream" ? 800 : 500,
|
||||
almost_new: tier === "elite" ? 900 : tier === "mainstream" ? 650 : 400,
|
||||
used_good: tier === "elite" ? 650 : tier === "mainstream" ? 480 : 280,
|
||||
used_fair: tier === "elite" ? 450 : tier === "mainstream" ? 320 : 200,
|
||||
unknown: tier === "elite" ? 700 : tier === "mainstream" ? 500 : 280,
|
||||
}[input.conditionLevel];
|
||||
|
||||
const standardBand = Math.round(conditionBand * 1.45);
|
||||
const reasons: string[] = [];
|
||||
if (input.brand) reasons.push(`品牌 ${input.brand}`);
|
||||
reasons.push(`成色 ${MARKET_CONDITION_LABELS[input.conditionLevel]}`);
|
||||
if (input.weightGram != null) reasons.push(`重量 ${Math.round(input.weightGram)}g`);
|
||||
if (input.category !== "unknown") reasons.push(MARKET_CATEGORY_LABELS[input.category]);
|
||||
|
||||
let gradeLevel: MarketGradeLevel = "pending_review";
|
||||
if (input.price <= conditionBand) {
|
||||
gradeLevel = "high_value";
|
||||
reasons.push("当前价格落在低价区间");
|
||||
} else if (input.price <= standardBand) {
|
||||
gradeLevel = "standard";
|
||||
reasons.push("当前价格处于常见成交区间");
|
||||
} else if (input.price > standardBand) {
|
||||
gradeLevel = "overpriced";
|
||||
reasons.push("当前价格高于常见区间");
|
||||
}
|
||||
|
||||
return {
|
||||
gradeLevel,
|
||||
gradeReason: reasons.join(" · "),
|
||||
isLowPriceCandidate: gradeLevel === "high_value",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMarketSearchQuery(rule: Pick<RacketWatchRule, "brand" | "modelKeyword" | "seriesKeyword" | "category">) {
|
||||
const parts = [
|
||||
rule.brand,
|
||||
rule.seriesKeyword ?? "",
|
||||
rule.modelKeyword ?? "",
|
||||
rule.category === "junior" ? "儿童" : "",
|
||||
"网球拍",
|
||||
];
|
||||
return normalizeWhitespace(parts.filter(Boolean).join(" "));
|
||||
}
|
||||
|
||||
export function deriveWatchRuleTitle(input: {
|
||||
title?: string | null;
|
||||
brand: string;
|
||||
modelKeyword?: string | null;
|
||||
seriesKeyword?: string | null;
|
||||
targetPrice: number;
|
||||
}) {
|
||||
const trimmed = input.title?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
const core = normalizeWhitespace([input.brand, input.seriesKeyword ?? "", input.modelKeyword ?? ""].filter(Boolean).join(" "));
|
||||
return `${core || input.brand} ≤ ¥${Math.round(input.targetPrice)}`;
|
||||
}
|
||||
|
||||
export async function loadMarketConfig(): Promise<MarketConfig> {
|
||||
return {
|
||||
defaultFeishuWebhook: await db.getAppSettingValue("market_default_feishu_webhook", ""),
|
||||
refreshIntervalMinutes: await db.getAppSettingValue("market_watch_refresh_interval_minutes", 30),
|
||||
repushDelta: await db.getAppSettingValue("market_price_repush_delta", 20),
|
||||
sourceTimeoutMs: await db.getAppSettingValue("market_source_timeout_ms", 12000),
|
||||
sourceRetryCount: await db.getAppSettingValue("market_source_retry_count", 1),
|
||||
xianyuCookie: await db.getAppSettingValue("market_xianyu_cookie", ""),
|
||||
xianyuUserAgent: await db.getAppSettingValue(
|
||||
"market_xianyu_user_agent",
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
),
|
||||
jdCookie: await db.getAppSettingValue("market_jd_cookie", ""),
|
||||
jdUserAgent: await db.getAppSettingValue(
|
||||
"market_jd_user_agent",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
),
|
||||
zhuanzhuanCookie: await db.getAppSettingValue("market_zhuanzhuan_cookie", ""),
|
||||
zhuanzhuanUserAgent: await db.getAppSettingValue(
|
||||
"market_zhuanzhuan_user_agent",
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
||||
),
|
||||
zhuanzhuanSearchUrlTemplate: await db.getAppSettingValue("market_zhuanzhuan_search_url_template", ""),
|
||||
};
|
||||
}
|
||||
|
||||
function buildHeaders(userAgent: string, cookie?: string) {
|
||||
const headers: Record<string, string> = {
|
||||
"accept-language": "zh-CN,zh;q=0.9",
|
||||
"content-type": "application/json",
|
||||
"user-agent": userAgent,
|
||||
};
|
||||
if (cookie?.trim()) headers.cookie = cookie.trim();
|
||||
return headers;
|
||||
}
|
||||
|
||||
function pickFirstUrl(candidate: Record<string, unknown>) {
|
||||
const url = firstString(findValuesByKey(candidate, /(url|href|jumpUrl|itemUrl|detailUrl)/i));
|
||||
if (!url) return null;
|
||||
return url;
|
||||
}
|
||||
|
||||
function pickImageUrl(candidate: Record<string, unknown>) {
|
||||
const image = firstString(findValuesByKey(candidate, /(image|img|pic|cover)/i));
|
||||
if (!image) return null;
|
||||
if (/^https?:\/\//i.test(image)) return image;
|
||||
if (image.startsWith("//")) return `https:${image}`;
|
||||
return image;
|
||||
}
|
||||
|
||||
function pickSellerName(candidate: Record<string, unknown>) {
|
||||
const keys = /(seller|nick|owner|userName|shopName|publisher|author)/i;
|
||||
return firstString(findValuesByKey(candidate, keys));
|
||||
}
|
||||
|
||||
function mapGenericCandidate(source: MarketSource, candidate: Record<string, unknown>) {
|
||||
const sourceListingId = firstString(findValuesByKey(candidate, /^(itemId|id|spuId|wareId|item_id)$/i));
|
||||
const title = firstString(findValuesByKey(candidate, /(title|itemTitle|name|summary|subject)/i));
|
||||
const priceCandidates = findValuesByKey(candidate, /(price|amount|showPrice|salePrice|soldPrice)/i)
|
||||
.map((value) => toNumber(value))
|
||||
.filter((value): value is number => value != null && value > 0);
|
||||
const price = priceCandidates[0] ?? null;
|
||||
|
||||
if (!sourceListingId || !title || price == null || !looksLikeRacketTitle(title)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const originalPrice = priceCandidates.find((value) => value > price) ?? null;
|
||||
const listingUrl = guessUrlFromValue(pickFirstUrl(candidate), source, sourceListingId);
|
||||
const sellerName = pickSellerName(candidate);
|
||||
const location = firstString(findValuesByKey(candidate, /(location|city|area|province|region)/i));
|
||||
const publishedAtRaw = firstString(findValuesByKey(candidate, /(publish|createTime|time|publishAt|gmtCreate)/i));
|
||||
|
||||
return {
|
||||
source,
|
||||
sourceListingId,
|
||||
title,
|
||||
listingUrl,
|
||||
imageUrl: pickImageUrl(candidate),
|
||||
price,
|
||||
originalPrice,
|
||||
sellerName,
|
||||
location,
|
||||
publishedAtRaw,
|
||||
extra: candidate,
|
||||
} satisfies RawSourceListing;
|
||||
}
|
||||
|
||||
function extractCandidatesFromPayload(source: MarketSource, payload: unknown) {
|
||||
const arrays = collectObjectArrays(payload);
|
||||
const mapped: RawSourceListing[] = [];
|
||||
for (const array of arrays) {
|
||||
for (const candidate of array) {
|
||||
const listing = mapGenericCandidate(source, candidate);
|
||||
if (listing) mapped.push(listing);
|
||||
}
|
||||
}
|
||||
|
||||
const unique = new Map<string, RawSourceListing>();
|
||||
for (const item of mapped) {
|
||||
unique.set(`${item.source}:${item.sourceListingId}`, item);
|
||||
}
|
||||
return Array.from(unique.values()).slice(0, 30);
|
||||
}
|
||||
|
||||
function parseJsonSafely(text: string) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url: string, init: RequestInit, timeoutMs: number, retries: number) {
|
||||
const response = await fetchWithTimeout(url, init, {
|
||||
timeoutMs,
|
||||
retries,
|
||||
retryStatuses: [408, 425, 429, 500, 502, 503, 504],
|
||||
});
|
||||
const text = await response.text();
|
||||
return {
|
||||
response,
|
||||
text,
|
||||
json: parseJsonSafely(text),
|
||||
};
|
||||
}
|
||||
|
||||
async function searchXianyu(query: string, config: MarketConfig): Promise<SearchSourceResult> {
|
||||
const endpoint = "https://acs.m.goofish.com/h5/mtop.taobao.idlemtopsearch.pc.search/1.0/";
|
||||
const data = JSON.stringify({
|
||||
pageNumber: 1,
|
||||
keyword: query,
|
||||
rowsPerPage: 30,
|
||||
searchReqFromPage: "pcSearch",
|
||||
});
|
||||
|
||||
const tokenMatch = config.xianyuCookie.match(/_m_h5_tk=([^_;]+)/);
|
||||
const t = Date.now().toString();
|
||||
const sign = tokenMatch
|
||||
? crypto.createHash("md5").update(`${tokenMatch[1]}&${t}&${XIANYU_APP_KEY}&${data}`).digest("hex")
|
||||
: "";
|
||||
|
||||
const url = `${endpoint}?jsv=2.7.2&appKey=${XIANYU_APP_KEY}&t=${t}&sign=${sign}&api=${XIANYU_API}&v=1.0&type=originaljson&dataType=json&timeout=20000&data=${encodeURIComponent(data)}`;
|
||||
const { response, text, json } = await fetchJson(url, {
|
||||
method: "GET",
|
||||
headers: buildHeaders(config.xianyuUserAgent, config.xianyuCookie),
|
||||
}, config.sourceTimeoutMs, config.sourceRetryCount);
|
||||
|
||||
const blocked = response.status >= 400
|
||||
|| /RGV587_ERROR|FAIL_SYS_ILLEGAL_ACCESS|passport\.goofish\.com|被挤爆|安全校验/i.test(text);
|
||||
if (blocked) {
|
||||
return {
|
||||
source: "xianyu",
|
||||
query,
|
||||
ok: false,
|
||||
blocked: true,
|
||||
message: "闲鱼搜索接口返回登录或风控校验,需要补充有效 Cookie 或降低请求频率。",
|
||||
listings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const listings = extractCandidatesFromPayload("xianyu", json);
|
||||
return {
|
||||
source: "xianyu",
|
||||
query,
|
||||
ok: true,
|
||||
blocked: false,
|
||||
message: listings.length > 0 ? `闲鱼返回 ${listings.length} 条结果` : "闲鱼接口成功但未提取到可用商品数据",
|
||||
listings,
|
||||
};
|
||||
}
|
||||
|
||||
async function searchJd(query: string, config: MarketConfig): Promise<SearchSourceResult> {
|
||||
const url = `https://so.m.jd.com/ware/searchList.action?_format_=json&keyword=${encodeURIComponent(query)}&page=1`;
|
||||
const { response, text, json } = await fetchJson(url, {
|
||||
method: "GET",
|
||||
headers: buildHeaders(config.jdUserAgent, config.jdCookie),
|
||||
}, config.sourceTimeoutMs, config.sourceRetryCount);
|
||||
|
||||
const blocked = response.status >= 400 || /403 Forbidden|京东验证|risk_handler|JDR_shields/i.test(text);
|
||||
if (blocked) {
|
||||
return {
|
||||
source: "jd",
|
||||
query,
|
||||
ok: false,
|
||||
blocked: true,
|
||||
message: "京东搜索接口当前返回风控页或 403,需补充 Cookie 或降低抓取频率。",
|
||||
listings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const listings = extractCandidatesFromPayload("jd", json);
|
||||
return {
|
||||
source: "jd",
|
||||
query,
|
||||
ok: true,
|
||||
blocked: false,
|
||||
message: listings.length > 0 ? `京东返回 ${listings.length} 条结果` : "京东接口成功但未提取到可用商品数据",
|
||||
listings,
|
||||
};
|
||||
}
|
||||
|
||||
async function searchZhuanzhuan(query: string, config: MarketConfig): Promise<SearchSourceResult> {
|
||||
if (!config.zhuanzhuanSearchUrlTemplate.includes("{query}")) {
|
||||
return {
|
||||
source: "zhuanzhuan",
|
||||
query,
|
||||
ok: false,
|
||||
blocked: false,
|
||||
message: "转转搜索 URL 模板尚未配置,当前来源处于待接线状态。",
|
||||
listings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const url = config.zhuanzhuanSearchUrlTemplate.replace(/\{query\}/g, encodeURIComponent(query));
|
||||
const { response, text, json } = await fetchJson(url, {
|
||||
method: "GET",
|
||||
headers: buildHeaders(config.zhuanzhuanUserAgent, config.zhuanzhuanCookie),
|
||||
}, config.sourceTimeoutMs, config.sourceRetryCount);
|
||||
|
||||
if (response.status >= 400) {
|
||||
return {
|
||||
source: "zhuanzhuan",
|
||||
query,
|
||||
ok: false,
|
||||
blocked: response.status === 403,
|
||||
message: `转转搜索模板返回 ${response.status},请检查配置的模板地址。`,
|
||||
listings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const payload = json ?? text;
|
||||
const listings = extractCandidatesFromPayload("zhuanzhuan", payload);
|
||||
return {
|
||||
source: "zhuanzhuan",
|
||||
query,
|
||||
ok: true,
|
||||
blocked: false,
|
||||
message: listings.length > 0 ? `转转返回 ${listings.length} 条结果` : "转转响应成功但未提取到可用商品数据",
|
||||
listings,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchMarketSource(source: MarketSource, query: string, config: MarketConfig): Promise<SearchSourceResult> {
|
||||
try {
|
||||
switch (source) {
|
||||
case "xianyu":
|
||||
return await searchXianyu(query, config);
|
||||
case "jd":
|
||||
return await searchJd(query, config);
|
||||
case "zhuanzhuan":
|
||||
return await searchZhuanzhuan(query, config);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown source error";
|
||||
return {
|
||||
source,
|
||||
query,
|
||||
ok: false,
|
||||
blocked: /403|校验|risk|timeout/i.test(message),
|
||||
message,
|
||||
listings: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function enrichRacketListing(raw: RawSourceListing): InsertRacketListing {
|
||||
const material = normalizeWhitespace([raw.title, raw.description ?? ""].join(" "));
|
||||
const brand = detectBrand(material);
|
||||
const series = detectSeries(material);
|
||||
const weightGram = detectWeightGram(material);
|
||||
const category = detectCategory(material, weightGram);
|
||||
const conditionLevel = detectConditionLevel(material);
|
||||
const { gradeLevel, gradeReason, isLowPriceCandidate } = gradeRacketListing({
|
||||
brand,
|
||||
price: raw.price,
|
||||
conditionLevel,
|
||||
category,
|
||||
weightGram,
|
||||
});
|
||||
|
||||
return {
|
||||
source: raw.source,
|
||||
sourceListingId: raw.sourceListingId,
|
||||
title: raw.title.slice(0, 512),
|
||||
description: raw.description ?? null,
|
||||
listingUrl: raw.listingUrl,
|
||||
imageUrl: raw.imageUrl ?? null,
|
||||
price: raw.price,
|
||||
originalPrice: raw.originalPrice ?? null,
|
||||
sellerName: raw.sellerName ?? null,
|
||||
location: raw.location ?? null,
|
||||
publishedAtRaw: raw.publishedAtRaw ?? null,
|
||||
brand,
|
||||
model: deriveModel(material, brand, series),
|
||||
series,
|
||||
category,
|
||||
weightGram,
|
||||
conditionLevel,
|
||||
gradeLevel,
|
||||
gradeReason,
|
||||
isLowPriceCandidate: isLowPriceCandidate ? 1 : 0,
|
||||
fingerprint: fingerprintForListing(raw.source, raw.sourceListingId, raw.title, raw.price),
|
||||
extra: raw.extra ?? null,
|
||||
fetchedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export function listingMatchesWatchRule(
|
||||
listing: Pick<InsertRacketListing, "price" | "brand" | "title" | "model" | "series" | "category" | "weightGram">,
|
||||
rule: Pick<RacketWatchRule, "brand" | "modelKeyword" | "seriesKeyword" | "category" | "weightMinGram" | "weightMaxGram" | "targetPrice">,
|
||||
) {
|
||||
if ((listing.price ?? Number.MAX_SAFE_INTEGER) > rule.targetPrice) return false;
|
||||
|
||||
const haystack = normalizeWhitespace([
|
||||
listing.brand ?? "",
|
||||
listing.title ?? "",
|
||||
listing.model ?? "",
|
||||
listing.series ?? "",
|
||||
].join(" ")).toLowerCase();
|
||||
|
||||
if (rule.brand && !haystack.includes(rule.brand.toLowerCase())) return false;
|
||||
if (rule.modelKeyword && !haystack.includes(rule.modelKeyword.toLowerCase())) return false;
|
||||
if (rule.seriesKeyword && !haystack.includes(rule.seriesKeyword.toLowerCase())) return false;
|
||||
if (rule.category && listing.category && rule.category !== listing.category) return false;
|
||||
if (rule.weightMinGram != null && listing.weightGram != null && listing.weightGram < rule.weightMinGram) return false;
|
||||
if (rule.weightMaxGram != null && listing.weightGram != null && listing.weightGram > rule.weightMaxGram) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function applyComparablePriceBenchmark(
|
||||
listing: InsertRacketListing,
|
||||
comparablePrices: number[],
|
||||
) {
|
||||
if (comparablePrices.length < 2) return listing;
|
||||
const avg = comparablePrices.reduce((sum, value) => sum + value, 0) / comparablePrices.length;
|
||||
if (listing.price <= avg * 0.85) {
|
||||
return {
|
||||
...listing,
|
||||
isLowPriceCandidate: 1,
|
||||
gradeReason: normalizeWhitespace(`${listing.gradeReason ?? ""} · 相比站内同型号样本均价更低`),
|
||||
};
|
||||
}
|
||||
return listing;
|
||||
}
|
||||
|
||||
export function maskWebhookUrl(url: string) {
|
||||
if (!url.trim()) return "";
|
||||
if (url.length <= 20) return url;
|
||||
return `${url.slice(0, 38)}...${url.slice(-8)}`;
|
||||
}
|
||||
|
||||
export function formatMarketPushText(payload: {
|
||||
ruleTitle: string;
|
||||
source: MarketSource;
|
||||
title: string;
|
||||
price: number;
|
||||
targetPrice: number;
|
||||
brand?: string | null;
|
||||
model?: string | null;
|
||||
category?: string | null;
|
||||
weightGram?: number | null;
|
||||
gradeLevel?: MarketGradeLevel | null;
|
||||
gradeReason?: string | null;
|
||||
listingUrl: string;
|
||||
fetchedAt?: string | Date | null;
|
||||
}) {
|
||||
const lines = [
|
||||
`命中监控: ${payload.ruleTitle}`,
|
||||
`来源: ${MARKET_SOURCE_LABELS[payload.source]}`,
|
||||
`标题: ${payload.title}`,
|
||||
`价格: ¥${payload.price.toFixed(0)} / 目标 ¥${payload.targetPrice.toFixed(0)}`,
|
||||
];
|
||||
|
||||
const tags = [
|
||||
payload.brand ? `品牌 ${payload.brand}` : "",
|
||||
payload.model ? `型号 ${payload.model}` : "",
|
||||
payload.category ? `品类 ${MARKET_CATEGORY_LABELS[payload.category as MarketCategory] ?? payload.category}` : "",
|
||||
payload.weightGram != null ? `重量 ${Math.round(payload.weightGram)}g` : "",
|
||||
payload.gradeLevel ? `分级 ${MARKET_GRADE_LABELS[payload.gradeLevel]}` : "",
|
||||
].filter(Boolean);
|
||||
if (tags.length > 0) {
|
||||
lines.push(`标签: ${tags.join(" · ")}`);
|
||||
}
|
||||
if (payload.gradeReason) {
|
||||
lines.push(`判断: ${payload.gradeReason}`);
|
||||
}
|
||||
if (payload.fetchedAt) {
|
||||
lines.push(`抓取时间: ${new Date(payload.fetchedAt).toISOString()}`);
|
||||
}
|
||||
lines.push(`链接: ${payload.listingUrl}`);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
95
server/match.test.ts
普通文件
95
server/match.test.ts
普通文件
@@ -0,0 +1,95 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildParticipantSettlement, deriveSuggestedMatchState } from "./match";
|
||||
|
||||
describe("deriveSuggestedMatchState", () => {
|
||||
it("aggregates point, game and set events into a suggested scoreboard", () => {
|
||||
const result = deriveSuggestedMatchState([
|
||||
{ eventIndex: 1, eventType: "point", source: "camera_a", winnerSlot: "player_a", confidence: 0.6, payload: { rallyCount: 4, isWinner: true } },
|
||||
{ eventIndex: 2, eventType: "point", source: "camera_b", winnerSlot: "player_a", confidence: 0.7, payload: { rallyCount: 7, isAce: true, firstServeIn: true, firstServeAttempt: true } },
|
||||
{ eventIndex: 3, eventType: "game", source: "camera_a", winnerSlot: "player_a", confidence: 0.65 },
|
||||
{ eventIndex: 4, eventType: "set", source: "camera_a", winnerSlot: "player_a", confidence: 0.8 },
|
||||
]);
|
||||
|
||||
expect(result.score.sets.player_a).toBe(1);
|
||||
expect(result.score.games.player_a).toBe(1);
|
||||
expect(result.score.points.player_a).toBe(0);
|
||||
expect(result.score.winnerSlot).toBe("player_a");
|
||||
expect(result.metrics.players.player_a.pointsWon).toBe(2);
|
||||
expect(result.metrics.players.player_a.aces).toBe(1);
|
||||
expect(result.metrics.players.player_a.firstServePct).toBe(100);
|
||||
expect(result.metrics.longestRally).toBe(7);
|
||||
expect(result.sourceCount).toBe(2);
|
||||
});
|
||||
|
||||
it("prefers later reviewed score snapshots while keeping metric snapshots", () => {
|
||||
const result = deriveSuggestedMatchState([
|
||||
{
|
||||
eventIndex: 1,
|
||||
eventType: "score_suggestion",
|
||||
source: "system",
|
||||
confidence: 0.78,
|
||||
payload: {
|
||||
score: {
|
||||
sets: { player_a: 1, player_b: 0 },
|
||||
games: { player_a: 6, player_b: 4 },
|
||||
points: { player_a: 0, player_b: 0 },
|
||||
},
|
||||
metrics: {
|
||||
player_a: { winners: 14, pointsWon: 32 },
|
||||
player_b: { winners: 10, pointsWon: 25 },
|
||||
totalRallies: 18,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
eventIndex: 2,
|
||||
eventType: "review_adjustment",
|
||||
source: "admin",
|
||||
confidence: 1,
|
||||
payload: {
|
||||
score: {
|
||||
sets: { player_a: 2, player_b: 0 },
|
||||
games: { player_a: 12, player_b: 6 },
|
||||
points: { player_a: 68, player_b: 53 },
|
||||
winnerSlot: "player_a",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result.score.sets.player_a).toBe(2);
|
||||
expect(result.score.games.player_b).toBe(6);
|
||||
expect(result.score.points.player_a).toBe(68);
|
||||
expect(result.score.winnerSlot).toBe("player_a");
|
||||
expect(result.metrics.players.player_a.winners).toBe(14);
|
||||
expect(result.metrics.totalRallies).toBe(18);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildParticipantSettlement", () => {
|
||||
it("projects scoreboard and metrics into per-player settlement rows", () => {
|
||||
const settlement = buildParticipantSettlement(
|
||||
{
|
||||
sets: { player_a: 2, player_b: 1 },
|
||||
games: { player_a: 13, player_b: 11 },
|
||||
points: { player_a: 74, player_b: 68 },
|
||||
winnerSlot: "player_a",
|
||||
},
|
||||
{
|
||||
players: {
|
||||
player_a: { aces: 6, firstServeIn: 32, firstServeAttempts: 40, pointsWon: 74 },
|
||||
player_b: { aces: 4, firstServeIn: 28, firstServeAttempts: 41, pointsWon: 68 },
|
||||
},
|
||||
totalRallies: 22,
|
||||
longestRally: 11,
|
||||
},
|
||||
);
|
||||
|
||||
expect(settlement.winnerSlot).toBe("player_a");
|
||||
expect(settlement.players.player_a.isWinner).toBe(true);
|
||||
expect(settlement.players.player_a.gamesWon).toBe(13);
|
||||
expect(settlement.players.player_a.stats.firstServePct).toBe(80);
|
||||
expect(settlement.players.player_b.stats.firstServePct).toBeCloseTo(68.3, 1);
|
||||
expect(settlement.summary.longestRally).toBe(11);
|
||||
});
|
||||
});
|
||||
440
server/match.ts
普通文件
440
server/match.ts
普通文件
@@ -0,0 +1,440 @@
|
||||
export const MATCH_PLAYER_SLOTS = ["player_a", "player_b"] as const;
|
||||
export type MatchPlayerSlot = (typeof MATCH_PLAYER_SLOTS)[number];
|
||||
|
||||
export type SlotMap<T> = Record<MatchPlayerSlot, T>;
|
||||
|
||||
export type MatchScoreboard = {
|
||||
sets: SlotMap<number>;
|
||||
games: SlotMap<number>;
|
||||
points: SlotMap<number>;
|
||||
winnerSlot: MatchPlayerSlot | null;
|
||||
confidence: number;
|
||||
};
|
||||
|
||||
export type MatchPlayerMetrics = {
|
||||
pointsWon: number;
|
||||
aces: number;
|
||||
doubleFaults: number;
|
||||
winners: number;
|
||||
unforcedErrors: number;
|
||||
breakPointsWon: number;
|
||||
breakPointsTotal: number;
|
||||
firstServeIn: number;
|
||||
firstServeAttempts: number;
|
||||
firstServePct: number;
|
||||
maxServeKph: number;
|
||||
longestRally: number;
|
||||
};
|
||||
|
||||
export type MatchMetrics = {
|
||||
players: SlotMap<MatchPlayerMetrics>;
|
||||
totalRallies: number;
|
||||
longestRally: number;
|
||||
sourceCount: number;
|
||||
};
|
||||
|
||||
export type MatchSettlement = {
|
||||
winnerSlot: MatchPlayerSlot | null;
|
||||
players: SlotMap<{
|
||||
setsWon: number;
|
||||
gamesWon: number;
|
||||
pointsWon: number;
|
||||
isWinner: boolean;
|
||||
stats: MatchPlayerMetrics;
|
||||
}>;
|
||||
summary: {
|
||||
totalRallies: number;
|
||||
longestRally: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type MatchEventInput = {
|
||||
eventType: string;
|
||||
winnerSlot?: MatchPlayerSlot | null;
|
||||
confidence?: number | null;
|
||||
payload?: unknown;
|
||||
source?: string | null;
|
||||
eventIndex?: number | null;
|
||||
};
|
||||
|
||||
function clampNumber(value: unknown, fallback = 0) {
|
||||
if (typeof value !== "number" || Number.isNaN(value)) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function toSlotMap<T>(factory: () => T): SlotMap<T> {
|
||||
return {
|
||||
player_a: factory(),
|
||||
player_b: factory(),
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyMatchPlayerMetrics(): MatchPlayerMetrics {
|
||||
return {
|
||||
pointsWon: 0,
|
||||
aces: 0,
|
||||
doubleFaults: 0,
|
||||
winners: 0,
|
||||
unforcedErrors: 0,
|
||||
breakPointsWon: 0,
|
||||
breakPointsTotal: 0,
|
||||
firstServeIn: 0,
|
||||
firstServeAttempts: 0,
|
||||
firstServePct: 0,
|
||||
maxServeKph: 0,
|
||||
longestRally: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyMatchMetrics(): MatchMetrics {
|
||||
return {
|
||||
players: toSlotMap(() => createEmptyMatchPlayerMetrics()),
|
||||
totalRallies: 0,
|
||||
longestRally: 0,
|
||||
sourceCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyMatchScoreboard(): MatchScoreboard {
|
||||
return {
|
||||
sets: toSlotMap(() => 0),
|
||||
games: toSlotMap(() => 0),
|
||||
points: toSlotMap(() => 0),
|
||||
winnerSlot: null,
|
||||
confidence: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePlayerMetrics(raw: unknown): MatchPlayerMetrics {
|
||||
const source = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||
const firstServeIn = clampNumber(source.firstServeIn);
|
||||
const firstServeAttempts = clampNumber(source.firstServeAttempts);
|
||||
const firstServePct = firstServeAttempts > 0
|
||||
? Math.round((firstServeIn / firstServeAttempts) * 1000) / 10
|
||||
: clampNumber(source.firstServePct);
|
||||
|
||||
return {
|
||||
pointsWon: clampNumber(source.pointsWon),
|
||||
aces: clampNumber(source.aces),
|
||||
doubleFaults: clampNumber(source.doubleFaults),
|
||||
winners: clampNumber(source.winners),
|
||||
unforcedErrors: clampNumber(source.unforcedErrors),
|
||||
breakPointsWon: clampNumber(source.breakPointsWon),
|
||||
breakPointsTotal: clampNumber(source.breakPointsTotal),
|
||||
firstServeIn,
|
||||
firstServeAttempts,
|
||||
firstServePct,
|
||||
maxServeKph: clampNumber(source.maxServeKph),
|
||||
longestRally: clampNumber(source.longestRally),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMatchMetrics(raw: unknown): MatchMetrics {
|
||||
const source = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||
const playersRaw = (source.players && typeof source.players === "object" ? source.players : source) as Record<string, unknown>;
|
||||
const players = {
|
||||
player_a: normalizePlayerMetrics(playersRaw.player_a),
|
||||
player_b: normalizePlayerMetrics(playersRaw.player_b),
|
||||
};
|
||||
const totalRallies = clampNumber(source.totalRallies);
|
||||
const longestRally = Math.max(
|
||||
clampNumber(source.longestRally),
|
||||
players.player_a.longestRally,
|
||||
players.player_b.longestRally,
|
||||
);
|
||||
const sourceCount = clampNumber(source.sourceCount);
|
||||
|
||||
return {
|
||||
players,
|
||||
totalRallies,
|
||||
longestRally,
|
||||
sourceCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeMatchScoreboard(raw: unknown): MatchScoreboard {
|
||||
const source = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
||||
const sets = (source.sets && typeof source.sets === "object" ? source.sets : source) as Record<string, unknown>;
|
||||
const games = (source.games && typeof source.games === "object" ? source.games : source) as Record<string, unknown>;
|
||||
const points = (source.points && typeof source.points === "object" ? source.points : source) as Record<string, unknown>;
|
||||
const winnerSlot = source.winnerSlot === "player_a" || source.winnerSlot === "player_b"
|
||||
? source.winnerSlot
|
||||
: null;
|
||||
|
||||
const normalized = {
|
||||
sets: {
|
||||
player_a: clampNumber(sets.player_a ?? source.playerASetCount),
|
||||
player_b: clampNumber(sets.player_b ?? source.playerBSetCount),
|
||||
},
|
||||
games: {
|
||||
player_a: clampNumber(games.player_a ?? source.playerAGameCount),
|
||||
player_b: clampNumber(games.player_b ?? source.playerBGameCount),
|
||||
},
|
||||
points: {
|
||||
player_a: clampNumber(points.player_a ?? source.playerAPointCount),
|
||||
player_b: clampNumber(points.player_b ?? source.playerBPointCount),
|
||||
},
|
||||
winnerSlot,
|
||||
confidence: Math.max(0, Math.min(1, clampNumber(source.confidence))),
|
||||
} satisfies MatchScoreboard;
|
||||
|
||||
return applyWinnerFallback(normalized);
|
||||
}
|
||||
|
||||
function applyWinnerFallback(scoreboard: MatchScoreboard): MatchScoreboard {
|
||||
if (scoreboard.winnerSlot) {
|
||||
return scoreboard;
|
||||
}
|
||||
|
||||
const dimensions: Array<keyof Pick<MatchScoreboard, "sets" | "games" | "points">> = ["sets", "games", "points"];
|
||||
for (const key of dimensions) {
|
||||
const a = scoreboard[key].player_a;
|
||||
const b = scoreboard[key].player_b;
|
||||
if (a > b) {
|
||||
return { ...scoreboard, winnerSlot: "player_a" };
|
||||
}
|
||||
if (b > a) {
|
||||
return { ...scoreboard, winnerSlot: "player_b" };
|
||||
}
|
||||
}
|
||||
|
||||
return scoreboard;
|
||||
}
|
||||
|
||||
function addMetricPatch(base: MatchPlayerMetrics, patch: Partial<MatchPlayerMetrics>) {
|
||||
base.pointsWon += clampNumber(patch.pointsWon);
|
||||
base.aces += clampNumber(patch.aces);
|
||||
base.doubleFaults += clampNumber(patch.doubleFaults);
|
||||
base.winners += clampNumber(patch.winners);
|
||||
base.unforcedErrors += clampNumber(patch.unforcedErrors);
|
||||
base.breakPointsWon += clampNumber(patch.breakPointsWon);
|
||||
base.breakPointsTotal += clampNumber(patch.breakPointsTotal);
|
||||
base.firstServeIn += clampNumber(patch.firstServeIn);
|
||||
base.firstServeAttempts += clampNumber(patch.firstServeAttempts);
|
||||
base.maxServeKph = Math.max(base.maxServeKph, clampNumber(patch.maxServeKph));
|
||||
base.longestRally = Math.max(base.longestRally, clampNumber(patch.longestRally));
|
||||
}
|
||||
|
||||
function mergeMetricSnapshot(base: MatchPlayerMetrics, snapshot: MatchPlayerMetrics) {
|
||||
base.pointsWon = Math.max(base.pointsWon, snapshot.pointsWon);
|
||||
base.aces = Math.max(base.aces, snapshot.aces);
|
||||
base.doubleFaults = Math.max(base.doubleFaults, snapshot.doubleFaults);
|
||||
base.winners = Math.max(base.winners, snapshot.winners);
|
||||
base.unforcedErrors = Math.max(base.unforcedErrors, snapshot.unforcedErrors);
|
||||
base.breakPointsWon = Math.max(base.breakPointsWon, snapshot.breakPointsWon);
|
||||
base.breakPointsTotal = Math.max(base.breakPointsTotal, snapshot.breakPointsTotal);
|
||||
base.firstServeIn = Math.max(base.firstServeIn, snapshot.firstServeIn);
|
||||
base.firstServeAttempts = Math.max(base.firstServeAttempts, snapshot.firstServeAttempts);
|
||||
base.maxServeKph = Math.max(base.maxServeKph, snapshot.maxServeKph);
|
||||
base.longestRally = Math.max(base.longestRally, snapshot.longestRally);
|
||||
}
|
||||
|
||||
function finalizeMetrics(metrics: MatchMetrics) {
|
||||
for (const slot of MATCH_PLAYER_SLOTS) {
|
||||
const row = metrics.players[slot];
|
||||
row.firstServePct = row.firstServeAttempts > 0
|
||||
? Math.round((row.firstServeIn / row.firstServeAttempts) * 1000) / 10
|
||||
: 0;
|
||||
metrics.longestRally = Math.max(metrics.longestRally, row.longestRally);
|
||||
}
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function applyScoreSnapshot(current: MatchScoreboard, raw: unknown, confidence: number) {
|
||||
const next = normalizeMatchScoreboard(raw);
|
||||
if (next.confidence <= 0) {
|
||||
next.confidence = confidence;
|
||||
}
|
||||
|
||||
if (next.confidence >= current.confidence) {
|
||||
return applyWinnerFallback(next);
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function deriveSuggestedMatchState(events: MatchEventInput[]): {
|
||||
score: MatchScoreboard;
|
||||
metrics: MatchMetrics;
|
||||
eventCount: number;
|
||||
sourceCount: number;
|
||||
} {
|
||||
const ordered = [...events].sort((a, b) => (a.eventIndex ?? 0) - (b.eventIndex ?? 0));
|
||||
const score = createEmptyMatchScoreboard();
|
||||
const metrics = createEmptyMatchMetrics();
|
||||
const sources = new Set<string>();
|
||||
|
||||
for (const event of ordered) {
|
||||
if (event.source) {
|
||||
sources.add(event.source);
|
||||
}
|
||||
|
||||
const confidence = Math.max(0, Math.min(1, event.confidence ?? 0.55));
|
||||
const payload = (event.payload && typeof event.payload === "object" ? event.payload : {}) as Record<string, unknown>;
|
||||
|
||||
switch (event.eventType) {
|
||||
case "point": {
|
||||
if (event.winnerSlot) {
|
||||
score.points[event.winnerSlot] += 1;
|
||||
metrics.players[event.winnerSlot].pointsWon += 1;
|
||||
}
|
||||
|
||||
const rallyCount = clampNumber(payload.rallyCount);
|
||||
if (rallyCount > 0) {
|
||||
metrics.totalRallies += 1;
|
||||
metrics.longestRally = Math.max(metrics.longestRally, rallyCount);
|
||||
if (event.winnerSlot) {
|
||||
metrics.players[event.winnerSlot].longestRally = Math.max(
|
||||
metrics.players[event.winnerSlot].longestRally,
|
||||
rallyCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.winnerSlot) {
|
||||
addMetricPatch(metrics.players[event.winnerSlot], {
|
||||
aces: payload.isAce ? 1 : 0,
|
||||
winners: payload.isWinner ? 1 : 0,
|
||||
breakPointsWon: payload.isBreakPoint ? 1 : 0,
|
||||
breakPointsTotal: payload.isBreakPoint ? 1 : 0,
|
||||
firstServeIn: payload.firstServeIn ? 1 : 0,
|
||||
firstServeAttempts: payload.firstServeAttempt ? 1 : 0,
|
||||
maxServeKph: clampNumber(payload.serveSpeedKph),
|
||||
longestRally: rallyCount,
|
||||
});
|
||||
}
|
||||
|
||||
if (payload.doubleFaultBy === "player_a" || payload.doubleFaultBy === "player_b") {
|
||||
metrics.players[payload.doubleFaultBy].doubleFaults += 1;
|
||||
}
|
||||
|
||||
if (payload.scoreboard) {
|
||||
const next = applyScoreSnapshot(score, payload.scoreboard, confidence);
|
||||
score.sets = next.sets;
|
||||
score.games = next.games;
|
||||
score.points = next.points;
|
||||
score.winnerSlot = next.winnerSlot;
|
||||
score.confidence = next.confidence;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "game": {
|
||||
if (event.winnerSlot) {
|
||||
score.games[event.winnerSlot] += 1;
|
||||
score.points.player_a = 0;
|
||||
score.points.player_b = 0;
|
||||
}
|
||||
if (payload.scoreboard) {
|
||||
const next = applyScoreSnapshot(score, payload.scoreboard, confidence);
|
||||
score.sets = next.sets;
|
||||
score.games = next.games;
|
||||
score.points = next.points;
|
||||
score.winnerSlot = next.winnerSlot;
|
||||
score.confidence = next.confidence;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "set": {
|
||||
if (event.winnerSlot) {
|
||||
score.sets[event.winnerSlot] += 1;
|
||||
}
|
||||
if (payload.scoreboard) {
|
||||
const next = applyScoreSnapshot(score, payload.scoreboard, confidence);
|
||||
score.sets = next.sets;
|
||||
score.games = next.games;
|
||||
score.points = next.points;
|
||||
score.winnerSlot = next.winnerSlot;
|
||||
score.confidence = next.confidence;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "metric": {
|
||||
const slot: MatchPlayerSlot | null = payload.playerSlot === "player_a" || payload.playerSlot === "player_b"
|
||||
? payload.playerSlot
|
||||
: event.winnerSlot ?? null;
|
||||
const metricMode = payload.metricMode === "delta" ? "delta" : "snapshot";
|
||||
if (slot) {
|
||||
const normalized = normalizePlayerMetrics(payload.metrics);
|
||||
if (metricMode === "delta") {
|
||||
addMetricPatch(metrics.players[slot], normalized);
|
||||
} else {
|
||||
mergeMetricSnapshot(metrics.players[slot], normalized);
|
||||
}
|
||||
}
|
||||
if (typeof payload.totalRallies === "number") {
|
||||
metrics.totalRallies = Math.max(metrics.totalRallies, payload.totalRallies);
|
||||
}
|
||||
if (typeof payload.longestRally === "number") {
|
||||
metrics.longestRally = Math.max(metrics.longestRally, payload.longestRally);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "score_suggestion":
|
||||
case "review_adjustment":
|
||||
case "finalized": {
|
||||
const next = applyScoreSnapshot(score, payload.score ?? payload.scoreboard ?? payload, confidence);
|
||||
score.sets = next.sets;
|
||||
score.games = next.games;
|
||||
score.points = next.points;
|
||||
score.winnerSlot = next.winnerSlot;
|
||||
score.confidence = event.eventType === "finalized" ? 1 : next.confidence;
|
||||
|
||||
const metricSnapshot = normalizeMatchMetrics(payload.metrics ?? payload.playerMetrics ?? payload);
|
||||
for (const slot of MATCH_PLAYER_SLOTS) {
|
||||
mergeMetricSnapshot(metrics.players[slot], metricSnapshot.players[slot]);
|
||||
}
|
||||
metrics.totalRallies = Math.max(metrics.totalRallies, metricSnapshot.totalRallies);
|
||||
metrics.longestRally = Math.max(metrics.longestRally, metricSnapshot.longestRally);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
metrics.sourceCount = sources.size;
|
||||
score.confidence = Math.max(score.confidence, ordered.length > 0 ? Math.min(0.98, 0.45 + ordered.length * 0.05) : 0);
|
||||
|
||||
return {
|
||||
score: applyWinnerFallback(score),
|
||||
metrics: finalizeMetrics(metrics),
|
||||
eventCount: ordered.length,
|
||||
sourceCount: sources.size,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildParticipantSettlement(scoreRaw: unknown, metricsRaw: unknown): MatchSettlement {
|
||||
const score = normalizeMatchScoreboard(scoreRaw);
|
||||
const metrics = finalizeMetrics(normalizeMatchMetrics(metricsRaw));
|
||||
|
||||
return {
|
||||
winnerSlot: score.winnerSlot,
|
||||
players: {
|
||||
player_a: {
|
||||
setsWon: score.sets.player_a,
|
||||
gamesWon: score.games.player_a,
|
||||
pointsWon: Math.max(score.points.player_a, metrics.players.player_a.pointsWon),
|
||||
isWinner: score.winnerSlot === "player_a",
|
||||
stats: metrics.players.player_a,
|
||||
},
|
||||
player_b: {
|
||||
setsWon: score.sets.player_b,
|
||||
gamesWon: score.games.player_b,
|
||||
pointsWon: Math.max(score.points.player_b, metrics.players.player_b.pointsWon),
|
||||
isWinner: score.winnerSlot === "player_b",
|
||||
stats: metrics.players.player_b,
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
totalRallies: metrics.totalRallies,
|
||||
longestRally: metrics.longestRally,
|
||||
},
|
||||
};
|
||||
}
|
||||
889
server/matchStore.ts
普通文件
889
server/matchStore.ts
普通文件
@@ -0,0 +1,889 @@
|
||||
import { and, asc, desc, eq, inArray, sql } from "drizzle-orm";
|
||||
import {
|
||||
matchParticipants,
|
||||
matchScoreEvents,
|
||||
matchSessions,
|
||||
users,
|
||||
type InsertMatchParticipant,
|
||||
type InsertMatchScoreEvent,
|
||||
type InsertMatchSession,
|
||||
} from "../drizzle/schema";
|
||||
import {
|
||||
createNotification,
|
||||
getDateKey,
|
||||
getDb,
|
||||
refreshAchievementsForUser,
|
||||
refreshUserTrainingSummary,
|
||||
upsertDailyTrainingAggregate,
|
||||
upsertTrainingRecordBySource,
|
||||
} from "./db";
|
||||
import {
|
||||
MATCH_PLAYER_SLOTS,
|
||||
buildParticipantSettlement,
|
||||
deriveSuggestedMatchState,
|
||||
normalizeMatchMetrics,
|
||||
normalizeMatchScoreboard,
|
||||
type MatchPlayerSlot,
|
||||
} from "./match";
|
||||
|
||||
type MatchMode = "daily" | "competitive";
|
||||
type WorkflowStatus = "draft" | "recording" | "review_pending" | "reviewed" | "finalizing" | "finalized" | "cancelled";
|
||||
type SuggestionStatus = "idle" | "queued" | "ready" | "failed";
|
||||
type CameraStatus = "pending" | "bound" | "active" | "completed" | "failed";
|
||||
type EventSource = "camera_a" | "camera_b" | "system" | "admin";
|
||||
type EventType = "point" | "game" | "set" | "metric" | "score_suggestion" | "review_adjustment" | "finalized";
|
||||
|
||||
type ParticipantRow = Awaited<ReturnType<typeof listMatchParticipants>>[number];
|
||||
|
||||
function orderByIds<T extends { id: number }>(rows: T[], ids: number[]) {
|
||||
const order = new Map(ids.map((id, index) => [id, index]));
|
||||
return [...rows].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||||
}
|
||||
|
||||
function indexBySlot(rows: ParticipantRow[]) {
|
||||
return {
|
||||
player_a: rows.find((row) => row.playerSlot === "player_a") ?? null,
|
||||
player_b: rows.find((row) => row.playerSlot === "player_b") ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadParticipantsMap(matchIds: number[]) {
|
||||
if (matchIds.length === 0) {
|
||||
return new Map<number, ParticipantRow[]>();
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) return new Map<number, ParticipantRow[]>();
|
||||
|
||||
const rows = await db.select({
|
||||
id: matchParticipants.id,
|
||||
matchId: matchParticipants.matchId,
|
||||
userId: matchParticipants.userId,
|
||||
userName: users.name,
|
||||
playerSlot: matchParticipants.playerSlot,
|
||||
cameraSlot: matchParticipants.cameraSlot,
|
||||
cameraStatus: matchParticipants.cameraStatus,
|
||||
cameraLabel: matchParticipants.cameraLabel,
|
||||
cameraVideoId: matchParticipants.cameraVideoId,
|
||||
cameraVideoUrl: matchParticipants.cameraVideoUrl,
|
||||
cameraSnapshot: matchParticipants.cameraSnapshot,
|
||||
isWinner: matchParticipants.isWinner,
|
||||
suggestedSetsWon: matchParticipants.suggestedSetsWon,
|
||||
suggestedGamesWon: matchParticipants.suggestedGamesWon,
|
||||
suggestedPointsWon: matchParticipants.suggestedPointsWon,
|
||||
finalSetsWon: matchParticipants.finalSetsWon,
|
||||
finalGamesWon: matchParticipants.finalGamesWon,
|
||||
finalPointsWon: matchParticipants.finalPointsWon,
|
||||
suggestedStats: matchParticipants.suggestedStats,
|
||||
finalStats: matchParticipants.finalStats,
|
||||
createdAt: matchParticipants.createdAt,
|
||||
updatedAt: matchParticipants.updatedAt,
|
||||
}).from(matchParticipants)
|
||||
.leftJoin(users, eq(users.id, matchParticipants.userId))
|
||||
.where(inArray(matchParticipants.matchId, matchIds))
|
||||
.orderBy(asc(matchParticipants.matchId), asc(matchParticipants.id));
|
||||
|
||||
const grouped = new Map<number, ParticipantRow[]>();
|
||||
for (const row of rows) {
|
||||
const list = grouped.get(row.matchId) ?? [];
|
||||
list.push(row);
|
||||
grouped.set(row.matchId, list);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
async function loadEventCounts(matchIds: number[]) {
|
||||
if (matchIds.length === 0) {
|
||||
return new Map<number, number>();
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) return new Map<number, number>();
|
||||
|
||||
const rows = await db.select({
|
||||
matchId: matchScoreEvents.matchId,
|
||||
}).from(matchScoreEvents).where(inArray(matchScoreEvents.matchId, matchIds));
|
||||
|
||||
const counts = new Map<number, number>();
|
||||
for (const row of rows) {
|
||||
counts.set(row.matchId, (counts.get(row.matchId) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
async function hydrateMatches(sessionIds: number[]) {
|
||||
const db = await getDb();
|
||||
if (!db || sessionIds.length === 0) return [];
|
||||
|
||||
const sessions = await db.select().from(matchSessions)
|
||||
.where(inArray(matchSessions.id, sessionIds));
|
||||
const participantsMap = await loadParticipantsMap(sessionIds);
|
||||
const eventCounts = await loadEventCounts(sessionIds);
|
||||
|
||||
return orderByIds(sessions, sessionIds).map((session) => ({
|
||||
...session,
|
||||
participants: participantsMap.get(session.id) ?? [],
|
||||
eventCount: eventCounts.get(session.id) ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getMatchSessionById(matchId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(matchSessions).where(eq(matchSessions.id, matchId)).limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function getMatchParticipantBySlot(matchId: number, playerSlot: MatchPlayerSlot) {
|
||||
const db = await getDb();
|
||||
if (!db) return undefined;
|
||||
const result = await db.select().from(matchParticipants)
|
||||
.where(and(eq(matchParticipants.matchId, matchId), eq(matchParticipants.playerSlot, playerSlot)))
|
||||
.limit(1);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
export async function listMatchParticipants(matchId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select({
|
||||
id: matchParticipants.id,
|
||||
matchId: matchParticipants.matchId,
|
||||
userId: matchParticipants.userId,
|
||||
userName: users.name,
|
||||
playerSlot: matchParticipants.playerSlot,
|
||||
cameraSlot: matchParticipants.cameraSlot,
|
||||
cameraStatus: matchParticipants.cameraStatus,
|
||||
cameraLabel: matchParticipants.cameraLabel,
|
||||
cameraVideoId: matchParticipants.cameraVideoId,
|
||||
cameraVideoUrl: matchParticipants.cameraVideoUrl,
|
||||
cameraSnapshot: matchParticipants.cameraSnapshot,
|
||||
isWinner: matchParticipants.isWinner,
|
||||
suggestedSetsWon: matchParticipants.suggestedSetsWon,
|
||||
suggestedGamesWon: matchParticipants.suggestedGamesWon,
|
||||
suggestedPointsWon: matchParticipants.suggestedPointsWon,
|
||||
finalSetsWon: matchParticipants.finalSetsWon,
|
||||
finalGamesWon: matchParticipants.finalGamesWon,
|
||||
finalPointsWon: matchParticipants.finalPointsWon,
|
||||
suggestedStats: matchParticipants.suggestedStats,
|
||||
finalStats: matchParticipants.finalStats,
|
||||
createdAt: matchParticipants.createdAt,
|
||||
updatedAt: matchParticipants.updatedAt,
|
||||
}).from(matchParticipants)
|
||||
.leftJoin(users, eq(users.id, matchParticipants.userId))
|
||||
.where(eq(matchParticipants.matchId, matchId))
|
||||
.orderBy(asc(matchParticipants.id));
|
||||
}
|
||||
|
||||
export async function listMatchScoreEvents(matchId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
return db.select().from(matchScoreEvents)
|
||||
.where(eq(matchScoreEvents.matchId, matchId))
|
||||
.orderBy(asc(matchScoreEvents.eventIndex), asc(matchScoreEvents.id));
|
||||
}
|
||||
|
||||
export async function getMatchDetail(matchId: number) {
|
||||
const session = await getMatchSessionById(matchId);
|
||||
if (!session) return undefined;
|
||||
const participants = await listMatchParticipants(matchId);
|
||||
const events = await listMatchScoreEvents(matchId);
|
||||
return {
|
||||
...session,
|
||||
participants,
|
||||
events,
|
||||
eventCount: events.length,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAccessibleMatchSessions(params: {
|
||||
viewerUserId: number;
|
||||
isAdmin: boolean;
|
||||
limit?: number;
|
||||
workflowStatus?: WorkflowStatus | "all";
|
||||
matchMode?: MatchMode | "all";
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const limit = params.limit ?? 50;
|
||||
const filters = [];
|
||||
|
||||
if (params.workflowStatus && params.workflowStatus !== "all") {
|
||||
filters.push(eq(matchSessions.workflowStatus, params.workflowStatus));
|
||||
}
|
||||
if (params.matchMode && params.matchMode !== "all") {
|
||||
filters.push(eq(matchSessions.matchMode, params.matchMode));
|
||||
}
|
||||
|
||||
if (params.isAdmin) {
|
||||
const sessions = await db.select({
|
||||
id: matchSessions.id,
|
||||
}).from(matchSessions)
|
||||
.where(filters.length > 0 ? and(...filters) : undefined)
|
||||
.orderBy(desc(matchSessions.updatedAt), desc(matchSessions.id))
|
||||
.limit(limit);
|
||||
return hydrateMatches(sessions.map((row) => row.id));
|
||||
}
|
||||
|
||||
const conditions = [
|
||||
eq(matchParticipants.userId, params.viewerUserId),
|
||||
...filters,
|
||||
];
|
||||
|
||||
const sessions = await db.select({
|
||||
id: matchSessions.id,
|
||||
}).from(matchParticipants)
|
||||
.innerJoin(matchSessions, eq(matchSessions.id, matchParticipants.matchId))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(matchSessions.updatedAt), desc(matchSessions.id))
|
||||
.limit(limit);
|
||||
|
||||
return hydrateMatches(sessions.map((row) => row.id));
|
||||
}
|
||||
|
||||
export async function createMatchSession(input: {
|
||||
createdByUserId: number;
|
||||
matchMode: MatchMode;
|
||||
title: string;
|
||||
courtName?: string | null;
|
||||
notes?: string | null;
|
||||
durationMinutes: number;
|
||||
scheduledAt?: Date | null;
|
||||
participantUserIds: [number, number];
|
||||
}) {
|
||||
if (input.participantUserIds[0] === input.participantUserIds[1]) {
|
||||
throw new Error("两位参赛用户必须不同");
|
||||
}
|
||||
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const matchId = await db.transaction(async (tx) => {
|
||||
const sessionValues: InsertMatchSession = {
|
||||
createdByUserId: input.createdByUserId,
|
||||
matchMode: input.matchMode,
|
||||
workflowStatus: "draft",
|
||||
title: input.title,
|
||||
courtName: input.courtName ?? null,
|
||||
notes: input.notes ?? null,
|
||||
durationMinutes: input.durationMinutes,
|
||||
scheduledAt: input.scheduledAt ?? null,
|
||||
suggestionStatus: "idle",
|
||||
suggestionTaskId: null,
|
||||
suggestedScore: null,
|
||||
suggestedMetrics: null,
|
||||
finalScore: null,
|
||||
finalMetrics: null,
|
||||
reviewNotes: null,
|
||||
reviewSubmittedAt: null,
|
||||
reviewedByUserId: null,
|
||||
reviewedAt: null,
|
||||
finalizedByUserId: null,
|
||||
finalizedAt: null,
|
||||
};
|
||||
|
||||
const inserted = await tx.insert(matchSessions).values(sessionValues);
|
||||
const createdMatchId = inserted[0].insertId;
|
||||
|
||||
const participantValues: InsertMatchParticipant[] = [
|
||||
{
|
||||
matchId: createdMatchId,
|
||||
userId: input.participantUserIds[0],
|
||||
playerSlot: "player_a",
|
||||
cameraSlot: "camera_a",
|
||||
cameraStatus: "pending",
|
||||
cameraLabel: null,
|
||||
cameraVideoId: null,
|
||||
cameraVideoUrl: null,
|
||||
cameraSnapshot: null,
|
||||
isWinner: 0,
|
||||
suggestedSetsWon: 0,
|
||||
suggestedGamesWon: 0,
|
||||
suggestedPointsWon: 0,
|
||||
finalSetsWon: 0,
|
||||
finalGamesWon: 0,
|
||||
finalPointsWon: 0,
|
||||
suggestedStats: null,
|
||||
finalStats: null,
|
||||
},
|
||||
{
|
||||
matchId: createdMatchId,
|
||||
userId: input.participantUserIds[1],
|
||||
playerSlot: "player_b",
|
||||
cameraSlot: "camera_b",
|
||||
cameraStatus: "pending",
|
||||
cameraLabel: null,
|
||||
cameraVideoId: null,
|
||||
cameraVideoUrl: null,
|
||||
cameraSnapshot: null,
|
||||
isWinner: 0,
|
||||
suggestedSetsWon: 0,
|
||||
suggestedGamesWon: 0,
|
||||
suggestedPointsWon: 0,
|
||||
finalSetsWon: 0,
|
||||
finalGamesWon: 0,
|
||||
finalPointsWon: 0,
|
||||
suggestedStats: null,
|
||||
finalStats: null,
|
||||
},
|
||||
];
|
||||
|
||||
await tx.insert(matchParticipants).values(participantValues);
|
||||
return createdMatchId;
|
||||
});
|
||||
|
||||
return getMatchDetail(matchId);
|
||||
}
|
||||
|
||||
export async function updateMatchSession(matchId: number, patch: Partial<InsertMatchSession>) {
|
||||
const db = await getDb();
|
||||
if (!db) return;
|
||||
await db.update(matchSessions).set(patch).where(eq(matchSessions.id, matchId));
|
||||
}
|
||||
|
||||
export async function bindMatchCamera(input: {
|
||||
matchId: number;
|
||||
playerSlot: MatchPlayerSlot;
|
||||
cameraStatus: CameraStatus;
|
||||
cameraLabel?: string | null;
|
||||
cameraVideoId?: number | null;
|
||||
cameraVideoUrl?: string | null;
|
||||
cameraSnapshot?: unknown;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const participant = await getMatchParticipantBySlot(input.matchId, input.playerSlot);
|
||||
if (!participant) {
|
||||
throw new Error("Match participant not found");
|
||||
}
|
||||
|
||||
await db.update(matchParticipants).set({
|
||||
cameraStatus: input.cameraStatus,
|
||||
cameraLabel: input.cameraLabel === undefined ? participant.cameraLabel : input.cameraLabel,
|
||||
cameraVideoId: input.cameraVideoId === undefined ? participant.cameraVideoId : input.cameraVideoId,
|
||||
cameraVideoUrl: input.cameraVideoUrl === undefined ? participant.cameraVideoUrl : input.cameraVideoUrl,
|
||||
cameraSnapshot: input.cameraSnapshot === undefined ? participant.cameraSnapshot : input.cameraSnapshot,
|
||||
}).where(eq(matchParticipants.id, participant.id));
|
||||
|
||||
const session = await getMatchSessionById(input.matchId);
|
||||
if (session?.workflowStatus === "draft" && input.cameraStatus !== "pending") {
|
||||
await updateMatchSession(input.matchId, {
|
||||
workflowStatus: "recording",
|
||||
});
|
||||
}
|
||||
|
||||
return getMatchDetail(input.matchId);
|
||||
}
|
||||
|
||||
async function getNextEventIndex(matchId: number) {
|
||||
const db = await getDb();
|
||||
if (!db) return 1;
|
||||
const result = await db.select({
|
||||
maxEventIndex: sql<number>`coalesce(max(${matchScoreEvents.eventIndex}), 0)`,
|
||||
}).from(matchScoreEvents).where(eq(matchScoreEvents.matchId, matchId));
|
||||
return (result[0]?.maxEventIndex ?? 0) + 1;
|
||||
}
|
||||
|
||||
export async function insertMatchScoreEvent(input: {
|
||||
matchId: number;
|
||||
source: EventSource;
|
||||
eventType: EventType;
|
||||
winnerSlot?: MatchPlayerSlot | null;
|
||||
matchSecond?: number | null;
|
||||
confidence?: number | null;
|
||||
payload?: unknown;
|
||||
createdByUserId?: number | null;
|
||||
}) {
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
const eventIndex = await getNextEventIndex(input.matchId);
|
||||
const values: InsertMatchScoreEvent = {
|
||||
matchId: input.matchId,
|
||||
eventIndex,
|
||||
source: input.source,
|
||||
eventType: input.eventType,
|
||||
winnerSlot: input.winnerSlot ?? null,
|
||||
matchSecond: input.matchSecond ?? null,
|
||||
confidence: input.confidence ?? null,
|
||||
payload: input.payload ?? null,
|
||||
createdByUserId: input.createdByUserId ?? null,
|
||||
};
|
||||
|
||||
const inserted = await db.insert(matchScoreEvents).values(values);
|
||||
const rows = await db.select().from(matchScoreEvents).where(eq(matchScoreEvents.id, inserted[0].insertId)).limit(1);
|
||||
|
||||
const session = await getMatchSessionById(input.matchId);
|
||||
if (session && session.workflowStatus === "draft") {
|
||||
await updateMatchSession(input.matchId, {
|
||||
workflowStatus: "recording",
|
||||
});
|
||||
}
|
||||
|
||||
return rows[0];
|
||||
}
|
||||
|
||||
export async function saveMatchSuggestion(matchId: number, params: {
|
||||
suggestionStatus: SuggestionStatus;
|
||||
suggestionTaskId?: string | null;
|
||||
suggestedScore?: unknown;
|
||||
suggestedMetrics?: unknown;
|
||||
}) {
|
||||
const session = await getMatchSessionById(matchId);
|
||||
if (!session) {
|
||||
throw new Error("Match session not found");
|
||||
}
|
||||
|
||||
const score = params.suggestedScore ? normalizeMatchScoreboard(params.suggestedScore) : normalizeMatchScoreboard(session.suggestedScore);
|
||||
const metrics = params.suggestedMetrics ? normalizeMatchMetrics(params.suggestedMetrics) : normalizeMatchMetrics(session.suggestedMetrics);
|
||||
const settlement = buildParticipantSettlement(score, metrics);
|
||||
const participants = await listMatchParticipants(matchId);
|
||||
|
||||
await updateMatchSession(matchId, {
|
||||
suggestionStatus: params.suggestionStatus,
|
||||
suggestionTaskId: params.suggestionTaskId === undefined ? session.suggestionTaskId : params.suggestionTaskId,
|
||||
suggestedScore: params.suggestedScore === undefined ? session.suggestedScore : score,
|
||||
suggestedMetrics: params.suggestedMetrics === undefined ? session.suggestedMetrics : metrics,
|
||||
workflowStatus: session.workflowStatus === "draft" || session.workflowStatus === "recording"
|
||||
? (params.suggestionStatus === "ready" ? "review_pending" : session.workflowStatus)
|
||||
: session.workflowStatus,
|
||||
});
|
||||
|
||||
const rowsBySlot = indexBySlot(participants);
|
||||
for (const slot of MATCH_PLAYER_SLOTS) {
|
||||
const row = rowsBySlot[slot];
|
||||
if (!row) continue;
|
||||
const player = settlement.players[slot];
|
||||
const dbConn = await getDb();
|
||||
if (!dbConn) continue;
|
||||
await dbConn.update(matchParticipants).set({
|
||||
suggestedSetsWon: player.setsWon,
|
||||
suggestedGamesWon: player.gamesWon,
|
||||
suggestedPointsWon: player.pointsWon,
|
||||
suggestedStats: player.stats,
|
||||
}).where(eq(matchParticipants.id, row.id));
|
||||
}
|
||||
|
||||
return getMatchDetail(matchId);
|
||||
}
|
||||
|
||||
export async function submitMatchReview(input: {
|
||||
matchId: number;
|
||||
reviewedByUserId: number;
|
||||
reviewNotes?: string | null;
|
||||
finalScore?: unknown;
|
||||
finalMetrics?: unknown;
|
||||
}) {
|
||||
const session = await getMatchSessionById(input.matchId);
|
||||
if (!session) {
|
||||
throw new Error("Match session not found");
|
||||
}
|
||||
|
||||
const score = normalizeMatchScoreboard(input.finalScore ?? session.suggestedScore);
|
||||
const metrics = normalizeMatchMetrics(input.finalMetrics ?? session.suggestedMetrics);
|
||||
const settlement = buildParticipantSettlement(score, metrics);
|
||||
const participants = await listMatchParticipants(input.matchId);
|
||||
const db = await getDb();
|
||||
if (!db) throw new Error("Database not available");
|
||||
|
||||
await updateMatchSession(input.matchId, {
|
||||
finalScore: score,
|
||||
finalMetrics: metrics,
|
||||
reviewNotes: input.reviewNotes ?? session.reviewNotes,
|
||||
reviewSubmittedAt: new Date(),
|
||||
reviewedByUserId: input.reviewedByUserId,
|
||||
reviewedAt: new Date(),
|
||||
workflowStatus: "reviewed",
|
||||
});
|
||||
|
||||
const rowsBySlot = indexBySlot(participants);
|
||||
for (const slot of MATCH_PLAYER_SLOTS) {
|
||||
const row = rowsBySlot[slot];
|
||||
if (!row) continue;
|
||||
const player = settlement.players[slot];
|
||||
await db.update(matchParticipants).set({
|
||||
isWinner: player.isWinner ? 1 : 0,
|
||||
finalSetsWon: player.setsWon,
|
||||
finalGamesWon: player.gamesWon,
|
||||
finalPointsWon: player.pointsWon,
|
||||
finalStats: player.stats,
|
||||
}).where(eq(matchParticipants.id, row.id));
|
||||
}
|
||||
|
||||
await insertMatchScoreEvent({
|
||||
matchId: input.matchId,
|
||||
source: "admin",
|
||||
eventType: "review_adjustment",
|
||||
winnerSlot: settlement.winnerSlot,
|
||||
confidence: 1,
|
||||
payload: {
|
||||
score,
|
||||
metrics,
|
||||
reviewNotes: input.reviewNotes ?? null,
|
||||
},
|
||||
createdByUserId: input.reviewedByUserId,
|
||||
});
|
||||
|
||||
return getMatchDetail(input.matchId);
|
||||
}
|
||||
|
||||
export async function markMatchSuggestionQueued(matchId: number, taskId: string) {
|
||||
const session = await getMatchSessionById(matchId);
|
||||
if (!session) {
|
||||
throw new Error("Match session not found");
|
||||
}
|
||||
await updateMatchSession(matchId, {
|
||||
suggestionStatus: "queued",
|
||||
suggestionTaskId: taskId,
|
||||
workflowStatus: session.workflowStatus === "draft" ? "recording" : session.workflowStatus,
|
||||
});
|
||||
}
|
||||
|
||||
export async function markMatchFinalizing(matchId: number) {
|
||||
const session = await getMatchSessionById(matchId);
|
||||
if (!session) {
|
||||
throw new Error("Match session not found");
|
||||
}
|
||||
await updateMatchSession(matchId, {
|
||||
workflowStatus: "finalizing",
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelMatchSession(matchId: number, notes?: string | null) {
|
||||
const session = await getMatchSessionById(matchId);
|
||||
if (!session) {
|
||||
throw new Error("Match session not found");
|
||||
}
|
||||
|
||||
await updateMatchSession(matchId, {
|
||||
workflowStatus: "cancelled",
|
||||
notes: notes === undefined ? session.notes : notes,
|
||||
});
|
||||
return getMatchDetail(matchId);
|
||||
}
|
||||
|
||||
export async function generateSuggestedMatchState(matchId: number) {
|
||||
const detail = await getMatchDetail(matchId);
|
||||
if (!detail) {
|
||||
throw new Error("Match session not found");
|
||||
}
|
||||
|
||||
const suggestion = deriveSuggestedMatchState(detail.events.map((event) => ({
|
||||
eventType: event.eventType,
|
||||
winnerSlot: event.winnerSlot,
|
||||
confidence: event.confidence,
|
||||
payload: event.payload,
|
||||
source: event.source,
|
||||
eventIndex: event.eventIndex,
|
||||
})));
|
||||
|
||||
await saveMatchSuggestion(matchId, {
|
||||
suggestionStatus: "ready",
|
||||
suggestionTaskId: detail.suggestionTaskId,
|
||||
suggestedScore: suggestion.score,
|
||||
suggestedMetrics: suggestion.metrics,
|
||||
});
|
||||
|
||||
await insertMatchScoreEvent({
|
||||
matchId,
|
||||
source: "system",
|
||||
eventType: "score_suggestion",
|
||||
winnerSlot: suggestion.score.winnerSlot,
|
||||
confidence: suggestion.score.confidence,
|
||||
payload: {
|
||||
score: suggestion.score,
|
||||
metrics: suggestion.metrics,
|
||||
eventCount: suggestion.eventCount,
|
||||
sourceCount: suggestion.sourceCount,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
score: suggestion.score,
|
||||
metrics: suggestion.metrics,
|
||||
eventCount: suggestion.eventCount,
|
||||
sourceCount: suggestion.sourceCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function finalizeMatchSettlement(matchId: number, finalizedByUserId: number) {
|
||||
const detail = await getMatchDetail(matchId);
|
||||
if (!detail) {
|
||||
throw new Error("Match session not found");
|
||||
}
|
||||
|
||||
if (detail.workflowStatus === "finalized") {
|
||||
return detail;
|
||||
}
|
||||
|
||||
if (detail.workflowStatus === "cancelled") {
|
||||
throw new Error("已取消的比赛不能结算");
|
||||
}
|
||||
|
||||
const score = normalizeMatchScoreboard(detail.finalScore ?? detail.suggestedScore);
|
||||
const metrics = normalizeMatchMetrics(detail.finalMetrics ?? detail.suggestedMetrics);
|
||||
const settlement = buildParticipantSettlement(score, metrics);
|
||||
const occurredAt = detail.endedAt ?? detail.reviewSubmittedAt ?? detail.startedAt ?? detail.scheduledAt ?? new Date();
|
||||
const trainingDateKey = getDateKey(occurredAt);
|
||||
const durationMinutes = Math.max(1, detail.durationMinutes || 90);
|
||||
const participantsBySlot = indexBySlot(detail.participants);
|
||||
const db = await getDb();
|
||||
if (!db) {
|
||||
throw new Error("Database not available");
|
||||
}
|
||||
|
||||
await updateMatchSession(matchId, {
|
||||
finalScore: score,
|
||||
finalMetrics: metrics,
|
||||
workflowStatus: "finalizing",
|
||||
reviewedByUserId: detail.reviewedByUserId ?? finalizedByUserId,
|
||||
reviewedAt: detail.reviewedAt ?? new Date(),
|
||||
reviewSubmittedAt: detail.reviewSubmittedAt ?? new Date(),
|
||||
});
|
||||
|
||||
for (const slot of MATCH_PLAYER_SLOTS) {
|
||||
const participant = participantsBySlot[slot];
|
||||
if (!participant) continue;
|
||||
|
||||
const opponent = MATCH_PLAYER_SLOTS
|
||||
.filter((item) => item !== slot)
|
||||
.map((item) => participantsBySlot[item])
|
||||
.find(Boolean) ?? null;
|
||||
const player = settlement.players[slot];
|
||||
const matchLabel = detail.matchMode === "competitive" ? "竞赛双人比赛" : "日常双人比赛";
|
||||
const sourceType = detail.matchMode === "competitive" ? "match_competitive" : "match_daily";
|
||||
const resultLabel = settlement.winnerSlot == null ? "已确认" : player.isWinner ? "获胜" : "失利";
|
||||
|
||||
await db.update(matchParticipants).set({
|
||||
isWinner: player.isWinner ? 1 : 0,
|
||||
finalSetsWon: player.setsWon,
|
||||
finalGamesWon: player.gamesWon,
|
||||
finalPointsWon: player.pointsWon,
|
||||
finalStats: player.stats,
|
||||
cameraStatus: participant.cameraStatus === "active" ? "completed" : participant.cameraStatus,
|
||||
}).where(eq(matchParticipants.id, participant.id));
|
||||
|
||||
const upsertResult = await upsertTrainingRecordBySource({
|
||||
userId: participant.userId,
|
||||
planId: null,
|
||||
linkedPlanId: null,
|
||||
matchConfidence: detail.suggestionStatus === "ready" ? score.confidence : null,
|
||||
exerciseName: matchLabel,
|
||||
exerciseType: "match",
|
||||
sourceType,
|
||||
sourceId: `${sourceType}:${matchId}:${slot}`,
|
||||
videoId: participant.cameraVideoId ?? null,
|
||||
actionCount: player.pointsWon,
|
||||
durationMinutes,
|
||||
completed: 1,
|
||||
notes: `${detail.title} · ${resultLabel}`,
|
||||
poseScore: null,
|
||||
trainingDate: occurredAt,
|
||||
metadata: {
|
||||
matchId,
|
||||
matchMode: detail.matchMode,
|
||||
playerSlot: slot,
|
||||
opponentUserId: opponent?.userId ?? null,
|
||||
winnerSlot: settlement.winnerSlot,
|
||||
finalScore: score,
|
||||
finalMetrics: metrics,
|
||||
courtName: detail.courtName ?? null,
|
||||
cameraStatus: participant.cameraStatus,
|
||||
},
|
||||
});
|
||||
|
||||
if (detail.matchMode === "daily" && upsertResult.isNew) {
|
||||
await upsertDailyTrainingAggregate({
|
||||
userId: participant.userId,
|
||||
trainingDate: trainingDateKey,
|
||||
deltaMinutes: durationMinutes,
|
||||
deltaSessions: 1,
|
||||
deltaTotalActions: player.pointsWon,
|
||||
deltaEffectiveActions: player.pointsWon,
|
||||
metadata: {
|
||||
latestDailyMatchId: matchId,
|
||||
latestDailyMatchResult: resultLabel,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (detail.matchMode === "competitive") {
|
||||
await refreshUserTrainingSummary(participant.userId);
|
||||
}
|
||||
|
||||
await createNotification({
|
||||
userId: participant.userId,
|
||||
notificationType: "match_settlement",
|
||||
title: `比赛已入库 · ${detail.title}`.slice(0, 256),
|
||||
message: `${matchLabel} 已完成审核入库,结果:${resultLabel},比分 ${player.setsWon}:${settlement.players[slot === "player_a" ? "player_b" : "player_a"].setsWon} 盘 / ${player.gamesWon}:${settlement.players[slot === "player_a" ? "player_b" : "player_a"].gamesWon} 局。`,
|
||||
isRead: 0,
|
||||
});
|
||||
|
||||
await refreshAchievementsForUser(participant.userId);
|
||||
}
|
||||
|
||||
await insertMatchScoreEvent({
|
||||
matchId,
|
||||
source: "admin",
|
||||
eventType: "finalized",
|
||||
winnerSlot: settlement.winnerSlot,
|
||||
confidence: 1,
|
||||
payload: {
|
||||
score,
|
||||
metrics,
|
||||
finalizedByUserId,
|
||||
finalizedAt: new Date().toISOString(),
|
||||
},
|
||||
createdByUserId: finalizedByUserId,
|
||||
});
|
||||
|
||||
await updateMatchSession(matchId, {
|
||||
workflowStatus: "finalized",
|
||||
finalizedByUserId,
|
||||
finalizedAt: new Date(),
|
||||
});
|
||||
|
||||
return getMatchDetail(matchId);
|
||||
}
|
||||
|
||||
export async function getAccessibleMatchSummary(params: {
|
||||
viewerUserId: number;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const rows = await listAccessibleMatchSessions({
|
||||
viewerUserId: params.viewerUserId,
|
||||
isAdmin: params.isAdmin,
|
||||
limit: 200,
|
||||
});
|
||||
|
||||
let competitiveWins = 0;
|
||||
let camerasBound = 0;
|
||||
|
||||
for (const match of rows) {
|
||||
for (const participant of match.participants) {
|
||||
if (participant.cameraStatus !== "pending") {
|
||||
camerasBound += 1;
|
||||
}
|
||||
if (!params.isAdmin && participant.userId !== params.viewerUserId) {
|
||||
continue;
|
||||
}
|
||||
if (match.matchMode === "competitive" && match.workflowStatus === "finalized" && participant.isWinner === 1) {
|
||||
competitiveWins += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
total: rows.length,
|
||||
draft: rows.filter((row) => row.workflowStatus === "draft").length,
|
||||
reviewPending: rows.filter((row) => row.workflowStatus === "review_pending" || row.workflowStatus === "reviewed").length,
|
||||
finalized: rows.filter((row) => row.workflowStatus === "finalized").length,
|
||||
daily: rows.filter((row) => row.matchMode === "daily").length,
|
||||
competitive: rows.filter((row) => row.matchMode === "competitive").length,
|
||||
competitiveWins,
|
||||
camerasBound,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCompetitiveLeaderboard(sortBy: "wins" | "winRate" | "setsWon" | "pointsWon" | "matches" = "wins", limit = 50) {
|
||||
const db = await getDb();
|
||||
if (!db) return [];
|
||||
|
||||
const rows = await db.select({
|
||||
matchId: matchParticipants.matchId,
|
||||
userId: matchParticipants.userId,
|
||||
userName: users.name,
|
||||
skillLevel: users.skillLevel,
|
||||
ntrpRating: users.ntrpRating,
|
||||
isWinner: matchParticipants.isWinner,
|
||||
finalSetsWon: matchParticipants.finalSetsWon,
|
||||
finalGamesWon: matchParticipants.finalGamesWon,
|
||||
finalPointsWon: matchParticipants.finalPointsWon,
|
||||
}).from(matchParticipants)
|
||||
.innerJoin(matchSessions, eq(matchSessions.id, matchParticipants.matchId))
|
||||
.innerJoin(users, eq(users.id, matchParticipants.userId))
|
||||
.where(and(
|
||||
eq(matchSessions.matchMode, "competitive"),
|
||||
eq(matchSessions.workflowStatus, "finalized"),
|
||||
))
|
||||
.orderBy(desc(matchParticipants.matchId), asc(matchParticipants.id));
|
||||
|
||||
const matchRows = new Map<number, typeof rows>();
|
||||
for (const row of rows) {
|
||||
const list = matchRows.get(row.matchId) ?? [];
|
||||
list.push(row);
|
||||
matchRows.set(row.matchId, list);
|
||||
}
|
||||
|
||||
const leaderboard = new Map<number, {
|
||||
id: number;
|
||||
name: string | null;
|
||||
ntrpRating: number | null;
|
||||
skillLevel: string | null;
|
||||
matches: number;
|
||||
wins: number;
|
||||
losses: number;
|
||||
winRate: number;
|
||||
setsWon: number;
|
||||
setsLost: number;
|
||||
gamesWon: number;
|
||||
gamesLost: number;
|
||||
pointsWon: number;
|
||||
pointsLost: number;
|
||||
}>();
|
||||
|
||||
for (const rowsOfMatch of Array.from(matchRows.values())) {
|
||||
const byUser = rowsOfMatch;
|
||||
for (const row of byUser) {
|
||||
const opponent = byUser.find((item: (typeof rows)[number]) => item.userId !== row.userId) ?? null;
|
||||
const current = leaderboard.get(row.userId) ?? {
|
||||
id: row.userId,
|
||||
name: row.userName ?? null,
|
||||
ntrpRating: row.ntrpRating ?? 1.5,
|
||||
skillLevel: row.skillLevel ?? "beginner",
|
||||
matches: 0,
|
||||
wins: 0,
|
||||
losses: 0,
|
||||
winRate: 0,
|
||||
setsWon: 0,
|
||||
setsLost: 0,
|
||||
gamesWon: 0,
|
||||
gamesLost: 0,
|
||||
pointsWon: 0,
|
||||
pointsLost: 0,
|
||||
};
|
||||
|
||||
current.matches += 1;
|
||||
current.wins += row.isWinner === 1 ? 1 : 0;
|
||||
current.losses += row.isWinner === 1 ? 0 : 1;
|
||||
current.setsWon += row.finalSetsWon || 0;
|
||||
current.setsLost += opponent?.finalSetsWon || 0;
|
||||
current.gamesWon += row.finalGamesWon || 0;
|
||||
current.gamesLost += opponent?.finalGamesWon || 0;
|
||||
current.pointsWon += row.finalPointsWon || 0;
|
||||
current.pointsLost += opponent?.finalPointsWon || 0;
|
||||
current.winRate = current.matches > 0 ? Math.round((current.wins / current.matches) * 1000) / 10 : 0;
|
||||
|
||||
leaderboard.set(row.userId, current);
|
||||
}
|
||||
}
|
||||
|
||||
const sorted = Array.from(leaderboard.values()).sort((a, b) => {
|
||||
const primary = {
|
||||
wins: [b.wins - a.wins, b.winRate - a.winRate, b.setsWon - a.setsWon, b.pointsWon - a.pointsWon],
|
||||
winRate: [b.winRate - a.winRate, b.wins - a.wins, b.setsWon - a.setsWon, b.pointsWon - a.pointsWon],
|
||||
setsWon: [b.setsWon - a.setsWon, b.wins - a.wins, b.winRate - a.winRate, b.pointsWon - a.pointsWon],
|
||||
pointsWon: [b.pointsWon - a.pointsWon, b.wins - a.wins, b.winRate - a.winRate, b.setsWon - a.setsWon],
|
||||
matches: [b.matches - a.matches, b.wins - a.wins, b.winRate - a.winRate, b.pointsWon - a.pointsWon],
|
||||
}[sortBy];
|
||||
|
||||
for (const value of primary) {
|
||||
if (value !== 0) return value;
|
||||
}
|
||||
return a.id - b.id;
|
||||
});
|
||||
|
||||
return sorted.slice(0, limit);
|
||||
}
|
||||
@@ -17,6 +17,20 @@ type RecentAnalysis = {
|
||||
fluidityScore: number | null;
|
||||
};
|
||||
|
||||
type TrainingAssessmentSnapshot = {
|
||||
heightCm: number | null;
|
||||
weightKg: number | null;
|
||||
sprintSpeedScore: number | null;
|
||||
explosivePowerScore: number | null;
|
||||
agilityScore: number | null;
|
||||
enduranceScore: number | null;
|
||||
flexibilityScore: number | null;
|
||||
coreStabilityScore: number | null;
|
||||
shoulderMobilityScore: number | null;
|
||||
hipMobilityScore: number | null;
|
||||
assessmentNotes: string | null;
|
||||
};
|
||||
|
||||
function skillLevelLabel(skillLevel: "beginner" | "intermediate" | "advanced") {
|
||||
switch (skillLevel) {
|
||||
case "intermediate":
|
||||
@@ -33,17 +47,43 @@ export function buildTrainingPlanPrompt(input: {
|
||||
durationDays: number;
|
||||
focusAreas?: string[];
|
||||
recentScores: RecentScore[];
|
||||
effectiveNtrpRating: number;
|
||||
ntrpSource: "system" | "manual" | "default";
|
||||
assessmentSnapshot: TrainingAssessmentSnapshot;
|
||||
}) {
|
||||
const assessmentLines = [
|
||||
`- 身高:${input.assessmentSnapshot.heightCm ?? "未知"} cm`,
|
||||
`- 体重:${input.assessmentSnapshot.weightKg ?? "未知"} kg`,
|
||||
`- 速度:${input.assessmentSnapshot.sprintSpeedScore ?? "未知"}/5`,
|
||||
`- 爆发力:${input.assessmentSnapshot.explosivePowerScore ?? "未知"}/5`,
|
||||
`- 敏捷性:${input.assessmentSnapshot.agilityScore ?? "未知"}/5`,
|
||||
`- 耐力:${input.assessmentSnapshot.enduranceScore ?? "未知"}/5`,
|
||||
`- 柔韧性:${input.assessmentSnapshot.flexibilityScore ?? "未知"}/5`,
|
||||
`- 核心稳定性:${input.assessmentSnapshot.coreStabilityScore ?? "未知"}/5`,
|
||||
`- 肩部灵活性:${input.assessmentSnapshot.shoulderMobilityScore ?? "未知"}/5`,
|
||||
`- 髋部灵活性:${input.assessmentSnapshot.hipMobilityScore ?? "未知"}/5`,
|
||||
input.assessmentSnapshot.assessmentNotes?.trim()
|
||||
? `- 额外备注:${input.assessmentSnapshot.assessmentNotes.trim()}`
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return [
|
||||
`你是一位专业网球教练。请为一位${skillLevelLabel(input.skillLevel)}水平的网球学员生成 ${input.durationDays} 天训练计划。`,
|
||||
"训练条件与要求:",
|
||||
"- 训练以个人可执行为主,可使用球拍、弹力带、标志盘、墙面等常见器材。",
|
||||
"- 每天训练 30-60 分钟,结构要清晰:热身、专项、脚步、力量/稳定、放松。",
|
||||
"- 输出内容要适合直接执行,不写空话,不写营销语,不写额外说明。",
|
||||
"- 必须返回合法 JSON,title 不能为空,exercises 数组不能为空。",
|
||||
`- exercises 总数至少 ${Math.max(input.durationDays * 3, 6)} 项,每一天至少 3 个训练项。`,
|
||||
"- 每个训练项都必须包含 day、name、category、duration、description、tips、sets、reps。",
|
||||
input.focusAreas?.length ? `- 重点关注:${input.focusAreas.join("、")}` : "- 如未指定重点,请自动平衡技术、脚步和体能。",
|
||||
`- 当前 NTRP 参考值:${input.effectiveNtrpRating.toFixed(1)}(来源:${input.ntrpSource === "system" ? "系统判定" : input.ntrpSource === "manual" ? "人工基线" : "默认基线"})`,
|
||||
"用户当前训练档案:",
|
||||
...assessmentLines,
|
||||
input.recentScores.length > 0
|
||||
? `- 用户最近分析摘要:${JSON.stringify(input.recentScores)}`
|
||||
: "- 暂无历史分析数据,请基于该水平的常见薄弱项设计。",
|
||||
"请根据身体指标调整训练强度和训练比重:速度/敏捷不足时增加脚步与启动训练,核心或灵活性不足时增加稳定性与活动度训练。",
|
||||
"每个训练项都要给出目标、动作描述、组次/次数、关键提示,避免重复堆砌。",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
@@ -9,13 +9,27 @@ import { ENV } from "./_core/env";
|
||||
import { storagePut } from "./storage";
|
||||
import * as db from "./db";
|
||||
import { nanoid } from "nanoid";
|
||||
import { deriveWatchRuleTitle, MARKET_SOURCES, maskWebhookUrl } from "./market";
|
||||
import * as matchStore from "./matchStore";
|
||||
import { prepareCorrectionImageUrls } from "./taskWorker";
|
||||
import { toPublicUrl } from "./publicUrl";
|
||||
import { ACTION_LABELS, refreshUserNtrp, syncAnalysisTrainingData, syncLiveTrainingData } from "./trainingAutomation";
|
||||
|
||||
async function enqueueTask(params: {
|
||||
userId: number;
|
||||
type: "media_finalize" | "training_plan_generate" | "training_plan_adjust" | "analysis_corrections" | "pose_correction_multimodal" | "ntrp_refresh_user" | "ntrp_refresh_all";
|
||||
type:
|
||||
| "media_finalize"
|
||||
| "training_plan_generate"
|
||||
| "training_plan_adjust"
|
||||
| "analysis_corrections"
|
||||
| "pose_correction_multimodal"
|
||||
| "ntrp_refresh_user"
|
||||
| "ntrp_refresh_all"
|
||||
| "market_source_sync"
|
||||
| "market_watch_refresh"
|
||||
| "market_push_delivery"
|
||||
| "match_score_suggest"
|
||||
| "match_finalize";
|
||||
title: string;
|
||||
payload: Record<string, unknown>;
|
||||
message: string;
|
||||
@@ -97,6 +111,73 @@ const liveRuntimeSnapshotSchema = z.object({
|
||||
})).optional(),
|
||||
}).passthrough();
|
||||
|
||||
const marketSourceSchema = z.enum(MARKET_SOURCES);
|
||||
const marketCategorySchema = z.enum(["adult", "junior", "competitive", "recreational", "unknown"]);
|
||||
const marketWatchRuleInputSchema = z.object({
|
||||
title: z.string().trim().max(256).optional(),
|
||||
brand: z.string().trim().min(1).max(64),
|
||||
modelKeyword: z.string().trim().max(128).optional(),
|
||||
seriesKeyword: z.string().trim().max(128).optional(),
|
||||
category: marketCategorySchema.optional(),
|
||||
weightMinGram: z.number().min(200).max(340).optional(),
|
||||
weightMaxGram: z.number().min(200).max(340).optional(),
|
||||
targetPrice: z.number().min(1).max(100000),
|
||||
pushEnabled: z.boolean().default(true),
|
||||
});
|
||||
|
||||
const matchModeSchema = z.enum(["daily", "competitive"]);
|
||||
const matchWorkflowStatusSchema = z.enum(["draft", "recording", "review_pending", "reviewed", "finalizing", "finalized", "cancelled"]);
|
||||
const matchPlayerSlotSchema = z.enum(["player_a", "player_b"]);
|
||||
const matchCameraStatusSchema = z.enum(["pending", "bound", "active", "completed", "failed"]);
|
||||
const matchEventSourceSchema = z.enum(["camera_a", "camera_b", "system", "admin"]);
|
||||
const matchEventTypeSchema = z.enum(["point", "game", "set", "metric", "score_suggestion", "review_adjustment", "finalized"]);
|
||||
const leaderboardScopeSchema = z.enum(["training", "competitive"]);
|
||||
const leaderboardSortSchema = z.enum(["ntrpRating", "totalMinutes", "totalSessions", "totalShots", "wins", "winRate", "setsWon", "pointsWon", "matches"]);
|
||||
|
||||
const matchScorePayloadSchema = z.object({
|
||||
sets: z.object({
|
||||
player_a: z.number().min(0),
|
||||
player_b: z.number().min(0),
|
||||
}),
|
||||
games: z.object({
|
||||
player_a: z.number().min(0),
|
||||
player_b: z.number().min(0),
|
||||
}),
|
||||
points: z.object({
|
||||
player_a: z.number().min(0),
|
||||
player_b: z.number().min(0),
|
||||
}),
|
||||
winnerSlot: matchPlayerSlotSchema.nullable().optional(),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
});
|
||||
|
||||
const matchPlayerMetricsPayloadSchema = z.object({
|
||||
pointsWon: z.number().min(0).optional(),
|
||||
aces: z.number().min(0).optional(),
|
||||
doubleFaults: z.number().min(0).optional(),
|
||||
winners: z.number().min(0).optional(),
|
||||
unforcedErrors: z.number().min(0).optional(),
|
||||
breakPointsWon: z.number().min(0).optional(),
|
||||
breakPointsTotal: z.number().min(0).optional(),
|
||||
firstServeIn: z.number().min(0).optional(),
|
||||
firstServeAttempts: z.number().min(0).optional(),
|
||||
firstServePct: z.number().min(0).optional(),
|
||||
maxServeKph: z.number().min(0).optional(),
|
||||
longestRally: z.number().min(0).optional(),
|
||||
});
|
||||
|
||||
const matchMetricsPayloadSchema = z.object({
|
||||
players: z.object({
|
||||
player_a: matchPlayerMetricsPayloadSchema.optional(),
|
||||
player_b: matchPlayerMetricsPayloadSchema.optional(),
|
||||
}).optional(),
|
||||
player_a: matchPlayerMetricsPayloadSchema.optional(),
|
||||
player_b: matchPlayerMetricsPayloadSchema.optional(),
|
||||
totalRallies: z.number().min(0).optional(),
|
||||
longestRally: z.number().min(0).optional(),
|
||||
sourceCount: z.number().min(0).optional(),
|
||||
});
|
||||
|
||||
function getRuntimeOwnerSid(ctx: { sessionSid: string | null; user: { openId: string } }) {
|
||||
return ctx.sessionSid || `legacy:${ctx.user.openId}`;
|
||||
}
|
||||
@@ -134,6 +215,53 @@ async function resolveLiveRuntimeRole(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function getAccessibleMatchDetailOrThrow(ctx: { user: { id: number; role: string } }, matchId: number) {
|
||||
const detail = await matchStore.getMatchDetail(matchId);
|
||||
if (!detail) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "比赛记录不存在" });
|
||||
}
|
||||
|
||||
if (ctx.user.role === "admin") {
|
||||
return detail;
|
||||
}
|
||||
|
||||
const isParticipant = detail.participants.some((participant) => participant.userId === ctx.user.id);
|
||||
if (!isParticipant) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "当前账号不能访问这场比赛" });
|
||||
}
|
||||
|
||||
return detail;
|
||||
}
|
||||
|
||||
async function enqueueMatchSuggestionIfNeeded(params: {
|
||||
actorUserId: number;
|
||||
matchId: number;
|
||||
title: string;
|
||||
}) {
|
||||
const session = await matchStore.getMatchSessionById(params.matchId);
|
||||
if (!session) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "比赛记录不存在" });
|
||||
}
|
||||
|
||||
if (session.suggestionStatus === "queued" && session.suggestionTaskId) {
|
||||
const existingTask = await db.getBackgroundTaskById(session.suggestionTaskId);
|
||||
if (existingTask && (existingTask.status === "queued" || existingTask.status === "running")) {
|
||||
return { taskId: existingTask.id, task: existingTask, deduped: true };
|
||||
}
|
||||
}
|
||||
|
||||
const task = await enqueueTask({
|
||||
userId: params.actorUserId,
|
||||
type: "match_score_suggest",
|
||||
title: `${params.title} 自动计分建议`,
|
||||
message: "比赛自动计分建议已加入后台队列",
|
||||
payload: { matchId: params.matchId },
|
||||
});
|
||||
|
||||
await matchStore.markMatchSuggestionQueued(params.matchId, task.taskId);
|
||||
return { ...task, deduped: false };
|
||||
}
|
||||
|
||||
export const appRouter = router({
|
||||
system: systemRouter,
|
||||
|
||||
@@ -860,6 +988,278 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
|
||||
match: router({
|
||||
stats: protectedProcedure.query(async ({ ctx }) => {
|
||||
return matchStore.getAccessibleMatchSummary({
|
||||
viewerUserId: ctx.user.id,
|
||||
isAdmin: ctx.user.role === "admin",
|
||||
});
|
||||
}),
|
||||
|
||||
list: protectedProcedure
|
||||
.input(z.object({
|
||||
limit: z.number().min(1).max(100).default(50).optional(),
|
||||
workflowStatus: matchWorkflowStatusSchema.or(z.literal("all")).optional(),
|
||||
matchMode: matchModeSchema.or(z.literal("all")).optional(),
|
||||
}).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
return matchStore.listAccessibleMatchSessions({
|
||||
viewerUserId: ctx.user.id,
|
||||
isAdmin: ctx.user.role === "admin",
|
||||
limit: input?.limit ?? 50,
|
||||
workflowStatus: input?.workflowStatus ?? "all",
|
||||
matchMode: input?.matchMode ?? "all",
|
||||
});
|
||||
}),
|
||||
|
||||
get: protectedProcedure
|
||||
.input(z.object({ matchId: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
return getAccessibleMatchDetailOrThrow(ctx, input.matchId);
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(z.object({
|
||||
title: z.string().trim().min(1).max(256),
|
||||
matchMode: matchModeSchema.default("daily"),
|
||||
courtName: z.string().trim().max(128).optional(),
|
||||
notes: z.string().trim().max(2000).optional(),
|
||||
durationMinutes: z.number().min(10).max(600).default(90),
|
||||
scheduledAt: z.number().optional(),
|
||||
playerAUserId: z.number(),
|
||||
playerBUserId: z.number(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.playerAUserId === input.playerBUserId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "两位参赛用户不能相同" });
|
||||
}
|
||||
|
||||
const playerA = await db.getUserById(input.playerAUserId);
|
||||
const playerB = await db.getUserById(input.playerBUserId);
|
||||
if (!playerA || !playerB) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "参赛用户不存在" });
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "admin" && ctx.user.id !== input.playerAUserId && ctx.user.id !== input.playerBUserId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "只能创建包含自己的比赛" });
|
||||
}
|
||||
|
||||
const detail = await matchStore.createMatchSession({
|
||||
createdByUserId: ctx.user.id,
|
||||
matchMode: input.matchMode,
|
||||
title: input.title,
|
||||
courtName: input.courtName?.trim() || null,
|
||||
notes: input.notes?.trim() || null,
|
||||
durationMinutes: input.durationMinutes,
|
||||
scheduledAt: input.scheduledAt ? new Date(input.scheduledAt) : null,
|
||||
participantUserIds: [input.playerAUserId, input.playerBUserId],
|
||||
});
|
||||
|
||||
if (ctx.user.role === "admin") {
|
||||
await auditAdminAction({
|
||||
adminUserId: ctx.user.id,
|
||||
actionType: "match_create",
|
||||
entityType: "match_session",
|
||||
entityId: String(detail?.id ?? ""),
|
||||
payload: {
|
||||
matchMode: input.matchMode,
|
||||
playerAUserId: input.playerAUserId,
|
||||
playerBUserId: input.playerBUserId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return detail;
|
||||
}),
|
||||
|
||||
bindCamera: protectedProcedure
|
||||
.input(z.object({
|
||||
matchId: z.number(),
|
||||
playerSlot: matchPlayerSlotSchema,
|
||||
cameraStatus: matchCameraStatusSchema.default("bound"),
|
||||
cameraLabel: z.string().trim().max(128).optional(),
|
||||
cameraVideoId: z.number().optional(),
|
||||
cameraVideoUrl: z.string().trim().min(1).optional(),
|
||||
cameraSnapshot: z.any().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const detail = await getAccessibleMatchDetailOrThrow(ctx, input.matchId);
|
||||
const participant = detail.participants.find((item) => item.playerSlot === input.playerSlot);
|
||||
if (!participant) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "参赛席位不存在" });
|
||||
}
|
||||
if (ctx.user.role !== "admin" && participant.userId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "只能绑定自己的机位" });
|
||||
}
|
||||
|
||||
return matchStore.bindMatchCamera({
|
||||
matchId: input.matchId,
|
||||
playerSlot: input.playerSlot,
|
||||
cameraStatus: input.cameraStatus,
|
||||
cameraLabel: input.cameraLabel?.trim() || null,
|
||||
cameraVideoId: input.cameraVideoId ?? null,
|
||||
cameraVideoUrl: input.cameraVideoUrl?.trim() || null,
|
||||
cameraSnapshot: input.cameraSnapshot,
|
||||
});
|
||||
}),
|
||||
|
||||
appendEvent: protectedProcedure
|
||||
.input(z.object({
|
||||
matchId: z.number(),
|
||||
source: matchEventSourceSchema,
|
||||
eventType: matchEventTypeSchema,
|
||||
winnerSlot: matchPlayerSlotSchema.nullable().optional(),
|
||||
matchSecond: z.number().min(0).optional(),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
payload: z.any().optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const detail = await getAccessibleMatchDetailOrThrow(ctx, input.matchId);
|
||||
if (detail.workflowStatus === "finalized" || detail.workflowStatus === "cancelled") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "当前比赛状态不能继续追加事件" });
|
||||
}
|
||||
|
||||
if (ctx.user.role !== "admin" && input.source === "admin") {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "只有管理员可以写入后台人工事件" });
|
||||
}
|
||||
|
||||
const event = await matchStore.insertMatchScoreEvent({
|
||||
matchId: input.matchId,
|
||||
source: input.source,
|
||||
eventType: input.eventType,
|
||||
winnerSlot: input.winnerSlot ?? null,
|
||||
matchSecond: input.matchSecond ?? null,
|
||||
confidence: input.confidence ?? null,
|
||||
payload: input.payload ?? null,
|
||||
createdByUserId: ctx.user.id,
|
||||
});
|
||||
|
||||
const suggestionTask = await enqueueMatchSuggestionIfNeeded({
|
||||
actorUserId: ctx.user.id,
|
||||
matchId: input.matchId,
|
||||
title: detail.title,
|
||||
});
|
||||
|
||||
return { event, suggestionTask };
|
||||
}),
|
||||
|
||||
requestSuggestion: protectedProcedure
|
||||
.input(z.object({ matchId: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const detail = await getAccessibleMatchDetailOrThrow(ctx, input.matchId);
|
||||
return enqueueMatchSuggestionIfNeeded({
|
||||
actorUserId: ctx.user.id,
|
||||
matchId: input.matchId,
|
||||
title: detail.title,
|
||||
});
|
||||
}),
|
||||
|
||||
reviewSubmit: adminProcedure
|
||||
.input(z.object({
|
||||
matchId: z.number(),
|
||||
reviewNotes: z.string().trim().max(4000).optional(),
|
||||
finalScore: matchScorePayloadSchema.optional(),
|
||||
finalMetrics: matchMetricsPayloadSchema.optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const detail = await matchStore.getMatchDetail(input.matchId);
|
||||
if (!detail) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "比赛记录不存在" });
|
||||
}
|
||||
if (detail.workflowStatus === "cancelled" || detail.workflowStatus === "finalized") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "当前比赛状态不能再提交审核" });
|
||||
}
|
||||
|
||||
const reviewed = await matchStore.submitMatchReview({
|
||||
matchId: input.matchId,
|
||||
reviewedByUserId: ctx.user.id,
|
||||
reviewNotes: input.reviewNotes?.trim() || null,
|
||||
finalScore: input.finalScore,
|
||||
finalMetrics: input.finalMetrics,
|
||||
});
|
||||
|
||||
await auditAdminAction({
|
||||
adminUserId: ctx.user.id,
|
||||
actionType: "match_review_submit",
|
||||
entityType: "match_session",
|
||||
entityId: String(input.matchId),
|
||||
payload: {
|
||||
reviewNotes: input.reviewNotes?.trim() || null,
|
||||
finalScore: input.finalScore ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return reviewed;
|
||||
}),
|
||||
|
||||
finalize: adminProcedure
|
||||
.input(z.object({ matchId: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const detail = await matchStore.getMatchDetail(input.matchId);
|
||||
if (!detail) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "比赛记录不存在" });
|
||||
}
|
||||
if (detail.workflowStatus === "cancelled") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "已取消的比赛不能正式结算" });
|
||||
}
|
||||
if (detail.workflowStatus === "finalized" || detail.workflowStatus === "finalizing") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "当前比赛已经在结算流程中" });
|
||||
}
|
||||
if (!detail.finalScore && !detail.suggestedScore) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "请先生成建议计分或提交审核比分" });
|
||||
}
|
||||
|
||||
const task = await enqueueTask({
|
||||
userId: ctx.user.id,
|
||||
type: "match_finalize",
|
||||
title: `${detail.title} 正式结算`,
|
||||
message: "比赛正式结算任务已加入后台队列",
|
||||
payload: {
|
||||
matchId: input.matchId,
|
||||
finalizedByUserId: ctx.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await matchStore.markMatchFinalizing(input.matchId);
|
||||
await auditAdminAction({
|
||||
adminUserId: ctx.user.id,
|
||||
actionType: "match_finalize_enqueue",
|
||||
entityType: "match_session",
|
||||
entityId: String(input.matchId),
|
||||
payload: { taskId: task.taskId },
|
||||
});
|
||||
|
||||
return task;
|
||||
}),
|
||||
|
||||
cancel: protectedProcedure
|
||||
.input(z.object({
|
||||
matchId: z.number(),
|
||||
notes: z.string().trim().max(2000).optional(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const detail = await getAccessibleMatchDetailOrThrow(ctx, input.matchId);
|
||||
if (ctx.user.role !== "admin" && detail.createdByUserId !== ctx.user.id) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "只有创建者或管理员可以取消比赛" });
|
||||
}
|
||||
if (detail.workflowStatus === "finalized") {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "已正式结算的比赛不能取消" });
|
||||
}
|
||||
|
||||
const cancelled = await matchStore.cancelMatchSession(input.matchId, input.notes?.trim() || null);
|
||||
if (ctx.user.role === "admin") {
|
||||
await auditAdminAction({
|
||||
adminUserId: ctx.user.id,
|
||||
actionType: "match_cancel",
|
||||
entityType: "match_session",
|
||||
entityId: String(input.matchId),
|
||||
payload: { notes: input.notes?.trim() || null },
|
||||
});
|
||||
}
|
||||
return cancelled;
|
||||
}),
|
||||
}),
|
||||
|
||||
// Training records
|
||||
record: router({
|
||||
create: protectedProcedure
|
||||
@@ -1071,11 +1471,26 @@ export const appRouter = router({
|
||||
leaderboard: router({
|
||||
get: protectedProcedure
|
||||
.input(z.object({
|
||||
sortBy: z.enum(["ntrpRating", "totalMinutes", "totalSessions", "totalShots"]).default("ntrpRating"),
|
||||
scope: leaderboardScopeSchema.default("training"),
|
||||
sortBy: leaderboardSortSchema.default("ntrpRating"),
|
||||
limit: z.number().default(50),
|
||||
}).optional())
|
||||
.query(async ({ input }) => {
|
||||
return db.getLeaderboard(input?.sortBy || "ntrpRating", input?.limit || 50);
|
||||
const scope = input?.scope || "training";
|
||||
const sortBy = input?.sortBy || "ntrpRating";
|
||||
const limit = input?.limit || 50;
|
||||
|
||||
if (scope === "competitive") {
|
||||
const competitiveSort = ["wins", "winRate", "setsWon", "pointsWon", "matches"].includes(sortBy)
|
||||
? sortBy as "wins" | "winRate" | "setsWon" | "pointsWon" | "matches"
|
||||
: "wins";
|
||||
return matchStore.getCompetitiveLeaderboard(competitiveSort, limit);
|
||||
}
|
||||
|
||||
const trainingSort = ["ntrpRating", "totalMinutes", "totalSessions", "totalShots"].includes(sortBy)
|
||||
? sortBy as "ntrpRating" | "totalMinutes" | "totalSessions" | "totalShots"
|
||||
: "ntrpRating";
|
||||
return db.getLeaderboard(trainingSort, limit);
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1171,6 +1586,216 @@ export const appRouter = router({
|
||||
}),
|
||||
}),
|
||||
|
||||
market: router({
|
||||
dashboard: protectedProcedure.query(async ({ ctx }) => {
|
||||
const [recentListings, recentHits, activeRuleCount, recentListingCount, taskRows, webhookUrl] = await Promise.all([
|
||||
db.listRacketListings({ limit: 120 }),
|
||||
db.listUserRacketWatchHits(ctx.user.id, 8),
|
||||
db.countUserActiveRacketWatchRules(ctx.user.id),
|
||||
db.countRecentRacketListings(24),
|
||||
db.listUserBackgroundTasks(ctx.user.id, 40),
|
||||
db.getAppSettingValue("market_default_feishu_webhook", ""),
|
||||
]);
|
||||
|
||||
const sourceSummary = MARKET_SOURCES.map((source) => {
|
||||
const rows = recentListings.filter((item) => item.source === source);
|
||||
return {
|
||||
source,
|
||||
total: rows.length,
|
||||
lowPriceCount: rows.filter((item) => item.isLowPriceCandidate === 1).length,
|
||||
latestFetchedAt: rows[0]?.fetchedAt ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
overview: {
|
||||
activeRuleCount,
|
||||
recentListingCount,
|
||||
hitCount: recentHits.length,
|
||||
hasWebhookConfigured: Boolean(webhookUrl),
|
||||
},
|
||||
spotlight: recentListings.filter((item) => item.isLowPriceCandidate === 1).slice(0, 8),
|
||||
recentHits,
|
||||
sourceSummary,
|
||||
recentTasks: taskRows.filter((task) =>
|
||||
task.type === "market_watch_refresh" ||
|
||||
task.type === "market_source_sync" ||
|
||||
task.type === "market_push_delivery"
|
||||
).slice(0, 10),
|
||||
};
|
||||
}),
|
||||
|
||||
listings: protectedProcedure
|
||||
.input(z.object({
|
||||
source: marketSourceSchema.optional(),
|
||||
brand: z.string().trim().max(64).optional(),
|
||||
category: marketCategorySchema.optional(),
|
||||
keyword: z.string().trim().max(128).optional(),
|
||||
lowPriceOnly: z.boolean().optional(),
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
}).optional())
|
||||
.query(async ({ input }) => {
|
||||
return db.listRacketListings({
|
||||
source: input?.source,
|
||||
brand: input?.brand?.trim() || undefined,
|
||||
category: input?.category,
|
||||
keyword: input?.keyword?.trim() || undefined,
|
||||
lowPriceOnly: input?.lowPriceOnly,
|
||||
limit: input?.limit ?? 50,
|
||||
});
|
||||
}),
|
||||
|
||||
watchRuleList: protectedProcedure.query(async ({ ctx }) => {
|
||||
return db.listUserRacketWatchRules(ctx.user.id);
|
||||
}),
|
||||
|
||||
watchRuleCreate: protectedProcedure
|
||||
.input(marketWatchRuleInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const title = deriveWatchRuleTitle(input);
|
||||
const ruleId = await db.createRacketWatchRule({
|
||||
userId: ctx.user.id,
|
||||
title,
|
||||
brand: input.brand,
|
||||
modelKeyword: input.modelKeyword?.trim() || null,
|
||||
seriesKeyword: input.seriesKeyword?.trim() || null,
|
||||
category: input.category ?? null,
|
||||
weightMinGram: input.weightMinGram ?? null,
|
||||
weightMaxGram: input.weightMaxGram ?? null,
|
||||
targetPrice: input.targetPrice,
|
||||
pushEnabled: input.pushEnabled ? 1 : 0,
|
||||
isActive: 1,
|
||||
});
|
||||
|
||||
const queued = await enqueueTask({
|
||||
userId: ctx.user.id,
|
||||
type: "market_watch_refresh",
|
||||
title: `${title} 刷新`,
|
||||
message: "监控规则已创建,后台开始抓取对应平台价格",
|
||||
payload: { scope: "user", ruleIds: [ruleId], trigger: "rule_create" },
|
||||
});
|
||||
|
||||
return { ruleId, taskId: queued.taskId };
|
||||
}),
|
||||
|
||||
watchRuleUpdate: protectedProcedure
|
||||
.input(marketWatchRuleInputSchema.extend({
|
||||
ruleId: z.number(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await db.getUserRacketWatchRuleById(ctx.user.id, input.ruleId);
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "监控规则不存在" });
|
||||
}
|
||||
|
||||
const title = deriveWatchRuleTitle(input);
|
||||
await db.updateRacketWatchRule(ctx.user.id, input.ruleId, {
|
||||
title,
|
||||
brand: input.brand,
|
||||
modelKeyword: input.modelKeyword?.trim() || null,
|
||||
seriesKeyword: input.seriesKeyword?.trim() || null,
|
||||
category: input.category ?? null,
|
||||
weightMinGram: input.weightMinGram ?? null,
|
||||
weightMaxGram: input.weightMaxGram ?? null,
|
||||
targetPrice: input.targetPrice,
|
||||
pushEnabled: input.pushEnabled ? 1 : 0,
|
||||
});
|
||||
|
||||
const queued = await enqueueTask({
|
||||
userId: ctx.user.id,
|
||||
type: "market_watch_refresh",
|
||||
title: `${title} 刷新`,
|
||||
message: "监控规则已更新,后台开始重新抓取对应平台价格",
|
||||
payload: { scope: "user", ruleIds: [input.ruleId], trigger: "rule_update" },
|
||||
});
|
||||
|
||||
return { success: true, taskId: queued.taskId };
|
||||
}),
|
||||
|
||||
watchRuleDelete: protectedProcedure
|
||||
.input(z.object({ ruleId: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await db.getUserRacketWatchRuleById(ctx.user.id, input.ruleId);
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "监控规则不存在" });
|
||||
}
|
||||
await db.deleteRacketWatchRule(ctx.user.id, input.ruleId);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
watchRuleToggle: protectedProcedure
|
||||
.input(z.object({ ruleId: z.number(), isActive: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const existing = await db.getUserRacketWatchRuleById(ctx.user.id, input.ruleId);
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "监控规则不存在" });
|
||||
}
|
||||
await db.toggleRacketWatchRule(ctx.user.id, input.ruleId, input.isActive ? 1 : 0);
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
watchHits: protectedProcedure
|
||||
.input(z.object({ limit: z.number().min(1).max(100).default(50) }).optional())
|
||||
.query(async ({ ctx, input }) => {
|
||||
return db.listUserRacketWatchHits(ctx.user.id, input?.limit ?? 50);
|
||||
}),
|
||||
|
||||
triggerRefresh: protectedProcedure
|
||||
.input(z.object({
|
||||
ruleId: z.number().optional(),
|
||||
source: marketSourceSchema.optional(),
|
||||
}).optional())
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input?.ruleId) {
|
||||
const existing = await db.getUserRacketWatchRuleById(ctx.user.id, input.ruleId);
|
||||
if (!existing) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "监控规则不存在" });
|
||||
}
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
userId: ctx.user.id,
|
||||
type: "market_watch_refresh",
|
||||
title: input?.ruleId ? "单条监控规则刷新" : "球拍行情手动刷新",
|
||||
message: "球拍行情刷新任务已加入后台队列",
|
||||
payload: {
|
||||
scope: "user",
|
||||
ruleIds: input?.ruleId ? [input.ruleId] : undefined,
|
||||
sources: input?.source ? [input.source] : undefined,
|
||||
trigger: "manual",
|
||||
},
|
||||
});
|
||||
}),
|
||||
|
||||
pushConfigGet: protectedProcedure.query(async ({ ctx }) => {
|
||||
const webhookUrl = await db.getAppSettingValue("market_default_feishu_webhook", "");
|
||||
return {
|
||||
hasWebhookConfigured: Boolean(webhookUrl),
|
||||
maskedWebhookUrl: maskWebhookUrl(webhookUrl),
|
||||
canEdit: ctx.user.role === "admin",
|
||||
};
|
||||
}),
|
||||
|
||||
pushConfigUpdate: adminProcedure
|
||||
.input(z.object({
|
||||
webhookUrl: z.string().trim().url().max(2048),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await db.updateAppSetting("market_default_feishu_webhook", {
|
||||
value: input.webhookUrl,
|
||||
type: "string",
|
||||
});
|
||||
await auditAdminAction({
|
||||
adminUserId: ctx.user.id,
|
||||
actionType: "update_market_feishu_webhook",
|
||||
entityType: "app_setting",
|
||||
entityId: "market_default_feishu_webhook",
|
||||
payload: { webhookUrl: input.webhookUrl },
|
||||
});
|
||||
return { success: true };
|
||||
}),
|
||||
}),
|
||||
|
||||
// Notifications
|
||||
notification: router({
|
||||
list: protectedProcedure
|
||||
|
||||
@@ -2,7 +2,18 @@ import { nanoid } from "nanoid";
|
||||
import { ENV } from "./_core/env";
|
||||
import { invokeLLM, type Message } from "./_core/llm";
|
||||
import * as db from "./db";
|
||||
import * as matchStore from "./matchStore";
|
||||
import { getRemoteMediaSession } from "./mediaService";
|
||||
import {
|
||||
applyComparablePriceBenchmark,
|
||||
buildMarketSearchQuery,
|
||||
enrichRacketListing,
|
||||
formatMarketPushText,
|
||||
listingMatchesWatchRule,
|
||||
loadMarketConfig,
|
||||
MARKET_SOURCES,
|
||||
searchMarketSource,
|
||||
} from "./market";
|
||||
import {
|
||||
buildAdjustedTrainingPlanPrompt,
|
||||
buildMultimodalCorrectionPrompt,
|
||||
@@ -562,6 +573,346 @@ async function runNtrpRefreshAllTask(task: NonNullable<TaskRow>) {
|
||||
};
|
||||
}
|
||||
|
||||
async function persistMarketListing(rawListing: Parameters<typeof enrichRacketListing>[0]) {
|
||||
const enriched = enrichRacketListing(rawListing);
|
||||
const saved = await db.upsertRacketListing(enriched);
|
||||
if (!saved) {
|
||||
throw new Error(`Failed to persist market listing: ${rawListing.source}:${rawListing.sourceListingId}`);
|
||||
}
|
||||
|
||||
const comparable = await db.listRecentComparableRacketListings({
|
||||
brand: saved.brand,
|
||||
model: saved.model,
|
||||
excludeId: saved.id,
|
||||
limit: 8,
|
||||
});
|
||||
const benchmarked = applyComparablePriceBenchmark({
|
||||
...enriched,
|
||||
brand: saved.brand,
|
||||
model: saved.model,
|
||||
series: saved.series,
|
||||
category: saved.category,
|
||||
weightGram: saved.weightGram,
|
||||
conditionLevel: saved.conditionLevel,
|
||||
gradeLevel: saved.gradeLevel,
|
||||
gradeReason: saved.gradeReason,
|
||||
isLowPriceCandidate: saved.isLowPriceCandidate,
|
||||
}, comparable.map((item) => item.price));
|
||||
|
||||
if (
|
||||
benchmarked.isLowPriceCandidate !== saved.isLowPriceCandidate ||
|
||||
benchmarked.gradeReason !== saved.gradeReason
|
||||
) {
|
||||
await db.updateRacketListing(saved.id, {
|
||||
isLowPriceCandidate: benchmarked.isLowPriceCandidate,
|
||||
gradeReason: benchmarked.gradeReason,
|
||||
});
|
||||
return (await db.getRacketListingById(saved.id)) ?? saved;
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
async function queueMarketPushTask(userId: number, hitId: number, title: string) {
|
||||
const taskId = nanoid();
|
||||
await db.createBackgroundTask({
|
||||
id: taskId,
|
||||
userId,
|
||||
type: "market_push_delivery",
|
||||
title: `低价推送 · ${title}`.slice(0, 256),
|
||||
message: "低价命中已加入飞书推送队列",
|
||||
payload: { hitId },
|
||||
progress: 0,
|
||||
maxAttempts: 3,
|
||||
});
|
||||
return taskId;
|
||||
}
|
||||
|
||||
async function recordMarketWatchHit(params: {
|
||||
rule: Awaited<ReturnType<typeof db.listActiveRacketWatchRules>>[number];
|
||||
listing: Awaited<ReturnType<typeof db.getRacketListingById>>;
|
||||
repushDelta: number;
|
||||
}) {
|
||||
if (!params.listing) {
|
||||
throw new Error("Listing is required to create a watch hit");
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const existing = await db.getRacketWatchHitByRuleAndListing(params.rule.id, params.listing.id);
|
||||
const pushEnabled = params.rule.pushEnabled === 1;
|
||||
|
||||
if (!existing) {
|
||||
const created = await db.createRacketWatchHit({
|
||||
watchRuleId: params.rule.id,
|
||||
userId: params.rule.userId,
|
||||
listingId: params.listing.id,
|
||||
matchedPrice: params.listing.price,
|
||||
status: pushEnabled ? "push_queued" : "suppressed",
|
||||
firstMatchedAt: now,
|
||||
lastMatchedAt: now,
|
||||
lastPushPrice: null,
|
||||
pushedAt: null,
|
||||
pushCount: 0,
|
||||
});
|
||||
return {
|
||||
hit: created,
|
||||
shouldQueuePush: pushEnabled,
|
||||
};
|
||||
}
|
||||
|
||||
const lastPushPrice = existing.lastPushPrice ?? null;
|
||||
const shouldQueuePush =
|
||||
pushEnabled &&
|
||||
existing.status !== "push_queued" &&
|
||||
(
|
||||
existing.pushCount === 0 ||
|
||||
lastPushPrice == null ||
|
||||
params.listing.price <= (lastPushPrice - params.repushDelta)
|
||||
);
|
||||
|
||||
await db.updateRacketWatchHit(existing.id, {
|
||||
matchedPrice: params.listing.price,
|
||||
lastMatchedAt: now,
|
||||
status: shouldQueuePush ? "push_queued" : (pushEnabled ? existing.status : "suppressed"),
|
||||
});
|
||||
|
||||
const hit = await db.getRacketWatchHitByRuleAndListing(params.rule.id, params.listing.id);
|
||||
return {
|
||||
hit,
|
||||
shouldQueuePush,
|
||||
};
|
||||
}
|
||||
|
||||
async function runMarketWatchRefreshTask(task: NonNullable<TaskRow>) {
|
||||
const payload = task.payload as {
|
||||
scope?: "user" | "all_users";
|
||||
ruleIds?: number[];
|
||||
sources?: Array<(typeof MARKET_SOURCES)[number]>;
|
||||
trigger?: string;
|
||||
};
|
||||
const config = await loadMarketConfig();
|
||||
const allowedSources = (payload.sources?.length
|
||||
? payload.sources.filter((item): item is (typeof MARKET_SOURCES)[number] => MARKET_SOURCES.includes(item))
|
||||
: [...MARKET_SOURCES]);
|
||||
|
||||
const rules = payload.scope === "all_users"
|
||||
? await db.listActiveRacketWatchRules({ ruleIds: payload.ruleIds })
|
||||
: await db.listActiveRacketWatchRules({ userId: task.userId, ruleIds: payload.ruleIds });
|
||||
|
||||
if (rules.length === 0) {
|
||||
return {
|
||||
kind: "market_watch_refresh" as const,
|
||||
trigger: payload.trigger ?? "manual",
|
||||
processedRules: 0,
|
||||
listingsSaved: 0,
|
||||
matchedHits: 0,
|
||||
queuedPushes: 0,
|
||||
sourceReports: [],
|
||||
};
|
||||
}
|
||||
|
||||
const sourceReports: Array<{
|
||||
ruleId: number;
|
||||
ruleTitle: string;
|
||||
source: string;
|
||||
ok: boolean;
|
||||
blocked: boolean;
|
||||
message: string;
|
||||
listings: number;
|
||||
}> = [];
|
||||
let listingsSaved = 0;
|
||||
let matchedHits = 0;
|
||||
let queuedPushes = 0;
|
||||
|
||||
for (const rule of rules) {
|
||||
const query = buildMarketSearchQuery(rule);
|
||||
let latestMatchedAt: Date | undefined;
|
||||
|
||||
for (const source of allowedSources) {
|
||||
const result = await searchMarketSource(source, query, config);
|
||||
sourceReports.push({
|
||||
ruleId: rule.id,
|
||||
ruleTitle: rule.title,
|
||||
source,
|
||||
ok: result.ok,
|
||||
blocked: result.blocked,
|
||||
message: result.message,
|
||||
listings: result.listings.length,
|
||||
});
|
||||
|
||||
for (const rawListing of result.listings) {
|
||||
const savedListing = await persistMarketListing(rawListing);
|
||||
listingsSaved += 1;
|
||||
|
||||
if (!listingMatchesWatchRule(savedListing, rule)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
latestMatchedAt = new Date();
|
||||
matchedHits += 1;
|
||||
|
||||
const { hit, shouldQueuePush } = await recordMarketWatchHit({
|
||||
rule,
|
||||
listing: savedListing,
|
||||
repushDelta: config.repushDelta,
|
||||
});
|
||||
|
||||
if (hit && shouldQueuePush) {
|
||||
await queueMarketPushTask(rule.userId, hit.id, rule.title);
|
||||
queuedPushes += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.updateRacketWatchRule(rule.userId, rule.id, {
|
||||
lastCheckedAt: new Date(),
|
||||
lastMatchedAt: latestMatchedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "market_watch_refresh" as const,
|
||||
trigger: payload.trigger ?? "manual",
|
||||
processedRules: rules.length,
|
||||
listingsSaved,
|
||||
matchedHits,
|
||||
queuedPushes,
|
||||
sourceReports,
|
||||
};
|
||||
}
|
||||
|
||||
async function runMarketSourceSyncTask(task: NonNullable<TaskRow>) {
|
||||
const payload = task.payload as {
|
||||
source: (typeof MARKET_SOURCES)[number];
|
||||
query: string;
|
||||
};
|
||||
const config = await loadMarketConfig();
|
||||
const result = await searchMarketSource(payload.source, payload.query, config);
|
||||
let savedCount = 0;
|
||||
|
||||
for (const rawListing of result.listings) {
|
||||
await persistMarketListing(rawListing);
|
||||
savedCount += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "market_source_sync" as const,
|
||||
source: payload.source,
|
||||
query: payload.query,
|
||||
ok: result.ok,
|
||||
blocked: result.blocked,
|
||||
message: result.message,
|
||||
listingsSaved: savedCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function runMarketPushDeliveryTask(task: NonNullable<TaskRow>) {
|
||||
const payload = task.payload as { hitId: number };
|
||||
const hit = await db.getRacketWatchHitDeliveryPayload(payload.hitId);
|
||||
if (!hit) {
|
||||
throw new Error("Market watch hit not found");
|
||||
}
|
||||
|
||||
const config = await loadMarketConfig();
|
||||
if (!config.defaultFeishuWebhook.trim()) {
|
||||
throw new Error("Market Feishu webhook is not configured");
|
||||
}
|
||||
|
||||
const text = formatMarketPushText({
|
||||
ruleTitle: hit.ruleTitle,
|
||||
source: hit.listingSource,
|
||||
title: hit.listingTitle,
|
||||
price: hit.matchedPrice,
|
||||
targetPrice: hit.ruleTargetPrice,
|
||||
brand: hit.listingBrand,
|
||||
model: hit.listingModel,
|
||||
category: hit.listingCategory,
|
||||
weightGram: hit.listingWeightGram,
|
||||
gradeLevel: hit.listingGradeLevel,
|
||||
gradeReason: hit.listingGradeReason,
|
||||
listingUrl: hit.listingUrl,
|
||||
fetchedAt: hit.listingFetchedAt,
|
||||
});
|
||||
|
||||
const response = await fetch(config.defaultFeishuWebhook, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
msg_type: "text",
|
||||
content: {
|
||||
text,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => "");
|
||||
throw new Error(`Feishu webhook failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ""}`);
|
||||
}
|
||||
|
||||
await db.updateRacketWatchHit(hit.id, {
|
||||
status: "pushed",
|
||||
lastPushPrice: hit.matchedPrice,
|
||||
pushedAt: new Date(),
|
||||
pushCount: (hit.pushCount ?? 0) + 1,
|
||||
});
|
||||
await db.createNotification({
|
||||
userId: hit.userId,
|
||||
notificationType: "racket_price_alert",
|
||||
title: `低价命中 · ${hit.ruleTitle}`.slice(0, 256),
|
||||
message: text,
|
||||
isRead: 0,
|
||||
});
|
||||
|
||||
return {
|
||||
kind: "market_push_delivery" as const,
|
||||
hitId: hit.id,
|
||||
delivered: true,
|
||||
destination: "feishu_webhook",
|
||||
};
|
||||
}
|
||||
|
||||
async function runMatchScoreSuggestTask(task: NonNullable<TaskRow>) {
|
||||
const payload = task.payload as { matchId: number };
|
||||
const session = await matchStore.getMatchSessionById(payload.matchId);
|
||||
if (!session) {
|
||||
throw new Error("Match session not found");
|
||||
}
|
||||
|
||||
const suggestion = await matchStore.generateSuggestedMatchState(payload.matchId);
|
||||
|
||||
return {
|
||||
kind: "match_score_suggest" as const,
|
||||
matchId: payload.matchId,
|
||||
workflowStatus: "review_pending",
|
||||
score: suggestion.score,
|
||||
metrics: suggestion.metrics,
|
||||
eventCount: suggestion.eventCount,
|
||||
sourceCount: suggestion.sourceCount,
|
||||
};
|
||||
}
|
||||
|
||||
async function runMatchFinalizeTask(task: NonNullable<TaskRow>) {
|
||||
const payload = task.payload as {
|
||||
matchId: number;
|
||||
finalizedByUserId?: number;
|
||||
};
|
||||
|
||||
const finalized = await matchStore.finalizeMatchSettlement(
|
||||
payload.matchId,
|
||||
payload.finalizedByUserId ?? task.userId,
|
||||
);
|
||||
|
||||
return {
|
||||
kind: "match_finalize" as const,
|
||||
matchId: payload.matchId,
|
||||
workflowStatus: finalized?.workflowStatus ?? "finalized",
|
||||
finalizedAt: finalized?.finalizedAt ?? new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function processBackgroundTask(task: NonNullable<TaskRow>) {
|
||||
switch (task.type) {
|
||||
case "training_plan_generate":
|
||||
@@ -578,6 +929,16 @@ export async function processBackgroundTask(task: NonNullable<TaskRow>) {
|
||||
return runNtrpRefreshUserTask(task);
|
||||
case "ntrp_refresh_all":
|
||||
return runNtrpRefreshAllTask(task);
|
||||
case "market_watch_refresh":
|
||||
return runMarketWatchRefreshTask(task);
|
||||
case "market_source_sync":
|
||||
return runMarketSourceSyncTask(task);
|
||||
case "market_push_delivery":
|
||||
return runMarketPushDeliveryTask(task);
|
||||
case "match_score_suggest":
|
||||
return runMatchScoreSuggestTask(task);
|
||||
case "match_finalize":
|
||||
return runMatchFinalizeTask(task);
|
||||
default:
|
||||
throw new Error(`Unsupported task type: ${String(task.type)}`);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,35 @@ describe("normalizeTrainingPlanResponse", () => {
|
||||
});
|
||||
expect(result.exercises[1]?.category).toBe("脚步移动");
|
||||
});
|
||||
|
||||
it("derives a fallback exercise when a day section has no exercises array", () => {
|
||||
const result = normalizeTrainingPlanResponse({
|
||||
content: JSON.stringify({
|
||||
day_1: {
|
||||
duration_minutes: 35,
|
||||
focus: "基础脚步与启动速度",
|
||||
summary: "围绕启动速度和小碎步调整展开",
|
||||
},
|
||||
}),
|
||||
fallbackTitle: "7天训练计划",
|
||||
});
|
||||
|
||||
expect(result.exercises).toHaveLength(1);
|
||||
expect(result.exercises[0]).toMatchObject({
|
||||
day: 1,
|
||||
name: "基础脚步与启动速度",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a readable error when no exercises can be derived", () => {
|
||||
expect(() => normalizeTrainingPlanResponse({
|
||||
content: JSON.stringify({
|
||||
title: "空计划",
|
||||
exercises: [],
|
||||
}),
|
||||
fallbackTitle: "fallback",
|
||||
})).toThrow("训练计划结果为空,请重试或缩小训练重点后再生成。");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAdjustedPlanResponse", () => {
|
||||
|
||||
@@ -25,6 +25,7 @@ type NormalizedPlan = z.infer<typeof normalizedPlanSchema>;
|
||||
type NormalizedAdjustedPlan = z.infer<typeof normalizedAdjustedPlanSchema>;
|
||||
|
||||
const dayKeyPattern = /^day[_\s-]?(\d+)$/i;
|
||||
const EMPTY_PLAN_ERROR_MESSAGE = "训练计划结果为空,请重试或缩小训练重点后再生成。";
|
||||
|
||||
function extractTextContent(content: unknown) {
|
||||
if (typeof content === "string") {
|
||||
@@ -66,6 +67,15 @@ function toPositiveInteger(value: unknown, fallback: number) {
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function firstMeaningfulText(...values: Array<unknown>) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferCategory(...values: Array<unknown>) {
|
||||
const text = values
|
||||
.filter((value): value is string => typeof value === "string")
|
||||
@@ -118,6 +128,52 @@ function normalizeExercise(
|
||||
};
|
||||
}
|
||||
|
||||
function createFallbackExercise(day: number, section: Record<string, unknown>) {
|
||||
const focus = firstMeaningfulText(section.focus, section.summary, section.title, section.theme);
|
||||
if (!focus) return null;
|
||||
|
||||
return normalizeExercise(day, {
|
||||
day,
|
||||
name: focus,
|
||||
description: firstMeaningfulText(section.summary, section.description, `${focus}训练`) ?? `${focus}训练`,
|
||||
duration: section.duration ?? section.duration_minutes ?? 12,
|
||||
tips: firstMeaningfulText(section.tips, section.notes, `重点关注:${focus}`) ?? `重点关注:${focus}`,
|
||||
sets: 3,
|
||||
reps: 10,
|
||||
}, section);
|
||||
}
|
||||
|
||||
function normalizeCanonicalPlan(
|
||||
raw: Record<string, unknown>,
|
||||
fallbackTitle: string,
|
||||
): NormalizedPlan {
|
||||
const rawExercises = Array.isArray(raw.exercises)
|
||||
? raw.exercises.filter(
|
||||
(item): item is Record<string, unknown> =>
|
||||
Boolean(item) && typeof item === "object" && !Array.isArray(item),
|
||||
)
|
||||
: [];
|
||||
|
||||
const exercises = rawExercises.map((exercise, index) =>
|
||||
normalizeExercise(
|
||||
toPositiveInteger(exercise.day, index + 1),
|
||||
exercise,
|
||||
),
|
||||
);
|
||||
|
||||
if (exercises.length === 0) {
|
||||
throw new Error(EMPTY_PLAN_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
return normalizedPlanSchema.parse({
|
||||
title:
|
||||
typeof raw.title === "string" && raw.title.trim().length > 0
|
||||
? raw.title.trim()
|
||||
: fallbackTitle,
|
||||
exercises,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDayMapPlan(
|
||||
raw: Record<string, unknown>,
|
||||
fallbackTitle: string
|
||||
@@ -143,9 +199,21 @@ function normalizeDayMapPlan(
|
||||
)
|
||||
: [];
|
||||
|
||||
for (const exercise of sectionExercises) {
|
||||
exercises.push(normalizeExercise(day, exercise, section));
|
||||
if (sectionExercises.length > 0) {
|
||||
for (const exercise of sectionExercises) {
|
||||
exercises.push(normalizeExercise(day, exercise, section));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackExercise = createFallbackExercise(day, section);
|
||||
if (fallbackExercise) {
|
||||
exercises.push(fallbackExercise);
|
||||
}
|
||||
}
|
||||
|
||||
if (exercises.length === 0) {
|
||||
throw new Error(EMPTY_PLAN_ERROR_MESSAGE);
|
||||
}
|
||||
|
||||
return normalizedPlanSchema.parse({
|
||||
@@ -164,7 +232,7 @@ export function normalizeTrainingPlanResponse(params: {
|
||||
const raw = parseJsonContent(params.content);
|
||||
|
||||
if (Array.isArray(raw.exercises)) {
|
||||
return normalizedPlanSchema.parse(raw);
|
||||
return normalizeCanonicalPlan(raw, params.fallbackTitle);
|
||||
}
|
||||
|
||||
return normalizeDayMapPlan(raw, params.fallbackTitle);
|
||||
@@ -177,8 +245,9 @@ export function normalizeAdjustedPlanResponse(params: {
|
||||
const raw = parseJsonContent(params.content);
|
||||
|
||||
if (Array.isArray(raw.exercises)) {
|
||||
const normalized = normalizeCanonicalPlan(raw, params.fallbackTitle);
|
||||
return normalizedAdjustedPlanSchema.parse({
|
||||
...raw,
|
||||
...normalized,
|
||||
adjustmentNotes:
|
||||
typeof raw.adjustmentNotes === "string" && raw.adjustmentNotes.trim().length > 0
|
||||
? raw.adjustmentNotes.trim()
|
||||
|
||||
54
server/tutorialMetrics.test.ts
普通文件
54
server/tutorialMetrics.test.ts
普通文件
@@ -0,0 +1,54 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchTutorialMetrics, shouldRefreshTutorialMetrics } from "./tutorialMetrics";
|
||||
|
||||
describe("shouldRefreshTutorialMetrics", () => {
|
||||
it("returns false without a supported source", () => {
|
||||
expect(shouldRefreshTutorialMetrics({ sourcePlatform: "site", platformVideoId: null })).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true when metrics are missing", () => {
|
||||
expect(shouldRefreshTutorialMetrics({
|
||||
sourcePlatform: "bilibili",
|
||||
platformVideoId: "BV1test",
|
||||
metricsFetchedAt: null,
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for fresh cached metrics", () => {
|
||||
expect(shouldRefreshTutorialMetrics({
|
||||
sourcePlatform: "bilibili",
|
||||
platformVideoId: "BV1test",
|
||||
metricsFetchedAt: new Date(),
|
||||
viewCount: 120,
|
||||
commentCount: 8,
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchTutorialMetrics", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("parses bilibili metrics payloads", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
data: {
|
||||
pic: "http://i0.hdslb.com/demo.jpg",
|
||||
stat: {
|
||||
view: 3210,
|
||||
reply: 42,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await fetchTutorialMetrics("bilibili", "BV1demo");
|
||||
expect(result).toMatchObject({
|
||||
viewCount: 3210,
|
||||
commentCount: 42,
|
||||
thumbnailUrl: "https://i0.hdslb.com/demo.jpg",
|
||||
});
|
||||
});
|
||||
});
|
||||
112
server/tutorialMetrics.ts
普通文件
112
server/tutorialMetrics.ts
普通文件
@@ -0,0 +1,112 @@
|
||||
import { ENV } from "./_core/env";
|
||||
|
||||
export type TutorialMetrics = {
|
||||
viewCount?: number;
|
||||
commentCount?: number;
|
||||
thumbnailUrl?: string;
|
||||
fetchedAt: Date;
|
||||
};
|
||||
|
||||
export type TutorialMetricSource = {
|
||||
sourcePlatform?: string | null;
|
||||
platformVideoId?: string | null;
|
||||
metricsFetchedAt?: Date | string | null;
|
||||
viewCount?: number | null;
|
||||
commentCount?: number | null;
|
||||
};
|
||||
|
||||
const METRIC_CACHE_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
function parseCount(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function shouldRefreshTutorialMetrics(source: TutorialMetricSource) {
|
||||
if (!source.sourcePlatform || !source.platformVideoId) return false;
|
||||
if (source.sourcePlatform !== "bilibili" && source.sourcePlatform !== "youtube") return false;
|
||||
|
||||
const fetchedAt =
|
||||
source.metricsFetchedAt instanceof Date
|
||||
? source.metricsFetchedAt
|
||||
: source.metricsFetchedAt
|
||||
? new Date(source.metricsFetchedAt)
|
||||
: null;
|
||||
|
||||
if (!fetchedAt || Number.isNaN(fetchedAt.getTime())) return true;
|
||||
if (source.viewCount == null && source.commentCount == null) return true;
|
||||
return (Date.now() - fetchedAt.getTime()) > METRIC_CACHE_MS;
|
||||
}
|
||||
|
||||
export async function fetchTutorialMetrics(sourcePlatform: string, platformVideoId: string): Promise<TutorialMetrics | null> {
|
||||
if (sourcePlatform === "bilibili") {
|
||||
return fetchBilibiliMetrics(platformVideoId);
|
||||
}
|
||||
if (sourcePlatform === "youtube") {
|
||||
return fetchYouTubeMetrics(platformVideoId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchBilibiliMetrics(bvid: string): Promise<TutorialMetrics | null> {
|
||||
const response = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${encodeURIComponent(bvid)}`, {
|
||||
headers: {
|
||||
"accept": "application/json",
|
||||
"user-agent": "tennis-training-hub/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Bilibili metrics request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const stat = payload?.data?.stat;
|
||||
if (!payload?.data || !stat) return null;
|
||||
|
||||
return {
|
||||
viewCount: parseCount(stat.view),
|
||||
commentCount: parseCount(stat.reply),
|
||||
thumbnailUrl: typeof payload.data.pic === "string" ? payload.data.pic.replace(/^http:\/\//, "https://") : undefined,
|
||||
fetchedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchYouTubeMetrics(videoId: string): Promise<TutorialMetrics | null> {
|
||||
if (!ENV.youtubeApiKey) return null;
|
||||
|
||||
const url = new URL("https://www.googleapis.com/youtube/v3/videos");
|
||||
url.searchParams.set("part", "statistics,snippet");
|
||||
url.searchParams.set("id", videoId);
|
||||
url.searchParams.set("key", ENV.youtubeApiKey);
|
||||
|
||||
const response = await fetch(url.toString(), {
|
||||
headers: {
|
||||
"accept": "application/json",
|
||||
"user-agent": "tennis-training-hub/1.0",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`YouTube metrics request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
const item = Array.isArray(payload?.items) ? payload.items[0] : null;
|
||||
if (!item) return null;
|
||||
|
||||
return {
|
||||
viewCount: parseCount(item.statistics?.viewCount),
|
||||
commentCount: parseCount(item.statistics?.commentCount),
|
||||
thumbnailUrl:
|
||||
item.snippet?.thumbnails?.maxres?.url ||
|
||||
item.snippet?.thumbnails?.high?.url ||
|
||||
item.snippet?.thumbnails?.medium?.url ||
|
||||
item.snippet?.thumbnails?.default?.url,
|
||||
fetchedAt: new Date(),
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,20 @@ function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function isRetriableBackgroundError(error: unknown) {
|
||||
if (!(error instanceof Error)) return false;
|
||||
return (
|
||||
error.message.startsWith("Request timed out after ") ||
|
||||
error.message.includes("fetch failed") ||
|
||||
error.message.includes("ECONNRESET") ||
|
||||
error.message.includes("ETIMEDOUT") ||
|
||||
error.message.includes("429") ||
|
||||
error.message.includes("502") ||
|
||||
error.message.includes("503") ||
|
||||
error.message.includes("504")
|
||||
);
|
||||
}
|
||||
|
||||
async function workOnce() {
|
||||
await db.failExhaustedBackgroundTasks();
|
||||
await db.requeueStaleBackgroundTasks(new Date(Date.now() - ENV.backgroundTaskStaleMs));
|
||||
@@ -31,9 +45,21 @@ async function workOnce() {
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown background task error";
|
||||
await db.failBackgroundTask(task.id, message);
|
||||
await db.failVisionTestRun(task.id, message);
|
||||
console.error(`[worker] task ${task.id} failed:`, error);
|
||||
if (isRetriableBackgroundError(error) && task.attempts < task.maxAttempts) {
|
||||
const nextAttempt = task.attempts + 1;
|
||||
const delayMs = Math.min(30_000, 5_000 * task.attempts);
|
||||
await db.rescheduleBackgroundTask(task.id, {
|
||||
progress: 15,
|
||||
message: `请求超时,已自动重试(第 ${nextAttempt}/${task.maxAttempts} 次尝试)`,
|
||||
error: message,
|
||||
delayMs,
|
||||
});
|
||||
console.warn(`[worker] task ${task.id} rescheduled after retriable error:`, error);
|
||||
} else {
|
||||
await db.failBackgroundTask(task.id, message);
|
||||
await db.failVisionTestRun(task.id, message);
|
||||
console.error(`[worker] task ${task.id} failed:`, error);
|
||||
}
|
||||
} finally {
|
||||
clearInterval(heartbeatTimer);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户