2 Commits

Author SHA1 Message Date
Purpur Build d5bc819684 feat: add transaction logs and custom bank inputs
Adds configurable fees, transaction history, chat amount input, and v1.1.0 release documentation.
2026-06-30 10:20:52 +08:00
Purpur Build a70fc0a77d docs: 新增 CLAUDE.md 记录项目约定与每次改动后的发行流程
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 18:17:44 +08:00
19 changed files with 757 additions and 40 deletions
+48
View File
@@ -0,0 +1,48 @@
# CraftBank — 项目约定
适用于 **Folia 1.21.8** 的经济与银行核心插件(Java 21+,Gradle 构建)。
## 仓库
- 远端:私有仓库 `dmz7p4xmqh-coder/CraftBank`(默认分支 `main`)。
- `gh``git` 已安装并登录,可直接用于推送与发行。
## 每次改动后的固定流程
完成任何代码改动后,**主动执行**以下步骤(无需等用户再次要求):
1. **构建验证**:运行 `./gradlew shadowJar`,确认编译通过并产出 `build/libs/CraftBank-<version>.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 接口可能在其它插件的异步线程被调用)。
+48
View File
@@ -0,0 +1,48 @@
# CraftBank — 项目约定
适用于 **Folia 1.21.8** 的经济与银行核心插件(Java 21+,Gradle 构建)。
## 仓库
- 远端:私有仓库 `dmz7p4xmqh-coder/CraftBank`(默认分支 `main`)。
- `gh``git` 已安装并登录,可直接用于推送与发行。
## 每次改动后的固定流程
完成任何代码改动后,**主动执行**以下步骤(无需等用户再次要求):
1. **构建验证**:运行 `./gradlew shadowJar`,确认编译通过并产出 `build/libs/CraftBank-<version>.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/``.claude/`(已在 `.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 接口可能在其它插件的异步线程被调用)。
+9 -1
View File
@@ -19,6 +19,9 @@ CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模
| 📜 **支票** | `/cheque <金额>` 具现化为可流通实体物品;右键兑现入钱包;PDC 防伪 + 防刷 |
| 💰 **活期储蓄** | 存取自由,按真实时间戳逐日复利结算(含离线补偿,带封顶)|
| ⏳ **定期存款** | 7/15/30 天可选,到期前**任何情况不可提前支取**;到期本息自动入活期 |
| 📒 **交易流水** | 记录转账、存取、支票、定期、利息与管理操作;管理员可分页查询 |
| 🧾 **手续费** | 转账与活期取现可按配置收取固定 / 百分比手续费 |
| 💬 **自定义金额输入** | 银行 GUI 左键进入聊天输入自定义金额,右键保留「全部」快捷操作 |
---
@@ -56,6 +59,7 @@ CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模
| `/bankadmin reload` | 重载配置 |
| `/bankadmin give/take/set <玩家> <金额>` | 增加 / 扣除 / 设置现金 |
| `/bankadmin setinterest <savings\|term_7d\|term_15d\|term_30d> <利率>` | 动态修改利率 |
| `/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、以及全部消息文案。
---
+1 -1
View File
@@ -4,7 +4,7 @@ plugins {
}
group = "com.craftbank"
version = "1.0.0"
version = "1.1.0"
// 用当前 JDK25)编译,通过 --release 21 产出兼容 Java 21 的字节码,
// 避免 toolchain 触发额外的 JDK 下载。Folia 要求运行时为 Java 21+。
@@ -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;
}
@@ -0,0 +1,48 @@
package com.craftbank.bank;
import com.craftbank.CraftBank;
import com.craftbank.util.Amounts;
/**
* 手续费计算。手续费 = 固定费 + 金额 × 百分比,并受最小/最大值约束。
*
* <p>配置位于 {@code Fees.<场景>},目前支持场景:{@code pay}(转账)、{@code withdraw}(取现)。
* 各场景独立配置,未配置或全为 0 表示不收费。</p>
*/
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;
}
}
@@ -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<Transaction> query(UUID player, int page, int pageSize) {
return plugin.getDatabaseManager().getTransactions(player, page, pageSize);
}
}
@@ -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<String> base = new ArrayList<>(List.of("reload", "give", "take", "set", "setinterest"));
List<String> 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();
@@ -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的支票。");
});
});
@@ -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余额不足。");
}
@@ -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<com.craftbank.model.Transaction> getTransactions(UUID player, int page, int pageSize) {
List<com.craftbank.model.Transaction> 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;
}
}
+11 -8
View File
@@ -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<Integer, Double> 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一旦存入到期前不可取出!"));
}
@@ -0,0 +1,62 @@
package com.craftbank.gui;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 记录玩家在 GUI 中触发的「待聊天输入金额」状态。
*
* <p>玩家点击「存入/取出/办定期」后进入对应 {@link Action} 状态,下一条聊天消息被
* 当作金额处理。状态存活有上限,由聊天监听器消费后清除。</p>
*/
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<UUID, Entry> 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);
}
}
@@ -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;
};
}
@@ -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 的输入端)。
*
* <p>聊天事件为异步触发;账户余额操作切回玩家区域线程,定期写库走异步线程。</p>
*/
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()));
}
});
});
}
}
@@ -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) + ")");
}
@@ -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到活期账户。");
}
});
@@ -0,0 +1,58 @@
package com.craftbank.model;
import java.util.UUID;
/**
* 一条交易流水记录,用于审计与防刷追溯。
*
* <p>{@link #counterparty} 为对手方(转账的另一方、支票开票人等),无对手方时为 null。
* {@link #fee} 为本次交易产生的手续费(无则为 0)。</p>
*/
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; }
}
+20
View File
@@ -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:
# 银行卡