@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.
@@ -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-cn/billing-cn.optima.chat),email 查找走 user-auth internal lookup API(无 SSH 隧道)。金额输入仍是 USD($1 = 700 积分,与 CN ¥1 = 100 积分同一账本单位)。例:`optima-grant-balance user@example.com --amount 1 --env cn-prod`
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-cn/billing-cn.optima.chat),email 查找经 user-auth internal lookup API(无 SSH 隧道)。金额输入仍是 USD($1 = 700 积分 = ¥7 档积分口径一致)。
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
- clientId: 'commerce-cli-cn-prod',
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
- console.log(`✓ Merchant registered successfully (ID: ${result.user_id})`);
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 { callBilling, resolveUserIdByEmail, validateEnvCnProd } from './billing-http';
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[]): { email: string; plan: string; months: number; env: 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 email = args[0];
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 { email, plan, months, env };
109
+ return { identifier, plan, months, env };
52
110
  }
53
111
 
54
112
  async function main() {
55
- const { email, plan, months, env } = parseArgs(process.argv.slice(2));
113
+ const { identifier, plan, months, env } = parseArgs(process.argv.slice(2));
56
114
 
57
- console.log(`\n🎁 Granting ${plan} subscription to ${email} for ${months} month(s) [${env.toUpperCase()}]\n`);
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
- // cn-prod has no SSH tunnel into the Aliyun RDS resolve via user-auth's
60
- // internal lookup API instead of the direct SQL path.
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
- userId = await resolveUserIdByEmail(env, email);
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(email, env, infisicalConfig, token);
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(`\n✅ Done! ${email} now has ${body.planId} until ${body.expiresAt}\n`);
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
- main().catch(error => {
89
- console.error('\n❌ Error:', error.message);
90
- process.exit(1);
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
- clientId: 'commerce-cli-cn-prod',
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
- console.log(`✓ Merchant registered successfully (ID: ${result.user_id})`);
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 email = args[0];
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 { email, plan, months, env };
107
+ return { identifier, plan, months, env };
56
108
  }
57
109
  async function main() {
58
- const { email, plan, months, env } = parseArgs(process.argv.slice(2));
59
- console.log(`\n🎁 Granting ${plan} subscription to ${email} for ${months} month(s) [${env.toUpperCase()}]\n`);
60
- // cn-prod has no SSH tunnel into the Aliyun RDS — resolve via user-auth's
61
- // internal lookup API instead of the direct SQL path.
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
- userId = await (0, billing_http_1.resolveUserIdByEmail)(env, email);
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)(email, env, infisicalConfig, token);
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! ${email} now has ${body.planId} until ${body.expiresAt}\n`);
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
- });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@optima-chat/dev-skills",
3
- "version": "0.7.41",
3
+ "version": "0.7.43",
4
4
  "description": "Claude Code Skills for Optima development team - cross-environment collaboration tools",
5
5
  "main": "index.js",
6
6
  "bin": {