Pārlūkot izejas kodu

feat(dms): 账号密码自动登录取 token(方案 C)

用户给出登录路径片段 /oauth/user/login 后,在隔壁项目 zhaoshang_admin_ui 找到已实测通过的实现
(src/services/auth.ts,对应 harness/product-specs/auth.md 的 auth-ams-001),照抄其请求形态:

- OAuth 服务在 http://121.43.55.7:10086(DMS 是 10081,另一个端口;此前两个仓库的文档里都没有)
- POST /oauth/api/user/login,form userName/password/clientId=0,无验证码
- 响应 {code:200, message:<token>, content:{...}} —— token 在 message 里,不在 content
- 实测端点存活:假账号 → {"code":202,"message":"用户不存在"}

新增 harness/tools/dms-login.mjs:登录后写入与方案 A 相同的 .dms-token。
- 凭据三种给法:参数 / DMS_USERNAME+DMS_PASSWORD / 本机文件 .dms-account(gitignore)
- --if-needed:token 还剩 20 分钟以上就跳过,供 npm run dev 的 predev 自动续期
- 没配账号时安静跳过并退出 0,绝不因为「没配自动登录」把 npm run dev 拦下来
- 登录失败原样透出后端文案;返回的不是 JWT 就拒绝写入
- package.json 增 dms:login 与 predev;.gitignore 增 /.dms-account;新建 .dms-account 模板

验证:--if-needed 有效 token 时跳过并退 0;未配账号退 1 并列出三种给法;
账号错误时打印 code=202 且 cmp 确认 .dms-token 未被改动;.dms-account 确被读取;
npm run predev 走通。build 与 validate-harness 通过。
⚠️ 成功登录那一步未实测(没有真实密码),首次使用需用户看一眼输出。

Co-Authored-By: Claude Code <noreply@anthropic.com>
gongtianxiao 1 dienu atpakaļ
vecāks
revīzija
8ad7e7f27b
5 mainītis faili ar 180 papildinājumiem un 1 dzēšanām
  1. 1 0
      .gitignore
  2. 34 0
      harness/progress.md
  3. 16 0
      harness/tools/README.md
  4. 126 0
      harness/tools/dms-login.mjs
  5. 3 1
      package.json

+ 1 - 0
.gitignore

@@ -12,6 +12,7 @@ node_modules
 
 # DMS token(本机单行文件,由 npm run dms:token 写入;带凭据,不入库)
 /.dms-token
+/.dms-account
 
 # local env files
 .env.local

+ 34 - 0
harness/progress.md

@@ -3213,3 +3213,37 @@ eyJ0eXAi...        ← token 在**下一行**
 DMS 主机(`121.43.55.7:10081`)是纯 API、根路径 404,没有登录页;
 两个仓库的文档里都没写登录端点(DMS 项目的 RUNBOOK 也标着「尚未实测」)。
 **需要用户提供**登录那个网站的地址,或(最快)在登录页 F12 → Network → 复制登录请求为 cURL。
+
+### 续 8:方案 C —— 账号密码自动登录取 token(用户给出 `/oauth/user/login`)
+
+**接口来源**:用户给出路径片段后,在隔壁项目 `F:\yysk\AI_zhaoshang\zhaoshang_admin_ui-master` 找到了
+**已经实测通过**的实现(`src/services/auth.ts` + `harness/product-specs/auth.md` 的 `auth-ams-001`):
+
+- OAuth 服务在 **`http://121.43.55.7:10086`**(DMS 是 10081,**另一个端口**——此前两个仓库的文档里都没有它)
+- 登录:`POST /oauth/api/user/login`,form `userName` / `password` / `clientId=0`,**无验证码**
+- 响应形如 `{code:200, message:<token>, content:{id,userName,failureTime}}`
+  —— ⚠️ **token 在 `message` 里**(不在 `content`),照抄实现时才注意到
+- 实测确认端点存活:假账号 POST → `{"code":202,"message":"用户不存在"}`
+
+**已完成**:
+- 新增 `harness/tools/dms-login.mjs`:账号密码登录 → 写 `.dms-token`(与方案 A 同一份文件)
+  - 凭据三种给法:参数 / `DMS_USERNAME`+`DMS_PASSWORD` / 本机文件 **`.dms-account`**(gitignore)
+  - `--if-needed`:当前 token 还剩 >20 分钟就跳过(`npm run dev` 的 **predev** 用它自动续期)
+  - 没配账号时**安静跳过、退出 0** —— 绝不能因为「没配自动登录」把 `npm run dev` 拦下来
+  - 登录失败原样透出后端文案(如 `code=202 用户不存在`);返回的不是 JWT 就拒绝写入
+- `package.json` 增 `dms:login` 与 `predev`;`.gitignore` 增 `/.dms-account`
+- 新建 `.dms-account` 模板(gitignore,空的 username/password + 注释说明两种方式)
+
+**运行过的验证**(成功路径需要用户真实密码,其余都实测了):
+| 验的是 | 结果 |
+|---|---|
+| `--if-needed` 且 token 还有效 | 打印「token 仍有效…跳过自动登录」,退出 0 ✅ |
+| 未配账号 | 退出 1 并列出三种给法 ✅ |
+| 账号错误 | `登录失败:code=202 message=用户不存在`,退出 1 ✅ |
+| 失败时**不写坏文件** | `cmp` 确认 `.dms-token` 未被改动 ✅ |
+| 从 `.dms-account` 读凭据 | 造假的账号文件 → 确实按它发起登录 ✅ |
+| `npm run predev` | 走通、退出 0 ✅ |
+
+**待用户做**:把账号密码填进 `.dms-account`(或直接告诉我不用管、继续用剪贴板方式)。
+填完 `npm run dms:login` 一次即可;之后 `npm run dev` 会自动续。
+⚠️ **成功登录那一步我没有实测**(没有密码),第一次跑请看一眼输出。

+ 16 - 0
harness/tools/README.md

@@ -84,10 +84,26 @@ npx esbuild harness/tools/_entry-company-classify.ts --bundle --format=esm \
 |---|---|
 | `dms-token.mjs` | 读取口径 + JWT 状态描述(不写文件) |
 | `set-dms-token.mjs` | 写入 `.dms-token`;`npm run dms:token`(无参数时**读剪贴板**)、`--check` 只看状态 |
+| `dms-login.mjs` | 账号密码自动登录 AMS OAuth 并写入 `.dms-token`;`npm run dms:login`,`--if-needed` 给 `predev` 用 |
 
 `.dms-token` 是仓库根的**单行**文件(gitignore)。vite 代理**每个请求现读**,
 所以换 token 之后 **dev server 不用重启** —— 这正是它存在的理由(以前要改 `.env.development.local` 再重启)。
 
+两条拿 token 的路,按「愿不愿意在本机存密码」选一条:
+
+```bash
+# ① 不存密码:浏览器登录 → 复制 token → 一条命令(从剪贴板读)
+npm run dms:token
+
+# ② 存密码:把账号填进 .dms-account(gitignore)后,登录全自动
+npm run dms:login     # 立即登录一次
+npm run dev           # predev 会自动续(token 还够用就跳过),之后基本不用再管
+```
+
+登录接口的形态照抄隔壁 `zhaoshang_admin_ui`(那边已实测通过):
+`POST {OAUTH_TARGET}/oauth/api/user/login`,form `userName/password/clientId`,
+响应 `{code:200, message:<token>}`(**token 在 `message` 里**)。默认 `OAUTH_TARGET=http://121.43.55.7:10086`,可用环境变量覆盖。
+
 > ⚠️ 2026-09-21 发现过一次「填了 token 却不生效」:`.env.development.local` 里写成了
 > `DMS_TOKEN=` 换行后再跟 token,dotenv 读出来是**空串**。这类错误很难看出来 ——
 > 用 `npm run dms:token -- --check` 一眼就能确认当前用的是谁、什么时候过期。

+ 126 - 0
harness/tools/dms-login.mjs

@@ -0,0 +1,126 @@
+/**
+ * 用账号密码**自动登录 AMS OAuth**,把拿到的 token 写进 `.dms-token`。
+ *
+ * 为什么要有它:DMS token 约 24 小时过期,以前要「打开某个网站 → 登录 → F12 复制 → 粘贴」。
+ * 有了这个脚本就是一条命令(`npm run dms:login`),`npm run dev` 前还会自动续(见 package.json 的 predev)。
+ *
+ * 接口来源:隔壁项目 `zhaoshang_admin_ui` 的 `src/services/auth.ts` —— 那边已实测通过
+ * (`harness/product-specs/auth.md` 的 `auth-ams-001`),本脚本照抄它的请求形态:
+ *   POST {OAUTH_TARGET}/oauth/api/user/login
+ *   Content-Type: application/x-www-form-urlencoded
+ *   body: userName / password / clientId
+ *   响应: { code: 200, message: <token>, content: { id, userName, identityName, failureTime } }
+ *   —— 注意 token 在 `message` 里(不是 `content`),无验证码。
+ *
+ * 凭据从哪来(按顺序):
+ *   ① 参数:node harness/tools/dms-login.mjs <userName> <password>
+ *   ② 环境变量:DMS_USERNAME / DMS_PASSWORD
+ *   ③ 本机文件 `.dms-account`(gitignore,两行 `username=` / `password=`)
+ *
+ * 用法:
+ *   npm run dms:login              # 登录并写入 .dms-token
+ *   npm run dms:login -- --if-needed   # 当前 token 还有效就跳过(predev 用这个)
+ *
+ * ⚠️ 密码是**明文**存在 `.dms-account` 里的(与 `.env.*.local` 同一性质:本机、不入库)。
+ * 不愿意存密码就别用这个脚本,改用 `npm run dms:token` 从剪贴板粘贴 —— 那条路不碰密码。
+ */
+
+import { existsSync, readFileSync, writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+import { DMS_TOKEN_FILE, readDmsToken, parseJwtPayload, describeDmsToken } from './dms-token.mjs';
+
+const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
+/** 本机账号文件(gitignore):两行 `username=` / `password=` */
+const ACCOUNT_FILE = join(repoRoot, '.dms-account');
+
+const OAUTH_TARGET = (process.env.OAUTH_TARGET || 'http://121.43.55.7:10086').replace(/\/+$/, '');
+const CLIENT_ID = process.env.OAUTH_CLIENT_ID || '0';
+
+const args = process.argv.slice(2).filter((a) => !a.startsWith('--'));
+const ifNeeded = process.argv.includes('--if-needed');
+
+/** 简单 KEY=VALUE 解析(够用即可,别为两个字段引入 dotenv) */
+const parseAccountFile = (text) => {
+  const out = {};
+  for (const line of String(text).split(/\r?\n/)) {
+    const m = line.match(/^\s*([A-Za-z_][\w]*)\s*=\s*(.*)$/);
+    if (m) out[m[1].toLowerCase()] = m[2].trim().replace(/^["']|["']$/g, '');
+  }
+  return out;
+};
+
+const readAccount = () => {
+  const fromFile = existsSync(ACCOUNT_FILE) ? parseAccountFile(readFileSync(ACCOUNT_FILE, 'utf8')) : {};
+  return {
+    userName: String(args[0] || process.env.DMS_USERNAME || fromFile.username || '').trim(),
+    password: String(args[1] || process.env.DMS_PASSWORD || fromFile.password || '').trim(),
+  };
+};
+
+/* ---------------- --if-needed:当前 token 还够用就直接退出 ---------------- */
+if (ifNeeded) {
+  const token = readDmsToken();
+  const exp = Number(parseJwtPayload(token)?.exp);
+  // 留 20 分钟余量:dev server 一开就是几小时,临过期才续会中途断掉
+  if (Number.isFinite(exp) && exp * 1000 - Date.now() > 20 * 60 * 1000) {
+    console.log(`[dms] token 仍有效(${describeDmsToken(token)}),跳过自动登录。`);
+    process.exit(0);
+  }
+  if (!readAccount().userName) {
+    // 没配账号就安静跳过 —— 绝不能因为「不想自动登录」把 npm run dev 拦下来
+    console.log('[dms] 当前没有可用的 DMS token,也没配自动登录账号(见 harness/tools/dms-login.mjs 头注释)。');
+    console.log('[dms] 手动方式:在浏览器复制 token 后跑 `npm run dms:token`。');
+    process.exit(0);
+  }
+}
+
+const { userName, password } = readAccount();
+if (!userName || !password) {
+  console.error(`缺账号或密码。三种给法:`);
+  console.error(`  ① 参数:node harness/tools/dms-login.mjs <userName> <password>`);
+  console.error(`  ② 环境变量:DMS_USERNAME / DMS_PASSWORD`);
+  console.error(`  ③ 本机文件 ${ACCOUNT_FILE},内容两行:`);
+  console.error(`       username=你的账号`);
+  console.error(`       password=你的密码`);
+  process.exit(1);
+}
+
+/* ---------------- 登录 ---------------- */
+const url = `${OAUTH_TARGET}/oauth/api/user/login`;
+let json = null;
+try {
+  const res = await fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+    body: new URLSearchParams({ userName, password, clientId: CLIENT_ID }).toString(),
+  });
+  const text = await res.text();
+  try {
+    json = JSON.parse(text);
+  } catch {
+    console.error(`登录响应不是 JSON(HTTP ${res.status}):${text.slice(0, 200)}`);
+    process.exit(1);
+  }
+} catch (err) {
+  console.error(`连不上 OAuth(${url}):${err?.message || err}`);
+  process.exit(1);
+}
+
+if (json?.code !== 200 || !json?.message) {
+  // 常见:202 用户不存在 / 203 密码错误(实际文案以后端返回为准,原样透出便于排查)
+  console.error(`登录失败:code=${json?.code} message=${json?.message ?? '(空)'}`);
+  process.exit(1);
+}
+
+const token = String(json.message).trim();
+if (!parseJwtPayload(token)) {
+  console.error('登录返回的 token 不是 JWT,已中止(不会写入 .dms-token)。');
+  console.error(`原始响应:${JSON.stringify(json).slice(0, 200)}`);
+  process.exit(1);
+}
+
+writeFileSync(DMS_TOKEN_FILE, `${token}\n`, 'utf8');
+console.log(`已登录并写入 ${DMS_TOKEN_FILE}`);
+console.log(`  ${describeDmsToken(token)}`);
+console.log('dev server **不用重启**(代理每个请求现读这个文件)。');

+ 3 - 1
package.json

@@ -11,7 +11,9 @@
     "build": "vite build --mode production ",
     "preview": "vite preview",
     "test": "jest",
-    "dms:token": "node harness/tools/set-dms-token.mjs"
+    "dms:token": "node harness/tools/set-dms-token.mjs",
+    "dms:login": "node harness/tools/dms-login.mjs",
+    "predev": "node harness/tools/dms-login.mjs --if-needed"
   },
   "dependencies": {
     "@microsoft/fetch-event-source": "^2.0.1",