koishi-plugin-hhs-huatu 1.3.13 → 1.3.15

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.
package/lib/config.d.ts CHANGED
@@ -12,6 +12,7 @@ export declare const modelMap: {
12
12
  readonly 'nai-v4-5-full': "nai-diffusion-4-5-full";
13
13
  readonly 'nai-v5-curated': "nai-diffusion-5-curated";
14
14
  readonly 'nai-v5-full': "nai-diffusion-5-full";
15
+ readonly 'nai-v5-full-medium': "nai-diffusion-5-full-medium";
15
16
  };
16
17
  export declare const orientMap: {
17
18
  readonly landscape: {
package/lib/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Context } from 'koishi';
2
2
  import { Config } from './config';
3
- export declare const usage = "\n# \uD83C\uDFA8 hhs-huatu \u63D2\u4EF6\n\n> **\u57FA\u4E8E [novelai-bot](https://bot.novelai.dev/) \u7684\u589E\u5F3A\u7248 AI \u7ED8\u56FE\u63D2\u4EF6\uFF0C\u63D0\u4F9B\u66F4\u667A\u80FD\u3001\u4FBF\u6377\u7684 NovelAI \u4F53\u9A8C\u3002**\n\n[![](https://img.shields.io/badge/QQ\u7FA4-112879548-blue)](https://qm.qq.com/q/4nKKvckKbu) [![](https://img.shields.io/badge/GitHub-\u4ED3\u5E93\u5730\u5740-black)](https://github.com/hhs2275/koishi-plugin-hhs-huatu)\n\n### \u2728 \u6838\u5FC3\u4EAE\u70B9\n\u65B0\u7248\u672C\u6539\u7528\u6570\u636E\u5E93\u3002\u4F60\u7684\u4F1A\u5458\u6570\u636E\u5728 \u4F60\u7684 Koishi \u6839\u76EE\u5F55/data/hhs-huatu-user-data.json\u3002\u628A\u5B83\u653E\u5728hhs-huatu-import\u6587\u4EF6\u5939\u4E0B\u9762\uFF0C\u7136\u540E\u53D1\u201C\u4F1A\u5458\u8C03\u8BD5--import\u201D\u6307\u4EE4\uFF0C\u5C31\u53EF\u4EE5\u628A\u6570\u636E\u5BFC\u5165\u6570\u636E\u5E93\u3002\n\n\u672C\u63D2\u4EF6\u9488\u5BF9 **NovelAI V4 / V4.5 / V5** \u6A21\u578B\u8FDB\u884C\u4E86\u6DF1\u5EA6\u9002\u914D\uFF0C\u9884\u8BBE\u5FEB\u6377\u6307\u4EE4\uFF0C\u52A9\u4F60\u5FEB\u901F\u5207\u6362\u6A21\u578B\uFF1A\n\n| \u6307\u4EE4 | \u5BF9\u5E94\u6A21\u578B (Model) | \u8BF4\u660E |\n| :--- | :--- | :--- |\n| `nai4` | `nai-diffusion-4-full` | V4 \u5168\u91CF\u6A21\u578B |\n| `nai4c` | `nai-diffusion-4-curated` | V4 \u7CBE\u9009\u6A21\u578B |\n| `nai4-5` | `nai-diffusion-4-5-full` | V4.5 \u5168\u91CF\u6A21\u578B |\n| `nai4-5c` | `nai-diffusion-4-5-curated` | V4.5 \u7CBE\u9009\u6A21\u578B |\n| `nai5` | `nai-diffusion-5-full` | V5 \u5168\u91CF\u6A21\u578B |\n| `nai5c` | `nai-diffusion-5-curated` | V5 \u7CBE\u9009\u6A21\u578B |\n\n### \uD83D\uDEE0\uFE0F \u529F\u80FD\u5217\u8868\n\n**\uD83C\uDFA8 \u7ED8\u56FE\u6838\u5FC3**\n* **\u5168\u9762\u652F\u6301**\uFF1A\u6587\u751F\u56FE\u3001\u56FE\u751F\u56FE\u57FA\u7840\u529F\u80FD\u5B8C\u6574\u3002\n* **\u5C40\u90E8\u91CD\u7ED8 (Inpaint)**\uFF1A\u652F\u6301\u5BF9\u56FE\u7247\u7279\u5B9A\u533A\u57DF\u8FDB\u884C\u91CD\u7ED8\u3002\n* **\u5BFC\u6F14\u5DE5\u5177**\uFF1Anovelai\u5B98\u7F51\u7684\u5BFC\u6F14\u5DE5\u5177\u529F\u80FD\u3002\n* **V4 \u89D2\u8272\u63D0\u793A\u8BCD**\uFF1Anovelai\u5B98\u7F51\u7684\u591A\u89D2\u8272\u63D0\u793A\u8BCD\u7CFB\u7EDF\u3002\n\n**\u2699\uFE0F \u7CFB\u7EDF\u4E0E\u7BA1\u7406**\n* **\u9AD8\u5E76\u53D1\u4F18\u5316**\uFF1A\u5185\u7F6E\u961F\u5217\u7CFB\u7EDF\u4E0E Token \u6C60\u8F6E\u8BE2\uFF0C\u591A\u8D26\u53F7\u8D1F\u8F7D\u5747\u8861\u3002\n* **\u4F1A\u5458\u7BA1\u7406**\uFF1A\u652F\u6301\u7528\u6237\u5206\u7EA7\u7BA1\u7406\uFF1B\u53EF\u914D\u7F6E\u4EC5\u4F1A\u5458\u4F7F\u7528\u4F1A\u6D88\u8017\u914D\u989D\u7684 nai5 / nai5c\u3002\n* **\u667A\u80FD\u5BA1\u6838**\uFF1A\u96C6\u6210\u817E\u8BAF AI \u6216 API4AI\uFF0C\u81EA\u52A8\u8FC7\u6EE4\u8FDD\u89C4\u5185\u5BB9\u3002\n\n### \uD83D\uDDD3\uFE0F \u5F00\u53D1\u8BA1\u5212\n- [\u221A] **\u70B9\u6570\u63A7\u5236\u7CFB\u7EDF**\uFF1A\u6309 NovelAI \u5B98\u7F51\u524D\u7AEF\u516C\u5F0F\u9884\u6263\u672C\u5730\u70B9\u6570\uFF08V5 \u4E3A V4.5 \u7684 1.5 \u500D\uFF09\u3002\u5B9E\u9645\u6263\u8D39\u4EE5 NovelAI \u8D26\u6237\u4E3A\u51C6\u3002\n- [x] ~~**\u6C1B\u56F4\u4F20\u8F93\u529F\u80FD**\uFF1A\u5B9E\u73B0novelai\u5B98\u7F51\u7684\u6C1B\u56F4\u4F20\u8F93\u529F\u80FD\u3002v4\u7248\u672C\u7684\u98CE\u5473\u4F20\u8F93api\u4E0D\u5141\u8BB8\u76F4\u63A5\u53D1\u56FE\uFF0C\u9700\u8981\u5148\u53BB\u5B98\u7F51\u751F\u6210\u5BF9\u5E94\u6587\u4EF6\uFF0C\u6211\u505A\u4E0D\u5230\u3002~~\n- [\u221A] **\u89D2\u8272\u53C2\u8003\u529F\u80FD**\uFF1A\u5B9E\u73B0novelai\u5B98\u7F51\u7684\u89D2\u8272\u53C2\u8003\u529F\u80FD\u3002\uFF08\u5DF2\u88AB\u7CBE\u786E\u53C2\u8003\u66FF\u4EE3\uFF09\n---\n\n### \uD83D\uDCD6 \u66F4\u591A\u8D44\u6E90\n* \u8BE6\u7EC6\u6559\u7A0B\u8BF7\u79FB\u6B65 [GitHub \u4ED3\u5E93](https://github.com/hhs2275/koishi-plugin-hhs-huatu)\n* \u9047\u5230\u95EE\u9898\uFF1F\u6B22\u8FCE\u52A0\u5165\u4EA4\u6D41\u7FA4\uFF1A[112879548](https://qm.qq.com/q/4nKKvckKbu) (\u8054\u7CFB\u7FA4\u4E3B\u53CD\u9988\u95EE\u9898/\u63D0\u4EA4\u5EFA\u8BAE/\u6109\u5FEB\u73A9\u800D)\n";
3
+ export declare const usage = "\n# \uD83C\uDFA8 hhs-huatu \u63D2\u4EF6\n\n> **\u57FA\u4E8E [novelai-bot](https://bot.novelai.dev/) \u7684\u589E\u5F3A\u7248 AI \u7ED8\u56FE\u63D2\u4EF6\uFF0C\u63D0\u4F9B\u66F4\u667A\u80FD\u3001\u4FBF\u6377\u7684 NovelAI \u4F53\u9A8C\u3002**\n\n[![](https://img.shields.io/badge/QQ\u7FA4-112879548-blue)](https://qm.qq.com/q/4nKKvckKbu) [![](https://img.shields.io/badge/GitHub-\u4ED3\u5E93\u5730\u5740-black)](https://github.com/hhs2275/koishi-plugin-hhs-huatu)\n\n### \u2728 \u6838\u5FC3\u4EAE\u70B9\n\u65B0\u7248\u672C\u6539\u7528\u6570\u636E\u5E93\u3002\u4F60\u7684\u4F1A\u5458\u6570\u636E\u5728 \u4F60\u7684 Koishi \u6839\u76EE\u5F55/data/hhs-huatu-user-data.json\u3002\u628A\u5B83\u653E\u5728hhs-huatu-import\u6587\u4EF6\u5939\u4E0B\u9762\uFF0C\u7136\u540E\u53D1\u201C\u4F1A\u5458\u8C03\u8BD5--import\u201D\u6307\u4EE4\uFF0C\u5C31\u53EF\u4EE5\u628A\u6570\u636E\u5BFC\u5165\u6570\u636E\u5E93\u3002\n\n\u672C\u63D2\u4EF6\u9488\u5BF9 **NovelAI V4 / V4.5 / V5** \u6A21\u578B\u8FDB\u884C\u4E86\u6DF1\u5EA6\u9002\u914D\uFF0C\u9884\u8BBE\u5FEB\u6377\u6307\u4EE4\uFF0C\u52A9\u4F60\u5FEB\u901F\u5207\u6362\u6A21\u578B\uFF1A\n\n| \u6307\u4EE4 | \u5BF9\u5E94\u6A21\u578B (Model) | \u8BF4\u660E |\n| :--- | :--- | :--- |\n| `nai4` | `nai-diffusion-4-full` | V4 \u5168\u91CF\u6A21\u578B |\n| `nai4c` | `nai-diffusion-4-curated` | V4 \u7CBE\u9009\u6A21\u578B |\n| `nai4-5` | `nai-diffusion-4-5-full` | V4.5 \u5168\u91CF\u6A21\u578B |\n| `nai4-5c` | `nai-diffusion-4-5-curated` | V4.5 \u7CBE\u9009\u6A21\u578B |\n| `nai5` | `nai-diffusion-5-full` | V5 \u5168\u91CF\u6A21\u578B |\n| `nai5m` | `nai-diffusion-5-full-medium` | V5 Medium\uFF08Effort=Medium\uFF0C\u56FA\u5B9A 14 \u6B65\uFF0C\u6BCF\u6B21\u6263 0.6 \u6B21 nai5 \u989D\u5EA6\uFF09 |\n| `nai5c` | `nai-diffusion-5-curated` | V5 \u7CBE\u9009\u6A21\u578B |\n\n### \uD83D\uDEE0\uFE0F \u529F\u80FD\u5217\u8868\n\n**\uD83C\uDFA8 \u7ED8\u56FE\u6838\u5FC3**\n* **\u5168\u9762\u652F\u6301**\uFF1A\u6587\u751F\u56FE\u3001\u56FE\u751F\u56FE\u57FA\u7840\u529F\u80FD\u5B8C\u6574\u3002\n* **\u5C40\u90E8\u91CD\u7ED8 (Inpaint)**\uFF1A\u652F\u6301\u5BF9\u56FE\u7247\u7279\u5B9A\u533A\u57DF\u8FDB\u884C\u91CD\u7ED8\u3002\n* **\u5BFC\u6F14\u5DE5\u5177**\uFF1Anovelai\u5B98\u7F51\u7684\u5BFC\u6F14\u5DE5\u5177\u529F\u80FD\u3002\n* **V4 \u89D2\u8272\u63D0\u793A\u8BCD**\uFF1Anovelai\u5B98\u7F51\u7684\u591A\u89D2\u8272\u63D0\u793A\u8BCD\u7CFB\u7EDF\u3002\n\n**\u2699\uFE0F \u7CFB\u7EDF\u4E0E\u7BA1\u7406**\n* **\u9AD8\u5E76\u53D1\u4F18\u5316**\uFF1A\u5185\u7F6E\u961F\u5217\u7CFB\u7EDF\u4E0E Token \u6C60\u8F6E\u8BE2\uFF0C\u591A\u8D26\u53F7\u8D1F\u8F7D\u5747\u8861\u3002\n* **\u4F1A\u5458\u7BA1\u7406**\uFF1A\u652F\u6301\u7528\u6237\u5206\u7EA7\u7BA1\u7406\uFF1B\u53EF\u914D\u7F6E\u4EC5\u4F1A\u5458\u4F7F\u7528\u4F1A\u6D88\u8017\u914D\u989D\u7684 nai5 / nai5c\u3002\n* **\u667A\u80FD\u5BA1\u6838**\uFF1A\u96C6\u6210\u817E\u8BAF AI \u6216 API4AI\uFF0C\u81EA\u52A8\u8FC7\u6EE4\u8FDD\u89C4\u5185\u5BB9\u3002\n\n### \uD83D\uDDD3\uFE0F \u5F00\u53D1\u8BA1\u5212\n- [\u221A] **\u70B9\u6570\u63A7\u5236\u7CFB\u7EDF**\uFF1A\u6309 NovelAI \u5B98\u7F51\u524D\u7AEF\u516C\u5F0F\u9884\u6263\u672C\u5730\u70B9\u6570\uFF08V5 \u4E3A V4.5 \u7684 1.5 \u500D\uFF09\u3002\u5B9E\u9645\u6263\u8D39\u4EE5 NovelAI \u8D26\u6237\u4E3A\u51C6\u3002\n- [x] ~~**\u6C1B\u56F4\u4F20\u8F93\u529F\u80FD**\uFF1A\u5B9E\u73B0novelai\u5B98\u7F51\u7684\u6C1B\u56F4\u4F20\u8F93\u529F\u80FD\u3002v4\u7248\u672C\u7684\u98CE\u5473\u4F20\u8F93api\u4E0D\u5141\u8BB8\u76F4\u63A5\u53D1\u56FE\uFF0C\u9700\u8981\u5148\u53BB\u5B98\u7F51\u751F\u6210\u5BF9\u5E94\u6587\u4EF6\uFF0C\u6211\u505A\u4E0D\u5230\u3002~~\n- [\u221A] **\u89D2\u8272\u53C2\u8003\u529F\u80FD**\uFF1A\u5B9E\u73B0novelai\u5B98\u7F51\u7684\u89D2\u8272\u53C2\u8003\u529F\u80FD\u3002\uFF08\u5DF2\u88AB\u7CBE\u786E\u53C2\u8003\u66FF\u4EE3\uFF09\n---\n\n### \uD83D\uDCD6 \u66F4\u591A\u8D44\u6E90\n* \u8BE6\u7EC6\u6559\u7A0B\u8BF7\u79FB\u6B65 [GitHub \u4ED3\u5E93](https://github.com/hhs2275/koishi-plugin-hhs-huatu)\n* \u9047\u5230\u95EE\u9898\uFF1F\u6B22\u8FCE\u52A0\u5165\u4EA4\u6D41\u7FA4\uFF1A[112879548](https://qm.qq.com/q/4nKKvckKbu) (\u8054\u7CFB\u7FA4\u4E3B\u53CD\u9988\u95EE\u9898/\u63D0\u4EA4\u5EFA\u8BAE/\u6109\u5FEB\u73A9\u800D)\n";
4
4
  export * from './config';
5
5
  export declare const reactive = true;
6
6
  export declare const name = "hhs-huatu";
package/lib/index.js CHANGED
@@ -586,7 +586,8 @@ function modelSupportsCharacters(model) {
586
586
  "nai-diffusion-4-5-curated",
587
587
  "nai-diffusion-4-5-full",
588
588
  "nai-diffusion-5-curated",
589
- "nai-diffusion-5-full"
589
+ "nai-diffusion-5-full",
590
+ "nai-diffusion-5-full-medium"
590
591
  ];
591
592
  return supportedModels.includes(model);
592
593
  }
@@ -910,7 +911,8 @@ var modelMap = {
910
911
  "nai-v4-5-curated": "nai-diffusion-4-5-curated",
911
912
  "nai-v4-5-full": "nai-diffusion-4-5-full",
912
913
  "nai-v5-curated": "nai-diffusion-5-curated",
913
- "nai-v5-full": "nai-diffusion-5-full"
914
+ "nai-v5-full": "nai-diffusion-5-full",
915
+ "nai-v5-full-medium": "nai-diffusion-5-full-medium"
914
916
  };
915
917
  var orientMap = {
916
918
  landscape: { height: 832, width: 1216 },
@@ -1263,6 +1265,10 @@ var Config = import_koishi2.Schema.intersect([
1263
1265
  model: import_koishi2.Schema.const("nai-v5-full"),
1264
1266
  ...NAI4ParamConfig.dict
1265
1267
  }),
1268
+ import_koishi2.Schema.object({
1269
+ model: import_koishi2.Schema.const("nai-v5-full-medium"),
1270
+ ...NAI4ParamConfig.dict
1271
+ }),
1266
1272
  import_koishi2.Schema.object({ sampler: sampler.createSchema(sampler.nai) })
1267
1273
  ]),
1268
1274
  import_koishi2.Schema.object({ decrisper: import_koishi2.Schema.boolean().description("默认启用 decrisper") })
@@ -1352,7 +1358,6 @@ var Config = import_koishi2.Schema.intersect([
1352
1358
  ]).description("点数模式").default("periodic"),
1353
1359
  pointsDefault: import_koishi2.Schema.number().description("会员默认初始点数").default(200),
1354
1360
  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
1361
  pointsRefreshAmount: import_koishi2.Schema.number().description("每次刷新恢复的点数").default(200),
1357
1362
  pointsRefreshIncludeNonMember: import_koishi2.Schema.boolean().description("刷新范围是否包括非会员").default(false)
1358
1363
  }).description("点数控制设置"),
@@ -1892,8 +1897,12 @@ async function fetchSubscription(ctx, config, token) {
1892
1897
  });
1893
1898
  }
1894
1899
  __name(fetchSubscription, "fetchSubscription");
1900
+ function isNovelAIV5MediumModel(model) {
1901
+ return model === "nai-diffusion-5-full-medium";
1902
+ }
1903
+ __name(isNovelAIV5MediumModel, "isNovelAIV5MediumModel");
1895
1904
  function isNovelAIV5Model(model) {
1896
- return model === "nai-diffusion-5-curated" || model === "nai-diffusion-5-full";
1905
+ return model === "nai-diffusion-5-curated" || model === "nai-diffusion-5-full" || isNovelAIV5MediumModel(model);
1897
1906
  }
1898
1907
  __name(isNovelAIV5Model, "isNovelAIV5Model");
1899
1908
  function isOpusQuotaExhausted(subscription) {
@@ -1934,7 +1943,6 @@ __name(formatOpusQuota, "formatOpusQuota");
1934
1943
  // src/membershipSystem.ts
1935
1944
  var MIN_TIER = 1;
1936
1945
  var MAX_TIER = 5;
1937
- var POINTS_REFRESH_TOLERANCE = 1e3;
1938
1946
  var MembershipSystem = class {
1939
1947
  constructor(ctx, config) {
1940
1948
  this.ctx = ctx;
@@ -1944,15 +1952,13 @@ var MembershipSystem = class {
1944
1952
  this.loadUserDataFromDB();
1945
1953
  this.setupCleanupTask();
1946
1954
  this.setupReminderTask();
1947
- this.setupPointsRefreshTask();
1948
- ctx.accept(["membershipEnabled", "memberCleanupEnabled", "memberCleanupTime", "memberExpiryReminderEnabled", "memberReminderTime", "memberReminderHours", "pointsEnabled", "pointsMode", "pointsRefreshCycleDays", "pointsRefreshCheckTime"], () => {
1955
+ ctx.accept(["membershipEnabled", "memberCleanupEnabled", "memberCleanupTime", "memberExpiryReminderEnabled", "memberReminderTime", "memberReminderHours"], () => {
1949
1956
  ctx.logger.info("会员系统配置已更新,重新安排定时任务");
1950
1957
  if (this.config.membershipEnabled && Object.keys(this.userData).length === 0) {
1951
1958
  this.loadUserDataFromDB().catch((err) => this.ctx.logger.error("动态加载用户数据失败", err));
1952
1959
  }
1953
1960
  this.setupCleanupTask();
1954
1961
  this.setupReminderTask();
1955
- this.setupPointsRefreshTask();
1956
1962
  });
1957
1963
  }
1958
1964
  static {
@@ -1965,14 +1971,9 @@ var MembershipSystem = class {
1965
1971
  // 定时任务取消函数
1966
1972
  cleanupTimerDispose = null;
1967
1973
  reminderTimerDispose = null;
1968
- pointsRefreshTimerDispose = null;
1969
- // 点数刷新的周期锚点:上次刷新**计划**的检查点(永远对齐到每日检查时刻);0 表示尚未初始化。
1970
- // 周期 = 锚点 + pointsRefreshCycleDays,因此周期天然对齐检查点、不会因手动刷新或补刷而漂移。
1971
- pointsRefreshAnchor = 0;
1972
- // 上次点数刷新的**实际**执行时刻(事实记录):手动刷新、停机补刷时可能与锚点不同。
1973
- pointsRefreshLastActualAt = 0;
1974
- // 库中记录仍是旧语义(lastUsed = 实际刷新时刻),加载后需要回写一次
1975
- pointsRefreshStateNeedsMigration = false;
1974
+ // 上一版本的全局刷新时间,仅用于迁移旧数据
1975
+ legacyPointsRefreshTime = 0;
1976
+ pointsRefreshLocks = /* @__PURE__ */ new Map();
1976
1977
  // 确保导入文件夹存在
1977
1978
  ensureImportDir() {
1978
1979
  try {
@@ -2000,8 +2001,11 @@ var MembershipSystem = class {
2000
2001
  dailyLimit: "unsigned",
2001
2002
  lastDrawTime: "unsigned",
2002
2003
  points: "integer",
2003
- nai5DailyUsage: "unsigned",
2004
- nai5Bucket: "unsigned",
2004
+ pointsRefreshAt: "unsigned",
2005
+ pointsLastRefreshedAt: "unsigned",
2006
+ // nai5DailyUsage / nai5Bucket:nai5m(Medium)按 0.6 权重扣减,会出现小数,用 float
2007
+ nai5DailyUsage: "float",
2008
+ nai5Bucket: "float",
2005
2009
  nai5BucketDay: "unsigned",
2006
2010
  nai5DepositTier: "unsigned"
2007
2011
  }, {
@@ -2030,10 +2034,7 @@ var MembershipSystem = class {
2030
2034
  let loadedCount = 0;
2031
2035
  for (const row of rows) {
2032
2036
  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;
2037
+ this.legacyPointsRefreshTime = row.lastUsed || 0;
2037
2038
  continue;
2038
2039
  }
2039
2040
  loadedCount++;
@@ -2045,6 +2046,8 @@ var MembershipSystem = class {
2045
2046
  dailyLimit: row.dailyLimit,
2046
2047
  lastDrawTime: row.lastDrawTime,
2047
2048
  points: row.points,
2049
+ pointsRefreshAt: row.pointsRefreshAt || 0,
2050
+ pointsLastRefreshedAt: row.pointsLastRefreshedAt || 0,
2048
2051
  nai5DailyUsage: row.nai5DailyUsage || 0,
2049
2052
  nai5Bucket: row.nai5Bucket || 0,
2050
2053
  nai5BucketDay: row.nai5BucketDay || 0,
@@ -2054,19 +2057,7 @@ var MembershipSystem = class {
2054
2057
  this.ctx.logger.info(`会员系统数据从数据库加载成功,共 ${loadedCount} 条记录`);
2055
2058
  await this.loadCardsFromDB();
2056
2059
  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
- }
2060
+ await this.migratePointsRefreshPlans();
2070
2061
  } catch (err) {
2071
2062
  this.ctx.logger.error("从数据库加载会员系统数据失败", err);
2072
2063
  }
@@ -2115,7 +2106,24 @@ var MembershipSystem = class {
2115
2106
  this.ctx.logger.info(`会员等级迁移完成:已为 ${migrated} 位存量会员生成 Lv1 会员卡`);
2116
2107
  }
2117
2108
  }
2118
- // 将指定用户的所有卡同步到数据库(先删后建,卡数量小,简单可靠)
2109
+ /**
2110
+ * 将旧版全局刷新时间迁移为用户级刷新计划。
2111
+ * 迁移只补字段,不改变当前点数、会员卡或使用记录。
2112
+ */
2113
+ async migratePointsRefreshPlans() {
2114
+ if (!this.legacyPointsRefreshTime || this.config.pointsMode !== "periodic") return;
2115
+ const cycleMs = (this.config.pointsRefreshCycleDays || 30) * 24 * 60 * 60 * 1e3;
2116
+ let changed = false;
2117
+ for (const userId in this.userData) {
2118
+ const user = this.userData[userId];
2119
+ const tier = this.computeActiveTier(user.cards || []);
2120
+ const isInScope = tier > 0 || this.config.pointsRefreshIncludeNonMember;
2121
+ if (!isInScope || user.pointsRefreshAt) continue;
2122
+ user.pointsRefreshAt = this.legacyPointsRefreshTime + cycleMs;
2123
+ changed = true;
2124
+ }
2125
+ if (changed) await this.saveUserData();
2126
+ }
2119
2127
  async syncCardsToDB(userId) {
2120
2128
  try {
2121
2129
  const user = this.userData[userId];
@@ -2155,25 +2163,9 @@ var MembershipSystem = class {
2155
2163
  this.ctx.logger.error("批量保存会员卡数据失败", err);
2156
2164
  }
2157
2165
  }
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
- }
2166
+ /** 旧版全局刷新记录仅保留用于读取迁移,不再写入。 */
2167
+ async saveLastPointsRefreshTime() {
2168
+ return;
2177
2169
  }
2178
2170
  // 将单个用户数据同步到数据库
2179
2171
  async syncUserToDB(userId) {
@@ -2189,6 +2181,8 @@ var MembershipSystem = class {
2189
2181
  dailyLimit: user.dailyLimit,
2190
2182
  lastDrawTime: user.lastDrawTime || 0,
2191
2183
  points: user.points || 0,
2184
+ pointsRefreshAt: user.pointsRefreshAt || 0,
2185
+ pointsLastRefreshedAt: user.pointsLastRefreshedAt || 0,
2192
2186
  nai5DailyUsage: user.nai5DailyUsage || 0,
2193
2187
  nai5Bucket: user.nai5Bucket || 0,
2194
2188
  nai5BucketDay: user.nai5BucketDay || 0,
@@ -2214,6 +2208,8 @@ var MembershipSystem = class {
2214
2208
  dailyLimit: user.dailyLimit,
2215
2209
  lastDrawTime: user.lastDrawTime || 0,
2216
2210
  points: user.points || 0,
2211
+ pointsRefreshAt: user.pointsRefreshAt || 0,
2212
+ pointsLastRefreshedAt: user.pointsLastRefreshedAt || 0,
2217
2213
  nai5DailyUsage: user.nai5DailyUsage || 0,
2218
2214
  nai5Bucket: user.nai5Bucket || 0,
2219
2215
  nai5BucketDay: user.nai5BucketDay || 0,
@@ -2385,6 +2381,7 @@ var MembershipSystem = class {
2385
2381
  if (this.config.pointsMode === "periodic") {
2386
2382
  const refreshAmount = this.getTierBenefit(safeTier).pointsRefresh;
2387
2383
  user.points = refreshAmount;
2384
+ user.pointsRefreshAt = now + (this.config.pointsRefreshCycleDays || 30) * 24 * 60 * 60 * 1e3;
2388
2385
  this.ctx.logger.info(`[会员系统] 用户 ${userId} 首次成为 Lv${safeTier} 会员,点数已初始化为 ${refreshAmount}`);
2389
2386
  } else if (!user.points) {
2390
2387
  user.points = this.config.pointsDefault || 200;
@@ -2510,6 +2507,7 @@ var MembershipSystem = class {
2510
2507
  async deductPoints(userId, amount) {
2511
2508
  if (!this.config.pointsEnabled || amount <= 0) return 0;
2512
2509
  this.ensureUserData(userId);
2510
+ await this.ensurePointsFresh(userId);
2513
2511
  const user = this.userData[userId];
2514
2512
  const currentPoints = user.points || 0;
2515
2513
  if (currentPoints < amount) {
@@ -2525,6 +2523,7 @@ var MembershipSystem = class {
2525
2523
  async refundPoints(userId, amount) {
2526
2524
  if (!this.config.pointsEnabled || amount <= 0) return;
2527
2525
  this.ensureUserData(userId);
2526
+ await this.ensurePointsFresh(userId);
2528
2527
  const user = this.userData[userId];
2529
2528
  user.points = (user.points || 0) + amount;
2530
2529
  await this.syncUserToDB(userId);
@@ -2547,6 +2546,7 @@ var MembershipSystem = class {
2547
2546
  let updatedCount = 0;
2548
2547
  const now = Date.now();
2549
2548
  for (const userId in this.userData) {
2549
+ await this.ensurePointsFresh(userId);
2550
2550
  const user = this.userData[userId];
2551
2551
  if (membersOnly && (!user.isMember || user.membershipExpiry < now)) {
2552
2552
  continue;
@@ -2565,245 +2565,70 @@ var MembershipSystem = class {
2565
2565
  const message = updatedCount > 0 ? `✅ 成功为 ${updatedCount} 位用户${action} ${Math.abs(amount)} 点数` : `⚠️ 没有符合条件的用户`;
2566
2566
  return { count: updatedCount, message };
2567
2567
  }
2568
- /**
2569
- * 刷新点数(定期任务 / 手动触发)。
2570
- * 等级制度下按刷新时刻的生效等级取刷新量:Lv1 = pointsRefreshAmount,Lv2-Lv5 = 各档 pointsRefresh;
2571
- * 非会员若在刷新范围内,刷为 Lv1 档值(与现有行为一致)。
2572
- * @param tierFilter 指定生效等级时仅刷新该等级会员;缺省刷新全部范围
2573
- */
2568
+ /** 确保用户在读取或扣除点数前完成到期的惰性刷新。 */
2569
+ async ensurePointsFresh(userId) {
2570
+ if (!this.config.pointsEnabled || this.config.pointsMode !== "periodic") return;
2571
+ this.ensureUserData(userId);
2572
+ const running = this.pointsRefreshLocks.get(userId);
2573
+ if (running) return running;
2574
+ const task = this.refreshUserPointsIfDue(userId).finally(() => {
2575
+ if (this.pointsRefreshLocks.get(userId) === task) this.pointsRefreshLocks.delete(userId);
2576
+ });
2577
+ this.pointsRefreshLocks.set(userId, task);
2578
+ return task;
2579
+ }
2580
+ async refreshUserPointsIfDue(userId) {
2581
+ const user = this.userData[userId];
2582
+ const now = Date.now();
2583
+ const cycleMs = (this.config.pointsRefreshCycleDays || 30) * 24 * 60 * 60 * 1e3;
2584
+ const tier = this.getActiveTier(userId);
2585
+ const inScope = tier > 0 || this.config.pointsRefreshIncludeNonMember;
2586
+ if (!user.pointsRefreshAt) {
2587
+ if (!inScope) return;
2588
+ user.pointsRefreshAt = now + cycleMs;
2589
+ await this.syncUserToDB(userId);
2590
+ return;
2591
+ }
2592
+ if (user.pointsRefreshAt > now || !inScope) return;
2593
+ const plannedAt = user.pointsRefreshAt;
2594
+ user.points = this.getTierBenefit(tier > 0 ? tier : 1).pointsRefresh;
2595
+ user.pointsLastRefreshedAt = now;
2596
+ do {
2597
+ user.pointsRefreshAt += cycleMs;
2598
+ } while (user.pointsRefreshAt <= now);
2599
+ await this.syncUserToDB(userId);
2600
+ this.ctx.logger.info(`[会员系统] 用户 ${userId} 惰性刷新点数:${plannedAt} → ${user.pointsRefreshAt}`);
2601
+ }
2602
+ /** 手动刷新点数,不改变用户自动刷新计划。 */
2574
2603
  async refreshPoints(tierFilter) {
2575
2604
  if (!this.config.pointsEnabled || this.config.pointsMode !== "periodic") {
2576
2605
  return { count: 0, message: "点数刷新未启用或不是周期模式" };
2577
2606
  }
2578
2607
  let refreshedCount = 0;
2579
- const now = Date.now();
2580
2608
  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
- }
2609
+ const tier = this.getActiveTier(userId);
2610
+ if (tier <= 0 && !this.config.pointsRefreshIncludeNonMember) continue;
2588
2611
  if (tierFilter !== void 0 && tier !== tierFilter) continue;
2612
+ await this.ensurePointsFresh(userId);
2613
+ const user = this.userData[userId];
2589
2614
  user.points = this.getTierBenefit(tier > 0 ? tier : 1).pointsRefresh;
2615
+ await this.syncUserToDB(userId);
2590
2616
  refreshedCount++;
2591
2617
  }
2592
- this.pointsRefreshLastActualAt = now;
2593
- await this.savePointsRefreshState();
2594
- if (refreshedCount > 0) {
2595
- await this.saveUserData();
2596
- }
2597
2618
  const scope = tierFilter !== void 0 ? `Lv${tierFilter} 会员` : "用户";
2598
- const message = refreshedCount > 0 ? `✅ 点数刷新完成,共刷新 ${refreshedCount} 位${scope}的点数(按各自生效等级)` : "⚠️ 没有符合条件的用户需要刷新点数";
2619
+ const message = refreshedCount > 0 ? `✅ 已手动刷新 ${refreshedCount} 位${scope}的点数,自动刷新计划未改变` : "⚠️ 没有符合条件的用户需要刷新点数";
2599
2620
  this.ctx.logger.info(message);
2600
2621
  return { count: refreshedCount, message };
2601
2622
  }
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() {
2623
+ getNextPointsRefreshAt(userId) {
2651
2624
  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();
2625
+ return this.userData[userId]?.pointsRefreshAt || 0;
2726
2626
  }
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;
2764
- }
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`;
2627
+ getDaysUntilNextRefresh(userId) {
2628
+ if (!this.config.pointsEnabled || this.config.pointsMode !== "periodic") return -1;
2629
+ const at = userId ? this.getNextPointsRefreshAt(userId) : 0;
2630
+ if (!at) return 0;
2631
+ return Math.max(0, Math.ceil((at - Date.now()) / (24 * 60 * 60 * 1e3)));
2807
2632
  }
2808
2633
  /**
2809
2634
  * 从 JSON 文件导入数据到数据库。
@@ -3050,39 +2875,7 @@ var MembershipSystem = class {
3050
2875
  scheduleReminder();
3051
2876
  }
3052
2877
  }
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
- }
2878
+ // 点数刷新采用按用户惰性执行,不注册全局定时任务。
3086
2879
  // 给所有有效会员卡增加天数(按卡粒度,每张有效卡独立延长)。
3087
2880
  // @param tierFilter 指定生效等级时仅操作该等级会员;缺省操作全部会员
3088
2881
  async addDaysToAllMembers(days, tierFilter) {
@@ -3124,6 +2917,8 @@ var MembershipSystem = class {
3124
2917
  lastUsed: Date.now(),
3125
2918
  dailyLimit: this.config.nonMemberDailyLimit,
3126
2919
  points: 0,
2920
+ pointsRefreshAt: 0,
2921
+ pointsLastRefreshedAt: 0,
3127
2922
  nai5DailyUsage: 0,
3128
2923
  nai5Bucket: 0,
3129
2924
  nai5BucketDay: 0,
@@ -3168,14 +2963,15 @@ var MembershipSystem = class {
3168
2963
  const bucketDay = user.nai5BucketDay || 0;
3169
2964
  if (!bucketDay) {
3170
2965
  const seedLimit = activeTier > 0 ? this.getTierBenefit(activeTier).nai5DailyLimit : 0;
3171
- user.nai5Bucket = seedLimit > 0 ? Math.max(0, seedLimit - (user.nai5DailyUsage || 0)) : 0;
2966
+ const seed = Math.max(0, seedLimit - (user.nai5DailyUsage || 0));
2967
+ user.nai5Bucket = seedLimit > 0 ? Math.round(seed * 10) / 10 : 0;
3172
2968
  user.nai5BucketDay = todayStart;
3173
2969
  user.nai5DepositTier = activeTier;
3174
2970
  } else if (bucketDay < todayStart && activeTier > 0) {
3175
2971
  const limit = this.getTierBenefit(activeTier).nai5DailyLimit;
3176
2972
  if (limit > 0) {
3177
2973
  const daysMissed = Math.max(1, Math.min(7, Math.round((todayStart - bucketDay) / 864e5)));
3178
- user.nai5Bucket = Math.min(limit * 7, (user.nai5Bucket || 0) + limit * daysMissed);
2974
+ user.nai5Bucket = Math.round(Math.min(limit * 7, (user.nai5Bucket || 0) + limit * daysMissed) * 10) / 10;
3179
2975
  user.nai5BucketDay = todayStart;
3180
2976
  user.nai5DepositTier = activeTier;
3181
2977
  }
@@ -3241,6 +3037,17 @@ var MembershipSystem = class {
3241
3037
  isNai5Model(model) {
3242
3038
  return isNovelAIV5Model(modelMap[model] || model);
3243
3039
  }
3040
+ /** 是否为 V5 Medium(Effort=Medium)模型。 */
3041
+ isNai5MediumModel(model) {
3042
+ return isNovelAIV5MediumModel(modelMap[model] || model);
3043
+ }
3044
+ /**
3045
+ * 该模型每次画图消耗的 nai5 免费次数权重。
3046
+ * Medium 档一次只扣 0.6 次,其余模型为 1 次。
3047
+ */
3048
+ getNai5UsageWeight(model) {
3049
+ return this.isNai5MediumModel(model) ? 0.6 : 1;
3050
+ }
3244
3051
  /**
3245
3052
  * 获取用户生效等级的 nai5 / nai5c 每日免费限额。
3246
3053
  * - 传入 userId:Lv1 = 配置字段 memberNai5DailyLimit,Lv2-Lv5 = tierBenefits;非会员返回 0
@@ -3348,24 +3155,28 @@ var MembershipSystem = class {
3348
3155
  }
3349
3156
  /**
3350
3157
  * 本次 nai5 任务中,超出会员免费额度、需要按 Anlas 计费的张数。
3158
+ * - Medium 模型每次画图消耗 0.6 次免费额度(weight < 1)
3351
3159
  * - 周桶模式:可用 = 桶余额 − 并发预占,超出可用部分计费(点数支付的部分不扣桶)
3352
3160
  * - 旧模式:日限按用户生效等级取值;日限为 0 表示不额外限制,全部走 Opus 免费档(标准分辨率不扣点)
3353
3161
  */
3354
- getNai5OverageCount(userId, drawCount = 1) {
3162
+ getNai5OverageCount(userId, drawCount = 1, model) {
3355
3163
  if (!this.config.membershipEnabled) return 0;
3356
3164
  const limit = this.getNai5DailyLimit(userId);
3357
3165
  if (limit <= 0 || drawCount <= 0) return 0;
3358
3166
  if (!this.isActiveMember(userId)) return 0;
3167
+ const weight = this.getNai5UsageWeight(model);
3168
+ const freeCountOf = /* @__PURE__ */ __name((available) => Math.min(drawCount, Math.floor(available / weight + 1e-9)), "freeCountOf");
3359
3169
  if (this.isNai5BucketEnabled()) {
3360
3170
  const bucket = this.userData[userId]?.nai5Bucket || 0;
3361
3171
  const available = Math.max(0, bucket - (this.pendingNai5Usage[userId] || 0));
3362
- return Math.max(0, drawCount - available);
3172
+ return drawCount - freeCountOf(available);
3363
3173
  }
3364
3174
  const used = this.getNai5DailyUsage(userId) + (this.pendingNai5Usage[userId] || 0);
3365
- return Math.min(drawCount, Math.max(0, used + drawCount - limit));
3175
+ const remaining = Math.max(0, limit - used);
3176
+ return drawCount - freeCountOf(remaining);
3366
3177
  }
3367
3178
  shouldChargeNai5Overage(userId, model, drawCount = 1) {
3368
- return this.isNai5Model(model) && this.getNai5OverageCount(userId, drawCount) > 0;
3179
+ return this.isNai5Model(model) && this.getNai5OverageCount(userId, drawCount, model) > 0;
3369
3180
  }
3370
3181
  reserveNai5Usage(userId, count) {
3371
3182
  if (!this.config.membershipEnabled || count <= 0) return;
@@ -3387,10 +3198,13 @@ var MembershipSystem = class {
3387
3198
  this.userData[userId].dailyUsage += drawCount;
3388
3199
  this.userData[userId].lastUsed = now;
3389
3200
  if (this.isNai5Model(model)) {
3390
- const add = nai5Count ?? drawCount;
3391
- this.userData[userId].nai5DailyUsage = (this.userData[userId].nai5DailyUsage || 0) + add;
3201
+ const weight = this.getNai5UsageWeight(model);
3202
+ const add = Math.round((nai5Count ?? drawCount) * weight * 10) / 10;
3203
+ const usage2 = Math.round(((this.userData[userId].nai5DailyUsage || 0) + add) * 10) / 10;
3204
+ this.userData[userId].nai5DailyUsage = usage2;
3392
3205
  if (this.isNai5BucketEnabled()) {
3393
- this.userData[userId].nai5Bucket = Math.max(0, (this.userData[userId].nai5Bucket || 0) - add);
3206
+ const balance = Math.max(0, Math.round(((this.userData[userId].nai5Bucket || 0) - add) * 10) / 10);
3207
+ this.userData[userId].nai5Bucket = balance;
3394
3208
  }
3395
3209
  this.releaseNai5Usage(userId, add);
3396
3210
  }
@@ -4010,7 +3824,7 @@ async function generateImageInner(runtime, session, options2, input) {
4010
3824
  caption: { base_caption: base, char_captions: [] },
4011
3825
  ...!isNegative && { use_coords: false, use_order: true }
4012
3826
  }), "createPrompt");
4013
- const isNAI5 = model === "nai-diffusion-5-curated" || model === "nai-diffusion-5-full";
3827
+ const isNAI5 = model === "nai-diffusion-5-curated" || model === "nai-diffusion-5-full" || model === "nai-diffusion-5-full-medium";
4014
3828
  parameters.params_version = isNAI5 ? 4 : 3;
4015
3829
  parameters.sampler = sampler.sd2nai(options2.sampler, model);
4016
3830
  if (parameters.uc) {
@@ -4023,6 +3837,12 @@ async function generateImageInner(runtime, session, options2, input) {
4023
3837
  parameters.add_original_image = false;
4024
3838
  parameters.legacy = false;
4025
3839
  parameters.cfg_rescale = options2.rescale ?? session.resolve(runtime.config.rescale);
3840
+ if (model === "nai-diffusion-5-full-medium") {
3841
+ parameters.steps = 14;
3842
+ parameters.sampler = "k_euler_ancestral";
3843
+ parameters.cfg_rescale = 0;
3844
+ parameters.ucPreset = 2;
3845
+ }
4026
3846
  const isNAI3 = model === "nai-diffusion-3";
4027
3847
  const isNAI4 = model === "nai-diffusion-4-curated-preview" || model === "nai-diffusion-4-full" || model === "nai-diffusion-4-5-curated" || model === "nai-diffusion-4-5-full" || isNAI5;
4028
3848
  if (isNAI3) {
@@ -4495,11 +4315,18 @@ async function generateImageInner(runtime, session, options2, input) {
4495
4315
  if (!auditResult.pass) {
4496
4316
  runtime.ctx.logger.warn(`[图片审核] 审核未通过: ${auditResult.message}, 分数: ${auditResult.score}`);
4497
4317
  if (runtime.config.membershipEnabled) {
4318
+ const model2 = options2.model || runtime.config.model;
4498
4319
  const nai5Count = options2.batch || 1;
4499
- runtime.membershipSystem.incrementUsage(session.userId, 1, options2.model || runtime.config.model, nai5Count);
4320
+ const freeQuota = options2._nai5FreeCount;
4321
+ const freeCount = typeof freeQuota === "number" ? Math.min(freeQuota, nai5Count) : nai5Count;
4322
+ runtime.membershipSystem.incrementUsage(session.userId, 1, model2, freeCount);
4500
4323
  if (options2._reservedNai5) {
4324
+ const weight = runtime.membershipSystem.getNai5UsageWeight(model2);
4325
+ options2._reservedNai5 = Math.max(0, options2._reservedNai5 - Math.round(freeCount * weight * 10) / 10);
4326
+ }
4327
+ if (typeof freeQuota === "number") {
4501
4328
  ;
4502
- options2._reservedNai5 = Math.max(0, options2._reservedNai5 - nai5Count);
4329
+ options2._nai5FreeCount = Math.max(0, freeQuota - freeCount);
4503
4330
  }
4504
4331
  }
4505
4332
  if (runtime.config.muteOnReviewFailed && session.guildId && session.userId) {
@@ -4569,11 +4396,18 @@ async function generateImageInner(runtime, session, options2, input) {
4569
4396
  runtime.ctx.logger.debug(`${session.uid}: ${finalPrompt}`);
4570
4397
  const messageIds = await session.send(getContent());
4571
4398
  if (runtime.config.membershipEnabled) {
4399
+ const model2 = options2.model || runtime.config.model;
4572
4400
  const nai5Count = options2.batch || 1;
4573
- runtime.membershipSystem.incrementUsage(session.userId, 1, options2.model || runtime.config.model, nai5Count);
4401
+ const freeQuota = options2._nai5FreeCount;
4402
+ const freeCount = typeof freeQuota === "number" ? Math.min(freeQuota, nai5Count) : nai5Count;
4403
+ runtime.membershipSystem.incrementUsage(session.userId, 1, model2, freeCount);
4574
4404
  if (options2._reservedNai5) {
4405
+ const weight = runtime.membershipSystem.getNai5UsageWeight(model2);
4406
+ options2._reservedNai5 = Math.max(0, options2._reservedNai5 - Math.round(freeCount * weight * 10) / 10);
4407
+ }
4408
+ if (typeof freeQuota === "number") {
4575
4409
  ;
4576
- options2._reservedNai5 = Math.max(0, options2._reservedNai5 - nai5Count);
4410
+ options2._nai5FreeCount = Math.max(0, freeQuota - freeCount);
4577
4411
  }
4578
4412
  }
4579
4413
  if (messageIds.length && runtime.config.recallTimeout) {
@@ -4637,7 +4471,7 @@ function calculateTaskPointsCost(runtime, session, options2, width, height, isIm
4637
4471
  for (let i = 0; i < count; i++) perImage.push(unitFree);
4638
4472
  return { total: unitFree * count, perImage };
4639
4473
  }
4640
- const overageCount = runtime.membershipSystem.getNai5OverageCount(uid, count);
4474
+ const overageCount = runtime.membershipSystem.getNai5OverageCount(uid, count, resolvedModel);
4641
4475
  const freeCount = count - overageCount;
4642
4476
  const unitOverage = overageCount > 0 ? getUnitPointsCost(runtime, session, options2, width, height, isImg2Img, preciseRefCount, true, resolvedModel) : unitFree;
4643
4477
  for (let i = 0; i < freeCount; i++) perImage.push(unitFree);
@@ -4943,7 +4777,7 @@ function registerNovelai(ctx, config, runtime) {
4943
4777
  "$1-$2"
4944
4778
  );
4945
4779
  });
4946
- const cmd = ctx.command("novelai [prompts...]").alias("nai").alias("imagine").alias("nai4", { options: { model: "nai-v4-full" } }).alias("nai4c", { options: { model: "nai-v4-curated-preview" } }).alias("nai4-5c", { options: { model: "nai-v4-5-curated" } }).alias("nai4-5", { options: { model: "nai-v4-5-full" } }).alias("nai5c", { options: { model: "nai-v5-curated" } }).alias("nai5", { options: { model: "nai-v5-full" } }).userFields(["authority"]).shortcut("imagine", { i18n: true, fuzzy: true }).shortcut("enhance", { i18n: true, fuzzy: true, options: { enhance: true } }).option("enhance", "-e", { hidden: some(thirdParty, noImage) }).option("model", "-m <model>", { type: models, hidden: thirdParty }).option("resolution", "-r <resolution>", { type: resolution2 }).option("output", "-o", { type: ["minimal", "default", "verbose"] }).option("override", "-O").option("sampler", "-s <sampler>").option("seed", "-x <seed:number>").option("steps", "-t <step>", { type: step }).option("scale", "-c <scale:number>").option("rescale", "-R <rescale:number>").option("noise", "-n <noise:number>", { hidden: thirdParty }).option("strength", "-N <strength:number>").option("variety", "-v").option("hiresFix", "-H", { hidden: /* @__PURE__ */ __name(() => config.type !== "sd-webui", "hidden") }).option("hiresFixSteps", "<step>", { type: step, hidden: /* @__PURE__ */ __name(() => config.type !== "sd-webui", "hidden") }).option("smea", "-S", { hidden: /* @__PURE__ */ __name(() => config.model !== "nai-v3", "hidden") }).option("smeaDyn", "-d", { hidden: /* @__PURE__ */ __name(() => config.model !== "nai-v3", "hidden") }).option("scheduler", "-C <scheduler:string>", {
4780
+ const cmd = ctx.command("novelai [prompts...]").alias("nai").alias("imagine").alias("nai4", { options: { model: "nai-v4-full" } }).alias("nai4c", { options: { model: "nai-v4-curated-preview" } }).alias("nai4-5c", { options: { model: "nai-v4-5-curated" } }).alias("nai4-5", { options: { model: "nai-v4-5-full" } }).alias("nai5c", { options: { model: "nai-v5-curated" } }).alias("nai5", { options: { model: "nai-v5-full" } }).alias("nai5m", { options: { model: "nai-v5-full-medium" } }).userFields(["authority"]).shortcut("imagine", { i18n: true, fuzzy: true }).shortcut("enhance", { i18n: true, fuzzy: true, options: { enhance: true } }).option("enhance", "-e", { hidden: some(thirdParty, noImage) }).option("model", "-m <model>", { type: models, hidden: thirdParty }).option("resolution", "-r <resolution>", { type: resolution2 }).option("output", "-o", { type: ["minimal", "default", "verbose"] }).option("override", "-O").option("sampler", "-s <sampler>").option("seed", "-x <seed:number>").option("steps", "-t <step>", { type: step }).option("scale", "-c <scale:number>").option("rescale", "-R <rescale:number>").option("noise", "-n <noise:number>", { hidden: thirdParty }).option("strength", "-N <strength:number>").option("variety", "-v").option("hiresFix", "-H", { hidden: /* @__PURE__ */ __name(() => config.type !== "sd-webui", "hidden") }).option("hiresFixSteps", "<step>", { type: step, hidden: /* @__PURE__ */ __name(() => config.type !== "sd-webui", "hidden") }).option("smea", "-S", { hidden: /* @__PURE__ */ __name(() => config.model !== "nai-v3", "hidden") }).option("smeaDyn", "-d", { hidden: /* @__PURE__ */ __name(() => config.model !== "nai-v3", "hidden") }).option("scheduler", "-C <scheduler:string>", {
4947
4781
  hidden: /* @__PURE__ */ __name(() => config.type === "naifu", "hidden"),
4948
4782
  type: ["token", "login"].includes(config.type) ? scheduler.nai : config.type === "sd-webui" ? scheduler.sd : config.type === "stable-horde" ? scheduler.horde : []
4949
4783
  }).option("decrisper", "-D", { hidden: thirdParty }).option("undesired", "-u <undesired:text>").option("noTranslator", "-T", { hidden: /* @__PURE__ */ __name(() => !ctx.translator || !config.translator, "hidden") }).option("iterations", "-i <iterations:posint>", { fallback: 1, hidden: /* @__PURE__ */ __name(() => config.maxIterations <= 1, "hidden") }).option("batch", "-b <batch:option>", { fallback: 1, hidden: /* @__PURE__ */ __name(() => config.maxIterations <= 1, "hidden") }).option("chars", "-K <chars>").option("inpaint", "-M", { hidden: thirdParty }).option("preciseRef", "-P", { hidden: thirdParty }).option("preciseRefParams", "-p <params:string>").action(async ({ session, options: options2, name: name2 }, ...prompts) => {
@@ -5026,6 +4860,10 @@ function registerNovelai(ctx, config, runtime) {
5026
4860
  const preciseRefCount = options2?._preciseRefImages?.length || 0;
5027
4861
  const drawCount = getTaskDrawCount(options2);
5028
4862
  const isImg2Img = !!options2.inpaint || imgUrl_check;
4863
+ const resolvedModel = options2.model || config.model;
4864
+ if (membershipSystem.isNai5MediumModel(resolvedModel)) {
4865
+ options2.steps = 14;
4866
+ }
5029
4867
  const cost = calculateTaskPointsCost(
5030
4868
  runtime,
5031
4869
  session,
@@ -5035,14 +4873,18 @@ function registerNovelai(ctx, config, runtime) {
5035
4873
  isImg2Img,
5036
4874
  preciseRefCount,
5037
4875
  userId,
5038
- options2.model || config.model,
4876
+ resolvedModel,
5039
4877
  drawCount
5040
4878
  );
5041
4879
  pointsCost = cost.total;
5042
- nai5Overage = membershipSystem.shouldChargeNai5Overage(userId, options2.model || config.model, drawCount);
5043
- if (membershipSystem.isNai5Model(options2.model || config.model) && membershipSystem.getNai5DailyLimit(userId) > 0) {
5044
- membershipSystem.reserveNai5Usage(userId, drawCount);
5045
- options2._reservedNai5 = drawCount;
4880
+ const nai5OverageCount = membershipSystem.isNai5Model(resolvedModel) ? membershipSystem.getNai5OverageCount(userId, drawCount, resolvedModel) : 0;
4881
+ nai5Overage = nai5OverageCount > 0;
4882
+ if (membershipSystem.isNai5Model(resolvedModel) && membershipSystem.getNai5DailyLimit(userId) > 0) {
4883
+ const weight = membershipSystem.getNai5UsageWeight(resolvedModel);
4884
+ const reserveCount = Math.round(weight * drawCount * 10) / 10;
4885
+ membershipSystem.reserveNai5Usage(userId, reserveCount);
4886
+ options2._reservedNai5 = reserveCount;
4887
+ options2._nai5FreeCount = drawCount - nai5OverageCount;
5046
4888
  }
5047
4889
  if (pointsCost > 0) {
5048
4890
  const result = await membershipSystem.deductPoints(userId, pointsCost);
@@ -5210,6 +5052,7 @@ function registerRedraw(ctx, config, runtime) {
5210
5052
  }
5211
5053
  let redrawDeductedPoints = 0;
5212
5054
  let redrawPerTask = [];
5055
+ let redrawFreePerTask = [];
5213
5056
  let nai5Overage = false;
5214
5057
  if (config.membershipEnabled && config.pointsEnabled) {
5215
5058
  let resWidth = 832, resHeight = 1216;
@@ -5246,18 +5089,27 @@ function registerRedraw(ctx, config, runtime) {
5246
5089
  drawCount
5247
5090
  );
5248
5091
  const imagesPerTask = Math.max(1, Math.floor(drawCount / repeatCount));
5092
+ const redrawModel = lastOptions.model || config.model;
5093
+ const nai5OverageCount = membershipSystem.isNai5Model(redrawModel) ? membershipSystem.getNai5OverageCount(userId, drawCount, redrawModel) : 0;
5094
+ const nai5FreeTotal = drawCount - nai5OverageCount;
5095
+ redrawFreePerTask.length = 0;
5249
5096
  for (let i = 0; i < repeatCount; i++) {
5250
5097
  const start = i * imagesPerTask;
5251
- redrawPerTask.push(cost.perImage.slice(start, start + imagesPerTask).reduce((sum, n) => sum + n, 0));
5098
+ const end = Math.min(drawCount, start + imagesPerTask);
5099
+ redrawPerTask.push(cost.perImage.slice(start, end).reduce((sum, n) => sum + n, 0));
5100
+ redrawFreePerTask.push(Math.max(0, Math.min(end, nai5FreeTotal) - start));
5252
5101
  }
5253
- nai5Overage = membershipSystem.shouldChargeNai5Overage(userId, lastOptions.model || config.model, drawCount);
5254
- if (membershipSystem.isNai5Model(lastOptions.model || config.model) && membershipSystem.getNai5DailyLimit(userId) > 0) {
5255
- membershipSystem.reserveNai5Usage(userId, drawCount);
5102
+ nai5Overage = nai5OverageCount > 0;
5103
+ let nai5ReserveCount = drawCount;
5104
+ if (membershipSystem.isNai5Model(redrawModel) && membershipSystem.getNai5DailyLimit(userId) > 0) {
5105
+ const weight = membershipSystem.getNai5UsageWeight(redrawModel);
5106
+ nai5ReserveCount = Math.round(weight * drawCount * 10) / 10;
5107
+ membershipSystem.reserveNai5Usage(userId, nai5ReserveCount);
5256
5108
  }
5257
5109
  if (cost.total > 0) {
5258
5110
  const result = await membershipSystem.deductPoints(userId, cost.total);
5259
5111
  if (result === -1) {
5260
- membershipSystem.releaseNai5Usage(userId, drawCount);
5112
+ membershipSystem.releaseNai5Usage(userId, nai5ReserveCount);
5261
5113
  const currentPoints = membershipSystem.getPoints(userId);
5262
5114
  queueSystem.releaseRedrawLock();
5263
5115
  return session.text("commands.novelai.messages.points-insufficient", [currentPoints, cost.total]);
@@ -5331,7 +5183,10 @@ function registerRedraw(ctx, config, runtime) {
5331
5183
  if (unitCost > 0) taskOptions._deductedPoints = unitCost;
5332
5184
  }
5333
5185
  if (config.membershipEnabled && membershipSystem.isNai5Model(lastTask.options?.model || config.model) && membershipSystem.getNai5DailyLimit(currentUserId) > 0) {
5334
- taskOptions._reservedNai5 = getTaskDrawCount(lastTask.options, 1);
5186
+ const weight = membershipSystem.getNai5UsageWeight(lastTask.options?.model || config.model);
5187
+ const perTaskCount = getTaskDrawCount(lastTask.options, 1);
5188
+ taskOptions._reservedNai5 = Math.round(weight * perTaskCount * 10) / 10;
5189
+ taskOptions._nai5FreeCount = redrawFreePerTask[index] ?? perTaskCount;
5335
5190
  }
5336
5191
  queueSystem.taskQueue.push({
5337
5192
  session: taskSession,
@@ -5482,6 +5337,7 @@ function registerMember(ctx, config, runtime) {
5482
5337
  return "您没有权限设置会员状态";
5483
5338
  }
5484
5339
  membershipSystem.checkAndResetDailyUsage(targetId);
5340
+ await membershipSystem.ensurePointsFresh(targetId);
5485
5341
  if (options2.refreshPoints) {
5486
5342
  if (!config.pointsEnabled) {
5487
5343
  return "点数控制未启用";
@@ -5609,10 +5465,12 @@ nai5/nai5c 今日免费:${benefit.nai5DailyLimit} 次,已用 ${nai5Used} 次
5609
5465
  pointsInfo = `
5610
5466
  点数余额:${points}`;
5611
5467
  if (config.pointsMode === "periodic") {
5612
- const remainDaysRefresh = membershipSystem.getDaysUntilNextRefresh();
5613
- if (remainDaysRefresh >= 0) {
5468
+ const refreshAt = membershipSystem.getNextPointsRefreshAt(targetId);
5469
+ const remainDaysRefresh = membershipSystem.getDaysUntilNextRefresh(targetId);
5470
+ if (refreshAt > 0) {
5471
+ const refreshDate = new Date(refreshAt).toLocaleString();
5614
5472
  pointsInfo += `
5615
- 下次点数刷新时间:${membershipSystem.formatNextRefreshTime()}(${remainDaysRefresh}天后,Lv${activeTier} 档刷新为 ${benefit.pointsRefresh} 点)`;
5473
+ 下次点数刷新时间:${refreshDate}(约${remainDaysRefresh}天后,Lv${activeTier} 档刷新为 ${benefit.pointsRefresh} 点)`;
5616
5474
  }
5617
5475
  }
5618
5476
  }
@@ -5625,10 +5483,11 @@ nai5/nai5c 今日免费:${benefit.nai5DailyLimit} 次,已用 ${nai5Used} 次
5625
5483
  pointsInfo = `
5626
5484
  点数余额:${user.points || 0}`;
5627
5485
  if (config.pointsMode === "periodic" && config.pointsRefreshIncludeNonMember) {
5628
- const remainDaysRefresh = membershipSystem.getDaysUntilNextRefresh();
5629
- if (remainDaysRefresh >= 0) {
5486
+ const refreshAt = membershipSystem.getNextPointsRefreshAt(targetId);
5487
+ if (refreshAt > 0) {
5488
+ const refreshDate = new Date(refreshAt).toLocaleString();
5630
5489
  pointsInfo += `
5631
- 下次点数刷新时间:${membershipSystem.formatNextRefreshTime()}(${remainDaysRefresh}天后)`;
5490
+ 下次点数刷新时间:${refreshDate}(约${membershipSystem.getDaysUntilNextRefresh(targetId)}天后)`;
5632
5491
  }
5633
5492
  }
5634
5493
  }
@@ -5647,7 +5506,7 @@ nai5/nai5c 今日免费:${benefit.nai5DailyLimit} 次,已用 ${nai5Used} 次
5647
5506
  }
5648
5507
  });
5649
5508
  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 }) => {
5509
+ 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
5510
  if (!config.membershipEnabled) {
5652
5511
  return "会员系统未启用";
5653
5512
  }
@@ -5819,24 +5678,16 @@ nai5 已使用:${user.nai5DailyUsage || 0} 次` : "";
5819
5678
  `;
5820
5679
  statusMsg += `✅ 点数控制:已启用
5821
5680
  `;
5822
- statusMsg += ` 点数模式:${config.pointsMode === "periodic" ? "按周期刷新" : "永久点数"}
5681
+ statusMsg += ` 点数模式:${config.pointsMode === "periodic" ? "按周期惰性刷新" : "永久点数"}
5823
5682
  `;
5824
5683
  if (config.pointsMode === "periodic") {
5825
5684
  statusMsg += ` 刷新周期:${config.pointsRefreshCycleDays || 30} 天
5826
- `;
5827
- statusMsg += ` 刷新检查时刻:${config.pointsRefreshCheckTime || "00:05"}
5828
5685
  `;
5829
5686
  statusMsg += ` 刷新点数:${config.pointsRefreshAmount || 200}
5830
5687
  `;
5831
- statusMsg += ` 刷新范围:${config.pointsRefreshIncludeNonMember ? "所有用户" : "仅会员"}
5832
- `;
5833
- statusMsg += ` 周期锚点:${membershipSystem.formatRefreshAnchor()}
5688
+ statusMsg += ` 刷新触发:用户发生点数相关操作时按用户惰性执行
5834
5689
  `;
5835
- statusMsg += ` 上次实际刷新:${membershipSystem.formatLastRefreshTime()}
5836
- `;
5837
- const nextDays = membershipSystem.getDaysUntilNextRefresh();
5838
- const planLabel = membershipSystem.formatRefreshPlanTime();
5839
- statusMsg += ` 下次刷新:${membershipSystem.formatNextRefreshTime()}(${nextDays}天后,计划 ${planLabel})${membershipSystem.isPointsRefreshOverdue() ? " ⚠️ 已逾期,将在下个检查点补刷" : ""}
5690
+ statusMsg += ` 刷新范围:${config.pointsRefreshIncludeNonMember ? "所有用户" : "仅会员"}
5840
5691
  `;
5841
5692
  }
5842
5693
  statusMsg += ` 默认点数:${config.pointsDefault || 200}
@@ -5867,27 +5718,6 @@ nai5 已使用:${user.nai5DailyUsage || 0} 次` : "";
5867
5718
  await membershipSystem.checkAndRemindExpiringMembers();
5868
5719
  return "✅ 提醒完成!请查看控制台日志获取详细信息。";
5869
5720
  }
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
5721
  if (options2.refreshPoints) {
5892
5722
  if (!config.pointsEnabled) {
5893
5723
  return "❌ 点数控制未启用";
@@ -5897,12 +5727,8 @@ nai5 已使用:${user.nai5DailyUsage || 0} 次` : "";
5897
5727
  }
5898
5728
  const refreshScope = debugTierFilter !== void 0 ? `Lv${debugTierFilter} 会员` : "所有会员";
5899
5729
  await session.send(`正在为${refreshScope}执行点数刷新...`);
5900
- const overdueBefore = membershipSystem.isPointsRefreshOverdue();
5901
5730
  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;
5731
+ return result.message;
5906
5732
  }
5907
5733
  if (options2.addPoints !== void 0) {
5908
5734
  if (!config.pointsEnabled) {
@@ -6744,6 +6570,7 @@ var usage = `
6744
6570
  | \`nai4-5\` | \`nai-diffusion-4-5-full\` | V4.5 全量模型 |
6745
6571
  | \`nai4-5c\` | \`nai-diffusion-4-5-curated\` | V4.5 精选模型 |
6746
6572
  | \`nai5\` | \`nai-diffusion-5-full\` | V5 全量模型 |
6573
+ | \`nai5m\` | \`nai-diffusion-5-full-medium\` | V5 Medium(Effort=Medium,固定 14 步,每次扣 0.6 次 nai5 额度) |
6747
6574
  | \`nai5c\` | \`nai-diffusion-5-curated\` | V5 精选模型 |
6748
6575
 
6749
6576
  ### 🛠️ 功能列表
@@ -162,6 +162,13 @@ export declare class MembershipSystem {
162
162
  isActiveMember(userId: string): boolean;
163
163
  canUseNai5(userId: string, session: Session, model?: string): boolean | string;
164
164
  isNai5Model(model?: string): boolean;
165
+ /** 是否为 V5 Medium(Effort=Medium)模型。 */
166
+ isNai5MediumModel(model?: string): boolean;
167
+ /**
168
+ * 该模型每次画图消耗的 nai5 免费次数权重。
169
+ * Medium 档一次只扣 0.6 次,其余模型为 1 次。
170
+ */
171
+ getNai5UsageWeight(model?: string): number;
165
172
  /**
166
173
  * 获取用户生效等级的 nai5 / nai5c 每日免费限额。
167
174
  * - 传入 userId:Lv1 = 配置字段 memberNai5DailyLimit,Lv2-Lv5 = tierBenefits;非会员返回 0
@@ -201,10 +208,11 @@ export declare class MembershipSystem {
201
208
  }>;
202
209
  /**
203
210
  * 本次 nai5 任务中,超出会员免费额度、需要按 Anlas 计费的张数。
211
+ * - Medium 模型每次画图消耗 0.6 次免费额度(weight < 1)
204
212
  * - 周桶模式:可用 = 桶余额 − 并发预占,超出可用部分计费(点数支付的部分不扣桶)
205
213
  * - 旧模式:日限按用户生效等级取值;日限为 0 表示不额外限制,全部走 Opus 免费档(标准分辨率不扣点)
206
214
  */
207
- getNai5OverageCount(userId: string, drawCount?: number): number;
215
+ getNai5OverageCount(userId: string, drawCount?: number, model?: string): number;
208
216
  shouldChargeNai5Overage(userId: string, model?: string, drawCount?: number): boolean;
209
217
  reserveNai5Usage(userId: string, count: number): void;
210
218
  releaseNai5Usage(userId: string, count: number): void;
@@ -3,7 +3,9 @@ import { Config } from '../config';
3
3
  import { Subscription } from '../types';
4
4
  /** 获取 NovelAI 当前订阅信息(包含 Opus usage 状态)。 */
5
5
  export declare function fetchSubscription(ctx: Context, config: Config, token: string): Promise<Subscription>;
6
- /** NovelAI V5 的两个免费额度模型。 */
6
+ /** NovelAI Diffusion V5 Medium(Effort=Medium,官网独立模型 ID)。 */
7
+ export declare function isNovelAIV5MediumModel(model: string): boolean;
8
+ /** NovelAI V5 的免费额度模型(含 Medium)。 */
7
9
  export declare function isNovelAIV5Model(model: string): boolean;
8
10
  /** 判断订阅响应中的 Opus 免费额度是否已经不可用。 */
9
11
  export declare function isOpusQuotaExhausted(subscription: Subscription): boolean;
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.15",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
7
7
  "files": [
@@ -47,11 +47,11 @@
47
47
  ]
48
48
  },
49
49
  "description": {
50
- "en": "Image Generation. Based on [novelai-bot](https://bot.novelai.dev/) plugin, supports NovelAI v5, v4.5 full, characters feature, director tools, and inpainting.",
51
- "zh": "对[novelai-bot](https://bot.novelai.dev/)插件二次开发,已经支持NovelAI v5、v4.5full和characters功能,导演工具功能,局部重绘,精准参考,点数控制系统",
52
- "zh-TW": "基於[novelai-bot](https://bot.novelai.dev/)插件二次開發,已支持NovelAI v5、v4.5 full和characters功能、導演工具、局部重繪、精準參考、點數控制系統",
53
- "fr": "Génération d'images. Basé sur le plugin [novelai-bot](https://bot.novelai.dev/), prend en charge NovelAI v5, v4.5 full, la fonctionnalité characters, les outils de direction et l'inpainting.",
54
- "ja": "画像生成。[novelai-bot](https://bot.novelai.dev/)プラグインの二次開発。NovelAI v5、v4.5 full、characters機能、ディレクターツール、部分再描画に対応。"
50
+ "en": "Image Generation. Based on [novelai-bot](https://bot.novelai.dev/) plugin, supports NovelAI v5 (incl. Medium effort), v4.5 full, characters feature, director tools, and inpainting.",
51
+ "zh": "对[novelai-bot](https://bot.novelai.dev/)插件二次开发,已经支持NovelAI v5(含 Medium Effort)、v4.5full和characters功能,导演工具功能,局部重绘,精准参考,点数控制系统",
52
+ "zh-TW": "基於[novelai-bot](https://bot.novelai.dev/)插件二次開發,已支持NovelAI v5(含 Medium Effort)、v4.5 full和characters功能、導演工具、局部重繪、精準參考、點數控制系統",
53
+ "fr": "Génération d'images. Basé sur le plugin [novelai-bot](https://bot.novelai.dev/), prend en charge NovelAI v5 (dont l'effort Medium), v4.5 full, la fonctionnalité characters, les outils de direction et l'inpainting.",
54
+ "ja": "画像生成。[novelai-bot](https://bot.novelai.dev/)プラグインの二次開発。NovelAI v5(Medium Effort を含む)、v4.5 full、characters機能、ディレクターツール、部分再描画に対応。"
55
55
  }
56
56
  },
57
57
  "keywords": [
package/readme.md CHANGED
@@ -165,7 +165,7 @@ nai4-5 <提示词> -P -p "cs,1,1;c,0.8,0.9"
165
165
 
166
166
  **计费细则:**
167
167
  - **默认消耗**:`ceil(2.951823174884865e-6 × 像素 + 5.753298233447344e-7 × 像素 × 步数)`。
168
- - **V5(nai5 / nai5c)**:在上述结果上再乘 **1.5**。例如 832×1216、28 步超额时,V4.5 为 20、V5 为 30。
168
+ - **V5(nai5 / nai5m / nai5c)**:在上述结果上再乘 **1.5**。例如 832×1216、28 步超额时,V4.5 为 20、V5 为 30。`nai5m` 固定 14 步,按同一公式计算(标准竖图约 18 点)。
169
169
  - **SMEA / DYN 加成**:SMEA 单独开启 **1.2 倍**,同时开启 SMEA 和 DYN **1.4 倍**。
170
170
  - **以图生图**:按去噪强度(`strength`)缩放后向上取整,最少 2 点。
171
171
  - **精确参考(-P)**:有参考图时取消 Opus 免费档,并每张参考图额外扣除 5 点数。
@@ -215,8 +215,8 @@ nai4-5 <提示词> -P -p "cs,1,1;c,0.8,0.9"
215
215
 
216
216
  ### ⚙️ 其他功能
217
217
 
218
- - **多模型支持**:快捷指令 `nai4`、`nai4c`、`nai4-5`、`nai4-5c`、`nai5`、`nai5c` 直接调用不同模型
219
- - **Opus 额度保护**:使用 `/配额` 查看 Opus 免费 V5 额度、下次恢复时间和状态;额度耗尽时默认阻止 `nai5`/`nai5c`,可在配置中开启允许消耗 Anlas 的熔断开关
218
+ - **多模型支持**:快捷指令 `nai4`、`nai4c`、`nai4-5`、`nai4-5c`、`nai5`、`nai5m`、`nai5c` 直接调用不同模型
219
+ - **Opus 额度保护**:使用 `/配额` 查看 Opus 免费 V5 额度、下次恢复时间和状态;额度耗尽时默认阻止 `nai5`/`nai5m`/`nai5c`,可在配置中开启允许消耗 Anlas 的熔断开关
220
220
  - **参数调整**:`-R` 参数调整 cfg_rescale(范围 0-1)
221
221
  - **调试模式**:可配置是否输出详细调试日志
222
222
 
@@ -232,6 +232,7 @@ nai4c <提示词> # 使用 v4 curated 模型
232
232
  nai4-5 <提示词> # 使用 v4.5 full 模型
233
233
  nai4-5c <提示词> # 使用 v4.5 curated 模型
234
234
  nai5 <提示词> # 使用 v5 full 模型
235
+ nai5m <提示词> # 使用 v5 Medium 模型(固定 14 步,一次扣 0.6 次免费额度)
235
236
  nai5c <提示词> # 使用 v5 curated 模型
236
237
  /配额 # 查看 Opus 免费 V5 额度
237
238
 
@@ -366,6 +367,23 @@ Region 的值一般为 ap- 加上你选择的所属地域的拼音,以下为
366
367
 
367
368
  ## 更新日志
368
369
 
370
+ ### v1.3.15
371
+
372
+ - ✨ **新增 `nai5m` 指令:NovelAI Diffusion V5 Medium(Effort=Medium)**
373
+ - 对应官网新增的 Effort 切换,本质是独立模型 ID `nai-diffusion-5-full-medium`(重绘为 `nai-diffusion-5-full-medium-inpainting`)
374
+ - 官网行为同步:固定 **14 步**、采样器固定 `k_euler_ancestral`、`cfg_rescale` 禁用(发送 0)、`ucPreset` 固定 heavy(发送 2);用户传入的 `-t` 步数将被忽略
375
+ - 与 `nai5` 能力一致:支持多角色提示词(V5 自由坐标定位)、走 V5 参数协议(`params_version: 4`)、受 Opus 免费额度熔断与会员限制约束
376
+ - **计费**:沿用 V5 官网公式,14 步自然比 High(28 步)便宜(标准竖图约 18 点 vs 30 点)
377
+ - ✨ **`nai5m` 免费额度按 0.6 权重扣减**
378
+ - 使用 `nai5m` 画一张只扣 **0.6 次** nai5 免费额度(日限/周桶均按权重折算),例如日限 20 次可画 33 张全免费的 Medium
379
+ - 免费额度不足以覆盖一张时(如剩 0.5 < 0.6),该张自动按点数计费,**剩余额度保留不清零**(点数支付的部分从不扣免费额度)
380
+ - 🐛 **修复点数支付后残余免费额度被误扣的问题**
381
+ - 修复前:出图后按全部张数扣免费额度,额度剩 0.x 时付了点数还会把残余额度一并抹掉
382
+ - 修复后:只有免费额度内的张数扣额度,点数支付的张数不扣(重画路径同步修复)
383
+ - 🐛 **修复免费额度浮点误差累积问题**
384
+ - 0.6 权重连加/连减会产生 `0.3999999999999999` 这类浮点尾巴,所有额度变动处统一对齐到 0.1
385
+ - 数据库字段 `nai5DailyUsage` / `nai5Bucket` 类型由 `unsigned` 改为 `float`,支持小数额度(控制台可正常编辑)
386
+
369
387
  ### v1.2.7
370
388
 
371
389
  - ✨ **NAI 5 角色自由坐标定位**