百游棋牌源代码开发搭建教程(五):房间创建、座位分配与请求幂等实现

百游棋牌开发搭建教程(五):房间创建、座位分配与请求幂等实现

文章摘要: 本篇使用两个真实测试账号完成创建房间、加入、准备与状态查询,给出房间服务完整代码,讲清唯一约束、事务行锁、请求摘要和已成功重试的处理顺序,并通过重复创建、抢占座位和旧状态请求验证边界。

文章目录


前言

玩家已经能够登录,接下来建立真正的多人房间。这里最重要的不是页面上出现两个头像,而是数据库中两个不同账号拥有不同座位,准备条件满足后房间只进入一次进行状态,并且重复请求不会创建第二个业务结果。

本系列教学房间容纳两名玩家、运行一局五子棋G1。创建者坐0号位,加入者坐1号位,双方准备后开始。多人数牌类可以复用身份、事务与请求处理思路,但座位、准备和局数规则需要明确扩展。

下文所有命令在前四篇基础上执行,完整服务应已就绪。两个账号使用不同令牌,不能把同一令牌放进两个变量就当成两位玩家。

一、确认房间状态与数据约束

本例只有三个房间状态:WAITING、PLAYING、FINISHED。等待状态允许第二人加入与准备;进行状态只接受合法游戏动作;结束状态允许查询,不接受继续落子。

text 复制代码
CREATE → WAITING
JOIN → 仍为 WAITING
双方 READY → PLAYING
胜出或棋盘填满 → FINISHED

room_members中的主键限制一个账号在同一房间只出现一次,UNIQUE(room_id,seat)限制同一座位只有一人。应用层仍要检查房间是否满员,数据库约束作为最后保护,两者不能互相替代。

为了集中验证核心链路,示例不实现离座、踢人、解散和多局续开。加入这些动作时,必须补全状态转换与历史记录,不能直接删除座位记录让运行中的房间失去参与者。

二、定义动作协议

创建房间请求包含requestId,可选指定俱乐部;房间内动作包含动作类型、请求编号及必要参数。JOIN不依赖旧画面序号,READY与PLAY要求当前序号一致。

json 复制代码
{
  "requestId": "本次操作的UUID",
  "action": "READY",
  "expectedSeq": 1
}

同一操作因超时重试时保留原编号;用户改变动作内容后重新提交,使用新编号。不要在每次网络重发时生成新UUID,那会把重试变成两个独立操作。

服务端操作人从认证上下文读取。请求中不需要自报座位,实际座位通过成员表确定。否则恶意客户端可以把自己的seat改成对方编号。

三、把防重与状态变更放进同一事务

once函数以账号和请求编号协调执行,保存规范化请求摘要和已经确认的响应。请求重试时先读历史结果,再检查当前状态,因此第一次成功推进序号后,第二次仍能得到原成功结果。

相同编号对应不同内容时返回409,不能直接拿旧结果套用新动作。摘要通过服务端构造固定字段顺序生成,避免网络JSON属性顺序不同导致相同语义被误判为冲突。

成功结果与房间变更一起提交;异常则回滚,两者不会各自成功一半。本示例只保存成功结果,失败动作可以修正后用新编号再试,业务效果仍受状态与成员检查约束。

四、阅读房间服务完整代码

附件src/rooms.mjs包括快照查询、防重、创建和动作处理,完整内容如下:

javascript 复制代码
import { randomUUID, createHash } from 'node:crypto';
import { inTransaction } from './db.mjs';
import { fail } from './errors.mjs';
import { newGame, play } from './game.mjs';

export async function roomView(client, roomId, userId) {
  const result = await client.query(`SELECT r.id,r.status,r.rule_version,r.seq,r.state,
    (SELECT jsonb_agg(jsonb_build_object('user_id',m.user_id,'seat',m.seat,'ready',m.ready) ORDER BY m.seat)
     FROM room_members m WHERE m.room_id=r.id) AS members
    FROM rooms r WHERE r.id=$1 AND EXISTS (
      SELECT 1 FROM room_members own WHERE own.room_id=r.id AND own.user_id=$2)`, [roomId,userId]);
  if (!result.rowCount) fail('NOT_A_MEMBER', 403);
  const room = result.rows[0];
  // G1 is a public-information board game. Card games MUST use per-player projections.
  return room;
}

async function requireClubMember(client, clubId, userId) {
  if (!clubId) return;
  const member = await client.query('SELECT 1 FROM club_members WHERE club_id=$1 AND user_id=$2', [clubId, userId]);
  if (!member.rowCount) fail('NOT_A_CLUB_MEMBER', 403);
}

async function once(pool, userId, requestId, data, work) {
  const fingerprint = createHash('sha256').update(JSON.stringify(data)).digest('hex');
  return inTransaction(pool, async client => {
    // Hash collisions only serialize unrelated commands, never grant authority.
    await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1,0))', [userId + ':' + requestId]);
    const old = (await client.query('SELECT fingerprint,response FROM command_results WHERE user_id=$1 AND request_id=$2', [userId, requestId])).rows[0];
    if (old) {
      if (old.fingerprint !== fingerprint) fail('REQUEST_ID_CONFLICT', 409);
      return old.response;
    }
    const response = await work(client);
    await client.query('INSERT INTO command_results(user_id,request_id,fingerprint,response) VALUES($1,$2,$3,$4)', [userId, requestId, fingerprint, response]);
    return response;
  });
}

export async function createRoom(pool, userId, requestId, clubId = null) {
  return once(pool, userId, requestId, { action: 'CREATE', clubId }, async client => {
    await requireClubMember(client, clubId, userId);
    const roomId = randomUUID();
    await client.query("INSERT INTO rooms(id,club_id,owner_id,status,state) VALUES($1,$2,$3,'WAITING',$4)", [roomId, clubId, userId, newGame()]);
    await client.query('INSERT INTO room_members(room_id,user_id,seat) VALUES($1,$2,0)', [roomId, userId]);
    return { roomId, seq: 0, status: 'WAITING' };
  });
}

export async function command(pool, userId, roomId, input) {
  // Construct a fixed-order structure; wire-property order does not alter fingerprint.
  const data = { roomId, action: input.action, expectedSeq: input.expectedSeq ?? null,
    row: input.row ?? null, col: input.col ?? null };
  return once(pool, userId, input.requestId, data, async client => {
    const room = (await client.query('SELECT * FROM rooms WHERE id=$1 FOR UPDATE', [roomId])).rows[0];
    if (!room) fail('ROOM_NOT_FOUND', 404);
    await requireClubMember(client, room.club_id, userId);
    let members = (await client.query('SELECT user_id,seat,ready FROM room_members WHERE room_id=$1 ORDER BY seat', [roomId])).rows;
    let me = members.find(m => m.user_id === userId);
    if (input.action === 'JOIN') {
      if (me) return { roomId, seq: room.seq, status: room.status };
      if (room.status !== 'WAITING') fail('ROOM_ALREADY_STARTED', 409);
      if (members.length >= 2) fail('ROOM_FULL', 409);
      await client.query('INSERT INTO room_members(room_id,user_id,seat) VALUES($1,$2,1)', [roomId, userId]);
    } else {
      if (!me) fail('NOT_A_MEMBER', 403);
      if (input.expectedSeq !== room.seq) fail('STALE_STATE', 409);
      if (input.action === 'READY') {
        if (room.status !== 'WAITING') fail('BAD_ROOM_STATE', 409);
        await client.query('UPDATE room_members SET ready=true WHERE room_id=$1 AND user_id=$2', [roomId, userId]);
        if (members.length === 2 && members.every(m => m.user_id === userId || m.ready)) room.status = 'PLAYING';
      } else if (input.action === 'PLAY') {
        if (room.status !== 'PLAYING') fail('BAD_ROOM_STATE', 409);
        room.state = play(room.state, me.seat, input.row, input.col);
        if (room.state.winner !== null || room.state.draw) {
          room.status = 'FINISHED';
          const result = { winner: room.state.winner, draw: room.state.draw, ruleVersion: room.rule_version };
          await client.query('INSERT INTO settlements(room_id,result) VALUES($1,$2)', [roomId, result]);
          for (const member of members) {
            const delta = room.state.draw ? 0 : member.seat === room.state.winner ? 1 : -1;
            await client.query('INSERT INTO score_entries(room_id,user_id,delta) VALUES($1,$2,$3)', [roomId, member.user_id, delta]);
          }
        }
      } else fail('UNKNOWN_ACTION');
    }
    room.seq++;
    await client.query('UPDATE rooms SET status=$2,state=$3,seq=$4 WHERE id=$1', [roomId, room.status, room.state, room.seq]);
    await client.query('INSERT INTO room_events(room_id,seq,event) VALUES($1,$2,$3)', [roomId, room.seq, {
      action: input.action, actor: userId, row: input.row ?? null, col: input.col ?? null,
      status: room.status, game: room.state
    }]);
    return { roomId, seq: room.seq, status: room.status };
  });
}

先看创建路径:确认可选俱乐部资格,生成房间UUID,插入初始棋盘,给创建人分配座位0,最后保存请求结果。任一步失败,事务回滚,不留下只有房间没有成员的半成品。

再看动作路径:读取房间并加行锁,查询成员,检查动作对应状态,保存新状态与序号,追加事件并记录响应。落子与结算将在后两篇展开,本篇先关注JOIN与READY。

roomView使用一个SQL语句读取房间与成员聚合,避免先读序号、再读成员时混入另一事务的新状态。本例棋盘是公开信息;牌类游戏必须增加按玩家过滤的视图,不能照搬完整state返回所有手牌。

五、为什么还需要数据库行锁

Node单线程只表示同一时刻执行一段JavaScript,不表示跨越数据库等待的两个请求不会交错。两个加入请求可能先后读取相同空位,因此同一房间变更事务先锁定房间行。

sql 复制代码
SELECT * FROM rooms WHERE id=$1 FOR UPDATE;

锁在事务结束时释放。事务内不执行外部短信、文件上传或长时间等待,减少占锁时间。所有会改变房间的路径需要遵守相同约定,新增管理接口也不能绕过它。PostgreSQL行锁

请求级咨询锁先于房间行锁取得,用于避免相同请求并发执行。摘要键的哈希碰撞最多导致无关请求额外等待,不会改变权限判断;账号与请求的唯一键仍保存在表中。

六、准备两个登录会话

按第三篇方式注册第二个账号bob_01。已有账号不要重复创建,直接登录。然后分别保存认证头:

powershell 复制代码
$apiBase = 'http://127.0.0.1:3000'
$a = @{ username='alice_01'; password='替换为Alice的测试密码' } | ConvertTo-Json
$b = @{ username='bob_01'; password='替换为Bob的测试密码' } | ConvertTo-Json
$loginA = Invoke-RestMethod -Method Post -Uri "$apiBase/api/auth/login" -ContentType 'application/json' -Body $a
$loginB = Invoke-RestMethod -Method Post -Uri "$apiBase/api/auth/login" -ContentType 'application/json' -Body $b
$headersA = @{ Authorization='Bearer ' + $loginA.token }
$headersB = @{ Authorization='Bearer ' + $loginB.token }

分别调用/api/me确认编号不同。示例密码文字必须替换,不作为默认凭据。后续一小时会话过期时,重新登录并更新认证头,不通过延长手机时间恢复。

七、创建房间并验证重复请求

powershell 复制代码
$createRequest = @{ requestId=[guid]::NewGuid().ToString() } | ConvertTo-Json
$created = Invoke-RestMethod -Method Post -Uri "$apiBase/api/rooms" -Headers $headersA -ContentType 'application/json' -Body $createRequest
$roomId = $created.roomId
$createdAgain = Invoke-RestMethod -Method Post -Uri "$apiBase/api/rooms" -Headers $headersA -ContentType 'application/json' -Body $createRequest
$created
$createdAgain

两次应返回同一个roomId、序号0和WAITING状态。数据库查询对应请求时只有一份结果,房间创建记录也只有一份。不要通过比较返回速度判断防重是否生效,检查实际业务对象才可靠。

若第二次产生新房间,先确认是否真的复用了同一段$createRequest,以及请求是否使用同一账号。重新生成UUID再发送,不属于重复请求测试。

八、加入第二个座位

powershell 复制代码
$joinRequest = @{ requestId=[guid]::NewGuid().ToString(); action='JOIN' } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "$apiBase/api/rooms/$roomId/commands" -Headers $headersB -ContentType 'application/json' -Body $joinRequest
$view = Invoke-RestMethod -Uri "$apiBase/api/rooms/$roomId" -Headers $headersA
$view | Select-Object id,status,seq
$view.members

预期序号变为1,成员有0、1两个座位,均未准备。Bob重试同一JOIN应返回第一次结果,不新增第三个成员。已有成员使用新的JOIN编号时,本例返回当前状态,不重复写事件。

第三个有效账号尝试加入应得到ROOM_FULL。非成员直接查询完整房间应得到403。这样既检查容量,也检查"知道房间UUID并不等于有权读取"。

九、双方准备并进入进行状态

准备前每次查询当前序号,再提交:

powershell 复制代码
$viewA = Invoke-RestMethod -Uri "$apiBase/api/rooms/$roomId" -Headers $headersA
$readyA = @{ requestId=[guid]::NewGuid().ToString(); action='READY'; expectedSeq=$viewA.seq } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "$apiBase/api/rooms/$roomId/commands" -Headers $headersA -ContentType 'application/json' -Body $readyA
$viewB = Invoke-RestMethod -Uri "$apiBase/api/rooms/$roomId" -Headers $headersB
$readyB = @{ requestId=[guid]::NewGuid().ToString(); action='READY'; expectedSeq=$viewB.seq } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "$apiBase/api/rooms/$roomId/commands" -Headers $headersB -ContentType 'application/json' -Body $readyB

首次准备后仍为WAITING,第二人准备后变为PLAYING。没有其他操作时序号依次为2和3。若两人都基于序号1提交,后执行的请求可能得到STALE_STATE,应同步后由用户确认重新提交,不自动改编号重放所有旧动作。

这种严格序号策略适用于本教学轮流棋局。麻将多人独立响应窗口需要更细的窗口编号与响应状态,不能机械要求每个动作都等于全局最新序号。

十、查询事件与数据库对应关系

powershell 复制代码
Invoke-RestMethod -Uri "$apiBase/api/rooms/$roomId/events" -Headers $headersA

对应SQL可以检查房间、成员和事件:

sql 复制代码
SELECT id,status,seq FROM rooms ORDER BY created_at DESC;
SELECT room_id,user_id,seat,ready FROM room_members ORDER BY room_id,seat;
SELECT room_id,seq,event->>'action' AS action FROM room_events ORDER BY room_id,seq;

本例创建状态从序号0开始,加入和两次准备产生三个事件。被拒绝动作不应增加序号,也不应留下成功请求结果。查记录时始终按同一房间筛选,避免把其他练习数据混在一起。

十一、重复、冲突和并发怎么验收

防重至少测三种情况:同编号同内容返回旧结果;同编号不同内容返回409;不同编号但不合法的新动作被状态规则拒绝。只测第一种还不够。

座位并发需要在真实PostgreSQL多连接环境中让两个不同账号同时抢最后一个位置,最终只允许一个加入。附件PGlite集成测试能验证SQL与顺序业务链路,但它是单连接环境,不代替真实数据库锁竞争测试。

生产规模下还要限制房间数量、等待期限、请求速率与保存期限。教学示例没有房间自动清理,不要在公开网络上无限创建房间;重复实验使用独立测试库并按明确计划管理数据。

十二、补做房间异常与并发演练

1. 把账号、座位和窗口分开记录

两个窗口不一定是两个账号,同一账号重复登录也可能拥有两个不同令牌。房间分配按用户编号而不是令牌数量判断成员,所以先查询/api/me,确认A和B的id不同,再执行加入。若两个窗口实际上属于同一用户,JOIN返回已有成员状态是正确行为,不是座位分配失败。

验收记录中写清用户编号、房间编号、座位和当前seq。发生错误时根据这四项定位,比凭窗口左右位置记忆更可靠。尤其关闭窗口重新登录后,左边不一定仍是原来的黑方,不能根据UI摆放推断服务端身份。

2. 已准备玩家再次点击会发生什么

相同requestId的READY重试返回旧结果;新的requestId属于另一条请求,在WAITING且序号有效时,当前实现仍会写一次准备事件。ready布尔值不会变回false,但seq会增加。文章必须说明这个实际行为,不能把"客户端禁止双击"描述成服务端天然不会产生任何重复准备事件。

正式产品若要求已经准备的成员再次准备直接返回当前状态,应在规则分支中明确添加无变化判断,并决定是否保存幂等结果和是否产生事件。更改后同步更新固定流程的预期序号及测试,不能只优化一段代码却让回放和统计仍使用旧假设。

3. 区分房间锁与请求锁

请求锁协调同一账号同一requestId的并发处理,房间行锁协调不同请求对同一房间的修改。两个用户的requestId偶然相同不会共用同一业务结果,因为去重键还包含用户编号;同一用户把编号用到另一房间则会触发内容冲突。

所有分支按一致顺序获取锁,有助于控制互相等待。以后增加一次操作修改多个房间的功能时,还要规定对象锁定顺序,并处理数据库报告的死锁或超时。不能因为当前单房间例子简单,就把跨房间转移写成两个互不相关的UPDATE。

4. 真实抢位测试要先准备同一个起点

在真实PostgreSQL验收库创建一个只有A的WAITING房间,再让B和C各持自己的令牌与不同requestId同时发送JOIN。最终应只有一个座位1,另一人得到满员或其他符合最终状态的拒绝。验收重点是成员唯一、事件数量和数据库一致性,不要求两条响应在屏幕上以某个固定顺序出现。

要重复该测试,应新建房间,而不是删除上次的成员行后继续用旧事件。手动删除会让房间历史与成员状态不一致,测试结果失去解释基础。进程内单连接测试没有制造真正竞争,因此本系列没有把它的通过结果当成抢位并发实测。

5. 响应未知时保留什么材料

网络超时后应保留账号归属、接口路径、requestId和原始业务字段。重试时原样发送这些字段,不能先读取新seq再替换expectedSeq,却仍沿用旧requestId;那样请求摘要改变,服务端正确返回冲突。读到最新状态用于展示,确认原操作则仍使用原请求,两者可以同时存在。

如果客户端已经丢失旧请求内容,就不能凭空构造一个看似相同的请求来保证确认。应查询房间状态并给用户明确反馈,必要时由受控日志定位历史结果。正式客户端的待确认队列需要持久化到何种程度,应依据切后台、进程被杀和切账号场景设计。

6. 不要用客户端时间判断谁先行动

本示例以数据库事务看到的房间状态和seq验证指令。手机上显示的点击时间、动画结束时间或本地倒计时不能替代服务端顺序。两名玩家同时看到旧画面时,后提交者可能被拒绝,需要刷新状态,而不是在客户端补一个时间戳要求服务端强行接受。

复杂牌类的响应窗口另有规则,但也需要由服务端定义截止与仲裁。用户设备时间可被修改,网络传输也会有抖动。若将本地时间当成最终裁决依据,不同客户端就可能对同一局得到不同结果,这比界面延迟更难修复。

7. 结束房间的保存与清理

FINISHED房间保留成员、状态和事件,供战绩与回放使用。等待房间长期无人使用会占用存储,当前示例没有自动清理。以后增加过期任务时,应区分等待房间、进行中房间和已结算房间,不能按创建时间一刀切删除所有对象。

清理还要考虑外键与幂等结果保存期限。原房间删除但旧请求结果仍保留时,重试可能返回一个已经不存在的roomId;幂等结果过早删除则失去确认能力。保留策略应作为产品规则和运维方案一起设计,而不是在数据库变大后临时执行无范围删除。

8. 查询结果只表示某一时刻的状态

HTTP查询与下一次提交之间可能发生其他动作,所以先查到seq等于3,并不保证提交时仍然等于3。expectedSeq的意义就是让服务端检查这个前提是否仍成立。遇到冲突应获取新视图,并根据当前操作资格重新决定,不能在客户端把所有409都自动忽略。

也不要在界面上先把服务器序号加一再提交。序号由成功事务推进,客户端预测的数字只能用于临时动画,不能成为下一条权威指令的依据。保留最后一次服务端确认的快照,可以让断线、冲突和重试都围绕同一个确定状态处理。

重复验收时保留每轮的独立房间编号,避免混用旧状态。

十三、本篇验收与下一步

完成后应能用两个账号创建、加入和准备;同一请求不产生重复房间;第三人不能加入;非成员不能读取状态;旧序号动作被拒绝;有效事件顺序连续。

记录本次房间UUID、两个用户编号和最后序号,下一篇在该房间内实现落子与胜负判断,逐步验证四个方向、边界坐标、重复位置和结束后的操作拒绝。

相关推荐
凤山老林1 小时前
Spring Boot 整合 Flowable 的企业级落地指南
数据库·spring boot·后端·flowable·工作流
企业数字化笔记2 小时前
AI写的系统出现504怎么办?接口超时和数据库慢查询排查
数据库·后端
IT_陈寒2 小时前
Redis的Set操作居然能把我的服务整挂了?
前端·人工智能·后端
计算机魔术师2 小时前
Suno 发布 v6 音乐模型,推出 v6、v6-wild、v6-mini 三个版本
前端
2601_962218612 小时前
万象生鲜系统订单全生命周期状态同步技术实现业务可视
大数据·数据库·人工智能·python·算法
whyweplay2 小时前
elpis : DSL动态组件学习
前端
Wang's Blog3 小时前
Java框架快速入门:Spring Security+OAuth2之用户注册与唯一性校验实现
java·数据库·spring
用户921080262863 小时前
前端 Vue 专栏 06:异步更新机制、任务队列与 nextTick
前端
跨境生态圈3 小时前
2026谷歌SEO快速排名深度解析:合规起量、避坑指南与实战落地策略
数据库·人工智能·爬虫·搜索引擎·chatgpt