完善了Web管理端

This commit is contained in:
2026-06-20 18:18:50 +08:00
parent aedc199777
commit c5f296fb0d
11 changed files with 735 additions and 82 deletions
+3
View File
@@ -24,4 +24,7 @@ cmake-build-debug
server.exe
embalming_girl_server.exe
embalming_girl_server_linux
+160 -60
View File
@@ -2,35 +2,76 @@ package admin
import (
"Embalming_Girl_Server/db"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
)
const AdminPassword = "admin123"
const AdminPassword = "Qwer123456789!"
var adminSession string
func genSession() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
func isAuth(r *http.Request) bool {
c, err := r.Cookie("admin_session")
return err == nil && c.Value != "" && c.Value == adminSession
}
func authWrap(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !isAuth(r) {
w.WriteHeader(401)
json.NewEncoder(w).Encode(map[string]interface{}{"error": "unauthorized"})
return
}
h(w, r)
}
}
func Register(mux *http.ServeMux, getStats func() map[string]interface{}) {
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, adminHTML)
w.Write([]byte(adminHTML))
})
mux.HandleFunc("/api/admin/login", func(w http.ResponseWriter, r *http.Request) {
var req struct {
Password string `json:"password"`
}
json.NewDecoder(r.Body).Decode(&req)
w.Header().Set("Content-Type", "application/json")
if req.Password != AdminPassword {
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false})
return
}
adminSession = genSession()
http.SetCookie(w, &http.Cookie{
Name: "admin_session", Value: adminSession,
Path: "/", MaxAge: 86400, HttpOnly: true,
})
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
})
mux.HandleFunc("/api/admin/stats", func(w http.ResponseWriter, r *http.Request) {
mux.HandleFunc("/api/admin/logout", func(w http.ResponseWriter, r *http.Request) {
adminSession = ""
http.SetCookie(w, &http.Cookie{Name: "admin_session", Value: "", Path: "/", MaxAge: -1})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
})
mux.HandleFunc("/api/admin/check_session", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": isAuth(r)})
})
mux.HandleFunc("/api/admin/stats", authWrap(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(getStats())
})
mux.HandleFunc("/api/admin/users", func(w http.ResponseWriter, r *http.Request) {
rows, err := db.DB.Query("SELECT id, username, nickname, bio, avatar, created_at, last_login FROM users ORDER BY id DESC")
}))
mux.HandleFunc("/api/admin/users", authWrap(func(w http.ResponseWriter, r *http.Request) {
rows, err := db.DB.Query("SELECT id,username,nickname,bio,avatar,created_at,last_login FROM users ORDER BY id DESC")
if err != nil {
json.NewEncoder(w).Encode([]int{})
return
@@ -49,70 +90,129 @@ func Register(mux *http.ServeMux, getStats func() map[string]interface{}) {
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(users)
})
mux.HandleFunc("/api/admin/delete_user", func(w http.ResponseWriter, r *http.Request) {
}))
mux.HandleFunc("/api/admin/delete_user", authWrap(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID int64 `json:"id"`
}
json.NewDecoder(r.Body).Decode(&req)
db.DB.Exec("DELETE FROM sessions WHERE user_id=?", req.ID)
db.DB.Exec("DELETE FROM users WHERE id=?", req.ID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
})
}))
}
const adminHTML = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>管理面板</title>
const adminHTML = `<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8"><title>Embalming Girl - Admin</title>
<style>
:root{--bg:#0f1117;--bg2:#161923;--bg3:#1e2231;--bg4:#272d3d;--border:#2a3148;--text:#d1d5e0;--text2:#8891a5;--accent:#6c9fff;--accent2:#4a7adc;--danger:#e5534b;--success:#56d364;--gold:#d4a72c;--radius:10px;--shadow:0 2px 12px rgba(0,0,0,0.3)}
*{margin:0;padding:0;box-sizing:border-box}
body{background:#1a1f2e;color:#e0e0e0;font-family:system-ui;padding:20px}
h1{color:#5eb3e6;margin-bottom:20px;text-align:center}
.card{background:#252b3d;border-radius:12px;padding:20px;margin-bottom:16px}
.card h2{color:#d4c5a3;margin-bottom:12px;font-size:18px}
.stats{display:flex;gap:16px;flex-wrap:wrap}
.stat{background:#1a1f2e;border-radius:8px;padding:16px;text-align:center;min-width:120px}
.stat .num{font-size:28px;color:#5eb3e6;font-weight:bold}
.stat .label{font-size:12px;color:#8a8f9d;margin-top:4px}
table{width:100%;border-collapse:collapse;margin-top:8px}
th,td{padding:8px 12px;text-align:left;border-bottom:1px solid #2a3040}
th{color:#8a8f9d;font-size:12px}
.btn{padding:4px 12px;border:none;border-radius:4px;cursor:pointer;font-size:12px}
.btn-danger{background:#d94f5c;color:white}
.btn-danger:hover{background:#c0392b}
#loginBox{max-width:300px;margin:100px auto;text-align:center}
#loginBox input{width:100%;padding:10px;margin:8px 0;border-radius:6px;border:1px solid #3a3f4e;background:#1a1f2e;color:white}
#loginBox button{width:100%;padding:10px;background:#5eb3e6;border:none;border-radius:6px;color:white;cursor:pointer;font-size:14px}
#main{display:none}
body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;display:flex;min-height:100vh;font-size:14px;line-height:1.5}
::selection{background:var(--accent);color:#fff}
::-webkit-scrollbar{width:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
.sidebar{width:220px;background:var(--bg2);border-right:1px solid var(--border);display:flex;flex-direction:column;flex-shrink:0}
.sidebar-header{padding:20px;border-bottom:1px solid var(--border)}
.sidebar-header h1{font-size:15px;color:var(--accent);font-weight:600}
.sidebar-header p{font-size:11px;color:var(--text2);margin-top:2px}
.sidebar-nav{flex:1;padding:8px}
.sidebar-nav a{display:flex;align-items:center;gap:10px;padding:10px 14px;color:var(--text2);text-decoration:none;border-radius:8px;font-size:13px;cursor:pointer;transition:all .15s;margin-bottom:2px}
.sidebar-nav a:hover{background:var(--bg3);color:var(--text)}
.sidebar-nav a.active{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#fff;box-shadow:0 2px 8px rgba(108,159,255,0.3)}
.sidebar-nav a .icon{font-size:16px;width:20px;text-align:center}
.sidebar-footer{padding:12px 8px;border-top:1px solid var(--border)}
.sidebar-footer a{display:flex;align-items:center;gap:10px;padding:10px 14px;color:var(--danger);text-decoration:none;border-radius:8px;font-size:13px;cursor:pointer;transition:all .15s}
.sidebar-footer a:hover{background:rgba(229,83,75,0.1)}
.main{flex:1;overflow-y:auto}.main-header{padding:20px 28px 0;margin-bottom:20px}
.main-header h2{font-size:20px;font-weight:600}.main-header p{font-size:12px;color:var(--text2);margin-top:2px}
.main-body{padding:0 28px 28px}
.page{display:none;animation:fadeIn .2s}.page.active{display:block}
@keyframes fadeIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:20px}
.stat-card{background:var(--bg3);border:1px solid var(--border);border-radius:var(--radius);padding:18px;transition:transform .15s,box-shadow .15s}
.stat-card:hover{transform:translateY(-2px);box-shadow:var(--shadow)}
.stat-card .value{font-size:30px;font-weight:700;color:var(--accent);line-height:1.2}
.stat-card .label{font-size:12px;color:var(--text2);margin-top:4px}
.card{background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);margin-bottom:16px;overflow:hidden}
.card-header{padding:14px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
.card-header h3{font-size:14px;font-weight:600}
.card-body{padding:18px}
table{width:100%;border-collapse:collapse}
th{text-align:left;padding:10px 14px;font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text2);border-bottom:1px solid var(--border);font-weight:500}
td{padding:10px 14px;border-bottom:1px solid rgba(42,49,72,0.5);font-size:13px}
tr:hover td{background:rgba(108,159,255,0.03)}tr:last-child td{border-bottom:none}
.btn{padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-size:12px;font-weight:500;transition:all .15s;display:inline-flex;align-items:center;gap:5px}
.btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent2);box-shadow:0 2px 8px rgba(108,159,255,0.25)}
.btn-danger{background:transparent;color:var(--danger);border:1px solid var(--danger)}.btn-danger:hover{background:var(--danger);color:#fff}
.btn-sm{padding:4px 10px;font-size:11px}
.btn-ghost{background:transparent;color:var(--text2);border:1px solid var(--border)}.btn-ghost:hover{background:var(--bg3);color:var(--text)}
input,textarea{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:9px 13px;color:var(--text);font-size:13px;width:100%;transition:border-color .15s;outline:none;font-family:inherit}
input:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(108,159,255,0.1)}
textarea{resize:vertical;min-height:80px}
label{display:block;font-size:12px;color:var(--text2);margin:12px 0 5px;font-weight:500}
input[type=file]{padding:8px}input[type=checkbox]{width:auto}
.badge{display:inline-block;padding:2px 8px;border-radius:20px;font-size:11px;font-weight:500}
.badge-green{background:rgba(86,211,100,0.12);color:var(--success)}
.badge-red{background:rgba(229,83,75,0.12);color:var(--danger)}
.badge-blue{background:rgba(108,159,255,0.12);color:var(--accent)}
.update-box{background:var(--bg);border:1px solid var(--border);border-radius:var(--radius);padding:18px}
.update-box .ver{font-size:22px;font-weight:700;color:var(--accent)}
.update-box .meta{color:var(--text2);font-size:12px;margin:6px 0}
.update-box pre{background:var(--bg3);padding:12px;border-radius:8px;font-size:12px;color:var(--text);white-space:pre-wrap;margin:10px 0;border:1px solid var(--border);font-family:Consolas,Monaco,monospace}
.mono{font-family:Consolas,Monaco,monospace}
#loginBox{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg);z-index:99}
.login-card{background:var(--bg2);border:1px solid var(--border);padding:36px;border-radius:16px;width:340px;box-shadow:0 8px 32px rgba(0,0,0,0.4)}
.login-card h2{font-size:18px;color:var(--accent);text-align:center;margin-bottom:4px}
.login-card p{font-size:12px;color:var(--text2);text-align:center;margin-bottom:20px}
.login-card .btn{width:100%;justify-content:center;padding:10px;font-size:14px;margin-top:12px}
.login-card .error{color:var(--danger);font-size:12px;text-align:center;margin-top:8px;min-height:16px}
#app{display:none;width:100%}
</style></head><body>
<div id="loginBox"><h1>管理面板</h1><input id="pw" type="password" placeholder="管理密码">
<button onclick="doLogin()">登录</button></div>
<div id="main"><h1>Embalming Girl 管理面板</h1>
<div class="card"><h2>服务器状态</h2><div class="stats" id="stats"></div></div>
<div class="card"><h2>注册用户</h2><table><thead><tr><th>ID</th><th>用户名</th><th>昵称</th><th>头像</th><th>简介</th><th>注册时间</th><th>操作</th></tr></thead><tbody id="users"></tbody></table></div>
</div>
<div id="loginBox"><div class="login-card">
<h2>Embalming Girl</h2><p>Server Admin Panel</p>
<input id="pw" type="password" placeholder="管理密码" onkeydown="if(event.key==='Enter')doLogin()">
<button class="btn btn-primary" onclick="doLogin()">登 录</button>
<div class="error" id="loginMsg"></div></div></div>
<div id="app">
<div class="sidebar"><div class="sidebar-header"><h1>Embalming Girl</h1><p>管理面板 v1.0</p></div>
<nav class="sidebar-nav">
<a class="active" onclick="showPage('dashboard',this)"><span class="icon">📊</span>仪表盘</a>
<a onclick="showPage('users',this)"><span class="icon">👥</span>用户管理</a>
<a onclick="showPage('updates',this)"><span class="icon">📦</span>更新管理</a>
</nav><div class="sidebar-footer"><a onclick="doLogout()"><span class="icon">🚪</span>退出登录</a></div></div>
<div class="main">
<div id="dashboard" class="page active">
<div class="main-header"><h2>仪表盘</h2><p>服务器运行状态总览</p></div>
<div class="main-body"><div class="stats-grid" id="stats"></div>
<div class="card"><div class="card-header"><h3>在线用户</h3><button class="btn btn-ghost btn-sm" onclick="loadOnline()">↻ 刷新</button></div>
<div class="card-body" style="padding:0"><table><thead><tr><th>连接ID</th><th>用户名</th><th>昵称</th><th>版本</th><th>状态</th></tr></thead><tbody id="onlineTable"></tbody></table></div></div></div></div>
<div id="users" class="page">
<div class="main-header"><h2>用户管理</h2><p>管理已注册的用户账号</p></div>
<div class="main-body"><div class="card"><div class="card-header"><h3>用户列表</h3><button class="btn btn-ghost btn-sm" onclick="loadUsers()">↻ 刷新</button></div>
<div class="card-body" style="padding:0"><table><thead><tr><th>ID</th><th>用户名</th><th>昵称</th><th>头像</th><th>注册时间</th><th>操作</th></tr></thead><tbody id="userTable"></tbody></table></div></div></div></div>
<div id="updates" class="page">
<div class="main-header"><h2>更新管理</h2><p>管理客户端 OTA 更新</p></div>
<div class="main-body"><div class="card"><div class="card-header"><h3>当前已发布版本</h3></div><div class="card-body" id="currentUpdate"></div></div>
<div class="card"><div class="card-header"><h3>发布新版本</h3></div><div class="card-body">
<label>版本号</label><input id="upVer" placeholder="例: 1.1.0">
<label>更新包 (.zip)</label><input id="upFile" type="file" accept=".zip">
<label>更新日志</label><textarea id="upNotes" placeholder="- 修复了...\n- 新增了..."></textarea>
<div style="margin-top:14px;display:flex;align-items:center;gap:14px">
<label style="margin:0;display:flex;align-items:center;gap:6px;cursor:pointer"><input type="checkbox" id="upMandatory"> 强制更新</label>
<button class="btn btn-primary" onclick="publishUpdate()">发布更新</button></div></div></div></div></div>
</div></div>
<script>
async function doLogin(){
let r=await fetch('/api/admin/login',{method:'POST',body:JSON.stringify({password:document.getElementById('pw').value})});
let d=await r.json();
if(d.ok){document.getElementById('loginBox').style.display='none';document.getElementById('main').style.display='block';loadData();}
else alert('密码错误');
}
async function loadData(){
let s=await(await fetch('/api/admin/stats')).json();
document.getElementById('stats').innerHTML=
'<div class="stat"><div class="num">'+s.total_users+'</div><div class="label">注册用户</div></div>'+
'<div class="stat"><div class="num">'+s.active_rooms+'</div><div class="label">活跃房间</div></div>'+
'<div class="stat"><div class="num">'+s.online_clients+'</div><div class="label">在线连接</div></div>';
let u=await(await fetch('/api/admin/users')).json();
let h='';(u||[]).forEach(x=>{
h+='<tr><td>'+x.id+'</td><td>'+x.username+'</td><td>'+x.nickname+'</td><td>'+
(x.avatar||'-')+'</td><td>'+(x.bio||'-')+'</td><td>'+(x.created_at||'-')+'</td>'+
'<td><button class="btn btn-danger" onclick="delUser('+x.id+')">删除</button></td></tr>';
});
document.getElementById('users').innerHTML=h||'<tr><td colspan="7">暂无用户</td></tr>';
}
async function delUser(id){
if(!confirm('确定删除此用户?'))return;
await fetch('/api/admin/delete_user',{method:'POST',body:JSON.stringify({id})});
loadData();
}
window.onload=async function(){try{let r=await fetch('/api/admin/check_session');let d=await r.json();if(d.ok)enterApp();}catch(e){}};
async function doLogin(){let pw=document.getElementById('pw').value;if(!pw){document.getElementById('loginMsg').textContent='请输入密码';return}let r=await fetch('/api/admin/login',{method:'POST',body:JSON.stringify({password:pw}),headers:{'Content-Type':'application/json'}});let d=await r.json();if(d.ok)enterApp();else document.getElementById('loginMsg').textContent='密码错误';}
function enterApp(){document.getElementById('loginBox').style.display='none';document.getElementById('app').style.display='flex';loadAll();}
async function doLogout(){await fetch('/api/admin/logout',{method:'POST'});location.reload();}
function showPage(id,el){document.querySelectorAll('.page').forEach(p=>p.classList.remove('active'));document.getElementById(id).classList.add('active');document.querySelectorAll('.sidebar-nav a').forEach(a=>a.classList.remove('active'));if(el)el.classList.add('active');}
async function loadAll(){loadStats();loadUsers();loadUpdate();loadOnline();}
async function loadStats(){let r=await fetch('/api/admin/stats');if(!r.ok)return;let s=await r.json();document.getElementById('stats').innerHTML='<div class="stat-card"><div class="value">'+s.total_users+'</div><div class="label">注册用户</div></div><div class="stat-card"><div class="value">'+s.online_clients+'</div><div class="label">在线连接</div></div><div class="stat-card"><div class="value">'+s.active_rooms+'</div><div class="label">活跃房间</div></div>';}
async function loadOnline(){let r=await fetch('/api/admin/online');if(!r.ok)return;let o=await r.json();let h='';(o||[]).forEach(x=>{let room=x.room_id?'<span class="badge badge-blue">'+x.room_id+'</span>':'<span style="color:var(--text2)">大厅</span>';let ver=x.version?'<span class="mono" style="font-size:12px">'+x.version+'</span>':'<span style="color:var(--text2)">-</span>';h+='<tr><td class="mono" style="font-size:12px;color:var(--text2)">'+x.id.substring(0,8)+'</td><td>'+(x.username||'<span style="color:var(--text2)">未登录</span>')+'</td><td>'+(x.nickname||'-')+'</td><td>'+ver+'</td><td>'+room+'</td></tr>';});document.getElementById('onlineTable').innerHTML=h||'<tr><td colspan="5" style="text-align:center;color:var(--text2);padding:24px">暂无在线用户</td></tr>';}
async function loadUsers(){let r=await fetch('/api/admin/users');if(!r.ok)return;let u=await r.json();let h='';(u||[]).forEach(x=>{h+='<tr><td class="mono" style="color:var(--text2)">'+x.id+'</td><td><strong>'+x.username+'</strong></td><td>'+x.nickname+'</td><td>'+(x.avatar?'<span class="badge badge-blue">'+x.avatar+'</span>':'-')+'</td><td style="color:var(--text2);font-size:12px">'+(x.created_at||'-')+'</td><td><button class="btn btn-danger btn-sm" onclick="delUser('+x.id+')">删除</button></td></tr>';});document.getElementById('userTable').innerHTML=h||'<tr><td colspan="6" style="text-align:center;color:var(--text2);padding:24px">暂无注册用户</td></tr>';}
async function delUser(id){if(!confirm('确定要删除此用户?此操作不可恢复。'))return;await fetch('/api/admin/delete_user',{method:'POST',body:JSON.stringify({id}),headers:{'Content-Type':'application/json'}});loadUsers();}
async function loadUpdate(){let r=await fetch('/api/admin/update_info');if(!r.ok)return;let d=await r.json();let el=document.getElementById('currentUpdate');if(!d.published){el.innerHTML='<p style="color:var(--text2)">尚未发布任何客户端更新</p>';return;}let i=d.info;el.innerHTML='<div class="update-box"><div class="ver">v'+i.version+' <span class="badge '+(i.mandatory?'badge-red':'badge-green')+'">'+(i.mandatory?'强制更新':'可选更新')+'</span></div><div class="meta">📁 '+i.filename+' &nbsp;·&nbsp; '+formatSize(i.size)+' &nbsp;·&nbsp; 🕐 '+i.published_at+'</div><div class="meta mono" style="font-size:11px">SHA256: '+i.sha256.substring(0,32)+'...</div><pre>'+i.release_notes+'</pre><button class="btn btn-danger btn-sm" onclick="deleteUpdate()">删除此版本</button></div>';}
async function deleteUpdate(){if(!confirm('确定删除已发布的更新?'))return;await fetch('/api/admin/delete_update',{method:'POST',headers:{'Content-Type':'application/json'}});loadUpdate();}
async function publishUpdate(){let ver=document.getElementById('upVer').value,file=document.getElementById('upFile').files[0],notes=document.getElementById('upNotes').value,mandatory=document.getElementById('upMandatory').checked;if(!ver||!file){alert('请填写版本号并选择更新包文件');return;}let fd=new FormData();fd.append('version',ver);fd.append('file',file);fd.append('release_notes',notes);fd.append('mandatory',mandatory);let r=await fetch('/api/admin/publish_update',{method:'POST',body:fd});let d=await r.json();if(d.ok){alert('✅ 发布成功!');loadUpdate();document.getElementById('upVer').value='';document.getElementById('upNotes').value='';}else alert('发布失败: '+(d.message||'未知错误'));}
function formatSize(b){if(b<1024)return b+' B';if(b<1048576)return(b/1024).toFixed(1)+' KB';return(b/1048576).toFixed(1)+' MB';}
</script></body></html>`
+160
View File
@@ -0,0 +1,160 @@
package admin
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"path/filepath"
"time"
)
type UpdateInfo struct {
Version string `json:"version"`
Platform string `json:"platform"`
FileName string `json:"filename"`
SHA256 string `json:"sha256"`
Size int64 `json:"size"`
ReleaseNotes string `json:"release_notes"`
Mandatory bool `json:"mandatory"`
PublishedAt string `json:"published_at"`
}
var updatesDir string
func initUpdatesDir() {
exe, _ := os.Executable()
updatesDir = filepath.Join(filepath.Dir(exe), "data", "updates")
os.MkdirAll(updatesDir, 0755)
}
func RegisterUpdateRoutes(mux *http.ServeMux) {
initUpdatesDir()
mux.HandleFunc("/api/update/check", handleUpdateCheck)
mux.Handle("/updates/", http.StripPrefix("/updates/",
http.FileServer(http.Dir(updatesDir))))
mux.HandleFunc("/api/admin/publish_update", authWrap(handlePublishUpdate))
mux.HandleFunc("/api/admin/update_info", handleGetUpdateInfo)
mux.HandleFunc("/api/admin/delete_update", authWrap(handleDeleteUpdate))
}
func getUpdateInfoPath() string {
return filepath.Join(updatesDir, "latest.json")
}
func loadUpdateInfo() *UpdateInfo {
data, err := os.ReadFile(getUpdateInfoPath())
if err != nil {
return nil
}
var info UpdateInfo
if json.Unmarshal(data, &info) != nil {
return nil
}
return &info
}
func saveUpdateInfo(info *UpdateInfo) error {
data, _ := json.MarshalIndent(info, "", " ")
return os.WriteFile(getUpdateInfoPath(), data, 0644)
}
func handleUpdateCheck(w http.ResponseWriter, r *http.Request) {
clientVersion := r.URL.Query().Get("version")
info := loadUpdateInfo()
w.Header().Set("Content-Type", "application/json")
if info == nil || info.Version == clientVersion {
json.NewEncoder(w).Encode(map[string]interface{}{
"update_available": false,
"current_version": clientVersion,
})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"update_available": true,
"latest_version": info.Version,
"download_url": "/updates/" + info.FileName,
"sha256": info.SHA256,
"size": info.Size,
"release_notes": info.ReleaseNotes,
"mandatory": info.Mandatory,
})
}
func handleGetUpdateInfo(w http.ResponseWriter, r *http.Request) {
info := loadUpdateInfo()
w.Header().Set("Content-Type", "application/json")
if info == nil {
json.NewEncoder(w).Encode(map[string]interface{}{"published": false})
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"published": true, "info": info,
})
}
func handleDeleteUpdate(w http.ResponseWriter, r *http.Request) {
info := loadUpdateInfo()
if info != nil {
os.Remove(filepath.Join(updatesDir, info.FileName))
os.Remove(getUpdateInfoPath())
}
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true})
}
func handlePublishUpdate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", 405)
return
}
r.ParseMultipartForm(200 << 20)
version := r.FormValue("version")
notes := r.FormValue("release_notes")
mandatory := r.FormValue("mandatory") == "true"
file, header, err := r.FormFile("file")
if err != nil {
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "文件上传失败"})
return
}
defer file.Close()
dstPath := filepath.Join(updatesDir, header.Filename)
dst, err := os.Create(dstPath)
if err != nil {
json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "message": "无法创建文件"})
return
}
hasher := sha256.New()
size, _ := io.Copy(io.MultiWriter(dst, hasher), file)
dst.Close()
hash := hex.EncodeToString(hasher.Sum(nil))
info := &UpdateInfo{
Version: version, Platform: "win64", FileName: header.Filename,
SHA256: hash, Size: size, ReleaseNotes: notes, Mandatory: mandatory,
PublishedAt: time.Now().Format("2006-01-02 15:04:05"),
}
saveUpdateInfo(info)
log.Printf("Update published: v%s (%s, %d bytes)", version, header.Filename, size)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "info": info})
}
func FormatSize(b int64) string {
if b < 1024 {
return fmt.Sprintf("%d B", b)
} else if b < 1024*1024 {
return fmt.Sprintf("%.1f KB", float64(b)/1024)
}
return fmt.Sprintf("%.1f MB", float64(b)/1024/1024)
}
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env pwsh
# Embalming Girl Server - Build Script
# Usage:
# .\build.ps1 # Build for Windows
# .\build.ps1 linux # Cross-compile for Linux
# .\build.ps1 all # Build for both platforms
param([string]$Target = "windows")
$ErrorActionPreference = "Stop"
$ProjectName = "embalming_girl_server"
Write-Host "=== Embalming Girl Server Build ===" -ForegroundColor Cyan
function Build-Windows {
Write-Host "[Building] Windows amd64..." -ForegroundColor Yellow
$env:GOOS = "windows"
$env:GOARCH = "amd64"
go build -o "$ProjectName.exe" .
if ($LASTEXITCODE -eq 0) {
Write-Host "[OK] $ProjectName.exe" -ForegroundColor Green
} else {
Write-Host "[FAILED] Windows build" -ForegroundColor Red
exit 1
}
}
function Build-Linux {
Write-Host "[Building] Linux amd64..." -ForegroundColor Yellow
$env:GOOS = "linux"
$env:GOARCH = "amd64"
$env:CGO_ENABLED = "0"
go build -o "${ProjectName}_linux" .
if ($LASTEXITCODE -eq 0) {
Write-Host "[OK] ${ProjectName}_linux" -ForegroundColor Green
} else {
Write-Host "[FAILED] Linux build" -ForegroundColor Red
exit 1
}
}
switch ($Target.ToLower()) {
"windows" { Build-Windows }
"linux" { Build-Linux }
"all" { Build-Windows; Build-Linux }
default {
Write-Host "Usage: .\build.ps1 [windows|linux|all]" -ForegroundColor Yellow
}
}
# Clean up environment
Remove-Item Env:GOOS -ErrorAction SilentlyContinue
Remove-Item Env:GOARCH -ErrorAction SilentlyContinue
Remove-Item Env:CGO_ENABLED -ErrorAction SilentlyContinue
Write-Host "=== Build Complete ===" -ForegroundColor Cyan
+2
View File
@@ -20,6 +20,8 @@ type Card struct {
TypeID string
Name string
MP int
PlacedBy string
PlacedByNick string
}
var AllCardDefs = []CardDef{
+40 -1
View File
@@ -11,6 +11,7 @@ type PlayerState struct {
ID string
Nickname string
IsBot bool
BotControlled bool
Hand []*Card
SkillZone []*Card
ChallengeZone []*Card
@@ -31,6 +32,8 @@ type Engine struct {
Effect *EffectState
ChaosMode bool
ActionLogs []string
TurnID int
RoundNum int
rng *rand.Rand
}
@@ -67,6 +70,8 @@ func (e *Engine) Deal() {
e.CurrentTurn = e.findStartingPlayer()
e.Phase = "playing"
e.TurnPhase = "select_card"
e.RoundNum = 1
e.TurnID = 0
e.startTurn()
}
@@ -82,6 +87,11 @@ func (e *Engine) findStartingPlayer() int {
}
func (e *Engine) startTurn() {
e.TurnID++
cp := e.CurrentPlayer()
if cp != nil && !cp.IsBot && !cp.BotControlled {
e.log(fmt.Sprintf("轮到 %s 的回合 (第%d轮)", cp.Nickname, e.RoundNum))
}
if e.CheckInfectedEffect() {
e.TurnPhase = "effect_resolving"
} else {
@@ -190,6 +200,8 @@ func (e *Engine) doChallenge(p *PlayerState, targetID string) error {
}
card := e.SelectedCard
e.removeFromHand(p, card.UID)
card.PlacedBy = p.ID
card.PlacedByNick = p.Nickname
target.ChallengeZone = append(target.ChallengeZone, card)
e.log(p.Nickname + " 质疑了 " + target.Nickname)
e.SelectedCard = nil
@@ -214,10 +226,14 @@ func (e *Engine) finishTurn(p *PlayerState) {
func (e *Engine) advanceTurn() {
n := len(e.Players)
old := e.CurrentTurn
next := (e.CurrentTurn + 1) % n
for i := 0; i < n; i++ {
if !e.Players[next].IsExited {
e.CurrentTurn = next
if next <= old {
e.RoundNum++
}
return
}
next = (next + 1) % n
@@ -256,6 +272,29 @@ func (e *Engine) IsHandTargetable(p *PlayerState) bool {
return !p.IsExited
}
func (e *Engine) UpdatePlayerID(oldID, newID string) {
for _, p := range e.Players {
if p.ID == oldID {
p.ID = newID
return
}
}
}
func (e *Engine) SetBotControlled(playerID string, v bool) {
for _, p := range e.Players {
if p.ID == playerID {
p.BotControlled = v
return
}
}
}
func (e *Engine) IsPlayerBotLike(playerID string) bool {
p := e.GetPlayer(playerID)
return p != nil && (p.IsBot || p.BotControlled)
}
func (e *Engine) EffectNeedsResponseFrom(playerID string) bool {
if !e.IsEffectResolving() {
return false
@@ -271,7 +310,7 @@ func (e *Engine) EffectNeedsResponseFrom(playerID string) bool {
func (e *Engine) BotAutoPlay(playerID string) (string, string) {
p := e.GetPlayer(playerID)
if p == nil || !p.IsBot || p.IsExited {
if p == nil || (!p.IsBot && !p.BotControlled) || p.IsExited {
return "", ""
}
var playable []*Card
+14 -2
View File
@@ -13,6 +13,8 @@ func (e *Engine) Snapshot(forPlayerID string) map[string]interface{} {
"current_turn": currentTurnID,
"your_player_id": forPlayerID,
"harmony_target": e.HarmonyTarget,
"round_num": e.RoundNum,
"turn_id": e.TurnID,
}
var players []map[string]interface{}
@@ -47,10 +49,20 @@ func (e *Engine) Snapshot(forPlayerID string) map[string]interface{} {
}
snap["hand_cards"] = hand
var myChallenge []map[string]interface{}
if me != nil {
for _, c := range me.ChallengeZone {
myChallenge = append(myChallenge, map[string]interface{}{
"uid": c.UID, "placed_by": c.PlacedByNick,
})
}
}
snap["my_challenge_zone"] = myChallenge
var harmony []map[string]interface{}
for _, c := range e.HarmonyZone {
for i, c := range e.HarmonyZone {
harmony = append(harmony, map[string]interface{}{
"uid": c.UID, "face_up": false,
"uid": c.UID, "face_up": false, "order": i + 1,
})
}
snap["harmony_zone"] = harmony
+198 -5
View File
@@ -5,6 +5,7 @@ import (
"Embalming_Girl_Server/game"
"Embalming_Girl_Server/room"
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
@@ -25,6 +26,7 @@ type Client struct {
Username string
Nickname string
Token string
Version string
Conn *websocket.Conn
Send chan []byte
mu sync.Mutex
@@ -51,6 +53,18 @@ func (h *Hub) OnlineCount() int {
return len(h.clients)
}
func (h *Hub) OnlineClientsList() []map[string]interface{} {
h.mu.RLock()
defer h.mu.RUnlock()
var list []map[string]interface{}
for _, c := range h.clients {
list = append(list, map[string]interface{}{
"id": c.ID, "username": c.Username, "nickname": c.Nickname, "room_id": c.RoomID, "version": c.Version,
})
}
return list
}
func (h *Hub) InitTestRooms() {
for _, n := range []int{3, 4, 5, 6} {
rid := "TEST" + string(rune('0'+n))
@@ -109,15 +123,59 @@ func (h *Hub) removeClient(c *Client) {
h.mu.Lock()
delete(h.clients, c.ID)
h.mu.Unlock()
if c.RoomID != "" {
h.mu.RLock()
_, hasGame := h.engines[c.RoomID]
h.mu.RUnlock()
if hasGame && c.Username != "" {
h.RoomMgr.DisconnectPlayer(c.RoomID, c.ID)
h.broadcastToRoom(c.RoomID, "player_disconnected", map[string]interface{}{
"nickname": c.Nickname, "message": c.Nickname + " 断线了",
})
log.Printf("Player %s disconnected, slot preserved, 60s takeover timer started", c.Username)
go func(roomID, playerID, nick string) {
time.Sleep(60 * time.Second)
if !h.RoomMgr.IsPlayerDisconnected(roomID, playerID) {
return
}
h.RoomMgr.SetPlayerBotControlled(roomID, playerID, true)
h.mu.RLock()
eng := h.engines[roomID]
h.mu.RUnlock()
if eng != nil {
eng.SetBotControlled(playerID, true)
}
h.broadcastToRoom(roomID, "player_bot_takeover", map[string]interface{}{
"nickname": nick, "message": nick + " 已被人机接管",
})
log.Printf("Player %s taken over by bot in room %s", nick, roomID)
if eng != nil && eng.Phase == "playing" {
cp := eng.CurrentPlayer()
if cp != nil && cp.ID == playerID {
h.processBotTurns(roomID)
}
}
}(c.RoomID, c.ID, c.Nickname)
} else {
h.RoomMgr.LeaveRoom(c.RoomID, c.ID)
rm := h.RoomMgr.GetRoom(c.RoomID)
if rm != nil {
h.broadcastRoomState(rm)
} else {
h.mu.Lock()
delete(h.engines, c.RoomID)
h.mu.Unlock()
}
}
}
close(c.Send)
log.Printf("Disconnected: %s", c.ID)
log.Printf("Disconnected: %s (%s)", c.ID, c.Username)
}
func (h *Hub) handleMessage(c *Client, msg map[string]interface{}) {
@@ -136,6 +194,8 @@ func (h *Hub) handleMessage(c *Client, msg map[string]interface{}) {
h.handleCreateRoom(c, p)
case "join_room":
h.handleJoinRoom(c, p)
case "rejoin_room":
h.handleRejoin(c, p)
case "leave_room":
h.handleLeaveRoom(c)
case "set_ready":
@@ -154,6 +214,8 @@ func (h *Hub) handleMessage(c *Client, msg map[string]interface{}) {
h.handleChat(c, p)
case "set_chaos":
h.handleSetChaos(c, p)
case "create_bot_room":
h.handleCreateBotRoom(c, p)
case "heartbeat":
h.sendTo(c, "heartbeat_ack", nil)
}
@@ -203,12 +265,20 @@ func (h *Hub) handleLogin(c *Client, p map[string]interface{}) {
c.Username = user.Username
c.Nickname = user.Nickname
c.Token = token
if v, ok := p["version"].(string); ok {
c.Version = v
}
log.Printf("User logged in: %s (%s)", user.Username, c.ID)
h.sendTo(c, "login_result", map[string]interface{}{
result := map[string]interface{}{
"success": true, "token": token,
"username": user.Username, "nickname": user.Nickname, "avatar": user.Avatar, "bio": user.Bio,
})
}
if roomID, playerID, found := h.RoomMgr.FindRoomByUsername(user.Username); found {
result["active_room"] = roomID
result["active_player_id"] = playerID
}
h.sendTo(c, "login_result", result)
}
func (h *Hub) handleUpdateProfile(c *Client, p map[string]interface{}) {
@@ -239,6 +309,41 @@ func (h *Hub) handleGetProfile(c *Client, p map[string]interface{}) {
})
}
func (h *Hub) handleRejoin(c *Client, p map[string]interface{}) {
roomID, _ := p["room_id"].(string)
oldPID, _ := p["player_id"].(string)
if roomID == "" || oldPID == "" {
h.sendTo(c, "error", map[string]interface{}{"message": "缺少参数"})
return
}
if !h.RoomMgr.ReconnectPlayer(roomID, oldPID, c.ID, c.Send) {
h.sendTo(c, "error", map[string]interface{}{"message": "无法重连到对局"})
return
}
c.RoomID = roomID
log.Printf("Player %s rejoined room %s", c.Username, roomID)
h.mu.RLock()
eng := h.engines[roomID]
h.mu.RUnlock()
if eng != nil {
eng.UpdatePlayerID(oldPID, c.ID)
eng.SetBotControlled(c.ID, false)
h.sendToChannel(c.Send, "game_start", map[string]interface{}{
"your_player_id": c.ID, "harmony_target": eng.HarmonyTarget,
"player_count": len(eng.Players), "rejoin": true,
})
snap := eng.Snapshot(c.ID)
h.sendToChannel(c.Send, "state_snapshot", snap)
}
rm := h.RoomMgr.GetRoom(roomID)
if rm != nil {
h.broadcastRoomState(rm)
}
h.sendTo(c, "rejoin_result", map[string]interface{}{"success": true, "room_id": roomID})
}
func (h *Hub) handleCreateRoom(c *Client, p map[string]interface{}) {
nick, _ := p["nickname"].(string)
mp := 4
@@ -251,6 +356,7 @@ func (h *Hub) handleCreateRoom(c *Client, p map[string]interface{}) {
rm := h.RoomMgr.CreateRoom(c.ID, nick, mp)
c.RoomID = rm.ID
rm.Players[c.ID].Send = c.Send
rm.Players[c.ID].Username = c.Username
h.sendTo(c, "room_created", map[string]interface{}{"room_id": rm.ID})
h.broadcastRoomState(rm)
}
@@ -268,6 +374,8 @@ func (h *Hub) handleJoinRoom(c *Client, p map[string]interface{}) {
}
c.RoomID = rm.ID
rm.Players[c.ID].Send = c.Send
rm.Players[c.ID].Username = c.Username
h.sendTo(c, "room_joined", map[string]interface{}{"room_id": rm.ID})
h.broadcastRoomState(rm)
}
@@ -298,6 +406,32 @@ func (h *Hub) handleSetChaos(c *Client, p map[string]interface{}) {
h.broadcastRoomState(rm)
}
func (h *Hub) handleCreateBotRoom(c *Client, p map[string]interface{}) {
count := 4
if v, ok := p["player_count"].(float64); ok {
count = int(v)
}
if count < 3 || count > 6 {
h.sendTo(c, "error", map[string]interface{}{"message": "人数必须在3-6之间"})
return
}
rid := fmt.Sprintf("BOT_%s", generateClientID()[:6])
h.RoomMgr.CreateTestRoom(rid, count)
rm, ok := h.RoomMgr.JoinRoom(rid, c.ID, c.Nickname)
if !ok {
h.sendTo(c, "error", map[string]interface{}{"message": "创建失败"})
return
}
c.RoomID = rm.ID
rm.Players[c.ID].Send = c.Send
rm.Players[c.ID].Username = c.Username
h.sendTo(c, "room_joined", map[string]interface{}{"room_id": rm.ID})
h.broadcastRoomState(rm)
log.Printf("Bot room %s created by %s (%d players)", rid, c.Username, count)
}
func (h *Hub) handleChat(c *Client, p map[string]interface{}) {
if c.RoomID == "" {
return
@@ -528,7 +662,7 @@ func (h *Hub) processBotTurns(roomID string) {
for eng.Phase == "playing" && !eng.IsEffectResolving() {
cp := eng.CurrentPlayer()
if cp == nil || !cp.IsBot {
if cp == nil || (!cp.IsBot && !cp.BotControlled) {
break
}
@@ -556,6 +690,7 @@ func (h *Hub) processBotTurns(roomID string) {
return
}
}
h.startTurnTimer(roomID)
}
func (h *Hub) handleAllExited(roomID string) {
@@ -624,7 +759,65 @@ func (h *Hub) handleAllExited(roomID string) {
h.broadcastRoomState(rm)
}
// ---- Audio relay ----
func (h *Hub) startTurnTimer(roomID string) {
h.mu.RLock()
eng := h.engines[roomID]
h.mu.RUnlock()
if eng == nil || eng.Phase != "playing" {
return
}
cp := eng.CurrentPlayer()
if cp == nil || cp.IsBot || cp.BotControlled {
return
}
turnID := eng.TurnID
go func() {
time.Sleep(30 * time.Second)
h.mu.RLock()
eng2 := h.engines[roomID]
h.mu.RUnlock()
if eng2 == nil || eng2.Phase != "playing" || eng2.TurnID != turnID {
return
}
cp2 := eng2.CurrentPlayer()
if cp2 == nil {
return
}
eng2.ActionLogs = append(eng2.ActionLogs, cp2.Nickname+" 超时,自动出牌")
action, target := eng2.BotAutoPlay(cp2.ID)
if action == "" {
return
}
eng2.ConfirmAction(cp2.ID, action, target)
h.broadcastSnapshots(roomID)
h.flushGameLogs(roomID)
if eng2.Phase == "all_exited" {
h.handleAllExited(roomID)
return
}
h.processBotTurns(roomID)
}()
}
// ---- Broadcast helpers ----
func (h *Hub) broadcastToRoom(roomID, msgType string, data map[string]interface{}) {
rm := h.RoomMgr.GetRoom(roomID)
if rm == nil {
return
}
for _, pid := range rm.PlayerOrder {
p := rm.Players[pid]
if p.IsBot || p.Send == nil {
continue
}
h.sendToChannel(p.Send, msgType, data)
}
}
func (h *Hub) flushGameLogs(roomID string) {
h.mu.RLock()
+6
View File
@@ -4,6 +4,7 @@ import (
"Embalming_Girl_Server/admin"
"Embalming_Girl_Server/db"
"Embalming_Girl_Server/handler"
"encoding/json"
"log"
"net/http"
)
@@ -33,6 +34,11 @@ func main() {
"online_clients": hub.OnlineCount(),
}
})
admin.RegisterUpdateRoutes(mux)
mux.HandleFunc("/api/admin/online", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(hub.OnlineClientsList())
})
addr := ":8080"
log.Printf("Server started on %s", addr)
+82
View File
@@ -9,10 +9,13 @@ import (
type Player struct {
ID string
Username string
Nickname string
IsReady bool
IsHost bool
IsBot bool
BotControlled bool
Disconnected bool
Send chan []byte
}
@@ -298,6 +301,85 @@ func (r *Room) SendToPlayer(playerID string, data []byte) {
}
}
func (m *Manager) FindRoomByUsername(username string) (string, string, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, rm := range m.rooms {
rm.mu.RLock()
for _, p := range rm.Players {
if p.Username == username && !p.IsBot {
rm.mu.RUnlock()
return rm.ID, p.ID, true
}
}
rm.mu.RUnlock()
}
return "", "", false
}
func (m *Manager) DisconnectPlayer(roomID, playerID string) {
rm := m.GetRoom(roomID)
if rm == nil {
return
}
rm.mu.Lock()
defer rm.mu.Unlock()
if p, ok := rm.Players[playerID]; ok {
p.Disconnected = true
p.Send = nil
}
}
func (m *Manager) IsPlayerDisconnected(roomID, playerID string) bool {
rm := m.GetRoom(roomID)
if rm == nil {
return false
}
rm.mu.RLock()
defer rm.mu.RUnlock()
p, ok := rm.Players[playerID]
return ok && p.Disconnected
}
func (m *Manager) SetPlayerBotControlled(roomID, playerID string, v bool) {
rm := m.GetRoom(roomID)
if rm == nil {
return
}
rm.mu.Lock()
defer rm.mu.Unlock()
if p, ok := rm.Players[playerID]; ok {
p.BotControlled = v
}
}
func (m *Manager) ReconnectPlayer(roomID, oldPID, newPID string, send chan []byte) bool {
rm := m.GetRoom(roomID)
if rm == nil {
return false
}
rm.mu.Lock()
defer rm.mu.Unlock()
p, ok := rm.Players[oldPID]
if !ok {
return false
}
p.ID = newPID
p.Disconnected = false
p.BotControlled = false
p.Send = send
delete(rm.Players, oldPID)
rm.Players[newPID] = p
for i, id := range rm.PlayerOrder {
if id == oldPID {
rm.PlayerOrder[i] = newPID
break
}
}
return true
}
func generateRoomID() string {
src := rand.NewSource(time.Now().UnixNano())
r := rand.New(src)
BIN
View File
Binary file not shown.