@optima-chat/dev-skills 0.7.41 → 0.7.43
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/grant-balance/SKILL.md +1 -1
- package/.codex/skills/grant-balance/SKILL.md +1 -1
- package/bin/cli.js +1 -1
- package/bin/helpers/billing-http.ts +72 -0
- package/bin/helpers/generate-test-token.ts +5 -2
- package/bin/helpers/grant-subscription.ts +111 -16
- package/dist/bin/helpers/billing-http.js +71 -0
- package/dist/bin/helpers/generate-test-token.js +5 -2
- package/dist/bin/helpers/grant-subscription.js +98 -14
- package/package.json +1 -1
|
@@ -52,7 +52,7 @@ optima-grant-balance user@example.com --amount 20 --description "服务中断补
|
|
|
52
52
|
| `--description <text>` | 描述/原因(仅 console 输出) | - |
|
|
53
53
|
| `--env <env>` | 环境:stage, prod, cn-prod | stage |
|
|
54
54
|
|
|
55
|
-
> **cn-prod(国内环境)**:全程 HTTPS(auth
|
|
55
|
+
> **cn-prod(国内环境)**:全程 HTTPS(auth.yzsgo.com / billing-api.yzsgo.com),email 查找走 user-auth internal lookup API(无 SSH 隧道)。金额输入仍是 USD($1 = 700 积分,与 CN ¥1 = 100 积分同一账本单位)。例:`optima-grant-balance user@example.com --amount 1 --env cn-prod`
|
|
56
56
|
|
|
57
57
|
## 与 grant-subscription 的区别
|
|
58
58
|
|
|
@@ -51,7 +51,7 @@ optima-grant-balance user@example.com --amount 20 --description "服务中断补
|
|
|
51
51
|
| `--description <text>` | 描述/原因(仅 console 输出) | - |
|
|
52
52
|
| `--env <env>` | 环境:stage, prod, cn-prod | stage |
|
|
53
53
|
|
|
54
|
-
> **cn-prod**:走 HTTPS(auth
|
|
54
|
+
> **cn-prod**:走 HTTPS(auth.yzsgo.com / billing-api.yzsgo.com),email 查找经 user-auth internal lookup API(无 SSH 隧道)。金额输入仍是 USD($1 = 700 积分 = ¥7 档积分口径一致)。
|
|
55
55
|
|
|
56
56
|
## 与 grant-subscription 的区别
|
|
57
57
|
|
package/bin/cli.js
CHANGED
|
@@ -35,7 +35,7 @@ switch (command) {
|
|
|
35
35
|
log(' optima-verify-health <service> [--env cn|prod|all] Probe L1-L5 上线健康', 'cyan');
|
|
36
36
|
log(' optima-generate-test-token [--env production] Generate test token', 'cyan');
|
|
37
37
|
log(' optima-grant-balance <email> --amount <usd> [--env] Grant USD wallet balance', 'cyan');
|
|
38
|
-
log(' optima-grant-subscription <email> --plan <p> [--env] Grant subscription', 'cyan');
|
|
38
|
+
log(' optima-grant-subscription <email|phone|userId> --plan <p> [--env] Grant subscription', 'cyan');
|
|
39
39
|
log(' /logs <service> [lines] [env] View service logs (skill)', 'cyan');
|
|
40
40
|
log(' /restart-ecs <service> [env] Restart ECS service (skill)', 'cyan');
|
|
41
41
|
|
|
@@ -271,3 +271,75 @@ export async function resolveUserIdByEmail(env: string, email: string): Promise<
|
|
|
271
271
|
console.log(`✓ Found user: ${parsed.user_id}`);
|
|
272
272
|
return parsed.user_id;
|
|
273
273
|
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Resolve a user's id by phone via user-auth's internal lookup endpoint
|
|
277
|
+
* (POST /api/v1/internal/users/lookup with {phone}). Mirrors
|
|
278
|
+
* resolveUserIdByEmail — pure-phone CN users have no email, so this is the
|
|
279
|
+
* only id path for them (gateway#923 root cause: email-only lookup couldn't
|
|
280
|
+
* resolve them and a wrong userId got hand-fed instead).
|
|
281
|
+
*/
|
|
282
|
+
export async function resolveUserIdByPhone(env: string, phone: string): Promise<string> {
|
|
283
|
+
console.log(`Looking up user by phone: ${phone}`);
|
|
284
|
+
const token = getServiceToken(env);
|
|
285
|
+
const authUrl = USER_AUTH_URLS[env];
|
|
286
|
+
if (!authUrl) throw new Error(`Unknown env: ${env}`);
|
|
287
|
+
|
|
288
|
+
const res = await fetch(`${authUrl}/api/v1/internal/users/lookup`, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
291
|
+
body: JSON.stringify({ phone }),
|
|
292
|
+
});
|
|
293
|
+
const text = await res.text();
|
|
294
|
+
if (res.status === 404) {
|
|
295
|
+
throw new Error(`User not found by phone (${env}): ${phone}`);
|
|
296
|
+
}
|
|
297
|
+
if (!res.ok) {
|
|
298
|
+
throw new Error(formatServiceError(res.status, res.statusText, text));
|
|
299
|
+
}
|
|
300
|
+
let parsed: { user_id?: string };
|
|
301
|
+
try {
|
|
302
|
+
parsed = JSON.parse(text);
|
|
303
|
+
} catch {
|
|
304
|
+
throw new Error(`user-auth lookup returned non-JSON 2xx body: ${text.slice(0, 200)}`);
|
|
305
|
+
}
|
|
306
|
+
if (!parsed.user_id) {
|
|
307
|
+
throw new Error(`user-auth lookup response missing user_id: ${text.slice(0, 200)}`);
|
|
308
|
+
}
|
|
309
|
+
console.log(`✓ Found user: ${parsed.user_id}`);
|
|
310
|
+
return parsed.user_id;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Fetch a user's identity by id via user-auth's internal endpoint
|
|
315
|
+
* (GET /api/v1/internal/users/{userId}). Used to reverse-verify the target
|
|
316
|
+
* account before granting — prints phone/email/current_plan so the operator
|
|
317
|
+
* can confirm they're hitting the right account (gateway#923).
|
|
318
|
+
*/
|
|
319
|
+
export async function getUserById(
|
|
320
|
+
env: string,
|
|
321
|
+
userId: string,
|
|
322
|
+
): Promise<{ user_id: string; phone: string | null; email: string | null; current_plan?: string }> {
|
|
323
|
+
const token = getServiceToken(env);
|
|
324
|
+
const authUrl = USER_AUTH_URLS[env];
|
|
325
|
+
if (!authUrl) throw new Error(`Unknown env: ${env}`);
|
|
326
|
+
|
|
327
|
+
const res = await fetch(`${authUrl}/api/v1/internal/users/${encodeURIComponent(userId)}`, {
|
|
328
|
+
method: 'GET',
|
|
329
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
330
|
+
});
|
|
331
|
+
const text = await res.text();
|
|
332
|
+
if (res.status === 404) {
|
|
333
|
+
throw new Error(`User not found by id (${env}): ${userId}`);
|
|
334
|
+
}
|
|
335
|
+
if (!res.ok) {
|
|
336
|
+
throw new Error(formatServiceError(res.status, res.statusText, text));
|
|
337
|
+
}
|
|
338
|
+
let parsed: { user_id: string; phone: string | null; email: string | null; current_plan?: string };
|
|
339
|
+
try {
|
|
340
|
+
parsed = JSON.parse(text);
|
|
341
|
+
} catch {
|
|
342
|
+
throw new Error(`user-auth user lookup returned non-JSON 2xx body: ${text.slice(0, 200)}`);
|
|
343
|
+
}
|
|
344
|
+
return parsed;
|
|
345
|
+
}
|
|
@@ -56,7 +56,9 @@ const ENV_CONFIG: Record<Environment, EnvironmentConfig> = {
|
|
|
56
56
|
// #201: yzsgo.com 全量迁移 (2026-06-12), 旧 *-cn.optima.chat 路由已下线
|
|
57
57
|
authUrl: 'https://auth.yzsgo.com',
|
|
58
58
|
apiUrl: 'https://commerce.yzsgo.com',
|
|
59
|
-
|
|
59
|
+
// #31: 必须用 cn user-auth 里注册的 public client(开 password grant)。
|
|
60
|
+
// 注意不要换成同名的 confidential client `commerce-cli-cn-prod-*`,那个需要 client_secret,走不通 CLI 的 public ROPC flow。
|
|
61
|
+
clientId: 'dev-skill-cli-cn-pro-acvkmcuq',
|
|
60
62
|
envName: 'cn-prod'
|
|
61
63
|
}
|
|
62
64
|
};
|
|
@@ -98,7 +100,8 @@ async function registerMerchant(
|
|
|
98
100
|
body: JSON.stringify(payload)
|
|
99
101
|
});
|
|
100
102
|
|
|
101
|
-
|
|
103
|
+
// #31: cn-prod register 返回体字段名是 `id`,AWS 是 `user_id`,两者兼容
|
|
104
|
+
console.log(`✓ Merchant registered successfully (ID: ${result.user_id ?? (result as any).id})`);
|
|
102
105
|
return result;
|
|
103
106
|
} catch (error: any) {
|
|
104
107
|
if (error.message.includes('409') || error.message.includes('already exists')) {
|
|
@@ -4,7 +4,65 @@
|
|
|
4
4
|
// 作废,改调 billing 服务态端点(与用户态 /api/admin/grant-subscription 同
|
|
5
5
|
// 业务体:supersede 旧授予 + 实得 credits + token quota,重试双发不双倍)。
|
|
6
6
|
import { getInfisicalConfig, getInfisicalToken, resolveUserId } from './db-utils';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
callBilling,
|
|
9
|
+
resolveUserIdByEmail,
|
|
10
|
+
resolveUserIdByPhone,
|
|
11
|
+
getUserById,
|
|
12
|
+
validateEnvCnProd,
|
|
13
|
+
} from './billing-http';
|
|
14
|
+
|
|
15
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Auto-detect what kind of identifier the operator passed. gateway#923: a
|
|
19
|
+
* pure-phone CN user (no email) couldn't be resolved by the email-only CLI,
|
|
20
|
+
* so a wrong userId was hand-fed. Accepting phone/userId directly + this
|
|
21
|
+
* classifier removes the manual-userId footgun.
|
|
22
|
+
*/
|
|
23
|
+
export function classifyIdentifier(s: string): 'email' | 'phone' | 'userId' {
|
|
24
|
+
if (s.includes('@')) return 'email';
|
|
25
|
+
if (UUID_RE.test(s)) return 'userId';
|
|
26
|
+
const digits = s.replace(/\D/g, '');
|
|
27
|
+
if (/^\d{6,}$/.test(digits)) return 'phone';
|
|
28
|
+
throw new Error('无法识别 identifier(需 email / 手机号 / userId)');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hard guard: when the operator gave a phone number, the resolved account's
|
|
33
|
+
* phone MUST match it. Normalizes both sides (strip non-digits) before
|
|
34
|
+
* comparing. A mismatch (or an account with no phone) throws to abort the
|
|
35
|
+
* grant — this is the assertion that would have stopped gateway#923.
|
|
36
|
+
*/
|
|
37
|
+
export function assertPhoneMatch(inputPhone: string, accountPhone: string | null): void {
|
|
38
|
+
const input = inputPhone.replace(/\D/g, '');
|
|
39
|
+
const account = (accountPhone ?? '').replace(/\D/g, '');
|
|
40
|
+
if (!account || input !== account) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'手机号不匹配:请求 ' +
|
|
43
|
+
input +
|
|
44
|
+
',但该 userId 的手机号是 ' +
|
|
45
|
+
(accountPhone || '(无)') +
|
|
46
|
+
'——拒绝授予以防发错账号',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* AWS (stage/prod) only supports email identifiers. The HTTP reverse-verify
|
|
53
|
+
* (getUserById) and phone/userId resolution rely on user-auth's internal
|
|
54
|
+
* endpoints, which require the token scope `internal:users:write` — only the
|
|
55
|
+
* cn-prod dev-skills client carries it (see billing-http getServiceToken).
|
|
56
|
+
* AWS resolves users via the RDS SSH tunnel (resolveUserId) and never hits
|
|
57
|
+
* those endpoints, so phone/userId here would 403. gateway#923 (wrong-account
|
|
58
|
+
* grant) is a cn-prod-only, pure-phone-user problem; AWS users have unambiguous
|
|
59
|
+
* emails, so restricting AWS to email is a zero-regression no-op for them.
|
|
60
|
+
*/
|
|
61
|
+
export function assertAwsEmailOnly(env: string, kind: 'email' | 'phone' | 'userId'): void {
|
|
62
|
+
if ((env === 'stage' || env === 'prod') && kind !== 'email') {
|
|
63
|
+
throw new Error('stage/prod 仅支持 email 标识;手机号/userId 解析仅 cn-prod 可用');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
8
66
|
|
|
9
67
|
// cn-prod sells the CNY-priced -cn plans (P12); the bare USD plan ids also
|
|
10
68
|
// exist in the cn DB, so a per-env whitelist (not billing-side validation)
|
|
@@ -15,9 +73,9 @@ const PLANS_BY_ENV: Record<string, string[]> = {
|
|
|
15
73
|
'cn-prod': ['trial', 'starter-cn', 'pro-cn', 'enterprise-cn'],
|
|
16
74
|
};
|
|
17
75
|
|
|
18
|
-
function parseArgs(args: string[]): {
|
|
76
|
+
function parseArgs(args: string[]): { identifier: string; plan: string; months: number; env: string } {
|
|
19
77
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
20
|
-
console.log(`Usage: optima-grant-subscription <email> [options]
|
|
78
|
+
console.log(`Usage: optima-grant-subscription <email|phone|userId> [options]
|
|
21
79
|
|
|
22
80
|
Options:
|
|
23
81
|
--plan <id> Plan: trial, starter, pro, enterprise (default: pro)
|
|
@@ -28,7 +86,7 @@ Options:
|
|
|
28
86
|
process.exit(0);
|
|
29
87
|
}
|
|
30
88
|
|
|
31
|
-
const
|
|
89
|
+
const identifier = args[0];
|
|
32
90
|
let plan: string | null = null;
|
|
33
91
|
let months = 1;
|
|
34
92
|
let env = 'stage';
|
|
@@ -48,23 +106,54 @@ Options:
|
|
|
48
106
|
}
|
|
49
107
|
if (months < 1) { console.error('Months must be >= 1'); process.exit(1); }
|
|
50
108
|
|
|
51
|
-
return {
|
|
109
|
+
return { identifier, plan, months, env };
|
|
52
110
|
}
|
|
53
111
|
|
|
54
112
|
async function main() {
|
|
55
|
-
const {
|
|
113
|
+
const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
|
|
56
114
|
|
|
57
|
-
|
|
115
|
+
const kind = classifyIdentifier(identifier);
|
|
116
|
+
assertAwsEmailOnly(env, kind);
|
|
117
|
+
console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${kind}) for ${months} month(s) [${env.toUpperCase()}]\n`);
|
|
58
118
|
|
|
59
|
-
//
|
|
60
|
-
//
|
|
119
|
+
// identity is the verified target account to print on success. AWS keeps the
|
|
120
|
+
// pre-change behavior (email-only, no internal HTTP reverse-verify); only
|
|
121
|
+
// cn-prod runs the full classify→resolve→getUserById→phone-assert防呆 path.
|
|
61
122
|
let userId: string;
|
|
123
|
+
let identity: { phone: string | null; email: string | null };
|
|
124
|
+
|
|
62
125
|
if (env === 'cn-prod') {
|
|
63
|
-
|
|
126
|
+
// cn-prod has no SSH tunnel into the Aliyun RDS — resolve via user-auth,
|
|
127
|
+
// and its dev-skills token carries internal:users:write for the lookups.
|
|
128
|
+
if (kind === 'userId') {
|
|
129
|
+
userId = identifier;
|
|
130
|
+
} else if (kind === 'phone') {
|
|
131
|
+
userId = await resolveUserIdByPhone(env, identifier);
|
|
132
|
+
} else {
|
|
133
|
+
userId = await resolveUserIdByEmail(env, identifier);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Reverse-verify: fetch and loudly print the target account identity
|
|
137
|
+
// before granting, so a wrong userId is caught by eye (gateway#923).
|
|
138
|
+
const acct = await getUserById(env, userId);
|
|
139
|
+
console.log(
|
|
140
|
+
`🎯 目标账号: userId=${userId} 手机=${acct.phone || '(无)'} email=${acct.email || '(无)'} 当前plan=${acct.current_plan || '?'}`,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Hard assertion: a phone-input grant must land on an account whose phone
|
|
144
|
+
// matches. Runs BEFORE callBilling — a mismatch aborts without granting.
|
|
145
|
+
if (kind === 'phone') {
|
|
146
|
+
assertPhoneMatch(identifier, acct.phone);
|
|
147
|
+
}
|
|
148
|
+
identity = { phone: acct.phone, email: acct.email };
|
|
64
149
|
} else {
|
|
150
|
+
// AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No
|
|
151
|
+
// internal HTTP reverse-verify (token lacks internal:users:write → 403).
|
|
65
152
|
const infisicalConfig = getInfisicalConfig();
|
|
66
153
|
const token = getInfisicalToken(infisicalConfig);
|
|
67
|
-
userId = await resolveUserId(
|
|
154
|
+
userId = await resolveUserId(identifier, env, infisicalConfig, token);
|
|
155
|
+
console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
|
|
156
|
+
identity = { phone: null, email: identifier };
|
|
68
157
|
}
|
|
69
158
|
|
|
70
159
|
const { body } = await callBilling<{
|
|
@@ -82,10 +171,16 @@ async function main() {
|
|
|
82
171
|
console.log(`✓ Credits: ${body.credits.toLocaleString()} (expires ${body.expiresAt})`);
|
|
83
172
|
console.log(`✓ Token limits: session ${body.sessionTokenLimit.toLocaleString()} / weekly ${body.weeklyTokenLimit.toLocaleString()}`);
|
|
84
173
|
if (body.warning) console.log(`⚠️ ${body.warning}`);
|
|
85
|
-
console.log(
|
|
174
|
+
console.log(
|
|
175
|
+
`\n✅ Done! 用户 userId=${userId} 手机=${identity.phone || '(无)'} email=${identity.email || '(无)'} now has ${body.planId} until ${body.expiresAt}\n`,
|
|
176
|
+
);
|
|
86
177
|
}
|
|
87
178
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
179
|
+
// Only run the CLI flow when invoked directly — being require()'d (e.g. by the
|
|
180
|
+
// unit tests for classifyIdentifier/assertPhoneMatch) must not trigger main().
|
|
181
|
+
if (require.main === module) {
|
|
182
|
+
main().catch(error => {
|
|
183
|
+
console.error('\n❌ Error:', error.message);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -6,6 +6,8 @@ exports.getServiceToken = getServiceToken;
|
|
|
6
6
|
exports.callBilling = callBilling;
|
|
7
7
|
exports.callSkills = callSkills;
|
|
8
8
|
exports.resolveUserIdByEmail = resolveUserIdByEmail;
|
|
9
|
+
exports.resolveUserIdByPhone = resolveUserIdByPhone;
|
|
10
|
+
exports.getUserById = getUserById;
|
|
9
11
|
const child_process_1 = require("child_process");
|
|
10
12
|
const infisical_secrets_1 = require("./infisical-secrets");
|
|
11
13
|
const db_utils_1 = require("./db-utils");
|
|
@@ -232,3 +234,72 @@ async function resolveUserIdByEmail(env, email) {
|
|
|
232
234
|
console.log(`✓ Found user: ${parsed.user_id}`);
|
|
233
235
|
return parsed.user_id;
|
|
234
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Resolve a user's id by phone via user-auth's internal lookup endpoint
|
|
239
|
+
* (POST /api/v1/internal/users/lookup with {phone}). Mirrors
|
|
240
|
+
* resolveUserIdByEmail — pure-phone CN users have no email, so this is the
|
|
241
|
+
* only id path for them (gateway#923 root cause: email-only lookup couldn't
|
|
242
|
+
* resolve them and a wrong userId got hand-fed instead).
|
|
243
|
+
*/
|
|
244
|
+
async function resolveUserIdByPhone(env, phone) {
|
|
245
|
+
console.log(`Looking up user by phone: ${phone}`);
|
|
246
|
+
const token = getServiceToken(env);
|
|
247
|
+
const authUrl = USER_AUTH_URLS[env];
|
|
248
|
+
if (!authUrl)
|
|
249
|
+
throw new Error(`Unknown env: ${env}`);
|
|
250
|
+
const res = await fetch(`${authUrl}/api/v1/internal/users/lookup`, {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
253
|
+
body: JSON.stringify({ phone }),
|
|
254
|
+
});
|
|
255
|
+
const text = await res.text();
|
|
256
|
+
if (res.status === 404) {
|
|
257
|
+
throw new Error(`User not found by phone (${env}): ${phone}`);
|
|
258
|
+
}
|
|
259
|
+
if (!res.ok) {
|
|
260
|
+
throw new Error(formatServiceError(res.status, res.statusText, text));
|
|
261
|
+
}
|
|
262
|
+
let parsed;
|
|
263
|
+
try {
|
|
264
|
+
parsed = JSON.parse(text);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
throw new Error(`user-auth lookup returned non-JSON 2xx body: ${text.slice(0, 200)}`);
|
|
268
|
+
}
|
|
269
|
+
if (!parsed.user_id) {
|
|
270
|
+
throw new Error(`user-auth lookup response missing user_id: ${text.slice(0, 200)}`);
|
|
271
|
+
}
|
|
272
|
+
console.log(`✓ Found user: ${parsed.user_id}`);
|
|
273
|
+
return parsed.user_id;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Fetch a user's identity by id via user-auth's internal endpoint
|
|
277
|
+
* (GET /api/v1/internal/users/{userId}). Used to reverse-verify the target
|
|
278
|
+
* account before granting — prints phone/email/current_plan so the operator
|
|
279
|
+
* can confirm they're hitting the right account (gateway#923).
|
|
280
|
+
*/
|
|
281
|
+
async function getUserById(env, userId) {
|
|
282
|
+
const token = getServiceToken(env);
|
|
283
|
+
const authUrl = USER_AUTH_URLS[env];
|
|
284
|
+
if (!authUrl)
|
|
285
|
+
throw new Error(`Unknown env: ${env}`);
|
|
286
|
+
const res = await fetch(`${authUrl}/api/v1/internal/users/${encodeURIComponent(userId)}`, {
|
|
287
|
+
method: 'GET',
|
|
288
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
289
|
+
});
|
|
290
|
+
const text = await res.text();
|
|
291
|
+
if (res.status === 404) {
|
|
292
|
+
throw new Error(`User not found by id (${env}): ${userId}`);
|
|
293
|
+
}
|
|
294
|
+
if (!res.ok) {
|
|
295
|
+
throw new Error(formatServiceError(res.status, res.statusText, text));
|
|
296
|
+
}
|
|
297
|
+
let parsed;
|
|
298
|
+
try {
|
|
299
|
+
parsed = JSON.parse(text);
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
throw new Error(`user-auth user lookup returned non-JSON 2xx body: ${text.slice(0, 200)}`);
|
|
303
|
+
}
|
|
304
|
+
return parsed;
|
|
305
|
+
}
|
|
@@ -60,7 +60,9 @@ const ENV_CONFIG = {
|
|
|
60
60
|
// #201: yzsgo.com 全量迁移 (2026-06-12), 旧 *-cn.optima.chat 路由已下线
|
|
61
61
|
authUrl: 'https://auth.yzsgo.com',
|
|
62
62
|
apiUrl: 'https://commerce.yzsgo.com',
|
|
63
|
-
|
|
63
|
+
// #31: 必须用 cn user-auth 里注册的 public client(开 password grant)。
|
|
64
|
+
// 注意不要换成同名的 confidential client `commerce-cli-cn-prod-*`,那个需要 client_secret,走不通 CLI 的 public ROPC flow。
|
|
65
|
+
clientId: 'dev-skill-cli-cn-pro-acvkmcuq',
|
|
64
66
|
envName: 'cn-prod'
|
|
65
67
|
}
|
|
66
68
|
};
|
|
@@ -90,7 +92,8 @@ async function registerMerchant(email, password, businessName, config, phone, ad
|
|
|
90
92
|
method: 'POST',
|
|
91
93
|
body: JSON.stringify(payload)
|
|
92
94
|
});
|
|
93
|
-
|
|
95
|
+
// #31: cn-prod register 返回体字段名是 `id`,AWS 是 `user_id`,两者兼容
|
|
96
|
+
console.log(`✓ Merchant registered successfully (ID: ${result.user_id ?? result.id})`);
|
|
94
97
|
return result;
|
|
95
98
|
}
|
|
96
99
|
catch (error) {
|
|
@@ -1,11 +1,63 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
"use strict";
|
|
3
3
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.classifyIdentifier = classifyIdentifier;
|
|
5
|
+
exports.assertPhoneMatch = assertPhoneMatch;
|
|
6
|
+
exports.assertAwsEmailOnly = assertAwsEmailOnly;
|
|
4
7
|
// P15 D8b:USD 钱包退役——原「SSH 直写 subscriptions/usd_wallets/token_quotas」
|
|
5
8
|
// 作废,改调 billing 服务态端点(与用户态 /api/admin/grant-subscription 同
|
|
6
9
|
// 业务体:supersede 旧授予 + 实得 credits + token quota,重试双发不双倍)。
|
|
7
10
|
const db_utils_1 = require("./db-utils");
|
|
8
11
|
const billing_http_1 = require("./billing-http");
|
|
12
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
+
/**
|
|
14
|
+
* Auto-detect what kind of identifier the operator passed. gateway#923: a
|
|
15
|
+
* pure-phone CN user (no email) couldn't be resolved by the email-only CLI,
|
|
16
|
+
* so a wrong userId was hand-fed. Accepting phone/userId directly + this
|
|
17
|
+
* classifier removes the manual-userId footgun.
|
|
18
|
+
*/
|
|
19
|
+
function classifyIdentifier(s) {
|
|
20
|
+
if (s.includes('@'))
|
|
21
|
+
return 'email';
|
|
22
|
+
if (UUID_RE.test(s))
|
|
23
|
+
return 'userId';
|
|
24
|
+
const digits = s.replace(/\D/g, '');
|
|
25
|
+
if (/^\d{6,}$/.test(digits))
|
|
26
|
+
return 'phone';
|
|
27
|
+
throw new Error('无法识别 identifier(需 email / 手机号 / userId)');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Hard guard: when the operator gave a phone number, the resolved account's
|
|
31
|
+
* phone MUST match it. Normalizes both sides (strip non-digits) before
|
|
32
|
+
* comparing. A mismatch (or an account with no phone) throws to abort the
|
|
33
|
+
* grant — this is the assertion that would have stopped gateway#923.
|
|
34
|
+
*/
|
|
35
|
+
function assertPhoneMatch(inputPhone, accountPhone) {
|
|
36
|
+
const input = inputPhone.replace(/\D/g, '');
|
|
37
|
+
const account = (accountPhone ?? '').replace(/\D/g, '');
|
|
38
|
+
if (!account || input !== account) {
|
|
39
|
+
throw new Error('手机号不匹配:请求 ' +
|
|
40
|
+
input +
|
|
41
|
+
',但该 userId 的手机号是 ' +
|
|
42
|
+
(accountPhone || '(无)') +
|
|
43
|
+
'——拒绝授予以防发错账号');
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* AWS (stage/prod) only supports email identifiers. The HTTP reverse-verify
|
|
48
|
+
* (getUserById) and phone/userId resolution rely on user-auth's internal
|
|
49
|
+
* endpoints, which require the token scope `internal:users:write` — only the
|
|
50
|
+
* cn-prod dev-skills client carries it (see billing-http getServiceToken).
|
|
51
|
+
* AWS resolves users via the RDS SSH tunnel (resolveUserId) and never hits
|
|
52
|
+
* those endpoints, so phone/userId here would 403. gateway#923 (wrong-account
|
|
53
|
+
* grant) is a cn-prod-only, pure-phone-user problem; AWS users have unambiguous
|
|
54
|
+
* emails, so restricting AWS to email is a zero-regression no-op for them.
|
|
55
|
+
*/
|
|
56
|
+
function assertAwsEmailOnly(env, kind) {
|
|
57
|
+
if ((env === 'stage' || env === 'prod') && kind !== 'email') {
|
|
58
|
+
throw new Error('stage/prod 仅支持 email 标识;手机号/userId 解析仅 cn-prod 可用');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
9
61
|
// cn-prod sells the CNY-priced -cn plans (P12); the bare USD plan ids also
|
|
10
62
|
// exist in the cn DB, so a per-env whitelist (not billing-side validation)
|
|
11
63
|
// is what prevents accidentally granting a USD-priced plan to a CN user.
|
|
@@ -16,7 +68,7 @@ const PLANS_BY_ENV = {
|
|
|
16
68
|
};
|
|
17
69
|
function parseArgs(args) {
|
|
18
70
|
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
19
|
-
console.log(`Usage: optima-grant-subscription <email> [options]
|
|
71
|
+
console.log(`Usage: optima-grant-subscription <email|phone|userId> [options]
|
|
20
72
|
|
|
21
73
|
Options:
|
|
22
74
|
--plan <id> Plan: trial, starter, pro, enterprise (default: pro)
|
|
@@ -26,7 +78,7 @@ Options:
|
|
|
26
78
|
-h, --help Show this help`);
|
|
27
79
|
process.exit(0);
|
|
28
80
|
}
|
|
29
|
-
const
|
|
81
|
+
const identifier = args[0];
|
|
30
82
|
let plan = null;
|
|
31
83
|
let months = 1;
|
|
32
84
|
let env = 'stage';
|
|
@@ -52,21 +104,49 @@ Options:
|
|
|
52
104
|
console.error('Months must be >= 1');
|
|
53
105
|
process.exit(1);
|
|
54
106
|
}
|
|
55
|
-
return {
|
|
107
|
+
return { identifier, plan, months, env };
|
|
56
108
|
}
|
|
57
109
|
async function main() {
|
|
58
|
-
const {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
110
|
+
const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
|
|
111
|
+
const kind = classifyIdentifier(identifier);
|
|
112
|
+
assertAwsEmailOnly(env, kind);
|
|
113
|
+
console.log(`\n🎁 Granting ${plan} subscription to ${identifier} (${kind}) for ${months} month(s) [${env.toUpperCase()}]\n`);
|
|
114
|
+
// identity is the verified target account to print on success. AWS keeps the
|
|
115
|
+
// pre-change behavior (email-only, no internal HTTP reverse-verify); only
|
|
116
|
+
// cn-prod runs the full classify→resolve→getUserById→phone-assert防呆 path.
|
|
62
117
|
let userId;
|
|
118
|
+
let identity;
|
|
63
119
|
if (env === 'cn-prod') {
|
|
64
|
-
|
|
120
|
+
// cn-prod has no SSH tunnel into the Aliyun RDS — resolve via user-auth,
|
|
121
|
+
// and its dev-skills token carries internal:users:write for the lookups.
|
|
122
|
+
if (kind === 'userId') {
|
|
123
|
+
userId = identifier;
|
|
124
|
+
}
|
|
125
|
+
else if (kind === 'phone') {
|
|
126
|
+
userId = await (0, billing_http_1.resolveUserIdByPhone)(env, identifier);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
userId = await (0, billing_http_1.resolveUserIdByEmail)(env, identifier);
|
|
130
|
+
}
|
|
131
|
+
// Reverse-verify: fetch and loudly print the target account identity
|
|
132
|
+
// before granting, so a wrong userId is caught by eye (gateway#923).
|
|
133
|
+
const acct = await (0, billing_http_1.getUserById)(env, userId);
|
|
134
|
+
console.log(`🎯 目标账号: userId=${userId} 手机=${acct.phone || '(无)'} email=${acct.email || '(无)'} 当前plan=${acct.current_plan || '?'}`);
|
|
135
|
+
// Hard assertion: a phone-input grant must land on an account whose phone
|
|
136
|
+
// matches. Runs BEFORE callBilling — a mismatch aborts without granting.
|
|
137
|
+
if (kind === 'phone') {
|
|
138
|
+
assertPhoneMatch(identifier, acct.phone);
|
|
139
|
+
}
|
|
140
|
+
identity = { phone: acct.phone, email: acct.email };
|
|
65
141
|
}
|
|
66
142
|
else {
|
|
143
|
+
// AWS (stage/prod): email-only, resolved via the RDS SSH tunnel. No
|
|
144
|
+
// internal HTTP reverse-verify (token lacks internal:users:write → 403).
|
|
67
145
|
const infisicalConfig = (0, db_utils_1.getInfisicalConfig)();
|
|
68
146
|
const token = (0, db_utils_1.getInfisicalToken)(infisicalConfig);
|
|
69
|
-
userId = await (0, db_utils_1.resolveUserId)(
|
|
147
|
+
userId = await (0, db_utils_1.resolveUserId)(identifier, env, infisicalConfig, token);
|
|
148
|
+
console.log(`🎯 目标账号: userId=${userId} email=${identifier}`);
|
|
149
|
+
identity = { phone: null, email: identifier };
|
|
70
150
|
}
|
|
71
151
|
const { body } = await (0, billing_http_1.callBilling)(env, 'POST', '/api/billing/admin/grant-subscription', { userId, planId: plan, months });
|
|
72
152
|
console.log(`✓ Subscription ${body.subscriptionId} (${body.planId})`);
|
|
@@ -74,9 +154,13 @@ async function main() {
|
|
|
74
154
|
console.log(`✓ Token limits: session ${body.sessionTokenLimit.toLocaleString()} / weekly ${body.weeklyTokenLimit.toLocaleString()}`);
|
|
75
155
|
if (body.warning)
|
|
76
156
|
console.log(`⚠️ ${body.warning}`);
|
|
77
|
-
console.log(`\n✅ Done!
|
|
157
|
+
console.log(`\n✅ Done! 用户 userId=${userId} 手机=${identity.phone || '(无)'} email=${identity.email || '(无)'} now has ${body.planId} until ${body.expiresAt}\n`);
|
|
158
|
+
}
|
|
159
|
+
// Only run the CLI flow when invoked directly — being require()'d (e.g. by the
|
|
160
|
+
// unit tests for classifyIdentifier/assertPhoneMatch) must not trigger main().
|
|
161
|
+
if (require.main === module) {
|
|
162
|
+
main().catch(error => {
|
|
163
|
+
console.error('\n❌ Error:', error.message);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
});
|
|
78
166
|
}
|
|
79
|
-
main().catch(error => {
|
|
80
|
-
console.error('\n❌ Error:', error.message);
|
|
81
|
-
process.exit(1);
|
|
82
|
-
});
|