946 行
28 KiB
TypeScript
946 行
28 KiB
TypeScript
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,
|
|
buildTextCorrectionPrompt,
|
|
buildTrainingPlanPrompt,
|
|
multimodalCorrectionSchema,
|
|
renderMultimodalCorrectionMarkdown,
|
|
} from "./prompts";
|
|
import { toPublicUrl } from "./publicUrl";
|
|
import { storagePut } from "./storage";
|
|
import { extractStructuredJsonContent, normalizeMultimodalCorrectionReport } from "./vision";
|
|
import {
|
|
normalizeAdjustedPlanResponse,
|
|
normalizeTrainingPlanResponse,
|
|
} from "./trainingPlan";
|
|
import { refreshAllUsersNtrp, refreshUserNtrp, syncRecordingTrainingData } from "./trainingAutomation";
|
|
|
|
type TaskRow = Awaited<ReturnType<typeof db.getBackgroundTaskById>>;
|
|
|
|
type StructuredParams<T> = {
|
|
model?: string;
|
|
baseMessages: Array<{ role: "system" | "user"; content: string | Message["content"] }>;
|
|
responseFormat: {
|
|
type: "json_schema";
|
|
json_schema: {
|
|
name: string;
|
|
strict: true;
|
|
schema: Record<string, unknown>;
|
|
};
|
|
};
|
|
parse: (content: unknown) => T;
|
|
timeoutMs?: number;
|
|
retryCount?: number;
|
|
};
|
|
|
|
const TRAINING_PLAN_LLM_TIMEOUT_MS = Math.max(ENV.llmTimeoutMs, 120_000);
|
|
const TRAINING_PLAN_LLM_RETRY_COUNT = Math.max(ENV.llmRetryCount, 2);
|
|
|
|
async function invokeStructured<T>(params: StructuredParams<T>) {
|
|
let lastError: unknown;
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
const retryHint =
|
|
attempt === 0 || !(lastError instanceof Error)
|
|
? []
|
|
: [{
|
|
role: "user" as const,
|
|
content:
|
|
`上一次输出无法被系统解析,错误是:${lastError.message}。` +
|
|
"请只返回合法完整的 JSON 对象,不要附加 Markdown 或说明。",
|
|
}];
|
|
|
|
const response = await invokeLLM({
|
|
apiUrl: params.model === ENV.llmVisionModel ? ENV.llmVisionApiUrl : undefined,
|
|
apiKey: params.model === ENV.llmVisionModel ? ENV.llmVisionApiKey : undefined,
|
|
model: params.model,
|
|
messages: [...params.baseMessages, ...retryHint],
|
|
response_format: params.responseFormat,
|
|
timeoutMs: params.timeoutMs,
|
|
retryCount: params.retryCount,
|
|
});
|
|
|
|
try {
|
|
return params.parse(response.choices[0]?.message?.content);
|
|
} catch (error) {
|
|
lastError = error;
|
|
}
|
|
}
|
|
|
|
throw lastError instanceof Error ? lastError : new Error("Failed to parse structured LLM response");
|
|
}
|
|
|
|
function contentToPlainText(content: Message["content"]) {
|
|
if (typeof content === "string") {
|
|
return content;
|
|
}
|
|
|
|
const parts = Array.isArray(content) ? content : [content];
|
|
|
|
return parts
|
|
.map((part) => {
|
|
if (typeof part === "string") {
|
|
return part;
|
|
}
|
|
if (part.type === "text") {
|
|
return part.text;
|
|
}
|
|
if (part.type === "image_url") {
|
|
return `[image] ${part.image_url.url}`;
|
|
}
|
|
if (part.type === "file_url") {
|
|
return `[file] ${part.file_url.url}`;
|
|
}
|
|
return "";
|
|
})
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
}
|
|
|
|
function parseDataUrl(input: string) {
|
|
const match = input.match(/^data:(.+?);base64,(.+)$/);
|
|
if (!match) {
|
|
throw new Error("Invalid image data URL");
|
|
}
|
|
return {
|
|
contentType: match[1],
|
|
buffer: Buffer.from(match[2], "base64"),
|
|
};
|
|
}
|
|
|
|
async function persistInlineImages(userId: number, imageDataUrls: string[]) {
|
|
const persistedUrls: string[] = [];
|
|
for (let index = 0; index < imageDataUrls.length; index++) {
|
|
const { contentType, buffer } = parseDataUrl(imageDataUrls[index]);
|
|
const extension = contentType.includes("png") ? "png" : "jpg";
|
|
const key = `analysis-images/${userId}/${nanoid()}.${extension}`;
|
|
const uploaded = await storagePut(key, buffer, contentType);
|
|
persistedUrls.push(toPublicUrl(uploaded.url));
|
|
}
|
|
return persistedUrls;
|
|
}
|
|
|
|
export async function prepareCorrectionImageUrls(input: {
|
|
userId: number;
|
|
imageUrls?: string[];
|
|
imageDataUrls?: string[];
|
|
}) {
|
|
const directUrls = (input.imageUrls ?? []).map((item) => toPublicUrl(item));
|
|
const uploadedUrls = input.imageDataUrls?.length
|
|
? await persistInlineImages(input.userId, input.imageDataUrls)
|
|
: [];
|
|
return [...directUrls, ...uploadedUrls];
|
|
}
|
|
|
|
async function runTrainingPlanGenerateTask(task: NonNullable<TaskRow>) {
|
|
const payload = task.payload as {
|
|
skillLevel: "beginner" | "intermediate" | "advanced";
|
|
durationDays: number;
|
|
focusAreas?: string[];
|
|
};
|
|
const user = await db.getUserById(task.userId);
|
|
if (!user) {
|
|
throw new Error("User not found");
|
|
}
|
|
const latestSnapshot = await db.getLatestNtrpSnapshot(task.userId);
|
|
const trainingProfileStatus = db.getTrainingProfileStatus(user, latestSnapshot);
|
|
if (!trainingProfileStatus.isComplete) {
|
|
const missingLabels = trainingProfileStatus.missingFields.map((field) => db.TRAINING_PROFILE_FIELD_LABELS[field]).join("、");
|
|
throw new Error(`训练计划生成前请先完善训练档案:${missingLabels}`);
|
|
}
|
|
|
|
const analyses = await db.getUserAnalyses(task.userId);
|
|
const recentScores = analyses.slice(0, 5).map((analysis) => ({
|
|
score: analysis.overallScore ?? null,
|
|
issues: analysis.detectedIssues,
|
|
exerciseType: analysis.exerciseType ?? null,
|
|
shotCount: analysis.shotCount ?? null,
|
|
strokeConsistency: analysis.strokeConsistency ?? null,
|
|
footworkScore: analysis.footworkScore ?? null,
|
|
}));
|
|
|
|
const parsed = await invokeStructured({
|
|
baseMessages: [
|
|
{ role: "system", content: "你是网球训练计划生成器。返回严格的 JSON 格式。" },
|
|
{
|
|
role: "user",
|
|
content: buildTrainingPlanPrompt({
|
|
...payload,
|
|
recentScores,
|
|
effectiveNtrpRating: trainingProfileStatus.effectiveNtrp,
|
|
ntrpSource: trainingProfileStatus.ntrpSource,
|
|
assessmentSnapshot: trainingProfileStatus.assessmentSnapshot,
|
|
}),
|
|
},
|
|
],
|
|
responseFormat: {
|
|
type: "json_schema",
|
|
json_schema: {
|
|
name: "training_plan",
|
|
strict: true,
|
|
schema: {
|
|
type: "object",
|
|
properties: {
|
|
title: { type: "string" },
|
|
exercises: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
day: { type: "number" },
|
|
name: { type: "string" },
|
|
category: { type: "string" },
|
|
duration: { type: "number" },
|
|
description: { type: "string" },
|
|
tips: { type: "string" },
|
|
sets: { type: "number" },
|
|
reps: { type: "number" },
|
|
},
|
|
required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
required: ["title", "exercises"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
parse: (content) => normalizeTrainingPlanResponse({
|
|
content,
|
|
fallbackTitle: `${payload.durationDays}天训练计划`,
|
|
}),
|
|
timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS,
|
|
retryCount: TRAINING_PLAN_LLM_RETRY_COUNT,
|
|
});
|
|
|
|
const planId = await db.createTrainingPlan({
|
|
userId: task.userId,
|
|
title: parsed.title,
|
|
skillLevel: payload.skillLevel,
|
|
durationDays: payload.durationDays,
|
|
exercises: parsed.exercises,
|
|
isActive: 1,
|
|
version: 1,
|
|
});
|
|
|
|
return {
|
|
kind: "training_plan_generate" as const,
|
|
planId,
|
|
plan: parsed,
|
|
};
|
|
}
|
|
|
|
async function runTrainingPlanAdjustTask(task: NonNullable<TaskRow>) {
|
|
const payload = task.payload as { planId: number };
|
|
const analyses = await db.getUserAnalyses(task.userId);
|
|
const recentAnalyses = analyses.slice(0, 5);
|
|
const currentPlan = (await db.getUserTrainingPlans(task.userId)).find((plan) => plan.id === payload.planId);
|
|
|
|
if (!currentPlan) {
|
|
throw new Error("Plan not found");
|
|
}
|
|
|
|
const parsed = await invokeStructured({
|
|
baseMessages: [
|
|
{ role: "system", content: "你是网球训练计划调整器。返回严格的 JSON 格式。" },
|
|
{
|
|
role: "user",
|
|
content: buildAdjustedTrainingPlanPrompt({
|
|
currentExercises: currentPlan.exercises,
|
|
recentAnalyses: recentAnalyses.map((analysis) => ({
|
|
score: analysis.overallScore ?? null,
|
|
issues: analysis.detectedIssues,
|
|
corrections: analysis.corrections,
|
|
shotCount: analysis.shotCount ?? null,
|
|
strokeConsistency: analysis.strokeConsistency ?? null,
|
|
footworkScore: analysis.footworkScore ?? null,
|
|
fluidityScore: analysis.fluidityScore ?? null,
|
|
})),
|
|
}),
|
|
},
|
|
],
|
|
responseFormat: {
|
|
type: "json_schema",
|
|
json_schema: {
|
|
name: "adjusted_plan",
|
|
strict: true,
|
|
schema: {
|
|
type: "object",
|
|
properties: {
|
|
title: { type: "string" },
|
|
adjustmentNotes: { type: "string" },
|
|
exercises: {
|
|
type: "array",
|
|
items: {
|
|
type: "object",
|
|
properties: {
|
|
day: { type: "number" },
|
|
name: { type: "string" },
|
|
category: { type: "string" },
|
|
duration: { type: "number" },
|
|
description: { type: "string" },
|
|
tips: { type: "string" },
|
|
sets: { type: "number" },
|
|
reps: { type: "number" },
|
|
},
|
|
required: ["day", "name", "category", "duration", "description", "tips", "sets", "reps"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
required: ["title", "adjustmentNotes", "exercises"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
},
|
|
parse: (content) => normalizeAdjustedPlanResponse({
|
|
content,
|
|
fallbackTitle: currentPlan.title,
|
|
}),
|
|
timeoutMs: TRAINING_PLAN_LLM_TIMEOUT_MS,
|
|
retryCount: TRAINING_PLAN_LLM_RETRY_COUNT,
|
|
});
|
|
|
|
await db.updateTrainingPlan(payload.planId, {
|
|
exercises: parsed.exercises,
|
|
adjustmentNotes: parsed.adjustmentNotes,
|
|
version: (currentPlan.version || 1) + 1,
|
|
});
|
|
|
|
return {
|
|
kind: "training_plan_adjust" as const,
|
|
planId: payload.planId,
|
|
plan: parsed,
|
|
adjustmentNotes: parsed.adjustmentNotes,
|
|
};
|
|
}
|
|
|
|
async function runTextCorrectionTask(task: NonNullable<TaskRow>) {
|
|
const payload = task.payload as {
|
|
exerciseType: string;
|
|
poseMetrics: unknown;
|
|
detectedIssues: unknown;
|
|
};
|
|
return createTextCorrectionResult(payload);
|
|
}
|
|
|
|
async function createTextCorrectionResult(payload: {
|
|
exerciseType: string;
|
|
poseMetrics: unknown;
|
|
detectedIssues: unknown;
|
|
}) {
|
|
const response = await invokeLLM({
|
|
messages: [
|
|
{
|
|
role: "system",
|
|
content: "你是一位专业网球技术教练。输出中文 Markdown,内容具体、克制、可执行。",
|
|
},
|
|
{
|
|
role: "user",
|
|
content: buildTextCorrectionPrompt(payload),
|
|
},
|
|
],
|
|
});
|
|
|
|
return {
|
|
kind: "analysis_corrections" as const,
|
|
corrections: contentToPlainText(response.choices[0]?.message?.content || "暂无建议"),
|
|
};
|
|
}
|
|
|
|
async function runMultimodalCorrectionTask(task: NonNullable<TaskRow>) {
|
|
const payload = task.payload as {
|
|
exerciseType: string;
|
|
poseMetrics: unknown;
|
|
detectedIssues: unknown;
|
|
imageUrls: string[];
|
|
};
|
|
try {
|
|
const report = await invokeStructured({
|
|
model: ENV.llmVisionModel,
|
|
baseMessages: [
|
|
{ role: "system", content: "你是专业网球教练。请基于图片和结构化姿态指标输出严格 JSON。" },
|
|
{
|
|
role: "user",
|
|
content: [
|
|
{ type: "text", text: buildMultimodalCorrectionPrompt({
|
|
exerciseType: payload.exerciseType,
|
|
poseMetrics: payload.poseMetrics,
|
|
detectedIssues: payload.detectedIssues,
|
|
imageCount: payload.imageUrls.length,
|
|
}) },
|
|
...payload.imageUrls.map((url) => ({
|
|
type: "image_url" as const,
|
|
image_url: {
|
|
url,
|
|
detail: "high" as const,
|
|
},
|
|
})),
|
|
],
|
|
},
|
|
],
|
|
responseFormat: {
|
|
type: "json_schema",
|
|
json_schema: {
|
|
name: "pose_correction_multimodal",
|
|
strict: true,
|
|
schema: multimodalCorrectionSchema,
|
|
},
|
|
},
|
|
parse: (content) => normalizeMultimodalCorrectionReport(extractStructuredJsonContent(content)),
|
|
});
|
|
|
|
const result = {
|
|
kind: "pose_correction_multimodal" as const,
|
|
imageUrls: payload.imageUrls,
|
|
report,
|
|
corrections: renderMultimodalCorrectionMarkdown(report as Parameters<typeof renderMultimodalCorrectionMarkdown>[0]),
|
|
visionStatus: "ok" as const,
|
|
};
|
|
|
|
await db.completeVisionTestRun(task.id, {
|
|
visionStatus: "ok",
|
|
summary: (report as { summary?: string }).summary ?? null,
|
|
corrections: result.corrections,
|
|
report,
|
|
warning: null,
|
|
});
|
|
|
|
return result;
|
|
} catch (error) {
|
|
const fallback = await createTextCorrectionResult(payload);
|
|
const result = {
|
|
kind: "pose_correction_multimodal" as const,
|
|
imageUrls: payload.imageUrls,
|
|
report: null,
|
|
corrections: fallback.corrections,
|
|
visionStatus: "fallback" as const,
|
|
warning: error instanceof Error ? error.message : "Vision model unavailable",
|
|
};
|
|
|
|
await db.completeVisionTestRun(task.id, {
|
|
visionStatus: "fallback",
|
|
summary: null,
|
|
corrections: result.corrections,
|
|
report: null,
|
|
warning: result.warning,
|
|
});
|
|
|
|
return result;
|
|
}
|
|
}
|
|
|
|
async function runMediaFinalizeTask(task: NonNullable<TaskRow>) {
|
|
const payload = task.payload as {
|
|
sessionId: string;
|
|
title: string;
|
|
exerciseType?: string;
|
|
sessionMode?: "practice" | "pk";
|
|
durationMinutes?: number;
|
|
actionCount?: number;
|
|
actionSummary?: Record<string, number>;
|
|
dominantAction?: string;
|
|
validityStatus?: string;
|
|
invalidReason?: string;
|
|
};
|
|
const session = await getRemoteMediaSession(payload.sessionId);
|
|
|
|
if (session.userId !== String(task.userId)) {
|
|
throw new Error("Media session does not belong to the task user");
|
|
}
|
|
|
|
if (session.archiveStatus === "queued") {
|
|
await db.rescheduleBackgroundTask(task.id, {
|
|
progress: 45,
|
|
message: "录制文件已入队,等待归档",
|
|
delayMs: 4_000,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
if (session.archiveStatus === "processing") {
|
|
await db.rescheduleBackgroundTask(task.id, {
|
|
progress: 78,
|
|
message: "录制文件正在整理与转码",
|
|
delayMs: 4_000,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
if (session.archiveStatus === "failed") {
|
|
throw new Error(session.lastError || "Media archive failed");
|
|
}
|
|
|
|
if (!session.playback.ready) {
|
|
await db.rescheduleBackgroundTask(task.id, {
|
|
progress: 70,
|
|
message: "等待回放文件就绪",
|
|
delayMs: 4_000,
|
|
});
|
|
return null;
|
|
}
|
|
|
|
const preferredUrl = session.playback.mp4Url || session.playback.webmUrl;
|
|
const format = session.playback.mp4Url ? "mp4" : "webm";
|
|
if (!preferredUrl) {
|
|
throw new Error("Media session did not expose a playback URL");
|
|
}
|
|
|
|
const fileKey = `media/sessions/${session.id}/recording.${format}`;
|
|
const existing = await db.getVideoByFileKey(task.userId, fileKey);
|
|
if (existing) {
|
|
return {
|
|
kind: "media_finalize" as const,
|
|
sessionId: session.id,
|
|
videoId: existing.id,
|
|
url: existing.url,
|
|
fileKey,
|
|
format,
|
|
};
|
|
}
|
|
|
|
const publicUrl = toPublicUrl(preferredUrl);
|
|
const videoId = await db.createVideo({
|
|
userId: task.userId,
|
|
title: payload.title || session.title,
|
|
fileKey,
|
|
url: publicUrl,
|
|
format,
|
|
fileSize: format === "mp4" ? (session.playback.mp4Size ?? null) : (session.playback.webmSize ?? null),
|
|
duration: null,
|
|
exerciseType: payload.exerciseType || "recording",
|
|
analysisStatus: "completed",
|
|
});
|
|
|
|
await syncRecordingTrainingData({
|
|
userId: task.userId,
|
|
videoId,
|
|
exerciseType: payload.exerciseType || "unknown",
|
|
title: payload.title || session.title,
|
|
sessionMode: payload.sessionMode || "practice",
|
|
durationMinutes: payload.durationMinutes ?? 5,
|
|
actionCount: payload.actionCount ?? 0,
|
|
actionSummary: payload.actionSummary ?? {},
|
|
dominantAction: payload.dominantAction ?? null,
|
|
validityStatus: payload.validityStatus ?? "pending",
|
|
invalidReason: payload.invalidReason ?? null,
|
|
});
|
|
|
|
return {
|
|
kind: "media_finalize" as const,
|
|
sessionId: session.id,
|
|
videoId,
|
|
url: publicUrl,
|
|
fileKey,
|
|
format,
|
|
};
|
|
}
|
|
|
|
async function runNtrpRefreshUserTask(task: NonNullable<TaskRow>) {
|
|
const payload = task.payload as { targetUserId?: number };
|
|
const targetUserId = payload.targetUserId ?? task.userId;
|
|
const snapshot = await refreshUserNtrp(targetUserId, { triggerType: "manual", taskId: task.id });
|
|
return {
|
|
kind: "ntrp_refresh_user" as const,
|
|
targetUserId,
|
|
snapshot,
|
|
};
|
|
}
|
|
|
|
async function runNtrpRefreshAllTask(task: NonNullable<TaskRow>) {
|
|
const results = await refreshAllUsersNtrp({ triggerType: "daily", taskId: task.id });
|
|
return {
|
|
kind: "ntrp_refresh_all" as const,
|
|
refreshedUsers: results.length,
|
|
results,
|
|
};
|
|
}
|
|
|
|
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":
|
|
return runTrainingPlanGenerateTask(task);
|
|
case "training_plan_adjust":
|
|
return runTrainingPlanAdjustTask(task);
|
|
case "analysis_corrections":
|
|
return runTextCorrectionTask(task);
|
|
case "pose_correction_multimodal":
|
|
return runMultimodalCorrectionTask(task);
|
|
case "media_finalize":
|
|
return runMediaFinalizeTask(task);
|
|
case "ntrp_refresh_user":
|
|
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)}`);
|
|
}
|
|
}
|