chore: 初始化 idsync 项目

飞书通讯录 ↔ LDAP 同步工具的首个提交。

- internal/feishu: 飞书通讯录客户端、快照与重试
- internal/ldaplib: LDAP 读写与批量操作
- internal/mapping: 飞书部门/用户到 LDAP 条目的映射
- internal/syncer: 同步计划生成、执行与调度
- internal/store: SQLite 台账
- internal/server + internal/webui: 管理后台与内嵌前端产物
- internal/password: SSHA 口令处理
- config.example.yml / Dockerfile / Makefile / web 前端源码

真实凭据(config.yml)、运行时数据(data/、backup/)与构建产物(bin/)不入库。
This commit is contained in:
2026-09-29 00:10:59 +08:00
commit 947b1a1475
43 changed files with 10079 additions and 0 deletions
+2
View File
File diff suppressed because one or more lines are too long
+423
View File
@@ -0,0 +1,423 @@
'use strict';
// idsync 管理界面。刻意不引入前端框架:页面数量少、状态简单,
// 原生 JS 足以胜任,且省去一整套构建链与依赖升级负担。
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
// 操作类型的中文标签,与后端 syncer.OpKind 对应。
const OP_LABELS = {
create_user: '新建用户',
update_user: '更新用户',
deactivate_user: '迁入离职 OU',
reactivate_user: '恢复用户',
create_group: '新建分组',
update_group: '更新分组',
add_member: '加入分组',
remove_member: '移出分组',
};
const OP_BADGES = {
create_user: 'badge-success',
create_group: 'badge-success',
update_user: 'badge-info',
update_group: 'badge-info',
reactivate_user: 'badge-warning',
deactivate_user: 'badge-error',
add_member: 'badge-success',
remove_member: 'badge-error',
};
// ---------------------------------------------------------------- 主题
function initTheme() {
const saved = localStorage.getItem('idsync-theme');
if (saved === 'light' || saved === 'dark') {
document.documentElement.dataset.theme = saved;
}
$('#themeToggle').addEventListener('click', () => {
const root = document.documentElement;
const next = root.dataset.theme === 'dark' ? 'light' : 'dark';
root.dataset.theme = next;
localStorage.setItem('idsync-theme', next);
});
}
// ---------------------------------------------------------------- HTTP
async function api(path, options = {}) {
const res = await fetch(path, {
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
...options,
});
if (res.status === 401) {
showLogin();
throw new Error('登录状态已失效');
}
let body = null;
try {
body = await res.json();
} catch (_) {
// 非 JSON 响应(如上游超时返回的 HTML),下面按状态码统一处理
}
if (!res.ok) {
throw new Error((body && body.error) || `请求失败(HTTP ${res.status})`);
}
return body;
}
function showError(msg) {
const el = $('#globalError');
el.textContent = msg;
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 8000);
}
function hideError() {
$('#globalError').classList.add('hidden');
}
// ---------------------------------------------------------------- 视图切换
function showLogin() {
$('#loginView').classList.remove('hidden');
$('#loginView').classList.add('flex');
$('#appView').classList.add('hidden');
}
function showApp() {
$('#loginView').classList.add('hidden');
$('#loginView').classList.remove('flex');
$('#appView').classList.remove('hidden');
}
function switchView(name) {
$$('.view').forEach((el) => el.classList.add('hidden'));
const view = $(`#view-${name}`);
if (view) view.classList.remove('hidden');
$$('.tabs .tab').forEach((t) => {
t.classList.toggle('tab-active', t.dataset.view === name);
});
if (name === 'overview') loadStatus();
if (name === 'users') loadUsers();
if (name === 'groups') loadGroups();
if (name === 'plan') loadPlan();
if (name === 'runs') loadRuns();
}
// ---------------------------------------------------------------- 登录
async function doLogin() {
const errEl = $('#loginError');
errEl.classList.add('hidden');
try {
await api('/api/login', {
method: 'POST',
body: JSON.stringify({ username: $('#username').value, password: $('#password').value }),
});
showApp();
switchView('overview');
} catch (err) {
errEl.textContent = err.message;
errEl.classList.remove('hidden');
}
}
async function doLogout() {
try {
await api('/api/logout', { method: 'POST' });
} catch (_) {
// 登出失败也要回到登录页,本地状态不该依赖服务端响应
}
showLogin();
}
// ---------------------------------------------------------------- 概览
async function loadStatus() {
try {
const d = await api('/api/status');
hideError();
$('#statActive').textContent = d.counts.activeUsers;
$('#statInactive').textContent = d.counts.inactiveUsers;
$('#statGroups').textContent = d.counts.groups;
$('#connStatus').innerHTML = [
statusRow('OpenLDAP', d.ldap.ok, d.ldap.error, d.ldap.url),
statusRow('飞书', d.feishu.ok, d.feishu.error, `App ID ${d.feishu.appId}`),
].join('');
$('#lastRunInfo').innerHTML = d.lastRun
? `<div class="space-y-1">
<div>时间:${fmtTime(d.lastRun.finishedAt)}</div>
<div>模式:${d.lastRun.mode === 'dry-run' ? '预览(未写入)' : '实际执行'}</div>
<div>变更:新建 ${d.lastRun.created} · 更新 ${d.lastRun.updated} · 加组 ${d.lastRun.added} · 移组 ${d.lastRun.removed}</div>
</div>`
: '<span class="text-base-content/50">暂无成功的同步记录</span>';
} catch (err) {
showError(err.message);
}
}
function statusRow(name, ok, error, detail) {
const badge = ok
? '<span class="badge badge-success badge-sm">正常</span>'
: '<span class="badge badge-error badge-sm">异常</span>';
const extra = ok
? `<span class="text-base-content/60">${escapeHtml(detail || '')}</span>`
: `<span class="text-error">${escapeHtml(error || '')}</span>`;
return `<div class="flex items-center justify-between gap-3">
<span class="flex items-center gap-2"><span class="font-medium">${name}</span>${badge}</span>
${extra}
</div>`;
}
// ---------------------------------------------------------------- 用户
let usersCache = [];
async function loadUsers() {
try {
const d = await api('/api/users');
usersCache = d.users || [];
renderUsers();
hideError();
} catch (err) {
showError(err.message);
}
}
function renderUsers() {
const kw = $('#userFilter').value.trim().toLowerCase();
const rows = usersCache.filter((u) => {
if (!kw) return true;
return [u.uid, u.name, u.email].some((v) => (v || '').toLowerCase().includes(kw));
});
$('#userRows').innerHTML = rows.map((u) => `
<tr>
<td class="font-medium">${escapeHtml(u.uid)}</td>
<td>${escapeHtml(u.name || '')}</td>
<td>${escapeHtml(u.email || '')}</td>
<td>${u.state === 'inactive'
? '<span class="badge badge-error badge-sm">离职</span>'
: '<span class="badge badge-success badge-sm">在职</span>'}</td>
<td>${(u.deptIds || '').split(',').filter(Boolean).length}</td>
<td class="text-base-content/60 whitespace-nowrap">${fmtTime(u.lastSyncedAt)}</td>
<td>${u.drift
? `<span class="badge badge-warning badge-sm" title="${escapeHtml(u.drift)}">需关注</span>`
: '<span class="text-base-content/40">—</span>'}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(u.dn)}</td>
</tr>`).join('');
$('#userEmpty').classList.toggle('hidden', rows.length > 0);
}
// ---------------------------------------------------------------- 分组
async function loadGroups() {
try {
const d = await api('/api/groups');
const groups = d.groups || [];
$('#groupRows').innerHTML = groups.map((g) => `
<tr>
<td class="font-medium">${escapeHtml(g.name || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.deptId || '')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.parentId || '—')}</td>
<td class="idsync-dn text-base-content/60">${escapeHtml(g.dn || '')}</td>
</tr>`).join('');
$('#groupEmpty').classList.toggle('hidden', groups.length > 0);
hideError();
} catch (err) {
showError(err.message);
}
}
// ---------------------------------------------------------------- 变更预览
let currentPlan = null;
async function loadPlan() {
$('#planRows').innerHTML = '';
$('#planSummary').innerHTML = '';
$('#planWarnings').classList.add('hidden');
$('#planEmpty').classList.add('hidden');
try {
const plan = await api('/api/plan');
currentPlan = plan;
renderPlan(plan);
hideError();
} catch (err) {
showError(err.message);
}
}
function renderPlan(plan) {
const s = plan.stats || {};
const cards = [
['新建用户', s.createUser, 'text-success'],
['更新用户', s.updateUser, 'text-info'],
['迁入离职', s.deactivateUser, 'text-error'],
['恢复用户', s.reactivateUser, 'text-warning'],
['新建分组', s.createGroup, 'text-success'],
['加入分组', s.addMember, 'text-success'],
['移出分组', s.removeMember, 'text-error'],
];
$('#planSummary').innerHTML = cards.map(([label, value, cls]) => `
<div class="card bg-base-100 shadow-sm">
<div class="card-body p-4">
<span class="text-xs text-base-content/60">${label}</span>
<span class="text-2xl font-semibold ${value > 0 ? cls : 'text-base-content/30'}">${value || 0}</span>
</div>
</div>`).join('');
const warnings = plan.warnings || [];
if (warnings.length > 0) {
const el = $('#planWarnings');
el.innerHTML = `<div class="alert alert-warning text-sm">
<div>
<div class="font-medium mb-1">需要注意 ${warnings.length} 项</div>
<ul class="list-disc pl-5 space-y-1">
${warnings.map((w) => `<li>${escapeHtml(w)}</li>`).join('')}
</ul>
</div>
</div>`;
el.classList.remove('hidden');
}
const ops = plan.ops || [];
$('#planRows').innerHTML = ops.map((op) => `
<tr>
<td><span class="badge ${OP_BADGES[op.kind] || 'badge-neutral'} badge-sm">${OP_LABELS[op.kind] || op.kind}</span></td>
<td class="font-medium">${escapeHtml(op.target)}</td>
<td>${escapeHtml(op.detail)}</td>
</tr>`).join('');
$('#planEmpty').classList.toggle('hidden', ops.length > 0);
}
async function applyPlan(force = false) {
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
try {
const d = await api('/api/sync', {
method: 'POST',
body: JSON.stringify({ dryRun: false, force }),
});
const res = d.result || {};
showError(`同步完成:新建 ${res.Created || 0},更新 ${res.Updated || 0},加组 ${res.Added || 0},移组 ${res.Removed || 0},失败 ${res.Failed || 0}`);
$('#globalError').className = (res.Failed > 0) ? 'alert alert-warning text-sm mb-4' : 'alert alert-success text-sm mb-4';
switchView('runs');
} catch (err) {
showError(err.message);
}
}
// ---------------------------------------------------------------- 同步历史
async function loadRuns() {
try {
const d = await api('/api/runs');
const runs = d.runs || [];
$('#runRows').innerHTML = runs.map((r) => `
<tr>
<td class="whitespace-nowrap">${fmtTime(r.startedAt)}</td>
<td>${r.mode === 'dry-run'
? '<span class="badge badge-ghost badge-sm">预览</span>'
: '<span class="badge badge-primary badge-sm">执行</span>'}</td>
<td>${r.trigger === 'schedule' ? '定时' : '手动'}</td>
<td>${r.status === 'success'
? '<span class="badge badge-success badge-sm">成功</span>'
: '<span class="badge badge-error badge-sm" title="' + escapeHtml(r.errorMsg || '') + '">失败</span>'}</td>
<td>${r.created}</td><td>${r.updated}</td><td>${r.moved}</td>
<td>${r.added}</td><td>${r.removed}</td>
<td>${r.failed > 0 ? `<span class="text-error">${r.failed}</span>` : '0'}</td>
</tr>`).join('');
$('#runEmpty').classList.toggle('hidden', runs.length > 0);
hideError();
} catch (err) {
showError(err.message);
}
}
// ---------------------------------------------------------------- 工具
function fmtTime(s) {
if (!s) return '—';
const d = new Date(s);
if (isNaN(d.getTime())) return '—';
const p = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ---------------------------------------------------------------- 启动
document.addEventListener('DOMContentLoaded', () => {
initTheme();
$('#loginBtn').addEventListener('click', doLogin);
$('#password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
$('#username').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
$('#logoutBtn').addEventListener('click', doLogout);
$$('.tabs .tab').forEach((t) => {
t.addEventListener('click', () => switchView(t.dataset.view));
});
$('#userFilter').addEventListener('input', renderUsers);
$('#reloadUsers').addEventListener('click', loadUsers);
$('#reloadRuns').addEventListener('click', loadRuns);
$('#previewBtn').addEventListener('click', loadPlan);
$('#applyBtn').addEventListener('click', () => {
if (!currentPlan) return;
const s = currentPlan.stats || {};
const total = (s.createUser || 0) + (s.updateUser || 0) + (s.deactivateUser || 0)
+ (s.reactivateUser || 0) + (s.createGroup || 0) + (s.addMember || 0) + (s.removeMember || 0);
if (total === 0) {
showError('没有需要应用的变更。');
return;
}
$('#applyModalBody').innerHTML = `
<div class="space-y-1">
<div>新建用户 <strong>${s.createUser || 0}</strong> · 更新用户 <strong>${s.updateUser || 0}</strong></div>
<div>迁入离职 OU <strong>${s.deactivateUser || 0}</strong> · 恢复用户 <strong>${s.reactivateUser || 0}</strong></div>
<div>新建分组 <strong>${s.createGroup || 0}</strong> · 加入分组 <strong>${s.addMember || 0}</strong> · 移出分组 <strong>${s.removeMember || 0}</strong></div>
</div>`;
const modal = document.getElementById('applyModal');
if (modal && typeof modal.showModal === 'function') modal.showModal();
});
$('#applyCancel').addEventListener('click', () => {
const modal = document.getElementById('applyModal');
if (modal && typeof modal.close === 'function') modal.close();
});
$('#applyConfirm').addEventListener('click', () => applyPlan(false));
// 已有会话则直接进入主界面
api('/api/status')
.then(() => { showApp(); switchView('overview'); })
.catch(() => showLogin());
});
+206
View File
File diff suppressed because one or more lines are too long
+229
View File
@@ -0,0 +1,229 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>idsync · 飞书通讯录同步</title>
<link rel="stylesheet" href="/static/app.css" />
</head>
<body class="bg-base-200 min-h-screen">
<!-- 未登录:登录卡片 -->
<div id="loginView" class="hidden items-center justify-center min-h-screen p-4">
<div class="card w-full max-w-sm bg-base-100 shadow-lg">
<div class="card-body">
<div class="flex items-center gap-2 mb-2">
<span class="inline-block size-3 rounded-full bg-primary"></span>
<h1 class="card-title text-lg m-0">idsync</h1>
</div>
<p class="text-sm text-base-content/60 mb-4">飞书通讯录 → OpenLDAP 同步</p>
<div id="loginError" class="alert alert-error text-sm mb-3 hidden"></div>
<label class="label" for="username"><span class="label-text">用户名</span></label>
<input id="username" type="text" class="input w-full mb-3" autocomplete="username" />
<label class="label" for="password"><span class="label-text">密码</span></label>
<input id="password" type="password" class="input w-full mb-5" autocomplete="current-password" />
<button id="loginBtn" class="btn btn-primary w-full">登录</button>
</div>
</div>
</div>
<!-- 已登录:主界面 -->
<div id="appView" class="hidden">
<header class="navbar bg-base-100 shadow-sm px-4 sticky top-0 z-20">
<div class="flex-1 flex items-center gap-2">
<span class="inline-block size-3 rounded-full bg-primary"></span>
<span class="text-lg font-semibold">idsync</span>
<span class="hidden sm:inline text-sm text-base-content/50">飞书通讯录 → OpenLDAP</span>
</div>
<div class="flex-none flex items-center gap-2">
<button id="themeToggle" class="btn btn-sm btn-ghost" title="切换明暗主题">主题</button>
<button id="logoutBtn" class="btn btn-sm btn-outline">退出</button>
</div>
</header>
<main class="max-w-7xl mx-auto p-4">
<!-- 页签 -->
<div class="tabs tabs-border mb-4" role="tablist">
<button class="tab tab-active" data-view="overview" role="tab">概览</button>
<button class="tab" data-view="users" role="tab">用户</button>
<button class="tab" data-view="groups" role="tab">部门分组</button>
<button class="tab" data-view="plan" role="tab">变更预览</button>
<button class="tab" data-view="runs" role="tab">同步历史</button>
</div>
<div id="globalError" class="alert alert-error text-sm mb-4 hidden"></div>
<!-- 概览 -->
<section id="view-overview" class="view">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-4">
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<span class="text-sm text-base-content/60">在职账号</span>
<span id="statActive" class="text-3xl font-semibold">–</span>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<span class="text-sm text-base-content/60">离职账号</span>
<span id="statInactive" class="text-3xl font-semibold">–</span>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<span class="text-sm text-base-content/60">部门分组</span>
<span id="statGroups" class="text-3xl font-semibold">–</span>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-base">服务状态</h2>
<div id="connStatus" class="space-y-2 text-sm"></div>
</div>
</div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-base">上次同步</h2>
<div id="lastRunInfo" class="text-sm text-base-content/70">暂无记录</div>
</div>
</div>
</div>
</section>
<!-- 用户 -->
<section id="view-users" class="view hidden">
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-3 mb-3">
<h2 class="card-title text-base">受管用户</h2>
<div class="flex items-center gap-2">
<input id="userFilter" type="search" class="input input-sm w-56" placeholder="按 uid / 姓名 / 邮箱过滤" />
<button id="reloadUsers" class="btn btn-sm btn-outline">刷新</button>
</div>
</div>
<div class="idsync-scroll">
<table class="table table-sm">
<thead>
<tr>
<th>uid</th><th>姓名</th><th>企业邮箱</th><th>状态</th>
<th>归属部门数</th><th>最后同步</th><th>一致性</th><th>DN</th>
</tr>
</thead>
<tbody id="userRows"></tbody>
</table>
</div>
<p id="userEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">暂无受管用户,请先执行一次同步。</p>
</div>
</div>
</section>
<!-- 部门分组 -->
<section id="view-groups" class="view hidden">
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h2 class="card-title text-base mb-3">受管部门分组</h2>
<div class="idsync-scroll">
<table class="table table-sm">
<thead>
<tr><th>部门</th><th>飞书部门 ID</th><th>父部门</th><th>DN</th></tr>
</thead>
<tbody id="groupRows"></tbody>
</table>
</div>
<p id="groupEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">暂无受管分组。</p>
</div>
</div>
</section>
<!-- 变更预览 -->
<section id="view-plan" class="view hidden">
<div class="card bg-base-100 shadow-sm mb-4">
<div class="card-body">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="card-title text-base">变更预览(dry-run)</h2>
<p class="text-sm text-base-content/60 mt-1">
预览与实际执行使用同一份计划代码,因此这里显示的就是将要发生的变更。
</p>
</div>
<div class="flex gap-2">
<button id="previewBtn" class="btn btn-sm btn-outline">重新计算</button>
<button id="applyBtn" class="btn btn-sm btn-primary">应用变更</button>
</div>
</div>
</div>
</div>
<div id="planWarnings" class="hidden mb-4"></div>
<div id="planSummary" class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3 mb-4"></div>
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<h3 class="card-title text-base mb-2">操作明细</h3>
<div class="idsync-scroll">
<table class="table table-sm">
<thead>
<tr><th class="w-40">类型</th><th class="w-56">对象</th><th>说明</th></tr>
</thead>
<tbody id="planRows"></tbody>
</table>
</div>
<p id="planEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">
没有需要变更的内容,两侧状态已一致。
</p>
</div>
</div>
</section>
<!-- 同步历史 -->
<section id="view-runs" class="view hidden">
<div class="card bg-base-100 shadow-sm">
<div class="card-body">
<div class="flex items-center justify-between mb-3">
<h2 class="card-title text-base">同步历史</h2>
<button id="reloadRuns" class="btn btn-sm btn-outline">刷新</button>
</div>
<div class="idsync-scroll">
<table class="table table-sm">
<thead>
<tr>
<th>开始时间</th><th>模式</th><th>触发</th><th>结果</th>
<th>新建</th><th>更新</th><th>迁移</th><th>加组</th><th>移组</th><th>失败</th>
</tr>
</thead>
<tbody id="runRows"></tbody>
</table>
</div>
<p id="runEmpty" class="hidden text-sm text-base-content/50 py-4 text-center">暂无同步记录。</p>
</div>
</div>
</section>
</main>
</div>
<!-- 应用变更的确认对话框 -->
<dialog id="applyModal" class="modal">
<div class="modal-box">
<h3 class="text-lg font-semibold mb-2">确认应用变更</h3>
<p class="text-sm text-base-content/70 mb-4">
将对 OpenLDAP 执行下列变更。用户密码不会被修改,Authelia 重置的密码不受影响。
</p>
<div id="applyModalBody" class="text-sm mb-4"></div>
<div class="modal-action">
<button id="applyCancel" class="btn btn-ghost">取消</button>
<button id="applyConfirm" class="btn btn-primary">确认执行</button>
</div>
</div>
</dialog>
<script src="/static/flyonui.js"></script>
<script src="/static/app.js"></script>
</body>
</html>
+130
View File
@@ -0,0 +1,130 @@
// Package webui 通过 go:embed 把前端资源打进二进制。
//
// 前端用 Tailwind v4 + FlyonUI 构建:FlyonUI 官方不支持 CDN 方式
// (样式在构建期生成),所以这里使用构建产物,用户无需 Node 环境。
// 构建命令见 scripts/build-webui.sh,产物在 internal/webui/dist 下。
package webui
import (
"crypto/sha256"
"embed"
"encoding/hex"
"fmt"
"io/fs"
"net/http"
"strings"
)
// dist 是前端构建产物。
//
//go:embed all:dist
var dist embed.FS
// assets 保存资源名 → 内容;assetETags 保存内容指纹用于 ETag。
var (
assets map[string][]byte
assetETags map[string]string
)
func init() {
fsys, err := fs.Sub(dist, "dist")
if err != nil {
return
}
assets = make(map[string][]byte, 8)
assetETags = make(map[string]string, 8)
_ = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
data, err := fs.ReadFile(fsys, path)
if err != nil {
return nil
}
assets[path] = data
sum := sha256.Sum256(data)
assetETags[path] = `"` + hex.EncodeToString(sum[:8]) + `"`
return nil
})
}
// contentTypes 是各静态资源的 MIME 类型。
var contentTypes = map[string]string{
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".html": "text/html; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".woff2": "font/woff2",
}
// IndexHandler 输出内嵌的 index.html。
//
// 刻意不缓存 HTML:它引用 app.js,一旦被缓存,升级二进制后浏览器仍会
// 加载旧脚本,表现为界面数据莫名其妙地缺失或显示 undefined。
func IndexHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
data, ok := assets["index.html"]
if !ok {
http.Error(w, "前端资源缺失,请先执行 make css", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(data)
}
}
// AssetHandler 提供 /static/ 下的静态资源。
//
// 用内容指纹做 ETag 并允许缓存,但每次回源校验(no-cache)。
// 这样既有 304 的高效,又不会在升级二进制后继续使用旧资源——
// 仅设 max-age 而不带指纹的策略在内嵌场景下会长期给出过期界面。
func AssetHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/static/")
if name == "" {
http.NotFound(w, r)
return
}
data, ok := assets[name]
if !ok {
http.NotFound(w, r)
return
}
etag := assetETags[name]
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "no-cache")
if match := r.Header.Get("If-None-Match"); match != "" && strings.Contains(match, etag) {
w.WriteHeader(http.StatusNotModified)
return
}
if ct, ok := contentTypes[extOf(name)]; ok {
w.Header().Set("Content-Type", ct)
}
w.Header().Set("Content-Length", fmt.Sprint(len(data)))
_, _ = w.Write(data)
}
}
func extOf(name string) string {
if i := strings.LastIndex(name, "."); i >= 0 {
return name[i:]
}
return ""
}
// AssetNames 返回已嵌入的资源名,供测试与健康检查使用。
func AssetNames() []string {
out := make([]string, 0, len(assets))
for name := range assets {
out = append(out, name)
}
return out
}