双向对账改造:全量/周期同步与PHP指纹对账多退少补,null整数列归一防每轮changed
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s

This commit is contained in:
youpaishidifu
2026-10-05 22:29:50 +08:00
parent 6a2bfaf1ff
commit ec4bd415d2
2 changed files with 671 additions and 6 deletions
@@ -2577,6 +2577,38 @@ public class DatabaseManager {
return list;
}
/**
* 全量服务商名单(含 active=0 的停用记录)。
* 对账必须拿到与 PHP 完全相同的集合:只查 active=1 会让停用记录在 PHP 侧永远是 extra,
* 进而被「整表 DELETE」的推送一条条抹掉。
*/
public List<Map<String, Object>>
getAllServiceProvidersFull() {
List<Map<String, Object>> list =
new ArrayList<>();
try {
Statement st = db.createStatement();
ResultSet rs = st.executeQuery(
"SELECT * FROM service_providers");
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
for (int i = 1; i <= rs.getMetaData()
.getColumnCount(); i++) {
row.put(rs.getMetaData()
.getColumnName(i),
rs.getObject(i));
}
list.add(row);
}
rs.close();
st.close();
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
// ==================== 领地方块/容器记录 ====================
/** 记录领地方块破坏/放置 */
+639 -6
View File
@@ -3677,6 +3677,536 @@ public class WebManager {
return item;
}
// ==================== 双向对账:多退少补(2026-10-05) ====================
//
// 改造前:全量同步每一轮都把各类数据整包推给 PHP;内存里的 lastXxxHash 一重启就失忆,
// 于是「开服 12 项全推、每轮周期同步再推一遍」,PHP 端还叠着整表 DELETE 覆盖。
// 改造后:每类数据先与 PHP「点一遍」——两边各算 key + 内容指纹,
// missing = Java 有 PHP 无 → Java 补推
// changed = 两边都有但内容不同 → 按「上次交换后谁改过」决定推还是收
// extra = PHP 有 Java 无 → Java 收回来(pull 类别)
// 三个差集全空 ⇒ 一条数据都不发。
//
// ★ 指纹算法必须与 PHP 的 alignmentRowHash() 逐字一致:
// 按 hashcols 顺序取值 → 整数列统一成十进制字符串、绝对值超过 1e12 的时间戳一律折成秒
// → 用 chr(31)(ASCII 31 分隔符)连接 → md5
// (折秒这一条是关键:Java 存毫秒、PHP 存秒,不折就永远对不上,变成每轮都在推。)
//
// ★ 「谁改过」的判据 alignSeen(上次交换后已知一致的指纹)**必须落盘**:
// 放内存里一重启就全丢,首轮会把所有 changed 误判成「Java 改过」,
// 于是把 PHP 端刚改的密码 / 积分 / 余额反过来覆盖掉。
/** 单个类别的对账配置(与 PHP alignmentCatConfig() 严格对应) */
private static final class AlignCfg {
final String cat;
final String[] keycols; // 主键列(DB 列名)
final String[] hashcols; // 参与指纹的列(DB 列名,顺序即优先级)
final String[] intcols; // 其中的整数列(要折秒/十进制化)
final Map<String, String> map; // DB 列名 -> Java 侧字段名
final boolean pull; // PHP 侧的行可否写回本地
final boolean pushOnExtra; // extra 是否也触发推送(PHP 侧「整表重建」的类别必须开)
AlignCfg(String cat, String[] keycols, String[] hashcols, String[] intcols,
Map<String, String> map, boolean pull, boolean pushOnExtra) {
this.cat = cat;
this.keycols = keycols;
this.hashcols = hashcols;
this.intcols = intcols;
this.map = map;
this.pull = pull;
this.pushOnExtra = pushOnExtra;
}
/** DB 列名 -> Java 行里的字段名(没映射就原样) */
String jf(String dbCol) {
String m = map.get(dbCol);
return (m == null || m.isEmpty()) ? dbCol : m;
}
boolean isIntCol(String dbCol) {
for (String c : intcols) if (c.equals(dbCol)) return true;
return false;
}
}
private static Map<String, String> alignMap(String... kv) {
Map<String, String> m = new LinkedHashMap<>();
for (int i = 0; i + 1 < kv.length; i += 2) m.put(kv[i], kv[i + 1]);
return m;
}
private static final Map<String, AlignCfg> ALIGN_CFGS = new LinkedHashMap<>();
static {
// —— 注册用户(PHP users)——
ALIGN_CFGS.put("users", new AlignCfg("users",
new String[]{"player_name"},
new String[]{"player_name", "register_time", "last_login_time", "email",
"points", "gift_stage", "total_online_time"},
new String[]{"register_time", "last_login_time", "points", "gift_stage", "total_online_time"},
alignMap(), true, false));
// —— 密码凭证(PHP weblogin_credentials,列名与 Java users 表不同)——
ALIGN_CFGS.put("credentials", new AlignCfg("credentials",
new String[]{"player_name"},
new String[]{"player_name", "password_hash", "salt", "temp_password_hash", "temp_pw_expire"},
new String[]{"temp_pw_expire"},
alignMap("salt", "password_salt", "temp_password_hash", "temp_password"), true, false));
// —— 商城商品:库存/销量由 Web 端独立变动,绝不进指纹(否则每轮都判「变了」)——
ALIGN_CFGS.put("shop", new AlignCfg("shop",
new String[]{"id"},
new String[]{"id", "category", "display_name", "material", "buy_price", "sell_price"},
new String[]{"buy_price", "sell_price"},
alignMap(), false, false));
// —— 债券余额 ——
ALIGN_CFGS.put("bonds", new AlignCfg("bonds",
new String[]{"player_name"},
new String[]{"player_name", "amount"},
new String[]{"amount"},
alignMap(), true, false));
// —— 玩家 IP(PHP player_ip_changes,列名叫 new_ip,Java 侧叫 ip)——
ALIGN_CFGS.put("ips", new AlignCfg("ips",
new String[]{"player_name"},
new String[]{"player_name", "new_ip"},
new String[]{},
alignMap("player_name", "name", "new_ip", "ip"), true, false));
// —— 服务商(Java 权威名单,PHP 多出来的先留着不覆盖本地)——
ALIGN_CFGS.put("providers", new AlignCfg("providers",
new String[]{"player_name"},
new String[]{"player_name", "role", "active", "join_time"},
new String[]{"active", "join_time"},
alignMap(), false, false));
// —— 插件管理员(PHP 端已改为只增改不删,PHP 多出的多为离线管理员,保留)——
ALIGN_CFGS.put("admins", new AlignCfg("admins",
new String[]{"player_name"},
new String[]{"player_name"},
new String[]{},
alignMap(), false, false));
// —— 封禁名单(Web 后台加的封禁要拉回游戏内生效)——
ALIGN_CFGS.put("bans", new AlignCfg("bans",
new String[]{"target", "ban_type"},
new String[]{"target", "ban_type", "reason", "source", "expire_time"},
new String[]{"expire_time"},
alignMap("ban_type", "type", "expire_time", "expire"), true, false));
// —— 在线玩家:PHP 端是「整表重建」,PHP 比 Java 多的行(人已下线)必须靠一次推送清掉
// 所以 extra 也要触发推送;指纹只比「谁在线」,login_time 每次都变不能进指纹 ——
ALIGN_CFGS.put("online", new AlignCfg("online",
new String[]{"player_name"},
new String[]{"player_name"},
new String[]{},
alignMap(), false, true));
}
/** 上次交换后已知「两边一致」的指纹:cat -> (key -> hash)。落盘,重启不丢。 */
private final Map<String, Map<String, String>> alignSeen = new ConcurrentHashMap<>();
private volatile boolean alignSeenLoaded = false;
private volatile long alignSeenDirtyAt = 0;
private volatile long alignSeenLastWrite = 0;
private File alignSeenFile() {
return new File(plugin.getDataFolder(), "align_seen.json");
}
/** 落盘读取(只读一次;文件损坏一律当作「首轮」,宁可多推一次也不崩) */
private void loadAlignSeen() {
if (alignSeenLoaded) return;
alignSeenLoaded = true;
try {
File f = alignSeenFile();
if (!f.exists()) return;
String txt = new String(java.nio.file.Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8);
Map<String, Map<String, String>> loaded = new Gson().fromJson(txt,
new com.google.gson.reflect.TypeToken<Map<String, Map<String, String>>>() {}.getType());
if (loaded != null) {
for (Map.Entry<String, Map<String, String>> e : loaded.entrySet()) {
if (e.getValue() != null) alignSeen.put(e.getKey(), new ConcurrentHashMap<>(e.getValue()));
}
int n = 0;
for (Map<String, String> m : alignSeen.values()) n += m.size();
plugin.getLogger().info("[对账] 已载入上次一致指纹 " + alignSeen.size() + " 类 / " + n + " 条");
}
} catch (Throwable t) {
plugin.getLogger().warning("[对账] 载入 align_seen.json 失败(按首轮处理): " + t.getMessage());
}
}
/** 落盘写入(合并 5 秒内的连续变更,避免每轮对账都刷盘) */
private void saveAlignSeen(boolean force) {
long now = System.currentTimeMillis();
if (!force) {
if (alignSeenDirtyAt == 0 || now - alignSeenDirtyAt < 5000) return;
if (now - alignSeenLastWrite < 5000) return;
}
alignSeenLastWrite = now;
alignSeenDirtyAt = 0;
try {
Map<String, Map<String, String>> snapshot = new LinkedHashMap<>();
for (Map.Entry<String, Map<String, String>> e : alignSeen.entrySet()) {
snapshot.put(e.getKey(), new LinkedHashMap<>(e.getValue()));
}
File f = alignSeenFile();
File parent = f.getParentFile();
if (parent != null && !parent.exists()) parent.mkdirs();
java.nio.file.Files.write(f.toPath(),
new Gson().toJson(snapshot).getBytes(StandardCharsets.UTF_8));
} catch (Throwable t) {
plugin.getLogger().warning("[对账] 写 align_seen.json 失败: " + t.getMessage());
}
}
/** 主键串:按 keycols 顺序用 | 连接(PHP alignmentKeyOf 同款:任一段为空即整行作废) */
private static String alignKeyOf(AlignCfg cfg, Map<String, Object> row) {
StringBuilder sb = new StringBuilder();
for (String c : cfg.keycols) {
Object v = row.get(cfg.jf(c));
String sv = v == null ? "" : String.valueOf(v);
if (sv.isEmpty()) return "";
if (sb.length() > 0) sb.append('|');
sb.append(sv);
}
return sb.toString();
}
/**
* 规范化单个值(PHP alignmentCanonVal 同款):
* 整数列统一十进制字符串,绝对值超过 1e12 的按毫秒时间戳折成秒。
* 解析不出来时返回 "0"——PHP 的 (int)"abc" 也是 0,两端必须一致。
*/
private static String alignCanonVal(Object v, boolean isInt) {
// ★ null + 整数列归一为 "0":与 PHP alignmentCanonVal 严格一致——
// 本地行缺 key、PHP 库 NULL、PHP 库 0 三种形态必须归一,否则每轮判 changed
if (v == null) return isInt ? "0" : "";
if (!isInt) return String.valueOf(v);
long n;
if (v instanceof Number) {
n = ((Number) v).longValue();
} else {
String t = String.valueOf(v).trim();
try {
n = Long.parseLong(t);
} catch (NumberFormatException e1) {
try {
n = (long) Double.parseDouble(t);
} catch (NumberFormatException e2) {
return "0";
}
}
}
if (n > 1000000000000L || n < -1000000000000L) n = n / 1000;
return String.valueOf(n);
}
/** 行内容指纹(PHP alignmentRowHash 同款:chr(31) 连接后 md5) */
private static String alignRowHash(AlignCfg cfg, Map<String, Object> row) {
StringBuilder sb = new StringBuilder();
for (String c : cfg.hashcols) {
if (sb.length() > 0) sb.append((char) 31);
sb.append(alignCanonVal(row.get(cfg.jf(c)), cfg.isIntCol(c)));
}
try {
byte[] digest = java.security.MessageDigest.getInstance("MD5").digest(
sb.toString().getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(digest.length * 2);
for (byte b : digest) {
hex.append(Character.forDigit((b >> 4) & 0xF, 16));
hex.append(Character.forDigit(b & 0xF, 16));
}
return hex.toString();
} catch (Exception e) {
return "";
}
}
/** 一次对账的结论 */
private static final class AlignResult {
boolean ok; // PHP 是否给了有效答复
final List<String> missing = new ArrayList<>();
final List<String> changed = new ArrayList<>();
final List<String> extra = new ArrayList<>();
final Map<String, Map<String, Object>> rows = new LinkedHashMap<>(); // PHP 侧行(字段名已是 Java 叫法)
final Set<String> pushKeys = new LinkedHashSet<>();
final Set<String> pullKeys = new LinkedHashSet<>();
int phpCount;
int pulled; // 实际写回本地的条数
/** 是否有需要 Java 补推的数据 */
boolean needPush() { return !pushKeys.isEmpty(); }
}
/**
* 调 PHP 的 check_alignment 拿三个差集。
* 返回 null 表示「对账没做成」(PHP 无答复/报错)——调用方按原有逻辑继续,绝不因此停摆。
*/
private AlignResult callCheckAlignment(AlignCfg cfg, Map<String, String> javaHash) {
try {
List<Map<String, Object>> records = new ArrayList<>();
for (Map.Entry<String, String> e : javaHash.entrySet()) {
Map<String, Object> r = new LinkedHashMap<>();
r.put("k", e.getKey());
r.put("h", e.getValue());
records.add(r);
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("cat", cfg.cat);
body.put("records", records);
body.put("secret", secretKey);
String token = generateAndSyncToken("system", "sync");
String url = webBaseUrl + "/api/sync.php?action=check_alignment&token="
+ java.net.URLEncoder.encode(token, "UTF-8");
String resp = doPostOnce(url, mapToJson(body));
if (resp == null) return null;
Map<String, Object> res = parseJson(resp);
if (!Boolean.TRUE.equals(res.get("success"))) {
String msg = res.get("message") != null ? String.valueOf(res.get("message")) : resp;
plugin.getLogger().warning("[对账] " + cfg.cat + " PHP 拒绝对账: "
+ (msg.length() > 200 ? msg.substring(0, 200) : msg));
return null;
}
Object dataObj = res.get("data");
if (!(dataObj instanceof Map)) return null;
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) dataObj;
AlignResult ar = new AlignResult();
ar.ok = true;
ar.missing.addAll(asStringList(data.get("missing")));
ar.changed.addAll(asStringList(data.get("changed")));
ar.extra.addAll(asStringList(data.get("extra")));
Object pc = data.get("php_count");
if (pc instanceof Number) ar.phpCount = ((Number) pc).intValue();
Object rowsObj = data.get("rows");
if (rowsObj instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> rawRows = (Map<String, Object>) rowsObj;
for (Map.Entry<String, Object> e : rawRows.entrySet()) {
if (e.getValue() instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> one = (Map<String, Object>) e.getValue();
ar.rows.put(e.getKey(), one);
}
}
}
return ar;
} catch (Throwable t) {
plugin.getLogger().warning("[对账] " + cfg.cat + " 对账异常: " + t.getMessage());
return null;
}
}
@SuppressWarnings("unchecked")
private static List<String> asStringList(Object v) {
List<String> out = new ArrayList<>();
if (v instanceof List) {
for (Object o : (List<Object>) v) if (o != null) out.add(String.valueOf(o));
}
return out;
}
/**
* 对账网关:拿本地全量数据与 PHP 点一遍,裁决出「该补推的」和「该拉回的」,并把该拉回的写进本地。
*
* @param cat 类别(必须在 ALIGN_CFGS 里)
* @param localRows 本地全量行(字段名用 Java 侧叫法)
* @return 对账结论;返回 null 表示对账没做成(PHP 无答复),调用方按原逻辑继续
*/
private AlignResult alignGate(String cat, List<Map<String, Object>> localRows) {
AlignCfg cfg = ALIGN_CFGS.get(cat);
if (cfg == null) return null;
loadAlignSeen();
// 本地全量指纹
Map<String, String> javaHash = new LinkedHashMap<>();
for (Map<String, Object> row : localRows) {
String k = alignKeyOf(cfg, row);
if (k.isEmpty()) continue;
String h = alignRowHash(cfg, row);
if (h.isEmpty()) continue;
javaHash.put(k, h);
}
AlignResult ar = callCheckAlignment(cfg, javaHash);
if (ar == null || !ar.ok) return null;
Map<String, String> seen = alignSeen.get(cat);
if (seen == null) {
seen = new ConcurrentHashMap<>();
alignSeen.put(cat, seen);
}
// —— 三个差集 → 推 / 收 ——
for (String k : ar.missing) {
if (javaHash.containsKey(k)) ar.pushKeys.add(k); // Java 有 PHP 无 → 补推
}
for (String k : ar.changed) {
String jh = javaHash.get(k);
if (jh == null) continue;
String last = seen.get(k);
if (last == null || !last.equals(jh)) {
ar.pushKeys.add(k); // 交换之后 Java 改过 → 推
} else if (cfg.pull) {
ar.pullKeys.add(k); // 只有 PHP 改过 → 收
}
// pull=false 的类别(PHP 改的):既不推也不收,保持 Java 权威
}
for (String k : ar.extra) {
if (cfg.pull) {
ar.pullKeys.add(k); // PHP 多的 → 收回来
} else if (cfg.pushOnExtra) {
ar.pushKeys.add(k); // PHP 整表重建类:要推一次才能清掉
}
}
// —— 拉回写本地 ——
for (String k : ar.pullKeys) {
Map<String, Object> row = ar.rows.get(k);
if (row == null) continue;
if (applyPulledRow(cat, row)) {
ar.pulled++;
// 收完两边就一致了:记 PHP 的指纹,下轮不再判「PHP 改过」
seen.put(k, alignRowHash(cfg, row));
}
}
// —— 完全一致的 key:把「已知一致」的指纹记下来(推/收没成功的那些绝不动,
// 否则下一轮会判成「Java 改过」而漏推,或者判成「PHP 改过」而漏收)——
boolean touched = false;
for (Map.Entry<String, String> e : javaHash.entrySet()) {
String k = e.getKey();
if (ar.missing.contains(k) || ar.changed.contains(k) || ar.extra.contains(k)) continue;
if (!e.getValue().equals(seen.get(k))) {
seen.put(k, e.getValue());
touched = true;
}
}
// 上一轮成功推过的 key,这一轮会出现在「一致」里,指纹自然就更新了
if (touched || ar.pulled > 0) {
alignSeenDirtyAt = System.currentTimeMillis();
saveAlignSeen(false);
}
if (!ar.pushKeys.isEmpty() || !ar.pullKeys.isEmpty()) {
plugin.getLogger().info("[对账] " + cat + ": PHP共" + ar.phpCount
+ " 本地" + javaHash.size()
+ " → 补推" + ar.pushKeys.size()
+ " 收回" + ar.pullKeys.size()
+ "(实际写入本地 " + ar.pulled + " 条)");
}
return ar;
}
/**
* 把 PHP 侧的行写回本地(pull=1 的类别)。
* @return 是否真的写进去了
*/
private boolean applyPulledRow(String cat, Map<String, Object> row) {
try {
switch (cat) {
case "users": {
DatabaseManager dbMgr = plugin.getDb();
if (dbMgr == null) return false;
String name = strOf(row.get("player_name"));
if (name.isEmpty()) return false;
if (!dbMgr.userExists(name)) {
// PHP 端(网页)注册的新用户:先建骨架,密码由 credentials 类别拉回补上
dbMgr.createUser(name, "", "");
}
dbMgr.setField(name, "register_time", longOf(row.get("register_time")));
dbMgr.setField(name, "last_login_time", longOf(row.get("last_login_time")));
dbMgr.setField(name, "email", strOf(row.get("email")));
dbMgr.setField(name, "points", intOf(row.get("points")));
dbMgr.setField(name, "gift_stage", intOf(row.get("gift_stage")));
dbMgr.setField(name, "total_online_time", intOf(row.get("total_online_time")));
return true;
}
case "credentials": {
DatabaseManager dbMgr = plugin.getDb();
if (dbMgr == null) return false;
String name = strOf(row.get("player_name"));
String hash = strOf(row.get("password_hash"));
String salt = strOf(row.get("password_salt"));
if (name.isEmpty() || hash.isEmpty() || salt.isEmpty()) return false;
if (!dbMgr.userExists(name)) dbMgr.createUser(name, hash, salt);
else dbMgr.updatePassword(name, hash, salt);
String tmpHash = strOf(row.get("temp_password"));
long tmpExpire = longOf(row.get("temp_pw_expire"));
if (!tmpHash.isEmpty() && tmpExpire > 0) {
dbMgr.setField(name, "temp_password", tmpHash);
dbMgr.setField(name, "temp_pw_expire", tmpExpire);
} else {
dbMgr.setField(name, "temp_password", "");
dbMgr.setField(name, "temp_pw_expire", 0L);
}
return true;
}
case "bonds": {
BondManager bondMgr = plugin.getBonds();
if (bondMgr == null) return false;
String name = strOf(row.get("player_name"));
if (name.isEmpty()) return false;
int amount = intOf(row.get("amount"));
if (bondMgr.getBonds(name) == amount) return false; // 没变就别动,避免无谓写库
bondMgr.setBonds(name, amount);
return true;
}
case "ips": {
DatabaseManager dbMgr = plugin.getDb();
if (dbMgr == null) return false;
String name = strOf(row.get("name"));
String ip = strOf(row.get("ip"));
if (name.isEmpty() || ip.isEmpty()) return false;
if (!dbMgr.userExists(name)) return false; // 用户还没拉回来,等 users 类别处理
dbMgr.setField(name, "ip_address", ip);
return true;
}
case "bans": {
String target = strOf(row.get("target"));
if (target.isEmpty()) return false;
boolean isIp = "ip".equalsIgnoreCase(strOf(row.get("type")));
org.bukkit.BanList.Type bt = isIp ? org.bukkit.BanList.Type.IP : org.bukkit.BanList.Type.NAME;
org.bukkit.BanList<org.bukkit.BanEntry<?>> banList = Bukkit.getBanList(bt);
if (banList.isBanned(target)) return false; // 本地已有,不重复封
String reason = strOf(row.get("reason"));
if (reason.isEmpty()) reason = "Web后台封禁";
String source = strOf(row.get("source"));
if (source.isEmpty()) source = "Web";
long expire = longOf(row.get("expire"));
// 真实签名:addBan(target, reason, expires, source)
// Date 必须全限定——本文件同时 import 了 java.sql.* 与 java.util.*
banList.addBan(target, reason,
expire > 0 ? new java.util.Date(expire) : null, source);
plugin.getLogger().info("[对账] 已收回 Web 端封禁: " + (isIp ? "IP " : "") + target);
return true;
}
default:
return false; // shop/providers/admins/online 等 pull=false 的类别不会走到这里
}
} catch (Throwable t) {
plugin.getLogger().warning("[对账] 写回本地失败 cat=" + cat + ": " + t.getMessage());
return false;
}
}
/** 把 Bukkit 的一条封禁转成对账行(字段名必须与 PHP 侧 map 之后的叫法一致) */
private static Map<String, Object> banRowOf(org.bukkit.BanEntry<?> entry, String type) {
Map<String, Object> r = new LinkedHashMap<>();
r.put("target", entry.getTarget());
r.put("type", type);
r.put("reason", entry.getReason() != null ? entry.getReason() : "");
r.put("source", entry.getSource() != null ? entry.getSource() : "");
r.put("expire", entry.getExpiration() != null ? entry.getExpiration().getTime() : 0L);
return r;
}
private static String strOf(Object v) { return v == null ? "" : String.valueOf(v); }
private static int intOf(Object v) {
if (v instanceof Number) return ((Number) v).intValue();
try { return Integer.parseInt(String.valueOf(v).trim()); } catch (Exception e) { return 0; }
}
private static long longOf(Object v) {
if (v instanceof Number) return ((Number) v).longValue();
try { return Long.parseLong(String.valueOf(v).trim()); } catch (Exception e) { return 0; }
}
// ==================== 业务功能 ====================
/**
@@ -3703,6 +4233,12 @@ public class WebManager {
items.addAll(catItems);
}
// ★ 双向对账(多退少补):商品目录与 PHP 一致就不发。
// 指纹刻意不含库存/销量(Web 端会独立改动,含了就每轮都判「变了」);
// PHP 端管理员改的价格也不触发推送——pull=false,只有 Java 改过才推。
AlignResult arAlign = alignGate("shop", items);
if (arAlign != null && !arAlign.needPush()) return;
if (items.isEmpty()) return;
// ★ 无变化静默:对比商品数量和内容hash
@@ -3739,7 +4275,8 @@ public class WebManager {
try {
String token = generateAndSyncToken("system", "sync");
List<Map<String, Object>> providers = new ArrayList<>();
for (Map<String, Object> sp : plugin.getDb().getAllServiceProviders()) {
// ★ 用全量(含 active=0):只取启用中的会让停用服务商在 PHP 侧永远是 extra
for (Map<String, Object> sp : plugin.getDb().getAllServiceProvidersFull()) {
Map<String, Object> p = new LinkedHashMap<>();
p.put("player_name", sp.get("player_name"));
p.put("role", sp.get("role"));
@@ -3748,6 +4285,11 @@ public class WebManager {
providers.add(p);
}
// ★ 双向对账(多退少补):名单与 PHP 一致就不发。
// pull=false —— PHP 多出来的先留着,不覆盖游戏内权威名单
AlignResult arAlign = alignGate("providers", providers);
if (arAlign != null && !arAlign.needPush()) return;
if (providers.isEmpty()) return;
// ★ 无变化静默
@@ -4244,6 +4786,16 @@ public class WebManager {
}
}
// ★ 双向对账(多退少补):管理员名单与 PHP 一致就不发
List<Map<String, Object>> adminRows = new ArrayList<>();
for (String nm0 : adminNames) {
Map<String, Object> r0 = new LinkedHashMap<>();
r0.put("player_name", nm0);
adminRows.add(r0);
}
AlignResult arAlign = alignGate("admins", adminRows);
if (arAlign != null && !arAlign.needPush()) return;
String currentHash = adminNames.stream().sorted().collect(Collectors.joining("|"));
if (currentHash.equals(lastAdminsHash)) {
plugin.getLogger().fine("[防护-sync] 管理员列表无变化,跳过");
@@ -4324,6 +4876,19 @@ public class WebManager {
hashBuilder.append("i:").append(target).append(":").append(reason).append(":").append(expireDate).append("|");
}
// ★ 双向对账(多退少补):封禁名单与 PHP 一致就不发;
// Web 后台加的封禁(PHP 多的)拉回游戏内生效
List<Map<String, Object>> banRows = new ArrayList<>();
@SuppressWarnings("unchecked")
Set<org.bukkit.BanEntry<?>> nb0 = (Set<org.bukkit.BanEntry<?>>) (Set<?>) nameBanList.getEntries();
for (org.bukkit.BanEntry<?> e0 : nb0) banRows.add(banRowOf(e0, "name"));
@SuppressWarnings("unchecked")
Set<org.bukkit.BanEntry<?>> ib0 = (Set<org.bukkit.BanEntry<?>>) (Set<?>) ipBanList.getEntries();
for (org.bukkit.BanEntry<?> e0 : ib0) banRows.add(banRowOf(e0, "ip"));
AlignResult arAlign = alignGate("bans", banRows);
if (arAlign != null && !arAlign.needPush()) return; // 零发送
if (arAlign != null && arAlign.pulled > 0) return; // 本轮只收不发
sb.append("]");
String currentHash = hashBuilder.toString();
if (currentHash.equals(lastBansHash)) {
@@ -5398,13 +5963,28 @@ public class WebManager {
if (bondMgr == null) return;
List<String> allPlayers = bondMgr.getAllPlayerNames();
if (allPlayers.isEmpty()) return;
Map<String, Object> bonds = new LinkedHashMap<>();
for (String name : allPlayers) {
bonds.put(name, bondMgr.getBonds(name));
}
// ★ 双向对账(多退少补):余额与 PHP 一致就不发;
// 网页/CDK/后台改过的余额先拉回本地
List<Map<String, Object>> bondRows = new ArrayList<>();
for (Map.Entry<String, Object> be : bonds.entrySet()) {
Map<String, Object> r0 = new LinkedHashMap<>();
r0.put("player_name", be.getKey());
r0.put("amount", be.getValue());
bondRows.add(r0);
}
AlignResult arAlign = alignGate("bonds", bondRows);
if (arAlign != null && !arAlign.needPush()) return;
if (arAlign != null && arAlign.pulled > 0) {
// 本轮只收不发:避免拿对账前的旧余额把刚收回来的余额推回去
return;
}
// ★ 无变化静默:对比债券数据hash
String currentHash = bonds.size() + ":" + bonds.hashCode();
if (currentHash.equals(lastBondBalanceHash)) return; // 无变化,跳过
@@ -6056,7 +6636,7 @@ public class WebManager {
if (dbMgr == null) return;
List<Map<String, Object>> users = dbMgr.getAllUsers();
if (users.isEmpty()) return;
// (不再「空列表直接 return」:Java 空、PHP 有数据时也要走对账把数据收回来)
List<Map<String, Object>> syncData = new ArrayList<>();
for (Map<String, Object> user : users) {
@@ -6071,6 +6651,17 @@ public class WebManager {
syncData.add(u);
}
// ★ 双向对账(多退少补):先与 PHP 点一遍,三差集全空就一条都不发
AlignResult arAlign = alignGate("users", syncData);
if (arAlign != null && !arAlign.needPush()) {
return; // 两边一致 / 只做了拉回 → 零发送
}
if (arAlign != null && arAlign.pulled > 0) {
// 本轮只收不发:本地刚被 PHP 的数据改过,若继续推送就会用
// 对账前的旧值把它覆盖回去。下一轮(60~90 秒后)自然会补齐缺口。
return;
}
// ★ 无变化静默:对比MD5 hash,避免无效网络请求
String currentHash = syncData.toString().hashCode() + "_" + syncData.size();
if (currentHash.equals(lastUserRegistrationHash)) return; // 无变化,跳过
@@ -6545,6 +7136,17 @@ public class WebManager {
playersData.add(playerInfo);
}
// ★ 双向对账(多退少补):在线名单与 PHP 完全一致就一条都不发。
// 指纹只比「谁在线」——login_time 每次都变,进指纹就永远对不上。
List<Map<String, Object>> onlineRows = new ArrayList<>();
for (Map<String, Object> p0 : playersData) {
Map<String, Object> r0 = new LinkedHashMap<>();
r0.put("player_name", p0.get("name"));
onlineRows.add(r0);
}
AlignResult arAlign = alignGate("online", onlineRows);
if (arAlign != null && !arAlign.needPush()) return; // 零发送
String playersJson = buildPlayersJsonArray(playersData);
// ★ 无变化静默:仅在在线人数或玩家列表变化时才打印日志
String currentHash = playersData.size() + ":" + loggedInPlayers;
@@ -6740,6 +7342,29 @@ public class WebManager {
List<Map<String, Object>> allUsers = dbMgr.getAllUsers();
if (allUsers.isEmpty()) return;
// ★ 双向对账(多退少补):IP 清单与 PHP 一致就一条都不发
List<Map<String, Object>> ipRows = new ArrayList<>();
for (Map<String, Object> row0 : allUsers) {
String nm0 = (String) row0.get("player_name");
if (nm0 == null || nm0.isEmpty()) continue;
Object ipObj0 = dbMgr.getField(nm0, "ip_address");
String ip0 = ipObj0 != null ? String.valueOf(ipObj0) : null;
if (ip0 == null || ip0.isEmpty()) ip0 = (String) row0.get("register_ip");
if (ip0 == null || ip0.isEmpty()) continue;
Map<String, Object> r0 = new LinkedHashMap<>();
r0.put("name", nm0);
r0.put("ip", ip0);
ipRows.add(r0);
}
AlignResult arAlign = alignGate("ips", ipRows);
if (arAlign != null && !arAlign.needPush()) return;
if (arAlign != null && arAlign.pulled > 0) return; // 本轮只收不发
// 有缺口 → 本轮推全量(跳过 ipCache 增量过滤);needSync 不能重新赋值,
// 它被后面的匿名内部类引用,必须保持 effectively final
final boolean forceFullIpPush = (arAlign != null && arAlign.needPush());
// 收集需要同步的玩家
List<Map<String, Object>> needSync = new ArrayList<>();
ConcurrentHashMap<String, String> newSnapshot = new ConcurrentHashMap<>();
@@ -6765,8 +7390,8 @@ public class WebManager {
if (currentIp.isEmpty()) continue;
String[] cached = ipCache.get(name);
if (cached != null && "0".equals(cached[1]) && currentIp.equals(cached[0])) {
// IP已同步且未变化,跳过
if (!forceFullIpPush && cached != null && "0".equals(cached[1]) && currentIp.equals(cached[0])) {
// IP已同步且未变化,跳过(对账发现缺口时不吃这个缓存,直接全量推)
continue;
}
@@ -6851,7 +7476,6 @@ public class WebManager {
if (dbMgr == null) return;
List<Map<String, Object>> allUsers = dbMgr.getAllUsers();
if (allUsers.isEmpty()) return;
List<Map<String, Object>> credentials = new ArrayList<>();
for (Map<String, Object> user : allUsers) {
@@ -6876,6 +7500,15 @@ public class WebManager {
}
}
// ★ 双向对账(多退少补):凭证与 PHP 一致就不发;
// PHP 端改过密码(网页改密/重置)时把新凭证收回来
AlignResult arAlign = alignGate("credentials", credentials);
if (arAlign != null && !arAlign.needPush()) return;
if (arAlign != null && arAlign.pulled > 0) {
// 本轮只收不发,防止用对账前的旧密码把刚收回来的新密码覆盖掉
return;
}
if (credentials.isEmpty()) return;
// ★ 无变化静默