diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 586870d..77bbe4e 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -295,3 +295,9 @@ A04派发已提交而消息尚未到达Worker的精确进程故障已通过[专 - 已实现:页顶应用/环境与队列筛选、列表和详情应用标签、URL 保持筛选;切换隔离旧请求和表单,错误范围不降级读取默认数据。 - 单管理员通过 Cookie 显式选择范围;业务事务重新核验会话与目标应用,SDK 不支持范围覆盖,订阅原凭据绑定不变。 - 同步提供[范围接口](api/console-scope.md)、真实 MySQL 授权/审计/退出后拒绝测试和 HTTP 列表及订阅管理回归。最终结果见[本批报告](reports/console-scope.md)。 + +### 换证过渡状态提示(2026-09-27) + +- 凭据卡片明确区分「过渡中 · 暂不可换证」与「已撤销 · 不可换证」,展示禁用原因、旧 Token 实际截止及服务切换步骤;到期后页面刷新恢复换证入口。 +- 保留过渡期禁止重复换证的保护;提前结束仍需管理员确认服务全部切换、在途任务收尾,不自动结束过渡或替换现有凭据。 +- 回归范围:标准竞态测试、vet/build、JavaScript 语法和文档链接;本地页面核对过渡说明、正常换证入口与结束过渡确认弹窗。说明同步到[应用接入手册](api/console-applications.md)。 diff --git a/docs/api/console-applications.md b/docs/api/console-applications.md index 09b7230..4c7ab68 100644 --- a/docs/api/console-applications.md +++ b/docs/api/console-applications.md @@ -91,6 +91,8 @@ prepare 请求: expected_revision 为读取到的字符串版本。只允许上述轮换字段,不混入新应用名称或能力组。准备对象包含原 application_id / namespace_id / credential_id,没有 queue_id。apply 复用原有凭据轮换事务:将原摘要保留到固定过渡截止,替换当前摘要与可选到期时间,版本加一,保存脱敏回执及人类账号审计;权限组不改变。已撤销的凭据禁止变更。ROTATE 的 grace_days 范围 0–30,API 省略/0 保持立即停用旧 Token 的兼容语义,页面显式提交 7;实际截止不超过旧 Token 原到期时间。过渡未结束时再次轮换返回 409。 +页面在过渡期将按钮标为「过渡中 · 暂不可换证」,同时显示原因、实际截止和切换步骤。这表示上一轮换证已生效;再次轮换会挤掉仍在使用的旧 Token,所以暂时禁止。将已保存的新 Token 配置到所有服务、Worker 和结果消费者,并收尾在途任务。到期后页面刷新会恢复换证按钮;只有确认全部切换完成,才可提前点「结束过渡,停用旧 Token」。结束过渡会立即停用旧 Token,不能仅为解锁按钮而操作。已撤销凭据单独显示「已撤销 · 不可换证」。 + apply 成功 data: ```json @@ -140,7 +142,7 @@ SET_EXPIRY 不生成新 Token,只更新当前有效期;valid_days=0 为长 | PERMISSION_DENIED | 403 | 核对唯一管理员配置、会话及同源请求 | | INVALID_ARGUMENT | 400 | 核对字段、能力组、固定身份及有效期 | | NOT_FOUND | 404 | 核对命名空间/凭据是否存在 | -| STATE_CONFLICT | 409 | 应用名称已存在、凭据已撤销或版本已变更;读取当前状态 | +| STATE_CONFLICT | 409 | 应用名称已存在、凭据已撤销、版本已变更或旧 Token 仍在过渡期;读取当前状态 | | IDEMPOTENCY_CONFLICT | 409 | 原操作内容被修改,保留原始操作核对 | | 数据库/网络故障 | 5xx / 网络错误 | 不假定失败,保留原操作重报 | diff --git a/internal/transport/httpserver/console/applications.js b/internal/transport/httpserver/console/applications.js index e16e02b..6d2456c 100644 --- a/internal/transport/httpserver/console/applications.js +++ b/internal/transport/httpserver/console/applications.js @@ -39,10 +39,22 @@ $('access-queue').innerHTML = detail.queues.map(q => ``).join(''); if (detail.queues.some(q => q.id === previous)) $('access-queue').value = previous; $('access-truncated').hidden = !detail.truncated; + const now = Date.now(); $('access-credentials').innerHTML = detail.credentials.map(c => { - const status = c.revoked_at ? '已撤销' : c.expires_at && new Date(c.expires_at) <= new Date() ? '已到期' : '有效'; + const status = c.revoked_at ? '已撤销' : c.expires_at && Date.parse(c.expires_at) <= now ? '已到期' : '有效'; + const transitioning = !c.revoked_at && Date.parse(c.previous_valid_until) > now; + const blocked = !!c.revoked_at || transitioning; + const helpID = `access-rotation-help-${c.id}`; + const reason = c.revoked_at ? '凭据已撤销,不能再次换证。' : '旧 Token 仍在过渡期,暂不能再次换证,以免中断尚未切换的服务。'; + const rotationHelp = transitioning ? `` : c.revoked_at ? `

${escape(reason)}

` : c.previous_valid_until ? `

旧 Token 已失效于:${escape(time(c.previous_valid_until))},可再次换证。

` : ''; const groups = [['tasks:submit', '任务'], ['workers:consume', 'Worker 执行'], ['events:consume', '结果订阅'], ['schedules:manage', '定时调度'], ['workflows:start', '流程编排'], ['tasks:review', '人工恢复'], ['application:resources:manage', '应用额度']].filter(([action]) => c.permissions.actions.includes(action)).map(([,name]) => name); - return `
${escape(c.name)} · ${status}

凭据 ID:${escape(c.id)}
有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}
Token 原文不保存在数据库;已有服务继续使用原配置。

${c.previous_valid_until ? `

旧 Token ${new Date(c.previous_valid_until) > new Date() ? '过渡截止' : '已失效于'}:${escape(time(c.previous_valid_until))}

` : ''}
${c.previous_valid_until && !c.revoked_at ? `` : ''}
${groups.map(a => `${escape(a)}`).join('')}
查看具体权限

${c.permissions.actions.map(escape).join(' · ')}
授权队列:${escape(c.permissions.queues.join(', '))}

`; + return `
+
${escape(c.name)} · ${status}
+

凭据 ID:${escape(c.id)}
有效期:${escape(time(c.expires_at))} · 版本 ${escape(c.revision)}
Token 原文不保存在数据库;请妥善保存接入配置。

+ ${rotationHelp} +
${c.previous_valid_until && !c.revoked_at ? `` : ''}
+
${groups.map(a => `${escape(a)}`).join('')}
查看具体权限

${c.permissions.actions.map(escape).join(' · ')}
授权队列:${escape(c.permissions.queues.join(', '))}

+
`; }).join('') || '

该环境暂无凭据。

'; document.querySelectorAll('[data-access-id]').forEach(el => el.classList.toggle('selected', el.dataset.accessId === namespace)); renderConfig(); } catch (error) { $('access-error').textContent = message(error); } diff --git a/internal/transport/httpserver/console/styles.css b/internal/transport/httpserver/console/styles.css index 0046dd9..e66cfb4 100644 --- a/internal/transport/httpserver/console/styles.css +++ b/internal/transport/httpserver/console/styles.css @@ -18,6 +18,7 @@ @media(max-width:1100px){.access-grid{grid-template-columns:1fr}.access-guide{gap:14px;flex-wrap:wrap}.access-list{display:grid;grid-template-columns:1fr 1fr}.access-heading h1{font-size:28px}} @media(max-width:600px){.access-heading{align-items:flex-start;flex-direction:column}.access-config-grid,.access-list{grid-template-columns:1fr}.access-dialog{padding:22px}.access-guide{flex-direction:column;align-items:flex-start;gap:10px}.access-guide>i{display:none}.access-heading p{font-size:13px}} .access-permission-details{margin-top:14px;font-size:11px;color:#8ba5bc}.access-permission-details summary{cursor:pointer}.access-permission-details p{font-size:10px;line-height:2} +.access-credential-header{flex-wrap:wrap}.access-credential button:disabled{color:#93a8b7;cursor:not-allowed}.access-credential-header button:disabled{padding:7px 10px;border:1px solid #344352;border-radius:7px;background:#1b2a3866}.access-rotation-notice{margin-top:16px;padding:14px 16px;border:1px solid #76603966;border-left:3px solid #caa869;border-radius:10px;background:#55411d24}.access-rotation-notice strong{font-size:12px;color:#e2c58e}.access-rotation-notice p{margin:7px 0 0;color:#b5bdc5}.access-rotation-notice time{color:#e2c58e;font-variant-numeric:tabular-nums} /* 应用范围在页面导航间保持;列表归属信息使用同一组数据库元数据。 */ .application-scope{display:flex;align-items:center;gap:24px;flex-wrap:wrap;margin:24px 0 8px;padding:20px 24px;background:linear-gradient(110deg,rgba(39,105,112,.16),rgba(13,25,43,.68));border:1px solid var(--line);border-radius:16px}.application-scope[hidden]{display:none}.scope-heading{display:flex;align-items:center;gap:12px;min-width:140px}.scope-symbol{display:grid;place-items:center;width:36px;height:36px;background:rgba(98,230,212,.09);color:var(--cyan);border-radius:10px;font-size:23px}.scope-heading b{font-size:13px}.scope-heading small{display:block;color:var(--muted);font-size:10px;margin-top:6px;max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.scope-field{flex:1;min-width:170px;max-width:340px}.scope-field>span{display:block;color:var(--muted);font-size:10px;margin-bottom:7px}.scope-field select{width:100%;padding:10px 30px 10px 12px;border:1px solid var(--line);border-radius:9px;background:#0b1829;color:var(--text);font:inherit;font-size:12px;text-overflow:ellipsis}.scope-field select:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}.scope-queue{max-width:240px}.scope-note{margin-left:auto;display:grid;gap:9px;text-align:right}.scope-note small{color:var(--muted);font-size:10px}.scope-note button{justify-self:end;font-size:11px}.scope-error{width:100%;margin:0;color:var(--orange);font-size:12px}.scope-error:empty{display:none}.application-badge{display:inline-flex;align-items:center;gap:7px;max-width:260px;padding:5px 8px;border:1px solid rgba(98,230,212,.13);border-radius:7px;background:rgba(98,230,212,.055);font-size:11px;white-space:nowrap}.application-badge i{width:5px;height:5px;border-radius:50%;background:var(--cyan);flex-shrink:0}.application-badge b{overflow:hidden;text-overflow:ellipsis;font-weight:500;color:#d4eae8;max-width:170px}.application-badge em{font-style:normal;font-size:10px;color:var(--muted);border-left:1px solid var(--line);padding-left:7px}.list-application{margin-left:auto;margin-right:18px}.list-copy{min-width:0}.list-copy b{overflow:hidden;text-overflow:ellipsis}.archive-job>.application-badge{margin:12px 0 0}.detail-scope,.retention-scope{margin-bottom:14px}.scope-field select:disabled{opacity:.7}.task-processor{max-width:260px;overflow-wrap:anywhere}