Add market watch and match hub workflows
这个提交包含在:
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) {
|
||||
|
||||
在新工单中引用
屏蔽一个用户