@optima-chat/dev-skills 0.7.36 → 0.7.38

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.
@@ -50,6 +50,7 @@
50
50
  - `ci` - CI 持续集成环境(开发环境,默认)
51
51
  - `stage` - Stage 预发布环境(ECS Fargate)
52
52
  - `prod` - 生产环境(ECS Fargate)
53
+ - `cn-prod`(别名 `cn`)- 阿里云 cn-prod 环境(SAE,cn-beijing,经 buildbox 取日志)
53
54
 
54
55
  ## 示例
55
56
 
@@ -71,6 +72,7 @@
71
72
  - `ci` 或未指定 → 使用 SSH + Docker Compose(第 0 节,默认)
72
73
  - `stage` → 使用 AWS CloudWatch Logs - ECS(第 1 节)
73
74
  - `prod` → 使用 AWS CloudWatch Logs - ECS(第 2 节)
75
+ - `cn-prod` / `cn` → 阿里云 SAE,经 buildbox 调 `DescribeInstanceLog`(第 3 节)
74
76
 
75
77
  ### 0. CI 环境(environment = "ci" 或默认)
76
78
 
@@ -209,6 +211,59 @@ aws logs get-log-events --log-group-name /ecs/commerce-backend-prod --log-stream
209
211
 
210
212
  **注意**: `optima-store` 仅在 Stage 环境部署
211
213
 
214
+ ### 3. cn-prod 环境(environment = "cn-prod" 或 "cn")
215
+
216
+ **部署方式**: 阿里云 **SAE**(Serverless App Engine,cn-beijing),不是 ECS/CloudWatch。
217
+ **访问方式**: 经 buildbox(`root@47.94.105.163`,唯一装了 `aliyun-optima` profile + VPC 可达的机器)调 SAE OpenAPI。
218
+
219
+ > ⚠️ SAE **未配 SLS**,stdout 日志只能用 `DescribeInstanceLog` 取实例**当前缓冲**(非历史检索;重启/滚动后旧日志丢)。要历史检索需给 SAE 配 SLS。
220
+
221
+ **日志链**: service → AppId → GroupId → InstanceId → `DescribeInstanceLog`
222
+
223
+ **关键坑**(实测):
224
+ - API 是 RPC-style `aliyun sae DescribeInstanceLog`,ROA 路径是 `/pop/v1/sam/**instance**/describeInstanceLog`(不是 `/sam/app/...`,写错报 "can not find api by path")。
225
+ - buildbox 密码在 1P `op://Optima/gdmixcizjci5bvuu3nuvgg4ooe/password`(标题带括号必须用 item ID)。
226
+
227
+ **service → AppId 映射**(cn-prod,2026-06-01;新增/变动用 `aliyun sae ListApplications` 重查):
228
+
229
+ | service | AppId |
230
+ |---|---|
231
+ | `gateway-core` | `a08ce23f-3d3e-4d89-a2cf-53c8adba614e` |
232
+ | `agentic-chat` | `6e290c73-a646-43ef-9da5-ad0b2e7eff73` |
233
+ | `gw-admin` | `c6bc5a78-b27f-46e2-825a-eaa338c23645` |
234
+ | `user-auth` | `d6fbf9de-fed6-4165-978f-7b3a0a456acc` |
235
+ | `user-auth-admin` | `54093f99-37bf-4354-abd7-f6e76abdbcb6` |
236
+ | `optima-scout` | `f5bb7e82-e57c-4bd4-83ae-2c25f8d38647` |
237
+ | `optima-skills` | `55a63ee7-fb75-46e4-99f3-20854a699237` |
238
+ | `infisical` | `52b33fc6-b26d-495a-8885-225d38b3d42f` |
239
+
240
+ **完整脚本**(本地跑、经 buildbox 中转;改 `SERVICE`/`LINES`):
241
+ ```bash
242
+ SERVICE=gateway-core; LINES=80
243
+ BBPW=$(op-win read "op://Optima/gdmixcizjci5bvuu3nuvgg4ooe/password")
244
+ declare -A APPID=(
245
+ [gateway-core]=a08ce23f-3d3e-4d89-a2cf-53c8adba614e
246
+ [agentic-chat]=6e290c73-a646-43ef-9da5-ad0b2e7eff73
247
+ [gw-admin]=c6bc5a78-b27f-46e2-825a-eaa338c23645
248
+ [user-auth]=d6fbf9de-fed6-4165-978f-7b3a0a456acc
249
+ [user-auth-admin]=54093f99-37bf-4354-abd7-f6e76abdbcb6
250
+ [optima-scout]=f5bb7e82-e57c-4bd4-83ae-2c25f8d38647
251
+ [optima-skills]=55a63ee7-fb75-46e4-99f3-20854a699237
252
+ [infisical]=52b33fc6-b26d-495a-8885-225d38b3d42f )
253
+ AID=${APPID[$SERVICE]}
254
+ sshpass -p "$BBPW" ssh -o StrictHostKeyChecking=no root@47.94.105.163 "bash -s" <<REMOTE | tail -n $LINES
255
+ P="--region cn-beijing --profile aliyun-optima"
256
+ GID=\$(aliyun sae GET /pop/v1/sam/app/describeApplicationGroups --AppId $AID \$P 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin)['Data'][0]['GroupId'])")
257
+ IID=\$(aliyun sae GET /pop/v1/sam/app/describeApplicationInstances --AppId $AID --GroupId \$GID \$P 2>/dev/null | python3 -c "import sys,json;d=json.load(sys.stdin)['Data'];print((d if isinstance(d,list) else d['Instances'])[0]['InstanceId'])")
258
+ aliyun sae DescribeInstanceLog --InstanceId "\$IID" \$P 2>/dev/null | python3 -c "import sys,json;print(json.load(sys.stdin).get('Data',''))"
259
+ REMOTE
260
+ ```
261
+
262
+ **局限 / 提示**:
263
+ - 只取第 1 个 InstanceId;多副本要遍历 `Data` 里全部 instance。
264
+ - `agent-runtime` 不在 SAE(是 ECI 动态容器)——它的日志看 `gateway-core` 里 `eci-bridge` 的转发/错误行,或单独查对应 ECI。
265
+ - 过滤:把上面输出 `| grep -iE 'error|eci|fail'` 即可(例:排 agentic-chat 链路 ECI 失败就 grep `gateway-core` 日志的 `eci-bridge`)。
266
+
212
267
  ## 完整示例脚本
213
268
 
214
269
  ### Stage 环境
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: "discount-codes"
3
+ description: "当用户请求创建优惠码、发优惠码、生成折扣码、promo code、discount code、批量生成优惠码、停用优惠码、查看优惠码时,使用此技能。支持 Stage、Prod 两个环境。"
4
+ allowed-tools: ["Bash"]
5
+ ---
6
+
7
+ # 优惠码管理
8
+
9
+ 为 billing 结账(技能包 / 会员)创建和管理百分比优惠码。
10
+
11
+ ## 执行方式:使用 CLI 工具
12
+
13
+ 无论用户用 `/discount-codes` 还是直接请求,都使用 `optima-discount` CLI(thin HTTP client,调 billing admin 端点):
14
+
15
+ ```bash
16
+ optima-discount <subcommand> [options]
17
+ ```
18
+
19
+ ## 子命令
20
+
21
+ ```bash
22
+ # 建共享码:LAUNCH20 = 8 折,限 scout,6/30 截止,最多核销 100 次
23
+ optima-discount create --code LAUNCH20 --percent 20 --products scout --ends 2026-06-30 --max 100 --env prod
24
+
25
+ # 批量唯一码(每码用一次)—— 码写入本地文件,不打屏
26
+ optima-discount generate --count 100 --percent 50 --campaign partner-q3 --products scout --env prod
27
+ # → ./discount-codes-partner-q3-<ts>.txt
28
+
29
+ # 查看(按 campaign / code 过滤)
30
+ optima-discount list --campaign partner-q3 --env prod
31
+
32
+ # 停用
33
+ optima-discount disable --code LAUNCH20 --env prod
34
+ ```
35
+
36
+ ## 参数
37
+
38
+ | 参数 | 说明 |
39
+ |------|------|
40
+ | `--code` | 优惠码(存为大写) |
41
+ | `--percent` | 折扣百分比 1-100 |
42
+ | `--products` | 逗号分隔的 productKey;省略 = 所有商品 |
43
+ | `--starts` / `--ends` | 有效期(`YYYY-MM-DD` 或 ISO datetime) |
44
+ | `--max` | 总核销上限(省略 = 不限;`1` = 一次性) |
45
+ | `--campaign` | 分组标签(generate 时也是码前缀) |
46
+ | `--count` | generate 生成数量 1-1000 |
47
+ | `--limit` | list 返回上限(默认 500,最大 1000) |
48
+ | `--env` | `stage` / `prod`(默认 `stage`) |
49
+ | `--yes` | 跳过 prod 二次确认 |
50
+
51
+ ## 安全提醒
52
+
53
+ 1. **Stage 优先**:默认 `stage`。
54
+ 2. **Prod 谨慎**:`create` / `generate` / `disable` 在 prod 会要求输入 "yes" 确认(`--yes` 跳过)。
55
+ 3. **唯一码写文件**:`generate` 的码写入当前目录文件(`mode 0600`),不打屏——避免复制时被终端 padding 破坏。
56
+ 4. 依赖 billing 已部署对应环境(admin 端点存在)。越界值(如 `--percent 0`)由 billing 服务端校验返 400。
57
+
58
+ ## 相关
59
+
60
+ - `optima-product` — 管理付费商品(优惠码作用于其结账)
61
+ - `optima-query-db` — 查 `discount_codes` / `discount_redemptions` 表核对
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: "logs"
3
- description: "当用户请求查看日志、查看服务日志、排查问题、看看日志、检查日志、商品服务日志、后端日志、API日志、正式环境日志、生产环境日志、CI环境日志、开发环境日志时,使用此技能。支持 CI、Stage、Prod 三个环境的 commerce-backend、user-auth、agentic-chat、bi-backendsession-gateway 等服务。"
3
+ description: "当用户请求查看日志、查看服务日志、排查问题、看看日志、检查日志、商品服务日志、后端日志、API日志、正式环境日志、生产环境日志、CI环境日志、开发环境日志、阿里云日志、cn-prod 日志、SAE 日志时,使用此技能。支持 CI、Stage、Prod(AWS ECS/CloudWatch)以及 cn-prod(阿里云 SAE,经 buildbox)四个环境的 commerce-backend、user-auth、agentic-chat、gateway-coreoptima-scout、optima-skills 等服务。"
4
4
  allowed-tools: ["Bash", "SlashCommand"]
5
5
  ---
6
6
 
@@ -175,6 +175,19 @@ INFO - Database query took 3200ms: SELECT * FROM products WHERE...
175
175
  - 通过 CloudWatch Logs 查看
176
176
  - 日志保留 7 天
177
177
 
178
+ ### cn-prod 环境(阿里云)
179
+
180
+ ```
181
+ /logs gateway-core 100 cn-prod
182
+ /logs agentic-chat 80 cn
183
+ ```
184
+
185
+ **特点**:
186
+ - 阿里云 **SAE**(cn-beijing),不是 AWS
187
+ - 经 buildbox(`root@47.94.105.163`)调 SAE `DescribeInstanceLog`
188
+ - SAE 未配 SLS → 只取实例**当前 stdout 缓冲**(非历史检索)
189
+ - 技术细节 + service→AppId 映射见 `/logs --help`(第 3 节)
190
+
178
191
  ## 支持的服务列表
179
192
 
180
193
  | 服务 | 说明 | CI | Stage | Prod |
package/README.md CHANGED
@@ -42,6 +42,7 @@ Optima Dev Skills 让 Claude Code 能够直接在 **CI、Stage、Prod** 三个
42
42
  - **generate-test-token** - 生成测试 Access Token 用于 API 测试
43
43
  - **use-commerce-cli** - 使用 Commerce CLI 管理电商店铺
44
44
  - **read-code** - 阅读 Optima-Chat 组织下任意仓库的代码
45
+ - **discount-codes** - 创建/生成/查看/停用 billing 优惠码(Stage/Prod)
45
46
 
46
47
  ## 👤 用户故事
47
48
 
@@ -103,6 +104,7 @@ Claude:
103
104
  | `optima-query-db` | 数据库查询工具 | `optima-query-db user-auth "SELECT COUNT(*) FROM users" prod` |
104
105
  | `optima-show-env` | 查看服务环境变量 | `optima-show-env commerce-backend stage --filter DATABASE` |
105
106
  | `optima-generate-test-token` | 生成测试 token | `optima-generate-test-token --business-name "测试店铺"` |
107
+ | `optima-discount` | 优惠码管理 | `optima-discount create --code LAUNCH20 --percent 20 --env stage` |
106
108
 
107
109
  **特点**:
108
110
  - ✅ 支持 CI、Stage、Prod 三个环境(query-db)
package/bin/cli.js CHANGED
@@ -32,6 +32,7 @@ switch (command) {
32
32
  log('Available Commands:', 'yellow');
33
33
  log(' optima-query-db <service> "<sql>" [env] Query database', 'cyan');
34
34
  log(' optima-show-env <service> [env] Show service env vars', 'cyan');
35
+ log(' optima-verify-health <service> [--env cn|prod|all] Probe L1-L5 上线健康', 'cyan');
35
36
  log(' optima-generate-test-token [--env production] Generate test token', 'cyan');
36
37
  log(' optima-grant-balance <email> --amount <usd> [--env] Grant USD wallet balance', 'cyan');
37
38
  log(' optima-grant-subscription <email> --plan <p> [--env] Grant subscription', 'cyan');
@@ -1,5 +1,6 @@
1
- import { execSync } from 'child_process';
1
+ import { execSync, spawn } from 'child_process';
2
2
  import * as fs from 'fs';
3
+ import * as os from 'os';
3
4
 
4
5
  // ─── Types ──────────────────────────────────────────────────────────────────
5
6
  export interface InfisicalConfig { url: string; clientId: string; clientSecret: string; projectId: string }
@@ -18,6 +19,11 @@ export const RDS_HOSTS: Record<string, string> = {
18
19
  prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com',
19
20
  };
20
21
 
22
+ // Shared bastion (optima-bi-data EC2, shared-services stack). Reached via SSM —
23
+ // no public port 22 dependency, so it is immune to the MaxStartups throttling that
24
+ // the open-to-world sshd suffers under internet scan load. SSH path kept as fallback.
25
+ const AWS_REGION = 'ap-southeast-1';
26
+ const BASTION_INSTANCE_ID = 'i-03286fb0a9ce7e6b1';
21
27
  const EC2_HOST = '3.0.210.113';
22
28
 
23
29
  // ─── SQL escaping ───────────────────────────────────────────────────────────
@@ -114,6 +120,75 @@ export function setupSSHTunnel(dbHost: string, localPort: number): void {
114
120
  );
115
121
  }
116
122
 
123
+ /** Block the current (sync) call for `ms` without spawning a subprocess. */
124
+ function sleepSync(ms: number): void {
125
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
126
+ }
127
+
128
+ function assertSSMPrereqs(): void {
129
+ for (const [bin, hint] of [
130
+ ['session-manager-plugin', 'https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html'],
131
+ ['aws', 'AWS CLI v2: https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html'],
132
+ ] as const) {
133
+ try { execSync(`command -v ${bin}`, { stdio: 'ignore' }); }
134
+ catch { throw new Error(`${bin} not found — required for the SSM DB tunnel. Install: ${hint}\n(or set OPTIMA_DB_TUNNEL=ssh to fall back to the legacy SSH tunnel)`); }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Open a local→RDS tunnel through the shared bastion using SSM port forwarding.
140
+ * No public SSH port is touched. A healthy existing tunnel on the port is reused
141
+ * (the ~2s SSM cold start is paid once, then every query is just a round-trip).
142
+ */
143
+ export function setupSSMTunnel(dbHost: string, localPort: number): void {
144
+ let portInUse = false;
145
+ try { execSync(`lsof -ti:${localPort}`, { stdio: 'ignore' }); portInUse = true; } catch { /* free */ }
146
+
147
+ if (portInUse) {
148
+ if (isTunnelHealthy(localPort)) return; // warm reuse — no cold start
149
+ console.log(`! Tunnel on port ${localPort} not responding (zombie), replacing...`);
150
+ killOrphanTunnel(localPort);
151
+ }
152
+
153
+ assertSSMPrereqs();
154
+ console.log(`Creating SSM tunnel: localhost:${localPort} -> ${BASTION_INSTANCE_ID} -> ${dbHost}:5432`);
155
+
156
+ const logFile = `${os.tmpdir()}/optima-ssm-tunnel-${localPort}.log`;
157
+ const out = fs.openSync(logFile, 'w');
158
+ const child = spawn('aws', [
159
+ 'ssm', 'start-session',
160
+ '--region', AWS_REGION,
161
+ '--target', BASTION_INSTANCE_ID,
162
+ '--document-name', 'AWS-StartPortForwardingSessionToRemoteHost',
163
+ '--parameters', `host=${dbHost},portNumber=5432,localPortNumber=${localPort}`,
164
+ ], { detached: true, stdio: ['ignore', out, out] });
165
+ child.unref();
166
+
167
+ // Wait until the remote leg actually forwards (pg_isready probes RDS through the
168
+ // tunnel). Local port binds instantly; the remote hop needs ~1-2s to come up.
169
+ const deadlineMs = Date.now() + 20000;
170
+ while (Date.now() < deadlineMs) {
171
+ if (isTunnelHealthy(localPort)) { console.log(`✓ SSM tunnel ready on port ${localPort}`); return; }
172
+ sleepSync(500);
173
+ }
174
+
175
+ let tail = '(no log)';
176
+ try { tail = fs.readFileSync(logFile, 'utf-8').split('\n').slice(-8).join('\n'); } catch { /* ignore */ }
177
+ throw new Error(`SSM tunnel on port ${localPort} did not become ready within 20s.\n--- ${logFile} ---\n${tail}`);
178
+ }
179
+
180
+ /**
181
+ * Open a DB tunnel through the shared bastion. Defaults to SSM (no public port 22);
182
+ * set OPTIMA_DB_TUNNEL=ssh to fall back to the legacy SSH tunnel.
183
+ */
184
+ export function setupTunnel(dbHost: string, localPort: number): void {
185
+ if ((process.env.OPTIMA_DB_TUNNEL || 'ssm').toLowerCase() === 'ssh') {
186
+ setupSSHTunnel(dbHost, localPort);
187
+ } else {
188
+ setupSSMTunnel(dbHost, localPort);
189
+ }
190
+ }
191
+
117
192
  // ─── psql ───────────────────────────────────────────────────────────────────
118
193
  function findPsqlPath(): string {
119
194
  try {
@@ -142,8 +217,7 @@ export async function connectAuthDB(env: string, infisicalConfig: InfisicalConfi
142
217
  const host = RDS_HOSTS[env as 'stage' | 'prod'];
143
218
  const port = env === 'stage' ? 15432 : 15433;
144
219
 
145
- setupSSHTunnel(host, port);
146
- await new Promise(r => setTimeout(r, 1000));
220
+ setupTunnel(host, port);
147
221
 
148
222
  const conn: DBConnection = { host: 'localhost', port, user: secrets['AUTH_DB_USER'], password: secrets['AUTH_DB_PASSWORD'], database: 'optima_auth' };
149
223
  return { query: (sql: string) => queryDB(conn, sql) };
@@ -158,8 +232,7 @@ export async function connectBillingDB(env: string, infisicalConfig: InfisicalCo
158
232
 
159
233
  const parsed = parseDatabaseUrl(dbUrl);
160
234
  const port = env === 'stage' ? 15434 : 15435;
161
- setupSSHTunnel(parsed.host, port);
162
- await new Promise(r => setTimeout(r, 1000));
235
+ setupTunnel(parsed.host, port);
163
236
 
164
237
  const conn: DBConnection = { host: 'localhost', port, user: parsed.user, password: parsed.password, database: parsed.database };
165
238
  return { query: (sql: string) => queryDB(conn, sql) };
@@ -0,0 +1,79 @@
1
+ import { callBilling, validateEnv } from '../billing-http';
2
+ import { confirmIfProd } from '../confirm-prompt';
3
+
4
+ interface CreateArgs {
5
+ code: string;
6
+ percentOff: number;
7
+ productKeys?: string[];
8
+ startsAt?: string;
9
+ endsAt?: string;
10
+ maxRedemptions?: number;
11
+ campaign?: string;
12
+ env: string;
13
+ yes: boolean;
14
+ }
15
+
16
+ /** Normalize a date/datetime arg to a full ISO datetime string (billing requires z.iso.datetime). */
17
+ function toIso(v: string): string {
18
+ const d = new Date(v);
19
+ if (isNaN(d.getTime())) throw new Error(`Invalid date: ${v} (use YYYY-MM-DD or ISO datetime)`);
20
+ return d.toISOString();
21
+ }
22
+
23
+ function parseArgs(argv: string[]): CreateArgs {
24
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
25
+ console.log(`Usage: optima-discount create --code <CODE> --percent <1-100> [options]
26
+
27
+ Required:
28
+ --code <CODE> Promo code (stored uppercased)
29
+ --percent <1-100> Percentage off
30
+
31
+ Optional:
32
+ --products <a,b,...> Limit to these productKeys (default: all)
33
+ --starts <date> Valid-from (YYYY-MM-DD or ISO; default: immediately)
34
+ --ends <date> Valid-until
35
+ --max <N> Max total redemptions (default: unlimited; 1 = single-use)
36
+ --campaign <label> Grouping label
37
+ --env stage|prod (default: stage)
38
+ --yes Skip prod confirmation`);
39
+ process.exit(0);
40
+ }
41
+ const out: Partial<CreateArgs> = { env: 'stage', yes: false };
42
+ for (let i = 0; i < argv.length; i++) {
43
+ const a = argv[i];
44
+ const next = argv[i + 1];
45
+ switch (a) {
46
+ case '--code': out.code = next; i++; break;
47
+ case '--percent': out.percentOff = parseInt(next, 10); i++; break;
48
+ case '--products': if (!next) throw new Error('--products requires a value'); out.productKeys = next.split(',').map((s) => s.trim()).filter(Boolean); i++; break;
49
+ case '--starts': out.startsAt = toIso(next); i++; break;
50
+ case '--ends': out.endsAt = toIso(next); i++; break;
51
+ case '--max': out.maxRedemptions = parseInt(next, 10); i++; break;
52
+ case '--campaign': out.campaign = next; i++; break;
53
+ case '--env': out.env = next; i++; break;
54
+ case '--yes': out.yes = true; break;
55
+ default: throw new Error(`Unknown arg: ${a}`);
56
+ }
57
+ }
58
+ if (!out.code) throw new Error('--code required');
59
+ if (out.percentOff === undefined || isNaN(out.percentOff)) throw new Error('--percent required (1-100)');
60
+ return out as CreateArgs;
61
+ }
62
+
63
+ export async function runCreate(argv: string[]): Promise<void> {
64
+ const args = parseArgs(argv);
65
+ validateEnv(args.env);
66
+ await confirmIfProd(args.env, `Create discount code ${args.code.toUpperCase()} (${args.percentOff}% off)`, args.yes);
67
+
68
+ const body: Record<string, unknown> = { code: args.code, percentOff: args.percentOff };
69
+ if (args.productKeys) body.productKeys = args.productKeys;
70
+ if (args.startsAt) body.startsAt = args.startsAt;
71
+ if (args.endsAt) body.endsAt = args.endsAt;
72
+ if (args.maxRedemptions !== undefined) body.maxRedemptions = args.maxRedemptions;
73
+ if (args.campaign) body.campaign = args.campaign;
74
+
75
+ console.log(`\n🎟️ Creating discount code on ${args.env.toUpperCase()}...`);
76
+ const res = await callBilling(args.env, 'POST', '/api/billing/admin/discount-codes', body);
77
+ console.log(`✓ Created (HTTP ${res.status}):`);
78
+ console.log(JSON.stringify(res.body, null, 2));
79
+ }
@@ -0,0 +1,44 @@
1
+ import { callBilling, validateEnv } from '../billing-http';
2
+ import { confirmIfProd } from '../confirm-prompt';
3
+
4
+ interface DisableArgs {
5
+ code: string;
6
+ env: string;
7
+ yes: boolean;
8
+ }
9
+
10
+ function parseArgs(argv: string[]): DisableArgs {
11
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
12
+ console.log(`Usage: optima-discount disable --code <CODE> [options]
13
+
14
+ Required:
15
+ --code <CODE>
16
+
17
+ Optional:
18
+ --env stage|prod (default: stage)
19
+ --yes Skip prod confirmation`);
20
+ process.exit(0);
21
+ }
22
+ const out: Partial<DisableArgs> = { env: 'stage', yes: false };
23
+ for (let i = 0; i < argv.length; i++) {
24
+ const a = argv[i];
25
+ const next = argv[i + 1];
26
+ switch (a) {
27
+ case '--code': out.code = next; i++; break;
28
+ case '--env': out.env = next; i++; break;
29
+ case '--yes': out.yes = true; break;
30
+ default: throw new Error(`Unknown arg: ${a}`);
31
+ }
32
+ }
33
+ if (!out.code) throw new Error('--code required');
34
+ return out as DisableArgs;
35
+ }
36
+
37
+ export async function runDisable(argv: string[]): Promise<void> {
38
+ const args = parseArgs(argv);
39
+ validateEnv(args.env);
40
+ await confirmIfProd(args.env, `Disable discount code ${args.code.toUpperCase()}`, args.yes);
41
+ const res = await callBilling(args.env, 'PATCH', `/api/billing/admin/discount-codes/${encodeURIComponent(args.code)}`, { status: 'DISABLED' });
42
+ console.log(`✓ Disabled (HTTP ${res.status}):`);
43
+ console.log(JSON.stringify(res.body, null, 2));
44
+ }
@@ -0,0 +1,84 @@
1
+ import * as fs from 'fs';
2
+ import { callBilling, validateEnv } from '../billing-http';
3
+ import { confirmIfProd } from '../confirm-prompt';
4
+
5
+ interface GenArgs {
6
+ count: number;
7
+ percentOff: number;
8
+ campaign: string;
9
+ productKeys?: string[];
10
+ startsAt?: string;
11
+ endsAt?: string;
12
+ env: string;
13
+ yes: boolean;
14
+ }
15
+
16
+ function toIso(v: string): string {
17
+ const d = new Date(v);
18
+ if (isNaN(d.getTime())) throw new Error(`Invalid date: ${v} (use YYYY-MM-DD or ISO datetime)`);
19
+ return d.toISOString();
20
+ }
21
+
22
+ function parseArgs(argv: string[]): GenArgs {
23
+ if (argv.length === 0 || argv[0] === '-h' || argv[0] === '--help') {
24
+ console.log(`Usage: optima-discount generate --count <N> --percent <1-100> --campaign <label> [options]
25
+
26
+ Generates N unique single-use codes (maxRedemptions=1). Codes are written to a
27
+ local file (NOT printed) to keep them copy-paste clean.
28
+
29
+ Required:
30
+ --count <N> How many codes (1-1000)
31
+ --percent <1-100> Percentage off
32
+ --campaign <label> Grouping label (also the code prefix)
33
+
34
+ Optional:
35
+ --products <a,b,...> Limit to these productKeys
36
+ --starts <date> Valid-from (YYYY-MM-DD or ISO)
37
+ --ends <date> Valid-until
38
+ --env stage|prod (default: stage)
39
+ --yes Skip prod confirmation`);
40
+ process.exit(0);
41
+ }
42
+ const out: Partial<GenArgs> = { env: 'stage', yes: false };
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const a = argv[i];
45
+ const next = argv[i + 1];
46
+ switch (a) {
47
+ case '--count': out.count = parseInt(next, 10); i++; break;
48
+ case '--percent': out.percentOff = parseInt(next, 10); i++; break;
49
+ case '--campaign': out.campaign = next; i++; break;
50
+ case '--products': if (!next) throw new Error('--products requires a value'); out.productKeys = next.split(',').map((s) => s.trim()).filter(Boolean); i++; break;
51
+ case '--starts': out.startsAt = toIso(next); i++; break;
52
+ case '--ends': out.endsAt = toIso(next); i++; break;
53
+ case '--env': out.env = next; i++; break;
54
+ case '--yes': out.yes = true; break;
55
+ default: throw new Error(`Unknown arg: ${a}`);
56
+ }
57
+ }
58
+ if (out.count === undefined || isNaN(out.count)) throw new Error('--count required (1-1000)');
59
+ if (out.percentOff === undefined || isNaN(out.percentOff)) throw new Error('--percent required (1-100)');
60
+ if (!out.campaign) throw new Error('--campaign required');
61
+ return out as GenArgs;
62
+ }
63
+
64
+ export async function runGenerate(argv: string[]): Promise<void> {
65
+ const args = parseArgs(argv);
66
+ validateEnv(args.env);
67
+ await confirmIfProd(args.env, `Generate ${args.count} unique discount codes (${args.percentOff}% off, campaign ${args.campaign})`, args.yes);
68
+
69
+ const body: Record<string, unknown> = { count: args.count, percentOff: args.percentOff, campaign: args.campaign };
70
+ if (args.productKeys) body.productKeys = args.productKeys;
71
+ if (args.startsAt) body.startsAt = args.startsAt;
72
+ if (args.endsAt) body.endsAt = args.endsAt;
73
+
74
+ console.log(`\n🎟️ Generating ${args.count} codes on ${args.env.toUpperCase()}...`);
75
+ const res = await callBilling<{ codes: string[] }>(args.env, 'POST', '/api/billing/admin/discount-codes/batch', body);
76
+ const codes = res.body.codes ?? [];
77
+
78
+ const safeCampaign = args.campaign.replace(/[^A-Za-z0-9_-]/g, '');
79
+ const file = `./discount-codes-${safeCampaign}-${Date.now()}.txt`;
80
+ // mode 0o600: single-use codes are sensitive-ish; written to the operator's CWD.
81
+ fs.writeFileSync(file, codes.join('\n') + '\n', { encoding: 'utf-8', mode: 0o600 });
82
+ console.log(`✓ Generated ${codes.length} codes (HTTP ${res.status}). Written to: ${file}`);
83
+ console.log(` (codes are in the file, not printed, to keep them copy-paste clean)`);
84
+ }
@@ -0,0 +1,46 @@
1
+ import { callBilling, validateEnv } from '../billing-http';
2
+
3
+ interface ListArgs {
4
+ campaign?: string;
5
+ code?: string;
6
+ limit?: number;
7
+ env: string;
8
+ }
9
+
10
+ function parseArgs(argv: string[]): ListArgs {
11
+ if (argv[0] === '-h' || argv[0] === '--help') {
12
+ console.log(`Usage: optima-discount list [options]
13
+
14
+ Optional:
15
+ --campaign <label> Filter by campaign
16
+ --code <CODE> Filter by exact code
17
+ --limit <N> Max rows (default 500, max 1000)
18
+ --env stage|prod (default: stage)`);
19
+ process.exit(0);
20
+ }
21
+ const out: Partial<ListArgs> = { env: 'stage' };
22
+ for (let i = 0; i < argv.length; i++) {
23
+ const a = argv[i];
24
+ const next = argv[i + 1];
25
+ switch (a) {
26
+ case '--campaign': out.campaign = next; i++; break;
27
+ case '--code': out.code = next; i++; break;
28
+ case '--limit': { const n = parseInt(next, 10); if (isNaN(n)) throw new Error('--limit requires a number'); out.limit = n; i++; break; }
29
+ case '--env': out.env = next; i++; break;
30
+ default: throw new Error(`Unknown arg: ${a}`);
31
+ }
32
+ }
33
+ return out as ListArgs;
34
+ }
35
+
36
+ export async function runList(argv: string[]): Promise<void> {
37
+ const args = parseArgs(argv);
38
+ validateEnv(args.env);
39
+ const qs = new URLSearchParams();
40
+ if (args.campaign) qs.set('campaign', args.campaign);
41
+ if (args.code) qs.set('code', args.code);
42
+ if (args.limit !== undefined) qs.set('limit', String(args.limit));
43
+ const suffix = qs.toString() ? `?${qs.toString()}` : '';
44
+ const res = await callBilling(args.env, 'GET', `/api/billing/admin/discount-codes${suffix}`);
45
+ console.log(JSON.stringify(res.body, null, 2));
46
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCreate } from './discount/create';
4
+ import { runGenerate } from './discount/generate';
5
+ import { runList } from './discount/list';
6
+ import { runDisable } from './discount/disable';
7
+
8
+ function printHelp() {
9
+ console.log(`Usage: optima-discount <subcommand> [options]
10
+
11
+ Subcommands:
12
+ create Create one discount code (shared or single-use)
13
+ generate Generate N unique single-use codes (written to a file)
14
+ list List discount codes (filter by campaign/code)
15
+ disable Disable a discount code
16
+
17
+ Run 'optima-discount <subcommand> --help' for subcommand-specific options.`);
18
+ }
19
+
20
+ async function main() {
21
+ const [, , subcommand, ...rest] = process.argv;
22
+ if (!subcommand || subcommand === '-h' || subcommand === '--help') {
23
+ printHelp();
24
+ process.exit(0);
25
+ }
26
+ switch (subcommand) {
27
+ case 'create': await runCreate(rest); break;
28
+ case 'generate': await runGenerate(rest); break;
29
+ case 'list': await runList(rest); break;
30
+ case 'disable': await runDisable(rest); break;
31
+ default:
32
+ console.error(`Unknown subcommand: ${subcommand}`);
33
+ printHelp();
34
+ process.exit(1);
35
+ }
36
+ }
37
+
38
+ main().catch((err) => {
39
+ console.error(err.message);
40
+ process.exit(1);
41
+ });
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { execSync } from 'child_process';
4
4
  import * as fs from 'fs';
5
+ import { setupTunnel } from './db-utils';
5
6
 
6
7
  interface InfisicalConfig {
7
8
  url: string;
@@ -96,9 +97,6 @@ const RDS_HOSTS = {
96
97
  prod: 'optima-prod-postgres.ctg866o0ehac.ap-southeast-1.rds.amazonaws.com'
97
98
  };
98
99
 
99
- // 统一使用 BI Data ARM Host 作为跳板机
100
- const EC2_HOST = '3.0.210.113';
101
-
102
100
  function parseDatabaseUrl(url: string): { user: string; password: string; host: string; port: number; database: string } {
103
101
  // postgresql://user:password@host:port/database?params
104
102
  const match = url.match(/^postgresql:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/);
@@ -148,29 +146,6 @@ function getInfisicalSecrets(config: InfisicalConfig, token: string, environment
148
146
  return secrets;
149
147
  }
150
148
 
151
- function setupSSHTunnel(ec2Host: string, dbHost: string, localPort: number): void {
152
- // 检查是否已有隧道
153
- try {
154
- execSync(`lsof -ti:${localPort}`, { stdio: 'ignore' });
155
- console.log(`✓ SSH tunnel already exists on port ${localPort}`);
156
- return;
157
- } catch {
158
- // 端口未占用,创建隧道
159
- }
160
-
161
- const sshKeyPath = `${process.env.HOME}/.ssh/optima-ec2-key`;
162
- if (!fs.existsSync(sshKeyPath)) {
163
- throw new Error(`SSH key not found: ${sshKeyPath}. Please obtain optima-ec2-key from xbfool.`);
164
- }
165
-
166
- console.log(`Creating SSH tunnel: localhost:${localPort} -> ${ec2Host} -> ${dbHost}:5432`);
167
- execSync(
168
- `ssh -i ${sshKeyPath} -f -N -o StrictHostKeyChecking=no -L ${localPort}:${dbHost}:5432 ec2-user@${ec2Host}`,
169
- { stdio: 'inherit' }
170
- );
171
- console.log(`✓ SSH tunnel established on port ${localPort}`);
172
- }
173
-
174
149
  function findPsqlPath(): string {
175
150
  // 1. 优先从 PATH 中查找
176
151
  const whichCmd = process.platform === 'win32' ? 'where psql' : 'which psql';
@@ -323,10 +298,7 @@ async function main() {
323
298
 
324
299
  const localPort = environment === 'stage' ? 15432 : 15433;
325
300
 
326
- setupSSHTunnel(EC2_HOST, dbHost, localPort);
327
-
328
- // 等待隧道建立
329
- await new Promise(resolve => setTimeout(resolve, 1000));
301
+ setupTunnel(dbHost, localPort);
330
302
 
331
303
  const result = queryDatabase('localhost', localPort, dbUser, dbPassword, database, sql);
332
304
  console.log('\n' + result);