逆向一个在线考试系统的前端反作弊机制，从分析到绕过
手痒逆向了一个女朋友老师自己搭建的考试系统，本人鄙视这种限制学生粘贴的行为🤣。本文记录了对两个版本的前端反作弊机制进行逆向分析与绕过的完整过程，最终通过编写 Tampermonkey 油猴脚本，实现了对两个版本所有反作弊机制的完全绕过，并额外实现了本地 Python 代码执行、下载任意学号报告等扩展功能。一、系统架构概述该考试系统采用前后端分离架构：┌─────────────────┐ ┌──────────────────┐ │ 前端 (HTML/JS) │ HTTP │ 后端 (Flask) │ │ Port 80 / 81 │ ◄─────► │ Port 5105 / 5106 │ │ Monaco Editor │ │ Python 3.10 │ └─────────────────┘ └──────────────────┘考试1：前端 :80，后端 :5105，基础反调试考试2：前端 :81，后端 :5106，升级版反调试两个版本共享相似的代码结构，但考试2在安全层面做了大量增强。后端基于 Werkzeug/Flask，提供题目获取、代码运行、批改、提交等 RESTful API。二、考试1逆向分析2.1 debugger 循环炸弹考试1的核心反调试手段是 debugger 循环。当开发者工具处于打开状态时，debugger 语句会导致 JavaScript 引擎暂停执行，配合计时就能判断工具是否打开：function detectDevToolsComprehensive() { // 学号 101242805（老师）不做检测 const studentId = localStorage.getItem('student_id'); if (studentId === "101242805") return false; const start = performance.now(); for (let i = 0; i < 100000; i++) { debugger; // 关键：如果 DevTools 打开，每次都会暂停 } const debuggerTime = performance.now() - start; if (debuggerTime > 5000) { // 正常执行远小于 50ms detected = true; } }原理分析：当 DevTools 关闭时，debugger 语句被忽略，10 万次循环几乎瞬间完成当 DevTools 打开时，每个 debugger 都会触发断点暂停，总耗时远超 5 秒⚠️ 注意：这里硬编码了老师的学号 **101242805 作为白名单。这个信息后面会被利用。2.2 计时器漂移检测第二道防线是 计时器漂移检测。页面有一个考试计时器，每秒通过 setInterval 更新 DOM。同时每 3 秒检查一次页面计时器的显示值是否与系统真实流逝时间一致：function detectDevToolsByTimer() { const timerText = document.getElementById('timer').textContent; const match = timerText.match(/考试计时[：:](\d+):(\d+)/); const totalTimerSeconds = parseInt(match[1]) * 60 + parseInt(match[2]); const expectedElapsed = Math.floor((Date.now() - startTime) / 1000); const timeDiff = Math.abs(totalTimerSeconds - expectedElapsed); if (timeDiff > 3) { // 允许 ±3 秒误差 handleDevToolsDetection(`计时器异常 (差值: ${timeDiff}秒)`); } }原理：如果 debugger 循环卡住了主线程，setInterval 回调会被延迟，导致页面显示的时间比系统实际流逝的时间慢。差值超过 3 秒就判定异常。2.3 综合检测调度中心所有检测通过一个调度函数统一启动：function setupCompleteProtection() { if (!isTopWindow()) { handleIframeEmbedding(); // 防止 iframe 嵌入 return; } setupVisibilityDetection(); // Page Visibility API setupFocusDetection(); // window blur/focus setInterval(detectDevToolsComprehensive, 3000); // 每 3 秒一次 debugger 检测 setInterval(detectDevToolsByTimer, 3000); // 每 3 秒一次计时器漂移检测 setTimeout(detectDevToolsByTimer, 5000); // 首次延迟 5 秒检测 }检测触发后的处置逻辑是分级制：次数处置第 1 次myAlert 警告第 2 次及以上showBlockingOverlay() 黑色全屏遮罩，阻止一切操作2.4 DOM 级别的防护除了 JavaScript 检测之外，还有 DOM 属性级别的防护：document.oncontextmenu = () => false; // 禁止右键 document.oncopy = () => false; // 禁止复制以及在 editorDiv 上直接绑定的 keydown 拦截器：editorDiv.addEventListener('keydown', e => { if ((e.ctrlKey && (e.key === 'v' || e.key === 'c')) || (e.shiftKey && e.key === 'Insert')) { e.preventDefault(); myAlert('⚠️ 当前考试禁止复制粘贴！'); } });三、考试2逆向分析考试2在考试1的基础上进行了全面升级（可能求助了豆包^\_^），新增了 7 项检测/防护机制。3.1 服务端 Token 验证考试1只检查 localStorage 里有没有 student_id，没有任何后端验证。考试2加入了 Token 机制：async function verifyLogin() { const sid = localStorage.getItem("student_id"); const token = localStorage.getItem("token"); const res = await fetch(`${BASE_URL}/verify_login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ student_id: sid, token: token }) }); const data = await res.json(); if (!data.valid) { localStorage.clear(); location.href = "login.html"; // 踢回登录页 } } verifyLogin(); // 页面加载即执行Token 的格式为 学号:时间戳:随机盐:HMAC签名，例如：24016666:1774455505:2567e28a458782ed:050665574339ba89f8a0aed36374536c4ada082da3aaec52a8eb4765bc8943583.2 窗口最大化 + 焦点检测这是考试2最显著的新增机制。每 500ms 检查一次窗口是否处于最大化状态：function checkExamStatus() { // 检测是否最大化（允许 20px 误差） const isMaximized = Math.abs(window.screen.availWidth - window.outerWidth) < 20 && Math.abs(window.screen.availHeight - window.outerHeight) < 20; // 检测当前窗口是否拥有焦点 const isFocused = document.hasFocus(); if (!isMaximized || !isFocused) { document.getElementById("warningLayer").style.display = "flex"; // 显示红色全屏警告遮罩 } } setInterval(checkExamStatus, 500); window.addEventListener('resize', checkExamStatus);warningLayer 是一个预置在 HTML 中的全屏红色遮罩层： 3.3 违规计数器考试1检测到异常直接封锁，没有宽限。考试2引入了分级违规计数器：let violationCount = parseInt(localStorage.getItem('violationCount') || 0); const MAX_VIOLATIONS = 3; function handleViolation(reason) { violationCount++; localStorage.setItem('violationCount', violationCount); if (violationCount >= MAX_VIOLATIONS) { myAlert(`请保持考试页面为顶层页面！`); } else { myAlert(`警告：${reason}！当前违规次数：${violationCount}/${MAX_VIOLATIONS}`); } } window.onblur = () => handleViolation("尝试切换窗口或离开考场");关键发现：违规计数存储在 localStorage 中，然而并不会上报后端。所有违规处置逻辑都是纯前端的，没有任何 fetch 调用将违规记录发送到服务器。3.4 自定义弹窗替换原生 alert考试1使用原生 alert()，很容易通过 window.alert = () => {} 拦截。考试2改用了自定义的 DOM 弹窗：function myAlert(msg) { const modal = document.getElementById("customAlert"); document.getElementById("myAlertBody").innerHTML = `考生 ID: ${getSid()} ${msg}`; modal.style.display = "flex"; }这意味着简单地覆盖 window.alert 无法拦截考试2的警告弹窗，必须同时拦截 window.myAlert。3.5 拖拽锁屏在每个编辑器区域 editorDiv 上绑定了 drop 事件监听器。一旦触发拖拽操作，立即创建黑色全屏遮罩并禁用所有交互：editorDiv.addEventListener('drop', (e) => { e.preventDefault(); const block = document.createElement('div'); block.style = `position:fixed; top:0; left:0; width:100%; height:100%; background:black; color:white; z-index:99999; ...`; block.innerHTML = '🚫 违规操作！页面已锁定 ...'; document.body.appendChild(block); // 禁用一切交互 document.addEventListener('click', e => e.preventDefault(), true); document.addEventListener('keydown', e => e.preventDefault(), true); });这种设计的意图是阻止学生通过拖拽文件到编辑器来导入代码。3.6 编辑器粘贴限制与行宽检测除了普通的 Ctrl+V / Shift+Insert 拦截，考试2还增加了行字符宽度检测：const charLimitPerLine = 80; function getStringWidth(str) { let width = 0; for (let i = 0; i < str.length; i++) { if (str.charAt(i).match(/[^\x00-\xff]/)) { width += 2; // 中文字符算 2 宽度 } else { width += 1; } } return width; } function handlePaste(e) { const pasteData = e.clipboardData.getData('text'); const pasteWidth = getStringWidth(pasteData); if (currentLineWidth + pasteWidth > charLimitPerLine) { e.preventDefault(); myAlert(`粘贴内容会导致行超过 ${charLimitPerLine} 字符宽度限制`); } }3.7 fetch() 全局劫持考试2最高级的防护是劫持 window.fetch，对所有发往后端的请求自动注入认证信息：const originalFetch = window.fetch; window.fetch = async function (url, options = {}) { if (typeof url === 'string' && url.includes(BASE_URL)) { options.credentials = "include"; // 自动注入认证头 const headers = { ...createAuthHeaders(), // Authorization, X-Auth-Token, X-Student-ID ...options.headers }; options.headers = headers; // 自动往 body 里注入 student_id 和 token if (options.method === 'POST' && options.body) { const bodyObj = JSON.parse(options.body); bodyObj.student_id = bodyObj.student_id || localStorage.getItem('student_id'); bodyObj.token = bodyObj.token || localStorage.getItem('token'); options.body = JSON.stringify(bodyObj); } } const response = await originalFetch.call(this, url, options); if (response.status === 401) { // 自动登出 location.href = 'login.html'; } return response; };这意味着如果我们想用不同的 student_id 调用 API（比如下载别的同学或老师的报告），必须绕过这个劫持。四、油猴脚本编写4.1 注入时机：document-start 的重要性整个绕过方案的基石是 @run-at document-start：// ==UserScript== // @name 考试系统反调试绕过 // @version 2.0 // @match http://47.100.117.38/* // @match http://47.100.117.38:81/* // @match http://47.100.117.38:5105/* // @match http://47.100.117.38:5106/* // @grant none // @run-at document-start // ==/UserScript==document-start 确保我们的脚本在页面的任何 标签执行之前就已经运行。这让我们有机会重写 setInterval、setTimeout、Function 等全局对象，拦截后续所有的反调试代码。4.2 保存原始原语在任何页面代码执行之前，先保存原始的底层 API 引用：const _origFetch = window.fetch.bind(window); const _origSetInterval = window.setInterval; const _origSetTimeout = window.setTimeout; const _origAlert = window.alert;为什么要这么做？_origSetInterval / _origSetTimeout ：我们要重写这些函数来过滤检测回调，但我们自己的代码（比如周期性清理遮罩层）仍需要使用原始版本_origFetch ：考试2会劫持 window.fetch 自动注入 student_id，当我们想用老师学号下载报告时，需要绕过这个劫持_origAlert ：我们要拦截检测弹窗，但脚本自己的错误提示仍需要弹窗4.3 Function 构造器劫持拦截通过 new Function() 或 eval() 动态生成的 debugger 语句：const _origFunction = window.Function; window.Function = function (...args) { if (args.length > 0) { const body = args[args.length - 1]; if (typeof body === 'string' && body.includes('debugger')) { args[args.length - 1] = body.replace(/debugger/g, ''); } } return _origFunction.apply(this, args); }; window.Function.prototype = _origFunction.prototype;注意最后一行，必须保留 prototype 链，否则 instanceof Function 等检查会失败，可能导致页面其他功能异常。4.4 setInterval / setTimeout 过滤器通过重写 setInterval 和 setTimeout，在回调注册时就过滤掉所有检测函数：const blockedKeywords = [ 'detectDevTools', 'detectDevToolsComprehensive', 'detectDevToolsByTimer', 'checkExamStatus', // 考试2 新增 'handleViolation', // 考试2 新增 ]; function shouldBlock(callback) { if (typeof callback === 'function') { const src = callback.toString(); // 获取函数源码 return blockedKeywords.some(kw => src.includes(kw)); } if (typeof callback === 'string') { return blockedKeywords.some(kw => callback.includes(kw)); } return false; } window.setInterval = function (callback, delay, ...args) { if (shouldBlock(callback)) { console.log(`[反反调试] 已拦截 setInterval`); return -1; // 返回假 ID，不实际注册 } return _origSetInterval.call(window, callback, delay, ...args); };关键技术点：callback.toString() 可以获取函数的完整源码字符串。即使回调是匿名函数，只要其函数体中包含检测函数的调用（如 detectDevToolsComprehensive），就会被过滤。4.5 全局函数覆盖即使 setInterval/setTimeout 拦截器能挡住新注册的定时器，但有些检测函数是通过 window.addEventListener 绑定的（如 resize 事件触发 checkExamStatus）。对此，采用直接覆盖函数 + 持续清除策略：const functionsToKill = [ 'detectDevToolsComprehensive', 'detectDevToolsByTimer', 'handleDevToolsDetection', 'showBlockingOverlay', 'setupCompleteProtection', 'setupFocusDetection', 'setupVisibilityDetection', 'handleIframeEmbedding', 'checkExamStatus', 'handleViolation', ]; function killDetectors() { functionsToKill.forEach(name => { if (typeof window[name] === 'function') { window[name] = () => false; // 替换为 noop } }); window.onblur = null; // 清除 blur 事件 // 持续清除 warningLayer const warningLayer = document.getElementById('warningLayer'); if (warningLayer) { warningLayer.style.display = 'none'; } localStorage.setItem('violationCount', '0'); // 重置违规计数 } killDetectors(); _origSetTimeout.call(window, killDetectors, 500); _origSetTimeout.call(window, killDetectors, 2000); _origSetTimeout.call(window, killDetectors, 5000); // 每秒持续覆盖 _origSetInterval.call(window, killDetectors, 1000);为什么要持续覆盖？ 因为 document-start 阶段页面 DOM 还没加载完，全局函数也还没定义。需要在函数被定义后立刻覆盖它们。多次延迟执行 + 周期性执行确保无论页面脚本在何时定义这些函数，都能在极短时间内被替换。4.6 DOM 事件拦截链恢复复制粘贴功能的关键在于理解 DOM 事件的捕获/冒泡模型： ┌── document (capture) ──┐ │ 我们的拦截器在这里 │ │ stopImmediatePropagation() ▼ │ ┌── editorDiv (bubble) ──┐ │ │ 页面的拦截器在这里 │ │ │ 永远不会执行 ✅ │ │ └────────────────────────┘ │ └────────────────────────┘// 在 document 的 capture 阶段（第三个参数 true）拦截 document.addEventListener('paste', (e) => { showToast(mockMessages[Math.random() * mockMessages.length | 0]); e.stopImmediatePropagation(); // 阻止后续所有同事件监听器 }, true); // ← capture 阶段 document.addEventListener('keydown', function (e) { // 拦截 Ctrl+C/V/X 和 Shift+Insert if (e.ctrlKey && (e.key === 'c' || e.key === 'v' || e.key === 'x')) { e.stopImmediatePropagation(); } if (e.shiftKey && e.key === 'Insert') { e.stopImmediatePropagation(); } }, true);同时清除属性级别的事件绑定：document.body.oncontextmenu = null; document.oncontextmenu = null; document.oncopy = null; // 考试2 的属性绑定 document.oncut = null;审计发现：考试2除了 Ctrl+V 还检测了 Shift+Insert，初始版本的脚本遗漏了这一点。document.oncopy = () => false 是属性赋值而非 addEventListener，stopImmediatePropagation 对它无效，必须显式清除。4.7 myAlert 的守株待兔式拦截考试2的 myAlert 在页面脚本中定义，可能在我们的 onReady 回调执行时还不存在。为此使用 Object.defineProperty 实现守株待兔式拦截：const alertBlockKeywords = [ '违规操作', '开发者工具', '计时异常', '禁止复制粘贴', '检测到', '窗口非最大化', '切屏', '离开页面', ]; // 如果 myAlert 还没定义，提前布下陷阱 let _myAlertFn = null; Object.defineProperty(window, 'myAlert', { get() { return _myAlertFn; }, set(fn) { // 页面脚本定义 myAlert 时，我们立即包裹它 _myAlertFn = function (msg) { if (typeof msg === 'string' && alertBlockKeywords.some(kw => msg.includes(kw))) { console.log(`[反反调试] 已拦截 myAlert: "${msg}"`); return; // 吞掉检测弹窗 } return fn.call(this, msg); // 放行正常弹窗 }; }, configurable: true, });原理：利用 Object.defineProperty 的 setter 拦截对 window.myAlert 的赋值操作。当页面脚本执行 function myAlert(msg) {...} 时，实际上会触发我们的 setter，我们在 setter 中用过滤逻辑包装了原始函数。4.8 warningLayer 持续闪现warningLayer 每 500ms 被 checkExamStatus 唤醒一次。虽然我们已经覆盖了 checkExamStatus，但 resize 事件的监听器引用的是原始函数（闭包引用），在覆盖前就已绑定。因此采用双保险策略：函数覆盖，每秒nop掉 window.checkExamStatusDOM 清除，每秒强制隐藏 warningLayer// 在 killDetectors 函数中（每秒执行）： const warningLayer = document.getElementById('warningLayer'); if (warningLayer) { warningLayer.style.display = 'none'; warningLayer.style.visibility = 'hidden'; }最坏情况下，warningLayer 可能闪现一帧（\~1ms）后就被隐藏。4.9 遮罩层清理器对于已经生成的遮罩层（如 showBlockingOverlay 创建的黑色遮罩、拖拽锁屏遮罩），通过定时扫描移除：function removeOverlays() { document.querySelectorAll('div').forEach(el => { const s = el.style; if ((s.zIndex === '99999' || s.zIndex === '10000') && !el.dataset.antiantiKeep) { // 保护自己注入的元素 el.remove(); } }); } _origSetInterval.call(window, removeOverlays, 2000);脚本注入的 UI 元素通过 data-antianti-keep="true" 标记来免受清理。五、扩展功能实现5.1 本地 Python 执行器考试系统的代码运行功能有服务端次数限制。为了不受限制地调试代码，直接在浏览器中集成了 一个编译成 WebAssembly 的 CPython 解释器Pyodide：let pyodideInstance = null; async function loadPyodide() { if (pyodideInstance) return pyodideInstance; const script = document.createElement('script'); script.src = 'https://cdn.jsdelivr.net/pyodide/v0.25.1/full/pyodide.js'; // ... 加载后初始化 pyodideInstance = await window.loadPyodide({ indexURL: 'https://cdn.jsdelivr.net/pyodide/v0.25.1/full/' }); }遇到的坑和解决方案：input() 函数：Pyodide 没有标准输入。解决方案是用浏览器 prompt() 模拟：builtins.input = lambda prompt_msg="": _js_prompt(str(prompt_msg))Windows \r\n 换行符：Monaco 编辑器在 Windows 上会产生 \r\n，导致 Python 解析器报 SyntaxError。需要在执行前统一替换为 \n。eval_code 解析器 bug：Pyodide 自带的 eval_code 在解析包含 if/else 块的代码时会报 SyntaxError。最终改用 exec(compile(code, "<user>", "exec")) 绕过。缩进修复：中文全角空格（\u3000）、BOM 标记（\uFEFF）等都可能破坏缩进。编写了 normalizePythonCode() 和 repairPythonBlockStructure() 两个函数来自动修复。5.2 下载任意同学和老师的报告通过审计发现，/download_report 接口未校验请求者身份与目标学号是否一致。只要有合法 token，就能下载任意学号的报告：// 用我们保存的 _origFetch 绕过页面的 fetch 劫持 // （页面的 fetch 会把 student_id 强制替换为当前登录者的学号） const res = await _origFetch(`${baseUrl}/download_report`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ student_id: targetStudentId, token }), });脚本注入了两个额外的下载按钮：下载别的同学报告：弹出输入框填写学号下载老师的报告：使用硬编码的老师学号 1012428055.3 无反作弊前端的搭建更进一步，可以直接搭建一个完全无反作弊限制的前端网站，对接同一个后端 API。CORS 测试：curl -X OPTIONS "http://47.100.117.38:5106/problems" \ -H "Origin: https://sb.teacher.com" \ -H "Access-Control-Request-Method: POST"返回结果显示后端使用了动态 CORS 镜像策略，会原样反射请求者的 Origin：Access-Control-Allow-Origin: https://sb.teacher.com Access-Control-Allow-Methods: DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT这意味着任何域名都可以直接调用后端 API，无需反代。但有一个坑：原版代码中的 credentials: "include" 会要求后端返回 Access-Control-Allow-Credentials: true，而后端并没有配置这个头。解决方案是将 credentials 改为 omit，这样认证完全通过 header 和 body 中的 token 传递，不需要 cookie。六、后端 API 安全审计完整 API 列表与安全问题：接口方法认证安全问题/loginPOST❌初始密码 = 学号，可枚举/problemsGET❌完全匿名可访问，无需登录/runPOST✅正常/gradePOST✅正常/download_reportPOST✅未校验 student\_id 与 token 所有者的关系/verify_loginPOST✅正常/submit_allPOST✅正常核心安全问题：后端不鉴权：/download_report 只验证 token 合法性，不验证 token 对应的用户与请求的 student_id 是否一致，导致任意用户可以下载他人报告。CORS 过于宽松：后端对所有 Origin 返回 Allow，等同于 *，允许任意第三方网站调用 API。所有反作弊措施仅在前端：违规计数、窗口检测、粘贴禁止——全部是前端逻辑，没有任何一项会上报后端。老师无法从服务端日志中发现学生是否使用了开发者工具或进行了违规操作。七、总结防护机制触发方式绕过手段debugger 循环炸弹setInterval + debugger重写 Function，过滤 setInterval回调计时器漂移检测setInterval(detectDevToolsByTimer)过滤 setInterval回调窗口最大化检测setInterval(checkExamStatus, 500)过滤回调 + 覆盖函数 + 隐藏 warningLayer违规计数器window.onblur清除 onblur+ 重置 localStorage禁止复制粘贴editorDiv.addEventListener('keydown/paste')document capture 阶段 stopImmediatePropagationdocument.oncopy属性赋值document.oncopy = null自定义弹窗 myAlert函数调用Object.defineProperty守株待兔拖拽锁屏editorDiv.addEventListener('drop')document capture 阶段 stopImmediatePropagation全屏遮罩层showBlockingOverlay()定时扫描并移除 z-index: 99999的 divfetch()劫持重写 window.fetch在 document-start 保存 _origFetchiframe 嵌入检测window.self !== window.top覆盖 handleIframeEmbedding 为空函数八、附件全部打包到蓝奏云了
浅析左、右外连接筛选出主表中不满足连接条件的行
[scode type="blue" size=""]在学习多表查询时许多人仅仅停留在对左外、右外和满外连接的运用上就止步，但这是十分基础的。实际上研究重点应放在左外和右外连接衍生出来的两个变式上，此文章仅仅是个人拙见，并不全面[/scode]🚩知识背景预设SQL语句中的左外查询和右外查询(左上图和右上图)是很常见的操作，故在此不多赘述，但基于左外查询和右外查询衍生出来的两种变式并不易于理解，如下图的左中和右中所示：以左中图为例，这是基于左外连接变化而来的情况，想要得到它可以分为如下步骤：使用左外连接，以A为主表，B为从表使用 WHERE子句过滤掉A表与B表的内连接(即A与B的交集)又有如下公式通解可用：SELECT 字段列表 FROM A表 LEFT JOIN B表 ON 关联条件 WHERE 从表关联字段 IS NULL;我们要探讨的问题便是[font color="#D64E41"]公式通解中的WHERE子句为何判断的是从表的关联字段，而非其他[/font]🎯实际问题引入在了解完知识背景后，我们通过一个实际案例来引入问题0️⃣数据源我们有两张表，一张表为 employees表，另一张表为 departments表employees表：存放员工的员工ID、姓名和部门ID等信息departments表：存放部门的部门ID和部门名称等信息我们首先来查看 employees表的表结构：+----------------+-------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +----------------+-------------+------+-----+---------+-------+ | employee_id | int(6) | NO | PRI | 0 | | | first_name | varchar(20) | YES | | NULL | | | last_name | varchar(25) | NO | | NULL | | | email | varchar(25) | NO | UNI | NULL | | | phone_number | varchar(20) | YES | | NULL | | | hire_date | date | NO | | NULL | | | job_id | varchar(10) | NO | MUL | NULL | | | salary | double(8,2) | YES | | NULL | | | commission_pct | double(2,2) | YES | | NULL | | | manager_id | int(6) | YES | MUL | NULL | | | department_id | int(4) | YES | MUL | NULL | | +----------------+-------------+------+-----+---------+-------+由 employees表结构可知，每一位员工都有其对应的 employee_id(员工ID)和 department_id(部门ID)及其他数据然而，employees表中有一位特殊的员工，他由于刚入职还没有部门，他的员工ID是178，我们暂且叫他菜鸟SELECT employee_id,department_id FROM employees WHERE department_id IS NULL; /*+-------------+---------------+ | employee_id | department_id | +-------------+---------------+ | 178 | NULL | +-------------+---------------+*/再来观察 departments表，还是老样子先来看看它的表结构：+-----------------+-------------+------+-----+---------+-------+ | Field | Type | Null | Key | Default | Extra | +-----------------+-------------+------+-----+---------+-------+ | department_id | int(4) | NO | PRI | 0 | | | department_name | varchar(30) | NO | | NULL | | | manager_id | int(6) | YES | MUL | NULL | | | location_id | int(4) | YES | MUL | NULL | | +-----------------+-------------+------+-----+---------+-------+仔细观察 employees表和 departments表，不难发现：我们可以通过 department_id这个字段来将两张表建立起连接来查询出每位员工的 department_name(部门名称)，有如下语句：SELECT emp.employee_id,emp.department_id,dept.department_name FROM employees emp INNER JOIN departments dept ON emp.department_id = dept.department_id; /*+-------------+---------------+------------------+ | employee_id | department_id | department_name | +-------------+---------------+------------------+ | 200 | 10 | Administration | | 201 | 20 | Marketing | | 202 | 20 | Marketing | | 114 | 30 | Purchasing | | 115 | 30 | Purchasing | ...还有更多就不复制了*/这是一个标准的内连接，通过内连接我们很轻松地将几乎每位员工的部门名称都查询了出来然而，由于菜鸟没有部门ID，也就不满足连接条件，这次查询的结果集中并没有他的记录1️⃣需求升级有一天，BOSS需要查看[font color="#D64E41"]所有[/font]员工的部门名称，并且即使某些员工没有部门(说的就是菜鸟)也要显示在结果集里这时我们就需要拿出左外连接来实现BOSS的需求：SELECT emp.employee_id,emp.department_id,dept.department_name FROM employees emp LEFT JOIN departments dept ON emp.department_id = dept.department_id; /*+-------------+---------------+------------------+ | employee_id | department_id | department_name | +-------------+---------------+------------------+ | 178 | NULL | NULL | | 200 | 10 | Administration | | 201 | 20 | Marketing | | 202 | 20 | Marketing | ...还有更多就不复制了*/可以看到使用左外连接成功搞定了BOSS的需求，这就是左外连接的应用2️⃣需求再升级说了对内连接和左外连接不再赘述，还是说了那么多，究其原因是为接下来做铺垫，进入文章的核心又有一天，BOSS心血来潮，说他就想要看看到底是谁还没有部门名称，这就进入我们的主题了我们在数据源那部分得知，由于菜鸟刚刚入职，他还没有被分到具体的部门，因此他的 department_id字段为 NULL这就好办了，我们只需要再加一个 WHERE子句，把刚才左外查询结果集中的 department_id字段为 NULL的筛选出来就好，由此可得以下语句：SELECT emp.employee_id,emp.department_id,dept.department_name FROM employees emp LEFT JOIN departments dept ON emp.department_id = dept.department_id WHERE emp.department_id IS NULL; /*+-------------+---------------+-----------------+ | employee_id | department_id | department_name | +-------------+---------------+-----------------+ | 178 | NULL | NULL | +-------------+---------------+-----------------+*/然而，这与我们开头知识背景中提到的公式通解相悖，在公式中的WHERE子句是这样的：WHERE 从表关联字段 IS NULL我们使用的 WHERE emp.department_id IS NULL明明是主表中的关联字段，为何也跑出了相同的结果呢？是否是公式有误呢？🔍问题深入剖析1️⃣问题所在在刚才的例子中，虽然使用的是 WHERE emp.department_id IS NULL也跑出了看似正确的结果集，但这样是[font color="#D64E41"]歪打正着，错误的！[/font][scode type="yellow" size="small"]⚠️ 我们需要清楚的是：不仅仅是department_id为NULL的员工没有部门名称，还有另外一种情况也同样没有部门名称[/scode]department_id为 NULL的刚入职的新员工(比如菜鸟就是这种类型的)employees表中这位员工的 department_id有数据，但在 departments表中却没有对应的 department_id与之相等，也就是该员工有部门ID，但该部门ID下却没有部门对于第二种情况，举一个例子。有一位员工暂且叫他倒霉蛋。他的部门ID是999，但由于部门调整，他的部门已经不存在了但他还没有被分配到新的部门。此时他在 employees表中的 department_id仍是999，但在 departments表中却没有与999对应的部门名称了+-------------+---------------+ | employee_id | department_id | +-------------+---------------+ | 400 | 999 | +-------------+---------------+因此，面对第二种情况，WHERE emp.department_id IS NULL就没办法把倒霉蛋也查询出来了，我们就需要使用正确的公式进行查询：SELECT emp.employee_id,emp.department_id,dept.department_name FROM employees emp LEFT JOIN departments dept ON emp.department_id = dept.department_id WHERE dept.department_id IS NULL; /*+-------------+---------------+-----------------+ | employee_id | department_id | department_name | +-------------+---------------+-----------------+ | 178 | NULL | NULL | | 200 | 999 | NULL | +-------------+---------------+-----------------+*/2️⃣公式原理逻辑分析可以看到，我们自作聪明写的 WHERE emp.department_id IS NULL并没有办法覆盖到全部情况，那公式让我们通过筛选从表关联字段是否为NULL来达到要求，又是怎么实现的呢？这就要从左外连接的特性说起，它除了把符合连接条件的行加入到结果集中，还把左表也就是主表中不符合连接条件的记录加入了进去然而，左表中不符合连接条件的记录进是进去了，但是右表中也不知道找什么与它对应呀，毕竟不符合连接条件，于是就用 NULL填充由如下语句我们可以直观感受出左外连接的特性：SELECT emp.employee_id,emp.department_id,dept.department_name,dept.location_id,dept.manager_id FROM employees emp LEFT JOIN departments dept ON emp.department_id = dept.department_id WHERE dept.department_id IS NULL; /*+-------------+---------------+------------------+-------------+------------+ | employee_id | department_id | department_name | location_id | manager_id | +-------------+---------------+------------------+-------------+------------+ | 178 | NULL | NULL | NULL | NULL | | 400 | 999 | NULL | NULL | NULL | +-------------+---------------+------------------+-------------+------------+可以看到，菜鸟那一行中凡是来自 departments表[font color="#D64E41"]（从表）[/font]中的字段，全部都被设为了 NULL，倒霉蛋也是如此[scode type="green" size=""]💡 因此，如果我们想达到在连接两张表时筛选出A - (A∩B)的效果，可以在 WHERE从句中筛选 从表关联字段是否为 NULL[/scode]🚀公式进阶与变体我们由刚才的公式原理逻辑分析得知，在左外连接下凡是不满足连接条件的记录中来自从表中的字段全部都设为了NULL，我们可不可以对公式进行改写？我个人的见解：[font color="#D64E41"]可以，但是最好不要[/font]-- 此为改写后的公式,将从表关联字段改为了从表中的任意字段 SELECT 字段列表 FROM A表 LEFT JOIN B表 ON 关联条件 WHERE 从表中任意字段 IS NULL;我们现在运用改写后的公式再跑一下刚才的需求，得知：SELECT emp.employee_id,emp.department_id,dept.department_name FROM employees emp LEFT JOIN departments dept ON emp.department_id = dept.department_id WHERE dept.location_id IS NULL; /*+-------------+---------------+-----------------+ | employee_id | department_id | department_name | +-------------+---------------+-----------------+ | 178 | NULL | NULL | | 200 | 999 | NULL | +-------------+---------------+-----------------+*/从结果集来看，改写后的公式也可以应对需求，也完全正确可以实际应用，但我个人还是不建议这么做，有如下原因：使用从表关联字段更加保险：我们在连接两张表中使用的就是从表关联字段，证明从表中一定存在从表关联字段且一定可用从表中的其他字段可能不存在：其他字段可能在从表里并不存在，而从表关联字段是一定存在的，如果连从表关联字段都不存在了，那早就连接不起来了更加易读：使用从表中的其他字段可能出现"魔法值"的情况，让其他读你写的SQL语句的人很懵逼，不知道你到底要干什么
思源笔记+Alist打造免费跨平台云端笔记
前置需求支持WebDAV的Alist V2/V3或其他WebDAV支持WebDAV同步方式的思源笔记版本(iOS/Linux/Windows/Android/Docker皆可)Alist挂载的网盘的WebDAV必须具有可写属性挂载任意可写盘到Alist根目录注意：由于思源笔记的WebDAV设置目录功能只读到根目录，所以必须将可写盘的文件夹目录挂载到Alist根目录举例：如果要将阿里云盘中的 \文本\思源笔记云端同步目录作为思源同步的文件夹，则需要在Alist根目录挂载阿里云盘，并将根文件夹ID填写为该目录的ID 63c0bba729ec4b7520474404bb410a2e0f6d5fef开启WebDAV代理Alist V2：开启 webdav proxy 功能Alist V3：WebDAV 策略 - 本地代理注：这一步不一定每个人都要开，可以先跳过这一步先做下面的，如果同步失败的话再回来开启代理设置思源笔记设置 - 云端 - 云端存储服务提供商 - WebDAV - 按照下面的表格填写项目填写内容Endpointhttp://你的服务器IP:5244/dav/Username同Alist用户名(V2用户在设置 - 前端 查看)Password同Alist密码(V2用户在设置 - 前端 查看)Timeout (s)默认30即可TLS Verify推荐Skip，如果选择验证的话同步失败后换回Skip然后点击 云端同步目录 - 设置，选择刚刚挂载好的思源同步目录即可总结至此已经完成了思源笔记通过WebDAV的云端同步设置，接下来在各个平台的思源笔记上分别设置一遍即可完成各平台基于Alist的跨平台同步
记思源笔记无法正常保存文档内容的解决过程
[scode type="blue" size="simple"]此文章仅适用于Docker版思源笔记且使用的是宝塔付费版并启用了Nginx防火墙[/scode]起因花了一个多小时用思源笔记编辑了一篇不错的笔记，但等到关闭文档后想再打开看看时却发现根本没有保存！首先排查的是思源笔记内的设置，发现 自动保存历史记录 这一功能是打开的，但却没有正常保存记录然后又将Docker容器进行重启，但仍旧无法保存没有效果。最后将目光锁定在容器程序在保存时向指定路径提交POST请求进行保存时出了错误，才开始排查防火墙。原因Nginx防火墙在拦截恶意POST请求时误拦截了思源笔记的保存函数路径 /api/transactions，由于传值时函数会将Markdown语法整个传入，导致了一部分Markdown被误判解决方法将 /api/transactions 路径加入防火墙白名单即可总结由于使用Docker版思源笔记的人数较少且同时使用宝塔付费Nginx防火墙的概率太小，如果遇到这种问题可以这样解决，也不枉我搭进去两篇心血笔记T T
问题WPF的MessageBox看上去像XP系统的风格，有种老系统的味道。但WPF作为微软的新一代技术，为何在MessageBox上反而不如同根同源而更古早的Winform？[column][block size="45%"][/block][block size="45%"][/block][/column]原因WPF控件的绘制方式和普通程序比如Winform不一样，它是采用 DirectX 进行绘制的，而 MessageBox 是在调用Windows API，所以 MessageBox 没有采用 DirectX 进行绘制，因此我们没能在MessageBox 上看到这种视觉样式。解决仔细观察Winform程序的Main方法会发现，方法中会调用一个方法去启用视觉样式Application.EnableVisualStyles();而对于WPF程序而言，可以采用 应用程序清单文件 来解决此问题在项目中添加一个应用程序清单文件然后进入清单文件，找到如下代码： <!-- <dependency> <dependentAssembly> <assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*" /> </dependentAssembly> </dependency> -->将注释解除后重新Bulid即可解决此问题附记如果重新Bulid后没有改变，在项目属性的Debug选项卡下面，找到Enable Visual Studio hosting process，把前面的勾去掉即可！
以下教程均以Linux宝塔面板为例，Windows Server需在远程桌面配合CMD和桌面端Docker使用环境要求Linux发行版或Windows Server符合Docker安装环境安装Docker管理器宝塔面板 - 软件商店 - Docker管理器 3.9.1 - 安装拉取镜像并启动在终端输入以下指令：拉取镜像docker pull teddysun/kms启动容器docker run -d -p 1688:1688 --name kms --restart=always teddysun/kms开放安全组端口在服务商安全组和宝塔面板的防火墙处开放TCP端口号1688检查PING情况打开PING网站，输入 你的服务器IP:1688 然后点击 Go 按钮等待后如果能全部Ping通显示Successful则表示KMS服务器已搭建成功检查Docker运行情况宝塔面板 - Docker - 容器查看容器名为kms的容器状态是否为已启动，已启动则为正常大功告成如果上述步骤都已经完成且没有异常，KMS服务器则已大功告成！
通过WebDAV像本地硬盘一样操作阿里云盘
[scode type="blue" size=""]此教程仅针对Windows系统而作，Linux系统推荐使用Docker[/scode]前言目前CSDN、知乎等通过WebDAV挂接阿里云盘可大致分为两类：利用Docker拉取zx5253大神的WebDAV镜像或是部署webdav-aliyundriver的jar包。奈何前者需要安装Docker，体积过于庞大有时还需要额外安装WSL 2才能正常跑起Docker且zx5253的WebDAV镜像源目前已被删除，暂无可靠的替换源。因此有了此篇文章，不需要Docker或Java知识即可轻松挂载阿里云盘。所用项目aliyundriver-webdav-software-uialiyundrive-webdav下载图形界面下载 aliyundriver-webdav-software-ui 并解压到文件夹，以待备用。[button color=#00BFFF icon="fa fa-github fa-lg" url="https://github.com/gylang/aliyundriver-webdav-software-ui"]Github[/button] [button color="info" icon="fa fa-cloud-download fa-lg" url="https://wwp.lanzoub.com/iVOQQ0au55ri"]蓝奏云盘[/button] [button color="primary" icon="fa fa-opera fa-lg" url="https://cloud.xn--e-5g8az75bbi3a.com/%E9%98%BF%E9%87%8C%E4%BA%91%E7%9B%98/%E8%BD%AF%E4%BB%B6/%E9%98%BF%E9%87%8C%E4%BA%91%E6%8C%82%E6%8E%A5/aliyundriver-webdav-software-ui-master.zip"]阿里云盘[/button] [button color="danger" icon="fa fa-buysellads fa-lg" url="https://mega.nz/file/RpIE1azL#NJxcANzMvICo3ljsxQ4D22E87BXMYIk3cqHtTjD1Utg"]MEGA(海外)[/button]下载阿里云盘WebDAV下载 aliyundrive-webdav-v1.10.1.x86_64-pc-windows-msvc ，将下载好的阿里云WebDAV压缩包中的 [font color="#00BFFF"]aliyundrive-webdav.exe[/font] 放入解压好的图形界面根目录的 [font color="#00BFFF"]bin[/font] 文件夹中，然后运行[font color="#FF0000"]根目录[/font]中的 [font color="#00BFFF"]aliyundriver-webdav.exe[/font]。[button color=#00BFFF icon="fa fa-github fa-lg" url="https://github.com/messense/aliyundrive-webdav/releases"]Github[/button] [button color="info" icon="fa fa-cloud-download fa-lg" url="https://wwp.lanzoub.com/iVeYj0au59ch"]蓝奏云盘[/button] [button color="primary" icon="fa fa-opera fa-lg" url="https://cloud.xn--e-5g8az75bbi3a.com/%E9%98%BF%E9%87%8C%E4%BA%91%E7%9B%98/%E8%BD%AF%E4%BB%B6/%E9%98%BF%E9%87%8C%E4%BA%91%E6%8C%82%E6%8E%A5/aliyundrive-webdav-v1.10.1.x86_64-pc-windows-msvc.zip"]阿里云盘[/button] [button color="danger" icon="fa fa-buysellads fa-lg" url="https://mega.nz/file/UthHRYSD#tWUV3hFr4hujfbKVq9eK0iwOkj0d6DuQtQKgiVVlfdc"]MEGA(海外)[/button][scode type="yellow" size=""]此处的两个 [font color="#00BFFF"]aliyundriver-webdav.exe[/font] 虽然同名，但WebDAV压缩包中的exe仅放置在图形界面中的bin目录即可，图形界面中的exe是需要运行的，切忌搞混淆！[/scode]图形界面中的设置填写设置开机自启webdav登录账号webdav登录密码监听端口阿里云盘refreshToken建议开启(管理员运行)自行设置自行设置默认8080即可见此文章中的取阿里云盘token并按照文章中教程操作关于阿里云盘refreshToken，由于此前本博客中已有教程教学，请参阅。[collapse status="false" title="没有阿里云盘手机端取token教学"]1、登录网页版阿里云网盘：阿里云盘2、以Chrome为例，按F12-->application-->local storage，找token里面的refresh_token，该token关系你账号的安全，要保密，不要给任何人。[/collapse]填写完成后需要点击最下方的 启动 按钮，点击后等待几秒启动成功后即可。[scode type="blue" size="small"]如果启动失败,可以执行change_mode.exe切换至web模式[/scode]安装并配置RaiDrive前往RaiDrive官网下载最新版并安装。安装完成后运行，依次点击右上角 Settings - Language - 中文(简体)切换为中文后点击右上角 添加 - NAS - WebDav 后根据下图进行填写填写完成后在RaiDrive主页面启动Raidrive服务即可。[scode type="blue" size="small"]请不要忘记在RaiDrive的设置中勾选 添加到启动项[/scode]检查是否成功成功后会在 此电脑 的网络位置出现挂接好的阿里云盘，如图所示搬运其他网盘文件到阿里云盘使用其他任意云盘，只需要像常规下载文件那样将下载路径设置到你设置的阿里云盘目录，然后等待下载并上传完成即可，上传速度取决于你的带宽上传速度。
搭建个人云盘并挂接阿里云盘、天翼云盘等
介绍Alist是一款支持多种存储的目录文件列表程序，支持 web 浏览与 webdav，后端基于 gin，前端使用 react。[scode type="blue" size=""]官方文档：https://alist-doc.nn.ci/docs/intro演示站：https://alist.nn.ci[/scode]能够挂载支持的网盘有：本地存储、阿里云盘、OneDrive / Sharepoint（国际版, 世纪互联,de,us）、天翼云盘、GoogleDrive(需要服务器能直链谷歌，科学上网无效)、123云盘、蓝奏云(因蓝奏云经常打击直链更换协议等，自测已失效)、Alist、FTP、PikPak、闪电盘、S3、WebDav、Teambition（中国，国际）、分秒帧、和彩云、Yandex.Disk、百度网盘、夸克网盘、迅雷云盘 等等。优势[column][block size="50%"]部署方便，开箱即用文件预览（PDF、markdown、代码、纯文本……）画廊模式下的图像预览视频和音频预览（mp4、mp3 等）Office 文档预览（docx、pptx、xlsx、...）README.md 预览渲染文件永久链接复制和直接文件下载黑暗模式[/block][block size="50%"]国际化受保护的路由（密码保护和身份验证）WebDav（具体见https://alist-doc.nn.ci/docs/webdav ）Docker 部署文件/文件夹打包下载支持视频列表播放和字幕(ass,srt,vtt)网页上传，删除，新建文件夹，重命名，移动，复制[/block][/column]官方文档：https://alist-doc.nn.ci/docs/intro预览截图安装教程1.开放端口在阿里云、腾讯云或其他服务商的安全组处开放5244的出入站端口，如果是宝塔面板还需要在面板的安全里额外放行2.创建网站如果要使用域名访问需要先创建网站并设置反向代理(后文提及)，使用IP直接访问可跳过此步3.安装#安装 默认安装在/opt/alist curl -fsSL "https://nn.ci/alist.sh" | bash -s install #自定义安装路径 curl -fsSL "https://nn.ci/alist.sh" | bash -s install /www/wwwroot/yourweb.com手动安装请参阅：https://alist-doc.nn.ci/docs/install/manual4.更新#更新 curl -fsSL "https://nn.ci/alist.sh" | bash -s update #按路径更新 curl -fsSL "https://nn.ci/alist.sh" | bash -s update /www/wwwroot/yourweb.com[scode type="blue" size="small"]安装后请务必更新，最新版功能更多更加稳定[/scode]配置教程1.后台安装完成后会出现以下提示Alist 安装成功！访问地址：http://YOUR_IP:5244/配置文件：/www/wwwroot/你的安装目录/alist/data/config.json初始管理密码：#########访问地址为：服务器IP:5244 ，输入初始管理密码后进入该页面，务必修改管理密码！2.设置反向代理[scode type="blue" size=""]如果是直接用IP访问可跳过此步，设置反向代理可通过域名访问云盘[/scode]代理名称目标URL发送域名任意http://服务器IP:5244$host挂接云盘后台依次点击 账号—添加—类型—各类云盘1.通用基本配置虚拟路径必填，唯一标识符，即你要挂载到的位置，如果你要挂载到根目录，即为 /如你要挂载到根目录的 阿里云盘 文件夹，即为 /阿里云盘挂载到根目录的 阿里云盘 文件夹中的 A账号，即为 /阿里云盘/A账号index（索引）当有多个账号时，用于排序，越小越靠前proxy（代理相关）proxy：网页端和直链走不走中转webdav_proxy：webdav文件下载走不走中转webdav_direct：webdav是否直接由本机中转如果没有proxy和webdav_proxy选项，则说明只能走中转2.添加阿里云盘取阿里云盘token访问网页：https://alist-doc.nn.ci/docs/driver/aliyundrive点击网页中的绿色按钮“Get Token”后会出现二维码，使用阿里云盘手机App扫码后确认然后[font color="#FF0000"]再次点击[/font]按钮“Use AliyunDrive APP To Scan Then Click”稍等会出现账号token，将token保存待下一步使用配置阿里云盘在后台依次点击 账号—添加—类型选择—阿里云盘虚拟路径和索引在上文有介绍，按照自身需求，在刷新令牌处填入上一步获取的阿里云盘token后点击保存即可3.其他云盘其他云盘填写方法与阿里云盘类似，若有特殊要求请参阅 官方文档 即可4.其他功能加密、隐藏、游客上传、负载均衡、功能限制等请参阅 官方文档总结Alist是我近几年用过的开源云盘来界面最优雅、部署最便捷、功能最全面的云盘项目，希望大家都能使用到如此优秀的项目并为它添砖加瓦！
[scode type="blue" size=""]本文于Ngnix 1.21版本及以下成功运行，高版本需自行测试可用性[/scode]1.在Ngnix下安装.NET Core SDK与Runtime引用自微软官方文档https://docs.microsoft.com/zh-cn/dotnet/core/install/linux-centosCentOS 7前置运行以下命令，将 Microsoft 包签名密钥添加到受信任密钥列表，并添加 Microsoft 包存储库sudo rpm -Uvh https://packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm安装SDKsudo yum install dotnet-sdk-6.0安装运行时sudo yum install aspnetcore-runtime-6.0作为 ASP.NET Core 运行时的一种替代方法，可以安装不包含 ASP.NET Core 支持的 .NET 运行时：将上一命令中的 aspnetcore-runtime-6.0 替换为 dotnet-runtime-6.0：sudo yum install dotnet-runtime-6.0CentOS 8[scode type="yellow" size=""]*CentOS 8 将于 2021 年 12 月 31 日提前结束生命周期 (EOL)。 有关详细信息，请参阅官方 CentOS Linux EOL 页。 因此，CentOS Linux 8 不支持 .NET 6。[/scode]安装SDKsudo dnf install dotnet-sdk-5.0安装运行时sudo dnf install aspnetcore-runtime-5.0安装其他版本SDK更改上述安装SDK代码中的版本号即可各版本号参考https://dotnet.microsoft.com/en-us/download/dotnet在其他系统上安装其他系统如Ubuntu、Debian、Fedora、macOS请参阅https://docs.microsoft.com/zh-cn/dotnet/core/install/其他安装方法转载自https://blog.csdn.net/zhaobw831/article/details/78632675执行以下命令：sudo rpm --import https://packages.microsoft.com/keys/microsoft.asc sudo sh -c 'echo -e "[packages-microsoft-com-prod]\nname=packages-microsoft-com-prod \nbaseurl= https://packages.microsoft.com/yumrepos/microsoft-rhel7.3-prod\nenabled=1\ngpgcheck=1\ngpgkey=https://packages.microsoft.com/keys/microsoft.asc" > /etc/yum.repos.d/dotnetdev.repo'sudo yum update sudo yum install libunwind libicu sudo yum install dotnet-sdk-2.0.3[scode type="blue" size="simple"]这里的SDK版本可参阅上述的SDK版本进行更改以安装自己想要的版本[/scode]执行完以上命令后，可使用 dotnet --version 查看当前.NET Core版本2.设置反向代理宝塔面板[scode type="blue" size="small"]以宝塔面板7.9为例，可向下兼容自行设置，需面板已安装Ngnix且至少有反向代理功能的版本[/scode]网站 - PHP项目 - 选择想要运行.Net程序的网站 - 设置 - 反向代理 - 添加反向代理 - 按照如下设置填写开启代理开启缓存代理名称目标URL发送域名开关自行填写http://localhost:5000localhost其他面板或系统自行配置Nginx反向代理：只需要配置如下内容，其他不需要修改，把80端口转发到5000即可3.生成并上传项目至网页目录4.运行项目在SSH终端进入项目网页目录并执行以下命令[scode type="blue" size="small"]项目名.dll需要替换为自己的项目dll名称，例如上图Estart.dll[/scode]dotnet 项目名.dll5.使用Screen保持程序运行由于Linux的[挂断信号SIGHUP]概念(在此不过多赘述)，在关闭终端后运行的.Net Core程序会随着结束。简而言之 ：就是SSH打开以后，Bash等都是他的子程序，一旦SSH关闭，系统将所有相关进程杀掉！ 导致一旦SSH关闭，执行中的任务就取消了。针对这样的情况，我们引入Screen来解决这个问题安装Screen流行的Linux发行版（例如Red Hat Enterprise Linux）通常会自带screen实用程序，如果没有的话，可以从GNU screen的官方网站下载。yum install screen rpm -qa|grep screen创建一个新的窗口安装完成后，直接敲命令screen就可以启动它。但是这样启动的screen会话没有名字(仍然会为它分配一个数字ID)，实践上推荐为每个screen会话取一个名字，方便分辨screen -S netcore运行想要的命令在新建的窗口中进入网站目录并运行第四步的代码dotnet 项目名.dll回到root主界面按下Ctrl-A + D返回主界面，Screen中的命令会保持运行状态，关闭SSH即可大功告成关于Screen一些简单用法screen -S yourname -> 新建一个叫yourname的session screen -ls -> 列出当前所有的session screen -r yourname -> 回到yourname这个session screen -d yourname -> 远程detach某个session screen -d -r yourname -> 结束当前session并回到yourname这个session快捷键：C-a ? -> 显示所有键绑定信息 C-a c -> 创建一个新的运行shell的窗口并切换到该窗口 C-a n -> Next，切换到下一个 window C-a p -> Previous，切换到前一个 window C-a 0..9 -> 切换到第 0..9 个 window Ctrl+a [Space] -> 由视窗0循序切换到视窗9 C-a C-a -> 在两个最近使用的 window 间切换 C-a x -> 锁住当前的 window，需用用户密码解锁 C-a d -> detach，暂时离开当前session，将目前的 screen session (可能含有多个 windows) 丢到后台执行，并会回到还没进 screen 时的状态，此时在 screen session 里，每个 window 内运行的 process (无论是前台/后台)都在继续执行，即使 logout 也不影响。 C-a z -> 把当前session放到后台执行，用 shell 的 fg 命令则可回去。 C-a w -> 显示所有窗口列表 C-a t -> time，显示当前时间，和系统的 load C-a k -> kill window，强行关闭当前的 window C-a [ -> 进入 copy mode，在 copy mode 下可以回滚、搜索、复制就像用使用 vi 一样 C-b Backward，PageUp C-f Forward，PageDown H(大写) High，将光标移至左上角 L Low，将光标移至左下角 0 移到行首 $ 行末 w forward one word，以字为单位往前移 b backward one word，以字为单位往后移 Space 第一次按为标记区起点，第二次按为终点 Esc 结束 copy mode C-a ] -> paste，把刚刚在 copy mode 选定的内容贴上参考文档[https://man.linuxde.net/screen](https://man.linuxde.net/screen)后续一些后续使用方法、心得、教程会陆续更新，此文章最后更新于2022年8月9日18:24
起因目前在购买腾讯云的云服务器或轻量应用服务器时选择镜像会有“宝塔Linux面板 7.9.2 腾讯云专享版”的选项，且在介绍中有如下优点，让不了解所谓专享版的用户心动不已(我当时就上了当)[scode type="blue" size=""]宝塔面板腾讯云专享版由腾讯云与堡塔公司联合开发，与普通版相比，专享版默认集成腾讯云COSFS、CDN和DNS解析插件，让用户更便捷的使用宝塔面板对腾讯云产品进行管理和操作。该镜像基于CentOS 7.9 64位操作系统。[/scode]优点这个专享版根据我个人的体验，有好有坏，我会尽量写全它的优缺点，但是根据个人感情来说，肯定是缺点大于优点的1.安装便捷在腾讯云的系统镜像里就可以选择并一键安装，但现在宝塔面板已经可以在SSH中一行命令全自动安装了，因此此优点不明显2.对腾讯云系的产品有额外支持我个人体验了大约4个月，比较好用的功能如下：功能说明评价DNSPod联动在添加网站时如果你的域名是腾讯云的，可以自动向DNSPod添加A记录解析好用腾讯云COS联动在备份网站或数据库时，可以自动向COS存储桶中也添加备份无敌好用腾讯云CDN因为我不用CDN所以没有体验到，个人觉得用处不大，能方便一部分人无3.查询服务器数据不用登录腾讯云的控制台即可查看 服务器的到期时间、流量用量、地区(是否有必要?) 等数据，有一定的便捷性，最关键的是可以在面板内对服务器进行续费，那么此功能的目的不言而喻，很大程度上是让用户续费服务器PS:对第二点的额外注释腾讯云专享版似乎只是把COS的这个功能预集成到了这个版本里，实际上即使是宝塔官方版依旧可以在 软件商店 里找到“腾讯云COS 4.8”这个软件并安装，安装后可以在备份网站或数据库时向COS存储桶添加备份缺点1.面板频繁地出现报错包括但不限于“软件列表解析失败”等各类不同的报错，在我用腾讯云专享版的这几个月已经数几十次报错，我以前在用官方版本时从未遇过的报错一个一个都被我遇上了，很多时候都在被“软件列表解析失败”折腾，面板里每切换一个页面都会报错，根本无法正常使用2.专享版优势不明显且存在BUG专享版特有的功能可有可无，COS存储在软件商店中正式版也可以安装而无须特地安装专享版，并且在获取服务器到期时间、流量时存在BUG，我在刚刚使用的前一两个月很频繁地出现获取服务器信息失败，从而导致面板的布局加载错乱甚至无法使用3.无官方正式版稳定面板更新可随官方更新而更新，但据宝塔论坛大量反馈更新内容不同步于官方版(可能落后)，且让用户承担了专享版面板的各种不稳定性风险，非常不建议运行大型稳定/商业项目回滚官方正式版方法适用于任意版本方法1、先在专享版面板首页解绑腾讯云的绑定的账号信息2、删除/www/server/panel/data/o.pl3、在服务器SSH终端中执行正式版的安装命令，在已有面板的基础上会执行覆盖安装，可保留先前的数据yum install -y wget && wget -O install.sh http://download.bt.cn/install/install_6.0.sh && sh install.sh ed8484bec适用于老版本方法在服务器SSH终端中执行下面一行代码即可，无须再次安装宝塔curl http://download.bt.cn/install/update6.sh|bash一点杂谈宝塔论坛上关于腾讯云专享版的各种求助就有上百条，我自己在几个月的使用中也确实“苦专享版久矣”，希望腾讯云老老实实做云服务，不要什么都想分一杯羹，吃相那么难看吧，祝你也助我！