@optima-chat/dev-skills 0.8.2 → 0.9.0
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/.claude/skills/account/SKILL.md +61 -0
- package/.claude/skills/entitlement/SKILL.md +55 -0
- package/.claude/skills/grant-balance/SKILL.md +3 -3
- package/.claude/skills/grant-subscription/SKILL.md +4 -4
- package/.codex/skills/account/SKILL.md +61 -0
- package/.codex/skills/entitlement/SKILL.md +55 -0
- package/.codex/skills/grant-balance/SKILL.md +3 -3
- package/.codex/skills/grant-subscription/SKILL.md +1 -1
- package/README.md +6 -0
- package/bin/helpers/account.ts +146 -0
- package/bin/helpers/billing-http.ts +105 -19
- package/bin/helpers/entitlement/grant.ts +18 -12
- package/bin/helpers/entitlement/list.ts +15 -11
- package/bin/helpers/entitlement/revoke.ts +18 -14
- package/bin/helpers/grant-balance.ts +17 -21
- package/bin/helpers/grant-subscription.ts +47 -21
- package/dist/bin/helpers/account.js +141 -0
- package/dist/bin/helpers/billing-http.js +99 -19
- package/dist/bin/helpers/entitlement/grant.js +20 -12
- package/dist/bin/helpers/entitlement/list.js +17 -11
- package/dist/bin/helpers/entitlement/revoke.js +20 -14
- package/dist/bin/helpers/grant-balance.js +15 -20
- package/dist/bin/helpers/grant-subscription.js +37 -20
- package/docs/.admin-cli-4env/spec.md +109 -0
- package/package.json +2 -1
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { callBilling, validateEnvCnProd
|
|
1
|
+
import { callBilling, validateEnvCnProd } from '../billing-http';
|
|
2
2
|
import { confirmIfProd } from '../confirm-prompt';
|
|
3
|
+
import { resolveTargetUser } from '../grant-subscription';
|
|
3
4
|
|
|
4
5
|
interface GrantArgs {
|
|
5
|
-
|
|
6
|
+
identifier: string;
|
|
6
7
|
productKey: string;
|
|
7
8
|
justification: string;
|
|
8
9
|
yes: boolean;
|
|
@@ -11,16 +12,16 @@ interface GrantArgs {
|
|
|
11
12
|
|
|
12
13
|
function parseArgs(argv: string[]): GrantArgs {
|
|
13
14
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
14
|
-
console.log(`Usage: optima-entitlement grant
|
|
15
|
+
console.log(`Usage: optima-entitlement grant <email|phone|userId> --product-key <slug> --justification "..." [options]
|
|
15
16
|
|
|
16
17
|
Required:
|
|
17
|
-
|
|
18
|
+
<email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
|
|
18
19
|
--product-key <productKey>
|
|
19
20
|
--justification "..." Required by billing (400 otherwise); stored on entitlement.justification
|
|
20
21
|
|
|
21
22
|
Optional:
|
|
22
|
-
--yes Skip prod confirmation prompt (no-op on stage)
|
|
23
|
-
--env stage|prod|cn-prod|cn-stage
|
|
23
|
+
--yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
|
|
24
|
+
--env stage|prod|cn-prod|cn-stage (default: stage)
|
|
24
25
|
|
|
25
26
|
Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy=<clientId>.`);
|
|
26
27
|
process.exit(0);
|
|
@@ -30,15 +31,18 @@ Hardcoded server-side: source=ADMIN_GRANT, priceCents=0, currency=USD, grantedBy
|
|
|
30
31
|
const a = argv[i];
|
|
31
32
|
const next = argv[i + 1];
|
|
32
33
|
switch (a) {
|
|
33
|
-
case '--email': out.
|
|
34
|
+
case '--email': out.identifier = next; i++; break; // back-compat alias for the positional identifier
|
|
34
35
|
case '--product-key': out.productKey = next; i++; break;
|
|
35
36
|
case '--justification': out.justification = next; i++; break;
|
|
36
37
|
case '--yes': out.yes = true; break;
|
|
37
38
|
case '--env': out.env = next; i++; break;
|
|
38
|
-
default:
|
|
39
|
+
default:
|
|
40
|
+
if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
|
|
41
|
+
if (out.identifier) throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
|
|
42
|
+
out.identifier = a;
|
|
39
43
|
}
|
|
40
44
|
}
|
|
41
|
-
if (!out.
|
|
45
|
+
if (!out.identifier) throw new Error('target user required (<email|phone|userId> positional, or --email)');
|
|
42
46
|
if (!out.productKey) throw new Error('--product-key required');
|
|
43
47
|
if (!out.justification) throw new Error('--justification required (billing returns 400 otherwise)');
|
|
44
48
|
return out as GrantArgs;
|
|
@@ -48,15 +52,17 @@ export async function runGrant(argv: string[]): Promise<void> {
|
|
|
48
52
|
const args = parseArgs(argv);
|
|
49
53
|
validateEnvCnProd(args.env);
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
// Shared resolver: accepts email/phone/userId, reverse-verifies + phone-asserts
|
|
56
|
+
// on cn-prod, email-only via SSH tunnel on AWS (gateway#923).
|
|
57
|
+
const { userId } = await resolveTargetUser(args.env, args.identifier);
|
|
52
58
|
|
|
53
59
|
await confirmIfProd(
|
|
54
60
|
args.env,
|
|
55
|
-
`Action: GRANT product '${args.productKey}' to
|
|
61
|
+
`Action: GRANT product '${args.productKey}' to ${args.identifier} (userId=${userId}) on ${args.env.toUpperCase()}\nJustification: ${args.justification}`,
|
|
56
62
|
args.yes,
|
|
57
63
|
);
|
|
58
64
|
|
|
59
|
-
console.log(`\n🎁 Granting ${args.productKey} to ${args.
|
|
65
|
+
console.log(`\n🎁 Granting ${args.productKey} to ${args.identifier}...`);
|
|
60
66
|
const res = await callBilling(args.env, 'POST', '/api/billing/admin/grant-entitlement', {
|
|
61
67
|
userId,
|
|
62
68
|
productKey: args.productKey,
|
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
import { callBilling, validateEnvCnProd
|
|
1
|
+
import { callBilling, validateEnvCnProd } from '../billing-http';
|
|
2
|
+
import { resolveTargetUser } from '../grant-subscription';
|
|
2
3
|
|
|
3
4
|
interface ListArgs {
|
|
4
|
-
|
|
5
|
+
identifier: string;
|
|
5
6
|
env: string;
|
|
6
7
|
}
|
|
7
8
|
|
|
8
9
|
function parseArgs(argv: string[]): ListArgs {
|
|
9
10
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
10
|
-
console.log(`Usage: optima-entitlement list
|
|
11
|
+
console.log(`Usage: optima-entitlement list <email|phone|userId> [options]
|
|
11
12
|
|
|
12
13
|
Required:
|
|
13
|
-
|
|
14
|
+
<email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
|
|
14
15
|
|
|
15
16
|
Optional:
|
|
16
|
-
--env stage|prod|cn-prod|cn-stage
|
|
17
|
+
--env stage|prod|cn-prod|cn-stage (default: stage)`);
|
|
17
18
|
process.exit(0);
|
|
18
19
|
}
|
|
19
20
|
const out: Partial<ListArgs> = { env: 'stage' };
|
|
@@ -21,12 +22,15 @@ Optional:
|
|
|
21
22
|
const a = argv[i];
|
|
22
23
|
const next = argv[i + 1];
|
|
23
24
|
switch (a) {
|
|
24
|
-
case '--email': out.
|
|
25
|
+
case '--email': out.identifier = next; i++; break; // back-compat alias for the positional identifier
|
|
25
26
|
case '--env': out.env = next; i++; break;
|
|
26
|
-
default:
|
|
27
|
+
default:
|
|
28
|
+
if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
|
|
29
|
+
if (out.identifier) throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
|
|
30
|
+
out.identifier = a;
|
|
27
31
|
}
|
|
28
32
|
}
|
|
29
|
-
if (!out.
|
|
33
|
+
if (!out.identifier) throw new Error('target user required (<email|phone|userId> positional, or --email)');
|
|
30
34
|
return out as ListArgs;
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -43,7 +47,7 @@ export async function runList(argv: string[]): Promise<void> {
|
|
|
43
47
|
const args = parseArgs(argv);
|
|
44
48
|
validateEnvCnProd(args.env);
|
|
45
49
|
|
|
46
|
-
const userId = await
|
|
50
|
+
const { userId } = await resolveTargetUser(args.env, args.identifier);
|
|
47
51
|
|
|
48
52
|
const res = await callBilling<{ entitlements: EntitlementRow[] }>(
|
|
49
53
|
args.env,
|
|
@@ -53,12 +57,12 @@ export async function runList(argv: string[]): Promise<void> {
|
|
|
53
57
|
|
|
54
58
|
const rows = res.body.entitlements ?? [];
|
|
55
59
|
if (rows.length === 0) {
|
|
56
|
-
console.log(`(no entitlements for ${args.
|
|
60
|
+
console.log(`(no entitlements for ${args.identifier} on ${args.env})`);
|
|
57
61
|
return;
|
|
58
62
|
}
|
|
59
63
|
// Newest first per spec
|
|
60
64
|
rows.sort((a, b) => b.purchasedAt.localeCompare(a.purchasedAt));
|
|
61
|
-
console.log(`${rows.length} entitlement(s) for ${args.
|
|
65
|
+
console.log(`${rows.length} entitlement(s) for ${args.identifier}:\n`);
|
|
62
66
|
console.log('id'.padEnd(38) + ' | ' + 'productKey'.padEnd(32) + ' | ' + 'status'.padEnd(9) + ' | ' + 'source'.padEnd(12) + ' | purchasedAt | refundedAt');
|
|
63
67
|
console.log('-'.repeat(140));
|
|
64
68
|
for (const r of rows) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { callBilling, validateEnvCnProd
|
|
1
|
+
import { callBilling, validateEnvCnProd } from '../billing-http';
|
|
2
2
|
import { confirmIfProd } from '../confirm-prompt';
|
|
3
|
+
import { resolveTargetUser } from '../grant-subscription';
|
|
3
4
|
|
|
4
5
|
interface RevokeArgs {
|
|
5
|
-
|
|
6
|
+
identifier: string;
|
|
6
7
|
productKey: string;
|
|
7
8
|
reason: string;
|
|
8
9
|
yes: boolean;
|
|
@@ -18,16 +19,16 @@ interface EntitlementRow {
|
|
|
18
19
|
|
|
19
20
|
function parseArgs(argv: string[]): RevokeArgs {
|
|
20
21
|
if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
|
|
21
|
-
console.log(`Usage: optima-entitlement revoke
|
|
22
|
+
console.log(`Usage: optima-entitlement revoke <email|phone|userId> --product-key <slug> --reason "..." [options]
|
|
22
23
|
|
|
23
24
|
Required:
|
|
24
|
-
|
|
25
|
+
<email|phone|userId> Target user. phone/userId only on cn-prod / cn-stage (AWS resolves email only).
|
|
25
26
|
--product-key <productKey>
|
|
26
27
|
--reason "..." Required by billing (400 otherwise); stored on entitlement.refundReason
|
|
27
28
|
|
|
28
29
|
Optional:
|
|
29
|
-
--yes Skip prod confirmation prompt (no-op on stage)
|
|
30
|
-
--env stage|prod|cn-prod|cn-stage
|
|
30
|
+
--yes Skip prod/cn-prod confirmation prompt (no-op on stage / cn-stage)
|
|
31
|
+
--env stage|prod|cn-prod|cn-stage (default: stage)
|
|
31
32
|
|
|
32
33
|
Refuses non-ADMIN_GRANT sources (PAYMENT, PARTNER) with source-specific
|
|
33
34
|
error pointing to the right reversal flow.`);
|
|
@@ -38,15 +39,18 @@ error pointing to the right reversal flow.`);
|
|
|
38
39
|
const a = argv[i];
|
|
39
40
|
const next = argv[i + 1];
|
|
40
41
|
switch (a) {
|
|
41
|
-
case '--email': out.
|
|
42
|
+
case '--email': out.identifier = next; i++; break; // back-compat alias for the positional identifier
|
|
42
43
|
case '--product-key': out.productKey = next; i++; break;
|
|
43
44
|
case '--reason': out.reason = next; i++; break;
|
|
44
45
|
case '--yes': out.yes = true; break;
|
|
45
46
|
case '--env': out.env = next; i++; break;
|
|
46
|
-
default:
|
|
47
|
+
default:
|
|
48
|
+
if (a.startsWith('--')) throw new Error(`Unknown arg: ${a}`);
|
|
49
|
+
if (out.identifier) throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
|
|
50
|
+
out.identifier = a;
|
|
47
51
|
}
|
|
48
52
|
}
|
|
49
|
-
if (!out.
|
|
53
|
+
if (!out.identifier) throw new Error('target user required (<email|phone|userId> positional, or --email)');
|
|
50
54
|
if (!out.productKey) throw new Error('--product-key required');
|
|
51
55
|
if (!out.reason) throw new Error('--reason required (billing returns 400 otherwise)');
|
|
52
56
|
return out as RevokeArgs;
|
|
@@ -60,7 +64,7 @@ export async function runRevoke(argv: string[]): Promise<void> {
|
|
|
60
64
|
const args = parseArgs(argv);
|
|
61
65
|
validateEnvCnProd(args.env);
|
|
62
66
|
|
|
63
|
-
const userId = await
|
|
67
|
+
const { userId } = await resolveTargetUser(args.env, args.identifier);
|
|
64
68
|
|
|
65
69
|
// Step 1: Fetch user's entitlements
|
|
66
70
|
const listRes = await callBilling<{ entitlements: EntitlementRow[] }>(
|
|
@@ -75,10 +79,10 @@ export async function runRevoke(argv: string[]): Promise<void> {
|
|
|
75
79
|
|
|
76
80
|
// Step 3: Validate count (partial unique index enforces ≤1 ACTIVE)
|
|
77
81
|
if (matches.length === 0) {
|
|
78
|
-
throw new Error(`no active entitlement for (user=${args.
|
|
82
|
+
throw new Error(`no active entitlement for (user=${args.identifier}, product=${args.productKey}) on ${args.env}`);
|
|
79
83
|
}
|
|
80
84
|
if (matches.length > 1) {
|
|
81
|
-
throw new Error(`unexpected: ${matches.length} ACTIVE entitlements for (user, product) — partial unique constraint should prevent this. Inspect with: optima-entitlement list
|
|
85
|
+
throw new Error(`unexpected: ${matches.length} ACTIVE entitlements for (user, product) — partial unique constraint should prevent this. Inspect with: optima-entitlement list ${args.identifier}`);
|
|
82
86
|
}
|
|
83
87
|
const target = matches[0];
|
|
84
88
|
|
|
@@ -89,7 +93,7 @@ export async function runRevoke(argv: string[]): Promise<void> {
|
|
|
89
93
|
|
|
90
94
|
await confirmIfProd(
|
|
91
95
|
args.env,
|
|
92
|
-
`Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for
|
|
96
|
+
`Action: REVOKE entitlement ${target.id} (productKey=${target.productKey}) for ${args.identifier} (userId=${userId}) on ${args.env.toUpperCase()}\nReason: ${args.reason}`,
|
|
93
97
|
args.yes,
|
|
94
98
|
);
|
|
95
99
|
|
|
@@ -102,7 +106,7 @@ export async function runRevoke(argv: string[]): Promise<void> {
|
|
|
102
106
|
// this branch only ever runs for ADMIN_GRANT (priceCents=0) — Stripe
|
|
103
107
|
// refund path in billing (admin-products.ts:296-307) is gated on
|
|
104
108
|
// source=PAYMENT and won't trigger here.
|
|
105
|
-
console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.
|
|
109
|
+
console.log(`\n♻️ Revoking entitlement ${target.id} for ${args.identifier}...`);
|
|
106
110
|
const res = await callBilling(args.env, 'POST', '/api/billing/admin/refund-entitlement', {
|
|
107
111
|
entitlementId: target.id,
|
|
108
112
|
refundReason: args.reason,
|
|
@@ -5,17 +5,20 @@
|
|
|
5
5
|
// 改调 billing 服务态端点(grantCredits → bonus 积分)。
|
|
6
6
|
// ⚠️ 语义变化:旧 wallet granted 无期限;积分 bonus 桶标准 30 天有效期。
|
|
7
7
|
import { randomUUID } from 'crypto';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { callBilling, validateEnvCnProd } from './billing-http';
|
|
9
|
+
import { resolveTargetUser } from './grant-subscription';
|
|
10
10
|
|
|
11
|
-
function parseArgs(args: string[]): {
|
|
11
|
+
function parseArgs(args: string[]): { identifier: string; amountUsd: number; description: string | null; env: string } {
|
|
12
12
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
13
|
-
console.log(`Usage: optima-grant-balance <email> --amount <usd> [options]
|
|
13
|
+
console.log(`Usage: optima-grant-balance <email|phone|userId> --amount <usd> [options]
|
|
14
14
|
|
|
15
15
|
Grant credits to a user (bonus bucket, expires in 30 days).
|
|
16
16
|
Used for promotional grants, compensation, referral rewards, etc.
|
|
17
17
|
$1 = 700 credits (P15 unified ledger; the USD wallet is retired).
|
|
18
18
|
|
|
19
|
+
Target user: <email|phone|userId> (positional). phone/userId only on
|
|
20
|
+
cn-prod / cn-stage; AWS stage/prod resolve email only.
|
|
21
|
+
|
|
19
22
|
Options:
|
|
20
23
|
--amount <usd> USD amount to grant (required, e.g. 5 for $5.00 = 3500 credits)
|
|
21
24
|
--description <text> Description for audit trail (optional)
|
|
@@ -24,13 +27,12 @@ Options:
|
|
|
24
27
|
|
|
25
28
|
Examples:
|
|
26
29
|
optima-grant-balance user@example.com --amount 5 --env prod
|
|
27
|
-
optima-grant-balance
|
|
28
|
-
optima-grant-balance user@example.com --amount 1 --env cn-prod # ¥-priced env, still USD input ($1 = 700 credits)
|
|
30
|
+
optima-grant-balance 18898654855 --amount 1 --env cn-prod # 手机号(cn 用户多为手机号注册)
|
|
29
31
|
optima-grant-balance user@example.com --amount 1 --env cn-stage # 阿里云预发`);
|
|
30
32
|
process.exit(0);
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
const
|
|
35
|
+
const identifier = args[0];
|
|
34
36
|
let amountUsd = 0;
|
|
35
37
|
let description: string | null = null;
|
|
36
38
|
let env = 'stage';
|
|
@@ -47,25 +49,19 @@ Examples:
|
|
|
47
49
|
}
|
|
48
50
|
validateEnvCnProd(env);
|
|
49
51
|
|
|
50
|
-
return {
|
|
52
|
+
return { identifier, amountUsd, description, env };
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
async function main() {
|
|
54
|
-
const {
|
|
56
|
+
const { identifier, amountUsd, description, env } = parseArgs(process.argv.slice(2));
|
|
55
57
|
|
|
56
|
-
console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${
|
|
58
|
+
console.log(`\n🎁 Granting $${amountUsd.toFixed(2)} (${Math.round(amountUsd * 700)} credits) to ${identifier} [${env.toUpperCase()}]\n`);
|
|
57
59
|
if (description) console.log(` Reason: ${description}`);
|
|
58
60
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
userId = await resolveUserIdByEmail(env, email);
|
|
64
|
-
} else {
|
|
65
|
-
const infisicalConfig = getInfisicalConfig();
|
|
66
|
-
const token = getInfisicalToken(infisicalConfig);
|
|
67
|
-
userId = await resolveUserId(email, env, infisicalConfig, token);
|
|
68
|
-
}
|
|
61
|
+
// Shared resolver: classify→resolve→reverse-verify echo→phone-assert on cn
|
|
62
|
+
// (so phone/userId works for cn's phone-registered users, gateway#923);
|
|
63
|
+
// email-only via the RDS SSH tunnel on AWS.
|
|
64
|
+
const { userId } = await resolveTargetUser(env, identifier);
|
|
69
65
|
|
|
70
66
|
// 幂等键 per-invocation 生成、callBilling 的 5xx retry 复用同 body —— 「已
|
|
71
67
|
// commit 但响应 5xx」场景重试不双发(billing spec R2-M3)。
|
|
@@ -80,7 +76,7 @@ async function main() {
|
|
|
80
76
|
);
|
|
81
77
|
|
|
82
78
|
console.log(`✓ Granted ${body.credits} credits (lot ${body.lotId})`);
|
|
83
|
-
console.log(`\n✅ Done! ${
|
|
79
|
+
console.log(`\n✅ Done! ${identifier} received ${body.credits} bonus credits (expires in 30 days)\n`);
|
|
84
80
|
}
|
|
85
81
|
|
|
86
82
|
main().catch(error => {
|
|
@@ -111,22 +111,37 @@ Options:
|
|
|
111
111
|
return { identifier, plan, months, env };
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a CLI identifier (`<email|phone|userId>`) to a userId + verified
|
|
116
|
+
* account identity, handling the AWS-vs-cn split. Shared by grant-subscription,
|
|
117
|
+
* grant-balance, the optima-entitlement subcommands, and optima-account so all
|
|
118
|
+
* four accept the same identifier forms (gateway#923: phone/userId on cn).
|
|
119
|
+
*
|
|
120
|
+
* - AWS (stage/prod): email-only via the RDS SSH tunnel; no internal HTTP
|
|
121
|
+
* reverse-verify (the AWS dev-skills token lacks internal:users:write).
|
|
122
|
+
* - cn-prod / cn-stage: no tunnel into the Aliyun RDS — classify → resolve via
|
|
123
|
+
* user-auth internal endpoints → getUserById reverse-verify → phone-assert.
|
|
124
|
+
* cn-stage shares cn-prod's HTTP path (same internal endpoints, cn-stage M2M
|
|
125
|
+
* token); the split is AWS-vs-cn, NOT prod-vs-stage.
|
|
126
|
+
*
|
|
127
|
+
* Prints a `🎯 目标账号` line in BOTH branches before returning, so a wrong
|
|
128
|
+
* userId is caught by eye before any destructive call (ban/grant/revoke).
|
|
129
|
+
*/
|
|
130
|
+
export async function resolveTargetUser(
|
|
131
|
+
env: string,
|
|
132
|
+
identifier: string,
|
|
133
|
+
): Promise<{
|
|
134
|
+
userId: string;
|
|
135
|
+
kind: 'email' | 'phone' | 'userId';
|
|
136
|
+
identity: { phone: string | null; email: string | null };
|
|
137
|
+
}> {
|
|
117
138
|
const kind = classifyIdentifier(identifier);
|
|
118
139
|
assertAwsEmailOnly(env, kind);
|
|
119
|
-
console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${kind}) for ${months} month(s) [${env.toUpperCase()}]\n`);
|
|
120
|
-
|
|
121
|
-
// identity is the verified target account to print on success. AWS keeps the
|
|
122
|
-
// pre-change behavior (email-only, no internal HTTP reverse-verify); only
|
|
123
|
-
// cn-prod / cn-stage run the full classify→resolve→getUserById→phone-assert防呆 path.
|
|
124
|
-
let userId: string;
|
|
125
|
-
let identity: { phone: string | null; email: string | null };
|
|
126
140
|
|
|
127
141
|
if (env === 'cn-prod' || env === 'cn-stage') {
|
|
128
142
|
// cn-prod / cn-stage have no SSH tunnel into the Aliyun RDS — resolve via
|
|
129
143
|
// user-auth, and the dev-skills token carries internal:users:write for lookups.
|
|
144
|
+
let userId: string;
|
|
130
145
|
if (kind === 'userId') {
|
|
131
146
|
userId = identifier;
|
|
132
147
|
} else if (kind === 'phone') {
|
|
@@ -136,28 +151,39 @@ async function main() {
|
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
// Reverse-verify: fetch and loudly print the target account identity
|
|
139
|
-
// before
|
|
154
|
+
// before any mutation, so a wrong userId is caught by eye (gateway#923).
|
|
140
155
|
const acct = await getUserById(env, userId);
|
|
141
156
|
console.log(
|
|
142
157
|
`🎯 目标账号: userId=${userId} 手机=${acct.phone || '(无)'} email=${acct.email || '(无)'} 当前plan=${acct.current_plan || '?'}`,
|
|
143
158
|
);
|
|
144
159
|
|
|
145
160
|
// Hard assertion: a phone-input grant must land on an account whose phone
|
|
146
|
-
// matches. Runs BEFORE
|
|
161
|
+
// matches. Runs BEFORE the caller's mutation — a mismatch aborts.
|
|
147
162
|
if (kind === 'phone') {
|
|
148
163
|
assertPhoneMatch(identifier, acct.phone);
|
|
149
164
|
}
|
|
150
|
-
identity
|
|
151
|
-
} else {
|
|
152
|
-
// AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No
|
|
153
|
-
// internal HTTP reverse-verify (token lacks internal:users:write → 403).
|
|
154
|
-
const infisicalConfig = getInfisicalConfig();
|
|
155
|
-
const token = getInfisicalToken(infisicalConfig);
|
|
156
|
-
userId = await resolveUserId(identifier, env, infisicalConfig, token);
|
|
157
|
-
console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
|
|
158
|
-
identity = { phone: null, email: identifier };
|
|
165
|
+
return { userId, kind, identity: { phone: acct.phone, email: acct.email } };
|
|
159
166
|
}
|
|
160
167
|
|
|
168
|
+
// AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No internal
|
|
169
|
+
// HTTP reverse-verify (token lacks internal:users:write → 403), but still
|
|
170
|
+
// echo the resolved account so destructive ops have a confirmation line (R1).
|
|
171
|
+
const infisicalConfig = getInfisicalConfig();
|
|
172
|
+
const token = getInfisicalToken(infisicalConfig);
|
|
173
|
+
const userId = await resolveUserId(identifier, env, infisicalConfig, token);
|
|
174
|
+
console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
|
|
175
|
+
return { userId, kind, identity: { phone: null, email: identifier } };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function main() {
|
|
179
|
+
const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
|
|
180
|
+
|
|
181
|
+
console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${classifyIdentifier(identifier)}) for ${months} month(s) [${env.toUpperCase()}]\n`);
|
|
182
|
+
|
|
183
|
+
// Shared resolver: classify → resolve → reverse-verify echo → phone-assert
|
|
184
|
+
// (cn-prod/cn-stage), or email-only via SSH tunnel (AWS). See resolveTargetUser.
|
|
185
|
+
const { userId, identity } = await resolveTargetUser(env, identifier);
|
|
186
|
+
|
|
161
187
|
const { body } = await callBilling<{
|
|
162
188
|
success: boolean;
|
|
163
189
|
subscriptionId: string;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
// optima-account:以用户为中心的运营 admin 操作。
|
|
5
|
+
// · status — 只读聚合(订阅 + 权益)。M2M。
|
|
6
|
+
// · ban / unban — user-auth 账号禁用/恢复。需 admin-用户 token(非 M2M)。
|
|
7
|
+
//
|
|
8
|
+
// 标识符 <email|phone|userId>:cn-prod/cn-stage 支持三种,AWS stage/prod 仅 email
|
|
9
|
+
// (见 resolveTargetUser)。两个生产环境(prod/cn-prod)ban/unban 前会要求确认。
|
|
10
|
+
const grant_subscription_1 = require("./grant-subscription");
|
|
11
|
+
const billing_http_1 = require("./billing-http");
|
|
12
|
+
const confirm_prompt_1 = require("./confirm-prompt");
|
|
13
|
+
function parseArgs(argv, opts = {}) {
|
|
14
|
+
const out = { env: 'stage', reason: null, yes: false };
|
|
15
|
+
for (let i = 0; i < argv.length; i++) {
|
|
16
|
+
const a = argv[i];
|
|
17
|
+
const next = argv[i + 1];
|
|
18
|
+
if (a === '--env') {
|
|
19
|
+
out.env = next;
|
|
20
|
+
i++;
|
|
21
|
+
}
|
|
22
|
+
else if (a === '--reason') {
|
|
23
|
+
out.reason = next;
|
|
24
|
+
i++;
|
|
25
|
+
}
|
|
26
|
+
else if (a === '--yes') {
|
|
27
|
+
out.yes = true;
|
|
28
|
+
}
|
|
29
|
+
else if (a.startsWith('--')) {
|
|
30
|
+
throw new Error(`Unknown arg: ${a}`);
|
|
31
|
+
}
|
|
32
|
+
else if (out.identifier) {
|
|
33
|
+
throw new Error(`Unexpected positional arg: ${a} (identifier already set to ${out.identifier})`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
out.identifier = a;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (!out.identifier)
|
|
40
|
+
throw new Error('target user required: <email|phone|userId>');
|
|
41
|
+
(0, billing_http_1.validateEnvCnProd)(out.env);
|
|
42
|
+
if (opts.reason && !out.reason)
|
|
43
|
+
throw new Error('--reason required for ban');
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
async function runStatus(argv) {
|
|
47
|
+
const { identifier, env } = parseArgs(argv);
|
|
48
|
+
const { userId, identity } = await (0, grant_subscription_1.resolveTargetUser)(env, identifier);
|
|
49
|
+
// 订阅(membership-status,M2M;路径前缀 /api/internal 经 billing base URL)
|
|
50
|
+
let sub = '(读取失败)';
|
|
51
|
+
try {
|
|
52
|
+
const { body } = await (0, billing_http_1.callBilling)(env, 'GET', `/api/internal/users/${encodeURIComponent(userId)}/membership-status`);
|
|
53
|
+
sub = `active=${body.active} plan=${body.planId} status=${body.status}`;
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
sub = `(读取失败: ${e.message})`;
|
|
57
|
+
}
|
|
58
|
+
// 权益(admin/entitlements,M2M)
|
|
59
|
+
let ents = '(读取失败)';
|
|
60
|
+
try {
|
|
61
|
+
const { body } = await (0, billing_http_1.callBilling)(env, 'GET', `/api/billing/admin/entitlements?userId=${encodeURIComponent(userId)}`);
|
|
62
|
+
const rows = body.entitlements ?? [];
|
|
63
|
+
ents = rows.length ? rows.map((r) => `${r.productKey}(${r.status})`).join(', ') : '(无)';
|
|
64
|
+
}
|
|
65
|
+
catch (e) {
|
|
66
|
+
ents = `(读取失败: ${e.message})`;
|
|
67
|
+
}
|
|
68
|
+
console.log(`\n=== 账号状态 [${env.toUpperCase()}] ===`);
|
|
69
|
+
console.log(`userId : ${userId}`);
|
|
70
|
+
console.log(`手机 : ${identity.phone || '(无)'}`);
|
|
71
|
+
console.log(`email : ${identity.email || '(无)'}`);
|
|
72
|
+
console.log(`订阅 : ${sub}`);
|
|
73
|
+
console.log(`权益 : ${ents}`);
|
|
74
|
+
console.log(`禁用态/credits 余额: 暂未纳入(banned_at 与 credits 无 M2M 读取端点,见 optima-dev-skills#36)\n`);
|
|
75
|
+
}
|
|
76
|
+
async function runBan(argv) {
|
|
77
|
+
const { identifier, env, reason, yes } = parseArgs(argv, { reason: true });
|
|
78
|
+
const { userId, identity } = await (0, grant_subscription_1.resolveTargetUser)(env, identifier);
|
|
79
|
+
await (0, confirm_prompt_1.confirmIfProd)(env, `Action: BAN userId=${userId} (手机=${identity.phone || '(无)'} email=${identity.email || '(无)'}) on ${env.toUpperCase()}\nReason: ${reason}`, yes);
|
|
80
|
+
console.log(`\n🚫 Banning ${identifier} (userId=${userId})...`);
|
|
81
|
+
const res = await (0, billing_http_1.callUserAuthAsAdmin)(env, 'POST', `/api/v1/admin/users/${encodeURIComponent(userId)}/ban`, { reason });
|
|
82
|
+
console.log(`✓ Banned (HTTP ${res.status})`);
|
|
83
|
+
console.log('⚠️ 注意:ban 仅置 is_active=false——挡新登录/刷新,但**不会立即失效已签发的 access token**(活跃会话到 token 过期才失效)。即时踢会话需 user-auth 后续支持。\n');
|
|
84
|
+
}
|
|
85
|
+
async function runUnban(argv) {
|
|
86
|
+
const { identifier, env, yes } = parseArgs(argv);
|
|
87
|
+
const { userId, identity } = await (0, grant_subscription_1.resolveTargetUser)(env, identifier);
|
|
88
|
+
await (0, confirm_prompt_1.confirmIfProd)(env, `Action: UNBAN userId=${userId} (手机=${identity.phone || '(无)'} email=${identity.email || '(无)'}) on ${env.toUpperCase()}`, yes);
|
|
89
|
+
console.log(`\n♻️ Unbanning ${identifier} (userId=${userId})...`);
|
|
90
|
+
const res = await (0, billing_http_1.callUserAuthAsAdmin)(env, 'POST', `/api/v1/admin/users/${encodeURIComponent(userId)}/unban`);
|
|
91
|
+
console.log(`✓ Unbanned (HTTP ${res.status})\n`);
|
|
92
|
+
}
|
|
93
|
+
function printHelp() {
|
|
94
|
+
console.log(`Usage: optima-account <subcommand> <email|phone|userId> [options]
|
|
95
|
+
|
|
96
|
+
Subcommands:
|
|
97
|
+
status 只读聚合:订阅(membership) + 权益(entitlements)
|
|
98
|
+
ban 禁用账号(user-auth is_active=false)。需 --reason
|
|
99
|
+
unban 恢复账号
|
|
100
|
+
|
|
101
|
+
Target user: <email|phone|userId> (positional). phone/userId only on
|
|
102
|
+
cn-prod / cn-stage; AWS stage/prod resolve email only.
|
|
103
|
+
|
|
104
|
+
Options:
|
|
105
|
+
--reason "..." Ban reason (required for ban; stored on the user)
|
|
106
|
+
--yes Skip prod/cn-prod confirmation prompt
|
|
107
|
+
--env <env> Environment: stage, prod, cn-prod, cn-stage (default: stage)
|
|
108
|
+
|
|
109
|
+
Notes:
|
|
110
|
+
· ban/unban 用 admin-用户 token(Infisical /shared-secrets/credentials)。
|
|
111
|
+
· ban 非即时踢会话:仅挡新登录/刷新,活跃 token 过期后失效。
|
|
112
|
+
· 禁用原因/banned_at 与 credits 余额暂不在 status 显示(见 #36)。
|
|
113
|
+
|
|
114
|
+
Examples:
|
|
115
|
+
optima-account status 18898654855 --env cn-prod
|
|
116
|
+
optima-account ban user@example.com --reason "abuse" --env prod
|
|
117
|
+
optima-account unban 18898654855 --env cn-prod`);
|
|
118
|
+
}
|
|
119
|
+
async function main() {
|
|
120
|
+
const [, , subcommand, ...rest] = process.argv;
|
|
121
|
+
if (!subcommand || subcommand === '-h' || subcommand === '--help') {
|
|
122
|
+
printHelp();
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
|
125
|
+
switch (subcommand) {
|
|
126
|
+
case 'status':
|
|
127
|
+
await runStatus(rest);
|
|
128
|
+
break;
|
|
129
|
+
case 'ban':
|
|
130
|
+
await runBan(rest);
|
|
131
|
+
break;
|
|
132
|
+
case 'unban':
|
|
133
|
+
await runUnban(rest);
|
|
134
|
+
break;
|
|
135
|
+
default:
|
|
136
|
+
console.error(`Unknown subcommand: ${subcommand}`);
|
|
137
|
+
printHelp();
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
main().catch((err) => { console.error('\n❌ Error:', err.message); process.exit(1); });
|