Skip to content

Commit c0d30fe

Browse files
author
wb-liuxuehuan
committed
feat(tokenplan): 添加 tokenplan seats 命令以列出订阅座位详情
新增 tokenplan seats 命令,支持分页和状态过滤,提供详细的座位信息查询功能。相关文档已更新。
1 parent 173e5a7 commit c0d30fe

11 files changed

Lines changed: 446 additions & 2 deletions

File tree

packages/cli/src/commands/catalog.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import quotaList from "./quota/list.ts";
4646
import quotaRequest from "./quota/request.ts";
4747
import quotaHistory from "./quota/history.ts";
4848
import quotaCheck from "./quota/check.ts";
49+
import tokenplanSeats from "./tokenplan/seats.ts";
4950

5051
/** Command registry map (no dependency on registry.ts — safe for build-time import). */
5152
export const commands: Record<string, Command> = {
@@ -94,5 +95,6 @@ export const commands: Record<string, Command> = {
9495
"quota request": quotaRequest,
9596
"quota history": quotaHistory,
9697
"quota check": quotaCheck,
98+
"tokenplan seats": tokenplanSeats,
9799
update: update,
98100
};
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import {
2+
defineCommand,
3+
buildCanonicalQuery,
4+
signRequest,
5+
modelStudioHost,
6+
detectOutputFormat,
7+
maskToken,
8+
trackingHeaders,
9+
type Config,
10+
type GlobalFlags,
11+
type GetSubscriptionSeatDetailsResponse,
12+
type TokenPlanSeatDetail,
13+
BailianError,
14+
ExitCode,
15+
} from "bailian-cli-core";
16+
import { emitResult, emitBare } from "../../output/output.ts";
17+
import { padEnd } from "../../output/cjk-width.ts";
18+
19+
const API_VERSION = "2026-02-10";
20+
const API_ACTION = "GetSubscriptionSeatDetails";
21+
const API_PATH = "/tokenplan/subscription/seat-detail";
22+
23+
export default defineCommand({
24+
name: "tokenplan seats",
25+
description: "List Token Plan subscription seat details",
26+
usage: "bl tokenplan seats [flags]",
27+
options: [
28+
{ flag: "--page-no <n>", description: "Page number (default: 1)", type: "number" },
29+
{ flag: "--page-size <n>", description: "Page size (default: 10)", type: "number" },
30+
{
31+
flag: "--caller-uac-account-id <id>",
32+
description: "Caller UAC account ID",
33+
},
34+
{
35+
flag: "--namespace-id <id>",
36+
description: "Product namespace ID (Token Plan default: namespace-1)",
37+
},
38+
{
39+
flag: "--status <status>",
40+
description:
41+
"Seat status filter (repeatable): CREATING, NORMAL, LIMIT, RELEASE, STOP, REFUNDED",
42+
type: "array",
43+
},
44+
{
45+
flag: "--status-list-str <json>",
46+
description: "StatusList as JSON string, e.g. '[\"NORMAL\"]'",
47+
},
48+
{ flag: "--seat-id <id>", description: "Filter by seat ID" },
49+
{
50+
flag: "--seat-type <type>",
51+
description: "Seat tier: standard, pro, or max",
52+
},
53+
{
54+
flag: "--query-assigned <bool>",
55+
description: "Filter by assignment: true=assigned, false=unassigned",
56+
},
57+
{ flag: "--access-key-id <key>", description: "Alibaba Cloud Access Key ID (deprecated)" },
58+
{
59+
flag: "--access-key-secret <key>",
60+
description: "Alibaba Cloud Access Key Secret (deprecated)",
61+
},
62+
],
63+
examples: [
64+
"bl tokenplan seats",
65+
"bl tokenplan seats --page-size 20 --status NORMAL",
66+
"bl tokenplan seats --query-assigned true --seat-type standard",
67+
],
68+
async run(config: Config, flags: GlobalFlags) {
69+
const format = detectOutputFormat(config.output);
70+
const accessKeyId = (flags.accessKeyId as string) || config.accessKeyId;
71+
const accessKeySecret = (flags.accessKeySecret as string) || config.accessKeySecret;
72+
73+
if (!accessKeyId || !accessKeySecret) {
74+
throw new BailianError(
75+
"No credentials found.\n" +
76+
"Set ALIBABA_CLOUD_ACCESS_KEY_ID and ALIBABA_CLOUD_ACCESS_KEY_SECRET.",
77+
ExitCode.AUTH,
78+
);
79+
}
80+
81+
const queryParams = buildQueryParams(flags);
82+
const queryString = buildCanonicalQuery(queryParams);
83+
const host = modelStudioHost(config.region);
84+
const endpoint = `https://${host}${API_PATH}${queryString ? `?${queryString}` : ""}`;
85+
86+
if (config.dryRun) {
87+
emitResult({ endpoint, query: queryParams }, format);
88+
return;
89+
}
90+
91+
const headers = signRequest({
92+
accessKeyId,
93+
accessKeySecret,
94+
action: API_ACTION,
95+
version: API_VERSION,
96+
body: "",
97+
host,
98+
pathname: API_PATH,
99+
method: "GET",
100+
queryString,
101+
});
102+
103+
if (config.verbose) {
104+
process.stderr.write(`> GET ${endpoint}\n`);
105+
process.stderr.write(`> AK: ${maskToken(accessKeyId)}\n`);
106+
}
107+
108+
const timeoutMs = config.timeout * 1000;
109+
const res = await fetch(endpoint, {
110+
method: "GET",
111+
headers: { ...headers, ...trackingHeaders() },
112+
signal: AbortSignal.timeout(timeoutMs),
113+
});
114+
115+
if (config.verbose) {
116+
process.stderr.write(`< ${res.status} ${res.statusText}\n`);
117+
}
118+
119+
const data = (await res.json()) as GetSubscriptionSeatDetailsResponse;
120+
121+
if (!res.ok || data.Success === false) {
122+
throw new BailianError(
123+
`${data.Code || res.status} - ${data.Message || res.statusText}`,
124+
ExitCode.GENERAL,
125+
);
126+
}
127+
128+
const items = data.Data?.Items ?? [];
129+
if (config.quiet || format === "text") {
130+
emitTextSeats(items, data.Data?.Total, data.Data?.PageNo, data.Data?.PageSize);
131+
} else {
132+
emitResult(data, format);
133+
}
134+
},
135+
});
136+
137+
function buildQueryParams(flags: GlobalFlags): Record<string, string | string[] | undefined> {
138+
const params: Record<string, string | string[] | undefined> = {};
139+
140+
if (flags.pageNo !== undefined) params.PageNo = String(flags.pageNo as number);
141+
if (flags.pageSize !== undefined) params.PageSize = String(flags.pageSize as number);
142+
if (flags.callerUacAccountId) params.CallerUacAccountId = flags.callerUacAccountId as string;
143+
if (flags.namespaceId) params.NamespaceId = flags.namespaceId as string;
144+
if (flags.statusListStr) params.StatusListStr = flags.statusListStr as string;
145+
146+
const status = flags.status;
147+
if (Array.isArray(status) && status.length > 0) {
148+
params.StatusList = status as string[];
149+
} else if (typeof status === "string" && status.length > 0) {
150+
params.StatusList = [status];
151+
}
152+
153+
if (flags.seatId) params.SeatId = flags.seatId as string;
154+
if (flags.seatType) params.SeatType = flags.seatType as string;
155+
156+
if (typeof flags.queryAssigned === "string" && flags.queryAssigned.length > 0) {
157+
params.QueryAssigned = flags.queryAssigned;
158+
}
159+
160+
return params;
161+
}
162+
163+
function emitTextSeats(
164+
items: TokenPlanSeatDetail[],
165+
total?: number,
166+
pageNo?: number,
167+
pageSize?: number,
168+
): void {
169+
if (items.length === 0) {
170+
emitBare("No seats found.");
171+
return;
172+
}
173+
174+
const header = [
175+
padEnd("SeatId", 18),
176+
padEnd("Type", 10),
177+
padEnd("Status", 10),
178+
padEnd("Assigned", 12),
179+
padEnd("Account", 20),
180+
].join(" ");
181+
emitBare(header);
182+
emitBare("-".repeat(header.length));
183+
184+
for (const item of items) {
185+
const row = [
186+
padEnd(item.SeatId ?? "-", 18),
187+
padEnd(item.SpecType ?? "-", 10),
188+
padEnd(item.Status ?? "-", 10),
189+
padEnd(item.AssignedStatus ?? "-", 12),
190+
padEnd(item.AccountName ?? item.AccountId ?? "-", 20),
191+
].join(" ");
192+
emitBare(row);
193+
}
194+
195+
if (total !== undefined) {
196+
emitBare("");
197+
emitBare(
198+
`Total: ${total}${pageNo !== undefined ? ` | Page: ${pageNo}` : ""}${pageSize !== undefined ? ` | PageSize: ${pageSize}` : ""}`,
199+
);
200+
}
201+
}

packages/cli/src/main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ const NO_AUTH_SETUP = [
7070
["quota", "request"],
7171
["quota", "history"],
7272
["quota", "check"],
73+
["tokenplan", "seats"],
7374
];
7475

7576
async function main() {

packages/cli/tests/e2e/helpers.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,15 @@ export function isKnowledgeAkSkReady(): boolean {
136136
);
137137
}
138138

139+
/** Token Plan POP commands (AK/SK only). */
140+
export function isTokenPlanAkSkReady(): boolean {
141+
return (
142+
isBailianE2EEnabled() &&
143+
!!process.env.ALIBABA_CLOUD_ACCESS_KEY_ID &&
144+
!!process.env.ALIBABA_CLOUD_ACCESS_KEY_SECRET
145+
);
146+
}
147+
139148
export interface RunCliResult {
140149
stdout: string;
141150
stderr: string;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { tmpdir } from "os";
2+
import { describe, expect, test } from "vite-plus/test";
3+
import { isTokenPlanAkSkReady, parseStdoutJson, runCli } from "./helpers.ts";
4+
5+
interface DryRunBody {
6+
endpoint?: string;
7+
query?: Record<string, unknown>;
8+
}
9+
10+
describe("e2e: tokenplan seats", () => {
11+
test("tokenplan 分组展示子命令帮助且成功退出", async () => {
12+
const { stdout, stderr, exitCode } = await runCli(["tokenplan"]);
13+
expect(exitCode, stderr).toBe(0);
14+
const out = `${stdout}\n${stderr}`;
15+
expect(out).toMatch(/tokenplan|seats/i);
16+
});
17+
18+
test("tokenplan seats --help 正常退出", async () => {
19+
const { stderr, exitCode } = await runCli(["tokenplan", "seats", "--help"]);
20+
expect(exitCode, stderr).toBe(0);
21+
expect(stderr).toMatch(/--page-no/i);
22+
expect(stderr).toMatch(/--page-size/i);
23+
expect(stderr).toMatch(/--seat-id/i);
24+
expect(stderr).toMatch(/--status/i);
25+
expect(stderr).toMatch(/--query-assigned/i);
26+
});
27+
});
28+
29+
describe("e2e: tokenplan seats errors", () => {
30+
test("无任何凭证时提示 No credentials found 并非零退出", async () => {
31+
const { stderr, exitCode } = await runCli(
32+
["tokenplan", "seats", "--non-interactive", "--output", "json"],
33+
{
34+
DASHSCOPE_API_KEY: undefined,
35+
DASHSCOPE_ACCESS_TOKEN: undefined,
36+
ALIBABA_CLOUD_ACCESS_KEY_ID: undefined,
37+
ALIBABA_CLOUD_ACCESS_KEY_SECRET: undefined,
38+
BAILIAN_CONFIG_DIR: tmpdir(),
39+
},
40+
);
41+
expect(exitCode).not.toBe(0);
42+
expect(stderr).toMatch(/no credentials found/i);
43+
});
44+
});
45+
46+
describe("e2e: tokenplan seats dry-run", () => {
47+
test("--dry-run 输出 endpoint 和 query 参数", async () => {
48+
const { stdout, stderr, exitCode } = await runCli(
49+
[
50+
"tokenplan",
51+
"seats",
52+
"--dry-run",
53+
"--page-no",
54+
"1",
55+
"--page-size",
56+
"10",
57+
"--status",
58+
"NORMAL",
59+
"--query-assigned",
60+
"true",
61+
"--non-interactive",
62+
"--output",
63+
"json",
64+
],
65+
{
66+
ALIBABA_CLOUD_ACCESS_KEY_ID: "LTAI-fake",
67+
ALIBABA_CLOUD_ACCESS_KEY_SECRET: "fake-secret",
68+
},
69+
);
70+
expect(exitCode, stderr).toBe(0);
71+
const data = parseStdoutJson<DryRunBody>(stdout);
72+
expect(data.endpoint).toMatch(/\/tokenplan\/subscription\/seat-detail/);
73+
expect(data.query?.PageNo).toBe("1");
74+
expect(data.query?.PageSize).toBe("10");
75+
expect(data.query?.QueryAssigned).toBe("true");
76+
expect(data.query?.StatusList).toEqual(["NORMAL"]);
77+
});
78+
});
79+
80+
describe.skipIf(!isTokenPlanAkSkReady())("e2e: tokenplan seats(AK/SK)", () => {
81+
test("GetSubscriptionSeatDetails 真实调用", async () => {
82+
const { stdout, stderr, exitCode } = await runCli([
83+
"tokenplan",
84+
"seats",
85+
"--page-size",
86+
"5",
87+
"--non-interactive",
88+
"--output",
89+
"json",
90+
]);
91+
expect(exitCode, stderr).toBe(0);
92+
const data = parseStdoutJson<{ Success?: boolean; Data?: { Items?: unknown[] } }>(stdout);
93+
expect(data.Success).toBe(true);
94+
expect(Array.isArray(data.Data?.Items)).toBe(true);
95+
});
96+
});

packages/core/src/client/ak-sign.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,33 @@ export interface AkSignConfig {
1818
host: string;
1919
pathname: string;
2020
method?: string;
21+
/** ACS3 canonical query string (sorted, encoded, no leading `?`). Empty for POST body-only APIs. */
22+
queryString?: string;
23+
}
24+
25+
/** Build ACS3 canonical query string from POP query parameters. */
26+
export function buildCanonicalQuery(params: Record<string, string | string[] | undefined>): string {
27+
const pairs: Array<[string, string]> = [];
28+
for (const [key, value] of Object.entries(params)) {
29+
if (value === undefined || value === "") continue;
30+
if (Array.isArray(value)) {
31+
const sorted = [...value].sort();
32+
for (const v of sorted) {
33+
if (v !== "") pairs.push([key, v]);
34+
}
35+
} else {
36+
pairs.push([key, value]);
37+
}
38+
}
39+
pairs.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
40+
return pairs.map(([k, v]) => `${encodeRFC3986(k)}=${encodeRFC3986(v)}`).join("&");
41+
}
42+
43+
function encodeRFC3986(str: string): string {
44+
return encodeURIComponent(str).replace(
45+
/[!'()*]/g,
46+
(c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`,
47+
);
2148
}
2249

2350
export function signRequest(cfg: AkSignConfig): Record<string, string> {
@@ -47,11 +74,13 @@ export function signRequest(cfg: AkSignConfig): Record<string, string> {
4774

4875
const signedHeadersStr = signedHeaderKeys.join(";");
4976

77+
const queryString = cfg.queryString ?? "";
78+
5079
// Build canonical request
5180
const canonicalRequest = [
5281
method,
5382
cfg.pathname,
54-
"", // query string (empty for POST)
83+
queryString,
5584
canonicalHeaders,
5685
signedHeadersStr,
5786
hashedBody,

0 commit comments

Comments
 (0)