麻將出租 · API 說明

本文件說明代理端(agency / backoffice)如何簽發 token,以及遊戲端如何以 token 進房開局。所有介面皆為「無狀態」:身份由 URL / 參數中的 game_id + user_id + token 決定,不依賴 session cookie。

安全模型:key 永不离开服务器。簽章用的 key 是該代理自己的 common_ag.key(出租時分發給各 AG 的專屬密鑰,每個 AG 一把)。代理端用自己拿到的 key 在「自己後台」計算出 chkstr 後,只把簽章(而非 key)送給伺服器;伺服器依 ag_no 查出對應的 common_ag.key 來驗證。

1. 簽發 Token — POST /api/token

代理端在「自己的後台」持有 system_md5_key,對請求欄位做 MD5 簽章後呼叫此 API,取得可發給玩家的 token。

Request body(JSON)

欄位型別說明
ag_nostring代理編號,須存在於 common_ag 且 is_enabled = 1。
game_idstring授權的局別(如 0001),須存在於 lob_room。
user_id_arystring授權玩家清單,逗號分隔(如 linlin,aa5002)。進房時的 user_id 必須在其中。每個 user_id 都必須已存在於 common_user 且 ag_no 相符(多租戶隔離:代理只能發給自己名下的帳號),否則回 account_not_found。
utimeinteger呼叫方 Unix time(秒)。與伺服器時間誤差須 ≤ 15 秒,否則視為重放攻擊拒絕。
chkstrstring簽章:md5( ag_no + game_id + user_id_ary + utime + common_ag.key )。
return_urlstring可省略:玩家遊戲結束後要跳回的網址(如 https://mj.168a.com.tw/go/index.html)。空白或不傳則連結中不帶此參數。會原樣附加到每個玩家連結的尾端(見下方 urls)。

chkstr 計算方式(前端/後台實作)

// JavaScript:AG_KEY 為貴司(ag_no)分發到的 common_ag.key
function md5(s){ /* 使用你們後台的 md5 函式 */ }
const raw = ag_no + game_id + user_id_ary + utime + AG_KEY;
const chkstr = md5(raw);

// PHP
$raw = $ag_no . $game_id . $user_id_ary . $utime . $AG_KEY;
$chkstr = md5($raw);

Response(成功 200)

{
  "ok": true,
  "token": "HUc4jfv9nXxSLhefzUDWNMcz5Ft2oiml",
  "game_id": "0001",
  "user_id_ary": [
    "pNGQiUfX1zsyqG2N_wz6iA",
    "pNGQiUfX1zu9CINiRGRjEg"
  ],
  "urls": [
    "https://mj.168a.com.tw?token=HUc4jfv9nXxSLhefzUDWNMcz5Ft2oiml&user_id=pNGQiUfX1zsyqG2N_wz6iA&return_url=https://mj.168a.com.tw/go/index.html",
    "https://mj.168a.com.tw?token=HUc4jfv9nXxSLhefzUDWNMcz5Ft2oiml&user_id=pNGQiUfX1zu9CINiRGRjEg&return_url=https://mj.168a.com.tw/go/index.html"
  ]
  }

urls 為每位玩家專屬的可點擊連結(順序對應 user_id_ary)。基底網域取自 system_var.base_domain(預設 mj.168a.com.tw);return_url 未傳時連結中不加該參數。出租方後台可直接把各自的 urls[i] 發給對應玩家。

注意:回傳的 user_id_ary 已是「加密陣列」(每個元素對應請求中的一個 user_id,順序一致)。遊戲網址要給每位玩家各自的那一個加密值:?token=TOKEN&user_id_ary=該玩家對應的加密元素。伺服器以 common_ag.key 解密還原真實 user_id 並綁定視角,玩家無法竄改。加密細節見下方「視角加解密」。

視角加解密(user_id_ary 元素)

每個元素 = encrypt("user_id=" + 真實id, common_ag.key);解密後去掉 user_id= 前綴即為真實 user_id。演算法:DES-ECB,金鑰 = common_ag.key 取前 8 bytes(UTF-8,不足 8 位元組右補 0x00),PKCS#7 填充,輸出為 URL-safe Base64(+/ 換 -_,去掉 = 填充)。

// JavaScript(Node,須以 --openssl-legacy-provider 啟動,因 DES 屬舊演算法)
const crypto = require('crypto');
function key8(k){ const b = Buffer.alloc(8); Buffer.from(k,'utf8').copy(b); return b; }
function enc(uid, k){
  const c = crypto.createCipheriv('des-ecb', key8(k), null);
  const e = Buffer.concat([c.update(Buffer.from('user_id='+uid)), c.final()]);
  return e.toString('base64').replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
}
function dec(blob, k){
  const s = blob.replace(/-/g,'+').replace(/_/g,'/'); while(s.length%4) s+='=';
  const d = crypto.createDecipheriv('des-ecb', key8(k), null);
  return Buffer.concat([d.update(Buffer.from(s,'base64')), d.final()]).toString();
}
const token0 = enc('linlin', AG_KEY);   // 回傳陣列第 0 個
const token1 = enc('aa5002', AG_KEY);   // 回傳陣列第 1 個

// PHP(openssl 原生支援 DES)
// function enc($uid,$k){ $k=substr(str_pad($k,8,"\0"),0,8); return str_replace(['+','/','='],['-','_',''],base64_encode(openssl_encrypt("user_id=".$uid,'DES-ECB',$k,OPENSSL_RAW_DATA))); }
// function dec($b,$k){ $k=substr(str_pad($k,8,"\0"),0,8); $b=str_replace(['-','_'],['+','/'],$b); $b.=str_repeat('=',(4-strlen($b)%4)%4); return openssl_decrypt(base64_decode($b),'DES-ECB',$k,OPENSSL_RAW_DATA); }
部署提醒:Node 17+ 預設停用 DES,遊戲伺服器啟動時須加 NODE_OPTIONS=--openssl-legacy-provider(本機 server_log.bat 已設定)。PHP / Java 等語言原生支援 DES,無此限制。

錯誤碼(均回 401)

error原因
time_driftutime 與伺服器時間誤差超過 15 秒。
bad_signaturechkstr 驗證不符。
agency_not_foundag_no 不存在或未啟用。
game_not_foundgame_id 不存在於 lob_room。
agency_key_missing該 ag_no 的 common_ag.key 未設定(未分發密鑰)。
account_not_founduser_id_ary 中有 user_id 不存在於 common_user,或其 ag_no 與請求的 ag_no 不符。回傳內容含 missing 陣列。

2. 遊戲端憑 Token 進房(game_id 由 token 決定)

簽發通過後,ag_no / game_id / user_id_ary 已寫入 twmj_token。玩家只需拿著 token 開啟遊戲;遊戲端收到 token 後直接查 twmj_token 取得上述參數,不需要(也不該)另行傳遞 game_id。玩家再以 user_id_ary 中的一個編號作為自身身份進房即可。

底層實作(遊戲頁內部呼叫,一般不需手動打):POST /api/lobby/rooms/:roomId/start?token=TOKEN&user_id_ary=加密值。伺服器以 token 查出 game_id 並據以開局,再用 common_ag.key 解密 user_id_ary 參數、核對還原出的 user_id 是否在 token 的清單內(不在則拒絕)。

Query / Body 參數

參數說明
token第 1 步取得的 token(遊戲頁從網址取得)。
user_id_ary該玩家專屬的單一加密值(取自第 1 步回傳陣列中對應自己的那一個),不可為明文 user_id。伺服器解密後綁定視角。
game_id可省略:若提供僅供前端顯示用,伺服器一律以 twmj_token 內綁定的 game_id 為準。

錯誤碼

error原因
token_invalidtoken 不存在 / 解密出的 user_id 不在 user_id_ary 清單內。
bad_uiduser_id_ary 加密值無法解密或格式錯誤(疑似偽造)。
user_id_not_allowed解密出的 user_id 不在 token 授權清單內。
user_id_required帶了 token 但未提供 user_id_ary(退回舊相容:以清單第一個為身份)。
room_not_foundtoken 綁定的 game_id 找不到對應房間。
login_required未帶 token 且無 session(開發通道)。
若 不帶 token,伺服器退回舊的「開發通道」:以 user_id 參數或 session cookie 認證(沿用既有行為)。

3. WebSocket 連線

遊戲即時通道只需帶 token,並附上該玩家專屬的加密 user_id_ary 以鎖定視角:

ws://<host>/ws?token=TOKEN&user_id_ary=加密值

連線時若帶 token,伺服器會非同步查 twmj_token 核對,並以 common_ag.key 解密 user_id_ary 參數鎖定 ws.userId(視角身份);訊息層帶來的 user_id 在 token 模式下會被忽略,避免客戶端偽造。多個真人帶同一把 token、各自不同的加密 user_id_ary 連線,即共享同一戰局、各自視角。

4. 線上測試

簽發 Token(本機計算 chkstr)

貼上貴司(ag_no)分發到的 common_ag.key,本頁會在瀏覽器內用 MD5 計算出 chkstr 後才送出;密鑰不會傳給伺服器。

進房開局(開發者備註)

實際進房由「上方簽發成功後的 開啟遊戲 連結」完成:遊戲頁以網址中的 token + user_id 呼叫 POST /api/lobby/rooms/:roomId/start,伺服器從 twmj_token 取得 game_id / user_id_ary / ag_no 並開局。一般不需手動打此 API。

curl 範例(token 模式,user_id_ary 為第 1 步回傳陣列中的加密值):

curl -X POST "https://mj.168a.com.tw/api/lobby/rooms/0001/start?token=TOKEN&user_id_ary=ENCRYPTED_UID"

# 或使用 JSON body(效果相同)
curl -X POST https://mj.168a.com.tw/api/lobby/rooms/0001/start \
  -H "Content-Type: application/json" \
  -d '{"token":"TOKEN","user_id_ary":"ENCRYPTED_UID"}'

成功回傳 { "ok": true, "room": {...}, "userId": "..." };只帶 token 不帶視角時,會以 token 清單第一個 user_id 作為身份。