koishi-plugin-rocom 1.0.18 → 1.0.24

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 (216) hide show
  1. package/lib/announcement-service.d.ts +21 -0
  2. package/lib/announcement-service.js +63 -0
  3. package/lib/async-control.d.ts +3 -0
  4. package/lib/async-control.js +48 -0
  5. package/lib/atlas-service.js +1 -1
  6. package/lib/client.d.ts +58 -7
  7. package/lib/client.js +282 -127
  8. package/lib/commands/egg.js +66 -4
  9. package/lib/commands/merchant.d.ts +105 -0
  10. package/lib/commands/merchant.js +600 -182
  11. package/lib/commands/query.d.ts +18 -0
  12. package/lib/commands/query.js +585 -145
  13. package/lib/commands/ranking.d.ts +2 -0
  14. package/lib/commands/ranking.js +31 -0
  15. package/lib/commands/share-code.d.ts +2 -0
  16. package/lib/commands/share-code.js +111 -0
  17. package/lib/commands/tools.js +48 -60
  18. package/lib/commands/wiki.js +1 -1
  19. package/lib/egg-service.d.ts +82 -8
  20. package/lib/egg-service.js +235 -19
  21. package/lib/element-colors.d.ts +3 -0
  22. package/lib/element-colors.js +34 -0
  23. package/lib/icon-metrics.d.ts +32 -0
  24. package/lib/icon-metrics.js +255 -0
  25. package/lib/img//345/274/202/350/211/262.png +0 -0
  26. package/lib/img//347/202/253/345/275/251.png +0 -0
  27. package/lib/index.d.ts +10 -0
  28. package/lib/index.js +11857 -175
  29. package/lib/merchant-catalog.d.ts +47 -0
  30. package/lib/merchant-catalog.js +193 -0
  31. package/lib/merchant-clock.d.ts +22 -0
  32. package/lib/merchant-clock.js +95 -0
  33. package/lib/merchant-price-log.d.ts +63 -0
  34. package/lib/merchant-price-log.js +187 -0
  35. package/lib/merchant-service.d.ts +38 -0
  36. package/lib/merchant-service.js +216 -0
  37. package/lib/permissions.d.ts +16 -0
  38. package/lib/permissions.js +66 -0
  39. package/lib/pet-data-service.d.ts +13 -0
  40. package/lib/pet-data-service.js +117 -10
  41. package/lib/pet-size.d.ts +13 -0
  42. package/lib/pet-size.js +54 -0
  43. package/lib/player-service.d.ts +37 -0
  44. package/lib/player-service.js +201 -0
  45. package/lib/ranking-service.d.ts +46 -0
  46. package/lib/ranking-service.js +137 -0
  47. package/lib/render-templates/announcement/detail.html +8 -0
  48. package/lib/render-templates/announcement/list.html +5 -0
  49. package/lib/render-templates/announcement/style.css +1 -0
  50. package/lib/render-templates/home/style.css +15 -14
  51. package/lib/render-templates/home-pets/index.html +80 -0
  52. package/lib/render-templates/home-pets/style.css +338 -0
  53. package/lib/render-templates/pet-detail-classic/index.html +151 -0
  54. package/lib/render-templates/pet-detail-classic/style.css +503 -0
  55. package/lib/render-templates/pet-detail-classic/texture2D/a_title.png +0 -0
  56. package/lib/render-templates/pet-detail-classic/texture2D/bg.jpg +0 -0
  57. package/lib/render-templates/pet-detail-classic/texture2D/footer.png +0 -0
  58. package/lib/render-templates/pet-detail-classic/texture2D/icon.png +0 -0
  59. package/lib/render-templates/pet-detail-classic/texture2D/img_head.png +0 -0
  60. package/lib/render-templates/pet-detail-classic/texture2D/jinhua_bg.png +0 -0
  61. package/lib/render-templates/pet-detail-classic/texture2D/level_icon.png +0 -0
  62. package/lib/render-templates/pet-detail-classic/texture2D/line.png +0 -0
  63. package/lib/render-templates/pet-detail-classic/texture2D/mask_bar.png +0 -0
  64. package/lib/render-templates/pet-detail-classic/texture2D/pet_bg.png +0 -0
  65. package/lib/render-templates/pet-detail-classic/texture2D/pet_rocom.png +0 -0
  66. package/lib/render-templates/pet-detail-classic/texture2D/pet_rocom_bg.png +0 -0
  67. package/lib/render-templates/pet-detail-classic/texture2D/poro_bar.png +0 -0
  68. package/lib/render-templates/pet-detail-classic/texture2D/right_jinhua.png +0 -0
  69. package/lib/render-templates/pet-detail-classic/texture2D/skill_bg.png +0 -0
  70. package/lib/render-templates/pet-detail-classic/texture2D/skill_mask.png +0 -0
  71. package/lib/render-templates/pet-detail-classic/texture2D/star.png +0 -0
  72. package/lib/render-templates/pet-detail-classic/texture2D/star_1.png +0 -0
  73. package/lib/render-templates/pet-detail-classic/texture2D/star_8.png +0 -0
  74. package/lib/render-templates/pet-detail-classic/texture2D/star_9.png +0 -0
  75. package/lib/render-templates/pet-detail-classic/texture2D/table.png +0 -0
  76. package/lib/render-templates/pet-detail-classic/texture2D/tags.png +0 -0
  77. package/lib/render-templates/pet-detail-classic/texture2D/title.png +0 -0
  78. package/lib/render-templates/pet-detail-classic/texture2D/top_bg.png +0 -0
  79. package/lib/render-templates/pet-detail-classic/texture2D/xuancai_overlay.png +0 -0
  80. package/lib/render-templates/pet-detail-classic/texture2D/yise_overlay.png +0 -0
  81. package/lib/render-templates/pet-detail-classic/texture2D/ys_bar.png +0 -0
  82. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/10.png +0 -0
  83. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/11.png +0 -0
  84. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/12.png +0 -0
  85. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/13.png +0 -0
  86. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/14.png +0 -0
  87. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/15.png +0 -0
  88. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/16.png +0 -0
  89. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/17.png +0 -0
  90. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/18.png +0 -0
  91. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/19.png +0 -0
  92. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/2.png +0 -0
  93. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/20.png +0 -0
  94. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/23.png +0 -0
  95. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/3.png +0 -0
  96. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/4.png +0 -0
  97. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/5.png +0 -0
  98. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/6.png +0 -0
  99. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/8.png +0 -0
  100. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247/9.png +0 -0
  101. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//345/205/211.png +0 -0
  102. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//345/206/260.png +0 -0
  103. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//345/234/260.png +0 -0
  104. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//345/271/273.png +0 -0
  105. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//345/271/275.png +0 -0
  106. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//346/201/266.png +0 -0
  107. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//346/227/240.png +0 -0
  108. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//346/231/256/351/200/232.png +0 -0
  109. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//346/234/272/346/242/260.png +0 -0
  110. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//346/255/246.png +0 -0
  111. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//346/257/222.png +0 -0
  112. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//346/260/264.png +0 -0
  113. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//347/201/253.png +0 -0
  114. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//347/224/265.png +0 -0
  115. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//347/277/274.png +0 -0
  116. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//350/215/211.png +0 -0
  117. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//350/220/214.png +0 -0
  118. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//350/231/253.png +0 -0
  119. package/lib/render-templates/pet-detail-classic/texture2D//345/261/236/346/200/247//351/276/231.png +0 -0
  120. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/1.png +0 -0
  121. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/10.png +0 -0
  122. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/11.png +0 -0
  123. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/12.png +0 -0
  124. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/13.png +0 -0
  125. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/14.png +0 -0
  126. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/15.png +0 -0
  127. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/16.png +0 -0
  128. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/17.png +0 -0
  129. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/18.png +0 -0
  130. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/19.png +0 -0
  131. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/2.png +0 -0
  132. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/21.png +0 -0
  133. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/23.png +0 -0
  134. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/24.png +0 -0
  135. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/3.png +0 -0
  136. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/4.png +0 -0
  137. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/5.png +0 -0
  138. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/6.png +0 -0
  139. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/7.png +0 -0
  140. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/8.png +0 -0
  141. package/lib/render-templates/pet-detail-classic/texture2D//350/241/200/350/204/211/9.png +0 -0
  142. package/lib/render-templates/pet-ranking/index.html +99 -0
  143. package/lib/render-templates/pet-ranking/style.css +338 -0
  144. package/lib/render-templates/player-search/index.html +7 -0
  145. package/lib/render-templates/searcheggs/index.html +1 -1
  146. package/lib/render-templates/searcheggs/size.html +70 -68
  147. package/lib/render-templates/share-code-classic/assets/eit-logo.png +0 -0
  148. package/lib/render-templates/share-code-classic/assets/img_xingxingdi.png +0 -0
  149. package/lib/render-templates/share-code-classic/assets/roco_icon.png +0 -0
  150. package/lib/render-templates/share-code-classic/assets//345/255/227/344/275/223/Rocofontblack-Regular .ttf +0 -0
  151. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//345/205/211.png +0 -0
  152. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//345/206/260.png +0 -0
  153. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//345/234/260.png +0 -0
  154. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//345/271/273.png +0 -0
  155. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//345/271/275.png +0 -0
  156. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//346/201/266.png +0 -0
  157. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//346/227/240.png +0 -0
  158. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//346/231/256/351/200/232.png +0 -0
  159. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//346/234/272/346/242/260.png +0 -0
  160. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//346/255/246.png +0 -0
  161. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//346/257/222.png +0 -0
  162. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//346/260/264.png +0 -0
  163. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//347/201/253.png +0 -0
  164. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//347/224/265.png +0 -0
  165. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//347/277/274.png +0 -0
  166. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//350/215/211.png +0 -0
  167. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//350/220/214.png +0 -0
  168. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//350/231/253.png +0 -0
  169. package/lib/render-templates/share-code-classic/assets//345/256/240/347/211/251/345/261/236/346/200/247//351/276/231.png +0 -0
  170. package/lib/render-templates/share-code-classic/assets//347/201/253/346/274/206/345/215/260.png +0 -0
  171. package/lib/render-templates/share-code-classic/assets//347/253/213/347/273/230/350/203/214/346/231/257/346/241/206.png +0 -0
  172. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//345/205/211.png +0 -0
  173. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//345/206/260.png +0 -0
  174. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//345/234/260.png +0 -0
  175. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//345/245/207/345/274/202.png +0 -0
  176. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//345/271/273.png +0 -0
  177. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//345/271/275.png +0 -0
  178. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//346/201/266.png +0 -0
  179. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//346/231/256/351/200/232.png +0 -0
  180. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//346/234/272/346/242/260.png +0 -0
  181. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//346/255/246.png +0 -0
  182. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//346/257/222.png +0 -0
  183. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//346/260/264.png +0 -0
  184. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//346/261/241/346/237/223.png +0 -0
  185. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//347/201/253.png +0 -0
  186. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//347/224/265.png +0 -0
  187. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//347/277/274.png +0 -0
  188. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//350/215/211.png +0 -0
  189. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//350/220/214.png +0 -0
  190. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//350/231/253.png +0 -0
  191. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//351/246/226/351/242/206.png +0 -0
  192. package/lib/render-templates/share-code-classic/assets//350/241/200/350/204/211//351/276/231.png +0 -0
  193. package/lib/render-templates/share-code-classic/index.html +119 -0
  194. package/lib/render-templates/share-code-classic/style.css +646 -0
  195. package/lib/render-templates/share-code-team/index.html +105 -0
  196. package/lib/render-templates/share-code-team/style.css +345 -0
  197. package/lib/render-templates/yuanxing-shangren/merchant.html +479 -461
  198. package/lib/render-templates/yuanxing-shangren/today.html +407 -461
  199. package/lib/render.d.ts +18 -2
  200. package/lib/render.js +138 -39
  201. package/lib/send-image.d.ts +1 -1
  202. package/lib/send-image.js +22 -1
  203. package/lib/share-code-service.d.ts +68 -0
  204. package/lib/share-code-service.js +257 -0
  205. package/lib/subscription-runner.d.ts +6 -0
  206. package/lib/subscription-runner.js +21 -0
  207. package/lib/subscription-send.d.ts +4 -2
  208. package/lib/subscription-send.js +45 -20
  209. package/lib/ttf/rocom_origin.ttf +0 -0
  210. package/lib/types.d.ts +14 -1
  211. package/lib/user.d.ts +11 -0
  212. package/lib/wiki-catalog.js +1 -1
  213. package/lib/wiki-service.js +1 -1
  214. package/package.json +5 -1
  215. package/readme.md +125 -124
  216. package/lib/ttf/rocom_skill_origin.ttf +0 -0
@@ -1,9 +1,20 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.buildMerchantRenderParams = buildMerchantRenderParams;
4
+ exports.buildMerchantCardItems = buildMerchantCardItems;
5
+ exports.buildTodayRows = buildTodayRows;
3
6
  exports.register = register;
4
7
  const koishi_1 = require("koishi");
5
8
  const send_image_1 = require("../send-image");
6
9
  const subscription_send_1 = require("../subscription-send");
10
+ const merchant_service_1 = require("../merchant-service");
11
+ const merchant_catalog_1 = require("../merchant-catalog");
12
+ const merchant_clock_1 = require("../merchant-clock");
13
+ const permissions_1 = require("../permissions");
14
+ const subscription_runner_1 = require("../subscription-runner");
15
+ const node_async_hooks_1 = require("node:async_hooks");
16
+ const async_control_1 = require("../async-control");
17
+ const icon_metrics_1 = require("../icon-metrics");
7
18
  const logger = new koishi_1.Logger('rocom-merchant');
8
19
  const TEXT = {
9
20
  merchant: '\u8fdc\u884c\u5546\u4eba',
@@ -28,31 +39,67 @@ const ROUND_WINDOWS = [
28
39
  { id: 3, label: '16:00-20:00', startHour: 16, endHour: 20 },
29
40
  { id: 4, label: '20:00-24:00', startHour: 20, endHour: 24 },
30
41
  ];
31
- const CHINA_TIMEZONE = 'Asia/Shanghai';
32
- const chinaPartsFormatter = new Intl.DateTimeFormat('zh-CN', {
33
- timeZone: CHINA_TIMEZONE,
34
- year: 'numeric',
35
- month: '2-digit',
36
- day: '2-digit',
37
- hour: '2-digit',
38
- minute: '2-digit',
39
- second: '2-digit',
40
- hour12: false,
41
- });
42
+ // 商人卡片按原生尺寸渲染:1080 设计稿缩到 1/4 排版,再整体 zoom 放大;
43
+ // 视口宽 960 + deviceScaleFactor 4 → 3840 物理像素输出。
44
+ const MERCHANT_CARD_SOURCE_WIDTH = 1080;
45
+ const MERCHANT_CARD_LAYOUT_SCALE = 0.25;
46
+ const MERCHANT_CARD_OUTPUT_WIDTH = 3840;
47
+ const MERCHANT_CARD_DEVICE_SCALE_FACTOR = 4;
48
+ const MERCHANT_CARD_RENDER_SCALE = MERCHANT_CARD_LAYOUT_SCALE;
49
+ const MERCHANT_CARD_RENDER_WIDTH = Math.ceil(MERCHANT_CARD_OUTPUT_WIDTH / MERCHANT_CARD_DEVICE_SCALE_FACTOR);
50
+ const MERCHANT_CARD_OUTPUT_SCALE = MERCHANT_CARD_RENDER_WIDTH / MERCHANT_CARD_SOURCE_WIDTH;
51
+ const MERCHANT_CARD_RENDER_BASE_WIDTH = MERCHANT_CARD_SOURCE_WIDTH * MERCHANT_CARD_LAYOUT_SCALE;
52
+ const MERCHANT_CARD_RENDER_ZOOM = MERCHANT_CARD_RENDER_WIDTH / MERCHANT_CARD_RENDER_BASE_WIDTH;
53
+ const MERCHANT_CARD_STROKE_11 = 11 * MERCHANT_CARD_RENDER_SCALE;
54
+ const MERCHANT_CARD_STROKE_10 = 10 * MERCHANT_CARD_RENDER_SCALE;
55
+ const MERCHANT_CARD_OFFSET_3 = 3 * MERCHANT_CARD_RENDER_SCALE;
56
+ // 今日卡片商品名只有 8.5px,沿用 11 倍描边半径会把字糊成一坨,单独用小半径
57
+ const MERCHANT_TODAY_NAME_STROKE = 3 * MERCHANT_CARD_RENDER_SCALE;
58
+ // 单件商品卡里 .goods-img-wrap 的尺寸(模板基准 px),图标等面积归一的框;
59
+ // 「不透明内容」统一缩放到的面积约等于 55×55 方图,再大细长商品会溢出图标框。
60
+ const MERCHANT_ICON_BOX_WIDTH = 74;
61
+ const MERCHANT_ICON_BOX_HEIGHT = 61.5;
62
+ const MERCHANT_ICON_TARGET_AREA = 3000;
63
+ // 单个图标取图超时;取不到就按原样渲染,不影响出卡片
64
+ const MERCHANT_ICON_FETCH_TIMEOUT_MS = 8000;
65
+ const MERCHANT_CARD_ICON_BOX = {
66
+ width: MERCHANT_ICON_BOX_WIDTH,
67
+ height: MERCHANT_ICON_BOX_HEIGHT,
68
+ targetArea: MERCHANT_ICON_TARGET_AREA,
69
+ };
70
+ // 今日卡片一轮一行:每行左侧轮次标签,右侧最多并排 5 件商品。
71
+ // 列宽固定(不随该轮商品数变化),否则同一张卡片里图标大小又会参差;
72
+ // 一轮超过 5 件时整行一起收窄、图标框跟着缩,保证不溢出行宽。
73
+ const MERCHANT_TODAY_START_Y = 592;
74
+ const MERCHANT_TODAY_ROW_HEIGHT = 368;
75
+ const MERCHANT_TODAY_ROW_GAP = 32;
76
+ // 底部信息框压在最后一行下沿 21px(沿用旧版 bottomFrameTop = lastTop + 287 算法),
77
+ // 写成正间距会在末尾留出一大片空白。
78
+ const MERCHANT_TODAY_FRAME_OVERLAP = 21;
79
+ const MERCHANT_TODAY_ROW_WIDTH = 260.75;
80
+ const MERCHANT_TODAY_TAB_WIDTH = 38;
81
+ const MERCHANT_TODAY_MAX_COLUMNS = 5;
82
+ const MERCHANT_TODAY_CELL_WIDTH = (MERCHANT_TODAY_ROW_WIDTH - MERCHANT_TODAY_TAB_WIDTH) / MERCHANT_TODAY_MAX_COLUMNS;
83
+ // 今日卡片的商品图标框(模板 .item-icon-wrap);等面积归一基准,行收窄时按面积比缩小
84
+ const MERCHANT_TODAY_ICON_SIZE = 40;
85
+ const MERCHANT_TODAY_ICON_TARGET_AREA = 1150;
86
+ // 图标尺寸按「去掉签名参数的地址」缓存,避免每次出卡片重复取图;失败缓存 null 不再重试。
87
+ const iconMetricsCache = new Map();
88
+ const defaultClock = new merchant_clock_1.MerchantClock('Asia/Shanghai');
89
+ // Scoped to each synchronous view build; plugin instances never overwrite another clock.
90
+ const clockScope = new node_async_hooks_1.AsyncLocalStorage();
91
+ const currentClock = () => clockScope.getStore() || defaultClock;
42
92
  function getChinaParts(input = Date.now()) {
43
- const parts = {};
44
- const date = input instanceof Date ? input : new Date(input);
45
- for (const item of chinaPartsFormatter.formatToParts(date)) {
46
- if (item.type !== 'literal')
47
- parts[item.type] = item.value;
48
- }
93
+ const ms = input instanceof Date ? input.getTime() : input;
94
+ const parts = currentClock().parts(ms);
95
+ const [year, month, day] = parts.date.split('-').map(Number);
49
96
  return {
50
- year: Number(parts.year || '0'),
51
- month: Number(parts.month || '0'),
52
- day: Number(parts.day || '0'),
53
- hour: Number(parts.hour || '0'),
54
- minute: Number(parts.minute || '0'),
55
- second: Number(parts.second || '0'),
97
+ year: year || 0,
98
+ month: month || 0,
99
+ day: day || 0,
100
+ hour: parts.hour,
101
+ minute: parts.minute,
102
+ second: parts.second,
56
103
  };
57
104
  }
58
105
  function padNumber(value) {
@@ -62,10 +109,6 @@ function getChinaDateText(input = Date.now()) {
62
109
  const parts = getChinaParts(input);
63
110
  return `${parts.year}-${padNumber(parts.month)}-${padNumber(parts.day)}`;
64
111
  }
65
- function getChinaDayStartMs(input = Date.now()) {
66
- const parts = getChinaParts(input);
67
- return new Date(`${parts.year}-${padNumber(parts.month)}-${padNumber(parts.day)}T00:00:00+08:00`).getTime();
68
- }
69
112
  function classifyMerchantItem(item) {
70
113
  const start = normalizeTimestamp(item?.start_time);
71
114
  const end = normalizeTimestamp(item?.end_time);
@@ -115,34 +158,6 @@ function getMerchantActivity(res) {
115
158
  const activities = res?.merchantActivities || res?.merchant_activities || [];
116
159
  return activities[0] || {};
117
160
  }
118
- function getMerchantProducts(res) {
119
- const activity = getMerchantActivity(res);
120
- const groups = [];
121
- if (Array.isArray(activity?.products))
122
- groups.push(activity.products);
123
- if (Array.isArray(activity?.product_list))
124
- groups.push(activity.product_list);
125
- if (Array.isArray(activity?.get_props))
126
- groups.push(activity.get_props);
127
- if (Array.isArray(activity?.get_extra_props))
128
- groups.push(activity.get_extra_props);
129
- if (Array.isArray(activity?.get_pets))
130
- groups.push(activity.get_pets);
131
- const merged = [];
132
- const seen = new Set();
133
- for (const list of groups) {
134
- for (const item of list) {
135
- const start = normalizeTimestamp(item?.start_time) ?? 0;
136
- const end = normalizeTimestamp(item?.end_time) ?? Infinity;
137
- const key = `${item?.id ?? ''}|${item?.name ?? ''}|${start}|${end === Infinity ? 'inf' : end}`;
138
- if (seen.has(key))
139
- continue;
140
- seen.add(key);
141
- merged.push(item);
142
- }
143
- }
144
- return merged;
145
- }
146
161
  function isMerchantItemActive(item, now = Date.now()) {
147
162
  const nowMs = now instanceof Date ? now.getTime() : now;
148
163
  const start = normalizeTimestamp(item?.start_time);
@@ -154,8 +169,7 @@ function isMerchantItemToday(item, now = Date.now()) {
154
169
  const end = normalizeTimestamp(item?.end_time);
155
170
  if (start === null || end === null)
156
171
  return true;
157
- const startOfDay = getChinaDayStartMs(now);
158
- const endOfDay = startOfDay + 24 * 60 * 60 * 1000;
172
+ const { start: startOfDay, end: endOfDay } = currentClock().dayBounds(now instanceof Date ? now.getTime() : now);
159
173
  return start < endOfDay && end > startOfDay;
160
174
  }
161
175
  function getRoundForItem(item, now = Date.now()) {
@@ -200,81 +214,230 @@ function getCurrentMerchantRound(now = new Date()) {
200
214
  round_id: `${datePart}-${currentRound || 'closed'}`,
201
215
  };
202
216
  }
217
+ // @全体 的写法。
218
+ const MENTION_ALL_ALIASES = new Set(['@全体', '全体', '@所有人', 'at全体', 'at所有人']);
203
219
  function parseMerchantSubscriptionArgs(args, defaultItems) {
204
220
  const parts = (args || '').split(/\s+/).filter(Boolean);
205
- let mentionAll = false;
221
+ let mentionAll = null;
206
222
  if (parts[0] === '1' || parts[0] === '0') {
207
223
  mentionAll = parts.shift() === '1';
208
224
  }
209
- const matchAll = parts.length === 1 && parts[0] === '全部';
225
+ // 支持「订阅远行商人 @全体 1 3」写法。
226
+ for (let i = parts.length - 1; i >= 0; i--) {
227
+ if (MENTION_ALL_ALIASES.has(parts[i].toLowerCase())) {
228
+ mentionAll = true;
229
+ parts.splice(i, 1);
230
+ }
231
+ }
232
+ const matchAll = parts.length === 1 && (0, merchant_catalog_1.isAllAlias)(parts[0]);
210
233
  if (matchAll)
211
234
  parts.shift();
212
235
  const items = matchAll ? [] : (parts.length ? parts : defaultItems);
213
236
  const source = matchAll ? '全部商品' : (parts.length ? TEXT.customSource : TEXT.defaultSource);
214
- return {
215
- mention_all: mentionAll,
216
- match_all: matchAll,
217
- items,
218
- source,
219
- };
237
+ return { mentionAll, match_all: matchAll, tokens: parts, items, source };
220
238
  }
221
239
  function getSubscriptionTarget(session) {
222
240
  const platform = session.platform || session.bot?.platform || '';
223
- const privateChat = !session.guildId;
241
+ const privateChat = (0, permissions_1.isDirectSession)(session);
224
242
  const channelId = session.channelId || session.guildId || session.userId || '';
225
- const key = privateChat ? `private_${session.userId}` : session.guildId;
243
+ const key = privateChat ? `private_${session.userId}` : (session.guildId || channelId);
226
244
  return { key, platform, channelId, privateChat };
227
245
  }
228
246
  function isBotAdmin(session, adminUserIds) {
229
247
  return adminUserIds.includes(session?.userId || '');
230
248
  }
249
+ // 订阅管理权限:群主/管理员与 Bot 管理员按独立开关生效。
250
+ function canManageGroupSubscription(deps, session) {
251
+ return (0, permissions_1.canManageSubscription)(deps.config, session);
252
+ }
231
253
  function sameStringArray(left, right) {
232
254
  return JSON.stringify(left) === JSON.stringify(right);
233
255
  }
234
- function getRandomGoodsMaps(res) {
235
- const priceMap = new Map();
236
- const limitMap = new Map();
237
- const randomGoods = Array.isArray(res?.random_goods)
238
- ? res.random_goods
239
- : Array.isArray(res?.randomGoods)
240
- ? res.randomGoods
241
- : [];
242
- for (const item of randomGoods) {
243
- const name = String(item?.goods_name || item?.name || '').trim();
244
- if (!name)
245
- continue;
246
- if (item?.price !== undefined && item?.price !== null && item.price !== '')
247
- priceMap.set(name, item.price);
248
- if (item?.buy_limit_num !== undefined && item?.buy_limit_num !== null && item.buy_limit_num !== '') {
249
- limitMap.set(name, item.buy_limit_num);
250
- }
251
- }
252
- return { priceMap, limitMap };
253
- }
254
- function normalizeMerchantProducts(res, now = new Date()) {
255
- return getMerchantProducts(res).map((item) => {
256
- const name = String(item?.name || item?.goods_name || TEXT.unknown).trim() || TEXT.unknown;
256
+ function normalizeMerchantProducts(res, now = new Date(), options) {
257
+ const nowMs = now.getTime();
258
+ const { items } = (0, merchant_service_1.normalizeMerchant)(res, nowMs, options);
259
+ return items.map((item) => {
260
+ const source = { start_time: item.startsAt, end_time: item.endsAt };
257
261
  return {
258
- name,
259
- image: String(item?.icon_url || item?.iconUrl || ''),
260
- time_label: formatProductWindow(item),
261
- category: classifyMerchantItem(item),
262
- round_id: getRoundForItem(item, now),
263
- is_active: isMerchantItemActive(item, now),
264
- start_time: normalizeTimestamp(item?.start_time),
265
- end_time: normalizeTimestamp(item?.end_time),
262
+ name: item.name,
263
+ image: item.icon,
264
+ time_label: formatProductWindow(source),
265
+ category: classifyMerchantItem(source),
266
+ round_id: getRoundForItem(source, now),
267
+ is_active: item.active,
268
+ start_time: item.startsAt,
269
+ end_time: item.endsAt,
270
+ price: item.price,
271
+ limit: item.limit,
272
+ item_category: item.category,
273
+ allDay: item.allDay,
274
+ goods_id: item.id,
266
275
  };
267
276
  });
268
277
  }
269
- function buildMerchantCardItems(products, res, options) {
270
- const { priceMap, limitMap } = getRandomGoodsMaps(res);
278
+ // 实时接口只给当前轮次的价格,轮次一过就拿不到了;
279
+ // 每次拿到实时数据先按 goods_id + 轮次落盘,供当天后续轮次补价。
280
+ function recordLiveMerchantPrices(deps, res, now = new Date()) {
281
+ const priceLog = deps.merchantPriceLog;
282
+ if (!priceLog || !res || res._source !== 'live' || !Array.isArray(res.goods))
283
+ return;
284
+ const nowMs = now.getTime();
285
+ const currentRound = getCurrentMerchantRound(now).current ?? 0;
286
+ const mapping = new Map();
287
+ for (const meta of Array.isArray(res.goods_mapping) ? res.goods_mapping : []) {
288
+ if (meta && meta.goods_id !== undefined && meta.goods_id !== null)
289
+ mapping.set(String(meta.goods_id), meta);
290
+ }
291
+ for (const meta of Array.isArray(res._goods_mapping) ? res._goods_mapping : []) {
292
+ if (meta && meta.goods_id !== undefined && meta.goods_id !== null && !mapping.has(String(meta.goods_id)))
293
+ mapping.set(String(meta.goods_id), meta);
294
+ }
295
+ const priceOf = (value, key) => {
296
+ if (value && typeof value === 'object') {
297
+ const target = value[key] ?? (key === 'real' ? value.origin : value.real);
298
+ const num = target && typeof target === 'object' ? Number(target.amount) : Number(target);
299
+ return Number.isFinite(num) && num > 0 ? num : 0;
300
+ }
301
+ const num = Number(value);
302
+ return Number.isFinite(num) && num > 0 ? num : 0;
303
+ };
304
+ // 轮次判定:带 next_refresh_time 的随轮次刷新,缺省视为全天(round 0)。
305
+ const roundOf = (raw) => {
306
+ const refreshAt = (0, merchant_service_1.merchantTimestampMs)(raw?.next_refresh_time);
307
+ if (!refreshAt)
308
+ return 0;
309
+ return currentRound;
310
+ };
311
+ const grouped = new Map();
312
+ for (const raw of res.goods) {
313
+ if (!raw || typeof raw !== 'object')
314
+ continue;
315
+ const goodsId = raw.goods_id ?? raw.id;
316
+ const meta = mapping.get(String(goodsId)) || {};
317
+ const round = roundOf(raw);
318
+ const list = grouped.get(round) || [];
319
+ list.push({
320
+ goods_id: goodsId,
321
+ goods_name: meta.goods_name || raw.goods_name || raw.name,
322
+ item_id: meta.item_id,
323
+ item_num: meta.item_num,
324
+ buy_limit_num: raw.limit_buy_num,
325
+ price: priceOf(raw.price, 'real'),
326
+ origin_price: priceOf(raw.price, 'origin'),
327
+ });
328
+ grouped.set(round, list);
329
+ }
330
+ for (const [round, list] of grouped) {
331
+ priceLog.record(list, { now, round });
332
+ }
333
+ }
334
+ // 今日远行商人:历史轮次商品在实时接口拿不到价格(旧接口 random_goods 恒为 0),
335
+ // 用当天记录下来的实时价格补上。
336
+ function backfillTodayPrices(priceLog, products, now = new Date()) {
337
+ if (!priceLog)
338
+ return products;
339
+ for (const product of products) {
340
+ if (product.price && product.price > 0)
341
+ continue;
342
+ const round = product.round_id ?? getRoundForItem({ start_time: product.start_time, end_time: product.end_time }, now) ?? 0;
343
+ const found = priceLog.lookup(product.goods_id, product.name, round);
344
+ if (!found)
345
+ continue;
346
+ product.price = found.price;
347
+ if (product.limit === null || product.limit === undefined)
348
+ product.limit = found.buy_limit_num || null;
349
+ }
350
+ return products;
351
+ }
352
+ function buildMerchantRenderParams(pageHeight) {
353
+ return {
354
+ pageHeight,
355
+ renderWidth: MERCHANT_CARD_RENDER_WIDTH,
356
+ renderHeight: Math.ceil(pageHeight * MERCHANT_CARD_OUTPUT_SCALE),
357
+ renderBaseWidth: MERCHANT_CARD_RENDER_BASE_WIDTH,
358
+ renderBaseHeight: pageHeight * MERCHANT_CARD_LAYOUT_SCALE,
359
+ renderScale: MERCHANT_CARD_RENDER_SCALE,
360
+ renderZoom: MERCHANT_CARD_RENDER_ZOOM,
361
+ renderDeviceScaleFactor: MERCHANT_CARD_DEVICE_SCALE_FACTOR,
362
+ renderStroke11: MERCHANT_CARD_STROKE_11,
363
+ renderStroke10: MERCHANT_CARD_STROKE_10,
364
+ renderStrokeName: MERCHANT_TODAY_NAME_STROKE,
365
+ renderOffset3: MERCHANT_CARD_OFFSET_3,
366
+ };
367
+ }
368
+ /** 把图标换算成模板用的 transform;没有测量结果就原样渲染。 */
369
+ function buildMerchantIconStyle(iconUrl, iconMetrics, box = MERCHANT_CARD_ICON_BOX) {
370
+ const fallback = { iconScale: 1, iconOffsetX: 0, iconOffsetY: 0 };
371
+ const url = String(iconUrl || '').trim();
372
+ if (!url || !iconMetrics)
373
+ return fallback;
374
+ const fit = (0, icon_metrics_1.buildIconFit)(iconMetrics.get(url), box);
375
+ return fit
376
+ ? { iconScale: fit.scale, iconOffsetX: fit.offsetX, iconOffsetY: fit.offsetY }
377
+ : fallback;
378
+ }
379
+ async function measureMerchantIcon(deps, url) {
380
+ const cacheKey = String(url || '').trim().split('?')[0];
381
+ if (!cacheKey)
382
+ return null;
383
+ if (iconMetricsCache.has(cacheKey))
384
+ return iconMetricsCache.get(cacheKey) ?? null;
385
+ let metrics = null;
386
+ try {
387
+ if (!deps.ctx?.http)
388
+ throw new Error('http service unavailable');
389
+ const response = await deps.ctx.http('GET', url, {
390
+ responseType: 'arraybuffer',
391
+ timeout: MERCHANT_ICON_FETCH_TIMEOUT_MS,
392
+ });
393
+ const status = Number(response?.status) || 0;
394
+ if (status >= 400)
395
+ throw new Error(`HTTP ${status}`);
396
+ const body = response?.data;
397
+ metrics = (0, icon_metrics_1.readImageMetrics)(Buffer.isBuffer(body) ? body : Buffer.from(body || []));
398
+ }
399
+ catch (error) {
400
+ logger.warn(`读取商品图标失败(${cacheKey}):${error?.message || error}`);
401
+ }
402
+ iconMetricsCache.set(cacheKey, metrics);
403
+ return metrics;
404
+ }
405
+ /** 批量量图标,返回 Map<icon_url, metrics>;个别失败只是缺那一条。 */
406
+ async function collectMerchantIconMetrics(deps, urls) {
407
+ const unique = [...new Set([...urls].map(url => String(url || '').trim()).filter(Boolean))];
408
+ const metrics = new Map();
409
+ await Promise.all(unique.map(async (url) => {
410
+ const measured = await measureMerchantIcon(deps, url);
411
+ if (measured)
412
+ metrics.set(url, measured);
413
+ }));
414
+ return metrics;
415
+ }
416
+ /** 收集商人响应里出现过的图标地址(去重);图标只挂在旧接口的 get_props / get_extra_props / get_pets 上。 */
417
+ function collectMerchantIconUrls(res) {
418
+ let root = res || {};
419
+ if (root.result && typeof root.result === 'object')
420
+ root = root.result;
421
+ const merchantActivities = Array.isArray(root.merchantActivities)
422
+ ? root.merchantActivities
423
+ : Array.isArray(root.merchant_activities) ? root.merchant_activities : [];
424
+ const urls = new Set();
425
+ for (const activity of merchantActivities) {
426
+ for (const key of ['get_props', 'get_extra_props', 'get_pets']) {
427
+ for (const item of Array.isArray(activity?.[key]) ? activity[key] : []) {
428
+ const url = String(item?.icon_url || '').trim();
429
+ if (url)
430
+ urls.add(url);
431
+ }
432
+ }
433
+ }
434
+ return [...urls];
435
+ }
436
+ function buildMerchantGoodsAll(products, options) {
271
437
  const catOrder = { round: 0, normal: 1, weekend: 2 };
272
- const startY = 592;
273
- const cardHeight = 308;
274
- const gap = 43;
275
438
  const goodsAll = products.map((product) => {
276
- const limit = limitMap.get(product.name);
277
- const limitText = limit === undefined || limit === null || limit === '' ? '--' : String(limit);
439
+ const limit = product.limit;
440
+ const limitText = limit === undefined || limit === null ? '--' : String(limit);
278
441
  const isHot = product.category !== 'round';
279
442
  const isEnded = options.includeEnded ? !product.is_active : false;
280
443
  const roundPrefix = product.round_id ? `第${product.round_id}轮·` : '';
@@ -286,7 +449,7 @@ function buildMerchantCardItems(products, res, options) {
286
449
  return {
287
450
  goods_name: product.name,
288
451
  iconUrl: product.image,
289
- price: priceMap.get(product.name) ?? 0,
452
+ price: product.price ?? 0,
290
453
  num: '',
291
454
  category: product.category,
292
455
  roundId: product.round_id || 0,
@@ -303,15 +466,65 @@ function buildMerchantCardItems(products, res, options) {
303
466
  return catOrder[a.category] - catOrder[b.category];
304
467
  return Number(b.price || 0) - Number(a.price || 0);
305
468
  });
469
+ return goodsAll;
470
+ }
471
+ function buildMerchantCardItems(products, options) {
472
+ const startY = 592;
473
+ const cardHeight = 308;
474
+ const gap = 43;
475
+ const goodsAll = buildMerchantGoodsAll(products, options);
306
476
  const goods = goodsAll.map((item, index) => ({
307
477
  ...item,
478
+ ...buildMerchantIconStyle(item.iconUrl, options.iconMetrics),
308
479
  num: String(index + 1).padStart(2, '0'),
309
480
  top: startY + index * (cardHeight + gap),
310
481
  }));
311
482
  const lastCardTop = goods.length > 0 ? goods[goods.length - 1].top : startY;
312
483
  const bottomFrameTop = lastCardTop + 287;
313
484
  const pageHeight = bottomFrameTop + 160;
314
- return { goods, bottomFrameTop, pageHeight };
485
+ return { goods, bottomFrameTop, ...buildMerchantRenderParams(pageHeight) };
486
+ }
487
+ /**
488
+ * 今日卡片按轮次分行:热销(roundId 0,全天/跨天在架)置顶,第 1~4 轮升序。
489
+ * 每件商品落在哪一轮由商品数据的 roundId 事先算好。
490
+ */
491
+ function buildTodayRows(goods, currentRoundId, iconMetrics) {
492
+ const grouped = new Map();
493
+ for (const item of goods) {
494
+ const roundId = Number(item.roundId) || 0;
495
+ if (!grouped.has(roundId))
496
+ grouped.set(roundId, []);
497
+ grouped.get(roundId).push(item);
498
+ }
499
+ const roundIds = [...grouped.keys()].sort((a, b) => {
500
+ if (a === 0)
501
+ return -1;
502
+ if (b === 0)
503
+ return 1;
504
+ return a - b;
505
+ });
506
+ const available = MERCHANT_TODAY_ROW_WIDTH - MERCHANT_TODAY_TAB_WIDTH;
507
+ return roundIds.map((roundId, index) => {
508
+ const win = ROUND_WINDOWS.find(item => item.id === roundId);
509
+ const list = grouped.get(roundId);
510
+ const cellWidth = Math.min(MERCHANT_TODAY_CELL_WIDTH, available / Math.max(list.length, 1));
511
+ const iconSize = Math.min(MERCHANT_TODAY_ICON_SIZE, cellWidth - 4);
512
+ const iconBox = {
513
+ width: iconSize,
514
+ height: iconSize,
515
+ targetArea: MERCHANT_TODAY_ICON_TARGET_AREA * ((iconSize / MERCHANT_TODAY_ICON_SIZE) ** 2),
516
+ };
517
+ return {
518
+ roundId,
519
+ label: win ? `第${roundId}轮` : '热销',
520
+ timeLabel: win ? `${padNumber(win.startHour)}-${padNumber(win.endHour)}` : '全天',
521
+ isCurrent: Boolean(currentRoundId) && roundId === currentRoundId,
522
+ top: MERCHANT_TODAY_START_Y + (index * (MERCHANT_TODAY_ROW_HEIGHT + MERCHANT_TODAY_ROW_GAP)),
523
+ cellWidth,
524
+ iconSize,
525
+ items: list.map(item => ({ ...item, ...buildMerchantIconStyle(item.iconUrl, iconMetrics, iconBox) })),
526
+ };
527
+ });
315
528
  }
316
529
  function getCurrentMerchantProducts(products, roundInfo) {
317
530
  return products.filter((product) => {
@@ -386,8 +599,20 @@ function getMerchantDateStr(now = new Date()) {
386
599
  const parts = getChinaParts(now);
387
600
  return `${parts.month}.${parts.day}`;
388
601
  }
389
- function buildMerchantRenderPayload(res, now = new Date()) {
390
- const allProducts = normalizeMerchantProducts(res, now);
602
+ // 商品缺图标时按 item_id/item_kind 拼 Wiki 物品图,仍缺失时用本地 logo 兜底。
603
+ function merchantViewOptions(deps) {
604
+ const secondsOfDay = (ms) => {
605
+ const parts = getChinaParts(ms);
606
+ return parts.hour * 3600 + parts.minute * 60 + parts.second;
607
+ };
608
+ return {
609
+ secondsOfDay,
610
+ assetBaseUrl: deps.client?.wikiAssetBaseUrl,
611
+ fallbackIcon: deps.renderer?.resourceUrl?.('img/logo.cVSpb3sL.png') || '',
612
+ };
613
+ }
614
+ function buildMerchantRenderPayload(res, now = new Date(), options, iconMetrics) {
615
+ const allProducts = normalizeMerchantProducts(res, now, options);
391
616
  const roundInfo = getCurrentMerchantRound(now);
392
617
  const products = getCurrentMerchantProducts(allProducts, roundInfo);
393
618
  const currentWindow = ROUND_WINDOWS.find(win => win.id === roundInfo.current);
@@ -397,13 +622,13 @@ function buildMerchantRenderPayload(res, now = new Date()) {
397
622
  const data = {
398
623
  dateStr: getMerchantDateStr(now),
399
624
  timeRange,
400
- ...buildMerchantCardItems(products, res, { includeEnded: false }),
625
+ ...buildMerchantCardItems(products, { includeEnded: false, iconMetrics }),
401
626
  };
402
627
  const fallback = buildMerchantFallbackText(TEXT.merchant, products, roundInfo);
403
628
  return { products, roundInfo, data, fallback };
404
629
  }
405
- function buildLegacyMerchantRenderPayload(res, now = new Date()) {
406
- const allProducts = normalizeMerchantProducts(res, now);
630
+ function buildLegacyMerchantRenderPayload(res, now = new Date(), options) {
631
+ const allProducts = normalizeMerchantProducts(res, now, options);
407
632
  const roundInfo = getCurrentMerchantRound(now);
408
633
  const products = getCurrentMerchantProducts(allProducts, roundInfo);
409
634
  const roundGroups = getLegacyRoundGroups(now, roundInfo);
@@ -421,31 +646,48 @@ function buildLegacyMerchantRenderPayload(res, now = new Date()) {
421
646
  const fallback = buildMerchantFallbackText(TEXT.merchant, products, roundInfo);
422
647
  return { products, roundInfo, data, fallback };
423
648
  }
424
- function buildTodayMerchantRenderPayload(res, now = new Date()) {
425
- const products = normalizeMerchantProducts(res, now)
649
+ function buildTodayMerchantRenderPayload(res, now = new Date(), options, priceLog, iconMetrics) {
650
+ const products = backfillTodayPrices(priceLog, normalizeMerchantProducts(res, now, options)
426
651
  .filter(product => {
427
652
  const source = {
428
653
  start_time: product.start_time,
429
654
  end_time: product.end_time,
430
655
  };
431
656
  return isMerchantItemToday(source, now);
432
- });
657
+ }), now);
658
+ const roundInfo = getCurrentMerchantRound(now);
659
+ const goodsAll = buildMerchantGoodsAll(products, { includeEnded: true });
660
+ const rows = buildTodayRows(goodsAll, roundInfo.current, iconMetrics);
661
+ // 扁平 goods 仍按行序编号,方便日志与订阅侧复用
662
+ const goods = [];
663
+ for (const row of rows) {
664
+ for (const item of row.items) {
665
+ goods.push({ ...item, num: String(goods.length + 1).padStart(2, '0') });
666
+ }
667
+ }
668
+ const lastRowTop = rows.length > 0 ? rows[rows.length - 1].top : MERCHANT_TODAY_START_Y;
669
+ const bottomFrameTop = (lastRowTop + MERCHANT_TODAY_ROW_HEIGHT) - MERCHANT_TODAY_FRAME_OVERLAP;
670
+ const pageHeight = bottomFrameTop + 160;
433
671
  const data = {
434
672
  dateStr: getMerchantDateStr(now),
435
- ...buildMerchantCardItems(products, res, { includeEnded: true }),
673
+ goods,
674
+ rows,
675
+ startTop: MERCHANT_TODAY_START_Y,
676
+ bottomFrameTop,
677
+ ...buildMerchantRenderParams(pageHeight),
436
678
  };
437
679
  const fallback = buildMerchantFallbackText(`今日远行商人 (${getChinaDateText(now)})`, products);
438
680
  return { products, data, fallback };
439
681
  }
440
- function buildLegacyTodayMerchantRenderPayload(res, now = new Date()) {
441
- const allProducts = normalizeMerchantProducts(res, now)
682
+ function buildLegacyTodayMerchantRenderPayload(res, now = new Date(), options, priceLog) {
683
+ const allProducts = backfillTodayPrices(priceLog, normalizeMerchantProducts(res, now, options)
442
684
  .filter(product => {
443
685
  const source = {
444
686
  start_time: product.start_time,
445
687
  end_time: product.end_time,
446
688
  };
447
689
  return isMerchantItemToday(source, now);
448
- });
690
+ }), now);
449
691
  const roundInfo = getCurrentMerchantRound(now);
450
692
  const roundGroups = getLegacyRoundGroups(now, roundInfo);
451
693
  for (const product of allProducts) {
@@ -471,45 +713,111 @@ function buildLegacyTodayMerchantRenderPayload(res, now = new Date()) {
471
713
  function useLegacyMerchantUi(deps) {
472
714
  return deps.config.merchantUiStyle === 'old';
473
715
  }
474
- function buildConfiguredMerchantRenderPayload(deps, res, now = new Date()) {
475
- const payload = useLegacyMerchantUi(deps)
476
- ? buildLegacyMerchantRenderPayload(res, now)
477
- : buildMerchantRenderPayload(res, now);
478
- return {
479
- ...payload,
480
- templateName: useLegacyMerchantUi(deps) ? 'yuanxing-shangren' : 'yuanxing-shangren/merchant',
481
- };
716
+ async function buildConfiguredMerchantRenderPayload(deps, res, now = new Date()) {
717
+ const options = merchantViewOptions(deps);
718
+ return clockScope.run(new merchant_clock_1.MerchantClock(deps.config.merchantTimezone || 'Asia/Shanghai'), async () => {
719
+ if (useLegacyMerchantUi(deps)) {
720
+ return {
721
+ ...buildLegacyMerchantRenderPayload(res, now, options),
722
+ templateName: 'yuanxing-shangren',
723
+ };
724
+ }
725
+ const iconMetrics = await collectMerchantIconMetrics(deps, collectMerchantIconUrls(res));
726
+ return {
727
+ ...buildMerchantRenderPayload(res, now, options, iconMetrics),
728
+ templateName: 'yuanxing-shangren/merchant',
729
+ };
730
+ });
482
731
  }
483
- function buildConfiguredTodayMerchantRenderPayload(deps, res, now = new Date()) {
484
- const payload = useLegacyMerchantUi(deps)
485
- ? buildLegacyTodayMerchantRenderPayload(res, now)
486
- : buildTodayMerchantRenderPayload(res, now);
487
- return {
488
- ...payload,
489
- templateName: useLegacyMerchantUi(deps) ? 'yuanxing-shangren' : 'yuanxing-shangren/today',
490
- };
732
+ async function buildConfiguredTodayMerchantRenderPayload(deps, res, now = new Date()) {
733
+ const options = merchantViewOptions(deps);
734
+ return clockScope.run(new merchant_clock_1.MerchantClock(deps.config.merchantTimezone || 'Asia/Shanghai'), async () => {
735
+ if (useLegacyMerchantUi(deps)) {
736
+ return {
737
+ ...buildLegacyTodayMerchantRenderPayload(res, now, options, deps.merchantPriceLog),
738
+ templateName: 'yuanxing-shangren',
739
+ };
740
+ }
741
+ const iconMetrics = await collectMerchantIconMetrics(deps, collectMerchantIconUrls(res));
742
+ return {
743
+ ...buildTodayMerchantRenderPayload(res, now, options, deps.merchantPriceLog, iconMetrics),
744
+ templateName: 'yuanxing-shangren/today',
745
+ };
746
+ });
747
+ }
748
+ // 读取订阅当日已推送的全天商品名集合(跨天自动失效)。
749
+ function getAlldayPushedNames(sub, todayKey) {
750
+ const state = sub.allday_pushed;
751
+ if (!state || state.date !== todayKey)
752
+ return new Set();
753
+ return new Set((state.names || []).map(String));
754
+ }
755
+ // 把本次推送命中的全天商品记入当日已推送状态。
756
+ function recordAlldayPushed(sub, todayKey, names) {
757
+ const existing = sub.allday_pushed?.date === todayKey ? [...(sub.allday_pushed.names || [])] : [];
758
+ for (const name of names) {
759
+ if (!existing.includes(name))
760
+ existing.push(name);
761
+ }
762
+ return existing.length ? { date: todayKey, names: existing } : undefined;
491
763
  }
492
- async function checkMerchantSubscriptions(deps) {
764
+ async function checkMerchantSubscriptions(deps, signal) {
493
765
  const { ctx, client, merchantSubMgr, renderer, config } = deps;
494
- const res = await client.getMerchantInfo(ctx, true);
495
- if (!res)
496
- return { subscriptions: 0, matched: 0, pushed: 0 };
497
- const { products, roundInfo, data, fallback, templateName } = buildConfiguredMerchantRenderPayload(deps, res);
766
+ const subs = merchantSubMgr.getAll();
767
+ if (!Object.keys(subs).length)
768
+ return { subscriptions: 0, matched: 0, pushed: 0, kind: 'no-subscriptions' };
769
+ if (signal?.aborted)
770
+ return { subscriptions: 0, matched: 0, pushed: 0, kind: 'cancelled' };
771
+ const res = await client.getMerchantInfo(ctx, true, { signal });
772
+ if (signal?.aborted)
773
+ return { subscriptions: 0, matched: 0, pushed: 0, kind: 'cancelled' };
774
+ // 接口失败视为空结果等待稍后重试,不再发送空商品图片。
775
+ if (!res) {
776
+ logger.warn(`远行商人订阅查询失败:${client.getLastErrorBrief?.() || '接口无返回'},等待重试`);
777
+ return { subscriptions: Object.keys(subs).length, matched: 0, pushed: 0, kind: 'error' };
778
+ }
779
+ recordLiveMerchantPrices(deps, res);
780
+ const { products, roundInfo, data, fallback, templateName } = await buildConfiguredMerchantRenderPayload(deps, res);
781
+ if (!products.length)
782
+ return { subscriptions: Object.keys(subs).length, matched: 0, pushed: 0, kind: 'empty' };
498
783
  const productNames = products.map((p) => p.name || '').filter(Boolean);
499
- const rendered = await renderer.renderHtml(ctx, templateName, data);
784
+ // 开启后全天商品每个订阅每天只推送一次;仅作用于按商品名订阅的推送,
785
+ // “全部商品”订阅仍是每轮推送整张商人图。
786
+ const alldayNames = config.merchantAllDayOnceEnabled ? (0, merchant_service_1.collectAllDayProductNames)(products) : new Set();
787
+ const todayKey = new merchant_clock_1.MerchantClock(config.merchantTimezone || 'Asia/Shanghai').date();
788
+ const rendered = await renderer.renderHtml(ctx, templateName, data, { signal });
500
789
  const renderedImage = rendered ? (0, send_image_1.compressPngImage)(rendered, config) : null;
501
- const subs = merchantSubMgr.getAll();
502
790
  let matchedCount = 0;
503
791
  let pushedCount = 0;
504
792
  for (const [key, sub] of Object.entries(subs)) {
793
+ if (signal?.aborted)
794
+ break;
795
+ if (JSON.stringify(merchantSubMgr.getAll()[key]) !== JSON.stringify(sub))
796
+ continue;
505
797
  const matchAll = !!sub.match_all;
506
- const matched = matchAll
798
+ let matched = matchAll
507
799
  ? productNames
508
800
  : sub.items.filter((item) => productNames.some(n => n.includes(item)));
509
801
  if (!matchAll && !matched.length)
510
802
  continue;
511
803
  if (matchAll && !products.length)
512
804
  continue;
805
+ // 当天已推送过的全天商品不再计入本轮命中,全部被剔除则本轮不推送。
806
+ // 订阅词条按子串匹配商品名,只有词条命中的商品全部为全天时才按全天去重。
807
+ const onlyAlldayItem = (item) => {
808
+ if (!alldayNames.size)
809
+ return false;
810
+ const hits = productNames.filter(n => n.includes(item));
811
+ return hits.length > 0 && hits.every(n => alldayNames.has(n));
812
+ };
813
+ let pushItems = matched;
814
+ if (!matchAll && alldayNames.size) {
815
+ const pushedToday = getAlldayPushedNames(sub, todayKey);
816
+ if (pushedToday.size)
817
+ pushItems = matched.filter(item => !(onlyAlldayItem(item) && pushedToday.has(item)));
818
+ }
819
+ if (!matchAll && !pushItems.length)
820
+ continue;
513
821
  matchedCount++;
514
822
  if (matchAll) {
515
823
  if (sub.last_push_round === roundInfo.round_id)
@@ -521,7 +829,7 @@ async function checkMerchantSubscriptions(deps) {
521
829
  }
522
830
  const msg = matchAll
523
831
  ? `\ud83d\udd14 \u8fdc\u884c\u5546\u4eba\u5237\u65b0\u63d0\u9192\n\u5f53\u524d\u5546\u54c1\uff1a${productNames.join('\u3001')}`
524
- : `\ud83d\udd14 \u8fdc\u884c\u5546\u4eba\u5237\u65b0\u63d0\u9192\n\u5f53\u524d\u5546\u54c1\uff1a${productNames.join('\u3001')}\n\u5339\u914d\u8ba2\u9605\uff1a${matched.join('\u3001')}`;
832
+ : `\ud83d\udd14 \u8fdc\u884c\u5546\u4eba\u5237\u65b0\u63d0\u9192\n\u5f53\u524d\u5546\u54c1\uff1a${productNames.join('\u3001')}\n\u5339\u914d\u8ba2\u9605\uff1a${pushItems.join('\u3001')}`;
525
833
  const platform = sub.platform || ctx.bots[0]?.platform;
526
834
  const channelId = sub.channel_id || sub.group_id || sub.user_id || key;
527
835
  if (!platform || !channelId) {
@@ -534,118 +842,228 @@ async function checkMerchantSubscriptions(deps) {
534
842
  channelId,
535
843
  guildId: sub.group_id || '',
536
844
  userId: sub.user_id || '',
537
- }, renderedImage, fallbackText, !!sub.mention_all);
538
- if (!sent)
845
+ selfId: sub.self_id || '',
846
+ }, renderedImage, fallbackText, !!sub.mention_all, signal);
847
+ if (!sent || signal?.aborted || JSON.stringify(merchantSubMgr.getAll()[key]) !== JSON.stringify(sub))
539
848
  continue;
540
849
  pushedCount++;
541
850
  merchantSubMgr.upsert(key, {
542
851
  ...sub,
543
852
  last_push_round: roundInfo.round_id,
544
853
  last_matched_items: matched,
854
+ allday_pushed: matchAll || !alldayNames.size
855
+ ? sub.allday_pushed
856
+ : recordAlldayPushed(sub, todayKey, pushItems.filter(onlyAlldayItem)),
545
857
  });
546
858
  }
547
- return { subscriptions: Object.keys(subs).length, matched: matchedCount, pushed: pushedCount };
859
+ return { subscriptions: Object.keys(subs).length, matched: matchedCount, pushed: pushedCount, kind: 'success' };
548
860
  }
549
861
  function register(deps) {
862
+ const merchantRunner = (0, subscription_runner_1.createRunner)(deps.ctx);
863
+ const merchantClock = new merchant_clock_1.MerchantClock(deps.config.merchantTimezone || 'Asia/Shanghai');
550
864
  const { ctx, config, client, merchantSubMgr } = deps;
551
865
  ctx.command(TEXT.merchant, '\u67e5\u770b\u8fdc\u884c\u5546\u4eba\u5546\u54c1')
552
866
  .alias('yxsr')
867
+ .userFields(['authority'])
553
868
  .action(async ({ session }) => {
554
869
  const res = await client.getMerchantInfo(ctx, true);
555
870
  if (!res)
556
871
  return `\u83b7\u53d6\u8fdc\u884c\u5546\u4eba\u6570\u636e\u5931\u8d25\uff1a${client.getLastErrorBrief()}`;
557
- const { data, fallback, templateName } = buildConfiguredMerchantRenderPayload(deps, res);
872
+ recordLiveMerchantPrices(deps, res);
873
+ const { data, fallback, templateName } = await buildConfiguredMerchantRenderPayload(deps, res);
558
874
  const png = await deps.renderer.renderHtml(ctx, templateName, data);
559
875
  await (0, send_image_1.sendImageWithFallback)(session, png, fallback, 'merchant:yuanxing-shangren', deps.config);
560
876
  });
561
877
  ctx.command(TEXT.todayMerchant, '查看今日远行商人全部商品')
878
+ .userFields(['authority'])
562
879
  .action(async ({ session }) => {
563
880
  const res = await client.getMerchantInfo(ctx, true);
564
881
  if (!res)
565
882
  return `获取今日远行商人数据失败:${client.getLastErrorBrief()}`;
566
- const { data, fallback, templateName } = buildConfiguredTodayMerchantRenderPayload(deps, res);
883
+ recordLiveMerchantPrices(deps, res);
884
+ const { data, fallback, templateName } = await buildConfiguredTodayMerchantRenderPayload(deps, res);
567
885
  const png = await deps.renderer.renderHtml(ctx, templateName, data);
568
886
  await (0, send_image_1.sendImageWithFallback)(session, png, fallback, 'merchant:yuanxing-shangren:today', deps.config);
569
887
  });
888
+ // 查看商品目录与订阅序号。
889
+ ctx.command('商人商品', '查看远行商人商品列表与订阅序号')
890
+ .alias('洛克.商人商品')
891
+ .alias('洛克商人商品')
892
+ .userFields(['authority'])
893
+ .action(async () => {
894
+ const catalog = await deps.merchantCatalog.getCatalog();
895
+ if (!catalog.length)
896
+ return '暂时取不到远行商人商品列表,请稍后再试。';
897
+ const lines = [
898
+ `远行商人商品列表(共 ${catalog.length} 件)`,
899
+ `订阅写法:${TEXT.subscribe} 1 3 5,也可以用商品名`,
900
+ '',
901
+ ];
902
+ for (const item of catalog)
903
+ lines.push(`${item.index}. ${item.name}`);
904
+ return lines.join('\n');
905
+ });
570
906
  ctx.command(`${TEXT.subscribe} [args:text]`, '\u8ba2\u9605\u8fdc\u884c\u5546\u4eba\u5546\u54c1\u63d0\u9192')
907
+ .userFields(['authority'])
571
908
  .action(async ({ session }, args) => {
572
909
  const target = getSubscriptionTarget(session);
573
- if (!target.privateChat && !isBotAdmin(session, config.adminUserIds))
574
- return '此指令仅限管理员使用。';
910
+ if (!target.privateChat && !canManageGroupSubscription(deps, session))
911
+ return '此指令仅限群管理员或机器人管理员使用。';
575
912
  if (target.privateChat && !config.merchantPrivateSubscriptionEnabled)
576
913
  return '个人私聊订阅功能已被禁用,请联系机器人管理员。';
577
914
  const parsed = parseMerchantSubscriptionArgs(args, config.merchantSubscriptionItems);
578
915
  const existing = merchantSubMgr.get(target.key);
916
+ // 序号 / 商品名 / 全部 都经商品目录解析,序号命中后换成规范名落盘。
917
+ const catalog = await deps.merchantCatalog.getCatalog();
918
+ const resolved = (0, merchant_catalog_1.resolveMerchantSelection)(parsed.tokens, catalog);
919
+ const matchAll = parsed.match_all || resolved.all;
920
+ const hasInput = parsed.match_all || parsed.tokens.length > 0;
921
+ if (hasInput && !matchAll && !resolved.names.length) {
922
+ return [
923
+ `没有识别到可订阅的商品:${resolved.unknownIndexes.join('、') || parsed.tokens.join('、')}`,
924
+ `先用 商人商品 查看商品序号,再按序号或名称订阅。`,
925
+ ].join('\n');
926
+ }
927
+ // 不带商品参数重复订阅时,沿用上次配置(全部模式或商品列表),
928
+ // 而不是悄悄重置成配置默认商品。
929
+ let sourceHint;
930
+ let items;
931
+ if (matchAll) {
932
+ items = [];
933
+ sourceHint = '全部商品(每轮推送)';
934
+ }
935
+ else if (resolved.names.length) {
936
+ items = resolved.names;
937
+ sourceHint = '本次指定';
938
+ }
939
+ else if (existing && (existing.match_all || (existing.items || []).length > 0)) {
940
+ items = existing.items || [];
941
+ sourceHint = '沿用上次配置';
942
+ }
943
+ else {
944
+ items = config.merchantSubscriptionItems;
945
+ sourceHint = '默认商品配置';
946
+ }
947
+ const matchAllFinal = matchAll || (resolved.names.length === 0 && !hasInput && existing?.match_all === true);
948
+ // 未显式指定 1/0 或 @全体 时沿用上次设置。
949
+ const mentionAll = target.privateChat
950
+ ? false
951
+ : (parsed.mentionAll === null ? (existing?.mention_all ?? false) : parsed.mentionAll);
579
952
  merchantSubMgr.upsert(target.key, {
580
953
  group_id: session.guildId || '',
581
954
  user_id: target.privateChat ? session.userId : '',
582
955
  type: target.privateChat ? '个人订阅' : '群订阅',
583
956
  channel_id: target.channelId,
584
957
  platform: target.platform,
585
- items: parsed.items,
586
- match_all: parsed.match_all,
587
- mention_all: target.privateChat ? false : parsed.mention_all,
958
+ self_id: session.selfId || session.bot?.selfId || '',
959
+ items,
960
+ match_all: matchAllFinal,
961
+ mention_all: mentionAll,
588
962
  last_push_round: existing?.last_push_round ?? null,
589
963
  last_matched_items: existing?.last_matched_items ?? [],
964
+ allday_pushed: existing?.allday_pushed,
590
965
  updated_by: session.userId,
591
966
  });
592
- return `\u2705 \u5df2\u8ba2\u9605\u8fdc\u884c\u5546\u4eba\u5546\u54c1\uff1a${parsed.match_all ? '\u5168\u90e8\u5546\u54c1\uff08\u6bcf\u8f6e\u63a8\u9001\uff09' : parsed.items.join('\u3001')}\uff08${parsed.source}\uff09\uff1b${target.privateChat ? '个人订阅' : (parsed.mention_all ? '\u547d\u4e2d\u540e\u4f1a @\u5168\u4f53' : '\u547d\u4e2d\u540e\u4e0d @\u5168\u4f53')}`;
967
+ const lines = [
968
+ `\u2705 \u5df2\u8ba2\u9605\u8fdc\u884c\u5546\u4eba\u5546\u54c1\uff08${target.privateChat ? '个人订阅' : '本群订阅'}):${matchAllFinal ? '全部商品(每轮推送整张商人图)' : `监听 ${items.length} 件商品`}`,
969
+ ];
970
+ if (!matchAllFinal)
971
+ lines.push(`监听商品:${(0, merchant_catalog_1.describeMerchantItems)(items, catalog)}`);
972
+ lines.push(`商品来源:${sourceHint}。`);
973
+ lines.push(mentionAll ? '命中后会尝试 @全体。' : '命中后不会 @全体。');
974
+ if (resolved.unknownIndexes.length)
975
+ lines.push(`未找到的序号:${resolved.unknownIndexes.join('、')}(已忽略)。`);
976
+ if (resolved.unrecognizedNames.length)
977
+ lines.push(`商品目录里没有:${resolved.unrecognizedNames.join('、')}(已按原名订阅)。`);
978
+ lines.push('');
979
+ lines.push('可用格式:');
980
+ lines.push(`${TEXT.subscribe} 1 3 国王球 按序号或名称订阅`);
981
+ lines.push(`${TEXT.subscribe} 全部 订阅全部商品,每轮推送整张商人图`);
982
+ lines.push(`${TEXT.subscribe} @全体 1 3 命中后尝试 @全体`);
983
+ lines.push(`商人商品 查看商品序号`);
984
+ return lines.join('\n');
593
985
  });
594
986
  ctx.command(TEXT.viewSubscribe, '\u67e5\u770b\u5f53\u524d\u4f1a\u8bdd\u7684\u8fdc\u884c\u5546\u4eba\u8ba2\u9605')
987
+ .userFields(['authority'])
595
988
  .action(async ({ session }) => {
596
989
  const target = getSubscriptionTarget(session);
597
- if (!target.privateChat && !isBotAdmin(session, config.adminUserIds))
598
- return '此指令仅限管理员使用。';
990
+ if (!target.privateChat && !canManageGroupSubscription(deps, session))
991
+ return '此指令仅限群管理员或机器人管理员使用。';
599
992
  const sub = merchantSubMgr.get(target.key);
600
993
  const scopeName = target.privateChat ? '你' : '当前群组';
601
994
  if (!sub)
602
995
  return `${scopeName}未订阅远行商人。\n用法:${TEXT.subscribe} [1/0] [商品名1] [商品名2] ...\n或:${TEXT.subscribe} 全部(每轮直接推送整张商人图)`;
603
- const itemsText = sub.match_all ? '全部商品(每轮推送)' : sub.items.join('、');
996
+ const catalog = await deps.merchantCatalog.getCatalog();
997
+ const itemsText = sub.match_all ? '全部商品(每轮推送)' : (0, merchant_catalog_1.describeMerchantItems)(sub.items || [], catalog) || (sub.items || []).join('、');
604
998
  return `${scopeName}订阅商品:${itemsText}\n提醒方式:${target.privateChat ? '私聊提醒' : (sub.mention_all ? '@全体' : '普通提醒')}`;
605
999
  });
606
1000
  ctx.command(TEXT.unsubscribe, '\u53d6\u6d88\u8fdc\u884c\u5546\u4eba\u8ba2\u9605')
1001
+ .userFields(['authority'])
607
1002
  .action(async ({ session }) => {
608
1003
  const target = getSubscriptionTarget(session);
609
- if (!target.privateChat && !isBotAdmin(session, config.adminUserIds))
610
- return '此指令仅限管理员使用。';
1004
+ if (!target.privateChat && !canManageGroupSubscription(deps, session))
1005
+ return '此指令仅限群管理员或机器人管理员使用。';
611
1006
  merchantSubMgr.delete(target.key);
612
1007
  return '\u2705 \u5df2\u53d6\u6d88\u8fdc\u884c\u5546\u4eba\u8ba2\u9605\u3002';
613
1008
  });
614
1009
  ctx.command('洛克').subcommand('.调试远行商人订阅', '立即执行一次远行商人订阅检查')
615
1010
  .alias('洛克调试远行商人订阅')
1011
+ .userFields(['authority'])
616
1012
  .action(async ({ session }) => {
617
1013
  if (!isBotAdmin(session, config.adminUserIds))
618
1014
  return '此指令仅限管理员使用。';
619
- const result = await checkMerchantSubscriptions(deps);
1015
+ const result = await merchantRunner(signal => checkMerchantSubscriptions(deps, signal));
1016
+ if (!result)
1017
+ return '订阅检查正在运行或插件已停止。';
620
1018
  return `远行商人订阅检查完成:订阅 ${result.subscriptions} 条,命中 ${result.matched} 条,推送 ${result.pushed} 条。`;
621
1019
  });
1020
+ const roundKey = () => {
1021
+ const p = merchantClock.parts();
1022
+ return p.date + ':' + Math.floor(p.hour / 4);
1023
+ };
1024
+ const runMerchantCheck = () => merchantRunner(async (signal) => {
1025
+ const round = roundKey();
1026
+ for (let retry = 0; retry <= 3; retry++) {
1027
+ if (signal.aborted || (retry > 0 && roundKey() !== round))
1028
+ return;
1029
+ if (!Object.keys(merchantSubMgr.getAll()).length)
1030
+ return;
1031
+ const result = await checkMerchantSubscriptions(deps, signal);
1032
+ // 接口失败与空结果一样等待稍后重试,避免直接放弃本轮推送。
1033
+ if ((result.kind !== 'empty' && result.kind !== 'error') || retry === 3)
1034
+ return;
1035
+ await (0, async_control_1.delay)(240000 + Math.floor(Math.random() * 60001) - 30000, signal);
1036
+ }
1037
+ }).catch(err => { if (!merchantRunner.aborted())
1038
+ logger.warn('远行商人订阅检查失败: ' + err); });
622
1039
  if (config.merchantSubscriptionEnabled) {
623
- // 上游 v3.1.0:检查前加入 ±30s 随机延迟,降低刷新窗口和并发请求冲突。
624
- const MERCHANT_JITTER_MS = 30000;
625
- const runCheckWithJitter = () => {
626
- const jitter = Math.floor(Math.random() * MERCHANT_JITTER_MS);
627
- setTimeout(() => {
628
- checkMerchantSubscriptions(deps).catch(err => logger.warn(`远行商人订阅检查失败: ${err}`));
629
- }, jitter);
630
- };
631
1040
  if (config.merchantCheckMode === 'times' && config.merchantCheckTimes.length > 0) {
632
- let lastMerchantCheckKey = '';
633
- ctx.setInterval(async () => {
634
- const now = new Date();
635
- const timeStr = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
636
- if (!config.merchantCheckTimes.includes(timeStr))
1041
+ const times = config.merchantCheckTimes.filter(value => /^([01]\d|2[0-3]):[0-5]\d$/.test(value));
1042
+ let lastWallKey = '';
1043
+ const schedule = (after = Date.now()) => {
1044
+ if (merchantRunner.aborted())
1045
+ return;
1046
+ const target = merchantClock.nextTimeFor(times, after);
1047
+ if (target === null)
637
1048
  return;
638
- const checkKey = `${now.toDateString()}-${timeStr}`;
639
- if (checkKey === lastMerchantCheckKey)
1049
+ const wallKey = merchantClock.date(target) + ':' + merchantClock.timeText(target);
1050
+ if (wallKey === lastWallKey) {
1051
+ schedule(target);
640
1052
  return;
641
- lastMerchantCheckKey = checkKey;
642
- runCheckWithJitter();
643
- }, 60000);
1053
+ }
1054
+ const jitter = Math.floor(Math.random() * 60001) - 30000;
1055
+ ctx.setTimeout(() => {
1056
+ lastWallKey = wallKey;
1057
+ void runMerchantCheck();
1058
+ schedule(target);
1059
+ }, Math.max(0, target + jitter - Date.now()));
1060
+ };
1061
+ schedule();
644
1062
  }
645
1063
  else {
646
- ctx.setInterval(async () => {
647
- runCheckWithJitter();
648
- }, config.merchantCheckInterval);
1064
+ ctx.setInterval(() => {
1065
+ ctx.setTimeout(() => { void runMerchantCheck(); }, Math.floor(Math.random() * 30001));
1066
+ }, Math.max(30000, config.merchantCheckInterval || 300000));
649
1067
  }
650
1068
  }
651
1069
  }