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