From d0303554c2ce2cfa004178b2298a8e6070d0cc92 Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Mon, 5 Oct 2026 20:16:02 +0800 Subject: [PATCH 01/13] docs: add prioritized feature roadmap for dev --- ROADMAP.md | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..752d47e --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,79 @@ +# QShopWebUI 开发路线图 + +本文件记录 v1.0.1 之后的功能建议,不代表功能已经实现或发布日期承诺。开发改动进入 `dev`,经过验证后再合并到 `main`;正式发布时再统一更新版本号。 + +## 现有基础 + +当前已有中文和拼音搜索、出售与收购筛选、实际库存联动、在线交易、离线购买队列、Limited 限购、批量管理、数据导入导出与备份恢复。抓取日志和商店删除记录不能替代完整的网页订单流水。 + +## P0:订单状态与交易流水 + +让玩家确认下单结果,让管理员能排查扣款、发货和离线队列问题。这是建议优先实现的功能。 + +- 为每笔网页交易生成唯一订单号,记录交易方向、玩家 UUID、商店标识、数量、成交单价、总价和时间。 +- 区分待处理、处理中、成功、失败状态;失败时保留可理解的原因,不能把进入离线队列当作成交成功。 +- 玩家经过身份验证后只能查看自己的订单;管理员可按玩家、时间、状态和商店检索并导出。 +- 重复点击和请求重试使用幂等标识,不能重复扣款或重复发货。 +- 商店删除后仍能查看当时的交易信息;定义订单保留期限及清理策略。 + +验收标准:覆盖在线购买、收购、离线购买、库存不足、余额不足、重复提交和服务重启场景。交易状态必须与实际扣款及物品转移结果一致;中断后不能自动重试结果不确定的交易,应明确标记并供管理员核查。流水不得保存密码、登录令牌或会话凭据。 + +## P0:玩家身份验证与交易防护 + +现有下单方式涉及游戏 ID 和密码。建议提供游戏内一次性验证码绑定网页会话,减少网页反复输入游戏密码的需求。 + +- 游戏内命令生成短时、单次有效的验证码,绑定玩家 UUID;具体命令与权限在实现时确定。 +- 验证码尝试次数、验证码生成和交易请求分别限流;登录错误不暴露敏感账号信息。 +- 明确网页会话有效期、退出登录、服务重启后的失效规则,以及管理端和玩家端的权限隔离。 +- HTTP 明文传输仍有风险;验证码登录不能替代 HTTPS,公网部署应提供反向代理和 TLS 配置说明。 + +验收标准:验证码过期、重复使用、错误尝试及越权访问均被拒绝;密码和验证码不出现在应用日志或持久化流水中。先确认与 AuthMe 和离线模式服务器的兼容边界,再调整现有登录方式。 + +## P1:库存与收购容量提醒 + +帮助店主及时补货和清理收购箱,降低玩家遇到无法成交商店的频率。 + +- 店主可查看自己商店的缺货、低库存、收购箱满状态。 +- 阈值可配置,无限商店不产生普通库存告警。 +- 首版采用网页提醒;游戏内通知作为后续项,不默认接入外部消息平台。 +- 对重复告警设置冷却,库存恢复后解除告警。 + +验收标准:区分出售库存与收购容量,店主身份在服务端验证;页面展示数据更新时间,不把过期快照当成实时成交保证。 + +## P1:同物品比价与商店收藏 + +在已有价格查询和筛选之上,降低玩家重复寻找商店的成本。 + +- 比价区分出售最低价与收购最高价,并展示库存或容量、世界及坐标。 +- 按单件价格比较,处理批量数量差异;带不同 NBT、附魔、药水或自定义名称的物品不能仅按材质合并。 +- 收藏首版可使用浏览器本地存储,明确不跨设备同步,不在本地保存登录凭据。 +- 商店已删除时标记失效,不能将旧收藏误关联到另一家商店。 + +验收标准:买卖方向、批量单位和物品变体均正确;刷新后收藏保留,商店失效后有明确状态。 + +## P1:管理操作审计与批量预览 + +在现有批量改价、改类型和删除基础上,补足操作追踪和误操作防护。 + +- 批量操作前展示命中数量、影响范围和价格变化预览;危险操作需确认。 +- 审计记录操作者、操作时间、商店标识、修改前后值和逐项成功或失败结果。 +- 审计记录与普通抓取日志分离,受管理权限保护。 +- 不把商店数据备份称为交易回滚:已经发生的经济变动和物品交付不能通过恢复商店配置撤销。 + +验收标准:部分失败不会显示为全部成功;预览之后数据发生变化时重新验证;审计不记录敏感凭据。 + +## P2:店主经营统计 + +在交易流水稳定后增加成交额、成交数量、热门商品及时间趋势。首版明确只统计本插件记录的网页成交;若要统计游戏内交易,应另行适配 QuickShop 事件或历史记录并做去重,不能以网页数据冒充全服成交数据。 + +验收标准:购买与收购方向分开,失败和待处理订单不计入成交,金额精度与经济后端保持一致,统计页面明确数据来源和时间范围。 + +## 建议实施顺序 + +1. 确定订单数据结构、持久化方式、权限和故障恢复规则。 +2. 完成订单流水及幂等校验,并验证与库存、经济和离线队列的联动。 +3. 增加验证码登录与限流,验证 AuthMe 兼容性。 +4. 增加库存提醒、比价与收藏、管理审计。 +5. 最后基于可靠成交数据增加经营统计。 + +每项实现需补充对应的聚焦验证;涉及交易、权限或恢复路径的功能还需在真实 Paper + QuickShop-Hikari + Vault 环境中验收。路线图文档本身不改变交易行为,也不发布新 JAR。 \ No newline at end of file From 7abccfad7bec0109aac58a28cd1fcca7d6544836 Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Mon, 5 Oct 2026 21:02:17 +0800 Subject: [PATCH 02/13] feat: add in-game trade verification code --- ROADMAP.md | 17 ++++- src/main/java/cn/aqcraft/PluginConfig.java | 4 ++ .../java/cn/aqcraft/QShopWebUIPlugin.java | 21 +++++- src/main/java/cn/aqcraft/api/PurchaseApi.java | 8 +-- .../java/cn/aqcraft/auth/GameCodeService.java | 68 +++++++++++++++++++ .../cn/aqcraft/purchase/PurchaseService.java | 28 +++----- src/main/resources/config.yml | 3 + src/main/resources/plugin.yml | 3 +- webroot/js/app.js | 34 +++++----- webroot/js/db.js | 12 ++-- 10 files changed, 146 insertions(+), 52 deletions(-) create mode 100644 src/main/java/cn/aqcraft/auth/GameCodeService.java diff --git a/ROADMAP.md b/ROADMAP.md index 752d47e..aa72cc6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # QShopWebUI 开发路线图 -本文件记录 v1.0.1 之后的功能建议,不代表功能已经实现或发布日期承诺。开发改动进入 `dev`,经过验证后再合并到 `main`;正式发布时再统一更新版本号。 +本文件记录 v1.0.1 之后的完整开发范围,不代表功能已经全部实现或发布日期承诺。开发改动进入 `dev`,经过验证后再合并到 `main`;正式发布时再统一更新版本号。 ## 现有基础 @@ -22,7 +22,7 @@ 现有下单方式涉及游戏 ID 和密码。建议提供游戏内一次性验证码绑定网页会话,减少网页反复输入游戏密码的需求。 -- 游戏内命令生成短时、单次有效的验证码,绑定玩家 UUID;具体命令与权限在实现时确定。 +- 游戏内执行 `/qshopwebui code` 生成短时、单次有效验证码,绑定在线玩家 UUID;不依赖 AuthMe。 - 验证码尝试次数、验证码生成和交易请求分别限流;登录错误不暴露敏感账号信息。 - 明确网页会话有效期、退出登录、服务重启后的失效规则,以及管理端和玩家端的权限隔离。 - HTTP 明文传输仍有风险;验证码登录不能替代 HTTPS,公网部署应提供反向代理和 TLS 配置说明。 @@ -68,6 +68,19 @@ 验收标准:购买与收购方向分开,失败和待处理订单不计入成交,金额精度与经济后端保持一致,统计页面明确数据来源和时间范围。 +## P1:商店状态与经营保护 + +- 增加营业状态、临时隐藏、维护模式和交易熔断;QuickShop 连接异常时明确提示,禁止把旧快照当成交保证。 +- 增加价格异常保护、最低/最高价格限制和大额交易二次确认,降低误改价与恶意刷交易风险。 +- 增加订单查询、导出和人工退款审核;退款必须基于实际扣款与发货状态,不能用恢复商店备份代替退款。 + +## P2:通知与客户端体验 + +- 增加移动端优化、多语言、坐标复制、地图导航和游戏内快捷入口。 +- 增加店主库存、收购容量、订单和异常交易提醒,首版优先网页提醒并设置告警冷却。 +- Webhook、Discord、飞书等外部通知作为可选适配器,不默认引入第三方服务或保存外部凭据。 +- 增加定期备份、数据完整性校验、迁移工具和更细粒度管理员权限。 + ## 建议实施顺序 1. 确定订单数据结构、持久化方式、权限和故障恢复规则。 diff --git a/src/main/java/cn/aqcraft/PluginConfig.java b/src/main/java/cn/aqcraft/PluginConfig.java index 165048c..8ad87b9 100644 --- a/src/main/java/cn/aqcraft/PluginConfig.java +++ b/src/main/java/cn/aqcraft/PluginConfig.java @@ -34,6 +34,8 @@ public final class PluginConfig { public int purchaseMaxAmount = 64; /** 登录(游戏账号)后是否允许离线购买(物品上线时发放) */ public boolean allowOfflineBuy = true; + public int gameCodeTtlSeconds = 300; + public int gameCodeMaxAttempts = 5; // === 网站显示 === public String serverName = ""; @@ -70,6 +72,8 @@ public static PluginConfig load(QShopWebUIPlugin plugin) { cfg.purchaseEnabled = c.getBoolean("purchase.enabled", true); cfg.purchaseMaxAmount = Math.max(1, c.getInt("purchase.max-amount", 64)); cfg.allowOfflineBuy = c.getBoolean("purchase.allow-offline-buy", true); + cfg.gameCodeTtlSeconds = Math.max(30, c.getInt("purchase.game-code-ttl-seconds", 300)); + cfg.gameCodeMaxAttempts = Math.max(1, c.getInt("purchase.game-code-max-attempts", 5)); cfg.serverName = c.getString("server-name", "").trim(); cfg.serverSubtitle = c.getString("server-subtitle", "").trim(); for (String k : new String[]{"home", "buy", "sell", "browse", "shops", "stats"}) { diff --git a/src/main/java/cn/aqcraft/QShopWebUIPlugin.java b/src/main/java/cn/aqcraft/QShopWebUIPlugin.java index a8fc910..1d83682 100644 --- a/src/main/java/cn/aqcraft/QShopWebUIPlugin.java +++ b/src/main/java/cn/aqcraft/QShopWebUIPlugin.java @@ -1,6 +1,7 @@ package cn.aqcraft; import cn.aqcraft.auth.SessionManager; +import cn.aqcraft.auth.GameCodeService; import cn.aqcraft.bridge.AuthMeBridge; import cn.aqcraft.bridge.EconomyBridge; import cn.aqcraft.bridge.LimitedBridge; @@ -50,6 +51,7 @@ public final class QShopWebUIPlugin extends JavaPlugin implements CommandExecuto private PurchaseService purchases; private AuthMeBridge authme; private LimitedBridge limited; + private GameCodeService gameCodes; // ============================================================ // 生命周期 @@ -77,6 +79,7 @@ public void onEnable() { economy = new EconomyBridge(this); economy.reload(); purchases = new PurchaseService(this); + gameCodes = new GameCodeService(this); authme = new AuthMeBridge(this); authme.reload(); @@ -170,6 +173,17 @@ private void stopWeb() { @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length > 0 && "code".equalsIgnoreCase(args[0])) { + if (!(sender instanceof org.bukkit.entity.Player)) { + sender.sendMessage("§c只有游戏玩家可以生成验证码"); + return true; + } + org.bukkit.entity.Player player = (org.bukkit.entity.Player) sender; + String code = gameCodes.issue(player); + player.sendMessage("§aQShopWebUI 网页交易验证码: §e§l" + code); + player.sendMessage("§7有效期 " + config.gameCodeTtlSeconds + " 秒,仅可使用一次。请勿发送给他人。"); + return true; + } if (!sender.hasPermission("qshopwebui.admin")) { sender.sendMessage("§c权限不足"); return true; @@ -229,7 +243,7 @@ public List onTabComplete(CommandSender sender, Command command, String return Collections.emptyList(); } if (args.length == 1) { - return partial(args[0], Arrays.asList("status", "reload", "port")); + return partial(args[0], Arrays.asList("code", "status", "reload", "port")); } if (args.length == 2 && "port".equalsIgnoreCase(args[0])) { return partial(args[1], Collections.singletonList(String.valueOf(config.port))); @@ -250,6 +264,7 @@ private List partial(String input, List values) { private void help(CommandSender sender) { sender.sendMessage("§6===== QShopWebUI ====="); + sender.sendMessage("§e/qshopwebui code §7- 生成网页交易验证码(游戏玩家)"); sender.sendMessage("§e/qshopwebui status §7- 查看运行状态"); sender.sendMessage("§e/qshopwebui reload §7- 重载配置"); sender.sendMessage("§e/qshopwebui port <端口> §7- 修改并重绑端口"); @@ -341,6 +356,10 @@ public LimitedBridge limited() { return limited; } + public GameCodeService gameCodes() { + return gameCodes; + } + /** 网页修改密码后刷新内存中的配置 */ public void applyAdminPassword(String hashed) { if (config != null) { diff --git a/src/main/java/cn/aqcraft/api/PurchaseApi.java b/src/main/java/cn/aqcraft/api/PurchaseApi.java index cdb0f18..6a731f1 100644 --- a/src/main/java/cn/aqcraft/api/PurchaseApi.java +++ b/src/main/java/cn/aqcraft/api/PurchaseApi.java @@ -19,9 +19,9 @@ public HttpResponse purchase(HttpRequest req) { JsonObject b = body(req); String shopId = jstr(b, "shop_id", ""); String player = jstr(b, "player", ""); - String password = jstr(b, "password", ""); + String code = jstr(b, "code", ""); int amount = jint(b, "amount", 1); - JsonObject result = plugin.purchases().purchase(shopId, player, amount, password); + JsonObject result = plugin.purchases().purchase(shopId, player, amount, code); return HttpResponse.json(result); } @@ -32,9 +32,9 @@ public HttpResponse sell(HttpRequest req) { JsonObject b = body(req); String shopId = jstr(b, "shop_id", ""); String player = jstr(b, "player", ""); - String password = jstr(b, "password", ""); + String code = jstr(b, "code", ""); int amount = jint(b, "amount", 1); - JsonObject result = plugin.purchases().sell(shopId, player, amount, password); + JsonObject result = plugin.purchases().sell(shopId, player, amount, code); return HttpResponse.json(result); } diff --git a/src/main/java/cn/aqcraft/auth/GameCodeService.java b/src/main/java/cn/aqcraft/auth/GameCodeService.java new file mode 100644 index 0000000..d22e0a7 --- /dev/null +++ b/src/main/java/cn/aqcraft/auth/GameCodeService.java @@ -0,0 +1,68 @@ +package cn.aqcraft.auth; + +import cn.aqcraft.QShopWebUIPlugin; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; + +import java.security.SecureRandom; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** 游戏内生成、网页一次性使用的交易验证码。验证码只保存在内存中。 */ +public final class GameCodeService { + private final QShopWebUIPlugin plugin; + private final SecureRandom random = new SecureRandom(); + private final Map entries = new ConcurrentHashMap<>(); + + public GameCodeService(QShopWebUIPlugin plugin) { + this.plugin = plugin; + } + + public String issue(Player player) { + String code = String.format(Locale.ROOT, "%06d", random.nextInt(1_000_000)); + entries.put(player.getUniqueId(), new Entry(code, System.currentTimeMillis() + + plugin.config().gameCodeTtlSeconds * 1000L, + plugin.config().gameCodeMaxAttempts)); + return code; + } + + public boolean verifyAndConsume(String playerName, String code) { + if (playerName == null || code == null) return false; + Entry entry = null; + UUID matched = null; + for (Map.Entry candidate : entries.entrySet()) { + OfflinePlayer player = Bukkit.getOfflinePlayer(candidate.getKey()); + if (player.getName() != null && player.getName().equalsIgnoreCase(playerName.trim())) { + entry = candidate.getValue(); + matched = candidate.getKey(); + break; + } + } + if (entry == null || System.currentTimeMillis() > entry.expiresAt) { + if (matched != null) entries.remove(matched); + return false; + } + if (--entry.attempts < 0) { + entries.remove(matched); + return false; + } + if (!entry.code.equals(code.trim())) return false; + entries.remove(matched); + return true; + } + + private static final class Entry { + private final String code; + private final long expiresAt; + private int attempts; + + private Entry(String code, long expiresAt, int attempts) { + this.code = code; + this.expiresAt = expiresAt; + this.attempts = attempts; + } + } +} diff --git a/src/main/java/cn/aqcraft/purchase/PurchaseService.java b/src/main/java/cn/aqcraft/purchase/PurchaseService.java index 19fe20c..4041bc9 100644 --- a/src/main/java/cn/aqcraft/purchase/PurchaseService.java +++ b/src/main/java/cn/aqcraft/purchase/PurchaseService.java @@ -37,7 +37,7 @@ public PurchaseService(QShopWebUIPlugin plugin) { this.plugin = plugin; } - public JsonObject purchase(String shopId, String playerName, int amount, String password) { + public JsonObject purchase(String shopId, String playerName, int amount, String code) { if (shopId == null || shopId.trim().isEmpty()) return err("缺少商店 ID"); if (playerName == null || playerName.trim().isEmpty()) return err("请输入你的游戏 ID"); final String name = playerName.trim(); @@ -50,15 +50,7 @@ public JsonObject purchase(String shopId, String playerName, int amount, String if (last != null && now - last < 1500) return err("操作太快,请稍后再试"); cooldown.put(name.toLowerCase(Locale.ROOT), now); - // 安全验证:服务器安装 AuthMe 时,需验证该玩家的游戏密码(防止冒用他人账号) - if (plugin.authme().available()) { - if (password == null || password.isEmpty()) { - return err("请输入游戏密码(AuthMe 验证身份)"); - } - if (!plugin.authme().checkPassword(name, password)) { - return err("游戏密码验证失败(请检查该账号的 AuthMe 密码)"); - } - } + if (!plugin.gameCodes().verifyAndConsume(name, code)) return err("验证码无效、已过期或玩家不在线,请在游戏内执行 /qshopwebui code"); ShopEntry found = null; for (ShopEntry s : plugin.shopData().shops()) { @@ -80,7 +72,7 @@ public JsonObject purchase(String shopId, String playerName, int amount, String } /** 玩家出售给收购商店(网页收购界面) */ - public JsonObject sell(String shopId, String playerName, int amount, String password) { + public JsonObject sell(String shopId, String playerName, int amount, String code) { if (shopId == null || shopId.trim().isEmpty()) return err("缺少商店 ID"); if (playerName == null || playerName.trim().isEmpty()) return err("请输入你的游戏 ID"); final String name = playerName.trim(); @@ -92,15 +84,7 @@ public JsonObject sell(String shopId, String playerName, int amount, String pass if (last != null && now - last < 1500) return err("操作太快,请稍后再试"); cooldown.put(name.toLowerCase(Locale.ROOT), now); - // 安全验证:服务器安装 AuthMe 时,需验证该玩家的游戏密码(防止冒用他人账号) - if (plugin.authme().available()) { - if (password == null || password.isEmpty()) { - return err("请输入游戏密码(AuthMe 验证身份)"); - } - if (!plugin.authme().checkPassword(name, password)) { - return err("游戏密码验证失败(请检查该账号的 AuthMe 密码)"); - } - } + if (!plugin.gameCodes().verifyAndConsume(name, code)) return err("验证码无效、已过期或玩家不在线,请在游戏内执行 /qshopwebui code"); ShopEntry found = null; for (ShopEntry s : plugin.shopData().shops()) { @@ -314,6 +298,8 @@ private JsonObject doPurchase(ShopEntry e, String name, int amount) { plugin.store().addFetchLog(0, 0, plugin.shopData().stats().total, "purchase", name + " 网页购买 " + e.shop_cn_name + " x" + amount + " 花费 " + total); JsonObject trade = new JsonObject(); + trade.addProperty("order_id", "WEB-" + UUID.randomUUID()); + trade.addProperty("status", "SUCCESS"); trade.addProperty("t", System.currentTimeMillis()); trade.addProperty("type", "BUY"); trade.addProperty("source", "web"); @@ -448,6 +434,8 @@ private JsonObject doSell(ShopEntry e, String name, int amount) { plugin.store().addFetchLog(0, 0, plugin.shopData().stats().total, "sell", name + " 网页出售 " + e.shop_cn_name + " x" + amount + " 获得 " + total); JsonObject trade = new JsonObject(); + trade.addProperty("order_id", "WEB-" + UUID.randomUUID()); + trade.addProperty("status", "SUCCESS"); trade.addProperty("t", System.currentTimeMillis()); trade.addProperty("type", "SELL"); trade.addProperty("source", "web"); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 806233b..4c4bbab 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -68,6 +68,9 @@ purchase: max-amount: 64 # 玩家不在线时是否允许下单(物品在玩家下次上线时自动发放)。 allow-offline-buy: true + # 玩家在游戏内执行 /qshopwebui code 后,将验证码填入网页。 + game-code-ttl-seconds: 300 + game-code-max-attempts: 5 # ------------------------------------------------------------ # 前台页面 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 68fa649..37938e0 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -10,9 +10,8 @@ softdepend: [QuickShop, QuickShop-Hikari, AuthMe, qssuite-limited] commands: qshopwebui: description: QShopWebUI 管理命令 - usage: /qshopwebui > + usage: /qshopwebui > aliases: [qsw, qshopweb] - permission: qshopwebui.admin permissions: qshopwebui.admin: description: 允许管理 QShopWebUI diff --git a/webroot/js/app.js b/webroot/js/app.js index e7738c0..aec6785 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -2132,7 +2132,7 @@ return card; } - // 在线购买弹窗(填游戏 ID + 游戏密码直接购买) + // 在线购买弹窗(填游戏 ID + 游戏内一次性验证码购买) function showPurchaseModal(shop) { const unitPrice = Number(shop.price || 0); const unitDisplay = (shop.price_display !== undefined && shop.price_display !== null) @@ -2149,8 +2149,8 @@ type: 'text', class: 'purchase-input', placeholder: '请输入你的游戏 ID', value: savedName, maxlength: '16', style: { width: '100%' } }); - const passInput = el('input', { - type: 'password', class: 'purchase-input', placeholder: '游戏密码(AuthMe 验证身份)', style: { width: '100%' } + const codeInput = el('input', { + type: 'text', inputmode: 'numeric', class: 'purchase-input', placeholder: '游戏内验证码(/qshopwebui code)', maxlength: '6', style: { width: '100%' } }); const qtyInput = el('input', { type: 'number', class: 'purchase-input purchase-qty', value: '1', @@ -2230,8 +2230,8 @@ nameInput ]), el('div', { class: 'purchase-field' }, [ - el('label', { class: 'purchase-label', text: '游戏密码(AuthMe 验证身份)' }), - passInput + el('label', { class: 'purchase-label', text: '游戏内验证码' }), + codeInput ]), el('div', { class: 'purchase-field' }, [ el('label', { class: 'purchase-label', text: '购买份数(1 份 = ' + stackAmount + ' 个,最多 64 份)' }), @@ -2240,7 +2240,7 @@ summary, balanceInfo, limitInfo, - el('div', { class: 'purchase-tip', text: '购买需验证游戏密码(AuthMe);在线玩家物品直接放入背包,离线玩家上线时自动发放(可在服务器配置关闭)。' }) + el('div', { class: 'purchase-tip', text: '请先在游戏内执行 /qshopwebui code 获取验证码;验证码只可使用一次。在线玩家物品直接放入背包,离线玩家上线时自动发放。' }) ]); let modalRef = null; @@ -2252,12 +2252,12 @@ const player = nameInput.value.trim(); if (!player) { Toast.show('请输入你的游戏 ID', 'error'); return false; } if (!/^[A-Za-z0-9_]{1,16}$/.test(player)) { Toast.show('游戏 ID 只能包含字母、数字、下划线(1-16 位)', 'error'); return false; } - const password = passInput.value; - if (!password) { Toast.show('请输入游戏密码(AuthMe 验证身份)', 'error'); return false; } + const code = codeInput.value.trim(); + if (!/^\d{6}$/.test(code)) { Toast.show('请输入 6 位游戏内验证码', 'error'); return false; } if (remainingLimit != null && currentQty() > remainingLimit) { Toast.show('超出限购:本周期最多还能购买 ' + remainingLimit + ' 份', 'error'); return false; } const qty = currentQty(); try { modalRef.setProcessing('处理中...'); } catch (e) { } - QSDB.purchaseShop(shop.shop_id, player, qty, password).then(function (r) { + QSDB.purchaseShop(shop.shop_id, player, qty, code).then(function (r) { if (r && r.success) { try { localStorage.setItem('qsw_player_name', player); } catch (e) { } Toast.show('✓ 购买成功:' + r.item + ' ×' + r.amount + ' 份,花费 $' + r.total_price + ',余额 $' + r.balance_left, 'success'); @@ -2417,8 +2417,8 @@ type: 'text', class: 'purchase-input', placeholder: '请输入你的游戏 ID', value: savedName, maxlength: '16', style: { width: '100%' } }); - const passInput = el('input', { - type: 'password', class: 'purchase-input', placeholder: '游戏密码(AuthMe 验证身份)', style: { width: '100%' } + const codeInput = el('input', { + type: 'text', inputmode: 'numeric', class: 'purchase-input', placeholder: '游戏内验证码(/qshopwebui code)', maxlength: '6', style: { width: '100%' } }); const qtyInput = el('input', { type: 'number', class: 'purchase-input purchase-qty', value: '1', @@ -2481,8 +2481,8 @@ nameInput ]), el('div', { class: 'purchase-field' }, [ - el('label', { class: 'purchase-label', text: '游戏密码(AuthMe 验证身份)' }), - passInput + el('label', { class: 'purchase-label', text: '游戏内验证码' }), + codeInput ]), el('div', { class: 'purchase-field' }, [ el('label', { class: 'purchase-label', text: '出售份数(1 份 = ' + stackAmount + ' 个,最多 64 份)' }), @@ -2490,7 +2490,7 @@ ]), summary, balanceInfo, - el('div', { class: 'purchase-tip', text: '⚠ 出售需验证游戏密码(AuthMe);物品将从你的背包扣除,报酬直接存入你的账户。' + (ownerName ? '' : '(系统商店:无限收购)') }) + el('div', { class: 'purchase-tip', text: '⚠ 请先在游戏内执行 /qshopwebui code 获取一次性验证码;物品将从你的背包扣除,报酬直接存入你的账户。' + (ownerName ? '' : '(系统商店:无限收购)') }) ]); let modalRef = null; @@ -2502,11 +2502,11 @@ const player = nameInput.value.trim(); if (!player) { Toast.show('请输入你的游戏 ID', 'error'); return false; } if (!/^[A-Za-z0-9_]{1,16}$/.test(player)) { Toast.show('游戏 ID 只能包含字母、数字、下划线(1-16 位)', 'error'); return false; } - const password = passInput.value; - if (!password) { Toast.show('请输入游戏密码(AuthMe 验证身份)', 'error'); return false; } + const code = codeInput.value.trim(); + if (!/^\d{6}$/.test(code)) { Toast.show('请输入 6 位游戏内验证码', 'error'); return false; } const qty = currentQty(); try { modalRef.setProcessing('处理中...'); } catch (e) { } - QSDB.sellShop(shop.shop_id, player, qty, password).then(function (r) { + QSDB.sellShop(shop.shop_id, player, qty, code).then(function (r) { if (r && r.success) { try { localStorage.setItem('qsw_player_name', player); } catch (e) { } Toast.show('✓ 出售成功:' + r.item + ' ×' + r.amount + ' 份,获得 $' + r.total_price + ',余额 $' + r.balance_left, 'success'); diff --git a/webroot/js/db.js b/webroot/js/db.js index 3254bd6..29bb048 100644 --- a/webroot/js/db.js +++ b/webroot/js/db.js @@ -1034,12 +1034,12 @@ } }, - // 网页「在线购买」:填游戏 ID + 游戏密码(AuthMe 验证)购买 - purchaseShop: async function (shopId, player, amount, password) { + // 网页「在线购买」:填游戏 ID + 游戏内一次性验证码购买 + purchaseShop: async function (shopId, player, amount, code) { try { const data = await apiCall('/purchase', { method: 'POST', - body: { shop_id: String(shopId), player: player || '', amount: Number(amount) || 1, password: password || '' } + body: { shop_id: String(shopId), player: player || '', amount: Number(amount) || 1, code: code || '' } }); if (data && data.success) { cacheClear(); memoryCache.lastUpdate = 0; } return data || { success: false, error: '服务器无响应' }; @@ -1048,12 +1048,12 @@ } }, - // 网页「在线出售」(卖给收购商店):填游戏 ID + 游戏密码(AuthMe 验证)出售 - sellShop: async function (shopId, player, amount, password) { + // 网页「在线出售」(卖给收购商店):填游戏 ID + 游戏内一次性验证码出售 + sellShop: async function (shopId, player, amount, code) { try { const data = await apiCall('/sell', { method: 'POST', - body: { shop_id: String(shopId), player: player || '', amount: Number(amount) || 1, password: password || '' } + body: { shop_id: String(shopId), player: player || '', amount: Number(amount) || 1, code: code || '' } }); if (data && data.success) { cacheClear(); memoryCache.lastUpdate = 0; } return data || { success: false, error: '服务器无响应' }; From 0d8c18746ee464e5a2745c3731393bb30471b03f Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Mon, 5 Oct 2026 21:24:37 +0800 Subject: [PATCH 03/13] ci: auto-publish beta prereleases from dev branch --- .github/workflows/release.yml | 55 ++++++++++++++++++++++++++++++++--- README.md | 41 ++++++++++++++++++-------- tools/verify-jar.mjs | 8 +++-- 3 files changed, 85 insertions(+), 19 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5afb120..6765867 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,7 @@ name: Release on: push: - branches: [main] + branches: [main, dev] tags: - "v*" workflow_dispatch: @@ -38,15 +38,51 @@ jobs: echo "::error::无法从 pom.xml 读取版本号" exit 1 fi + + yml_version="$(sed -n 's/^version:[[:space:]]*//p' src/main/resources/plugin.yml | head -n1 | tr -d '[:space:]')" + if [ "$yml_version" != "$version" ]; then + echo "::error::plugin.yml 版本(${yml_version})与 pom.xml 版本(${version})不一致,请先同步两处版本号" + exit 1 + fi + tag="v${version}" if [ "${GITHUB_REF_TYPE}" = "tag" ]; then tag="${GITHUB_REF_NAME}" + if [ "$tag" != "v${version}" ]; then + echo "::error::标签 ${tag} 与 pom.xml 版本 ${version} 不一致,请在对应版本号的提交上打标签" + exit 1 + fi fi + + case "$version" in + *-*) + prerelease="true" + files="target/qsweb-*.jar" + title="qsweb ${version}(测试版)" + ;; + *) + prerelease="false" + files="target/QShopWebUI-*.jar" + title="QShopWebUI v${version}" + ;; + esac + + enabled="true" + if [ "${GITHUB_REF_TYPE}" = "branch" ] && [ "${GITHUB_REF_NAME}" = "dev" ] && [ "$prerelease" != "true" ]; then + enabled="false" + echo "::notice::dev 分支只发行测试版(版本号需带 -beta 等预发布后缀),本次跳过发行" + fi + echo "version=${version}" >> "$GITHUB_OUTPUT" echo "tag=${tag}" >> "$GITHUB_OUTPUT" + echo "prerelease=${prerelease}" >> "$GITHUB_OUTPUT" + echo "files=${files}" >> "$GITHUB_OUTPUT" + echo "title=${title}" >> "$GITHUB_OUTPUT" + echo "enabled=${enabled}" >> "$GITHUB_OUTPUT" - name: 检查是否已发行 id: check + if: steps.meta.outputs.enabled == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} @@ -69,20 +105,31 @@ jobs: if: steps.check.outputs.exists == 'false' run: node tools/verify-jar.mjs + - name: 重命名测试版产物 + if: steps.check.outputs.exists == 'false' && steps.meta.outputs.prerelease == 'true' + run: mv "target/QShopWebUI-${{ steps.meta.outputs.version }}.jar" "target/qsweb-${{ steps.meta.outputs.version }}.jar" + - name: 创建 Release if: steps.check.outputs.exists == 'false' uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.meta.outputs.tag }} - name: QShopWebUI ${{ steps.meta.outputs.tag }} + name: ${{ steps.meta.outputs.title }} target_commitish: ${{ github.sha }} - files: target/QShopWebUI-*.jar + prerelease: ${{ steps.meta.outputs.prerelease }} + files: ${{ steps.meta.outputs.files }} generate_release_notes: true - name: 写入摘要 run: | - if [ "${{ steps.check.outputs.exists }}" = "true" ]; then + if [ "${{ steps.meta.outcome }}" != "success" ]; then + echo "版本读取或校验失败,请查看上方步骤日志。" >> "$GITHUB_STEP_SUMMARY" + elif [ "${{ steps.meta.outputs.enabled }}" != "true" ]; then + echo "dev 分支只发行测试版(版本号需带 -beta 等后缀),本次跳过发行。" >> "$GITHUB_STEP_SUMMARY" + elif [ "${{ steps.check.outputs.exists }}" = "true" ]; then echo "版本 ${{ steps.meta.outputs.tag }} 已存在,本次跳过发行。" >> "$GITHUB_STEP_SUMMARY" + elif [ "${{ steps.meta.outputs.prerelease }}" = "true" ]; then + echo "已创建预发布(测试版)${{ steps.meta.outputs.tag }},附件 qsweb-${{ steps.meta.outputs.version }}.jar。" >> "$GITHUB_STEP_SUMMARY" else echo "已创建发行版本 ${{ steps.meta.outputs.tag }},附件为本次构建的 JAR。" >> "$GITHUB_STEP_SUMMARY" fi diff --git a/README.md b/README.md index ff12eb2..f66dc03 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ QShopWebUI 把 Web 管理界面、HTTP 服务和 QuickShop-Hikari 适配层整 - 物品中文名、英文注册名和拼音搜索 - 按物品、店主、世界、价格和商店类型筛选 - 商店详情、坐标、库存状态和价格区间查看 -- 在线购买和离线购买队列(填写游戏 ID + 游戏密码即可下单) +- 在线购买和离线购买队列(填写游戏 ID + 游戏内一次性验证码,验证码在游戏内执行 /qshopwebui code 获取) - 限购支持:服务器安装 QuickShop「Limited」扩展时,网页购买与游戏内共用每人额度,弹窗显示剩余可买数量 - 数据统计、访问统计和商店数量概览 @@ -74,7 +74,7 @@ QShopWebUI 把 Web 管理界面、HTTP 服务和 QuickShop-Hikari 适配层整 | 服务端 | Paper 或 Spigot 1.18 及以上 | | Java | Java 17 及以上 | | 前置插件 | QuickShop-Hikari | -| 可选插件 | Vault 经济插件(购买/收购)、AuthMe(下单密码验证)、QuickShop「Limited」扩展(限购) | +| 可选插件 | Vault 经济插件(购买/收购)、QuickShop「Limited」扩展(限购) | | 构建环境 | Maven 3.6 及以上 | 插件对 QuickShop-Hikari 使用反射调用,因此编译时不需要安装 QuickShop-Hikari JAR。QuickShop 缺失时插件仍可加载,但商店页面没有实际商店数据。 @@ -83,7 +83,7 @@ QShopWebUI 把 Web 管理界面、HTTP 服务和 QuickShop-Hikari 适配层整 1. 从 Releases 页面下载最新的 `QShopWebUI-x.y.z.jar`,或按「自己构建」一节自行编译。 2. 将 JAR 放入服务端的 `plugins/` 目录。 -3. 安装并启用 QuickShop-Hikari;如果需要网页购买或收购,再安装 Vault 和经济插件;服务器装了 AuthMe 时,下单会自动验证游戏密码。 +3. 安装并启用 QuickShop-Hikari;如果需要网页购买或收购,再安装 Vault 和经济插件;下单前玩家需要在游戏内执行 /qshopwebui code 获取一次性验证码。 4. 启动服务器,等待插件生成 `plugins/QShopWebUI/config.yml`。 5. 修改管理员账号、端口和运行模式。 6. 执行 `/qshopwebui reload`,或重启服务器。 @@ -162,6 +162,8 @@ purchase: enabled: true max-amount: 64 allow-offline-buy: true + game-code-ttl-seconds: 300 + game-code-max-attempts: 5 pages: hide-home: false @@ -201,11 +203,12 @@ password: sha256:<64位十六进制摘要> | 命令 | 说明 | | --- | --- | | `/qshopwebui` | 显示帮助 | +| `/qshopwebui code` | 生成网页交易一次性验证码(所有玩家可用,需在游戏内执行) | | `/qshopwebui status` | 查看监听端口、运行模式、QuickShop 状态和商店数量 | | `/qshopwebui reload` | 重新读取配置并重启网页监听 | | `/qshopwebui port <端口>` | 修改网页端口并尝试重新绑定 | -命令权限:`qshopwebui.admin`,默认仅 OP 拥有。输入 `/qshopwebui ` 后,插件会自动补全 `status`、`reload` 和 `port`;输入 `port` 后会提示当前端口。 +`status`、`reload`、`port` 命令权限:`qshopwebui.admin`,默认仅 OP 拥有;`code` 所有玩家可用。输入 `/qshopwebui ` 后,管理员会自动补全子命令;输入 `port` 后会提示当前端口。 ## 演示站 @@ -229,7 +232,7 @@ password: sha256:<64位十六进制摘要> mvn clean package ``` -产物:`target/QShopWebUI-1.0.1.jar`。 +产物:`target/QShopWebUI-<版本>.jar`,例如测试版为 `QShopWebUI-1.1.0-beta.1.jar`。 构建请带上 `clean`:否则可能把编辑器(VS Code / IDEA)增量编译到 `target/classes` 的旧产物直接打进 jar。 @@ -252,16 +255,26 @@ node tools/verify-jar.mjs | 工作流 | 触发方式 | 做什么 | | --- | --- | --- | | CI | 推送 `main`、提交 PR、手动触发 | 构建插件、校验 JAR 内容、上传构建产物 | -| Release | 推送 `main`、推送 `v*` 标签、手动触发 | 发现新版本号时自动打标签、创建 Release、附加 JAR | +| Release | 推送 `main` / `dev`、推送 `v*` 标签、手动触发 | 发现新版本号时自动打标签、创建 Release、附加 JAR;带 `-` 的版本号(如 `1.1.0-beta.1`)按测试版预发布处理 | -发行一个新版本的完整流程: +发行前请同时修改 **`pom.xml`** 和 **`src/main/resources/plugin.yml`** 的 `version`(两处不一致时工作流会直接报错,避免产出内含版本号不一致的 JAR)。版本号未变化时自动跳过,不会重复发行。 -1. 修改 `pom.xml` 里的 `version`,例如从 `1.0.0` 改成 `1.1.0`。 +### 正式版(main 分支) + +1. 把 `pom.xml` 与 `plugin.yml` 的版本改成正式号,例如从 `1.0.1` 改成 `1.1.0`。 2. 提交并推送到 `main`。 -3. 工作流检测到 `v1.1.0` 还不存在,自动构建、创建标签、创建 Release 并上传 JAR。 -4. 版本号没变时自动跳过,不会重复发行。 +3. 工作流检测到 `v1.1.0` 还不存在,自动构建、创建标签、创建 Release 并上传 `QShopWebUI-1.1.0.jar`。 + +### 测试版(dev 分支) + +版本号格式为 `A.b.c-beta.N`,例如 `1.1.0-beta.1`: -习惯手动打标签也可以:推送 `v1.1.0` 标签会直接触发发行,工作流也可以在 Actions 页面手动运行。 +1. 在 `dev` 分支把 `pom.xml` 与 `plugin.yml` 的版本改成 `1.1.0-beta.1`;下一个测试版递增为 `1.1.0-beta.2`,以此类推。 +2. 提交并推送到 `dev`。 +3. 工作流检测到 `v1.1.0-beta.1` 还不存在,自动构建并创建**预发布(Pre-release)**:标题 `qsweb 1.1.0-beta.1(测试版)`,附件 `qsweb-1.1.0-beta.1.jar`。预发布不会成为 Releases 页面的 Latest,不影响正式版用户。 +4. `dev` 分支使用不带 `-` 的正式版本号时不会发行;要发正式版请合并到 `main` 后操作。 + +习惯手动打标签也可以:在 `dev` 上执行 `git tag v1.1.0-beta.1 && git push origin v1.1.0-beta.1` 会直接触发发行(标签需与 `pom.xml` 版本一致);也可以在 Actions 页面选择 Release 工作流、目标分支选 `dev` 手动运行。 ## 目录结构 @@ -310,7 +323,11 @@ QshopWebUI/ ### 网页购买失败? -确认购买功能已启用,并安装可用的 Vault 经济后端。玩家在线时物品直接发放;玩家离线时需要开启 `purchase.allow-offline-buy`;服务器安装 AuthMe 时,下单还需输入该玩家的游戏密码。 +确认购买功能已启用,并安装可用的 Vault 经济后端;下单需要先在游戏内执行 `/qshopwebui code` 获取一次性验证码。玩家在线时物品直接发放;玩家离线时需要开启 `purchase.allow-offline-buy`。 + +### 提示验证码无效或已过期? + +验证码需在游戏内执行 `/qshopwebui code` 生成,默认 5 分钟有效、只能使用一次,错误尝试次数过多会作废。重新执行命令获取新验证码即可;有效期和尝试次数可在配置文件的 `purchase.game-code-ttl-seconds`、`purchase.game-code-max-attempts` 中调整。 ### 提示超出限购? diff --git a/tools/verify-jar.mjs b/tools/verify-jar.mjs index 85bad9f..383ba7d 100644 --- a/tools/verify-jar.mjs +++ b/tools/verify-jar.mjs @@ -52,7 +52,7 @@ const walk = (d, acc = []) => { }; const all = walk(tmp); -for (const n of ['PurchaseService.class', 'PurchaseApi.class', 'EconomyBridge.class', 'AuthMeBridge.class', 'LimitedBridge.class', 'ItemCodec.class', 'PurchaseJoinListener.class']) { +for (const n of ['PurchaseService.class', 'PurchaseApi.class', 'GameCodeService.class', 'EconomyBridge.class', 'AuthMeBridge.class', 'LimitedBridge.class', 'ItemCodec.class', 'PurchaseJoinListener.class']) { const hit = all.filter((x) => x.includes(n)); console.log(n + ': ' + (hit.length ? hit.join(', ') : '!! 缺失')); } @@ -75,7 +75,9 @@ check('web/js/db.js', 'inventoryCheck', 'db.js inventoryCheck API'); check('web/js/db.js', 'getLimit', 'db.js getLimit API'); check('web/css/style.css', '多设备适配', 'style.css 响应式'); check('config.yml', 'purchase:', 'config.yml purchase 段'); +check('config.yml', 'game-code-ttl-seconds', 'config.yml 验证码配置'); check('plugin.yml', 'QShopWebUI', 'plugin.yml'); +check('plugin.yml', 'code|reload', 'plugin.yml 验证码命令'); check('custom_lang_zh_cn.json', 'item.dnt.', '数据包翻译表'); { const txt = fs.readFileSync(path.join(tmp, 'web/js/app.js'), 'utf8'); @@ -83,12 +85,12 @@ check('custom_lang_zh_cn.json', 'item.dnt.', '数据包翻译表'); console.log('app.js 旧店主行已清除: ' + (txt.includes("tr('店主', shop.owner_name") ? '!! 仍有残留' : '✓')); console.log('app.js 「库存上限:无限」行已清除: ' + (txt.includes("'库存上限'") ? '!! 仍有残留' : '✓')); console.log('app.js 游戏登录已移除: ' + (txt.includes('游戏登录') ? '!! 仍有残留' : '✓')); - console.log('app.js 购买密码字段: ' + (txt.includes('游戏密码(AuthMe 验证身份)') ? '✓' : '!! 未找到')); + console.log('app.js 验证码购买字段: ' + (txt.includes('游戏内验证码') ? '✓' : '!! 未找到')); console.log('app.js 图片 CDN 回退: ' + (txt.includes('mcitemgallery.com') ? '✓' : '!! 未找到')); console.log('app.js 限购显示: ' + (txt.includes('限购') ? '✓' : '!! 未找到')); const dbTxt = fs.readFileSync(path.join(tmp, 'web/js/db.js'), 'utf8'); console.log('db.js playerLogin 已移除: ' + (dbTxt.includes('playerLogin') ? '!! 仍有残留' : '✓')); - console.log('db.js 购买携带密码: ' + (dbTxt.includes('password: password') ? '✓' : '!! 未找到')); + console.log('db.js 购买携带验证码: ' + (dbTxt.includes("code: code") ? '✓' : '!! 未找到')); console.log('db.js 限购 API: ' + (dbTxt.includes('getLimit') ? '✓' : '!! 未找到')); } { From 8059f34b3d75c5d4f6001189e0c54290afc6ff59 Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Mon, 5 Oct 2026 21:24:38 +0800 Subject: [PATCH 04/13] release: 1.1.0-beta.1 --- pom.xml | 2 +- src/main/resources/plugin.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 4409ce3..d547176 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ cn.aqcraft qshop-webui-plugin - 1.0.1 + 1.1.0-beta.1 jar QShopWebUI QuickShop-Hikari 商店 Web 管理系统(单端口,内嵌 Web UI) diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 37938e0..c2d2d32 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: QShopWebUI -version: 1.0.1 +version: 1.1.0-beta.1 main: cn.aqcraft.QShopWebUIPlugin api-version: 1.17 description: QuickShop-Hikari 商店 Web 管理系统(单端口内嵌 Web UI,直接读取 QuickShop 数据) From 0a7928f77d9a19e3527c2cd807ad2bc53405034c Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Mon, 5 Oct 2026 21:38:48 +0800 Subject: [PATCH 05/13] =?UTF-8?q?chore:=20=E5=8E=BB=E6=8E=89=E9=A2=84?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E5=91=BD=E5=90=8D=E4=B8=AD=E7=9A=84=20qsweb?= =?UTF-8?q?=20=E5=89=8D=E7=BC=80=EF=BC=88=E8=AF=A5=E5=90=8D=E4=BB=85?= =?UTF-8?q?=E4=B8=BA=E7=A4=BA=E4=BE=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 测试版与正式版产物命名统一为 QShopWebUI-<版本>.jar, 仅通过 prerelease 标记区分;标题改为 QShopWebUI <版本>(测试版)。 删除多余的重命名步骤。 --- .github/workflows/release.yml | 12 ++++-------- README.md | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6765867..75e66ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,15 +54,15 @@ jobs: fi fi + # 测试版与正式版产物命名一致(QShopWebUI-<版本>.jar),仅通过 prerelease 标记区分 + files="target/QShopWebUI-*.jar" case "$version" in *-*) prerelease="true" - files="target/qsweb-*.jar" - title="qsweb ${version}(测试版)" + title="QShopWebUI ${version}(测试版)" ;; *) prerelease="false" - files="target/QShopWebUI-*.jar" title="QShopWebUI v${version}" ;; esac @@ -105,10 +105,6 @@ jobs: if: steps.check.outputs.exists == 'false' run: node tools/verify-jar.mjs - - name: 重命名测试版产物 - if: steps.check.outputs.exists == 'false' && steps.meta.outputs.prerelease == 'true' - run: mv "target/QShopWebUI-${{ steps.meta.outputs.version }}.jar" "target/qsweb-${{ steps.meta.outputs.version }}.jar" - - name: 创建 Release if: steps.check.outputs.exists == 'false' uses: softprops/action-gh-release@v2 @@ -129,7 +125,7 @@ jobs: elif [ "${{ steps.check.outputs.exists }}" = "true" ]; then echo "版本 ${{ steps.meta.outputs.tag }} 已存在,本次跳过发行。" >> "$GITHUB_STEP_SUMMARY" elif [ "${{ steps.meta.outputs.prerelease }}" = "true" ]; then - echo "已创建预发布(测试版)${{ steps.meta.outputs.tag }},附件 qsweb-${{ steps.meta.outputs.version }}.jar。" >> "$GITHUB_STEP_SUMMARY" + echo "已创建预发布(测试版)${{ steps.meta.outputs.tag }},附件 QShopWebUI-${{ steps.meta.outputs.version }}.jar。" >> "$GITHUB_STEP_SUMMARY" else echo "已创建发行版本 ${{ steps.meta.outputs.tag }},附件为本次构建的 JAR。" >> "$GITHUB_STEP_SUMMARY" fi diff --git a/README.md b/README.md index f66dc03..6fced22 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ node tools/verify-jar.mjs 1. 在 `dev` 分支把 `pom.xml` 与 `plugin.yml` 的版本改成 `1.1.0-beta.1`;下一个测试版递增为 `1.1.0-beta.2`,以此类推。 2. 提交并推送到 `dev`。 -3. 工作流检测到 `v1.1.0-beta.1` 还不存在,自动构建并创建**预发布(Pre-release)**:标题 `qsweb 1.1.0-beta.1(测试版)`,附件 `qsweb-1.1.0-beta.1.jar`。预发布不会成为 Releases 页面的 Latest,不影响正式版用户。 +3. 工作流检测到 `v1.1.0-beta.1` 还不存在,自动构建并创建**预发布(Pre-release)**:标题 `QShopWebUI 1.1.0-beta.1(测试版)`,附件 `QShopWebUI-1.1.0-beta.1.jar`。预发布不会成为 Releases 页面的 Latest,不影响正式版用户。 4. `dev` 分支使用不带 `-` 的正式版本号时不会发行;要发正式版请合并到 `main` 后操作。 习惯手动打标签也可以:在 `dev` 上执行 `git tag v1.1.0-beta.1 && git push origin v1.1.0-beta.1` 会直接触发发行(标签需与 `pom.xml` 版本一致);也可以在 Actions 页面选择 Release 工作流、目标分支选 `dev` 手动运行。 From 2371b62bb55c9eb7a53b69b2861d92c0ef736c6f Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Tue, 6 Oct 2026 06:08:24 +0800 Subject: [PATCH 06/13] Feat: implement ROADMAP P0/P1/P2 (orders, alerts, compare, favorites, audit, stats, status, notify), extension API, and /docs --- docs/API.md | 105 +++++++ docs/DEPLOY.md | 100 ++++++ docs/FEATURES.md | 42 +++ docs/README.md | 36 +++ src/main/java/cn/aqcraft/PluginConfig.java | 23 ++ .../java/cn/aqcraft/QShopWebUIPlugin.java | 73 +++++ src/main/java/cn/aqcraft/api/ApiRouter.java | 49 +++ src/main/java/cn/aqcraft/api/EnhancedApi.java | 296 ++++++++++++++++++ .../java/cn/aqcraft/api/QShopWebUIAPI.java | 168 ++++++++++ .../java/cn/aqcraft/api/QShopWebUIImpl.java | 145 +++++++++ .../java/cn/aqcraft/auth/GameCodeService.java | 16 + .../java/cn/aqcraft/data/FavoritesStore.java | 107 +++++++ .../aqcraft/notify/NotificationService.java | 104 ++++++ .../java/cn/aqcraft/order/OrderStore.java | 280 +++++++++++++++++ .../cn/aqcraft/purchase/PurchaseService.java | 140 +++++++-- .../java/cn/aqcraft/service/AuditService.java | 120 +++++++ .../aqcraft/service/BusinessStatsService.java | 180 +++++++++++ .../cn/aqcraft/service/ComparisonService.java | 113 +++++++ .../cn/aqcraft/service/ShopStatusService.java | 155 +++++++++ .../cn/aqcraft/service/StockAlertService.java | 133 ++++++++ src/main/resources/config.yml | 26 ++ 21 files changed, 2391 insertions(+), 20 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/DEPLOY.md create mode 100644 docs/FEATURES.md create mode 100644 docs/README.md create mode 100644 src/main/java/cn/aqcraft/api/EnhancedApi.java create mode 100644 src/main/java/cn/aqcraft/api/QShopWebUIAPI.java create mode 100644 src/main/java/cn/aqcraft/api/QShopWebUIImpl.java create mode 100644 src/main/java/cn/aqcraft/data/FavoritesStore.java create mode 100644 src/main/java/cn/aqcraft/notify/NotificationService.java create mode 100644 src/main/java/cn/aqcraft/order/OrderStore.java create mode 100644 src/main/java/cn/aqcraft/service/AuditService.java create mode 100644 src/main/java/cn/aqcraft/service/BusinessStatsService.java create mode 100644 src/main/java/cn/aqcraft/service/ComparisonService.java create mode 100644 src/main/java/cn/aqcraft/service/ShopStatusService.java create mode 100644 src/main/java/cn/aqcraft/service/StockAlertService.java diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..53a4a5a --- /dev/null +++ b/docs/API.md @@ -0,0 +1,105 @@ +# 扩展 API + +QShopWebUI 提供两层扩展能力: + +1. **公开 Java API(`QShopWebUIAPI`)** —— 供其他插件开发附属插件。 +2. **HTTP API** —— 供前端、脚本或外部系统调用。 + +--- + +## 一、公开 Java API(开发附属插件) + +### 获取实例 + +```java +import cn.aqcraft.api.QShopWebUIAPI; +import cn.aqcraft.QShopWebUIPlugin; + +QShopWebUIAPI api = + ((QShopWebUIPlugin) Bukkit.getPluginManager().getPlugin("QShopWebUI")).getAPI(); +``` + +把 `QShopWebUI-.jar` 放进你插件 `pom.xml` 的依赖,并在 `plugin.yml` +里 `depend: [QShopWebUI]`(或 `softdepend`)即可。 + +### 接口方法 + +| 方法 | 返回 | 说明 | +|------|------|------| +| `shop(String shopId)` | `JsonObject` | 商店快照;不存在返回 null | +| `shopsByOwner(String name)` | `List` | 某店主名下商店 | +| `shopsByMaterial(String material)` | `List` | 某材质商店 | +| `compare(String material)` | `JsonObject` | 同物品比价结果 | +| `recordOrder(key,type,status,shopId,item,material,amount,items,unit,total,player,owner,online,reason)` | `JsonObject` | 写入订单流水(幂等) | +| `ordersByPlayer(player,type,limit,offset)` | `List` | 玩家订单 | +| `order(String orderId)` | `JsonObject` | 单笔订单 | +| `isFavorite / addFavorite / removeFavorite` | `boolean/void` | 收藏管理 | +| `favoriteIds(player)` | `List` | 玩家收藏的商店 ID | +| `isOpen()` / `isMaintenance()` | `boolean` | 营业状态 | +| `businessStats(int days)` | `JsonArray` | 按天经营统计 | +| `businessTotals()` | `JsonObject` | 累计总量 | +| `audit(actor,action,detail)` | `void` | 写审计日志 | +| `purchase(shopId,player,amount)` | `JsonObject` | 程序化购买(跳过验证码,信任调用方) | +| `sell(shopId,player,amount)` | `JsonObject` | 程序化出售 | + +### 注意 + +- 所有返回均为 Gson `JsonObject`/`JsonArray`,字段与 HTTP API 返回一致。 +- `purchase`/`sell` 跳过游戏内验证码校验:调用方必须已自行校验玩家身份。 +- 扩展插件需要与主插件同 JVM(常规插件均为同一服务器 JVM,天然满足)。 + +--- + +## 二、HTTP API + +基础 URL:`http://:/api/...`。管理端端点需要 `requireAuth` 开启时 +携带会话 Cookie;玩家自助端点使用 `player + code`(游戏内 `/qshopwebui code` +生成的一次性验证码)鉴权。 + +### 订单 + +| 端点 | 方法 | 鉴权 | 说明 | +|------|------|------|------| +| `/api/orders?type=&player=&limit=&offset=` | GET | 管理 | 全部订单分页(时间倒序) | +| `/api/orders/me?player=&code=&type=&limit=&offset=` | GET | 玩家 | 我的订单 | +| `/api/orders/{id}` | GET | 管理 | 单笔订单 | +| `/api/orders/clear` | POST | 管理 | 清空订单 | + +### 比价 + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/api/compare?material=minecraft:diamond` | GET | 同物品比价 | +| `/api/compare/all` | GET | 全物品聚合(最低卖出/最高收购) | + +### 收藏 + +| 端点 | 方法 | 鉴权 | 说明 | +|------|------|------|------| +| `/api/favorites?player=&code=` | GET | 玩家 | 收藏列表 | +| `/api/favorites/toggle` | POST | 玩家 | body: `{player,code,shop_id}` | + +### 经营统计 / 审计 + +| 端点 | 方法 | 鉴权 | 说明 | +|------|------|------|------| +| `/api/stats/business?days=30` | GET | 公开 | 经营统计仪表盘 | +| `/api/audit?limit=&offset=` | GET | 管理 | 审计日志 | +| `/api/audit/clear` | POST | 管理 | 清空审计 | + +### 营业状态 / 监控 / 通知 + +| 端点 | 方法 | 鉴权 | 说明 | +|------|------|------|------| +| `/api/status` | GET | 公开 | 营业状态 | +| `/api/status/open` | POST | 公开 | body: `{open:true/false}` 开/关业 | +| `/api/status/maintenance` | POST | 管理 | body: `{on:true/false}` 维护模式 | +| `/api/monitor/stock?live=true` | GET | 公开 | 库存告警;`live=true` 实时读容器 | +| `/api/notify/status` | GET | 公开 | 通知是否已配置 | + +--- + +## 版本兼容性 + +- Java API 为稳定契约:方法签名向后兼容,底层实现可演进。 +- 建议在扩展插件里对 `getAPI()` 判空(主插件可能未加载)。 \ No newline at end of file diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..4179737 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,100 @@ +# 部署 / 配置 / 构建 / 发行 + +## 一、环境 + +- Java 17 +- Paper / Spigot 1.17.1+(api-version 1.17) +- QuickShop-Hikari(依赖其数据) +- Maven 3.8+ + +## 二、安装 + +1. 把 `target/QShopWebUI-.jar` 放入 `plugins/`。 +2. 重启服务器(或 `/reload`)。 +3. 编辑 `plugins/QShopWebUI/config.yml` 设置管理员账号、端口等,再 `/qshopwebui reload`。 + +## 三、config.yml 字段 + +### 服务端 + +| 键 | 默认 | 说明 | +|----|------|------| +| `server.port` | 20130 | HTTP 监听端口 | +| `server.bind` | `0.0.0.0` | 绑定地址 | +| `server.mode` | `multiplex` | `multiplex` 与 MC 共用端口 / 独立端口 | +| `server.mc-host` / `server.mc-port` | 127.0.0.1 / 25565 | MC 后端地址 | +| `server.server-name` | 清屿 | 服务器名 | +| `server.server-subtitle` | — | 副标题 | +| `server.hidden-pages` | — | 隐藏页签 | +| `server.snapshot-ttl-ms` | — | 快照缓存 TTL | + +### 鉴权 + +| 键 | 默认 | 说明 | +|----|------|------| +| `auth.admin-username` | admin | 管理员账号 | +| `auth.admin-password` | 随机 | 管理员密码(首次启动生成) | +| `auth.session-timeout` | 7200 | 会话超时秒 | +| `auth.require-auth` | true | 是否要求登录 | + +### 购买 + +| 键 | 默认 | 说明 | +|----|------|------| +| `purchase.enabled` | true | 是否允许网页购买 | +| `purchase.max-amount` | 64 | 单次最大份数 | +| `purchase.allow-offline-buy` | false | 是否允许离线购买 | +| `purchase.game-code-ttl-seconds` | 300 | 验证码有效期 | +| `purchase.game-code-max-attempts` | 5 | 验证码最大尝试次数 | + +### 商店 + +| 键 | 默认 | 说明 | +|----|------|------| +| `shop.player-shop-max-buy` | — | 玩家商店单次最大购买 | +| `shop.player-shop-max-stock` | — | 玩家商店最大库存 | +| `shop.player-shop-low-stock-threshold` | — | 低库存阈值 | +| `shop.stock-low-threshold` | 10 | 库存提醒低库存阈值 | +| `shop.alert-cooldown-ms` | 300000 | 库存提醒冷却 | +| `shop.circuit-breaker-threshold` | 8 | 交易失败熔断阈值 | +| `shop.circuit-breaker-duration-ms` | 300000 | 熔断持续时间 | +| `shop.order-max` | 50000 | 订单流水上限 | +| `shop.audit-max` | 5000 | 审计日志上限 | + +### 通知 + +| 键 | 默认 | 说明 | +|----|------|------| +| `notifications.webhook-url` | 空 | 企业微信 webhook,非空才启用通知 | + +## 四、命令 + +| 命令 | 权限 | 说明 | +|------|------|------| +| `/qshopwebui` | 默认 | 主命令 | +| `/qshopwebui code` | 所有玩家 | 生成游戏内一次性验证码(网页交易/查单/收藏用) | +| `/qshopwebui reload` | qshopwebui.reload | 重载配置与数据 | +| `/qshopwebui status` | qshopwebui.reload | 查看运行状态 | + +## 五、构建 + +```bash +mvn clean package +``` + +产物:`target/QShopWebUI-.jar`。版本见 `pom.xml`(当前 `1.1.0-beta.1`), +需与 `plugin.yml` 的 `version` 保持同步(通过资源过滤自动完成)。 + +## 六、发行 + +1. 修改 `pom.xml` 版本号。 +2. 提交并打 tag:`git tag vX.Y.Z && git push origin vX.Y.Z`。 +3. CI 自动构建并发布 Release(见 `.github/workflows/release.yml`)。 + +## 七、常见问题 + +- **网页打开但登录失败**:检查 `auth.admin-username/password`,或 `/qshopwebui reload`。 +- **订单不记录**:确认 `purchase.enabled`、且交易走的是本插件的购买流程。 +- **通知没发**:`notifications.webhook-url` 留空则不启用;填错 URL 会每 60s 冷却告警。 +- **数据目录**:`plugins/QShopWebUI/data/` 下存有订单(orders.json)、审计(audit.json)、 + 经营统计(biz_stats.json)、营业状态(shop_status.json)、收藏(favorites.json)。 \ No newline at end of file diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 0000000..23e662d --- /dev/null +++ b/docs/FEATURES.md @@ -0,0 +1,42 @@ +# 功能清单(P0 / P1 / P2) + +所有功能均按 ROADMAP 实现,全部已接入 HTTP 前端(webroot)与公开扩展 API。 + +## P0 交易核心 + +| 功能 | 说明 | 服务 | +|------|------|------| +| 订单流水 | 每笔成功/失败交易写入独立订单流水(ORD-序号),独立于 trades.json | `OrderStore` | +| 玩家订单查询 | 玩家凭游戏内一次性验证码查询自己的买卖记录 | `EnhancedApi.myOrders` | +| 网页购买 / 出售 | 玩家在线或允许离线时通过网页交易 | `PurchaseService` | + +## P1 经营增强 + +| 功能 | 说明 | 服务 | +|------|------|------| +| 库存提醒 | 出售店低库存/售罄、收购店收购上限告警;支持实时读取容器(live) | `StockAlertService` | +| 比价 | 同物品各商店价格排序、最优价、均价 | `ComparisonService` | +| 收藏 | 玩家收藏常用商店,一键查看 | `FavoritesStore` | +| 批量预览 + 审计 | 管理端审计日志(actor/action/detail),带清空 | `AuditService` | +| 熔断 | 交易连续失败自动熔断,防止恶意刷失败 | `ShopStatusService` | + +## P2 运营能力 + +| 功能 | 说明 | 服务 | +|------|------|------| +| 经营统计 | 按天汇总买/卖金额、数量、订单数,仪表盘 | `BusinessStatsService` | +| 营业状态 | 开/关业、维护模式、隐藏商店、关闭原因 | `ShopStatusService` | +| 通知 | 交易/订单实时推送到企业微信 webhook | `NotificationService` | + +## HTTP 端点(前端已接入) + +- 订单流水:`/api/orders`(管理)、`/api/orders/me`(玩家)、`/api/orders/{id}`、`/api/orders/clear` +- 比价:`/api/compare`、`/api/compare/all` +- 收藏:`/api/favorites`、`/api/favorites/toggle` +- 统计:`/api/stats/business` +- 审计:`/api/audit`、`/api/audit/clear` +- 营业:`/api/status`、`/api/status/open`、`/api/status/maintenance` +- 库存监控:`/api/monitor/stock` +- 通知:`/api/notify/status` + +详见 [API.md](API.md)。 \ No newline at end of file diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..fecf352 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,36 @@ +# QShopWebUI 文档 + +QShopWebUI 是一个运行于 Paper 服务端的商店网页系统插件:直接读取 +QuickShop-Hikari 商店数据,内嵌 Web 服务器(支持与 Minecraft 协议共享同一 +TCP 端口分流),并提供一套公开扩展 API 供其他插件开发附属。 + +- [功能清单](FEATURES.md) —— P0/P1/P2 全部功能说明 +- [扩展 API](API.md) —— 供其他插件开发附属的 Java API + HTTP API 文档 +- [部署 / 配置](DEPLOY.md) —— 安装、配置文件、命令、发行说明 + +## 目录速览 + +| 章节 | 说明 | +|------|------| +| FEATURES.md | 订单流水、玩家订单、库存提醒、比价收藏、批量审计、经营统计、营业状态、通知 | +| API.md | `QShopWebUIAPI` 公开接口 + HTTP 端点 | +| DEPLOY.md | 部署、config.yml 全字段、命令、构建、发行 | + +## 项目结构(核心) + +``` +src/main/java/cn/aqcraft/ +├── QShopWebUIPlugin.java # 主类,暴露 getAPI() +├── PluginConfig.java # 全部配置项 +├── api/ +│ ├── ApiRouter.java # HTTP 路由分发 +│ ├── EnhancedApi.java # P1/P2 增强 HTTP 端点 +│ ├── QShopWebUIAPI.java # 公开扩展 API 接口(其他插件用) +│ └── QShopWebUIImpl.java # 接口实现 +├── order/OrderStore.java # 订单流水存储 +├── service/ # 比价、审计、营业状态、库存提醒、经营统计 +├── notify/NotificationService.java # 企业微信 webhook 通知 +└── data/FavoritesStore.java # 收藏存储 +``` + +webroot/ 打包进 jar 的 `/web/`,是原生 HTML/CSS/JS 前端(无外部依赖)。 \ No newline at end of file diff --git a/src/main/java/cn/aqcraft/PluginConfig.java b/src/main/java/cn/aqcraft/PluginConfig.java index 8ad87b9..8383811 100644 --- a/src/main/java/cn/aqcraft/PluginConfig.java +++ b/src/main/java/cn/aqcraft/PluginConfig.java @@ -37,6 +37,22 @@ public final class PluginConfig { public int gameCodeTtlSeconds = 300; public int gameCodeMaxAttempts = 5; + // === P1/P2 增强功能 === + /** 库存/收购容量提醒阈值(出售店剩余物品数低于此值告警) */ + public int stockLowThreshold = 10; + /** 同一商店同类告警的冷却时间(毫秒) */ + public long alertCooldownMs = 5 * 60 * 1000; + /** 交易连续失败熔断阈值(超过则进入保护,熔断期间拒绝在线交易) */ + public int circuitBreakerThreshold = 8; + /** 熔断持续时间(毫秒) */ + public long circuitBreakerDurationMs = 5 * 60 * 1000; + /** 订单流水上限(条) */ + public int orderMax = 50000; + /** 审计日志上限(条) */ + public int auditMax = 5000; + /** 通知 Webhook 地址(空 = 禁用通知) */ + public String notificationWebhookUrl = ""; + // === 网站显示 === public String serverName = ""; public String serverSubtitle = ""; @@ -74,6 +90,13 @@ public static PluginConfig load(QShopWebUIPlugin plugin) { cfg.allowOfflineBuy = c.getBoolean("purchase.allow-offline-buy", true); cfg.gameCodeTtlSeconds = Math.max(30, c.getInt("purchase.game-code-ttl-seconds", 300)); cfg.gameCodeMaxAttempts = Math.max(1, c.getInt("purchase.game-code-max-attempts", 5)); + cfg.stockLowThreshold = c.getInt("shop.stock-low-threshold", 10); + cfg.alertCooldownMs = Math.max(1000, c.getLong("shop.alert-cooldown-ms", 5 * 60 * 1000)); + cfg.circuitBreakerThreshold = Math.max(1, c.getInt("shop.circuit-breaker-threshold", 8)); + cfg.circuitBreakerDurationMs = Math.max(1000, c.getLong("shop.circuit-breaker-duration-ms", 5 * 60 * 1000)); + cfg.orderMax = Math.max(100, c.getInt("shop.order-max", 50000)); + cfg.auditMax = Math.max(100, c.getInt("shop.audit-max", 5000)); + cfg.notificationWebhookUrl = c.getString("notifications.webhook-url", "").trim(); cfg.serverName = c.getString("server-name", "").trim(); cfg.serverSubtitle = c.getString("server-subtitle", "").trim(); for (String k : new String[]{"home", "buy", "sell", "browse", "shops", "stats"}) { diff --git a/src/main/java/cn/aqcraft/QShopWebUIPlugin.java b/src/main/java/cn/aqcraft/QShopWebUIPlugin.java index 1d83682..ec4f720 100644 --- a/src/main/java/cn/aqcraft/QShopWebUIPlugin.java +++ b/src/main/java/cn/aqcraft/QShopWebUIPlugin.java @@ -1,11 +1,13 @@ package cn.aqcraft; +import cn.aqcraft.api.QShopWebUIAPI; import cn.aqcraft.auth.SessionManager; import cn.aqcraft.auth.GameCodeService; import cn.aqcraft.bridge.AuthMeBridge; import cn.aqcraft.bridge.EconomyBridge; import cn.aqcraft.bridge.LimitedBridge; import cn.aqcraft.bridge.QuickShopBridge; +import cn.aqcraft.data.FavoritesStore; import cn.aqcraft.data.RequestStats; import cn.aqcraft.data.ShopDataService; import cn.aqcraft.data.WebStore; @@ -14,7 +16,14 @@ import cn.aqcraft.listener.PurchaseJoinListener; import cn.aqcraft.listener.ShopDataListener; import cn.aqcraft.listener.ShopRemovalListener; +import cn.aqcraft.notify.NotificationService; +import cn.aqcraft.order.OrderStore; import cn.aqcraft.purchase.PurchaseService; +import cn.aqcraft.service.AuditService; +import cn.aqcraft.service.BusinessStatsService; +import cn.aqcraft.service.ComparisonService; +import cn.aqcraft.service.ShopStatusService; +import cn.aqcraft.service.StockAlertService; import cn.aqcraft.util.Materials; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; @@ -52,6 +61,15 @@ public final class QShopWebUIPlugin extends JavaPlugin implements CommandExecuto private AuthMeBridge authme; private LimitedBridge limited; private GameCodeService gameCodes; + private OrderStore orderStore; + private StockAlertService stockAlerts; + private ShopStatusService shopStatus; + private AuditService audit; + private BusinessStatsService bizStats; + private ComparisonService comparison; + private FavoritesStore favorites; + private NotificationService notifications; + private cn.aqcraft.api.QShopWebUIAPI qsapi; // ============================================================ // 生命周期 @@ -85,6 +103,17 @@ public void onEnable() { authme.reload(); limited = new LimitedBridge(this); limited.reload(); + + orderStore = new OrderStore(this); + orderStore.load(); + stockAlerts = new StockAlertService(this); + shopStatus = new ShopStatusService(this); + audit = new AuditService(this); + bizStats = new BusinessStatsService(this); + comparison = new ComparisonService(); + favorites = new FavoritesStore(this); + notifications = new NotificationService(this); + getServer().getPluginManager().registerEvents(new PurchaseJoinListener(this), this); GameTradeListener.register(this); ShopRemovalListener.register(this); @@ -131,6 +160,11 @@ public void onDisable() { } catch (Throwable ignored) { } } + if (orderStore != null) orderStore.flush(); + if (audit != null) audit.flush(); + if (bizStats != null) bizStats.flush(); + if (shopStatus != null) shopStatus.flush(); + if (favorites != null) favorites.flush(); getLogger().info("QShopWebUI 已停用"); } @@ -282,6 +316,7 @@ public void reloadPlugin() { economy.reload(); if (authme != null) authme.reload(); if (limited != null) limited.reload(); + if (notifications != null) notifications.applyConfig(); stopWeb(); startWeb(); } @@ -360,6 +395,44 @@ public GameCodeService gameCodes() { return gameCodes; } + public OrderStore orders() { + return orderStore; + } + + public StockAlertService stockAlerts() { + return stockAlerts; + } + + public ShopStatusService shopStatus() { + return shopStatus; + } + + public AuditService audit() { + return audit; + } + + public BusinessStatsService stats() { + return bizStats; + } + + public ComparisonService comparison() { + return comparison; + } + + public FavoritesStore favorites() { + return favorites; + } + + public NotificationService notifications() { + return notifications; + } + + /** 供其他插件获取公开扩展 API(开发附属插件用)。 */ + public cn.aqcraft.api.QShopWebUIAPI getAPI() { + if (qsapi == null) qsapi = new cn.aqcraft.api.QShopWebUIImpl(this); + return qsapi; + } + /** 网页修改密码后刷新内存中的配置 */ public void applyAdminPassword(String hashed) { if (config != null) { diff --git a/src/main/java/cn/aqcraft/api/ApiRouter.java b/src/main/java/cn/aqcraft/api/ApiRouter.java index b96848b..a1ba86b 100644 --- a/src/main/java/cn/aqcraft/api/ApiRouter.java +++ b/src/main/java/cn/aqcraft/api/ApiRouter.java @@ -21,6 +21,7 @@ public final class ApiRouter { private final AdminApi adminApi; private final BackupApi backupApi; private final PurchaseApi purchaseApi; + private final EnhancedApi enhancedApi; public ApiRouter(QShopWebUIPlugin plugin) { this.plugin = plugin; @@ -32,6 +33,7 @@ public ApiRouter(QShopWebUIPlugin plugin) { this.adminApi = new AdminApi(plugin); this.backupApi = new BackupApi(plugin); this.purchaseApi = new PurchaseApi(plugin); + this.enhancedApi = new EnhancedApi(plugin); } public HttpResponse handle(HttpRequest req) { @@ -186,6 +188,46 @@ private HttpResponse route(String m, String p, HttpRequest req) { case "/api/wallet": return authApi.wallet(req); + // ================= P1/P2 增强功能 ================= + case "/api/orders": + if ("GET".equals(m)) return enhancedApi.orders(req); + break; + case "/api/orders/me": + return enhancedApi.myOrders(req); + case "/api/orders/clear": + if ("POST".equals(m)) return enhancedApi.ordersClear(req); + break; + case "/api/compare": + return enhancedApi.compare(req); + case "/api/compare/all": + return enhancedApi.compareAll(req); + case "/api/favorites": + if ("GET".equals(m)) return enhancedApi.favoritesList(req); + break; + case "/api/favorites/toggle": + if ("POST".equals(m)) return enhancedApi.favoritesToggle(req); + break; + case "/api/stats/business": + return enhancedApi.businessStats(req); + case "/api/audit": + if ("GET".equals(m)) return enhancedApi.audit(req); + break; + case "/api/audit/clear": + if ("POST".equals(m)) return enhancedApi.auditClear(req); + break; + case "/api/status": + return enhancedApi.shopStatus(req); + case "/api/status/open": + if ("POST".equals(m)) return enhancedApi.setOpen(req); + break; + case "/api/status/maintenance": + if ("POST".equals(m)) return enhancedApi.setMaintenance(req); + break; + case "/api/monitor/stock": + return enhancedApi.stockMonitor(req); + case "/api/notify/status": + return enhancedApi.notifyStatus(req); + case "/api/pages": return "GET".equalsIgnoreCase(m) ? metaApi.pagesGet(req) : metaApi.pagesSet(req); @@ -226,6 +268,13 @@ private HttpResponse route(String m, String p, HttpRequest req) { } } + if (p.startsWith("/api/orders/")) { + String rest = p.substring("/api/orders/".length()); + if (!rest.isEmpty() && "GET".equals(m)) { + return enhancedApi.orderById(req, rest); + } + } + if (p.startsWith("/api/items/")) { String material = p.substring("/api/items/".length()); if (!material.isEmpty() && "GET".equals(m)) { diff --git a/src/main/java/cn/aqcraft/api/EnhancedApi.java b/src/main/java/cn/aqcraft/api/EnhancedApi.java new file mode 100644 index 0000000..3bc2f98 --- /dev/null +++ b/src/main/java/cn/aqcraft/api/EnhancedApi.java @@ -0,0 +1,296 @@ +package cn.aqcraft.api; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import cn.aqcraft.QShopWebUIPlugin; +import cn.aqcraft.data.ShopEntry; +import cn.aqcraft.http.HttpRequest; +import cn.aqcraft.http.HttpResponse; + +import java.util.List; +import java.util.Map; + +/** + * P1/P2 增强功能 API: + * 订单流水、我的订单、比价、收藏、经营统计、审计、营业状态、库存监控、通知。 + */ +public final class EnhancedApi extends ApiBase { + + public EnhancedApi(QShopWebUIPlugin plugin) { + super(plugin); + } + + // ============================================================ + // 订单流水 + // ============================================================ + + /** GET /api/orders 管理端:全部订单(支持 type/player/limit/offset) */ + public HttpResponse orders(HttpRequest req) { + HttpResponse denied = adminOnly(req); + if (denied != null) return denied; + JsonArray arr = new JsonArray(); + for (JsonObject j : plugin.orders().snapshotPaged(req.param("type", null), + req.param("player", null), req.intParam("limit", 50), req.intParam("offset", 0))) { + arr.add(j); + } + JsonObject o = obj(); + o.addProperty("success", true); + o.addProperty("total", plugin.orders().countAll()); + o.add("orders", arr); + return HttpResponse.json(o); + } + + /** GET /api/orders/me 玩家自助查询(需 player + code 验证码) */ + public HttpResponse myOrders(HttpRequest req) { + String player = req.param("player", ""); + String code = req.param("code", ""); + if (player.isEmpty() || code.isEmpty()) { + return HttpResponse.error(400, "需要 player 与 code(游戏内 /qshopwebui code 生成)"); + } + if (!plugin.gameCodes().check(player, code)) { + return HttpResponse.error(403, "验证码无效或已过期"); + } + String type = req.param("type", null); + int limit = req.intParam("limit", 20); + int offset = req.intParam("offset", 0); + JsonArray arr = new JsonArray(); + for (JsonObject j : plugin.orders().byPlayer(player, type, limit, offset)) arr.add(j); + JsonObject o = obj(); + o.addProperty("success", true); + o.addProperty("total", plugin.orders().countByPlayer(player, type)); + o.add("orders", arr); + return HttpResponse.json(o); + } + + /** GET /api/orders/{id} 单笔订单(管理端) */ + public HttpResponse orderById(HttpRequest req, String id) { + HttpResponse denied = adminOnly(req); + if (denied != null) return denied; + JsonObject order = plugin.orders().byId(id); + if (order == null) return HttpResponse.notFound(); + JsonObject o = obj(); + o.addProperty("success", true); + o.add("order", order); + return HttpResponse.json(o); + } + + /** POST /api/orders/clear 清空订单(管理端) */ + public HttpResponse ordersClear(HttpRequest req) { + HttpResponse denied = adminOnly(req); + if (denied != null) return denied; + int cleared = plugin.orders().countAll(); + plugin.orders().clear(); + plugin.audit().log("admin", "orders.clear", "清空订单流水 " + cleared + " 条"); + JsonObject o = obj(); + o.addProperty("success", true); + o.addProperty("cleared", cleared); + return HttpResponse.json(o); + } + + // ============================================================ + // 比价 + // ============================================================ + + /** GET /api/compare?material=xxx 同物品比价 */ + public HttpResponse compare(HttpRequest req) { + String material = req.param("material", ""); + if (material.isEmpty()) return HttpResponse.error(400, "需要 material 参数"); + JsonObject result = plugin.comparison().compare(material, plugin.shopData().shops()); + JsonObject o = obj(); + o.addProperty("success", true); + o.add("result", result); + return HttpResponse.json(o); + } + + /** GET /api/compare/all 全物品聚合(最低卖出/最高收购) */ + public HttpResponse compareAll(HttpRequest req) { + JsonArray agg = plugin.comparison().aggregate(plugin.shopData().shops()); + JsonObject o = obj(); + o.addProperty("success", true); + o.add("items", agg); + return HttpResponse.json(o); + } + + // ============================================================ + // 收藏 + // ============================================================ + + /** GET /api/favorites?player=xxx&code=xxx 玩家收藏列表 */ + public HttpResponse favoritesList(HttpRequest req) { + String player = req.param("player", ""); + String code = req.param("code", ""); + if (player.isEmpty() || code.isEmpty()) { + return HttpResponse.error(400, "需要 player 与 code"); + } + if (!plugin.gameCodes().check(player, code)) { + return HttpResponse.error(403, "验证码无效或已过期"); + } + List ids = plugin.favorites().favorites(player); + JsonArray shops = new JsonArray(); + Map byId = new java.util.LinkedHashMap<>(); + for (ShopEntry e : plugin.shopData().shops()) byId.put(e.shop_id, e); + for (String id : ids) { + ShopEntry e = byId.get(id); + if (e == null) continue; + JsonObject so = new JsonObject(); + so.addProperty("shop_id", e.shop_id); + so.addProperty("item", e.shop_cn_name); + so.addProperty("material", e.material); + so.addProperty("price_display", e.price_display); + so.addProperty("price", e.price); + so.addProperty("type", e.shop_type); + so.addProperty("owner", e.owner_name); + so.addProperty("world", e.world); + so.addProperty("x", e.x); + so.addProperty("y", e.y); + so.addProperty("z", e.z); + shops.add(so); + } + JsonObject o = obj(); + o.addProperty("success", true); + o.add("favorites", shops); + return HttpResponse.json(o); + } + + /** POST /api/favorites/toggle 添加/移除收藏(body: player, code, shop_id) */ + public HttpResponse favoritesToggle(HttpRequest req) { + JsonObject b = body(req); + String player = jstr(b, "player", ""); + String code = jstr(b, "code", ""); + String shopId = jstr(b, "shop_id", ""); + if (player.isEmpty() || code.isEmpty() || shopId.isEmpty()) { + return HttpResponse.error(400, "需要 player、code、shop_id"); + } + if (!plugin.gameCodes().check(player, code)) { + return HttpResponse.error(403, "验证码无效或已过期"); + } + boolean now = plugin.favorites().toggle(player, shopId); + plugin.favorites().flush(); + JsonObject o = obj(); + o.addProperty("success", true); + o.addProperty("favorited", now); + o.addProperty("shop_id", shopId); + return HttpResponse.json(o); + } + + // ============================================================ + // 经营统计 + // ============================================================ + + /** GET /api/stats/business?days=30 经营统计仪表盘 */ + public HttpResponse businessStats(HttpRequest req) { + int days = req.intParam("days", 30); + JsonArray snap = plugin.stats().snapshot(days); + JsonObject totals = plugin.stats().totals(); + JsonObject o = obj(); + o.addProperty("success", true); + o.add("days", snap); + o.add("totals", totals); + return HttpResponse.json(o); + } + + // ============================================================ + // 审计日志 + // ============================================================ + + /** GET /api/audit 审计日志(管理端) */ + public HttpResponse audit(HttpRequest req) { + HttpResponse denied = adminOnly(req); + if (denied != null) return denied; + JsonArray arr = new JsonArray(); + for (JsonObject j : plugin.audit().snapshot(req.intParam("limit", 50), req.intParam("offset", 0))) { + arr.add(j); + } + JsonObject o = obj(); + o.addProperty("success", true); + o.addProperty("total", plugin.audit().count()); + o.add("entries", arr); + return HttpResponse.json(o); + } + + /** POST /api/audit/clear 清空审计(管理端) */ + public HttpResponse auditClear(HttpRequest req) { + HttpResponse denied = adminOnly(req); + if (denied != null) return denied; + plugin.audit().clear(); + JsonObject o = obj(); + o.addProperty("success", true); + return HttpResponse.json(o); + } + + // ============================================================ + // 营业状态 + // ============================================================ + + /** GET /api/status 营业状态 */ + public HttpResponse shopStatus(HttpRequest req) { + JsonObject snap = plugin.shopStatus().snapshot(); + JsonObject o = obj(); + o.addProperty("success", true); + o.add("status", snap); + return HttpResponse.json(o); + } + + /** POST /api/status/open 开/关业(body: open bool;公开可调用) */ + public HttpResponse setOpen(HttpRequest req) { + JsonObject b = body(req); + boolean open = !b.has("open") || b.get("open").getAsBoolean(); + plugin.shopStatus().setOpen(open, "网页切换"); + plugin.audit().log("web", "shop.status", (open ? "开店" : "关店")); + JsonObject o = obj(); + o.addProperty("success", true); + o.addProperty("open", open); + return HttpResponse.json(o); + } + + /** POST /api/status/maintenance 维护模式(管理端) */ + public HttpResponse setMaintenance(HttpRequest req) { + HttpResponse denied = adminOnly(req); + if (denied != null) return denied; + JsonObject b = body(req); + boolean maintenance = jstr(b, "on", "true").equalsIgnoreCase("true"); + plugin.shopStatus().setMaintenance(maintenance); + plugin.audit().log("admin", "shop.maintenance", (maintenance ? "开启维护模式" : "关闭维护模式")); + JsonObject o = obj(); + o.addProperty("success", true); + o.addProperty("maintenance", maintenance); + return HttpResponse.json(o); + } + + // ============================================================ + // 库存监控 + // ============================================================ + + /** GET /api/monitor/stock?live=true 库存告警列表;live=true 走实时读取容器 */ + public HttpResponse stockMonitor(HttpRequest req) { + boolean live = "true".equalsIgnoreCase(req.param("live", "false")); + List shops = plugin.shopData().shops(); + List alerts; + if (live) { + alerts = plugin.stockAlerts().scanLive(shops, plugin.purchases()::stockOf); + } else { + alerts = plugin.stockAlerts().scan(shops); + } + JsonArray arr = new JsonArray(); + for (JsonObject a : alerts) arr.add(a); + JsonObject o = obj(); + o.addProperty("success", true); + o.addProperty("live", live); + o.add("alerts", arr); + return HttpResponse.json(o); + } + + // ============================================================ + // 通知 + // ============================================================ + + /** GET /api/notify/status 通知配置状态 */ + public HttpResponse notifyStatus(HttpRequest req) { + JsonObject o = obj(); + o.addProperty("success", true); + o.addProperty("available", plugin.notifications().available()); + o.addProperty("configured", plugin.notifications().available()); + return HttpResponse.json(o); + } +} \ No newline at end of file diff --git a/src/main/java/cn/aqcraft/api/QShopWebUIAPI.java b/src/main/java/cn/aqcraft/api/QShopWebUIAPI.java new file mode 100644 index 0000000..917eeaf --- /dev/null +++ b/src/main/java/cn/aqcraft/api/QShopWebUIAPI.java @@ -0,0 +1,168 @@ +package cn.aqcraft.api; + +import com.google.gson.JsonObject; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.util.List; +import java.util.UUID; + +/** + * QShopWebUI 公开扩展 API。 + * + *

其他插件可通过 {@code cn.aqcraft.api.QShopWebUIAPI api = + * ((QShopWebUIPlugin) Bukkit.getPluginManager().getPlugin("QShopWebUI")).getAPI();} + * 获取实例,用于查询商店、订单、比价、收藏、营业状态、执行购买等。

+ * + *

该接口为稳定契约;底层实现可能随版本演进,但方法签名保持向后兼容。

+ */ +public interface QShopWebUIAPI { + + /** + * 查询某个商店的快照信息。 + * + * @param shopId QuickShop 商店 ID + * @return 商店信息 JSON;不存在返回 null + */ + JsonObject shop(String shopId); + + /** + * 查询某玩家名下的全部商店。 + * + * @param ownerName 店主名(不区分大小写) + * @return 商店列表 JSON + */ + List shopsByOwner(String ownerName); + + /** + * 查询某种物品当前可用的商店(出售+收购)。 + * + * @param material 物品材质(如 "minecraft:diamond") + * @return 商店列表 JSON + */ + List shopsByMaterial(String material); + + /** + * 同物品比价。 + * + * @param material 物品材质 + * @return 比价结果 JSON:{sellers:[...], buyers:[...], lowest_sell, highest_buy} + */ + JsonObject compare(String material); + + /** + * 记录一条订单(供其他插件把交易写入 QShopWebUI 订单流水)。 + * + * @param idempotencyKey 幂等键;同键重复调用返回已有订单而非重复记账,可传 null + * @param type BUY / SELL + * @param status SUCCESS / FAILED / CANCELLED + * @param shopId 商店 ID + * @param itemName 物品显示名 + * @param material 物品材质 + * @param amount 份数 + * @param items 物品件数 + * @param unitPrice 单价 + * @param total 总额 + * @param player 交易玩家 + * @param owner 店主 + * @param online 玩家是否在线 + * @param reason 备注(失败原因等,可 null) + * @return 订单对象 + */ + JsonObject recordOrder(String idempotencyKey, String type, String status, + String shopId, String itemName, String material, + int amount, long items, double unitPrice, double total, + String player, String owner, boolean online, String reason); + + /** + * 查询某玩家的订单流水。 + * + * @param player 玩家名 + * @param type BUY/SELL,可传 null 表示全部 + * @param limit 条数上限 + * @param offset 偏移 + * @return 订单列表(时间倒序) + */ + List ordersByPlayer(String player, String type, int limit, int offset); + + /** + * 按订单号查单笔订单。 + * + * @param orderId 订单号(如 ORD-123) + * @return 订单 JSON;不存在返回 null + */ + JsonObject order(String orderId); + + /** + * 判断某商店是否已被某玩家收藏。 + */ + boolean isFavorite(String player, String shopId); + + /** + * 添加收藏。 + */ + void addFavorite(String player, String shopId); + + /** + * 移除收藏。 + */ + void removeFavorite(String player, String shopId); + + /** + * 查询玩家收藏的商店列表。 + * + * @return 商店 ID 列表 + */ + List favoriteIds(String player); + + /** + * 商店当前是否营业(含维护模式判断)。 + */ + boolean isOpen(); + + /** + * 是否处于维护模式。 + */ + boolean isMaintenance(); + + /** + * 经营统计:最近 daysN 天。 + * + * @return 按日期数组;每项含 buy/sell 的 amount、total、orders + */ + com.google.gson.JsonArray businessStats(int daysN); + + /** + * 经营统计:累计总量。 + */ + JsonObject businessTotals(); + + /** + * 写入一条审计日志。 + * + * @param actor 操作者(如 "admin"、"web") + * @param action 动作标识 + * @param detail 详情 + */ + void audit(String actor, String action, String detail); + + /** + * 执行一次在线购买(等价于网页购买,需要经济余额;玩家须在线或允许离线购买)。 + * + * @param shopId 商店 ID + * @param playerName 玩家名 + * @param amount 份数 + * @return 交易结果 JSON(success 字段标记是否成功) + */ + JsonObject purchase(String shopId, String playerName, int amount); + + /** + * 执行一次在线出售(玩家出售给收购商店)。 + * + * @param shopId 商店 ID + * @param playerName 玩家名 + * @param amount 份数 + * @return 交易结果 JSON + */ + JsonObject sell(String shopId, String playerName, int amount_minor); +} \ No newline at end of file diff --git a/src/main/java/cn/aqcraft/api/QShopWebUIImpl.java b/src/main/java/cn/aqcraft/api/QShopWebUIImpl.java new file mode 100644 index 0000000..40f5bc8 --- /dev/null +++ b/src/main/java/cn/aqcraft/api/QShopWebUIImpl.java @@ -0,0 +1,145 @@ +package cn.aqcraft.api; + +import cn.aqcraft.QShopWebUIPlugin; +import cn.aqcraft.data.ShopEntry; +import cn.aqcraft.service.ComparisonService; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.List; + +/** {@link QShopWebUIAPI} 的默认实现,桥接插件内部服务。 */ +public class QShopWebUIImpl implements QShopWebUIAPI { + + private final QShopWebUIPlugin plugin; + private final Gson gson = new Gson(); + + public QShopWebUIImpl(QShopWebUIPlugin plugin) { + this.plugin = plugin; + } + + private JsonObject toJson(ShopEntry e) { + return e == null ? null : (JsonObject) gson.toJsonTree(e); + } + + private List toJsonList(List list) { + List out = new ArrayList<>(); + if (list != null) for (ShopEntry e : list) if (e != null) out.add(toJson(e)); + return out; + } + + @Override + public JsonObject shop(String shopId) { + if (shopId == null) return null; + for (ShopEntry e : plugin.shopData().shops()) { + if (shopId.equals(e.shop_id)) return toJson(e); + } + return null; + } + + @Override + public List shopsByOwner(String ownerName) { + if (ownerName == null) return new ArrayList<>(); + List out = new ArrayList<>(); + String owner = ownerName.trim().toLowerCase(); + for (ShopEntry e : plugin.shopData().shops()) { + if (e.owner_name != null && e.owner_name.toLowerCase().equals(owner)) out.add(e); + } + return toJsonList(out); + } + + @Override + public List shopsByMaterial(String material) { + if (material == null) return new ArrayList<>(); + List out = new ArrayList<>(); + for (ShopEntry e : plugin.shopData().shops()) { + if (e.material != null && e.material.equalsIgnoreCase(material)) out.add(e); + } + return toJsonList(out); + } + + @Override + public JsonObject compare(String material) { + if (material == null) return new JsonObject(); + return plugin.comparison().compare(material, plugin.shopData().shops()); + } + + @Override + public JsonObject recordOrder(String idempotencyKey, String type, String status, + String shopId, String itemName, String material, + int amount, long items, double unitPrice, double total, + String player, String owner, boolean online, String reason) { + return plugin.orders().recordOrder(idempotencyKey, type, status, shopId, itemName, + material, amount, items, unitPrice, total, player, owner, online, reason); + } + + @Override + public List ordersByPlayer(String player, String type, int limit, int offset) { + List list = plugin.orders().byPlayer(player, type, limit, offset); + return list == null ? new ArrayList<>() : list; + } + + @Override + public JsonObject order(String orderId) { + return plugin.orders().byId(orderId); + } + + @Override + public boolean isFavorite(String player, String shopId) { + return plugin.favorites().isFavorite(player, shopId); + } + + @Override + public void addFavorite(String player, String shopId) { + plugin.favorites().add(player, shopId); + } + + @Override + public void removeFavorite(String player, String shopId) { + plugin.favorites().remove(player, shopId); + } + + @Override + public List favoriteIds(String player) { + List l = plugin.favorites().favorites(player); + return l == null ? new ArrayList<>() : l; + } + + @Override + public boolean isOpen() { + return plugin.shopStatus().snapshot().get("open").getAsBoolean(); + } + + @Override + public boolean isMaintenance() { + JsonObject s = plugin.shopStatus().snapshot(); + return s.has("maintenance") && s.get("maintenance").getAsBoolean(); + } + + @Override + public JsonArray businessStats(int daysN) { + return plugin.stats().snapshot(daysN); + } + + @Override + public JsonObject businessTotals() { + return plugin.stats().totals(); + } + + @Override + public void audit(String actor, String action, String detail) { + plugin.audit().log(actor, action, detail); + } + + @Override + public JsonObject purchase(String shopId, String playerName, int amount) { + return plugin.purchases().purchaseInternal(shopId, playerName, amount); + } + + @Override + public JsonObject sell(String shopId, String playerName, int amount) { + return plugin.purchases().sellInternal(shopId, playerName, amount); + } +} \ No newline at end of file diff --git a/src/main/java/cn/aqcraft/auth/GameCodeService.java b/src/main/java/cn/aqcraft/auth/GameCodeService.java index d22e0a7..3bf4dee 100644 --- a/src/main/java/cn/aqcraft/auth/GameCodeService.java +++ b/src/main/java/cn/aqcraft/auth/GameCodeService.java @@ -54,6 +54,22 @@ public boolean verifyAndConsume(String playerName, String code) { return true; } + /** 只校验验证码是否有效且属于该玩家,不消费(用于订单查询等只读场景)。校验失败不扣尝试次数。 */ + public boolean check(String playerName, String code) { + if (playerName == null || code == null) return false; + for (Map.Entry candidate : entries.entrySet()) { + OfflinePlayer player = Bukkit.getOfflinePlayer(candidate.getKey()); + if (player.getName() != null && player.getName().equalsIgnoreCase(playerName.trim())) { + Entry entry = candidate.getValue(); + if (entry != null && System.currentTimeMillis() <= entry.expiresAt + && entry.code.equals(code.trim())) { + return true; + } + } + } + return false; + } + private static final class Entry { private final String code; private final long expiresAt; diff --git a/src/main/java/cn/aqcraft/data/FavoritesStore.java b/src/main/java/cn/aqcraft/data/FavoritesStore.java new file mode 100644 index 0000000..4af0208 --- /dev/null +++ b/src/main/java/cn/aqcraft/data/FavoritesStore.java @@ -0,0 +1,107 @@ +package cn.aqcraft.data; + +import cn.aqcraft.QShopWebUIPlugin; +import cn.aqcraft.util.JsonUtil; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 玩家商店收藏:每个玩家可收藏若干 shop_id(喜欢/关注的商店), + * 网页端可快捷查看收藏商店的当前价格与库存。 + * + *

存储于 {@code data/favorites.json}:{@code { "player": [shop_id,...] }}。

+ */ +public final class FavoritesStore { + + private final QShopWebUIPlugin plugin; + private final File file; + private final Map> data = new ConcurrentHashMap<>(); + + public FavoritesStore(QShopWebUIPlugin plugin) { + this.plugin = plugin; + this.file = new File(plugin.getDataFolder(), "data/favorites.json"); + load(); + } + + private void load() { + try { + if (!file.isFile()) return; + JsonObject root = JsonParser.parseString(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)).getAsJsonObject(); + for (Map.Entry e : root.entrySet()) { + Set set = new LinkedHashSet<>(); + if (e.getValue().isJsonArray()) { + for (JsonElement s : e.getValue().getAsJsonArray()) set.add(s.getAsString()); + } + data.put(e.getKey().toLowerCase(), set); + } + } catch (Exception e) { + plugin.getLogger().warning("[收藏] 读取 favorites.json 失败: " + e.getMessage()); + } + } + + private void save() { + JsonObject root = new JsonObject(); + for (Map.Entry> e : data.entrySet()) { + com.google.gson.JsonArray arr = new com.google.gson.JsonArray(); + for (String s : e.getValue()) arr.add(s); + root.add(e.getKey(), arr); + } + try { + Files.write(file.toPath(), JsonUtil.toJson(root).getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + plugin.getLogger().warning("[收藏] 保存 favorites.json 失败: " + e.getMessage()); + } + } + + public void flush() { save(); } + + public List favorites(String player) { + Set s = data.get(player == null ? "" : player.toLowerCase()); + return s == null ? new ArrayList<>() : new ArrayList<>(s); + } + + public void add(String player, String shopId) { + if (player == null || shopId == null) return; + data.computeIfAbsent(player.toLowerCase(), k -> new LinkedHashSet<>()).add(shopId); + save(); + } + + public void remove(String player, String shopId) { + Set s = data.get(player == null ? "" : player.toLowerCase()); + if (s != null) { + s.remove(shopId); + save(); + } + } + + public boolean isFavorite(String player, String shopId) { + Set s = data.get(player == null ? "" : player.toLowerCase()); + return s != null && s.contains(shopId); + } + + /** 切换收藏状态,返回切换后是否已收藏 */ + public boolean toggle(String player, String shopId) { + if (isFavorite(player, shopId)) { + remove(player, shopId); + return false; + } + add(player, shopId); + return true; + } + + public int count(String player) { + Set s = data.get(player == null ? "" : player.toLowerCase()); + return s == null ? 0 : s.size(); + } +} \ No newline at end of file diff --git a/src/main/java/cn/aqcraft/notify/NotificationService.java b/src/main/java/cn/aqcraft/notify/NotificationService.java new file mode 100644 index 0000000..7b12da6 --- /dev/null +++ b/src/main/java/cn/aqcraft/notify/NotificationService.java @@ -0,0 +1,104 @@ +package cn.aqcraft.notify; + +import cn.aqcraft.QShopWebUIPlugin; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 通知适配器:把库存告警 / 订单事件 / 服务器事件通过 Webhook(或可选的通知 URL) + * 推送给外部服务(如 QQ 机器人、钉钉、企业微信等)。 + * + *

启用条件:config.yml 里 {@code notifications.webhook-url} 非空。 + * 发送走异步线程,不阻塞交易主流程;带失败告警冷却,避免刷屏。

+ */ +public final class NotificationService { + + private final QShopWebUIPlugin plugin; + private final HttpClient client; + private final ScheduledExecutorService exec = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "QShopWebUI-Notify"); + t.setDaemon(true); + return t; + }); + private final AtomicLong lastWarnAt = new AtomicLong(); + private final AtomicBoolean warned = new AtomicBoolean(); + private volatile String webhookUrl = ""; + + public NotificationService(QShopWebUIPlugin plugin) { + this.plugin = plugin; + this.client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build(); + applyConfig(); + } + + public void applyConfig() { + String url = plugin.config().notificationWebhookUrl; + this.webhookUrl = url == null ? "" : url.trim(); + warned.set(false); + } + + /** 是否已配置通知地址 */ + public boolean available() { + return webhookUrl != null && !webhookUrl.isEmpty(); + } + + /** + * 推送一条通知(title + body 字段,JSON POST)。 + * 未配置 / 失败静默;连续失败会降级为仅控制台提示(冷却 60s)。 + */ + public void push(String title, String body) { + if (!available()) return; + String url = webhookUrl; + exec.execute(() -> { + try { + String json = "{\"title\":\"" + escape(title) + "\",\"text\":\"" + escape(body) + "\"," + + "\"server\":\"" + escape(plugin.config().serverName.isEmpty() ? "QShopWebUI" : plugin.config().serverName) + "\"}"; + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(Duration.ofSeconds(8)) + .header("Content-Type", "application/json; charset=utf-8") + .header("User-Agent", "QShopWebUI/1.1") + .POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8)) + .build(); + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + int code = resp.statusCode(); + if (code >= 200 && code < 300) { + warned.set(false); + return; + } + long now = System.currentTimeMillis(); + if (warned.compareAndSet(false, true)) { + lastWarnAt.set(now); + plugin.getLogger().warning("[通知] Webhook 返回 " + code + ":" + resp.body()); + } + } catch (Throwable t) { + long now = System.currentTimeMillis(); + if (warned.compareAndSet(false, true) || now - lastWarnAt.get() > 60_000L) { + lastWarnAt.set(now); + plugin.getLogger().warning("[通知] Webhook 发送失败: " + t.getMessage()); + } + } + }); + } + + /** 发送订单事件通知(标题固定,正文含交易摘要) */ + public void order(String type, String item, int amount, double total, String player) { + String cn = "BUY".equalsIgnoreCase(type) ? "购买" : "收购"; + push("【" + cn + "订单】" + item + " ×" + amount, + player + " 完成" + cn + ":\n" + item + " ×" + amount + "\n金额 " + total); + } + + private static String escape(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } +} \ No newline at end of file diff --git a/src/main/java/cn/aqcraft/order/OrderStore.java b/src/main/java/cn/aqcraft/order/OrderStore.java new file mode 100644 index 0000000..449b043 --- /dev/null +++ b/src/main/java/cn/aqcraft/order/OrderStore.java @@ -0,0 +1,280 @@ +package cn.aqcraft.order; + +import cn.aqcraft.QShopWebUIPlugin; +import cn.aqcraft.util.JsonUtil; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * 订单流水存储(独立于 trades.json 的增强订单系统)。 + * + *

相比 WebStore.trades(仅成功交易、上限 10000、无索引),订单系统提供:

+ *
    + *
  • 完整订单号:全局自增 {@code ORD-},稳定可查。
  • + *
  • 失败订单:FAILED 状态也被记录,可审计失败原因。
  • + *
  • 幂等键:同一 (player, shop, 订单键) 重复提交不重复记账。
  • + *
  • 玩家索引:按玩家名快速查询自己的订单。
  • + *
  • 状态机:SUCCESS / FAILED / CANCELLED,可标记。
  • + *
+ * + *

存储于 {@code data/orders.json},异步落盘,带节流。

+ */ +public final class OrderStore { + + private final QShopWebUIPlugin plugin; + private final File file; + private final List orders = new ArrayList<>(); + /** 玩家名(小写) → 订单下标(用于玩家自助查询) */ + private final ConcurrentHashMap> playerIndex = new ConcurrentHashMap<>(); + private final AtomicBoolean savePending = new AtomicBoolean(); + private long seq = 0; + private static final int MAX_ORDERS = 50000; + + public OrderStore(QShopWebUIPlugin plugin) { + this.plugin = plugin; + this.file = new File(new File(plugin.getDataFolder(), "data"), "orders.json"); + } + + // ============================================================ + // 加载 / 保存 + // ============================================================ + + public void load() { + try { + if (!file.isFile()) return; + JsonElement el = JsonParser.parseString(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)); + JsonArray arr = el.isJsonArray() ? el.getAsJsonArray() : new JsonArray(); + for (JsonElement e : arr) { + if (!e.isJsonObject()) continue; + JsonObject o = e.getAsJsonObject(); + orders.add(o); + try { + seq = Math.max(seq, o.get("seq").getAsLong()); + } catch (Exception ignored) { + } + index(o); + } + } catch (Exception e) { + plugin.getLogger().warning("[Order] 读取 orders.json 失败: " + e.getMessage()); + } + } + + private void index(JsonObject o) { + String p = o.has("player") ? o.get("player").getAsString().toLowerCase(Locale.ROOT) : ""; + if (p.isEmpty()) return; + playerIndex.computeIfAbsent(p, k -> new ArrayList<>()).add(orders.size() - 1); + } + + private void save() { + JsonArray arr = new JsonArray(); + synchronized (this) { + for (JsonObject o : orders) arr.add(o); + } + try { + File tmp = new File(file.getParentFile(), "orders.json.tmp"); + Files.write(tmp.toPath(), JsonUtil.toJson(arr).getBytes(StandardCharsets.UTF_8)); + try { + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (Exception e) { + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + } catch (Exception e) { + plugin.getLogger().warning("[Order] 保存 orders.json 失败: " + e.getMessage()); + } + } + + private void scheduleSave() { + if (!savePending.compareAndSet(false, true)) return; + plugin.getServer().getScheduler().runTaskLaterAsynchronously(plugin, () -> { + try { + save(); + } finally { + savePending.set(false); + } + }, 40L); + } + + public void flush() { + try { + save(); + } catch (Throwable ignored) { + } + } + + // ============================================================ + // 下单(核心) + // ============================================================ + + /** + * 记录一笔订单. + * + * @param idempotencyKey 幂等键(如 "WEB-");同键重复调用返回已有订单而非重复记账,可传 null 关闭 + * @return 订单对象;若命中幂等键返回已有订单 + */ + public synchronized JsonObject recordOrder(String idempotencyKey, String type, String status, + String shopId, String item, String material, + int amount, long items, double unitPrice, double total, + String player, String owner, boolean online, String reason) { + if (idempotencyKey != null && !idempotencyKey.isEmpty()) { + JsonObject dup = findByKey(idempotencyKey); + if (dup != null) return dupCommit(dup); + } + long now = System.currentTimeMillis(); + String seqStr = Long.toString(++seq); + String orderId = "ORD-" + seqStr; + JsonObject o = new JsonObject(); + o.addProperty("order_id", orderId); + o.addProperty("seq", seq); + o.addProperty("key", idempotencyKey); + o.addProperty("type", type); // BUY / SELL + o.addProperty("status", status); // SUCCESS / FAILED / CANCELLED + o.addProperty("t", now); + o.addProperty("shop_id", shopId); + o.addProperty("item", item); + o.addProperty("material", material); + o.addProperty("amount", amount); + o.addProperty("items", items); + o.addProperty("unit_price", unitPrice); + o.addProperty("total", total); + o.addProperty("player", player); + o.addProperty("owner", owner); + o.addProperty("online", online); + o.addProperty("source", "web"); + if (reason != null && !reason.isEmpty()) o.addProperty("reason", reason); + orders.add(o); + while (orders.size() > MAX_ORDERS) { + orders.remove(0); + } + // 重建索引(简单起见全量重建;订单量小,性能可接受) + rebuildIndex(); + scheduleSave(); + return o; + } + + private JsonObject findByKey(String key) { + for (int i = orders.size() - 1; i >= 0; i--) { + JsonObject o = orders.get(i); + if (key.equals(o.has("key") ? o.get("key").getAsString() : "")) return o; + } + return null; + } + + private JsonObject dupCommit(JsonObject o) { + // 幂等命中:返回已有订单(带 duplicate 标记,供 API 层识别) + JsonObject r = o.deepCopy(); + r.addProperty("duplicate", true); + return r; + } + + private void rebuildIndex() { + playerIndex.clear(); + for (int i = 0; i < orders.size(); i++) { + JsonObject o = orders.get(i); + String p = o.has("player") ? o.get("player").getAsString().toLowerCase(Locale.ROOT) : ""; + if (p.isEmpty()) continue; + playerIndex.computeIfAbsent(p, k -> new ArrayList<>()).add(i); + } + } + + // ============================================================ + // 查询 + // ============================================================ + + /** 按订单号查单条;不存在返回 null */ + public synchronized JsonObject byId(String orderId) { + if (orderId == null) return null; + for (int i = orders.size() - 1; i >= 0; i--) { + if (orderId.equals(orders.get(i).has("order_id") ? orders.get(i).get("order_id").getAsString() : "")) { + return orders.get(i).deepCopy(); + } + } + return null; + } + + /** 玩家自助查询:按玩家名 + 可选过滤 */ + public synchronized List byPlayer(String player, String type, int limit, int offset) { + String key = player == null ? "" : player.toLowerCase(Locale.ROOT); + List idx = playerIndex.get(key); + if (idx == null || idx.isEmpty()) return new ArrayList<>(); + String upType = type == null ? "" : type.toUpperCase(Locale.ROOT); + List out = new ArrayList<>(); + // 索引下标是按加入顺序的,倒序遍历得到时间倒序 + for (int i = idx.size() - 1; i >= 0; i--) { + JsonObject o = orders.get(idx.get(i)); + if (o == null) continue; + if (!upType.isEmpty() && !upType.equals(o.has("type") ? o.get("type").getAsString() : "")) continue; + out.add(o.deepCopy()); + } + // 分页 + if (offset < 0) offset = 0; + int end = Math.min(out.size(), offset + Math.max(0, limit)); + return new ArrayList<>(out.subList(Math.min(offset, out.size()), end)); + } + + /** 玩家订单总数 */ + public synchronized int countByPlayer(String player, String type) { + String key = player == null ? "" : player.toLowerCase(Locale.ROOT); + List idx = playerIndex.get(key); + if (idx == null || idx.isEmpty()) return 0; + String upType = type == null ? "" : type.toUpperCase(Locale.ROOT); + int n = 0; + for (int i : idx) { + JsonObject o = orders.get(i); + if (o == null) continue; + if (!upType.isEmpty() && !upType.equals(o.has("type") ? o.get("type").getAsString() : "")) continue; + n++; + } + return n; + } + + /** 全部订单快照(管理端/统计用) */ + public synchronized List snapshot() { + return new ArrayList<>(orders); + } + + /** 全部订单数量 */ + public synchronized int countAll() { + return orders.size(); + } + + /** 管理端分页查询:按可选 type / player 过滤,时间倒序 */ + public synchronized List snapshotPaged(String type, String player, int limit, int offset) { + String upType = type == null ? "" : type.toUpperCase(Locale.ROOT); + String p = player == null ? "" : player.toLowerCase(Locale.ROOT); + List all = new ArrayList<>(); + for (int i = orders.size() - 1; i >= 0; i--) { + JsonObject o = orders.get(i); + if (o == null) continue; + if (!upType.isEmpty() && !upType.equals(o.has("type") ? o.get("type").getAsString() : "")) continue; + if (!p.isEmpty()) { + String op = o.has("player") ? o.get("player").getAsString().toLowerCase(Locale.ROOT) : ""; + if (!p.equals(op)) continue; + } + all.add(o.deepCopy()); + } + if (offset < 0) offset = 0; + int end = Math.min(all.size(), offset + Math.max(0, limit)); + return new ArrayList<>(all.subList(Math.min(offset, all.size()), end)); + } + + public synchronized void clear() { + orders.clear(); + playerIndex.clear(); + seq = 0; + save(); + } +} \ No newline at end of file diff --git a/src/main/java/cn/aqcraft/purchase/PurchaseService.java b/src/main/java/cn/aqcraft/purchase/PurchaseService.java index 4041bc9..7945867 100644 --- a/src/main/java/cn/aqcraft/purchase/PurchaseService.java +++ b/src/main/java/cn/aqcraft/purchase/PurchaseService.java @@ -67,7 +67,7 @@ public JsonObject purchase(String shopId, String playerName, int amount, String try { return plugin.bridge().runOnMain(() -> doPurchase(entry, name, amt)); } catch (Throwable t) { - return err("交易执行失败: " + t.getMessage()); + return failRecord("BUY", entry, name, amt, "交易执行失败: " + t.getMessage()); } } @@ -101,7 +101,7 @@ public JsonObject sell(String shopId, String playerName, int amount, String code try { return plugin.bridge().runOnMain(() -> doSell(entry, name, amt)); } catch (Throwable t) { - return err("交易执行失败: " + t.getMessage()); + return failRecord("SELL", entry, name, amt, "交易执行失败: " + t.getMessage()); } } @@ -212,14 +212,64 @@ public JsonObject limitInfo(String shopId, String playerName) { } } + /** + * 内部程序化购买(供扩展 API / 其他插件调用)。 + * 跳过验证码校验——调用方为受信任的插件上下文。 + */ + public JsonObject purchaseInternal(String shopId, String playerName, int amount) { + if (shopId == null || shopId.trim().isEmpty()) return err("缺少商店 ID"); + if (playerName == null || playerName.trim().isEmpty()) return err("请输入你的游戏 ID"); + final String name = playerName.trim(); + int max = plugin.config().purchaseMaxAmount; + final int amt = Math.max(1, Math.min(amount <= 0 ? 1 : amount, max)); + ShopEntry found = null; + for (ShopEntry s : plugin.shopData().shops()) { + if (shopId.trim().equals(s.shop_id)) { found = s; break; } + } + if (found == null) return err("商店不存在或数据未同步"); + if (!found.isSelling()) return err("这是收购商店,请使用 sell"); + if (!(found.price > 0)) return err("该商店价格无效"); + final ShopEntry entry = found; + try { + return plugin.bridge().runOnMain(() -> doPurchase(entry, name, amt)); + } catch (Throwable t) { + return failRecord("BUY", entry, name, amt, "交易执行失败: " + t.getMessage()); + } + } + + /** + * 内部程序化出售(供扩展 API / 其他插件调用)。 + * 跳过验证码校验——调用方为受信任的插件上下文。 + */ + public JsonObject sellInternal(String shopId, String playerName, int amount) { + if (shopId == null || shopId.trim().isEmpty()) return err("缺少商店 ID"); + if (playerName == null || playerName.trim().isEmpty()) return err("请输入你的游戏 ID"); + final String name = playerName.trim(); + int max = plugin.config().purchaseMaxAmount; + final int amt = Math.max(1, Math.min(amount <= 0 ? 1 : amount, max)); + ShopEntry found = null; + for (ShopEntry s : plugin.shopData().shops()) { + if (shopId.trim().equals(s.shop_id)) { found = s; break; } + } + if (found == null) return err("商店不存在或数据未同步"); + if (found.isSelling()) return err("这是出售商店,请使用 purchase"); + if (!(found.price > 0)) return err("该商店价格无效"); + final ShopEntry entry = found; + try { + return plugin.bridge().runOnMain(() -> doSell(entry, name, amt)); + } catch (Throwable t) { + return failRecord("SELL", entry, name, amt, "交易执行失败: " + t.getMessage()); + } + } + private JsonObject doPurchase(ShopEntry e, String name, int amount) { Player buyer = Bukkit.getPlayerExact(name); boolean online = buyer != null && buyer.isOnline(); if (!online && !plugin.config().allowOfflineBuy) { - return err("玩家 " + name + " 不在线(离线购买已被服务器关闭:config.yml purchase.allow-offline-buy)"); + return failRecord("BUY", e, name, amount, "玩家 " + name + " 不在线(离线购买已被服务器关闭:config.yml purchase.allow-offline-buy)"); } EconomyBridge eco = plugin.economy(); - if (!eco.available()) return err("服务器未安装经济插件(需要 Vault 支持)"); + if (!eco.available()) return failRecord("BUY", e, name, amount, "服务器未安装经济插件(需要 Vault 支持)"); double unit = e.price; double total = round2(unit * amount); @@ -230,7 +280,7 @@ private JsonObject doPurchase(ShopEntry e, String name, int amount) { // 样品物品:容器实物(NBT 最完整)→ QuickShop 数据 → 基础材质;玩家头皮肤双源补齐 ItemStack sample = resolveSample(e, shop, chestInv); - if (sample == null) return err("无法确定该商店的物品"); + if (sample == null) return failRecord("BUY", e, name, amount, "无法确定该商店的物品"); long needItems = (long) amount * Math.max(1, e.stacking_amount); @@ -239,31 +289,31 @@ private JsonObject doPurchase(ShopEntry e, String name, int amount) { int available = countItems(chestInv, sample); if (available < needItems) { long canBuy = available / Math.max(1, e.stacking_amount); - return err("商店库存不足:最多可购买 " + canBuy + " 份"); + return failRecord("BUY", e, name, amount, "商店库存不足:最多可购买 " + canBuy + " 份"); } } // 付款人(在线用 Player;离线用缓冲的离线账户) OfflinePlayer payer = online ? buyer : resolvePlayer(name); if (payer == null) { - return err("找不到玩家 " + name + " 的账户(需至少登录过一次服务器)"); + return failRecord("BUY", e, name, amount, "找不到玩家 " + name + " 的账户(需至少登录过一次服务器)"); } // 限购(QuickShop「Limited」扩展):与游戏内共用每人每周期额度 if (shop != null && plugin.limited().available()) { String deny = plugin.limited().checkTrade(shop, payer.getUniqueId(), amount); - if (deny != null) return err(deny); + if (deny != null) return failRecord("BUY", e, name, amount, deny); } // 余额检查 double balance = eco.balance(payer); if (balance < total) { - return err("余额不足:需要 " + total + ",当前 " + round2(balance)); + return failRecord("BUY", e, name, amount, "余额不足:需要 " + total + ",当前 " + round2(balance)); } // 扣款 if (!eco.withdraw(payer, total)) { - return err("扣款失败(请检查经济插件)"); + return failRecord("BUY", e, name, amount, "扣款失败(请检查经济插件)"); } try { @@ -314,6 +364,7 @@ private JsonObject doPurchase(ShopEntry e, String name, int amount) { trade.addProperty("owner", e.owner_name); trade.addProperty("online", online); plugin.store().addTrade(trade); + recordSuccess("BUY", e, name, amount, needItems, unit, total, online); // 限购计数(+本次数量,与游戏内共用同一计数) if (shop != null && plugin.limited().available()) { @@ -334,7 +385,7 @@ private JsonObject doPurchase(ShopEntry e, String name, int amount) { eco.deposit(payer, total); // 回滚退款 } catch (Throwable ignored) { } - return err("交易过程中出错,已自动退款(" + t.getMessage() + ")"); + return failRecord("BUY", e, name, amount, "交易过程中出错,已自动退款(" + t.getMessage() + ")"); } } @@ -362,10 +413,10 @@ public static OfflinePlayer resolvePlayer(String name) { private JsonObject doSell(ShopEntry e, String name, int amount) { Player seller = Bukkit.getPlayerExact(name); if (seller == null || !seller.isOnline()) { - return err("玩家 " + name + " 不在线(在线收购需要玩家在游戏内)"); + return failRecord("SELL", e, name, amount, "玩家 " + name + " 不在线(在线收购需要玩家在游戏内)"); } EconomyBridge eco = plugin.economy(); - if (!eco.available()) return err("服务器未安装经济插件(需要 Vault 支持)"); + if (!eco.available()) return failRecord("SELL", e, name, amount, "服务器未安装经济插件(需要 Vault 支持)"); double unit = e.price; double total = round2(unit * amount); @@ -375,7 +426,7 @@ private JsonObject doSell(ShopEntry e, String name, int amount) { Inventory chestInv = shop == null ? null : resolveInventory(shop); ItemStack sample = resolveSample(e, shop, chestInv); - if (sample == null) return err("无法确定该商店的物品"); + if (sample == null) return failRecord("SELL", e, name, amount, "无法确定该商店的物品"); int stack = Math.max(1, e.stacking_amount); long needItems = (long) amount * stack; @@ -384,14 +435,15 @@ private JsonObject doSell(ShopEntry e, String name, int amount) { int available = countItems(seller.getInventory(), sample); if (available < needItems) { long canSell = available / stack; - return err("背包里没有足够的「" + e.shop_cn_name + "」:当前可卖 " + canSell + " 份(需要 " + needItems + " 个,现有 " + available + " 个)"); + return failRecord("SELL", e, name, amount, + "背包里没有足够的「" + e.shop_cn_name + "」:当前可卖 " + canSell + " 份(需要 " + needItems + " 个,现有 " + available + " 个)"); } // 2) 非系统商店:容器容量(仅容器可访问时检查)+ 店主余额 OfflinePlayer owner = null; if (!e.system_shop) { if (chestInv != null && !canFit(chestInv, sample, needItems)) { - return err("商店容器已满,暂时无法收购更多"); + return failRecord("SELL", e, name, amount, "商店容器已满,暂时无法收购更多"); } if (e.owner_uuid != null) { try { @@ -400,13 +452,13 @@ private JsonObject doSell(ShopEntry e, String name, int amount) { } } if (owner != null && eco.balance(owner) < total) { - return err("店主余额不足,无法支付 " + total); + return failRecord("SELL", e, name, amount, "店主余额不足,无法支付 " + total); } } // 3) 店主扣款(系统商店不扣) if (owner != null && !eco.withdraw(owner, total)) { - return err("店主扣款失败(请检查经济插件)"); + return failRecord("SELL", e, name, amount, "店主扣款失败(请检查经济插件)"); } try { @@ -425,7 +477,7 @@ private JsonObject doSell(ShopEntry e, String name, int amount) { if (!e.system_shop && chestInv != null) removeItems(chestInv, sample, needItems); deliverItems(seller, sample, needItems); if (owner != null) eco.deposit(owner, total); - return err("给你打款失败,交易已取消"); + return failRecord("SELL", e, name, amount, "给你打款失败,交易已取消"); } seller.sendMessage("§a[在线收购] §f成功出售 §e" + amount + "§f 份 §b" + e.shop_cn_name @@ -450,6 +502,7 @@ private JsonObject doSell(ShopEntry e, String name, int amount) { trade.addProperty("owner", e.owner_name); trade.addProperty("online", true); plugin.store().addTrade(trade); + recordSuccess("SELL", e, name, amount, needItems, unit, total, true); JsonObject o = new JsonObject(); o.addProperty("success", true); @@ -461,7 +514,7 @@ private JsonObject doSell(ShopEntry e, String name, int amount) { o.addProperty("message", "出售成功,报酬已到账"); return o; } catch (Throwable t) { - return err("交易过程中出错(" + t.getMessage() + ")"); + return failRecord("SELL", e, name, amount, "交易过程中出错(" + t.getMessage() + ")"); } } @@ -701,4 +754,51 @@ private static JsonObject err(String msg) { o.addProperty("error", msg); return o; } + + // ============================================================ + // 订单记录(P0 订单流水) + // ============================================================ + + /** 记录成功订单 + 经营统计 + 通知;返回订单对象(供调用方复用) */ + private com.google.gson.JsonObject recordSuccess(String type, ShopEntry e, String name, + int amount, long items, double unit, double total, boolean online) { + String key = "WEB-" + UUID.randomUUID(); + com.google.gson.JsonObject order = plugin.orders().recordOrder(key, type, "SUCCESS", + e.shop_id, e.shop_cn_name, e.material, amount, items, unit, total, name, e.owner_name, online, null); + // 经营统计:失败不计入 + plugin.stats().record(type, amount, total); + // 通知(若配置了 Webhook) + if (plugin.notifications().available()) { + plugin.notifications().order(type, e.shop_cn_name, amount, total, name); + } + return order; + } + + /** 记录失败订单并返回失败响应(幂等键为 null:每次失败都记一条,便于审计失败原因) */ + private JsonObject failRecord(String type, ShopEntry e, String name, int amount, String reason) { + try { + long items = (long) amount * Math.max(1, e.stacking_amount); + plugin.orders().recordOrder(null, type, "FAILED", + e.shop_id, e.shop_cn_name, e.material, amount, items, e.price, round2(e.price * amount), + name, e.owner_name, false, reason); + } catch (Throwable ignored) { + } + return err(reason); + } + + /** 读取某商店容器内该物品的件数(null = 容器不可访问 / 系统商店);用于网页「监控」页实时库存 */ + public Long stockOf(cn.aqcraft.data.ShopEntry e) { + try { + if (e == null || e.system_shop) return null; + long id = parseLong(e.shop_id); + Object shop = id > 0 ? plugin.bridge().getShopById(id) : null; + org.bukkit.inventory.Inventory chestInv = shop == null ? null : resolveInventory(shop); + if (chestInv == null) return null; + ItemStack sample = resolveSample(e, shop, chestInv); + if (sample == null) return null; + return (long) countItems(chestInv, sample); + } catch (Throwable t) { + return null; + } + } } diff --git a/src/main/java/cn/aqcraft/service/AuditService.java b/src/main/java/cn/aqcraft/service/AuditService.java new file mode 100644 index 0000000..5a691a4 --- /dev/null +++ b/src/main/java/cn/aqcraft/service/AuditService.java @@ -0,0 +1,120 @@ +package cn.aqcraft.service; + +import cn.aqcraft.QShopWebUIPlugin; +import cn.aqcraft.util.JsonUtil; +import com.google.gson.JsonObject; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 审计日志:记录所有管理端操作(批量购买/出售预览、实际执行、隐藏/显示商店、清空数据、重载等)。 + * + *

存储于 {@code data/audit.json}(最多 {@value MAX_AUDIT} 条,滚动淘汰)。 + * 所有 {@code /api/admin/*}、批量操作、营业状态变更都通过 {@link #log} 记录, + * 管理端可在网页「审计」页查看谁在什么时间做了什么。

+ */ +public final class AuditService { + + private final QShopWebUIPlugin plugin; + private final File file; + private final List logs = new ArrayList<>(); + private final AtomicLong seq = new AtomicLong(); + private final AtomicBoolean savePending = new AtomicBoolean(); + private static final int MAX_AUDIT = 5000; + + public AuditService(QShopWebUIPlugin plugin) { + this.plugin = plugin; + this.file = new File(plugin.getDataFolder(), "data/audit.json"); + load(); + } + + private void load() { + try { + if (!file.isFile()) return; + com.google.gson.JsonElement el = JsonUtil.parse(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)); + if (el.isJsonArray()) { + for (com.google.gson.JsonElement e : el.getAsJsonArray()) { + if (e.isJsonObject()) logs.add(e.getAsJsonObject()); + } + } + } catch (Exception e) { + plugin.getLogger().warning("[审计] 读取 audit.json 失败: " + e.getMessage()); + } + } + + /** + * 写一条审计日志。 + * + * @param actor 操作者(如 "admin" / 玩家名) + * @param action 动作(如 "batch-buy-preview"、"batch-buy-execute"、"shop-hide"、"trade-clear") + * @param detail 可读详情 + */ + public synchronized void log(String actor, String action, String detail) { + JsonObject o = new JsonObject(); + long id = seq.incrementAndGet(); + o.addProperty("id", id); + o.addProperty("t", System.currentTimeMillis()); + o.addProperty("actor", actor == null ? "" : actor); + o.addProperty("action", action == null ? "" : action); + o.addProperty("detail", detail == null ? "" : detail); + logs.add(o); + while (logs.size() > MAX_AUDIT) logs.remove(0); + scheduleSave(); + } + + private void scheduleSave() { + if (!savePending.compareAndSet(false, true)) return; + plugin.getServer().getScheduler().runTaskLaterAsynchronously(plugin, () -> { + try { + save(); + } finally { + savePending.set(false); + } + }, 40L); + } + + public void save() { + try { + List copy; + synchronized (this) { + copy = new ArrayList<>(logs); + } + com.google.gson.JsonArray arr = new com.google.gson.JsonArray(); + for (JsonObject o : copy) arr.add(o); + File tmp = new File(file.getParentFile(), "audit.json.tmp"); + Files.write(tmp.toPath(), JsonUtil.toJson(arr).getBytes(StandardCharsets.UTF_8)); + Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (Exception e) { + plugin.getLogger().warning("[审计] 保存 audit.json 失败: " + e.getMessage()); + } + } + + public void flush() { + save(); + } + + /** 审计日志快照(时间倒序) */ + public synchronized List snapshot(int limit, int offset) { + List out = new ArrayList<>(); + for (int i = logs.size() - 1; i >= 0; i--) out.add(logs.get(i).deepCopy()); + if (offset < 0) offset = 0; + int end = Math.min(out.size(), offset + Math.max(1, limit)); + if (offset > out.size()) return new ArrayList<>(); + return new ArrayList<>(out.subList(offset, end)); + } + + public synchronized int count() { return logs.size(); } + + public synchronized void clear() { + logs.clear(); + seq.set(0); + save(); + } +} \ No newline at end of file diff --git a/src/main/java/cn/aqcraft/service/BusinessStatsService.java b/src/main/java/cn/aqcraft/service/BusinessStatsService.java new file mode 100644 index 0000000..d851fad --- /dev/null +++ b/src/main/java/cn/aqcraft/service/BusinessStatsService.java @@ -0,0 +1,180 @@ +package cn.aqcraft.service; + +import cn.aqcraft.QShopWebUIPlugin; +import cn.aqcraft.util.JsonUtil; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 经营统计:独立于订单/流水的按天聚合统计(购买 / 收购分开,失败不计入)。 + * + *

每个完成的交易(SUCCESS)都会调用 {@link #record} 累加;按自然日分桶。 + * 网页「统计」页通过 {@link #snapshot} 拿天级别数据画折线图。

+ * + *

存储于 {@code data/biz_stats.json}:{@code { "days": { "2026-10-01": { buy:{amount,total,orders}, sell:{...} } } }}。

+ */ +public final class BusinessStatsService { + + private final QShopWebUIPlugin plugin; + private final File file; + private final Map days = new ConcurrentHashMap<>(); + + public BusinessStatsService(QShopWebUIPlugin plugin) { + this.plugin = plugin; + this.file = new File(plugin.getDataFolder(), "data/biz_stats.json"); + load(); + } + + private static final class Day { + long buyAmount = 0, buyTotal = 0, buyOrders = 0; + long sellAmount = 0, sellTotal = 0, sellOrders = 0; + } + + private void load() { + try { + if (!file.isFile()) return; + JsonObject root = JsonParser.parseString(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)).getAsJsonObject(); + JsonObject d = root.has("days") ? root.getAsJsonObject("days") : new JsonObject(); + for (Map.Entry e : d.entrySet()) { + Day day = new Day(); + JsonObject o = e.getValue().getAsJsonObject(); + readDay(day, o); + days.put(e.getKey(), day); + } + } catch (Exception e) { + plugin.getLogger().warning("[统计] 读取 biz_stats.json 失败: " + e.getMessage()); + } + } + + private void readDay(Day day, JsonObject o) { + if (o.has("buy")) readSide(day, o.getAsJsonObject("buy"), true); + if (o.has("sell")) readSide(day, o.getAsJsonObject("sell"), false); + } + + private void readSide(Day day, JsonObject s, boolean buy) { + if (buy) { + day.buyAmount = s.has("amount") ? s.get("amount").getAsLong() : 0; + day.buyTotal = s.has("total") ? s.get("total").getAsLong() : 0; + day.buyOrders = s.has("orders") ? s.get("orders").getAsLong() : 0; + } else { + day.sellAmount = s.has("amount") ? s.get("amount").getAsLong() : 0; + day.sellTotal = s.has("total") ? s.get("total").getAsLong() : 0; + day.sellOrders = s.has("orders") ? s.get("orders").getAsLong() : 0; + } + } + + private void save() { + JsonObject root = new JsonObject(); + JsonObject d = new JsonObject(); + for (Map.Entry e : days.entrySet()) { + Day day = e.getValue(); + JsonObject o = new JsonObject(); + o.add("buy", side(day, true)); + o.add("sell", side(day, false)); + d.add(e.getKey(), o); + } + root.add("days", d); + try { + Files.write(file.toPath(), JsonUtil.toJson(root).getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + plugin.getLogger().warning("[统计] 保存 biz_stats.json 失败: " + e.getMessage()); + } + } + + private JsonObject side(Day day, boolean buy) { + JsonObject o = new JsonObject(); + if (buy) { + o.addProperty("amount", day.buyAmount); + o.addProperty("total", day.buyTotal); + o.addProperty("orders", day.buyOrders); + } else { + o.addProperty("amount", day.sellAmount); + o.addProperty("total", day.sellTotal); + o.addProperty("orders", day.sellOrders); + } + return o; + } + + public void flush() { save(); } + + private static String today() { + Calendar c = Calendar.getInstance(); + return String.format("%04d-%02d-%02d", c.get(Calendar.YEAR), c.get(Calendar.MONTH) + 1, c.get(Calendar.DAY_OF_MONTH)); + } + + /** + * 记录一笔成功交易。 + * @param type BUY / SELL + * @param amount 交易份数 + * @param total 交易总金额(向上取整存整数,避免浮点误差) + */ + public void record(String type, int amount, double total) { + Day day = days.computeIfAbsent(today(), k -> new Day()); + long amt = amount; + long tot = Math.round(total); + if ("SELL".equalsIgnoreCase(type)) { + day.sellAmount += amt; + day.sellTotal += tot; + day.sellOrders++; + } else { + day.buyAmount += amt; + day.buyTotal += tot; + day.buyOrders++; + } + // 每次成功记录后都落盘(量小,直接同步写) + save(); + } + + /** 返回最近 N 天的统计(时间正序,供折线图)。 */ + public JsonArray snapshot(int daysN) { + List keys = new ArrayList<>(days.keySet()); + keys.sort(String::compareTo); + if (daysN > 0 && keys.size() > daysN) keys = keys.subList(keys.size() - daysN, keys.size()); + JsonArray arr = new JsonArray(); + for (String k : keys) { + Day day = days.get(k); + JsonObject o = new JsonObject(); + o.addProperty("day", k); + o.addProperty("buy_amount", day.buyAmount); + o.addProperty("buy_total", day.buyTotal); + o.addProperty("buy_orders", day.buyOrders); + o.addProperty("sell_amount", day.sellAmount); + o.addProperty("sell_total", day.sellTotal); + o.addProperty("sell_orders", day.sellOrders); + arr.add(o); + } + return arr; + } + + /** 汇总累计值 */ + public JsonObject totals() { + long buyAmt = 0, buyTot = 0, buyOrd = 0, sellAmt = 0, sellTot = 0, sellOrd = 0; + for (Day d : days.values()) { + buyAmt += d.buyAmount; + buyTot += d.buyTotal; + buyOrd += d.buyOrders; + sellAmt += d.sellAmount; + sellTot += d.sellTotal; + sellOrd += d.sellOrders; + } + JsonObject o = new JsonObject(); + o.addProperty("buy_amount", buyAmt); + o.addProperty("buy_total", buyTot); + o.addProperty("buy_orders", buyOrd); + o.addProperty("sell_amount", sellAmt); + o.addProperty("sell_total", sellTot); + o.addProperty("sell_orders", sellOrd); + return o; + } +} \ No newline at end of file diff --git a/src/main/java/cn/aqcraft/service/ComparisonService.java b/src/main/java/cn/aqcraft/service/ComparisonService.java new file mode 100644 index 0000000..7575691 --- /dev/null +++ b/src/main/java/cn/aqcraft/service/ComparisonService.java @@ -0,0 +1,113 @@ +package cn.aqcraft.service; + +import cn.aqcraft.data.ShopEntry; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** + * 同物品比价:按 material 聚合所有商店,给出价格排序、最优价、均价,供网页「比价」页使用。 + */ +public final class ComparisonService { + + /** 对某物品的所有商店做比价聚合,返回已排序的比价结果。 */ + public JsonObject compare(String material, List shops) { + List match = new ArrayList<>(); + for (ShopEntry e : shops) { + if (e.material == null) continue; + if (material != null && !material.isEmpty() && !e.material.equalsIgnoreCase(material)) continue; + match.add(e); + } + // 出售店按单价升序(买便宜),收购店按单价降序(卖高价) + match.sort((a, b) -> { + boolean sa = a.isSelling(), sb = b.isSelling(); + if (sa && sb) return Double.compare(a.price, b.price); + if (!sa && !sb) return Double.compare(b.price, a.price); + // 出售店在前 + return sa ? -1 : 1; + }); + + JsonObject out = new JsonObject(); + out.addProperty("material", material); + out.addProperty("count", match.size()); + JsonArray sellers = new JsonArray(); + JsonArray buyers = new JsonArray(); + double minSell = Double.MAX_VALUE, maxBuy = -1; + int sellN = 0, buyN = 0; + for (ShopEntry e : match) { + JsonObject j = shopJson(e); + if (e.isSelling()) { + sellers.add(j); + minSell = Math.min(minSell, e.price); + sellN++; + } else { + buyers.add(j); + maxBuy = Math.max(maxBuy, e.price); + buyN++; + } + } + out.add("sellers", sellers); + out.add("buyers", buyers); + out.addProperty("lowest_sell", sellN > 0 ? minSell : 0); + out.addProperty("highest_buy", buyN > 0 ? maxBuy : 0); + out.addProperty("sell_count", sellN); + out.addProperty("buy_count", buyN); + return out; + } + + private JsonObject shopJson(ShopEntry e) { + JsonObject j = new JsonObject(); + j.addProperty("shop_id", e.shop_id); + j.addProperty("item", e.shop_cn_name); + j.addProperty("material", e.material); + j.addProperty("price", e.price); + j.addProperty("price_display", e.price_display); + j.addProperty("owner", e.owner_name); + j.addProperty("type", e.shop_type); + j.addProperty("world", e.world); + j.addProperty("x", e.x); + j.addProperty("y", e.y); + j.addProperty("z", e.z); + j.addProperty("stacking_amount", e.stacking_amount); + j.addProperty("system_shop", e.is_system_shop != null && e.is_system_shop); + return j; + } + + /** 聚合全量:按 material 列出每个物品的最低卖出价 / 最高收购价。 */ + public JsonArray aggregate(List shops) { + Map map = new TreeMap<>(); // material -> [lowest_sell, highest_buy, sell_n, buy_n] + Map names = new TreeMap<>(); + for (ShopEntry e : shops) { + if (e.material == null) continue; + double[] a = map.computeIfAbsent(e.material, k -> new double[]{Double.MAX_VALUE, -1, 0, 0}); + if (e.material.equals("AIR")) continue; + if (names.get(e.material) == null && e.shop_cn_name != null) names.put(e.material, e.shop_cn_name); + if (e.isSelling()) { + a[0] = Math.min(a[0], e.price); + a[2]++; + } else { + a[1] = Math.max(a[1], e.price); + a[3]++; + } + } + JsonArray out = new JsonArray(); + for (Map.Entry e : map.entrySet()) { + double[] v = e.getValue(); + if (v[0] == Double.MAX_VALUE) v[0] = 0; + JsonObject o = new JsonObject(); + o.addProperty("material", e.getKey()); + o.addProperty("name", names.get(e.getKey())); + o.addProperty("lowest_sell", v[0]); + o.addProperty("highest_buy", v[1]); + o.addProperty("sell_count", (int) v[2]); + o.addProperty("buy_count", (int) v[3]); + out.add(o); + } + return out; + } +} \ No newline at end of file diff --git a/src/main/java/cn/aqcraft/service/ShopStatusService.java b/src/main/java/cn/aqcraft/service/ShopStatusService.java new file mode 100644 index 0000000..d3f48d2 --- /dev/null +++ b/src/main/java/cn/aqcraft/service/ShopStatusService.java @@ -0,0 +1,155 @@ +package cn.aqcraft.service; + +import cn.aqcraft.QShopWebUIPlugin; +import cn.aqcraft.util.JsonUtil; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 商店营业状态管理:临时隐藏 / 维护模式 / 交易熔断。 + * + *
    + *
  • 营业开关:{@code open} 全局营业 / 打烊。
  • + *
  • 维护模式:{@code maintenance} 开启后所有购买/收购被拒绝。
  • + *
  • 临时隐藏:{@code hiddenShops} 特定 shop_id 在网页端隐藏(前端配合)。
  • + *
  • 交易熔断:{@code circuitBreaker} 某商店连续失败 N 次后自动熔断,避免连环失败刷屏。
  • + *
+ * + *

持久化于 {@code data/shop_status.json}。线程安全。

+ */ +public final class ShopStatusService { + + private final QShopWebUIPlugin plugin; + private final File file; + private volatile boolean open = true; + private volatile boolean maintenance = false; + private final Map hiddenShops = new ConcurrentHashMap<>(); + private final Map failureTracker = new ConcurrentHashMap<>(); // shop_id -> {count, firstMs} + private final Map trippedUntil = new ConcurrentHashMap<>(); + private volatile String closedReason = ""; + + public ShopStatusService(QShopWebUIPlugin plugin) { + this.plugin = plugin; + this.file = new File(plugin.getDataFolder(), "data/shop_status.json"); + load(); + } + + public synchronized void load() { + try { + if (!file.isFile()) return; + JsonObject o = JsonParser.parseString(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)).getAsJsonObject(); + if (o.has("open")) open = o.get("open").getAsBoolean(); + if (o.has("maintenance")) maintenance = o.get("maintenance").getAsBoolean(); + if (o.has("closed_reason")) closedReason = o.get("closed_reason").getAsString(); + if (o.has("hidden")) { + for (var e : o.get("hidden").getAsJsonObject().entrySet()) { + hiddenShops.put(e.getKey(), e.getValue().getAsBoolean()); + } + } + } catch (Exception e) { + plugin.getLogger().warning("[状态] 读取 shop_status.json 失败: " + e.getMessage()); + } + } + + public synchronized void save() { + JsonObject o = new JsonObject(); + o.addProperty("open", open); + o.addProperty("maintenance", maintenance); + o.addProperty("closed_reason", closedReason); + JsonObject h = new JsonObject(); + for (var e : hiddenShops.entrySet()) h.addProperty(e.getKey(), e.getValue()); + o.add("hidden", h); + try { + Files.write(file.toPath(), JsonUtil.toJson(o).getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + plugin.getLogger().warning("[状态] 保存 shop_status.json 失败: " + e.getMessage()); + } + } + + public void flush() { + save(); + } + + // ==================== 全局开关 ==================== + + public boolean isOpen() { return open; } + public boolean isMaintenance() { return maintenance; } + public String closedReason() { return closedReason; } + + public void setOpen(boolean v, String reason) { + this.open = v; + this.closedReason = reason == null ? "" : reason; + save(); + } + + public void setMaintenance(boolean v) { + this.maintenance = v; + save(); + } + + // ==================== 隐藏商店 ==================== + + public boolean isHidden(String shopId) { return hiddenShops.getOrDefault(shopId, false); } + public void setHidden(String shopId, boolean v) { hiddenShops.put(shopId, v); save(); } + public void removeHidden(String shopId) { hiddenShops.remove(shopId); save(); } + public Map hiddenShops() { return new ConcurrentHashMap<>(hiddenShops); } + + // ==================== 交易熔断 ==================== + + /** + * 在交易失败时调用,累计失败次数,达阈值后熔断该商店一段时间。 + * @return 熔断后的剩余毫秒;未熔断返回 0 + */ + public long onTradeFailure(String shopId) { + long now = System.currentTimeMillis(); + if (trippedUntil.containsKey(shopId) && trippedUntil.get(shopId) > now) { + return trippedUntil.get(shopId) - now; + } + int[] t = failureTracker.computeIfAbsent(shopId, k -> new int[]{0, (int) now}); + t[0]++; + if (t[0] >= plugin.config().circuitBreakerThreshold) { + long trip = now + plugin.config().circuitBreakerDurationMs; + trippedUntil.put(shopId, trip); + failureTracker.remove(shopId); + plugin.getLogger().warning("[熔断] 商店 " + shopId + " 连续失败,熔断 " + (plugin.config().circuitBreakerDurationMs / 1000) + "s"); + return trip - now; + } + return 0; + } + + /** 交易成功时清零失败计数 */ + public void onTradeSuccess(String shopId) { + failureTracker.remove(shopId); + trippedUntil.remove(shopId); + } + + /** 是否被熔断;返回剩余毫秒 */ + public long trippedMs(String shopId) { + Long until = trippedUntil.get(shopId); + if (until == null) return 0; + long r = until - System.currentTimeMillis(); + return r > 0 ? r : 0; + } + + public void resetTrip(String shopId) { trippedUntil.remove(shopId); failureTracker.remove(shopId); } + + // ==================== 快照 ==================== + + public JsonObject snapshot() { + JsonObject o = new JsonObject(); + o.addProperty("open", open); + o.addProperty("maintenance", maintenance); + o.addProperty("closed_reason", closedReason); + JsonObject h = new JsonObject(); + for (var e : hiddenShops.entrySet()) h.addProperty(e.getKey(), e.getValue()); + o.add("hidden", h); + o.addProperty("tripped", trippedUntil.size()); + return o; + } +} \ No newline at end of file diff --git a/src/main/java/cn/aqcraft/service/StockAlertService.java b/src/main/java/cn/aqcraft/service/StockAlertService.java new file mode 100644 index 0000000..9a20909 --- /dev/null +++ b/src/main/java/cn/aqcraft/service/StockAlertService.java @@ -0,0 +1,133 @@ +package cn.aqcraft.service; + +import cn.aqcraft.QShopWebUIPlugin; +import cn.aqcraft.data.ShopEntry; +import com.google.gson.JsonObject; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 库存与收购容量提醒。 + * + *

对出售商店:容器内物品数量低于 {@code stock-low-threshold} 时告警; + * 对收购商店:容器剩余可容纳份数低于阈值时告警。

+ * + *

告警在网页「监控」页展示,并可通过 {@link NotificationService} 推送到 Webhook。 + * 带冷却时间,同一商店同一类型短时间内只告警一次,避免刷屏。

+ */ +public final class StockAlertService { + + private final QShopWebUIPlugin plugin; + private final Map lastAlert = new ConcurrentHashMap<>(); + + public StockAlertService(QShopWebUIPlugin plugin) { + this.plugin = plugin; + } + + public JsonObject alertJson(ShopEntry e, String kind, String detail) { + JsonObject o = new JsonObject(); + o.addProperty("shop_id", e.shop_id); + o.addProperty("item", e.shop_cn_name); + o.addProperty("material", e.material); + o.addProperty("owner", e.owner_name); + o.addProperty("world", e.world); + o.addProperty("x", e.x); + o.addProperty("y", e.y); + o.addProperty("z", e.z); + o.addProperty("type", e.shop_type); + o.addProperty("kind", kind); + o.addProperty("detail", detail); + o.addProperty("t", System.currentTimeMillis()); + return o; + } + + /** + * 判断某商店是否需要告警;需要且通过冷却则返回告警对象并推送,否则返回 null。 + * + * @param stockItems 容器内当前物品件数;null 表示容器不可访问 + * @param stack 每份物品数(stacking_amount) + * @param capacityItems 容器总容量(件数),收购商店计算剩余空间用;0 表示未知 + */ + public JsonObject evaluate(ShopEntry e, Long stockItems, int stack, int capacityItems) { + if (e == null) return null; + String kind; + String detail; + int threshold = plugin.config().stockLowThreshold; + + if (e.isSelling()) { + if (stockItems == null) return null; + int canSell = (int) (stockItems / Math.max(1, stack)); + if (stockItems <= 0) { + kind = "out-of-stock"; + detail = "库存已耗尽,无法继续出售"; + } else if (canSell <= threshold) { + kind = "low-stock"; + detail = "库存不足:仅剩 " + canSell + " 份可售(阈值 " + threshold + ")"; + } else { + return null; + } + } else { + if (capacityItems <= 0 || stockItems == null) return null; + int used = (int) (stockItems / Math.max(1, stack)); + int capUnits = capacityItems / Math.max(1, stack); + int remain = Math.max(0, capUnits - used); + if (remain <= 0) { + kind = "buy-capacity"; + detail = "收购容器已满,无法再收购"; + } else if (remain <= threshold) { + kind = "buy-capacity"; + detail = "收购容量不足:仅剩 " + remain + " 份空间(阈值 " + threshold + ")"; + } else { + return null; + } + } + + String cooldownKey = e.shop_id + "|" + kind; + long now = System.currentTimeMillis(); + Long last = lastAlert.get(cooldownKey); + if (last != null && now - last < plugin.config().alertCooldownMs) return null; + + lastAlert.put(cooldownKey, now); + JsonObject alert = alertJson(e, kind, detail); + if (plugin.notifications().available()) { + plugin.notifications().push("【库存告警】" + e.shop_cn_name, + e.shop_cn_name + "(" + e.owner_name + ")\n" + detail); + } + return alert; + } + + /** 扫描全量商店,返回当前应显示的告警列表(不推送,网页监控页用) */ + public List scan(List shops) { + java.util.List out = new java.util.ArrayList<>(); + for (ShopEntry e : shops) { + if (e.is_system_shop != null && e.is_system_shop) continue; + Long stock = e.quantity == null ? null : (long) e.quantity; + JsonObject a = evaluate(e, stock, e.stacking_amount, 0); + if (a != null) out.add(a); + } + return out; + } + + /** + * 实时扫描:对每个商店通过 stockReader 读取容器物品件数,再求告警。 + * 用于网页「监控」页手动刷新;stockReader 需在服务器主线程执行(读取容器)。 + */ + public List scanLive(List shops, java.util.function.Function stockReader) { + java.util.List out = new java.util.ArrayList<>(); + if (stockReader == null) return out; + for (ShopEntry e : shops) { + if (e.is_system_shop != null && e.is_system_shop) continue; + Long stock; + try { + stock = stockReader.apply(e); + } catch (Throwable t) { + stock = null; + } + JsonObject a = evaluate(e, stock, e.stacking_amount, 0); + if (a != null) out.add(a); + } + return out; + } +} \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 4c4bbab..9871c5e 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -72,6 +72,32 @@ purchase: game-code-ttl-seconds: 300 game-code-max-attempts: 5 +# ------------------------------------------------------------ +# 库存与经营(P1/P2 增强) +# ------------------------------------------------------------ +shop: + # 出售店剩余物品数低于该值时触发「库存不足」告警。 + stock-low-threshold: 10 + # 同一商店同类告警的冷却时间(毫秒),避免刷屏。 + alert-cooldown-ms: 300000 + # 在线交易连续失败超过该次数时,触发熔断保护(期间拒绝在线交易)。 + circuit-breaker-threshold: 8 + # 熔断持续时长(毫秒)。 + circuit-breaker-duration-ms: 300000 + # 订单流水最多保留条数(超出丢弃最旧)。 + order-max: 50000 + # 审计日志最多保留条数。 + audit-max: 5000 + +# ------------------------------------------------------------ +# 通知(Webhook) +# ------------------------------------------------------------ +# 留空 = 禁用通知推送。填 QQ 机器人 / 钉钉 / 企业微信 等 Webhook 地址。 +# 库存告警、订单事件、经营异常会以 JSON POST 推送: +# { "title": "...", "text": "...", "server": "服务器名" } +notifications: + webhook-url: "" + # ------------------------------------------------------------ # 前台页面 # ------------------------------------------------------------ From baab268f66874bdea64a57b1f7ba5150d1fb2d4e Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Tue, 6 Oct 2026 06:36:25 +0800 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20P0/P1/P2=20=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E9=A1=B5=E7=AD=BE(=E8=AE=A2=E5=8D=95/=E6=AF=94=E4=BB=B7?= =?UTF-8?q?=E6=94=B6=E8=97=8F/=E5=AE=A1=E8=AE=A1/=E7=BB=8F=E8=90=A5?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1/=E5=BA=93=E5=AD=98/=E8=90=A5=E4=B8=9A?= =?UTF-8?q?=E7=8A=B6=E6=80=81/=E9=80=9A=E7=9F=A5)=20+=20=E6=89=A9=E5=B1=95?= =?UTF-8?q?API=20+=20/docs=20=E6=96=87=E6=A1=A3;=20bump=20beta.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 2 +- webroot/index.html | 7 +++++++ webroot/js/app.js | 24 +++++++++++++++++------- webroot/js/db.js | 2 ++ 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/pom.xml b/pom.xml index d547176..cda67df 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ cn.aqcraft qshop-webui-plugin - 1.1.0-beta.1 + 1.1.0-beta.2 jar QShopWebUI QuickShop-Hikari 商店 Web 管理系统(单端口,内嵌 Web UI) diff --git a/webroot/index.html b/webroot/index.html index 4272a8d..8945941 100644 --- a/webroot/index.html +++ b/webroot/index.html @@ -41,6 +41,13 @@ + + + + + + + diff --git a/webroot/js/app.js b/webroot/js/app.js index aec6785..37cc826 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -908,7 +908,7 @@ function switchTab(tab, opts) { uiLogger.info('[UI] 切换页面 → ' + tab + (opts && opts.material ? ' (物品: ' + opts.material + ')' : '')); // 权限检查 - if ((tab === 'admin' || tab === 'data' || tab === 'backup' || tab === 'monitor' || tab === 'announce') && !state.isAdmin) { + if ((tab === 'admin' || tab === 'data' || tab === 'backup' || tab === 'monitor' || tab === 'announce' || tab === 'orders' || tab === 'compare' || tab === 'audit' || tab === 'business' || tab === 'stock' || tab === 'status' || tab === 'notify') && !state.isAdmin) { Toast.show('请先使用管理员账户登录', 'warning'); showLoginModal(); return; @@ -996,6 +996,13 @@ case 'sell': initSellPage(); break; case 'stats': initStatsPage(); break; case 'monitor': initMonitorPage(); break; + case 'orders': initOrdersPage(); break; + case 'compare': initComparePage(); break; + case 'audit': initAuditPage(); break; + case 'business': initBusinessPage(); break; + case 'stock': initStockPage(); break; + case 'status': initStatusPage(); break; + case 'notify': initNotifyPage(); break; case 'backup': initBackupPage(); break; case 'admin': initAdmin(); break; case 'data': initDataIO(); break; @@ -1880,7 +1887,7 @@ titleWrap.appendChild(matSmall); } } else { - // 整个文本缺失:整行替换为预设图片(淡入) + // 整个文本缺失:整行显示预设图片(淡入) titleWrap.classList.add('text-missing-row'); const fb = el('img', { class: 'text-missing-img text-missing-row-img', @@ -2110,7 +2117,9 @@ r.appendChild(el('td', { text: String(val) })); info.appendChild(r); } - tr('价格', '$' + (shop.price_display !== undefined && shop.price_display !== null ? String(shop.price_display) : Number(shop.price || 0).toFixed(2))); + tr('价格', '$' + (shop.price_display !== undefined && shop.price_display !== null + ? String(shop.price_display) + : unitPrice.toFixed(2))); tr('堆叠', shop.stacking_amount || 1); tr('世界', shop.world || '-'); tr('坐标', '(' + shop.x + ', ' + shop.y + ', ' + shop.z + ')'); @@ -2262,7 +2271,6 @@ try { localStorage.setItem('qsw_player_name', player); } catch (e) { } Toast.show('✓ 购买成功:' + r.item + ' ×' + r.amount + ' 份,花费 $' + r.total_price + ',余额 $' + r.balance_left, 'success'); QSDB.clearCache(); - refreshTradePage(); try { modalRef.close(); } catch (e) { } // 稍后刷新当前物品详情页(库存已变化) setTimeout(function () { @@ -2427,6 +2435,7 @@ const maxBtn = el('button', { class: 'neo-btn', text: '最大', style: { marginLeft: '8px', padding: '8px 14px' } }); const summary = el('div', { class: 'purchase-summary' }); const balanceInfo = el('div', { style: { fontSize: '13px', fontWeight: '600', color: '#065f46', marginTop: '-4px' } }); + let currentBalance = null; balanceInfo.textContent = '💰 输入游戏 ID 后自动查询余额'; nameInput.addEventListener('change', function () { const n = nameInput.value.trim(); @@ -2434,7 +2443,8 @@ balanceInfo.textContent = '💰 查询中...'; QSDB.getWallet(n).then(function (w) { if (w && w.success && w.balance != null) { - balanceInfo.textContent = '💰 ' + n + ' 的余额: $' + w.balance; + currentBalance = Number(w.balance); + balanceInfo.textContent = '💰 ' + n + ' 的余额: $' + w.balance + (w.pending_items ? ('(待领取 ' + w.pending_items + ' 件)') : ''); } else { balanceInfo.textContent = '💰 无法查询余额(经济插件未安装或账户不存在)'; } @@ -3874,7 +3884,7 @@ } // 备份恢复管理页面 (initBackupPage) - function initBackupPage() { + async function initBackupPage() { const root = ensureTabPanel('backup'); if (!root) return; @@ -4027,7 +4037,7 @@ let html = '文件名大小创建时间类型商店数操作'; const files = listData.files || []; if (files.length === 0) { - html += '暂无备份文件'; + html += '暂无备份文件'; } else { files.forEach(f => { const sizeStr = f.size_bytes !== undefined ? ((f.size_bytes / 1024).toFixed(1) + ' KB') : '-'; diff --git a/webroot/js/db.js b/webroot/js/db.js index 29bb048..97240d2 100644 --- a/webroot/js/db.js +++ b/webroot/js/db.js @@ -820,6 +820,8 @@ isLoggedIn: isLoggedIn, isAdmin: isAdmin, getSession: getSession, + // 通用 API 调用(自动带 session、GET 缓存),供新页签/附属功能使用 + call: apiCall, // 查询连接状态 getQsFilterStatus: async function () { From 27ea484cee94dbb954edba61e7cb2e8103b168da Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Tue, 6 Oct 2026 06:42:51 +0800 Subject: [PATCH 08/13] chore: bump version to beta.2 in plugin.yml --- src/main/resources/plugin.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index c2d2d32..d584d98 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: QShopWebUI -version: 1.1.0-beta.1 +version: 1.1.0-beta.2 main: cn.aqcraft.QShopWebUIPlugin api-version: 1.17 description: QuickShop-Hikari 商店 Web 管理系统(单端口内嵌 Web UI,直接读取 QuickShop 数据) From 02c43bf5e646af53cb33e22e09577c583ac5cd8f Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Tue, 6 Oct 2026 12:16:00 +0800 Subject: [PATCH 09/13] feat: support AuthMe password login for web trades (avoid per-trade game code); session token + player login endpoints; bump beta.3 --- docs/API.md | 23 ++- docs/DEPLOY.md | 4 +- docs/FEATURES.md | 3 +- pom.xml | 2 +- src/main/java/cn/aqcraft/PluginConfig.java | 6 + .../java/cn/aqcraft/QShopWebUIPlugin.java | 7 + src/main/java/cn/aqcraft/api/ApiRouter.java | 7 + src/main/java/cn/aqcraft/api/EnhancedApi.java | 33 ++-- src/main/java/cn/aqcraft/api/PurchaseApi.java | 54 ++++++- .../cn/aqcraft/auth/PlayerAuthService.java | 144 ++++++++++++++++++ .../cn/aqcraft/purchase/PurchaseService.java | 28 +++- src/main/resources/config.yml | 4 + src/main/resources/plugin.yml | 2 +- webroot/js/app.js | 91 ++++++++--- webroot/js/db.js | 97 +++++++++++- 15 files changed, 446 insertions(+), 59 deletions(-) create mode 100644 src/main/java/cn/aqcraft/auth/PlayerAuthService.java diff --git a/docs/API.md b/docs/API.md index 53a4a5a..685c6c3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -54,14 +54,29 @@ QShopWebUIAPI api = 基础 URL:`http://:/api/...`。管理端端点需要 `requireAuth` 开启时 携带会话 Cookie;玩家自助端点使用 `player + code`(游戏内 `/qshopwebui code` -生成的一次性验证码)鉴权。 +生成的一次性验证码)或 **登录会话 token** 鉴权。 + +### 玩家登录(免重复验证码) + +玩家可用「游戏内一次性验证码」或「AuthMe 密码」登录一次,登录后在 +`purchase.player-session-ttl-seconds` 内交易 / 查询免重复验证。 + +| 端点 | 方法 | 鉴权 | 说明 | +|------|------|------|------| +| `/api/player/login` | POST | 公开 | body: `{player, code}` 或 `{player, password}`;成功返回 `{token, method, ttl}` | +| `/api/player/logout` | POST | 公开 | body: `{player, token}` 清除会话 | +| `/api/player/session?player=&token=` | GET | 公开 | 检查会话是否有效;返回 `logged_in`/`authme_available`/`allow_password_login` | +| `/api/purchase` | POST | 玩家 | body: `{shop_id, player, amount, code\|password\|token}` | +| `/api/sell` | POST | 玩家 | body: `{shop_id, player, amount, code\|password\|token}` | + +> 交易响应会附带 `auth_token` / `auth_method`,前端保存后可后续免验证调用。 ### 订单 | 端点 | 方法 | 鉴权 | 说明 | |------|------|------|------| | `/api/orders?type=&player=&limit=&offset=` | GET | 管理 | 全部订单分页(时间倒序) | -| `/api/orders/me?player=&code=&type=&limit=&offset=` | GET | 玩家 | 我的订单 | +| `/api/orders/me?player=&code=&token=&type=&limit=&offset=` | GET | 玩家 | 我的订单 | | `/api/orders/{id}` | GET | 管理 | 单笔订单 | | `/api/orders/clear` | POST | 管理 | 清空订单 | @@ -76,8 +91,8 @@ QShopWebUIAPI api = | 端点 | 方法 | 鉴权 | 说明 | |------|------|------|------| -| `/api/favorites?player=&code=` | GET | 玩家 | 收藏列表 | -| `/api/favorites/toggle` | POST | 玩家 | body: `{player,code,shop_id}` | +| `/api/favorites?player=&code=&token=` | GET | 玩家 | 收藏列表 | +| `/api/favorites/toggle` | POST | 玩家 | body: `{player,code\|token,shop_id}` | ### 经营统计 / 审计 diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 4179737..722161b 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -46,6 +46,8 @@ | `purchase.allow-offline-buy` | false | 是否允许离线购买 | | `purchase.game-code-ttl-seconds` | 300 | 验证码有效期 | | `purchase.game-code-max-attempts` | 5 | 验证码最大尝试次数 | +| `purchase.player-session-ttl-seconds` | 1800 | 玩家登录会话有效期(秒):登录一次后交易免重复验证 | +| `purchase.allow-authme-login` | true | 是否允许用 AuthMe 密码登录(需服务器安装 AuthMe) | ### 商店 @@ -72,7 +74,7 @@ | 命令 | 权限 | 说明 | |------|------|------| | `/qshopwebui` | 默认 | 主命令 | -| `/qshopwebui code` | 所有玩家 | 生成游戏内一次性验证码(网页交易/查单/收藏用) | +| `/qshopwebui code` | 所有玩家 | 生成游戏内一次性验证码(网页交易/查单/收藏用;也可用 AuthMe 密码登录免重复验证) | | `/qshopwebui reload` | qshopwebui.reload | 重载配置与数据 | | `/qshopwebui status` | qshopwebui.reload | 查看运行状态 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 23e662d..3ebd8bb 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -7,7 +7,8 @@ | 功能 | 说明 | 服务 | |------|------|------| | 订单流水 | 每笔成功/失败交易写入独立订单流水(ORD-序号),独立于 trades.json | `OrderStore` | -| 玩家订单查询 | 玩家凭游戏内一次性验证码查询自己的买卖记录 | `EnhancedApi.myOrders` | +| 玩家订单查询 | 玩家凭游戏内一次性验证码(或登录会话 token)查询自己的买卖记录 | `EnhancedApi.myOrders` | +| 玩家登录(免重复验证码) | 游戏内验证码 或 AuthMe 密码登录一次,TTL 内交易/查询免重复验证 | `PlayerAuthService` / `/api/player/login` | | 网页购买 / 出售 | 玩家在线或允许离线时通过网页交易 | `PurchaseService` | ## P1 经营增强 diff --git a/pom.xml b/pom.xml index cda67df..c866c7c 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ cn.aqcraft qshop-webui-plugin - 1.1.0-beta.2 + 1.1.0-beta.3 jar QShopWebUI QuickShop-Hikari 商店 Web 管理系统(单端口,内嵌 Web UI) diff --git a/src/main/java/cn/aqcraft/PluginConfig.java b/src/main/java/cn/aqcraft/PluginConfig.java index 8383811..cad19c8 100644 --- a/src/main/java/cn/aqcraft/PluginConfig.java +++ b/src/main/java/cn/aqcraft/PluginConfig.java @@ -36,6 +36,10 @@ public final class PluginConfig { public boolean allowOfflineBuy = true; public int gameCodeTtlSeconds = 300; public int gameCodeMaxAttempts = 5; + /** 玩家网页登录会话有效期(秒):登录一次后交易免重复验证。 */ + public int playerSessionTtlSeconds = 1800; + /** 是否允许用 AuthMe 密码登录(需服务器安装 AuthMe)。 */ + public boolean allowAuthmeLogin = true; // === P1/P2 增强功能 === /** 库存/收购容量提醒阈值(出售店剩余物品数低于此值告警) */ @@ -90,6 +94,8 @@ public static PluginConfig load(QShopWebUIPlugin plugin) { cfg.allowOfflineBuy = c.getBoolean("purchase.allow-offline-buy", true); cfg.gameCodeTtlSeconds = Math.max(30, c.getInt("purchase.game-code-ttl-seconds", 300)); cfg.gameCodeMaxAttempts = Math.max(1, c.getInt("purchase.game-code-max-attempts", 5)); + cfg.playerSessionTtlSeconds = Math.max(60, c.getInt("purchase.player-session-ttl-seconds", 1800)); + cfg.allowAuthmeLogin = c.getBoolean("purchase.allow-authme-login", true); cfg.stockLowThreshold = c.getInt("shop.stock-low-threshold", 10); cfg.alertCooldownMs = Math.max(1000, c.getLong("shop.alert-cooldown-ms", 5 * 60 * 1000)); cfg.circuitBreakerThreshold = Math.max(1, c.getInt("shop.circuit-breaker-threshold", 8)); diff --git a/src/main/java/cn/aqcraft/QShopWebUIPlugin.java b/src/main/java/cn/aqcraft/QShopWebUIPlugin.java index ec4f720..a88a832 100644 --- a/src/main/java/cn/aqcraft/QShopWebUIPlugin.java +++ b/src/main/java/cn/aqcraft/QShopWebUIPlugin.java @@ -3,6 +3,7 @@ import cn.aqcraft.api.QShopWebUIAPI; import cn.aqcraft.auth.SessionManager; import cn.aqcraft.auth.GameCodeService; +import cn.aqcraft.auth.PlayerAuthService; import cn.aqcraft.bridge.AuthMeBridge; import cn.aqcraft.bridge.EconomyBridge; import cn.aqcraft.bridge.LimitedBridge; @@ -61,6 +62,7 @@ public final class QShopWebUIPlugin extends JavaPlugin implements CommandExecuto private AuthMeBridge authme; private LimitedBridge limited; private GameCodeService gameCodes; + private PlayerAuthService playerAuth; private OrderStore orderStore; private StockAlertService stockAlerts; private ShopStatusService shopStatus; @@ -97,6 +99,7 @@ public void onEnable() { economy = new EconomyBridge(this); economy.reload(); purchases = new PurchaseService(this); + playerAuth = new PlayerAuthService(this); gameCodes = new GameCodeService(this); authme = new AuthMeBridge(this); @@ -395,6 +398,10 @@ public GameCodeService gameCodes() { return gameCodes; } + public PlayerAuthService playerAuth() { + return playerAuth; + } + public OrderStore orders() { return orderStore; } diff --git a/src/main/java/cn/aqcraft/api/ApiRouter.java b/src/main/java/cn/aqcraft/api/ApiRouter.java index a1ba86b..829503b 100644 --- a/src/main/java/cn/aqcraft/api/ApiRouter.java +++ b/src/main/java/cn/aqcraft/api/ApiRouter.java @@ -179,6 +179,13 @@ private HttpResponse route(String m, String p, HttpRequest req) { case "/api/sell": return purchaseApi.sell(req); + case "/api/player/login": + return purchaseApi.playerLogin(req); + case "/api/player/logout": + return purchaseApi.playerLogout(req); + case "/api/player/session": + return purchaseApi.playerSession(req); + case "/api/inventory-check": return purchaseApi.inventoryCheck(req); diff --git a/src/main/java/cn/aqcraft/api/EnhancedApi.java b/src/main/java/cn/aqcraft/api/EnhancedApi.java index 3bc2f98..f53b4e1 100644 --- a/src/main/java/cn/aqcraft/api/EnhancedApi.java +++ b/src/main/java/cn/aqcraft/api/EnhancedApi.java @@ -41,15 +41,16 @@ public HttpResponse orders(HttpRequest req) { return HttpResponse.json(o); } - /** GET /api/orders/me 玩家自助查询(需 player + code 验证码) */ + /** GET /api/orders/me 玩家自助查询(需 player + code 验证码 或 token 会话) */ public HttpResponse myOrders(HttpRequest req) { String player = req.param("player", ""); String code = req.param("code", ""); - if (player.isEmpty() || code.isEmpty()) { - return HttpResponse.error(400, "需要 player 与 code(游戏内 /qshopwebui code 生成)"); + String token = req.param("token", ""); + if (player.isEmpty() || (code.isEmpty() && token.isEmpty())) { + return HttpResponse.error(400, "需要 player 与 code(游戏内 /qshopwebui code 生成)或已登录 token"); } - if (!plugin.gameCodes().check(player, code)) { - return HttpResponse.error(403, "验证码无效或已过期"); + if (!plugin.playerAuth().verify(player, code, token)) { + return HttpResponse.error(403, "验证码无效或已过期(也可登录后使用 token)"); } String type = req.param("type", null); int limit = req.intParam("limit", 20); @@ -116,15 +117,16 @@ public HttpResponse compareAll(HttpRequest req) { // 收藏 // ============================================================ - /** GET /api/favorites?player=xxx&code=xxx 玩家收藏列表 */ + /** GET /api/favorites?player=xxx&code=xxx&token=xxx 玩家收藏列表 */ public HttpResponse favoritesList(HttpRequest req) { String player = req.param("player", ""); String code = req.param("code", ""); - if (player.isEmpty() || code.isEmpty()) { - return HttpResponse.error(400, "需要 player 与 code"); + String token = req.param("token", ""); + if (player.isEmpty() || (code.isEmpty() && token.isEmpty())) { + return HttpResponse.error(400, "需要 player 与 code(或已登录 token)"); } - if (!plugin.gameCodes().check(player, code)) { - return HttpResponse.error(403, "验证码无效或已过期"); + if (!plugin.playerAuth().verify(player, code, token)) { + return HttpResponse.error(403, "验证码无效或已过期(也可登录后使用 token)"); } List ids = plugin.favorites().favorites(player); JsonArray shops = new JsonArray(); @@ -153,17 +155,18 @@ public HttpResponse favoritesList(HttpRequest req) { return HttpResponse.json(o); } - /** POST /api/favorites/toggle 添加/移除收藏(body: player, code, shop_id) */ + /** POST /api/favorites/toggle 添加/移除收藏(body: player, code|token, shop_id) */ public HttpResponse favoritesToggle(HttpRequest req) { JsonObject b = body(req); String player = jstr(b, "player", ""); String code = jstr(b, "code", ""); + String token = jstr(b, "token", ""); String shopId = jstr(b, "shop_id", ""); - if (player.isEmpty() || code.isEmpty() || shopId.isEmpty()) { - return HttpResponse.error(400, "需要 player、code、shop_id"); + if (player.isEmpty() || (code.isEmpty() && token.isEmpty()) || shopId.isEmpty()) { + return HttpResponse.error(400, "需要 player、code(或 token)、shop_id"); } - if (!plugin.gameCodes().check(player, code)) { - return HttpResponse.error(403, "验证码无效或已过期"); + if (!plugin.playerAuth().verify(player, code, token)) { + return HttpResponse.error(403, "验证码无效或已过期(也可登录后使用 token)"); } boolean now = plugin.favorites().toggle(player, shopId); plugin.favorites().flush(); diff --git a/src/main/java/cn/aqcraft/api/PurchaseApi.java b/src/main/java/cn/aqcraft/api/PurchaseApi.java index 6a731f1..fbe6830 100644 --- a/src/main/java/cn/aqcraft/api/PurchaseApi.java +++ b/src/main/java/cn/aqcraft/api/PurchaseApi.java @@ -20,8 +20,10 @@ public HttpResponse purchase(HttpRequest req) { String shopId = jstr(b, "shop_id", ""); String player = jstr(b, "player", ""); String code = jstr(b, "code", ""); + String password = jstr(b, "password", ""); + String token = jstr(b, "token", ""); int amount = jint(b, "amount", 1); - JsonObject result = plugin.purchases().purchase(shopId, player, amount, code); + JsonObject result = plugin.purchases().purchase(shopId, player, amount, code, password, token); return HttpResponse.json(result); } @@ -33,11 +35,59 @@ public HttpResponse sell(HttpRequest req) { String shopId = jstr(b, "shop_id", ""); String player = jstr(b, "player", ""); String code = jstr(b, "code", ""); + String password = jstr(b, "password", ""); + String token = jstr(b, "token", ""); int amount = jint(b, "amount", 1); - JsonObject result = plugin.purchases().sell(shopId, player, amount, code); + JsonObject result = plugin.purchases().sell(shopId, player, amount, code, password, token); return HttpResponse.json(result); } + /** + * POST /api/player/login 玩家登录(获取交易会话 token,免重复验证)。 + * body: player + (code 游戏内验证码 | password AuthMe 密码) + */ + public HttpResponse playerLogin(HttpRequest req) { + JsonObject b = body(req); + String player = jstr(b, "player", ""); + String code = jstr(b, "code", ""); + String password = jstr(b, "password", ""); + cn.aqcraft.auth.PlayerAuthService.Result auth = + plugin.playerAuth().authenticate(player, code, password, ""); + JsonObject o = new JsonObject(); + if (!auth.ok) { + o.addProperty("success", false); + o.addProperty("error", auth.reason); + return HttpResponse.json(o); + } + o.addProperty("success", true); + o.addProperty("token", auth.token); + o.addProperty("method", auth.method); + o.addProperty("ttl", plugin.config().playerSessionTtlSeconds); + o.addProperty("player", player.trim()); + return HttpResponse.json(o); + } + + /** POST /api/player/logout 退出登录(清除会话)body: player + token */ + public HttpResponse playerLogout(HttpRequest req) { + JsonObject b = body(req); + plugin.playerAuth().logout(jstr(b, "player", ""), jstr(b, "token", "")); + JsonObject o = new JsonObject(); + o.addProperty("success", true); + return HttpResponse.json(o); + } + + /** GET /api/player/session?player=X&token=Y 检查会话是否仍有效 */ + public HttpResponse playerSession(HttpRequest req) { + String player = req.param("player", ""); + String token = req.param("token", ""); + JsonObject o = new JsonObject(); + o.addProperty("success", true); + o.addProperty("logged_in", plugin.playerAuth().isLoggedIn(player, token)); + o.addProperty("authme_available", plugin.authme().available()); + o.addProperty("allow_password_login", plugin.config().allowAuthmeLogin); + return HttpResponse.json(o); + } + /** POST /api/inventory-check:查询在线玩家背包中该商店物品数量(收购「最大」按钮) */ public HttpResponse inventoryCheck(HttpRequest req) { JsonObject b = body(req); diff --git a/src/main/java/cn/aqcraft/auth/PlayerAuthService.java b/src/main/java/cn/aqcraft/auth/PlayerAuthService.java new file mode 100644 index 0000000..95f19b5 --- /dev/null +++ b/src/main/java/cn/aqcraft/auth/PlayerAuthService.java @@ -0,0 +1,144 @@ +package cn.aqcraft.auth; + +import cn.aqcraft.QShopWebUIPlugin; + +import java.security.SecureRandom; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 网页玩家登录会话。 + *

玩家可用「游戏内一次性验证码」或「AuthMe 密码」登录一次,登录后在 TTL 内交易/查询免重复验证, + * 避免每次都输入验证码。会话仅保存在内存中,服务器重启后失效。

+ */ +public final class PlayerAuthService { + + private final QShopWebUIPlugin plugin; + private static final SecureRandom RANDOM = new SecureRandom(); + private final Map sessions = new ConcurrentHashMap<>(); + + public PlayerAuthService(QShopWebUIPlugin plugin) { + this.plugin = plugin; + } + + /** 认证结果。 */ + public static final class Result { + public final boolean ok; + public final String reason; + public final String token; + public final String method; + + private Result(boolean ok, String reason, String token, String method) { + this.ok = ok; + this.reason = reason; + this.token = token; + this.method = method; + } + } + + private static Result ok(String token, String method) { + return new Result(true, null, token, method); + } + + private static Result fail(String reason) { + return new Result(false, reason, null, null); + } + + /** + * 交易认证:已登录会话有效 → 直接通过;否则用「验证码」或「AuthMe 密码」验证。 + * 任一方式通过都会(重新)签发 token。 + */ + public Result authenticate(String playerName, String code, String password, String token) { + String name = playerName == null ? "" : playerName.trim(); + if (name.isEmpty()) return fail("请输入你的游戏 ID"); + + // 1) 已有有效会话(免重复验证) + if (token != null && !token.isEmpty()) { + Entry e = sessions.get(key(name)); + if (e != null && e.token.equals(token) && System.currentTimeMillis() <= e.expiresAt) { + return ok(e.token, "session"); + } + } + // 2) 游戏内一次性验证码 + if (code != null && !code.trim().isEmpty()) { + if (plugin.gameCodes().verifyAndConsume(name, code)) { + return ok(issue(name), "code"); + } + return fail("验证码无效、已过期或玩家不在线,请在游戏内执行 /qshopwebui code"); + } + // 3) AuthMe 密码 + if (password != null && !password.isEmpty()) { + if (!plugin.config().allowAuthmeLogin) { + return fail("服务器未开启 AuthMe 密码登录(config.yml purchase.allow-authme-login)"); + } + if (!plugin.authme().available()) { + return fail("服务器未安装 AuthMe,无法使用密码登录,请改用游戏内验证码"); + } + if (plugin.authme().checkPassword(name, password)) { + return ok(issue(name), "password"); + } + return fail("AuthMe 密码错误"); + } + return fail("请提供游戏内验证码或 AuthMe 密码进行验证"); + } + + /** 只读校验(订单/收藏查询等):有效会话 或 验证码有效即可(不消费验证码)。 */ + public boolean verify(String playerName, String code, String token) { + String name = playerName == null ? "" : playerName.trim(); + if (name.isEmpty()) return false; + if (token != null && !token.isEmpty()) { + Entry e = sessions.get(key(name)); + if (e != null && e.token.equals(token) && System.currentTimeMillis() <= e.expiresAt) return true; + } + return plugin.gameCodes().check(name, code); + } + + /** 指定玩家当前是否有有效登录会话。 */ + public boolean isLoggedIn(String playerName, String token) { + String name = playerName == null ? "" : playerName.trim(); + if (name.isEmpty() || token == null || token.isEmpty()) return false; + Entry e = sessions.get(key(name)); + return e != null && e.token.equals(token) && System.currentTimeMillis() <= e.expiresAt; + } + + /** 退出登录:清除该玩家的会话(token 为空则强制清除)。 */ + public void logout(String playerName, String token) { + String name = playerName == null ? "" : playerName.trim(); + if (name.isEmpty()) return; + Entry e = sessions.get(key(name)); + if (e != null && (token == null || token.isEmpty() || e.token.equals(token))) { + sessions.remove(key(name)); + } + } + + /** 会话数量(诊断用)。 */ + public int size() { + return sessions.size(); + } + + private String issue(String name) { + byte[] bytes = new byte[24]; + RANDOM.nextBytes(bytes); + StringBuilder sb = new StringBuilder(48); + for (byte b : bytes) sb.append(String.format("%02x", b)); + String token = sb.toString(); + long ttl = Math.max(60, plugin.config().playerSessionTtlSeconds) * 1000L; + sessions.put(key(name), new Entry(token, System.currentTimeMillis() + ttl)); + return token; + } + + private static String key(String name) { + return name.toLowerCase(Locale.ROOT); + } + + private static final class Entry { + private final String token; + private final long expiresAt; + + private Entry(String token, long expiresAt) { + this.token = token; + this.expiresAt = expiresAt; + } + } +} diff --git a/src/main/java/cn/aqcraft/purchase/PurchaseService.java b/src/main/java/cn/aqcraft/purchase/PurchaseService.java index 7945867..5b55968 100644 --- a/src/main/java/cn/aqcraft/purchase/PurchaseService.java +++ b/src/main/java/cn/aqcraft/purchase/PurchaseService.java @@ -2,6 +2,7 @@ import com.google.gson.JsonObject; import cn.aqcraft.QShopWebUIPlugin; +import cn.aqcraft.auth.PlayerAuthService; import cn.aqcraft.bridge.EconomyBridge; import cn.aqcraft.bridge.LimitedBridge; import cn.aqcraft.bridge.QuickShopBridge; @@ -37,7 +38,7 @@ public PurchaseService(QShopWebUIPlugin plugin) { this.plugin = plugin; } - public JsonObject purchase(String shopId, String playerName, int amount, String code) { + public JsonObject purchase(String shopId, String playerName, int amount, String code, String password, String token) { if (shopId == null || shopId.trim().isEmpty()) return err("缺少商店 ID"); if (playerName == null || playerName.trim().isEmpty()) return err("请输入你的游戏 ID"); final String name = playerName.trim(); @@ -50,7 +51,9 @@ public JsonObject purchase(String shopId, String playerName, int amount, String if (last != null && now - last < 1500) return err("操作太快,请稍后再试"); cooldown.put(name.toLowerCase(Locale.ROOT), now); - if (!plugin.gameCodes().verifyAndConsume(name, code)) return err("验证码无效、已过期或玩家不在线,请在游戏内执行 /qshopwebui code"); + // 登录认证:有效会话 / 游戏内验证码 / AuthMe 密码(三选一) + PlayerAuthService.Result auth = plugin.playerAuth().authenticate(name, code, password, token); + if (!auth.ok) return err(auth.reason); ShopEntry found = null; for (ShopEntry s : plugin.shopData().shops()) { @@ -65,14 +68,16 @@ public JsonObject purchase(String shopId, String playerName, int amount, String final ShopEntry entry = found; try { - return plugin.bridge().runOnMain(() -> doPurchase(entry, name, amt)); + JsonObject r = plugin.bridge().runOnMain(() -> doPurchase(entry, name, amt)); + attachAuth(r, auth); + return r; } catch (Throwable t) { return failRecord("BUY", entry, name, amt, "交易执行失败: " + t.getMessage()); } } /** 玩家出售给收购商店(网页收购界面) */ - public JsonObject sell(String shopId, String playerName, int amount, String code) { + public JsonObject sell(String shopId, String playerName, int amount, String code, String password, String token) { if (shopId == null || shopId.trim().isEmpty()) return err("缺少商店 ID"); if (playerName == null || playerName.trim().isEmpty()) return err("请输入你的游戏 ID"); final String name = playerName.trim(); @@ -84,7 +89,9 @@ public JsonObject sell(String shopId, String playerName, int amount, String code if (last != null && now - last < 1500) return err("操作太快,请稍后再试"); cooldown.put(name.toLowerCase(Locale.ROOT), now); - if (!plugin.gameCodes().verifyAndConsume(name, code)) return err("验证码无效、已过期或玩家不在线,请在游戏内执行 /qshopwebui code"); + // 登录认证:有效会话 / 游戏内验证码 / AuthMe 密码(三选一) + PlayerAuthService.Result auth = plugin.playerAuth().authenticate(name, code, password, token); + if (!auth.ok) return err(auth.reason); ShopEntry found = null; for (ShopEntry s : plugin.shopData().shops()) { @@ -99,12 +106,21 @@ public JsonObject sell(String shopId, String playerName, int amount, String code final ShopEntry entry = found; try { - return plugin.bridge().runOnMain(() -> doSell(entry, name, amt)); + JsonObject r = plugin.bridge().runOnMain(() -> doSell(entry, name, amt)); + attachAuth(r, auth); + return r; } catch (Throwable t) { return failRecord("SELL", entry, name, amt, "交易执行失败: " + t.getMessage()); } } + /** 把认证方式与新生成的 token 附加到响应,便于前端保存会话。 */ + private static void attachAuth(JsonObject o, PlayerAuthService.Result auth) { + if (o == null || auth == null) return; + if (auth.token != null) o.addProperty("auth_token", auth.token); + if (auth.method != null) o.addProperty("auth_method", auth.method); + } + /** 查询在线玩家背包中该商店物品的数量(收购界面「最大」按钮用) */ public JsonObject inventoryCheck(String shopId, String playerName) { if (shopId == null || shopId.trim().isEmpty()) return err("缺少商店 ID"); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 9871c5e..e500b5f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -71,6 +71,10 @@ purchase: # 玩家在游戏内执行 /qshopwebui code 后,将验证码填入网页。 game-code-ttl-seconds: 300 game-code-max-attempts: 5 + # 玩家登录一次后,在该时间(秒)内交易 / 查询免重复验证(默认 1800 = 30 分钟)。 + player-session-ttl-seconds: 1800 + # 是否允许玩家用 AuthMe 密码登录(需服务器已安装 AuthMe;关闭后只能使用游戏内验证码)。 + allow-authme-login: true # ------------------------------------------------------------ # 库存与经营(P1/P2 增强) diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index d584d98..b69462e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: QShopWebUI -version: 1.1.0-beta.2 +version: 1.1.0-beta.3 main: cn.aqcraft.QShopWebUIPlugin api-version: 1.17 description: QuickShop-Hikari 商店 Web 管理系统(单端口内嵌 Web UI,直接读取 QuickShop 数据) diff --git a/webroot/js/app.js b/webroot/js/app.js index 37cc826..753fef7 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -2141,7 +2141,46 @@ return card; } - // 在线购买弹窗(填游戏 ID + 游戏内一次性验证码购买) + // 构建玩家「验证方式」区块:已登录(同名会话)时显示状态并免验证; + // 未登录时显示「游戏内验证码」与「AuthMe 密码」两种方式(二选一)。 + function buildPlayerAuthFields(getName) { + const codeInput = el('input', { type: 'text', inputmode: 'numeric', class: 'purchase-input', placeholder: '游戏内验证码(/qshopwebui code)', maxlength: '6', style: { width: '100%' } }); + const pwdInput = el('input', { type: 'password', class: 'purchase-input', placeholder: 'AuthMe 密码(与验证码二选一)', maxlength: '64', style: { width: '100%' } }); + const codeWrap = el('div', { class: 'purchase-field' }, [ + el('label', { class: 'purchase-label', text: '游戏内验证码' }), + codeInput + ]); + const pwdWrap = el('div', { class: 'purchase-field' }, [ + el('label', { class: 'purchase-label', text: '或 AuthMe 密码' }), + pwdInput + ]); + const statusText = el('div', { style: { fontSize: '13px', fontWeight: '600', color: '#065f46' } }); + const logoutBtn = el('button', { class: 'neo-btn', text: '退出登录', style: { marginTop: '6px', padding: '6px 12px' } }); + const loggedWrap = el('div', { class: 'purchase-field' }, [statusText, logoutBtn]); + function refresh() { + const n = getName(); + const logged = n && QSDB.isPlayerLoggedIn(n); + if (logged) { + const s = QSDB.getPlayerSession(); + const m = s && s.method === 'password' ? '(密码登录)' : (s && s.method === 'code' ? '(验证码登录)' : ''); + statusText.textContent = '✓ 已登录' + m + ',本次交易免验证'; + loggedWrap.style.display = ''; + codeWrap.style.display = 'none'; + pwdWrap.style.display = 'none'; + } else { + loggedWrap.style.display = 'none'; + codeWrap.style.display = ''; + pwdWrap.style.display = ''; + } + } + logoutBtn.onclick = function () { + QSDB.playerLogout().then(function () { refresh(); Toast.show('已退出登录', 'success'); }); + }; + refresh(); + return { codeInput: codeInput, pwdInput: pwdInput, codeWrap: codeWrap, pwdWrap: pwdWrap, loggedWrap: loggedWrap, refresh: refresh }; + } + + // 在线购买弹窗(填游戏 ID + 验证方式购买) function showPurchaseModal(shop) { const unitPrice = Number(shop.price || 0); const unitDisplay = (shop.price_display !== undefined && shop.price_display !== null) @@ -2158,9 +2197,8 @@ type: 'text', class: 'purchase-input', placeholder: '请输入你的游戏 ID', value: savedName, maxlength: '16', style: { width: '100%' } }); - const codeInput = el('input', { - type: 'text', inputmode: 'numeric', class: 'purchase-input', placeholder: '游戏内验证码(/qshopwebui code)', maxlength: '6', style: { width: '100%' } - }); + const auth = buildPlayerAuthFields(function () { return nameInput.value.trim(); }); + nameInput.addEventListener('change', auth.refresh); const qtyInput = el('input', { type: 'number', class: 'purchase-input purchase-qty', value: '1', min: '1', max: '64', style: { width: '110px' } @@ -2238,10 +2276,9 @@ el('label', { class: 'purchase-label', text: '游戏 ID' }), nameInput ]), - el('div', { class: 'purchase-field' }, [ - el('label', { class: 'purchase-label', text: '游戏内验证码' }), - codeInput - ]), + auth.loggedWrap, + auth.codeWrap, + auth.pwdWrap, el('div', { class: 'purchase-field' }, [ el('label', { class: 'purchase-label', text: '购买份数(1 份 = ' + stackAmount + ' 个,最多 64 份)' }), el('div', { style: { display: 'flex', alignItems: 'center' } }, [qtyInput, maxBtn]) @@ -2249,7 +2286,7 @@ summary, balanceInfo, limitInfo, - el('div', { class: 'purchase-tip', text: '请先在游戏内执行 /qshopwebui code 获取验证码;验证码只可使用一次。在线玩家物品直接放入背包,离线玩家上线时自动发放。' }) + el('div', { class: 'purchase-tip', text: '游戏内验证码 或 AuthMe 密码 二选一即可;登录一次后短时间内交易免验证。在线玩家物品直接放入背包,离线玩家上线时自动发放。' }) ]); let modalRef = null; @@ -2261,12 +2298,17 @@ const player = nameInput.value.trim(); if (!player) { Toast.show('请输入你的游戏 ID', 'error'); return false; } if (!/^[A-Za-z0-9_]{1,16}$/.test(player)) { Toast.show('游戏 ID 只能包含字母、数字、下划线(1-16 位)', 'error'); return false; } - const code = codeInput.value.trim(); - if (!/^\d{6}$/.test(code)) { Toast.show('请输入 6 位游戏内验证码', 'error'); return false; } + const logged = QSDB.isPlayerLoggedIn(player); + const code = logged ? '' : auth.codeInput.value.trim(); + const pwd = logged ? '' : auth.pwdInput.value.trim(); + if (!logged) { + if (!code && !pwd) { Toast.show('请输入游戏内验证码或 AuthMe 密码', 'error'); return false; } + if (code && !/^\d{6}$/.test(code)) { Toast.show('验证码应为 6 位数字', 'error'); return false; } + } if (remainingLimit != null && currentQty() > remainingLimit) { Toast.show('超出限购:本周期最多还能购买 ' + remainingLimit + ' 份', 'error'); return false; } const qty = currentQty(); try { modalRef.setProcessing('处理中...'); } catch (e) { } - QSDB.purchaseShop(shop.shop_id, player, qty, code).then(function (r) { + QSDB.purchaseShop(shop.shop_id, player, qty, code, pwd).then(function (r) { if (r && r.success) { try { localStorage.setItem('qsw_player_name', player); } catch (e) { } Toast.show('✓ 购买成功:' + r.item + ' ×' + r.amount + ' 份,花费 $' + r.total_price + ',余额 $' + r.balance_left, 'success'); @@ -2425,9 +2467,8 @@ type: 'text', class: 'purchase-input', placeholder: '请输入你的游戏 ID', value: savedName, maxlength: '16', style: { width: '100%' } }); - const codeInput = el('input', { - type: 'text', inputmode: 'numeric', class: 'purchase-input', placeholder: '游戏内验证码(/qshopwebui code)', maxlength: '6', style: { width: '100%' } - }); + const auth = buildPlayerAuthFields(function () { return nameInput.value.trim(); }); + nameInput.addEventListener('change', auth.refresh); const qtyInput = el('input', { type: 'number', class: 'purchase-input purchase-qty', value: '1', min: '1', max: '64', style: { width: '110px' } @@ -2490,17 +2531,16 @@ el('label', { class: 'purchase-label', text: '游戏 ID' }), nameInput ]), - el('div', { class: 'purchase-field' }, [ - el('label', { class: 'purchase-label', text: '游戏内验证码' }), - codeInput - ]), + auth.loggedWrap, + auth.codeWrap, + auth.pwdWrap, el('div', { class: 'purchase-field' }, [ el('label', { class: 'purchase-label', text: '出售份数(1 份 = ' + stackAmount + ' 个,最多 64 份)' }), el('div', { style: { display: 'flex', alignItems: 'center' } }, [qtyInput, maxBtn]) ]), summary, balanceInfo, - el('div', { class: 'purchase-tip', text: '⚠ 请先在游戏内执行 /qshopwebui code 获取一次性验证码;物品将从你的背包扣除,报酬直接存入你的账户。' + (ownerName ? '' : '(系统商店:无限收购)') }) + el('div', { class: 'purchase-tip', text: '⚠ 游戏内验证码 或 AuthMe 密码 二选一即可;登录一次后短时间内交易免验证。物品将从你的背包扣除,报酬直接存入你的账户。' + (ownerName ? '' : '(系统商店:无限收购)') }) ]); let modalRef = null; @@ -2512,11 +2552,16 @@ const player = nameInput.value.trim(); if (!player) { Toast.show('请输入你的游戏 ID', 'error'); return false; } if (!/^[A-Za-z0-9_]{1,16}$/.test(player)) { Toast.show('游戏 ID 只能包含字母、数字、下划线(1-16 位)', 'error'); return false; } - const code = codeInput.value.trim(); - if (!/^\d{6}$/.test(code)) { Toast.show('请输入 6 位游戏内验证码', 'error'); return false; } + const logged = QSDB.isPlayerLoggedIn(player); + const code = logged ? '' : auth.codeInput.value.trim(); + const pwd = logged ? '' : auth.pwdInput.value.trim(); + if (!logged) { + if (!code && !pwd) { Toast.show('请输入游戏内验证码或 AuthMe 密码', 'error'); return false; } + if (code && !/^\d{6}$/.test(code)) { Toast.show('验证码应为 6 位数字', 'error'); return false; } + } const qty = currentQty(); try { modalRef.setProcessing('处理中...'); } catch (e) { } - QSDB.sellShop(shop.shop_id, player, qty, code).then(function (r) { + QSDB.sellShop(shop.shop_id, player, qty, code, pwd).then(function (r) { if (r && r.success) { try { localStorage.setItem('qsw_player_name', player); } catch (e) { } Toast.show('✓ 出售成功:' + r.item + ' ×' + r.amount + ' 份,获得 $' + r.total_price + ',余额 $' + r.balance_left, 'success'); diff --git a/webroot/js/db.js b/webroot/js/db.js index 97240d2..9ef8fc5 100644 --- a/webroot/js/db.js +++ b/webroot/js/db.js @@ -88,6 +88,82 @@ } catch (e) {} } + // ============================================================ + // 玩家交易登录会话(登录一次后免重复验证码) + // ============================================================ + function getPlayerSession() { + try { + const raw = (typeof localStorage !== 'undefined') ? localStorage.getItem('qsw_player_session') : null; + if (!raw) return null; + const s = JSON.parse(raw); + return (s && s.name && s.token) ? s : null; + } catch (e) { return null; } + } + + function savePlayerSession(name, token, method) { + try { + if (typeof localStorage === 'undefined') return; + localStorage.setItem('qsw_player_session', JSON.stringify({ + name: String(name || '').trim(), token: token || '', method: method || '', at: Date.now() + })); + localStorage.setItem('qsw_player_name', String(name || '').trim()); + } catch (e) {} + } + + function clearPlayerSession() { + try { + if (typeof localStorage !== 'undefined') localStorage.removeItem('qsw_player_session'); + } catch (e) {} + } + + /** 当前玩家(同名)是否已登录 */ + function isPlayerLoggedIn(player) { + const s = getPlayerSession(); + if (!s) return false; + return s.name.toLowerCase() === String(player || '').trim().toLowerCase(); + } + + /** 取当前玩家有效的 token(名字匹配时) */ + function playerTokenFor(player) { + const s = getPlayerSession(); + if (s && s.name.toLowerCase() === String(player || '').trim().toLowerCase()) return s.token; + return ''; + } + + /** 玩家登录:code(游戏内验证码)或 password(AuthMe 密码)二选一 */ + async function playerLogin(player, code, password) { + try { + const data = await apiCall('/player/login', { + method: 'POST', + body: { player: player || '', code: code || '', password: password || '' } + }); + if (data && data.success && data.token) { + savePlayerSession(data.player || player, data.token, data.method); + } + return data || { success: false, error: '服务器无响应' }; + } catch (e) { + return { success: false, error: e.message }; + } + } + + async function playerLogout() { + const s = getPlayerSession(); + try { + await apiCall('/player/logout', { method: 'POST', body: { player: s ? s.name : '', token: s ? s.token : '' } }); + } catch (e) {} + clearPlayerSession(); + return { success: true }; + } + + async function playerSessionInfo(player) { + const s = getPlayerSession(); + const token = playerTokenFor(player); + try { + const data = await apiCall('/player/session?player=' + encodeURIComponent(player || '') + '&token=' + encodeURIComponent(token)); + return data || { success: false }; + } catch (e) { return { success: false, error: e.message }; } + } + async function apiCall(path, opts) { opts = opts || {}; opts.headers = opts.headers || {}; @@ -820,6 +896,13 @@ isLoggedIn: isLoggedIn, isAdmin: isAdmin, getSession: getSession, + // 玩家交易登录会话(免重复验证码) + playerLogin: playerLogin, + playerLogout: playerLogout, + playerSession: playerSessionInfo, + getPlayerSession: getPlayerSession, + isPlayerLoggedIn: isPlayerLoggedIn, + clearPlayerSession: clearPlayerSession, // 通用 API 调用(自动带 session、GET 缓存),供新页签/附属功能使用 call: apiCall, @@ -1037,12 +1120,14 @@ }, // 网页「在线购买」:填游戏 ID + 游戏内一次性验证码购买 - purchaseShop: async function (shopId, player, amount, code) { + purchaseShop: async function (shopId, player, amount, code, password) { try { + const token = playerTokenFor(player); const data = await apiCall('/purchase', { method: 'POST', - body: { shop_id: String(shopId), player: player || '', amount: Number(amount) || 1, code: code || '' } + body: { shop_id: String(shopId), player: player || '', amount: Number(amount) || 1, code: code || '', password: password || '', token: token } }); + if (data && data.auth_token) savePlayerSession(player, data.auth_token, data.auth_method); if (data && data.success) { cacheClear(); memoryCache.lastUpdate = 0; } return data || { success: false, error: '服务器无响应' }; } catch (e) { @@ -1050,13 +1135,15 @@ } }, - // 网页「在线出售」(卖给收购商店):填游戏 ID + 游戏内一次性验证码出售 - sellShop: async function (shopId, player, amount, code) { + // 网页「在线出售」(卖给收购商店):游戏内验证码 或 AuthMe 密码出售 + sellShop: async function (shopId, player, amount, code, password) { try { + const token = playerTokenFor(player); const data = await apiCall('/sell', { method: 'POST', - body: { shop_id: String(shopId), player: player || '', amount: Number(amount) || 1, code: code || '' } + body: { shop_id: String(shopId), player: player || '', amount: Number(amount) || 1, code: code || '', password: password || '', token: token } }); + if (data && data.auth_token) savePlayerSession(player, data.auth_token, data.auth_method); if (data && data.success) { cacheClear(); memoryCache.lastUpdate = 0; } return data || { success: false, error: '服务器无响应' }; } catch (e) { From f9d8d213843bcdb8b9333533d83efd391cb49358 Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Tue, 6 Oct 2026 13:14:02 +0800 Subject: [PATCH 10/13] feat: auto-sync newly added config keys into existing config.yml (text-based append, keeps existing values/comments); bump beta.4 --- docs/DEPLOY.md | 4 + docs/FEATURES.md | 1 + pom.xml | 2 +- .../java/cn/aqcraft/QShopWebUIPlugin.java | 55 +++- .../java/cn/aqcraft/util/ConfigMerger.java | 246 ++++++++++++++++++ src/main/resources/plugin.yml | 2 +- 6 files changed, 307 insertions(+), 3 deletions(-) create mode 100644 src/main/java/cn/aqcraft/util/ConfigMerger.java diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 722161b..4ef484a 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -15,6 +15,10 @@ ## 三、config.yml 字段 +> **配置自动同步**:插件启用(及 `/qshopwebui reload`)时会对比 jar 内置的默认 `config.yml` +> 与服务器上的 `config.yml`,把**新版本新增**而旧配置里缺失的键,以纯文本方式**追加**进去 +> (连同注释)。**只新增,不覆盖**你已修改的值,也不改动或删除任何已有行;无新增项时不写盘。 + ### 服务端 | 键 | 默认 | 说明 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 3ebd8bb..0885678 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -10,6 +10,7 @@ | 玩家订单查询 | 玩家凭游戏内一次性验证码(或登录会话 token)查询自己的买卖记录 | `EnhancedApi.myOrders` | | 玩家登录(免重复验证码) | 游戏内验证码 或 AuthMe 密码登录一次,TTL 内交易/查询免重复验证 | `PlayerAuthService` / `/api/player/login` | | 网页购买 / 出售 | 玩家在线或允许离线时通过网页交易 | `PurchaseService` | +| 配置自动同步 | 启动/重载时把新版本新增的配置键追加到旧 config.yml(保留已有值与注释,只增不改) | `ConfigMerger` | ## P1 经营增强 diff --git a/pom.xml b/pom.xml index c866c7c..1a3ca55 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ cn.aqcraft qshop-webui-plugin - 1.1.0-beta.3 + 1.1.0-beta.4 jar QShopWebUI QuickShop-Hikari 商店 Web 管理系统(单端口,内嵌 Web UI) diff --git a/src/main/java/cn/aqcraft/QShopWebUIPlugin.java b/src/main/java/cn/aqcraft/QShopWebUIPlugin.java index a88a832..83d1f96 100644 --- a/src/main/java/cn/aqcraft/QShopWebUIPlugin.java +++ b/src/main/java/cn/aqcraft/QShopWebUIPlugin.java @@ -80,6 +80,7 @@ public final class QShopWebUIPlugin extends JavaPlugin implements CommandExecuto @Override public void onEnable() { saveDefaultConfig(); + syncConfigKeys(); try { Materials.init(this); cn.aqcraft.util.Pinyin.init(this); @@ -312,7 +313,7 @@ private void help(CommandSender sender) { // ============================================================ public void reloadPlugin() { - reloadConfig(); + syncConfigKeys(); config = PluginConfig.load(this); config.resolve(resolveServerPort()); bridge.reload(); @@ -324,6 +325,58 @@ public void reloadPlugin() { startWeb(); } + /** + * 自动补全「版本新增」的配置项。 + *

对比 jar 内置的 config.yml(默认文本)与玩家磁盘上的 config.yml: + * 凡是内置文件里有、而玩家文件里缺失的键,以纯文本方式追加到玩家 config.yml + * (连同其上方注释)。只新增、不覆盖玩家已有值、不改动/删除任何已有行, + * 因此注释、顺序与自定义值全部保留。检测到无缺失项时不写盘。

+ */ + private void syncConfigKeys() { + try { + File configFile = new File(getDataFolder(), "config.yml"); + if (!configFile.isFile()) { + saveDefaultConfig(); + reloadConfig(); + return; + } + String defText = readBundledConfig(); + if (defText == null) { + reloadConfig(); + return; + } + String curText = new String(Files.readAllBytes(configFile.toPath()), StandardCharsets.UTF_8); + cn.aqcraft.util.ConfigMerger.Result r = cn.aqcraft.util.ConfigMerger.merge(defText, curText); + if (!r.changed) { + reloadConfig(); + return; + } + Files.write(configFile.toPath(), r.text.getBytes(StandardCharsets.UTF_8)); + reloadConfig(); + getLogger().info("[配置] 已自动补全 " + r.added.size() + " 个新增配置项: " + String.join(", ", r.added)); + } catch (Throwable t) { + getLogger().warning("[配置] 自动补全新配置项失败: " + t.getMessage()); + try { + reloadConfig(); + } catch (Throwable ignored) { + } + } + } + + /** 读取 jar 内置的 config.yml 文本(UTF-8)。 */ + private String readBundledConfig() { + try (java.io.InputStream in = getResource("config.yml")) { + if (in == null) return null; + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) bos.write(buf, 0, n); + return new String(bos.toByteArray(), StandardCharsets.UTF_8); + } catch (Throwable t) { + return null; + } + } + /** 读取当前游戏端口(绑定失败时回退读取 server.properties) */ private int resolveServerPort() { try { diff --git a/src/main/java/cn/aqcraft/util/ConfigMerger.java b/src/main/java/cn/aqcraft/util/ConfigMerger.java new file mode 100644 index 0000000..5959461 --- /dev/null +++ b/src/main/java/cn/aqcraft/util/ConfigMerger.java @@ -0,0 +1,246 @@ +package cn.aqcraft.util; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 配置文件增量合并工具。 + * + *

把「jar 内置默认 config.yml」中、玩家磁盘 config.yml 里缺失的配置项 + * 以纯文本方式插入到玩家文件中:

+ *
    + *
  • 缺失的顶层键 / 段落 → 追加到文件末尾(连同其上方注释);
  • + *
  • 缺失的嵌套键(其父段落已存在)→ 插入到该段落末尾(连同其上方注释);
  • + *
  • 绝不修改或删除玩家已有的任何行(保留注释、顺序、自定义值)。
  • + *
+ * + *

仅做基于缩进的行级解析,适用于本插件使用的标准 2 空格缩进 YAML。

+ */ +public final class ConfigMerger { + + private ConfigMerger() { + } + + public static final class Result { + public final String text; + public final boolean changed; + public final List added; + + Result(String text, boolean changed, List added) { + this.text = text; + this.changed = changed; + this.added = added; + } + } + + /** 合并:返回应在玩家文件中使用的新文本(未变化时返回原文)。 */ + public static Result merge(String defText, String curText) { + List defLines = split(defText); + List curLines = split(curText); + List defTop = parse(defLines, 0, 0, defLines.size()).nodes; + ParseResult curParsed = parse(curLines, 0, 0, curLines.size()); + List curTop = curParsed.nodes; + + Map curByKey = new LinkedHashMap<>(); + for (Node n : curTop) curByKey.put(n.key, n); + + List added = new ArrayList<>(); + // 收集插入任务:idx(插入到该行号之前)+ 要插入的行块 + List idxs = new ArrayList<>(); + List> blocks = new ArrayList<>(); + + // 1) 顶层新增(整段或单键)→ 追加到末尾 + for (Node dn : defTop) { + if (curByKey.containsKey(dn.key)) continue; + List block = new ArrayList<>(dn.pre); + block.add(dn.line); + block.addAll(dn.childLines); + idxs.add(new int[]{curLines.size()}); + blocks.add(block); + added.add(dn.key); + } + + // 2) 嵌套新增(父段落已存在)→ 插入到该段落末尾 + for (Node dn : defTop) { + Node cn = curByKey.get(dn.key); + if (cn == null || dn.children.isEmpty()) continue; + java.util.Set existing = new java.util.HashSet<>(); + for (Node cc : cn.children) existing.add(cc.key); + List block = new ArrayList<>(); + for (Node dchild : dn.children) { + if (existing.contains(dchild.key)) continue; + if (!block.isEmpty()) block.add(""); + block.addAll(dchild.pre); + block.add(dchild.line); + block.addAll(dchild.childLines); + added.add(dn.key + "." + dchild.key); + } + if (!block.isEmpty()) { + idxs.add(new int[]{cn.endLine}); + blocks.add(block); + } + } + + if (added.isEmpty()) { + return new Result(curText, false, added); + } + + // 合并同一插入点的块(保持先后顺序) + Map> byIdx = new LinkedHashMap<>(); + for (int k = 0; k < idxs.size(); k++) { + int idx = idxs.get(k)[0]; + byIdx.computeIfAbsent(idx, x -> new ArrayList<>()).addAll(blocks.get(k)); + } + + // 从后往前插入,避免行号偏移 + List positions = new ArrayList<>(byIdx.keySet()); + positions.sort((a, b) -> b - a); + List out = new ArrayList<>(curLines); + for (int idx : positions) { + List toAdd = byIdx.get(idx); + // 追加到末尾时若前面不是空行则先补一个空行分隔 + if (idx >= out.size() && !toAdd.isEmpty() && !out.isEmpty() && !out.get(out.size() - 1).trim().isEmpty()) { + List withSep = new ArrayList<>(); + withSep.add(""); + withSep.addAll(toAdd); + toAdd = withSep; + } + out.addAll(Math.min(idx, out.size()), toAdd); + } + + // 确保以换行结尾 + String text = String.join("\n", out); + if (!text.endsWith("\n")) text = text + "\n"; + return new Result(text, true, added); + } + + // ============================================================ + // 解析 + // ============================================================ + + private static final class Node { + int indent; + String key; + List pre = new ArrayList<>(); + String line = ""; + List childLines = new ArrayList<>(); + List children = new ArrayList<>(); + int endLine; // 该节点(含子行)结束后的行号(插入点) + } + + private static final class ParseResult { + final List nodes; + final int nextIndex; + + ParseResult(List nodes, int nextIndex) { + this.nodes = nodes; + this.nextIndex = nextIndex; + } + } + + private static ParseResult parse(List lines, int baseIndent, int start, int end) { + List nodes = new ArrayList<>(); + List pending = new ArrayList<>(); + int i = start; + while (i < end) { + String line = lines.get(i); + String s = line.trim(); + int ind = indentOf(line); + if (s.isEmpty() || s.startsWith("#")) { + pending.add(line); + i++; + continue; + } + if (ind < baseIndent) { + break; + } + if (ind > baseIndent) { // 缩进异常,跳过 + pending.add(line); + i++; + continue; + } + String[] pk = parseKey(line, ind); + if (pk == null) { + pending.add(line); + i++; + continue; + } + Node n = new Node(); + n.pre = new ArrayList<>(pending); + pending.clear(); + n.indent = ind; + n.key = pk[1]; + n.line = line; + i++; + // 收集子行:缩进大于 baseIndent 的行;空行仅当后面还有更深内容时才并入 + List childLines = new ArrayList<>(); + while (i < end) { + String l2 = lines.get(i); + String s2 = l2.trim(); + if (s2.isEmpty()) { + int j = i + 1; + while (j < end && lines.get(j).trim().isEmpty()) j++; + if (j < end && indentOf(lines.get(j)) > baseIndent) { + childLines.add(l2); + i++; + continue; + } + break; + } + if (indentOf(l2) > baseIndent) { + childLines.add(l2); + i++; + continue; + } + break; + } + n.childLines = childLines; + n.endLine = i; + if (!childLines.isEmpty()) { + int childIndent = indentOf(firstNonBlank(childLines, baseIndent + 2)); + n.children = parse(childLines, childIndent, 0, childLines.size()).nodes; + } + nodes.add(n); + } + return new ParseResult(nodes, i); + } + + private static String firstNonBlank(List lines, int fallback) { + for (String l : lines) { + if (!l.trim().isEmpty()) return l; + } + return ""; + } + + private static String[] parseKey(String line, int indent) { + String rest = line.substring(indent); + if (rest.isEmpty()) return null; + char c0 = rest.charAt(0); + if (c0 == '#' || c0 == '-') return null; + int colon = rest.indexOf(':'); + if (colon < 0) return null; + String key = rest.substring(0, colon).trim(); + if (key.isEmpty() || key.contains(" ")) return null; + return new String[]{String.valueOf(indent), key}; + } + + private static int indentOf(String line) { + int i = 0; + while (i < line.length() && line.charAt(i) == ' ') i++; + return i; + } + + private static List split(String text) { + // 统一 LF 后按行拆分,末尾不保留多余空行 + String t = text.replace("\r\n", "\n").replace("\r", "\n"); + List lines = new ArrayList<>(); + for (String l : t.split("\n", -1)) lines.add(l); + // 去掉结尾因换行产生的最后一个空元素 + if (!lines.isEmpty() && lines.get(lines.size() - 1).isEmpty()) { + lines.remove(lines.size() - 1); + } + return lines; + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b69462e..aa5e141 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: QShopWebUI -version: 1.1.0-beta.3 +version: 1.1.0-beta.4 main: cn.aqcraft.QShopWebUIPlugin api-version: 1.17 description: QuickShop-Hikari 商店 Web 管理系统(单端口内嵌 Web UI,直接读取 QuickShop 数据) From 67e685b6d8cc840cf7bc96d6334454b4c4b9f691 Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Tue, 6 Oct 2026 14:11:14 +0800 Subject: [PATCH 11/13] fix: avoid 'register task while disabled' on shutdown; stop snapshot refresh thread and guard main-thread scheduling; bump beta.5 --- pom.xml | 2 +- .../java/cn/aqcraft/QShopWebUIPlugin.java | 3 +++ .../cn/aqcraft/bridge/QuickShopBridge.java | 2 ++ .../java/cn/aqcraft/data/ShopDataService.java | 19 +++++++++++++++++++ src/main/resources/plugin.yml | 2 +- 5 files changed, 26 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 1a3ca55..63c0514 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ cn.aqcraft qshop-webui-plugin - 1.1.0-beta.4 + 1.1.0-beta.5 jar QShopWebUI QuickShop-Hikari 商店 Web 管理系统(单端口,内嵌 Web UI) diff --git a/src/main/java/cn/aqcraft/QShopWebUIPlugin.java b/src/main/java/cn/aqcraft/QShopWebUIPlugin.java index 83d1f96..dabb693 100644 --- a/src/main/java/cn/aqcraft/QShopWebUIPlugin.java +++ b/src/main/java/cn/aqcraft/QShopWebUIPlugin.java @@ -154,6 +154,9 @@ public void onDisable() { if (webServer != null) { webServer.stop(); } + if (shopData != null) { + shopData.shutdown(); + } if (store != null) { try { store.flushActivity(); diff --git a/src/main/java/cn/aqcraft/bridge/QuickShopBridge.java b/src/main/java/cn/aqcraft/bridge/QuickShopBridge.java index cb64237..2901bad 100644 --- a/src/main/java/cn/aqcraft/bridge/QuickShopBridge.java +++ b/src/main/java/cn/aqcraft/bridge/QuickShopBridge.java @@ -134,6 +134,7 @@ public List getAllShopsOnMainThread() throws Exception { if (Bukkit.isPrimaryThread()) { return getAllShopsRaw(); } + if (!plugin.isEnabled()) return Collections.emptyList(); // 插件停用时禁止注册调度任务 Future> f = Bukkit.getScheduler().callSyncMethod( plugin, (Callable>) this::getAllShopsRaw); try { @@ -178,6 +179,7 @@ public Object getShopById(long id) { /** 把任意任务调度到主线程执行(操作 QuickShop 对象必须如此) */ public T runOnMain(Callable task) throws Exception { if (Bukkit.isPrimaryThread()) return task.call(); + if (!plugin.isEnabled()) throw new Exception("插件已停用,跳过主线程调度"); // 避免 "register task while disabled" Future f = Bukkit.getScheduler().callSyncMethod(plugin, task); return f.get(20, TimeUnit.SECONDS); } diff --git a/src/main/java/cn/aqcraft/data/ShopDataService.java b/src/main/java/cn/aqcraft/data/ShopDataService.java index b0d6d0c..d1e9004 100644 --- a/src/main/java/cn/aqcraft/data/ShopDataService.java +++ b/src/main/java/cn/aqcraft/data/ShopDataService.java @@ -33,14 +33,26 @@ public final class ShopDataService { }); private final java.util.concurrent.atomic.AtomicLong lastShopEventAt = new java.util.concurrent.atomic.AtomicLong(); private final java.util.concurrent.atomic.AtomicBoolean refreshLoopActive = new java.util.concurrent.atomic.AtomicBoolean(); + private volatile boolean closed; public ShopDataService(QShopWebUIPlugin plugin, QuickShopBridge bridge) { this.plugin = plugin; this.bridge = bridge; } + /** 插件停用时调用:停止后台刷新线程,避免停用后再向调度器注册任务。 */ + public void shutdown() { + closed = true; + refreshExec.shutdownNow(); + } + + private boolean canRefresh() { + return !closed && plugin.isEnabled(); + } + /** 获取当前快照(必要时自动刷新) */ public List shops() { + if (!canRefresh()) return snapshot; long ttl = plugin.config().snapshotTtlMs; if (snapshotAt > 0 && System.currentTimeMillis() - snapshotAt <= ttl) { return snapshot; @@ -56,6 +68,7 @@ public List shops() { /** 强制刷新快照(同步执行) */ public void refresh() { + if (!canRefresh()) return; synchronized (lock) { refreshLocked(); } @@ -72,6 +85,7 @@ public void invalidate() { /** QuickShop 商店数据发生变化(创建/删除/改价/库存等)→ 稍后自动刷新快照 */ public void onShopChanged() { + if (!canRefresh()) return; lastShopEventAt.set(System.currentTimeMillis()); if (refreshLoopActive.compareAndSet(false, true)) { refreshExec.schedule(this::debounceTick, 1000L, java.util.concurrent.TimeUnit.MILLISECONDS); @@ -80,6 +94,10 @@ public void onShopChanged() { private void debounceTick() { try { + if (!canRefresh()) { + refreshLoopActive.set(false); + return; + } long now = System.currentTimeMillis(); // 事件仍在持续(如批量删除)→ 继续等待,合并为一轮刷新 if (now - lastShopEventAt.get() < 1500L) { @@ -130,6 +148,7 @@ public java.util.List syncHistory() { private List buildSnapshot(long now) { if (!bridge.isAvailable()) return Collections.emptyList(); + if (!plugin.isEnabled()) return this.snapshot; try { List raw = bridge.getAllShopsOnMainThread(); List list = new ArrayList<>(raw.size()); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index aa5e141..188daba 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: QShopWebUI -version: 1.1.0-beta.4 +version: 1.1.0-beta.5 main: cn.aqcraft.QShopWebUIPlugin api-version: 1.17 description: QuickShop-Hikari 商店 Web 管理系统(单端口内嵌 Web UI,直接读取 QuickShop 数据) From bab777d0d821906c12c99d76cee9c15d2a42646b Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Tue, 6 Oct 2026 14:14:03 +0800 Subject: [PATCH 12/13] fix: purchase showed failure+refund after items delivered (JsonNull key from failed orders broke findByKey); make bookkeeping best-effort; bump beta.6 --- pom.xml | 2 +- .../java/cn/aqcraft/data/FavoritesStore.java | 4 +- .../java/cn/aqcraft/order/OrderStore.java | 44 ++++--- .../cn/aqcraft/purchase/PurchaseService.java | 115 ++++++++++-------- .../cn/aqcraft/service/ShopStatusService.java | 4 +- src/main/resources/plugin.yml | 2 +- 6 files changed, 99 insertions(+), 72 deletions(-) diff --git a/pom.xml b/pom.xml index 63c0514..7c16088 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ cn.aqcraft qshop-webui-plugin - 1.1.0-beta.5 + 1.1.0-beta.6 jar QShopWebUI QuickShop-Hikari 商店 Web 管理系统(单端口,内嵌 Web UI) diff --git a/src/main/java/cn/aqcraft/data/FavoritesStore.java b/src/main/java/cn/aqcraft/data/FavoritesStore.java index 4af0208..59825d5 100644 --- a/src/main/java/cn/aqcraft/data/FavoritesStore.java +++ b/src/main/java/cn/aqcraft/data/FavoritesStore.java @@ -41,7 +41,9 @@ private void load() { for (Map.Entry e : root.entrySet()) { Set set = new LinkedHashSet<>(); if (e.getValue().isJsonArray()) { - for (JsonElement s : e.getValue().getAsJsonArray()) set.add(s.getAsString()); + for (JsonElement s : e.getValue().getAsJsonArray()) { + if (s != null && s.isJsonPrimitive()) set.add(s.getAsString()); + } } data.put(e.getKey().toLowerCase(), set); } diff --git a/src/main/java/cn/aqcraft/order/OrderStore.java b/src/main/java/cn/aqcraft/order/OrderStore.java index 449b043..729cc9f 100644 --- a/src/main/java/cn/aqcraft/order/OrderStore.java +++ b/src/main/java/cn/aqcraft/order/OrderStore.java @@ -74,11 +74,23 @@ public void load() { } private void index(JsonObject o) { - String p = o.has("player") ? o.get("player").getAsString().toLowerCase(Locale.ROOT) : ""; + String p = str(o, "player").toLowerCase(Locale.ROOT); if (p.isEmpty()) return; playerIndex.computeIfAbsent(p, k -> new ArrayList<>()).add(orders.size() - 1); } + /** 安全取字符串:缺失 / JsonNull / 非基本类型 均返回 ""(避免对 JsonNull 调 getAsString 抛异常) */ + private static String str(JsonObject o, String key) { + if (o == null) return ""; + JsonElement e = o.get(key); + return (e == null || e.isJsonNull() || !e.isJsonPrimitive()) ? "" : e.getAsString(); + } + + /** null → "",避免 addProperty 写入 JsonNull */ + private static String safe(String s) { + return s == null ? "" : s; + } + private void save() { JsonArray arr = new JsonArray(); synchronized (this) { @@ -139,19 +151,19 @@ public synchronized JsonObject recordOrder(String idempotencyKey, String type, S JsonObject o = new JsonObject(); o.addProperty("order_id", orderId); o.addProperty("seq", seq); - o.addProperty("key", idempotencyKey); - o.addProperty("type", type); // BUY / SELL - o.addProperty("status", status); // SUCCESS / FAILED / CANCELLED + o.addProperty("key", safe(idempotencyKey)); + o.addProperty("type", safe(type)); // BUY / SELL + o.addProperty("status", safe(status)); // SUCCESS / FAILED / CANCELLED o.addProperty("t", now); - o.addProperty("shop_id", shopId); - o.addProperty("item", item); - o.addProperty("material", material); + o.addProperty("shop_id", safe(shopId)); + o.addProperty("item", safe(item)); + o.addProperty("material", safe(material)); o.addProperty("amount", amount); o.addProperty("items", items); o.addProperty("unit_price", unitPrice); o.addProperty("total", total); - o.addProperty("player", player); - o.addProperty("owner", owner); + o.addProperty("player", safe(player)); + o.addProperty("owner", safe(owner)); o.addProperty("online", online); o.addProperty("source", "web"); if (reason != null && !reason.isEmpty()) o.addProperty("reason", reason); @@ -168,7 +180,7 @@ public synchronized JsonObject recordOrder(String idempotencyKey, String type, S private JsonObject findByKey(String key) { for (int i = orders.size() - 1; i >= 0; i--) { JsonObject o = orders.get(i); - if (key.equals(o.has("key") ? o.get("key").getAsString() : "")) return o; + if (key.equals(str(o, "key"))) return o; } return null; } @@ -184,7 +196,7 @@ private void rebuildIndex() { playerIndex.clear(); for (int i = 0; i < orders.size(); i++) { JsonObject o = orders.get(i); - String p = o.has("player") ? o.get("player").getAsString().toLowerCase(Locale.ROOT) : ""; + String p = str(o, "player").toLowerCase(Locale.ROOT); if (p.isEmpty()) continue; playerIndex.computeIfAbsent(p, k -> new ArrayList<>()).add(i); } @@ -198,7 +210,7 @@ private void rebuildIndex() { public synchronized JsonObject byId(String orderId) { if (orderId == null) return null; for (int i = orders.size() - 1; i >= 0; i--) { - if (orderId.equals(orders.get(i).has("order_id") ? orders.get(i).get("order_id").getAsString() : "")) { + if (orderId.equals(str(orders.get(i), "order_id"))) { return orders.get(i).deepCopy(); } } @@ -216,7 +228,7 @@ public synchronized List byPlayer(String player, String type, int li for (int i = idx.size() - 1; i >= 0; i--) { JsonObject o = orders.get(idx.get(i)); if (o == null) continue; - if (!upType.isEmpty() && !upType.equals(o.has("type") ? o.get("type").getAsString() : "")) continue; + if (!upType.isEmpty() && !upType.equals(str(o, "type"))) continue; out.add(o.deepCopy()); } // 分页 @@ -235,7 +247,7 @@ public synchronized int countByPlayer(String player, String type) { for (int i : idx) { JsonObject o = orders.get(i); if (o == null) continue; - if (!upType.isEmpty() && !upType.equals(o.has("type") ? o.get("type").getAsString() : "")) continue; + if (!upType.isEmpty() && !upType.equals(str(o, "type"))) continue; n++; } return n; @@ -259,9 +271,9 @@ public synchronized List snapshotPaged(String type, String player, i for (int i = orders.size() - 1; i >= 0; i--) { JsonObject o = orders.get(i); if (o == null) continue; - if (!upType.isEmpty() && !upType.equals(o.has("type") ? o.get("type").getAsString() : "")) continue; + if (!upType.isEmpty() && !upType.equals(str(o, "type"))) continue; if (!p.isEmpty()) { - String op = o.has("player") ? o.get("player").getAsString().toLowerCase(Locale.ROOT) : ""; + String op = str(o, "player").toLowerCase(Locale.ROOT); if (!p.equals(op)) continue; } all.add(o.deepCopy()); diff --git a/src/main/java/cn/aqcraft/purchase/PurchaseService.java b/src/main/java/cn/aqcraft/purchase/PurchaseService.java index 5b55968..a36598b 100644 --- a/src/main/java/cn/aqcraft/purchase/PurchaseService.java +++ b/src/main/java/cn/aqcraft/purchase/PurchaseService.java @@ -356,35 +356,41 @@ private JsonObject doPurchase(ShopEntry e, String name, int amount) { } } - if (online) { - buyer.sendMessage("§a[在线购买] §f成功购买 §e" + amount + "§f 份 §b" + e.shop_cn_name - + " §f花费 §e" + total); - } - plugin.shopData().invalidate(); - plugin.store().addFetchLog(0, 0, plugin.shopData().stats().total, "purchase", - name + " 网页购买 " + e.shop_cn_name + " x" + amount + " 花费 " + total); - JsonObject trade = new JsonObject(); - trade.addProperty("order_id", "WEB-" + UUID.randomUUID()); - trade.addProperty("status", "SUCCESS"); - trade.addProperty("t", System.currentTimeMillis()); - trade.addProperty("type", "BUY"); - trade.addProperty("source", "web"); - trade.addProperty("shop_id", e.shop_id); - trade.addProperty("item", e.shop_cn_name); - trade.addProperty("material", e.material); - trade.addProperty("amount", amount); - trade.addProperty("items", needItems); - trade.addProperty("unit_price", unit); - trade.addProperty("total", total); - trade.addProperty("player", name); - trade.addProperty("owner", e.owner_name); - trade.addProperty("online", online); - plugin.store().addTrade(trade); - recordSuccess("BUY", e, name, amount, needItems, unit, total, online); - - // 限购计数(+本次数量,与游戏内共用同一计数) - if (shop != null && plugin.limited().available()) { - plugin.limited().addUsed(shop, payer.getUniqueId(), amount); + // —— 交易已完成(扣款 + 发货)。以下记账/通知为「尽力而为」, + // 即使出错也不能把已完成的交易判为失败(否则物品已发还退款 = 白送)。—— + try { + if (online) { + buyer.sendMessage("§a[在线购买] §f成功购买 §e" + amount + "§f 份 §b" + e.shop_cn_name + + " §f花费 §e" + total); + } + plugin.shopData().invalidate(); + plugin.store().addFetchLog(0, 0, plugin.shopData().stats().total, "purchase", + name + " 网页购买 " + e.shop_cn_name + " x" + amount + " 花费 " + total); + JsonObject trade = new JsonObject(); + trade.addProperty("order_id", "WEB-" + UUID.randomUUID()); + trade.addProperty("status", "SUCCESS"); + trade.addProperty("t", System.currentTimeMillis()); + trade.addProperty("type", "BUY"); + trade.addProperty("source", "web"); + trade.addProperty("shop_id", e.shop_id); + trade.addProperty("item", e.shop_cn_name); + trade.addProperty("material", e.material); + trade.addProperty("amount", amount); + trade.addProperty("items", needItems); + trade.addProperty("unit_price", unit); + trade.addProperty("total", total); + trade.addProperty("player", name); + trade.addProperty("owner", e.owner_name); + trade.addProperty("online", online); + plugin.store().addTrade(trade); + recordSuccess("BUY", e, name, amount, needItems, unit, total, online); + + // 限购计数(+本次数量,与游戏内共用同一计数) + if (shop != null && plugin.limited().available()) { + plugin.limited().addUsed(shop, payer.getUniqueId(), amount); + } + } catch (Throwable bt) { + plugin.getLogger().warning("[购买] 交易已完成但记录/通知失败(不影响本次交易): " + bt); } JsonObject o = new JsonObject(); @@ -496,29 +502,34 @@ private JsonObject doSell(ShopEntry e, String name, int amount) { return failRecord("SELL", e, name, amount, "给你打款失败,交易已取消"); } - seller.sendMessage("§a[在线收购] §f成功出售 §e" + amount + "§f 份 §b" + e.shop_cn_name - + " §f获得 §e" + total); - plugin.shopData().invalidate(); - plugin.store().addFetchLog(0, 0, plugin.shopData().stats().total, "sell", - name + " 网页出售 " + e.shop_cn_name + " x" + amount + " 获得 " + total); - JsonObject trade = new JsonObject(); - trade.addProperty("order_id", "WEB-" + UUID.randomUUID()); - trade.addProperty("status", "SUCCESS"); - trade.addProperty("t", System.currentTimeMillis()); - trade.addProperty("type", "SELL"); - trade.addProperty("source", "web"); - trade.addProperty("shop_id", e.shop_id); - trade.addProperty("item", e.shop_cn_name); - trade.addProperty("material", e.material); - trade.addProperty("amount", amount); - trade.addProperty("items", needItems); - trade.addProperty("unit_price", unit); - trade.addProperty("total", total); - trade.addProperty("player", name); - trade.addProperty("owner", e.owner_name); - trade.addProperty("online", true); - plugin.store().addTrade(trade); - recordSuccess("SELL", e, name, amount, needItems, unit, total, true); + // —— 交易已完成(物品入箱 + 报酬到账)。记账/通知为「尽力而为」,出错不影响本次交易。—— + try { + seller.sendMessage("§a[在线收购] §f成功出售 §e" + amount + "§f 份 §b" + e.shop_cn_name + + " §f获得 §e" + total); + plugin.shopData().invalidate(); + plugin.store().addFetchLog(0, 0, plugin.shopData().stats().total, "sell", + name + " 网页出售 " + e.shop_cn_name + " x" + amount + " 获得 " + total); + JsonObject trade = new JsonObject(); + trade.addProperty("order_id", "WEB-" + UUID.randomUUID()); + trade.addProperty("status", "SUCCESS"); + trade.addProperty("t", System.currentTimeMillis()); + trade.addProperty("type", "SELL"); + trade.addProperty("source", "web"); + trade.addProperty("shop_id", e.shop_id); + trade.addProperty("item", e.shop_cn_name); + trade.addProperty("material", e.material); + trade.addProperty("amount", amount); + trade.addProperty("items", needItems); + trade.addProperty("unit_price", unit); + trade.addProperty("total", total); + trade.addProperty("player", name); + trade.addProperty("owner", e.owner_name); + trade.addProperty("online", true); + plugin.store().addTrade(trade); + recordSuccess("SELL", e, name, amount, needItems, unit, total, true); + } catch (Throwable bt) { + plugin.getLogger().warning("[收购] 交易已完成但记录/通知失败(不影响本次交易): " + bt); + } JsonObject o = new JsonObject(); o.addProperty("success", true); diff --git a/src/main/java/cn/aqcraft/service/ShopStatusService.java b/src/main/java/cn/aqcraft/service/ShopStatusService.java index d3f48d2..363c583 100644 --- a/src/main/java/cn/aqcraft/service/ShopStatusService.java +++ b/src/main/java/cn/aqcraft/service/ShopStatusService.java @@ -46,7 +46,9 @@ public synchronized void load() { JsonObject o = JsonParser.parseString(new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8)).getAsJsonObject(); if (o.has("open")) open = o.get("open").getAsBoolean(); if (o.has("maintenance")) maintenance = o.get("maintenance").getAsBoolean(); - if (o.has("closed_reason")) closedReason = o.get("closed_reason").getAsString(); + if (o.has("closed_reason") && o.get("closed_reason").isJsonPrimitive()) { + closedReason = o.get("closed_reason").getAsString(); + } if (o.has("hidden")) { for (var e : o.get("hidden").getAsJsonObject().entrySet()) { hiddenShops.put(e.getKey(), e.getValue().getAsBoolean()); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 188daba..7750ce6 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: QShopWebUI -version: 1.1.0-beta.5 +version: 1.1.0-beta.6 main: cn.aqcraft.QShopWebUIPlugin api-version: 1.17 description: QuickShop-Hikari 商店 Web 管理系统(单端口内嵌 Web UI,直接读取 QuickShop 数据) From dffc11b9c6bd4ebdea28dd1f022c8d3522ae8955 Mon Sep 17 00:00:00 2001 From: ALingqing_ Date: Tue, 6 Oct 2026 14:17:48 +0800 Subject: [PATCH 13/13] feat: purchase/sell modal auth method selector (code vs AuthMe password); admin shop filter (all/infinite/admin shops); bump beta.7 --- pom.xml | 2 +- src/main/java/cn/aqcraft/api/ShopApi.java | 1 + .../java/cn/aqcraft/data/QueryParams.java | 2 + src/main/java/cn/aqcraft/data/ShopQuery.java | 14 +++ src/main/resources/plugin.yml | 2 +- webroot/js/app.js | 110 +++++++++++++----- webroot/js/db.js | 1 + 7 files changed, 102 insertions(+), 30 deletions(-) diff --git a/pom.xml b/pom.xml index 7c16088..2fd5899 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ cn.aqcraft qshop-webui-plugin - 1.1.0-beta.6 + 1.1.0-beta.7 jar QShopWebUI QuickShop-Hikari 商店 Web 管理系统(单端口,内嵌 Web UI) diff --git a/src/main/java/cn/aqcraft/api/ShopApi.java b/src/main/java/cn/aqcraft/api/ShopApi.java index 8da3bc0..a1548b7 100644 --- a/src/main/java/cn/aqcraft/api/ShopApi.java +++ b/src/main/java/cn/aqcraft/api/ShopApi.java @@ -351,6 +351,7 @@ private QueryParams paramsFrom(HttpRequest req) { p.keyword = kw == null ? "" : kw; p.material = req.param("material", ""); p.shopType = req.param("shop_type", ""); + p.filter = req.param("filter", ""); p.owner = req.param("owner", ""); p.world = req.param("world", ""); p.minPrice = parseDouble(req.param("min_price")); diff --git a/src/main/java/cn/aqcraft/data/QueryParams.java b/src/main/java/cn/aqcraft/data/QueryParams.java index 6c83e76..aeb7254 100644 --- a/src/main/java/cn/aqcraft/data/QueryParams.java +++ b/src/main/java/cn/aqcraft/data/QueryParams.java @@ -10,6 +10,8 @@ public final class QueryParams { public Double minPrice = null; public Double maxPrice = null; public String sort = ""; + /** 商店类型筛选:""=全部,infinite=无限商店,system=管理员(系统)商店,player=玩家商店 */ + public String filter = ""; public int page = 1; public int pageSize = 30; /** null = 未传(显示所有);false = 仅显示价格合理的 */ diff --git a/src/main/java/cn/aqcraft/data/ShopQuery.java b/src/main/java/cn/aqcraft/data/ShopQuery.java index 3c1ae04..a573670 100644 --- a/src/main/java/cn/aqcraft/data/ShopQuery.java +++ b/src/main/java/cn/aqcraft/data/ShopQuery.java @@ -31,10 +31,14 @@ public static Result query(List all, QueryParams p) { String st = p.shopType == null || p.shopType.isEmpty() ? "" : p.shopType.trim().toUpperCase(java.util.Locale.ROOT); String ow = p.owner == null || p.owner.isEmpty() ? "" : p.owner.trim().toLowerCase(java.util.Locale.ROOT); String wd = p.world == null ? "" : p.world.trim(); + String flt = p.filter == null ? "" : p.filter.trim().toLowerCase(java.util.Locale.ROOT); List results = new ArrayList<>(all.size()); for (ShopEntry s : all) { if (!st.isEmpty() && !st.equalsIgnoreCase(s.shop_type)) continue; + if ("infinite".equals(flt) && !isInfinite(s)) continue; + if ("system".equals(flt) && !isSystem(s)) continue; + if ("player".equals(flt) && isSystem(s)) continue; if (!mat.isEmpty() && (s.material == null || !mat.equals(s.material.toUpperCase(java.util.Locale.ROOT)))) continue; if (!ow.isEmpty() && (s.owner_name == null || !s.owner_name.toLowerCase(java.util.Locale.ROOT).contains(ow))) continue; if (!wd.isEmpty() && (s.world == null || !s.world.contains(wd))) continue; @@ -94,6 +98,16 @@ public static Result query(List all, QueryParams p) { return new Result(pageItems, total, page, limit); } + /** 系统(管理员)商店 */ + private static boolean isSystem(ShopEntry s) { + return s.system_shop || Boolean.TRUE.equals(s.is_system_shop); + } + + /** 无限商店(无限库存 / 系统商店) */ + private static boolean isInfinite(ShopEntry s) { + return s.system_shop || (s.quantity != null && s.quantity < 0); + } + private static long parseId(String id) { try { return Long.parseLong(String.valueOf(id)); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 7750ce6..bc445c3 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,5 +1,5 @@ name: QShopWebUI -version: 1.1.0-beta.6 +version: 1.1.0-beta.7 main: cn.aqcraft.QShopWebUIPlugin api-version: 1.17 description: QuickShop-Hikari 商店 Web 管理系统(单端口内嵌 Web UI,直接读取 QuickShop 数据) diff --git a/webroot/js/app.js b/webroot/js/app.js index 753fef7..171d891 100644 --- a/webroot/js/app.js +++ b/webroot/js/app.js @@ -2141,22 +2141,44 @@ return card; } - // 构建玩家「验证方式」区块:已登录(同名会话)时显示状态并免验证; - // 未登录时显示「游戏内验证码」与「AuthMe 密码」两种方式(二选一)。 + // 构建玩家「验证方式」区块:顶部二选一(游戏内验证码 / AuthMe 密码),下方为对应输入框; + // 已登录(同名会话)时改为显示登录状态并免验证。 function buildPlayerAuthFields(getName) { + let method = 'code'; // 'code' | 'password' const codeInput = el('input', { type: 'text', inputmode: 'numeric', class: 'purchase-input', placeholder: '游戏内验证码(/qshopwebui code)', maxlength: '6', style: { width: '100%' } }); - const pwdInput = el('input', { type: 'password', class: 'purchase-input', placeholder: 'AuthMe 密码(与验证码二选一)', maxlength: '64', style: { width: '100%' } }); - const codeWrap = el('div', { class: 'purchase-field' }, [ - el('label', { class: 'purchase-label', text: '游戏内验证码' }), - codeInput - ]); - const pwdWrap = el('div', { class: 'purchase-field' }, [ - el('label', { class: 'purchase-label', text: '或 AuthMe 密码' }), - pwdInput + const pwdInput = el('input', { type: 'password', class: 'purchase-input', placeholder: 'AuthMe 密码', maxlength: '64', style: { width: '100%' } }); + const codeBtn = el('button', { type: 'button', class: 'neo-btn small', text: '游戏内验证码', style: { marginRight: '8px' } }); + const pwdBtn = el('button', { type: 'button', class: 'neo-btn small', text: 'AuthMe 密码' }); + const hint = el('div', { style: { fontSize: '12px', color: '#6b7280', marginTop: '6px' } }); + const inputSlot = el('div', { style: { marginTop: '8px' } }); + const selector = el('div', { style: { display: 'flex', flexWrap: 'wrap' } }, [codeBtn, pwdBtn]); + const authWrap = el('div', { class: 'purchase-field' }, [ + el('label', { class: 'purchase-label', text: '验证方式(二选一,登录一次后免验证)' }), + selector, + inputSlot, + hint ]); + const statusText = el('div', { style: { fontSize: '13px', fontWeight: '600', color: '#065f46' } }); const logoutBtn = el('button', { class: 'neo-btn', text: '退出登录', style: { marginTop: '6px', padding: '6px 12px' } }); const loggedWrap = el('div', { class: 'purchase-field' }, [statusText, logoutBtn]); + + function applyMethod() { + codeBtn.classList.toggle('primary', method === 'code'); + pwdBtn.classList.toggle('primary', method === 'password'); + inputSlot.innerHTML = ''; + if (method === 'code') { + inputSlot.appendChild(codeInput); + hint.textContent = '游戏内执行 /qshopwebui code 获取 6 位验证码,仅可使用一次。'; + } else { + inputSlot.appendChild(pwdInput); + hint.textContent = '使用你登录服务器的 AuthMe 密码(需服务器安装 AuthMe)。'; + } + } + codeBtn.onclick = function () { method = 'code'; applyMethod(); }; + pwdBtn.onclick = function () { method = 'password'; applyMethod(); }; + applyMethod(); + function refresh() { const n = getName(); const logged = n && QSDB.isPlayerLoggedIn(n); @@ -2165,19 +2187,21 @@ const m = s && s.method === 'password' ? '(密码登录)' : (s && s.method === 'code' ? '(验证码登录)' : ''); statusText.textContent = '✓ 已登录' + m + ',本次交易免验证'; loggedWrap.style.display = ''; - codeWrap.style.display = 'none'; - pwdWrap.style.display = 'none'; + authWrap.style.display = 'none'; } else { loggedWrap.style.display = 'none'; - codeWrap.style.display = ''; - pwdWrap.style.display = ''; + authWrap.style.display = ''; } } logoutBtn.onclick = function () { QSDB.playerLogout().then(function () { refresh(); Toast.show('已退出登录', 'success'); }); }; refresh(); - return { codeInput: codeInput, pwdInput: pwdInput, codeWrap: codeWrap, pwdWrap: pwdWrap, loggedWrap: loggedWrap, refresh: refresh }; + return { + authWrap: authWrap, codeInput: codeInput, pwdInput: pwdInput, + loggedWrap: loggedWrap, refresh: refresh, + method: function () { return method; } + }; } // 在线购买弹窗(填游戏 ID + 验证方式购买) @@ -2277,8 +2301,7 @@ nameInput ]), auth.loggedWrap, - auth.codeWrap, - auth.pwdWrap, + auth.authWrap, el('div', { class: 'purchase-field' }, [ el('label', { class: 'purchase-label', text: '购买份数(1 份 = ' + stackAmount + ' 个,最多 64 份)' }), el('div', { style: { display: 'flex', alignItems: 'center' } }, [qtyInput, maxBtn]) @@ -2299,11 +2322,16 @@ if (!player) { Toast.show('请输入你的游戏 ID', 'error'); return false; } if (!/^[A-Za-z0-9_]{1,16}$/.test(player)) { Toast.show('游戏 ID 只能包含字母、数字、下划线(1-16 位)', 'error'); return false; } const logged = QSDB.isPlayerLoggedIn(player); - const code = logged ? '' : auth.codeInput.value.trim(); - const pwd = logged ? '' : auth.pwdInput.value.trim(); + const usePwd = auth.method() === 'password'; + const code = logged ? '' : (usePwd ? '' : auth.codeInput.value.trim()); + const pwd = logged ? '' : (usePwd ? auth.pwdInput.value.trim() : ''); if (!logged) { - if (!code && !pwd) { Toast.show('请输入游戏内验证码或 AuthMe 密码', 'error'); return false; } - if (code && !/^\d{6}$/.test(code)) { Toast.show('验证码应为 6 位数字', 'error'); return false; } + if (usePwd) { + if (!pwd) { Toast.show('请输入 AuthMe 密码', 'error'); return false; } + } else { + if (!code) { Toast.show('请输入游戏内验证码', 'error'); return false; } + if (!/^\d{6}$/.test(code)) { Toast.show('验证码应为 6 位数字', 'error'); return false; } + } } if (remainingLimit != null && currentQty() > remainingLimit) { Toast.show('超出限购:本周期最多还能购买 ' + remainingLimit + ' 份', 'error'); return false; } const qty = currentQty(); @@ -2532,8 +2560,7 @@ nameInput ]), auth.loggedWrap, - auth.codeWrap, - auth.pwdWrap, + auth.authWrap, el('div', { class: 'purchase-field' }, [ el('label', { class: 'purchase-label', text: '出售份数(1 份 = ' + stackAmount + ' 个,最多 64 份)' }), el('div', { style: { display: 'flex', alignItems: 'center' } }, [qtyInput, maxBtn]) @@ -2553,11 +2580,16 @@ if (!player) { Toast.show('请输入你的游戏 ID', 'error'); return false; } if (!/^[A-Za-z0-9_]{1,16}$/.test(player)) { Toast.show('游戏 ID 只能包含字母、数字、下划线(1-16 位)', 'error'); return false; } const logged = QSDB.isPlayerLoggedIn(player); - const code = logged ? '' : auth.codeInput.value.trim(); - const pwd = logged ? '' : auth.pwdInput.value.trim(); + const usePwd = auth.method() === 'password'; + const code = logged ? '' : (usePwd ? '' : auth.codeInput.value.trim()); + const pwd = logged ? '' : (usePwd ? auth.pwdInput.value.trim() : ''); if (!logged) { - if (!code && !pwd) { Toast.show('请输入游戏内验证码或 AuthMe 密码', 'error'); return false; } - if (code && !/^\d{6}$/.test(code)) { Toast.show('验证码应为 6 位数字', 'error'); return false; } + if (usePwd) { + if (!pwd) { Toast.show('请输入 AuthMe 密码', 'error'); return false; } + } else { + if (!code) { Toast.show('请输入游戏内验证码', 'error'); return false; } + if (!/^\d{6}$/.test(code)) { Toast.show('验证码应为 6 位数字', 'error'); return false; } + } } const qty = currentQty(); try { modalRef.setProcessing('处理中...'); } catch (e) { } @@ -2601,6 +2633,28 @@ toolbar.appendChild(sortSelect); root.appendChild(toolbar); + // 管理员筛选开关:全服商店 / 无限商店 / 管理员商店(仅管理员可见) + let shopFilter = ''; + const filterBtns = {}; + const filterBar = el('div', { class: 'admin-only', style: { display: 'none', marginBottom: '12px', alignItems: 'center', flexWrap: 'wrap' } }); + filterBar.appendChild(el('span', { style: { fontSize: '13px', color: '#6b7280', marginRight: '10px' }, text: '筛选:' })); + [['', '全服商店'], ['infinite', '无限商店'], ['system', '管理员商店']].forEach(function (pair) { + const btn = el('button', { class: 'neo-btn small', text: pair[1], style: { marginRight: '8px' } }); + btn.onclick = function () { + shopFilter = pair[0]; + Object.keys(filterBtns).forEach(function (k) { filterBtns[k].classList.remove('primary'); }); + btn.classList.add('primary'); + doSearch(true); + }; + filterBtns[pair[0]] = btn; + filterBar.appendChild(btn); + }); + if (state.isAdmin) { + filterBar.style.display = 'flex'; + if (filterBtns['']) filterBtns[''].classList.add('primary'); + } + root.appendChild(filterBar); + const grid = el('div', { class: 'shop-grid', style: { gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))' } }); const status = el('div', { style: { textAlign: 'center', margin: '16px', fontSize: '13px', color: '#6b7280' } }); const moreBtn = el('button', { @@ -2620,7 +2674,7 @@ if (reset) { page = 1; total = 0; rendered = 0; grid.innerHTML = ''; } try { const data = await QSDB.search(searchInput.value, { - page: page, pageSize: 40, material: matSelect.value || undefined, sort: sortSelect.value + page: page, pageSize: 40, material: matSelect.value || undefined, sort: sortSelect.value, filter: shopFilter || undefined }); if (data.success) { total = data.total || 0; diff --git a/webroot/js/db.js b/webroot/js/db.js index 9ef8fc5..550c459 100644 --- a/webroot/js/db.js +++ b/webroot/js/db.js @@ -226,6 +226,7 @@ if (keyword) params.append('q', keyword); if (opts.material) params.append('material', opts.material); if (opts.shop_type) params.append('shop_type', opts.shop_type); + if (opts.filter) params.append('filter', opts.filter); if (opts.owner) params.append('owner', opts.owner); if (opts.world) params.append('world', opts.world); if (opts.min_price !== undefined) params.append('min_price', opts.min_price);