@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,25 @@
|
|
|
1
|
+
# 储值流水查询边界
|
|
2
|
+
|
|
3
|
+
需要解释记录金额、关联单号或是否还能继续读取时,先读 `zan-saas schema valuecard.transaction.list`,再按本页判断。
|
|
4
|
+
|
|
5
|
+
## 选对对象
|
|
6
|
+
|
|
7
|
+
`zan-saas valuecard transaction list` 只读取目标店铺、一个业务类型、明确业务发生时间范围内的一页记录。它不按客户、卡号或订单筛选,不提供完整客户历史或自动余额对账。
|
|
8
|
+
|
|
9
|
+
例如,假设用户问“这张卡现在还有多少本金”,应选择 `valuecard card get` 并提供完整系统卡号。若问“本店这段时间的充值流水里本金和赠送金各是多少”,选择流水入口,但一页数据不能解释为该时间内的完整合计。
|
|
10
|
+
|
|
11
|
+
`billCardNo` 仅是流水记载的卡号,不保证与当前卡资产 `cardNo`、卡面号或汇总号相同。储值流水号、交易单号和外部支付请求号各自表示不同关联,不互相替代。手机号和昵称不作为稳定客户身份。
|
|
12
|
+
|
|
13
|
+
## 解释金额和记录
|
|
14
|
+
|
|
15
|
+
本金、赠送金及记录余额默认返回,具体字段与单位读同版 schema。`amount` 保留记录原始符号,不猜统一增减方向。`reportedBalance` 的时点未明确,不据此重建交易前后余额。
|
|
16
|
+
|
|
17
|
+
充值与充值赠送、退款与充值退款分别表达。有效性不是支付或退款处理状态,无效记录仍保留。未知类型或有效性文本只表示服务端返回了该值,不能补造业务含义。
|
|
18
|
+
|
|
19
|
+
## 范围与继续读取
|
|
20
|
+
|
|
21
|
+
按同版 schema 的分页字段判断是否有后页,保持店铺、业务类型、原时间范围和容量。短页或空页不单独证明耗尽;总数未知时也不能把当前页当作完整历史。达到页窗口后缩小时间范围,不自动分片或跨页去重,不承诺稳定排序或快照。
|
|
22
|
+
|
|
23
|
+
支持的产品范围由服务端决定。微商城单店、零售单店、连锁 D 总部及连锁 L 总部高级版在接口范围内;新储值商家可查询全部卡交易,老储值商家仅支持礼品卡交易。位置适用不授予跨店或员工权限,也不证明历史留存完整。
|
|
24
|
+
|
|
25
|
+
请求失败或超限时本次不输出部分流水。核对原店铺、时间、分页和权限后重新构造调用,不切换身份或重复发送未知结果的请求。查询成功不代表关联业务已完成。
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# 有界流水查找
|
|
2
|
+
|
|
3
|
+
用于按一种业务类型、明确的北京时间范围,寻找带原始手机号、流水卡号、流水号或有赞交易单号的储值流水。先读 `zan-saas schema valuecard.transaction.scan`。四种条件至少一个,多条件为 AND,区分大小写,保留前导零与国家码。条件只在本地精确匹配,不是服务端客户筛选。
|
|
4
|
+
|
|
5
|
+
例如,商家要找“某流水卡号在指定时间的消费”,使用 `billCardNo`,不能把当前 `card.cardNo` 或客户 `yzOpenId` 换进去。只知道系统卡号时先说明缺少原始流水标识,不能从客户当前持卡推断其历史流水归属。
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
zan-saas valuecard transaction scan --biz-type pay \
|
|
9
|
+
--occurred-at "2026-11-01 00:00:00..2026-11-11 12:00:00" \
|
|
10
|
+
--bill-card-no bill-card-demo --kdt-id 63077 --dry-run
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
实际调用需有效上下文与同店凭证。预览不读取凭证或联网,全部条件值遮盖。请求按页串行,固定最多 200 条匹配记录、21 次业务请求,包含一次店铺绑定。为完整页预留容量,可能不足 200 条即停止。不提供 `--max-items`、`--max-requests` 或其它调参入口。
|
|
14
|
+
|
|
15
|
+
新储值商家可查全部卡交易,老储值商家仅支持礼品卡流水,不能据此宣称覆盖所有储值资产。
|
|
16
|
+
|
|
17
|
+
读取 `data.transactions` 和 `data.pagination`,不把原始接口总数当成匹配总数。`scannedRecords` 包含未匹配记录。只有 `stopReason=pages_exhausted` 才表示从本次指定起始页之后走到可观察结束,不表示全历史。`max_items`、`max_requests`、`window_limit`、`total_unknown`、`inconsistent_page` 都表示未完成,匹配数组为空不能解释为没有历史。
|
|
18
|
+
|
|
19
|
+
未完成且 `nextPageNo` 非空时,保持同店、业务类型、时间、全部条件和 `pageSize`,用 `--page-no` 继续。它不是稳定游标,数据变化可能重复或遗漏;`nextPageNo=null` 不能单独证明耗尽。窗口满时缩小时间范围重查,总数漂移时建议缩小范围从第一页重查。不自动拆分时间,不存在 dump/resume 或快照保证。
|
|
20
|
+
|
|
21
|
+
结果保留无效记录、重复项及未知非空字典值。六个金额为 CNY 元两位字符串,保留源符号。`amount` 不是统一增减方向,`reportedBalance` 的时点未明确,不能用来推断交易前后余额、可支付余额或自动对账。时间为北京时间固定毫秒文本。`tradeNo` 是有赞交易单号,`externalRequestNo` 是外部支付请求号,不能互换。
|
|
22
|
+
|
|
23
|
+
单响应最多 10 MiB、累计业务响应最多 20 MiB、最终输出含换行最多 5 MiB。后页业务、网络、模型或限额错误会整体失败,不返回前页匹配记录。失败先核查原店铺、条件与授权,不能自动重试、改店、改身份或把失败当成无记录。当前命令不承诺稳定排序;即使本次观察到时间最晚记录,也不能称客户全历史上次充值。
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createHash } = require('node:crypto');
|
|
4
|
+
const { AuthConfigurationError } = require('./config');
|
|
5
|
+
|
|
6
|
+
function authSource(env) {
|
|
7
|
+
const source = env.ZAN_SAAS_AUTH_SOURCE ?? 'auto';
|
|
8
|
+
if (!['auto', 'personal', 'env'].includes(source)) {
|
|
9
|
+
throw new AuthConfigurationError('ZAN_SAAS_AUTH_SOURCE 只接受 auto、personal 或 env。');
|
|
10
|
+
}
|
|
11
|
+
return source;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function credentialChoice(env, credential) {
|
|
15
|
+
const mode = authSource(env);
|
|
16
|
+
if (mode !== 'env' && credential) {
|
|
17
|
+
return { source: 'login', credentialRef: `login:${credential.generation || credential.loginAt || 'legacy'}` };
|
|
18
|
+
}
|
|
19
|
+
if (mode !== 'personal' && env.ZAN_SAAS_USER_ACCESS_TOKEN) {
|
|
20
|
+
const hash = createHash('sha256').update(env.ZAN_SAAS_USER_ACCESS_TOKEN).digest('hex');
|
|
21
|
+
return { source: 'env', credentialRef: `env:${hash}` };
|
|
22
|
+
}
|
|
23
|
+
return { source: 'missing', credentialRef: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { authSource, credentialChoice };
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const crypto = require('node:crypto');
|
|
6
|
-
const { configDir } = require('./config');
|
|
6
|
+
const { configDir, AuthConfigurationError } = require('./config');
|
|
7
|
+
const { credentialChoice } = require('./credential-source');
|
|
7
8
|
|
|
8
9
|
const fileName = 'credentials.json';
|
|
9
10
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -53,16 +54,70 @@ const readAll = env => readStore(fileName, env);
|
|
|
53
54
|
const writeAll = (data, env) => writeStore(fileName, data, env);
|
|
54
55
|
|
|
55
56
|
// Credentials are keyed by oauth origin so logins to different environments do not overwrite each other.
|
|
57
|
+
function credentialDocument(env) {
|
|
58
|
+
let data;
|
|
59
|
+
let descriptor;
|
|
60
|
+
try {
|
|
61
|
+
descriptor = fs.openSync(credentialPath(env), 'r');
|
|
62
|
+
const stat = fs.fstatSync(descriptor);
|
|
63
|
+
data = JSON.parse(fs.readFileSync(descriptor, 'utf8'));
|
|
64
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error();
|
|
65
|
+
return { data, metadata: { mtimeMs: stat.mtimeMs, size: stat.size, ino: stat.ino, dev: stat.dev } };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.code === 'ENOENT') return { data: {}, metadata: null };
|
|
68
|
+
throw new AuthConfigurationError('个人凭证文件损坏或不可读,请重新登录;未改用环境身份。');
|
|
69
|
+
} finally {
|
|
70
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
function readCredential(server, env = process.env) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
const { data } = credentialDocument(env);
|
|
76
|
+
if (!Object.hasOwn(data, server)) return null;
|
|
77
|
+
const entry = data[server];
|
|
78
|
+
if (!entry || typeof entry.accessToken !== 'string' || !entry.accessToken ||
|
|
79
|
+
typeof entry.refreshToken !== 'string' || !entry.refreshToken) {
|
|
80
|
+
throw new AuthConfigurationError('个人凭证记录不完整,请重新登录;未改用环境身份。');
|
|
81
|
+
}
|
|
82
|
+
return entry;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function syncCredentialPresence(env = process.env) {
|
|
86
|
+
const filename = 'credential-state.json';
|
|
87
|
+
const { data, metadata } = credentialDocument(env);
|
|
88
|
+
const records = Object.fromEntries(Object.entries(data).map(([server, credential]) => [server, {
|
|
89
|
+
present: true,
|
|
90
|
+
credentialRef: credential && typeof credential.accessToken === 'string' &&
|
|
91
|
+
typeof credential.refreshToken === 'string'
|
|
92
|
+
? credentialChoice({ ZAN_SAAS_AUTH_SOURCE: 'personal' }, credential).credentialRef : 'login:pending'
|
|
93
|
+
}]));
|
|
94
|
+
const next = { schemaVersion: 1, metadata, records };
|
|
95
|
+
if (JSON.stringify(readStore(filename, env)) === JSON.stringify(next)) return;
|
|
96
|
+
writeStore(filename, next, env);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Offline context selection reads only this non-secret index and file metadata.
|
|
100
|
+
function credentialPresence(server, env = process.env) {
|
|
101
|
+
const stat = fs.existsSync(credentialPath(env)) ? fs.statSync(credentialPath(env)) : null;
|
|
102
|
+
if (!stat) return { present: false, credentialRef: null };
|
|
103
|
+
const index = readStore('credential-state.json', env);
|
|
104
|
+
const metadata = index.metadata;
|
|
105
|
+
if (index.schemaVersion === 1 && metadata && metadata.mtimeMs === stat.mtimeMs &&
|
|
106
|
+
metadata.size === stat.size && metadata.ino === stat.ino && metadata.dev === stat.dev &&
|
|
107
|
+
index.records && typeof index.records === 'object' && !Array.isArray(index.records)) {
|
|
108
|
+
if (!Object.hasOwn(index.records, server)) return { present: false, credentialRef: null };
|
|
109
|
+
const entry = index.records[server];
|
|
110
|
+
if (entry?.present === true && typeof entry.credentialRef === 'string' &&
|
|
111
|
+
entry.credentialRef.startsWith('login:')) return entry;
|
|
112
|
+
}
|
|
113
|
+
return { present: true, credentialRef: 'login:pending' };
|
|
60
114
|
}
|
|
61
115
|
|
|
62
116
|
function saveCredential(server, credential, env = process.env) {
|
|
63
117
|
const data = readAll(env);
|
|
64
118
|
data[server] = credential;
|
|
65
119
|
writeAll(data, env);
|
|
120
|
+
syncCredentialPresence(env);
|
|
66
121
|
}
|
|
67
122
|
|
|
68
123
|
function deleteCredential(server, env = process.env) {
|
|
@@ -70,6 +125,7 @@ function deleteCredential(server, env = process.env) {
|
|
|
70
125
|
if (!Object.hasOwn(data, server)) return false;
|
|
71
126
|
delete data[server];
|
|
72
127
|
writeAll(data, env);
|
|
128
|
+
syncCredentialPresence(env);
|
|
73
129
|
return true;
|
|
74
130
|
}
|
|
75
131
|
|
|
@@ -105,5 +161,5 @@ async function withFileLock(env, task, { staleMs = 30000, waitMs = 15000 } = {})
|
|
|
105
161
|
|
|
106
162
|
module.exports = {
|
|
107
163
|
credentialPath, deleteCredential, readCredential, readStore, saveCredential, storePath, withFileLock,
|
|
108
|
-
writeStore
|
|
164
|
+
writeStore, credentialPresence, syncCredentialPresence
|
|
109
165
|
};
|
package/src/auth/session.js
CHANGED
|
@@ -8,6 +8,8 @@ const { openBrowser } = require('./browser');
|
|
|
8
8
|
const { OAuthError, exchangeCode, refreshTokens, revokeToken } = require('./oauth-client');
|
|
9
9
|
const store = require('./credential-store');
|
|
10
10
|
const { deleteContext } = require('../identity/context-store');
|
|
11
|
+
const { randomUUID } = require('node:crypto');
|
|
12
|
+
const { authSource, credentialChoice } = require('./credential-source');
|
|
11
13
|
|
|
12
14
|
const dayMs = 24 * 60 * 60 * 1000;
|
|
13
15
|
|
|
@@ -29,7 +31,8 @@ function summary(server, credential, now = Date.now()) {
|
|
|
29
31
|
if (!credential) return { loggedIn: false, server };
|
|
30
32
|
const iso = value => (Number.isFinite(value) ? new Date(value).toISOString() : null);
|
|
31
33
|
return {
|
|
32
|
-
loggedIn:
|
|
34
|
+
loggedIn: !credential.reauthRequired &&
|
|
35
|
+
(Number.isFinite(credential.refreshExpiresAt) ? credential.refreshExpiresAt > now : true),
|
|
33
36
|
server,
|
|
34
37
|
clientId: credential.clientId,
|
|
35
38
|
tool: credential.tool,
|
|
@@ -43,6 +46,7 @@ async function login({
|
|
|
43
46
|
env = process.env, browser = true, port = defaultPort, log = message => process.stderr.write(`${message}\n`),
|
|
44
47
|
open = openBrowser, timeoutMs
|
|
45
48
|
} = {}) {
|
|
49
|
+
authSource(env);
|
|
46
50
|
const config = oauthConfig(env);
|
|
47
51
|
const pkce = createPkce();
|
|
48
52
|
const tool = detectTool(env);
|
|
@@ -72,38 +76,66 @@ async function login({
|
|
|
72
76
|
throw error;
|
|
73
77
|
}
|
|
74
78
|
const credential = {
|
|
75
|
-
...tokens, clientId: config.clientId, oauthBaseUrl: config.oauthBaseUrl, tool, loginAt: Date.now()
|
|
79
|
+
...tokens, clientId: config.clientId, oauthBaseUrl: config.oauthBaseUrl, tool, loginAt: Date.now(),
|
|
80
|
+
generation: randomUUID()
|
|
76
81
|
};
|
|
77
|
-
store.saveCredential(config.server, credential, env);
|
|
78
|
-
|
|
82
|
+
await store.withFileLock(env, async () => store.saveCredential(config.server, credential, env));
|
|
83
|
+
require('../identity/session-store').resetIdentity(env, {
|
|
84
|
+
pendingCredentialRef: credentialChoice({ ...env, ZAN_SAAS_AUTH_SOURCE: 'personal' }, credential).credentialRef
|
|
85
|
+
});
|
|
86
|
+
return status({ env });
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
async function logout({ env = process.env } = {}) {
|
|
82
90
|
const config = oauthConfig(env);
|
|
83
91
|
const credential = store.readCredential(config.server, env);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
let localContext = 'deleted';
|
|
93
|
+
for (const clear of [
|
|
94
|
+
() => deleteContext(env),
|
|
95
|
+
() => require('../identity/session-store').resetIdentity(env, { logout: true })
|
|
96
|
+
]) {
|
|
97
|
+
try { clear(); } catch { localContext = 'delete_failed'; }
|
|
98
|
+
}
|
|
99
|
+
if (!credential && localContext === 'deleted') return { loggedOut: true, revoked: false, server: config.server };
|
|
100
|
+
let remoteRevocation = credential ? 'revoked' : 'not_attempted';
|
|
88
101
|
let httpStatus;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
if (credential) {
|
|
103
|
+
try {
|
|
104
|
+
await revokeToken(config, credential.refreshToken);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
httpStatus = error instanceof OAuthError ? error.httpStatus : undefined;
|
|
107
|
+
remoteRevocation = httpStatus === undefined ? 'unknown' : 'failed';
|
|
108
|
+
}
|
|
94
109
|
}
|
|
95
110
|
let localCredential = 'deleted';
|
|
111
|
+
let loginChanged = false;
|
|
96
112
|
try {
|
|
97
|
-
store.
|
|
113
|
+
await store.withFileLock(env, async () => {
|
|
114
|
+
const current = store.readCredential(config.server, env);
|
|
115
|
+
const sameLogin = !current || (credential?.generation
|
|
116
|
+
? current.generation === credential.generation
|
|
117
|
+
: current.loginAt === credential?.loginAt && current.refreshToken === credential?.refreshToken);
|
|
118
|
+
if (!sameLogin) {
|
|
119
|
+
loginChanged = true;
|
|
120
|
+
throw new Error('The current login changed during logout');
|
|
121
|
+
}
|
|
122
|
+
store.deleteCredential(config.server, env);
|
|
123
|
+
});
|
|
98
124
|
} catch {
|
|
99
125
|
localCredential = 'delete_failed';
|
|
100
126
|
}
|
|
101
|
-
if (
|
|
102
|
-
|
|
127
|
+
if (!['revoked', 'not_attempted'].includes(remoteRevocation) ||
|
|
128
|
+
localCredential !== 'deleted' || localContext !== 'deleted') {
|
|
129
|
+
const error = new Error(loginChanged
|
|
130
|
+
? '退出期间已产生新登录,已保留新凭证;旧凭证的服务端撤销结果请按退出信息核对。'
|
|
131
|
+
: localContext !== 'deleted'
|
|
132
|
+
? '选店记录清理未完成;本机凭证清理与服务端撤销已分别尝试,请按退出结果核对。'
|
|
133
|
+
: localCredential === 'deleted'
|
|
103
134
|
? '本机个人凭证已清除,服务端会话撤销未确认;请核对授权服务状态。'
|
|
104
135
|
: '本机个人凭证清理失败;请按退出结果核对本地凭证与服务端会话。');
|
|
105
136
|
error.cliDetails = {
|
|
106
137
|
command: 'auth.logout', server: config.server, localCredential, remoteRevocation,
|
|
138
|
+
...(localContext === 'deleted' ? {} : { localContext }),
|
|
107
139
|
...(httpStatus === undefined ? {} : { httpStatus })
|
|
108
140
|
};
|
|
109
141
|
throw error;
|
|
@@ -113,7 +145,10 @@ async function logout({ env = process.env } = {}) {
|
|
|
113
145
|
|
|
114
146
|
function status({ env = process.env } = {}) {
|
|
115
147
|
const { server } = oauthConfig(env);
|
|
116
|
-
|
|
148
|
+
const credential = store.readCredential(server, env);
|
|
149
|
+
const choice = credentialChoice(env, credential);
|
|
150
|
+
return { ...summary(server, credential), credentialSource: choice.source,
|
|
151
|
+
environmentTokenIgnored: choice.source === 'login' && Boolean(env.ZAN_SAAS_USER_ACCESS_TOKEN) };
|
|
117
152
|
}
|
|
118
153
|
|
|
119
154
|
function interactive(env) {
|
|
@@ -127,17 +162,24 @@ async function refreshIfNeeded(config, env, now) {
|
|
|
127
162
|
// Another process may have refreshed while this one waited for the lock.
|
|
128
163
|
const credential = store.readCredential(key, env);
|
|
129
164
|
if (!credential) return null;
|
|
130
|
-
if (
|
|
131
|
-
if (credential.refreshExpiresAt <= now) {
|
|
132
|
-
store.deleteCredential(key, env);
|
|
165
|
+
if (credential.reauthRequired || credential.refreshExpiresAt <= now) {
|
|
133
166
|
return null;
|
|
134
167
|
}
|
|
168
|
+
if (!(credential.accessExpiresAt - now < dayMs)) return credential;
|
|
169
|
+
const assertUnchanged = () => {
|
|
170
|
+
const current = store.readCredential(key, env);
|
|
171
|
+
if (!current || current.generation !== credential.generation ||
|
|
172
|
+
current.refreshToken !== credential.refreshToken) {
|
|
173
|
+
throw new AuthRequiredError('个人登录在续期期间改变,请核对当前身份后重试;未改用环境身份。');
|
|
174
|
+
}
|
|
175
|
+
};
|
|
135
176
|
let tokens;
|
|
136
177
|
try {
|
|
137
178
|
tokens = await refreshTokens(config, credential.refreshToken);
|
|
138
179
|
} catch (error) {
|
|
139
180
|
if (error instanceof OAuthError && error.oauthError === 'invalid_grant') {
|
|
140
|
-
|
|
181
|
+
assertUnchanged();
|
|
182
|
+
store.saveCredential(key, { ...credential, reauthRequired: true }, env);
|
|
141
183
|
return null;
|
|
142
184
|
}
|
|
143
185
|
// Keep using an access token that has not expired yet when refresh is unreachable.
|
|
@@ -145,20 +187,29 @@ async function refreshIfNeeded(config, env, now) {
|
|
|
145
187
|
throw error;
|
|
146
188
|
}
|
|
147
189
|
const next = { ...credential, ...tokens };
|
|
190
|
+
assertUnchanged();
|
|
148
191
|
store.saveCredential(key, next, env);
|
|
149
192
|
return next;
|
|
150
193
|
});
|
|
151
194
|
}
|
|
152
195
|
|
|
153
|
-
//
|
|
154
|
-
// existing controlled environments behave as before.
|
|
196
|
+
// Select once before a request; a failed personal login never changes to another identity.
|
|
155
197
|
async function resolveAccessToken(env = process.env, { now = Date.now(), loginImpl = login } = {}) {
|
|
156
|
-
|
|
157
|
-
|
|
198
|
+
const mode = authSource(env);
|
|
199
|
+
if (mode === 'env') {
|
|
200
|
+
if (!env.ZAN_SAAS_USER_ACCESS_TOKEN) throw new AuthRequiredError('环境凭证模式缺少 ZAN_SAAS_USER_ACCESS_TOKEN。');
|
|
201
|
+
return Object.freeze({ token: env.ZAN_SAAS_USER_ACCESS_TOKEN, ...credentialChoice(env) });
|
|
158
202
|
}
|
|
159
203
|
const config = oauthConfig(env);
|
|
160
204
|
let credential = store.readCredential(config.server, env);
|
|
161
|
-
|
|
205
|
+
const choice = credentialChoice(env, credential);
|
|
206
|
+
if (choice.source === 'env') {
|
|
207
|
+
store.syncCredentialPresence(env);
|
|
208
|
+
return Object.freeze({ token: env.ZAN_SAAS_USER_ACCESS_TOKEN, ...choice });
|
|
209
|
+
}
|
|
210
|
+
if (credential?.reauthRequired || credential?.refreshExpiresAt <= now) {
|
|
211
|
+
credential = null;
|
|
212
|
+
} else if (credential && Number.isFinite(credential.accessExpiresAt) &&
|
|
162
213
|
credential.accessExpiresAt - now < dayMs) {
|
|
163
214
|
credential = await refreshIfNeeded(config, env, now);
|
|
164
215
|
}
|
|
@@ -170,7 +221,9 @@ async function resolveAccessToken(env = process.env, { now = Date.now(), loginIm
|
|
|
170
221
|
credential = store.readCredential(config.server, env);
|
|
171
222
|
if (!credential) throw new AuthRequiredError('Login did not produce a credential');
|
|
172
223
|
}
|
|
173
|
-
|
|
224
|
+
store.syncCredentialPresence(env);
|
|
225
|
+
return Object.freeze({ token: credential.accessToken, ...credentialChoice(
|
|
226
|
+
{ ...env, ZAN_SAAS_AUTH_SOURCE: 'personal' }, credential) });
|
|
174
227
|
}
|
|
175
228
|
|
|
176
229
|
module.exports = {
|
package/src/cli/command-paths.js
CHANGED
|
@@ -101,12 +101,13 @@ function commandContract(path, command) {
|
|
|
101
101
|
if (['schema', 'doctor', 'skills.list', 'skills.read'].includes(path)) {
|
|
102
102
|
return { kind: 'system', risk: 'read', availability: 'available' };
|
|
103
103
|
}
|
|
104
|
-
if (['auth.status', 'auth.whoami', 'shop.list', 'shop.current'].includes(path)) {
|
|
104
|
+
if (['auth.status', 'auth.whoami', 'shop.list', 'shop.current', 'shop.resolve'].includes(path)) {
|
|
105
105
|
return { kind: 'system', risk: 'read', availability: 'available' };
|
|
106
106
|
}
|
|
107
107
|
// Login and logout change the local credential and the server-side session; shop use and clear
|
|
108
108
|
// change the saved context.
|
|
109
|
-
if (['install', 'auth.login', 'auth.logout', 'shop.use', 'shop.clear'
|
|
109
|
+
if (['install', 'auth.login', 'auth.logout', 'shop.use', 'shop.clear',
|
|
110
|
+
'shop.session.init', 'shop.unit.use'].includes(path)) {
|
|
110
111
|
return { kind: 'system', risk: 'low', availability: 'available' };
|
|
111
112
|
}
|
|
112
113
|
if (path === 'api') return { kind: 'raw-api', risk: 'unknown', availability: 'available' };
|
package/src/cli/context-help.js
CHANGED
|
@@ -60,7 +60,9 @@ function configureContextHelp(program, state, loadDomain) {
|
|
|
60
60
|
}
|
|
61
61
|
const status = `当前位置:${positionLabels[state.context.orgType]};实际权限由服务端校验。`;
|
|
62
62
|
if (contract) {
|
|
63
|
-
|
|
63
|
+
const scopeHint = state.context.orgType === 'hq' && command.options.some(option => option.long === '--kdt-id')
|
|
64
|
+
? '\n省略 --kdt-id 只查询当前总部本身,不包含分店。显式分店目标是否支持及核验前置,以本命令 help/schema 为准。' : '';
|
|
65
|
+
return `\n${declared}${status}${scopeHint}\n${isAvailable(contract.at, state.context)
|
|
64
66
|
? '此命令适用于当前位置。'
|
|
65
67
|
: `当前组织不可使用此命令,需要${positionLabels[contract.at]}上下文。`}`;
|
|
66
68
|
}
|
package/src/cli/program.js
CHANGED
|
@@ -22,9 +22,16 @@ const { commandSchema } = require('../execution/command-schema');
|
|
|
22
22
|
const { commandContract, commandPath } = require('./command-paths');
|
|
23
23
|
const { configureContextHelp, isContextHelp } = require('./context-help');
|
|
24
24
|
const { configureParserPolicy } = require('./parser-policy');
|
|
25
|
+
const { bindApiContext, runWithApiSession } = require('../transport/api');
|
|
25
26
|
|
|
26
|
-
function createProgram(
|
|
27
|
-
|
|
27
|
+
function createProgram(settings = {}) {
|
|
28
|
+
const { catalog = openRegistry(), root = nativeRoot, rootSkills = skillsRoot,
|
|
29
|
+
eagerDomains = false } = settings;
|
|
30
|
+
const injected = settings.context !== undefined;
|
|
31
|
+
let context;
|
|
32
|
+
let contextError;
|
|
33
|
+
try { context = injected ? settings.context : contextInput(process.env); }
|
|
34
|
+
catch (error) { contextError = error; context = {}; }
|
|
28
35
|
const { domains, skills, businessCommandCount } = catalog.manifest;
|
|
29
36
|
const domainNames = new Set(domains.map(domain => domain.name));
|
|
30
37
|
const loadedDomains = new Set();
|
|
@@ -62,7 +69,7 @@ function createProgram({ catalog = openRegistry(), root = nativeRoot, rootSkills
|
|
|
62
69
|
program.addCommand(createDoctorCommand(businessCommandCount));
|
|
63
70
|
program.addCommand(createApiCommand());
|
|
64
71
|
program.addCommand(createAuthCommand());
|
|
65
|
-
program.addCommand(createShopCommand());
|
|
72
|
+
program.addCommand(createShopCommand(undefined, { context: injected ? context : undefined }));
|
|
66
73
|
program.addCommand(createSkillsCommand(skills, rootSkills));
|
|
67
74
|
program.addCommand(createInstallCommand());
|
|
68
75
|
function configureSystemErrors(command) {
|
|
@@ -83,6 +90,11 @@ function createProgram({ catalog = openRegistry(), root = nativeRoot, rootSkills
|
|
|
83
90
|
program.hook('preAction', (_parent, command) => {
|
|
84
91
|
if (isContextHelp(command)) return;
|
|
85
92
|
const contract = commandContract(commandPath(command), command);
|
|
93
|
+
if ((['atomic', 'task'].includes(contract.kind) && !command.opts().dryRun) ||
|
|
94
|
+
contract.kind === 'raw-api') {
|
|
95
|
+
if (contextError) throw contextError;
|
|
96
|
+
bindApiContext(contextState.context);
|
|
97
|
+
}
|
|
86
98
|
if (['atomic', 'task'].includes(contract.kind)) {
|
|
87
99
|
const details = commandSchema(command)?.error?.properties?.details;
|
|
88
100
|
const stages = details?.properties?.stage?.enum;
|
|
@@ -112,7 +124,7 @@ function createProgram({ catalog = openRegistry(), root = nativeRoot, rootSkills
|
|
|
112
124
|
快速开始:
|
|
113
125
|
1. 登录:zan-saas auth login,按提示在浏览器完成授权
|
|
114
126
|
2. 查看当前店铺:zan-saas shop current;已有有效上下文时沿用
|
|
115
|
-
3.
|
|
127
|
+
3. 宿主会话先 shop session init;账号选店用 shop use,连锁内切换用 shop unit use,临时查询用 shop resolve
|
|
116
128
|
4. 阅读使用指引:zan-saas skills read zan-saas
|
|
117
129
|
|
|
118
130
|
查询命令:
|
|
@@ -128,6 +140,10 @@ function createProgram({ catalog = openRegistry(), root = nativeRoot, rootSkills
|
|
|
128
140
|
if (eagerDomains) {
|
|
129
141
|
for (const domain of domains) loadDomain(domain.name);
|
|
130
142
|
}
|
|
143
|
+
const parseAsync = program.parseAsync;
|
|
144
|
+
program.parseAsync = function (...args) {
|
|
145
|
+
return runWithApiSession(() => parseAsync.apply(this, args));
|
|
146
|
+
};
|
|
131
147
|
return program;
|
|
132
148
|
}
|
|
133
149
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"availability": "available",
|
|
10
10
|
"at": "all",
|
|
11
11
|
"module": "access/role/list/command.js",
|
|
12
|
-
"fingerprint": "
|
|
12
|
+
"fingerprint": "9e489554a22cd27edf3fc4c22fcf53dbb820b2f26b030bb558c1df6644c25c75"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "access.staff-perms.get",
|
|
@@ -10,19 +10,13 @@ const { readApiEnvelope, assertApiSuccess, object } = require('../../../../../ex
|
|
|
10
10
|
const { failure, bindingPath } = require('../../../../../execution/shop-query');
|
|
11
11
|
const { snapshotApiEnvironment } = require('../../../../../transport/api');
|
|
12
12
|
const schema = require('./schema.json');
|
|
13
|
+
const { organizationPath, organizationBody, maxOrganizationPages,
|
|
14
|
+
verifyMembership } = require('../../../../../identity/unit-membership');
|
|
13
15
|
|
|
14
16
|
const command = 'access.role.list';
|
|
15
|
-
const organizationPath = 'youzan.shop.chain.descendent.organization.list/1.0.1';
|
|
16
|
-
const maxOrganizationPages = 20;
|
|
17
|
-
const pageSize = 50;
|
|
18
17
|
const reject = (category, stage, scope, state, facts) =>
|
|
19
18
|
failure(command, category, stage, scope, state, facts);
|
|
20
19
|
|
|
21
|
-
function organizationBody(pageNo) {
|
|
22
|
-
return { page_num: pageNo, page_size: pageSize, with_dept_info: false,
|
|
23
|
-
with_shop_status_and_lifecycle_end_time: false, with_crm_shop: false };
|
|
24
|
-
}
|
|
25
|
-
|
|
26
20
|
function previewFor(input, context) {
|
|
27
21
|
const crossShop = context && String(input.kdtId) !== context.shopId;
|
|
28
22
|
const requests = [{ stage: 'shop-binding', method: 'POST', path: bindingPath, body: {} }];
|
|
@@ -34,29 +28,6 @@ function previewFor(input, context) {
|
|
|
34
28
|
return { scope: input, requests, maxOrganizationPages };
|
|
35
29
|
}
|
|
36
30
|
|
|
37
|
-
async function verifyMembership(read, input) {
|
|
38
|
-
const seen = new Set();
|
|
39
|
-
let total;
|
|
40
|
-
for (let pageNo = 1; pageNo <= maxOrganizationPages; pageNo += 1) {
|
|
41
|
-
const { data, facts } = await read(organizationPath, organizationBody(pageNo), 'target-membership');
|
|
42
|
-
const invalid = category => reject(category, 'target-membership', input, 'sent', facts);
|
|
43
|
-
if (!object(data) || !Array.isArray(data.organization_list) ||
|
|
44
|
-
data.page_num !== pageNo || data.page_size !== pageSize ||
|
|
45
|
-
!Number.isSafeInteger(data.total) || data.total < 0 ||
|
|
46
|
-
data.organization_list.length !== Math.min(pageSize, Math.max(0, data.total - seen.size)) ||
|
|
47
|
-
(total !== undefined && total !== data.total)) throw invalid('protocol');
|
|
48
|
-
total = data.total;
|
|
49
|
-
for (const unit of data.organization_list) {
|
|
50
|
-
if (!object(unit) || !Number.isSafeInteger(unit.kdt_id) || unit.kdt_id < 1 ||
|
|
51
|
-
!Number.isSafeInteger(unit.role) || unit.role < 0 || unit.role === 9 ||
|
|
52
|
-
seen.has(unit.kdt_id)) throw invalid('protocol');
|
|
53
|
-
seen.add(unit.kdt_id);
|
|
54
|
-
}
|
|
55
|
-
if (seen.has(input.kdtId)) return;
|
|
56
|
-
if (seen.size === total || pageNo === maxOrganizationPages) throw invalid('scope_unresolved');
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
|
|
60
31
|
async function execute(input, context, preview) {
|
|
61
32
|
let env;
|
|
62
33
|
try { env = await snapshotApiEnvironment(undefined, context.shopId); }
|
|
@@ -79,7 +50,8 @@ async function execute(input, context, preview) {
|
|
|
79
50
|
if (context.orgType !== 'hq' || String(binding.data.sid) !== context.hq || binding.data.role !== 1) {
|
|
80
51
|
throw reject('scope_unresolved', 'shop-binding', input, 'sent', binding.facts);
|
|
81
52
|
}
|
|
82
|
-
await verifyMembership(read, input
|
|
53
|
+
await verifyMembership(read, input.kdtId,
|
|
54
|
+
(category, stage, state, facts) => reject(category, stage, input, state, facts));
|
|
83
55
|
}
|
|
84
56
|
const request = preview.requests.at(-1);
|
|
85
57
|
const { data, facts } = await read(request.path, request.body, request.stage);
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"availability": "available",
|
|
46
46
|
"at": "all",
|
|
47
47
|
"module": "customer/customer-tag/list/command.js",
|
|
48
|
-
"fingerprint": "
|
|
48
|
+
"fingerprint": "9847ca5a4011dc084d074f434c0cd4ad24633e12fe6f79395a1dfb173927bd0b"
|
|
49
49
|
},
|
|
50
50
|
{
|
|
51
51
|
"path": "customer.segment.list",
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const { withTargetShopDefault } = require('../../../../../execution/target-shop-default');
|
|
4
4
|
|
|
5
5
|
const { Command } = require('commander');
|
|
6
|
-
const { requestApi, ApiConfigurationError } = require('../../../../../transport/api');
|
|
6
|
+
const { requestApi, snapshotApiEnvironment, ApiConfigurationError } = require('../../../../../transport/api');
|
|
7
7
|
const { validateCommandValue } = require('../../../../../execution/command-schema');
|
|
8
8
|
const schema = require('./schema.json');
|
|
9
9
|
|
|
@@ -47,7 +47,7 @@ async function listCustomerTags(options, context) {
|
|
|
47
47
|
}
|
|
48
48
|
let response;
|
|
49
49
|
try {
|
|
50
|
-
response = await requestApi(request);
|
|
50
|
+
response = await requestApi(request, await snapshotApiEnvironment(undefined, input.kdtId));
|
|
51
51
|
} catch (error) {
|
|
52
52
|
if (error instanceof ApiConfigurationError) throw failure('configuration', 'configuration');
|
|
53
53
|
throw failure('transport', 'request', 'unknown');
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"availability": "available",
|
|
37
37
|
"at": "all",
|
|
38
38
|
"module": "member/level/get/command.js",
|
|
39
|
-
"fingerprint": "
|
|
39
|
+
"fingerprint": "23c8774db6b4525aa8fc978babee0b64da63498f21a0e261f06fa63c7ffaa78d"
|
|
40
40
|
},
|
|
41
41
|
{
|
|
42
42
|
"path": "member.level.list",
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"availability": "available",
|
|
10
10
|
"at": "all",
|
|
11
11
|
"module": "order/order/get/command.js",
|
|
12
|
-
"fingerprint": "
|
|
12
|
+
"fingerprint": "8b15ec74ec9af89adb9f82ab504a36d9404ab186ad0d7f7e9159a3668d1a7ee7"
|
|
13
13
|
},
|
|
14
14
|
{
|
|
15
15
|
"path": "order.order.list",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"availability": "available",
|
|
19
19
|
"at": "all",
|
|
20
20
|
"module": "order/order/list/command.js",
|
|
21
|
-
"fingerprint": "
|
|
21
|
+
"fingerprint": "ea68f35fc0bfe35168751b08ed46247e91a925b244642234770de0f12855907d"
|
|
22
22
|
},
|
|
23
23
|
{
|
|
24
24
|
"path": "order.order.scan",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"availability": "available",
|
|
28
28
|
"at": "all",
|
|
29
29
|
"module": "order/order/scan/command.js",
|
|
30
|
-
"fingerprint": "
|
|
30
|
+
"fingerprint": "ea25f05e8976c1f2e99e9b7e63fb35757c1b72a1298318dabc428f0ead3886fc"
|
|
31
31
|
}
|
|
32
32
|
]
|
|
33
33
|
}
|