fix(update): 修复云端安装交接并完善发布验收
修复隐藏缓存目录下载和响应收尾,使用原生助手校验安装包并确认交接。 补齐真实静默升级、便携版及中文路径验收,并按现行设备和科目授权契约更新回归。 服务端安装时生成独立密钥,覆盖升级保留既有配置;补齐安装包副本验证说明。
This commit is contained in:
1 parent
66be3a63b1
commit
71cc8eb605
19 files changed
+1424
-91
No files matched your search
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Web.Script.Serialization;
|
||||
|
||||
// 独立 WinExe:从 updates 目录运行,安装器覆盖应用时不会锁住安装目录。
|
||||
// 只接收主进程生成的清单;不接受任意命令或安装参数。
|
||||
internal static class UpdateHelper
|
||||
{
|
||||
public sealed class Manifest
|
||||
{
|
||||
public string Attempt;
|
||||
public int ParentId;
|
||||
public string ParentExe;
|
||||
public string Installer;
|
||||
public string RestartExe;
|
||||
public string Version;
|
||||
public string ServerUrl;
|
||||
public string Username;
|
||||
public string Sha256;
|
||||
public long Size;
|
||||
public bool Portable;
|
||||
}
|
||||
|
||||
private static readonly JavaScriptSerializer Json = new JavaScriptSerializer();
|
||||
private static readonly Encoding Utf8 = new UTF8Encoding(false);
|
||||
private static string directory;
|
||||
private static string prefix;
|
||||
private static Manifest manifest;
|
||||
private static Process parent;
|
||||
private static bool installerRunning;
|
||||
|
||||
[STAThread]
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (args.Length != 1) return 2;
|
||||
var file = Path.GetFullPath(args[0]);
|
||||
directory = Path.GetDirectoryName(file);
|
||||
if (!String.Equals(Path.GetFileName(directory), "updates", StringComparison.OrdinalIgnoreCase)) return 2;
|
||||
manifest = Json.Deserialize<Manifest>(File.ReadAllText(file, Utf8));
|
||||
if (manifest == null || !Regex.IsMatch(manifest.Attempt ?? "", "^[0-9a-f-]{36}$")) return 2;
|
||||
prefix = Path.Combine(directory, "install-" + manifest.Attempt);
|
||||
if (!SamePath(file, prefix + ".json")) return 2;
|
||||
Log("助手启动,目标版本 " + manifest.Version);
|
||||
parent = Process.GetProcessById(manifest.ParentId);
|
||||
if (!SamePath(parent.MainModule.FileName, manifest.ParentExe)) throw new Exception("旧进程身份不匹配");
|
||||
var installer = Path.GetFullPath(manifest.Installer);
|
||||
if (!SamePath(Path.GetDirectoryName(installer), directory) ||
|
||||
!String.Equals(Path.GetExtension(installer), ".exe", StringComparison.OrdinalIgnoreCase) ||
|
||||
(File.GetAttributes(installer) & FileAttributes.ReparsePoint) != 0)
|
||||
throw new Exception("安装包不在受管理目录内");
|
||||
if (!Regex.IsMatch(manifest.Sha256 ?? "", "^[0-9a-fA-F]{64}$")) throw new Exception("缺少有效安装包摘要");
|
||||
if (manifest.Size <= 0 || new FileInfo(installer).Length != manifest.Size) throw new Exception("安装包长度不匹配");
|
||||
using (var stream = File.OpenRead(installer))
|
||||
using (var hash = SHA256.Create())
|
||||
{
|
||||
var actual = BitConverter.ToString(hash.ComputeHash(stream)).Replace("-", "");
|
||||
if (!String.Equals(actual, manifest.Sha256, StringComparison.OrdinalIgnoreCase)) throw new Exception("安装包 SHA256 校验失败");
|
||||
}
|
||||
File.WriteAllText(prefix + ".ready", manifest.Attempt, Utf8);
|
||||
Log("校验通过,等待主进程确认交接");
|
||||
var deadline = DateTime.UtcNow.AddSeconds(30);
|
||||
while (!File.Exists(prefix + ".start"))
|
||||
{
|
||||
if (File.Exists(prefix + ".cancel")) { Log("主进程取消交接"); return 0; }
|
||||
if (parent.HasExited) throw new Exception("主进程在确认交接前退出");
|
||||
if (DateTime.UtcNow >= deadline) throw new Exception("等待主进程确认超时");
|
||||
Thread.Sleep(50);
|
||||
}
|
||||
if (File.ReadAllText(prefix + ".start", Utf8) != manifest.Attempt) throw new Exception("交接标识不匹配");
|
||||
if (!parent.WaitForExit(60000)) throw new Exception("等待旧客户端退出超时");
|
||||
Log("旧进程已退出,开始安装");
|
||||
int exitCode;
|
||||
using (var installerProcess = Process.Start(new ProcessStartInfo(installer, "/S --updated --force-run")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = directory
|
||||
}))
|
||||
{
|
||||
if (installerProcess == null) throw new Exception("安装器未能启动");
|
||||
installerRunning = true;
|
||||
if (!installerProcess.WaitForExit(10 * 60 * 1000)) throw new Exception("安装器执行超时,请查看安装日志");
|
||||
installerRunning = false;
|
||||
exitCode = installerProcess.ExitCode;
|
||||
}
|
||||
Log("安装器退出码:" + exitCode);
|
||||
if (exitCode != 0) throw new Exception("安装器退出码:" + exitCode);
|
||||
if (!manifest.Portable)
|
||||
{
|
||||
var expected = ParseVersion(manifest.Version);
|
||||
var actual = ParseVersion(FileVersionInfo.GetVersionInfo(manifest.RestartExe).ProductVersion);
|
||||
if (actual.CompareTo(expected) < 0) throw new Exception("安装器结束后客户端版本仍未更新");
|
||||
// 安装器通常会启动新版;未启动时补启一次,避免留在“安装中”。
|
||||
var startedAt = DateTime.UtcNow;
|
||||
while (!IsClientRunning(manifest.RestartExe) && DateTime.UtcNow - startedAt < TimeSpan.FromSeconds(3)) Thread.Sleep(100);
|
||||
if (!IsClientRunning(manifest.RestartExe)) RestartClient();
|
||||
}
|
||||
Result("installed", "", exitCode);
|
||||
Log("安装交接完成,等待新版客户端心跳确认");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
try { Log("更新失败:" + error.Message); Result("failed", error.Message, null); } catch { }
|
||||
// 超时的安装器可能仍在替换文件,不可在它退出之前重新打开客户端。
|
||||
try { if (!installerRunning && parent != null && parent.HasExited && manifest != null && File.Exists(manifest.RestartExe) && !IsClientRunning(manifest.RestartExe)) RestartClient(); }
|
||||
catch (Exception restoreError) { try { Log("恢复客户端失败:" + restoreError.Message); } catch { } }
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (prefix != null) foreach (var suffix in new[] { ".ready", ".start", ".cancel", ".json" })
|
||||
try { File.Delete(prefix + suffix); } catch { }
|
||||
if (parent != null) parent.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SamePath(string a, string b)
|
||||
{
|
||||
return String.Equals(Path.GetFullPath(a), Path.GetFullPath(b), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static Version ParseVersion(string value)
|
||||
{
|
||||
Version version;
|
||||
if (!Version.TryParse((value ?? "").Trim().TrimStart('v', 'V').Split('-', '+')[0], out version)) throw new Exception("无法识别客户端版本:" + value);
|
||||
return version;
|
||||
}
|
||||
|
||||
private static bool IsClientRunning(string exe)
|
||||
{
|
||||
foreach (var process in Process.GetProcessesByName(Path.GetFileNameWithoutExtension(exe)))
|
||||
{
|
||||
using (process) try { if (!process.HasExited && SamePath(process.MainModule.FileName, exe)) return true; } catch { }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void RestartClient()
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(manifest.RestartExe, "--hidden")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(manifest.RestartExe)
|
||||
});
|
||||
Log("已启动客户端");
|
||||
}
|
||||
|
||||
private static void Log(string message)
|
||||
{
|
||||
if (directory != null) File.AppendAllText(Path.Combine(directory, "install-helper.log"), DateTime.UtcNow.ToString("o") + " " + message + Environment.NewLine, Utf8);
|
||||
}
|
||||
|
||||
private static void Result(string stage, string error, int? exitCode)
|
||||
{
|
||||
if (manifest == null || prefix == null) return;
|
||||
var value = new { version = manifest.Version, serverUrl = manifest.ServerUrl, username = manifest.Username, attempt = manifest.Attempt, stage = stage, error = error, installerExitCode = exitCode, at = DateTime.UtcNow.ToString("o") };
|
||||
var target = Path.Combine(directory, "install-result.json");
|
||||
var temp = prefix + ".result";
|
||||
File.WriteAllText(temp, Json.Serialize(value), Utf8);
|
||||
if (File.Exists(target)) File.Delete(target);
|
||||
File.Move(temp, target);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,10 @@
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { build } from 'esbuild';
|
||||
import { buildUpdateHelper } from './build-update-helper.mjs';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
buildUpdateHelper(path.join(root, 'dist/main/update-helper.exe'));
|
||||
|
||||
const shared = {
|
||||
bundle: true,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
/** Windows 自带 .NET Framework 编译器;构建产物随客户端分发,不在教室机上编译。 */
|
||||
export function buildUpdateHelper(output) {
|
||||
if (process.platform !== 'win32') return;
|
||||
const framework = path.join(
|
||||
process.env.SystemRoot ?? 'C:\\Windows',
|
||||
'Microsoft.NET',
|
||||
'Framework64',
|
||||
'v4.0.30319',
|
||||
);
|
||||
const compiler = path.join(framework, 'csc.exe');
|
||||
if (!fs.existsSync(compiler)) throw new Error('缺少 Windows .NET Framework 编译器,无法构建更新助手');
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
execFileSync(
|
||||
compiler,
|
||||
[
|
||||
'/nologo',
|
||||
'/target:winexe',
|
||||
'/optimize+',
|
||||
'/platform:anycpu',
|
||||
`/reference:${path.join(framework, 'System.Web.Extensions.dll')}`,
|
||||
`/out:${output}`,
|
||||
fileURLToPath(new URL('../native/update-helper.cs', import.meta.url)),
|
||||
],
|
||||
{ stdio: 'pipe', windowsHide: true },
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { BrowserWindow, app } from 'electron';
|
||||
import {
|
||||
isNewerVersion,
|
||||
@@ -15,6 +14,7 @@ import { getConfig, saveConfig } from './config.js';
|
||||
import { island } from './island.js';
|
||||
import { logger } from './logger.js';
|
||||
import { connectionEvents } from './config-events.js';
|
||||
import { prepareUpdateInstaller } from './update-installer.js';
|
||||
|
||||
let updateEpoch = 0;
|
||||
let downloadAbort = new AbortController();
|
||||
@@ -43,15 +43,17 @@ connectionEvents.on('changed', () => {
|
||||
* 空闲安装要求不在上课且主窗口隐藏满五分钟;
|
||||
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
|
||||
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
|
||||
* 3. **安装脚本脱离本进程**:PowerShell 等旧进程退出、安装器完成后重新拉起应用,
|
||||
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
|
||||
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
|
||||
* 3. **独立安装助手**:助手就绪后才退出旧进程,由助手执行安装并确认新版启动;
|
||||
* 助手从更新目录运行,不锁住正在被安装器替换的应用文件。
|
||||
*/
|
||||
|
||||
let policy:
|
||||
| (Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> & {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
updateSha256: string;
|
||||
updateSize: number;
|
||||
updateFileName: string;
|
||||
})
|
||||
| null = null;
|
||||
function policyPath(): string {
|
||||
@@ -107,6 +109,9 @@ export function syncUpdatePolicy(next: ClientHeartbeatResponse): void {
|
||||
updateTargetVersion: next.updateTargetVersion,
|
||||
updateMode: next.updateMode ?? 'idle',
|
||||
updateNotBefore: next.updateNotBefore ?? null,
|
||||
updateSha256: next.update?.version === next.updateTargetVersion ? next.update.sha256 : '',
|
||||
updateSize: next.update?.version === next.updateTargetVersion ? next.update.size : 0,
|
||||
updateFileName: next.update?.version === next.updateTargetVersion ? next.update.fileName : '',
|
||||
};
|
||||
fs.mkdirSync(pendingUpdateDir(), { recursive: true });
|
||||
fs.writeFileSync(policyPath(), JSON.stringify(policy), 'utf8');
|
||||
@@ -458,6 +463,14 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
|
||||
}
|
||||
const active = loadPolicy();
|
||||
if (!active || active.updateTargetVersion !== version) return;
|
||||
if (
|
||||
!/^[0-9a-f]{64}$/i.test(active.updateSha256 ?? '') ||
|
||||
active.updateSize <= 0 ||
|
||||
active.updateFileName !== path.basename(installer)
|
||||
) {
|
||||
logger.warn(`更新 ${version} 尚无有效的服务端摘要,等待心跳重新确认安装包`);
|
||||
return;
|
||||
}
|
||||
if (active.updateMode === 'after-school') {
|
||||
const at = Date.parse(active.updateNotBefore ?? '');
|
||||
if (!Number.isFinite(at) || Date.now() < at) return;
|
||||
@@ -520,33 +533,29 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
|
||||
logger.info(`开始静默安装更新 ${version}:${installer}`);
|
||||
try {
|
||||
const exe = process.env.PORTABLE_EXECUTABLE_FILE || app.getPath('exe');
|
||||
const quote = (value: string): string => "'" + value.replaceAll("'", "''") + "'";
|
||||
const config = getConfig();
|
||||
const resultFile = path.join(pendingUpdateDir(), 'install-result.json');
|
||||
// 上一次失败的结果不能冒充这一次:安装开始时先清掉,只有本轮失败才重新写入。
|
||||
fs.rmSync(resultFile, { force: true });
|
||||
const script =
|
||||
`$ErrorActionPreference = 'Stop'; try { Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` +
|
||||
`$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -WindowStyle Hidden -Wait -PassThru; ` +
|
||||
`if ($installer.ExitCode -ne 0) { throw ('安装器退出码:' + $installer.ExitCode) } ` +
|
||||
`} catch { @{ version=${quote(version)}; serverUrl=${quote(config.serverUrl)}; username=${quote(config.username)}; error=$_.Exception.Message } | ConvertTo-Json -Compress | Set-Content -LiteralPath ${quote(resultFile)} -Encoding UTF8; ` +
|
||||
`if (Test-Path -LiteralPath ${quote(exe)}) { Start-Process -FilePath ${quote(exe)} -WindowStyle Hidden } }`;
|
||||
const encoded = Buffer.from(script, 'utf16le').toString('base64');
|
||||
const child = spawn(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', encoded],
|
||||
{
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.once('spawn', resolve);
|
||||
child.once('error', reject);
|
||||
const helper = await prepareUpdateInstaller({
|
||||
directory: pendingUpdateDir(),
|
||||
installer,
|
||||
version,
|
||||
sha256: active.updateSha256,
|
||||
size: active.updateSize,
|
||||
serverUrl: config.serverUrl,
|
||||
username: config.username,
|
||||
restartExe: exe,
|
||||
portable: Boolean(process.env.PORTABLE_EXECUTABLE_FILE),
|
||||
});
|
||||
child.unref();
|
||||
// 安装器从实际安装目录启动新版;免安装版的旧 exe 可能随退出消失,只在安装失败时尝试恢复它。
|
||||
if (
|
||||
installEpoch !== updateEpoch ||
|
||||
loadPolicy()?.updateTargetVersion !== version ||
|
||||
getConfig().autoUpdate === false
|
||||
) {
|
||||
helper.cancel();
|
||||
installing = false;
|
||||
return;
|
||||
}
|
||||
helper.commit();
|
||||
logger.info(`更新 ${version} 的独立安装助手已就绪,确认退出旧客户端`);
|
||||
if (quitHandler) {
|
||||
quitHandler();
|
||||
} else {
|
||||
@@ -555,6 +564,8 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
|
||||
}
|
||||
} catch (error) {
|
||||
installing = false;
|
||||
if (installEpoch !== updateEpoch) return;
|
||||
installationRetryAt = Date.now() + 5 * 60_000;
|
||||
publishProgress({ stage: 'failed', error: String(error).slice(0, 1000) });
|
||||
logger.error(
|
||||
`启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`,
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
interface InstallerOptions {
|
||||
directory: string;
|
||||
installer: string;
|
||||
version: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
restartExe: string;
|
||||
portable: boolean;
|
||||
}
|
||||
|
||||
/** 先确认独立助手就绪;调用方复核策略后 commit,再退出应用。 */
|
||||
export async function prepareUpdateInstaller(options: InstallerOptions) {
|
||||
const attempt = crypto.randomUUID();
|
||||
const prefix = path.join(options.directory, `install-${attempt}`);
|
||||
const helper = path.join(options.directory, `updater-${attempt}.exe`);
|
||||
const resultFile = path.join(options.directory, 'install-result.json');
|
||||
const logFile = path.join(options.directory, 'install-helper.log');
|
||||
fs.mkdirSync(options.directory, { recursive: true });
|
||||
for (const name of fs.readdirSync(options.directory)) {
|
||||
if (/^updater-[0-9a-f-]{36}\.exe$/i.test(name)) {
|
||||
try {
|
||||
fs.rmSync(path.join(options.directory, name));
|
||||
} catch {
|
||||
/* 仍在运行的助手留待下次清理 */
|
||||
}
|
||||
}
|
||||
}
|
||||
fs.copyFileSync(path.join(__dirname, 'update-helper.exe'), helper);
|
||||
fs.writeFileSync(
|
||||
`${prefix}.json`,
|
||||
JSON.stringify({
|
||||
Attempt: attempt,
|
||||
ParentId: process.pid,
|
||||
ParentExe: process.execPath,
|
||||
Installer: options.installer,
|
||||
RestartExe: options.restartExe,
|
||||
Version: options.version,
|
||||
ServerUrl: options.serverUrl,
|
||||
Username: options.username,
|
||||
Sha256: options.sha256,
|
||||
Size: options.size,
|
||||
Portable: options.portable,
|
||||
}),
|
||||
'utf8',
|
||||
);
|
||||
fs.rmSync(resultFile, { force: true });
|
||||
fs.writeFileSync(logFile, `${new Date().toISOString()} 准备安装 ${options.version}\n`, 'utf8');
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(helper, [`${prefix}.json`], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
cwd: options.directory,
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => finish(new Error('更新助手就绪超时,请查看 install-helper.log')),
|
||||
15_000,
|
||||
);
|
||||
const poll = setInterval(() => {
|
||||
try {
|
||||
if (fs.readFileSync(`${prefix}.ready`, 'utf8').trim() === attempt) finish();
|
||||
} catch {
|
||||
/* 尚未就绪 */
|
||||
}
|
||||
}, 50);
|
||||
const onError = (error: Error) => finish(error);
|
||||
const onExit = (code: number | null) => {
|
||||
let detail = '';
|
||||
try {
|
||||
detail = JSON.parse(fs.readFileSync(resultFile, 'utf8')).error ?? '';
|
||||
} catch {
|
||||
/* 原生进程未能启动 */
|
||||
}
|
||||
finish(new Error(detail || `更新助手提前退出(${code ?? '未知退出码'}),请查看 install-helper.log`));
|
||||
};
|
||||
function finish(error?: Error) {
|
||||
clearTimeout(timeout);
|
||||
clearInterval(poll);
|
||||
child.removeListener('error', onError);
|
||||
child.removeListener('exit', onExit);
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
}
|
||||
child.once('error', onError);
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
child.unref();
|
||||
} catch (error) {
|
||||
fs.writeFileSync(`${prefix}.cancel`, attempt, 'utf8');
|
||||
throw error;
|
||||
}
|
||||
return {
|
||||
commit: () => fs.writeFileSync(`${prefix}.start`, attempt, 'utf8'),
|
||||
cancel: () => fs.writeFileSync(`${prefix}.cancel`, attempt, 'utf8'),
|
||||
};
|
||||
}
|
||||
@@ -1624,11 +1624,23 @@ async function main() {
|
||||
stateNote: '待机',
|
||||
};
|
||||
|
||||
// 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 JWT 一律 401
|
||||
const deviceLogin = await api('/auth/class-login', {
|
||||
method: 'POST',
|
||||
body: { code: classes.find((item) => item.id === classId)?.code, password: '123456' },
|
||||
});
|
||||
const deviceToken = deviceLogin.payload?.data?.token;
|
||||
if (!deviceToken) throw new Error('隔离班级设备登录失败,不能用员工身份替代');
|
||||
const staffHeartbeat = await api('/clients/heartbeat', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: heartbeatBase,
|
||||
});
|
||||
record('员工会话不能冒充班级设备上报', staffHeartbeat.status === 403, 'status=' + staffHeartbeat.status);
|
||||
// 只有本班 CLASS_DEVICE 可以上报和回执;管理端仍用管理员读取及下发。
|
||||
const heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase });
|
||||
const heartbeat = await api('/clients/heartbeat', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
token: deviceToken,
|
||||
body: heartbeatBase,
|
||||
});
|
||||
const heartbeatData = heartbeat.payload?.data;
|
||||
@@ -1675,7 +1687,7 @@ async function main() {
|
||||
});
|
||||
const heartbeatWithCommand = await api('/clients/heartbeat', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
token: deviceToken,
|
||||
body: { ...heartbeatBase, stateNote: '待机' },
|
||||
});
|
||||
const delivered = heartbeatWithCommand.payload?.data?.command;
|
||||
@@ -1688,13 +1700,13 @@ async function main() {
|
||||
const ackBody = { deviceKey, ok: true, result: 'e2e' };
|
||||
const ackFirst = await api(`/clients/commands/${delivered?.id}/ack`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
token: deviceToken,
|
||||
body: ackBody,
|
||||
});
|
||||
// 幂等:客户端重试回执不报错、也不覆盖第一次结果
|
||||
const ackSecond = await api(`/clients/commands/${delivered?.id}/ack`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
token: deviceToken,
|
||||
body: { deviceKey, ok: false, result: '重试不应覆盖结果' },
|
||||
});
|
||||
record(
|
||||
@@ -1716,13 +1728,13 @@ async function main() {
|
||||
const otherDeviceKey = `${deviceKey}-other`;
|
||||
const otherHeartbeat = await api('/clients/heartbeat', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
token: deviceToken,
|
||||
body: { ...heartbeatBase, deviceKey: otherDeviceKey, name: 'E2E 另一台测试机' },
|
||||
});
|
||||
const otherDeviceId = otherHeartbeat.payload?.data?.deviceId ?? null;
|
||||
const ackWrongDevice = await api(`/clients/commands/${delivered?.id}/ack`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
token: deviceToken,
|
||||
body: { deviceKey: otherDeviceKey, ok: true, result: 'e2e-other' },
|
||||
});
|
||||
record(
|
||||
@@ -2496,13 +2508,14 @@ async function main() {
|
||||
// 8.3.1 科目归属越权回归(修复"非该科科任老师可以布置/导入其他科内容"):
|
||||
// ① 新建成同名课程必须被拒绝 —— 否则谁都能借"新建科目"把自己写成该科任课老师,
|
||||
// 后面那道 assertCanManageSubjectContent 的科目门随即失效;
|
||||
// ② 班主任导入**别人任教科目**的成绩必须逐行拒绝(原先只判到班级这一层)。
|
||||
// ② 班主任导入**别人任教科目**的成绩必须拒绝(原先只判到班级这一层)。
|
||||
{
|
||||
const guardedCourseName = `越权校验科目${Date.now().toString(36).slice(-5).toUpperCase()}`;
|
||||
const otherTeacher = await api('/auth/me', { token: teacher2Token });
|
||||
const firstCourse = await api('/courses', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { classId, name: guardedCourseName },
|
||||
body: { classId, name: guardedCourseName, teacherId: otherTeacher.payload?.data?.id },
|
||||
});
|
||||
const firstCourseId = firstCourse.payload?.data?.id;
|
||||
record(
|
||||
@@ -2524,8 +2537,7 @@ async function main() {
|
||||
`status=${duplicateCourse.status} code=${duplicateCourse.payload?.code} details=${JSON.stringify(duplicateCourse.payload?.details ?? {})}`,
|
||||
);
|
||||
|
||||
// 这门课的任课老师是**管理员**(course.create 会把创建者写成任课老师):
|
||||
// 班主任导入它的成绩必须被拒,且不能落库。
|
||||
// 显式配置另一位教师任课;班主任身份不能代替科目写权限。
|
||||
const guardedCsv = [
|
||||
'学号,考试名称,分数,总分,科目',
|
||||
`${targetStudent?.studentNo ?? '202601'},越权校验考试,88,100,${guardedCourseName}`,
|
||||
@@ -2548,14 +2560,12 @@ async function main() {
|
||||
mode: 'append',
|
||||
},
|
||||
});
|
||||
const guardedErrors = guardedImport.payload?.data?.errors ?? [];
|
||||
const guardedRows = await api('/grades?classId=' + classId, { token: adminToken });
|
||||
record(
|
||||
'班主任导入他人任教科目成绩被逐行拒绝(不落库)',
|
||||
guardedImport.status === 200 &&
|
||||
guardedImport.payload?.data?.inserted === 0 &&
|
||||
guardedImport.payload?.data?.failed === 1 &&
|
||||
String(guardedErrors[0]?.message ?? '').includes('不是你任教的科目'),
|
||||
`新增=${guardedImport.payload?.data?.inserted} 失败=${guardedImport.payload?.data?.failed} 提示=${guardedErrors[0]?.message ?? '-'}`,
|
||||
'班主任不能导入他人任教科目成绩,拒绝后不落库',
|
||||
guardedImport.status === 403 &&
|
||||
!(guardedRows.payload?.data ?? []).some((item) => item.examName === '越权校验考试'),
|
||||
'status=' + guardedImport.status + ' message=' + guardedImport.payload?.message,
|
||||
);
|
||||
|
||||
if (firstCourseId) {
|
||||
@@ -2588,7 +2598,7 @@ async function main() {
|
||||
);
|
||||
|
||||
// 8.5 需求 6:表格导入在"老师端"可用且不越权
|
||||
// 班主任 → 本班成绩可预览/导入;科任 → 403;班主任 → 名单导入仍 403(人员管理仅管理员)
|
||||
// 授课教师可导入本人科目;他人科目与班级范围之外仍拒绝,名单导入另走人员管理权限。
|
||||
const teacherGradesPreview = await api('/imports/table/preview', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
@@ -2628,32 +2638,66 @@ async function main() {
|
||||
`status=${teacherGradesCommit.status} 新增=${teacherGradesCommit.payload?.data?.inserted} 更新=${teacherGradesCommit.payload?.data?.updated} 失败=${teacherGradesCommit.payload?.data?.failed}`,
|
||||
);
|
||||
|
||||
// teacher2 是2025级3班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景
|
||||
const subjectOnlyClass = classes.find((item) => item.name?.includes('2025级3')) ?? null;
|
||||
// 科任可以导入本人任教科目,不因没有班主任身份被拒。
|
||||
const subjectOnlyClass = classes.find((item) => item.name?.includes('2025级3'));
|
||||
if (!subjectOnlyClass) throw new Error('隔离科任班级缺失');
|
||||
const subjectIdentity = await api('/auth/me', { token: teacher2Token });
|
||||
const subjectCourses = await api('/courses?classId=' + subjectOnlyClass.id, { token: adminToken });
|
||||
const subjectCourse = (subjectCourses.payload?.data ?? []).find(
|
||||
(item) => item.teacherId === subjectIdentity.payload?.data?.id,
|
||||
);
|
||||
const subjectStudents = await api('/students?classId=' + subjectOnlyClass.id, { token: adminToken });
|
||||
const subjectStudent = subjectStudents.payload?.data?.[0];
|
||||
if (!subjectCourse || !subjectStudent) throw new Error('隔离科任导入数据缺失');
|
||||
const subjectExam = '科任导入验证' + Date.now();
|
||||
const subjectCsv = [
|
||||
'学号,考试名称,分数,总分,科目',
|
||||
[subjectStudent.studentNo, subjectExam, 88, 100, subjectCourse.name].join(','),
|
||||
].join(NL);
|
||||
const subjectBody = {
|
||||
kind: 'grades',
|
||||
classId: subjectOnlyClass.id,
|
||||
fileName: 'subject.csv',
|
||||
contentBase64: Buffer.from('\ufeff' + subjectCsv + NL, 'utf8').toString('base64'),
|
||||
mapping: {
|
||||
studentNo: '学号',
|
||||
examName: '考试名称',
|
||||
score: '分数',
|
||||
totalScore: '总分',
|
||||
courseName: '科目',
|
||||
},
|
||||
mode: 'append',
|
||||
};
|
||||
const subjectGradesImport = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: teacher2Token,
|
||||
body: {
|
||||
kind: 'grades',
|
||||
classId: subjectOnlyClass?.id ?? classId,
|
||||
fileName: 'grades.csv',
|
||||
contentBase64: gradesCsvBase64,
|
||||
mapping: {
|
||||
studentNo: '学号',
|
||||
name: '姓名',
|
||||
examName: '考试名称',
|
||||
score: '分数',
|
||||
totalScore: '总分',
|
||||
courseName: '科目',
|
||||
},
|
||||
mode: 'upsert',
|
||||
},
|
||||
body: subjectBody,
|
||||
});
|
||||
record(
|
||||
'科任老师导入本班成绩被拒绝(403,需求 7)',
|
||||
Boolean(subjectOnlyClass) && subjectGradesImport.status === 403,
|
||||
`班级=${subjectOnlyClass?.name ?? '未找到2025级3班'} status=${subjectGradesImport.status}`,
|
||||
'科任老师可导入本人授课科目成绩',
|
||||
subjectGradesImport.status === 200 && subjectGradesImport.payload?.data?.inserted === 1,
|
||||
'status=' + subjectGradesImport.status,
|
||||
);
|
||||
const otherSubject = (subjectCourses.payload?.data ?? []).find(
|
||||
(item) => item.teacherId !== subjectIdentity.payload?.data?.id,
|
||||
);
|
||||
if (!otherSubject) throw new Error('隔离越权科目缺失');
|
||||
const deniedCsv = [
|
||||
'学号,考试名称,分数,总分,科目',
|
||||
[subjectStudent.studentNo, subjectExam + '越权', 88, 100, otherSubject.name].join(','),
|
||||
].join(NL);
|
||||
const deniedImport = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: teacher2Token,
|
||||
body: {
|
||||
...subjectBody,
|
||||
contentBase64: Buffer.from('\ufeff' + deniedCsv + NL, 'utf8').toString('base64'),
|
||||
},
|
||||
});
|
||||
record('科任老师仍不能导入他人任教科目成绩', deniedImport.status === 403, 'status=' + deniedImport.status);
|
||||
const subjectRows = await api('/grades?classId=' + subjectOnlyClass.id, { token: adminToken });
|
||||
for (const row of subjectRows.payload?.data ?? [])
|
||||
if (row.examName === subjectExam) await api('/grades/' + row.id, { method: 'DELETE', token: adminToken });
|
||||
|
||||
if (foreignClass) {
|
||||
const crossGradesImport = await api('/imports/table/commit', {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { assertClassDevice, assertPermission, classIdsFor } from '../../lib/authz/index.js';
|
||||
import { getReleaseProgress, setReleaseProgress } from './release-progress.js';
|
||||
import { Router } from 'express';
|
||||
import path from 'node:path';
|
||||
import { sendCreated, sendOk, ApiError } from '../../lib/http.js';
|
||||
import {
|
||||
authenticate,
|
||||
@@ -132,7 +133,7 @@ router.delete(
|
||||
router.get(
|
||||
'/client/download/:version',
|
||||
validate({ params: clientReleaseVersionParamSchema }),
|
||||
async (req, res) => {
|
||||
async (req, res, next) => {
|
||||
const user = getAuthUser(req);
|
||||
if (user.role === 'CLASS_DEVICE') assertClassDevice(user, user.classId ?? user.sub);
|
||||
else {
|
||||
@@ -143,12 +144,25 @@ router.get(
|
||||
const { version } = validatedParams<{ version: string }>(req);
|
||||
const found = await clientReleases.resolveReleaseFile(version);
|
||||
if (!found) throw ApiError.notFound('服务端没有缓存这个版本的安装包');
|
||||
res.download(found.file, found.fileName, (error) => {
|
||||
if (error) {
|
||||
// 客户端中途取消下载也会走到这里,只记日志,不要往已发出的响应里再写东西
|
||||
res.locals.downloadError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
});
|
||||
res.download(
|
||||
path.basename(found.file),
|
||||
found.fileName,
|
||||
{ root: path.dirname(found.file), dotfiles: 'allow' },
|
||||
(error) => {
|
||||
if (!error || res.destroyed) return;
|
||||
if (res.headersSent) {
|
||||
res.destroy(error);
|
||||
return;
|
||||
}
|
||||
// 文件可能在解析元数据后被移除;尚未发送响应时必须结束请求,不能让客户端一直等待。
|
||||
const code = (error as NodeJS.ErrnoException).code;
|
||||
next(
|
||||
code === 'ENOENT' || code === 'ENOTDIR' || code === 'EISDIR'
|
||||
? ApiError.notFound('安装包文件不存在或无法读取,请重新缓存')
|
||||
: error,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user