diff --git a/README.md b/README.md index ab5335c..780a3b8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ TOMEET 是一个可以长期对话的社交 Agent。 -Agent 通过用户主动提供的文本、图片、短录音和持续对话认识用户。当用户想社交时,系统使用 LLM 匹配合适的 3–10 人,并从人工策划的线下游戏中选择合适的游戏。活动结束后,用户向 Agent 反馈感受,Agent 据此持续对齐用户意图。 +Agent 通过用户主动提供的文本、图片、短录音和持续对话认识用户。当用户想社交时,系统先使用 LLM 贪心选择当前最高匹配的一位用户;双方接受后建立房间,再逐位邀请新用户直至满员或成员明确停止匹配。活动结束后,用户向 Agent 反馈感受,Agent 据此持续对齐用户意图。 ## 当前实现 @@ -176,8 +176,8 @@ flowchart LR Conversation["与 Agent 长期对话"] Understanding["持续认识用户"] Intent["用户表达社交意图"] - Match["LLM 匹配 3–10 人和线下游戏"] - Room["生成匹配房间"] + Match["LLM 贪心选择下一位用户"] + Room["双边接受后建房并逐位扩充"] Offline["线下参与游戏"] Feedback["向 Agent 反馈感受"] Alignment["更新用户理解和意图"] @@ -262,13 +262,13 @@ LLM 匹配任务只读取受治理的自然语言输入: 原始 profile、详细记忆、多模态原文、兴趣标签、`intentTags`、`traits`、性格类型、人口属性、关键词计数和标签分数都不会进入匹配模型输入。游戏目录可以保留运营元数据,但匹配只看到自然语言体验说明与硬性人数条件。 -LLM 输出结构化 `MatchDecision`: +初始 LLM 输出结构化 `MatchDecision`: -- 3–10 名成员。 +- 触发用户和当前最高匹配的另一位用户,共 2 人。 - 选中的线下游戏。 - 匹配判断摘要。 -系统校验成员仍在等待、没有重复分配、人数符合要求、游戏适合该人数后创建房间。 +系统先创建双边邀请,双方接受后才创建房间。房间处于 `active` 时,每次只为当前最高匹配的一位等待用户创建入房邀请;接受后原子加入并继续下一轮。达到 `capacity` 自动变为 `full`,任一成员明确说“停止匹配”等同义指令后变为 `stopped`。 ### Offline Game Catalog @@ -286,9 +286,10 @@ LLM 输出结构化 `MatchDecision`: 匹配房间只保存: -- 3–10 名成员。 +- 2–10 名成员。 - 选中的线下游戏。 - 成员确认状态。 +- 持续匹配状态和房间人数上限。 - 活动完成状态。 房间不承载在线游戏过程。 @@ -369,7 +370,9 @@ export interface MatchRequest { requestId: string; userId: string; intentSnapshot: Record; - status: "matching" | "matched" | "cancelled" | "expired"; + status: "matching" | "invited" | "matched" | "cancelled" | "expired"; + roomId: string | null; + inviteId: string | null; } export interface MatchDecision { @@ -383,6 +386,8 @@ export interface MatchRoom { memberIds: string[]; offlineGameId: string; status: "confirming" | "confirmed" | "completed"; + matchingStatus: "active" | "stopped" | "full"; + capacity: number; } export interface PostEventFeedback { @@ -404,9 +409,13 @@ POST /agent/multimodal-inputs POST /match-requests GET /match-requests/:id POST /match-requests/:id/cancel +GET /match-invites/:id +POST /match-invites/:id/accept +POST /match-invites/:id/decline GET /rooms/:id POST /rooms/:id/confirm +POST /rooms/:id/stop-match POST /rooms/:id/complete POST /rooms/:id/feedback ``` @@ -521,6 +530,8 @@ ADVENTUREX_MATCHING_V1 - 用户能通过 Agent 收到并自然语言选择 1–3 个现场活动候选。 - 系统只用明确接受的候选原子创建 3–10 人确认房间,并支持合适的开放局补位。 - 成员、集合信息和招募状态变化能通过幂等 Agent 消息主动通知。 +- LLM 能按贪心机制逐位选择候选用户并选择一款已有线下游戏。 +- 系统能在双边接受后建房,持续邀请新成员,并在满员或收到停止指令后终止匹配。 - 活动结束后,用户能向 Agent 反馈感受和连接结果。 - 反馈能更新用户意图,并影响下一次匹配和游戏选择。 diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 94e297c..e9dfbe2 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -475,8 +475,14 @@ describe("TOMEET core flow", () => { const requestId = socialResult.actions[0].matchRequest.requestId as string; const requestResponse = await app.inject({ method: "GET", url: `/match-requests/${requestId}` }); - const roomId = requestResponse.json().matchRequest.roomId as string; - expect(roomId).toBeTruthy(); + expect(requestResponse.json().matchRequest.status).toBe("invited"); + expect(requestResponse.json().matchRequest.roomId).toBeNull(); + expect(requestResponse.json().matchRequest.inviteId).toBeTruthy(); + + const acceptResponse = await send("接受匹配"); + expect(acceptResponse.json().job.result.actions[0].type).toBe("accept_match"); + expect(acceptResponse.json().job.result.actions[0].room.status).toBe("confirmed"); + const roomId = acceptResponse.json().job.result.actions[0].room.roomId as string; const duplicateRoomMatch = await app.inject({ method: "POST", @@ -486,10 +492,13 @@ describe("TOMEET core flow", () => { expect(duplicateRoomMatch.statusCode).toBe(409); const historyResponse = await app.inject({ method: "GET", url: `/agent/messages/${userId}` }); - expect(historyResponse.json().messages.some((message: { content: string }) => message.content.includes("匹配已经完成"))).toBe(true); + expect(historyResponse.json().messages.some( + (message: { content: string }) => message.content.includes("房间已建立") + )).toBe(true); - const confirmResponse = await send("确认参加,没问题"); - expect(confirmResponse.json().job.result.actions[0].room.status).toBe("confirmed"); + const stopResponse = await send("停止匹配,不要再往房间里加人了"); + expect(stopResponse.json().job.result.actions[0].type).toBe("stop_match"); + expect(stopResponse.json().job.result.actions[0].room.matchingStatus).toBe("stopped"); const completeResponse = await send("活动已经结束了"); expect(completeResponse.json().job.result.actions[0].room.status).toBe("completed"); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 67106d5..52da91f 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -208,6 +208,37 @@ export async function buildApp(options: BuildAppOptions) { return scheduleAdventurexMatchRequest(options.store, matchRequest); } + async function enqueueRoomContinuation(roomId: string, memberCount: number, reason: string): Promise { + const room = await options.store.getRoom(roomId); + if ( + !room + || room.status === "completed" + || room.matchingStatus !== "active" + || room.members.length >= room.capacity + ) return; + const job = await options.store.enqueueJob({ + type: "matchmaking", + payload: { roomId }, + idempotencyKey: `match:room:${roomId}:${memberCount}:${reason}`, + partitionKey: `room:${roomId}` + }); + await runInline(job.id); + } + + async function enqueueRequeuedRequests(requestIds: string[], reason: string): Promise { + await Promise.all(requestIds.map(async (requestId) => { + const matchRequest = await options.store.getMatchRequest(requestId); + if (!matchRequest || matchRequest.status !== "matching") return; + const job = await options.store.enqueueJob({ + type: "matchmaking", + payload: { requestId }, + idempotencyKey: `match:${requestId}:${reason}`, + partitionKey: `user:${matchRequest.userId}` + }); + await runInline(job.id); + })); + } + app.get("/health", { config: { rateLimit: false } }, async () => ({ status: "ok", service: "tomeet-api", @@ -655,7 +686,12 @@ export async function buildApp(options: BuildAppOptions) { } const currentJob = await runInline(job.id); const latestRequest = await options.store.getMatchRequest(matchRequest.requestId); - return reply.code(latestRequest?.status === "matched" ? 201 : 202).send({ matchRequest: latestRequest, job: currentJob }); + const invite = await options.store.getLatestMatchInviteForUser(input.userId); + return reply.code(latestRequest?.status === "matched" ? 201 : 202).send({ + matchRequest: latestRequest, + invite, + job: currentJob + }); }); app.get("/match-requests/:id", async (request) => { @@ -762,6 +798,49 @@ export async function buildApp(options: BuildAppOptions) { return { room }; }); + app.get("/match-invites/:id", async (request) => { + const { id } = z.object({ id: uuidSchema }).parse(request.params); + const invite = await options.store.getMatchInvite(id); + if (!invite) throw new StoreNotFoundError("匹配邀请不存在"); + if (request.authUserId && !invite.participants.some( + (participant) => participant.userId === request.authUserId + )) { + throw new StoreNotFoundError("匹配邀请不存在"); + } + return { invite }; + }); + + app.post("/match-invites/:id/accept", async (request) => { + const { id } = z.object({ id: uuidSchema }).parse(request.params); + const { userId } = z.object({ userId: uuidSchema }).parse(request.body); + assertCurrentUser(request, userId); + const resolution = await options.store.acceptMatchInvite(id, userId); + if (resolution.room) { + await enqueueRoomContinuation( + resolution.room.roomId, + resolution.room.members.length, + `api-accepted:${id}` + ); + } + return resolution; + }); + + app.post("/match-invites/:id/decline", async (request) => { + const { id } = z.object({ id: uuidSchema }).parse(request.params); + const { userId } = z.object({ userId: uuidSchema }).parse(request.body); + assertCurrentUser(request, userId); + const resolution = await options.store.declineMatchInvite(id, userId); + await enqueueRequeuedRequests(resolution.requeuedRequestIds, `api-declined:${id}`); + if (resolution.room) { + await enqueueRoomContinuation( + resolution.room.roomId, + resolution.room.members.length, + `api-declined:${id}` + ); + } + return resolution; + }); + async function getJob(request: FastifyRequest) { const { id } = z.object({ id: uuidSchema }).parse(request.params); const job = await options.store.getJob(id); @@ -819,6 +898,15 @@ export async function buildApp(options: BuildAppOptions) { }; }); + app.post("/rooms/:id/stop-match", async (request) => { + const { id } = z.object({ id: uuidSchema }).parse(request.params); + const { userId } = z.object({ userId: uuidSchema }).parse(request.body); + assertCurrentUser(request, userId); + const stopped = await options.store.stopRoomMatching(id, userId); + await enqueueRequeuedRequests(stopped.requeuedRequestIds, `api-room-stopped:${id}`); + return stopped; + }); + app.post("/rooms/:id/complete", async (request) => { const { id } = z.object({ id: uuidSchema }).parse(request.params); const room = await options.store.getRoom(id); diff --git a/apps/api/src/wechat-routes.test.ts b/apps/api/src/wechat-routes.test.ts index 225b4a0..86431be 100644 --- a/apps/api/src/wechat-routes.test.ts +++ b/apps/api/src/wechat-routes.test.ts @@ -561,6 +561,21 @@ describe("WeChat one-time QR onboarding", () => { expect(response.statusCode).toBe(200); } + for (const webUser of webUsers) { + const accepted = await app.inject({ + method: "POST", + url: "/agent/messages", + headers: { authorization: `Bearer ${webUser.token}` }, + payload: { + userId: webUser.userId, + displayName: webUser.displayName, + content: "接受匹配", + idempotencyKey: randomUUID() + } + }); + expect(accepted.statusCode).toBe(200); + } + const wechatResponse = await app.inject({ method: "POST", url: "/internal/agent/messages", @@ -574,6 +589,19 @@ describe("WeChat one-time QR onboarding", () => { }); expect(wechatResponse.statusCode).toBe(200); + const wechatAccepted = await app.inject({ + method: "POST", + url: "/internal/agent/messages", + headers: { "x-tomeet-internal-token": internalApiToken }, + payload: { + userId: wechatUserId, + displayName: "微信用户", + content: "接受邀请", + idempotencyKey: randomUUID() + } + }); + expect(wechatAccepted.statusCode).toBe(200); + const allUserIds = [...webUsers.map((user) => user.userId), wechatUserId]; const rooms = await Promise.all( allUserIds.map((userId) => store.getLatestRoomForUser(userId)) @@ -591,7 +619,7 @@ describe("WeChat one-time QR onboarding", () => { }); expect(history.statusCode).toBe(200); expect(history.json().messages.some( - (message: { content: string }) => message.content.includes("匹配已经完成") + (message: { content: string }) => message.content.includes("房间已建立") )).toBe(true); } @@ -602,7 +630,7 @@ describe("WeChat one-time QR onboarding", () => { }); expect(wechatHistory.statusCode).toBe(200); expect(wechatHistory.json().messages.some( - (message: { content: string }) => message.content.includes("匹配已经完成") + (message: { content: string }) => message.content.includes("已接受邀请并进入房间") )).toBe(true); }); diff --git a/apps/intelligence-worker/src/smoke.ts b/apps/intelligence-worker/src/smoke.ts index 49b2b6a..d648b3f 100644 --- a/apps/intelligence-worker/src/smoke.ts +++ b/apps/intelligence-worker/src/smoke.ts @@ -61,6 +61,8 @@ const room = { recruitmentStatus: "closed" as const, version: 0, meetingPoint: null, + matchingStatus: "active" as const, + capacity: 6, createdAt: new Date().toISOString(), completedAt: null }; @@ -104,6 +106,7 @@ const candidates = Array.from({ length: 5 }, (_, index) => { status: "matching" as const, proactivePushEnabled: false, roomId: null, + inviteId: null, createdAt: now, updatedAt: now }, diff --git a/docs/api.md b/docs/api.md index 14d061d..aeef657 100644 --- a/docs/api.md +++ b/docs/api.md @@ -266,11 +266,15 @@ await supabase.storage } ``` -也可显式传入 `intent` 对象。没有明确社交意图、存在未结束房间时返回 `409`。启用 `ADVENTUREX_MATCHING_V1=true` 后,请求进入下一个 30 秒后台清算 tick;该 tick 不是用户等待倒计时。只有真实候选发送成功后才开始 90 秒选择窗口。 +也可显式传入 `intent` 对象。没有明确社交意图、存在未结束房间时返回 `409`。 + +默认流程先按贪心机制选择当前最匹配的一位用户,创建双边邀请;双方接受后才创建房间。响应为 `{ "matchRequest": MatchRequest, "invite": MatchInvite | null, "job": LlmJob }`,等待或已发出邀请时返回 `202`,已进入房间时返回 `201`。 + +启用 `ADVENTUREX_MATCHING_V1=true` 后,请求进入下一个 30 秒后台清算 tick;该 tick 不是用户等待倒计时。只有真实候选发送成功后才开始 90 秒选择窗口。 ### `GET /match-requests/:id` -返回 `{ "matchRequest": MatchRequest }`。`status` 为 `matching | matched | cancelled | expired`;其中 `expired` 表示候选窗口结束后本次请求未成局且没有保留主动推送授权。活跃请求另有 `phase=waiting | offered | selected | settling | push_consent | watching`,并通过 `proactivePushEnabled` 表示是否允许微信主动推送。匹配成功时 `roomId` 非空。 +返回 `{ "matchRequest": MatchRequest }`。`status` 为 `matching | invited | matched | cancelled | expired`;`invited` 时 `inviteId` 非空,进入房间后 `roomId` 非空。`expired` 表示候选窗口结束后本次请求未成局且没有保留主动推送授权。活跃请求另有 `phase=waiting | offered | selected | settling | push_consent | watching`,并通过 `proactivePushEnabled` 表示是否允许微信主动推送。 ### `GET /match-requests/:id/options` @@ -316,9 +320,17 @@ await supabase.storage 该接口同时要求正常 Supabase Bearer 登录和 `ADVENTUREX_TEST_POOL_EMAIL` 邮箱白名单。未配置 `ADVENTUREX_TEST_POOL_ENABLED=true` 时返回 `503`,非白名单账号返回 `403`。测试用户只会进入所有者的 `adventurex-test::` 隔离轮次。 -### `POST /match-requests/:id/cancel` +### `GET /match-invites/:id` + +仅邀请参与者可读取,返回 `{ "invite": MatchInvite }`。 + +### `POST /match-invites/:id/accept` -无请求体。只能取消 `matching` 状态,响应为 `{ "matchRequest": MatchRequest, "canRematch": true }`。 +请求体为 `{ "userId": "UUID" }`。初始邀请需双方接受才会原子建房;入房邀请由候选用户接受后原子加入。返回 `{ "invite": MatchInvite, "room": MatchRoom | null, "requeuedRequestIds": string[] }`。 + +### `POST /match-invites/:id/decline` + +请求体为 `{ "userId": "UUID" }`。初始邀请被拒绝时,拒绝方请求取消,另一方重新进入队列;入房邀请被拒绝时,房间保持持续匹配。 ## 9. 房间与反馈 @@ -346,6 +358,10 @@ AdventureX 新流程中,用户对候选的选择已构成参加意愿,最终 成员行保留并标记为 `withdrawn`,房间 `version` 增加;有空位时重新开放招募。退出用户不会再次收到同一个开放局。剩余确认成员通过 Agent 消息收到一次幂等变化通知。响应包含 `canRematch=false`、最新 `matchRequest` 与 `interestState`:此前已授权主动推送时请求变为 `matching/watching`,否则变为 `cancelled`,且不会自动询问或启动重新匹配。 +### `POST /rooms/:id/stop-match` + +请求体为 `{ "userId": "UUID" }`。任一在房成员可停止继续加人;房间 `matchingStatus` 变为 `stopped`,待处理的入房邀请被取消,其候选请求重新入队。达到 `capacity` 时房间自动变为 `full`。 + ### `POST /rooms/:id/complete` 无请求体。当前登录用户必须是房间成员,且所有成员已确认。重复调用是幂等的。 diff --git a/docs/architecture.md b/docs/architecture.md index 332005a..1d3c42e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -124,13 +124,13 @@ Agent 首轮只冻结回复草稿、当前意图、产品 action、记忆检索 匹配输入明确排除兴趣标签、`intentTags`、`traits`、性格分类、人口属性、关键词计数和标签分数。LLM 只能根据多模态材料形成的整体表达节奏、能量、关系距离和可能的线下互动流动做判断。 -LLM 直接输出: +初始匹配时,LLM 直接输出: -- 3–10 名成员。 +- 触发用户和当前最高匹配的另一位用户。 - 一款已有线下游戏。 - 匹配判断摘要。 -API 在事务中校验请求仍然有效、成员没有重复分配、人数符合要求、游戏支持该人数,然后写入 `MatchRoom` 和 `RoomMembers`。 +API 在事务中创建双边邀请,双方接受后才写入 2 人 `MatchRoom`。之后每轮只选择一名最高匹配的等待用户并创建入房邀请;接受后原子写入 `RoomMembers`。房间达到 `capacity` 后自动 `full`,或由任一成员通过语义 action `stop_match` 变为 `stopped`。 ## 后台任务 diff --git a/docs/frontend-api-integration.md b/docs/frontend-api-integration.md index 0112ac0..5b65595 100644 --- a/docs/frontend-api-integration.md +++ b/docs/frontend-api-integration.md @@ -153,12 +153,30 @@ export interface MatchRequest { requestId: string; userId: string; intentSnapshot: Record; - status: "matching" | "matched" | "cancelled"; + status: "matching" | "invited" | "matched" | "cancelled"; roomId: string | null; + inviteId: string | null; createdAt: string; updatedAt: string; } +export interface MatchInvite { + inviteId: string; + kind: "initial_pair" | "room_join"; + roomId: string | null; + participants: Array<{ + userId: string; + requestId: string; + displayName: string; + accepted: boolean; + }>; + offlineGameId: string; + matchSummary: string; + status: "pending" | "accepted" | "declined" | "cancelled"; + createdAt: string; + resolvedAt: string | null; +} + export interface OfflineGame { id: string; name: string; @@ -183,6 +201,8 @@ export interface MatchRoom { offlineGame: OfflineGame; matchSummary: string; status: "confirming" | "confirmed" | "completed"; + matchingStatus: "active" | "stopped" | "full"; + capacity: number; createdAt: string; completedAt: string | null; } @@ -204,9 +224,13 @@ export interface MatchRoom { | `POST` | `/match-requests` | Bearer | 201/202 | 创建匹配请求 | | `GET` | `/match-requests/:id` | Bearer | 200 | 查询匹配状态 | | `POST` | `/match-requests/:id/cancel` | Bearer | 200 | 取消等待中的匹配 | +| `GET` | `/match-invites/:id` | Bearer | 200 | 查询双边或入房邀请 | +| `POST` | `/match-invites/:id/accept` | Bearer | 200 | 接受匹配邀请 | +| `POST` | `/match-invites/:id/decline` | Bearer | 200 | 拒绝匹配邀请 | | `GET` | `/jobs/:id` | Bearer | 200 | 查询异步任务 | | `GET` | `/rooms/:id` | Bearer | 200 | 查询房间 | | `POST` | `/rooms/:id/confirm` | Bearer | 200 | 确认参加 | +| `POST` | `/rooms/:id/stop-match` | Bearer | 200 | 停止房间继续匹配 | | `POST` | `/rooms/:id/complete` | Bearer | 200 | 标记活动完成 | | `POST` | `/rooms/:id/feedback` | Bearer | 200/202 | 提交活动反馈 | | `POST` | `/wechat/connect/sessions` | Bearer 推荐;可匿名 | 201 | 创建微信扫码会话并优先绑定当前 Web 用户 | @@ -482,12 +506,13 @@ interface OfflineGamesResponse { ```ts interface CreateMatchRequestResponse { matchRequest: MatchRequest | null; + invite: MatchInvite | null; job: LlmJob; } ``` -- `201`:本次请求内已经匹配成功 -- `202`:已进入异步匹配 +- `201`:用户已进入房间 +- `202`:已进入队列或已收到待处理邀请 - `409`:社交意图未确认,或用户仍有未结束房间 ### 8.2 查询匹配 @@ -500,7 +525,7 @@ interface MatchRequestResponse { } ``` -当 `status === "matched"` 时,读取 `roomId` 并调用房间接口。 +当 `status === "invited"` 时读取 `inviteId`;当 `status === "matched"` 时读取 `roomId` 并调用房间接口。 ### 8.3 取消匹配 @@ -508,6 +533,14 @@ interface MatchRequestResponse { 无请求体,只能取消 `matching` 状态。 +### 8.4 处理匹配邀请 + +- `GET /match-invites/:id` +- `POST /match-invites/:id/accept`,请求体 `{ "userId": "UUID" }` +- `POST /match-invites/:id/decline`,请求体 `{ "userId": "UUID" }` + +初始邀请需要双方接受,第二位接受后响应中的 `room` 才会非空。`room_join` 邀请只需要被邀请的新用户接受。 + ## 9. 房间和反馈 ### 9.1 获取房间 @@ -536,13 +569,23 @@ interface RoomResponse { 所有成员确认后,房间状态变为 `confirmed`。 -### 9.3 标记完成 +### 9.3 停止房间匹配 + +`POST /rooms/:id/stop-match` + +```json +{ "userId": "UUID" } +``` + +任一房间成员都可以调用。停止后 `matchingStatus === "stopped"`;达到 `capacity` 时后端自动设置为 `full`。 + +### 9.4 标记完成 `POST /rooms/:id/complete` 无请求体。当前用户必须是房间成员,且房间状态允许完成。重复调用按后端业务状态处理。 -### 9.4 提交反馈 +### 9.5 提交反馈 `POST /rooms/:id/feedback` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1bb178d..2e376e0 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -497,6 +497,77 @@ paths: description: 已加入开放局 "409": $ref: "#/components/responses/Conflict" + /match-invites/{id}: + get: + tags: [Matchmaking] + summary: 查询匹配邀请 + parameters: + - $ref: "#/components/parameters/ResourceId" + responses: + "200": + description: 当前用户参与的匹配邀请 + content: + application/json: + schema: + type: object + required: [invite] + properties: + invite: + $ref: "#/components/schemas/MatchInvite" + "401": + $ref: "#/components/responses/Unauthenticated" + "404": + $ref: "#/components/responses/NotFound" + /match-invites/{id}/accept: + post: + tags: [Matchmaking] + summary: 接受初始匹配或入房邀请 + parameters: + - $ref: "#/components/parameters/ResourceId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserIdBody" + responses: + "200": + description: 邀请接受结果;初始邀请双方接受后才创建房间 + content: + application/json: + schema: + $ref: "#/components/schemas/MatchInviteResolution" + "401": + $ref: "#/components/responses/Unauthenticated" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" + /match-invites/{id}/decline: + post: + tags: [Matchmaking] + summary: 拒绝初始匹配或入房邀请 + parameters: + - $ref: "#/components/parameters/ResourceId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserIdBody" + responses: + "200": + description: 邀请拒绝及重新入队结果 + content: + application/json: + schema: + $ref: "#/components/schemas/MatchInviteResolution" + "401": + $ref: "#/components/responses/Unauthenticated" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" /jobs/{id}: get: tags: [Jobs] @@ -570,6 +641,39 @@ paths: $ref: "#/components/responses/Forbidden" "409": $ref: "#/components/responses/Conflict" + /rooms/{id}/stop-match: + post: + tags: [Rooms] + summary: 停止房间继续匹配新成员 + parameters: + - $ref: "#/components/parameters/ResourceId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserIdBody" + responses: + "200": + description: 已停止继续匹配;待处理候选重新入队 + content: + application/json: + schema: + type: object + required: [room, requeuedRequestIds] + properties: + room: + $ref: "#/components/schemas/MatchRoom" + requeuedRequestIds: + type: array + items: + $ref: "#/components/schemas/Uuid" + "401": + $ref: "#/components/responses/Unauthenticated" + "403": + $ref: "#/components/responses/Forbidden" + "409": + $ref: "#/components/responses/Conflict" /rooms/{id}/complete: post: tags: [Rooms] @@ -1214,7 +1318,7 @@ components: format: date-time MatchRequest: type: object - required: [requestId, userId, intentSnapshot, status, phase, proactivePushEnabled, activeRoundId, optionsExpiresAt, roomId, createdAt, updatedAt] + required: [requestId, userId, intentSnapshot, status, phase, proactivePushEnabled, activeRoundId, optionsExpiresAt, roomId, inviteId, createdAt, updatedAt] properties: requestId: $ref: "#/components/schemas/Uuid" @@ -1224,7 +1328,7 @@ components: $ref: "#/components/schemas/JsonObject" status: type: string - enum: [matching, matched, cancelled, expired] + enum: [matching, invited, matched, cancelled, expired] phase: type: string enum: [waiting, offered, selected, settling, push_consent, watching] @@ -1239,12 +1343,72 @@ components: roomId: type: [string, "null"] format: uuid + inviteId: + type: [string, "null"] + format: uuid createdAt: type: string format: date-time updatedAt: type: string format: date-time + MatchInviteParticipant: + type: object + required: [userId, requestId, displayName, accepted] + properties: + userId: + $ref: "#/components/schemas/Uuid" + requestId: + $ref: "#/components/schemas/Uuid" + displayName: + type: string + accepted: + type: boolean + MatchInvite: + type: object + required: [inviteId, kind, roomId, participants, offlineGameId, matchSummary, status, createdAt, resolvedAt] + properties: + inviteId: + $ref: "#/components/schemas/Uuid" + kind: + type: string + enum: [initial_pair, room_join] + roomId: + type: [string, "null"] + format: uuid + participants: + type: array + minItems: 1 + maxItems: 2 + items: + $ref: "#/components/schemas/MatchInviteParticipant" + offlineGameId: + type: string + matchSummary: + type: string + status: + type: string + enum: [pending, accepted, declined, cancelled] + createdAt: + type: string + format: date-time + resolvedAt: + type: [string, "null"] + format: date-time + MatchInviteResolution: + type: object + required: [invite, room, requeuedRequestIds] + properties: + invite: + $ref: "#/components/schemas/MatchInvite" + room: + oneOf: + - $ref: "#/components/schemas/MatchRoom" + - type: "null" + requeuedRequestIds: + type: array + items: + $ref: "#/components/schemas/Uuid" RoomMember: type: object required: [userId, displayName, confirmed, participationStatus] @@ -1260,13 +1424,13 @@ components: enum: [invited, confirmed, withdrawn] MatchRoom: type: object - required: [roomId, members, offlineGame, matchSummary, status, sourceDraftId, targetPlayers, recruitmentStatus, version, meetingPoint, createdAt, completedAt] + required: [roomId, members, offlineGame, matchSummary, status, sourceDraftId, targetPlayers, recruitmentStatus, version, meetingPoint, matchingStatus, capacity, createdAt, completedAt] properties: roomId: $ref: "#/components/schemas/Uuid" members: type: array - minItems: 3 + minItems: 2 maxItems: 10 items: $ref: "#/components/schemas/RoomMember" @@ -1292,6 +1456,13 @@ components: minimum: 0 meetingPoint: type: [string, "null"] + matchingStatus: + type: string + enum: [active, stopped, full] + capacity: + type: integer + minimum: 2 + maximum: 10 createdAt: type: string format: date-time @@ -1514,12 +1685,16 @@ components: description: 当前版本保留字段 CreateMatchRequestResponse: type: object - required: [matchRequest, job] + required: [matchRequest, invite, job] properties: matchRequest: oneOf: - $ref: "#/components/schemas/MatchRequest" - type: "null" + invite: + oneOf: + - $ref: "#/components/schemas/MatchInvite" + - type: "null" job: $ref: "#/components/schemas/LlmJob" UserIdBody: diff --git a/docs/product-flow.md b/docs/product-flow.md index d53ed7f..302a5c8 100644 --- a/docs/product-flow.md +++ b/docs/product-flow.md @@ -42,6 +42,18 @@ flowchart TB ExitReason -->|未授权主动推送| ExitEnded ``` +## 动态房间贪心流程 + +默认即时匹配路径按以下生命周期运行: + +1. `user0` 发起匹配后,系统从队列中贪心选择当前最合适的 `user1`,只创建邀请,不提前建房。 +2. `user0` 与 `user1` 双方接受后,系统在同一事务中建立 2 人房间。 +3. 房间保持 `matchingStatus=active`。新用户进入队列时,系统优先在开放房间与候选用户之间贪心选择一个组合,每轮只发出一份入房邀请。 +4. 候选用户接受后原子加入房间,系统继续寻找下一位;达到活动 `maxPlayers` 后自动标记 `full` 并停止。 +5. 任一在房成员明确表达“停止匹配”“不要再加人”等语义时,房间标记 `stopped`,待处理邀请取消,候选请求重新入队。 + +所有邀请接受、拒绝、建房、加人、满员与停止操作均由数据库事务校验当前状态,避免并发重复建房或超员。 + ## 候选与成局事实边界 - 候选池中尚未确认的成员必须用不确定语气表达,不能写成已经参加。 diff --git a/packages/agent-core/src/context.ts b/packages/agent-core/src/context.ts index 60954f6..612ff76 100644 --- a/packages/agent-core/src/context.ts +++ b/packages/agent-core/src/context.ts @@ -1,5 +1,6 @@ import type { AdventurexOnboardingState, + MatchInvite, MatchOptionContext, MatchRequest, MatchRoom, @@ -30,6 +31,7 @@ export interface AgentContext { matchRequest: MatchRequest | null; matchOptions: MatchOptionContext | null; onboardingState: AdventurexOnboardingState | null; + matchInvite: MatchInvite | null; room: MatchRoom | null; promptRuntime: Record; budget: AgentContextBudget; @@ -119,6 +121,7 @@ function buildPromptRuntime( matchRequest: MatchRequest | null, matchOptions: MatchOptionContext | null, onboardingState: AdventurexOnboardingState | null, + matchInvite: MatchInvite | null, room: MatchRoom | null, socialHooks: SocialHook[], maxTokens: number @@ -157,10 +160,21 @@ function buildPromptRuntime( } : null, onboardingState, + matchInvite: matchInvite + ? { + inviteId: matchInvite.inviteId, + kind: matchInvite.kind, + roomId: matchInvite.roomId, + status: matchInvite.status, + participants: matchInvite.participants + } + : null, room: room ? { roomId: room.roomId, status: room.status, + matchingStatus: room.matchingStatus, + capacity: room.capacity, members: room.members.map((member) => ({ userId: member.userId, displayName: member.displayName, @@ -205,10 +219,15 @@ function buildPromptRuntime( } : null, onboardingState, + matchInvite: matchInvite + ? { inviteId: matchInvite.inviteId, kind: matchInvite.kind, status: matchInvite.status } + : null, room: room ? { roomId: room.roomId, status: room.status, + matchingStatus: room.matchingStatus, + capacity: room.capacity, memberIds: room.members.map((member) => member.userId), offlineGame: { id: room.offlineGame.id, @@ -225,6 +244,7 @@ export function buildAgentContext( userModel: UserModel, socialState: { matchRequest?: MatchRequest | null; + matchInvite?: MatchInvite | null; room?: MatchRoom | null; checkpoint?: string; memoryProfile?: UserMemoryProfile | null; @@ -267,6 +287,7 @@ export function buildAgentContext( const matchRequest = socialState.matchRequest ?? null; const matchOptions = socialState.matchOptions ?? null; const onboardingState = socialState.onboardingState ?? null; + const matchInvite = socialState.matchInvite ?? null; const room = socialState.room ?? null; const socialHooks = socialState.socialHooks ?? []; const promptRuntime = buildPromptRuntime( @@ -274,6 +295,7 @@ export function buildAgentContext( matchRequest, matchOptions, onboardingState, + matchInvite, room, socialHooks, limits.runtimeTokenBudget @@ -284,6 +306,7 @@ export function buildAgentContext( matchRequest, matchOptions, onboardingState, + matchInvite, room, profileReadiness: buildProfileReadiness(socialHooks) }) > runtimeTokens) { @@ -306,6 +329,7 @@ export function buildAgentContext( matchRequest, matchOptions, onboardingState, + matchInvite, room, promptRuntime, budget: { diff --git a/packages/agent-core/src/index.test.ts b/packages/agent-core/src/index.test.ts index 7058bb0..11fd4d1 100644 --- a/packages/agent-core/src/index.test.ts +++ b/packages/agent-core/src/index.test.ts @@ -220,6 +220,7 @@ describe("mock agent intelligence", () => { activeRoundId: null, optionsExpiresAt: null, roomId: null, + inviteId: null, createdAt: nowIso(), updatedAt: nowIso() } @@ -244,6 +245,7 @@ describe("mock agent intelligence", () => { activeRoundId: null, optionsExpiresAt: null, roomId: null, + inviteId: null, createdAt: nowIso(), updatedAt: nowIso() } @@ -282,6 +284,8 @@ describe("mock agent intelligence", () => { recruitmentStatus: "full" as const, version: 0, meetingPoint: null, + matchingStatus: "full" as const, + capacity: 3, createdAt: now, completedAt: null }; @@ -295,6 +299,7 @@ describe("mock agent intelligence", () => { activeRoundId: null, optionsExpiresAt: null, roomId: room.roomId, + inviteId: null, createdAt: now, updatedAt: now }; diff --git a/packages/agent-core/src/index.ts b/packages/agent-core/src/index.ts index 9f8ed77..b4de586 100644 --- a/packages/agent-core/src/index.ts +++ b/packages/agent-core/src/index.ts @@ -24,6 +24,9 @@ export * from "./memory.js"; export type AgentAction = | { type: "start_match"; intent: Record } + | { type: "accept_match" } + | { type: "decline_match" } + | { type: "stop_match" } | { type: "select_match_options"; preferredOptionNumber: 1 | 2 | 3 | null; @@ -210,6 +213,9 @@ export class MockAgentIntelligence implements AgentIntelligence { const actions: AgentAction[] = []; const socialHooks: SocialHookDraft[] = []; const normalized = userContent.trim(); + const stopMatchRequested = /(停止|结束|取消|不要|别再|不用再).{0,8}(匹配|找人|加人)|(匹配|找人|加人).{0,8}(停止|结束|取消)/u.test(normalized); + const acceptMatchRequested = /(接受|同意|愿意|可以|确认).{0,8}(匹配|邀请|加入)|(匹配|邀请).{0,8}(接受|同意|确认)/u.test(normalized); + const declineMatchRequested = /(拒绝|不接受|不同意|不参加|不加入|算了).{0,8}(匹配|邀请|加入)?/u.test(normalized); let onboardingTransition: ConversationInsight["onboardingTransition"] = "none"; const previousAssistantMessage = [...context.recentMessages] .reverse() @@ -233,6 +239,24 @@ export class MockAgentIntelligence implements AgentIntelligence { onboardingTransition = "engaged"; reply = /建议的追问方向:(.+)/u.exec(normalized)?.[1]?.trim() || "你刚发的这些里,哪一件是你自己在做的?"; + } else if ( + stopMatchRequested + && ( + context.room?.matchingStatus === "active" + || context.matchRequest?.status === "matching" + || context.matchRequest?.status === "invited" + ) + ) { + actions.push({ type: "stop_match" }); + reply = "收到,我会停止当前匹配流程。"; + } else if (context.matchInvite?.status === "pending" && acceptMatchRequested) { + actions.push({ type: "accept_match" }); + reply = context.matchInvite.kind === "initial_pair" + ? "好的,我会接受这次匹配;双方都接受后就建立房间。" + : "好的,我会接受邀请并加入这个房间。"; + } else if (context.matchInvite?.status === "pending" && declineMatchRequested) { + actions.push({ type: "decline_match" }); + reply = "收到,我会拒绝这次匹配邀请。"; } else if (context.onboardingState && /(英文|英语|用 English|use English|speak English|in English)/iu.test(normalized)) { onboardingTransition = "language_en"; reply = "Okay, I'll continue in English. What part of what you just shared feels most representative of you?"; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 9757e42..c9f806b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -167,12 +167,13 @@ export const matchRequestSchema = z.object({ requestId: idSchema, userId: idSchema, intentSnapshot: z.record(z.unknown()), - status: z.enum(["matching", "matched", "cancelled", "expired"]), + status: z.enum(["matching", "invited", "matched", "cancelled", "expired"]), phase: matchRequestPhaseSchema.default("waiting"), proactivePushEnabled: z.boolean().default(false), activeRoundId: idSchema.nullable().default(null), optionsExpiresAt: z.string().datetime().nullable().default(null), roomId: idSchema.nullable(), + inviteId: idSchema.nullable(), createdAt: z.string().datetime(), updatedAt: z.string().datetime() }); @@ -308,13 +309,42 @@ export const finalRoomDecisionSchema = z.object({ export type FinalRoomDecision = z.infer; export const matchDecisionSchema = z.object({ - memberIds: z.array(idSchema).min(3).max(10), - requestIds: z.array(idSchema).min(3).max(10), + memberIds: z.array(idSchema).length(2), + requestIds: z.array(idSchema).length(2), offlineGameId: idSchema, summary: z.string().min(1).max(2_000) }); export type MatchDecision = z.infer; +export const roomJoinDecisionSchema = z.object({ + roomId: idSchema, + userId: idSchema, + requestId: idSchema, + summary: z.string().min(1).max(2_000) +}); +export type RoomJoinDecision = z.infer; + +export const matchInviteParticipantSchema = z.object({ + userId: idSchema, + requestId: idSchema, + displayName: z.string(), + accepted: z.boolean() +}); +export type MatchInviteParticipant = z.infer; + +export const matchInviteSchema = z.object({ + inviteId: idSchema, + kind: z.enum(["initial_pair", "room_join"]), + roomId: idSchema.nullable(), + participants: z.array(matchInviteParticipantSchema).min(1).max(2), + offlineGameId: idSchema, + matchSummary: z.string(), + status: z.enum(["pending", "accepted", "declined", "cancelled"]), + createdAt: z.string().datetime(), + resolvedAt: z.string().datetime().nullable() +}); +export type MatchInvite = z.infer; + export const offlineGameSchema = z.object({ id: idSchema, name: z.string(), @@ -338,7 +368,7 @@ export type RoomMember = z.infer; export const matchRoomSchema = z.object({ roomId: idSchema, - members: z.array(roomMemberSchema).min(3).max(10), + members: z.array(roomMemberSchema).min(2).max(10), offlineGame: offlineGameSchema, matchSummary: z.string(), status: z.enum(["confirming", "confirmed", "completed"]), @@ -347,11 +377,20 @@ export const matchRoomSchema = z.object({ recruitmentStatus: z.enum(["open", "full", "closed"]).default("closed"), version: z.number().int().nonnegative().default(0), meetingPoint: z.string().max(500).nullable().default(null), + matchingStatus: z.enum(["active", "stopped", "full"]), + capacity: z.number().int().min(2).max(10), createdAt: z.string().datetime(), completedAt: z.string().datetime().nullable() }); export type MatchRoom = z.infer; +export const matchInviteResolutionSchema = z.object({ + invite: matchInviteSchema, + room: matchRoomSchema.nullable(), + requeuedRequestIds: z.array(idSchema) +}); +export type MatchInviteResolution = z.infer; + export const postEventFeedbackSchema = z.object({ userId: idSchema, roomId: idSchema, diff --git a/packages/data/src/memory-store.ts b/packages/data/src/memory-store.ts index 9df4620..691c2a0 100644 --- a/packages/data/src/memory-store.ts +++ b/packages/data/src/memory-store.ts @@ -10,6 +10,8 @@ import type { MatchChoice, MatchDecision, MatchDraft, + MatchInvite, + MatchInviteResolution, MatchOptionContext, MatchOptionOffer, MatchRequest, @@ -18,6 +20,7 @@ import type { Message, OfflineGame, PostEventFeedback, + RoomJoinDecision, SaveMatchChoicesInput, SocialHook, SocialHookDraft, @@ -27,10 +30,11 @@ import type { } from "@tomeet/contracts"; import { adventurexWelcomeContent } from "@tomeet/contracts"; import { curatedGames } from "@tomeet/game-catalog"; -import type { MatchCandidate } from "@tomeet/matchmaking"; +import type { MatchCandidate, RoomMatchCandidate } from "@tomeet/matchmaking"; import { validateFinalRoomDecision, - validateMatchDecision + validateMatchDecision, + validateRoomJoinDecision } from "@tomeet/matchmaking"; import { createDefaultUserModel } from "@tomeet/user-model"; import type { @@ -43,7 +47,8 @@ import type { MultimodalRecordInput, RoomChangeNotification, RoundSettlementState, - SaveRoundPlanInput + SaveRoundPlanInput, + StopRoomMatchingResult } from "./store.js"; import { StoreConflictError, StoreNotFoundError } from "./store.js"; @@ -56,11 +61,18 @@ interface MemoryUser { }; } +interface MemoryMatchInvite { + invite: MatchInvite; + participantRequestIds: Record; + sourceJobId?: string; +} + export class MemoryStore implements DataStore { private readonly users = new Map(); private readonly messages: Message[] = []; private readonly wechatResponseGenerations = new Map(); private readonly matchRequests = new Map(); + private readonly matchInvites = new Map(); private readonly rooms = new Map(); private readonly jobs = new Map(); private readonly jobLocks = new Map(); @@ -68,7 +80,7 @@ export class MemoryStore implements DataStore { private readonly multimodal = new Map }>(); private readonly uploadedFiles = new Map(); private readonly feedbackKeys = new Map(); - private readonly sourceJobRooms = new Map(); + private readonly sourceJobInvites = new Map(); private readonly userMemories = new Map(); private readonly memoryProfiles = new Map(); private readonly channelIdentities = new Map(); @@ -158,6 +170,7 @@ export class MemoryStore implements DataStore { activeRoundId: null, optionsExpiresAt: null, roomId: null, + inviteId: null, createdAt: now, updatedAt: now }); @@ -608,7 +621,8 @@ export class MemoryStore implements DataStore { ); if (activeRoom) throw new StoreConflictError("你还有一个未结束的匹配房间"); const existing = [...this.matchRequests.values()].find( - (request) => request.userId === userId && request.status === "matching" + (request) => request.userId === userId + && (request.status === "matching" || request.status === "invited") ); if (existing) return structuredClone(existing); const now = new Date().toISOString(); @@ -622,6 +636,7 @@ export class MemoryStore implements DataStore { activeRoundId: null, optionsExpiresAt: null, roomId: null, + inviteId: null, createdAt: now, updatedAt: now }; @@ -650,6 +665,7 @@ export class MemoryStore implements DataStore { request.proactivePushEnabled = false; request.activeRoundId = null; request.optionsExpiresAt = null; + request.inviteId = null; request.updatedAt = new Date().toISOString(); for (const offer of this.offers.values()) { if (offer.requestId === requestId && offer.status === "offered") offer.status = "expired"; @@ -1080,6 +1096,10 @@ export class MemoryStore implements DataStore { recruitmentStatus: decision.memberIds.length < Math.min(decision.targetPlayers, game.maxPlayers) ? "open" : "full", version: 0, meetingPoint: "TOMEET 集合点", + matchingStatus: decision.memberIds.length < Math.min(decision.targetPlayers, game.maxPlayers) + ? "active" + : "full", + capacity: Math.min(decision.targetPlayers, game.maxPlayers), createdAt: now, completedAt: null }; @@ -1185,6 +1205,265 @@ export class MemoryStore implements DataStore { return structuredClone(room); } + async getMatchInvite(inviteId: string): Promise { + const record = this.matchInvites.get(inviteId); + return record ? structuredClone(record.invite) : null; + } + + async getLatestMatchInviteForUser(userId: string): Promise { + const record = [...this.matchInvites.values()] + .filter(({ invite }) => invite.participants.some((participant) => participant.userId === userId)) + .sort((a, b) => b.invite.createdAt.localeCompare(a.invite.createdAt))[0]; + return record ? structuredClone(record.invite) : null; + } + + async createInitialMatchInvite(decision: MatchDecision, sourceJobId?: string): Promise { + if (sourceJobId) { + const existingId = this.sourceJobInvites.get(sourceJobId); + if (existingId) return structuredClone(this.matchInvites.get(existingId)!.invite); + } + const requests = decision.requestIds.map((id) => this.matchRequests.get(id)).filter(Boolean) as MatchRequest[]; + const game = curatedGames.find((item) => item.id === decision.offlineGameId); + validateMatchDecision(decision, requests, game); + const inviteId = randomUUID(); + const now = new Date().toISOString(); + const participants = decision.memberIds.map((userId, index) => ({ + userId, + requestId: decision.requestIds[index]!, + displayName: this.users.get(userId)?.displayName ?? "成员", + accepted: userId.startsWith("demo-") + })); + const invite: MatchInvite = { + inviteId, + kind: "initial_pair", + roomId: null, + participants, + offlineGameId: decision.offlineGameId, + matchSummary: decision.summary, + status: "pending", + createdAt: now, + resolvedAt: null + }; + this.matchInvites.set(inviteId, { + invite, + participantRequestIds: Object.fromEntries( + participants.map((participant) => [participant.userId, participant.requestId]) + ), + sourceJobId + }); + if (sourceJobId) this.sourceJobInvites.set(sourceJobId, inviteId); + for (const request of requests) { + request.status = "invited"; + request.inviteId = inviteId; + request.updatedAt = now; + } + return structuredClone(invite); + } + + async createRoomJoinInvite(decision: RoomJoinDecision, sourceJobId?: string): Promise { + if (sourceJobId) { + const existingId = this.sourceJobInvites.get(sourceJobId); + if (existingId) return structuredClone(this.matchInvites.get(existingId)!.invite); + } + const request = this.matchRequests.get(decision.requestId); + const room = this.rooms.get(decision.roomId); + validateRoomJoinDecision(decision, request ? [request] : [], room ? [room] : []); + if ([...this.matchInvites.values()].some( + ({ invite }) => invite.kind === "room_join" + && invite.roomId === decision.roomId + && invite.status === "pending" + )) { + throw new StoreConflictError("目标房间已有待处理邀请"); + } + const inviteId = randomUUID(); + const now = new Date().toISOString(); + const participant = { + userId: decision.userId, + requestId: decision.requestId, + displayName: this.users.get(decision.userId)?.displayName ?? "成员", + accepted: decision.userId.startsWith("demo-") + }; + const invite: MatchInvite = { + inviteId, + kind: "room_join", + roomId: decision.roomId, + participants: [participant], + offlineGameId: room!.offlineGame.id, + matchSummary: decision.summary, + status: "pending", + createdAt: now, + resolvedAt: null + }; + this.matchInvites.set(inviteId, { + invite, + participantRequestIds: { [participant.userId]: participant.requestId }, + sourceJobId + }); + if (sourceJobId) this.sourceJobInvites.set(sourceJobId, inviteId); + request!.status = "invited"; + request!.inviteId = inviteId; + request!.updatedAt = now; + if (participant.accepted) { + return (await this.acceptMatchInvite(inviteId, participant.userId)).invite; + } + return structuredClone(invite); + } + + async acceptMatchInvite(inviteId: string, userId: string): Promise { + const record = this.matchInvites.get(inviteId); + if (!record) throw new StoreNotFoundError("匹配邀请不存在"); + const { invite } = record; + const participant = invite.participants.find((item) => item.userId === userId); + if (!participant) throw new StoreConflictError("用户不在该匹配邀请中"); + if (invite.status === "accepted") { + return { + invite: structuredClone(invite), + room: invite.roomId ? structuredClone(this.rooms.get(invite.roomId) ?? null) : null, + requeuedRequestIds: [] + }; + } + if (invite.status !== "pending") throw new StoreConflictError("该匹配邀请已失效"); + participant.accepted = true; + if (!invite.participants.every((item) => item.accepted)) { + return { invite: structuredClone(invite), room: null, requeuedRequestIds: [] }; + } + + const now = new Date().toISOString(); + let room: MatchRoom; + if (invite.kind === "initial_pair") { + const game = curatedGames.find((item) => item.id === invite.offlineGameId); + if (!game) throw new StoreNotFoundError("线下游戏不存在"); + const roomId = randomUUID(); + const capacity = game.maxPlayers; + room = { + roomId, + members: invite.participants.map((item) => ({ + userId: item.userId, + displayName: item.displayName, + confirmed: true, + participationStatus: "confirmed" + })), + offlineGame: structuredClone(game), + matchSummary: invite.matchSummary, + status: "confirmed", + sourceDraftId: null, + targetPlayers: capacity, + recruitmentStatus: invite.participants.length >= capacity ? "full" : "open", + version: 0, + meetingPoint: null, + matchingStatus: invite.participants.length >= capacity ? "full" : "active", + capacity, + createdAt: now, + completedAt: null + }; + this.rooms.set(roomId, room); + invite.roomId = roomId; + for (const requestId of Object.values(record.participantRequestIds)) { + const request = this.matchRequests.get(requestId); + if (!request) continue; + request.status = "matched"; + request.phase = "settling"; + request.roomId = roomId; + request.updatedAt = now; + const user = this.users.get(request.userId); + if (user && !user.model.socialHistory.includes(roomId)) { + user.model.socialHistory = [...user.model.socialHistory, roomId].slice(-50); + user.model.version += 1; + user.model.updatedAt = now; + } + } + } else { + room = this.rooms.get(invite.roomId!)!; + const activeMembers = room?.members.filter((member) => member.participationStatus !== "withdrawn") ?? []; + if (!room || room.status === "completed" || room.matchingStatus !== "active") { + throw new StoreConflictError("目标房间已停止匹配"); + } + if (activeMembers.length >= room.capacity) { + room.matchingStatus = "full"; + room.recruitmentStatus = "full"; + throw new StoreConflictError("目标房间已满"); + } + const existing = room.members.find((member) => member.userId === participant.userId); + if (existing) { + existing.confirmed = true; + existing.participationStatus = "confirmed"; + } else { + room.members.push({ + userId: participant.userId, + displayName: participant.displayName, + confirmed: true, + participationStatus: "confirmed" + }); + } + const request = this.matchRequests.get(participant.requestId)!; + request.status = "matched"; + request.phase = "settling"; + request.roomId = room.roomId; + request.updatedAt = now; + room.version += 1; + const memberCount = room.members.filter((member) => member.participationStatus !== "withdrawn").length; + if (memberCount >= room.capacity) { + room.matchingStatus = "full"; + room.recruitmentStatus = "full"; + } + this.recordRoomChange(room, "member_joined", { + joinedUserId: participant.userId, + memberCount + }); + const user = this.users.get(participant.userId); + if (user && !user.model.socialHistory.includes(room.roomId)) { + user.model.socialHistory = [...user.model.socialHistory, room.roomId].slice(-50); + user.model.version += 1; + user.model.updatedAt = now; + } + } + invite.status = "accepted"; + invite.resolvedAt = now; + return { + invite: structuredClone(invite), + room: structuredClone(room), + requeuedRequestIds: [] + }; + } + + async declineMatchInvite(inviteId: string, userId: string): Promise { + const record = this.matchInvites.get(inviteId); + if (!record) throw new StoreNotFoundError("匹配邀请不存在"); + const { invite } = record; + const participant = invite.participants.find((item) => item.userId === userId); + if (!participant) throw new StoreConflictError("用户不在该匹配邀请中"); + if (invite.status !== "pending") throw new StoreConflictError("该匹配邀请已失效"); + const now = new Date().toISOString(); + const request = this.matchRequests.get(participant.requestId); + if (request) { + request.status = "cancelled"; + request.phase = "waiting"; + request.inviteId = null; + request.updatedAt = now; + } + const requeuedRequestIds: string[] = []; + if (invite.kind === "initial_pair") { + for (const other of invite.participants.filter((item) => item.userId !== userId)) { + const otherRequest = this.matchRequests.get(other.requestId); + if (!otherRequest || otherRequest.status !== "invited") continue; + otherRequest.status = "matching"; + otherRequest.phase = "waiting"; + otherRequest.activeRoundId = null; + otherRequest.optionsExpiresAt = null; + otherRequest.inviteId = null; + otherRequest.updatedAt = now; + requeuedRequestIds.push(otherRequest.requestId); + } + } + invite.status = "declined"; + invite.resolvedAt = now; + return { + invite: structuredClone(invite), + room: invite.roomId ? structuredClone(this.rooms.get(invite.roomId) ?? null) : null, + requeuedRequestIds + }; + } + async listMatchCandidates(limit = 50): Promise { const requests = [...this.matchRequests.values()] .filter((request) => request.status === "matching") @@ -1207,53 +1486,47 @@ export class MemoryStore implements DataStore { })); } - async listOfflineGames(): Promise { - return structuredClone(curatedGames); + async listOpenRoomsForMatching(limit = 20): Promise { + return [...this.rooms.values()] + .filter((room) => { + const memberCount = room.members.filter( + (member) => member.participationStatus !== "withdrawn" + ).length; + return room.status !== "completed" + && room.matchingStatus === "active" + && memberCount < room.capacity + && ![...this.matchInvites.values()].some( + ({ invite }) => invite.kind === "room_join" + && invite.roomId === room.roomId + && invite.status === "pending" + ); + }) + .sort((a, b) => a.createdAt.localeCompare(b.createdAt)) + .slice(0, limit) + .map((room) => ({ + room: structuredClone(room), + members: room.members + .filter((member) => member.participationStatus !== "withdrawn") + .flatMap((member) => { + const request = [...this.matchRequests.values()] + .filter((item) => item.userId === member.userId && item.roomId === room.roomId) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0]; + if (!request) return []; + return [{ + request: structuredClone(request), + userModel: structuredClone(this.users.get(member.userId)!.model), + matchingNarrative: this.memoryProfiles.get(member.userId)?.matchingNarrative + || this.users.get(member.userId)!.model.vibeNarrative, + socialHooks: [...this.socialHooks.values()] + .filter((hook) => hook.userId === member.userId && hook.status === "active") + .map((hook) => structuredClone(hook)) + }]; + }) + })); } - async createRoomFromDecision(decision: MatchDecision, sourceJobId?: string): Promise { - if (sourceJobId && this.sourceJobRooms.has(sourceJobId)) return this.sourceJobRooms.get(sourceJobId)!; - const requests = decision.requestIds.map((id) => this.matchRequests.get(id)).filter(Boolean) as MatchRequest[]; - const game = curatedGames.find((item) => item.id === decision.offlineGameId); - validateMatchDecision(decision, requests, game); - if (!game) throw new StoreNotFoundError("线下游戏不存在"); - - const roomId = randomUUID(); - const now = new Date().toISOString(); - const members = decision.memberIds.map((userId) => ({ - userId, - displayName: this.users.get(userId)?.displayName ?? "成员", - confirmed: userId.startsWith("demo-"), - participationStatus: userId.startsWith("demo-") ? "confirmed" as const : "invited" as const - })); - const room: MatchRoom = { - roomId, - members, - offlineGame: structuredClone(game), - matchSummary: decision.summary, - status: members.every((member) => member.confirmed) ? "confirmed" : "confirming", - sourceDraftId: null, - targetPlayers: decision.memberIds.length, - recruitmentStatus: "closed", - version: 0, - meetingPoint: null, - createdAt: now, - completedAt: null - }; - this.rooms.set(roomId, room); - if (sourceJobId) this.sourceJobRooms.set(sourceJobId, roomId); - for (const request of requests) { - request.status = "matched"; - request.roomId = roomId; - request.updatedAt = now; - const user = this.users.get(request.userId); - if (user && !user.model.socialHistory.includes(roomId)) { - user.model.socialHistory = [...user.model.socialHistory, roomId].slice(-50); - user.model.version += 1; - user.model.updatedAt = now; - } - } - return roomId; + async listOfflineGames(): Promise { + return structuredClone(curatedGames); } async getRoom(roomId: string): Promise { @@ -1300,7 +1573,10 @@ export class MemoryStore implements DataStore { room.version += 1; const remaining = room.members.filter((item) => item.participationStatus === "confirmed").length; room.status = remaining >= room.offlineGame.minPlayers ? "confirmed" : "confirming"; - if (remaining < (room.targetPlayers ?? room.offlineGame.maxPlayers)) room.recruitmentStatus = "open"; + if (remaining < room.capacity && room.matchingStatus !== "stopped") { + room.recruitmentStatus = "open"; + room.matchingStatus = "active"; + } const request = [...this.matchRequests.values()].find((item) => item.roomId === roomId && item.userId === userId); if (request) { request.status = request.proactivePushEnabled ? "matching" : "cancelled"; @@ -1308,6 +1584,7 @@ export class MemoryStore implements DataStore { request.roomId = null; request.activeRoundId = null; request.optionsExpiresAt = null; + request.inviteId = null; request.updatedAt = new Date().toISOString(); } this.recordRoomChange(room, "member_withdrawn", { withdrawnUserId: userId, memberCount: remaining }); @@ -1361,6 +1638,37 @@ export class MemoryStore implements DataStore { this.deliveredDraftNotifications.add(key); } + async stopRoomMatching(roomId: string, userId: string): Promise { + const room = this.rooms.get(roomId); + if (!room || room.status === "completed") throw new StoreNotFoundError("房间不存在或已经结束"); + if (!room.members.some( + (member) => member.userId === userId && member.participationStatus !== "withdrawn" + )) { + throw new StoreConflictError("用户不在房间中"); + } + if (room.matchingStatus === "active") room.matchingStatus = "stopped"; + if (room.recruitmentStatus === "open") room.recruitmentStatus = "closed"; + const now = new Date().toISOString(); + const requeuedRequestIds: string[] = []; + for (const { invite } of this.matchInvites.values()) { + if (invite.kind !== "room_join" || invite.roomId !== roomId || invite.status !== "pending") continue; + invite.status = "cancelled"; + invite.resolvedAt = now; + for (const participant of invite.participants) { + const request = this.matchRequests.get(participant.requestId); + if (!request || request.status !== "invited") continue; + request.status = "matching"; + request.phase = "waiting"; + request.activeRoundId = null; + request.optionsExpiresAt = null; + request.inviteId = null; + request.updatedAt = now; + requeuedRequestIds.push(request.requestId); + } + } + return { room: structuredClone(room), requeuedRequestIds }; + } + async completeRoom(roomId: string): Promise { const room = this.rooms.get(roomId); if (!room) throw new StoreNotFoundError("房间不存在"); @@ -1369,6 +1677,8 @@ export class MemoryStore implements DataStore { throw new StoreConflictError("所有成员确认后才能完成活动"); } room.status = "completed"; + if (room.matchingStatus !== "full") room.matchingStatus = "stopped"; + room.recruitmentStatus = room.matchingStatus === "full" ? "full" : "closed"; room.completedAt ??= new Date().toISOString(); for (const member of room.members) { const user = this.users.get(member.userId); diff --git a/packages/data/src/migration.test.ts b/packages/data/src/migration.test.ts index 7fbf2b6..1ebb48c 100644 --- a/packages/data/src/migration.test.ts +++ b/packages/data/src/migration.test.ts @@ -53,9 +53,9 @@ describe("Supabase migration", () => { const result = await db.query<{ table_name: string }>(` select table_name from information_schema.tables where table_schema = 'public' - and table_name in ('users','messages','user_models','match_requests','match_rooms','room_members','offline_games','post_event_feedback','llm_jobs') + and table_name in ('users','messages','user_models','match_requests','match_rooms','room_members','match_invites','offline_games','post_event_feedback','llm_jobs') `); - expect(result.rows).toHaveLength(9); + expect(result.rows).toHaveLength(10); const memoryTables = await db.query<{ table_name: string }>(` select table_name from information_schema.tables where table_schema = 'public' @@ -1125,6 +1125,33 @@ describe("Supabase migration", () => { }); }); + it("keeps dynamic matchmaking state and RPCs service-role only", async () => { + const privileges = await db.query<{ + rls_enabled: boolean; + anon_table: boolean; + authenticated_table: boolean; + anon_accept: boolean; + authenticated_stop: boolean; + service_accept: boolean; + }>(` + select + (select relrowsecurity from pg_class where oid = 'public.match_invites'::regclass) as rls_enabled, + has_table_privilege('anon', 'public.match_invites', 'select') as anon_table, + has_table_privilege('authenticated', 'public.match_invites', 'select') as authenticated_table, + has_function_privilege('anon', 'public.accept_match_invite(uuid,uuid)', 'execute') as anon_accept, + has_function_privilege('authenticated', 'public.stop_room_matching(uuid,uuid)', 'execute') as authenticated_stop, + has_function_privilege('service_role', 'public.accept_match_invite(uuid,uuid)', 'execute') as service_accept + `); + expect(privileges.rows[0]).toEqual({ + rls_enabled: true, + anon_table: false, + authenticated_table: false, + anon_accept: false, + authenticated_stop: false, + service_accept: true + }); + }); + it("enforces the aligned match, room, history, and feedback lifecycle", async () => { const userIds = [ "60000000-0000-4000-8000-000000000001", @@ -1196,4 +1223,150 @@ describe("Supabase migration", () => { nextIntent: "下次继续" })])).rejects.toThrow("连接用户不能包含自己"); }); + + it("creates a room only after bilateral acceptance and fills it one invite at a time", async () => { + const userIds = Array.from( + { length: 7 }, + (_, index) => `70000000-0000-4000-8000-${String(index + 1).padStart(12, "0")}` + ); + const requestIds: string[] = []; + for (const [index, userId] of userIds.slice(0, 2).entries()) { + await db.query("select ensure_tomeet_user($1::uuid, $2)", [userId, `动态用户${index + 1}`]); + const request = await db.query<{ create_match_request: { id: string } }>( + "select create_match_request($1::uuid, $2::jsonb)", + [userId, JSON.stringify({ rawText: `动态匹配用户${index + 1}` })] + ); + requestIds.push(request.rows[0]!.create_match_request.id); + } + + const pairInvite = await db.query<{ create_initial_match_invite: { inviteId: string } }>( + "select create_initial_match_invite($1::jsonb, null)", + [JSON.stringify({ + memberIds: userIds.slice(0, 2), + requestIds, + offlineGameId: "game-story-table", + summary: "先匹配当前最合适的两位用户" + })] + ); + const inviteId = pairInvite.rows[0]!.create_initial_match_invite.inviteId; + const firstAccepted = await db.query<{ + accept_match_invite: { room: null }; + }>("select accept_match_invite($1::uuid, $2::uuid)", [inviteId, userIds[0]]); + expect(firstAccepted.rows[0]!.accept_match_invite.room).toBeNull(); + + const secondAccepted = await db.query<{ + accept_match_invite: { + room: { + roomId: string; + matchingStatus: string; + capacity: number; + members: Array<{ userId: string }>; + }; + }; + }>("select accept_match_invite($1::uuid, $2::uuid)", [inviteId, userIds[1]]); + const initialRoom = secondAccepted.rows[0]!.accept_match_invite.room; + expect(initialRoom.members).toHaveLength(2); + expect(initialRoom.matchingStatus).toBe("active"); + expect(initialRoom.capacity).toBe(6); + + let latestRoom = initialRoom; + for (const [offset, userId] of userIds.slice(2, 6).entries()) { + await db.query("select ensure_tomeet_user($1::uuid, $2)", [userId, `扩房用户${offset + 3}`]); + const request = await db.query<{ create_match_request: { id: string } }>( + "select create_match_request($1::uuid, $2::jsonb)", + [userId, JSON.stringify({ rawText: `加入动态房间${offset + 3}` })] + ); + const requestId = request.rows[0]!.create_match_request.id; + const joinInvite = await db.query<{ create_room_join_invite: { inviteId: string } }>( + "select create_room_join_invite($1::jsonb, null)", + [JSON.stringify({ + roomId: initialRoom.roomId, + userId, + requestId, + summary: "当前队列里的最高匹配用户" + })] + ); + const accepted = await db.query<{ + accept_match_invite: { room: typeof initialRoom }; + }>( + "select accept_match_invite($1::uuid, $2::uuid)", + [joinInvite.rows[0]!.create_room_join_invite.inviteId, userId] + ); + latestRoom = accepted.rows[0]!.accept_match_invite.room; + } + expect(latestRoom.members).toHaveLength(6); + expect(latestRoom.matchingStatus).toBe("full"); + + await db.query("select ensure_tomeet_user($1::uuid, $2)", [userIds[6], "满员后用户"]); + const extraRequest = await db.query<{ create_match_request: { id: string } }>( + "select create_match_request($1::uuid, $2::jsonb)", + [userIds[6], JSON.stringify({ rawText: "房间满员后请求" })] + ); + await expect(db.query("select create_room_join_invite($1::jsonb, null)", [JSON.stringify({ + roomId: initialRoom.roomId, + userId: userIds[6], + requestId: extraRequest.rows[0]!.create_match_request.id, + summary: "不应进入满员房间" + })])).rejects.toThrow("停止匹配"); + }); + + it("lets any room member stop matching and requeues a pending invitee", async () => { + const userIds = [ + "80000000-0000-4000-8000-000000000001", + "80000000-0000-4000-8000-000000000002", + "80000000-0000-4000-8000-000000000003" + ]; + const requestIds: string[] = []; + for (const [index, userId] of userIds.entries()) { + await db.query("select ensure_tomeet_user($1::uuid, $2)", [userId, `停止测试用户${index + 1}`]); + const request = await db.query<{ create_match_request: { id: string } }>( + "select create_match_request($1::uuid, $2::jsonb)", + [userId, JSON.stringify({ rawText: `停止测试${index + 1}` })] + ); + requestIds.push(request.rows[0]!.create_match_request.id); + } + const pairInvite = await db.query<{ create_initial_match_invite: { inviteId: string } }>( + "select create_initial_match_invite($1::jsonb, null)", + [JSON.stringify({ + memberIds: userIds.slice(0, 2), + requestIds: requestIds.slice(0, 2), + offlineGameId: "game-city-clues", + summary: "停止测试初始匹配" + })] + ); + const pairInviteId = pairInvite.rows[0]!.create_initial_match_invite.inviteId; + await db.query("select accept_match_invite($1::uuid, $2::uuid)", [pairInviteId, userIds[0]]); + const accepted = await db.query<{ + accept_match_invite: { room: { roomId: string } }; + }>("select accept_match_invite($1::uuid, $2::uuid)", [pairInviteId, userIds[1]]); + const roomId = accepted.rows[0]!.accept_match_invite.room.roomId; + + const joinInvite = await db.query<{ create_room_join_invite: { inviteId: string } }>( + "select create_room_join_invite($1::jsonb, null)", + [JSON.stringify({ + roomId, + userId: userIds[2], + requestId: requestIds[2], + summary: "停止前的待处理邀请" + })] + ); + const stopped = await db.query<{ + stop_room_matching: { + room: { matchingStatus: string }; + requeuedRequestIds: string[]; + }; + }>("select stop_room_matching($1::uuid, $2::uuid)", [roomId, userIds[0]]); + expect(stopped.rows[0]!.stop_room_matching.room.matchingStatus).toBe("stopped"); + expect(stopped.rows[0]!.stop_room_matching.requeuedRequestIds).toContain(requestIds[2]); + + const requeued = await db.query<{ status: string; invite_id: string | null }>( + "select status, invite_id from match_requests where id = $1::uuid", + [requestIds[2]] + ); + expect(requeued.rows[0]).toMatchObject({ status: "matching", invite_id: null }); + await expect(db.query( + "select accept_match_invite($1::uuid, $2::uuid)", + [joinInvite.rows[0]!.create_room_join_invite.inviteId, userIds[2]] + )).rejects.toThrow("失效"); + }); }); diff --git a/packages/data/src/store.ts b/packages/data/src/store.ts index 4b56c01..449df61 100644 --- a/packages/data/src/store.ts +++ b/packages/data/src/store.ts @@ -11,6 +11,8 @@ import type { MatchChoice, MatchDecision, MatchDraft, + MatchInvite, + MatchInviteResolution, MatchOptionContext, MatchOptionHook, MatchOptionOffer, @@ -21,6 +23,7 @@ import type { Message, OfflineGame, PostEventFeedback, + RoomJoinDecision, SaveMatchChoicesInput, SocialHook, SocialHookDraft, @@ -31,7 +34,7 @@ import type { UserMemorySourceType, UserModel } from "@tomeet/contracts"; -import type { MatchCandidate } from "@tomeet/matchmaking"; +import type { MatchCandidate, RoomMatchCandidate } from "@tomeet/matchmaking"; export interface EnqueueJobInput { type: LlmJobType; @@ -123,6 +126,11 @@ export interface ApplyMemoryChangesResult { forgottenCount: number; } +export interface StopRoomMatchingResult { + room: MatchRoom; + requeuedRequestIds: string[]; +} + export interface DataStore { ensureUser(userId: string, displayName?: string): Promise; ensureAdventurexOnboardingState(userId: string): Promise; @@ -222,9 +230,15 @@ export interface DataStore { settleMatchRound(roundId: string, decisions: FinalRoomDecision[]): Promise; listSuitableOpenRooms(userId: string, limit?: number): Promise; joinOpenRoom(requestId: string, offerId: string, sourceVersion: number): Promise; + getMatchInvite(inviteId: string): Promise; + getLatestMatchInviteForUser(userId: string): Promise; + createInitialMatchInvite(decision: MatchDecision, sourceJobId?: string): Promise; + createRoomJoinInvite(decision: RoomJoinDecision, sourceJobId?: string): Promise; + acceptMatchInvite(inviteId: string, userId: string): Promise; + declineMatchInvite(inviteId: string, userId: string): Promise; listMatchCandidates(limit?: number): Promise; + listOpenRoomsForMatching(limit?: number): Promise; listOfflineGames(): Promise; - createRoomFromDecision(decision: MatchDecision, sourceJobId?: string): Promise; getRoom(roomId: string): Promise; getLatestRoomForUser(userId: string): Promise; confirmRoom(roomId: string, userId: string): Promise; @@ -235,6 +249,7 @@ export interface DataStore { markRoomChangeNotificationDelivered(eventId: string, userId: string): Promise; listPendingDraftChangeNotifications(limit?: number): Promise; markDraftChangeNotificationDelivered(eventId: string, userId: string): Promise; + stopRoomMatching(roomId: string, userId: string): Promise; completeRoom(roomId: string): Promise; saveFeedback(feedback: PostEventFeedback): Promise; enqueueWechatOutboundMessage(message: Message): Promise; diff --git a/packages/data/src/supabase-store.ts b/packages/data/src/supabase-store.ts index 027294d..9368213 100644 --- a/packages/data/src/supabase-store.ts +++ b/packages/data/src/supabase-store.ts @@ -6,6 +6,8 @@ import { llmJobSchema, matchChoiceSchema, matchDraftSchema, + matchInviteResolutionSchema, + matchInviteSchema, matchOptionContextSchema, matchOptionOfferSchema, matchRequestSchema, @@ -26,6 +28,8 @@ import { type FinalRoomDecision, type MatchChoice, type MatchDecision, + type MatchInvite, + type MatchInviteResolution, type MatchOptionContext, type MatchOptionOffer, type MatchRequest, @@ -34,6 +38,7 @@ import { type Message, type OfflineGame, type PostEventFeedback, + type RoomJoinDecision, type SaveMatchChoicesInput, type SocialHook, type SocialHookDraft, @@ -41,7 +46,7 @@ import { type UserMemoryProfile, type UserModel } from "@tomeet/contracts"; -import type { MatchCandidate } from "@tomeet/matchmaking"; +import type { MatchCandidate, RoomMatchCandidate } from "@tomeet/matchmaking"; import type { ApplyMemoryChangesInput, ApplyMemoryChangesResult, @@ -53,7 +58,8 @@ import type { MultimodalRecordInput, RoomChangeNotification, RoundSettlementState, - SaveRoundPlanInput + SaveRoundPlanInput, + StopRoomMatchingResult } from "./store.js"; import { StoreConflictError, StoreNotFoundError } from "./store.js"; @@ -119,11 +125,29 @@ function mapMatchRequest(row: JsonRow): MatchRequest { activeRoundId: row.active_round_id ?? row.activeRoundId ?? null, optionsExpiresAt: normalizeDateTime(row.options_expires_at ?? row.optionsExpiresAt ?? null), roomId: row.room_id ?? row.roomId ?? null, + inviteId: row.invite_id ?? row.inviteId ?? null, createdAt: normalizeDateTime(row.created_at ?? row.createdAt), updatedAt: normalizeDateTime(row.updated_at ?? row.updatedAt) }); } +function mapMatchInvite(row: JsonRow): MatchInvite { + return matchInviteSchema.parse({ + ...row, + createdAt: normalizeDateTime(row.createdAt ?? row.created_at), + resolvedAt: normalizeDateTime(row.resolvedAt ?? row.resolved_at ?? null) + }); +} + +function mapMatchInviteResolution(data: unknown): MatchInviteResolution { + const row = unwrapRpcData(data) as JsonRow; + return matchInviteResolutionSchema.parse({ + invite: mapMatchInvite(row.invite as JsonRow), + room: row.room ? matchRoomSchema.parse(row.room) : null, + requeuedRequestIds: row.requeuedRequestIds ?? row.requeued_request_ids ?? [] + }); +} + function mapGame(row: JsonRow): OfflineGame { return offlineGameSchema.parse({ id: row.id, @@ -938,6 +962,60 @@ export class SupabaseStore implements DataStore { return matchRoomSchema.parse(data); } + async getMatchInvite(inviteId: string): Promise { + const { data, error } = await this.client.rpc("get_match_invite", { p_invite_id: inviteId }); + if (error) this.throwError("读取匹配邀请", error); + return data ? mapMatchInvite(unwrapRpcData(data) as JsonRow) : null; + } + + async getLatestMatchInviteForUser(userId: string): Promise { + const { data, error } = await this.client + .from("match_invites") + .select("id") + .or(`inviter_user_id.eq.${userId},invitee_user_id.eq.${userId}`) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + if (error) this.throwError("读取用户最近匹配邀请", error); + return data ? this.getMatchInvite(String(data.id)) : null; + } + + async createInitialMatchInvite(decision: MatchDecision, sourceJobId?: string): Promise { + const { data, error } = await this.client.rpc("create_initial_match_invite", { + p_decision: decision, + p_source_job_id: sourceJobId ?? null + }); + if (error) this.throwError("创建初始匹配邀请", error); + return mapMatchInvite(unwrapRpcData(data) as JsonRow); + } + + async createRoomJoinInvite(decision: RoomJoinDecision, sourceJobId?: string): Promise { + const { data, error } = await this.client.rpc("create_room_join_invite", { + p_decision: decision, + p_source_job_id: sourceJobId ?? null + }); + if (error) this.throwError("创建入房邀请", error); + return mapMatchInvite(unwrapRpcData(data) as JsonRow); + } + + async acceptMatchInvite(inviteId: string, userId: string): Promise { + const { data, error } = await this.client.rpc("accept_match_invite", { + p_invite_id: inviteId, + p_user_id: userId + }); + if (error) this.throwError("接受匹配邀请", error); + return mapMatchInviteResolution(data); + } + + async declineMatchInvite(inviteId: string, userId: string): Promise { + const { data, error } = await this.client.rpc("decline_match_invite", { + p_invite_id: inviteId, + p_user_id: userId + }); + if (error) this.throwError("拒绝匹配邀请", error); + return mapMatchInviteResolution(data); + } + async listMatchCandidates(limit = 50): Promise { const { data, error } = await this.client.rpc("list_match_candidates", { p_limit: Math.min(limit, 100) }); if (error) this.throwError("读取匹配候选人", error); @@ -956,21 +1034,38 @@ export class SupabaseStore implements DataStore { })); } + async listOpenRoomsForMatching(limit = 20): Promise { + const { data, error } = await this.client.rpc("list_open_match_rooms", { + p_limit: Math.min(limit, 100) + }); + if (error) this.throwError("读取持续匹配房间", error); + return ((data ?? []) as Array<{ + room: JsonRow; + members: Array<{ + request: JsonRow; + user_model: JsonRow; + matching_narrative?: unknown; + social_hooks?: JsonRow[]; + }>; + }>).map((row) => ({ + room: matchRoomSchema.parse(row.room), + members: row.members.map((member) => ({ + request: mapMatchRequest(member.request), + userModel: mapUserModel(member.user_model), + matchingNarrative: typeof member.matching_narrative === "string" + ? member.matching_narrative + : undefined, + socialHooks: (member.social_hooks ?? []).map((hook) => mapSocialHook(hook)) + })) + })); + } + async listOfflineGames(): Promise { const { data, error } = await this.client.from("offline_games").select("*").eq("active", true).order("name"); if (error) this.throwError("读取游戏目录", error); return (data ?? []).map((row) => mapGame(row)); } - async createRoomFromDecision(decision: MatchDecision, sourceJobId?: string): Promise { - const { data, error } = await this.client.rpc("create_match_room", { - p_decision: decision, - p_source_job_id: sourceJobId ?? null - }); - if (error) this.throwError("创建匹配房间", error); - return String(data); - } - async getRoom(roomId: string): Promise { const { data, error } = await this.client.rpc("get_match_room", { p_room_id: roomId }); if (error) this.throwError("读取房间", error); @@ -1077,6 +1172,21 @@ export class SupabaseStore implements DataStore { if (error) this.throwError("标记候选局变化通知", error); } + async stopRoomMatching(roomId: string, userId: string): Promise { + const { data, error } = await this.client.rpc("stop_room_matching", { + p_room_id: roomId, + p_user_id: userId + }); + if (error) this.throwError("停止房间匹配", error); + const row = unwrapRpcData(data) as JsonRow; + return { + room: matchRoomSchema.parse(row.room), + requeuedRequestIds: Array.isArray(row.requeuedRequestIds) + ? row.requeuedRequestIds.map(String) + : [] + }; + } + async completeRoom(roomId: string): Promise { const { data, error } = await this.client.rpc("complete_match_room", { p_room_id: roomId }); if (error) this.throwError("完成活动", error); diff --git a/packages/intelligence/src/hosted-llm.test.ts b/packages/intelligence/src/hosted-llm.test.ts index f848eb3..dcbe8e8 100644 --- a/packages/intelligence/src/hosted-llm.test.ts +++ b/packages/intelligence/src/hosted-llm.test.ts @@ -27,6 +27,7 @@ function confirmedRoomContext(proactivePushEnabled: boolean): AgentContext { activeRoundId: null, optionsExpiresAt: null, roomId: "room-confirmed", + inviteId: null, createdAt: now, updatedAt: now }, @@ -56,6 +57,8 @@ function confirmedRoomContext(proactivePushEnabled: boolean): AgentContext { recruitmentStatus: "full", version: 0, meetingPoint: null, + matchingStatus: "full", + capacity: 3, createdAt: now, completedAt: null } @@ -589,6 +592,7 @@ describe("hosted Agent proactive matching actions", () => { activeRoundId: null, optionsExpiresAt: null, roomId: null, + inviteId: null, createdAt: now, updatedAt: now } @@ -882,10 +886,10 @@ describe("hosted vibe matchmaking", () => { requestBody = String(init?.body ?? ""); return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ - memberIds: ["u1", "u2", "u3"], - requestIds: ["r1", "r2", "r3"], + memberIds: ["u1", "u2"], + requestIds: ["r1", "r2"], offlineGameId: "game-1", - summary: "三个人的表达节奏和相处空间能自然形成流动。" + summary: "两个人的表达节奏和相处空间能自然形成流动。" }) } }] }), { status: 200, headers: { "Content-Type": "application/json" } }); })); @@ -909,6 +913,7 @@ describe("hosted vibe matchmaking", () => { status: "matching" as const, proactivePushEnabled: false, roomId: null, + inviteId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }, diff --git a/packages/intelligence/src/hosted-llm.ts b/packages/intelligence/src/hosted-llm.ts index 5d9066a..aa3f271 100644 --- a/packages/intelligence/src/hosted-llm.ts +++ b/packages/intelligence/src/hosted-llm.ts @@ -15,6 +15,7 @@ import { memoryProfileDraftSchema, matchDecisionSchema, matchRoundProposalSchema, + roomJoinDecisionSchema, socialHookDraftSchema, type MemoryExtractionResult, type AdventurexLanguage, @@ -27,13 +28,18 @@ import { type Message, type OfflineGame, type PostEventFeedback, + type RoomJoinDecision, type UserMemory, type UserMemoryProfile, type UserModel, type WebSearchMeta, type WebSearchSource } from "@tomeet/contracts"; -import type { MatchCandidate, MatchmakingIntelligence } from "@tomeet/matchmaking"; +import type { + MatchCandidate, + MatchmakingIntelligence, + RoomMatchCandidate +} from "@tomeet/matchmaking"; import { z } from "zod"; import { prepareWebSearchResults, @@ -50,6 +56,9 @@ const conversationPlanSchema = z.object({ currentIntent: z.record(z.unknown()).optional(), actions: z.array(z.discriminatedUnion("type", [ z.object({ type: z.literal("start_match"), intent: z.record(z.unknown()) }), + z.object({ type: z.literal("accept_match") }), + z.object({ type: z.literal("decline_match") }), + z.object({ type: z.literal("stop_match") }), z.object({ type: z.literal("select_match_options"), preferredOptionNumber: z.union([z.literal(1), z.literal(2), z.literal(3)]).nullable(), @@ -469,8 +478,10 @@ export class HostedLlmIntelligence implements AgentIntelligence, MatchmakingInte "用户要求围绕某个活动的地点、日期或日程约酒、组局、找搭子,也属于明确社交意图:同时输出 start_match 和用于核实活动事实的 searchPlan;不得等搜索完成后再决定是否开始匹配。", "只有假设、将来可能、泛泛讨论社交,或只是说喜欢某个兴趣而没有想认识人的表达,socialIntentDetected 才为 false。", "回复自然、克制,不虚构尚未发生的匹配或状态变化。所有产品操作必须通过 actions 输出,由系统执行。", - "可用 action:start_match、select_match_options、explain_match_option、refresh_match_options、cancel_match、restart_match、enable_match_push、disable_match_push、activate_match、leave_room、complete_room、submit_feedback。没有操作时 actions=[]。", + "可用 action:start_match、accept_match、decline_match、stop_match、select_match_options、explain_match_option、refresh_match_options、cancel_match、restart_match、enable_match_push、disable_match_push、activate_match、leave_room、confirm_room、complete_room、submit_feedback。没有操作时 actions=[]。", "只有用户明确表达现在想社交,且没有等待中的请求或未结束房间时,才输出 start_match,并把本次意图放入 intent。", + "当前有 pending 匹配邀请时,用户明确接受就输出 accept_match,明确拒绝就输出 decline_match。", + "用户明确说“停止匹配”“不要再找人”“停止加人”等同义指令时输出 stop_match;它表示停止等待或停止当前房间继续扩充,不等于线下活动已经结束。", "runtime.matchOptions 存在时,把数字、中文序号、多选偏好和人物描述映射到稳定 optionNumber。‘3’只接受3;‘3优先,1也行’接受[3,1];‘都可以’接受所有当前选项。", "用户明确因为某个人物事实选择时,requiredHookIds 只能从所选 option.hooks 中复制对应 hookId;绝不能编造 ID。用户只是追问某个候选详情、多讲一点、再介绍一下时,输出 explain_match_option 并填对应 optionNumber,不要同时输出 select_match_options。", "用户要求换一批时输出 refresh_match_options;等待/候选阶段说不去了输出 cancel_match;请求取消或超时后,只有用户明确说重新匹配、再来三个时才输出 restart_match。", @@ -514,25 +525,33 @@ export class HostedLlmIntelligence implements AgentIntelligence, MatchmakingInte const roomExitPolicy = exitRequiresReason ? "leave_room 仅在用户当前消息提供非空退出理由时允许;只说退出或不去了时必须 actions=[] 并追问一个简单理由。reason 必须来自当前消息,不得编造。退出后不得询问重新匹配。" : "leave_room 只在用户明确要退出当前房间时允许;退出后不得询问重新匹配。"; - const actionPolicy = context.room?.status === "confirming" - ? (this.options.adventurexMatchingV1 - ? `当前房间人数未达活动最低要求,仍在补位。只允许 actions=[] 或 leave_room;禁止 confirm_room。${roomExitPolicy}` - : `当前只允许 actions=[]、leave_room 或 confirm_room。${roomExitPolicy}`) - : context.room?.status === "confirmed" - ? `当前只允许 actions=[]、leave_room 或 complete_room。${roomExitPolicy}` - : context.room?.status === "completed" - ? "当前只允许 actions=[] 或 submit_feedback;禁止 confirm_room 和 complete_room。" - : context.matchRequest?.status === "cancelled" || context.matchRequest?.status === "expired" - ? "当前只允许 actions=[] 或 restart_match。" - : context.matchOptions - ? "当前只允许 actions=[]、select_match_options、explain_match_option、refresh_match_options 或 cancel_match。" - : context.matchRequest?.status === "matching" && context.matchRequest.phase === "push_consent" - ? "当前只允许 actions=[]、enable_match_push、disable_match_push、activate_match 或 cancel_match。" - : context.matchRequest?.status === "matching" && context.matchRequest.phase === "watching" - ? "当前只允许 actions=[]、activate_match、disable_match_push 或 cancel_match。" - : context.matchRequest?.status === "matching" - ? "当前只允许 actions=[] 或 cancel_match。" - : "没有活动房间时,只允许 actions=[] 或 start_match。"; + const actionPolicy = context.room?.status === "completed" + ? "当前只允许 actions=[] 或 submit_feedback;禁止 confirm_room 和 complete_room。" + : context.matchInvite?.status === "pending" + ? "当前只允许 actions=[]、accept_match、decline_match 或 stop_match。" + : context.room?.status === "confirming" + ? this.options.adventurexMatchingV1 + ? context.room.matchingStatus === "active" + ? `当前房间人数未达活动最低要求,仍在补位。只允许 actions=[]、leave_room 或 stop_match;禁止 confirm_room。${roomExitPolicy}` + : `当前房间人数未达活动最低要求,仍在补位。只允许 actions=[] 或 leave_room;禁止 confirm_room。${roomExitPolicy}` + : context.room.matchingStatus === "active" + ? `当前只允许 actions=[]、leave_room、confirm_room 或 stop_match。${roomExitPolicy}` + : `当前只允许 actions=[]、leave_room 或 confirm_room。${roomExitPolicy}` + : context.room?.status === "confirmed" + ? context.room.matchingStatus === "active" + ? `当前只允许 actions=[]、leave_room、complete_room 或 stop_match。${roomExitPolicy}` + : `当前只允许 actions=[]、leave_room 或 complete_room。${roomExitPolicy}` + : context.matchRequest?.status === "cancelled" || context.matchRequest?.status === "expired" + ? "当前只允许 actions=[] 或 restart_match。" + : context.matchOptions + ? "当前只允许 actions=[]、select_match_options、explain_match_option、refresh_match_options、cancel_match 或 stop_match。" + : context.matchRequest?.status === "matching" && context.matchRequest.phase === "push_consent" + ? "当前只允许 actions=[]、enable_match_push、disable_match_push、activate_match、cancel_match 或 stop_match。" + : context.matchRequest?.status === "matching" && context.matchRequest.phase === "watching" + ? "当前只允许 actions=[]、activate_match、disable_match_push、cancel_match 或 stop_match。" + : context.matchRequest?.status === "matching" || context.matchRequest?.status === "invited" + ? "当前只允许 actions=[]、cancel_match 或 stop_match。" + : "没有活动房间时,只允许 actions=[] 或 start_match。"; let insight = await this.parseOrRepair( plannedConversationInsightSchema, result, @@ -857,7 +876,7 @@ export class HostedLlmIntelligence implements AgentIntelligence, MatchmakingInte } async decide(candidates: MatchCandidate[], games: OfflineGame[], requiredRequestId?: string): Promise { - if (candidates.length < 3) return null; + if (candidates.length < 2) return null; const matchingInput = { requiredRequestId, candidates: candidates.map(({ request, userModel, matchingNarrative }) => ({ @@ -880,18 +899,65 @@ export class HostedLlmIntelligence implements AgentIntelligence, MatchmakingInte [ "你负责 TOMEET 线下社交的纯 vibe 匹配。只能选择输入中的等待用户和已有游戏。", "requiredRequestId 是触发本次匹配的请求,输出的 requestIds 必须包含它,对应的用户也必须包含在 memberIds。", - "选择 3–10 人。只根据每个人当下原话 currentVibe 和经过治理的连续自然语言 matchingNarrative 判断相处是否自然。", + "这是贪心匹配的第一步:必须选择触发用户和当前队列中与其最合适的唯一一位用户,一共正好 2 人。", + "只根据每个人当下原话 currentVibe 和经过治理的连续自然语言 matchingNarrative,整体判断哪一位与触发用户相处最自然。", "严禁使用兴趣标签重合、intentTags、traits、性格分类、人口属性、关键词计数、向量标签或任何打分维度。不要因为提到相同名词就判定合适。", "关注表达节奏、能量互补、关系距离、好奇心方向、线下相处画面与潜在互动流动;不要推断敏感属性。", - "输入已有至少 3 位候选人时必须给出一个 3–10 人的小组,memberIds 与 requestIds 数量必须相同且按同一顺序一一对应,不能只返回触发用户。", - "游戏人数必须覆盖成员数。只输出 JSON:memberIds, requestIds, offlineGameId, summary。" + "输入已有至少 2 位候选人时必须给出正好 2 人;memberIds 与 requestIds 按同一顺序一一对应。", + "选择一个 maxPlayers 至少为 2 的游戏,其 maxPlayers 同时作为后续房间人数上限。只输出 JSON:memberIds, requestIds, offlineGameId, summary。" ].join("\n"), JSON.stringify(matchingInput) ); return this.parseOrRepair( matchDecisionSchema, result, - "只输出 memberIds, requestIds, offlineGameId, summary;必须选择 3–10 人,成员与请求数量相同、顺序一一对应,并包含 requiredRequestId。", + "只输出 memberIds, requestIds, offlineGameId, summary;必须正好选择 2 人,成员与请求顺序一一对应,并包含 requiredRequestId。", + matchingInput + ); + } + + async decideRoomJoin( + candidates: MatchCandidate[], + rooms: RoomMatchCandidate[], + requiredRequestId?: string, + requiredRoomId?: string + ): Promise { + if (candidates.length === 0 || rooms.length === 0) return null; + const matchingInput = { + requiredRequestId, + requiredRoomId, + candidates: candidates.map(({ request, userModel, matchingNarrative }) => ({ + requestId: request.requestId, + userId: request.userId, + currentVibe: typeof request.intentSnapshot.rawText === "string" ? request.intentSnapshot.rawText : "", + matchingNarrative: matchingNarrative || userModel.vibeNarrative + })), + rooms: rooms.map(({ room, members }) => ({ + roomId: room.roomId, + capacity: room.capacity, + memberCount: room.members.length, + members: members.map(({ request, userModel, matchingNarrative }) => ({ + userId: request.userId, + currentVibe: typeof request.intentSnapshot.rawText === "string" ? request.intentSnapshot.rawText : "", + matchingNarrative: matchingNarrative || userModel.vibeNarrative + })) + })) + }; + const result = await this.chatJson( + [ + "你负责 TOMEET 动态房间的下一位用户贪心匹配。只能选择输入中的 active 房间和 matching 用户。", + "每次只选择一个 roomId 和一个候选用户;这一步必须是当前可选组合里整体相处最自然的一组。", + "requiredRequestId 非空时必须选择该请求;requiredRoomId 非空时必须选择该房间。", + "只根据 currentVibe 和 matchingNarrative 判断表达节奏、能量互补、关系距离与线下互动流动。", + "严禁使用人口属性、兴趣标签重合、关键词计数、向量标签或拆分打分维度。", + "只输出 JSON:roomId, userId, requestId, summary。" + ].join("\n"), + JSON.stringify(matchingInput) + ); + return this.parseOrRepair( + roomJoinDecisionSchema, + result, + "只输出 roomId, userId, requestId, summary;必须满足 requiredRequestId 和 requiredRoomId。", matchingInput ); } @@ -1029,12 +1095,20 @@ function isActionAllowed( const roomExitAllowed = type === "leave_room" && hasRoomExitIntent(context, userContent) && (!roomExitRequiresReason(context) || Boolean(action.reason?.trim())); + if (context.room?.status === "completed") return type === "submit_feedback"; + if (context.matchInvite?.status === "pending") { + return type === "accept_match" || type === "decline_match" || type === "stop_match"; + } if (context.room?.status === "confirming") { - if (adventurexMatchingV1) return roomExitAllowed; - return type === "confirm_room" || roomExitAllowed; + return (!adventurexMatchingV1 && type === "confirm_room") + || roomExitAllowed + || (type === "stop_match" && context.room.matchingStatus === "active"); + } + if (context.room?.status === "confirmed") { + return type === "complete_room" + || roomExitAllowed + || (type === "stop_match" && context.room.matchingStatus === "active"); } - if (context.room?.status === "confirmed") return type === "complete_room" || roomExitAllowed; - if (context.room?.status === "completed") return type === "submit_feedback"; if (context.matchRequest?.status === "cancelled" || context.matchRequest?.status === "expired") { return type === "restart_match"; } @@ -1043,15 +1117,17 @@ function isActionAllowed( return action.type === "explain_match_option" && context.matchOptions.options.some((option) => option.optionNumber === action.optionNumber); } - return ["select_match_options", "refresh_match_options", "cancel_match"].includes(type); + return ["select_match_options", "refresh_match_options", "cancel_match", "stop_match"].includes(type); } if (context.matchRequest?.status === "matching" && context.matchRequest.phase === "push_consent") { - return ["enable_match_push", "disable_match_push", "activate_match", "cancel_match"].includes(type); + return ["enable_match_push", "disable_match_push", "activate_match", "cancel_match", "stop_match"].includes(type); } if (context.matchRequest?.status === "matching" && context.matchRequest.phase === "watching") { - return ["activate_match", "disable_match_push", "cancel_match"].includes(type); + return ["activate_match", "disable_match_push", "cancel_match", "stop_match"].includes(type); + } + if (context.matchRequest?.status === "matching" || context.matchRequest?.status === "invited") { + return type === "cancel_match" || type === "stop_match"; } - if (context.matchRequest?.status === "matching") return type === "cancel_match"; return type === "start_match"; } diff --git a/packages/intelligence/src/index.ts b/packages/intelligence/src/index.ts index ed3fd64..686ab26 100644 --- a/packages/intelligence/src/index.ts +++ b/packages/intelligence/src/index.ts @@ -17,6 +17,7 @@ import { type AgentProductEvent, type AgentProductMessage, type FinalRoomDecision, + type MatchInvite, type MatchRequest, type MatchRoundProposal, type MatchRoom, @@ -35,7 +36,8 @@ import { type MatchCandidate, type MatchmakingIntelligence, validateMatchDecision, - validateMatchRoundProposal + validateMatchRoundProposal, + validateRoomJoinDecision } from "@tomeet/matchmaking"; import { applyConversationInsight, applyMultimodalInsight } from "@tomeet/user-model"; import { @@ -375,9 +377,19 @@ export class JobProcessor { ): Promise> { const { userId, userContent, userMessageId } = input; const sourceChannel = sourceChannelFromJob(job); - const [model, initialMatchRequest, initialRoom, memoryProfile, matchOptions, onboardingState, socialHooks] = await Promise.all([ + const [ + model, + initialMatchRequest, + initialMatchInvite, + initialRoom, + memoryProfile, + matchOptions, + onboardingState, + socialHooks + ] = await Promise.all([ this.store.getUserModel(userId), this.store.getLatestMatchRequestForUser(userId), + this.store.getLatestMatchInviteForUser(userId), this.store.getLatestRoomForUser(userId), this.store.getMemoryProfile(userId), this.store.listCurrentMatchOptions(userId), @@ -390,6 +402,7 @@ export class JobProcessor { ]); const context = buildAgentContext(messages, model, { matchRequest: initialMatchRequest, + matchInvite: initialMatchInvite, room: initialRoom, checkpoint, memoryProfile, @@ -440,13 +453,23 @@ export class JobProcessor { ); const actionResults: Array> = []; let matchRequest = initialMatchRequest; + let matchInvite = initialMatchInvite; let room = initialRoom; for (const action of insight.actions) { try { assertActionMatchesMatchingFlag(action, this.adventurexMatchingV1); - const result = await this.executeAgentAction(job, userId, action, updatedModel, matchRequest, room); + const result = await this.executeAgentAction( + job, + userId, + action, + updatedModel, + matchRequest, + matchInvite, + room + ); actionResults.push({ type: action.type, ok: true, ...result.result, replyOverride: result.replyOverride }); matchRequest = result.matchRequest; + if (result.matchInvite !== undefined) matchInvite = result.matchInvite; room = result.room; } catch (error) { if ( @@ -509,6 +532,7 @@ export class JobProcessor { webSearch: insight.webSearch, actions: actionResults, matchRequest, + matchInvite, room, memoryJobId: memoryJob?.id ?? null, contextBudget: context.budget, @@ -553,10 +577,12 @@ export class JobProcessor { action: AgentAction, userModel: UserModel, currentMatchRequest: MatchRequest | null, + currentMatchInvite: MatchInvite | null, currentRoom: MatchRoom | null ): Promise<{ result: Record; matchRequest: MatchRequest | null; + matchInvite?: MatchInvite | null; room: MatchRoom | null; replyOverride?: string; }> { @@ -592,6 +618,77 @@ export class JobProcessor { room: currentRoom }; } + case "accept_match": { + if (!currentMatchInvite || currentMatchInvite.status !== "pending") { + throw new StoreNotFoundError("当前没有可以接受的匹配邀请"); + } + const resolution = await this.store.acceptMatchInvite(currentMatchInvite.inviteId, userId); + const matchRequest = await this.store.getLatestMatchRequestForUser(userId); + if (resolution.room) { + await this.notifyRoomUpdate(resolution.room, resolution.invite); + await this.enqueueRoomContinuation(resolution.room, `accepted:${resolution.invite.inviteId}`); + } + return { + result: resolution, + matchRequest, + matchInvite: resolution.invite, + room: resolution.room ?? currentRoom + }; + } + case "decline_match": { + if (!currentMatchInvite || currentMatchInvite.status !== "pending") { + throw new StoreNotFoundError("当前没有可以拒绝的匹配邀请"); + } + const resolution = await this.store.declineMatchInvite(currentMatchInvite.inviteId, userId); + await this.enqueueRequeuedRequests( + resolution.requeuedRequestIds, + `declined:${currentMatchInvite.inviteId}` + ); + if (resolution.room) { + await this.enqueueRoomContinuation(resolution.room, `declined:${currentMatchInvite.inviteId}`); + } + return { + result: resolution, + matchRequest: await this.store.getLatestMatchRequestForUser(userId), + matchInvite: resolution.invite, + room: resolution.room ?? currentRoom + }; + } + case "stop_match": { + if (currentRoom && currentRoom.status !== "completed" && currentRoom.matchingStatus === "active") { + const stopped = await this.store.stopRoomMatching(currentRoom.roomId, userId); + await this.enqueueRequeuedRequests(stopped.requeuedRequestIds, `room-stopped:${currentRoom.roomId}`); + return { + result: { ...stopped }, + matchRequest: currentMatchRequest, + matchInvite: currentMatchInvite, + room: stopped.room + }; + } + if (currentMatchInvite?.status === "pending") { + const resolution = await this.store.declineMatchInvite(currentMatchInvite.inviteId, userId); + await this.enqueueRequeuedRequests( + resolution.requeuedRequestIds, + `invite-stopped:${currentMatchInvite.inviteId}` + ); + return { + result: resolution, + matchRequest: await this.store.getLatestMatchRequestForUser(userId), + matchInvite: resolution.invite, + room: resolution.room ?? currentRoom + }; + } + if (currentMatchRequest?.status === "matching" || currentMatchRequest?.status === "invited") { + const matchRequest = await this.store.cancelMatchRequest(currentMatchRequest.requestId); + return { + result: { matchRequest }, + matchRequest, + matchInvite: currentMatchInvite, + room: currentRoom + }; + } + throw new StoreConflictError("当前没有正在进行的匹配"); + } case "select_match_options": { if (!this.adventurexMatchingV1) { throw new StoreConflictError("未开启 AdventureX V1 时不支持候选选择"); @@ -793,6 +890,77 @@ export class JobProcessor { } } + private async enqueueRequeuedRequests(requestIds: string[], reason: string): Promise { + await Promise.all(requestIds.map(async (requestId) => { + const request = await this.store.getMatchRequest(requestId); + if (!request || request.status !== "matching") return; + await this.store.enqueueJob({ + type: "matchmaking", + payload: { requestId }, + idempotencyKey: `match:${requestId}:${reason}`, + partitionKey: `user:${request.userId}` + }); + })); + } + + private async enqueueRoomContinuation(room: MatchRoom, reason: string): Promise { + if ( + room.status === "completed" + || room.matchingStatus !== "active" + || room.members.length >= room.capacity + ) return; + await this.store.enqueueJob({ + type: "matchmaking", + payload: { roomId: room.roomId }, + idempotencyKey: `match:room:${room.roomId}:${room.members.length}:${reason}`, + partitionKey: `room:${room.roomId}` + }); + } + + private async notifyRoomUpdate(room: MatchRoom, invite: MatchInvite): Promise { + const joinedParticipant = invite.kind === "room_join" ? invite.participants[0] : null; + const content = invite.kind === "initial_pair" + ? [ + `双方已经接受匹配,房间已建立:${room.members.map((member) => member.displayName).join("、")}。`, + `当前 ${room.members.length}/${room.capacity} 人,系统会继续按贪心匹配寻找下一位成员。`, + "达到人数上限会自动停止;任一成员也可以直接说“停止匹配”。" + ].join("\n\n") + : [ + `${joinedParticipant?.displayName ?? "新成员"} 已接受邀请并进入房间。`, + `当前 ${room.members.length}/${room.capacity} 人。`, + room.matchingStatus === "full" + ? "房间已达到人数上限,匹配已自动停止。" + : "系统会继续寻找下一位最合适的成员。" + ].join("\n\n"); + await Promise.all(room.members.map((member) => this.appendProactiveMessage({ + userId: member.userId, + content, + idempotencyKey: `room-update:${invite.inviteId}:${member.userId}` + }))); + } + + private async notifyMatchInvite(invite: MatchInvite, room: MatchRoom | null = null): Promise { + await Promise.all(invite.participants.map((participant) => { + const other = invite.participants.find((item) => item.userId !== participant.userId); + const content = invite.kind === "initial_pair" + ? [ + `我找到了当前与你最合适的匹配对象:${other?.displayName ?? "一位新朋友"}。`, + `匹配考虑:${invite.matchSummary}`, + "你们双方都接受后才会建立房间。回复“接受匹配”或“拒绝匹配”。" + ].join("\n\n") + : [ + `一个正在组建的房间向你发出了邀请,目前 ${room?.members.length ?? "若干"}/${room?.capacity ?? "上限"} 人。`, + `匹配考虑:${invite.matchSummary}`, + "回复“接受邀请”即可进入房间,或回复“拒绝邀请”。" + ].join("\n\n"); + return this.appendProactiveMessage({ + userId: participant.userId, + content, + idempotencyKey: `match-invite:${invite.inviteId}:${participant.userId}` + }); + })); + } + private async processMultimodal(job: LlmJob): Promise> { const userId = requireString(job.payload, "userId"); const inputIds = Array.isArray(job.payload.inputIds) @@ -872,37 +1040,87 @@ export class JobProcessor { } private async processMatchmaking(job: LlmJob): Promise> { - const requiredRequestId = requireString(job.payload, "requestId"); - const [candidates, games] = await Promise.all([ + const requiredRequestId = typeof job.payload.requestId === "string" + ? job.payload.requestId + : undefined; + const requiredRoomId = typeof job.payload.roomId === "string" + ? job.payload.roomId + : undefined; + if (!requiredRequestId && !requiredRoomId) { + throw new Error("匹配任务缺少 requestId 或 roomId"); + } + const [candidates, games, openRooms] = await Promise.all([ this.store.listMatchCandidates(50), - this.store.listOfflineGames() + this.store.listOfflineGames(), + this.store.listOpenRoomsForMatching(20) ]); + if ( + requiredRequestId + && !candidates.some((candidate) => candidate.request.requestId === requiredRequestId) + ) { + return { matched: false, skipped: "request_not_waiting" }; + } + if ( + requiredRoomId + && !openRooms.some((candidate) => candidate.room.roomId === requiredRoomId) + ) { + return { matched: false, skipped: "room_not_open" }; + } + + const waitingRequests = candidates.map((candidate) => candidate.request) as MatchRequest[]; + if (openRooms.length > 0 && candidates.length > 0) { + const joinDecision = await this.matchmaking.decideRoomJoin( + candidates, + openRooms, + requiredRequestId, + requiredRoomId + ); + if (joinDecision) { + validateRoomJoinDecision( + joinDecision, + waitingRequests, + openRooms.map((candidate) => candidate.room), + requiredRequestId, + requiredRoomId + ); + try { + const invite = await this.store.createRoomJoinInvite(joinDecision, job.id); + const room = await this.store.getRoom(joinDecision.roomId); + if (!room) throw new StoreNotFoundError("入房邀请创建后无法读取房间"); + if (invite.status === "accepted") { + await this.notifyRoomUpdate(room, invite); + await this.enqueueRoomContinuation(room, `auto-accepted:${invite.inviteId}`); + } else { + await this.notifyMatchInvite(invite, room); + } + return { + matched: invite.status === "accepted", + invited: invite.status === "pending", + invite, + room + }; + } catch (error) { + if (!(error instanceof StoreConflictError)) throw error; + return { matched: false, skipped: "concurrent_state_change" }; + } + } + } + + if (!requiredRequestId) { + return { matched: false, waitingCount: candidates.length }; + } const decision = await this.matchmaking.decide(candidates, games, requiredRequestId); if (!decision) return { matched: false, waitingCount: candidates.length }; - const waitingRequests = candidates.map((candidate) => candidate.request) as MatchRequest[]; const game = games.find((item) => item.id === decision.offlineGameId); validateMatchDecision(decision, waitingRequests, game, requiredRequestId); - const roomId = await this.store.createRoomFromDecision(decision, job.id); - const room = await this.store.getRoom(roomId); - if (!room) throw new StoreNotFoundError("匹配房间创建后无法读取"); - await Promise.all(room.members.map(async (member) => { - const composed = await this.composeProductMessage(member.userId, { - kind: "legacy_match_ready", - facts: { - activity: room.offlineGame, - memberCount: room.members.length, - memberNames: room.members.map((item) => item.displayName), - matchSummary: room.matchSummary, - requiresConfirmation: true - } - }); - await this.appendProactiveMessage({ - userId: member.userId, - content: composed.content, - idempotencyKey: `room-ready:${roomId}:${member.userId}` - }); - })); - return { matched: true, roomId, decision, room }; + try { + const invite = await this.store.createInitialMatchInvite(decision, job.id); + await this.notifyMatchInvite(invite); + return { matched: false, invited: true, decision, invite }; + } catch (error) { + if (!(error instanceof StoreConflictError)) throw error; + return { matched: false, skipped: "concurrent_state_change" }; + } } private async buildRoomCandidates(room: MatchRoom): Promise { @@ -922,6 +1140,7 @@ export class JobProcessor { status: "matched" as const, proactivePushEnabled: false, roomId: room.roomId, + inviteId: null, createdAt: room.createdAt, updatedAt: room.createdAt }, diff --git a/packages/matchmaking/src/index.test.ts b/packages/matchmaking/src/index.test.ts index 44f671a..e063729 100644 --- a/packages/matchmaking/src/index.test.ts +++ b/packages/matchmaking/src/index.test.ts @@ -11,8 +11,8 @@ import { describe("match decision validation", () => { it("rejects duplicated members", () => { expect(() => validateMatchDecision({ - memberIds: ["u1", "u1", "u3"], - requestIds: ["r1", "r2", "r3"], + memberIds: ["u1", "u1"], + requestIds: ["r1", "r2"], offlineGameId: "game-story-table", summary: "test" }, [], curatedGames[1])).toThrow("不能重复"); @@ -52,6 +52,7 @@ describe("match decision validation", () => { activeRoundId: "round1", optionsExpiresAt: new Date(Date.now() + 60_000).toISOString(), roomId: null, + inviteId: null, createdAt: now, updatedAt: now })); diff --git a/packages/matchmaking/src/index.ts b/packages/matchmaking/src/index.ts index 34cf13d..1d40f57 100644 --- a/packages/matchmaking/src/index.ts +++ b/packages/matchmaking/src/index.ts @@ -5,13 +5,14 @@ import type { MatchDecision, MatchDraft, MatchRequest, + MatchRoom, MatchRoundProposal, OfflineGame, + RoomJoinDecision, SocialHook, UserModel } from "@tomeet/contracts"; import { matchRoundProposalSchema } from "@tomeet/contracts"; -import { gamesSupportingPlayerCount } from "@tomeet/game-catalog"; export interface MatchCandidate { request: Omit & @@ -22,10 +23,21 @@ export interface MatchCandidate { matchingPriority?: "active_waiting" | "confirmation_follow_up" | "watching"; } +export interface RoomMatchCandidate { + room: MatchRoom; + members: MatchCandidate[]; +} + export interface MatchmakingIntelligence { decide(candidates: MatchCandidate[], games: OfflineGame[], requiredRequestId?: string): Promise; proposeMatchRound(candidates: MatchCandidate[], games: OfflineGame[]): Promise; judgeGroup(candidates: MatchCandidate[], game: OfflineGame): Promise; + decideRoomJoin( + candidates: MatchCandidate[], + rooms: RoomMatchCandidate[], + requiredRequestId?: string, + requiredRoomId?: string + ): Promise; } export const MATCH_UTILITY_WEIGHTS = { @@ -42,17 +54,33 @@ export class MockMatchmakingIntelligence implements MatchmakingIntelligence { const waiting = candidates .filter(({ request }) => request.status === "matching") .sort((a, b) => Number(b.request.requestId === requiredRequestId) - Number(a.request.requestId === requiredRequestId)) - .slice(0, 10); - if (waiting.length < 3) return null; - const desiredSize = waiting.length >= 5 ? 5 : waiting.length; - const selected = waiting.slice(0, desiredSize); - const game = gamesSupportingPlayerCount(games, selected.length)[0]; + .slice(0, 2); + if (waiting.length < 2) return null; + const selected = waiting; + const game = games.find((item) => item.maxPlayers >= 2); if (!game) return null; return { memberIds: selected.map(({ request }) => request.userId), requestIds: selected.map(({ request }) => request.requestId), offlineGameId: game.id, - summary: `根据本次意图选择 ${selected.length} 人小组;${game.name}支持当前人数,并能通过共同任务降低初次见面的交流压力。` + summary: `先邀请当前最合适的两位用户建立房间,再按相同匹配机制逐位扩充至 ${game.maxPlayers} 人。` + }; + } + + async decideRoomJoin( + candidates: MatchCandidate[], + rooms: RoomMatchCandidate[], + requiredRequestId?: string, + requiredRoomId?: string + ): Promise { + const room = rooms.find((item) => item.room.roomId === requiredRoomId) ?? rooms[0]; + const candidate = candidates.find((item) => item.request.requestId === requiredRequestId) ?? candidates[0]; + if (!room || !candidate) return null; + return { + roomId: room.room.roomId, + userId: candidate.request.userId, + requestId: candidate.request.requestId, + summary: `邀请当前队列中与房间整体氛围最匹配的下一位用户加入。` }; } @@ -116,7 +144,9 @@ export function validateMatchDecision( game: OfflineGame | undefined, requiredRequestId?: string ): void { - if (decision.memberIds.length < 3 || decision.memberIds.length > 10) throw new Error("匹配人数必须在 3–10 人之间"); + if (decision.memberIds.length !== 2 || decision.requestIds.length !== 2) { + throw new Error("初始匹配必须且只能包含两位用户"); + } if (new Set(decision.memberIds).size !== decision.memberIds.length) throw new Error("匹配成员不能重复"); if (new Set(decision.requestIds).size !== decision.requestIds.length) throw new Error("匹配请求不能重复"); if (decision.memberIds.length !== decision.requestIds.length) throw new Error("成员和请求数量不一致"); @@ -132,8 +162,34 @@ export function validateMatchDecision( }); if (!game || game.id !== decision.offlineGameId) throw new Error("只能选择目录中的线下游戏"); - if (decision.memberIds.length < game.minPlayers || decision.memberIds.length > game.maxPlayers) { - throw new Error("线下游戏不支持当前人数"); + if (game.maxPlayers < decision.memberIds.length) { + throw new Error("线下游戏房间上限小于初始匹配人数"); + } +} + +export function validateRoomJoinDecision( + decision: RoomJoinDecision, + waitingRequests: MatchRequest[], + rooms: MatchRoom[], + requiredRequestId?: string, + requiredRoomId?: string +): void { + const request = waitingRequests.find((item) => item.requestId === decision.requestId); + if (!request || request.status !== "matching") throw new Error("入房候选请求已不在等待中"); + if (request.userId !== decision.userId) throw new Error("入房候选用户和请求不对应"); + if (requiredRequestId && decision.requestId !== requiredRequestId) { + throw new Error("入房邀请必须使用触发本次任务的用户"); + } + const room = rooms.find((item) => item.roomId === decision.roomId); + if (!room || room.status === "completed" || room.matchingStatus !== "active") { + throw new Error("目标房间已停止匹配"); + } + if (requiredRoomId && room.roomId !== requiredRoomId) { + throw new Error("入房邀请必须使用触发本次任务的房间"); + } + if (room.members.length >= room.capacity) throw new Error("目标房间已满"); + if (room.members.some((member) => member.userId === decision.userId)) { + throw new Error("候选用户已经在目标房间中"); } } diff --git a/scripts/migration-safety.config.json b/scripts/migration-safety.config.json index 47ded1a..9b6f46c 100644 --- a/scripts/migration-safety.config.json +++ b/scripts/migration-safety.config.json @@ -36,6 +36,10 @@ "20260725223000_wechat_turn_generation_guards.sql": { "sha256": "c88f5bdc9bd203dbdbd94542abfc5066da2f6beb14d56b8e8f11f87335a1d0d7", "reason": "Create the response-generation guard table and RPCs, enable RLS, and restrict all access to service_role." + }, + "20260725230000_dynamic_room_matchmaking.sql": { + "sha256": "8b164fb510321928434c5f5438086e6ec35a9bcdc4e5bdb5733a2d48f6dde937", + "reason": "Replace matching status constraints and legacy execution grants while adding transactional invitations, RLS, and synchronized room lifecycle state." } } } diff --git a/supabase/migrations/20260725230000_dynamic_room_matchmaking.sql b/supabase/migrations/20260725230000_dynamic_room_matchmaking.sql new file mode 100644 index 0000000..09349b3 --- /dev/null +++ b/supabase/migrations/20260725230000_dynamic_room_matchmaking.sql @@ -0,0 +1,903 @@ +alter table public.match_requests + drop constraint if exists match_requests_status_check; +alter table public.match_requests + add constraint match_requests_status_check + check (status in ('matching', 'invited', 'matched', 'cancelled', 'expired')); + +drop index if exists public.match_requests_one_active_per_user; +create unique index match_requests_one_active_per_user + on public.match_requests (user_id) + where status in ('matching', 'invited'); + +alter table public.match_rooms + add column if not exists capacity smallint, + add column if not exists matching_status text; + +update public.match_rooms mr +set capacity = least(coalesce(mr.target_players, og.max_players), og.max_players), + matching_status = case + when mr.recruitment_status = 'full' then 'full' + when mr.recruitment_status = 'open' then 'active' + else 'stopped' + end +from public.offline_games og +where og.id = mr.offline_game_id + and (mr.capacity is null or mr.matching_status is null); + +alter table public.match_rooms + alter column capacity set default 10, + alter column capacity set not null, + alter column matching_status set default 'active', + alter column matching_status set not null; +alter table public.match_rooms + drop constraint if exists match_rooms_capacity_check; +alter table public.match_rooms + add constraint match_rooms_capacity_check check (capacity between 2 and 10); +alter table public.match_rooms + drop constraint if exists match_rooms_matching_status_check; +alter table public.match_rooms + add constraint match_rooms_matching_status_check + check (matching_status in ('active', 'stopped', 'full')); + +create or replace function public.sync_match_room_matching_state() +returns trigger +language plpgsql +set search_path = public +as $$ +declare v_game_max smallint; +begin + select max_players into v_game_max from offline_games where id = new.offline_game_id; + new.capacity := least(coalesce(new.target_players, v_game_max), v_game_max); + if tg_op = 'UPDATE' + and old.matching_status = 'stopped' + and new.matching_status = old.matching_status then + new.recruitment_status := 'closed'; + new.matching_status := 'stopped'; + else + new.matching_status := case + when new.recruitment_status = 'full' then 'full' + when new.recruitment_status = 'open' then 'active' + else 'stopped' + end; + end if; + return new; +end; +$$; + +drop trigger if exists sync_match_room_matching_state on public.match_rooms; +create trigger sync_match_room_matching_state +before insert or update of offline_game_id, target_players, recruitment_status +on public.match_rooms +for each row execute function public.sync_match_room_matching_state(); + +create table if not exists public.match_invites ( + id uuid primary key default gen_random_uuid(), + kind text not null check (kind in ('initial_pair', 'room_join')), + room_id uuid references public.match_rooms(id) on delete cascade, + inviter_user_id uuid references public.users(id) on delete cascade, + inviter_request_id uuid references public.match_requests(id) on delete cascade, + invitee_user_id uuid not null references public.users(id) on delete cascade, + invitee_request_id uuid not null references public.match_requests(id) on delete cascade, + inviter_accepted boolean not null default false, + invitee_accepted boolean not null default false, + offline_game_id text not null references public.offline_games(id), + match_summary text not null, + status text not null default 'pending' + check (status in ('pending', 'accepted', 'declined', 'cancelled')), + source_job_id uuid unique references public.llm_jobs(id) on delete set null, + created_at timestamptz not null default now(), + resolved_at timestamptz, + constraint match_invites_shape_check check ( + ( + kind = 'initial_pair' + and inviter_user_id is not null + and inviter_request_id is not null + and inviter_user_id <> invitee_user_id + and inviter_request_id <> invitee_request_id + ) + or ( + kind = 'room_join' + and room_id is not null + and inviter_user_id is null + and inviter_request_id is null + and inviter_accepted + ) + ) +); + +create unique index if not exists match_invites_pending_invitee_request + on public.match_invites (invitee_request_id) + where status = 'pending'; +create unique index if not exists match_invites_pending_inviter_request + on public.match_invites (inviter_request_id) + where status = 'pending' and inviter_request_id is not null; +create unique index if not exists match_invites_one_pending_per_room + on public.match_invites (room_id) + where status = 'pending' and kind = 'room_join'; +create index if not exists match_invites_user_created_idx + on public.match_invites (invitee_user_id, created_at desc); +create index if not exists match_invites_inviter_created_idx + on public.match_invites (inviter_user_id, created_at desc) + where inviter_user_id is not null; + +alter table public.match_requests + add column if not exists invite_id uuid; +alter table public.match_requests + drop constraint if exists match_requests_invite_id_fkey; +alter table public.match_requests + add constraint match_requests_invite_id_fkey + foreign key (invite_id) references public.match_invites(id) on delete set null; + +alter table public.match_invites enable row level security; +revoke all on table public.match_invites from public, anon, authenticated; +grant all on table public.match_invites to service_role; + +create or replace function public.get_match_invite(p_invite_id uuid) +returns jsonb +language sql +stable +security definer +set search_path = public +as $$ + select jsonb_build_object( + 'inviteId', mi.id, + 'kind', mi.kind, + 'roomId', mi.room_id, + 'participants', coalesce(( + select jsonb_agg(jsonb_build_object( + 'userId', participant.user_id, + 'requestId', participant.request_id, + 'displayName', u.display_name, + 'accepted', participant.accepted + ) order by participant.position) + from ( + select mi.inviter_user_id as user_id, + mi.inviter_request_id as request_id, + mi.inviter_accepted as accepted, + 1 as position + where mi.inviter_user_id is not null + union all + select mi.invitee_user_id, mi.invitee_request_id, mi.invitee_accepted, 2 + ) participant + join users u on u.id = participant.user_id + ), '[]'::jsonb), + 'offlineGameId', mi.offline_game_id, + 'matchSummary', mi.match_summary, + 'status', mi.status, + 'createdAt', mi.created_at, + 'resolvedAt', mi.resolved_at + ) + from match_invites mi + where mi.id = p_invite_id; +$$; + +create or replace function public.get_match_room(p_room_id uuid) +returns jsonb +language sql +stable +security definer +set search_path = public +as $$ + select jsonb_build_object( + 'roomId', mr.id, + 'members', coalesce(( + select jsonb_agg(jsonb_build_object( + 'userId', rm.user_id, + 'displayName', u.display_name, + 'confirmed', rm.confirmed, + 'participationStatus', rm.participation_status + ) order by rm.created_at) + from room_members rm + join users u on u.id = rm.user_id + where rm.room_id = mr.id + ), '[]'::jsonb), + 'offlineGame', jsonb_build_object( + 'id', og.id, + 'name', og.name, + 'description', og.description, + 'minPlayers', og.min_players, + 'maxPlayers', og.max_players, + 'intentTags', to_jsonb(og.intent_tags), + 'traits', to_jsonb(og.traits), + 'requirements', to_jsonb(og.requirements), + 'instructions', to_jsonb(og.instructions) + ), + 'matchSummary', mr.match_summary, + 'status', mr.status, + 'sourceDraftId', mr.source_draft_id, + 'targetPlayers', mr.target_players, + 'recruitmentStatus', mr.recruitment_status, + 'version', mr.version, + 'meetingPoint', mr.meeting_point, + 'matchingStatus', mr.matching_status, + 'capacity', mr.capacity, + 'createdAt', mr.created_at, + 'completedAt', mr.completed_at + ) + from match_rooms mr + join offline_games og on og.id = mr.offline_game_id + where mr.id = p_room_id; +$$; + +create or replace function public.create_match_request( + p_user_id uuid, + p_intent_snapshot jsonb +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare v_row match_requests%rowtype; +begin + if p_intent_snapshot is null or p_intent_snapshot = '{}'::jsonb then + raise exception '必须先确认本次社交意图' using errcode = 'P0001'; + end if; + perform ensure_tomeet_user(p_user_id, '新朋友'); + if exists ( + select 1 + from room_members rm + join match_rooms mr on mr.id = rm.room_id + where rm.user_id = p_user_id + and rm.participation_status <> 'withdrawn' + and mr.status <> 'completed' + ) then + raise exception '你还有一个未结束的匹配房间' using errcode = 'P0001'; + end if; + + select * + into v_row + from match_requests + where user_id = p_user_id and status in ('matching', 'invited') + order by created_at desc + limit 1; + if found then return to_jsonb(v_row); end if; + + begin + insert into match_requests (user_id, intent_snapshot) + values (p_user_id, p_intent_snapshot) + returning * into v_row; + exception when unique_violation then + select * + into v_row + from match_requests + where user_id = p_user_id and status in ('matching', 'invited') + order by created_at desc + limit 1; + end; + return to_jsonb(v_row); +end; +$$; + +create or replace function public.create_initial_match_invite( + p_decision jsonb, + p_source_job_id uuid default null +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_member_ids uuid[]; + v_request_ids uuid[]; + v_game_id text := p_decision->>'offlineGameId'; + v_summary text := nullif(p_decision->>'summary', ''); + v_invite_id uuid; + v_required_request_id uuid; + v_inviter_demo boolean; + v_invitee_demo boolean; +begin + if p_source_job_id is not null then + select id into v_invite_id from match_invites where source_job_id = p_source_job_id; + if found then return get_match_invite(v_invite_id); end if; + end if; + + select array_agg(value::uuid order by ordinality) + into v_member_ids + from jsonb_array_elements_text(p_decision->'memberIds') with ordinality; + select array_agg(value::uuid order by ordinality) + into v_request_ids + from jsonb_array_elements_text(p_decision->'requestIds') with ordinality; + if coalesce(array_length(v_member_ids, 1), 0) <> 2 + or coalesce(array_length(v_request_ids, 1), 0) <> 2 then + raise exception '初始匹配必须且只能包含两位用户' using errcode = 'P0001'; + end if; + if v_member_ids[1] = v_member_ids[2] or v_request_ids[1] = v_request_ids[2] then + raise exception '初始匹配成员或请求不能重复' using errcode = 'P0001'; + end if; + + if p_source_job_id is not null then + select nullif(payload->>'requestId', '')::uuid + into v_required_request_id + from llm_jobs + where id = p_source_job_id; + if v_required_request_id is not null + and not (v_required_request_id = any(v_request_ids)) then + raise exception '匹配结果必须包含触发本次任务的用户' using errcode = 'P0001'; + end if; + end if; + + perform 1 + from match_requests + where id = any(v_request_ids) + order by id + for update; + if ( + select count(*) + from match_requests + where id = any(v_request_ids) and status = 'matching' + ) <> 2 then + raise exception '部分匹配请求已不在等待中' using errcode = 'P0001'; + end if; + if exists ( + select 1 + from unnest(v_request_ids, v_member_ids) as pair(request_id, member_id) + left join match_requests mr on mr.id = pair.request_id + where mr.user_id is distinct from pair.member_id + ) then + raise exception '成员和匹配请求不对应' using errcode = 'P0001'; + end if; + perform 1 from offline_games where id = v_game_id and active and max_players >= 2; + if not found then + raise exception '线下游戏不存在、已停用或房间上限无效' using errcode = 'P0001'; + end if; + + select is_demo into v_inviter_demo from users where id = v_member_ids[1]; + select is_demo into v_invitee_demo from users where id = v_member_ids[2]; + insert into match_invites ( + kind, + inviter_user_id, + inviter_request_id, + invitee_user_id, + invitee_request_id, + inviter_accepted, + invitee_accepted, + offline_game_id, + match_summary, + source_job_id + ) values ( + 'initial_pair', + v_member_ids[1], + v_request_ids[1], + v_member_ids[2], + v_request_ids[2], + coalesce(v_inviter_demo, false), + coalesce(v_invitee_demo, false), + v_game_id, + coalesce(v_summary, '已找到当前最合适的初始匹配对象'), + p_source_job_id + ) + returning id into v_invite_id; + + update match_requests + set status = 'invited', invite_id = v_invite_id, updated_at = now() + where id = any(v_request_ids); + return get_match_invite(v_invite_id); +end; +$$; + +create or replace function public.create_room_join_invite( + p_decision jsonb, + p_source_job_id uuid default null +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_room_id uuid := (p_decision->>'roomId')::uuid; + v_user_id uuid := (p_decision->>'userId')::uuid; + v_request_id uuid := (p_decision->>'requestId')::uuid; + v_summary text := nullif(p_decision->>'summary', ''); + v_game_id text; + v_matching_status text; + v_capacity smallint; + v_member_count integer; + v_invite_id uuid; + v_is_demo boolean; +begin + if p_source_job_id is not null then + select id into v_invite_id from match_invites where source_job_id = p_source_job_id; + if found then return get_match_invite(v_invite_id); end if; + end if; + + select offline_game_id, matching_status, capacity + into v_game_id, v_matching_status, v_capacity + from match_rooms + where id = v_room_id and status <> 'completed' + for update; + if not found then raise exception '目标房间不存在或已经结束' using errcode = 'P0002'; end if; + if v_matching_status <> 'active' then + raise exception '目标房间已停止匹配' using errcode = 'P0001'; + end if; + select count(*) into v_member_count + from room_members + where room_id = v_room_id and participation_status <> 'withdrawn'; + if v_member_count >= v_capacity then + update match_rooms set matching_status = 'full' where id = v_room_id; + raise exception '目标房间已满' using errcode = 'P0001'; + end if; + if exists ( + select 1 from match_invites + where room_id = v_room_id and kind = 'room_join' and status = 'pending' + ) then + raise exception '目标房间已有待处理邀请' using errcode = 'P0001'; + end if; + + perform 1 + from match_requests + where id = v_request_id and user_id = v_user_id and status = 'matching' + for update; + if not found then raise exception '入房候选请求已不在等待中' using errcode = 'P0001'; end if; + if exists ( + select 1 + from room_members + where room_id = v_room_id + and user_id = v_user_id + and participation_status <> 'withdrawn' + ) then + raise exception '候选用户已经在目标房间中' using errcode = 'P0001'; + end if; + + select is_demo into v_is_demo from users where id = v_user_id; + insert into match_invites ( + kind, + room_id, + invitee_user_id, + invitee_request_id, + inviter_accepted, + invitee_accepted, + offline_game_id, + match_summary, + source_job_id + ) values ( + 'room_join', + v_room_id, + v_user_id, + v_request_id, + true, + coalesce(v_is_demo, false), + v_game_id, + coalesce(v_summary, '邀请当前最适合房间氛围的下一位用户加入'), + p_source_job_id + ) + returning id into v_invite_id; + + update match_requests + set status = 'invited', invite_id = v_invite_id, updated_at = now() + where id = v_request_id; + + if coalesce(v_is_demo, false) then + insert into room_members ( + room_id, + user_id, + confirmed, + confirmed_at, + participation_status, + withdrawn_at + ) + values (v_room_id, v_user_id, true, now(), 'confirmed', null) + on conflict (room_id, user_id) do update + set confirmed = true, + confirmed_at = now(), + participation_status = 'confirmed', + withdrawn_at = null; + update match_requests + set status = 'matched', + phase = 'settling', + room_id = v_room_id, + updated_at = now() + where id = v_request_id; + update match_rooms + set version = version + 1, + recruitment_status = case when v_member_count + 1 >= v_capacity then 'full' else 'open' end + where id = v_room_id; + perform record_room_change_event( + v_room_id, + 'member_joined', + jsonb_build_object('joinedUserId', v_user_id, 'memberCount', v_member_count + 1) + ); + update match_invites + set status = 'accepted', resolved_at = now() + where id = v_invite_id; + if v_member_count + 1 >= v_capacity then + update match_rooms set matching_status = 'full' where id = v_room_id; + end if; + end if; + + return get_match_invite(v_invite_id); +end; +$$; + +create or replace function public.accept_match_invite( + p_invite_id uuid, + p_user_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_invite match_invites%rowtype; + v_room_id uuid; + v_capacity smallint; + v_member_count integer; + v_game_max smallint; +begin + select * into v_invite from match_invites where id = p_invite_id for update; + if not found then raise exception '匹配邀请不存在' using errcode = 'P0002'; end if; + if p_user_id is distinct from v_invite.inviter_user_id + and p_user_id is distinct from v_invite.invitee_user_id then + raise exception '用户不在该匹配邀请中' using errcode = 'P0001'; + end if; + if v_invite.status = 'accepted' then + return jsonb_build_object( + 'invite', get_match_invite(p_invite_id), + 'room', get_match_room(v_invite.room_id), + 'requeuedRequestIds', '[]'::jsonb + ); + end if; + if v_invite.status <> 'pending' then + raise exception '该匹配邀请已失效' using errcode = 'P0001'; + end if; + + if p_user_id = v_invite.inviter_user_id then + update match_invites set inviter_accepted = true where id = p_invite_id; + else + update match_invites set invitee_accepted = true where id = p_invite_id; + end if; + select * into v_invite from match_invites where id = p_invite_id; + if not (v_invite.inviter_accepted and v_invite.invitee_accepted) then + return jsonb_build_object( + 'invite', get_match_invite(p_invite_id), + 'room', null, + 'requeuedRequestIds', '[]'::jsonb + ); + end if; + + if v_invite.kind = 'initial_pair' then + select max_players into v_game_max + from offline_games + where id = v_invite.offline_game_id and active + for share; + if not found then raise exception '线下游戏不存在或已停用' using errcode = 'P0001'; end if; + + insert into match_rooms ( + offline_game_id, + match_summary, + status, + target_players, + recruitment_status, + matching_status, + capacity + ) values ( + v_invite.offline_game_id, + v_invite.match_summary, + 'confirmed', + case when v_game_max >= 3 then v_game_max else null end, + case when v_game_max <= 2 then 'full' else 'open' end, + case when v_game_max <= 2 then 'full' else 'active' end, + v_game_max + ) + returning id into v_room_id; + insert into room_members ( + room_id, + user_id, + confirmed, + confirmed_at, + participation_status + ) + values + (v_room_id, v_invite.inviter_user_id, true, now(), 'confirmed'), + (v_room_id, v_invite.invitee_user_id, true, now(), 'confirmed'); + update match_requests + set status = 'matched', + phase = 'settling', + room_id = v_room_id, + updated_at = now() + where id in (v_invite.inviter_request_id, v_invite.invitee_request_id); + else + v_room_id := v_invite.room_id; + select capacity into v_capacity + from match_rooms + where id = v_room_id and status <> 'completed' and matching_status = 'active' + for update; + if not found then raise exception '目标房间已停止匹配' using errcode = 'P0001'; end if; + select count(*) into v_member_count + from room_members + where room_id = v_room_id and participation_status <> 'withdrawn'; + if v_member_count >= v_capacity then + update match_rooms set matching_status = 'full' where id = v_room_id; + raise exception '目标房间已满' using errcode = 'P0001'; + end if; + insert into room_members ( + room_id, + user_id, + confirmed, + confirmed_at, + participation_status, + withdrawn_at + ) + values (v_room_id, v_invite.invitee_user_id, true, now(), 'confirmed', null) + on conflict (room_id, user_id) do update + set confirmed = true, + confirmed_at = now(), + participation_status = 'confirmed', + withdrawn_at = null; + update match_requests + set status = 'matched', + phase = 'settling', + room_id = v_room_id, + updated_at = now() + where id = v_invite.invitee_request_id; + update match_rooms + set version = version + 1, + recruitment_status = case when v_member_count + 1 >= v_capacity then 'full' else 'open' end + where id = v_room_id; + perform record_room_change_event( + v_room_id, + 'member_joined', + jsonb_build_object( + 'joinedUserId', + v_invite.invitee_user_id, + 'memberCount', + v_member_count + 1 + ) + ); + if v_member_count + 1 >= v_capacity then + update match_rooms set matching_status = 'full' where id = v_room_id; + end if; + end if; + + update match_invites + set room_id = v_room_id, status = 'accepted', resolved_at = now() + where id = p_invite_id; + return jsonb_build_object( + 'invite', get_match_invite(p_invite_id), + 'room', get_match_room(v_room_id), + 'requeuedRequestIds', '[]'::jsonb + ); +end; +$$; + +create or replace function public.decline_match_invite( + p_invite_id uuid, + p_user_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_invite match_invites%rowtype; + v_declined_request_id uuid; + v_requeued_request_id uuid; +begin + select * into v_invite from match_invites where id = p_invite_id for update; + if not found then raise exception '匹配邀请不存在' using errcode = 'P0002'; end if; + if v_invite.status = 'declined' then + return jsonb_build_object( + 'invite', get_match_invite(p_invite_id), + 'room', get_match_room(v_invite.room_id), + 'requeuedRequestIds', '[]'::jsonb + ); + end if; + if v_invite.status <> 'pending' then + raise exception '该匹配邀请已失效' using errcode = 'P0001'; + end if; + if p_user_id = v_invite.inviter_user_id then + v_declined_request_id := v_invite.inviter_request_id; + v_requeued_request_id := v_invite.invitee_request_id; + elsif p_user_id = v_invite.invitee_user_id then + v_declined_request_id := v_invite.invitee_request_id; + v_requeued_request_id := case + when v_invite.kind = 'initial_pair' then v_invite.inviter_request_id + else null + end; + else + raise exception '用户不在该匹配邀请中' using errcode = 'P0001'; + end if; + + update match_invites + set status = 'declined', resolved_at = now() + where id = p_invite_id; + update match_requests + set status = 'cancelled', + phase = 'waiting', + active_round_id = null, + options_expires_at = null, + invite_id = null, + updated_at = now() + where id = v_declined_request_id and status = 'invited'; + if v_requeued_request_id is not null then + update match_requests + set status = 'matching', + phase = 'waiting', + active_round_id = null, + options_expires_at = null, + invite_id = null, + updated_at = now() + where id = v_requeued_request_id and status = 'invited'; + end if; + + return jsonb_build_object( + 'invite', get_match_invite(p_invite_id), + 'room', get_match_room(v_invite.room_id), + 'requeuedRequestIds', case + when v_requeued_request_id is null then '[]'::jsonb + else jsonb_build_array(v_requeued_request_id) + end + ); +end; +$$; + +create or replace function public.stop_room_matching( + p_room_id uuid, + p_user_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare + v_requeued_request_ids uuid[]; +begin + perform 1 + from match_rooms + where id = p_room_id and status <> 'completed' + for update; + if not found then raise exception '房间不存在或已经结束' using errcode = 'P0002'; end if; + perform 1 + from room_members + where room_id = p_room_id + and user_id = p_user_id + and participation_status <> 'withdrawn'; + if not found then raise exception '用户不在房间中' using errcode = 'P0001'; end if; + + update match_rooms + set matching_status = case when matching_status = 'active' then 'stopped' else matching_status end, + recruitment_status = case when recruitment_status = 'open' then 'closed' else recruitment_status end + where id = p_room_id; + + select coalesce(array_agg(invitee_request_id), '{}') + into v_requeued_request_ids + from match_invites + where room_id = p_room_id and kind = 'room_join' and status = 'pending'; + update match_invites + set status = 'cancelled', resolved_at = now() + where room_id = p_room_id and kind = 'room_join' and status = 'pending'; + update match_requests + set status = 'matching', + phase = 'waiting', + active_round_id = null, + options_expires_at = null, + invite_id = null, + updated_at = now() + where id = any(v_requeued_request_ids) and status = 'invited'; + + return jsonb_build_object( + 'room', get_match_room(p_room_id), + 'requeuedRequestIds', to_jsonb(v_requeued_request_ids) + ); +end; +$$; + +create or replace function public.list_open_match_rooms(p_limit integer default 20) +returns setof jsonb +language sql +stable +security definer +set search_path = public +as $$ + select jsonb_build_object( + 'room', get_match_room(mr.id), + 'members', coalesce(( + select jsonb_agg(jsonb_build_object( + 'request', to_jsonb(member_request), + 'user_model', to_jsonb(um), + 'matching_narrative', case + when ump.stale and ump.version = 0 then um.vibe_narrative + when ump.stale then '' + when ump.version is null or ump.version = 0 + then coalesce(nullif(ump.matching_narrative, ''), um.vibe_narrative) + else ump.matching_narrative + end + ) order by rm.created_at) + from room_members rm + join user_models um on um.user_id = rm.user_id + left join user_memory_profiles ump on ump.user_id = rm.user_id + join lateral ( + select match_request.* + from match_requests match_request + where match_request.user_id = rm.user_id + and match_request.room_id = mr.id + order by match_request.created_at desc + limit 1 + ) member_request on true + where rm.room_id = mr.id + and rm.participation_status <> 'withdrawn' + ), '[]'::jsonb) + ) + from match_rooms mr + where mr.status <> 'completed' + and mr.matching_status = 'active' + and ( + select count(*) + from room_members rm + where rm.room_id = mr.id and rm.participation_status <> 'withdrawn' + ) < mr.capacity + and not exists ( + select 1 + from match_invites mi + where mi.room_id = mr.id and mi.kind = 'room_join' and mi.status = 'pending' + ) + order by mr.created_at + limit least(greatest(p_limit, 1), 100); +$$; + +create or replace function public.complete_match_room(p_room_id uuid) +returns jsonb +language plpgsql +security definer +set search_path = public +as $$ +declare v_status text; +begin + select status into v_status from match_rooms where id = p_room_id for update; + if not found then raise exception '房间不存在' using errcode = 'P0002'; end if; + if v_status = 'completed' then return get_match_room(p_room_id); end if; + if exists ( + select 1 + from room_members + where room_id = p_room_id + and participation_status <> 'withdrawn' + and not confirmed + ) then + raise exception '所有成员确认后才能完成活动' using errcode = 'P0001'; + end if; + update match_rooms + set status = 'completed', + matching_status = case when matching_status = 'full' then 'full' else 'stopped' end, + recruitment_status = case when matching_status = 'full' then 'full' else 'closed' end, + completed_at = coalesce(completed_at, now()) + where id = p_room_id; + update user_models + set current_intent = '{}'::jsonb, version = version + 1, updated_at = now() + where user_id in ( + select user_id + from room_members + where room_id = p_room_id and participation_status <> 'withdrawn' + ) + and current_intent <> '{}'::jsonb; + return get_match_room(p_room_id); +end; +$$; + +revoke all on function public.get_match_invite(uuid) from public, anon, authenticated; +revoke all on function public.get_match_room(uuid) from public, anon, authenticated; +revoke all on function public.create_match_request(uuid, jsonb) from public, anon, authenticated; +revoke all on function public.create_initial_match_invite(jsonb, uuid) from public, anon, authenticated; +revoke all on function public.create_room_join_invite(jsonb, uuid) from public, anon, authenticated; +revoke all on function public.accept_match_invite(uuid, uuid) from public, anon, authenticated; +revoke all on function public.decline_match_invite(uuid, uuid) from public, anon, authenticated; +revoke all on function public.stop_room_matching(uuid, uuid) from public, anon, authenticated; +revoke all on function public.list_open_match_rooms(integer) from public, anon, authenticated; +revoke all on function public.complete_match_room(uuid) from public, anon, authenticated; +revoke all on function public.sync_match_room_matching_state() from public, anon, authenticated; +revoke execute on function public.create_match_room(jsonb, uuid) from service_role; + +grant execute on function public.get_match_invite(uuid) to service_role; +grant execute on function public.get_match_room(uuid) to service_role; +grant execute on function public.create_match_request(uuid, jsonb) to service_role; +grant execute on function public.create_initial_match_invite(jsonb, uuid) to service_role; +grant execute on function public.create_room_join_invite(jsonb, uuid) to service_role; +grant execute on function public.accept_match_invite(uuid, uuid) to service_role; +grant execute on function public.decline_match_invite(uuid, uuid) to service_role; +grant execute on function public.stop_room_matching(uuid, uuid) to service_role; +grant execute on function public.list_open_match_rooms(integer) to service_role; +grant execute on function public.complete_match_room(uuid) to service_role;