客户端功能:
1、带验证码登录功能;
2、请求并发采集;
3、数据展示;
服务端功能:
1、下发验证码;
2、鉴权登录,带OAuth 2.0认证;
3、模拟数据生成并下发;
*先运行服务端,再运行客户端;

客户端代码:
import win.ui;
/*DSG{{*/
var winform = win.form(text="数据中枢节点B (带自动OAuth认证的采集器 + 本地API服务端)";right=1149;bottom=539)
winform.add(
btnLogin={cls="button";text="鉴权登录";left=440;top=14;right=520;bottom=42;z=12};
btnStart={cls="button";text="开始并发采集";left=530;top=14;right=650;bottom=42;z=1};
editLog={cls="edit";left=720;top=85;right=1130;bottom=520;edge=1;multiline=1;readonly=1;vscroll=1;z=4};
lblLog={cls="static";text='\u2B07\uFE0F 下游客户端访问日志 (ESP32 / Web前端)';left=720;top=60;right=1130;bottom=80;transparent=1;z=5};
lblP={cls="static";text="密码:";left=150;top=20;right=190;bottom=40;transparent=1;z=8};
lblU={cls="static";text="账号:";left=20;top=20;right=60;bottom=40;transparent=1;z=6};
listview={cls="listview";left=20;top=85;right=700;bottom=520;edge=1;fullRow=1;gridLines=1;z=2};
picCaptcha={cls="plus";left=280;top=15;right=360;bottom=45;notify=1;z=10};
txtCaptcha={cls="edit";left=370;top=15;right=430;bottom=40;edge=1;tip="输入左侧验证码";z=11};
txtPass={cls="edit";text="123456";left=190;top=15;right=270;bottom=40;edge=1;password=1;z=9};
txtStatus={cls="static";text="等待登录。本地查询服务(8081端口)已启动。";left=175;top=55;right=695;bottom=75;color=0x0000FF;transparent=1;z=3};
txtUser={cls="edit";text="admin";left=60;top=15;right=140;bottom=40;edge=1;z=7}
)
/*}}*/
import sqlite;
import thread.command;
import thread.table;
import wsock.tcp.asynHttpServer;
import web.json;
import web.rest.client; // 【新增】用于处理会话(Session)的 HTTP 客户端
// 全局变量:存放我们向 ServerA 换取的合法 Token
winform.bearerToken = null;
// 专用于获取验证码和登录的HTTP客户端 (它会自动维护 Session Cookies)
var authHttp = web.rest.client();
// ================= 1. 初始化数据库与列表 =================
winform.listview.insertColumn("ID", 70);
winform.listview.insertColumn("材料名称", 140);
winform.listview.insertColumn("规格型号", 140);
winform.listview.insertColumn("所属地区", 100);
winform.listview.insertColumn("除税价", 80);
winform.listview.insertColumn("含税价", 80);
var db = sqlite("/data.db");
db.exec("CREATE TABLE IF NOT EXISTS MaterialPrice (
id TEXT PRIMARY KEY, fillinYear INTEGER, fillinMonth INTEGER,
categoryName TEXT, name TEXT, format TEXT, unit TEXT,
price REAL, priceTax REAL, areaName TEXT
)");
//var sqlInsert = db.prepare("INSERT INTO MaterialPrice (...) VALUES (...)"); // (简化显示,底层完整)
var sqlInsert = db.prepare("INSERT INTO MaterialPrice (id, fillinYear, fillinMonth, categoryName, name, format, unit, price, priceTax, areaName) VALUES (@id, @fillinYear, @fillinMonth, @categoryName, @name, @format, @unit, @price, @priceTax, @areaName);");
/*
for row in db.each("SELECT * FROM MaterialPrice") {
winform.listview.addItem({row.id; row.name; row.format; row.areaName; row.price; row.priceTax;});
}
*/
// ================= 2. 身份认证模块 (OAuth & 验证码) =================
// 刷新验证码图片函数
winform.refreshCaptcha = function(){
winform.picCaptcha.background = null; // 清空旧图
// 获取验证码字节流,authHttp 会自动将服务端返回的 Cookie 存下来
var imgBytes = authHttp.get("http://127.0.0.1:8080/api/captcha");
if(imgBytes){
winform.picCaptcha.background = imgBytes; // 显示到界面 plus 控件上
}else{
winform.txtStatus.text = "⚠️ 无法连接服务端,请确保 Mock Server A 已启动!";
}
}
// 点击图片时刷新验证码
winform.picCaptcha.oncommand = function(id,event){
winform.refreshCaptcha();
}
winform.refreshCaptcha(); // 启动时自动获取一次
// 登录鉴权按钮事件
winform.btnLogin.oncommand = function(id,event){
var user = winform.txtUser.text;
var pass = winform.txtPass.text;
var code = winform.txtCaptcha.text;
if(!#code){ return winform.msgbox("请输入验证码!"); }
// 向服务端A发起登录请求
var response = authHttp.post("http://127.0.0.1:8080/api/login", web.json.stringify({
username = user;
password = pass;
captcha = code;
}));
var resData = web.json.tryParse(response);
if(resData && resData.code == 200){
winform.bearerToken = resData.token; // 永久保存拿到的 Token
winform.txtStatus.text = "✅ 鉴权成功!已获取 Token: " + resData.token;
winform.msgbox("鉴权成功,可以开始采集数据了!");
}else{
winform.msgbox("登录失败:" + (resData ? resData.msg : "未知错误"));
winform.refreshCaptcha(); // 登录失败后验证码已失效,自动刷新
winform.txtCaptcha.text = "";
}
}
// ================= 3. 启动本地 API 服务端 (未改动) =================
var server = wsock.tcp.asynHttpServer();
server.start("0.0.0.0", 8081);
server.run(
function(response, request, session){
response.headers["Access-Control-Allow-Origin"] = "*";
var clientIp = request.remoteAddr;
var reqTime = tostring(time());
// 路由:通用高级查询接口 /api/search
if(request.path == "/api/search"){
// 1. 定义安全白名单(以后要增加查询字段,只要往这两个数组里加即可)
var allowedFilters = {"name", "areaName", "format", "categoryName"};
var allowedSorts = {"price", "priceTax", "fillinYear", "name"};
var sqlWhere = " WHERE 1=1";
var sqlParams = {}; // 存放参数化查询的值,防注入
// 2. 动态构建过滤条件 (循环判断前端是否传了白名单里的字段)
for(i, field in allowedFilters){
var val = request.get[field];
if(val && #val){
sqlWhere = sqlWhere ++ " AND " ++ field ++ " LIKE ?";
table.push(sqlParams, "%" ++ val ++ "%");
}
}
// 3. 统计符合条件的总记录数 (用于前端计算总页数)
var countSql = "SELECT COUNT(*) as total FROM MaterialPrice" ++ sqlWhere;
var totalCount = db.getTable(countSql, sqlParams)[1].total;
// 4. 动态构建排序规则
var sqlOrder = "";
var sortBy = request.get["sortBy"];
var orderDir = string.upper(request.get["orderDir"]:"ASC");
if(orderDir != "DESC") orderDir = "ASC"; // 强制规范为 ASC 或 DESC
// 必须验证 sortBy 是否在白名单内,防止前端恶意传入 SQL 破坏语句
if(sortBy && table.find(allowedSorts, sortBy)){
sqlOrder = " ORDER BY " ++ sortBy ++ " " ++ orderDir;
}
// 5. 动态构建分页
var page = request.get["page"] ? tonumber(request.get["page"]) : 1;
var pageSize = request.get["pageSize"] ? tonumber(request.get["pageSize"]) : 20; // 默认每页20条
var offset = (page - 1) * pageSize;
var sqlLimit = " LIMIT ? OFFSET ?";
table.push(sqlParams, pageSize, offset);
// 6. 拼装最终 SQL 并执行
var finalSql = "SELECT * FROM MaterialPrice" ++ sqlWhere ++ sqlOrder ++ sqlLimit;
var queryData = db.getTable(finalSql, sqlParams);
// 7. 返回标准分页 JSON 结构
response.contentType = "application/json; charset=utf-8";
response.write( web.json.stringify({
code = 200;
msg = "success";
data = queryData;
pagination = {
total = totalCount;
page = page;
pageSize = pageSize;
totalPages = math.ceil(totalCount / pageSize);
}
}) );
winform.editLog.print(string.format("[%s] 高级查询 | IP:%s | SQL: %s | 返回: %d条", reqTime, clientIp, finalSql, #queryData));
}
}
);
// ================= 4. 数据完整性策略与线程通信 (未改动) =================
var listener = thread.command();
var totalPages = 30;
var completedPages = 0;
var activeThreadCount = 0;
var tempDataCache = {};
listener.onPageData = function(dataList, page){
for(i, item in dataList){ table.push(tempDataCache, item); }
completedPages++;
winform.txtStatus.text = "正在采集中... 进度: " + completedPages + "/" + totalPages + " 页";
}
listener.onThreadFinished = function(){
activeThreadCount--;
if(activeThreadCount <= 0){
winform.txtStatus.text = "采集完成!正在进行数据替换(保证完整性)...";
winform.listview.clear();
db.beginTrans();
db.exec("DELETE FROM MaterialPrice");
for(i, item in tempDataCache){
sqlInsert.step(
id = item.id; fillinYear = item.fillinYear; fillinMonth = item.fillinMonth;
categoryName = item.categoryName; name = item.name; format = item.format;
unit = item.unit; price = item.price; priceTax = item.priceTax; areaName = item.areaName;
);
winform.listview.addItem({item.id; item.name; item.format; item.areaName; item.price; item.priceTax;});
}
db.commitTrans();
tempDataCache = {};
winform.txtStatus.text = "数据更新完毕!API查询服务(8081端口)运行中。";
winform.btnStart.text = "开始并发采集";
winform.btnStart.disabled = false;
}
}
// ================= 5. 采集按钮事件 (【新增】Token线程分发) =================
winform.btnStart.oncommand = function(id,event){
if(!winform.bearerToken){
return winform.msgboxErr("请先输入验证码并点击【鉴权登录】,获取 Token 后才能采集!");
}
winform.btnStart.disabled = true;
winform.btnStart.text = "正在拉取...";
completedPages = 0;
tempDataCache = {};
var taskQueue = thread.table("myTaskQueue", true);
for(i=totalPages; 1; -1){ taskQueue.push(i); }
var threadCount = 5;
activeThreadCount = threadCount;
// 核心点:把主线程拿到的 Token 作为参数传给所有后台线程
var token = winform.bearerToken;
for(i=1; threadCount; 1){
thread.invoke(
function(authToken){
import web.rest.jsonLiteClient;
import thread.command;
import thread.table;
var taskQueue = thread.table("myTaskQueue");
var http = web.rest.jsonLiteClient();
var apiUrl = "http://127.0.0.1:8080/api/mock";
// 【核心机制】告诉 HTTP 客户端:在以后的所有请求中自动带上 OAuth 认证头
http.addHeaders = {
["Authorization"] = "Bearer " + authToken
};
while(true){
var page = taskQueue.pop();
if(!page) break;
var result = http.get(apiUrl, { page = page; pageSize = 10; });
// 如果 Token 失效,服务端会返回 401 并且 result 结构为空,不会报错但会跳过
if(result && result.status == 200 && result.data && result.data.list){
thread.command.onPageData(result.data.list, page);
}
}
thread.command.onThreadFinished();
}, token // <- 将局部变量传入线程环境
)
}
}
winform.show();
win.loopMessage();服务端代码
import win.ui;
/*DSG{{*/
var winform = win.form(text="模拟信息价 API 服务端 (多线程高并发版)";right=759;bottom=469)
winform.add(
btnStart={cls="button";text="启动多线程服务";left=20;top=15;right=160;bottom=50;z=1};
editLog={cls="edit";left=20;top=65;right=740;bottom=450;edge=1;multiline=1;readonly=1;vscroll=1;z=2};
txtInfo={cls="static";text="等待启动...";left=175;top=25;right=735;bottom=45;transparent=1;z=3}
)
/*}}*/
import thread.command;
import thread.table;
// 创建全局跨线程共享表,用于存放验证码和已签发的Token
var sharedCache = thread.table("mockServerCache", true);
sharedCache.totalItems = 10 * 10; // 10页
sharedCache.usersDb = { ["admin"]="123456"; ["adUser"]="112233" };
// 接收后台线程发来的日志
var listener = thread.command();
listener.printLog = function(msg){
winform.editLog.print(msg);
}
var serverThreadId = null;
winform.btnStart.oncommand = function(id,event){
if(serverThreadId){
return winform.msgboxErr("服务已在运行中!多线程服务端请直接关闭窗口来停止。");
}
sharedCache.totalItems = math.random(100, 200);
winform.btnStart.text = "服务运行中";
winform.btnStart.disabled = true;
winform.txtInfo.text = "真·并发模式运行中! 监听: http://127.0.0.1:8080";
winform.editLog.print("================ 高并发 OAuth 2.0 服务已启动 ================");
winform.editLog.print("🎲 动态总量模拟:本次数据库共生成了 " + sharedCache.totalItems + " 条测试数据。");
// 【核心架构修复】将多线程服务器引擎放入后台运行,彻底释放 UI 界面
serverThreadId = thread.invoke(
function(){
import wsock.tcp.simpleHttpServer;
import thread.command;
import thread.table;
// 创建并绑定端口 (如果端口被占用,会静默失败或报错,这里以正常情况处理)
var server = wsock.tcp.simpleHttpServer("127.0.0.1", 8080);
// 运行多线程分发引擎 (这个 run 是阻塞的,但因为它在后台线程,所以 UI 不卡)
// 当有 5 个请求同时来时,这个引擎会瞬间派发给 5 个不同的底层 worker 线程处理
server.run(
function(response, request, session){
import web.json;
import math;
import string;
import thread.command;
import thread.table;
import gdip.bitmap;
import gdip.font;
import gdip.pen;
import gdip.solidBrush;
import gdip.stringformat;
var sharedCache = thread.table("mockServerCache");
var clientIp = request.remoteAddr;
// ------------------ 路由 1: 获取图形验证码 ------------------
if(request.path == "/api/captcha"){
var code = string.random(4, "0123456789");
sharedCache["captcha_" ++ clientIp] = code;
var bmp = gdip.bitmap(80, 30);
var graphics = bmp.getGraphics();
graphics.clear(0xFFEEEEEE);
var pen = gdip.pen(0xFFCCCCCC, 1);
graphics.drawLine(pen, 0, math.random(5,25), 80, math.random(5,25));
graphics.drawLine(pen, 0, math.random(5,25), 80, math.random(5,25));
pen.delete();
var font = gdip.font("Arial", 16, 1);
var brush = gdip.solidBrush(0xFFD9534F);
var strfmt = gdip.stringformat();
graphics.drawString(code, font, gdip.RECTF(8, 2, 80, 30), strfmt, brush);
font.delete(); brush.delete(); strfmt.delete();
response.contentType = "image/jpeg";
response.write(bmp.saveToBuffer(".jpg"));
thread.command.printLog("[鉴权] 下发验证码图片: " + code);
return;
}
// ------------------ 路由 2: 登录鉴权 ------------------
if(request.path == "/api/login"){
var postData = web.json.tryParse(request.postData());
if(!postData) { response.status = 400; return; }
var savedCaptcha = sharedCache["captcha_" ++ clientIp];
if(!savedCaptcha || postData.captcha != savedCaptcha){
response.write(web.json.stringify({code=400; msg="验证码错误或已失效!"}));
return;
}
var usersDb = sharedCache.usersDb;
if(usersDb[postData.username] && usersDb[postData.username] == postData.password){
var token = string.format("%08X%08X", tonumber(time()), math.random(1000000,9999999));
sharedCache["token_" ++ token] = postData.username;
sharedCache["captcha_" ++ clientIp] = null;
response.write(web.json.stringify({code=200; msg="登录成功"; token=token}));
thread.command.printLog("[登录成功] 用户: " + postData.username + " | Token: " + token);
}else{
response.write(web.json.stringify({code=401; msg="账号或密码错误!"}));
}
return;
}
// ------------------ 路由 3: 并发模拟数据 ------------------
if(request.path == "/api/mock"){
var authHeader = request.headers["authorization"];
if(!authHeader || !string.startWith(authHeader, "Bearer ", true)){
response.status = 401;
response.write(web.json.stringify({code=401; msg="无Token"}));
return;
}
var token = string.right(authHeader, -8);
var username = sharedCache["token_" ++ token];
if(!username){
response.status = 401;
response.write(web.json.stringify({code=401; msg="Token无效"}));
return;
}
var page = request.get["page"] ? tonumber(request.get["page"]) : 1;
var pageSize = request.get["pageSize"] ? tonumber(request.get["pageSize"]) : 10;
// 模拟耗时 1.5 ~ 2.5 秒
var delayMs = math.random(50, 200);
thread.command.printLog(string.format("[接单] 线程准备处理第 %d 页 -> 预计耗时 %d 毫秒...", page, delayMs));
// 【核心机制】在独立请求线程中 sleep,绝不会阻塞其他请求!
sleep(delayMs);
var list = {};
var totalItems = sharedCache.totalItems;
var startIdx = (page - 1) * pageSize + 1;
var endIdx = startIdx + pageSize - 1;
if(startIdx <= totalItems){
if(endIdx > totalItems) endIdx = totalItems;
for(i=startIdx; endIdx; 1){
table.push(list, {
"id" = tostring(2800000 + i); "fillinYear" = 2026; "fillinMonth" = 2;
"categoryName" = "01金属材料"; "name" = "并发螺纹钢-" + i;
"format" = "HRB400 φ" + math.random(10, 32); "unit" = "t";
"price" = math.random(3000, 3500); "priceTax" = math.random(3500, 4000);
"areaName" = "虚拟区" + math.random(1, 6);
});
}
}
response.contentType = "application/json; charset=utf-8";
response.write( web.json.stringify({
"httpStatus" = "OK"; "status" = 200; "message" = "成功"; "code" = 0;
"data" = { "count" = null; "list" = list; "total" = totalItems; };
}) );
thread.command.printLog(string.format("✅ [返回] 第 %d 页处理完毕并返回!", page));
}
}
)
}
)
}
winform.btnStart.oncommand();
winform.show();
win.loopMessage();
最新回复 (0)