From eddc706c9acc51164a2d292be11e14ca581d85c0 Mon Sep 17 00:00:00 2001 From: Purpur Build Date: Mon, 29 Jun 2026 18:16:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20CraftBank=20Folia=201.21.8=20=E7=BB=8F?= =?UTF-8?q?=E6=B5=8E=E4=B8=8E=E9=93=B6=E8=A1=8C=E6=A0=B8=E5=BF=83=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=88=9D=E5=A7=8B=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实现完整的现金/钱包、银行卡、支票、活期储蓄与定期存款系统, 实现并以 Highest 优先级注册 Vault Economy 接口,接入 PlaceholderAPI。 全程遵循 Folia 调度模型(AsyncScheduler / 实体区域线程), 数据缓存线程安全,支票兑现与定期领取做防刷处理。 Co-Authored-By: Claude Opus 4.8 --- .gitignore | 23 ++ README.md | 101 ++++++ Readme.txt | 94 ++++++ build.gradle.kts | 65 ++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43583 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 252 +++++++++++++++ gradlew.bat | 94 ++++++ settings.gradle.kts | 1 + src/main/java/com/craftbank/CraftBank.java | 233 ++++++++++++++ .../java/com/craftbank/bank/BankManager.java | 161 ++++++++++ .../com/craftbank/commands/BaltopCommand.java | 66 ++++ .../craftbank/commands/BankAdminCommand.java | 160 ++++++++++ .../com/craftbank/commands/BankCommand.java | 179 +++++++++++ .../com/craftbank/commands/ChequeCommand.java | 80 +++++ .../com/craftbank/commands/MoneyCommand.java | 71 +++++ .../com/craftbank/commands/PayCommand.java | 98 ++++++ .../craftbank/database/DatabaseManager.java | 272 +++++++++++++++++ .../com/craftbank/economy/EconomyManager.java | 212 +++++++++++++ .../economy/VaultEconomyProvider.java | 288 ++++++++++++++++++ src/main/java/com/craftbank/gui/BankGUI.java | 156 ++++++++++ .../java/com/craftbank/gui/BankGuiHolder.java | 36 +++ .../craftbank/hooks/CraftBankExpansion.java | 64 ++++ .../com/craftbank/items/BankCardManager.java | 122 ++++++++ .../com/craftbank/items/ChequeManager.java | 111 +++++++ src/main/java/com/craftbank/items/Keys.java | 32 ++ .../craftbank/listeners/InteractListener.java | 110 +++++++ .../listeners/InventoryListener.java | 173 +++++++++++ .../listeners/PlayerConnectionListener.java | 63 ++++ .../com/craftbank/model/PlayerAccount.java | 134 ++++++++ .../java/com/craftbank/model/TermDeposit.java | 80 +++++ src/main/java/com/craftbank/util/Amounts.java | 36 +++ src/main/java/com/craftbank/util/Msg.java | 40 +++ src/main/java/com/craftbank/util/Text.java | 37 +++ src/main/resources/config.yml | 84 +++++ src/main/resources/plugin.yml | 40 +++ 36 files changed, 3775 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 Readme.txt create mode 100644 build.gradle.kts create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/main/java/com/craftbank/CraftBank.java create mode 100644 src/main/java/com/craftbank/bank/BankManager.java create mode 100644 src/main/java/com/craftbank/commands/BaltopCommand.java create mode 100644 src/main/java/com/craftbank/commands/BankAdminCommand.java create mode 100644 src/main/java/com/craftbank/commands/BankCommand.java create mode 100644 src/main/java/com/craftbank/commands/ChequeCommand.java create mode 100644 src/main/java/com/craftbank/commands/MoneyCommand.java create mode 100644 src/main/java/com/craftbank/commands/PayCommand.java create mode 100644 src/main/java/com/craftbank/database/DatabaseManager.java create mode 100644 src/main/java/com/craftbank/economy/EconomyManager.java create mode 100644 src/main/java/com/craftbank/economy/VaultEconomyProvider.java create mode 100644 src/main/java/com/craftbank/gui/BankGUI.java create mode 100644 src/main/java/com/craftbank/gui/BankGuiHolder.java create mode 100644 src/main/java/com/craftbank/hooks/CraftBankExpansion.java create mode 100644 src/main/java/com/craftbank/items/BankCardManager.java create mode 100644 src/main/java/com/craftbank/items/ChequeManager.java create mode 100644 src/main/java/com/craftbank/items/Keys.java create mode 100644 src/main/java/com/craftbank/listeners/InteractListener.java create mode 100644 src/main/java/com/craftbank/listeners/InventoryListener.java create mode 100644 src/main/java/com/craftbank/listeners/PlayerConnectionListener.java create mode 100644 src/main/java/com/craftbank/model/PlayerAccount.java create mode 100644 src/main/java/com/craftbank/model/TermDeposit.java create mode 100644 src/main/java/com/craftbank/util/Amounts.java create mode 100644 src/main/java/com/craftbank/util/Msg.java create mode 100644 src/main/java/com/craftbank/util/Text.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/plugin.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..df7d60c --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.classpath +.project +bin/ + +# OS +.DS_Store +Thumbs.db + +# 日志 +*.log + +# Claude Code 本地配置 +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..995fb0f --- /dev/null +++ b/README.md @@ -0,0 +1,101 @@ +# 🏦 CraftBank(工艺银行) + +> 适用于 **Folia 1.21.8** 的全能经济与银行核心插件 —— 旨在完全替代 XConomy 等传统经济插件。 + +[![Folia](https://img.shields.io/badge/Folia-1.21.8-blue)](https://papermc.io/software/folia) +[![Java](https://img.shields.io/badge/Java-21%2B-orange)](https://adoptium.net/) +[![Vault](https://img.shields.io/badge/Vault-required-green)](https://www.spigotmc.org/resources/vault.34315/) + +CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模拟的银行系统:实体银行卡、可交易支票、活期储蓄与严格的定期存款(带利息)。全程遵循 **Folia 多核服务端**的线程模型,使用 `AsyncScheduler` / `GlobalRegionScheduler` / `RegionScheduler` 调度,绝不使用已弃用的 `Bukkit.getScheduler()`。 + +--- + +## ✨ 核心功能 + +| 模块 | 说明 | +|------|------| +| 💵 **核心经济** | 每位玩家独立现金账户;实现 Vault `Economy` 接口并以 `Highest` 优先级注册,接管全服扣款 | +| 💳 **银行卡** | 实体物品,PDC 绑定 UUID + 序列号;右键指定方块开柜;丢失可挂失补办(旧卡作废)| +| 📜 **支票** | `/cheque <金额>` 具现化为可流通实体物品;右键兑现入钱包;PDC 防伪 + 防刷 | +| 💰 **活期储蓄** | 存取自由,按真实时间戳逐日复利结算(含离线补偿,带封顶)| +| ⏳ **定期存款** | 7/15/30 天可选,到期前**任何情况不可提前支取**;到期本息自动入活期 | + +--- + +## 🛠️ 依赖 + +- **必须**:[Vault](https://www.spigotmc.org/resources/vault.34315/)(CraftBank 作为其 Service Provider 注入) +- **可选**:[PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) + +> MySQL 驱动默认未内置;若使用 MySQL,请将驱动放入服务端 `libraries`,或在 `build.gradle.kts` 中取消相应依赖注释后自行打包。 + +--- + +## 💻 指令与权限 + +### 基础经济(所有人) +| 指令 | 说明 | +|------|------| +| `/money [玩家]` | 查看现金余额(查他人需 `craftbank.money.others`)| +| `/pay <玩家> <金额>` | 现金转账 | +| `/baltop [页码]` | 现金排行榜 | + +### 银行玩家(`craftbank.use`) +| 指令 | 说明 | +|------|------| +| `/bank open` | 打开银行 GUI(可配置是否允许)| +| `/bank card apply` | 申请新银行卡 | +| `/bank card revoke` | 挂失并作废当前银行卡 | +| `/bank deposit <金额>` | 现金 → 活期 | +| `/bank withdraw <金额>` | 活期 → 现金 | +| `/cheque <金额>` | 开具支票 | + +### 管理员(`craftbank.admin`) +| 指令 | 说明 | +|------|------| +| `/bankadmin reload` | 重载配置 | +| `/bankadmin give/take/set <玩家> <金额>` | 增加 / 扣除 / 设置现金 | +| `/bankadmin setinterest <利率>` | 动态修改利率 | + +--- + +## 🔌 PlaceholderAPI 变量 + +| 变量 | 含义 | +|------|------| +| `%craftbank_cash%` | 现金(纯数字)| +| `%craftbank_cash_formatted%` | 现金(含货币符号)| +| `%craftbank_bank_saving%` | 活期(纯数字)| +| `%craftbank_bank_saving_formatted%` | 活期(含货币符号)| + +--- + +## ⚙️ 配置文件 + +详见生成的 `config.yml`,涵盖:数据库(SQLite/MySQL)、货币设置、利率(活期 / 各定期)、银行交互方块、银行卡与支票的自定义材质 / CustomModelData / 名称 / Lore、以及全部消息文案。 + +--- + +## 🔨 从源码构建 + +```bash +# 需要 JDK 21+(已用 JDK 25 验证通过) +./gradlew shadowJar +``` + +产物位于 `build/libs/CraftBank-.jar`,已内置 SQLite 与 HikariCP 驱动,可直接丢进 `plugins/` 目录。 + +--- + +## 🤖 Folia 线程安全设计 + +- **后台任务**(数据库、`/baltop` 排序、利息扫描)走 `AsyncScheduler`。 +- **玩家相关操作**(扣物品、开 GUI、发消息)从异步切回时调度到 `player.getScheduler()`(实体区域线程)。 +- 经济数据缓存使用 `ConcurrentHashMap`,单账户余额变更用 `synchronized` 保证原子,抵御来自其它插件异步线程(经 Vault)的并发刷钱。 +- 支票兑现在玩家区域线程独占操作物品栏;定期领取用数据库 `UPDATE ... WHERE claimed = 0` 原子置位防重复发放。 + +--- + +## 📄 许可证 + +见 [LICENSE](LICENSE)。 diff --git a/Readme.txt b/Readme.txt new file mode 100644 index 0000000..ab7e365 --- /dev/null +++ b/Readme.txt @@ -0,0 +1,94 @@ +# 🏦 CraftBank (工艺银行) - 适用于 Folia 1.21.8 的全能经济与银行核心插件 + +## 📖 简介 +CraftBank 是一款专为 **Folia 1.21.8 (多核服务端)** 设计的全能经济核心插件,**旨在完全替代 XConomy 等传统经济插件**。它不仅内置了完善的“个人钱包/现金”系统,还引入了深度模拟的银行系统:包含实体银行卡、可交易支票、活期储蓄以及严格的定期存款(带利息)。 + +**⚠️ 核心目标开发者注意 (Folia 兼容性)**: +本插件运行在 **Folia** 服务端上!这意味着传统的 Bukkit Scheduler 已被弃用。你必须使用 Folia 的 `AsyncScheduler`, `GlobalRegionScheduler` 和 `RegionScheduler` 来处理所有任务。要求使用 Java 21 编译。本插件必须自己实现 Vault 的 Economy 接口。所有自定义物品必须使用 `PersistentDataContainer (PDC)` 存储数据。 + +--- + +## ✨ 核心功能 (Core Features) + +### 1. 💵 核心经济与钱包 (Core Economy - 替代 XConomy) +* **独立经济系统**:每个玩家拥有独立的“现金(Cash)”账户。 +* **Vault 注册**:插件必须实现 Vault API,成为服务器的默认经济提供者(Economy Provider),接管所有商店、权限插件的扣款请求。 +* **基础指令**:内置原生的 `/money`, `/pay`, `/baltop` 等基础经济指令。 + +### 2. 💳 银行卡系统 (Bank Cards) +* **实体物品**:玩家可以通过指令或银行 GUI 办理一张实体银行卡。 +* **数据绑定**:银行卡通过 PDC 绑定玩家的 UUID。 +* **交互操作**:玩家拿着银行卡右键点击特定的方块(例如:铁块,可在 Config 自定义),即可打开个人银行 GUI。 +* **防丢机制**:如果银行卡丢失,玩家可以去银行挂失并补办(旧卡将自动作废)。 + +### 3. 📜 支票系统 (Cheques) +* **开具支票**:玩家通过指令 `/cheque <金额>` 将钱包或银行里的钱具现化为一张实体支票物品。 +* **流通与兑换**:支票可以扔给其他玩家。任何人手持支票右键点击,即可将金额存入自己的钱包。 +* **防伪验证**:支票包含开票人 UUID、生成时间戳和金额,数据均存储在 PDC 中。 + +### 4. 💰 活期储蓄 (Savings Account) +* **存取自由**:玩家可以将钱包里的“现金”存入银行活期账户,随时通过 GUI 或指令取回。 +* **基础利息**:活期账户可享受较低的日利率(利息结算在每日午夜或玩家首次登录时离线结算)。 + +### 5. ⏳ 定期存款 (Term Deposits) +* **强制锁定**:玩家可以选择将资金存入“定期账户”,可选期限(如:7天、15天、30天)。**一旦存入,在到期之前,任何情况都不允许提前取出**。 +* **高额利息**:定期存款享受比活期高得多的利率。 +* **到期结算**:时间到期后,本金加利息将自动转入玩家的活期账户,或允许玩家手动在 GUI 中领取。 +* **存储方式**:定期存款记录应使用真实时间戳(Timestamp)计算,以防服务器重启导致时间丢失。 + +--- + +## 🛠️ 依赖项 (Dependencies) +* **必须**: [Vault](https://www.spigotmc.org/resources/vault.34315/) (本插件将作为其 Service Provider 注入) +* **可选/推荐**: [PlaceholderAPI](https://www.spigotmc.org/resources/placeholderapi.6245/) (用于提供 `%craftbank_cash%`, `%craftbank_bank_saving%` 等变量) + +--- + +## 💻 指令与权限 (Commands & Permissions) + +### 基础经济指令 (所有人可用) +* `/money [玩家]` - 查看自己或他人的现金余额。 +* `/pay <玩家> <金额>` - 给其他玩家转账(现金)。 +* `/baltop` - 查看现金排行榜。 + +### 银行玩家指令 (基础权限: `craftbank.use`) +* `/bank open` - 打开银行 GUI(可配置为需要银行卡或身处特定区域)。 +* `/bank card apply` - 申请一张新的银行卡。 +* `/bank card revoke` - 挂失并作废当前的银行卡。 +* `/cheque <金额>` - 开具一张指定金额的支票。 +* `/bank deposit <金额>` - 存入现金到活期账户。 +* `/bank withdraw <金额>` - 从活期账户提取现金。 + +### 管理员指令 (管理员权限: `craftbank.admin`) +* `/bankadmin reload` - 重载配置文件。 +* `/bankadmin give <玩家> <金额>` - 给予玩家现金。 +* `/bankadmin take <玩家> <金额>` - 扣除玩家现金。 +* `/bankadmin set <玩家> <金额>` - 设置玩家现金。 +* `/bankadmin setinterest <利率>` - 动态修改银行利率。 + +--- + +## ⚙️ 配置文件 (config.yml 需求) +开发者请确保生成包含以下配置项的 `config.yml`: +* **Database**: 支持 SQLite (默认) 和 MySQL。玩家数据表应包含:`uuid`, `player_name`, `cash`, `bank_saving`,以及独立的定期存款表。 +* **Economy_Settings**: + * `currency_symbol`: "$" + * `starting_balance`: 1000.0 (新玩家初始现金) +* **Interest_Rates**: + * `savings`: 0.001 (活期日利率 0.1%) + * `term_7d`: 0.005 (7天定期日利率 0.5%) + * `term_30d`: 0.01 (30天定期日利率 1%) +* **Items**: 自定义银行卡和支票的 Material (材质)、CustomModelData、Name 和 Lore。 + +--- + +## 🤖 给 AI 开发者的代码要求 (AI Developer Notes - Folia 严格规范) +请严格遵循以下要求编写代码,**任何违反 Folia 规范的代码都将被拒绝**: +1. **Folia 任务调度 (核心)**: + * 严禁使用 `Bukkit.getScheduler()`。 + * 纯后台任务(如数据库查询、`/baltop` 排序、定期存款的定时扫描)必须使用 `Bukkit.getAsyncScheduler().runNow()` 或 `runAtFixedRate()`。 + * 涉及玩家操作(如扣除支票物品、打开 GUI、发送消息),如果从异步线程切回主逻辑,必须调度到该玩家所在的实体区域线程:`player.getScheduler().run(...)`。 +2. **Vault 注册必须正确**:在 `onEnable()` 中,必须实例化一个实现 `net.milkbowl.vault.economy.Economy` 接口的类,并通过 ServiceManager 注册,优先度设为 `Highest`。 +3. **PDC 防伪**:支票的生成和兑现,必须在 `NamespacedKey` 中读写特定的标识符,并在 `PlayerInteractEvent` 中处理防伪与兑换逻辑。 +4. **并发与线程安全**:Vault 接口的调用(如 `withdrawPlayer`)可能会在其他插件的异步线程中被触发。所有的经济数据操作必须是线程安全的(建议对缓存数据使用 `ConcurrentHashMap`,并确保数据库入库操作不会出现脏读)。 +5. **GUI 系统与防刷**:请使用 1.21.8 标准的 Inventory API 构建 GUI。处理支票兑现时,一定要注意 `ItemStack#setAmount()` 的安全逻辑,防止多线程并发导致的刷钱问题。 \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..341569c --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + java + id("com.gradleup.shadow") version "8.3.5" +} + +group = "com.craftbank" +version = "1.0.0" + +// 用当前 JDK(25)编译,通过 --release 21 产出兼容 Java 21 的字节码, +// 避免 toolchain 触发额外的 JDK 下载。Folia 要求运行时为 Java 21+。 + +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") // Paper / Folia API + maven("https://jitpack.io") // Vault API + maven("https://repo.extendedclip.com/content/repositories/placeholderapi/") // PlaceholderAPI +} + +dependencies { + // Folia API(含 Paper + Bukkit + Folia 调度器)。运行时由服务端提供。 + compileOnly("dev.folia:folia-api:1.21.8-R0.1-SNAPSHOT") + + // Vault 经济接口。运行时由 Vault 插件提供。 + compileOnly("com.github.MilkBowl:VaultAPI:1.7.1") + + // PlaceholderAPI(可选软依赖)。运行时由其插件提供。 + compileOnly("me.clip:placeholderapi:2.11.6") + + // 需要打进最终 jar 的运行时库。 + implementation("org.xerial:sqlite-jdbc:3.46.1.3") + implementation("com.zaxxer:HikariCP:5.1.0") + // MySQL 驱动由用户按需放入服务端 lib(避免 jar 膨胀)。如需内置取消下行注释: + // implementation("com.mysql:mysql-connector-j:9.0.0") +} + +tasks { + compileJava { + options.encoding = "UTF-8" + options.release.set(21) + } + + processResources { + // 把 ${version} 占位符替换为项目版本号。 + val props = mapOf("version" to project.version.toString()) + inputs.properties(props) + filesMatching("plugin.yml") { + expand(props) + } + } + + shadowJar { + archiveClassifier.set("") + // 不做包重定位:当前 shadow 版本的字节码重映射器在新 JDK 产出的 + // invokedynamic class 上会失败;HikariCP/SQLite 直接原样打包即可。 + } + + build { + dependsOn(shadowJar) + } + + // 仅产出 shadowJar,禁用空的默认 jar。 + jar { + enabled = false + } +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..a4b76b9530d66f5e68d973ea569d8e19de379189 GIT binary patch literal 43583 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 yu?e%!_ptzGB|8GrCfu%p?(_ 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 GZ~q4|mN%#X literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..2e11132 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..d95bf61 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..640d686 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..7fd7832 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "CraftBank" diff --git a/src/main/java/com/craftbank/CraftBank.java b/src/main/java/com/craftbank/CraftBank.java new file mode 100644 index 0000000..465d50d --- /dev/null +++ b/src/main/java/com/craftbank/CraftBank.java @@ -0,0 +1,233 @@ +package com.craftbank; + +import com.craftbank.bank.BankManager; +import com.craftbank.commands.BankAdminCommand; +import com.craftbank.commands.BankCommand; +import com.craftbank.commands.BaltopCommand; +import com.craftbank.commands.ChequeCommand; +import com.craftbank.commands.MoneyCommand; +import com.craftbank.commands.PayCommand; +import com.craftbank.database.DatabaseManager; +import com.craftbank.economy.EconomyManager; +import com.craftbank.economy.VaultEconomyProvider; +import com.craftbank.gui.BankGUI; +import com.craftbank.hooks.CraftBankExpansion; +import com.craftbank.items.BankCardManager; +import com.craftbank.items.ChequeManager; +import com.craftbank.items.Keys; +import com.craftbank.listeners.InteractListener; +import com.craftbank.listeners.InventoryListener; +import com.craftbank.listeners.PlayerConnectionListener; +import com.craftbank.model.PlayerAccount; +import com.craftbank.util.Msg; +import net.milkbowl.vault.economy.Economy; +import org.bukkit.Bukkit; +import org.bukkit.command.PluginCommand; +import org.bukkit.command.TabExecutor; +import org.bukkit.plugin.ServicePriority; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; + +/** + * CraftBank 主类。负责在 Folia 环境下初始化所有子系统、注册 Vault 经济提供者, + * 并通过 Folia 的调度器运行后台任务。 + */ +public final class CraftBank extends JavaPlugin { + + private Keys keys; + private DatabaseManager databaseManager; + private EconomyManager economyManager; + private BankManager bankManager; + private BankCardManager bankCardManager; + private ChequeManager chequeManager; + private BankGUI bankGUI; + private VaultEconomyProvider vaultProvider; + private Msg msg; + + @Override + public void onEnable() { + saveDefaultConfig(); + this.msg = new Msg(this); + + if (getServer().getPluginManager().getPlugin("Vault") == null) { + getLogger().severe("未找到 Vault! CraftBank 需要 Vault 才能作为经济核心运行, 插件将禁用。"); + getServer().getPluginManager().disablePlugin(this); + return; + } + + this.keys = new Keys(this); + this.databaseManager = new DatabaseManager(this); + try { + // 启动期建表为一次性阻塞操作, 可接受。 + databaseManager.init(); + } catch (Exception ex) { + getLogger().log(Level.SEVERE, "数据库初始化失败, 插件将禁用!", ex); + getServer().getPluginManager().disablePlugin(this); + return; + } + + this.economyManager = new EconomyManager(this); + this.bankManager = new BankManager(this); + this.bankCardManager = new BankCardManager(this); + this.chequeManager = new ChequeManager(this); + this.bankGUI = new BankGUI(this); + + registerVault(); + registerCommands(); + registerListeners(); + registerPlaceholders(); + startBackgroundTasks(); + + // 处理热重载: 为已在线玩家补加载账户。 + for (var player : getServer().getOnlinePlayers()) { + final var p = player; + runAsync(() -> economyManager.loadBlocking(p.getUniqueId(), p.getName(), true)); + } + + getLogger().info("CraftBank 已启用 (Folia 模式)。"); + } + + @Override + public void onDisable() { + if (databaseManager != null) { + if (economyManager != null) { + // 落库所有脏账户。 + for (PlayerAccount account : economyManager.getAllCached()) { + if (account.isDirty()) { + databaseManager.saveAccount(account); + } + } + } + databaseManager.close(); + } + getLogger().info("CraftBank 已禁用。"); + } + + // --------------------------------------------------------------------- + // 注册 + // --------------------------------------------------------------------- + + private void registerVault() { + this.vaultProvider = new VaultEconomyProvider(this); + getServer().getServicesManager().register(Economy.class, vaultProvider, this, ServicePriority.Highest); + getLogger().info("已以 Highest 优先级注册 Vault 经济提供者。"); + } + + private void registerCommands() { + bind("money", new MoneyCommand(this)); + bind("pay", new PayCommand(this)); + bind("baltop", new BaltopCommand(this)); + bind("cheque", new ChequeCommand(this)); + bind("bank", new BankCommand(this)); + bind("bankadmin", new BankAdminCommand(this)); + } + + private void bind(String name, TabExecutor executor) { + PluginCommand command = getCommand(name); + if (command == null) { + getLogger().warning("plugin.yml 中缺少指令: " + name); + return; + } + command.setExecutor(executor); + command.setTabCompleter(executor); + } + + private void registerListeners() { + getServer().getPluginManager().registerEvents(new PlayerConnectionListener(this), this); + getServer().getPluginManager().registerEvents(new InteractListener(this), this); + getServer().getPluginManager().registerEvents(new InventoryListener(this), this); + } + + private void registerPlaceholders() { + if (getServer().getPluginManager().getPlugin("PlaceholderAPI") != null) { + try { + new CraftBankExpansion(this).register(); + getLogger().info("已挂接 PlaceholderAPI。"); + } catch (Throwable t) { + getLogger().warning("PlaceholderAPI 挂接失败: " + t.getMessage()); + } + } + } + + private void startBackgroundTasks() { + long periodMinutes = 60L; + // 后台周期任务: 结算在线玩家活期利息 + 自动结算到期定期存款。 + getServer().getAsyncScheduler().runAtFixedRate(this, task -> { + for (PlayerAccount account : economyManager.getAllCached()) { + bankManager.settleSavingsInterest(account); + } + bankManager.settleAllMatured(); + }, periodMinutes, periodMinutes, TimeUnit.MINUTES); + + // 定期落库脏账户, 降低崩溃丢数据风险。 + getServer().getAsyncScheduler().runAtFixedRate(this, task -> { + for (PlayerAccount account : economyManager.getAllCached()) { + if (account.isDirty()) { + databaseManager.saveAccount(account); + } + } + }, 5L, 5L, TimeUnit.MINUTES); + } + + // --------------------------------------------------------------------- + // Folia 调度辅助 + // --------------------------------------------------------------------- + + /** 在异步线程立即执行 (用于数据库读写等纯后台任务)。 */ + public void runAsync(Runnable runnable) { + getServer().getAsyncScheduler().runNow(this, task -> runnable.run()); + } + + /** 在指定玩家所在的实体区域线程执行 (用于操作物品、打开 GUI、发消息)。 */ + public void runForPlayer(org.bukkit.entity.Player player, Runnable runnable) { + player.getScheduler().run(this, task -> runnable.run(), null); + } + + /** 在全局区域线程执行。 */ + public void runGlobal(Runnable runnable) { + getServer().getGlobalRegionScheduler().run(this, task -> runnable.run()); + } + + /** 异步持久化某账户 (脏标记已由数据变更方法设置)。 */ + public void persistAsync(PlayerAccount account) { + runAsync(() -> databaseManager.saveAccount(account)); + } + + // --------------------------------------------------------------------- + // 访问器 + // --------------------------------------------------------------------- + + public Msg msg() { + return msg; + } + + public Keys getKeys() { + return keys; + } + + public DatabaseManager getDatabaseManager() { + return databaseManager; + } + + public EconomyManager getEconomyManager() { + return economyManager; + } + + public BankManager getBankManager() { + return bankManager; + } + + public BankCardManager getBankCardManager() { + return bankCardManager; + } + + public ChequeManager getChequeManager() { + return chequeManager; + } + + public BankGUI getBankGUI() { + return bankGUI; + } +} diff --git a/src/main/java/com/craftbank/bank/BankManager.java b/src/main/java/com/craftbank/bank/BankManager.java new file mode 100644 index 0000000..d3cefb8 --- /dev/null +++ b/src/main/java/com/craftbank/bank/BankManager.java @@ -0,0 +1,161 @@ +package com.craftbank.bank; + +import com.craftbank.CraftBank; +import com.craftbank.model.PlayerAccount; +import com.craftbank.model.TermDeposit; +import com.craftbank.util.Amounts; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * 银行业务: 活期利息结算、定期存款的创建/锁定/到期结算。 + * + *

所有涉及数据库的方法都设计为在 AsyncScheduler 线程上执行。

+ */ +public class BankManager { + + private static final long DAY_MILLIS = TimeUnit.DAYS.toMillis(1); + + private final CraftBank plugin; + + public BankManager(CraftBank plugin) { + this.plugin = plugin; + } + + // --------------------------------------------------------------------- + // 活期利息 + // --------------------------------------------------------------------- + + /** 配置中可用的定期期限 (天) 与其日利率, 保持插入顺序供 GUI 展示。 */ + public Map getTermOptions() { + Map options = new LinkedHashMap<>(); + options.put(7, plugin.getConfig().getDouble("Interest_Rates.term_7d", 0.005)); + options.put(15, plugin.getConfig().getDouble("Interest_Rates.term_15d", 0.0075)); + options.put(30, plugin.getConfig().getDouble("Interest_Rates.term_30d", 0.01)); + return options; + } + + public double getSavingsRate() { + return plugin.getConfig().getDouble("Interest_Rates.savings", 0.001); + } + + /** + * 结算一个账户的活期利息。基于真实时间戳计算经过的整数天数, + * 对超长离线时间做封顶, 防止天文数字利息。 + * + * @return 本次结算产生的利息金额 (可能为 0)。 + */ + public double settleSavingsInterest(PlayerAccount account) { + long now = System.currentTimeMillis(); + long last = account.getLastInterestSettle(); + if (last <= 0) { + account.setLastInterestSettle(now); + return 0; + } + long elapsed = now - last; + long days = elapsed / DAY_MILLIS; + if (days <= 0) { + return 0; + } + int maxDays = plugin.getConfig().getInt("Bank_Settings.max_offline_interest_days", 30); + long settledDays = Math.min(days, maxDays); + + double rate = getSavingsRate(); + double saving = account.getBankSaving(); + double interest = 0; + // 复利逐日计息。 + for (int i = 0; i < settledDays; i++) { + interest += (saving + interest) * rate; + } + interest = Amounts.normalize(interest); + if (interest > 0) { + account.depositSaving(interest); + } + // 推进结算时间戳: 仅推进已结算的整数天, 保留不足一天的余量。 + account.setLastInterestSettle(last + days * DAY_MILLIS); + plugin.persistAsync(account); + return interest; + } + + // --------------------------------------------------------------------- + // 定期存款 + // --------------------------------------------------------------------- + + public boolean isValidTerm(int durationDays) { + return getTermOptions().containsKey(durationDays); + } + + /** + * 创建一笔定期存款, 资金从玩家的活期账户扣除。 + * + * @return 创建成功的记录, 失败 (期限非法/活期余额不足) 返回 null。 + */ + public TermDeposit createTermDeposit(PlayerAccount account, double amount, int durationDays) { + amount = Amounts.normalize(amount); + Double rate = getTermOptions().get(durationDays); + if (rate == null || amount <= 0) { + return null; + } + // 原子地从活期扣款, 再写入定期记录。 + synchronized (account) { + if (!account.withdrawSaving(amount)) { + return null; + } + } + plugin.persistAsync(account); + + long start = System.currentTimeMillis(); + long end = start + (long) durationDays * DAY_MILLIS; + TermDeposit deposit = plugin.getDatabaseManager() + .insertTermDeposit(account.getUuid(), amount, rate, durationDays, start, end); + if (deposit == null) { + // 写库失败, 退款回活期, 避免吞钱。 + account.depositSaving(amount); + plugin.persistAsync(account); + } + return deposit; + } + + /** + * 领取一笔已到期的定期存款, 本息转入活期账户。 + * + * @return 实际入账的本息总额; 若未到期、已被领取或失败则返回 -1。 + */ + public double claimTermDeposit(TermDeposit deposit) { + if (!deposit.isMatured()) { + return -1; + } + // 原子置位, 防止定时任务与玩家手动领取并发导致双重发放。 + if (!plugin.getDatabaseManager().markClaimed(deposit.getId())) { + return -1; + } + deposit.setClaimed(true); + double payout = Amounts.normalize(deposit.calculateTotalPayout()); + + UUID owner = deposit.getOwner(); + PlayerAccount account = plugin.getEconomyManager().loadBlocking(owner, null, true); + account.depositSaving(payout); + plugin.persistAsync(account); + return payout; + } + + public List getDeposits(UUID owner) { + return plugin.getDatabaseManager().getTermDeposits(owner, false); + } + + /** 扫描并自动结算所有到期未领取的定期存款 (供定时任务调用)。 */ + public int settleAllMatured() { + List matured = plugin.getDatabaseManager().getMaturedUnclaimed(); + int count = 0; + for (TermDeposit deposit : matured) { + if (claimTermDeposit(deposit) >= 0) { + count++; + } + } + return count; + } +} diff --git a/src/main/java/com/craftbank/commands/BaltopCommand.java b/src/main/java/com/craftbank/commands/BaltopCommand.java new file mode 100644 index 0000000..11c0436 --- /dev/null +++ b/src/main/java/com/craftbank/commands/BaltopCommand.java @@ -0,0 +1,66 @@ +package com.craftbank.commands; + +import com.craftbank.CraftBank; +import com.craftbank.economy.EconomyManager; +import com.craftbank.util.Msg; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * /baltop [页码] —— 现金排行榜。数据库排序在异步线程执行。 + */ +public final class BaltopCommand implements TabExecutor { + + private static final int PAGE_SIZE = 10; + + private final CraftBank plugin; + private final Msg msg; + + public BaltopCommand(CraftBank plugin) { + this.plugin = plugin; + this.msg = new Msg(plugin); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + int page = 1; + if (args.length >= 1) { + try { + page = Math.max(1, Integer.parseInt(args[0])); + } catch (NumberFormatException ignored) { + page = 1; + } + } + final int finalPage = page; + EconomyManager econ = plugin.getEconomyManager(); + + plugin.runAsync(() -> { + // 取足够多再分页, 数量级在排行榜场景可接受。 + List> top = plugin.getDatabaseManager().getTopCash(finalPage * PAGE_SIZE); + int totalShown = top.size(); + int start = (finalPage - 1) * PAGE_SIZE; + if (start >= totalShown && finalPage > 1) { + msg.raw(sender, "&c没有更多内容了。"); + return; + } + msg.raw(sender, "&b&l现金排行榜 &7- 第 " + finalPage + " 页"); + int end = Math.min(start + PAGE_SIZE, totalShown); + for (int i = start; i < end; i++) { + Map.Entry entry = top.get(i); + sender.sendMessage(com.craftbank.util.Text.color( + "&7#" + (i + 1) + " &f" + entry.getKey() + " &7- &a" + econ.format(entry.getValue()))); + } + }); + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + return Collections.emptyList(); + } +} diff --git a/src/main/java/com/craftbank/commands/BankAdminCommand.java b/src/main/java/com/craftbank/commands/BankAdminCommand.java new file mode 100644 index 0000000..d115690 --- /dev/null +++ b/src/main/java/com/craftbank/commands/BankAdminCommand.java @@ -0,0 +1,160 @@ +package com.craftbank.commands; + +import com.craftbank.CraftBank; +import com.craftbank.economy.EconomyManager; +import com.craftbank.util.Amounts; +import com.craftbank.util.Msg; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * /bankadmin <reload|give|take|set|setinterest> —— 管理员指令。 + */ +public final class BankAdminCommand implements TabExecutor { + + private final CraftBank plugin; + private final Msg msg; + + public BankAdminCommand(CraftBank plugin) { + this.plugin = plugin; + this.msg = new Msg(plugin); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!sender.hasPermission("craftbank.admin")) { + msg.key(sender, "no-permission", "&c你没有权限执行该操作。"); + return true; + } + if (args.length == 0) { + sendHelp(sender); + return true; + } + + switch (args[0].toLowerCase()) { + case "reload" -> { + plugin.reloadConfig(); + plugin.getEconomyManager().reload(); + msg.key(sender, "reload-success", "&a配置文件已重载。"); + } + case "give" -> modify(sender, args, ModifyType.GIVE); + case "take" -> modify(sender, args, ModifyType.TAKE); + case "set" -> modify(sender, args, ModifyType.SET); + case "setinterest" -> setInterest(sender, args); + default -> sendHelp(sender); + } + return true; + } + + private enum ModifyType { + GIVE, TAKE, SET + } + + private void modify(CommandSender sender, String[] args, ModifyType type) { + if (args.length < 3) { + msg.raw(sender, "&c用法: /bankadmin " + type.name().toLowerCase() + " <玩家> <金额>"); + return; + } + double amount = Amounts.parse(args[2]); + if (amount < 0 || (type != ModifyType.SET && amount <= 0)) { + msg.key(sender, "invalid-amount", "&c请输入一个有效的正数金额。"); + return; + } + String targetName = args[1]; + EconomyManager econ = plugin.getEconomyManager(); + + 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; + } + econ.loadBlocking(uuid, target.getName(), true); + boolean ok; + switch (type) { + case GIVE -> ok = econ.deposit(uuid, amount); + case TAKE -> ok = econ.withdraw(uuid, amount); + case SET -> ok = econ.set(uuid, amount); + default -> ok = false; + } + if (!ok) { + msg.raw(sender, "&c操作失败 (可能是余额不足)。"); + return; + } + msg.raw(sender, "&a已" + label(type) + " &f" + targetName + " &a现金 " + econ.format(amount) + + " &7(当前: " + econ.format(econ.getCash(uuid)) + ")"); + }); + } + + private String label(ModifyType type) { + return switch (type) { + case GIVE -> "给予"; + case TAKE -> "扣除"; + case SET -> "设置"; + }; + } + + private void setInterest(CommandSender sender, String[] args) { + if (args.length < 3) { + msg.raw(sender, "&c用法: /bankadmin setinterest <利率>"); + return; + } + String key = args[1].toLowerCase(); + List valid = List.of("savings", "term_7d", "term_15d", "term_30d"); + if (!valid.contains(key)) { + msg.raw(sender, "&c利率类型无效, 可选: " + String.join(", ", valid)); + return; + } + double rate; + try { + rate = Double.parseDouble(args[2]); + } catch (NumberFormatException ex) { + msg.raw(sender, "&c请输入一个有效的利率 (例如 0.005)。"); + return; + } + if (!Double.isFinite(rate) || rate < 0) { + msg.raw(sender, "&c利率必须为非负数。"); + return; + } + plugin.getConfig().set("Interest_Rates." + key, rate); + plugin.saveConfig(); + msg.raw(sender, "&a已将 &f" + key + " &a的日利率设置为 &e" + rate + " &7(" + (rate * 100) + "%)"); + } + + private void sendHelp(CommandSender sender) { + msg.raw(sender, "&b&l工艺银行管理 &7指令帮助:"); + sender.sendMessage(com.craftbank.util.Text.color("&7/bankadmin reload")); + sender.sendMessage(com.craftbank.util.Text.color("&7/bankadmin give <玩家> <金额>")); + 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 <类型> <利率>")); + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (!sender.hasPermission("craftbank.admin")) { + return List.of(); + } + if (args.length == 1) { + List base = new ArrayList<>(List.of("reload", "give", "take", "set", "setinterest")); + 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())) { + return null; // 在线玩家名 + } + return List.of(); + } +} diff --git a/src/main/java/com/craftbank/commands/BankCommand.java b/src/main/java/com/craftbank/commands/BankCommand.java new file mode 100644 index 0000000..3aaeca8 --- /dev/null +++ b/src/main/java/com/craftbank/commands/BankCommand.java @@ -0,0 +1,179 @@ +package com.craftbank.commands; + +import com.craftbank.CraftBank; +import com.craftbank.economy.EconomyManager; +import com.craftbank.model.PlayerAccount; +import com.craftbank.util.Amounts; +import com.craftbank.util.Msg; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * /bank <open|card|deposit|withdraw> —— 玩家银行业务入口。 + */ +public final class BankCommand implements TabExecutor { + + private final CraftBank plugin; + private final Msg msg; + + public BankCommand(CraftBank plugin) { + this.plugin = plugin; + this.msg = new Msg(plugin); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + msg.key(sender, "player-only", "&c该指令只能由玩家执行。"); + return true; + } + if (!player.hasPermission("craftbank.use")) { + msg.key(sender, "no-permission", "&c你没有权限执行该操作。"); + return true; + } + if (args.length == 0) { + sendHelp(player); + return true; + } + + switch (args[0].toLowerCase()) { + case "open" -> open(player); + case "card" -> card(player, args); + case "deposit", "存款" -> deposit(player, args); + case "withdraw", "取款" -> withdraw(player, args); + default -> sendHelp(player); + } + return true; + } + + private void open(Player player) { + if (!plugin.getConfig().getBoolean("Bank_Settings.allow_command_open", false)) { + msg.raw(player, "&c请手持银行卡右键银行方块来打开银行。"); + return; + } + PlayerAccount account = plugin.getEconomyManager().getCached(player.getUniqueId()); + if (account == null) { + msg.raw(player, "&c账户尚未加载完成, 请稍后再试。"); + return; + } + plugin.getBankGUI().openMain(player, account); + } + + private void card(Player player, String[] args) { + if (args.length < 2) { + msg.raw(player, "&c用法: /bank card "); + return; + } + PlayerAccount account = plugin.getEconomyManager().getCached(player.getUniqueId()); + if (account == null) { + msg.raw(player, "&c账户尚未加载完成, 请稍后再试。"); + return; + } + switch (args[1].toLowerCase()) { + case "apply" -> { + if (account.hasValidCard()) { + msg.raw(player, "&e你已经持有一张有效的银行卡。如已遗失请使用 &f/bank card revoke &e后重新申请。"); + return; + } + ItemStack card = plugin.getBankCardManager().issueCard(player, account); + Map leftover = player.getInventory().addItem(card); + if (!leftover.isEmpty()) { + player.getWorld().dropItem(player.getLocation(), card); + } + msg.raw(player, "&a已为你办理一张全新的银行卡, 请妥善保管。"); + } + case "revoke" -> { + account.setCardSerial(0); + plugin.persistAsync(account); + msg.raw(player, "&a已挂失当前银行卡, 旧卡立即作废。可使用 &f/bank card apply &a补办。"); + } + default -> msg.raw(player, "&c用法: /bank card "); + } + } + + private void deposit(Player player, String[] args) { + if (args.length < 2) { + msg.raw(player, "&c用法: /bank deposit <金额>"); + return; + } + double amount = Amounts.parse(args[1]); + if (amount <= 0) { + msg.key(player, "invalid-amount", "&c请输入一个有效的正数金额。"); + return; + } + PlayerAccount account = plugin.getEconomyManager().getCached(player.getUniqueId()); + if (account == null) { + msg.raw(player, "&c账户尚未加载完成, 请稍后再试。"); + return; + } + EconomyManager econ = plugin.getEconomyManager(); + synchronized (account) { + if (!account.withdrawCash(amount)) { + msg.key(player, "insufficient-funds", "&c钱包现金不足。"); + return; + } + account.depositSaving(amount); + } + plugin.persistAsync(account); + msg.raw(player, "&a已存入活期 &f" + econ.format(amount) + " &a, 当前活期余额: &f" + econ.format(account.getBankSaving())); + } + + private void withdraw(Player player, String[] args) { + if (args.length < 2) { + msg.raw(player, "&c用法: /bank withdraw <金额>"); + return; + } + double amount = Amounts.parse(args[1]); + if (amount <= 0) { + msg.key(player, "invalid-amount", "&c请输入一个有效的正数金额。"); + return; + } + PlayerAccount account = plugin.getEconomyManager().getCached(player.getUniqueId()); + if (account == null) { + msg.raw(player, "&c账户尚未加载完成, 请稍后再试。"); + return; + } + EconomyManager econ = plugin.getEconomyManager(); + synchronized (account) { + if (!account.withdrawSaving(amount)) { + msg.key(player, "insufficient-funds", "&c活期余额不足。"); + return; + } + account.depositCash(amount); + } + plugin.persistAsync(account); + msg.raw(player, "&a已从活期取回 &f" + econ.format(amount) + " &a到钱包, 当前活期余额: &f" + econ.format(account.getBankSaving())); + } + + private void sendHelp(Player player) { + msg.raw(player, "&b&l工艺银行 &7指令帮助:"); + player.sendMessage(com.craftbank.util.Text.color("&7/bank open &8- &f打开银行界面")); + player.sendMessage(com.craftbank.util.Text.color("&7/bank card apply &8- &f申请一张银行卡")); + player.sendMessage(com.craftbank.util.Text.color("&7/bank card revoke &8- &f挂失并作废银行卡")); + player.sendMessage(com.craftbank.util.Text.color("&7/bank deposit <金额> &8- &f存入活期")); + player.sendMessage(com.craftbank.util.Text.color("&7/bank withdraw <金额> &8- &f从活期取出")); + player.sendMessage(com.craftbank.util.Text.color("&7/cheque <金额> &8- &f开具支票")); + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (args.length == 1) { + List base = new ArrayList<>(List.of("open", "card", "deposit", "withdraw")); + base.removeIf(s -> !s.startsWith(args[0].toLowerCase())); + return base; + } + if (args.length == 2 && args[0].equalsIgnoreCase("card")) { + List sub = new ArrayList<>(List.of("apply", "revoke")); + sub.removeIf(s -> !s.startsWith(args[1].toLowerCase())); + return sub; + } + return List.of(); + } +} diff --git a/src/main/java/com/craftbank/commands/ChequeCommand.java b/src/main/java/com/craftbank/commands/ChequeCommand.java new file mode 100644 index 0000000..2021e08 --- /dev/null +++ b/src/main/java/com/craftbank/commands/ChequeCommand.java @@ -0,0 +1,80 @@ +package com.craftbank.commands; + +import com.craftbank.CraftBank; +import com.craftbank.economy.EconomyManager; +import com.craftbank.util.Amounts; +import com.craftbank.util.Msg; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * /cheque <金额> —— 将现金具现化为一张实体支票。 + * + *

流程:异步扣除现金 → 切回玩家区域线程生成并发放支票物品。背包满时退款, + * 避免吞钱。

+ */ +public final class ChequeCommand implements TabExecutor { + + private final CraftBank plugin; + private final Msg msg; + + public ChequeCommand(CraftBank plugin) { + this.plugin = plugin; + this.msg = new Msg(plugin); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + msg.key(sender, "player-only", "&c该指令只能由玩家执行。"); + return true; + } + if (args.length < 1) { + msg.raw(sender, "&c用法: /cheque <金额>"); + return true; + } + double amount = Amounts.parse(args[0]); + if (amount <= 0) { + msg.key(sender, "invalid-amount", "&c请输入一个有效的正数金额。"); + return true; + } + + EconomyManager econ = plugin.getEconomyManager(); + + plugin.runAsync(() -> { + econ.loadBlocking(player.getUniqueId(), player.getName(), true); + if (!econ.withdraw(player.getUniqueId(), amount)) { + msg.key(sender, "insufficient-funds", "&c余额不足。"); + return; + } + // 物品操作必须回到玩家所在区域线程。 + plugin.runForPlayer(player, () -> { + ItemStack cheque = plugin.getChequeManager() + .createCheque(player.getUniqueId(), player.getName(), amount); + Map leftover = player.getInventory().addItem(cheque); + if (!leftover.isEmpty()) { + // 背包放不下: 异步退款。 + plugin.runAsync(() -> { + econ.deposit(player.getUniqueId(), amount); + msg.raw(player, "&c背包已满, 无法生成支票, 金额已退回。"); + }); + return; + } + msg.raw(player, "&a已开具一张面额 &f" + econ.format(amount) + " &a的支票。"); + }); + }); + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + return Collections.emptyList(); + } +} diff --git a/src/main/java/com/craftbank/commands/MoneyCommand.java b/src/main/java/com/craftbank/commands/MoneyCommand.java new file mode 100644 index 0000000..0119399 --- /dev/null +++ b/src/main/java/com/craftbank/commands/MoneyCommand.java @@ -0,0 +1,71 @@ +package com.craftbank.commands; + +import com.craftbank.CraftBank; +import com.craftbank.economy.EconomyManager; +import com.craftbank.util.Msg; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.entity.Player; + +import java.util.Collections; +import java.util.List; + +/** + * /money [玩家] —— 查看自己或他人的现金余额。 + */ +public final class MoneyCommand implements TabExecutor { + + private final CraftBank plugin; + private final Msg msg; + + public MoneyCommand(CraftBank plugin) { + this.plugin = plugin; + this.msg = new Msg(plugin); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + EconomyManager econ = plugin.getEconomyManager(); + + if (args.length == 0) { + if (!(sender instanceof Player player)) { + msg.key(sender, "player-only", "&c该指令只能由玩家执行。"); + return true; + } + double cash = econ.getCash(player.getUniqueId()); + msg.raw(sender, "&7你的现金余额: &a" + econ.format(cash)); + return true; + } + + // 查看他人余额需要权限。 + if (!sender.hasPermission("craftbank.money.others") && !sender.hasPermission("craftbank.admin")) { + msg.key(sender, "no-permission", "&c你没有权限执行该操作。"); + return true; + } + + String target = args[0]; + // 余额查询涉及数据库读取, 放到异步线程。 + plugin.runAsync(() -> { + @SuppressWarnings("deprecation") + OfflinePlayer op = Bukkit.getOfflinePlayer(target); + if (op.getUniqueId() == null || (!op.hasPlayedBefore() && !op.isOnline())) { + msg.key(sender, "player-not-found", "&c找不到目标玩家。"); + return; + } + double cash = econ.getCash(op.getUniqueId()); + msg.raw(sender, "&7" + target + " 的现金余额: &a" + econ.format(cash)); + }); + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (args.length == 1 && (sender.hasPermission("craftbank.money.others") || sender.hasPermission("craftbank.admin"))) { + return null; // 返回 null 让服务端补全在线玩家名 + } + return Collections.emptyList(); + } +} diff --git a/src/main/java/com/craftbank/commands/PayCommand.java b/src/main/java/com/craftbank/commands/PayCommand.java new file mode 100644 index 0000000..a460fed --- /dev/null +++ b/src/main/java/com/craftbank/commands/PayCommand.java @@ -0,0 +1,98 @@ +package com.craftbank.commands; + +import com.craftbank.CraftBank; +import com.craftbank.economy.EconomyManager; +import com.craftbank.model.PlayerAccount; +import com.craftbank.util.Amounts; +import com.craftbank.util.Msg; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.entity.Player; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +/** + * /pay <玩家> <金额> —— 现金转账。 + * + *

资金操作经 {@link EconomyManager#transfer} 原子完成;目标若离线先异步加载其账户。

+ */ +public final class PayCommand implements TabExecutor { + + private final CraftBank plugin; + private final Msg msg; + + public PayCommand(CraftBank plugin) { + this.plugin = plugin; + this.msg = new Msg(plugin); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + msg.key(sender, "player-only", "&c该指令只能由玩家执行。"); + return true; + } + if (args.length < 2) { + msg.raw(sender, "&c用法: /pay <玩家> <金额>"); + return true; + } + + double amount = Amounts.parse(args[1]); + if (amount <= 0) { + msg.key(sender, "invalid-amount", "&c请输入一个有效的正数金额。"); + return true; + } + + String targetName = args[0]; + EconomyManager econ = plugin.getEconomyManager(); + + plugin.runAsync(() -> { + @SuppressWarnings("deprecation") + OfflinePlayer target = Bukkit.getOfflinePlayer(targetName); + UUID targetUuid = target.getUniqueId(); + if (targetUuid == null || (!target.hasPlayedBefore() && !target.isOnline())) { + msg.key(sender, "player-not-found", "&c找不到目标玩家。"); + return; + } + if (targetUuid.equals(player.getUniqueId())) { + msg.raw(sender, "&c你不能给自己转账。"); + return; + } + // 确保双方账户均已加载入缓存。 + econ.loadBlocking(player.getUniqueId(), player.getName(), true); + PlayerAccount targetAcc = econ.loadBlocking(targetUuid, target.getName(), true); + if (targetAcc == null) { + msg.key(sender, "player-not-found", "&c找不到目标玩家。"); + return; + } + + if (!econ.has(player.getUniqueId(), amount)) { + msg.key(sender, "insufficient-funds", "&c余额不足。"); + return; + } + if (econ.transfer(player.getUniqueId(), targetUuid, amount)) { + msg.raw(sender, "&a成功向 &f" + targetName + " &a转账 " + econ.format(amount)); + Player onlineTarget = Bukkit.getPlayer(targetUuid); + if (onlineTarget != null) { + msg.raw(onlineTarget, "&a你收到来自 &f" + player.getName() + " &a的转账 " + econ.format(amount)); + } + } else { + msg.key(sender, "insufficient-funds", "&c余额不足。"); + } + }); + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (args.length == 1) { + return null; // 在线玩家名 + } + return Collections.emptyList(); + } +} diff --git a/src/main/java/com/craftbank/database/DatabaseManager.java b/src/main/java/com/craftbank/database/DatabaseManager.java new file mode 100644 index 0000000..de1f028 --- /dev/null +++ b/src/main/java/com/craftbank/database/DatabaseManager.java @@ -0,0 +1,272 @@ +package com.craftbank.database; + +import com.craftbank.CraftBank; +import com.craftbank.model.PlayerAccount; +import com.craftbank.model.TermDeposit; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import org.bukkit.configuration.file.FileConfiguration; + +import java.io.File; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Level; + +/** + * 数据库管理器。支持 SQLite (默认) 与 MySQL, 通过 HikariCP 管理连接池。 + * + *

所有方法均为阻塞式 JDBC 调用, 必须 在 Folia 的 AsyncScheduler + * 线程上调用, 严禁在主区域线程同步调用。

+ */ +public class DatabaseManager { + + public enum Type { + SQLITE, MYSQL + } + + private final CraftBank plugin; + private HikariDataSource dataSource; + private Type type; + + public DatabaseManager(CraftBank plugin) { + this.plugin = plugin; + } + + public void init() throws SQLException { + FileConfiguration cfg = plugin.getConfig(); + String typeName = cfg.getString("Database.type", "SQLITE").toUpperCase(); + this.type = "MYSQL".equals(typeName) ? Type.MYSQL : Type.SQLITE; + + HikariConfig hikari = new HikariConfig(); + hikari.setPoolName("CraftBank-Pool"); + + if (type == Type.MYSQL) { + String host = cfg.getString("Database.mysql.host", "localhost"); + int port = cfg.getInt("Database.mysql.port", 3306); + String db = cfg.getString("Database.mysql.database", "craftbank"); + boolean ssl = cfg.getBoolean("Database.mysql.useSSL", false); + hikari.setJdbcUrl("jdbc:mysql://" + host + ":" + port + "/" + db + + "?useSSL=" + ssl + "&useUnicode=true&characterEncoding=utf8&autoReconnect=true"); + hikari.setUsername(cfg.getString("Database.mysql.username", "root")); + hikari.setPassword(cfg.getString("Database.mysql.password", "")); + hikari.setMaximumPoolSize(cfg.getInt("Database.mysql.pool-size", 10)); + } else { + File file = new File(plugin.getDataFolder(), cfg.getString("Database.sqlite-file", "craftbank.db")); + hikari.setDriverClassName("org.sqlite.JDBC"); + hikari.setJdbcUrl("jdbc:sqlite:" + file.getAbsolutePath()); + // SQLite 单写入者, 限制为 1 条连接以避免 "database is locked"。 + hikari.setMaximumPoolSize(1); + } + hikari.setConnectionTimeout(10000); + + this.dataSource = new HikariDataSource(hikari); + createTables(); + } + + private void createTables() throws SQLException { + String autoInc = type == Type.MYSQL ? "INT AUTO_INCREMENT PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT"; + String players = "CREATE TABLE IF NOT EXISTS craftbank_players (" + + "uuid VARCHAR(36) PRIMARY KEY," + + "player_name VARCHAR(32)," + + "cash DOUBLE NOT NULL DEFAULT 0," + + "bank_saving DOUBLE NOT NULL DEFAULT 0," + + "last_interest BIGINT NOT NULL DEFAULT 0," + + "card_serial BIGINT NOT NULL DEFAULT 0" + + ")"; + String deposits = "CREATE TABLE IF NOT EXISTS craftbank_term_deposits (" + + "id " + autoInc + "," + + "owner VARCHAR(36) NOT NULL," + + "principal DOUBLE NOT NULL," + + "daily_rate DOUBLE NOT NULL," + + "duration_days INT NOT NULL," + + "start_time BIGINT NOT NULL," + + "end_time BIGINT NOT NULL," + + "claimed INT NOT NULL DEFAULT 0" + + ")"; + try (Connection conn = dataSource.getConnection(); Statement st = conn.createStatement()) { + st.executeUpdate(players); + st.executeUpdate(deposits); + } + } + + public void close() { + if (dataSource != null && !dataSource.isClosed()) { + dataSource.close(); + } + } + + // --------------------------------------------------------------------- + // 玩家账户 + // --------------------------------------------------------------------- + + /** 读取账户, 不存在返回 null。 */ + public PlayerAccount loadAccount(UUID uuid) { + String sql = "SELECT player_name, cash, bank_saving, last_interest, card_serial FROM craftbank_players WHERE uuid = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, uuid.toString()); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return new PlayerAccount(uuid, + rs.getString("player_name"), + rs.getDouble("cash"), + rs.getDouble("bank_saving"), + rs.getLong("last_interest"), + rs.getLong("card_serial")); + } + } + } catch (SQLException ex) { + plugin.getLogger().log(Level.SEVERE, "读取账户失败: " + uuid, ex); + } + return null; + } + + public void saveAccount(PlayerAccount account) { + String sql = type == Type.MYSQL + ? "INSERT INTO craftbank_players (uuid, player_name, cash, bank_saving, last_interest, card_serial) " + + "VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE player_name=VALUES(player_name)," + + "cash=VALUES(cash), bank_saving=VALUES(bank_saving), last_interest=VALUES(last_interest), card_serial=VALUES(card_serial)" + : "INSERT INTO craftbank_players (uuid, player_name, cash, bank_saving, last_interest, card_serial) " + + "VALUES (?,?,?,?,?,?) ON CONFLICT(uuid) DO UPDATE SET player_name=excluded.player_name," + + "cash=excluded.cash, bank_saving=excluded.bank_saving, last_interest=excluded.last_interest, card_serial=excluded.card_serial"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + // 在持有快照期间数据可能被其他线程修改, 直接读取同步后的字段。 + synchronized (account) { + ps.setString(1, account.getUuid().toString()); + ps.setString(2, account.getName()); + ps.setDouble(3, account.getCash()); + ps.setDouble(4, account.getBankSaving()); + ps.setLong(5, account.getLastInterestSettle()); + ps.setLong(6, account.getCardSerial()); + } + ps.executeUpdate(); + account.setDirty(false); + } catch (SQLException ex) { + plugin.getLogger().log(Level.SEVERE, "保存账户失败: " + account.getUuid(), ex); + } + } + + /** 现金排行榜。 */ + public List> getTopCash(int limit) { + List> result = new ArrayList<>(); + String sql = "SELECT player_name, cash FROM craftbank_players ORDER BY cash DESC LIMIT ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, limit); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + String name = rs.getString("player_name"); + result.add(Map.entry(name == null ? "?" : name, rs.getDouble("cash"))); + } + } + } catch (SQLException ex) { + plugin.getLogger().log(Level.SEVERE, "读取排行榜失败", ex); + } + return result; + } + + // --------------------------------------------------------------------- + // 定期存款 + // --------------------------------------------------------------------- + + /** 插入一条新的定期存款, 返回生成的记录 (含自增 id)。 */ + public TermDeposit insertTermDeposit(UUID owner, double principal, double dailyRate, + int durationDays, long startTime, long endTime) { + String sql = "INSERT INTO craftbank_term_deposits (owner, principal, daily_rate, duration_days, start_time, end_time, claimed) " + + "VALUES (?,?,?,?,?,?,0)"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + ps.setString(1, owner.toString()); + ps.setDouble(2, principal); + ps.setDouble(3, dailyRate); + ps.setInt(4, durationDays); + ps.setLong(5, startTime); + ps.setLong(6, endTime); + ps.executeUpdate(); + int id = -1; + try (ResultSet keys = ps.getGeneratedKeys()) { + if (keys.next()) { + id = keys.getInt(1); + } + } + return new TermDeposit(id, owner, principal, dailyRate, durationDays, startTime, endTime, false); + } catch (SQLException ex) { + plugin.getLogger().log(Level.SEVERE, "创建定期存款失败: " + owner, ex); + return null; + } + } + + public List getTermDeposits(UUID owner, boolean includeClaimed) { + List list = new ArrayList<>(); + String sql = "SELECT * FROM craftbank_term_deposits WHERE owner = ?" + + (includeClaimed ? "" : " AND claimed = 0") + " ORDER BY end_time ASC"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, owner.toString()); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + list.add(readDeposit(rs)); + } + } + } catch (SQLException ex) { + plugin.getLogger().log(Level.SEVERE, "读取定期存款失败: " + owner, ex); + } + return list; + } + + /** 所有已到期但尚未领取的定期存款 (供定时扫描自动结算)。 */ + public List getMaturedUnclaimed() { + List list = new ArrayList<>(); + String sql = "SELECT * FROM craftbank_term_deposits WHERE claimed = 0 AND end_time <= ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setLong(1, System.currentTimeMillis()); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + list.add(readDeposit(rs)); + } + } + } catch (SQLException ex) { + plugin.getLogger().log(Level.SEVERE, "读取到期定期存款失败", ex); + } + return list; + } + + /** + * 原子地将一笔定期存款标记为已领取。 + * + * @return true 表示本次调用成功完成了标记 (claimed 由 0 改为 1); + * false 表示该笔存款已被其他线程领取, 调用方不应重复发放本息。 + */ + public boolean markClaimed(int id) { + String sql = "UPDATE craftbank_term_deposits SET claimed = 1 WHERE id = ? AND claimed = 0"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setInt(1, id); + return ps.executeUpdate() > 0; + } catch (SQLException ex) { + plugin.getLogger().log(Level.SEVERE, "标记定期存款失败: id=" + id, ex); + return false; + } + } + + private TermDeposit readDeposit(ResultSet rs) throws SQLException { + return new TermDeposit( + rs.getInt("id"), + UUID.fromString(rs.getString("owner")), + rs.getDouble("principal"), + rs.getDouble("daily_rate"), + rs.getInt("duration_days"), + rs.getLong("start_time"), + rs.getLong("end_time"), + rs.getInt("claimed") != 0); + } +} diff --git a/src/main/java/com/craftbank/economy/EconomyManager.java b/src/main/java/com/craftbank/economy/EconomyManager.java new file mode 100644 index 0000000..07c0461 --- /dev/null +++ b/src/main/java/com/craftbank/economy/EconomyManager.java @@ -0,0 +1,212 @@ +package com.craftbank.economy; + +import com.craftbank.CraftBank; +import com.craftbank.model.PlayerAccount; +import com.craftbank.util.Amounts; + +import java.text.DecimalFormat; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 经济核心。维护玩家账户的线程安全内存缓存, 并提供现金/活期的原子操作。 + * + *

缓存使用 {@link ConcurrentHashMap}; 单个账户内的余额变动通过 + * {@link PlayerAccount} 的同步方法保证原子性, 从而抵御来自其他插件异步线程 + * (经由 Vault) 的并发调用导致的刷钱。

+ */ +public class EconomyManager { + + private final CraftBank plugin; + private final Map cache = new ConcurrentHashMap<>(); + private final DecimalFormat format = new DecimalFormat("#,##0.00"); + + private double startingBalance; + private String currencySymbol; + + public EconomyManager(CraftBank plugin) { + this.plugin = plugin; + reload(); + } + + public void reload() { + this.startingBalance = Amounts.normalize(plugin.getConfig().getDouble("Economy_Settings.starting_balance", 1000.0)); + this.currencySymbol = plugin.getConfig().getString("Economy_Settings.currency_symbol", "$"); + } + + // --------------------------------------------------------------------- + // 缓存管理 + // --------------------------------------------------------------------- + + public PlayerAccount getCached(UUID uuid) { + return cache.get(uuid); + } + + public boolean isLoaded(UUID uuid) { + return cache.containsKey(uuid); + } + + public void cache(PlayerAccount account) { + cache.put(account.getUuid(), account); + } + + public void uncache(UUID uuid) { + cache.remove(uuid); + } + + public Iterable getAllCached() { + return cache.values(); + } + + /** + * 从缓存获取账户; 若不在缓存中则阻塞地从数据库读取并缓存。 + * + *

必须在异步线程调用 (例如 PlayerJoin 时的 AsyncScheduler 任务, + * 或 Vault 对离线玩家的查询)。

+ * + * @param createIfAbsent 数据库中也不存在时, 是否以初始余额新建账户。 + */ + public PlayerAccount loadBlocking(UUID uuid, String name, boolean createIfAbsent) { + PlayerAccount cached = cache.get(uuid); + if (cached != null) { + cached.setName(name); + return cached; + } + // 同一 UUID 仅加载一次, 避免并发重复建账。 + return cache.computeIfAbsent(uuid, id -> { + PlayerAccount loaded = plugin.getDatabaseManager().loadAccount(id); + if (loaded == null) { + if (!createIfAbsent) { + return null; + } + loaded = new PlayerAccount(id, name, startingBalance, 0.0, System.currentTimeMillis(), 0L); + loaded.setDirty(true); + plugin.getDatabaseManager().saveAccount(loaded); + } else { + loaded.setName(name); + } + return loaded; + }); + } + + // --------------------------------------------------------------------- + // 现金操作 (线程安全) + // --------------------------------------------------------------------- + + public double getCash(UUID uuid) { + PlayerAccount account = cache.get(uuid); + if (account != null) { + return account.getCash(); + } + // 离线/未缓存玩家: 主键查询代价极小, 缓存后续复用。 + PlayerAccount loaded = loadBlocking(uuid, null, false); + return loaded == null ? 0.0 : loaded.getCash(); + } + + public boolean has(UUID uuid, double amount) { + return getCash(uuid) >= Amounts.normalize(amount); + } + + /** 扣款。成功返回 true。 */ + public boolean withdraw(UUID uuid, double amount) { + amount = Amounts.normalize(amount); + if (amount <= 0) { + return false; + } + PlayerAccount account = requireAccount(uuid); + if (account == null) { + return false; + } + boolean ok = account.withdrawCash(amount); + if (ok) { + plugin.persistAsync(account); + } + return ok; + } + + /** 存款。 */ + public boolean deposit(UUID uuid, double amount) { + amount = Amounts.normalize(amount); + if (amount <= 0) { + return false; + } + PlayerAccount account = requireAccount(uuid); + if (account == null) { + return false; + } + account.depositCash(amount); + plugin.persistAsync(account); + return true; + } + + public boolean set(UUID uuid, double amount) { + amount = Amounts.normalize(amount); + if (amount < 0) { + return false; + } + PlayerAccount account = requireAccount(uuid); + if (account == null) { + return false; + } + account.setCash(amount); + plugin.persistAsync(account); + return true; + } + + /** + * 原子转账: 同时锁定双方账户避免死锁 (按 UUID 顺序加锁)。 + */ + public boolean transfer(UUID from, UUID to, double amount) { + amount = Amounts.normalize(amount); + if (amount <= 0 || from.equals(to)) { + return false; + } + PlayerAccount a = requireAccount(from); + PlayerAccount b = requireAccount(to); + if (a == null || b == null) { + return false; + } + PlayerAccount first = from.compareTo(to) < 0 ? a : b; + PlayerAccount second = first == a ? b : a; + synchronized (first) { + synchronized (second) { + if (!a.withdrawCash(amount)) { + return false; + } + b.depositCash(amount); + } + } + plugin.persistAsync(a); + plugin.persistAsync(b); + return true; + } + + private PlayerAccount requireAccount(UUID uuid) { + PlayerAccount account = cache.get(uuid); + if (account != null) { + return account; + } + return loadBlocking(uuid, null, false); + } + + // --------------------------------------------------------------------- + // 格式化 + // --------------------------------------------------------------------- + + public String getCurrencySymbol() { + return currencySymbol; + } + + public double getStartingBalance() { + return startingBalance; + } + + public String formatNumber(double amount) { + return format.format(amount); + } + + public String format(double amount) { + return currencySymbol + format.format(amount); + } +} diff --git a/src/main/java/com/craftbank/economy/VaultEconomyProvider.java b/src/main/java/com/craftbank/economy/VaultEconomyProvider.java new file mode 100644 index 0000000..5486e87 --- /dev/null +++ b/src/main/java/com/craftbank/economy/VaultEconomyProvider.java @@ -0,0 +1,288 @@ +package com.craftbank.economy; + +import com.craftbank.CraftBank; +import net.milkbowl.vault.economy.Economy; +import net.milkbowl.vault.economy.EconomyResponse; +import org.bukkit.OfflinePlayer; +import org.bukkit.entity.Player; + +import java.util.List; +import java.util.UUID; + +/** + * Vault {@link Economy} 接口实现。CraftBank 以此接管整个服务器的经济提供者 + * 角色 (替代 XConomy)。所有操作均委托给线程安全的 {@link EconomyManager}。 + */ +public class VaultEconomyProvider implements Economy { + + private final CraftBank plugin; + private final EconomyManager economy; + + public VaultEconomyProvider(CraftBank plugin) { + this.plugin = plugin; + this.economy = plugin.getEconomyManager(); + } + + @Override + public boolean isEnabled() { + return plugin.isEnabled(); + } + + @Override + public String getName() { + return "CraftBank"; + } + + @Override + public boolean hasBankSupport() { + // 这里的 "bank" 指 Vault 的多人共享账户概念, CraftBank 不提供。 + return false; + } + + @Override + public int fractionalDigits() { + return plugin.getConfig().getInt("Economy_Settings.fractional_digits", 2); + } + + @Override + public String format(double amount) { + return economy.format(amount); + } + + @Override + public String currencyNamePlural() { + return plugin.getConfig().getString("Economy_Settings.currency_name_plural", ""); + } + + @Override + public String currencyNameSingular() { + return plugin.getConfig().getString("Economy_Settings.currency_name_singular", ""); + } + + // ---- 账户存在性 ---- + + @Override + public boolean hasAccount(String playerName) { + return playerName != null; + } + + @Override + public boolean hasAccount(OfflinePlayer player) { + return player != null; + } + + @Override + public boolean hasAccount(String playerName, String worldName) { + return hasAccount(playerName); + } + + @Override + public boolean hasAccount(OfflinePlayer player, String worldName) { + return hasAccount(player); + } + + @Override + public boolean createPlayerAccount(OfflinePlayer player) { + if (player == null) { + return false; + } + economy.loadBlocking(player.getUniqueId(), player.getName(), true); + return true; + } + + @Override + public boolean createPlayerAccount(OfflinePlayer player, String worldName) { + return createPlayerAccount(player); + } + + @Override + public boolean createPlayerAccount(String playerName) { + return false; + } + + @Override + public boolean createPlayerAccount(String playerName, String worldName) { + return false; + } + + // ---- 余额查询 ---- + + @Override + public double getBalance(OfflinePlayer player) { + return economy.getCash(player.getUniqueId()); + } + + @Override + @SuppressWarnings("deprecation") + public double getBalance(String playerName) { + OfflinePlayer player = plugin.getServer().getOfflinePlayer(playerName); + return getBalance(player); + } + + @Override + public double getBalance(String playerName, String world) { + return getBalance(playerName); + } + + @Override + public double getBalance(OfflinePlayer player, String world) { + return getBalance(player); + } + + @Override + public boolean has(OfflinePlayer player, double amount) { + return economy.has(player.getUniqueId(), amount); + } + + @Override + @SuppressWarnings("deprecation") + public boolean has(String playerName, double amount) { + return has(plugin.getServer().getOfflinePlayer(playerName), amount); + } + + @Override + public boolean has(String playerName, String worldName, double amount) { + return has(playerName, amount); + } + + @Override + public boolean has(OfflinePlayer player, String worldName, double amount) { + return has(player, amount); + } + + // ---- 扣款 ---- + + @Override + public EconomyResponse withdrawPlayer(OfflinePlayer player, double amount) { + if (amount < 0) { + return fail("无法扣除一个负数金额"); + } + UUID uuid = player.getUniqueId(); + if (economy.withdraw(uuid, amount)) { + return ok(amount, economy.getCash(uuid)); + } + return fail("余额不足"); + } + + @Override + @SuppressWarnings("deprecation") + public EconomyResponse withdrawPlayer(String playerName, double amount) { + return withdrawPlayer(plugin.getServer().getOfflinePlayer(playerName), amount); + } + + @Override + public EconomyResponse withdrawPlayer(String playerName, String worldName, double amount) { + return withdrawPlayer(playerName, amount); + } + + @Override + public EconomyResponse withdrawPlayer(OfflinePlayer player, String worldName, double amount) { + return withdrawPlayer(player, amount); + } + + // ---- 存款 ---- + + @Override + public EconomyResponse depositPlayer(OfflinePlayer player, double amount) { + if (amount < 0) { + return fail("无法存入一个负数金额"); + } + UUID uuid = player.getUniqueId(); + if (economy.deposit(uuid, amount)) { + return ok(amount, economy.getCash(uuid)); + } + return fail("账户不存在"); + } + + @Override + @SuppressWarnings("deprecation") + public EconomyResponse depositPlayer(String playerName, double amount) { + return depositPlayer(plugin.getServer().getOfflinePlayer(playerName), amount); + } + + @Override + public EconomyResponse depositPlayer(String playerName, String worldName, double amount) { + return depositPlayer(playerName, amount); + } + + @Override + public EconomyResponse depositPlayer(OfflinePlayer player, String worldName, double amount) { + return depositPlayer(player, amount); + } + + // ---- 不支持的 Vault 银行账户 (多人共享) ---- + + @Override + public EconomyResponse createBank(String name, String player) { + return unsupported(); + } + + @Override + public EconomyResponse createBank(String name, OfflinePlayer player) { + return unsupported(); + } + + @Override + public EconomyResponse deleteBank(String name) { + return unsupported(); + } + + @Override + public EconomyResponse bankBalance(String name) { + return unsupported(); + } + + @Override + public EconomyResponse bankHas(String name, double amount) { + return unsupported(); + } + + @Override + public EconomyResponse bankWithdraw(String name, double amount) { + return unsupported(); + } + + @Override + public EconomyResponse bankDeposit(String name, double amount) { + return unsupported(); + } + + @Override + public EconomyResponse isBankOwner(String name, String playerName) { + return unsupported(); + } + + @Override + public EconomyResponse isBankOwner(String name, OfflinePlayer player) { + return unsupported(); + } + + @Override + public EconomyResponse isBankMember(String name, String playerName) { + return unsupported(); + } + + @Override + public EconomyResponse isBankMember(String name, OfflinePlayer player) { + return unsupported(); + } + + @Override + public List getBanks() { + return List.of(); + } + + // ---- 辅助 ---- + + private EconomyResponse ok(double amount, double balance) { + return new EconomyResponse(amount, balance, EconomyResponse.ResponseType.SUCCESS, null); + } + + private EconomyResponse fail(String msg) { + return new EconomyResponse(0, 0, EconomyResponse.ResponseType.FAILURE, msg); + } + + private EconomyResponse unsupported() { + return new EconomyResponse(0, 0, EconomyResponse.ResponseType.NOT_IMPLEMENTED, "CraftBank 不支持 Vault 共享银行账户"); + } +} diff --git a/src/main/java/com/craftbank/gui/BankGUI.java b/src/main/java/com/craftbank/gui/BankGUI.java new file mode 100644 index 0000000..1ac7207 --- /dev/null +++ b/src/main/java/com/craftbank/gui/BankGUI.java @@ -0,0 +1,156 @@ +package com.craftbank.gui; + +import com.craftbank.CraftBank; +import com.craftbank.economy.EconomyManager; +import com.craftbank.model.PlayerAccount; +import com.craftbank.model.TermDeposit; +import com.craftbank.util.Text; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * 银行 GUI 的构建与布局。所有打开/刷新操作都应在玩家所在区域线程执行。 + */ +public class BankGUI { + + // 主界面槽位 + public static final int SLOT_CASH = 10; + public static final int SLOT_SAVING = 13; + public static final int SLOT_TERM_LIST = 16; + public static final int SLOT_DEPOSIT_ALL = 29; + public static final int SLOT_WITHDRAW_ALL = 33; + public static final int SLOT_TERM_7 = 20; + public static final int SLOT_TERM_15 = 22; + public static final int SLOT_TERM_30 = 24; + + private final CraftBank plugin; + private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + + public BankGUI(CraftBank plugin) { + this.plugin = plugin; + } + + public void openMain(Player player, PlayerAccount account) { + BankGuiHolder holder = new BankGuiHolder(BankGuiHolder.Type.MAIN); + Inventory inv = Bukkit.createInventory(holder, 36, Text.color("&8工艺银行 · 个人账户")); + holder.setInventory(inv); + + EconomyManager eco = plugin.getEconomyManager(); + fillBackground(inv); + + inv.setItem(SLOT_CASH, icon(Material.GOLD_INGOT, "&e钱包现金", + List.of("&7当前现金: &f" + eco.format(account.getCash()), + "&8(可用于转账、商店消费)"))); + + inv.setItem(SLOT_SAVING, icon(Material.GOLD_BLOCK, "&6活期储蓄", + List.of("&7活期余额: &f" + eco.format(account.getBankSaving()), + "&7日利率: &a" + percent(plugin.getBankManager().getSavingsRate()), + "&8存取自由, 每日结算利息"))); + + 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_WITHDRAW_ALL, icon(Material.DROPPER, "&c取出全部活期", + List.of("&7将活期余额全部取回钱包", + "&7活期余额: &f" + eco.format(account.getBankSaving())))); + + Map terms = plugin.getBankManager().getTermOptions(); + inv.setItem(SLOT_TERM_7, termIcon(eco, 7, terms.getOrDefault(7, 0.0), account.getBankSaving())); + inv.setItem(SLOT_TERM_15, termIcon(eco, 15, terms.getOrDefault(15, 0.0), account.getBankSaving())); + inv.setItem(SLOT_TERM_30, termIcon(eco, 30, terms.getOrDefault(30, 0.0), account.getBankSaving())); + + player.openInventory(inv); + } + + private ItemStack termIcon(EconomyManager eco, int days, double rate, double saving) { + return icon(Material.DIAMOND, "&b定期存款 · " + days + "天", + List.of("&7日利率: &a" + percent(rate), + "&7到期利息: &a" + percent(rate * days) + " (单利)", + "&8点击将<活期全部余额>存入该定期", + "&8可存金额: &f" + eco.format(saving), + "&c一旦存入到期前不可取出!")); + } + + public void openTermList(Player player, List deposits) { + BankGuiHolder holder = new BankGuiHolder(BankGuiHolder.Type.TERM_LIST); + int rows = Math.max(1, Math.min(5, (deposits.size() / 9) + 1)); + Inventory inv = Bukkit.createInventory(holder, rows * 9 + 9, Text.color("&8工艺银行 · 定期存款")); + holder.setInventory(inv); + + EconomyManager eco = plugin.getEconomyManager(); + int slot = 0; + for (TermDeposit d : deposits) { + if (slot >= inv.getSize() - 9) { + break; + } + boolean matured = d.isMatured(); + List lore = new ArrayList<>(); + lore.add("&7本金: &f" + eco.format(d.getPrincipal())); + lore.add("&7期限: &f" + d.getDurationDays() + "天"); + lore.add("&7到期利息: &a" + eco.format(d.calculateInterest())); + lore.add("&7到期本息: &a" + eco.format(d.calculateTotalPayout())); + lore.add("&7到期时间: &f" + dateFormat.format(new Date(d.getEndTime()))); + if (matured) { + lore.add("&a✔ 已到期 - 点击领取本息到活期"); + } else { + long remain = d.getEndTime() - System.currentTimeMillis(); + lore.add("&c锁定中, 剩余 " + TimeUnit.MILLISECONDS.toDays(remain) + " 天 " + + (TimeUnit.MILLISECONDS.toHours(remain) % 24) + " 小时"); + } + ItemStack item = icon(matured ? Material.EMERALD : Material.PAPER, + "&b定期 #" + d.getId(), lore); + // 将存款 id 写入 PDC, 供点击时稳健解析。 + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.getPersistentDataContainer().set( + plugin.getKeys().guiTermId, + org.bukkit.persistence.PersistentDataType.INTEGER, d.getId()); + item.setItemMeta(meta); + } + inv.setItem(slot++, item); + } + + // 返回按钮 + inv.setItem(inv.getSize() - 5, icon(Material.ARROW, "&e返回", List.of("&7点击回到主界面"))); + player.openInventory(inv); + } + + // --------------------------------------------------------------------- + + private void fillBackground(Inventory inv) { + ItemStack pane = icon(Material.GRAY_STAINED_GLASS_PANE, " ", List.of()); + for (int i = 0; i < inv.getSize(); i++) { + inv.setItem(i, pane); + } + } + + private ItemStack icon(Material material, String name, List lore) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.setDisplayName(Text.color(name)); + meta.setLore(Text.color(lore)); + item.setItemMeta(meta); + } + return item; + } + + private String percent(double rate) { + return String.format("%.2f%%", rate * 100); + } +} diff --git a/src/main/java/com/craftbank/gui/BankGuiHolder.java b/src/main/java/com/craftbank/gui/BankGuiHolder.java new file mode 100644 index 0000000..1c966bf --- /dev/null +++ b/src/main/java/com/craftbank/gui/BankGuiHolder.java @@ -0,0 +1,36 @@ +package com.craftbank.gui; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.jetbrains.annotations.NotNull; + +/** + * 用于标识 CraftBank 自有 GUI 的 InventoryHolder, 便于事件监听器区分点击来源。 + */ +public class BankGuiHolder implements InventoryHolder { + + public enum Type { + MAIN, TERM_LIST + } + + private final Type type; + private Inventory inventory; + + public BankGuiHolder(Type type) { + this.type = type; + } + + public Type getType() { + return type; + } + + public void setInventory(Inventory inventory) { + this.inventory = inventory; + } + + @NotNull + @Override + public Inventory getInventory() { + return inventory; + } +} diff --git a/src/main/java/com/craftbank/hooks/CraftBankExpansion.java b/src/main/java/com/craftbank/hooks/CraftBankExpansion.java new file mode 100644 index 0000000..80072e3 --- /dev/null +++ b/src/main/java/com/craftbank/hooks/CraftBankExpansion.java @@ -0,0 +1,64 @@ +package com.craftbank.hooks; + +import com.craftbank.CraftBank; +import com.craftbank.model.PlayerAccount; +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; + +/** + * PlaceholderAPI 扩展, 提供: + *
    + *
  • %craftbank_cash% —— 现金余额
  • + *
  • %craftbank_bank_saving% —— 活期余额
  • + *
  • %craftbank_cash_formatted% —— 带货币符号的现金
  • + *
  • %craftbank_bank_saving_formatted% —— 带货币符号的活期
  • + *
+ */ +public class CraftBankExpansion extends PlaceholderExpansion { + + private final CraftBank plugin; + + public CraftBankExpansion(CraftBank plugin) { + this.plugin = plugin; + } + + @Override + public @NotNull String getIdentifier() { + return "craftbank"; + } + + @Override + public @NotNull String getAuthor() { + return "CraftBank"; + } + + @Override + public @NotNull String getVersion() { + return plugin.getDescription().getVersion(); + } + + @Override + public boolean persist() { + return true; + } + + @Override + public String onRequest(OfflinePlayer player, @NotNull String params) { + if (player == null) { + return ""; + } + // 占位符在主线程被解析: 仅读取已缓存数据, 不触发阻塞式数据库查询。 + PlayerAccount account = plugin.getEconomyManager().getCached(player.getUniqueId()); + double cash = account != null ? account.getCash() : 0.0; + double saving = account != null ? account.getBankSaving() : 0.0; + + 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); + default -> null; + }; + } +} diff --git a/src/main/java/com/craftbank/items/BankCardManager.java b/src/main/java/com/craftbank/items/BankCardManager.java new file mode 100644 index 0000000..784af7f --- /dev/null +++ b/src/main/java/com/craftbank/items/BankCardManager.java @@ -0,0 +1,122 @@ +package com.craftbank.items; + +import com.craftbank.CraftBank; +import com.craftbank.model.PlayerAccount; +import com.craftbank.util.Text; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 银行卡实体物品的创建与校验。银行卡通过 PDC 绑定持卡人 UUID 与序列号, + * 序列号用于挂失/补办: 旧卡序列号与账户当前序列号不符即作废。 + */ +public class BankCardManager { + + private final CraftBank plugin; + private final Keys keys; + + public BankCardManager(CraftBank plugin) { + this.plugin = plugin; + this.keys = plugin.getKeys(); + } + + /** + * 为玩家签发一张新银行卡, 并更新账户的有效序列号 (旧卡随之作废)。 + */ + public ItemStack issueCard(Player player, PlayerAccount account) { + long serial = System.currentTimeMillis(); + account.setCardSerial(serial); + plugin.persistAsync(account); + return buildCard(player.getName(), player.getUniqueId(), serial); + } + + private ItemStack buildCard(String ownerName, UUID owner, long serial) { + Material material = parseMaterial(plugin.getConfig().getString("Items.bank_card.material", "PAPER"), Material.PAPER); + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return item; + } + + String name = plugin.getConfig().getString("Items.bank_card.name", "&b工艺银行 · 储蓄卡"); + meta.setDisplayName(Text.color(name.replace("%owner%", ownerName == null ? "?" : ownerName))); + + List lore = new ArrayList<>(); + for (String line : plugin.getConfig().getStringList("Items.bank_card.lore")) { + lore.add(Text.color(line.replace("%owner%", ownerName == null ? "?" : ownerName))); + } + meta.setLore(lore); + + int cmd = plugin.getConfig().getInt("Items.bank_card.custom_model_data", 0); + if (cmd > 0) { + meta.setCustomModelData(cmd); + } + + PersistentDataContainer pdc = meta.getPersistentDataContainer(); + pdc.set(keys.cardOwner, PersistentDataType.STRING, owner.toString()); + pdc.set(keys.cardSerial, PersistentDataType.LONG, serial); + + item.setItemMeta(meta); + return item; + } + + public boolean isCard(ItemStack item) { + if (item == null) { + return false; + } + ItemMeta meta = item.getItemMeta(); + return meta != null && meta.getPersistentDataContainer().has(keys.cardOwner, PersistentDataType.STRING); + } + + public UUID getOwner(ItemStack item) { + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return null; + } + String raw = meta.getPersistentDataContainer().get(keys.cardOwner, PersistentDataType.STRING); + if (raw == null) { + return null; + } + try { + return UUID.fromString(raw); + } catch (IllegalArgumentException ex) { + return null; + } + } + + public long getSerial(ItemStack item) { + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return 0; + } + Long serial = meta.getPersistentDataContainer().get(keys.cardSerial, PersistentDataType.LONG); + return serial == null ? 0 : serial; + } + + /** 银行卡是否仍然有效 (持卡人匹配且序列号未被挂失作废)。 */ + public boolean isValid(ItemStack item, PlayerAccount account) { + if (account == null || !isCard(item)) { + return false; + } + UUID owner = getOwner(item); + return account.getUuid().equals(owner) + && account.hasValidCard() + && account.getCardSerial() == getSerial(item); + } + + private Material parseMaterial(String name, Material def) { + if (name == null) { + return def; + } + Material m = Material.matchMaterial(name.toUpperCase()); + return m == null ? def : m; + } +} diff --git a/src/main/java/com/craftbank/items/ChequeManager.java b/src/main/java/com/craftbank/items/ChequeManager.java new file mode 100644 index 0000000..f03ec49 --- /dev/null +++ b/src/main/java/com/craftbank/items/ChequeManager.java @@ -0,0 +1,111 @@ +package com.craftbank.items; + +import com.craftbank.CraftBank; +import com.craftbank.util.Text; +import org.bukkit.Material; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +/** + * 支票实体物品的开具与防伪校验。每张支票都在 PDC 中记录金额、开票人 UUID、 + * 签发时间戳以及一个唯一 ID, 兑现逻辑配合 ItemStack 数量处理防止刷钱。 + */ +public class ChequeManager { + + private final CraftBank plugin; + private final Keys keys; + private final SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + + public ChequeManager(CraftBank plugin) { + this.plugin = plugin; + this.keys = plugin.getKeys(); + } + + public ItemStack createCheque(UUID issuer, String issuerName, double amount) { + Material material = parseMaterial(plugin.getConfig().getString("Items.cheque.material", "PAPER"), Material.PAPER); + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta == null) { + return item; + } + + long now = System.currentTimeMillis(); + String amountStr = plugin.getEconomyManager().format(amount); + String dateStr = dateFormat.format(new Date(now)); + + String name = plugin.getConfig().getString("Items.cheque.name", "&a银行支票 &7- &f%amount%"); + meta.setDisplayName(Text.color(applyPlaceholders(name, issuerName, amountStr, dateStr))); + + List lore = new ArrayList<>(); + for (String line : plugin.getConfig().getStringList("Items.cheque.lore")) { + lore.add(Text.color(applyPlaceholders(line, issuerName, amountStr, dateStr))); + } + meta.setLore(lore); + + int cmd = plugin.getConfig().getInt("Items.cheque.custom_model_data", 0); + if (cmd > 0) { + meta.setCustomModelData(cmd); + } + + PersistentDataContainer pdc = meta.getPersistentDataContainer(); + pdc.set(keys.chequeAmount, PersistentDataType.DOUBLE, amount); + pdc.set(keys.chequeIssuer, PersistentDataType.STRING, issuer.toString()); + pdc.set(keys.chequeIssuerName, PersistentDataType.STRING, issuerName == null ? "?" : issuerName); + pdc.set(keys.chequeTime, PersistentDataType.LONG, now); + pdc.set(keys.chequeId, PersistentDataType.STRING, UUID.randomUUID().toString()); + + item.setItemMeta(meta); + return item; + } + + private String applyPlaceholders(String input, String issuer, String amount, String date) { + return input + .replace("%issuer%", issuer == null ? "?" : issuer) + .replace("%amount%", amount) + .replace("%date%", date); + } + + public boolean isCheque(ItemStack item) { + if (item == null) { + return false; + } + ItemMeta meta = item.getItemMeta(); + return meta != null && meta.getPersistentDataContainer().has(keys.chequeAmount, PersistentDataType.DOUBLE); + } + + /** 读取支票金额; 非支票或非法返回 -1。 */ + public double getAmount(ItemStack item) { + if (!isCheque(item)) { + return -1; + } + Double amount = item.getItemMeta().getPersistentDataContainer().get(keys.chequeAmount, PersistentDataType.DOUBLE); + if (amount == null || !Double.isFinite(amount) || amount <= 0) { + return -1; + } + return amount; + } + + public String getIssuerName(ItemStack item) { + if (!isCheque(item)) { + return "?"; + } + String name = item.getItemMeta().getPersistentDataContainer().get(keys.chequeIssuerName, PersistentDataType.STRING); + return name == null ? "?" : name; + } + + private Material parseMaterial(String name, Material def) { + if (name == null) { + return def; + } + Material m = Material.matchMaterial(name.toUpperCase()); + return m == null ? def : m; + } +} diff --git a/src/main/java/com/craftbank/items/Keys.java b/src/main/java/com/craftbank/items/Keys.java new file mode 100644 index 0000000..a584ed8 --- /dev/null +++ b/src/main/java/com/craftbank/items/Keys.java @@ -0,0 +1,32 @@ +package com.craftbank.items; + +import com.craftbank.CraftBank; +import org.bukkit.NamespacedKey; + +/** + * 集中管理所有用于 PDC 防伪的 {@link NamespacedKey}。 + */ +public final class Keys { + + public final NamespacedKey cardOwner; + public final NamespacedKey cardSerial; + + public final NamespacedKey chequeAmount; + public final NamespacedKey chequeIssuer; + public final NamespacedKey chequeIssuerName; + public final NamespacedKey chequeTime; + public final NamespacedKey chequeId; + + public final NamespacedKey guiTermId; + + public Keys(CraftBank plugin) { + this.cardOwner = new NamespacedKey(plugin, "card_owner"); + this.cardSerial = new NamespacedKey(plugin, "card_serial"); + this.chequeAmount = new NamespacedKey(plugin, "cheque_amount"); + this.chequeIssuer = new NamespacedKey(plugin, "cheque_issuer"); + this.chequeIssuerName = new NamespacedKey(plugin, "cheque_issuer_name"); + this.chequeTime = new NamespacedKey(plugin, "cheque_time"); + this.chequeId = new NamespacedKey(plugin, "cheque_id"); + this.guiTermId = new NamespacedKey(plugin, "gui_term_id"); + } +} diff --git a/src/main/java/com/craftbank/listeners/InteractListener.java b/src/main/java/com/craftbank/listeners/InteractListener.java new file mode 100644 index 0000000..52b8300 --- /dev/null +++ b/src/main/java/com/craftbank/listeners/InteractListener.java @@ -0,0 +1,110 @@ +package com.craftbank.listeners; + +import com.craftbank.CraftBank; +import com.craftbank.model.PlayerAccount; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.ItemStack; + +/** + * 处理银行卡右键开柜与支票右键兑现 (含 PDC 防伪与防刷)。 + * + *

{@link PlayerInteractEvent} 已在玩家所在区域线程触发, 因此物品操作、 + * 打开 GUI 与发送消息均可直接在此线程安全进行。

+ */ +public class InteractListener implements Listener { + + private final CraftBank plugin; + + public InteractListener(CraftBank plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onInteract(PlayerInteractEvent event) { + // 仅处理主手, 避免一次交互触发两次。 + if (event.getHand() != EquipmentSlot.HAND) { + return; + } + Action action = event.getAction(); + if (action != Action.RIGHT_CLICK_AIR && action != Action.RIGHT_CLICK_BLOCK) { + return; + } + + Player player = event.getPlayer(); + ItemStack item = event.getItem(); + if (item == null || item.getType() == Material.AIR) { + return; + } + + // 支票兑现 (空中或方块上右键均可)。 + if (plugin.getChequeManager().isCheque(item)) { + event.setCancelled(true); + redeemCheque(player, item); + return; + } + + // 银行卡右键指定方块打开 GUI。 + if (plugin.getBankCardManager().isCard(item) && action == Action.RIGHT_CLICK_BLOCK) { + Block block = event.getClickedBlock(); + if (block == null) { + return; + } + Material required = Material.matchMaterial( + plugin.getConfig().getString("Bank_Settings.interact_block", "IRON_BLOCK")); + if (required != null && block.getType() != required) { + return; + } + event.setCancelled(true); + openWithCard(player, item); + } + } + + private void openWithCard(Player player, ItemStack card) { + PlayerAccount account = plugin.getEconomyManager().getCached(player.getUniqueId()); + if (account == null) { + plugin.msg().raw(player, "&c账户尚未加载完成, 请稍后再试。"); + return; + } + if (!plugin.getBankCardManager().isValid(card, account)) { + plugin.msg().raw(player, "&c这张银行卡已失效 (可能已被挂失), 请到银行补办。"); + return; + } + plugin.getBankGUI().openMain(player, account); + } + + private void redeemCheque(Player player, ItemStack chequeInHand) { + double amount = plugin.getChequeManager().getAmount(chequeInHand); + if (amount <= 0) { + plugin.msg().raw(player, "&c这是一张无效的支票。"); + return; + } + + // 防刷核心: 先安全地从手中扣减一张支票, 再入账。 + // 本线程为玩家区域线程, 对其物品栏的修改是独占且安全的。 + ItemStack current = player.getInventory().getItemInMainHand(); + if (!plugin.getChequeManager().isCheque(current) || current.getAmount() <= 0) { + return; + } + double verified = plugin.getChequeManager().getAmount(current); + if (verified != amount) { + return; + } + + if (current.getAmount() == 1) { + player.getInventory().setItemInMainHand(null); + } else { + current.setAmount(current.getAmount() - 1); + } + + plugin.getEconomyManager().deposit(player.getUniqueId(), 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 new file mode 100644 index 0000000..70a60f8 --- /dev/null +++ b/src/main/java/com/craftbank/listeners/InventoryListener.java @@ -0,0 +1,173 @@ +package com.craftbank.listeners; + +import com.craftbank.CraftBank; +import com.craftbank.gui.BankGUI; +import com.craftbank.gui.BankGuiHolder; +import com.craftbank.model.PlayerAccount; +import com.craftbank.model.TermDeposit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; + +/** + * 处理银行 GUI 内的点击逻辑。所有写入数据库的操作都被分派到异步线程, + * 完成后再回到玩家区域线程刷新界面。 + */ +public class InventoryListener implements Listener { + + private final CraftBank plugin; + + public InventoryListener(CraftBank plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onDrag(InventoryDragEvent event) { + if (event.getInventory().getHolder() instanceof BankGuiHolder) { + event.setCancelled(true); + } + } + + @EventHandler + public void onClick(InventoryClickEvent event) { + Inventory top = event.getView().getTopInventory(); + InventoryHolder holder = top.getHolder(); + if (!(holder instanceof BankGuiHolder gui)) { + return; + } + // GUI 内一律取消, 防止取走展示物品 (防刷)。 + event.setCancelled(true); + + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + // 只响应点击顶部 GUI 的有效槽位。 + if (event.getClickedInventory() == null || !event.getClickedInventory().equals(top)) { + return; + } + + PlayerAccount account = plugin.getEconomyManager().getCached(player.getUniqueId()); + if (account == null) { + player.closeInventory(); + return; + } + + if (gui.getType() == BankGuiHolder.Type.MAIN) { + handleMain(player, account, event.getRawSlot()); + } 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) { + switch (slot) { + case BankGUI.SLOT_DEPOSIT_ALL -> { + double cash = account.getCash(); + if (cash <= 0) { + plugin.msg().raw(player, "&c你的钱包里没有现金可以存入。"); + return; + } + 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 (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); + default -> { + // 其它槽位 (信息/背景) 无操作。 + } + } + } + + private void createTerm(Player player, PlayerAccount account, int days) { + double amount = account.getBankSaving(); + if (amount <= 0) { + plugin.msg().raw(player, "&c活期余额不足, 无法开设定期存款。"); + return; + } + player.closeInventory(); + // 创建定期涉及数据库写入, 走异步。 + plugin.runAsync(() -> { + TermDeposit deposit = plugin.getBankManager().createTermDeposit(account, amount, days); + plugin.runForPlayer(player, () -> { + if (deposit == null) { + plugin.msg().raw(player, "&c定期存款创建失败, 请稍后再试。"); + } else { + plugin.msg().raw(player, "&a成功存入 " + days + "天定期: &e" + + plugin.getEconomyManager().format(deposit.getPrincipal()) + + "&a, 到期本息 &e" + plugin.getEconomyManager().format(deposit.calculateTotalPayout())); + } + }); + }); + } + + private void openTermList(Player player, PlayerAccount account) { + plugin.runAsync(() -> { + var deposits = plugin.getBankManager().getDeposits(account.getUuid()); + plugin.runForPlayer(player, () -> plugin.getBankGUI().openTermList(player, deposits)); + }); + } + + private void handleTermList(Player player, PlayerAccount account, ItemStack clicked, int slot, int size) { + // 返回按钮。 + if (slot == size - 5) { + plugin.getBankGUI().openMain(player, account); + return; + } + if (clicked == null) { + return; + } + ItemMeta meta = clicked.getItemMeta(); + if (meta == null) { + return; + } + Integer id = meta.getPersistentDataContainer().get(plugin.getKeys().guiTermId, PersistentDataType.INTEGER); + if (id == null) { + return; + } + player.closeInventory(); + final int depositId = id; + plugin.runAsync(() -> { + TermDeposit target = plugin.getBankManager().getDeposits(account.getUuid()).stream() + .filter(d -> d.getId() == depositId) + .findFirst().orElse(null); + if (target == null) { + plugin.runForPlayer(player, () -> plugin.msg().raw(player, "&c该笔定期存款不存在或已领取。")); + return; + } + double payout = plugin.getBankManager().claimTermDeposit(target); + plugin.runForPlayer(player, () -> { + if (payout < 0) { + plugin.msg().raw(player, "&c该定期存款尚未到期, 无法提前支取!"); + } else { + plugin.msg().raw(player, "&a已领取定期本息 &e" + plugin.getEconomyManager().format(payout) + " &a到活期账户。"); + } + }); + }); + } +} diff --git a/src/main/java/com/craftbank/listeners/PlayerConnectionListener.java b/src/main/java/com/craftbank/listeners/PlayerConnectionListener.java new file mode 100644 index 0000000..7e25168 --- /dev/null +++ b/src/main/java/com/craftbank/listeners/PlayerConnectionListener.java @@ -0,0 +1,63 @@ +package com.craftbank.listeners; + +import com.craftbank.CraftBank; +import com.craftbank.model.PlayerAccount; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +import java.util.UUID; + +/** + * 处理玩家登录 / 退出时的账户加载、利息离线结算与数据落库。 + */ +public class PlayerConnectionListener implements Listener { + + private final CraftBank plugin; + + public PlayerConnectionListener(CraftBank plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + UUID uuid = player.getUniqueId(); + String name = player.getName(); + + // 所有数据库操作走异步线程。 + plugin.runAsync(() -> { + PlayerAccount account = plugin.getEconomyManager().loadBlocking(uuid, name, true); + if (account == null) { + return; + } + // 离线期间的活期利息结算。 + double interest = plugin.getBankManager().settleSavingsInterest(account); + // 结算该玩家所有到期定期存款。 + plugin.getBankManager().getDeposits(uuid).stream() + .filter(d -> d.isMatured() && !d.isClaimed()) + .forEach(plugin.getBankManager()::claimTermDeposit); + + if (interest > 0 && player.isOnline()) { + plugin.runForPlayer(player, () -> + plugin.msg().raw(player, "&a欢迎回来! 你的活期账户结算了 &e" + + plugin.getEconomyManager().format(interest) + " &a利息。")); + } + }); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + UUID uuid = event.getPlayer().getUniqueId(); + PlayerAccount account = plugin.getEconomyManager().getCached(uuid); + if (account == null) { + return; + } + plugin.runAsync(() -> { + plugin.getDatabaseManager().saveAccount(account); + plugin.getEconomyManager().uncache(uuid); + }); + } +} diff --git a/src/main/java/com/craftbank/model/PlayerAccount.java b/src/main/java/com/craftbank/model/PlayerAccount.java new file mode 100644 index 0000000..6f12809 --- /dev/null +++ b/src/main/java/com/craftbank/model/PlayerAccount.java @@ -0,0 +1,134 @@ +package com.craftbank.model; + +import java.util.UUID; + +/** + * 玩家经济账户的内存缓存对象。 + * + *

由于 Vault 接口可能在任意插件的异步线程中被触发,对现金 (cash) 和活期 + * 余额 (bankSaving) 的所有读写都通过同步方法完成,以保证线程安全、避免刷钱。

+ */ +public class PlayerAccount { + + private final UUID uuid; + private volatile String name; + private double cash; + private double bankSaving; + + /** 标记数据是否被修改, 用于决定是否需要写库。 */ + private volatile boolean dirty; + + /** 上次活期利息结算的时间戳 (毫秒)。 */ + private long lastInterestSettle; + + /** 当前有效银行卡的序列号; 0 表示当前没有有效银行卡 (未办理或已挂失)。 */ + private volatile long cardSerial; + + public PlayerAccount(UUID uuid, String name, double cash, double bankSaving, + long lastInterestSettle, long cardSerial) { + this.uuid = uuid; + this.name = name; + this.cash = cash; + this.bankSaving = bankSaving; + this.lastInterestSettle = lastInterestSettle; + this.cardSerial = cardSerial; + } + + public UUID getUuid() { + return uuid; + } + + public String getName() { + return name; + } + + public void setName(String name) { + if (name != null && !name.equals(this.name)) { + this.name = name; + this.dirty = true; + } + } + + public synchronized double getCash() { + return cash; + } + + public synchronized void setCash(double cash) { + this.cash = Math.max(0, cash); + this.dirty = true; + } + + /** 尝试扣除现金, 余额不足返回 false。 */ + public synchronized boolean withdrawCash(double amount) { + if (amount < 0 || cash < amount) { + return false; + } + cash -= amount; + dirty = true; + return true; + } + + public synchronized void depositCash(double amount) { + if (amount <= 0) { + return; + } + cash += amount; + dirty = true; + } + + public synchronized double getBankSaving() { + return bankSaving; + } + + public synchronized void setBankSaving(double bankSaving) { + this.bankSaving = Math.max(0, bankSaving); + this.dirty = true; + } + + public synchronized boolean withdrawSaving(double amount) { + if (amount < 0 || bankSaving < amount) { + return false; + } + bankSaving -= amount; + dirty = true; + return true; + } + + public synchronized void depositSaving(double amount) { + if (amount <= 0) { + return; + } + bankSaving += amount; + dirty = true; + } + + public long getLastInterestSettle() { + return lastInterestSettle; + } + + public void setLastInterestSettle(long lastInterestSettle) { + this.lastInterestSettle = lastInterestSettle; + this.dirty = true; + } + + public long getCardSerial() { + return cardSerial; + } + + public void setCardSerial(long cardSerial) { + this.cardSerial = cardSerial; + this.dirty = true; + } + + public boolean hasValidCard() { + return cardSerial != 0; + } + + public boolean isDirty() { + return dirty; + } + + public void setDirty(boolean dirty) { + this.dirty = dirty; + } +} diff --git a/src/main/java/com/craftbank/model/TermDeposit.java b/src/main/java/com/craftbank/model/TermDeposit.java new file mode 100644 index 0000000..586effd --- /dev/null +++ b/src/main/java/com/craftbank/model/TermDeposit.java @@ -0,0 +1,80 @@ +package com.craftbank.model; + +import java.util.UUID; + +/** + * 定期存款记录。使用真实时间戳计算到期, 避免服务器重启导致计时丢失。 + */ +public class TermDeposit { + + private final int id; + private final UUID owner; + private final double principal; + private final double dailyRate; + private final int durationDays; + private final long startTime; + private final long endTime; + private boolean claimed; + + public TermDeposit(int id, UUID owner, double principal, double dailyRate, + int durationDays, long startTime, long endTime, boolean claimed) { + this.id = id; + this.owner = owner; + this.principal = principal; + this.dailyRate = dailyRate; + this.durationDays = durationDays; + this.startTime = startTime; + this.endTime = endTime; + this.claimed = claimed; + } + + public int getId() { + return id; + } + + public UUID getOwner() { + return owner; + } + + public double getPrincipal() { + return principal; + } + + public double getDailyRate() { + return dailyRate; + } + + public int getDurationDays() { + return durationDays; + } + + public long getStartTime() { + return startTime; + } + + public long getEndTime() { + return endTime; + } + + public boolean isClaimed() { + return claimed; + } + + public void setClaimed(boolean claimed) { + this.claimed = claimed; + } + + public boolean isMatured() { + return System.currentTimeMillis() >= endTime; + } + + /** 到期应得的利息 (单利, 基于期限天数)。 */ + public double calculateInterest() { + return principal * dailyRate * durationDays; + } + + /** 到期应领取的总额 (本金 + 利息)。 */ + public double calculateTotalPayout() { + return principal + calculateInterest(); + } +} diff --git a/src/main/java/com/craftbank/util/Amounts.java b/src/main/java/com/craftbank/util/Amounts.java new file mode 100644 index 0000000..5f259c6 --- /dev/null +++ b/src/main/java/com/craftbank/util/Amounts.java @@ -0,0 +1,36 @@ +package com.craftbank.util; + +/** + * 金额解析与校验工具。统一对金额做四舍五入到 2 位小数, 防止浮点误差刷钱。 + */ +public final class Amounts { + + private Amounts() { + } + + /** 将金额规整到 2 位小数 (向下取整到分, 避免凭空多出小数刷钱)。 */ + public static double normalize(double amount) { + return Math.floor(amount * 100.0) / 100.0; + } + + /** + * 解析用户输入的金额字符串。 + * + * @return 规整后的正数金额, 解析失败或非正数返回 -1。 + */ + public static double parse(String input) { + if (input == null || input.isBlank()) { + return -1; + } + double value; + try { + value = Double.parseDouble(input.trim()); + } catch (NumberFormatException ex) { + return -1; + } + if (!Double.isFinite(value) || value <= 0) { + return -1; + } + return normalize(value); + } +} diff --git a/src/main/java/com/craftbank/util/Msg.java b/src/main/java/com/craftbank/util/Msg.java new file mode 100644 index 0000000..201ddc3 --- /dev/null +++ b/src/main/java/com/craftbank/util/Msg.java @@ -0,0 +1,40 @@ +package com.craftbank.util; + +import com.craftbank.CraftBank; +import org.bukkit.command.CommandSender; + +/** + * 消息发送辅助。从 config.yml 的 Messages 段读取文案并自动附加前缀。 + */ +public final class Msg { + + private final CraftBank plugin; + + public Msg(CraftBank plugin) { + this.plugin = plugin; + } + + public String prefix() { + return Text.color(plugin.getConfig().getString("Messages.prefix", "&8[&b工艺银行&8] &r")); + } + + /** 读取 Messages., 缺失时回退到 def。 */ + public String get(String key, String def) { + return Text.color(plugin.getConfig().getString("Messages." + key, def)); + } + + /** 发送一条带前缀的原始 (已含颜色) 文本。 */ + public void send(CommandSender to, String rawColored) { + to.sendMessage(prefix() + rawColored); + } + + /** 发送一条带前缀的、应用 & 颜色代码的文本。 */ + public void raw(CommandSender to, String text) { + to.sendMessage(prefix() + Text.color(text)); + } + + /** 发送 Messages. 的内容 (带前缀)。 */ + public void key(CommandSender to, String key, String def) { + send(to, get(key, def)); + } +} diff --git a/src/main/java/com/craftbank/util/Text.java b/src/main/java/com/craftbank/util/Text.java new file mode 100644 index 0000000..3868f69 --- /dev/null +++ b/src/main/java/com/craftbank/util/Text.java @@ -0,0 +1,37 @@ +package com.craftbank.util; + +import org.bukkit.ChatColor; + +import java.util.ArrayList; +import java.util.List; + +/** + * 文本与颜色代码处理工具。 + */ +public final class Text { + + private Text() { + } + + public static String color(String input) { + if (input == null) { + return ""; + } + return ChatColor.translateAlternateColorCodes('&', input); + } + + public static List color(List input) { + List out = new ArrayList<>(); + if (input == null) { + return out; + } + for (String line : input) { + out.add(color(line)); + } + return out; + } + + public static String strip(String input) { + return input == null ? "" : ChatColor.stripColor(color(input)); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..07bc9b0 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,84 @@ +# ===================================================================== +# CraftBank 配置文件 +# 适用于 Folia 1.21.8 +# ===================================================================== + +# 数据库设置 +Database: + # 可选: SQLITE 或 MYSQL + type: SQLITE + # SQLite 文件名 (位于插件目录内) + sqlite-file: craftbank.db + # MySQL 连接设置 (仅当 type 为 MYSQL 时生效) + mysql: + host: localhost + port: 3306 + database: craftbank + username: root + password: "password" + useSSL: false + pool-size: 10 + +# 经济设置 +Economy_Settings: + # 货币符号 + currency_symbol: "$" + # 货币名称 (单数/复数, 供 Vault 使用) + currency_name_singular: "元" + currency_name_plural: "元" + # 新玩家初始现金 + starting_balance: 1000.0 + # 小数位数 (Vault fractionalDigits) + fractional_digits: 2 + +# 利率设置 (动态可调, 可由 /bankadmin setinterest 修改) +Interest_Rates: + # 活期日利率 (0.1%) + savings: 0.001 + # 各定期期限的日利率 + term_7d: 0.005 + term_15d: 0.0075 + term_30d: 0.01 + +# 银行设置 +Bank_Settings: + # 打开 GUI 是否需要手持银行卡右键方块 + require_card_to_open: true + # 右键哪种方块会触发银行卡打开 GUI + interact_block: IRON_BLOCK + # 是否允许使用 /bank open 直接打开 (无需银行卡) + allow_command_open: false + # 利息结算最大补偿天数 (防止离线过久产生天文数字利息) + max_offline_interest_days: 30 + +# 自定义物品设置 +Items: + # 银行卡 + bank_card: + material: PAPER + custom_model_data: 100001 + name: "&b&l工艺银行 · 储蓄卡" + lore: + - "&7持卡人: &f%owner%" + - "&7右键铁块即可打开银行" + - "&8请妥善保管, 遗失可挂失补办" + # 支票 + cheque: + material: PAPER + custom_model_data: 100002 + name: "&a&l银行支票 &7- &f%amount%" + lore: + - "&7开票人: &f%issuer%" + - "&7金额: &a%amount%" + - "&7签发时间: &f%date%" + - "&8右键手持以兑现到钱包" + +# 消息设置 (支持 & 颜色代码, %prefix% 为前缀占位) +Messages: + prefix: "&8[&b工艺银行&8] &r" + no-permission: "&c你没有权限执行该操作。" + player-only: "&c该指令只能由玩家执行。" + invalid-amount: "&c请输入一个有效的正数金额。" + player-not-found: "&c找不到目标玩家。" + insufficient-funds: "&c余额不足。" + reload-success: "&a配置文件已重载。" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..2cbf0cc --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,40 @@ +name: CraftBank +version: '${version}' +main: com.craftbank.CraftBank +api-version: '1.21' +folia-supported: true +authors: [CraftBank] +description: All-in-one economy and bank core plugin for Folia 1.21.8. +softdepend: [Vault, PlaceholderAPI] +loadbefore: [Vault] + +commands: + money: + description: View cash balance. + usage: /money [player] + aliases: [bal, balance, money] + pay: + description: Pay cash to another player. + usage: /pay + baltop: + description: View the cash leaderboard. + usage: /baltop [page] + cheque: + description: Issue a cheque. + usage: /cheque + aliases: [check] + bank: + description: Open the bank or manage your account. + usage: /bank + bankadmin: + description: CraftBank admin commands. + usage: /bankadmin + aliases: [cba] + +permissions: + craftbank.use: + description: Allows basic bank usage. + default: true + craftbank.admin: + description: Allows administration of CraftBank. + default: op