feat: 重构为前后端分离架构并完善设备端演示

- 新增 Web 用户端(登录体系 + 统一 REST API 调用)
- 后端增加用户认证、统一 ApiResponse、CORS 支持
- Android 设备端迁移至 MVVM + DataBinding + Retrofit 网络层
- 完善 README 架构说明与密码学原理文档
- 新增 .gitignore 与持久化数据表说明
This commit is contained in:
TongTongStudio
2026-08-21 04:34:58 +08:00
parent eda03d241d
commit 93499fa189
63 changed files with 4398 additions and 455 deletions

253
web-client/app.js Normal file
View File

@@ -0,0 +1,253 @@
// ============================================================
// Secure Device 用户端Web—— 前后端分离演示
//
// 后端地址:默认 localhost:8080CORS 已放开)。
// 认证:登录/注册后获得 Bearer Token存 localStorage
// 后续所有用户端接口自动携带 Authorization: Bearer <token>。
// ============================================================
const API_BASE = 'http://localhost:8080';
// ---------- 状态 ----------
let token = localStorage.getItem('sd_token') || '';
let userId = localStorage.getItem('sd_userId') || '';
// ---------- DOM 引用 ----------
const $ = (id) => document.getElementById(id);
const sections = {
auth: $('authSection'),
device: $('deviceSection'),
recover: $('recoverSection'),
photo: $('photoSection'),
};
// 登录后才需要显示的面板(登录区 auth 始终显示,不在其中)
const authedSections = {
device: $('deviceSection'),
recover: $('recoverSection'),
photo: $('photoSection'),
};
// ---------- 通用请求 ----------
async function api(path, { method = 'GET', body = null } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = 'Bearer ' + token;
const resp = await fetch(API_BASE + path, {
method,
headers,
body: body ? JSON.stringify(body) : null,
});
// 统一响应格式 { code, message, data },即使 HTTP 4xx/5xx 也是该结构
const json = await resp.json().catch(() => null);
showRaw(method, path, resp.status, json);
if (!resp.ok || !json || json.code !== 0) {
throw new Error((json && json.message) || `HTTP ${resp.status}`);
}
return json.data;
}
// ---------- 原始响应展示 ----------
function showRaw(method, path, status, json) {
$('rawOutput').textContent =
`HTTP ${status} ${method} ${API_BASE}${path}\n` +
JSON.stringify(json, null, 2);
}
// ---------- 结果展示 ----------
function showResult(elId, text, isErr = false) {
const el = $(elId);
el.textContent = text;
el.className = 'result ' + (isErr ? 'err' : 'ok');
}
// ---------- 登录态 UI ----------
function refreshAuthUI() {
const loggedIn = !!token;
$('userInfo').classList.toggle('hidden', !loggedIn);
$('userLabel').textContent = loggedIn ? `当前用户:${userId}` : '';
// 登录区始终显示;仅「设备/恢复/照片」三个面板在登录后才显示
$('authSection').classList.toggle('hidden', false);
Object.values(authedSections).forEach((s) => s.classList.toggle('hidden', !loggedIn));
if (!loggedIn) $('authResult').textContent = '';
}
function login(user, pass) {
token = user;
userId = user;
localStorage.setItem('sd_token', token);
localStorage.setItem('sd_userId', userId);
refreshAuthUI();
}
function logout() {
token = '';
userId = '';
localStorage.removeItem('sd_token');
localStorage.removeItem('sd_userId');
refreshAuthUI();
}
// ============================================================
// 事件绑定
// ============================================================
// 登录
$('btnLogin').addEventListener('click', async () => {
const uid = $('authUserId').value.trim();
const pass = $('authPassword').value || '123456';
if (!uid) return showResult('authResult', '请输入用户名', true);
try {
const data = await api('/api/auth/login', {
method: 'POST',
body: { userId: uid, password: pass },
});
login(data.userId, data.token);
showResult('authResult', `登录成功Token: ${data.token.slice(0, 16)}...`);
} catch (e) {
showResult('authResult', `登录失败:${e.message}`, true);
}
});
// 注册
$('btnRegister').addEventListener('click', async () => {
const uid = $('authUserId').value.trim();
const pass = $('authPassword').value || '123456';
const phone = $('authPhone').value.trim() || '13800138000';
if (!uid) return showResult('authResult', '请输入用户名', true);
try {
const data = await api('/api/auth/register', {
method: 'POST',
body: { userId: uid, phone, password: pass },
});
login(data.userId, data.token);
showResult('authResult', `注册成功并已登录Token: ${data.token.slice(0, 16)}...`);
} catch (e) {
showResult('authResult', `注册失败:${e.message}`, true);
}
});
// 退出登录
$('btnLogout').addEventListener('click', () => {
logout();
showResult('authResult', '已退出登录');
});
// 绑定设备
$('btnBind').addEventListener('click', async () => {
const sn = $('bindSn').value.trim();
if (!sn) return showResult('bindResult', '请输入设备 SN', true);
try {
const data = await api('/api/device/bind', {
method: 'POST',
body: { sn },
});
showResult('bindResult', data.message || '绑定成功');
} catch (e) {
showResult('bindResult', `绑定失败:${e.message}`, true);
}
});
// 设备列表
$('btnListDevices').addEventListener('click', async () => {
try {
const data = await api('/api/user/devices');
const devices = data.devices || [];
if (devices.length === 0) {
showResult('devicesResult', '暂无绑定设备');
return;
}
const lines = devices.map(
(d) => `${d.sn} | deviceId: ${d.deviceId} | 状态: ${d.active ? '启用' : '停用'}`
);
showResult('devicesResult', lines.join('\n'));
} catch (e) {
showResult('devicesResult', `获取设备列表失败:${e.message}`, true);
}
});
// 发送短信验证码
$('btnSendSms').addEventListener('click', async () => {
const phone = $('recoverPhone').value.trim() || '13800138000';
try {
const data = await api('/api/device/sms/send', {
method: 'POST',
body: { phone },
});
showResult('recoverResult', data.message || '验证码已发送demo 固定 000000');
} catch (e) {
showResult('recoverResult', `发送失败:${e.message}`, true);
}
});
// 恢复授权
$('btnRecover').addEventListener('click', async () => {
const sn = $('recoverSn').value.trim();
const newPubKey = $('recoverNewPubKey').value.trim();
const smsCode = $('recoverSmsCode').value.trim();
if (!sn) return showResult('recoverResult', '请输入设备 SN', true);
if (!newPubKey) return showResult('recoverResult', '请输入新设备公钥(从设备端复制)', true);
if (!smsCode) return showResult('recoverResult', '请输入短信验证码', true);
try {
const data = await api('/api/device/recover', {
method: 'POST',
body: { sn, smsCode, newPublicKeyBase64: newPubKey },
});
showResult(
'recoverResult',
`恢复授权成功deviceId: ${data.deviceId}\n` +
`encryptedRecoveryToken: ${data.encryptedRecoveryToken.slice(0, 32)}...\n` +
`(设备端可凭此 Token 取回照片 DEK`
);
} catch (e) {
showResult('recoverResult', `恢复授权失败:${e.message}`, true);
}
});
// 照片列表
$('btnListPhotos').addEventListener('click', async () => {
try {
const data = await api('/api/user/photos');
const photos = data.photos || [];
if (photos.length === 0) {
showResult('photosResult', '暂无照片(请先在设备端上传)');
return;
}
showResult('photosResult', photos.map((p) => `${p}`).join('\n'));
} catch (e) {
showResult('photosResult', `获取照片列表失败:${e.message}`, true);
}
});
// 下载解密照片
$('btnDecrypt').addEventListener('click', async () => {
const photoId = $('decryptPhotoId').value.trim();
if (!photoId) return showResult('decryptResult', '请输入 photoId', true);
try {
const data = await api(`/api/photo/${photoId}/decrypt`);
const plaintext = decodeBase64Text(data.plaintextBase64);
showResult(
'decryptResult',
`解密成功photoId: ${data.photoId}\n明文内容:\n${plaintext}`
);
} catch (e) {
showResult('decryptResult', `解密失败:${e.message}`, true);
}
});
// ---------- 工具 ----------
function decodeBase64Text(b64) {
try {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new TextDecoder('utf-8').decode(bytes);
} catch {
return '<无法解码为文本,可能为二进制照片数据>';
}
}
// ---------- 初始化 ----------
refreshAuthUI();

85
web-client/index.html Normal file
View File

@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Secure Device 用户端控制台</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="topbar">
<h1>Secure Device 用户端控制台</h1>
<div id="userInfo" class="user-info hidden">
<span id="userLabel"></span>
<button id="btnLogout" class="btn btn-sm">退出登录</button>
</div>
</header>
<main>
<!-- ============ 登录 / 注册 ============ -->
<section id="authSection" class="panel">
<h2>用户登录 / 注册</h2>
<p class="hint">模拟真实场景用户端Web先登录获取 Token再操作设备设备端Android无登录体系。</p>
<div class="form-row">
<input id="authUserId" placeholder="用户名,如 alice" autocomplete="username">
<input id="authPassword" type="password" placeholder="密码,注册时可自定义(默认 123456" autocomplete="current-password">
<input id="authPhone" placeholder="手机号(仅注册用),如 13800138000">
<button id="btnLogin" class="btn btn-primary">登录</button>
<button id="btnRegister" class="btn">注册</button>
</div>
<div id="authResult" class="result"></div>
</section>
<!-- ============ 设备管理 ============ -->
<section id="deviceSection" class="panel hidden">
<h2>绑定设备</h2>
<div class="form-row">
<input id="bindSn" placeholder="设备 SN如 SN-DEMO-001">
<button id="btnBind" class="btn btn-primary">绑定设备</button>
</div>
<div id="bindResult" class="result"></div>
<h2>我的设备</h2>
<button id="btnListDevices" class="btn">刷新设备列表</button>
<div id="devicesResult" class="result"></div>
</section>
<!-- ============ 短信 / 恢复授权 ============ -->
<section id="recoverSection" class="panel hidden">
<h2>恢复授权(出厂重置后重新取回照片)</h2>
<p class="hint">流程:设备端恢复出厂后生成 <b>新设备公钥</b> → 在此填入 SN + 新公钥 → 发送短信验证码 → 恢复授权。</p>
<div class="form-row">
<input id="recoverSn" placeholder="设备 SN如 SN-DEMO-001">
<input id="recoverPhone" placeholder="绑定手机号,如 13800138000">
<button id="btnSendSms" class="btn">发送验证码</button>
</div>
<div class="form-row">
<input id="recoverNewPubKey" placeholder="新设备公钥 Base64从设备端复制">
<input id="recoverSmsCode" placeholder="短信验证码demo 固定 000000">
<button id="btnRecover" class="btn btn-primary">恢复授权</button>
</div>
<div id="recoverResult" class="result"></div>
</section>
<!-- ============ 照片 ============ -->
<section id="photoSection" class="panel hidden">
<h2>我的照片(密文存储,需登录后下载解密)</h2>
<button id="btnListPhotos" class="btn">刷新照片列表</button>
<div id="photosResult" class="result"></div>
<div class="form-row">
<input id="decryptPhotoId" placeholder="photoId如 photo-001">
<button id="btnDecrypt" class="btn btn-primary">下载并解密</button>
</div>
<div id="decryptResult" class="result"></div>
</section>
<!-- ============ 原始响应展示 ============ -->
<section id="rawSection" class="panel">
<h2>最近一次 API 响应(统一格式 code/message/data</h2>
<pre id="rawOutput" class="raw">尚未发起请求</pre>
</section>
</main>
<script src="app.js"></script>
</body>
</html>

111
web-client/style.css Normal file
View File

@@ -0,0 +1,111 @@
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: #f0f4f8;
color: #1f2937;
min-height: 100vh;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 32px;
background: linear-gradient(135deg, #0f766e, #115e59);
color: #fff;
}
.topbar h1 { font-size: 20px; font-weight: 600; }
.user-info { display: flex; align-items: center; gap: 12px; }
main {
max-width: 900px;
margin: 24px auto;
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.panel {
background: #fff;
border-radius: 12px;
padding: 20px 24px;
box-shadow: 0 1px 3px rgba(15, 23, 42, .08);
}
.panel h2 {
font-size: 16px;
margin-bottom: 12px;
color: #0f766e;
border-left: 4px solid #0f766e;
padding-left: 10px;
}
.hint { font-size: 13px; color: #6b7280; margin-bottom: 14px; line-height: 1.6; }
.form-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 12px;
}
input {
flex: 1 1 180px;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 14px;
outline: none;
transition: border-color .15s;
}
input:focus { border-color: #0f766e; }
.btn {
padding: 10px 18px;
border: 1px solid #d1d5db;
border-radius: 8px;
background: #fff;
color: #374151;
font-size: 14px;
cursor: pointer;
transition: all .15s;
white-space: nowrap;
}
.btn:hover { border-color: #0f766e; color: #0f766e; }
.btn-primary { background: #0f766e; border-color: #0f766e; color: #fff; }
.btn-primary:hover { background: #115e59; color: #fff; }
.btn-sm { padding: 6px 12px; font-size: 13px; }
.result {
margin-top: 10px;
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
}
.result.ok { color: #047857; }
.result.err { color: #b91c1c; }
.raw {
background: #0f172a;
color: #a5f3fc;
border-radius: 8px;
padding: 14px;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
max-height: 320px;
overflow-y: auto;
}
.hidden { display: none; }