From d5bc819684f6acb9a37b44d5cbacd44f13c3c82b Mon Sep 17 00:00:00 2001 From: Purpur Build Date: Tue, 30 Jun 2026 10:20:52 +0800 Subject: [PATCH] feat: add transaction logs and custom bank inputs Adds configurable fees, transaction history, chat amount input, and v1.1.0 release documentation. --- AGENTS.md | 48 ++++++ README.md | 10 +- build.gradle.kts | 2 +- src/main/java/com/craftbank/CraftBank.java | 22 +++ .../com/craftbank/bank/FeeCalculator.java | 48 ++++++ .../com/craftbank/bank/TransactionLog.java | 44 ++++++ .../craftbank/commands/BankAdminCommand.java | 73 ++++++++- .../com/craftbank/commands/ChequeCommand.java | 2 + .../com/craftbank/commands/PayCommand.java | 24 ++- .../craftbank/database/DatabaseManager.java | 79 ++++++++++ src/main/java/com/craftbank/gui/BankGUI.java | 19 ++- .../java/com/craftbank/gui/PendingInput.java | 62 ++++++++ .../craftbank/hooks/CraftBankExpansion.java | 9 ++ .../listeners/ChatInputListener.java | 138 ++++++++++++++++++ .../craftbank/listeners/InteractListener.java | 2 + .../listeners/InventoryListener.java | 89 +++++++---- .../java/com/craftbank/model/Transaction.java | 58 ++++++++ src/main/resources/config.yml | 20 +++ 18 files changed, 709 insertions(+), 40 deletions(-) create mode 100644 AGENTS.md create mode 100644 src/main/java/com/craftbank/bank/FeeCalculator.java create mode 100644 src/main/java/com/craftbank/bank/TransactionLog.java create mode 100644 src/main/java/com/craftbank/gui/PendingInput.java create mode 100644 src/main/java/com/craftbank/listeners/ChatInputListener.java create mode 100644 src/main/java/com/craftbank/model/Transaction.java diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..401b2c5 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# CraftBank — 项目约定 + +适用于 **Folia 1.21.8** 的经济与银行核心插件(Java 21+,Gradle 构建)。 + +## 仓库 + +- 远端:私有仓库 `dmz7p4xmqh-coder/CraftBank`(默认分支 `main`)。 +- `gh` 与 `git` 已安装并登录,可直接用于推送与发行。 + +## 每次改动后的固定流程 + +完成任何代码改动后,**主动执行**以下步骤(无需等用户再次要求): + +1. **构建验证**:运行 `./gradlew shadowJar`,确认编译通过并产出 `build/libs/CraftBank-.jar`。 +2. **更新 README**:若改动影响功能、指令、权限、配置项或 PlaceholderAPI 变量,同步更新 `README.md`。 +3. **提交并推送**到 `main`。 +4. **发布 Release**:用 `gh release create` 创建新发行版,附上编译好的 jar。 + +## 版本号约定(语义化版本) + +- 新功能 → 递增次版本号:`v1.x.0` +- Bug 修复 → 递增修订号:`v1.0.x` +- 不兼容的重大改动 → 递增主版本号:`v2.0.0` + +发行版 tag 与 `build.gradle.kts` 里的 `version`、`plugin.yml` 的版本需保持一致(`plugin.yml` 通过 `${version}` 占位符在构建时自动填充,改 `build.gradle.kts` 的 `version` 即可)。 + +## 构建注意事项 + +- 用当前 JDK(已验证 JDK 25)编译,通过 `--release 21` 产出 Java 21 字节码。 +- **不要**在 shadowJar 中启用包重定位(`relocate`):当前 shadow 8.3.5 的字节码重映射器在新 JDK 产出的 `invokedynamic` class 上会崩溃。如确需重定位,改用 JDK 21 编译或升级 shadow 插件。 +- MySQL 驱动默认不打包;需要时在 `build.gradle.kts` 取消相应依赖注释。 +- 不要提交 `build/`、`.gradle/`、`.Codex/`(已在 `.gitignore` 中排除)。 + +## 架构速览 + +- 主类 `CraftBank`:初始化各子系统、注册 Vault(Highest)、封装 Folia 调度(`runAsync` / `runForPlayer` / `runGlobal`)。 +- `economy/`:`EconomyManager`(`ConcurrentHashMap` 缓存 + `synchronized` 账户)、`VaultEconomyProvider`(Vault 接口实现)。 +- `bank/BankManager`:活期复利结算、定期存款创建 / 锁定 / 到期结算。 +- `database/DatabaseManager`:SQLite/MySQL,定期领取用 `UPDATE ... WHERE claimed = 0` 原子置位防重复。 +- `items/`:银行卡与支票的 PDC 创建与防伪校验。 +- `commands/`、`listeners/`、`gui/`、`hooks/`:指令、事件、GUI、PlaceholderAPI。 + +## Folia 线程纪律 + +- 严禁 `Bukkit.getScheduler()`。 +- 数据库 / 排序 / 利息扫描等后台任务走 `AsyncScheduler`。 +- 操作玩家物品、开 GUI、发消息从异步切回时调度到 `player.getScheduler()`(实体区域线程)。 +- 经济数据操作必须线程安全(Vault 接口可能在其它插件的异步线程被调用)。 diff --git a/README.md b/README.md index 995fb0f..dc8b034 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模 | 📜 **支票** | `/cheque <金额>` 具现化为可流通实体物品;右键兑现入钱包;PDC 防伪 + 防刷 | | 💰 **活期储蓄** | 存取自由,按真实时间戳逐日复利结算(含离线补偿,带封顶)| | ⏳ **定期存款** | 7/15/30 天可选,到期前**任何情况不可提前支取**;到期本息自动入活期 | +| 📒 **交易流水** | 记录转账、存取、支票、定期、利息与管理操作;管理员可分页查询 | +| 🧾 **手续费** | 转账与活期取现可按配置收取固定 / 百分比手续费 | +| 💬 **自定义金额输入** | 银行 GUI 左键进入聊天输入自定义金额,右键保留「全部」快捷操作 | --- @@ -56,6 +59,7 @@ CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模 | `/bankadmin reload` | 重载配置 | | `/bankadmin give/take/set <玩家> <金额>` | 增加 / 扣除 / 设置现金 | | `/bankadmin setinterest <利率>` | 动态修改利率 | +| `/bankadmin log <玩家> [页码]` | 分页查看玩家交易流水 | --- @@ -67,12 +71,16 @@ CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模 | `%craftbank_cash_formatted%` | 现金(含货币符号)| | `%craftbank_bank_saving%` | 活期(纯数字)| | `%craftbank_bank_saving_formatted%` | 活期(含货币符号)| +| `%craftbank_total%` | 现金 + 活期总额(纯数字)| +| `%craftbank_total_formatted%` | 现金 + 活期总额(含货币符号)| +| `%craftbank_has_card%` | 是否持有有效银行卡 | +| `%craftbank_savings_rate%` | 当前活期日利率百分比 | --- ## ⚙️ 配置文件 -详见生成的 `config.yml`,涵盖:数据库(SQLite/MySQL)、货币设置、利率(活期 / 各定期)、银行交互方块、银行卡与支票的自定义材质 / CustomModelData / 名称 / Lore、以及全部消息文案。 +详见生成的 `config.yml`,涵盖:数据库(SQLite/MySQL)、货币设置、利率(活期 / 各定期)、银行交互方块、转账 / 取现手续费、交易流水开关、银行卡与支票的自定义材质 / CustomModelData / 名称 / Lore、以及全部消息文案。 --- diff --git a/build.gradle.kts b/build.gradle.kts index 341569c..a062078 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "com.craftbank" -version = "1.0.0" +version = "1.1.0" // 用当前 JDK(25)编译,通过 --release 21 产出兼容 Java 21 的字节码, // 避免 toolchain 触发额外的 JDK 下载。Folia 要求运行时为 Java 21+。 diff --git a/src/main/java/com/craftbank/CraftBank.java b/src/main/java/com/craftbank/CraftBank.java index 465d50d..0212449 100644 --- a/src/main/java/com/craftbank/CraftBank.java +++ b/src/main/java/com/craftbank/CraftBank.java @@ -1,6 +1,8 @@ package com.craftbank; import com.craftbank.bank.BankManager; +import com.craftbank.bank.FeeCalculator; +import com.craftbank.bank.TransactionLog; import com.craftbank.commands.BankAdminCommand; import com.craftbank.commands.BankCommand; import com.craftbank.commands.BaltopCommand; @@ -11,6 +13,7 @@ import com.craftbank.database.DatabaseManager; import com.craftbank.economy.EconomyManager; import com.craftbank.economy.VaultEconomyProvider; import com.craftbank.gui.BankGUI; +import com.craftbank.gui.PendingInput; import com.craftbank.hooks.CraftBankExpansion; import com.craftbank.items.BankCardManager; import com.craftbank.items.ChequeManager; @@ -40,6 +43,9 @@ public final class CraftBank extends JavaPlugin { private DatabaseManager databaseManager; private EconomyManager economyManager; private BankManager bankManager; + private TransactionLog transactionLog; + private FeeCalculator feeCalculator; + private PendingInput pendingInput; private BankCardManager bankCardManager; private ChequeManager chequeManager; private BankGUI bankGUI; @@ -70,6 +76,9 @@ public final class CraftBank extends JavaPlugin { this.economyManager = new EconomyManager(this); this.bankManager = new BankManager(this); + this.transactionLog = new TransactionLog(this); + this.feeCalculator = new FeeCalculator(this); + this.pendingInput = new PendingInput(); this.bankCardManager = new BankCardManager(this); this.chequeManager = new ChequeManager(this); this.bankGUI = new BankGUI(this); @@ -138,6 +147,7 @@ public final class CraftBank extends JavaPlugin { getServer().getPluginManager().registerEvents(new PlayerConnectionListener(this), this); getServer().getPluginManager().registerEvents(new InteractListener(this), this); getServer().getPluginManager().registerEvents(new InventoryListener(this), this); + getServer().getPluginManager().registerEvents(new com.craftbank.listeners.ChatInputListener(this), this); } private void registerPlaceholders() { @@ -219,6 +229,18 @@ public final class CraftBank extends JavaPlugin { return bankManager; } + public TransactionLog getTransactionLog() { + return transactionLog; + } + + public FeeCalculator getFeeCalculator() { + return feeCalculator; + } + + public PendingInput getPendingInput() { + return pendingInput; + } + public BankCardManager getBankCardManager() { return bankCardManager; } diff --git a/src/main/java/com/craftbank/bank/FeeCalculator.java b/src/main/java/com/craftbank/bank/FeeCalculator.java new file mode 100644 index 0000000..86e8eae --- /dev/null +++ b/src/main/java/com/craftbank/bank/FeeCalculator.java @@ -0,0 +1,48 @@ +package com.craftbank.bank; + +import com.craftbank.CraftBank; +import com.craftbank.util.Amounts; + +/** + * 手续费计算。手续费 = 固定费 + 金额 × 百分比,并受最小/最大值约束。 + * + *

配置位于 {@code Fees.<场景>},目前支持场景:{@code pay}(转账)、{@code withdraw}(取现)。 + * 各场景独立配置,未配置或全为 0 表示不收费。

+ */ +public final class FeeCalculator { + + private final CraftBank plugin; + + public FeeCalculator(CraftBank plugin) { + this.plugin = plugin; + } + + /** + * 计算指定场景下、给定金额应收的手续费(已规整到 2 位小数)。 + * + * @param scene 配置场景键,如 "pay" / "withdraw" + * @param amount 交易金额 + */ + public double calculate(String scene, double amount) { + String base = "Fees." + scene + "."; + double percent = plugin.getConfig().getDouble(base + "percent", 0.0); + double fixed = plugin.getConfig().getDouble(base + "fixed", 0.0); + double min = plugin.getConfig().getDouble(base + "min", 0.0); + double max = plugin.getConfig().getDouble(base + "max", Double.MAX_VALUE); + + double fee = fixed + amount * percent; + if (fee < min) fee = min; + if (fee > max) fee = max; + if (fee < 0) fee = 0; + // 手续费不应超过交易金额本身。 + if (fee > amount) fee = amount; + return Amounts.normalize(fee); + } + + /** 该场景是否配置了任何手续费。 */ + public boolean hasFee(String scene) { + String base = "Fees." + scene + "."; + return plugin.getConfig().getDouble(base + "percent", 0.0) > 0 + || plugin.getConfig().getDouble(base + "fixed", 0.0) > 0; + } +} diff --git a/src/main/java/com/craftbank/bank/TransactionLog.java b/src/main/java/com/craftbank/bank/TransactionLog.java new file mode 100644 index 0000000..2ef8874 --- /dev/null +++ b/src/main/java/com/craftbank/bank/TransactionLog.java @@ -0,0 +1,44 @@ +package com.craftbank.bank; + +import com.craftbank.CraftBank; +import com.craftbank.model.Transaction; + +import java.util.List; +import java.util.UUID; + +/** + * 交易流水服务。统一封装异步写入与查询,并受配置开关控制是否记录。 + */ +public final class TransactionLog { + + private final CraftBank plugin; + + public TransactionLog(CraftBank plugin) { + this.plugin = plugin; + } + + private boolean enabled() { + return plugin.getConfig().getBoolean("Transaction_Log.enabled", true); + } + + /** 异步记录一条流水(无手续费、无对手方的简化重载)。 */ + public void record(UUID player, Transaction.Type type, double amount, String description) { + record(player, type, amount, 0.0, null, description); + } + + /** 异步记录一条流水。本方法可在任意线程调用,内部自行切换到异步线程写库。 */ + public void record(UUID player, Transaction.Type type, double amount, double fee, + UUID counterparty, String description) { + if (!enabled() || player == null) { + return; + } + Transaction tx = new Transaction(0, player, type, amount, fee, counterparty, + description, System.currentTimeMillis()); + plugin.runAsync(() -> plugin.getDatabaseManager().insertTransaction(tx)); + } + + /** 查询玩家流水(阻塞,须在异步线程调用)。 */ + public List query(UUID player, int page, int pageSize) { + return plugin.getDatabaseManager().getTransactions(player, page, pageSize); + } +} diff --git a/src/main/java/com/craftbank/commands/BankAdminCommand.java b/src/main/java/com/craftbank/commands/BankAdminCommand.java index d115690..3749ccb 100644 --- a/src/main/java/com/craftbank/commands/BankAdminCommand.java +++ b/src/main/java/com/craftbank/commands/BankAdminCommand.java @@ -48,6 +48,7 @@ public final class BankAdminCommand implements TabExecutor { case "take" -> modify(sender, args, ModifyType.TAKE); case "set" -> modify(sender, args, ModifyType.SET); case "setinterest" -> setInterest(sender, args); + case "log" -> queryLog(sender, args); default -> sendHelp(sender); } return true; @@ -90,6 +91,13 @@ public final class BankAdminCommand implements TabExecutor { msg.raw(sender, "&c操作失败 (可能是余额不足)。"); return; } + com.craftbank.model.Transaction.Type txType = switch (type) { + case GIVE -> com.craftbank.model.Transaction.Type.ADMIN_GIVE; + case TAKE -> com.craftbank.model.Transaction.Type.ADMIN_TAKE; + case SET -> com.craftbank.model.Transaction.Type.ADMIN_SET; + }; + plugin.getTransactionLog().record(uuid, txType, amount, + "管理员 " + sender.getName() + " 操作"); msg.raw(sender, "&a已" + label(type) + " &f" + targetName + " &a现金 " + econ.format(amount) + " &7(当前: " + econ.format(econ.getCash(uuid)) + ")"); }); @@ -130,6 +138,66 @@ public final class BankAdminCommand implements TabExecutor { msg.raw(sender, "&a已将 &f" + key + " &a的日利率设置为 &e" + rate + " &7(" + (rate * 100) + "%)"); } + private void queryLog(CommandSender sender, String[] args) { + if (args.length < 2) { + msg.raw(sender, "&c用法: /bankadmin log <玩家> [页码]"); + return; + } + String targetName = args[1]; + int page = 1; + if (args.length >= 3) { + try { + page = Math.max(1, Integer.parseInt(args[2])); + } catch (NumberFormatException ignored) { + page = 1; + } + } + final int finalPage = page; + final int pageSize = 10; + + plugin.runAsync(() -> { + @SuppressWarnings("deprecation") + OfflinePlayer target = Bukkit.getOfflinePlayer(targetName); + UUID uuid = target.getUniqueId(); + if (uuid == null || (!target.hasPlayedBefore() && !target.isOnline())) { + msg.key(sender, "player-not-found", "&c找不到目标玩家。"); + return; + } + var list = plugin.getTransactionLog().query(uuid, finalPage, pageSize); + if (list.isEmpty()) { + msg.raw(sender, "&7" + targetName + " 在第 " + finalPage + " 页没有交易记录。"); + return; + } + EconomyManager econ = plugin.getEconomyManager(); + java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat("MM-dd HH:mm"); + msg.raw(sender, "&b&l" + targetName + " 的交易流水 &7- 第 " + finalPage + " 页"); + for (var tx : list) { + String feePart = tx.getFee() > 0 ? " &8(费 " + econ.format(tx.getFee()) + ")" : ""; + sender.sendMessage(com.craftbank.util.Text.color( + "&7[" + fmt.format(new java.util.Date(tx.getTimestamp())) + "] &f" + + typeLabel(tx.getType()) + " &a" + econ.format(tx.getAmount()) + + feePart + " &8" + (tx.getDescription() == null ? "" : tx.getDescription()))); + } + }); + } + + private String typeLabel(com.craftbank.model.Transaction.Type type) { + return switch (type) { + case PAY_OUT -> "转出"; + case PAY_IN -> "转入"; + case DEPOSIT -> "存活期"; + case WITHDRAW -> "取活期"; + case CHEQUE_ISSUE -> "开支票"; + case CHEQUE_REDEEM -> "兑支票"; + case TERM_CREATE -> "存定期"; + case TERM_CLAIM -> "领定期"; + case INTEREST -> "利息"; + case ADMIN_GIVE -> "管理给予"; + case ADMIN_TAKE -> "管理扣除"; + case ADMIN_SET -> "管理设置"; + }; + } + private void sendHelp(CommandSender sender) { msg.raw(sender, "&b&l工艺银行管理 &7指令帮助:"); sender.sendMessage(com.craftbank.util.Text.color("&7/bankadmin reload")); @@ -137,6 +205,7 @@ public final class BankAdminCommand implements TabExecutor { sender.sendMessage(com.craftbank.util.Text.color("&7/bankadmin take <玩家> <金额>")); sender.sendMessage(com.craftbank.util.Text.color("&7/bankadmin set <玩家> <金额>")); sender.sendMessage(com.craftbank.util.Text.color("&7/bankadmin setinterest <类型> <利率>")); + sender.sendMessage(com.craftbank.util.Text.color("&7/bankadmin log <玩家> [页码]")); } @Override @@ -145,14 +214,14 @@ public final class BankAdminCommand implements TabExecutor { return List.of(); } if (args.length == 1) { - List base = new ArrayList<>(List.of("reload", "give", "take", "set", "setinterest")); + List base = new ArrayList<>(List.of("reload", "give", "take", "set", "setinterest", "log")); base.removeIf(s -> !s.startsWith(args[0].toLowerCase())); return base; } if (args.length == 2 && args[0].equalsIgnoreCase("setinterest")) { return new ArrayList<>(List.of("savings", "term_7d", "term_15d", "term_30d")); } - if (args.length == 2 && List.of("give", "take", "set").contains(args[0].toLowerCase())) { + if (args.length == 2 && List.of("give", "take", "set", "log").contains(args[0].toLowerCase())) { return null; // 在线玩家名 } return List.of(); diff --git a/src/main/java/com/craftbank/commands/ChequeCommand.java b/src/main/java/com/craftbank/commands/ChequeCommand.java index 2021e08..859d43c 100644 --- a/src/main/java/com/craftbank/commands/ChequeCommand.java +++ b/src/main/java/com/craftbank/commands/ChequeCommand.java @@ -67,6 +67,8 @@ public final class ChequeCommand implements TabExecutor { }); return; } + plugin.getTransactionLog().record(player.getUniqueId(), + com.craftbank.model.Transaction.Type.CHEQUE_ISSUE, amount, "开具支票"); msg.raw(player, "&a已开具一张面额 &f" + econ.format(amount) + " &a的支票。"); }); }); diff --git a/src/main/java/com/craftbank/commands/PayCommand.java b/src/main/java/com/craftbank/commands/PayCommand.java index a460fed..589378c 100644 --- a/src/main/java/com/craftbank/commands/PayCommand.java +++ b/src/main/java/com/craftbank/commands/PayCommand.java @@ -71,16 +71,34 @@ public final class PayCommand implements TabExecutor { return; } - if (!econ.has(player.getUniqueId(), amount)) { - msg.key(sender, "insufficient-funds", "&c余额不足。"); + // 手续费由付款方承担:需同时覆盖转账金额与手续费。 + double fee = plugin.getFeeCalculator().calculate("pay", amount); + if (!econ.has(player.getUniqueId(), amount + fee)) { + if (fee > 0) { + msg.raw(sender, "&c余额不足 (需 " + econ.format(amount) + " + 手续费 " + econ.format(fee) + ")。"); + } else { + msg.key(sender, "insufficient-funds", "&c余额不足。"); + } return; } if (econ.transfer(player.getUniqueId(), targetUuid, amount)) { - msg.raw(sender, "&a成功向 &f" + targetName + " &a转账 " + econ.format(amount)); + // 转账成功后再扣手续费(金额已校验充足)。 + if (fee > 0) { + econ.withdraw(player.getUniqueId(), fee); + } + String feeNote = fee > 0 ? " &7(手续费 " + econ.format(fee) + ")" : ""; + msg.raw(sender, "&a成功向 &f" + targetName + " &a转账 " + econ.format(amount) + feeNote); Player onlineTarget = Bukkit.getPlayer(targetUuid); if (onlineTarget != null) { msg.raw(onlineTarget, "&a你收到来自 &f" + player.getName() + " &a的转账 " + econ.format(amount)); } + // 记录双方流水。 + plugin.getTransactionLog().record(player.getUniqueId(), + com.craftbank.model.Transaction.Type.PAY_OUT, amount, fee, targetUuid, + "转账给 " + targetName); + plugin.getTransactionLog().record(targetUuid, + com.craftbank.model.Transaction.Type.PAY_IN, amount, 0, player.getUniqueId(), + "收到 " + player.getName() + " 的转账"); } else { msg.key(sender, "insufficient-funds", "&c余额不足。"); } diff --git a/src/main/java/com/craftbank/database/DatabaseManager.java b/src/main/java/com/craftbank/database/DatabaseManager.java index de1f028..1eb4167 100644 --- a/src/main/java/com/craftbank/database/DatabaseManager.java +++ b/src/main/java/com/craftbank/database/DatabaseManager.java @@ -90,9 +90,23 @@ public class DatabaseManager { "end_time BIGINT NOT NULL," + "claimed INT NOT NULL DEFAULT 0" + ")"; + String transactions = "CREATE TABLE IF NOT EXISTS craftbank_transactions (" + + "id " + autoInc + "," + + "player VARCHAR(36) NOT NULL," + + "type VARCHAR(24) NOT NULL," + + "amount DOUBLE NOT NULL," + + "fee DOUBLE NOT NULL DEFAULT 0," + + "counterparty VARCHAR(36)," + + "description VARCHAR(128)," + + "timestamp BIGINT NOT NULL" + + ")"; try (Connection conn = dataSource.getConnection(); Statement st = conn.createStatement()) { st.executeUpdate(players); st.executeUpdate(deposits); + st.executeUpdate(transactions); + // 按玩家 + 时间查询的索引(日志翻页常用)。 + st.executeUpdate("CREATE INDEX IF NOT EXISTS idx_tx_player_time " + + "ON craftbank_transactions(player, timestamp)"); } } @@ -269,4 +283,69 @@ public class DatabaseManager { rs.getLong("end_time"), rs.getInt("claimed") != 0); } + + // --------------------------------------------------------------------- + // 交易流水 + // --------------------------------------------------------------------- + + /** 写入一条交易流水。 */ + public void insertTransaction(com.craftbank.model.Transaction tx) { + String sql = "INSERT INTO craftbank_transactions " + + "(player, type, amount, fee, counterparty, description, timestamp) VALUES (?,?,?,?,?,?,?)"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, tx.getPlayer().toString()); + ps.setString(2, tx.getType().name()); + ps.setDouble(3, tx.getAmount()); + ps.setDouble(4, tx.getFee()); + ps.setString(5, tx.getCounterparty() == null ? null : tx.getCounterparty().toString()); + ps.setString(6, tx.getDescription()); + ps.setLong(7, tx.getTimestamp()); + ps.executeUpdate(); + } catch (SQLException ex) { + plugin.getLogger().log(Level.WARNING, "写入交易流水失败: " + tx.getPlayer(), ex); + } + } + + /** + * 查询某玩家的交易流水(按时间倒序分页)。 + * + * @param page 从 1 开始的页码 + * @param pageSize 每页条数 + */ + public List getTransactions(UUID player, int page, int pageSize) { + List list = new ArrayList<>(); + int offset = Math.max(0, (page - 1) * pageSize); + String sql = "SELECT * FROM craftbank_transactions WHERE player = ? " + + "ORDER BY timestamp DESC LIMIT ? OFFSET ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, player.toString()); + ps.setInt(2, pageSize); + ps.setInt(3, offset); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String cp = rs.getString("counterparty"); + com.craftbank.model.Transaction.Type t; + try { + t = com.craftbank.model.Transaction.Type.valueOf(rs.getString("type")); + } catch (IllegalArgumentException ignored) { + continue; // 跳过未知类型(向后兼容) + } + list.add(new com.craftbank.model.Transaction( + rs.getLong("id"), + UUID.fromString(rs.getString("player")), + t, + rs.getDouble("amount"), + rs.getDouble("fee"), + cp == null ? null : UUID.fromString(cp), + rs.getString("description"), + rs.getLong("timestamp"))); + } + } + } catch (SQLException ex) { + plugin.getLogger().log(Level.SEVERE, "读取交易流水失败: " + player, ex); + } + return list; + } } diff --git a/src/main/java/com/craftbank/gui/BankGUI.java b/src/main/java/com/craftbank/gui/BankGUI.java index 1ac7207..5da39e3 100644 --- a/src/main/java/com/craftbank/gui/BankGUI.java +++ b/src/main/java/com/craftbank/gui/BankGUI.java @@ -61,13 +61,15 @@ public class BankGUI { inv.setItem(SLOT_TERM_LIST, icon(Material.BOOK, "&b我的定期存款", List.of("&7点击查看 / 领取到期定期存款"))); - inv.setItem(SLOT_DEPOSIT_ALL, icon(Material.HOPPER, "&a存入全部现金", - List.of("&7将钱包中全部现金存入活期", - "&7当前现金: &f" + eco.format(account.getCash())))); + inv.setItem(SLOT_DEPOSIT_ALL, icon(Material.HOPPER, "&a存入活期", + List.of("&7当前现金: &f" + eco.format(account.getCash()), + "&e左键: &f输入自定义金额", + "&e右键: &f存入全部现金"))); - inv.setItem(SLOT_WITHDRAW_ALL, icon(Material.DROPPER, "&c取出全部活期", - List.of("&7将活期余额全部取回钱包", - "&7活期余额: &f" + eco.format(account.getBankSaving())))); + inv.setItem(SLOT_WITHDRAW_ALL, icon(Material.DROPPER, "&c取出活期", + List.of("&7活期余额: &f" + eco.format(account.getBankSaving()), + "&e左键: &f输入自定义金额", + "&e右键: &f取出全部活期"))); Map terms = plugin.getBankManager().getTermOptions(); inv.setItem(SLOT_TERM_7, termIcon(eco, 7, terms.getOrDefault(7, 0.0), account.getBankSaving())); @@ -81,8 +83,9 @@ public class BankGUI { return icon(Material.DIAMOND, "&b定期存款 · " + days + "天", List.of("&7日利率: &a" + percent(rate), "&7到期利息: &a" + percent(rate * days) + " (单利)", - "&8点击将<活期全部余额>存入该定期", - "&8可存金额: &f" + eco.format(saving), + "&7可存活期: &f" + eco.format(saving), + "&e左键: &f输入自定义金额", + "&e右键: &f存入全部活期", "&c一旦存入到期前不可取出!")); } diff --git a/src/main/java/com/craftbank/gui/PendingInput.java b/src/main/java/com/craftbank/gui/PendingInput.java new file mode 100644 index 0000000..4391f78 --- /dev/null +++ b/src/main/java/com/craftbank/gui/PendingInput.java @@ -0,0 +1,62 @@ +package com.craftbank.gui; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 记录玩家在 GUI 中触发的「待聊天输入金额」状态。 + * + *

玩家点击「存入/取出/办定期」后进入对应 {@link Action} 状态,下一条聊天消息被 + * 当作金额处理。状态存活有上限,由聊天监听器消费后清除。

+ */ +public final class PendingInput { + + public enum Action { + DEPOSIT, // 现金 -> 活期 + WITHDRAW, // 活期 -> 现金 + TERM // 办理定期(extra = 天数) + } + + /** 单条待输入记录。 */ + public static final class Entry { + public final Action action; + public final int extra; // TERM 时为天数,其它为 0 + public final long createdAt; + + Entry(Action action, int extra) { + this.action = action; + this.extra = extra; + this.createdAt = System.currentTimeMillis(); + } + } + + /** 待输入状态有效期:超时后视为放弃。 */ + private static final long EXPIRE_MS = 60_000L; + + private final Map pending = new ConcurrentHashMap<>(); + + public void await(UUID player, Action action, int extra) { + pending.put(player, new Entry(action, extra)); + } + + /** 取出并移除待输入状态;不存在或已过期返回 null。 */ + public Entry consume(UUID player) { + Entry e = pending.remove(player); + if (e == null) { + return null; + } + if (System.currentTimeMillis() - e.createdAt > EXPIRE_MS) { + return null; + } + return e; + } + + public boolean isAwaiting(UUID player) { + return pending.containsKey(player); + } + + public void cancel(UUID player) { + pending.remove(player); + } +} diff --git a/src/main/java/com/craftbank/hooks/CraftBankExpansion.java b/src/main/java/com/craftbank/hooks/CraftBankExpansion.java index 80072e3..55eed38 100644 --- a/src/main/java/com/craftbank/hooks/CraftBankExpansion.java +++ b/src/main/java/com/craftbank/hooks/CraftBankExpansion.java @@ -48,16 +48,25 @@ public class CraftBankExpansion extends PlaceholderExpansion { if (player == null) { return ""; } + // 不依赖账户的变量先处理。 + if (params.equalsIgnoreCase("savings_rate")) { + return String.format("%.3f%%", plugin.getBankManager().getSavingsRate() * 100); + } + // 占位符在主线程被解析: 仅读取已缓存数据, 不触发阻塞式数据库查询。 PlayerAccount account = plugin.getEconomyManager().getCached(player.getUniqueId()); double cash = account != null ? account.getCash() : 0.0; double saving = account != null ? account.getBankSaving() : 0.0; + double total = cash + saving; return switch (params.toLowerCase()) { case "cash" -> plugin.getEconomyManager().formatNumber(cash); case "cash_formatted" -> plugin.getEconomyManager().format(cash); case "bank_saving" -> plugin.getEconomyManager().formatNumber(saving); case "bank_saving_formatted" -> plugin.getEconomyManager().format(saving); + case "total" -> plugin.getEconomyManager().formatNumber(total); + case "total_formatted" -> plugin.getEconomyManager().format(total); + case "has_card" -> account != null && account.hasValidCard() ? "是" : "否"; default -> null; }; } diff --git a/src/main/java/com/craftbank/listeners/ChatInputListener.java b/src/main/java/com/craftbank/listeners/ChatInputListener.java new file mode 100644 index 0000000..4ea6ecb --- /dev/null +++ b/src/main/java/com/craftbank/listeners/ChatInputListener.java @@ -0,0 +1,138 @@ +package com.craftbank.listeners; + +import com.craftbank.CraftBank; +import com.craftbank.economy.EconomyManager; +import com.craftbank.gui.PendingInput; +import com.craftbank.model.PlayerAccount; +import com.craftbank.model.TermDeposit; +import com.craftbank.model.Transaction; +import com.craftbank.util.Amounts; +import io.papermc.paper.event.player.AsyncChatEvent; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.TextComponent; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import java.util.UUID; + +/** + * 捕获处于「待输入金额」状态玩家的下一条聊天消息,将其解析为金额并执行对应的 + * 存入 / 取出 / 办理定期操作(自定义金额 GUI 的输入端)。 + * + *

聊天事件为异步触发;账户余额操作切回玩家区域线程,定期写库走异步线程。

+ */ +public final class ChatInputListener implements Listener { + + private final CraftBank plugin; + + public ChatInputListener(CraftBank plugin) { + this.plugin = plugin; + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onChat(AsyncChatEvent event) { + Player player = event.getPlayer(); + UUID uuid = player.getUniqueId(); + if (!plugin.getPendingInput().isAwaiting(uuid)) { + return; + } + PendingInput.Entry entry = plugin.getPendingInput().consume(uuid); + // 拦截这条聊天,不广播给其他玩家。 + event.setCancelled(true); + if (entry == null) { + plugin.msg().raw(player, "&c输入已超时, 请重新在银行界面操作。"); + return; + } + + String raw = plainText(event.message()).trim(); + if (raw.equalsIgnoreCase("cancel") || raw.equalsIgnoreCase("取消")) { + plugin.msg().raw(player, "&7已取消本次操作。"); + return; + } + double amount = Amounts.parse(raw); + if (amount <= 0) { + plugin.msg().raw(player, "&c无效金额, 操作已取消。请输入正数, 例如 &f1000"); + return; + } + + PlayerAccount account = plugin.getEconomyManager().getCached(uuid); + if (account == null) { + plugin.msg().raw(player, "&c账户尚未加载完成, 请稍后再试。"); + return; + } + + switch (entry.action) { + case DEPOSIT -> plugin.runForPlayer(player, () -> doDeposit(player, account, amount)); + case WITHDRAW -> plugin.runForPlayer(player, () -> doWithdraw(player, account, amount)); + case TERM -> doTerm(player, account, amount, entry.extra); + } + } + + /** + * 将聊天消息 Component 递归提取为纯文本。folia-api 不打包 Adventure 的 + * PlainTextComponentSerializer, 故这里手动遍历 TextComponent 内容与子节点, + * 对聊天输入 (单行数字/关键字) 已足够。 + */ + private String plainText(Component component) { + StringBuilder sb = new StringBuilder(); + if (component instanceof TextComponent tc) { + sb.append(tc.content()); + } + for (Component child : component.children()) { + sb.append(plainText(child)); + } + return sb.toString(); + } + + private void doDeposit(Player player, PlayerAccount account, double amount) { + EconomyManager econ = plugin.getEconomyManager(); + synchronized (account) { + if (!account.withdrawCash(amount)) { + plugin.msg().key(player, "insufficient-funds", "&c余额不足。"); + return; + } + account.depositSaving(amount); + } + plugin.persistAsync(account); + plugin.getTransactionLog().record(player.getUniqueId(), Transaction.Type.DEPOSIT, amount, "存入活期"); + plugin.msg().raw(player, "&a已将 &e" + econ.format(amount) + " &a存入活期账户。"); + } + + private void doWithdraw(Player player, PlayerAccount account, double amount) { + EconomyManager econ = plugin.getEconomyManager(); + // 取现手续费(若配置)。 + double fee = plugin.getFeeCalculator().calculate("withdraw", amount); + synchronized (account) { + if (!account.withdrawSaving(amount)) { + plugin.msg().raw(player, "&c活期余额不足。"); + return; + } + // 实际到手 = 金额 - 手续费。 + account.depositCash(amount - fee); + } + plugin.persistAsync(account); + plugin.getTransactionLog().record(player.getUniqueId(), Transaction.Type.WITHDRAW, amount, fee, null, "取出活期"); + String feeNote = fee > 0 ? " &7(手续费 " + econ.format(fee) + ", 到手 " + econ.format(amount - fee) + ")" : ""; + plugin.msg().raw(player, "&a已从活期取回 &e" + econ.format(amount) + " &a到钱包。" + feeNote); + } + + private void doTerm(Player player, PlayerAccount account, double amount, int days) { + EconomyManager econ = plugin.getEconomyManager(); + plugin.runAsync(() -> { + TermDeposit deposit = plugin.getBankManager().createTermDeposit(account, amount, days); + plugin.runForPlayer(player, () -> { + if (deposit == null) { + plugin.msg().raw(player, "&c办理失败: 活期余额不足或期限无效。"); + } else { + plugin.getTransactionLog().record(player.getUniqueId(), Transaction.Type.TERM_CREATE, + deposit.getPrincipal(), days + "天定期"); + plugin.msg().raw(player, "&a成功存入 " + days + "天定期: &e" + + econ.format(deposit.getPrincipal()) + + "&a, 到期本息 &e" + econ.format(deposit.calculateTotalPayout())); + } + }); + }); + } +} diff --git a/src/main/java/com/craftbank/listeners/InteractListener.java b/src/main/java/com/craftbank/listeners/InteractListener.java index 52b8300..ee7dc3f 100644 --- a/src/main/java/com/craftbank/listeners/InteractListener.java +++ b/src/main/java/com/craftbank/listeners/InteractListener.java @@ -104,6 +104,8 @@ public class InteractListener implements Listener { } plugin.getEconomyManager().deposit(player.getUniqueId(), amount); + plugin.getTransactionLog().record(player.getUniqueId(), + com.craftbank.model.Transaction.Type.CHEQUE_REDEEM, amount, "兑现支票"); plugin.msg().raw(player, "&a成功兑现支票 &e" + plugin.getEconomyManager().format(amount) + " &a到你的钱包! (开票人: " + plugin.getChequeManager().getIssuerName(current) + ")"); } diff --git a/src/main/java/com/craftbank/listeners/InventoryListener.java b/src/main/java/com/craftbank/listeners/InventoryListener.java index 70a60f8..6644ece 100644 --- a/src/main/java/com/craftbank/listeners/InventoryListener.java +++ b/src/main/java/com/craftbank/listeners/InventoryListener.java @@ -60,50 +60,87 @@ public class InventoryListener implements Listener { } if (gui.getType() == BankGuiHolder.Type.MAIN) { - handleMain(player, account, event.getRawSlot()); + handleMain(player, account, event.getRawSlot(), event.isRightClick()); } else if (gui.getType() == BankGuiHolder.Type.TERM_LIST) { handleTermList(player, account, event.getCurrentItem(), event.getRawSlot(), top.getSize()); } } - private void handleMain(Player player, PlayerAccount account, int slot) { + private void handleMain(Player player, PlayerAccount account, int slot, boolean rightClick) { switch (slot) { case BankGUI.SLOT_DEPOSIT_ALL -> { - double cash = account.getCash(); - if (cash <= 0) { - plugin.msg().raw(player, "&c你的钱包里没有现金可以存入。"); - return; + if (rightClick) { + depositAll(player, account); + } else { + promptInput(player, com.craftbank.gui.PendingInput.Action.DEPOSIT, 0, "存入活期"); } - if (account.withdrawCash(cash)) { - account.depositSaving(cash); - plugin.persistAsync(account); - plugin.msg().raw(player, "&a已将 &e" + plugin.getEconomyManager().format(cash) + " &a存入活期。"); - } - plugin.getBankGUI().openMain(player, account); } case BankGUI.SLOT_WITHDRAW_ALL -> { - double saving = account.getBankSaving(); - if (saving <= 0) { - plugin.msg().raw(player, "&c你的活期账户里没有余额可以取出。"); - return; + if (rightClick) { + withdrawAll(player, account); + } else { + promptInput(player, com.craftbank.gui.PendingInput.Action.WITHDRAW, 0, "取出活期"); } - if (account.withdrawSaving(saving)) { - account.depositCash(saving); - plugin.persistAsync(account); - plugin.msg().raw(player, "&a已从活期取回 &e" + plugin.getEconomyManager().format(saving) + " &a到钱包。"); - } - plugin.getBankGUI().openMain(player, account); } case BankGUI.SLOT_TERM_LIST -> openTermList(player, account); - case BankGUI.SLOT_TERM_7 -> createTerm(player, account, 7); - case BankGUI.SLOT_TERM_15 -> createTerm(player, account, 15); - case BankGUI.SLOT_TERM_30 -> createTerm(player, account, 30); + case BankGUI.SLOT_TERM_7 -> termSlot(player, account, 7, rightClick); + case BankGUI.SLOT_TERM_15 -> termSlot(player, account, 15, rightClick); + case BankGUI.SLOT_TERM_30 -> termSlot(player, account, 30, rightClick); default -> { // 其它槽位 (信息/背景) 无操作。 } } } + /** 进入聊天输入态,让玩家自定义金额。 */ + private void promptInput(Player player, com.craftbank.gui.PendingInput.Action action, int extra, String label) { + player.closeInventory(); + plugin.getPendingInput().await(player.getUniqueId(), action, extra); + plugin.msg().raw(player, "&e请在聊天框输入要" + label + "的金额 &7(输入 &fcancel &7取消, 60 秒内有效)"); + } + + private void termSlot(Player player, PlayerAccount account, int days, boolean rightClick) { + if (rightClick) { + createTerm(player, account, days); // 右键:用全部活期办定期(旧行为) + } else { + promptInput(player, com.craftbank.gui.PendingInput.Action.TERM, days, days + "天定期存入"); + } + } + + private void depositAll(Player player, PlayerAccount account) { + double cash = account.getCash(); + if (cash <= 0) { + plugin.msg().raw(player, "&c你的钱包里没有现金可以存入。"); + return; + } + if (account.withdrawCash(cash)) { + account.depositSaving(cash); + plugin.persistAsync(account); + plugin.getTransactionLog().record(player.getUniqueId(), + com.craftbank.model.Transaction.Type.DEPOSIT, cash, "存入活期(全部)"); + plugin.msg().raw(player, "&a已将 &e" + plugin.getEconomyManager().format(cash) + " &a存入活期。"); + } + plugin.getBankGUI().openMain(player, account); + } + + private void withdrawAll(Player player, PlayerAccount account) { + double saving = account.getBankSaving(); + if (saving <= 0) { + plugin.msg().raw(player, "&c你的活期账户里没有余额可以取出。"); + return; + } + double fee = plugin.getFeeCalculator().calculate("withdraw", saving); + if (account.withdrawSaving(saving)) { + account.depositCash(saving - fee); + plugin.persistAsync(account); + plugin.getTransactionLog().record(player.getUniqueId(), + com.craftbank.model.Transaction.Type.WITHDRAW, saving, fee, null, "取出活期(全部)"); + String feeNote = fee > 0 ? " &7(手续费 " + plugin.getEconomyManager().format(fee) + ")" : ""; + plugin.msg().raw(player, "&a已从活期取回 &e" + plugin.getEconomyManager().format(saving) + " &a到钱包。" + feeNote); + } + plugin.getBankGUI().openMain(player, account); + } + private void createTerm(Player player, PlayerAccount account, int days) { double amount = account.getBankSaving(); if (amount <= 0) { @@ -165,6 +202,8 @@ public class InventoryListener implements Listener { if (payout < 0) { plugin.msg().raw(player, "&c该定期存款尚未到期, 无法提前支取!"); } else { + plugin.getTransactionLog().record(player.getUniqueId(), + com.craftbank.model.Transaction.Type.TERM_CLAIM, payout, "领取定期本息"); plugin.msg().raw(player, "&a已领取定期本息 &e" + plugin.getEconomyManager().format(payout) + " &a到活期账户。"); } }); diff --git a/src/main/java/com/craftbank/model/Transaction.java b/src/main/java/com/craftbank/model/Transaction.java new file mode 100644 index 0000000..b71165a --- /dev/null +++ b/src/main/java/com/craftbank/model/Transaction.java @@ -0,0 +1,58 @@ +package com.craftbank.model; + +import java.util.UUID; + +/** + * 一条交易流水记录,用于审计与防刷追溯。 + * + *

{@link #counterparty} 为对手方(转账的另一方、支票开票人等),无对手方时为 null。 + * {@link #fee} 为本次交易产生的手续费(无则为 0)。

+ */ +public class Transaction { + + /** 交易类型。 */ + public enum Type { + PAY_OUT, // 转出(/pay 付款方) + PAY_IN, // 转入(/pay 收款方) + DEPOSIT, // 现金 -> 活期 + WITHDRAW, // 活期 -> 现金 + CHEQUE_ISSUE, // 开具支票 + CHEQUE_REDEEM, // 兑现支票 + TERM_CREATE, // 创建定期 + TERM_CLAIM, // 领取定期本息 + INTEREST, // 活期利息结算 + ADMIN_GIVE, // 管理员给予 + ADMIN_TAKE, // 管理员扣除 + ADMIN_SET // 管理员设置 + } + + private final long id; + private final UUID player; + private final Type type; + private final double amount; + private final double fee; + private final UUID counterparty; + private final String description; + private final long timestamp; + + public Transaction(long id, UUID player, Type type, double amount, double fee, + UUID counterparty, String description, long timestamp) { + this.id = id; + this.player = player; + this.type = type; + this.amount = amount; + this.fee = fee; + this.counterparty = counterparty; + this.description = description; + this.timestamp = timestamp; + } + + public long getId() { return id; } + public UUID getPlayer() { return player; } + public Type getType() { return type; } + public double getAmount() { return amount; } + public double getFee() { return fee; } + public UUID getCounterparty() { return counterparty; } + public String getDescription() { return description; } + public long getTimestamp() { return timestamp; } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 07bc9b0..94c72eb 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -51,6 +51,26 @@ Bank_Settings: # 利息结算最大补偿天数 (防止离线过久产生天文数字利息) max_offline_interest_days: 30 +# 手续费设置 (手续费 = fixed + 金额 × percent, 受 min/max 约束; 全为 0 即不收费) +Fees: + # 转账手续费 (由付款方承担) + pay: + percent: 0.0 # 百分比, 如 0.01 = 1% + fixed: 0.0 # 固定费用 + min: 0.0 # 最低手续费 + max: 1000000.0 # 最高手续费 + # 活期取现手续费 (从取出金额中扣除) + withdraw: + percent: 0.0 + fixed: 0.0 + min: 0.0 + max: 1000000.0 + +# 交易流水记录 +Transaction_Log: + # 是否记录交易流水 (转账/存取/支票/定期/管理操作) + enabled: true + # 自定义物品设置 Items: # 银行卡