koishi-plugin-hhs-huatu 1.3.13 → 1.3.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/index.js +105 -350
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -1352,7 +1352,6 @@ var Config = import_koishi2.Schema.intersect([
1352
1352
  ]).description("点数模式").default("periodic"),
1353
1353
  pointsDefault: import_koishi2.Schema.number().description("会员默认初始点数").default(200),
1354
1354
  pointsRefreshCycleDays: import_koishi2.Schema.number().description("刷新周期(天数),默认每30天刷新一次").default(30).min(1).max(365),
1355
- pointsRefreshCheckTime: import_koishi2.Schema.string().description("每天检查并执行刷新的时刻(HH:MM,24小时制,本地时区)。实际刷新发生在「周期到期后的第一个该时刻」。").default("00:05"),
1356
1355
  pointsRefreshAmount: import_koishi2.Schema.number().description("每次刷新恢复的点数").default(200),
1357
1356
  pointsRefreshIncludeNonMember: import_koishi2.Schema.boolean().description("刷新范围是否包括非会员").default(false)
1358
1357
  }).description("点数控制设置"),
@@ -1934,7 +1933,6 @@ __name(formatOpusQuota, "formatOpusQuota");
1934
1933
  // src/membershipSystem.ts
1935
1934
  var MIN_TIER = 1;
1936
1935
  var MAX_TIER = 5;
1937
- var POINTS_REFRESH_TOLERANCE = 1e3;
1938
1936
  var MembershipSystem = class {
1939
1937
  constructor(ctx, config) {
1940
1938
  this.ctx = ctx;
@@ -1944,15 +1942,13 @@ var MembershipSystem = class {
1944
1942
  this.loadUserDataFromDB();
1945
1943
  this.setupCleanupTask();
1946
1944
  this.setupReminderTask();
1947
- this.setupPointsRefreshTask();
1948
- ctx.accept(["membershipEnabled", "memberCleanupEnabled", "memberCleanupTime", "memberExpiryReminderEnabled", "memberReminderTime", "memberReminderHours", "pointsEnabled", "pointsMode", "pointsRefreshCycleDays", "pointsRefreshCheckTime"], () => {
1945
+ ctx.accept(["membershipEnabled", "memberCleanupEnabled", "memberCleanupTime", "memberExpiryReminderEnabled", "memberReminderTime", "memberReminderHours"], () => {
1949
1946
  ctx.logger.info("会员系统配置已更新,重新安排定时任务");
1950
1947
  if (this.config.membershipEnabled && Object.keys(this.userData).length === 0) {
1951
1948
  this.loadUserDataFromDB().catch((err) => this.ctx.logger.error("动态加载用户数据失败", err));
1952
1949
  }
1953
1950
  this.setupCleanupTask();
1954
1951
  this.setupReminderTask();
1955
- this.setupPointsRefreshTask();
1956
1952
  });
1957
1953
  }
1958
1954
  static {
@@ -1965,14 +1961,9 @@ var MembershipSystem = class {
1965
1961
  // 定时任务取消函数
1966
1962
  cleanupTimerDispose = null;
1967
1963
  reminderTimerDispose = null;
1968
- pointsRefreshTimerDispose = null;
1969
- // 点数刷新的周期锚点:上次刷新**计划**的检查点(永远对齐到每日检查时刻);0 表示尚未初始化。
1970
- // 周期 = 锚点 + pointsRefreshCycleDays,因此周期天然对齐检查点、不会因手动刷新或补刷而漂移。
1971
- pointsRefreshAnchor = 0;
1972
- // 上次点数刷新的**实际**执行时刻(事实记录):手动刷新、停机补刷时可能与锚点不同。
1973
- pointsRefreshLastActualAt = 0;
1974
- // 库中记录仍是旧语义(lastUsed = 实际刷新时刻),加载后需要回写一次
1975
- pointsRefreshStateNeedsMigration = false;
1964
+ // 上一版本的全局刷新时间,仅用于迁移旧数据
1965
+ legacyPointsRefreshTime = 0;
1966
+ pointsRefreshLocks = /* @__PURE__ */ new Map();
1976
1967
  // 确保导入文件夹存在
1977
1968
  ensureImportDir() {
1978
1969
  try {
@@ -2000,6 +1991,8 @@ var MembershipSystem = class {
2000
1991
  dailyLimit: "unsigned",
2001
1992
  lastDrawTime: "unsigned",
2002
1993
  points: "integer",
1994
+ pointsRefreshAt: "unsigned",
1995
+ pointsLastRefreshedAt: "unsigned",
2003
1996
  nai5DailyUsage: "unsigned",
2004
1997
  nai5Bucket: "unsigned",
2005
1998
  nai5BucketDay: "unsigned",
@@ -2030,10 +2023,7 @@ var MembershipSystem = class {
2030
2023
  let loadedCount = 0;
2031
2024
  for (const row of rows) {
2032
2025
  if (row.visitorId === "_system_points_refresh") {
2033
- const actualAt = row.lastUsed || 0;
2034
- this.pointsRefreshAnchor = actualAt > 0 ? this.alignToCheck(actualAt - POINTS_REFRESH_TOLERANCE) : 0;
2035
- this.pointsRefreshLastActualAt = row.lastDrawTime || actualAt || 0;
2036
- this.pointsRefreshStateNeedsMigration = (row.lastUsed || 0) !== this.pointsRefreshAnchor || (row.lastDrawTime || 0) !== this.pointsRefreshLastActualAt;
2026
+ this.legacyPointsRefreshTime = row.lastUsed || 0;
2037
2027
  continue;
2038
2028
  }
2039
2029
  loadedCount++;
@@ -2045,6 +2035,8 @@ var MembershipSystem = class {
2045
2035
  dailyLimit: row.dailyLimit,
2046
2036
  lastDrawTime: row.lastDrawTime,
2047
2037
  points: row.points,
2038
+ pointsRefreshAt: row.pointsRefreshAt || 0,
2039
+ pointsLastRefreshedAt: row.pointsLastRefreshedAt || 0,
2048
2040
  nai5DailyUsage: row.nai5DailyUsage || 0,
2049
2041
  nai5Bucket: row.nai5Bucket || 0,
2050
2042
  nai5BucketDay: row.nai5BucketDay || 0,
@@ -2054,19 +2046,7 @@ var MembershipSystem = class {
2054
2046
  this.ctx.logger.info(`会员系统数据从数据库加载成功,共 ${loadedCount} 条记录`);
2055
2047
  await this.loadCardsFromDB();
2056
2048
  await this.migrateLegacyMembers();
2057
- try {
2058
- await this.checkAndCatchUpPointsRefresh();
2059
- } catch (err) {
2060
- this.ctx.logger.error("点数刷新补刷失败", err);
2061
- }
2062
- if (this.config.pointsEnabled && this.config.pointsMode === "periodic") {
2063
- if (this.pointsRefreshStateNeedsMigration) {
2064
- await this.savePointsRefreshState();
2065
- this.pointsRefreshStateNeedsMigration = false;
2066
- this.ctx.logger.info(`点数刷新状态已迁移为周期锚点制:锚点 ${this.formatRefreshAnchor()},上次实际刷新 ${this.formatLastRefreshTime()}`);
2067
- }
2068
- this.ctx.logger.info(`点数刷新计划:周期锚点 ${this.formatRefreshAnchor()},上次实际刷新 ${this.formatLastRefreshTime()},下次刷新 ${this.formatNextRefreshTime()}(${this.getDaysUntilNextRefresh()} 天后)`);
2069
- }
2049
+ await this.migratePointsRefreshPlans();
2070
2050
  } catch (err) {
2071
2051
  this.ctx.logger.error("从数据库加载会员系统数据失败", err);
2072
2052
  }
@@ -2115,7 +2095,24 @@ var MembershipSystem = class {
2115
2095
  this.ctx.logger.info(`会员等级迁移完成:已为 ${migrated} 位存量会员生成 Lv1 会员卡`);
2116
2096
  }
2117
2097
  }
2118
- // 将指定用户的所有卡同步到数据库(先删后建,卡数量小,简单可靠)
2098
+ /**
2099
+ * 将旧版全局刷新时间迁移为用户级刷新计划。
2100
+ * 迁移只补字段,不改变当前点数、会员卡或使用记录。
2101
+ */
2102
+ async migratePointsRefreshPlans() {
2103
+ if (!this.legacyPointsRefreshTime || this.config.pointsMode !== "periodic") return;
2104
+ const cycleMs = (this.config.pointsRefreshCycleDays || 30) * 24 * 60 * 60 * 1e3;
2105
+ let changed = false;
2106
+ for (const userId in this.userData) {
2107
+ const user = this.userData[userId];
2108
+ const tier = this.computeActiveTier(user.cards || []);
2109
+ const isInScope = tier > 0 || this.config.pointsRefreshIncludeNonMember;
2110
+ if (!isInScope || user.pointsRefreshAt) continue;
2111
+ user.pointsRefreshAt = this.legacyPointsRefreshTime + cycleMs;
2112
+ changed = true;
2113
+ }
2114
+ if (changed) await this.saveUserData();
2115
+ }
2119
2116
  async syncCardsToDB(userId) {
2120
2117
  try {
2121
2118
  const user = this.userData[userId];
@@ -2155,25 +2152,9 @@ var MembershipSystem = class {
2155
2152
  this.ctx.logger.error("批量保存会员卡数据失败", err);
2156
2153
  }
2157
2154
  }
2158
- // 保存点数刷新状态(周期锚点 + 上次实际刷新时刻)到元数据行
2159
- async savePointsRefreshState() {
2160
- try {
2161
- await this.ctx.database.upsert("hhs_huatu_user", [{
2162
- visitorId: "_system_points_refresh",
2163
- isMember: false,
2164
- membershipExpiry: 0,
2165
- dailyUsage: 0,
2166
- // lastUsed 在该行语义 = 周期锚点(对齐到检查点)
2167
- lastUsed: this.pointsRefreshAnchor,
2168
- dailyLimit: 0,
2169
- // lastDrawTime 在该行语义 = 上次实际刷新时刻
2170
- lastDrawTime: this.pointsRefreshLastActualAt,
2171
- points: 0,
2172
- nai5DailyUsage: 0
2173
- }], ["visitorId"]);
2174
- } catch (err) {
2175
- this.ctx.logger.error("保存点数刷新状态失败", err);
2176
- }
2155
+ /** 旧版全局刷新记录仅保留用于读取迁移,不再写入。 */
2156
+ async saveLastPointsRefreshTime() {
2157
+ return;
2177
2158
  }
2178
2159
  // 将单个用户数据同步到数据库
2179
2160
  async syncUserToDB(userId) {
@@ -2189,6 +2170,8 @@ var MembershipSystem = class {
2189
2170
  dailyLimit: user.dailyLimit,
2190
2171
  lastDrawTime: user.lastDrawTime || 0,
2191
2172
  points: user.points || 0,
2173
+ pointsRefreshAt: user.pointsRefreshAt || 0,
2174
+ pointsLastRefreshedAt: user.pointsLastRefreshedAt || 0,
2192
2175
  nai5DailyUsage: user.nai5DailyUsage || 0,
2193
2176
  nai5Bucket: user.nai5Bucket || 0,
2194
2177
  nai5BucketDay: user.nai5BucketDay || 0,
@@ -2214,6 +2197,8 @@ var MembershipSystem = class {
2214
2197
  dailyLimit: user.dailyLimit,
2215
2198
  lastDrawTime: user.lastDrawTime || 0,
2216
2199
  points: user.points || 0,
2200
+ pointsRefreshAt: user.pointsRefreshAt || 0,
2201
+ pointsLastRefreshedAt: user.pointsLastRefreshedAt || 0,
2217
2202
  nai5DailyUsage: user.nai5DailyUsage || 0,
2218
2203
  nai5Bucket: user.nai5Bucket || 0,
2219
2204
  nai5BucketDay: user.nai5BucketDay || 0,
@@ -2385,6 +2370,7 @@ var MembershipSystem = class {
2385
2370
  if (this.config.pointsMode === "periodic") {
2386
2371
  const refreshAmount = this.getTierBenefit(safeTier).pointsRefresh;
2387
2372
  user.points = refreshAmount;
2373
+ user.pointsRefreshAt = now + (this.config.pointsRefreshCycleDays || 30) * 24 * 60 * 60 * 1e3;
2388
2374
  this.ctx.logger.info(`[会员系统] 用户 ${userId} 首次成为 Lv${safeTier} 会员,点数已初始化为 ${refreshAmount}`);
2389
2375
  } else if (!user.points) {
2390
2376
  user.points = this.config.pointsDefault || 200;
@@ -2510,6 +2496,7 @@ var MembershipSystem = class {
2510
2496
  async deductPoints(userId, amount) {
2511
2497
  if (!this.config.pointsEnabled || amount <= 0) return 0;
2512
2498
  this.ensureUserData(userId);
2499
+ await this.ensurePointsFresh(userId);
2513
2500
  const user = this.userData[userId];
2514
2501
  const currentPoints = user.points || 0;
2515
2502
  if (currentPoints < amount) {
@@ -2525,6 +2512,7 @@ var MembershipSystem = class {
2525
2512
  async refundPoints(userId, amount) {
2526
2513
  if (!this.config.pointsEnabled || amount <= 0) return;
2527
2514
  this.ensureUserData(userId);
2515
+ await this.ensurePointsFresh(userId);
2528
2516
  const user = this.userData[userId];
2529
2517
  user.points = (user.points || 0) + amount;
2530
2518
  await this.syncUserToDB(userId);
@@ -2547,6 +2535,7 @@ var MembershipSystem = class {
2547
2535
  let updatedCount = 0;
2548
2536
  const now = Date.now();
2549
2537
  for (const userId in this.userData) {
2538
+ await this.ensurePointsFresh(userId);
2550
2539
  const user = this.userData[userId];
2551
2540
  if (membersOnly && (!user.isMember || user.membershipExpiry < now)) {
2552
2541
  continue;
@@ -2565,245 +2554,70 @@ var MembershipSystem = class {
2565
2554
  const message = updatedCount > 0 ? `✅ 成功为 ${updatedCount} 位用户${action} ${Math.abs(amount)} 点数` : `⚠️ 没有符合条件的用户`;
2566
2555
  return { count: updatedCount, message };
2567
2556
  }
2568
- /**
2569
- * 刷新点数(定期任务 / 手动触发)。
2570
- * 等级制度下按刷新时刻的生效等级取刷新量:Lv1 = pointsRefreshAmount,Lv2-Lv5 = 各档 pointsRefresh;
2571
- * 非会员若在刷新范围内,刷为 Lv1 档值(与现有行为一致)。
2572
- * @param tierFilter 指定生效等级时仅刷新该等级会员;缺省刷新全部范围
2573
- */
2557
+ /** 确保用户在读取或扣除点数前完成到期的惰性刷新。 */
2558
+ async ensurePointsFresh(userId) {
2559
+ if (!this.config.pointsEnabled || this.config.pointsMode !== "periodic") return;
2560
+ this.ensureUserData(userId);
2561
+ const running = this.pointsRefreshLocks.get(userId);
2562
+ if (running) return running;
2563
+ const task = this.refreshUserPointsIfDue(userId).finally(() => {
2564
+ if (this.pointsRefreshLocks.get(userId) === task) this.pointsRefreshLocks.delete(userId);
2565
+ });
2566
+ this.pointsRefreshLocks.set(userId, task);
2567
+ return task;
2568
+ }
2569
+ async refreshUserPointsIfDue(userId) {
2570
+ const user = this.userData[userId];
2571
+ const now = Date.now();
2572
+ const cycleMs = (this.config.pointsRefreshCycleDays || 30) * 24 * 60 * 60 * 1e3;
2573
+ const tier = this.getActiveTier(userId);
2574
+ const inScope = tier > 0 || this.config.pointsRefreshIncludeNonMember;
2575
+ if (!user.pointsRefreshAt) {
2576
+ if (!inScope) return;
2577
+ user.pointsRefreshAt = now + cycleMs;
2578
+ await this.syncUserToDB(userId);
2579
+ return;
2580
+ }
2581
+ if (user.pointsRefreshAt > now || !inScope) return;
2582
+ const plannedAt = user.pointsRefreshAt;
2583
+ user.points = this.getTierBenefit(tier > 0 ? tier : 1).pointsRefresh;
2584
+ user.pointsLastRefreshedAt = now;
2585
+ do {
2586
+ user.pointsRefreshAt += cycleMs;
2587
+ } while (user.pointsRefreshAt <= now);
2588
+ await this.syncUserToDB(userId);
2589
+ this.ctx.logger.info(`[会员系统] 用户 ${userId} 惰性刷新点数:${plannedAt} → ${user.pointsRefreshAt}`);
2590
+ }
2591
+ /** 手动刷新点数,不改变用户自动刷新计划。 */
2574
2592
  async refreshPoints(tierFilter) {
2575
2593
  if (!this.config.pointsEnabled || this.config.pointsMode !== "periodic") {
2576
2594
  return { count: 0, message: "点数刷新未启用或不是周期模式" };
2577
2595
  }
2578
2596
  let refreshedCount = 0;
2579
- const now = Date.now();
2580
2597
  for (const userId in this.userData) {
2581
- const user = this.userData[userId];
2582
- let tier = this.computeActiveTier(user.cards || []);
2583
- if (tier > 1 && this.config.tierEnabled === false) tier = 1;
2584
- const isActive = tier > 0;
2585
- if (!this.config.pointsRefreshIncludeNonMember && !isActive) {
2586
- continue;
2587
- }
2598
+ const tier = this.getActiveTier(userId);
2599
+ if (tier <= 0 && !this.config.pointsRefreshIncludeNonMember) continue;
2588
2600
  if (tierFilter !== void 0 && tier !== tierFilter) continue;
2601
+ await this.ensurePointsFresh(userId);
2602
+ const user = this.userData[userId];
2589
2603
  user.points = this.getTierBenefit(tier > 0 ? tier : 1).pointsRefresh;
2604
+ await this.syncUserToDB(userId);
2590
2605
  refreshedCount++;
2591
2606
  }
2592
- this.pointsRefreshLastActualAt = now;
2593
- await this.savePointsRefreshState();
2594
- if (refreshedCount > 0) {
2595
- await this.saveUserData();
2596
- }
2597
2607
  const scope = tierFilter !== void 0 ? `Lv${tierFilter} 会员` : "用户";
2598
- const message = refreshedCount > 0 ? `✅ 点数刷新完成,共刷新 ${refreshedCount} 位${scope}的点数(按各自生效等级)` : "⚠️ 没有符合条件的用户需要刷新点数";
2608
+ const message = refreshedCount > 0 ? `✅ 已手动刷新 ${refreshedCount} 位${scope}的点数,自动刷新计划未改变` : "⚠️ 没有符合条件的用户需要刷新点数";
2599
2609
  this.ctx.logger.info(message);
2600
2610
  return { count: refreshedCount, message };
2601
2611
  }
2602
- // ========== 点数刷新:权威时刻推导(展示与执行共用,避免两套口径) ==========
2603
- /** 周期毫秒数 */
2604
- getPointsRefreshCycleMs() {
2605
- return (this.config.pointsRefreshCycleDays || 30) * 24 * 60 * 60 * 1e3;
2606
- }
2607
- /** 每日检查时刻(HH:MM,默认 00:05);配置非法时回落 00:05 */
2608
- getCheckTimeParts() {
2609
- const raw = String(this.config.pointsRefreshCheckTime || "00:05").trim();
2610
- const m = raw.match(/^(\d{1,2}):(\d{1,2})$/);
2611
- if (!m) return { hour: 0, minute: 5 };
2612
- const hour = Number(m[1]);
2613
- const minute = Number(m[2]);
2614
- if (!Number.isInteger(hour) || !Number.isInteger(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
2615
- return { hour: 0, minute: 5 };
2616
- }
2617
- return { hour, minute };
2618
- }
2619
- /** 指定日期(本地时区)的检查点时刻 */
2620
- checkPointOfDay(timestamp) {
2621
- const { hour, minute } = this.getCheckTimeParts();
2622
- const d = new Date(timestamp);
2623
- return new Date(d.getFullYear(), d.getMonth(), d.getDate(), hour, minute, 0, 0).getTime();
2624
- }
2625
- /** 首个 ≥ timestamp 的检查点 */
2626
- alignToCheck(timestamp) {
2627
- const check = this.checkPointOfDay(timestamp);
2628
- return check < timestamp ? check + 24 * 60 * 60 * 1e3 : check;
2629
- }
2630
- /** 最近一个 ≤ timestamp 的检查点 */
2631
- lastCheckPointAtOrBefore(timestamp) {
2632
- const check = this.checkPointOfDay(timestamp);
2633
- return check > timestamp ? check - 24 * 60 * 60 * 1e3 : check;
2634
- }
2635
- /**
2636
- * 下次刷新的**计划**时刻(毫秒);0 表示尚未初始化(首次运行,将在下一个检查点刷新)。
2637
- * 计划时刻可能已经过去(例如停机漏检、或运维把锚点设到了过去),此时由 isPointsRefreshOverdue() 反映。
2638
- */
2639
- getPointsRefreshPlan() {
2640
- if (this.pointsRefreshAnchor === 0) return 0;
2641
- return this.alignToCheck(this.pointsRefreshAnchor + this.getPointsRefreshCycleMs());
2642
- }
2643
- /**
2644
- * 下次刷新**实际**会发生的时刻(毫秒时间戳);-1 表示未启用周期刷新。
2645
- * 展示(会员 -u / 会员调试 -s)与执行判定都以这里为准:
2646
- * - 尚未初始化(锚点 0)→ 下一个检查点(首次刷新)
2647
- * - 计划时刻还没到 → 计划时刻
2648
- * - 计划已过(停机漏检 / 手动把锚点设到过去)→ 下一个检查点(届时会补刷)
2649
- */
2650
- getNextRefreshTime() {
2612
+ getNextPointsRefreshAt(userId) {
2651
2613
  if (!this.config.pointsEnabled || this.config.pointsMode !== "periodic") return -1;
2652
- const now = Date.now();
2653
- const plan = this.getPointsRefreshPlan();
2654
- if (plan === 0) return this.alignToCheck(now);
2655
- return plan <= now + POINTS_REFRESH_TOLERANCE ? this.alignToCheck(now + POINTS_REFRESH_TOLERANCE) : plan;
2656
- }
2657
- /** 是否已逾期(计划刷新时刻已过但尚未刷新):仅在停机漏检或手动把锚点设到过去时成立 */
2658
- isPointsRefreshOverdue() {
2659
- const plan = this.getPointsRefreshPlan();
2660
- if (plan === 0) return false;
2661
- return Date.now() + POINTS_REFRESH_TOLERANCE >= plan;
2662
- }
2663
- // 把时间戳格式化为本地时区的 YYYY-MM-DD HH:mm(不依赖 locale,输出稳定)
2664
- formatTimeValue(timestamp) {
2665
- const d = new Date(timestamp);
2666
- const p = /* @__PURE__ */ __name((n) => String(n).padStart(2, "0"), "p");
2667
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
2668
- }
2669
- /** 下次刷新时刻的展示文本(如 2026-11-05 00:05);未启用周期刷新时返回空串 */
2670
- formatNextRefreshTime() {
2671
- const t = this.getNextRefreshTime();
2672
- return t < 0 ? "" : this.formatTimeValue(t);
2673
- }
2674
- /** 上次**实际**刷新时刻的展示文本;从未刷新过时返回「尚未记录」 */
2675
- formatLastRefreshTime() {
2676
- return this.pointsRefreshLastActualAt === 0 ? "尚未记录" : this.formatTimeValue(this.pointsRefreshLastActualAt);
2677
- }
2678
- /** 周期锚点的展示文本;尚未初始化时返回「尚未初始化」 */
2679
- formatRefreshAnchor() {
2680
- return this.pointsRefreshAnchor === 0 ? "尚未初始化" : this.formatTimeValue(this.pointsRefreshAnchor);
2681
- }
2682
- /** 下次刷新**计划**时刻的展示文本(可能已过去);尚未初始化时返回「尚未初始化」 */
2683
- formatRefreshPlanTime() {
2684
- const plan = this.getPointsRefreshPlan();
2685
- return plan === 0 ? "尚未初始化" : this.formatTimeValue(plan);
2686
- }
2687
- /** 距离下次刷新的天数(用于「N 天后」文案) */
2688
- getDaysUntilNextRefresh() {
2689
- const next = this.getNextRefreshTime();
2690
- if (next < 0) return -1;
2691
- return Math.max(0, Math.ceil((next - Date.now()) / (24 * 60 * 60 * 1e3)));
2692
- }
2693
- /**
2694
- * 刷新完成后推进周期锚点:锚点 = 本次刷新所属的检查点(取本次刷新时刻附近的检查点),
2695
- * 而不是"实际执行时刻"。因此:
2696
- * - 正常在检查点触发 → 锚点就是该检查点,周期保持对齐;
2697
- * - 停机补刷(白天启动)→ 锚点仍是当天的检查点,周期不会漂移到白天;
2698
- * - 定时器早/晚几十毫秒触发 → 仍归到同一个检查点,不会退/进一天。
2699
- */
2700
- advanceAnchorAfterRefresh() {
2701
- this.pointsRefreshAnchor = this.lastCheckPointAtOrBefore(Date.now() + POINTS_REFRESH_TOLERANCE);
2702
- }
2703
- /**
2704
- * 停机漏检补刷(S1):启动加载完成后若计划时刻已过(说明实例在检查点离线),立即补刷一次,
2705
- * 并把锚点推进到"本次补刷所属的检查点"。返回是否执行了补刷。
2706
- */
2707
- async checkAndCatchUpPointsRefresh() {
2708
- if (!this.config.membershipEnabled || !this.config.pointsEnabled || this.config.pointsMode !== "periodic") return false;
2709
- if (!this.isPointsRefreshOverdue()) return false;
2710
- const plan = this.getPointsRefreshPlan();
2711
- this.ctx.logger.info(`检测到点数刷新计划已逾期(计划 ${this.formatTimeValue(plan)},当前 ${this.formatTimeValue(Date.now())}),正在补刷...`);
2712
- const result = await this.refreshPoints();
2713
- this.advanceAnchorAfterRefresh();
2714
- await this.savePointsRefreshState();
2715
- this.ctx.logger.info(`点数刷新补刷完成:${result.message};周期锚点推进至 ${this.formatTimeValue(this.pointsRefreshAnchor)},下次刷新 ${this.formatTimeValue(this.getNextRefreshTime())}`);
2716
- return true;
2717
- }
2718
- // ========== 点数刷新:运维接口(查看 / 设置锚点与计划时刻,均不触发刷新) ==========
2719
- /** 周期锚点时间戳(0 = 尚未初始化) */
2720
- getPointsRefreshAnchor() {
2721
- return this.pointsRefreshAnchor;
2722
- }
2723
- /** 下次刷新的计划时刻(可能已过);0 = 尚未初始化 */
2724
- getPointsRefreshPlanTime() {
2725
- return this.getPointsRefreshPlan();
2726
- }
2727
- /** 上次实际刷新时刻 */
2728
- getPointsRefreshLastActualAt() {
2729
- return this.pointsRefreshLastActualAt;
2730
- }
2731
- /**
2732
- * 解析运维输入的时间:
2733
- * - `now`:当前时刻
2734
- * - `+Nd` / `+Nh` / `+Nm`:相对当前时刻 N 天/小时/分钟
2735
- * - `YYYY-MM-DD`:该日期(dateOnly=true,调用方按"当天的检查点"解释)
2736
- * - `YYYY-MM-DD HH:mm` / `YYYY-MM-DDTHH:mm`:精确到分
2737
- * 解析失败返回 null。
2738
- */
2739
- parseRefreshTimeInput(input) {
2740
- const s = String(input ?? "").trim();
2741
- if (!s) return null;
2742
- const now = Date.now();
2743
- if (s === "now" || s === "现在") return { timestamp: now, dateOnly: false };
2744
- const rel = s.match(/^\+(\d+)([dhm])$/i);
2745
- if (rel) {
2746
- const n = Number(rel[1]);
2747
- const unit = rel[2].toLowerCase();
2748
- const unitMs = unit === "d" ? 864e5 : unit === "h" ? 36e5 : 6e4;
2749
- return { timestamp: now + n * unitMs, dateOnly: false };
2750
- }
2751
- const abs = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[ T](\d{1,2}):(\d{1,2}))?$/);
2752
- if (abs) {
2753
- const year = Number(abs[1]);
2754
- const month = Number(abs[2]);
2755
- const day = Number(abs[3]);
2756
- const hour = abs[4] ? Number(abs[4]) : 0;
2757
- const minute = abs[5] ? Number(abs[5]) : 0;
2758
- if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59) return null;
2759
- const d = new Date(year, month - 1, day, hour, minute, 0, 0);
2760
- if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null;
2761
- return { timestamp: d.getTime(), dateOnly: !abs[4] };
2762
- }
2763
- return null;
2614
+ return this.userData[userId]?.pointsRefreshAt || 0;
2764
2615
  }
2765
- /**
2766
- * 设置周期锚点(不触发刷新)。
2767
- * - 输入仅日期 → 取该日期的检查点
2768
- * - 输入含时间 → 取"最近一个 ≤ 该时刻的检查点"
2769
- */
2770
- async setPointsRefreshAnchor(input) {
2771
- if (!this.config.pointsEnabled || this.config.pointsMode !== "periodic") return "❌ 未启用周期刷新,无法设置周期锚点";
2772
- const parsed = this.parseRefreshTimeInput(input);
2773
- if (!parsed) return `❌ 时间格式无法识别:${input}(支持 now、+5d、2026-11-01、2026-11-01 00:05)`;
2774
- const anchor = parsed.dateOnly ? this.checkPointOfDay(parsed.timestamp) : this.lastCheckPointAtOrBefore(parsed.timestamp);
2775
- this.pointsRefreshAnchor = anchor;
2776
- await this.savePointsRefreshState();
2777
- this.ctx.logger.info(`[会员系统] 周期锚点被设置为 ${this.formatTimeValue(anchor)}(输入:${input})`);
2778
- const next = this.getNextRefreshTime();
2779
- return `✅ 周期锚点已设为 ${this.formatTimeValue(anchor)}(已对齐检查点)
2780
- 下次刷新:${this.formatTimeValue(next)}(${this.getDaysUntilNextRefresh()}天后)${this.isPointsRefreshOverdue() ? "\n⚠️ 该计划已逾期,将在下一个检查点自动补刷(也可用 -f 立即刷新)" : ""}`;
2781
- }
2782
- /** 直接指定下次刷新时刻(不触发刷新):取"首个 ≥ 该时刻的检查点",锚点由它反推 */
2783
- async setPointsRefreshNextAt(input) {
2784
- if (!this.config.pointsEnabled || this.config.pointsMode !== "periodic") return "❌ 未启用周期刷新,无法设置下次刷新时刻";
2785
- const parsed = this.parseRefreshTimeInput(input);
2786
- if (!parsed) return `❌ 时间格式无法识别:${input}(支持 now、+5d、2026-11-01、2026-11-01 00:05)`;
2787
- const nextAt = parsed.dateOnly ? this.checkPointOfDay(parsed.timestamp) : this.alignToCheck(parsed.timestamp);
2788
- const anchor = nextAt - this.getPointsRefreshCycleMs();
2789
- if (anchor <= 0) return "❌ 目标时刻过于久远,无法反推周期锚点";
2790
- this.pointsRefreshAnchor = anchor;
2791
- await this.savePointsRefreshState();
2792
- this.ctx.logger.info(`[会员系统] 下次刷新时刻被设置为 ${this.formatTimeValue(nextAt)}(输入:${input})`);
2793
- return `✅ 下次刷新已设为 ${this.formatTimeValue(nextAt)}(周期锚点反推为 ${this.formatTimeValue(anchor)})
2794
- 本次设置不会立即刷新点数,如需立即刷新请加 -f`;
2795
- }
2796
- /** 重开一个周期:以当前检查点为锚点(不触发刷新) */
2797
- async resetPointsRefreshCycle() {
2798
- if (!this.config.pointsEnabled || this.config.pointsMode !== "periodic") return "❌ 未启用周期刷新,无法重置周期";
2799
- const anchor = this.lastCheckPointAtOrBefore(Date.now() + POINTS_REFRESH_TOLERANCE);
2800
- this.pointsRefreshAnchor = anchor;
2801
- await this.savePointsRefreshState();
2802
- this.ctx.logger.info(`[会员系统] 周期已重置,锚点 ${this.formatTimeValue(anchor)}`);
2803
- const next = this.getNextRefreshTime();
2804
- return `✅ 刷新周期已重置,周期锚点:${this.formatTimeValue(anchor)}
2805
- 下次刷新:${this.formatTimeValue(next)}(${this.getDaysUntilNextRefresh()}天后)
2806
- 本次操作不会立即刷新点数,如需立即刷新请加 -f`;
2616
+ getDaysUntilNextRefresh(userId) {
2617
+ if (!this.config.pointsEnabled || this.config.pointsMode !== "periodic") return -1;
2618
+ const at = userId ? this.getNextPointsRefreshAt(userId) : 0;
2619
+ if (!at) return 0;
2620
+ return Math.max(0, Math.ceil((at - Date.now()) / (24 * 60 * 60 * 1e3)));
2807
2621
  }
2808
2622
  /**
2809
2623
  * 从 JSON 文件导入数据到数据库。
@@ -3050,39 +2864,7 @@ var MembershipSystem = class {
3050
2864
  scheduleReminder();
3051
2865
  }
3052
2866
  }
3053
- // 设置点数刷新定时任务(每天在配置的检查时刻检查一次是否需要刷新)
3054
- setupPointsRefreshTask() {
3055
- if (this.pointsRefreshTimerDispose) {
3056
- this.pointsRefreshTimerDispose();
3057
- this.pointsRefreshTimerDispose = null;
3058
- }
3059
- if (this.config.membershipEnabled && this.config.pointsEnabled && this.config.pointsMode === "periodic") {
3060
- const cycleDays = this.config.pointsRefreshCycleDays || 30;
3061
- const { hour, minute } = this.getCheckTimeParts();
3062
- const checkTime = `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
3063
- const scheduleDailyCheck = /* @__PURE__ */ __name(() => {
3064
- const delay = this.getDelayUntilTime(checkTime);
3065
- this.ctx.logger.info(`点数刷新检查任务已安排,将在 ${new Date(Date.now() + delay).toLocaleString()} 检查(检查时刻 ${checkTime},刷新周期 ${cycleDays} 天)`);
3066
- this.pointsRefreshTimerDispose = this.ctx.setTimeout(async () => {
3067
- const firstRun = this.pointsRefreshAnchor === 0;
3068
- if (firstRun || this.isPointsRefreshOverdue()) {
3069
- this.ctx.logger.info(firstRun ? "点数刷新尚未初始化,正在执行首次刷新..." : `已达到刷新周期 (${cycleDays} 天),正在执行点数刷新...`);
3070
- await this.refreshPoints();
3071
- this.advanceAnchorAfterRefresh();
3072
- await this.savePointsRefreshState();
3073
- this.ctx.logger.info(`本轮刷新完成,周期锚点 ${this.formatTimeValue(this.pointsRefreshAnchor)},下次刷新 ${this.formatTimeValue(this.getNextRefreshTime())}`);
3074
- } else {
3075
- const remainDays = this.getDaysUntilNextRefresh();
3076
- if (this.config.debugLog) {
3077
- this.ctx.logger.info(`点数刷新检查:距离下次刷新还有 ${remainDays} 天(${this.formatTimeValue(this.getNextRefreshTime())})`);
3078
- }
3079
- }
3080
- scheduleDailyCheck();
3081
- }, delay);
3082
- }, "scheduleDailyCheck");
3083
- scheduleDailyCheck();
3084
- }
3085
- }
2867
+ // 点数刷新采用按用户惰性执行,不注册全局定时任务。
3086
2868
  // 给所有有效会员卡增加天数(按卡粒度,每张有效卡独立延长)。
3087
2869
  // @param tierFilter 指定生效等级时仅操作该等级会员;缺省操作全部会员
3088
2870
  async addDaysToAllMembers(days, tierFilter) {
@@ -3124,6 +2906,8 @@ var MembershipSystem = class {
3124
2906
  lastUsed: Date.now(),
3125
2907
  dailyLimit: this.config.nonMemberDailyLimit,
3126
2908
  points: 0,
2909
+ pointsRefreshAt: 0,
2910
+ pointsLastRefreshedAt: 0,
3127
2911
  nai5DailyUsage: 0,
3128
2912
  nai5Bucket: 0,
3129
2913
  nai5BucketDay: 0,
@@ -5482,6 +5266,7 @@ function registerMember(ctx, config, runtime) {
5482
5266
  return "您没有权限设置会员状态";
5483
5267
  }
5484
5268
  membershipSystem.checkAndResetDailyUsage(targetId);
5269
+ await membershipSystem.ensurePointsFresh(targetId);
5485
5270
  if (options2.refreshPoints) {
5486
5271
  if (!config.pointsEnabled) {
5487
5272
  return "点数控制未启用";
@@ -5609,10 +5394,12 @@ nai5/nai5c 今日免费:${benefit.nai5DailyLimit} 次,已用 ${nai5Used} 次
5609
5394
  pointsInfo = `
5610
5395
  点数余额:${points}`;
5611
5396
  if (config.pointsMode === "periodic") {
5612
- const remainDaysRefresh = membershipSystem.getDaysUntilNextRefresh();
5613
- if (remainDaysRefresh >= 0) {
5397
+ const refreshAt = membershipSystem.getNextPointsRefreshAt(targetId);
5398
+ const remainDaysRefresh = membershipSystem.getDaysUntilNextRefresh(targetId);
5399
+ if (refreshAt > 0) {
5400
+ const refreshDate = new Date(refreshAt).toLocaleString();
5614
5401
  pointsInfo += `
5615
- 下次点数刷新时间:${membershipSystem.formatNextRefreshTime()}(${remainDaysRefresh}天后,Lv${activeTier} 档刷新为 ${benefit.pointsRefresh} 点)`;
5402
+ 下次点数刷新时间:${refreshDate}(约${remainDaysRefresh}天后,Lv${activeTier} 档刷新为 ${benefit.pointsRefresh} 点)`;
5616
5403
  }
5617
5404
  }
5618
5405
  }
@@ -5625,10 +5412,11 @@ nai5/nai5c 今日免费:${benefit.nai5DailyLimit} 次,已用 ${nai5Used} 次
5625
5412
  pointsInfo = `
5626
5413
  点数余额:${user.points || 0}`;
5627
5414
  if (config.pointsMode === "periodic" && config.pointsRefreshIncludeNonMember) {
5628
- const remainDaysRefresh = membershipSystem.getDaysUntilNextRefresh();
5629
- if (remainDaysRefresh >= 0) {
5415
+ const refreshAt = membershipSystem.getNextPointsRefreshAt(targetId);
5416
+ if (refreshAt > 0) {
5417
+ const refreshDate = new Date(refreshAt).toLocaleString();
5630
5418
  pointsInfo += `
5631
- 下次点数刷新时间:${membershipSystem.formatNextRefreshTime()}(${remainDaysRefresh}天后)`;
5419
+ 下次点数刷新时间:${refreshDate}(约${membershipSystem.getDaysUntilNextRefresh(targetId)}天后)`;
5632
5420
  }
5633
5421
  }
5634
5422
  }
@@ -5647,7 +5435,7 @@ nai5/nai5c 今日免费:${benefit.nai5DailyLimit} 次,已用 ${nai5Used} 次
5647
5435
  }
5648
5436
  });
5649
5437
  if (config.memberDebugCommandEnabled) {
5650
- ctx.command("novelai.member-debug", "会员系统调试指令", { authority: config.memberDebugCommandAuthLv }).alias("会员调试").option("cleanup", "-c 立即执行会员信息清理").option("remind", "-r 立即执行会员到期提醒").option("status", "-s 查看定时任务状态").option("resetUsage", "-u <userId:string> 重置指定用户的使用次数").option("addDaysAll", "-a <days:number> 给会员增加天数(配合 -t 仅操作指定等级)").option("tier", "-t <tier:number> 指定生效等级(Lv1-Lv5),缺省对所有会员操作").option("refreshPoints", "-f 立即执行点数刷新(只补发点数,不改变周期;配合 -t 仅刷新指定等级)").option("setAnchor", '--set-anchor <value:string> 设置周期锚点(now | +5d | 2026-11-01 | "2026-11-01 00:05"),只重排计划不刷新').option("setNextAt", "--set-next-at <value:string> 直接指定下次刷新时刻(同上格式),只重排计划不刷新").option("resetCycle", "--reset-cycle 以当前检查点为锚点重开一个刷新周期(不刷新点数)").option("addPoints", "--add-points <amount:number> 给会员加点数(配合 -t 仅操作指定等级)").option("subPoints", "--sub-points <amount:number> 给会员减点数(配合 -t 仅操作指定等级)").option("setPoints", "--set-points <value:string> 设置指定用户点数,格式: 用户ID:点数").option("addBucket", "--add-bucket [days:number] 给会员赠送免费次数(发福利:每人按各自档位每日额度 × 天数到账,受 7 天上限约束;配合 -t 指定等级、--bucket-user 指定单人)").option("bucketUser", "--bucket-user <userId:string> 配合 --add-bucket 仅给指定用户赠送").option("setBucket", "--set-bucket <value:string> 设置指定用户免费次数余额,格式: 用户ID:次数").option("importData", "--import 从 JSON 文件导入数据").action(async ({ session, options: options2 }) => {
5438
+ ctx.command("novelai.member-debug", "会员系统调试指令", { authority: config.memberDebugCommandAuthLv }).alias("会员调试").option("cleanup", "-c 立即执行会员信息清理").option("remind", "-r 立即执行会员到期提醒").option("status", "-s 查看定时任务状态").option("resetUsage", "-u <userId:string> 重置指定用户的使用次数").option("addDaysAll", "-a <days:number> 给会员增加天数(配合 -t 仅操作指定等级)").option("tier", "-t <tier:number> 指定生效等级(Lv1-Lv5),缺省对所有会员操作").option("refreshPoints", "-f 立即手动刷新点数(不改变自动刷新计划,配合 -t 仅刷新指定等级)").option("addPoints", "--add-points <amount:number> 给会员加点数(配合 -t 仅操作指定等级)").option("subPoints", "--sub-points <amount:number> 给会员减点数(配合 -t 仅操作指定等级)").option("setPoints", "--set-points <value:string> 设置指定用户点数,格式: 用户ID:点数").option("addBucket", "--add-bucket [days:number] 给会员赠送免费次数(发福利:每人按各自档位每日额度 × 天数到账,受 7 天上限约束;配合 -t 指定等级、--bucket-user 指定单人)").option("bucketUser", "--bucket-user <userId:string> 配合 --add-bucket 仅给指定用户赠送").option("setBucket", "--set-bucket <value:string> 设置指定用户免费次数余额,格式: 用户ID:次数").option("importData", "--import 从 JSON 文件导入数据").action(async ({ session, options: options2 }) => {
5651
5439
  if (!config.membershipEnabled) {
5652
5440
  return "会员系统未启用";
5653
5441
  }
@@ -5819,24 +5607,16 @@ nai5 已使用:${user.nai5DailyUsage || 0} 次` : "";
5819
5607
  `;
5820
5608
  statusMsg += `✅ 点数控制:已启用
5821
5609
  `;
5822
- statusMsg += ` 点数模式:${config.pointsMode === "periodic" ? "按周期刷新" : "永久点数"}
5610
+ statusMsg += ` 点数模式:${config.pointsMode === "periodic" ? "按周期惰性刷新" : "永久点数"}
5823
5611
  `;
5824
5612
  if (config.pointsMode === "periodic") {
5825
5613
  statusMsg += ` 刷新周期:${config.pointsRefreshCycleDays || 30} 天
5826
- `;
5827
- statusMsg += ` 刷新检查时刻:${config.pointsRefreshCheckTime || "00:05"}
5828
5614
  `;
5829
5615
  statusMsg += ` 刷新点数:${config.pointsRefreshAmount || 200}
5830
5616
  `;
5831
- statusMsg += ` 刷新范围:${config.pointsRefreshIncludeNonMember ? "所有用户" : "仅会员"}
5617
+ statusMsg += ` 刷新触发:用户发生点数相关操作时按用户惰性执行
5832
5618
  `;
5833
- statusMsg += ` 周期锚点:${membershipSystem.formatRefreshAnchor()}
5834
- `;
5835
- statusMsg += ` 上次实际刷新:${membershipSystem.formatLastRefreshTime()}
5836
- `;
5837
- const nextDays = membershipSystem.getDaysUntilNextRefresh();
5838
- const planLabel = membershipSystem.formatRefreshPlanTime();
5839
- statusMsg += ` 下次刷新:${membershipSystem.formatNextRefreshTime()}(${nextDays}天后,计划 ${planLabel})${membershipSystem.isPointsRefreshOverdue() ? " ⚠️ 已逾期,将在下个检查点补刷" : ""}
5619
+ statusMsg += ` 刷新范围:${config.pointsRefreshIncludeNonMember ? "所有用户" : "仅会员"}
5840
5620
  `;
5841
5621
  }
5842
5622
  statusMsg += ` 默认点数:${config.pointsDefault || 200}
@@ -5867,27 +5647,6 @@ nai5 已使用:${user.nai5DailyUsage || 0} 次` : "";
5867
5647
  await membershipSystem.checkAndRemindExpiringMembers();
5868
5648
  return "✅ 提醒完成!请查看控制台日志获取详细信息。";
5869
5649
  }
5870
- if (options2.setAnchor !== void 0) {
5871
- if (!config.pointsEnabled || config.pointsMode !== "periodic") {
5872
- return "❌ 未启用周期刷新,无法设置周期锚点";
5873
- }
5874
- const result = await membershipSystem.setPointsRefreshAnchor(options2.setAnchor);
5875
- return result;
5876
- }
5877
- if (options2.setNextAt !== void 0) {
5878
- if (!config.pointsEnabled || config.pointsMode !== "periodic") {
5879
- return "❌ 未启用周期刷新,无法设置下次刷新时刻";
5880
- }
5881
- const result = await membershipSystem.setPointsRefreshNextAt(options2.setNextAt);
5882
- return result;
5883
- }
5884
- if (options2.resetCycle) {
5885
- if (!config.pointsEnabled || config.pointsMode !== "periodic") {
5886
- return "❌ 未启用周期刷新,无法重置周期";
5887
- }
5888
- const result = await membershipSystem.resetPointsRefreshCycle();
5889
- return result;
5890
- }
5891
5650
  if (options2.refreshPoints) {
5892
5651
  if (!config.pointsEnabled) {
5893
5652
  return "❌ 点数控制未启用";
@@ -5897,12 +5656,8 @@ nai5 已使用:${user.nai5DailyUsage || 0} 次` : "";
5897
5656
  }
5898
5657
  const refreshScope = debugTierFilter !== void 0 ? `Lv${debugTierFilter} 会员` : "所有会员";
5899
5658
  await session.send(`正在为${refreshScope}执行点数刷新...`);
5900
- const overdueBefore = membershipSystem.isPointsRefreshOverdue();
5901
5659
  const result = await membershipSystem.refreshPoints(debugTierFilter);
5902
- const note = overdueBefore ? `
5903
- ℹ️ 本次刷新不改变周期锚点(${membershipSystem.formatRefreshAnchor()}),下次刷新仍为 ${membershipSystem.formatNextRefreshTime()};如需立即重开周期请用 --reset-cycle` : `
5904
- ℹ️ 周期不受影响,下次刷新仍为 ${membershipSystem.formatNextRefreshTime()}(${membershipSystem.getDaysUntilNextRefresh()}天后)`;
5905
- return result.message + note;
5660
+ return result.message;
5906
5661
  }
5907
5662
  if (options2.addPoints !== void 0) {
5908
5663
  if (!config.pointsEnabled) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-hhs-huatu",
3
3
  "description": "Generate images by diffusion models",
4
- "version": "1.3.13",
4
+ "version": "1.3.14",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [