dsh-xdpool 2.4.3 → 2.4.5

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/CHANGELOG.md CHANGED
@@ -1,6 +1,46 @@
1
1
  # Changelog
2
2
  本项目的所有重要变更都会记录在此文件。
3
3
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
4
+ ## 2.4.5 — 侧栏面板能滚动了
5
+
6
+ > 2.4.4 把账号池搬到侧栏那行之后,页面在折叠处被切掉、怎么都翻不到下半页。这不是新功能缺失,是我把设置页里的组件原样塞进了另一个容器,而**那个容器不滚动**。
7
+
8
+ ### 面板被裁切
9
+
10
+ `@deepseek-ai/dsh-client-ui-layout` 把中央列样式成 `display:flex; flex-direction:column; overflow:hidden`——设置对话框自己带滚动容器,所以同一个组件放在设置里能滚动,放进主区域就只能被裁掉。现在页面外面包一层 `.dsm-xdpool-page`(`height:100%; min-height:0; overflow:auto`),由它当滚动容器;顺带补上设置对话框原本提供的内边距,否则卡片会贴着窗口边缘。设置页那条路径**不受影响**:那里宿主已经在滚,再加一层会双份。
11
+
12
+ ### 其他
13
+
14
+ - 侧栏那行标签保持 `XD Pool`(中英文都不译)。
15
+
16
+ ## 2.4.4 — 扫码登录后仍显示「登录已被拒绝」,以及侧栏入口
17
+
18
+ > 两个都不算新功能:一个是**我上一版自己的代码**把刚扫的凭据判成「旧的」;另一个是入口位置——账号池一直只能从「设置」里进。
19
+
20
+ ### 扫了码、账号池还是说「登录已被拒绝」
21
+
22
+ **你看到的**:某两个账号卡片一直显示「登录已被拒绝 / 上游已不再接受这个登录状态」,提示你去 WorkBuddy 桌面端重新登录。可你的登录途径是这个插件的扫码登录,而且刚刚扫过——扫完还是那句。
23
+
24
+ **真实原因**(两处,都在本插件自己的代码里,不在上游):
25
+
26
+ 1. **新鲜度排序把扫码凭据排在最后**。同一个账号可能同时存在两份凭据:桌面 App 留在 `auth` 目录里的快照文件,和你扫码后由本插件铸造的凭据。`compareFreshness` 原先只认三档——桌面 live 文件、`lastRefreshAtMs`、`expiresAtMs`。扫码凭据的 `sourcePath` 是 `channel-pack:<provider>:<ref>`,既不是 live 文件名、也永远没有 `lastRefreshAtMs`,于是**无条件输给任何残留的旧快照**。它输一次,凭据就不被替换;不替换,就走不到「清除死亡标记」那一行——卡片上那句提示于是永远不会消失,反复扫码也没反应。
27
+
28
+ 现在改为按来源分三级:桌面 live 文件 > 本插件扫码凭据 > 其它残留快照,同级才继续比签发时间和有效期。
29
+
30
+ 2. **渠道包的去重按昵称分组,会删掉刚扫进来的凭据**。本插件对渠道包做过本地补丁:同渠道同昵称视为重复条目,只保留一条。问题有两个——重扫时新凭据遇到未过期的旧条目会被当作重复删掉(连凭据一起删);而**同一个人的两个账号如果昵称相同,也会被当成同一个人删掉一个**。本机正好有两个都叫「青楫渡」的账号。
31
+
32
+ 现在改用凭据里的上游身份 `user_id` 判重,并且:认不出身份的一律保留(宁可留一条重复,也不能删掉别人的账号);登录半成品(昵称还是生成 id、没有有效期)在比较中让位给已完成登录的条目;都可用时按 `createdAt` 留新的。
33
+
34
+ ### 左侧栏可以直接进账号池了
35
+
36
+ 账号池此前只能从「设置 → XD Pool」进。现在侧栏底部(设置按钮附近)多了一个图标,点开就是同一个账号池卡片,`Esc` 或点面板外关闭。
37
+
38
+ 按钮与面板是两个互不相邻的宿主插槽(`sidebar.footer.action` 与 `shell.overlay`),没有共同祖先,因此开合状态放在模块级而不是组件状态里;面板直接渲染同一个 `PoolCard`,不复制一份界面。宿主只给侧栏注册者 `{ wide }`,没有「打开设置」的接口——这也是同类插件都用自己的浮层而不是跳转的原因。老宿主缺少 `shell.overlay` 时只损失这个入口,不影响设置页。
39
+
40
+ ### 测试
41
+
42
+ 账号发现新增 3 例:扫码凭据胜过旧快照、桌面 live 文件仍胜过扫码凭据、凭据被替换后死亡标记清除;另有 1 例钉住「同昵称的两个人不会被合并」。全套 **79 文件 1023 用例**。
43
+
4
44
  ## 2.4.3 — 渠道包补上 11128、粘性绑定跨重启、网关白名单、目录 TTL,外加 CodeArts 重签开关
5
45
 
6
46
  > 2.4.1 与 2.4.2 修的是**池车道**(`src/upstream.ts`);这一版把同一套 11128 规则补进**渠道车道**自己那份 channel pack,并顺带解决三件独立的事:会话粘性绑定不该止于进程边界、网关页面的勾选该是真开关、远端模型目录该会过期。四个 PR 来自社区([@GIGIGIGIbaby](https://github.com/GIGIGIGIbaby)),复核与发布由维护者完成。另附一个默认关闭的 CodeArts 重签开关(见末节)。
package/lib/bin.js CHANGED
@@ -3084,24 +3084,49 @@ function isLiveAuthFile(path) {
3084
3084
  return basename(path) === WORKBUDDY_LIVE_FILENAME;
3085
3085
  }
3086
3086
  /**
3087
+ * True for a credential this plugin minted from the channel-pack sign-in flow.
3088
+ *
3089
+ * `packCredentialToWorkBuddy` stamps `channel-pack:<provider>:<ref>` here, so the
3090
+ * prefix is the only thing distinguishing a scanned credential from a file the
3091
+ * desktop app wrote. Nothing about it is a path on disk — there is no file to
3092
+ * stat and no mtime to read.
3093
+ */
3094
+ function isChannelPackSource(path) {
3095
+ return path.startsWith("channel-pack:");
3096
+ }
3097
+ /**
3087
3098
  * Which of two credentials for the same account the pool should keep.
3088
3099
  *
3089
3100
  * Ordering, highest first:
3090
3101
  *
3091
3102
  * 1. the live file the desktop app is signed in with;
3092
- * 2. the credential the upstream issued most recently (`lastRefreshAtMs`);
3093
- * 3. the longer stored expiry, as a fallback for documents that carry no issue
3103
+ * 2. a credential minted by our own channel-pack sign-in;
3104
+ * 3. the credential the upstream issued most recently (`lastRefreshAtMs`);
3105
+ * 4. the longer stored expiry, as a fallback for documents that carry no issue
3094
3106
  * time (the plugin's own refreshed copy, older builds).
3095
3107
  *
3096
3108
  * The stored expiry alone is NOT a freshness signal: the upstream does not
3097
3109
  * rewrite it when it revokes a token, so a long-dead backup can claim to expire
3098
3110
  * later than the token that actually works. Selecting on it made every upstream
3099
3111
  * call return 401 while a perfectly good credential sat in the same directory.
3112
+ *
3113
+ * Rank 2 exists because a scanned credential loses both of the lower tests by
3114
+ * construction: it is not the live filename, and the sign-in flow records no
3115
+ * `lastRefreshAtMs`. Ranking it below a leftover snapshot meant a user who had
3116
+ * just scanned a fresh sign-in still saw "sign-in was rejected", because the
3117
+ * abandoned file kept winning the comparison and the dead mark was never
3118
+ * cleared. The live file stays on top: that is the session the running desktop
3119
+ * app is genuinely using right now, and it is newer than anything we mint.
3100
3120
  */
3101
3121
  function compareFreshness(a, b) {
3102
- const aLive = isLiveAuthFile(a.sourcePath) ? 1 : 0;
3103
- const bLive = isLiveAuthFile(b.sourcePath) ? 1 : 0;
3104
- if (aLive !== bLive) return bLive - aLive;
3122
+ const rankOf = (credential) => {
3123
+ if (isLiveAuthFile(credential.sourcePath)) return 2;
3124
+ if (isChannelPackSource(credential.sourcePath)) return 1;
3125
+ return 0;
3126
+ };
3127
+ const aRank = rankOf(a);
3128
+ const bRank = rankOf(b);
3129
+ if (aRank !== bRank) return bRank - aRank;
3105
3130
  const aIssued = a.lastRefreshAtMs ?? 0;
3106
3131
  const bIssued = b.lastRefreshAtMs ?? 0;
3107
3132
  if (aIssued !== bIssued) return bIssued - aIssued;
package/lib/client.js CHANGED
@@ -1670,6 +1670,19 @@ window.__ModuleLoader__.load({
1670
1670
  ):active:not(:disabled){transform:none}
1671
1671
  }
1672
1672
 
1673
+ `.trim();
1674
+ /**
1675
+ * Frame for hosting the pool in the main column instead of the settings dialog.
1676
+ *
1677
+ * `.centerCol` is `overflow:hidden`, so this element is the scrollport; without
1678
+ * it the page clips at the fold. The horizontal inset is asymmetric on purpose —
1679
+ * the sidebar sits on the left of this column, so the left edge needs less air
1680
+ * than the right one to look centred.
1681
+ */
1682
+ const PAGE_CSS = `
1683
+ .dsm-xdpool-page{flex:1 1 auto;height:100%;min-height:0;overflow:auto;box-sizing:border-box;padding:20px 28px 34px}
1684
+ .dsm-xdpool-page > .dsm-xdpool-shell{margin:0 auto}
1685
+ @media (max-width:720px){.dsm-xdpool-page{padding:14px 16px 26px}}
1673
1686
  `.trim();
1674
1687
  //#endregion
1675
1688
  //#region src/client/avatar.ts
@@ -12325,7 +12338,7 @@ window.__ModuleLoader__.load({
12325
12338
  *
12326
12339
  * @module dsh-xdpool/version
12327
12340
  */
12328
- const POOL_VERSION = "2.4.3";
12341
+ const POOL_VERSION = "2.4.5";
12329
12342
  //#endregion
12330
12343
  //#region src/client/PoolCard.tsx
12331
12344
  /**
@@ -14699,15 +14712,33 @@ window.__ModuleLoader__.load({
14699
14712
  * `fill-rule="evenodd"` cuts the interior notches out of the silhouette so the
14700
14713
  * three layers stay distinguishable at that size.
14701
14714
  */
14715
+ /**
14716
+ * The mark's geometry, independent of any wrapper element.
14717
+ *
14718
+ * Kept separate from {@link POOL_NAV_ICON_SVG} because the two consumers need
14719
+ * different frames: the settings nav row masks a standalone document, while the
14720
+ * sidebar panel row renders paths into an `svg` the shell sizes for it (16px
14721
+ * wide, 18px in the collapsed rail). Sharing the paths — rather than the whole
14722
+ * document, parsed back out with a regex — is what keeps the two placements from
14723
+ * drifting apart when the artwork changes.
14724
+ */
14725
+ const POOL_NAV_ICON_PATHS = [{
14726
+ fill: "currentColor",
14727
+ fillRule: "evenodd",
14728
+ d: "M12 2.6a3.1 3.1 0 1 1 0 6.2 3.1 3.1 0 0 1 0-6.2Zm0 1.7a1.4 1.4 0 1 0 0 2.8 1.4 1.4 0 0 0 0-2.8ZM6.6 8.9a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2Zm0 1.6a1 1 0 1 0 0 2 1 1 0 0 0 0-2ZM17.4 8.9a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2Zm0 1.6a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z"
14729
+ }, {
14730
+ fill: "none",
14731
+ stroke: "currentColor",
14732
+ strokeWidth: 1.7,
14733
+ strokeLinecap: "round",
14734
+ strokeDasharray: "2.6 2.2",
14735
+ d: "M4.4 17.2a8.6 8.6 0 0 0 15.2 0"
14736
+ }];
14737
+ /** The same mark as a standalone SVG document, for `mask-image`. */
14702
14738
  const POOL_NAV_ICON_SVG = [
14703
14739
  "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" width=\"16\" height=\"16\">",
14704
- "<path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"",
14705
- "M12 2.6a3.1 3.1 0 1 1 0 6.2 3.1 3.1 0 0 1 0-6.2Zm0 1.7a1.4 1.4 0 1 0 0 2.8 1.4 1.4 0 0 0 0-2.8Z",
14706
- "M6.6 8.9a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2Zm0 1.6a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z",
14707
- "M17.4 8.9a2.6 2.6 0 1 1 0 5.2 2.6 2.6 0 0 1 0-5.2Zm0 1.6a1 1 0 1 0 0 2 1 1 0 0 0 0-2Z",
14708
- "\"/>",
14709
- "<path fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.7\" stroke-linecap=\"round\"",
14710
- " d=\"M4.4 17.2a8.6 8.6 0 0 0 15.2 0\" stroke-dasharray=\"2.6 2.2\"/>",
14740
+ `<path fill="currentColor" fill-rule="evenodd" d="${POOL_NAV_ICON_PATHS[0].d}"/>`,
14741
+ `<path fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" d="${POOL_NAV_ICON_PATHS[1].d}" stroke-dasharray="2.6 2.2"/>`,
14711
14742
  "</svg>"
14712
14743
  ].join("");
14713
14744
  /**
@@ -14719,6 +14750,70 @@ window.__ModuleLoader__.load({
14719
14750
  */
14720
14751
  const POOL_NAV_ICON_MASK_URL = `data:image/svg+xml;utf8,${encodeURIComponent(POOL_NAV_ICON_SVG)}`;
14721
14752
  //#endregion
14753
+ //#region src/client/panel-icon.tsx
14754
+ /**
14755
+ * Render the pool's mark.
14756
+ *
14757
+ * Inlined rather than masked from a data URI because the shell gives the glyph an
14758
+ * 18px box in the collapsed rail: a `mask-size: 16px` silhouette would read as
14759
+ * smaller than the first-party icons beside it, and scaling the mask would blur
14760
+ * its 1.7px arc. Both placements share {@link POOL_NAV_ICON_PATHS}, so the artwork
14761
+ * cannot drift between the settings nav row and this one.
14762
+ */
14763
+ function PanelIcon({ size }) {
14764
+ const edge = typeof size === "number" && size > 0 ? size : 16;
14765
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
14766
+ xmlns: "http://www.w3.org/2000/svg",
14767
+ viewBox: "0 0 24 24",
14768
+ width: edge,
14769
+ height: edge,
14770
+ "aria-hidden": "true",
14771
+ focusable: "false",
14772
+ children: POOL_NAV_ICON_PATHS.map((shape, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { ...shape }, index))
14773
+ });
14774
+ }
14775
+ //#endregion
14776
+ //#region src/client/page.tsx
14777
+ /**
14778
+ * The wrapper that makes the pool usable as a *sidebar panel*.
14779
+ *
14780
+ * Two problems it solves, both invisible from inside `PoolShell`:
14781
+ *
14782
+ * 1. Scrolling. The settings dialog owns a scroll container, so a page dropped
14783
+ * into `settings.plugin.item` can be as tall as it likes. The main column
14784
+ * cannot: `@deepseek-ai/dsh-client-ui-layout` styles `.centerCol` as
14785
+ * `display:flex; flex-direction:column; overflow:hidden`, and the slot is
14786
+ * rendered into that box with no scrolling of its own. Without a scrollport
14787
+ * here the page simply clips — which is what happens if you register
14788
+ * `PoolCard` into the `main` slot directly. The reference community plugin
14789
+ * solves it the same way, with `height:100%; min-height:0; overflow:auto` on
14790
+ * its page root.
14791
+ *
14792
+ * 2. Insets. The settings dialog supplies padding around the card; the main
14793
+ * column supplies none, so the same markup arrives flush against the window
14794
+ * edge. The padding below restores the frame the dialog used to provide.
14795
+ *
14796
+ * `PAGE_CSS` is injected here rather than living in `SHARED_CARD_CSS` because
14797
+ * the stylesheets in `PoolShell` are rendered *inside* the shell — this wrapper
14798
+ * is its parent, so nothing from that subtree could style it.
14799
+ *
14800
+ * @module dsh-xdpool/client/page
14801
+ */
14802
+ /**
14803
+ * `PoolCard`, sized and scrolled for the main column.
14804
+ *
14805
+ * Kept as a separate component instead of an option on `PoolCard` so the
14806
+ * settings registration stays exactly what it was: the dialog already provides
14807
+ * both the scrollport and the insets, and adding either again would double
14808
+ * them.
14809
+ */
14810
+ function PoolPage(props) {
14811
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
14812
+ className: "dsm-xdpool-page",
14813
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: PAGE_CSS }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PoolCard, { ...props })]
14814
+ });
14815
+ }
14816
+ //#endregion
14722
14817
  //#region src/client/locales.ts
14723
14818
  /**
14724
14819
  * Plugin-card copy registered under the `settings.xdpool` locale
@@ -15795,6 +15890,24 @@ window.__ModuleLoader__.load({
15795
15890
  }
15796
15891
  }, PoolCard));
15797
15892
  installNavIcon(ctx, () => t("row.navLabel"));
15893
+ try {
15894
+ ctx.slots.inject("sidebar.panellist", () => ctx.slots.register({
15895
+ name: "sidebar.panellist",
15896
+ id: "xdpool",
15897
+ order: 20,
15898
+ label: () => t("row.navLabel")
15899
+ }, PanelIcon));
15900
+ ctx.slots.inject("main", () => ctx.slots.register({
15901
+ name: "main",
15902
+ key: "xdpool",
15903
+ inject: () => settingsScope === void 0 ? { t } : {
15904
+ t,
15905
+ settingsScope
15906
+ }
15907
+ }, PoolPage));
15908
+ } catch (error) {
15909
+ console.error("[dsh-xdpool] sidebar entry unavailable (settings page unaffected):", error);
15910
+ }
15798
15911
  } catch (error) {
15799
15912
  console.error("[dsh-xdpool] client page failed to load (host provider unaffected):", error);
15800
15913
  }
package/lib/index.js CHANGED
@@ -3260,24 +3260,49 @@ function isLiveAuthFile(path) {
3260
3260
  return basename(path) === WORKBUDDY_LIVE_FILENAME;
3261
3261
  }
3262
3262
  /**
3263
+ * True for a credential this plugin minted from the channel-pack sign-in flow.
3264
+ *
3265
+ * `packCredentialToWorkBuddy` stamps `channel-pack:<provider>:<ref>` here, so the
3266
+ * prefix is the only thing distinguishing a scanned credential from a file the
3267
+ * desktop app wrote. Nothing about it is a path on disk — there is no file to
3268
+ * stat and no mtime to read.
3269
+ */
3270
+ function isChannelPackSource(path) {
3271
+ return path.startsWith("channel-pack:");
3272
+ }
3273
+ /**
3263
3274
  * Which of two credentials for the same account the pool should keep.
3264
3275
  *
3265
3276
  * Ordering, highest first:
3266
3277
  *
3267
3278
  * 1. the live file the desktop app is signed in with;
3268
- * 2. the credential the upstream issued most recently (`lastRefreshAtMs`);
3269
- * 3. the longer stored expiry, as a fallback for documents that carry no issue
3279
+ * 2. a credential minted by our own channel-pack sign-in;
3280
+ * 3. the credential the upstream issued most recently (`lastRefreshAtMs`);
3281
+ * 4. the longer stored expiry, as a fallback for documents that carry no issue
3270
3282
  * time (the plugin's own refreshed copy, older builds).
3271
3283
  *
3272
3284
  * The stored expiry alone is NOT a freshness signal: the upstream does not
3273
3285
  * rewrite it when it revokes a token, so a long-dead backup can claim to expire
3274
3286
  * later than the token that actually works. Selecting on it made every upstream
3275
3287
  * call return 401 while a perfectly good credential sat in the same directory.
3288
+ *
3289
+ * Rank 2 exists because a scanned credential loses both of the lower tests by
3290
+ * construction: it is not the live filename, and the sign-in flow records no
3291
+ * `lastRefreshAtMs`. Ranking it below a leftover snapshot meant a user who had
3292
+ * just scanned a fresh sign-in still saw "sign-in was rejected", because the
3293
+ * abandoned file kept winning the comparison and the dead mark was never
3294
+ * cleared. The live file stays on top: that is the session the running desktop
3295
+ * app is genuinely using right now, and it is newer than anything we mint.
3276
3296
  */
3277
3297
  function compareFreshness(a, b) {
3278
- const aLive = isLiveAuthFile(a.sourcePath) ? 1 : 0;
3279
- const bLive = isLiveAuthFile(b.sourcePath) ? 1 : 0;
3280
- if (aLive !== bLive) return bLive - aLive;
3298
+ const rankOf = (credential) => {
3299
+ if (isLiveAuthFile(credential.sourcePath)) return 2;
3300
+ if (isChannelPackSource(credential.sourcePath)) return 1;
3301
+ return 0;
3302
+ };
3303
+ const aRank = rankOf(a);
3304
+ const bRank = rankOf(b);
3305
+ if (aRank !== bRank) return bRank - aRank;
3281
3306
  const aIssued = a.lastRefreshAtMs ?? 0;
3282
3307
  const bIssued = b.lastRefreshAtMs ?? 0;
3283
3308
  if (aIssued !== bIssued) return bIssued - aIssued;
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "dsh-xdpool",
3
3
  "displayName": "DSH XD Pool",
4
4
  "description": "One DSH model pool for every free quota you have: merge all locally signed-in WorkBuddy accounts with auto-failover, sign in to 13 vendors by QR/web, aggregate 76 vendors' free tiers, plus a usage dashboard, model routing, a local OpenAI-compatible gateway and a points-automation suite.",
5
- "version": "2.4.3",
5
+ "version": "2.4.5",
6
6
  "license": "MIT",
7
7
  "author": "XDTrees",
8
8
  "homepage": "https://github.com/XDTrees/dsh-xdpool#readme",
@@ -68043,50 +68043,86 @@ function apply(ctx, config = {}) {
68043
68043
  // 非空(用户勾选的模型已被移出黑名单),不会重复静音。
68044
68044
  let channelPackModelAdaptersRef;
68045
68045
  const rawAddAccount = pool.addAccount.bind(pool);
68046
- // ── 本地适配(dsh-xdpool):清掉同渠道同昵称的重复账号 ──
68046
+ // ── 本地适配(dsh-xdpool):清掉同一账号的重复条目 ──
68047
68047
  // 需求(2026-10-16):用户在扫码登录页反复点「添加账号」,上游 `addAccount`
68048
- // 是纯追加(无任何去重),于是同一渠道攒出多条同号账号,账号池页一张卡里
68049
- // 显示 3-4 个一模一样的人。判定键取 (provider, nickname):同一渠道里昵称
68050
- // 相同即视为同一个账号(上游所有渠道在凭据落定后都用 `updateAccount` 把
68051
- // nickname 改成真实显示名,所以昵称才是稳定身份,id 每次都是新随机值)。
68048
+ // 是纯追加(无任何去重),于是同一账号攒出多条,账号池页一张卡里显示
68049
+ // 3-4 个一模一样的人。
68052
68050
  //
68053
- // ⚠️ 保留哪一条不能只看 createdAt newest。反复点「添加账号」时,**新加的
68054
- // 那条是登录还没走完的半成品**(expiresAt 缺失),而**旧的那条是完整可用
68055
- // 的旧凭据**(zcode 的凭据甚至不带过期时间)。按 newest 保留会留下半成品、
68056
- // 删掉能用的 —— 这正是本机 zcode 的实际形态(10-16 15:15 的三条无
68057
- // expiresAt,16:15 新加的那条也无 expiresAt,而其它渠道都有)。所以判定
68058
- // 优先级改成「可用性」:
68059
- // ① expiresAt 为空视为长期凭据、可用;有值时必须晚于「现在」才算可用;
68060
- // ② 双方都可用 → 留旧的(先登录的那条,凭据久经使用);
68061
- // 旧的已过期而新的可用 → 换新;其余 → 留旧。
68062
- const channelAccountIsUsable = (account, now) => {
68051
+ // ⚠️ 判定键必须是上游身份(凭据里的 `user_id`),不能是昵称。
68052
+ // 2026-10-23 实测教训:按 (provider, nickname) 分组会把**两位不同的人**
68053
+ // 误判成同一个 —— 本机有两个账号昵称都叫「青楫渡」(uid 29890334 与
68054
+ // 8e606182),结果扫进来的第二个被当成重复条目删掉。
68055
+ // 同一份代码还有第二个更严重的后果:新扫进来的凭据只要遇到一条
68056
+ // 「还没过期」的旧条目就会被删(旧规则「双方都可用 → 留旧的」),
68057
+ // 用户重新扫码后账号池毫无变化,卡片继续显示「登录已被拒绝」。
68058
+ //
68059
+ // 保留哪一条:优先「真实条目」而非登录半成品(半成品的 nickname 还是
68060
+ // 随机 id,因为上游要到 `updateAccount` 才写入真实显示名),再优先
68061
+ // 未过期的,最后取最新创建的那条 —— 用户刚完成的那次登录才是他此刻
68062
+ // 在用的凭据。
68063
+ const channelAccountUsable = (account, now) => {
68063
68064
  const expiresAt = Number(account.expiresAt);
68064
68065
  if (!Number.isFinite(expiresAt) || expiresAt <= 0) return true;
68065
68066
  return expiresAt > now;
68066
68067
  };
68068
+ const channelAccountIsPlaceholder = (account) =>
68069
+ typeof account.nickname !== "string" || account.nickname === "" || account.nickname === account.id;
68070
+ const channelAccountCreatedAt = (account) => {
68071
+ const createdAt = Number(account.createdAt);
68072
+ return Number.isFinite(createdAt) ? createdAt : 0;
68073
+ };
68074
+ /** True when `candidate` should survive and `incumbent` should be dropped. */
68075
+ const channelAccountBeats = (candidate, incumbent, now) => {
68076
+ const candidatePlaceholder = channelAccountIsPlaceholder(candidate);
68077
+ const incumbentPlaceholder = channelAccountIsPlaceholder(incumbent);
68078
+ if (candidatePlaceholder !== incumbentPlaceholder) return incumbentPlaceholder;
68079
+ const candidateUsable = channelAccountUsable(candidate, now);
68080
+ const incumbentUsable = channelAccountUsable(incumbent, now);
68081
+ if (candidateUsable !== incumbentUsable) return candidateUsable;
68082
+ return channelAccountCreatedAt(candidate) > channelAccountCreatedAt(incumbent);
68083
+ };
68084
+ /**
68085
+ * Resolve the upstream identity a row belongs to.
68086
+ *
68087
+ * Returns undefined when the credential cannot be read: an unresolvable row
68088
+ * cannot be proven to be a duplicate of anything, and deleting it would trade
68089
+ * one bug for a worse one.
68090
+ */
68091
+ const channelAccountIdentity = async (account) => {
68092
+ const refName = typeof account.credentialRef === "string" ? account.credentialRef : "";
68093
+ if (refName === "") return void 0;
68094
+ try {
68095
+ const credential = await pool.resolveCredentialByRef(refName);
68096
+ if (credential === void 0 || credential === null || typeof credential !== "object") return void 0;
68097
+ const userId = credential.user_id;
68098
+ return typeof userId === "string" && userId !== "" ? userId : void 0;
68099
+ } catch {
68100
+ return void 0;
68101
+ }
68102
+ };
68067
68103
  const dedupeChannelAccounts = async () => {
68068
68104
  try {
68069
68105
  const all = await pool.listAllAccounts();
68070
68106
  const now = Date.now();
68071
- const groups = new Map();
68107
+ const groups = /* @__PURE__ */ new Map();
68072
68108
  for (const account of all) {
68073
- const nickname = typeof account.nickname === "string" ? account.nickname : "";
68074
- // 昵称为空的条目无从判断是否重复(登录尚未落定的中间态),一律保留。
68075
- if (account.provider === "" || nickname === "") continue;
68076
- const key = `${account.provider}\u0000${nickname}`;
68109
+ const provider = typeof account.provider === "string" ? account.provider : "";
68110
+ if (provider === "") continue;
68111
+ const identity = await channelAccountIdentity(account);
68112
+ // 认不出身份的条目一律保留:宁可留一条重复,也不能删掉别人的账号。
68113
+ if (identity === void 0) continue;
68114
+ const key = `${provider}\u0000${identity}`;
68077
68115
  const kept = groups.get(key);
68078
- if (kept === undefined) {
68116
+ if (kept === void 0) {
68079
68117
  groups.set(key, account);
68080
68118
  continue;
68081
68119
  }
68082
- const keptUsable = channelAccountIsUsable(kept, now);
68083
- const accountUsable = channelAccountIsUsable(account, now);
68084
- const replace = !keptUsable && accountUsable;
68120
+ const replace = channelAccountBeats(account, kept, now);
68085
68121
  const drop = replace ? kept : account;
68086
68122
  if (replace) groups.set(key, account);
68087
68123
  await pool.removeAccount(drop.id);
68088
68124
  ctx.logger.info(
68089
- `[channel-pack] ${drop.provider} 重复账号 ${drop.nickname}(${drop.id}):已清理,保留 ${(replace ? account : kept).id}`
68125
+ `[channel-pack] ${drop.provider} 重复账号 ${drop.nickname ?? drop.id}(${drop.id}):已清理,保留 ${(replace ? account : kept).id}`
68090
68126
  );
68091
68127
  }
68092
68128
  } catch (error) {
@@ -68095,6 +68131,7 @@ function apply(ctx, config = {}) {
68095
68131
  );
68096
68132
  }
68097
68133
  };
68134
+
68098
68135
  // 启动时先扫一遍:用户升级前攒下的重复只能靠这一次清掉。
68099
68136
  void Promise.resolve().then(() => dedupeChannelAccounts());
68100
68137
  // ── 本地适配(dsh-xdpool):启动时补静音「黑名单为空但已有账号」的渠道 ──