Add market watch and match hub workflows

这个提交包含在:
cryptocommuniums-afk
2026-04-07 11:00:03 +08:00
父节点 495da60212
当前提交 32ffad1545
修改 39 个文件,包含 6974 行新增330 行删除

查看文件

@@ -0,0 +1,39 @@
ALTER TABLE `tutorial_videos`
ADD `slug` varchar(128),
ADD `topicArea` varchar(32) DEFAULT 'tennis_skill',
ADD `contentFormat` varchar(16) DEFAULT 'video',
ADD `sourcePlatform` varchar(16) DEFAULT 'none',
ADD `heroSummary` text,
ADD `externalUrl` text,
ADD `platformVideoId` varchar(64),
ADD `estimatedEffortMinutes` int,
ADD `prerequisites` json,
ADD `learningObjectives` json,
ADD `stepSections` json,
ADD `deliverables` json,
ADD `relatedDocPaths` json,
ADD `viewCount` int,
ADD `commentCount` int,
ADD `metricsFetchedAt` timestamp NULL,
ADD `completionAchievementKey` varchar(64),
ADD `isFeatured` int DEFAULT 0,
ADD `featuredOrder` int DEFAULT 0;
--> statement-breakpoint
ALTER TABLE `tutorial_progress`
ADD `completed` int DEFAULT 0,
ADD `completedAt` timestamp NULL;
--> statement-breakpoint
UPDATE `tutorial_videos`
SET
`topicArea` = COALESCE(`topicArea`, 'tennis_skill'),
`contentFormat` = COALESCE(`contentFormat`, 'video'),
`sourcePlatform` = COALESCE(`sourcePlatform`, 'none'),
`heroSummary` = COALESCE(`heroSummary`, `description`),
`estimatedEffortMinutes` = COALESCE(`estimatedEffortMinutes`, CASE WHEN `duration` IS NULL THEN NULL ELSE ROUND(`duration` / 60) END),
`isFeatured` = COALESCE(`isFeatured`, 0),
`featuredOrder` = COALESCE(`featuredOrder`, 0);
--> statement-breakpoint
UPDATE `tutorial_progress`
SET
`completed` = CASE WHEN `watched` = 1 THEN 1 ELSE COALESCE(`completed`, 0) END,
`completedAt` = CASE WHEN `watched` = 1 AND `completedAt` IS NULL THEN `updatedAt` ELSE `completedAt` END;

查看文件

@@ -0,0 +1,14 @@
ALTER TABLE `users`
ADD `manualNtrpRating` float,
ADD `manualNtrpCapturedAt` timestamp NULL,
ADD `heightCm` float,
ADD `weightKg` float,
ADD `sprintSpeedScore` int,
ADD `explosivePowerScore` int,
ADD `agilityScore` int,
ADD `enduranceScore` int,
ADD `flexibilityScore` int,
ADD `coreStabilityScore` int,
ADD `shoulderMobilityScore` int,
ADD `hipMobilityScore` int,
ADD `assessmentNotes` text;

查看文件

@@ -0,0 +1,86 @@
CREATE TABLE `racket_listings` (
`id` int AUTO_INCREMENT NOT NULL,
`source` enum('xianyu','jd','zhuanzhuan') NOT NULL,
`sourceListingId` varchar(128) NOT NULL,
`title` varchar(512) NOT NULL,
`description` text,
`listingUrl` text NOT NULL,
`imageUrl` text,
`price` float NOT NULL,
`originalPrice` float,
`sellerName` varchar(128),
`location` varchar(128),
`publishedAtRaw` varchar(128),
`brand` varchar(64),
`model` varchar(128),
`series` varchar(128),
`category` varchar(64),
`weightGram` float,
`conditionLevel` enum('brand_new','almost_new','used_good','used_fair','unknown') NOT NULL DEFAULT 'unknown',
`gradeLevel` enum('high_value','standard','overpriced','pending_review') NOT NULL DEFAULT 'pending_review',
`gradeReason` text,
`isLowPriceCandidate` int NOT NULL DEFAULT 0,
`fingerprint` varchar(128) NOT NULL,
`extra` json,
`fetchedAt` timestamp NOT NULL DEFAULT (now()),
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `racket_listings_id` PRIMARY KEY(`id`),
CONSTRAINT `racket_listings_source_listing_idx` UNIQUE(`source`,`sourceListingId`),
CONSTRAINT `racket_listings_fingerprint_idx` UNIQUE(`fingerprint`)
);
--> statement-breakpoint
CREATE TABLE `racket_watch_rules` (
`id` int AUTO_INCREMENT NOT NULL,
`userId` int NOT NULL,
`title` varchar(256) NOT NULL,
`brand` varchar(64) NOT NULL,
`modelKeyword` varchar(128),
`seriesKeyword` varchar(128),
`category` varchar(64),
`weightMinGram` float,
`weightMaxGram` float,
`targetPrice` float NOT NULL,
`pushEnabled` int NOT NULL DEFAULT 1,
`isActive` int NOT NULL DEFAULT 1,
`lastCheckedAt` timestamp,
`lastMatchedAt` timestamp,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `racket_watch_rules_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `racket_watch_hits` (
`id` int AUTO_INCREMENT NOT NULL,
`watchRuleId` int NOT NULL,
`userId` int NOT NULL,
`listingId` int NOT NULL,
`matchedPrice` float NOT NULL,
`status` enum('matched','push_queued','pushed','suppressed') NOT NULL DEFAULT 'matched',
`firstMatchedAt` timestamp NOT NULL DEFAULT (now()),
`lastMatchedAt` timestamp NOT NULL DEFAULT (now()),
`lastPushPrice` float,
`pushedAt` timestamp,
`pushCount` int NOT NULL DEFAULT 0,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `racket_watch_hits_id` PRIMARY KEY(`id`),
CONSTRAINT `racket_watch_hits_rule_listing_idx` UNIQUE(`watchRuleId`,`listingId`)
);
--> statement-breakpoint
ALTER TABLE `background_tasks`
MODIFY COLUMN `type` enum(
'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'
) NOT NULL;

90
drizzle/0013_match_hub.sql 普通文件
查看文件

@@ -0,0 +1,90 @@
CREATE TABLE `match_sessions` (
`id` int AUTO_INCREMENT NOT NULL,
`createdByUserId` int NOT NULL,
`matchMode` enum('daily','competitive') NOT NULL DEFAULT 'daily',
`workflowStatus` enum('draft','recording','review_pending','reviewed','finalizing','finalized','cancelled') NOT NULL DEFAULT 'draft',
`title` varchar(256) NOT NULL,
`courtName` varchar(128),
`notes` text,
`durationMinutes` int NOT NULL DEFAULT 90,
`scheduledAt` timestamp,
`startedAt` timestamp,
`endedAt` timestamp,
`suggestionStatus` enum('idle','queued','ready','failed') NOT NULL DEFAULT 'idle',
`suggestionTaskId` varchar(64),
`suggestedScore` json,
`suggestedMetrics` json,
`finalScore` json,
`finalMetrics` json,
`reviewNotes` text,
`reviewSubmittedAt` timestamp,
`reviewedByUserId` int,
`reviewedAt` timestamp,
`finalizedByUserId` int,
`finalizedAt` timestamp,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `match_sessions_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `match_participants` (
`id` int AUTO_INCREMENT NOT NULL,
`matchId` int NOT NULL,
`userId` int NOT NULL,
`playerSlot` enum('player_a','player_b') NOT NULL,
`cameraSlot` enum('camera_a','camera_b') NOT NULL,
`cameraStatus` enum('pending','bound','active','completed','failed') NOT NULL DEFAULT 'pending',
`cameraLabel` varchar(128),
`cameraVideoId` int,
`cameraVideoUrl` text,
`cameraSnapshot` json,
`isWinner` int NOT NULL DEFAULT 0,
`suggestedSetsWon` int NOT NULL DEFAULT 0,
`suggestedGamesWon` int NOT NULL DEFAULT 0,
`suggestedPointsWon` int NOT NULL DEFAULT 0,
`finalSetsWon` int NOT NULL DEFAULT 0,
`finalGamesWon` int NOT NULL DEFAULT 0,
`finalPointsWon` int NOT NULL DEFAULT 0,
`suggestedStats` json,
`finalStats` json,
`createdAt` timestamp NOT NULL DEFAULT (now()),
`updatedAt` timestamp NOT NULL DEFAULT (now()) ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `match_participants_id` PRIMARY KEY(`id`),
CONSTRAINT `match_participants_match_player_idx` UNIQUE(`matchId`,`playerSlot`),
CONSTRAINT `match_participants_match_user_idx` UNIQUE(`matchId`,`userId`)
);
--> statement-breakpoint
CREATE TABLE `match_score_events` (
`id` int AUTO_INCREMENT NOT NULL,
`matchId` int NOT NULL,
`eventIndex` int NOT NULL,
`source` enum('camera_a','camera_b','system','admin') NOT NULL DEFAULT 'system',
`eventType` enum('point','game','set','metric','score_suggestion','review_adjustment','finalized') NOT NULL,
`winnerSlot` enum('player_a','player_b'),
`matchSecond` int,
`confidence` float,
`payload` json,
`createdByUserId` int,
`createdAt` timestamp NOT NULL DEFAULT (now()),
CONSTRAINT `match_score_events_id` PRIMARY KEY(`id`),
CONSTRAINT `match_score_events_match_event_idx` UNIQUE(`matchId`,`eventIndex`)
);
--> statement-breakpoint
ALTER TABLE `background_tasks`
MODIFY COLUMN `type` enum(
'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'
) NOT NULL;

查看文件

@@ -85,6 +85,20 @@
"when": 1773691200000,
"tag": "0011_live_analysis_runtime",
"breakpoints": true
},
{
"idx": 12,
"version": "5",
"when": 1773955200000,
"tag": "0012_market_watch",
"breakpoints": true
},
{
"idx": 13,
"version": "5",
"when": 1774569600000,
"tag": "0013_match_hub",
"breakpoints": true
}
]
}

查看文件

@@ -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(),