@youzan-cloud/zan-saas-cli 0.1.0-beta.4 → 0.1.0-beta.8

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 (66) hide show
  1. package/bin/zan-saas-cli.js +8 -11
  2. package/internal/registry/manifest.json +25 -20
  3. package/npm-shrinkwrap.json +2 -2
  4. package/package.json +1 -1
  5. package/skills/zan-saas/SKILL.md +3 -3
  6. package/skills/zan-saas/references/host-entry.md +8 -0
  7. package/skills/zan-saas-order/SKILL.md +12 -1
  8. package/skills/zan-saas-order/references/branch-counts.md +45 -0
  9. package/skills/zan-saas-order/references/order-get.md +2 -0
  10. package/skills/zan-saas-order/references/order-list.md +2 -0
  11. package/skills/zan-saas-order/references/order-scan.md +2 -0
  12. package/skills/zan-saas-org/SKILL.md +6 -0
  13. package/skills/zan-saas-org/references/unit-list.md +2 -0
  14. package/skills/zan-saas-shared/SKILL.md +20 -5
  15. package/skills/zan-saas-shared/references/authentication.md +9 -4
  16. package/skills/zan-saas-shared/references/gateway-errors.md +29 -0
  17. package/skills/zan-saas-shared/references/shop-context.md +39 -0
  18. package/skills/zan-saas-valuecard/SKILL.md +8 -2
  19. package/skills/zan-saas-valuecard/references/transaction-list.md +25 -0
  20. package/skills/zan-saas-valuecard/references/transaction-scan.md +23 -0
  21. package/src/auth/credential-source.js +26 -0
  22. package/src/auth/credential-store.js +61 -5
  23. package/src/auth/session.js +80 -27
  24. package/src/cli/command-paths.js +3 -2
  25. package/src/cli/context-help.js +3 -1
  26. package/src/cli/program.js +20 -4
  27. package/src/commands/native/access/commands.index.json +1 -1
  28. package/src/commands/native/access/role/list/command.js +4 -32
  29. package/src/commands/native/customer/commands.index.json +1 -1
  30. package/src/commands/native/customer/customer-tag/list/command.js +2 -2
  31. package/src/commands/native/member/commands.index.json +1 -1
  32. package/src/commands/native/order/commands.index.json +3 -3
  33. package/src/commands/native/order/order/get/command.js +14 -9
  34. package/src/commands/native/order/order/get/schema.json +197 -41
  35. package/src/commands/native/order/order/list/command.js +9 -6
  36. package/src/commands/native/order/order/list/schema.json +140 -10
  37. package/src/commands/native/order/order/scan/command.js +8 -5
  38. package/src/commands/native/order/order/scan/scan.js +1 -1
  39. package/src/commands/native/order/order/scan/schema.json +135 -8
  40. package/src/commands/native/order/shared/query.js +57 -9
  41. package/src/commands/native/order/shared/search.js +1 -1
  42. package/src/commands/native/org/commands.index.json +4 -4
  43. package/src/commands/native/valuecard/commands.index.json +20 -2
  44. package/src/commands/native/valuecard/domain.json +1 -1
  45. package/src/commands/native/valuecard/transaction/list/command.js +110 -0
  46. package/src/commands/native/valuecard/transaction/list/projection.js +71 -0
  47. package/src/commands/native/valuecard/transaction/list/schema.json +888 -0
  48. package/src/commands/native/valuecard/transaction/scan/command.js +130 -0
  49. package/src/commands/native/valuecard/transaction/scan/projection.js +58 -0
  50. package/src/commands/native/valuecard/transaction/scan/scan.js +70 -0
  51. package/src/commands/native/valuecard/transaction/scan/schema.json +954 -0
  52. package/src/commands/system/api.js +1 -1
  53. package/src/commands/system/auth.js +13 -2
  54. package/src/commands/system/contracts.js +27 -19
  55. package/src/commands/system/shop-contracts.js +97 -0
  56. package/src/commands/system/shop.js +128 -27
  57. package/src/execution/error-details.js +13 -18
  58. package/src/execution/gateway-guidance.js +43 -0
  59. package/src/execution/shop-query.js +6 -3
  60. package/src/identity/context-store.js +14 -10
  61. package/src/identity/file-store.js +147 -0
  62. package/src/identity/session-store.js +261 -0
  63. package/src/identity/shop-location.js +158 -0
  64. package/src/identity/unit-membership.js +42 -0
  65. package/src/transport/api.js +78 -11
  66. package/src/transport/shop-app.js +8 -1
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ const account = require('../transport/account-api');
4
+ const { oauthConfig } = require('../auth/config');
5
+ const { readCredential } = require('../auth/credential-store');
6
+ const { authSource } = require('../auth/credential-source');
7
+ const { snapshotApiIdentity } = require('../transport/api');
8
+ const { createBoundShopQuery, failure } = require('../execution/shop-query');
9
+ const { verifyMembership } = require('./unit-membership');
10
+ const sessions = require('./session-store');
11
+ const { readSavedContext } = require('./context-store');
12
+ const { createContext } = require('./context');
13
+
14
+ function id(value) {
15
+ if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value) || !Number.isSafeInteger(Number(value))) {
16
+ throw sessions.fail('invalid_target', 'kdtId 必须是正整数店铺 ID。');
17
+ }
18
+ return value;
19
+ }
20
+
21
+ function selection(context, { tenantName = null, unitName = null, role = null } = {}) {
22
+ return {
23
+ tenantRootKdtId: context.hq, tenantName, currentUnitKdtId: context.shopId,
24
+ unitName, position: context.orgType, role
25
+ };
26
+ }
27
+
28
+ async function identify(env) {
29
+ const { environment: pinned, source, credentialRef: ref } = await snapshotApiIdentity(env);
30
+ const user = await account.whoami(pinned);
31
+ const actor = { user, pinned, ref, source };
32
+ assertIdentity(actor, env);
33
+ return actor;
34
+ }
35
+
36
+ function assertIdentity(actor, env) {
37
+ const credential = authSource(env) === 'env' ? null : readCredential(oauthConfig(env).server, env);
38
+ if (actor.ref !== sessions.credentialRef(env, credential)) {
39
+ throw sessions.fail('context_conflict', '登录状态在核实账号期间发生变化,请重新查看当前身份。');
40
+ }
41
+ }
42
+
43
+ function rejectOverride(env, explicitContext) {
44
+ if (env.ZAN_SAAS_CONTEXT || explicitContext !== undefined) {
45
+ throw sessions.fail('context_override_conflict',
46
+ '本次上下文由显式输入覆盖,请使用会话调用环境切店后核对 shop current。');
47
+ }
48
+ }
49
+
50
+ async function rootLocation(kdtId, actor) {
51
+ id(kdtId);
52
+ const page = await account.listShops({ includeKdtIds: [kdtId], pageNum: 1, pageSize: 1 }, actor.pinned);
53
+ const shop = page.items.find(item => item.kdtId === kdtId);
54
+ if (!shop) throw new Error(`当前账号下没有可选店铺 ${kdtId}。执行 zan-saas shop list 查看可选店铺。`);
55
+ if (!shop.orgType) throw new Error(`店铺 ${kdtId} 不是本版支持的总部或单店入口。`);
56
+ const status = await account.staffStatus(kdtId, actor.pinned);
57
+ if (status === account.staffStatuses.disabled) throw new Error(`你在店铺 ${kdtId} 的员工账号已停用,不能选择。`);
58
+ const context = createContext({
59
+ userId: actor.user.userId, hq: kdtId, shopId: kdtId, orgType: shop.orgType
60
+ });
61
+ return { context, selection: selection(context, {
62
+ tenantName: shop.shopName, unitName: shop.shopName, role: shop.shopRole
63
+ }) };
64
+ }
65
+
66
+ async function unitLocation(kdtId, root, actor, command) {
67
+ id(kdtId);
68
+ if (kdtId === root.context.hq) return root;
69
+ if (root.context.orgType !== 'hq') {
70
+ throw sessions.fail('unit_scope_unresolved', '当前店铺没有已核实的连锁总部,不能进入下级组织。');
71
+ }
72
+ const scope = { kdtId };
73
+ const reject = (category, stage, state, facts) => {
74
+ const error = failure(command, category, stage, scope, state, facts);
75
+ error.cliDetails.nextStep = '未改变当前位置。核对原总部及目标,目录未读完不代表目标不属于该总部;不自动重试或初始化分店应用。';
76
+ return error;
77
+ };
78
+ const { read, binding } = await createBoundShopQuery({ kdtId: Number(root.context.hq) },
79
+ command, 'headquarters-binding', { environment: actor.pinned,
80
+ transportOptions: { shopApp: false, identityCheck: false, maxResponseBytes: () => 256 * 1024 } });
81
+ if (binding.data.role !== 1) {
82
+ throw reject('scope_unresolved', 'headquarters-binding', 'sent', binding.facts);
83
+ }
84
+ const data = await verifyMembership(read, kdtId, reject, { notFoundCategory: 'permission' });
85
+ if (![2001, 2002].includes(data.role)) {
86
+ throw sessions.fail('unit_not_supported', '本版仅支持已核实的连锁门店或网店位置。');
87
+ }
88
+ const context = createContext({ ...root.context, shopId: kdtId, orgType: 'shop' });
89
+ return { context, selection: selection(context, {
90
+ tenantName: root.selection.tenantName,
91
+ unitName: typeof data.name === 'string' && data.name.trim() ? data.name : null, role: data.role
92
+ }) };
93
+ }
94
+
95
+ function displayName(location) {
96
+ const { tenantName, unitName, currentUnitKdtId, tenantRootKdtId } = location.selection;
97
+ if (currentUnitKdtId === tenantRootKdtId) return tenantName || unitName;
98
+ return tenantName && unitName ? `${tenantName} / ${unitName}` : unitName || tenantName;
99
+ }
100
+
101
+ function result(action, effect, env) {
102
+ const state = sessions.snapshot(env);
103
+ const location = state.binding?.location;
104
+ return { context: location?.context ?? null, selection: location?.selection ?? null,
105
+ binding: sessions.publicBinding(state), effect: { action, ...effect } };
106
+ }
107
+
108
+ async function initialize(options = {}, env = process.env) {
109
+ if (!sessions.sessionId(env)) throw sessions.fail('session_missing', '会话初始化需要 ZAN_SAAS_SESSION_ID。');
110
+ rejectOverride(env);
111
+ const actor = await identify(env);
112
+ let legacy = null;
113
+ const existing = sessions.snapshot(env, { userId: actor.user.userId, credentialRef: actor.ref });
114
+ if (!options.resume && !existing.binding && !existing.recent && !existing.legacyChecked &&
115
+ !env.ZAN_SAAS_ACCOUNT_API_BASE_URL && !env.ZAN_SAAS_API_BASE_URL) {
116
+ const saved = readSavedContext(env);
117
+ if (saved?.context.userId === actor.user.userId && saved.context.hq === saved.context.shopId) {
118
+ const root = await rootLocation(saved.context.hq, actor);
119
+ if (JSON.stringify(root.context) === JSON.stringify(saved.context)) legacy = root;
120
+ }
121
+ }
122
+ const currentChanged = sessions.initialize({ ...options, legacy,
123
+ verifiedIdentity: { userId: actor.user.userId, credentialRef: actor.ref } }, env);
124
+ return result('init', { currentChanged, recentChanged: false }, env);
125
+ }
126
+
127
+ async function select(kdtId, options = {}, env = process.env, explicitContext) {
128
+ id(kdtId);
129
+ if (!sessions.sessionId(env)) throw sessions.fail('session_missing', '会话切换需要 ZAN_SAAS_SESSION_ID。');
130
+ rejectOverride(env, explicitContext);
131
+ if (env.ZAN_SAAS_KDT_ID && env.ZAN_SAAS_KDT_ID !== kdtId) {
132
+ throw sessions.fail('context_override_conflict', '请求店铺头覆盖与切换目标不同,未改变当前位置。');
133
+ }
134
+ const expected = sessions.snapshot(env);
135
+ if (!expected.binding) throw sessions.fail('session_uninitialized', '请先执行 zan-saas shop session init。');
136
+ const actor = await identify(env);
137
+ if (actor.user.userId !== expected.identity?.userId || actor.ref !== expected.identity?.credentialRef) {
138
+ throw sessions.fail('context_conflict', '登录身份已改变,请重新初始化本会话。');
139
+ }
140
+ const rootId = options.unit ? expected.binding.location?.context.hq : kdtId;
141
+ if (!rootId) throw sessions.fail('unit_scope_unresolved', '请先选择当前连锁。');
142
+ const root = await rootLocation(rootId, actor);
143
+ const location = options.unit ? await unitLocation(kdtId, root, actor, 'shop.unit.use') : root;
144
+ const effect = sessions.commit(location, expected, options, env);
145
+ return result(options.unit ? 'use_unit' : 'use_root', effect, env);
146
+ }
147
+
148
+ async function resolve(kdtId, options = {}, env = process.env) {
149
+ id(kdtId);
150
+ const rootId = options.rootKdtId === undefined ? kdtId : id(options.rootKdtId);
151
+ const actor = await identify(env);
152
+ const root = await rootLocation(rootId, actor);
153
+ const location = await unitLocation(kdtId, root, actor, 'shop.resolve');
154
+ return { ...location, effect: { action: 'resolve', currentChanged: false, recentChanged: false },
155
+ revision: null };
156
+ }
157
+
158
+ module.exports = { selection, identify, assertIdentity, initialize, select, resolve, displayName, rejectOverride };
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+
3
+ const { object } = require('../execution/query-api');
4
+
5
+ const organizationPath = 'youzan.shop.chain.descendent.organization.list/1.0.1';
6
+ const maxOrganizationPages = 20;
7
+ const pageSize = 50;
8
+
9
+ function organizationBody(pageNo) {
10
+ return { page_num: pageNo, page_size: pageSize, with_dept_info: false,
11
+ with_shop_status_and_lifecycle_end_time: false, with_crm_shop: false };
12
+ }
13
+
14
+ async function verifyMembership(read, targetId, reject, { maxPages = maxOrganizationPages,
15
+ notFoundCategory = 'scope_unresolved' } = {}) {
16
+ const seen = new Set();
17
+ let total;
18
+ let lastFacts;
19
+ for (let pageNo = 1; pageNo <= maxPages; pageNo += 1) {
20
+ const { data, facts } = await read(organizationPath, organizationBody(pageNo), 'target-membership');
21
+ lastFacts = facts;
22
+ const invalid = category => reject(category, 'target-membership', 'sent', facts);
23
+ if (!object(data) || !Array.isArray(data.organization_list) ||
24
+ data.page_num !== pageNo || data.page_size !== pageSize ||
25
+ !Number.isSafeInteger(data.total) || data.total < 0 ||
26
+ data.organization_list.length !== Math.min(pageSize, Math.max(0, data.total - seen.size)) ||
27
+ (total !== undefined && total !== data.total)) throw invalid('protocol');
28
+ total = data.total;
29
+ for (const unit of data.organization_list) {
30
+ if (!object(unit) || !Number.isSafeInteger(unit.kdt_id) || unit.kdt_id < 1 ||
31
+ !Number.isSafeInteger(unit.role) || unit.role < 0 || unit.role === 9 ||
32
+ seen.has(unit.kdt_id)) throw invalid('protocol');
33
+ seen.add(unit.kdt_id);
34
+ }
35
+ const member = data.organization_list.find(unit => String(unit.kdt_id) === String(targetId));
36
+ if (member) return member;
37
+ if (seen.size === total) throw invalid(notFoundCategory);
38
+ }
39
+ throw reject('scope_unresolved', 'target-membership', 'sent', lastFacts);
40
+ }
41
+
42
+ module.exports = { organizationPath, organizationBody, maxOrganizationPages, verifyMembership };
@@ -11,6 +11,8 @@ const { runWithRequestState, beginRequest } = require('../execution/request-stat
11
11
 
12
12
  const apiPathPattern = /^\/?([a-z][a-z0-9._-]*)\/([0-9]+\.[0-9]+\.[0-9]+)$/;
13
13
  const apiSessions = new AsyncLocalStorage();
14
+ const boundTargets = new WeakMap();
15
+ const pinnedCredentials = new WeakMap();
14
16
 
15
17
  function runWithApiSession(action, environment) {
16
18
  return runWithRequestState(() =>
@@ -20,10 +22,22 @@ function runWithApiSession(action, environment) {
20
22
  function apiEnvironment() {
21
23
  const session = apiSessions.getStore();
22
24
  if (!session) return Object.freeze({ ...process.env });
23
- session.env ||= Object.freeze({ ...(session.environment || process.env) });
25
+ session.env ||= Object.freeze({
26
+ ...(session.environment || process.env),
27
+ ...(Object.hasOwn(session, 'context') ? { ZAN_SAAS_CONTEXT: session.context } : {})
28
+ });
24
29
  return session.env;
25
30
  }
26
31
 
32
+ function bindApiContext(context, environment = process.env) {
33
+ const session = apiSessions.getStore();
34
+ if (session) {
35
+ session.environment = environment;
36
+ session.context = JSON.stringify(context ?? null);
37
+ session.env = undefined;
38
+ }
39
+ }
40
+
27
41
  // Business APIs select the shop per request; account APIs serve the user and send no shop.
28
42
  const gateways = {
29
43
  business: { key: 'ZAN_SAAS_API_BASE_URL', base: defaultApiBaseUrl, label: 'API base URL', shop: true },
@@ -46,14 +60,15 @@ class ApiResponseLimitError extends Error {
46
60
  }
47
61
  }
48
62
 
49
- async function accessToken(env) {
63
+ async function accessCredential(env) {
50
64
  try {
65
+ if (pinnedCredentials.has(env)) return pinnedCredentials.get(env);
51
66
  const session = apiSessions.getStore();
52
67
  if (session && env === session.env) {
53
68
  session.credential ||= resolveAccessToken(env);
54
- return (await session.credential).token;
69
+ return await session.credential;
55
70
  }
56
- return (await resolveAccessToken(env)).token;
71
+ return await resolveAccessToken(env);
57
72
  } catch (error) {
58
73
  if (error instanceof AuthRequiredError || error instanceof AuthConfigurationError) {
59
74
  throw new ApiConfigurationError(error.message,
@@ -63,6 +78,20 @@ async function accessToken(env) {
63
78
  }
64
79
  }
65
80
 
81
+ function pinEnvironment(env, credential, overrides = {}) {
82
+ const snapshot = Object.freeze({ ...env, ...overrides });
83
+ pinnedCredentials.set(snapshot, credential);
84
+ if (boundTargets.has(env)) boundTargets.set(snapshot, boundTargets.get(env));
85
+ return snapshot;
86
+ }
87
+
88
+ async function snapshotApiIdentity(env = apiEnvironment()) {
89
+ requestAddress('youzan.user.info.get/1.0.0', env, gateways.account);
90
+ const credential = await accessCredential(env);
91
+ return { environment: pinEnvironment(env, credential), source: credential.source,
92
+ credentialRef: credential.credentialRef };
93
+ }
94
+
66
95
  // Personal tokens select the shop per request: ZAN_SAAS_KDT_ID, else the shopId of ZAN_SAAS_CONTEXT,
67
96
  // else the shop saved by `zan-saas shop use`.
68
97
  function shopHeader(env) {
@@ -76,7 +105,7 @@ function shopHeader(env) {
76
105
  return state.status === 'ready' ? state.context.shopId : undefined;
77
106
  }
78
107
 
79
- async function endpoint(path, env, gateway = gateways.business) {
108
+ function requestAddress(path, env, gateway) {
80
109
  const base = env[gateway.key] || gateway.base;
81
110
  const match = apiPathPattern.exec(path);
82
111
  if (!match) throw new ApiConfigurationError('API path must be <api-name>/<version>');
@@ -88,19 +117,47 @@ async function endpoint(path, env, gateway = gateways.business) {
88
117
  url.password || url.search || url.hash || url.origin === 'null') {
89
118
  throw new ApiConfigurationError(`${gateway.label} must be an HTTPS URL without credentials, query or fragment`);
90
119
  }
91
- // Resolve the token last so invalid configuration never triggers a login or refresh.
92
- const token = await accessToken(env);
93
120
  const requestUrl = new URL(
94
121
  `${url.pathname.replace(/\/$/, '')}/${match[1]}/${match[2]}`, url.origin
95
122
  );
123
+ return requestUrl;
124
+ }
125
+
126
+ async function endpoint(path, env, gateway = gateways.business) {
127
+ const requestUrl = requestAddress(path, env, gateway);
128
+ // Resolve the token last so invalid configuration never triggers a login or refresh.
129
+ const credential = await accessCredential(env);
130
+ const { token } = credential;
96
131
  requestUrl.searchParams.set('access_token', token);
97
- return { requestUrl, token };
132
+ return { requestUrl, token, credential };
98
133
  }
99
134
 
100
135
  async function requestApi({ path, body }, env = apiEnvironment(), options = {}) {
101
136
  const target = gateways[options.gateway || 'business'];
102
137
  if (!target) throw new ApiConfigurationError(`Unknown API gateway: ${options.gateway}`);
103
138
  const kdtId = target.shop ? shopHeader(env) : undefined;
139
+ if (target.shop && env.ZAN_SAAS_SESSION_ID !== undefined && options.identityCheck !== false) {
140
+ const state = readContext(contextInput(env));
141
+ if (state.status !== 'ready') {
142
+ throw new ApiConfigurationError('会话没有有效上下文,请先初始化并选择店铺。', 'session_uninitialized');
143
+ }
144
+ if (env.ZAN_SAAS_KDT_ID && env.ZAN_SAAS_KDT_ID !== state.context.shopId &&
145
+ boundTargets.get(env) !== env.ZAN_SAAS_KDT_ID) {
146
+ throw new ApiConfigurationError('请求店铺头与会话位置冲突,业务请求未发送。', 'context_override_conflict');
147
+ }
148
+ const { credential } = await endpoint(path, env, target);
149
+ env = pinEnvironment(env, credential);
150
+ let user;
151
+ try { user = await require('./account-api').whoami(env); }
152
+ catch { throw new ApiConfigurationError('无法核实当前凭证账号,业务请求未发送。', 'context_identity_mismatch'); }
153
+ if (user.userId !== state.context.userId) {
154
+ throw new ApiConfigurationError('当前凭证账号与任务上下文不一致或无法核实,业务请求未发送。', 'context_identity_mismatch');
155
+ }
156
+ }
157
+ if (!pinnedCredentials.has(env)) {
158
+ const { credential } = await endpoint(path, env, target);
159
+ env = pinEnvironment(env, credential);
160
+ }
104
161
  const send = () => sendApi(path, body, env, target, kdtId, options);
105
162
  const response = await send();
106
163
  if (!kdtId || env.ZAN_SAAS_SHOP_APP_INIT === '0' || options.shopApp === false) return response;
@@ -189,11 +246,21 @@ async function readResponseBody(response, maxResponseBytes) {
189
246
  }
190
247
 
191
248
  async function snapshotApiEnvironment(env = apiEnvironment(), kdtId) {
192
- const { token } = await endpoint('youzan.shop.basic.get/3.0.0', env);
193
- return Object.freeze({ ...env, ZAN_SAAS_USER_ACCESS_TOKEN: token, ZAN_SAAS_KDT_ID: String(kdtId) });
249
+ if (!boundTargets.has(env) && env.ZAN_SAAS_SESSION_ID !== undefined && env.ZAN_SAAS_KDT_ID &&
250
+ env.ZAN_SAAS_KDT_ID !== String(kdtId)) {
251
+ throw new ApiConfigurationError('显式请求店铺头与本次命令目标冲突。', 'context_override_conflict');
252
+ }
253
+ const context = contextInput(env);
254
+ const { credential } = await endpoint('youzan.shop.basic.get/3.0.0', env);
255
+ const snapshot = pinEnvironment(env, credential, {
256
+ ZAN_SAAS_KDT_ID: String(kdtId),
257
+ ZAN_SAAS_CONTEXT: typeof context === 'string' ? context : JSON.stringify(context ?? null)
258
+ });
259
+ boundTargets.set(snapshot, String(kdtId));
260
+ return snapshot;
194
261
  }
195
262
 
196
263
  module.exports = {
197
264
  requestApi, snapshotApiEnvironment, runWithApiSession, ApiConfigurationError,
198
- ApiResponseLimitError, readResponseBody
265
+ ApiResponseLimitError, readResponseBody, bindApiContext, snapshotApiIdentity
199
266
  };
@@ -5,6 +5,10 @@ const { callAccountApi, paths } = require('./account-api');
5
5
  // The business gateway answers 1106 when the selected shop has no usable app. The request never
6
6
  // reached the API, so it is safe to send again once the app is ready.
7
7
  const noAppCode = '1106';
8
+ // bifrost-gateway maps the oauth 1106 to its own 4204 and keeps only the message. 4204 also covers
9
+ // other permission failures, so it counts as "no app" only together with this message.
10
+ const noAppGatewayCode = '4204';
11
+ const noAppMessage = '该店铺下没有可用的应用';
8
12
  // CloudAppStateEnums: only 2 is ready; 4, 5 and 6 never become ready without a person stepping in.
9
13
  const appStates = Object.freeze({ 1: '审核中', 2: '已创建', 3: '初始化中', 4: '初始化失败', 5: '审核驳回', 6: '已删除' });
10
14
  const readyState = 2;
@@ -16,7 +20,10 @@ function isNoAppResponse(response) {
16
20
  try { json = JSON.parse(response.body); } catch { return false; }
17
21
  if (!json || typeof json !== 'object') return false;
18
22
  const source = json.gw_err_resp && typeof json.gw_err_resp === 'object' ? json.gw_err_resp : json;
19
- return String(source.code ?? source.err_code) === noAppCode;
23
+ const code = String(source.code ?? source.err_code);
24
+ if (code === noAppCode) return true;
25
+ const message = source.err_msg ?? source.message ?? source.msg;
26
+ return code === noAppGatewayCode && typeof message === 'string' && message.includes(noAppMessage);
20
27
  }
21
28
 
22
29
  const stateText = app => (app ? `应用 ${app.appId} ${appStates[app.appState] || `状态 ${app.appState}`}` : '应用尚未生成');