Add market watch and match hub workflows
这个提交包含在:
@@ -280,6 +280,95 @@ export const liveActionSegments = mysqlTable("live_action_segments", {
|
||||
export type LiveActionSegment = typeof liveActionSegments.$inferSelect;
|
||||
export type InsertLiveActionSegment = typeof liveActionSegments.$inferInsert;
|
||||
|
||||
/**
|
||||
* Dual-player match sessions with admin-reviewed score settlement.
|
||||
*/
|
||||
export const matchSessions = mysqlTable("match_sessions", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
createdByUserId: int("createdByUserId").notNull(),
|
||||
matchMode: mysqlEnum("matchMode", ["daily", "competitive"]).default("daily").notNull(),
|
||||
workflowStatus: mysqlEnum("workflowStatus", ["draft", "recording", "review_pending", "reviewed", "finalizing", "finalized", "cancelled"]).default("draft").notNull(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
courtName: varchar("courtName", { length: 128 }),
|
||||
notes: text("notes"),
|
||||
durationMinutes: int("durationMinutes").default(90).notNull(),
|
||||
scheduledAt: timestamp("scheduledAt"),
|
||||
startedAt: timestamp("startedAt"),
|
||||
endedAt: timestamp("endedAt"),
|
||||
suggestionStatus: mysqlEnum("suggestionStatus", ["idle", "queued", "ready", "failed"]).default("idle").notNull(),
|
||||
suggestionTaskId: varchar("suggestionTaskId", { length: 64 }),
|
||||
suggestedScore: json("suggestedScore"),
|
||||
suggestedMetrics: json("suggestedMetrics"),
|
||||
finalScore: json("finalScore"),
|
||||
finalMetrics: json("finalMetrics"),
|
||||
reviewNotes: text("reviewNotes"),
|
||||
reviewSubmittedAt: timestamp("reviewSubmittedAt"),
|
||||
reviewedByUserId: int("reviewedByUserId"),
|
||||
reviewedAt: timestamp("reviewedAt"),
|
||||
finalizedByUserId: int("finalizedByUserId"),
|
||||
finalizedAt: timestamp("finalizedAt"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type MatchSession = typeof matchSessions.$inferSelect;
|
||||
export type InsertMatchSession = typeof matchSessions.$inferInsert;
|
||||
|
||||
/**
|
||||
* Match-bound participants and their dedicated camera slot.
|
||||
*/
|
||||
export const matchParticipants = mysqlTable("match_participants", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
matchId: int("matchId").notNull(),
|
||||
userId: int("userId").notNull(),
|
||||
playerSlot: mysqlEnum("playerSlot", ["player_a", "player_b"]).notNull(),
|
||||
cameraSlot: mysqlEnum("cameraSlot", ["camera_a", "camera_b"]).notNull(),
|
||||
cameraStatus: mysqlEnum("cameraStatus", ["pending", "bound", "active", "completed", "failed"]).default("pending").notNull(),
|
||||
cameraLabel: varchar("cameraLabel", { length: 128 }),
|
||||
cameraVideoId: int("cameraVideoId"),
|
||||
cameraVideoUrl: text("cameraVideoUrl"),
|
||||
cameraSnapshot: json("cameraSnapshot"),
|
||||
isWinner: int("isWinner").default(0).notNull(),
|
||||
suggestedSetsWon: int("suggestedSetsWon").default(0).notNull(),
|
||||
suggestedGamesWon: int("suggestedGamesWon").default(0).notNull(),
|
||||
suggestedPointsWon: int("suggestedPointsWon").default(0).notNull(),
|
||||
finalSetsWon: int("finalSetsWon").default(0).notNull(),
|
||||
finalGamesWon: int("finalGamesWon").default(0).notNull(),
|
||||
finalPointsWon: int("finalPointsWon").default(0).notNull(),
|
||||
suggestedStats: json("suggestedStats"),
|
||||
finalStats: json("finalStats"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
matchPlayerUnique: uniqueIndex("match_participants_match_player_idx").on(table.matchId, table.playerSlot),
|
||||
matchUserUnique: uniqueIndex("match_participants_match_user_idx").on(table.matchId, table.userId),
|
||||
}));
|
||||
|
||||
export type MatchParticipant = typeof matchParticipants.$inferSelect;
|
||||
export type InsertMatchParticipant = typeof matchParticipants.$inferInsert;
|
||||
|
||||
/**
|
||||
* Match score and metric events from camera automation or admin review.
|
||||
*/
|
||||
export const matchScoreEvents = mysqlTable("match_score_events", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
matchId: int("matchId").notNull(),
|
||||
eventIndex: int("eventIndex").notNull(),
|
||||
source: mysqlEnum("source", ["camera_a", "camera_b", "system", "admin"]).default("system").notNull(),
|
||||
eventType: mysqlEnum("eventType", ["point", "game", "set", "metric", "score_suggestion", "review_adjustment", "finalized"]).notNull(),
|
||||
winnerSlot: mysqlEnum("winnerSlot", ["player_a", "player_b"]),
|
||||
matchSecond: int("matchSecond"),
|
||||
confidence: float("confidence"),
|
||||
payload: json("payload"),
|
||||
createdByUserId: int("createdByUserId"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
}, (table) => ({
|
||||
matchEventUnique: uniqueIndex("match_score_events_match_event_idx").on(table.matchId, table.eventIndex),
|
||||
}));
|
||||
|
||||
export type MatchScoreEvent = typeof matchScoreEvents.$inferSelect;
|
||||
export type InsertMatchScoreEvent = typeof matchScoreEvents.$inferInsert;
|
||||
|
||||
/**
|
||||
* Daily training aggregate used for streaks, achievements and daily NTRP refresh.
|
||||
*/
|
||||
@@ -523,6 +612,93 @@ export const notificationLog = mysqlTable("notification_log", {
|
||||
export type NotificationLogEntry = typeof notificationLog.$inferSelect;
|
||||
export type InsertNotificationLog = typeof notificationLog.$inferInsert;
|
||||
|
||||
/**
|
||||
* Normalized racket market listings aggregated from multiple public sources.
|
||||
*/
|
||||
export const racketListings = mysqlTable("racket_listings", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
source: mysqlEnum("source", ["xianyu", "jd", "zhuanzhuan"]).notNull(),
|
||||
sourceListingId: varchar("sourceListingId", { length: 128 }).notNull(),
|
||||
title: varchar("title", { length: 512 }).notNull(),
|
||||
description: text("description"),
|
||||
listingUrl: text("listingUrl").notNull(),
|
||||
imageUrl: text("imageUrl"),
|
||||
price: float("price").notNull(),
|
||||
originalPrice: float("originalPrice"),
|
||||
sellerName: varchar("sellerName", { length: 128 }),
|
||||
location: varchar("location", { length: 128 }),
|
||||
publishedAtRaw: varchar("publishedAtRaw", { length: 128 }),
|
||||
brand: varchar("brand", { length: 64 }),
|
||||
model: varchar("model", { length: 128 }),
|
||||
series: varchar("series", { length: 128 }),
|
||||
category: varchar("category", { length: 64 }),
|
||||
weightGram: float("weightGram"),
|
||||
conditionLevel: mysqlEnum("conditionLevel", ["brand_new", "almost_new", "used_good", "used_fair", "unknown"]).default("unknown").notNull(),
|
||||
gradeLevel: mysqlEnum("gradeLevel", ["high_value", "standard", "overpriced", "pending_review"]).default("pending_review").notNull(),
|
||||
gradeReason: text("gradeReason"),
|
||||
isLowPriceCandidate: int("isLowPriceCandidate").default(0).notNull(),
|
||||
fingerprint: varchar("fingerprint", { length: 128 }).notNull(),
|
||||
extra: json("extra"),
|
||||
fetchedAt: timestamp("fetchedAt").defaultNow().notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
sourceListingUnique: uniqueIndex("racket_listings_source_listing_idx").on(table.source, table.sourceListingId),
|
||||
fingerprintUnique: uniqueIndex("racket_listings_fingerprint_idx").on(table.fingerprint),
|
||||
}));
|
||||
|
||||
export type RacketListing = typeof racketListings.$inferSelect;
|
||||
export type InsertRacketListing = typeof racketListings.$inferInsert;
|
||||
|
||||
/**
|
||||
* User-defined racket watch rules for brand/model/price monitoring.
|
||||
*/
|
||||
export const racketWatchRules = mysqlTable("racket_watch_rules", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: int("userId").notNull(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
brand: varchar("brand", { length: 64 }).notNull(),
|
||||
modelKeyword: varchar("modelKeyword", { length: 128 }),
|
||||
seriesKeyword: varchar("seriesKeyword", { length: 128 }),
|
||||
category: varchar("category", { length: 64 }),
|
||||
weightMinGram: float("weightMinGram"),
|
||||
weightMaxGram: float("weightMaxGram"),
|
||||
targetPrice: float("targetPrice").notNull(),
|
||||
pushEnabled: int("pushEnabled").default(1).notNull(),
|
||||
isActive: int("isActive").default(1).notNull(),
|
||||
lastCheckedAt: timestamp("lastCheckedAt"),
|
||||
lastMatchedAt: timestamp("lastMatchedAt"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
});
|
||||
|
||||
export type RacketWatchRule = typeof racketWatchRules.$inferSelect;
|
||||
export type InsertRacketWatchRule = typeof racketWatchRules.$inferInsert;
|
||||
|
||||
/**
|
||||
* Historical watch hits to dedupe and manage push delivery state.
|
||||
*/
|
||||
export const racketWatchHits = mysqlTable("racket_watch_hits", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
watchRuleId: int("watchRuleId").notNull(),
|
||||
userId: int("userId").notNull(),
|
||||
listingId: int("listingId").notNull(),
|
||||
matchedPrice: float("matchedPrice").notNull(),
|
||||
status: mysqlEnum("status", ["matched", "push_queued", "pushed", "suppressed"]).default("matched").notNull(),
|
||||
firstMatchedAt: timestamp("firstMatchedAt").defaultNow().notNull(),
|
||||
lastMatchedAt: timestamp("lastMatchedAt").defaultNow().notNull(),
|
||||
lastPushPrice: float("lastPushPrice"),
|
||||
pushedAt: timestamp("pushedAt"),
|
||||
pushCount: int("pushCount").default(0).notNull(),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||
}, (table) => ({
|
||||
watchRuleListingUnique: uniqueIndex("racket_watch_hits_rule_listing_idx").on(table.watchRuleId, table.listingId),
|
||||
}));
|
||||
|
||||
export type RacketWatchHit = typeof racketWatchHits.$inferSelect;
|
||||
export type InsertRacketWatchHit = typeof racketWatchHits.$inferInsert;
|
||||
|
||||
/**
|
||||
* Background task queue for long-running or retryable work.
|
||||
*/
|
||||
@@ -537,6 +713,11 @@ export const backgroundTasks = mysqlTable("background_tasks", {
|
||||
"pose_correction_multimodal",
|
||||
"ntrp_refresh_user",
|
||||
"ntrp_refresh_all",
|
||||
"market_source_sync",
|
||||
"market_watch_refresh",
|
||||
"market_push_delivery",
|
||||
"match_score_suggest",
|
||||
"match_finalize",
|
||||
]).notNull(),
|
||||
status: mysqlEnum("status", ["queued", "running", "succeeded", "failed"]).notNull().default("queued"),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
|
||||
在新工单中引用
屏蔽一个用户