作者嘗試解決早期聊天室「每次刷新頁面體驗差、消息重複」的問題,提出多框架隔離 + 定時輪詢 + JavaScript 跨框架 DOM 注入的方案:
chat.php 定義 4 個框架——消息顯示區 list.html、在線用戶區 username.php、消息獲取區 getmsg.php、發言輸入區 fayan.php。fayan.php 提交 → 寫入 MySQL chat 表 → getmsg.php 每 6 秒輪詢最新消息 → 通過 parent.frames[0].document.write() 直接注入 list.html 的 <div id="lin">。username.php 每 60 秒刷新,更新 chat_getmsg 表心跳,清理超時用戶與舊消息。getmsg1.php 首次加載寫入歡迎語,6 秒後跳轉 getmsg.php。<?php
if (!isset($username)) $username = "guest";
$conid = mysql_connect("localhost", "yourcounter", "password");
mysql_select_db("yourdadabase", $conid);
$dstr = date("YmdHis");
mysql_query("INSERT INTO chat_getmsg (username, shijian) VALUES ('$username', '$dstr')");
mysql_query("UPDATE chat_getmsg SET shijian='$dstr' WHERE username='$username'");
mysql_close($conid);
?>
<frameset rows="283,105">
<frameset cols="520,113">
<frame name="topFrame" src="list.html"> // 消息顯示
<frame name="rightFrame" src="username.php"> // 在線列表
</frameset>
<frameset rows="17,90">
<frame name="topFrame1" src="getmsg1.php?username=$username"> // 歡迎+轉輪詢
<frame name="bottomFrame" src="fayan.php?username=$username"> // 輸入
</frameset>
</frameset>
問題:直接使用 $username 變量(register_globals),密碼明文寫死,mysql_* 已移除(PHP 7+),frameset 已棄用。
<form method="post" action="fayan.php?username=$username&yanse=$yanse&objectname=$objectname">
<select name="yanse">...顏色選項...</select>
<input name="fayan" size=35>
<input type="hidden" name="username" value="$username">
<input type="button" value="發言" onclick="submitfayan()">
<input name="objectname" size=10 value="$objectname"> // 私聊目標
</form>
<script>
function submitfayan() { if (fayanform.fayan.value!="") fayanform.submit(); else alert("不能發空信息"); fayanform.fayan.focus(); }
</script>
<?php
if (isset($username) && isset($fayan)) {
$conid = mysql_connect(...);
mysql_query("INSERT INTO chat (username, objectname, action, msg, color) VALUES ('$username','$objectname','NO','$fayan','$yanse')");
mysql_close($conid);
}
?>
<?php
$conid = mysql_pconnect(...);
$fromdate = date("YmdHis", time()-6); // 最近 6 秒
$sql = "SELECT username, msg, action, objectname, shijian, color FROM chat WHERE shijian >= '$fromdate'";
$res = mysql_query($sql);
echo '<meta http-equiv="Refresh" content="6,url=getmsg.php?username=$username">';
echo '<script>';
echo 'var win=parent.frames[0]; var doc=win.document; var divlin=doc.body.all("lin");';
while (list($name,$msg,$action,$objectname,$shijian,$color)=mysql_fetch_row($res)) {
$object = ($objectname!="大家"&&$objectname) ? "對 <a href=JavaScript:selectusername('$objectname')>$objectname</a> " : "";
echo "divlin.innerHTML=divlin.innerHTML+"<font color=$color size=3><a href=JavaScript:selectusername('$name')>$name</a> $object 說: $msg </font><br>"";
}
echo "win.scrollBy(6000,6000);";
echo '</script>';
?>
關鍵技術:parent.frames[0] 獲取第一個框架(list.html),doc.body.all("lin") 為 IE 專有 API 獲取 id="lin" 元素,innerHTML 疊加消息,scrollBy 強制滾動到底部。
<meta http-equiv="Refresh" content="60">
<?php
$fromdate = date("YmdHis", time()-200);
$sql = "SELECT username FROM chat_getmsg WHERE shijian >= '$fromdate'";
while (list($username)=mysql_fetch_row($res)) {
echo "<a href=JavaScript:selectusername('$username')>$username</a><br>";
// 順便清理舊消息
$sql = "DELETE FROM chat WHERE shijian <= '$fromdate100'";
}
?>
| 隱患 | 影響 | 風險 |
|---|---|---|
| SQL 注入遍地 | 所有 $username、$fayan、$objectname 直接拼接 SQL | 🔴 嚴重 |
| XSS 存儲型 | 消息內容 $msg 直接拼接進 innerHTML,無轉義 | 🔴 嚴重 |
| 依賴 register_globals | 變量來源不可控 | 🔴 嚴重 |
| mysql_* 已移除 | PHP 7+ 無法運行 | 🔴 嚴重 |
| frameset 已棄用 | HTML5 不合法,SEO/無障礙/收藏/打印全失效 | 🟠 高 |
| IE 專有 API | doc.body.all() 非標準,現代瀏覽器不支持 | 🟠 高 |
| Meta Refresh 體驗差 | 閃爍、滾動重置、無法實時、高併發壓力大 | 🟠 高 |
| 明文密碼/無認證 | 數據庫密碼寫死、用戶無密碼驗證 | 🟠 高 |
| 消息無 ID 去重 | 輪詢時間窗重疊導致重複消息 | 🟡 中 |
| 編碼未指定 | MySQL 連接、HTML 頁面、JS 字符串編碼易亂碼 | 🟡 中 |
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
pass_hash CHAR(60) NOT NULL,
color VARCHAR(7) DEFAULT '#000000',
last_active_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
is_online BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE rooms (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL,
is_lobby BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE messages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
room_id BIGINT REFERENCES rooms(id),
from_id BIGINT REFERENCES users(id),
to_id BIGINT DEFAULT 0, -- 0=公開
content TEXT NOT NULL,
color VARCHAR(7) DEFAULT '#000000',
is_system BOOLEAN DEFAULT FALSE,
is_action BOOLEAN DEFAULT FALSE, // /: 動作
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_room_time (room_id, created_at)
);
CREATE TABLE user_rooms (
user_id BIGINT REFERENCES users(id),
room_id BIGINT REFERENCES rooms(id),
joined_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, room_id)
);
// Workerman 示例
use Workerman\Worker;
use Workerman\Connection\TcpConnection;
$ws = new Worker('websocket://0.0.0.0:2346');
$ws->count = 4; // 多進程
$ws->onConnect = function(TcpConnection $conn) {
$conn->auth = false;
$conn->userId = null;
$conn->roomId = null;
};
$ws->onMessage = function(TcpConnection $conn, $data) {
$msg = json_decode($data, true);
if (!$msg) return;
switch ($msg['type']) {
case 'auth':
$user = User::where('username', $msg['username'])->first();
if ($user && password_verify($msg['password'], $user->pass_hash)) {
$conn->auth = true;
$conn->userId = $user->id;
$user->update(['is_online'=>true, 'last_active_at'=>now()]);
$conn->send(json_encode(['type'=>'auth_ok', 'user'=>$user->toArray()]));
// 加入大廳
joinRoom($conn, 1);
} else {
$conn->send(json_encode(['type'=>'auth_fail', 'error'=>'認證失敗']));
$conn->close();
}
break;
case 'join_room':
if (!$conn->auth) return;
joinRoom($conn, $msg['room_id'], $msg['password'] ?? null);
break;
case 'send':
if (!$conn->auth) return;
$message = MessageService::create(
$conn->userId, $conn->roomId, $msg['content'],
$msg['color'] ?? '#000000', $msg['to_id'] ?? 0, $msg['is_action'] ?? false
);
broadcastToRoom($conn->roomId, ['type'=>'message', 'payload'=>$message->toArray()]);
break;
case 'heartbeat':
if ($conn->auth) User::where('id', $conn->userId)->update(['last_active_at'=>now()]);
break;
}
};
$ws->onClose = function(TcpConnection $conn) {
if ($conn->auth) {
User::where('id', $conn->userId)->update(['is_online'=>false]);
leaveRoom($conn);
}
};
function joinRoom(TcpConnection $conn, int $roomId, ?string $password = null) {
$room = Room::find($roomId);
if (!$room) return $conn->send(json_encode(['type'=>'error', 'msg'=>'房間不存在']));
if ($room->pass_hash && !password_verify($password, $room->pass_hash)) {
return $conn->send(json_encode(['type'=>'error', 'msg'=>'密碼錯誤']));
}
// 離開舊房間
if ($conn->roomId) leaveRoom($conn);
$conn->roomId = $roomId;
UserRoom::firstOrCreate(['user_id'=>$conn->userId, 'room_id'=>$roomId]);
// 發送房間歷史消息(最近 50 條)
$history = Message::where('room_id', $roomId)->latest()->limit(50)->get()->reverse()->values();
$conn->send(json_encode(['type'=>'history', 'payload'=>$history]));
// 廣播用戶加入
broadcastToRoom($roomId, ['type'=>'user_joined', 'payload'=>['user_id'=>$conn->userId, 'username'=>User::find($conn->userId)->username]]);
// 發送當前在線列表
$members = User::whereHas('rooms', fn($q)=>$q->where('room_id',$roomId))->get(['id','username','color']);
broadcastToRoom($roomId, ['type'=>'member_list', 'payload'=>$members]);
}
function leaveRoom(TcpConnection $conn) {
if (!$conn->roomId) return;
$roomId = $conn->roomId;
UserRoom::where('user_id', $conn->userId)->where('room_id', $roomId)->delete();
broadcastToRoom($roomId, ['type'=>'user_left', 'payload'=>['user_id'=>$conn->userId]]);
$conn->roomId = null;
}
function broadcastToRoom(int $roomId, array $data) {
global $ws;
$json = json_encode($data);
foreach ($ws->connections as $conn) {
if (isset($conn->roomId) && $conn->roomId === $roomId && $conn->auth) {
$conn->send($json);
}
}
}
Worker::runAll();
<!-- Vue 3 + Pinia 核心邏輯 -->
const ws = ref(null)
const messages = ref([])
const members = ref([])
const currentRoom = ref(1)
const user = ref(null)
const messageInput = ref('')
function connect() {
ws.value = new WebSocket('wss://chat.example.com/ws')
ws.value.onopen = () => {
ws.value.send(JSON.stringify({ type: 'auth', username: '...', password: '...' }))
}
ws.value.onmessage = (e) => {
const data = JSON.parse(e.data)
switch (data.type) {
case 'auth_ok': user.value = data.user; break
case 'history': messages.value = data.payload; scrollToBottom(); break
case 'message': messages.value.push(data.payload); scrollToBottom(); break
case 'member_list': members.value = data.payload; break
case 'user_joined': members.value.push(data.payload); break
case 'user_left': members.value = members.value.filter(m => m.id !== data.payload.user_id); break
}
}
// 心跳
setInterval(() => ws.value?.send(JSON.stringify({type:'heartbeat'})), 30000)
}
function send() {
if (!messageInput.value.trim()) return
ws.value.send(JSON.stringify({
type: 'send',
content: messageInput.value,
color: user.value?.color || '#000000',
}))
messageInput.value = ''
}
function scrollToBottom() {
nextTick(() => { document.querySelector('.messages')?.scrollTop = 99999 })
}
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Nginx │────▶│ PHP-FPM │────▶│ MySQL │
│ (靜態/SSL) │ │ (API/SSR) │ │ (持久化) │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ Workerman │
│ (WebSocket)│
└─────────────┘
│
▼
┌─────────────┐
│ Redis │
│ (發布/訂閱/ │
│ 在線集合) │
└─────────────┘
/ws 到 Workerman 進程組。原專案以「框架隔離 + Meta Refresh 輪詢 + 跨框架 DOM 注入」在無 Ajax/WebSocket 時代實現了「無刷新」聊天體驗,展現了極強的工程創造力。但其技術棧(mysql_*、register_globals、frameset、document.all、Meta Refresh)已全部過時且存在嚴重安全漏洞。現代重構應採用 WebSocket 長連接 + 結構化數據庫 + 前後端分離 + 標準 DOM API + 統一認證授權。本文提供的 Workerman + Vue 3 + MySQL + Redis 完整架構可直接用於生產部署,支撐萬級並發、實時推送、多房間、私聊、表情、指令等全功能,並具備良好的擴展性(如接入 Swoole、Kubernetes、消息隊列、語音視頻等)。