本文件說明代理端(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 來驗證。
POST /api/token代理端在「自己的後台」持有 system_md5_key,對請求欄位做 MD5 簽章後呼叫此 API,取得可發給玩家的 token。
| 欄位 | 型別 | 說明 |
|---|---|---|
ag_no | string | 代理編號,須存在於 common_ag 且 is_enabled = 1。 |
game_id | string | 授權的局別(如 0001),須存在於 lob_room。 |
user_id_ary | string | 授權玩家清單,逗號分隔(如 linlin,aa5002)。進房時的 user_id 必須在其中。每個 user_id 都必須已存在於 common_user 且 ag_no 相符(多租戶隔離:代理只能發給自己名下的帳號),否則回 account_not_found。 |
utime | integer | 呼叫方 Unix time(秒)。與伺服器時間誤差須 ≤ 15 秒,否則視為重放攻擊拒絕。 |
chkstr | string | 簽章:md5( ag_no + game_id + user_id_ary + utime + common_ag.key )。 |
return_url | string | 可省略:玩家遊戲結束後要跳回的網址(如 https://mj.168a.com.tw/go/index.html)。空白或不傳則連結中不帶此參數。會原樣附加到每個玩家連結的尾端(見下方 urls)。 |
// 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);
{
"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 並綁定視角,玩家無法竄改。加密細節見下方「視角加解密」。
每個元素 = 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_OPTIONS=--openssl-legacy-provider(本機 server_log.bat 已設定)。PHP / Java 等語言原生支援 DES,無此限制。| error | 原因 |
|---|---|
time_drift | utime 與伺服器時間誤差超過 15 秒。 |
bad_signature | chkstr 驗證不符。 |
agency_not_found | ag_no 不存在或未啟用。 |
game_not_found | game_id 不存在於 lob_room。 |
agency_key_missing | 該 ag_no 的 common_ag.key 未設定(未分發密鑰)。 |
account_not_found | user_id_ary 中有 user_id 不存在於 common_user,或其 ag_no 與請求的 ag_no 不符。回傳內容含 missing 陣列。 |
簽發通過後,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 的清單內(不在則拒絕)。
| 參數 | 說明 |
|---|---|
token | 第 1 步取得的 token(遊戲頁從網址取得)。 |
user_id_ary | 該玩家專屬的單一加密值(取自第 1 步回傳陣列中對應自己的那一個),不可為明文 user_id。伺服器解密後綁定視角。 |
game_id | 可省略:若提供僅供前端顯示用,伺服器一律以 twmj_token 內綁定的 game_id 為準。 |
| error | 原因 |
|---|---|
token_invalid | token 不存在 / 解密出的 user_id 不在 user_id_ary 清單內。 |
bad_uid | user_id_ary 加密值無法解密或格式錯誤(疑似偽造)。 |
user_id_not_allowed | 解密出的 user_id 不在 token 授權清單內。 |
user_id_required | 帶了 token 但未提供 user_id_ary(退回舊相容:以清單第一個為身份)。 |
room_not_found | token 綁定的 game_id 找不到對應房間。 |
login_required | 未帶 token 且無 session(開發通道)。 |
user_id 參數或 session cookie 認證(沿用既有行為)。遊戲即時通道只需帶 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 連線,即共享同一戰局、各自視角。
貼上貴司(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 作為身份。