feat: 重构为前后端分离架构并完善设备端演示
- 新增 Web 用户端(登录体系 + 统一 REST API 调用) - 后端增加用户认证、统一 ApiResponse、CORS 支持 - Android 设备端迁移至 MVVM + DataBinding + Retrofit 网络层 - 完善 README 架构说明与密码学原理文档 - 新增 .gitignore 与持久化数据表说明
This commit is contained in:
253
web-client/app.js
Normal file
253
web-client/app.js
Normal file
@@ -0,0 +1,253 @@
|
||||
// ============================================================
|
||||
// Secure Device 用户端(Web)—— 前后端分离演示
|
||||
//
|
||||
// 后端地址:默认 localhost:8080(CORS 已放开)。
|
||||
// 认证:登录/注册后获得 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
85
web-client/index.html
Normal 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
111
web-client/style.css
Normal 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; }
|
||||
Reference in New Issue
Block a user