feat: CraftBank Folia 1.21.8 经济与银行核心插件初始实现

实现完整的现金/钱包、银行卡、支票、活期储蓄与定期存款系统,
实现并以 Highest 优先级注册 Vault Economy 接口,接入 PlaceholderAPI。
全程遵循 Folia 调度模型(AsyncScheduler / 实体区域线程),
数据缓存线程安全,支票兑现与定期领取做防刷处理。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Purpur Build
2026-06-29 18:16:06 +08:00
parent 6a5ee9906b
commit eddc706c9a
36 changed files with 3775 additions and 0 deletions
@@ -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 管理连接池。
*
* <p>所有方法均为阻塞式 JDBC 调用, <b>必须</b> 在 Folia 的 AsyncScheduler
* 线程上调用, 严禁在主区域线程同步调用。</p>
*/
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<Map.Entry<String, Double>> getTopCash(int limit) {
List<Map.Entry<String, Double>> 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<TermDeposit> getTermDeposits(UUID owner, boolean includeClaimed) {
List<TermDeposit> 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<TermDeposit> getMaturedUnclaimed() {
List<TermDeposit> 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);
}
}