ソースを参照

feat: 技能通用化(在线安装+零依赖 OSS 客户端回退+去本地路径)

- oss-client-mini.mjs 新增:零依赖 OSS ListObjectsV2(与 ali-oss 一致的 V1 签名,continuation-token 子资源规则),无服务端项目时 oss.mjs 自动回退,行为与服务端模块一致(本机对照 146/131 对象一致)
- lib.mjs: env 回退链增加技能仓库根 .env 候选(clone 后 cp .env.example 填值)
- oss.mjs: 客户端两态切换(服务端模块优先,回退内置实现)
- README: 快速开始改 git clone 在线安装;安装为 Claude 技能含 macOS/Linux
- SKILL.md/.env.example: 凭据回退链通用化,清除全部本机绝对路径
Huccct 2 週間 前
コミット
32e5537f83
6 ファイル変更130 行追加39 行削除
  1. 5 4
      .env.example
  2. 27 21
      README.md
  3. 2 2
      SKILL.md
  4. 2 1
      scripts/lib.mjs
  5. 84 0
      scripts/oss-client-mini.mjs
  6. 10 11
      scripts/oss.mjs

+ 5 - 4
.env.example

@@ -1,14 +1,15 @@
-# 智能工牌运维技能环境模板(复制为 .env 或置入系统环境变量,不提交真实凭据)
-# 各脚本的凭据回退链:--env 参数 > SMARTBADGE_ENV_FILE 环境变量 > 自动发现(见 README)
+# 智能工牌运维技能环境模板:复制为仓库根 .env 并填入真实值(已被 .gitignore 忽略,不会提交)
+# 各脚本的凭据回退链:--env 参数 > SMARTBADGE_ENV_FILE 环境变量 > 技能仓库根 .env(本文件) > 自动发现服务端项目 server/.env
+# 注意:同机有服务端项目时 OSS 四项可留空(自动发现),仅 API 登录项必填。
 
-# 接口地址(线上默认; 本地开发用 http://localhost:3002)
+# 接口地址(线上默认; 本地开发用 http://localhost:3002)
 SMARTBADGE_API_BASE=https://recording.sh-lami.com
 
 # 登录账号
 SMARTBADGE_USERNAME=lami123456
 SMARTBADGE_PASSWORD=<填登录密码>
 
-# OSS 直连凭据(可选; 通常由自动发现的 server/.env 提供)
+# OSS 直连凭据(可选; 通常由自动发现的服务端项目 server/.env 提供; 无服务端项目的环境在此填写)
 # OSS_ACCESS_KEY_ID=xxx
 # OSS_ACCESS_KEY_SECRET=xxx
 # OSS_BUCKET=xxx

+ 27 - 21
README.md

@@ -4,21 +4,30 @@
 
 一个技能覆盖:巡检、故障自诊断、补跑/重试、OSS 同步、列表排查、报告解读、**月报/年报按需聚合分析**、线上部署核验。
 
-## 快速开始
+## 快速开始(在线安装)
 
 ```bash
-# 1. 安装(Windows): 把本目录链接到 Claude 全局技能目录
-cmd /c mklink /D "C:\Users\11729\.claude\skills\smartbadge-ops" "D:\1\skills-smartbadge-ops"
+# 1. 克隆技能仓库到本地
+git clone https://git.fmode.cn/lami/skills-smartbadge-ops.git
+cd skills-smartbadge-ops
 
-# 2. 登录(在任意位置,技能会自行定位)
-node <技能目录>/scripts/api.mjs login <密码>
+# 2. 配置凭据(仅登录密码必需;OSS 四项见下方密钥清单)
+cp .env.example .env          # Windows: copy .env.example .env
+# 编辑 .env,填写 SMARTBADGE_PASSWORD
 
-# 3. 一键核查
-node <技能目录>/scripts/check-online.mjs
+# 3. 登录并一键核查
+node scripts/api.mjs login <密码>
+node scripts/check-online.mjs
 ```
 
 之后 Claude 中直接提问即可(如「昨天管道有问题吗」「帮我出 8 月月报」),技能通过「确认门」交互流程逐步完成。
 
+### 安装为 Claude/通用 AI 技能(任选其一)
+
+- **Claude Code(Windows)**: `cmd /c mklink /D "%USERPROFILE%\.claude\skills\smartbadge-ops" "<本仓库绝对路径>"`
+- **Claude Code(macOS/Linux)**: `ln -s "<本仓库绝对路径>" ~/.claude/skills/smartbadge-ops`
+- **其他 AI**: 把仓库目录当作技能目录配置(含 `SKILL.md` 的目录即可)。
+
 ## 环境要求
 
 | 项 | 要求 |
@@ -44,29 +53,26 @@ node <技能目录>/scripts/check-online.mjs
 
 ## 凭据与安全
 
-- 本仓库**不含任何密钥**。登录 token 缓存 `.token`;OSS 凭据从项目 `server/.env` 自动发现。
-- 凭据定位回退链:`--env` 参数 > 环境变量 `SMARTBADGE_ENV_FILE` > 自动发现(本仓库同级/兄弟目录中名称含 `device|smartbadge` 且带 `server/.env` 的项目)> 已导出的 `OSS_*` 环境变量。
+- 本仓库**不含任何密钥**。登录 token 缓存 `.token`;OSS 凭据从服务端项目 `server/.env` 自动发现,或技能仓库根 `.env`,或进程环境变量
+- 凭据定位回退链:`--env` 参数 > 环境变量 `SMARTBADGE_ENV_FILE` > 技能仓库根 `.env`(cp `.env.example` 填值)> 自动发现(本仓库同级/兄弟目录中名称含 `device|smartbadge` 且带 `server/.env` 的项目)> 已导出的 `OSS_*` 环境变量。
 - 排查凭据定位:`node scripts/oss.mjs --find-env`。
+- OSS 客户端两态:同机有服务端项目(`server/services/oss-client.js`)时复用其模块;否则自动回退内置零依赖实现(`scripts/oss-client-mini.mjs`,与 ali-oss 签名行为一致)。
 
 ## 给 AI 的完整配置提示(安装时粘贴)
 
-**步骤 1**:确认技能已就位——本仓库即技能本体(名称 `smartbadge-ops`),已链接到 Claude 全局技能目录:
-
-```bash
-cmd /c mklink /D "%USERPROFILE%\.claude\skills\smartbadge-ops" "本仓库路径"
-```
+**步骤 1**:克隆技能仓库(名称 `smartbadge-ops`)并按上方方式链接到 AI 技能目录。
 
-**步骤 2**:密钥清单。Windows 本机使用时**只需登录密码**(脚本会自动发现同机项目的 `server/.env` 读取 OSS 凭据);跨机/无项目目录环境才需要手工导出 OSS 四项:
+**步骤 2**:密钥清单。**只需 `SMARTBADGE_PASSWORD` 登录密码**(脚本自动发现同机服务端项目的 `server/.env` 读取 OSS 凭据);无服务端项目的环境才需要 OSS 四项(填入技能仓库 `.env` 或系统环境变量):
 
 | 环境变量 | 必填 | 取值来源 | 说明 |
 |---|---|---|---|
 | `SMARTBADGE_API_BASE` | 否 | 线上 `https://recording.sh-lami.com` | 或本机后端 `http://localhost:3002` |
 | `SMARTBADGE_USERNAME` | 否 | 登录用户名 | 脚本默认 `lami123456` |
 | `SMARTBADGE_PASSWORD` | **是** | API 登录密码(与系统一致) | 唯一必需项 |
-| `OSS_ACCESS_KEY_ID` | 条件 | 本机免配:自动发现 `server/.env` | 仅跨机需导出 |
-| `OSS_ACCESS_KEY_SECRET` | 条件 | 同上 | 仅跨机需导出 |
-| `OSS_REGION` | 条件 | 同上(如 `oss-cn-shanghai`) | 仅跨机需导出 |
-| `OSS_BUCKET` | 条件 | 同上 | 仅跨机需导出 |
+| `OSS_ACCESS_KEY_ID` | 条件 | 自动发现服务端项目 `server/.env`;无则填入技能仓库 `.env` | 需 OSS 对账时才需要 |
+| `OSS_ACCESS_KEY_SECRET` | 条件 | 同上 | 需 OSS 对账时才需要 |
+| `OSS_REGION` | 条件 | 同上(如 `oss-cn-shanghai`) | 同上 |
+| `OSS_BUCKET` | 条件 | 同上 | 同上 |
 
 **步骤 3**:给 AI 的完整提示词模板(复制粘贴,替换 `<...>` 占位):
 
@@ -76,8 +82,8 @@ cmd /c mklink /D "%USERPROFILE%\.claude\skills\smartbadge-ops" "本仓库路径"
 SMARTBADGE_API_BASE=https://recording.sh-lami.com
 SMARTBADGE_USERNAME=lami123456
 SMARTBADGE_PASSWORD=<填 API 登录密码>
-# 机: OSS 四项免配,技能自动发现项目 server/.env
-# 跨机: 手工导出 OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET/OSS_REGION/OSS_BUCKET
+# 有服务端项目同机: OSS 四项免配,技能自动发现项目 server/.env
+# 无服务端项目: 在技能仓库 .env 填 OSS_ACCESS_KEY_ID/OSS_ACCESS_KEY_SECRET/OSS_REGION/OSS_BUCKET
 ## 使用规范
 按 SKILL.md §1 确认门协议执行: 先复述我的问题与方案,回问「是这个意思吗?」;
 只读操作(巡检/列表/对账/聚合)可一句备案继续;写操作(补跑/重试/补传/改配置)必须先罗列将修改的数据面并等我确认。

+ 2 - 2
SKILL.md

@@ -44,8 +44,8 @@ export MSYS_NO_PATHCONV=1     # Windows Git Bash: 以 / 开头的路径参数先
 ```
 - **接口地址**:默认线上 `https://recording.sh-lami.com`;本地开发:环境变量 `SMARTBADGE_API_BASE=http://localhost:3002`(或各脚本 `--base`)。
 - **登录**:`node scripts/api.mjs login <密码>`,token 缓存到 `scripts/.token`(各脚本自动共享)。
-- **凭据回退链**(OSS 等):`--env 参数` > `SMARTBADGE_ENV_FILE` > 自动发现(向上扫本仓库同级/兄弟目录含 `server/.env` 的项目,如 `D:\1\device-management-backend`) > 已存在的 `OSS_*` 进程环境变量。
-- 敏感凭据只存在于项目 `server/.env`(OSS/讯飞/DeepSeek/masterKey),本技能仓库**不含任何密钥**。
+- **凭据回退链**(OSS 等):`--env 参数` > `SMARTBADGE_ENV_FILE` > 技能仓库根 `.env`(clone 后复制 `.env.example` 填值) > 自动发现(向上扫本仓库同级/兄弟目录含 `server/.env` 的项目,如服务端项目与技能放在同一父目录) > 已存在的 `OSS_*` 进程环境变量。
+- 敏感凭据只存在于服务端项目 `server/.env`(OSS/讯飞/DeepSeek/masterKey),本技能仓库**不含任何密钥**;`node scripts/oss.mjs --find-env` 可查看实际采用的 `server/.env` 路径
 
 ## §3 能力矩阵与编排表
 

+ 2 - 1
scripts/lib.mjs

@@ -13,12 +13,13 @@ export function fail(msg, code = 1) {
 }
 
 // ---------- env 回退链 ----------
-// --env > SMARTBADGE_ENV_FILE > 自目录向上扫 server/.env > 兄弟目录自动发现(名称含 device|smartbadge 的仓库)
+// --env > SMARTBADGE_ENV_FILE > 技能仓库根 .env(clone 后 cp .env.example) > 自目录向上扫 server/.env > 兄弟目录自动发现(名称含 device|smartbadge 的仓库)
 export function findEnvFile({ envPath, envFileEnv = process.env.SMARTBADGE_ENV_FILE } = {}) {
   const candidates = [];
   const push = (p) => candidates.push(p);
   if (envPath) push(path.resolve(envPath));
   if (envFileEnv) push(path.resolve(envFileEnv));
+  push(path.join(REPO_ROOT, '.env'));
   for (let dir = REPO_ROOT; ; dir = path.dirname(dir)) {
     push(path.join(dir, 'server', '.env'));
     if (path.dirname(dir) === dir) break;

+ 84 - 0
scripts/oss-client-mini.mjs

@@ -0,0 +1,84 @@
+// 零依赖 OSS 客户端(通用安装、无服务端项目时的回退实现):
+// OSS ListObjectsV2 + V1 签名(Header Authorization: OSS AK:SIG),与 server/services/oss-client.js 行为对齐
+// 凭据只从进程环境读取(scripts/oss.mjs 已先 loadEnv 注入),本模块不含任何密钥
+import crypto from 'crypto';
+
+export function ossConfig() {
+  return {
+    bucket: process.env.OSS_BUCKET || '',
+    region: process.env.OSS_REGION || '',
+    accessKeyId: process.env.OSS_ACCESS_KEY_ID || '',
+    accessKeySecret: process.env.OSS_ACCESS_KEY_SECRET || '',
+  };
+}
+
+export function ossEnabled() {
+  const c = ossConfig();
+  return Boolean(c.bucket && c.region && c.accessKeyId && c.accessKeySecret);
+}
+
+/** 公开读直链域名: 默认桶默认域名(oss-<region>.aliyuncs.com),配 OSS_PUBLIC_DOMAIN 可切换(同服务端规则) */
+export function publicDomain() {
+  const c = ossConfig();
+  return (process.env.OSS_PUBLIC_DOMAIN || `${c.bucket}.oss-${c.region}.aliyuncs.com`).replace(/^https?:\/\//, '');
+}
+
+function xmlDecode(s) {
+  return String(s)
+    .replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>')
+    .replace(/&quot;/g, '"').replace(/&apos;/g, "'")
+    .replace(/&#x([0-9a-f]+);/gi, (m, n) => { try { return String.fromCharCode(parseInt(n, 16)); } catch { return m; } })
+    .replace(/&#(\d+);/g, (m, n) => { try { return String.fromCharCode(Number(n)); } catch { return m; } });
+}
+
+function xmlTag(text, name) {
+  const m = new RegExp(`<${name}>([\\s\\S]*?)<\\/${name}>`).exec(text);
+  return m ? xmlDecode(m[1]) : null;
+}
+
+// 与 ali-oss 一致的 V1 签名:
+// - canonicalized resource = /bucket/ + (?continuation-token=<原值> 仅当存在) —— continue-token 是子资源
+// - list-type/max-keys/prefix 为普通 query,不参与签名,只在请求 URL 中 percent 编码
+// - 日期走 x-oss-date 头(GMT)
+export async function _listPage(params, token) {
+  const c = ossConfig();
+  const date = new Date().toUTCString();
+  const urlQ = Object.entries(params)
+    .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
+    .concat(token ? [`continuation-token=${encodeURIComponent(token)}`] : [])
+    .join('&');
+  const canonicalResource = `/${c.bucket}/${token ? `?continuation-token=${token}` : ''}`;
+  const stringToSign = ['GET', '', '', date, `x-oss-date:${date}`, canonicalResource].join('\n');
+  const sig = crypto.createHmac('sha1', c.accessKeySecret).update(stringToSign).digest('base64');
+  const url = `https://${c.bucket}.oss-${c.region}.aliyuncs.com/?${urlQ}`;
+  const res = await fetch(url, {
+    headers: { 'x-oss-date': date, Authorization: `OSS ${c.accessKeyId}:${sig}` },
+  });
+  if (!res.ok) throw new Error(`OSS 列举失败 HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
+  return await res.text();
+}
+
+/** 列举桶内对象(prefix 为空=全桶),与 server/services/oss-client.js listObjects 返回结构一致 */
+export async function listObjects(prefix = '') {
+  const objects = [];
+  let token = null;
+  for (;;) {
+    const params = prefix ? { 'list-type': '2', 'max-keys': '1000', prefix } : { 'list-type': '2', 'max-keys': '1000' };
+    const text = await _listPage(params, token);
+    const contents = new RegExp('<Contents>([\\s\\S]*?)<\\/Contents>', 'g');
+    let m;
+    while ((m = contents.exec(text))) {
+      const size = Number(xmlTag(m[1], 'Size')) || 0;
+      const last = xmlTag(m[1], 'LastModified');
+      objects.push({
+        name: xmlTag(m[1], 'Key'),
+        size,
+        lastModified: last ? new Date(last).toISOString() : null,
+      });
+    }
+    if (xmlTag(text, 'IsTruncated') !== 'true') break;
+    token = xmlTag(text, 'NextContinuationToken');
+    if (!token) break;
+  }
+  return objects;
+}

+ 10 - 11
scripts/oss.mjs

@@ -5,13 +5,13 @@
 //   node oss.mjs recordings/ # 列出该前缀全部对象(按目录分组)
 //   node oss.mjs recordings/ --json          # JSON 输出 {domain,prefix,objects}
 //   node oss.mjs --find-env                  # 打印实际采用的 .env 路径
-//   node oss.mjs reports/ --env D:/1/device-management-backend/server/.env  # 显式指定 .env
-// 凭据回退链: --env > SMARTBADGE_ENV_FILE > 自动发现(见 lib.mjs) > 进程环境已有 OSS_*
-// 注意:需要项目 server/services/oss-client.js 所在仓库(自动发现通常命中 D:\1\device-management-backend)
+//   node oss.mjs records/ --env <server/.env 路径>   # 显式指定 .env
+// 凭据回退链: --env > SMARTBADGE_ENV_FILE > 自动发现(见 lib.mjs) > 进程环境已有 OSS_* > 技能仓库根 .env
+// OSS 客户端两态: 有服务端项目(server/services/oss-client.js)时复用其模块;否则回退内置零依赖实现(oss-client-mini.mjs)
 import fs from 'fs';
 import path from 'path';
 import { pathToFileURL } from 'url';
-import { findEnvFile, loadEnv, fail } from './lib.mjs';
+import { findEnvFile, loadEnv, fail, SCRIPTS_DIR } from './lib.mjs';
 
 const args = process.argv.slice(2);
 const isFlag = (a) => a.startsWith('--');
@@ -42,8 +42,8 @@ async function main() {
   if (!env.hit) {
     console.error('未找到 OSS 凭据文件(探测序列如下),三选一:');
     for (const c of env.candidates) console.error(`  ${c}`);
-    console.error('  → 1. node oss.mjs --env <server/.env 路径>');
-    console.error('  → 2. 设置 SMARTBADGE_ENV_FILE 指向 server/.env');
+    console.error('  → 1. 复制 .env.example 为 <技能仓库>/.env 并填入 OSS_* 四项');
+    console.error('  → 2. node oss.mjs --env <server/.env 路径>(服务端项目在别处时)');
     console.error('  → 3. 直接导出 OSS_* 环境变量(与 server/.env 同键名)');
     process.exit(2);
   }
@@ -53,12 +53,11 @@ async function main() {
   const missing = ['OSS_ACCESS_KEY_ID', 'OSS_ACCESS_KEY_SECRET', 'OSS_BUCKET'].filter((k) => !process.env[k]);
   if (missing.length) fail(`OSS 凭据不全,缺: ${missing.join(', ')}。请检查 --env 指向的 .env 或环境变量`, 4);
 
+  // 客户端两态: 服务端项目模块优先(行为一致),否则内置零依赖实现
   const ossClientPath = path.join(serverDir, 'services', 'oss-client.js');
-  if (!fs.existsSync(ossClientPath)) {
-    fail(`未找到 oss-client.js: ${ossClientPath}。自动发现命中路径可能不含项目 server/;请用 --env 显式指向 <项目>/server/.env`, 3);
-  }
-
-  const { listObjects, publicDomain } = await import(pathToFileURL(ossClientPath));
+  const { listObjects, publicDomain } = fs.existsSync(ossClientPath)
+    ? await import(pathToFileURL(ossClientPath))
+    : await import(pathToFileURL(path.join(SCRIPTS_DIR, 'oss-client-mini.mjs')));
   const objects = await listObjects(prefix);
   const domain = publicDomain();