@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.
- package/bin/zan-saas-cli.js +8 -11
- package/internal/registry/manifest.json +25 -20
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/zan-saas/SKILL.md +3 -3
- package/skills/zan-saas/references/host-entry.md +8 -0
- package/skills/zan-saas-order/SKILL.md +12 -1
- package/skills/zan-saas-order/references/branch-counts.md +45 -0
- package/skills/zan-saas-order/references/order-get.md +2 -0
- package/skills/zan-saas-order/references/order-list.md +2 -0
- package/skills/zan-saas-order/references/order-scan.md +2 -0
- package/skills/zan-saas-org/SKILL.md +6 -0
- package/skills/zan-saas-org/references/unit-list.md +2 -0
- package/skills/zan-saas-shared/SKILL.md +20 -5
- package/skills/zan-saas-shared/references/authentication.md +9 -4
- package/skills/zan-saas-shared/references/gateway-errors.md +29 -0
- package/skills/zan-saas-shared/references/shop-context.md +39 -0
- package/skills/zan-saas-valuecard/SKILL.md +8 -2
- package/skills/zan-saas-valuecard/references/transaction-list.md +25 -0
- package/skills/zan-saas-valuecard/references/transaction-scan.md +23 -0
- package/src/auth/credential-source.js +26 -0
- package/src/auth/credential-store.js +61 -5
- package/src/auth/session.js +80 -27
- package/src/cli/command-paths.js +3 -2
- package/src/cli/context-help.js +3 -1
- package/src/cli/program.js +20 -4
- package/src/commands/native/access/commands.index.json +1 -1
- package/src/commands/native/access/role/list/command.js +4 -32
- package/src/commands/native/customer/commands.index.json +1 -1
- package/src/commands/native/customer/customer-tag/list/command.js +2 -2
- package/src/commands/native/member/commands.index.json +1 -1
- package/src/commands/native/order/commands.index.json +3 -3
- package/src/commands/native/order/order/get/command.js +14 -9
- package/src/commands/native/order/order/get/schema.json +197 -41
- package/src/commands/native/order/order/list/command.js +9 -6
- package/src/commands/native/order/order/list/schema.json +140 -10
- package/src/commands/native/order/order/scan/command.js +8 -5
- package/src/commands/native/order/order/scan/scan.js +1 -1
- package/src/commands/native/order/order/scan/schema.json +135 -8
- package/src/commands/native/order/shared/query.js +57 -9
- package/src/commands/native/order/shared/search.js +1 -1
- package/src/commands/native/org/commands.index.json +4 -4
- package/src/commands/native/valuecard/commands.index.json +20 -2
- package/src/commands/native/valuecard/domain.json +1 -1
- package/src/commands/native/valuecard/transaction/list/command.js +110 -0
- package/src/commands/native/valuecard/transaction/list/projection.js +71 -0
- package/src/commands/native/valuecard/transaction/list/schema.json +888 -0
- package/src/commands/native/valuecard/transaction/scan/command.js +130 -0
- package/src/commands/native/valuecard/transaction/scan/projection.js +58 -0
- package/src/commands/native/valuecard/transaction/scan/scan.js +70 -0
- package/src/commands/native/valuecard/transaction/scan/schema.json +954 -0
- package/src/commands/system/api.js +1 -1
- package/src/commands/system/auth.js +13 -2
- package/src/commands/system/contracts.js +27 -19
- package/src/commands/system/shop-contracts.js +97 -0
- package/src/commands/system/shop.js +128 -27
- package/src/execution/error-details.js +13 -18
- package/src/execution/gateway-guidance.js +43 -0
- package/src/execution/shop-query.js +6 -3
- package/src/identity/context-store.js +14 -10
- package/src/identity/file-store.js +147 -0
- package/src/identity/session-store.js +261 -0
- package/src/identity/shop-location.js +158 -0
- package/src/identity/unit-membership.js +42 -0
- package/src/transport/api.js +78 -11
- 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 };
|
package/src/transport/api.js
CHANGED
|
@@ -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({
|
|
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
|
|
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
|
|
69
|
+
return await session.credential;
|
|
55
70
|
}
|
|
56
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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}`}` : '应用尚未生成');
|