Compare commits

..
16 Commits
Author SHA1 Message Date
ken.li 5426b573c1 fix(jira_env): 为JIRA_BASE_URL添加默认值并优化错误提示
修改require_credentials函数,为JIRA_BASE_URL添加默认值"https://n2nafe.atlassian.net"
优化错误提示信息,明确指导用户如何配置和获取API token
2026-04-02 17:59:12 +08:00
ken.li 44b13c96f6 feat(jira): 增强提交检查和文档路径规范
- 添加已推送提交检查,避免不安全的 amend 操作
- 规范文档保存路径为 `doc/{日期}` 格式
- 扩展 markdown 解析支持表格和更多格式
- 优化 Jira 搜索 API 调用方式
2026-04-02 17:38:39 +08:00
ken.li 062e9a652b Merge branch 'main' of http://192.168.3.110:3000/AFE_SZ_DEV/agent-skills 2026-03-23 13:40:14 +08:00
ken.li 57fca4e468 feat(g3fo-commit-jira): 新增无 MCP 的 Jira CLI 并增强 AI 执行流程约束
- 新增 `jira_cli.py` 脚本,支持在不依赖 Atlassian MCP 的环境下通过同一 Jira Token 进行搜索、创建 Issue 和评论
- 新增共享配置模块 `jira_env.py`,统一加载 `jira_upload.env` 并支持在技能根目录查找配置文件
- 更新 `SKILL.md` 和参考文档,增加「AI 执行契约」和详细的前置条件说明,强制 Step 1→5 线性执行顺序
- 新增 `agent_execution_checklist.md` 检查清单,防止 AI 跳步、误用工具或未等用户确认就绑定 Issue
- 改进 `jira_cli.py` 的评论和描述生成,将常见 Markdown 语法转换为 Jira ADF 格式以获得更好的渲染效果
2026-03-23 13:38:41 +08:00
ken.li cd6c8a0450 docs(g3fo-docs): 添加中间件版本清单文档并更新技能指南
- 新增 `middleware-versions.md` 作为标准版本参考文档
- 在技能指南中更新资源引用,添加版本清单问答规范
- 明确 Web Server 与 AP Server 的角色划分及输出要求
2026-03-23 13:35:08 +08:00
man.zhong 81727978f1 Merge branch 'main' of http://afe.git:3000/AFE_SZ_DEV/agent-skills 2026-03-17 17:20:34 +08:00
man.zhong 1a0aecbd7e chore(g3fo-db-ops): add Windows + double-confirm rules
Made-with: Cursor
2026-03-17 17:20:24 +08:00
ken.li f7381c3f85 Merge branch 'main' of http://192.168.3.110:3000/AFE_SZ_DEV/agent-skills 2026-03-16 18:11:40 +08:00
ken.li dc2d6697af feat: 添加g3fo-commit-jira skill 2026-03-16 18:11:34 +08:00
man.zhong cf6dc208ef chore(g3fo-db-ops): sync SKILL.md and db_ops implementation from project
Made-with: Cursor
2026-03-16 16:05:34 +08:00
man.zhong 23df8ffd75 feat(g3fo-db-ops): replace MCP MySQL with Python db_ops tool, add connection timeout
Made-with: Cursor
2026-03-11 09:27:19 +08:00
man.zhong 8e2afda170 feat(git-control): show commit author in branch compare, add by-user summary
Made-with: Cursor
2026-02-27 15:52:27 +08:00
man.zhong f50d4ab4a3 feat(git-control): add branch compare (main/uat/prod), org/single repo support
Made-with: Cursor
2026-02-27 15:43:46 +08:00
ken.li 2768d58aa3 feat(git-control): add Git repository and branch access control features
- Introduced new scripts for managing Gitea repository permissions, including branch protection, user access, and organization-wide settings.
- Added documentation for the git-control skill, detailing API usage and capabilities.
- Included .gitignore to prevent committing sensitive configuration files.
- Created references for Gitea API endpoints related to branch protection and user permissions.
2026-02-27 14:16:36 +08:00
ken.li a5840ed7c6 Merge branch 'main' of http://192.168.3.110:3000/AFE_SZ_DEV/agent-skills 2026-02-09 17:25:22 +08:00
ken.li e798751e4b docs(nacos-config): 添加 nacos_config 目录分析与修改指南
新增 g3fo-nacos-config 目录下的 SKILL.md 文档,详细说明 nacos_config 目录的结构、用户交互流程及配置文件的添加与修改规则。新增 yml-placement.md 文件,提供常见顶级键的插入顺序,以保持配置文件风格一致。
2026-02-09 17:25:13 +08:00
41 changed files with 3928 additions and 142 deletions
+2
View File
@@ -0,0 +1,2 @@
# 勿提交含 API Token 的配置文件
jira_upload.env
+108
View File
@@ -0,0 +1,108 @@
# 影响分析报告 — g3fo-commit-jira 无 MCP 支持
## 1. 改动概览
- **背景**:技能原依赖 Cursor Atlassian MCP 完成 Jira 搜索/创建/评论;其它 IDE 无 MCP 时无法完成流程。
- **目标**:在仅配置 `jira_upload.env`(与上传附件相同 Token)时,通过脚本完成查询、创建 Issue、添加评论。
- **涉及模块**:`skills/g3fo-commit-jira/scripts/`、`SKILL.md`、`references/workflow.md`、`references/env_config.md`。
- **改动类型**:功能新增(脚本)+ 文档更新。
## 2. 方法级改动分析
| 项 | 说明 |
|----|------|
| 新增 `jira_env.py` | 统一加载 `jira_upload.env`,支持在技能根目录查找配置文件。 |
| 新增 `jira_cli.py` | `myself` / `search` / `issue` / `create` / `comment` REST 调用。 |
| 修改 `upload_attachment.py` | 改为引用 `jira_env`,行为与原先一致(多一层技能目录 config 查找)。 |
| SKILL / workflow / env_config | 描述无 MCP 路径与命令示例。 |
**与原有逻辑差异**:上传附件 URL、认证方式未变;配置查找增加 `skills/g3fo-commit-jira/jira_upload.env`。
## 3. 调用方与影响范围
- **调用方**:Agent 按 SKILL 执行;用户手动运行脚本。
- **破坏性变更**:否。未配置 env 时 `upload_attachment` 仍报错;原 MCP 流程仍可用。
- **边界**:Jira Server/Data Center 与 Cloud API 差异未专门适配(与现有上传脚本一致,面向 Cloud)。
## 4. 风险与回滚
- **风险级别**:低。新脚本失败时退回 MCP 或仅本地 amend。
- **回滚**:删除 `jira_cli.py`/`jira_env.py` 并恢复 `upload_attachment.py` 内联配置逻辑;回滚文档。
- **回滚方式是否简单**:是。
## 5. 验证与测试
- `python -m py_compile` 通过;`jira_cli.py --help` 正常。
- 真实 Jira 调用需用户环境凭证,未在 CI 中执行。
---
# 影响分析报告 — jira_cli Markdown → ADF 渲染(评论/描述)
## 1. 改动概览
- **背景**:REST 脚本原先把正文整段当作 ADF 段落,`###` 等在 Jira 界面显示为原文,与 Atlassian 插件/MCP 的 Markdown 体验不一致。
- **目标**:在 `jira_cli.py` 内将常见 Markdown 转为 ADF(heading、list、strong、code、link、codeBlock、rule、blockquote),使 `comment` / `create` 的展示接近网页富文本。
- **涉及模块**:`scripts/jira_cli.py`、`SKILL.md`。
- **改动类型**:功能增强(无 API 变更)。
## 2. 方法级改动分析
| 项 | 说明 |
|----|------|
| `markdown_to_adf`(新) | 按行解析 Markdown,输出 ADF `doc`。 |
| `plain_to_adf` | 改为委托 `markdown_to_adf`,保持调用方不变。 |
| `parse_inline_adf`(新) | 行内 `**`、`` ` ``、`[text](url)`。 |
**与原有逻辑差异**:非 Markdown 行仍按段落(含换行 hardBreak)输出;纯文本行为与旧版「多段段落」接近,但连续单行不再强制 `\\n\\n` 才分段。
## 3. 调用方与影响范围
- **调用方**:`cmd_comment`、`cmd_create` 经 `plain_to_adf` → `markdown_to_adf`。
- **破坏性变更**:否。极端表格/复杂 MD 语法未实现,可能仍以普通文本行展示。
- **边界**:与 Jira Cloud ADF 一致;Server/DC 若 API 不同需单独验证。
## 4. 风险与回滚
- **风险级别**:低。若某 ADF 节点被实例拒绝,可回退 `plain_to_adf` 为旧实现。
- **回滚方式是否简单**:是(恢复旧 `plain_to_adf` 单函数)。
## 5. 验证与测试
- 本地 `markdown_to_adf` 样例 JSON 结构校验;`py_compile` 通过。
- 完整评论 POST 需对接真实 Jira。
---
# 影响分析报告 — AI 执行流程防走偏(SKILL / workflow / checklist)
## 1. 改动概览
- **背景**:不同 AI 执行本技能时偶发跳步、先写 Jira 再看 diff、占位 Key 上传、误用 MCP 冒充附件等。
- **目标**:在文档层强制 **Step 1→5 线性顺序**、门禁表、红线与 Step 3 交互规则;新增逐步检查清单供 Agent 对照。
- **涉及模块**:`SKILL.md`、`references/workflow.md`、新增 `references/agent_execution_checklist.md`。
- **改动类型**:文档 / 流程约束增强(无脚本行为变更)。
## 2. 方法级改动分析
| 项 | 说明 |
|----|------|
| `SKILL.md` | 前置「AI 执行契约」、各 Step「前置」说明、frontmatter description 强调线性流程。 |
| `workflow.md` | 目录增加「AI 执行顺序提醒」,链回 SKILL 与 checklist。 |
| `agent_execution_checklist.md`(新) | Step 0~5 打勾表 + 走偏速查。 |
**与原有逻辑差异**:仅约束 Agent 阅读与执行顺序;不改变 Python/API 行为。
## 3. 调用方与影响范围
- **调用方**:所有读取本技能的 Agent。
- **破坏性变更**:否。用户手动跑脚本不受影响。
## 4. 风险与回滚
- **风险级别**:低。文档过长可能略增 token;可精简 checklist。
- **回滚方式是否简单**:是(还原 SKILL/workflow、删除 checklist)。
## 5. 验证与测试
- 文档结构与人读一致性自检;无自动化测试。
+266
View File
@@ -0,0 +1,266 @@
---
name: g3fo-commit-jira
description: G3FO 项目 Git 提交规范自动化工具。**AI 必须严格按 Step 1→5 线性执行**(见 SKILL 内「AI 执行契约」),每步完成后再进入下一步;强制 git 修订号校验与 HEAD 校验(amend 场景);Jira 附件仅用 upload_attachment.py。按任务类型生成文档并上传,评论影响分析,最后 amend 追加 Jira、禁止 push。详见 references/agent_execution_checklist.md。
---
# G3FO Commit Jira 技能
本技能通过 git 修订号自动分析改动,绑定/创建 Jira 任务,按**公司 Jira 工单附件文档政策**生成并上传所需文档(开发计划、影响分析、任务摘要),并更新 commit message。
> **公司文档政策**(自 2026-03-09 起):何时附加哪些文件见 `references/jira_commit_docs_policy.md`。
> 报告模板、任务类型判定与 JQL 参考 `references/workflow.md`。
---
## AI 执行契约(防走偏,**必读且优先于即兴发挥**)
### 1. 线性流程,禁止跳步
必须按 **Step 1 → Step 2 → Step 3 → Step 4 → Step 5** 顺序执行。**每完成一步**,在回复中用一句话标明 **`Step N 已完成`**,再进入下一步。禁止在未完成前置步骤时执行后续操作。
| 在未完成… | 禁止执行… |
|-----------|-----------|
| **Step 1**:`git rev-parse <hash>` 成功;若需 amend,已确认该 hash **就是当前 HEAD** | 调用 Jira、生成报告正文、落盘 `<KEY>_*.md`、`git commit --amend` |
| **Step 2**:已对照政策表写出**任务类型** + 三文档各是否需要(✅/❌) | 批量生成无关文档,或该写报告却跳过 |
| **Step 3**:已持有**真实** Jira Issue Key(用户给出 / 用户从列表选定 / 新建命令返回的 key) | 以真实路径上传附件、写绑定该 Issue 的评论(禁止用 `JIRA-XXX` 等占位 Key 落盘上传) |
| **Step 4**:本任务在 Jira 侧应做的上传/评论/建单已按政策做完 | `git commit --amend` |
### 2. 红线(违反即视为流程错误)
1. **禁止 `git push`**。本技能只做到 amend 为止。
2. **禁止跳过** `git rev-parse <hash>`;amend 场景下禁止在 **hash ≠ HEAD** 时仍执行 amend。
3. **禁止 amend 已推送的提交**(除非用户明确要求并确认风险)。使用 `git branch -r --contains <hash>` 检测。
4. **禁止用 Atlassian MCP 或其它方式冒充「已上传 MD 附件」**;上传文件 **必须** 使用 `scripts/upload_attachment.py`(MCP 无可靠上传能力时不得虚构成功)。
5. **禁止**在用户未选定 Issue、也未完成新建并取得 Key 的情况下,把某 Key 写进 commit message。
6. **禁止**未读 `references/jira_commit_docs_policy.md`(或本 SKILL 中的条件表)就默认「三份全要」或「一律不要文档」。
7. **禁止**在用户已提供 Jira 编号时仍执行搜索、展示列表或创建新 Issue。
### 3. Step 3 交互规则(易走偏)
- 用户**已给** Jira 编号 → **直接进入 Step 4**,Key = 用户给的编号,**禁止执行搜索、展示列表或创建新 Issue**。
- 用户**未给** → 搜索展示列表后,**必须等待用户输入 1~N 或 0**(或明确同意新建),**禁止**擅自替用户选一个 Issue 绑定。
- 搜索为空 → 可进入新建流程;新建成功后 Key 以 API 返回为准。
### 4. 自检
逐步执行时可对照 **`references/agent_execution_checklist.md`** 逐项确认。
---
## 前置要求
**1. 强制提供 Git 修订号 (hash)**
用户必须提供一个有效的 git 修订号(如 `HEAD` 或具体的 `hash`)。
- **校验逻辑**:使用 `git rev-parse <hash>` 检查修订号是否存在。
- **错误处理**:如果修订号无效或找不到,**必须** 停止操作并提示用户:"找不到修订号 `<hash>`,请提供正确的 git 修订号(例如 HEAD 或 7 位以上的 commit hash)。"
- **路径要求**:必须在相关的项目根目录下执行 git 命令。如果当前目录不是 git 仓库或不是目标项目,请提示用户切换到正确的项目路径。
- **已 push 提交检测**:使用 `git branch -r --contains <hash>` 检查提交是否已推送到远程。如果已推送,**默认跳过 Step 5(amend)**,仅完成 Jira 绑定和文档上传,并告知用户:
> "提交 `<hash>` 已推送到远程,无法 amend。已完成 Jira 绑定和文档上传。如需修改 commit message,请手动处理或提供特别说明。"
- **例外**:如果用户明确要求修改已 push 的提交(如通过 `--force` 或其他方式),需用户确认风险后再执行。
**2. Jira 编号 (可选)**
用户可以主动提供 Jira 编号(如 `G3SF-123`)。
- 如果提供了 Jira 编号,**直接使用该编号,跳过「查找/选择/新建 Jira 任务」的步骤**,进入 Step 4 上传文档和评论。
- 如果未提供,则按流程自动查找或提示用户新建。
**3. Jira 访问方式(二选一)**
| 方式 | 适用场景 |
|------|----------|
| **REST 脚本(推荐通用)** | 任意 IDE;仅需 `jira_upload.env`(与上传附件**同一套** Token)。查 Issue、新建 Task、写评论、上传附件**全部**可走脚本。 |
| **Atlassian MCP** | 仅 Cursor 等已安装并授权 Atlassian 插件的环境;可与脚本混用(附件仍必须用脚本)。 |
**无 MCP 时**:必须配置 `jira_upload.env`,并用 `scripts/jira_cli.py` 完成 Step 3/4 中的查询、创建与评论(见下文「无 MCP 执行要点」)。
**有 MCP 时**:可用 MCP 搜索/创建/评论;或仍用 `jira_cli.py`(行为一致,便于脚本化)。
**4. `jira_upload.env`(上传附件 + 无 MCP 时全部 Jira API)**
- 在技能目录 `skills/g3fo-commit-jira/` 放置 `jira_upload.env`(或 `--config` 指定),包含:`JIRA_BASE_URL`、`JIRA_EMAIL`、`JIRA_API_TOKEN`。示例见 `jira_upload.env.example`,说明见 `references/env_config.md`。
- **上传附件**:`scripts/upload_attachment.py`(MCP 不支持上传文件)。
- **无 MCP 时查 Jira / 建单 / 评论**:`scripts/jira_cli.py`,与上传使用**相同** Token,详见 `references/env_config.md` 与 `references/workflow.md`「无 MCP / jira_cli」。
- 若未配置:无法调用 Jira API;须提示用户创建 `jira_upload.env` 或在本机 Cursor 使用 MCP。
---
## 执行步骤(严格顺序)
> **提醒**:仅当上一节「执行契约」中本步的前置条件已满足时,才执行本节对应步骤。
### Step 1 - 获取提交内容
```powershell
git show <hash> # 获取 diff + 元数据
git log -1 --format="%B" <hash> # 获取原始 commit message
git branch -r --contains <hash> # 检查是否已推送到远程(有输出则已 push)
```
分析要点:
- 涉及的服务模块/包名
- 新增/修改/删除的方法与接口
- 逻辑改动的核心目的
- **是否已推送**:如果 `git branch -r --contains <hash>` 有输出,说明已推送,Step 5 将跳过 amend
### Step 2 - 判定任务类型并确定需生成的文档
**前置**:Step 1 已完成。
根据改动内容与 `references/jira_commit_docs_policy.md` 中的**详细条件表**,先判定任务类型,再决定需要生成并上传的文档(须在回复中写明类型与三文档要/不要):
| 任务类型 | 开发计划 | 影响分析 | 任务摘要 |
|----------|:--------:|:--------:|:--------:|
| 漏洞修复——简单(如拼写、界面对齐) | ❌ | ❌ | ❌ |
| 漏洞修复——中等(范围有限的逻辑错误) | ❌ | ✅ | ❌ |
| 漏洞修复——复杂/高风险(并发、核心模块) | ✅ | ✅ | ✅ |
| 小幅度增强(如小效用方法) | ❌ | ✅ | ❌ |
| 新功能/模块 | ✅ | ✅ | ✅ |
| 主要重构 | ✅ | ✅ | ✅ |
| 配置/基础设施变更 | ❌ | ✅ | ❌ |
- **无需任何文档**时:仅执行 Step 3(确定 Jira)、Step 5(amend),不生成报告、不写评论、不上传附件;仍将 Jira 编号追加到 commit message。
- **需要文档**时:继续 Step 2 下半部分,生成对应内容。
### Step 2(续)- 生成所需报告内容
对上述判定为"需要"的文档,按 `references/workflow.md` 中的模板生成内容(可先存于内存或临时文件):
- **开发计划**:步骤、设计方法、技术决策。
- **影响分析**:改动概览、方法级分析、影响范围、风险与回滚、验证与测试(模板见 workflow.md)。
- **任务摘要**:所完成工作的简要回顾,以及任何偏离计划之处。
生成后先不按"公司附件名"落盘,等取得 Jira 编号后再以 `<JIRA-ID>_*.md` 命名保存并上传。
**文档保存路径规则**:
- 所有生成的 MD 文档统一保存到项目根目录下的 `doc/{日期}` 文件夹
- 日期格式为 `YYYY-MM-DD`(如 `doc/2026-03-26`)
- 若该文件夹不存在,**必须先创建**再保存文件
- 示例路径:`doc/2026-03-26/G3SF-123_Impact_Analysis.md`
### Step 3 - 确定 Jira 任务
**前置**:Step 2 已完成(至少已判定文档需求;若需文档,可先草稿内容,**真实 Key 确定后再按 `<KEY>_*.md` 保存**)。
**1. 如果用户已提供 Jira 编号:**
- **直接使用该编号**(如 `G3SF-123`),**跳过查找、选择和新建流程**,直接进入 Step 4。
- 不执行 JQL 搜索,不展示列表,不创建新 Issue。
**2. 如果用户未提供 Jira 编号:**
- 使用 JQL 查找当前用户在 G3SF 项目下的进行中任务(limit 5):
```
project = G3SF AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC
```
- **有 MCP**:用 MCP 的 JQL 搜索,展示列表。
- **无 MCP**:执行(PowerShell 用 `;` 分隔):
```powershell
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" search --limit 5
```
可加 `--format json` 供解析。展示列表供用户选择,或输入 `0` 新建。
- 如果搜索结果为空,自动进入新建流程。
### Step 4 - 写入 Jira(附件 + 评论)
**前置**:已持有本任务最终 **Issue Key**(Step 3)。
**附件命名规范(公司要求)**:上传到 Jira 的 MD 文件名必须为
`<JIRA-ID>_Dev_Plan.md`、`<JIRA-ID>_Impact_Analysis.md`、`<JIRA-ID>_Task_Summary.md`。
根据 Step 2 判定结果,仅上传“需要”的文档;每个文件先按该命名写入本地再上传(脚本以本地文件名为 Jira 附件名)。
**如果选择/新建的任务 ID > 0:**
1. **保存并上传附件**(已配置 `jira_upload.env` 且本任务需要文档时):
将 Step 2 生成的各文档内容,按公司规范命名写入项目 `doc/{日期}` 目录(日期格式 `YYYY-MM-DD`,若不存在则先创建),再调用上传脚本。PowerShell 中不要用 `&&`,改用 `;` 或换行。
```powershell
# 创建日期目录(若不存在)
$dateFolder = "doc/$(Get-Date -Format 'yyyy-MM-dd')"
if (-not (Test-Path $dateFolder)) { New-Item -ItemType Directory -Path $dateFolder -Force }
# 示例:需要三份文档时,先写入 G3SF-123_Dev_Plan.md、G3SF-123_Impact_Analysis.md、G3SF-123_Task_Summary.md,再:
python skills/g3fo-commit-jira/scripts/upload_attachment.py --config "skills/g3fo-commit-jira/jira_upload.env" --issue "G3SF-123" --file "$dateFolder/G3SF-123_Impact_Analysis.md" --file "$dateFolder/G3SF-123_Dev_Plan.md" --file "$dateFolder/G3SF-123_Task_Summary.md"
```
若本任务仅需影响分析,则只写入并上传 `G3SF-123_Impact_Analysis.md`。未配置 `jira_upload.env` 时跳过上传;有 MCP 时可仅写评论,无 MCP 则必须配置 env 才能完成评论。
2. **写评论**(有影响分析正文时):
- **MCP**:`jira_add_comment`,正文为影响分析报告(插件侧多为 Markdown 渲染)。
- **无 MCP**:`jira_cli.py comment/create` 会将常见 **Markdown**(`#`~`######` 标题、`-`/`1.` 列表、`**粗体**`、`` `代码` ``、代码块、`[链](url)` 等)转为 **Jira ADF**,在网页上与富文本一致;仍建议长文用 `--body-file`。将报告写入临时文件后:
```powershell
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" comment --issue "G3SF-123" --body-file "path\to\impact_body.md"
```
或使用 `--body "..."`(长文建议 `--body-file`)。
3. 记录该任务的 issue key。
**如果用户选择 0 或搜索无结果:**
- **MCP**:调用 `jira_create_issue`(见 `references/workflow.md`)。
- **无 MCP**:使用 `jira_cli.py create`(经办人默认为 Token 对应用户):
```powershell
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" create --summary "[G3SF] 提炼后的改动摘要" --description-file "path\to\impact.md"
```
脚本会打印新 issue key(如 `G3SF-123`);可加 `--format json` 解析 `key` 字段。
**新建 Issue 字段约定**(两种途径均需遵守):
- project: `G3SF`,issuetype: `Task`
- **Summary**:`[G3SF]` 前缀 + 一句话摘要
- **Description**:完整影响分析(Markdown 可先写入文件再用 `--description-file`)
- **Assignee**:当前用户(MCP 传 accountId;`jira_cli` 默认 `assignee` = API Token 对应账号)
- 若需要文档,创建成功后按公司命名上传对应 MD 到该 Issue
### Step 5 - 更新 Git 提交信息
**前置**:Step 4 已按政策完成(无需文档的绑定类任务可跳过上传/评论,但须已有 Key)。
**检查提交是否已推送**:
```powershell
git branch -r --contains <hash>
```
- 如果有输出,说明提交已推送到远程分支,**跳过 amend**,告知用户:
> "提交 `<hash>` 已推送到远程,无法 amend。已完成 Jira 绑定和文档上传。如需修改 commit message,请手动处理或提供特别说明。"
- 如果无输出,说明提交未推送,继续执行 amend。
**执行 amend(仅当未推送时)**:
使用获取到的 Jira 编号,通过 `git commit --amend` 更新 commit message。**严禁 push**。
**注意**:在 Windows PowerShell 环境下,需确保 UTF8 编码以防止中文乱码。
```powershell
# 设置 UTF8 编码
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$origMsg = (git log -1 --format="%B").TrimEnd()
$jiraKey = "G3SF-123" # 实际获取到的 key
# 使用 --cleanup=verbatim 确保原始消息格式不被 git 自动修剪导致编码转换问题
git commit --amend -m "$origMsg`n`n**** Jira $jiraKey" --cleanup=verbatim
```
完成后告知用户:
**未推送时**:
```
绑定成功:
Jira: G3SF-123 (https://your-jira/browse/G3SF-123)
Commit: <git log --oneline -1 的结果>
已追加 Jira 编号到 commit message
```
**已推送时**:
```
绑定完成(提交已推送,未修改 commit message):
Jira: G3SF-123 (https://your-jira/browse/G3SF-123)
Commit: <git log --oneline -1 的结果>
提示:提交已推送到远程,如需修改 commit message 请手动处理
```
---
## 相关参考
- **AI 逐步检查清单(防走偏)**:`references/agent_execution_checklist.md`
- **公司 Jira 附件文档政策**(何时附加哪些文件):`references/jira_commit_docs_policy.md`
- **报告模板、三文档说明与错误处理**:`references/workflow.md`
- **上传附件配置**:`references/env_config.md`;脚本 `scripts/upload_attachment.py`,依赖见 `scripts/requirements.txt`
+32
View File
@@ -0,0 +1,32 @@
# 任务总结 — g3fo-commit-jira 增强
## 任务信息
- **任务**:无 Atlassian MCP 时仍可通过同一 Jira Token 查 Jira、建单、评论。
- **范围**:`skills/g3fo-commit-jira`。
## 改动说明
- 新增 `scripts/jira_cli.py`(search / issue / create / comment / myself)。
- 新增 `scripts/jira_env.py`,`upload_attachment.py` 复用配置加载;支持在技能目录放置 `jira_upload.env`。
- 更新 `SKILL.md`、`references/workflow.md`、`references/env_config.md`。
## 影响与风险
- 无破坏性变更;风险低。详见 `IMPACT_ANALYSIS.md`。
## 测试
- 本地语法与 CLI `--help` 已验证;连通性依赖用户 `jira_upload.env`。
## 后续
- 若需同步到全局技能目录,可使用项目内 `skill-sync` 流程。
---
## 任务总结 — AI 流程防走偏(文档增强)
- **目标**:减少 Agent 跳步、误上传、未等用户选 Issue 等偏离。
- **改动**:`SKILL.md` 增加「AI 执行契约」与各 Step 前置条件;新增 `references/agent_execution_checklist.md`;`workflow.md` 增加执行顺序提醒。
- **测试**:文档审阅;逻辑无代码变更。
@@ -0,0 +1,6 @@
# Jira 上传附件配置示例
# 复制为 jira_upload.env 并填入真实值,勿提交 jira_upload.env 到 Git
JIRA_BASE_URL=https://你的站点.atlassian.net
JIRA_EMAIL=你的邮箱@company.com
JIRA_API_TOKEN=你的 API Token
@@ -0,0 +1,80 @@
# AI 执行检查清单(g3fo-commit-jira)
执行本技能时建议 **边做边勾**( mentally 或写在回复里),避免跳步、顺序颠倒或误用工具。
---
## 开始前(Step 0)
- [ ] 用户已提供 **git 修订号**(如 `HEAD` 或完整/短 hash)
- [ ] 在 **目标业务仓库根目录** 执行 git(`git rev-parse --is-inside-work-tree` 为真)
- [ ] 已执行 `git rev-parse <hash>`,修订号存在
- [ ] 若要对提交做 amend:已确认 `git rev-parse HEAD` **等于** `git rev-parse <hash>`(否则停止,提示用户)
- [ ] 已检查 `git branch -r --contains <hash>`,确认提交是否已推送(已推送则跳过 amend)
---
## Step 1 — 获取提交内容
- [ ] 已运行 `git show <hash>`(或等效)并理解改动范围
- [ ] 已记录原始 commit message(供 Step 5)
**未完成 Step 0~1 前:禁止** 写 Jira、生成报告、amend。
---
## Step 2 — 任务类型与文档
- [ ] 已阅读并对照 `references/jira_commit_docs_policy.md`(或 SKILL 中的条件表)
- [ ] 已明确写出:**任务类型** + **开发计划 / 影响分析 / 任务摘要** 各是否需要(✅/❌)
- [ ] 若需要文档:已按 `workflow.md` 模板准备内容(可先草稿,**真实 Jira Key 出来后再按名落盘**)
**禁止**:未判定类型就上传三份或一份都不写却写长评论(应与政策一致)。
---
## Step 3 — Jira Key
- [ ] 已有 **真实 Issue Key**(如 `G3SF-123`):来自用户直给、用户从列表选择、或 `create` 成功返回
- [ ] 若用户已提供 Jira 编号:**跳过搜索、展示列表和新建流程**,直接使用该编号
- [ ] 若走列表:已 **等用户选 1~N 或 0**,未擅自替用户绑定
**未取得真实 Key 前:禁止** 使用 `<JIRA-ID>_*.md` 落盘上传(禁止占位符 Key)。
**用户已提供 Jira 时:禁止** 执行搜索、展示列表或创建新 Issue。
---
## Step 4 — 写入 Jira
- [ ] 需要附件时:文件名为 `<KEY>_Dev_Plan.md` / `_Impact_Analysis.md` / `_Task_Summary.md`(仅实际上传需要的)
- [ ] 文档保存路径:`doc/{日期}`(日期格式 `YYYY-MM-DD`),若目录不存在则先创建
- [ ] 附件 **仅** 通过 `scripts/upload_attachment.py` + `jira_upload.env`(不用 MCP 冒充上传)
- [ ] 需要影响分析进评论时:MCP `jira_add_comment` 或 `jira_cli.py comment`(与政策一致)
- [ ] 新建 Issue 时:Summary 带 `[G3SF]`,类型 Task,描述/评论与内容一致
---
## Step 5 — Git amend
- [ ] 已检查 `git branch -r --contains <hash>`:若已推送,**跳过 amend**,告知用户
- [ ] 若未推送:仅 `git commit --amend`,**未** `git push`
- [ ] Windows 已按 SKILL 处理 UTF-8 / `--cleanup=verbatim`(如适用)
- [ ] 若 message 已含 `**** Jira`,未重复追加
---
## 走偏速查
| 现象 | 纠正 |
|------|------|
| 先写 Jira 再去看 diff | 回到 Step 1 |
| 没有 Key 就上传 | 先完成 Step 3 |
| 用 MCP 上传 md | 改用 `upload_attachment.py` |
| 简单拼写改却写三份文档 | 重读政策表,按类型裁剪 |
| 执行了 push | 违反技能范围,后续勿再 push |
| amend 已推送的提交 | 先检查 `git branch -r --contains`,已推送则跳过 amend |
| 用户已给 Jira 却还搜索/新建 | 直接使用用户提供的编号,跳过 Step 3 的搜索流程 |
---
**原则**:顺序 = 1 → 2 → 3 → 4 → 5;每步通过后再进入下一步;不确定时重读 `SKILL.md` 本节与 `workflow.md` 错误处理表。
@@ -0,0 +1,100 @@
# Jira 上传附件 — 配置文件说明
本技能通过脚本 `scripts/upload_attachment.py` 将影响分析报告 MD 文件上传到 Jira Issue 附件(Atlassian MCP 插件不支持上传文件)。脚本从**配置文件**或环境变量读取 Jira 认证与站点信息,环境变量可覆盖配置文件中的同名项。
---
## 配置文件方式(推荐)
### 1. 创建配置文件
在技能目录 `skills/g3fo-commit-jira/` 下放置 `jira_upload.env`,或复制示例后改名并填写真实值:
```powershell
# 在技能目录下
copy jira_upload.env.example jira_upload.env
# 编辑 jira_upload.env,填入 JIRA_BASE_URL、JIRA_EMAIL、JIRA_API_TOKEN
```
### 2. 文件格式
`jira_upload.env` 为 KEY=VALUE 格式,支持 `#` 注释:
```ini
# Jira 站点 URL,不要带末尾 /
JIRA_BASE_URL=https://你的站点.atlassian.net
# 登录邮箱
JIRA_EMAIL=你的邮箱@company.com
# API Token(在 Atlassian 账户设置中创建)
JIRA_API_TOKEN=你的 API Token
```
### 3. 指定配置文件路径
从仓库根目录执行时,建议用 `--config` 指定技能目录下的配置文件:
```powershell
python skills/g3fo-commit-jira/scripts/upload_attachment.py --config "skills/g3fo-commit-jira/jira_upload.env" --issue G3SF-123 --file "G3SF-123_Impact_Analysis.md"
```
---
## 配置项说明
| 配置项 | 说明 | 示例 |
|--------|------|------|
| `JIRA_BASE_URL` | Jira 站点 URL,不要带末尾 `/` | `https://your-domain.atlassian.net` |
| `JIRA_EMAIL` | 登录 Jira 的邮箱 | `you@company.com` |
| `JIRA_API_TOKEN` | Jira 账户的 API Token | 在 [Atlassian API Tokens](https://id.atlassian.com/manage-profile/security/api-tokens) 创建 |
---
## 无 MCP:`jira_cli.py`(查 Jira / 建单 / 评论)
在 **未安装 Atlassian MCP** 的环境(其它 IDE、终端)中,使用与上传附件**相同**的 `jira_upload.env`,通过 `scripts/jira_cli.py` 调用 Jira REST API v3:
| 子命令 | 作用 |
|--------|------|
| `myself` | 当前用户 accountId / 邮箱 |
| `search` | JQL 搜索(默认:G3SF 进行中且指派给当前用户) |
| `issue <KEY>` | 查看单条 Issue |
| `create` | 创建 Issue(默认 G3SF + Task,经办人为 Token 用户) |
| `comment` | 添加评论 |
依赖:`pip install -r scripts/requirements.txt`(仅需 `requests`)。
```powershell
# 搜索(与技能 Step 3 默认 JQL 一致)
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" search --limit 5
# 新建(描述来自文件)
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" create --summary "[G3SF] 简述" --description-file "D:\tmp\impact.md"
# 评论
python skills/g3fo-commit-jira/scripts/jira_cli.py --config "skills/g3fo-commit-jira/jira_upload.env" comment --issue G3SF-123 --body-file "D:\tmp\comment.md"
```
配置文件查找顺序:当前工作目录、`scripts/` 下、`skills/g3fo-commit-jira/` 下的 `jira_upload.env`。详细参数见 `references/workflow.md`「无 MCP:jira_cli 速查」。
---
## 环境变量覆盖
若同时存在配置文件和环境变量,**环境变量优先**。
---
## API Token 获取
1. 打开 https://id.atlassian.com/manage-profile/security/api-tokens
2. 使用 Jira 登录邮箱登录
3. 点击 “Create API token”,命名后复制
4. Token 只显示一次,请妥善保存
---
## 安全注意
- **不要将 `jira_upload.env` 提交到 Git**。技能目录下已通过 `.gitignore` 忽略。
- 不要将 `JIRA_API_TOKEN` 写入代码或提交到仓库。
- 定期在 Atlassian 账户中轮换 API Token。
@@ -0,0 +1,54 @@
# Jira 工单附件文档政策(公司强制性指南)
自 **2026年3月9日** 起,以下为将 **开发计划**、**影响分析**、**任务摘要** 附加到 Jira 工单的强制性指南,用于跟踪设计决策、评估风险、为未来参考提供背景及项目间协作。
---
## 一般规则(经验法则)
| 场景 | 需要附加的文档 |
|------|----------------|
| **漏洞修复** | 仅包括 **影响分析**(若修复有非简单副作用) |
| **新功能 / 重大核心变更** | **全部三个文件**:开发计划、影响分析、任务摘要 |
| **琐碎 / 小改动** | 无需文件 |
---
## 详细条件(按任务类型)
| 任务类型 | 开发计划 | 影响分析 | 任务摘要 |
|----------|:--------:|:--------:|:--------:|
| 漏洞修复——简单(如拼写错误、界面对齐) | ❌ | ❌ | ❌ |
| 漏洞修复——中等(如范围有限的逻辑错误) | ❌ | ✅ | ❌ |
| 漏洞修复——复杂/高风险(如并发、核心模块) | ✅ | ✅ | ✅ |
| 小幅度增强(如添加小效用方法) | ❌ | ✅ | ❌ |
| 新功能/模块 | ✅ | ✅ | ✅ |
| 主要重构 | ✅ | ✅ | ✅ |
| 配置/基础设施变更 | ❌ | ✅ | ❌ |
---
## 文档含义说明
- **开发计划**:概述步骤、设计方法和技术决策。
- **影响分析**:列出受影响区域、风险、性能考虑因素及向后兼容性。
- **任务摘要**:简要回顾所完成的工作,包括任何偏离计划的地方。
---
## 提交方式与命名规范
- 将文件(Markdown)**直接放在工单的附件部分**。
- 使用一致的命名规范:
- `<JIRA-ID>_Dev_Plan.md`
- `<JIRA-ID>_Impact_Analysis.md`
- `<JIRA-ID>_Task_Summary.md`
示例:工单 `G3SF-123` 的三个附件命名为
`G3SF-123_Dev_Plan.md`、`G3SF-123_Impact_Analysis.md`、`G3SF-123_Task_Summary.md`。
**本地保存路径**:
- 所有文档统一保存到项目根目录下的 `doc/{日期}` 文件夹
- 日期格式为 `YYYY-MM-DD`(如 `doc/2026-03-26`)
- 若该文件夹不存在,**必须先创建**再保存文件
- 完整路径示例:`doc/2026-03-26/G3SF-123_Impact_Analysis.md`
@@ -0,0 +1,413 @@
# G3FO Commit Jira — 详细工作流参考
## 目录
0. [AI 执行顺序提醒](#ai-执行顺序提醒)
1. [公司 Jira 附件文档政策](#公司-jira-附件文档政策)
2. [Step 1: 获取并解析提交内容](#step-1-获取并解析提交内容)
3. [Step 2: 任务类型判定与三文档模板](#step-2-任务类型判定与三文档模板)
4. [Step 3: Jira 任务查找与确认](#step-3-jira-任务查找与确认)
5. [Step 4: Jira 字段映射与写入](#step-4-jira-字段映射与写入)
6. [Step 5: Git Amend 命令详解](#step-5-git-amend-命令详解)
7. [错误处理表](#错误处理表)
8. [Atlassian MCP 工具速查](#atlassian-mcp-工具速查)
---
## AI 执行顺序提醒
与 **`SKILL.md` 中「AI 执行契约」** 一致:必须 **Step 1 → 2 → 3 → 4 → 5**,不可跳步。附件仅通过 **`upload_attachment.py`**;**禁止 push**。
逐步打勾请用 **`references/agent_execution_checklist.md`**。
---
## 公司 Jira 附件文档政策
自 **2026年3月9日** 起,何时附加「开发计划」「影响分析」「任务摘要」以公司强制性指南为准。
完整政策与详细条件表见 **`references/jira_commit_docs_policy.md`**。
- **附件命名规范**:上传到 Jira 的 MD 必须命名为
`<JIRA-ID>_Dev_Plan.md`、`<JIRA-ID>_Impact_Analysis.md`、`<JIRA-ID>_Task_Summary.md`
例如:`G3SF-123_Impact_Analysis.md`。
- 根据任务类型仅生成并上传“需要”的文档;无需文档的琐碎改动仍可绑定 Jira 并 amend,但不生成报告、不写评论、不上传附件。
---
## Step 1: 获取并解析提交内容
### 命令
```powershell
# 检查 hash 是否为 HEAD
$head = git rev-parse HEAD
$provided = git rev-parse <hash>
if ($head -ne $provided) { Write-Error "提交不是 HEAD,无法 amend" }
# 检查提交是否已推送到远程
git branch -r --contains <hash>
# 如果有输出,说明已推送,Step 5 将跳过 amend
# 获取完整 diff 和元数据
git show <hash>
# 仅获取原始 commit message(用于后续 amend 保留)
git log -1 --format="%B" <hash>
# 列出修改的文件(不含 diff 内容,快速预览)
git diff-tree --no-commit-id -r --name-status <hash>
```
### 解析要点
从 `git show` 输出中提取:
| 信息 | 说明 |
|------|------|
| `--- a/path` / `+++ b/path` | 修改的文件路径 |
| `@@ ... @@` 上下文 | 修改的类名、方法名 |
| `+` 行 | 新增代码(方法签名、接口、配置) |
| `-` 行 | 删除/替换代码 |
| 文件路径中的包名 | 推断所属服务(`g3fo-trade-service`, `g3fo-margin-service` 等) |
| `git branch -r --contains` 输出 | 是否已推送(有输出 = 已推送,跳过 amend) |
G3FO 服务包路径规律:
- `com.afe.g3fo.<service-short-name>.*` → 对应服务
- `controller` / `api` → REST 接口层
- `facade` / `facade.impl` → Dubbo 接口层
- `service` / `service.impl` → 业务逻辑层
---
## Step 2: 任务类型判定与三文档模板
### 2.1 按任务类型确定需生成的文档
| 任务类型 | 开发计划 | 影响分析 | 任务摘要 |
|----------|:--------:|:--------:|:--------:|
| 漏洞修复——简单(如拼写、界面对齐) | ❌ | ❌ | ❌ |
| 漏洞修复——中等(范围有限的逻辑错误) | ❌ | ✅ | ❌ |
| 漏洞修复——复杂/高风险(并发、核心模块) | ✅ | ✅ | ✅ |
| 小幅度增强(如小效用方法) | ❌ | ✅ | ❌ |
| 新功能/模块 | ✅ | ✅ | ✅ |
| 主要重构 | ✅ | ✅ | ✅ |
| 配置/基础设施变更 | ❌ | ✅ | ❌ |
生成内容后,在**取得 Jira 编号后**按公司规范保存为 `<JIRA-ID>_Dev_Plan.md` / `_Impact_Analysis.md` / `_Task_Summary.md` 再上传。无需文档时跳过生成与上传,仅绑定 Jira 并 amend。
### 2.2 影响分析报告模板
生成影响分析时严格按以下结构输出(Markdown 格式):
```markdown
## 影响分析报告
### 1. 改动概览
- **背景与目标**:[说明为什么改、解决什么问题]
- **涉及模块**:[列举受影响的服务/模块,如 g3fo-trade-service、g3fo-margin-service]
- **改动类型**:功能新增 / 缺陷修复 / 重构 / 配置变更(选一或多个)
### 2. 方法级改动分析
| 文件/类 | 方法/接口 | 改动说明 | 行为是否变更 |
|---------|----------|---------|------------|
| XxxService.java | `calculateMargin()` | 新增空值校验 | 否(边界补强) |
### 3. 调用方与影响范围
| 调用方 | 文件路径 | 影响说明 |
|--------|---------|---------|
| XxxController | controller/XxxController.java | 入参校验增强,现有调用方兼容 |
- **破坏性变更**:是 / 否
- 如为"是",具体变更点:[列出方法签名、返回值、异常类型变化]
### 4. 风险与回滚
- **风险级别**:低 / 中 / 高
- **风险说明**:[说明原因]
- **回滚方式**:回退版本 / 关闭特性开关 / 回滚配置
- **回滚方式是否简单**:是 / 否
### 5. 验证与测试
- **已执行测试**:单测 / 集成测试 / 冒烟测试(选已执行的)
- **关键用例**:[简述关键场景及预期结果]
- **需线上观察**:[列出需关注的日志关键字、监控指标或告警项]
```
> 如果 diff 信息不足以确认调用方,在报告中注明:「建议在代码库中搜索 `<方法名>` 确认所有调用方」。
### 2.3 开发计划模板(仅当任务类型要求时生成)
开发计划用于概述步骤、设计方法和技术决策。结构示例:
```markdown
## 开发计划
### 1. 目标与范围
- **目标**:[本任务要达成的结果]
- **范围**:[涉及模块、接口、配置等]
### 2. 步骤与设计方法
1. [步骤一:如需求分析、接口设计]
2. [步骤二:如实现方案、关键类/方法]
3. [步骤三:如测试与验证]
### 3. 技术决策
- [关键技术选型或实现方式及理由]
- [与现有架构的衔接方式]
```
### 2.4 任务摘要模板(仅当任务类型要求时生成)
任务摘要用于简要回顾所完成的工作,包括任何偏离计划的地方。结构示例:
```markdown
## 任务摘要
### 1. 完成内容
- [已完成的主要改动与交付物]
### 2. 与计划差异(如有)
- [若实际实现与开发计划有偏差,在此说明原因与结果]
### 3. 后续建议(可选)
- [遗留事项、后续优化或文档更新建议]
```
---
## Step 3: Jira 任务查找与确认
### 用户已提供 Jira 编号
如果用户已提供 Jira 编号(如 `G3SF-123`):
- **直接使用该编号**,跳过 JQL 搜索、列表展示和新建流程
- **禁止**执行搜索、展示列表或创建新 Issue
- 直接进入 Step 4 上传文档和评论
### 用户未提供 Jira 编号
执行 JQL 查询:
### JQL 查询
```
project = G3SF AND assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC
```
- 限制返回 5 条,避免信息过载
- 若返回 0 条,直接告知用户并自动进入新建流程
### 展示格式
```
找到以下 G3SF 进行中任务,请输入编号绑定,或输入 0 新建:
1. G3SF-101 [修复保证金计算逻辑] 状态: In Progress
2. G3SF-98 [交易服务批量撤单性能优化] 状态: To Do
3. G3SF-95 [用户服务登录接口重构] 状态: In Progress
0. 新建一个 G3SF Task
请输入 (1/2/3/0):
```
### 确认规则
| 用户输入 | 行为 |
|---------|------|
| `1`~`N` | 使用对应任务,写入评论 |
| `0` | 新建 Jira Task |
| `n` / `no` | 等同于 `0`,新建任务 |
| 其他 | 重新提问一次 |
---
## Step 4: Jira 字段映射与写入
### 新建 Issue 字段
调用 `jira_create_issue`(或鉴权后发现的等效工具),传入:
| 字段 | 值 |
|------|----|
| `project` | `G3SF` |
| `issuetype` | `Task` |
| `summary` | **必须带 `[G3SF]` 前缀**,格式:`[G3SF] 提炼后的一句话描述`(≤100 字符) |
| `description` | 完整影响分析报告(Markdown / ADF 格式) |
| `assignee` | **必须为当前登录账号**(传入当前用户 accountId/name,确保新建任务经办人为自己) |
Summary 生成规则:
- **标题必须统一加前缀**:`[G3SF]` + 正文。例如:`[G3SF] [g3fo-trade-service] 修复批量撤单时的并发锁问题`
- 正文优先使用原始 commit message 的第一行(若描述清晰),否则根据修改内容生成:`[服务名] 功能描述`
新建 Issue 成功后,若已配置 `jira_upload.env` 且本任务需要文档,按公司命名将对应 MD 写入本地后上传到该新 Issue。
### 保存报告为 MD 并上传附件
1. **按公司规范命名并保存**:取得 Jira 编号(如 `G3SF-123`)后,将 Step 2 生成的各文档内容分别写入本地文件,文件名必须为:
- `<JIRA-ID>_Dev_Plan.md`
- `<JIRA-ID>_Impact_Analysis.md`
- `<JIRA-ID>_Task_Summary.md`
仅保存本任务类型要求的那几份(见 [2.1 按任务类型确定需生成的文档](#21-按任务类型确定需生成的文档))。
**保存路径规则**:
- 所有文档统一保存到项目根目录下的 `doc/{日期}` 文件夹
- 日期格式为 `YYYY-MM-DD`(如 `doc/2026-03-26`)
- 若该文件夹不存在,**必须先创建**再保存文件
- 完整路径示例:`doc/2026-03-26/G3SF-123_Impact_Analysis.md`
2. **上传到 Jira 附件**:Atlassian MCP 不支持上传文件,使用本技能自带脚本上传上述 MD 到当前 Issue:
- 配置文件:在技能目录下配置 `jira_upload.env`(`JIRA_BASE_URL`、`JIRA_EMAIL`、`JIRA_API_TOKEN`),详见 `references/env_config.md`。
- 命令示例(PowerShell 用 `;` 连接,勿用 `&&`),按需上传多文件:
```powershell
# 创建日期目录(若不存在)
$dateFolder = "doc/$(Get-Date -Format 'yyyy-MM-dd')"
if (-not (Test-Path $dateFolder)) { New-Item -ItemType Directory -Path $dateFolder -Force }
python skills/g3fo-commit-jira/scripts/upload_attachment.py --config "skills/g3fo-commit-jira/jira_upload.env" --issue "G3SF-101" --file "$dateFolder/G3SF-101_Impact_Analysis.md" --file "$dateFolder/G3SF-101_Dev_Plan.md" --file "$dateFolder/G3SF-101_Task_Summary.md"
```
- 未配置 `jira_upload.env` 时跳过上传,仅写评论。
### 写入已有 Issue(评论)
调用 `jira_add_comment`(或等效工具),字段:
| 字段 | 值 |
|------|----|
| `issue_key` | 用户选择的 issue key,如 `G3SF-101` |
| `body` | 完整影响分析报告(与已保存的 MD 内容一致) |
### Jira 描述格式兼容
部分 Jira 实例使用 Atlassian Document Format (ADF) 而非纯 Markdown。若 MCP 工具提示格式错误,则:
- 将 Markdown 内容作为纯文本写入
- 或使用 `{code}` 块包裹(Jira Wiki 格式兼容)
---
## Step 5: Git Amend 命令详解
### 检查提交是否已推送
在执行 amend 前,必须检查提交是否已推送到远程:
```powershell
# 检查提交是否已推送
$pushedBranches = git branch -r --contains <hash>
if ($pushedBranches) {
Write-Host "提交已推送到远程,跳过 amend"
Write-Host "已完成 Jira 绑定和文档上传"
Write-Host "如需修改 commit message,请手动处理或提供特别说明"
# 跳过 amend,直接结束
return
}
```
**已推送的处理**:
- 如果 `git branch -r --contains <hash>` 有输出,说明提交已推送到远程分支
- **跳过 amend**,仅完成 Jira 绑定和文档上传
- 告知用户提交已推送,无法 amend
- 例外:用户明确要求修改已推送的提交时,需确认风险后再执行
### PowerShell(Windows 环境)
在 Windows PowerShell 中,直接使用 `$origMsg` 可能会导致编码问题(乱码)。必须显式指定输出编码为 UTF8。
```powershell
# 设置控制台和输出编码为 UTF8,防止中文乱码
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
# 获取原始 commit message(保留完整内容)
$origMsg = (git log -1 --format="%B").TrimEnd()
# Jira key(从上一步获取)
$jiraKey = "G3SF-123"
# 追加 Jira 行并 amend(不 push)
# 使用 --cleanup=verbatim 确保消息内容不被 git 自动修剪导致编码转换问题
git commit --amend -m "$origMsg`n`n**** Jira $jiraKey" --cleanup=verbatim
```
### Bash/Linux(供参考)
```bash
origMsg=$(git log -1 --format="%B" | sed 's/[[:space:]]*$//')
jiraKey="G3SF-123"
git commit --amend -m "$origMsg
**** Jira $jiraKey"
```
### Amend 注意事项
- `--amend` 只修改 HEAD,不改变工作区内容
- 如果有 GPG 签名要求,告知用户需确认签名
- amend 后 commit hash 会变更,这是正常现象
- **禁止 push**:本 skill 不执行 `git push`,提交操作到 `amend` 为止
### 结果验证
```powershell
git log -1 --format="%B" # 确认 Jira 行已追加
git log --oneline -1 # 查看 commit 摘要
```
---
## 无 MCP:`jira_cli.py` 速查
与 `upload_attachment.py` **共用** `jira_upload.env`(`JIRA_BASE_URL`、`JIRA_EMAIL`、`JIRA_API_TOKEN`)。从仓库根执行时建议 `--config` 指向技能目录下的配置文件。
| 操作 | 命令示例 |
|------|----------|
| 当前用户(accountId) | `python .../jira_cli.py --config ".../jira_upload.env" myself` |
| 默认 JQL 搜索(G3SF 进行中、指派给我) | `python .../jira_cli.py --config "..." search --limit 5` |
| 自定义 JQL | `python .../jira_cli.py --config "..." search --jql "project = G3SF AND key = G3SF-1" --limit 10` |
| 机器可读 | 上述命令加 `--format json` |
| 单条 Issue | `python .../jira_cli.py --config "..." issue G3SF-123` |
| 新建 Task | `python .../jira_cli.py --config "..." create --summary "[G3SF] 标题" --description-file report.md` |
| 新建(内联描述) | `create --summary "..." --description "多段用\n\n分隔"` |
| 添加评论 | `python .../jira_cli.py --config "..." comment --issue G3SF-123 --body-file impact.md` |
| 不上传经办人 | `create ... --no-assign-self`(若站点禁止创建时指定经办人) |
**说明**:`create` 默认 `project=G3SF`、`issuetype=Task`;描述与评论由脚本将 **Markdown** 转为 Jira Cloud **ADF**(`#` 标题、列表、`**粗体**`、代码块等会按富文本展示,不再出现 `###` 原文)。上传 MD 附件仍用 `upload_attachment.py`。
---
## 错误处理表
| 错误场景 | 检测方式 | 处理方式 |
|---------|---------|---------|
| 修订号不存在 | `git rev-parse <hash>` 报错 | **停止**,提示用户提供正确的修订号。 |
| 提交 hash 不是 HEAD | `git rev-parse HEAD` ≠ `git rev-parse <hash>` | 停止,提示用户确认 hash(只有 HEAD 才能 amend)。 |
| 提交已推送到远程 | `git branch -r --contains <hash>` 有输出 | **跳过 amend**,仅完成 Jira 绑定和文档上传,告知用户提交已推送。 |
| 项目路径不正确 | `git rev-parse --is-inside-work-tree` 失败 | 提示用户在正确的 git 项目目录下操作。 |
| 无 MCP 且未配置 jira_upload.env | 无法执行 jira_cli / 上传 | **必须**配置 `jira_upload.env` 或改用 Cursor + MCP。 |
| Atlassian MCP 未鉴权 | `STATUS.md` 有提示 / 工具调用返回 401 | 调用 `mcp_auth`,或改用 `jira_cli.py`。 |
| Atlassian 插件未安装 | `tools/` 目录为空或 MCP 调用失败 | 使用 `jira_cli.py` + `jira_upload.env`,或在 Cursor 安装 Atlassian 插件 |
| Jira 项目 G3SF 不存在 | 创建/搜索返回 404/400 | 提示用户确认 `JIRA_BASE_URL`、项目 key;`create` 可用 `--project` |
| 工作区有未提交变更 | `git status --porcelain` 有输出 | 警告:amend 会将未暂存变更排除在外,建议先 `git add` 或 stash |
| commit message 中已有 Jira 行 | message 中包含 `**** Jira` | 跳过追加,提示用户该提交已绑定 |
| 用户已提供 Jira 编号 | 用户输入包含 Jira key | **跳过搜索和新建**,直接使用用户提供的编号 |
| Jira 搜索无结果 | 工具返回空列表 | 直接进入新建流程,无需用户确认 |
| 未配置 jira_upload.env | 上传前检查配置不存在 | **有 MCP**:跳过上传,仅评论。**无 MCP**:无法完成 Jira 操作,须配置 env 或使用 MCP。 |
| 任务类型判定为「无需文档」 | 按政策表属简单漏洞/琐碎改动 | 不生成报告、不写评论、不上传附件;仍执行 Jira 绑定与 commit --amend,将 Jira 编号追加到 commit message |
---
## Atlassian MCP 工具速查
鉴权成功后,通过列举 `mcps/plugin-atlassian-atlassian/tools/` 确认可用工具。常见工具名:
| 功能 | 可能的工具名 |
|------|------------|
| 搜索 Issue | `jira_search_issues`, `jira_jql_search` |
| 创建 Issue | `jira_create_issue` |
| 获取 Issue 详情 | `jira_get_issue` |
| 添加评论 | `jira_add_comment` |
| 更新 Issue | `jira_update_issue` |
| 获取当前用户 | `jira_get_current_user` |
| 获取项目列表 | `jira_get_projects` |
| 上传附件 | MCP 不支持,使用 `scripts/upload_attachment.py` + `jira_upload.env` |
> 实际工具名以 `tools/` 目录下的 JSON 文件名为准,鉴权后务必先列目录确认。上传附件需使用本技能自带的 Python 脚本,见 `references/env_config.md`。
+673
View File
@@ -0,0 +1,673 @@
#!/usr/bin/env python3
"""
Jira REST API CLI — same credentials as upload_attachment.py (jira_upload.env).
Use when Atlassian MCP is unavailable (other IDEs).
Commands:
myself Current user (accountId for assignee)
search JQL search
issue Get one issue by key
create Create Task (G3SF by default), assign to self
comment Add comment (ADF) to issue
Examples:
python jira_cli.py --config ../jira_upload.env search --limit 5
python jira_cli.py --config ../jira_upload.env issue G3SF-123
python jira_cli.py --config ../jira_upload.env create --summary "[G3SF] fix foo" --description-file report.md
python jira_cli.py --config ../jira_upload.env comment --issue G3SF-123 --body-file impact.md
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
try:
import requests
except ImportError:
print("ERROR: pip install requests", file=sys.stderr)
sys.exit(1)
from jira_env import CONFIG_FILENAME, load_config, require_credentials, session_headers
DEFAULT_JQL = (
"project = G3SF AND assignee = currentUser() AND statusCategory != Done "
"ORDER BY updated DESC"
)
def _empty_doc() -> Dict[str, Any]:
return {
"type": "doc",
"version": 1,
"content": [{"type": "paragraph", "content": [{"type": "text", "text": " "}]}],
}
def _text_nodes(s: str) -> List[Dict[str, Any]]:
"""Single plain text node (non-empty)."""
if not s:
return []
return [{"type": "text", "text": s}]
def parse_inline_adf(s: str) -> List[Dict[str, Any]]:
"""
Parse inline **bold**, ~~strikethrough~~, `code`, *italic*, [text](url), ***bold italic***
into ADF text nodes with marks.
"""
if not s:
return []
# Order: links, bold+italic, bold, strikethrough, italic, code (non-greedy).
pattern = re.compile(
r"\[([^\]]+)\]\(([^)]+)\)" # [label](url)
r"|\*\*\*(.+?)\*\*\*" # ***bold italic***
r"|\*\*(.+?)\*\*" # **bold**
r"|~~(.+?)~~" # ~~strikethrough~~
r"|(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)" # *italic* (avoid matching **)
r"|(?<!`)`([^`\n]+)`(?!`)" # `code`
)
nodes: List[Dict[str, Any]] = []
last = 0
for m in pattern.finditer(s):
if m.start() > last:
chunk = s[last : m.start()]
if chunk:
nodes.append({"type": "text", "text": chunk})
if m.group(1) is not None and m.group(2) is not None:
nodes.append(
{
"type": "text",
"text": m.group(1),
"marks": [{"type": "link", "attrs": {"href": m.group(2).strip()}}],
}
)
elif m.group(3) is not None:
nodes.append(
{
"type": "text",
"text": m.group(3),
"marks": [{"type": "strong"}, {"type": "em"}],
}
)
elif m.group(4) is not None:
nodes.append(
{"type": "text", "text": m.group(4), "marks": [{"type": "strong"}]}
)
elif m.group(5) is not None:
nodes.append(
{"type": "text", "text": m.group(5), "marks": [{"type": "strike"}]}
)
elif m.group(6) is not None:
nodes.append(
{"type": "text", "text": m.group(6), "marks": [{"type": "em"}]}
)
elif m.group(7) is not None:
nodes.append(
{"type": "text", "text": m.group(7), "marks": [{"type": "code"}]}
)
last = m.end()
if last < len(s):
tail = s[last:]
if tail:
nodes.append({"type": "text", "text": tail})
return nodes
def _split_table_row(line: str) -> List[str]:
"""Split a pipe-delimited table row into cells, stripping whitespace."""
parts = line.split("|")
cells = []
for p in parts:
c = p.strip()
if c:
cells.append(c)
return cells
def _is_separator_row(cells: List[str]) -> bool:
"""Check if a row is a GFM table separator (e.g., |---|---|)."""
return bool(cells) and all(re.fullmatch(r"-{3,}", c.strip()) for c in cells)
def _is_table_row(line: str) -> bool:
"""Detect if a line looks like a GFM table row."""
stripped = line.strip()
if not stripped.startswith("|"):
return False
cells = _split_table_row(stripped)
return len(cells) >= 1
def _parse_table(lines: List[str], start: int, n: int):
"""
Parse a GFM table starting at index start.
Returns (adf_table_dict, next_index).
"""
header_cells = _split_table_row(lines[start].strip())
num_cols = len(header_cells)
i = start + 1
# Skip separator row if present
if i < n and _is_separator_row(_split_table_row(lines[i].strip())):
i += 1
data_rows: List[List[str]] = []
while i < n:
s = lines[i].strip()
if not s or not _is_table_row(s):
break
row_cells = _split_table_row(s)
# Pad or trim to match header column count
while len(row_cells) < num_cols:
row_cells.append("")
data_rows.append(row_cells[:num_cols])
i += 1
# Build ADF table
def _make_cell(text: str, is_header: bool = False) -> Dict[str, Any]:
cell_type = "tableHeader" if is_header else "tableCell"
inline_nodes = parse_inline_adf(text) or _text_nodes(text or " ")
return {
"type": cell_type,
"content": [{"type": "paragraph", "content": inline_nodes}],
}
def _make_row(cell_texts: List[str], is_header: bool = False) -> Dict[str, Any]:
return {
"type": "tableRow",
"content": [_make_cell(c, is_header=is_header) for c in cell_texts],
}
table_content: List[Dict[str, Any]] = [_make_row(header_cells, is_header=True)]
for dr in data_rows:
table_content.append(_make_row(dr))
return {"type": "table", "content": table_content}, i
def _paragraph_from_buffer(lines: List[str]) -> Optional[Dict[str, Any]]:
if not lines:
return None
inner: List[Dict[str, Any]] = []
for i, line in enumerate(lines):
if i > 0:
inner.append({"type": "hardBreak"})
inner.extend(parse_inline_adf(line) or _text_nodes(line))
if not inner:
return None
return {"type": "paragraph", "content": inner}
def markdown_to_adf(text: str) -> Dict[str, Any]:
"""
Convert common Markdown to Atlassian Document Format (headings, lists, bold, code, links).
Jira REST API stores comments/descriptions as ADF; plain paragraphs showed ### literally.
"""
text = text or ""
if not text.strip():
return _empty_doc()
lines = text.split("\n")
content: List[Dict[str, Any]] = []
n = len(lines)
i = 0
para_buf: List[str] = []
def flush_paragraph() -> None:
nonlocal para_buf
if not para_buf:
return
p = _paragraph_from_buffer(para_buf)
para_buf = []
if p:
content.append(p)
while i < n:
raw = lines[i]
stripped = raw.strip()
if not stripped:
flush_paragraph()
i += 1
continue
# ATX heading # .. ######
hm = re.match(r"^(#{1,6})\s+(.+)$", stripped)
if hm and len(hm.group(1)) <= 6:
flush_paragraph()
level = len(hm.group(1))
title = hm.group(2).strip()
title_nodes = parse_inline_adf(title) or _text_nodes(title)
content.append(
{"type": "heading", "attrs": {"level": level}, "content": title_nodes}
)
i += 1
continue
# Horizontal rule
if re.fullmatch(r"[-*_]{3,}", stripped):
flush_paragraph()
content.append({"type": "rule"})
i += 1
continue
# GFM Table: | col1 | col2 |
if _is_table_row(stripped):
flush_paragraph()
table_adf, i = _parse_table(lines, i, n)
content.append(table_adf)
continue
# Fenced code block
if stripped.startswith("```"):
flush_paragraph()
lang = stripped[3:].strip() or "plaintext"
code_lines: List[str] = []
i += 1
while i < n:
if lines[i].strip().startswith("```"):
i += 1
break
code_lines.append(lines[i])
i += 1
code_text = "\n".join(code_lines)
content.append(
{
"type": "codeBlock",
"attrs": {"language": lang},
"content": [{"type": "text", "text": code_text or " "}],
}
)
continue
# Bullet list (consecutive - or *)
if re.match(r"^[-*]\s+", stripped):
flush_paragraph()
items: List[str] = []
while i < n:
s = lines[i].strip()
if not s:
break
bm = re.match(r"^[-*]\s+(.*)$", s)
if not bm:
break
items.append(bm.group(1))
i += 1
if items:
content.append(
{
"type": "bulletList",
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": parse_inline_adf(it)
or _text_nodes(it),
}
],
}
for it in items
],
}
)
continue
# Ordered list
om = re.match(r"^(\d+)\.\s+(.*)$", stripped)
if om:
flush_paragraph()
start_order = int(om.group(1))
items = [om.group(2)]
i += 1
while i < n:
s = lines[i].strip()
if not s:
break
m = re.match(r"^\d+\.\s+(.*)$", s)
if not m:
break
items.append(m.group(1))
i += 1
content.append(
{
"type": "orderedList",
"attrs": {"order": start_order},
"content": [
{
"type": "listItem",
"content": [
{
"type": "paragraph",
"content": parse_inline_adf(it)
or _text_nodes(it),
}
],
}
for it in items
],
}
)
continue
# Blockquote: single line > text
if stripped.startswith("> "):
flush_paragraph()
quote_lines: List[str] = [stripped[2:].strip()]
i += 1
while i < n and lines[i].strip().startswith("> "):
quote_lines.append(lines[i].strip()[2:].strip())
i += 1
q_inner: List[Dict[str, Any]] = []
for j, ql in enumerate(quote_lines):
if j > 0:
q_inner.append({"type": "hardBreak"})
q_inner.extend(parse_inline_adf(ql) or _text_nodes(ql))
content.append(
{
"type": "blockquote",
"content": [{"type": "paragraph", "content": q_inner}],
}
)
continue
para_buf.append(raw)
i += 1
flush_paragraph()
if not content:
return _empty_doc()
return {"type": "doc", "version": 1, "content": content}
def plain_to_adf(text: str) -> Dict[str, Any]:
"""Backward-compatible name: Markdown-aware conversion for Jira ADF."""
return markdown_to_adf(text)
def unescape_text(text: str) -> str:
"""
Unescape common escape sequences that may appear as literal strings
in CLI arguments or generated content.
Converts \\n -> newline, \\t -> tab, \\\\ -> backslash, etc.
"""
if not text:
return text
result = text.replace("\\\\", "\x00ESCAPED_BACKSLASH\x00")
result = result.replace("\\n", "\n")
result = result.replace("\\t", "\t")
result = result.replace("\\r", "\r")
result = result.replace("\x00ESCAPED_BACKSLASH\x00", "\\")
return result
def _out(data: Any, fmt: str) -> None:
if fmt == "json":
print(json.dumps(data, ensure_ascii=False, indent=2))
else:
print(data)
def cmd_myself(base: str, email: str, token: str, fmt: str) -> int:
url = f"{base}/rest/api/3/myself"
r = requests.get(url, headers=session_headers(email, token, json_body=False), timeout=60)
if r.status_code != 200:
print(f"ERROR: HTTP {r.status_code}", file=sys.stderr)
print(r.text[:800], file=sys.stderr)
return 1
j = r.json()
if fmt == "json":
_out(j, "json")
else:
print(f"accountId: {j.get('accountId')}")
print(f"displayName: {j.get('displayName')}")
print(f"email: {j.get('emailAddress')}")
return 0
def cmd_search(base: str, email: str, token: str, jql: str, limit: int, fmt: str) -> int:
url = f"{base}/rest/api/3/search/jql"
payload = {
"jql": jql,
"maxResults": limit,
"fields": ["key", "summary", "status", "assignee", "updated"],
}
r = requests.post(
url,
headers=session_headers(email, token),
json=payload,
timeout=60,
)
if r.status_code != 200:
print(f"ERROR: HTTP {r.status_code}", file=sys.stderr)
print(r.text[:800], file=sys.stderr)
return 1
data = r.json()
issues = data.get("issues") or []
if fmt == "json":
_out(data, "json")
return 0
if not issues:
print("(no issues)")
return 0
for i, iss in enumerate(issues, 1):
f = iss.get("fields") or {}
st = (f.get("status") or {}).get("name") or "?"
summ = (f.get("summary") or "")[:80]
print(f"{i}. {iss.get('key')} [{summ}] status: {st}")
return 0
def cmd_issue(base: str, email: str, token: str, key: str, fmt: str) -> int:
url = f"{base}/rest/api/3/issue/{key}"
params = {"fields": "summary,status,assignee,project,description,created,updated"}
r = requests.get(
url,
headers=session_headers(email, token, json_body=False),
params=params,
timeout=60,
)
if r.status_code != 200:
print(f"ERROR: HTTP {r.status_code}", file=sys.stderr)
print(r.text[:800], file=sys.stderr)
return 1
j = r.json()
if fmt == "json":
_out(j, "json")
return 0
f = j.get("fields") or {}
print(f"key: {j.get('key')}")
print(f"summary: {f.get('summary')}")
print(f"status: {(f.get('status') or {}).get('name')}")
proj = f.get("project") or {}
print(f"project: {proj.get('key')} {proj.get('name')}")
asn = f.get("assignee")
print(f"assignee: {(asn or {}).get('displayName') or asn}")
return 0
def cmd_create(
base: str,
email: str,
token: str,
project: str,
summary: str,
description: str,
issuetype: str,
assign_self: bool,
fmt: str,
) -> int:
fields: Dict[str, Any] = {
"project": {"key": project},
"summary": summary.strip(),
"description": plain_to_adf(description),
"issuetype": {"name": issuetype},
}
if assign_self:
mr = requests.get(
f"{base}/rest/api/3/myself",
headers=session_headers(email, token, json_body=False),
timeout=30,
)
if mr.status_code == 200:
aid = mr.json().get("accountId")
if aid:
fields["assignee"] = {"accountId": aid}
payload = {"fields": fields}
url = f"{base}/rest/api/3/issue"
r = requests.post(url, headers=session_headers(email, token), json=payload, timeout=60)
if r.status_code not in (200, 201):
# Retry without assignee if permission error
if r.status_code == 400 and assign_self and "assignee" in fields:
del fields["assignee"]
r2 = requests.post(
url, headers=session_headers(email, token), json={"fields": fields}, timeout=60
)
if r2.status_code in (200, 201):
key = r2.json().get("key")
print(f"WARN: created without assignee (API rejected assignee). key={key}", file=sys.stderr)
if fmt == "json":
_out(r2.json(), "json")
else:
print(key)
return 0
print(f"ERROR: HTTP {r.status_code}", file=sys.stderr)
print(r.text[:1200], file=sys.stderr)
return 1
j = r.json()
if fmt == "json":
_out(j, "json")
else:
print(j.get("key", j))
return 0
def cmd_comment(base: str, email: str, token: str, issue: str, body: str, fmt: str) -> int:
url = f"{base}/rest/api/3/issue/{issue.strip()}/comment"
payload = {"body": plain_to_adf(body)}
r = requests.post(url, headers=session_headers(email, token), json=payload, timeout=60)
if r.status_code not in (200, 201):
print(f"ERROR: HTTP {r.status_code}", file=sys.stderr)
print(r.text[:800], file=sys.stderr)
return 1
j = r.json()
if fmt == "json":
_out(j, "json")
else:
print(f"OK comment id={j.get('id')}")
return 0
def read_body(description: Optional[str], path: Optional[str]) -> str:
if path:
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"File not found: {p}")
return unescape_text(p.read_text(encoding="utf-8"))
return unescape_text(description or "")
def main() -> int:
parser = argparse.ArgumentParser(description="Jira REST CLI (same token as upload_attachment)")
parser.add_argument(
"--config",
default=None,
help=f"path to {CONFIG_FILENAME} (default: cwd / script dir / skill dir)",
)
parser.add_argument("--format", choices=("text", "json"), default="text", dest="fmt")
sub = parser.add_subparsers(dest="cmd", required=True)
p_my = sub.add_parser("myself", help="GET /myself (accountId, email)")
p_my.set_defaults(func="myself")
p_se = sub.add_parser("search", help="JQL search")
p_se.add_argument("--jql", default=DEFAULT_JQL, help="JQL (default: G3SF in-progress for current user)")
p_se.add_argument("--limit", type=int, default=5)
p_se.set_defaults(func="search")
p_is = sub.add_parser("issue", help="Get issue by key")
p_is.add_argument("key", help="e.g. G3SF-123")
p_is.set_defaults(func="issue")
p_cr = sub.add_parser("create", help="Create issue (Task)")
p_cr.add_argument("--project", default="G3SF")
p_cr.add_argument("--summary", required=True)
p_cr.add_argument(
"--description",
default="",
help="Markdown → ADF (headings, lists, bold, code, links)",
)
p_cr.add_argument("--description-file", dest="description_file", default=None)
p_cr.add_argument("--issuetype", default="Task", dest="issuetype")
p_cr.add_argument(
"--no-assign-self",
action="store_true",
help="do not set assignee to API token user",
)
p_cr.set_defaults(func="create")
p_co = sub.add_parser("comment", help="Add comment to issue")
p_co.add_argument("--issue", required=True)
p_co.add_argument("--body", default="")
p_co.add_argument("--body-file", dest="body_file", default=None)
p_co.set_defaults(func="comment")
args = parser.parse_args()
script_dir = Path(__file__).resolve().parent
try:
cfg = load_config(args.config, script_dir)
base, email, token = require_credentials(cfg)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
except FileNotFoundError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
fmt = args.fmt
if args.func == "myself":
return cmd_myself(base, email, token, fmt)
if args.func == "search":
return cmd_search(base, email, token, args.jql, args.limit, fmt)
if args.func == "issue":
return cmd_issue(base, email, token, args.key.strip(), fmt)
if args.func == "create":
try:
desc = read_body(args.description, args.description_file)
except FileNotFoundError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
return cmd_create(
base,
email,
token,
args.project,
args.summary,
desc,
args.issuetype,
assign_self=not args.no_assign_self,
fmt=fmt,
)
if args.func == "comment":
try:
body = read_body(args.body, args.body_file)
except FileNotFoundError as e:
print(f"ERROR: {e}", file=sys.stderr)
return 1
if not body.strip():
print("ERROR: --body or --body-file required", file=sys.stderr)
return 1
return cmd_comment(base, email, token, args.issue, body, fmt)
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,76 @@
#!/usr/bin/env python3
"""
Shared Jira config loading for g3fo-commit-jira scripts.
Uses the same jira_upload.env / env vars as upload_attachment.py.
"""
import base64
import os
from pathlib import Path
from typing import Dict, Optional
CONFIG_FILENAME = "jira_upload.env"
def find_config_file(explicit_path: Optional[str], script_dir: Path) -> Optional[Path]:
if explicit_path:
p = Path(explicit_path)
return p if p.exists() and p.is_file() else None
cwd_file = Path.cwd() / CONFIG_FILENAME
if cwd_file.exists():
return cwd_file
script_dir_file = script_dir / CONFIG_FILENAME
if script_dir_file.exists():
return script_dir_file
parent_skill = script_dir.parent / CONFIG_FILENAME
if parent_skill.exists():
return parent_skill
return None
def load_config(config_path: Optional[str] = None, script_dir: Optional[Path] = None) -> Dict[str, str]:
"""Load KEY=VALUE from jira_upload.env. Env vars override file values."""
if script_dir is None:
script_dir = Path(__file__).resolve().parent
path = find_config_file(config_path, script_dir)
out: Dict[str, str] = {}
if path:
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
k, _, v = line.partition("=")
out[k.strip()] = v.strip().strip('"').strip("'")
for key in ("JIRA_BASE_URL", "JIRA_EMAIL", "JIRA_API_TOKEN"):
if key in os.environ:
out[key] = os.environ[key]
return out
def require_credentials(cfg: Dict[str, str]) -> tuple:
base_url = (cfg.get("JIRA_BASE_URL") or "https://n2nafe.atlassian.net").rstrip("/")
email = cfg.get("JIRA_EMAIL")
token = cfg.get("JIRA_API_TOKEN")
if not email or not token:
raise ValueError(
"Missing JIRA_EMAIL or JIRA_API_TOKEN. "
f"Please configure {CONFIG_FILENAME} file first. "
f"You can apply for API token at: https://id.atlassian.com/manage-profile/security/api-tokens"
)
return base_url, email, token
def auth_header(email: str, token: str) -> str:
return base64.b64encode(f"{email}:{token}".encode()).decode()
def session_headers(email: str, token: str, json_body: bool = True) -> Dict[str, str]:
h = {
"Authorization": f"Basic {auth_header(email, token)}",
"Accept": "application/json",
}
if json_body:
h["Content-Type"] = "application/json"
return h
@@ -0,0 +1 @@
requests>=2.28.0
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""
Upload one or more files as attachments to a Jira Cloud issue via REST API v3.
Config: from jira_upload.env (current dir, script dir, or skill parent dir) or env vars.
Usage:
python upload_attachment.py --issue ISSUE_KEY --file path1 [--file path2 ...]
python upload_attachment.py --config /path/to/jira_upload.env --issue KEY --file path1
"""
import argparse
import sys
from pathlib import Path
try:
import requests
except ImportError:
print("ERROR: 'requests' is required. Run: pip install requests", file=sys.stderr)
sys.exit(1)
from jira_env import CONFIG_FILENAME, auth_header, load_config, require_credentials
SCRIPT_DIR = Path(__file__).resolve().parent
def main() -> int:
parser = argparse.ArgumentParser(description="Upload files as attachments to a Jira issue")
parser.add_argument("--issue", required=True, help="Jira issue key (e.g. G3SF-123)")
parser.add_argument(
"--file",
action="append",
required=True,
dest="files",
help="Path to file to upload (can be repeated)",
)
parser.add_argument(
"--config",
default=None,
help=f"Path to config file (default: {CONFIG_FILENAME} in cwd, script dir, or skill dir)",
)
args = parser.parse_args()
try:
cfg = load_config(args.config, SCRIPT_DIR)
base_url, email, token = require_credentials(cfg)
except ValueError as e:
print(f"ERROR: {e}", file=sys.stderr)
print(f" Use --config PATH or create {CONFIG_FILENAME}", file=sys.stderr)
return 1
issue_key = args.issue.strip()
files_to_upload = []
for p in args.files:
path = Path(p)
if not path.exists():
print(f"ERROR: File not found: {path}", file=sys.stderr)
return 1
if not path.is_file():
print(f"ERROR: Not a file: {path}", file=sys.stderr)
return 1
files_to_upload.append(path)
url = f"{base_url}/rest/api/3/issue/{issue_key}/attachments"
headers = {
"Authorization": f"Basic {auth_header(email, token)}",
"X-Atlassian-Token": "no-check",
}
for path in files_to_upload:
with open(path, "rb") as f:
files = {"file": (path.name, f)}
try:
resp = requests.post(url, headers=headers, files=files, timeout=60)
except requests.RequestException as e:
print(f"ERROR: Request failed for {path.name}: {e}", file=sys.stderr)
return 1
if resp.status_code not in (200, 201):
print(f"ERROR: Upload failed for {path.name}: HTTP {resp.status_code}", file=sys.stderr)
if resp.text:
print(resp.text[:500], file=sys.stderr)
return 1
data = resp.json()
if isinstance(data, list) and data:
for att in data:
print(f"OK: {att.get('filename', path.name)} (id={att.get('id')}, size={att.get('size', '?')})")
else:
print(f"OK: {path.name}")
return 0
if __name__ == "__main__":
sys.exit(main())
+137 -14
View File
@@ -1,6 +1,6 @@
--- ---
name: g3fo-db-ops name: g3fo-db-ops
description: Comprehensive database operations guide for G3FO project. Use when updating G3FO database data, creating tables, modifying table structures, inserting system menus/routes, or managing database changes that require SQL generation, execution, and Git commit workflows. Includes automated workflows for i18n updates, version management, file naming conventions, and duplicate sequence number detection. description: Comprehensive database operations guide for G3FO project. Use when updating G3FO database data, creating tables, modifying table structures, inserting system menus/routes, or managing database changes that require SQL generation, execution, and Git commit workflows. Includes automated workflows for i18n updates, version management, file naming conventions, and duplicate sequence number detection. Uses Python db_ops tool (not MCP MySQL) for database operations.
--- ---
# G3FO Database Operations # G3FO Database Operations
@@ -9,6 +9,118 @@ Guide for performing database operations in the G3FO project, including automate
**Detailed documentation**: See `references/DATABASE_UPDATE_AUTOMATION_GUIDE.md` for complete automation guide. **Detailed documentation**: See `references/DATABASE_UPDATE_AUTOMATION_GUIDE.md` for complete automation guide.
## Database Tool (db_ops.py)
**本技能不再使用 MCP MySQL**,改用 Python 程序 `scripts/db_ops.py` 执行所有数据库操作。
### 安装依赖与运行前提
> **重要:本技能依赖本机可用的 Python 解释器。若未安装 Python 或 `python` 不在 PATH 中,在 Cursor 的 Shell 里直接执行脚本会出现 Windows `Exit code: 9009`(命令未找到)错误。**
1. 在本机安装 Python(3.8+),并确认命令行中可以直接运行:
```bash
python --version
```
如命令无效,请将 Python 安装目录加入系统 PATH,或使用 `py` / `python3` 等本机实际命令名。
2. 安装依赖:
```bash
pip install -r .claude/skills/g3fo-db-ops/scripts/requirements.txt
```
### 默认连接配置
与 MCP user-mysql 一致,未指定时自动使用:
| 参数 | 默认值 |
|------|--------|
| host | 192.168.3.233 |
| port | 3306 |
| user | root |
| password | afe123456 |
| database | g3fo_base |
**切换数据库**:系统表用 `g3fo_base`,交易相关用 `g3fo_trade`。通过 `--database g3fo_trade` 或环境变量 `MYSQL_DATABASE` 覆盖。
### 覆盖连接方式
1. **命令行参数**:`--host`, `--port`, `--user`, `--password`, `--database`
2. **环境变量**:`MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`
### 命令列表
| 命令 | 说明 |
|------|------|
| `query` | 执行 SELECT 查询 |
| `execute` | 执行 INSERT/UPDATE/DELETE |
| `list_tables` | 列举数据库所有表 |
| `describe_table` | 获取表结构(列信息、表注释) |
| `call_procedure` | 执行存储过程 |
| `batch_execute` | 批量执行 SQL(支持文件、DELIMITER) |
| `get_table_comment` | 获取表注释 |
| `set_table_comment` | 修改表注释 |
| `get_column_comment` | 获取列注释 |
| `set_column_comment` | 修改列注释 |
| `test_connection` | 测试连接 |
### 使用示例
```bash
# 从 skill 根目录或 server 根目录执行,base_dir 为 .claude/skills/g3fo-db-ops 所在路径
# 测试连接
python scripts/db_ops.py test_connection
python scripts/db_ops.py --database g3fo_trade test_connection
# 查询
python scripts/db_ops.py query "SELECT * FROM m_system_code LIMIT 5"
# 执行增删改
python scripts/db_ops.py execute "REPLACE INTO g3fo_base.m_system_code (...) VALUES (...)"
# 列举所有表
python scripts/db_ops.py list_tables
python scripts/db_ops.py --database g3fo_trade list_tables
# 获取表结构
python scripts/db_ops.py describe_table m_system_code
# 执行存储过程
python scripts/db_ops.py call_procedure InsertSystemMenu --params "菜单备注" "menu-name" "title_remark" "ADMIN"
# 批量执行 SQL 文件
python scripts/db_ops.py batch_execute --file path/to/script.sql
# 修改表注释
python scripts/db_ops.py set_table_comment m_system_code "系统编码表"
```
**在 Cursor 中调用时**:使用 `run_terminal_cmd` 或 Shell 工具执行上述命令,工作目录为 `d:\AFE Git\G3SF\G3FO\server`,脚本路径为 `.claude/skills/g3fo-db-ops/scripts/db_ops.py`。
## Windows 执行规范(必读)
### Python 命令优先级(Windows)
- **优先使用**:`py`
- **备选使用**:`python`
- **要求**:在 Windows 上执行本 skill 的所有命令时,先尝试 `py`,失败再尝试 `python`(不要反过来)。
### 控制台编码(避免 UnicodeEncodeError)
在 Windows/PowerShell 中执行 `db_ops.py` 时,如果输出包含中文,可能因控制台默认 GBK 编码导致 `UnicodeEncodeError`。执行前请统一设置:
```bash
# PowerShell
$env:PYTHONIOENCODING='utf-8'; py .claude/skills/g3fo-db-ops/scripts/db_ops.py test_connection
```
## 变更执行流程(双确认,强制)
1. **生成 SQL 文件**(按版本目录与序号规则落盘)
2. **展示 SQL 内容并等待用户确认**(用户确认 `yes` 后才允许执行)
3. **执行 SQL**(`batch_execute --file` / `execute`),确认返回 `"success": true`
4. **再次询问用户是否提交 Git**(用户确认后才允许 `git pull/add/commit/push`)
## Core Principles ## Core Principles
1. **Use existing templates**: Prioritize templates and tools in `g3fo-db/common_sql/` 1. **Use existing templates**: Prioritize templates and tools in `g3fo-db/common_sql/`
@@ -72,16 +184,16 @@ Display generated SQL and ask:
### 4. SQL Execution Phase ### 4. SQL Execution Phase
If user confirms: If user confirms:
1. Connect to database using MySQL MCP 1. Use **db_ops.py** to execute SQL (run in terminal):
- **Default database connection** (automatically use when MySQL MCP or database connection is needed): - **Default connection** (see "Database Tool" section above): host 192.168.3.233, user root, password afe123456
- host: 192.168.3.233 - System tables: `--database g3fo_base`
- user: root - Trade tables: `--database g3fo_trade`
- password: afe123456 - Override via `--host`, `--port`, `--user`, `--password`, `--database` if user provides different connection info
- database: g3fo_base (for system tables like m_system_code, m_system_i18n, m_system_error_message, etc.) 2. For single SELECT: `python .claude/skills/g3fo-db-ops/scripts/db_ops.py query "SQL"`
- database: g3fo_trade (for trade-related tables) 3. For INSERT/UPDATE/DELETE: `python .claude/skills/g3fo-db-ops/scripts/db_ops.py execute "SQL"`
2. Execute SQL 4. For batch SQL or file: `python .claude/skills/g3fo-db-ops/scripts/db_ops.py batch_execute --file path/to/file.sql`
3. Verify results 5. Verify results (check JSON output for `"success": true`)
4. If failed, report error and stop 6. If failed, report error and stop
### 5. Git Commit Phase ### 5. Git Commit Phase
@@ -195,9 +307,9 @@ If confirmed, **execute in this order** to ensure remote repository and local da
**Execution and commit rules:** **Execution and commit rules:**
1. **SQL execution phase**: 1. **SQL execution phase**:
- MySQL MCP doesn't support `DELIMITER` for stored procedures - Use **db_ops.py** `batch_execute --file` for SQL files with `DELIMITER` (script handles it)
- Can use direct `INSERT INTO` or `REPLACE INTO` to insert same data - Or use `call_procedure` for stored procedure calls
- Ensure data matches stored procedure output exactly - Or use `execute` with direct `INSERT INTO` / `REPLACE INTO` if data matches procedure output
2. **Git commit phase**: 2. **Git commit phase**:
- **Must use complete SQL from stored procedure file** - **Must use complete SQL from stored procedure file**
@@ -242,6 +354,17 @@ Reference `references/table_change_template.sql` method:
- Use `IF EXISTS` for idempotency checks - Use `IF EXISTS` for idempotency checks
- Delete procedure after execution - Delete procedure after execution
#### 列新增/DDL(强制使用存储过程模板)
所有涉及 `ALTER TABLE` 的结构变更(新增列/修改列/删除列等),一律按 `references/table_change_template.sql` 的模式执行:
- `USE <schema>;`
- `DROP PROCEDURE IF EXISTS <ProcName>;`
- `CREATE PROCEDURE <ProcName>() BEGIN ... END;`
- 在过程内通过 `information_schema.columns` 做存在性判断后再执行 `ALTER TABLE`
- `CALL <ProcName>();`
- `DROP PROCEDURE IF EXISTS <ProcName>;`
### Order Table Field Synchronization Rule ### Order Table Field Synchronization Rule
**Important: When `m_order` table adds a field, must simultaneously add same field to:** **Important: When `m_order` table adds a field, must simultaneously add same field to:**
@@ -32,14 +32,20 @@ g3fo-db/
#### 执行 SQL 阶段 #### 执行 SQL 阶段
由于 MySQL MCP 不支持使用 `DELIMITER` 的存储过程,执行时可以使用其他方法插入或更新相同的数据: 使用 **db_ops.py**(Python 数据库工具,替代 MCP MySQL)执行 SQL:
1. **使用直接的 INSERT/REPLACE 语句**: 1. **批量执行含 DELIMITER 的存储过程文件**:
- 使用 `INSERT INTO` 或 `REPLACE INTO` 语句直接插入数据 - 使用 `python scripts/db_ops.py batch_execute --file path/to/file.sql`
- db_ops.py 支持 DELIMITER,可正确解析并执行存储过程
2. **或使用 call_procedure 调用已存在的存储过程**:
- `python scripts/db_ops.py call_procedure InsertSystemMenu --params "..." "..." "..."`
3. **或使用直接的 INSERT/REPLACE 语句**:
- 使用 `execute` 命令执行 `INSERT INTO` 或 `REPLACE INTO`
- 确保数据与存储过程生成的数据完全一致 - 确保数据与存储过程生成的数据完全一致
- 需要手动处理 ID 生成、权限记录创建等逻辑
2. **数据一致性要求**: 4. **数据一致性要求**:
- 菜单数据必须插入到 `m_system_menus` 表 - 菜单数据必须插入到 `m_system_menus` 表
- 路由数据必须插入到 `m_system_routes` 表 - 路由数据必须插入到 `m_system_routes` 表
- 相应的权限记录必须插入到 `m_admin_role_rights` 或 `m_user_role_rights` 表 - 相应的权限记录必须插入到 `m_admin_role_rights` 或 `m_user_role_rights` 表
@@ -328,12 +334,13 @@ Cursor 自动执行以下操作:
#### 步骤 4: 执行SQL #### 步骤 4: 执行SQL
如果用户确认无误,使用 MySQL MCP 执行 SQL 语句: 如果用户确认无误,使用 **db_ops.py** 执行 SQL 语句:
1. 连接到数据库 1. 在终端执行:`python .claude/skills/g3fo-db-ops/scripts/db_ops.py execute "SQL"` 或 `batch_execute --file path`
2. 执行 SQL 语句 2. 默认连接:host 192.168.3.233, user root, password afe123456;可通过 `--database g3fo_base` 或 `g3fo_trade` 指定库
3. 验证执行结果 3. 用户可提供不同连接信息时,使用 `--host`, `--port`, `--user`, `--password`, `--database` 覆盖
4. 如果成功,继续下一步;如果失败,报告错误并停止 4. 验证执行结果(JSON 输出中 `"success": true`)
5. 如果成功,继续下一步;如果失败,报告错误并停止
#### 步骤 5: 询问是否提交到Git #### 步骤 5: 询问是否提交到Git
@@ -764,8 +771,9 @@ feat: {简短描述}
**重要:增加菜单和路径权限必须使用存储过程文件** **重要:增加菜单和路径权限必须使用存储过程文件**
1. **执行 SQL 阶段**: 1. **执行 SQL 阶段**:
- 由于 MySQL MCP 不支持 `DELIMITER`,可以使用直接的 `INSERT INTO` 或 `REPLACE INTO` 语句执行 - 使用 **db_ops.py** 的 `batch_execute --file` 执行含 DELIMITER 的存储过程文件
- 确保数据与存储过程生成的数据完全一致 - 或使用 `call_procedure` 调用已存在的存储过程
- 或使用 `execute` 配合直接的 `INSERT INTO` / `REPLACE INTO`,确保数据与存储过程生成的数据完全一致
2. **提交到 Git 阶段**: 2. **提交到 Git 阶段**:
- **必须使用存储过程文件生成的完整 SQL** - **必须使用存储过程文件生成的完整 SQL**
+58
View File
@@ -0,0 +1,58 @@
# G3FO Database Operations Script
替代 MCP MySQL 的 Python 数据库操作工具。
## 连接管理(无内存泄漏)
- **单次执行**:每次命令是独立进程,执行完即退出
- **连接生命周期**:连接仅在单次命令执行期间存在(通常几毫秒到几秒),`finally` 中统一 `conn.close()`
- **无长连接/连接池**:用完即关,不会累积连接
- **连接超时**:默认 10 秒,防止网络慢时长时间挂起
## 安装
```bash
pip install -r requirements.txt
# 或
pip install mysql-connector-python
```
## 默认连接
与 MCP user-mysql 一致:
- host: 192.168.3.233
- port: 3306
- user: root
- password: afe123456
- database: g3fo_base
## 覆盖连接
- 命令行:`--host`, `--port`, `--user`, `--password`, `--database`, `--connection-timeout`
- 环境变量:`MYSQL_HOST`, `MYSQL_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, `MYSQL_DATABASE`, `MYSQL_CONNECTION_TIMEOUT`
## 命令
| 命令 | 说明 |
|------|------|
| query | SELECT 查询 |
| execute | INSERT/UPDATE/DELETE |
| list_tables | 列举所有表 |
| describe_table | 表结构 |
| call_procedure | 执行存储过程 |
| batch_execute | 批量 SQL(支持文件) |
| get_table_comment | 获取表注释 |
| set_table_comment | 修改表注释 |
| get_column_comment | 获取列注释 |
| set_column_comment | 修改列注释 |
| test_connection | 测试连接 |
## 示例
```bash
python db_ops.py test_connection
python db_ops.py query "SELECT 1"
python db_ops.py --database g3fo_trade list_tables
python db_ops.py describe_table m_order
python db_ops.py batch_execute --file script.sql
```
+477
View File
@@ -0,0 +1,477 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
G3FO Database Operations Tool
替代 MCP MySQL,提供完整的数据库操作能力:
- 增删改查 (query, execute)
- 列举所有表 (list_tables)
- 执行存储过程 (call_procedure)
- 批量执行 SQL (batch_execute)
- 读取表结构 (describe_table)
- 读取/修改表和列注释 (get/set comments)
默认连接配置与 MCP user-mysql 一致,可通过参数或环境变量覆盖。
"""
import argparse
import json
import os
import sys
from typing import Any, Optional
from datetime import date, datetime, time
from decimal import Decimal
try:
import mysql.connector
from mysql.connector import Error as MySQLError
except ImportError:
print("Error: mysql-connector-python is required. Run: pip install mysql-connector-python", file=sys.stderr)
sys.exit(1)
# 默认连接配置(与 MCP user-mysql 一致)
DEFAULT_CONFIG = {
"host": "192.168.3.233",
"port": 3306,
"user": "root",
"password": "afe123456",
"database": "g3fo_base",
"connection_timeout": 10, # 连接超时(秒),防止长时间挂起
}
ENV_MAPPING = {
"host": "MYSQL_HOST",
"port": "MYSQL_PORT",
"user": "MYSQL_USER",
"password": "MYSQL_PASSWORD",
"database": "MYSQL_DATABASE",
"connection_timeout": "MYSQL_CONNECTION_TIMEOUT",
}
def get_connection_config(
host: Optional[str] = None,
port: Optional[int] = None,
user: Optional[str] = None,
password: Optional[str] = None,
database: Optional[str] = None,
connection_timeout: Optional[int] = None,
) -> dict:
"""从默认配置、环境变量、参数中合并连接配置,参数优先级最高。"""
config = {}
params = {"host": host, "port": port, "user": user, "password": password, "database": database, "connection_timeout": connection_timeout}
for key, default in DEFAULT_CONFIG.items():
env_key = ENV_MAPPING.get(key)
env_val = os.environ.get(env_key) if env_key else None
param_val = params.get(key)
if param_val is not None:
config[key] = int(param_val) if key in ("port", "connection_timeout") else param_val
elif env_val is not None:
config[key] = int(env_val) if key in ("port", "connection_timeout") else env_val
else:
config[key] = default
return config
def get_connection(config: dict):
"""
创建数据库连接。
连接仅在单次命令执行期间存在,命令结束或异常时在 finally 中关闭,不会长期占用。
"""
return mysql.connector.connect(
host=config["host"],
port=config["port"],
user=config["user"],
password=config["password"],
database=config["database"],
charset="utf8mb4",
collation="utf8mb4_unicode_ci",
connection_timeout=config.get("connection_timeout", 10),
)
def _normalize_value(value: Any) -> Any:
"""将 MySQL 返回的值转换为可 JSON 序列化的类型。"""
if isinstance(value, (datetime, date, time)):
return value.isoformat()
if isinstance(value, Decimal):
# 大多数场景下用 float 即可,避免 JSON 不支持 Decimal
return float(value)
return value
def cmd_query(args: argparse.Namespace, config: dict) -> dict:
"""执行 SELECT 查询。"""
conn = get_connection(config)
try:
cursor = conn.cursor(dictionary=True)
cursor.execute(args.sql, args.params or [])
rows = cursor.fetchall()
cursor.close()
normalized_rows = [
{k: _normalize_value(v) for k, v in row.items()} for row in rows
]
return {"success": True, "data": normalized_rows, "rowCount": len(normalized_rows)}
except MySQLError as e:
return {"success": False, "error": str(e)}
finally:
conn.close()
def cmd_execute(args: argparse.Namespace, config: dict) -> dict:
"""执行 INSERT/UPDATE/DELETE。"""
conn = get_connection(config)
try:
cursor = conn.cursor()
cursor.execute(args.sql, args.params or [])
conn.commit()
affected = cursor.rowcount
cursor.close()
return {"success": True, "affectedRows": affected}
except MySQLError as e:
conn.rollback()
return {"success": False, "error": str(e)}
finally:
conn.close()
def cmd_list_tables(args: argparse.Namespace, config: dict) -> dict:
"""列举数据库中的所有表。"""
conn = get_connection(config)
try:
cursor = conn.cursor()
cursor.execute(
"SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE, TABLE_COMMENT "
"FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s ORDER BY TABLE_NAME",
(config["database"],),
)
rows = cursor.fetchall()
cursor.close()
tables = [
{
"schema": r[0],
"name": r[1],
"type": r[2],
"comment": r[3] or "",
}
for r in rows
]
return {"success": True, "tables": tables, "count": len(tables)}
except MySQLError as e:
return {"success": False, "error": str(e)}
finally:
conn.close()
def cmd_describe_table(args: argparse.Namespace, config: dict) -> dict:
"""获取表结构(列信息)。"""
conn = get_connection(config)
try:
cursor = conn.cursor()
table = args.table
schema = args.schema or config["database"]
cursor.execute(
"""
SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_KEY, COLUMN_DEFAULT, COLUMN_COMMENT,
EXTRA
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s
ORDER BY ORDINAL_POSITION
""",
(schema, table),
)
rows = cursor.fetchall()
cursor.close()
columns = [
{
"name": r[0],
"type": r[1],
"nullable": r[2],
"key": r[3] or "",
"default": r[4],
"comment": r[5] or "",
"extra": r[6] or "",
}
for r in rows
]
# 获取表注释
cursor = conn.cursor()
cursor.execute(
"SELECT TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
(schema, table),
)
tbl = cursor.fetchone()
table_comment = tbl[0] if tbl else ""
cursor.close()
return {"success": True, "table": table, "schema": schema, "columns": columns, "tableComment": table_comment}
except MySQLError as e:
return {"success": False, "error": str(e)}
finally:
conn.close()
def cmd_call_procedure(args: argparse.Namespace, config: dict) -> dict:
"""执行存储过程。"""
conn = get_connection(config)
try:
cursor = conn.cursor()
placeholders = ", ".join(["%s"] * len(args.params)) if args.params else ""
sql = f"CALL {args.procedure}({placeholders})" if placeholders else f"CALL {args.procedure}()"
cursor.execute(sql, args.params or [])
rows = []
if cursor.description:
rows = cursor.fetchall()
while cursor.nextset():
if cursor.description:
rows.extend(cursor.fetchall())
conn.commit()
cursor.close()
return {"success": True, "data": rows, "rowCount": len(rows)}
except MySQLError as e:
conn.rollback()
return {"success": False, "error": str(e)}
finally:
conn.close()
def cmd_batch_execute(args: argparse.Namespace, config: dict) -> dict:
"""批量执行 SQL(支持多条语句、存储过程、DELIMITER)。"""
sql_text = args.sql or ""
if args.file:
with open(args.file, "r", encoding="utf-8") as f:
sql_text = f.read()
if not sql_text.strip():
return {"success": False, "error": "No SQL provided. Use 'sql' argument or --file"}
# 处理 DELIMITER(存储过程等):移除 DELIMITER 行,将 $$ 替换为 ;
if "DELIMITER" in sql_text.upper():
lines = []
for line in sql_text.split("\n"):
if line.strip().upper().startswith("DELIMITER"):
continue
lines.append(line.replace("$$", ";"))
sql_text = "\n".join(lines)
conn = get_connection(config)
results = []
try:
cursor = conn.cursor()
# mysql-connector-python 9.2+ 已移除 multi 参数,直接 execute 即可执行多条语句
cursor.execute(sql_text)
# 消费所有结果集(SELECT 返回行、DML 返回 affected rows)
while True:
if cursor.description:
results.append({"type": "query", "rowCount": len(cursor.fetchall())})
else:
results.append({"type": "execute", "affectedRows": cursor.rowcount})
if not cursor.nextset():
break
conn.commit()
cursor.close()
return {"success": True, "results": results}
except MySQLError as e:
conn.rollback()
return {"success": False, "error": str(e)}
finally:
conn.close()
def cmd_get_table_comment(args: argparse.Namespace, config: dict) -> dict:
"""获取表注释。"""
conn = get_connection(config)
try:
cursor = conn.cursor()
schema = args.schema or config["database"]
cursor.execute(
"SELECT TABLE_COMMENT FROM information_schema.TABLES WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s",
(schema, args.table),
)
row = cursor.fetchone()
cursor.close()
return {"success": True, "table": args.table, "schema": schema, "comment": row[0] if row else ""}
except MySQLError as e:
return {"success": False, "error": str(e)}
finally:
conn.close()
def cmd_set_table_comment(args: argparse.Namespace, config: dict) -> dict:
"""修改表注释。"""
conn = get_connection(config)
try:
cursor = conn.cursor()
schema = args.schema or config["database"]
sql = f"ALTER TABLE `{schema}`.`{args.table}` COMMENT = %s"
cursor.execute(sql, (args.comment,))
conn.commit()
cursor.close()
return {"success": True, "table": args.table, "schema": schema}
except MySQLError as e:
conn.rollback()
return {"success": False, "error": str(e)}
finally:
conn.close()
def cmd_get_column_comment(args: argparse.Namespace, config: dict) -> dict:
"""获取列注释。"""
conn = get_connection(config)
try:
cursor = conn.cursor()
schema = args.schema or config["database"]
cursor.execute(
"SELECT COLUMN_COMMENT FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s",
(schema, args.table, args.column),
)
row = cursor.fetchone()
cursor.close()
return {"success": True, "table": args.table, "column": args.column, "comment": row[0] if row else ""}
except MySQLError as e:
return {"success": False, "error": str(e)}
finally:
conn.close()
def cmd_set_column_comment(args: argparse.Namespace, config: dict) -> dict:
"""修改列注释(需要 COLUMN_TYPE,可通过 describe_table 获取)。"""
conn = get_connection(config)
try:
cursor = conn.cursor()
schema = args.schema or config["database"]
cursor.execute(
"SELECT COLUMN_TYPE FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = %s AND TABLE_NAME = %s AND COLUMN_NAME = %s",
(schema, args.table, args.column),
)
row = cursor.fetchone()
if not row:
cursor.close()
return {"success": False, "error": f"Column {args.column} not found"}
col_type = row[0]
sql = f"ALTER TABLE `{schema}`.`{args.table}` MODIFY COLUMN `{args.column}` {col_type} COMMENT %s"
cursor.execute(sql, (args.comment,))
conn.commit()
cursor.close()
return {"success": True, "table": args.table, "column": args.column}
except MySQLError as e:
conn.rollback()
return {"success": False, "error": str(e)}
finally:
conn.close()
def cmd_test_connection(args: argparse.Namespace, config: dict) -> dict:
"""测试数据库连接。"""
conn = None
try:
conn = get_connection(config)
cursor = conn.cursor()
cursor.execute("SELECT 1 AS conn_ok")
row = cursor.fetchone()
cursor.close()
return {"success": True, "message": "Connection OK", "conn_ok": row[0] if row else 1}
except MySQLError as e:
return {"success": False, "error": str(e)}
finally:
if conn:
conn.close()
def main():
parser = argparse.ArgumentParser(description="G3FO Database Operations Tool")
parser.add_argument("--host", help="MySQL host (default: 192.168.3.233)")
parser.add_argument("--port", type=int, help="MySQL port (default: 3306)")
parser.add_argument("--user", help="MySQL user (default: root)")
parser.add_argument("--password", help="MySQL password")
parser.add_argument("--database", help="MySQL database (default: g3fo_base)")
parser.add_argument("--connection-timeout", type=int, dest="connection_timeout", help="Connection timeout in seconds (default: 10)")
subparsers = parser.add_subparsers(dest="command", required=True)
# query
p_query = subparsers.add_parser("query", help="Execute SELECT query")
p_query.add_argument("sql", help="SQL SELECT statement")
p_query.add_argument("--params", nargs="*", help="Query parameters")
# execute
p_execute = subparsers.add_parser("execute", help="Execute INSERT/UPDATE/DELETE")
p_execute.add_argument("sql", help="SQL statement")
p_execute.add_argument("--params", nargs="*", help="Query parameters")
# list_tables
subparsers.add_parser("list_tables", help="List all tables in database")
# describe_table
p_desc = subparsers.add_parser("describe_table", help="Get table structure")
p_desc.add_argument("table", help="Table name")
p_desc.add_argument("--schema", help="Schema/database (default: current database)")
# call_procedure
p_proc = subparsers.add_parser("call_procedure", help="Execute stored procedure")
p_proc.add_argument("procedure", help="Procedure name (e.g. InsertSystemMenu)")
p_proc.add_argument("--params", nargs="*", help="Procedure parameters")
# batch_execute
p_batch = subparsers.add_parser("batch_execute", help="Execute batch SQL")
p_batch.add_argument("sql", nargs="?", help="SQL text (multiple statements separated by ;)")
p_batch.add_argument("--file", "-f", help="SQL file path")
p_batch.add_argument("--continue-on-error", action="store_true", dest="continue_on_error", help="Continue on error")
# get_table_comment
p_gtc = subparsers.add_parser("get_table_comment", help="Get table comment")
p_gtc.add_argument("table", help="Table name")
p_gtc.add_argument("--schema", help="Schema (default: current database)")
# set_table_comment
p_stc = subparsers.add_parser("set_table_comment", help="Set table comment")
p_stc.add_argument("table", help="Table name")
p_stc.add_argument("comment", help="Comment text")
p_stc.add_argument("--schema", help="Schema (default: current database)")
# get_column_comment
p_gcc = subparsers.add_parser("get_column_comment", help="Get column comment")
p_gcc.add_argument("table", help="Table name")
p_gcc.add_argument("column", help="Column name")
p_gcc.add_argument("--schema", help="Schema (default: current database)")
# set_column_comment
p_scc = subparsers.add_parser("set_column_comment", help="Set column comment")
p_scc.add_argument("table", help="Table name")
p_scc.add_argument("column", help="Column name")
p_scc.add_argument("comment", help="Comment text")
p_scc.add_argument("--schema", help="Schema (default: current database)")
# test_connection
subparsers.add_parser("test_connection", help="Test database connection")
args = parser.parse_args()
config = get_connection_config(
host=args.host,
port=args.port,
user=args.user,
password=args.password,
database=args.database,
connection_timeout=getattr(args, "connection_timeout", None),
)
commands = {
"query": cmd_query,
"execute": cmd_execute,
"list_tables": cmd_list_tables,
"describe_table": cmd_describe_table,
"call_procedure": cmd_call_procedure,
"batch_execute": cmd_batch_execute,
"get_table_comment": cmd_get_table_comment,
"set_table_comment": cmd_set_table_comment,
"get_column_comment": cmd_get_column_comment,
"set_column_comment": cmd_set_column_comment,
"test_connection": cmd_test_connection,
}
handler = commands[args.command]
result = handler(args, config)
print(json.dumps(result, ensure_ascii=False, indent=2))
sys.exit(0 if result.get("success", False) else 1)
if __name__ == "__main__":
main()
@@ -0,0 +1 @@
mysql-connector-python>=8.0.0
+11
View File
@@ -14,6 +14,7 @@ description: g3fo 系统相关文档。管理业务流程说明、服务职责
- **分析测试流程**:参考 `references/business_flows/_TEST_FLOW_TEMPLATE.md`。 - **分析测试流程**:参考 `references/business_flows/_TEST_FLOW_TEMPLATE.md`。
- **查标准运维流程**:去 `references/middleware/` 或 `references/system-init/`。 - **查标准运维流程**:去 `references/middleware/` 或 `references/system-init/`。
- **查中间件总览/高可用架构**:查阅 `references/middleware/middleware-comprehensive-guide.md`。 - **查中间件总览/高可用架构**:查阅 `references/middleware/middleware-comprehensive-guide.md`。
- **查中间件与标准组件版本**:查阅 `references/middleware/middleware-versions.md`(含 `g3fo-*-service` 应用版本、中间件与 JDK;**Web Server** 仅 Nginx / Vue / Flutter,其余为 **AP Server**;用户要「所有版本」时须分两类列出,见 §2.D)。
- **查具体环境/客户资产**: - **查具体环境/客户资产**:
- 内部环境(Dev/UAT):查阅 `references/inventory/internal.md`。 - 内部环境(Dev/UAT):查阅 `references/inventory/internal.md`。
- 外部客户(客户A、B等):查阅 `references/inventory/clients/[客户名].md`。 - 外部客户(客户A、B等):查阅 `references/inventory/clients/[客户名].md`。
@@ -61,6 +62,13 @@ description: g3fo 系统相关文档。管理业务流程说明、服务职责
4. **表格生成**:汇总信息,输出包含“步骤”、“涉及服务”、“关键接口/代码逻辑”、“测试内容/预期结果”的表格。 4. **表格生成**:汇总信息,输出包含“步骤”、“涉及服务”、“关键接口/代码逻辑”、“测试内容/预期结果”的表格。
- **格式参考**:`references/business_flows/_TEST_FLOW_TEMPLATE.md`。 - **格式参考**:`references/business_flows/_TEST_FLOW_TEMPLATE.md`。
### D. 版本清单问答
1. **文档来源**:以 `references/middleware/middleware-versions.md` 为唯一标准版本表(除非用户指定环境资产清单覆盖)。
2. **角色区分**:
- **Web Server**:仅 **Nginx、Vue、Flutter**。
- **AP Server**:全部 **`g3fo-*-service`**、**全部中间件**(MySQL、Redis、RocketMQ、Nacos、PowerJob、Beszel、Dozzle、dufs 等)、**OpenJDK**。
3. **用户问「所有版本 / 完整版本 / 各组件版本号」等**:必须分两块输出,且标题或小节名明确为 **Web Server** 与 **AP Server**(可先 Web 后 AP,或先 AP 后 Web,但两类不可混为一张无标签表)。
## 3. 示例 Prompt (用户可参考) ## 3. 示例 Prompt (用户可参考)
- **业务咨询**: - **业务咨询**:
- “我想测试下单业务流程,请分析代码并列出详细的测试步骤表格。” - “我想测试下单业务流程,请分析代码并列出详细的测试步骤表格。”
@@ -71,6 +79,8 @@ description: g3fo 系统相关文档。管理业务流程说明、服务职责
- “请根据 `references/middleware/middleware-comprehensive-guide.md` 和 `references/middleware/nacos/deploy.md`,结合客户资产清单生成 Nacos 部署配置。” - “请根据 `references/middleware/middleware-comprehensive-guide.md` 和 `references/middleware/nacos/deploy.md`,结合客户资产清单生成 Nacos 部署配置。”
- **故障排查**: - **故障排查**:
- “我的 MySQL 出现了复制冲突,报错 `Duplicate entry`,请根据 `references/middleware/mysql/fault-analysis.md` 提供排查脚本 and 修复建议。” - “我的 MySQL 出现了复制冲突,报错 `Duplicate entry`,请根据 `references/middleware/mysql/fault-analysis.md` 提供排查脚本 and 修复建议。”
- **版本清单**:
- “请根据 `middleware-versions.md` 列出当前约定的全部组件版本,并按 Web Server / AP Server 分开。”
## 4. 资源地图 ## 4. 资源地图
- **业务与服务**: - **业务与服务**:
@@ -79,6 +89,7 @@ description: g3fo 系统相关文档。管理业务流程说明、服务职责
- **架构概览**: `architecture/domain_overview.md` - **架构概览**: `architecture/domain_overview.md`
- **中间件标准**(部署/故障分析等详见各子目录): - **中间件标准**(部署/故障分析等详见各子目录):
- **总览**:`middleware/middleware-comprehensive-guide.md` - **总览**:`middleware/middleware-comprehensive-guide.md`
- **版本清单**:`middleware/middleware-versions.md`(Web:Nginx/Vue/Flutter;AP:`g3fo-*-service` + 中间件 + JDK)
- **Keepalived**: `middleware/keepalived/` - **Keepalived**: `middleware/keepalived/`
- **MySQL**: `middleware/mysql/` - **MySQL**: `middleware/mysql/`
- **Nacos**: `middleware/nacos/` - **Nacos**: `middleware/nacos/`
@@ -4,23 +4,26 @@
## 故障场景表 ## 故障场景表
| 场景编号 | 故障场景描述 | 节点 A 状态(MySQL/Keepalived) | 节点 B 状态(MySQL/Keepalived) | 节点 C 状态(Keepalived) | 各节点有效优先级 | VIP 最终归属 | 关键说明 |
| --- | --- | --- | --- | --- | --- | --- | --- | | 场景编号 | 故障场景描述 | 节点 A 状态(MySQL/Keepalived) | 节点 B 状态(MySQL/Keepalived) | 节点 C 状态(Keepalived) | 各节点有效优先级 | VIP 最终归属 | 关键说明 |
| 1 | 初始正常状态 | 正常/正常 | 正常/正常 | 正常 | A=110、B=100、C=40 | Node A | A 优先级最高,成为 Master | | ---- | ---------------------------- | ------------------------- | ------------------------- | ------------------- | ---------------- | --------- | ------------------------------ |
| 2 | A 的 MySQL 停机,Keepalived 正常 | 故障/正常 | 正常/正常 | 正常 | A=60、B=100、C=40 | Node B | A 扣权重后,B 优先级更高接管 VIP | | 1 | 初始正常状态 | 正常/正常 | 正常/正常 | 正常 | A=110、B=100、C=40 | Node A | A 优先级最高,成为 Master |
| 3 | 场景 2 后,A 的 MySQL 恢复 | 恢复/正常 | 正常/正常 | 正常 | A=110、B=100、C=40 | Node A | A 优先级恢复,30 秒后抢占 VIP | | 2 | A 的 MySQL 停机,Keepalived 正常 | 故障/正常 | 正常/正常 | 正常 | A=60、B=100、C=40 | Node B | A 扣权重后,B 优先级更高接管 VIP |
| 4 | A 的 Keepalived 停机 | 正常/故障(离线) | 正常/正常 | 正常 | A=离线、B=100、C=40 | Node B | A 无选举资格,B 接管 | | 3 | 场景 2 后,A 的 MySQL 恢复 | 恢复/正常 | 正常/正常 | 正常 | A=110、B=100、C=40 | Node A | A 优先级恢复,30 秒后抢占 VIP |
| 5 | B 的 MySQL 停机 | 正常/正常 | 故障/正常 | 正常 | A=110、B=50、C=40 | Node A | B 扣权重后,A 仍为最高 | | 4 | A 的 Keepalived 停机 | 正常/故障(离线) | 正常/正常 | 正常 | A=离线、B=100、C=40 | Node B | A 无选举资格,B 接管 |
| 6 | B 的 Keepalived 停机 | 正常/正常 | 正常/故障(离线) | 正常 | A=110、B=离线、C=40 | Node A | B 无选举资格,A 保持 Master | | 5 | B 的 MySQL 停机 | 正常/正常 | 故障/正常 | 正常 | A=110、B=50、C=40 | Node A | B 扣权重后,A 仍为最高 |
| 7 | A、B MySQL 均停机 | 故障/正常 | 故障/正常 | 正常 | A=60、B=50、C=40 | Node A | 无可用 MySQL,但 Keepalived 仍按优先级选举 | | 6 | B 的 Keepalived 停机 | 正常/正常 | 正常/故障(离线) | 正常 | A=110、B=离线、C=40 | Node A | B 无选举资格,A 保持 Master |
| 8 | A MySQL 停机 + B Keepalived 停机 | 故障/正常 | 无意义/故障(离线) | 正常 | A=60、B=离线、C=40 | Node A | B 离线,A 优先级高于 C(但 MySQL 不可用) | | 7 | A、B MySQL 均停机 | 故障/正常 | 故障/正常 | 正常 | A=60、B=50、C=40 | Node A | 无可用 MySQL,但 Keepalived 仍按优先级选举 |
| 9 | A Keepalived 停机 + B MySQL 停机 | 正常/故障(离线) | 故障/正常 | 正常 | A=离线、B=50、C=40 | Node B | A 离线,B 优先级高于 C(但 MySQL 不可用) | | 8 | A MySQL 停机 + B Keepalived 停机 | 故障/正常 | 无意义/故障(离线) | 正常 | A=60、B=离线、C=40 | Node A | B 离线,A 优先级高于 C(但 MySQL 不可用) |
| 10 | A、B Keepalived 均停机 | 正常/故障(离线) | 正常/故障(离线) | 正常 | A=离线、B=离线、C=40 | 无节点绑定 VIP | C 无 VIP 配置,仅参与选举不持有 VIP | | 9 | A Keepalived 停机 + B MySQL 停机 | 正常/故障(离线) | 故障/正常 | 正常 | A=离线、B=50、C=40 | Node B | A 离线,B 优先级高于 C(但 MySQL 不可用) |
| 11 | A MySQL+Keepalived 均停机 | 故障/故障(离线) | 正常/正常 | 正常 | A=离线、B=100、C=40 | Node B | A 完全离线,B 正常接管 | | 10 | A、B Keepalived 均停机 | 正常/故障(离线) | 正常/故障(离线) | 正常 | A=离线、B=离线、C=40 | 无节点绑定 VIP | C 无 VIP 配置,仅参与选举不持有 VIP |
| 12 | B MySQL+Keepalived 均停机 | 正常/正常 | 故障/故障(离线) | 正常 | A=110、B=离线、C=40 | Node A | B 完全离线,A 保持 Master | | 11 | A MySQL+Keepalived 均停机 | 故障/故障(离线) | 正常/正常 | 正常 | A=离线、B=100、C=40 | Node B | A 完全离线,B 正常接管 |
| 13 | 场景 7 后,A MySQL 恢复 | 恢复/正常 | 故障/正常 | 正常 | A=110、B=50、C=40 | Node A | A 优先级恢复最高,接管 VIP | | 12 | B MySQL+Keepalived 均停机 | 正常/正常 | 故障/故障(离线) | 正常 | A=110、B=离线、C=40 | Node A | B 完全离线,A 保持 Master |
| 14 | 场景 7 后,B MySQL 恢复 | 故障/正常 | 恢复/正常 | 正常 | A=60、B=100、C=40 | Node B | B 优先级高于 A,接管 VIP | | 13 | 场景 7 后,A MySQL 恢复 | 恢复/正常 | 故障/正常 | 正常 | A=110、B=50、C=40 | Node A | A 优先级恢复最高,接管 VIP |
| 15 | C Keepalived 停机 | 正常/正常 | 正常/正常 | 故障(离线) | A=110、B=100、C=离线 | Node A | C 仅为仲裁,离线不影响 A/B 选举 | | 14 | 场景 7 后,B MySQL 恢复 | 故障/正常 | 恢复/正常 | 正常 | A=60、B=100、C=40 | Node B | B 优先级高于 A,接管 VIP |
| 16 | A、B MySQL 均停机 + C 停机 | 故障/正常 | 故障/正常 | 故障(离线) | A=60、B=50、C=离线 | Node A | C 离线不影响 A/B 选举 | | 15 | C Keepalived 停机 | 正常/正常 | 正常/正常 | 故障(离线) | A=110、B=100、C=离线 | Node A | C 仅为仲裁,离线不影响 A/B 选举 |
| 17 | 所有节点 Keepalived 均停机 | 正常/故障(离线) | 正常/故障(离线) | 故障(离线) | 全离线 | 无节点绑定 VIP | 无 Keepalived 参与选举,VIP 失联 | | 16 | A、B MySQL 均停机 + C 停机 | 故障/正常 | 故障/正常 | 故障(离线) | A=60、B=50、C=离线 | Node A | C 离线不影响 A/B 选举 |
| 18 | A 恢复但复制异常(check_preempt 失败) | 恢复/正常 | 正常/正常 | 正常 | A=90、B=100、C=40 | Node B | 副库端口正常但 A 本地复制异常,A 降权不抢占 | | 17 | 所有节点 Keepalived 均停机 | 正常/故障(离线) | 正常/故障(离线) | 故障(离线) | 全离线 | 无节点绑定 VIP | 无 Keepalived 参与选举,VIP 失联 |
| 18 | A 恢复但复制异常(check_preempt 失败) | 恢复/正常 | 正常/正常 | 正常 | A=90、B=100、C=40 | Node B | 副库端口正常但 A 本地复制异常,A 降权不抢占 |
@@ -0,0 +1,74 @@
# 中间件、应用服务与标准组件版本清单
本文档列出 g3fo 相关环境中**约定使用的镜像、发行或应用版本**,便于部署对齐、升级评估与问题排查。若与某客户现场或分支实际镜像不一致,以该环境资产清单或运行中的 `docker images` / 构建配置为准。
## 角色划分:AP Server 与 Web Server
| 角色 | 范围 | 说明 |
| ---- | ---- | ---- |
| **AP Server** | 全部 `g3fo-*-service` 业务应用、**全部中间件**(MySQL、Redis、RocketMQ、Nacos、PowerJob、观测与辅助组件等)、Java 运行时(OpenJDK) | 后端与基础设施侧;**不**含 Nginx / Vue / Flutter。 |
| **Web Server** | 仅 **Nginx**、**Vue**、**Flutter** | 反向代理 / 静态与前端构建链;与 AP 侧分开罗列。 |
**AI / 读者输出约定**:当用户询问「所有版本」「完整版本清单」「环境上各组件版本」等时,须**分两节**回答:**先列 Web Server**,**再列 AP Server**(或按用户要求的顺序,但必须明确标注两类,不可混成一张不分角色的总表)。
---
## Web Server(仅下列三项)
| 组件 | 版本 / 标识 | 说明 |
| ---- | ------------- | ---- |
| Nginx | `1.27.3` | 反向代理 / 网关前置 |
| Vue | `3.5.17` | 前端框架(与构建依赖对齐) |
| Flutter | `3.38.4` | 移动端 / 跨端构建工具链 |
---
## AP Server
### 业务应用(`g3fo-*-service`)
以下应用版本统一为 **`1.5.1.22`**。
| 服务 | 版本 |
| ---- | ---- |
| g3fo-trade-service | `1.5.1.22` |
| g3fo-product-service | `1.5.1.22` |
| g3fo-base-service | `1.5.1.22` |
| g3fo-admin-service | `1.5.1.22` |
| g3fo-monitor-service | `1.5.1.22` |
| g3fo-dx-service | `1.5.1.22` |
| g3fo-user-service | `1.5.1.22` |
| g3fo-margin-service | `1.5.1.22` |
| g3fo-notification-service | `1.5.1.22` |
| g3fo-exchange-fix-engine-service | `1.5.1.22` |
| g3fo-gateway-service | `1.5.1.22` |
| g3fo-push-service | `1.5.1.22` |
| g3fo-utility-service | `1.5.1.22` |
### 中间件与运行时(同属 AP Server 侧)
| 组件 | 版本 / 镜像标识 | 说明 |
| ---- | ---------------- | ---- |
| MySQL | `8.4.7` | 关系型数据库 |
| Redis | `8.4.0` | 缓存与会话等 |
| RocketMQ | `5.3.2` | 消息队列 |
| RocketMQ Dashboard | `2.1` | RocketMQ 管理控制台 |
| Nacos Server | `v3.1.1` | 注册与配置中心 |
| PowerJob Server | `v5.1.2` | 分布式任务调度 |
| Beszel | `v0.18.4` | 轻量监控相关(容器化部署时按此标签) |
| Dozzle | `v9.0.3` | 容器日志查看 |
| dufs | `v0.43.0` | 文件服务(静态/上传等场景) |
| OpenJDK | `amazoncorretto:17-al2023` | Java 运行时(Amazon Corretto 17,AL2023 基础镜像) |
---
## 使用说明
- **中间件**:部署、Compose 或 K8s 清单中的镜像 tag 建议与本表一致,避免隐式 `latest`。
- **OpenJDK**:表内为 Docker 镜像习惯写法;本地非容器环境需安装同主版本 JDK 17 并与团队规范一致。
- **Vue / Flutter**:仅归入 **Web Server**;升级时需同步 CI、锁文件与团队文档。
## 相关文档
- 中间件高可用与部署总览:`middleware-comprehensive-guide.md`
- 各组件细则:同目录下 `mysql/`、`redis/`、`rocketmq/`、`nacos/`、`powerjob/` 等子目录
@@ -1,26 +1,32 @@
# MySQL 8.4 双主(Source-Source)同步部署文档(跨主机统一目录/容器名版) # MySQL 8.4 双主(Source-Source)同步部署文档(跨主机统一目录/容器名版)
> **AI 响应规范 (MySQL 执行策略)**: > **AI 响应规范 (MySQL 执行策略)**:
>
> 1. **强制参数**: 所有 `docker exec` 命令必须包含 `-h127.0.0.1` 参数。 > 1. **强制参数**: 所有 `docker exec` 命令必须包含 `-h127.0.0.1` 参数。
> 2. **双重输出**: 涉及 SQL 操作时,必须同时输出 `Docker 执行命令` 和 `纯 SQL 脚本`。 > 2. **双重输出**: 涉及 SQL 操作时,必须同时输出 `Docker 执行命令` 和 `纯 SQL 脚本`。
## 文档概述 ## 文档概述
你需要部署跨两台独立主机的 MySQL 8.4 双主双向复制集群,核心要求是:两台主机的容器名、目录路径统一使用 `mysql`(仅通过 IP 区分节点),保证两节点数据一致性,支持初始化、校验、宕机重启恢复及节点重建全流程。核心设计遵循: 你需要部署跨两台独立主机的 MySQL 8.4 双主双向复制集群,核心要求是:两台主机的容器名、目录路径统一使用 `mysql`(仅通过 IP 区分节点),保证两节点数据一致性,支持初始化、校验、宕机重启恢复及节点重建全流程。核心设计遵循:
+ **GTID + AUTO_POSITION**:重启后自动定位同步位点,减少人工干预; - **GTID + AUTO_POSITION**:重启后自动定位同步位点,减少人工干预;
+ **ROW 模式 binlog**:避免非确定性函数导致的数据不一致; - **ROW 模式 binlog**:避免非确定性函数导致的数据不一致;
+ **持久化数据卷**:独立 Volume 保障容器重启数据不丢失; - **持久化数据卷**:独立 Volume 保障容器重启数据不丢失;
+ **自增键隔离**:通过步长/偏移配置避免双写主键冲突。 - **自增键隔离**:通过步长/偏移配置避免双写主键冲突。
## 1. 环境准备 ## 1. 环境准备
### 1.1 节点信息(核心区分点) ### 1.1 节点信息(核心区分点)
| 节点 | 主机IP | server_id | auto_increment_offset | 核心标识 |
| :--- | :--- | :--- | :--- | :--- |
| 主节点A | ${NODE_A_IP} | 1 | 1 | 生成主键1、3、5… | | 节点 | 主机IP | server_id | auto_increment_offset | 核心标识 |
| 主节点B | ${NODE_B_IP} | 2 | 2 | 生成主键2、4、6… | | ---- | ------------ | --------- | --------------------- | ---------- |
| 主节点A | ${NODE_A_IP} | 1 | 1 | 生成主键1、3、5… |
| 主节点B | ${NODE_B_IP} | 2 | 2 | 生成主键2、4、6… |
### 1.2 系统依赖(两台主机统一执行) ### 1.2 系统依赖(两台主机统一执行)
```plain ```plain
# 安装 Docker & Docker Compose、MySQL 客户端 # 安装 Docker & Docker Compose、MySQL 客户端
sudo apt update && sudo apt install -y docker.io docker-compose-plugin mysql-client sudo apt update && sudo apt install -y docker.io docker-compose-plugin mysql-client
@@ -29,6 +35,7 @@ docker --version && docker compose version && mysql --version
``` ```
### 1.3 目录规划(两台主机完全一致) ### 1.3 目录规划(两台主机完全一致)
```plain ```plain
# 两台主机均执行以下命令,目录统一为 /data/mysql # 两台主机均执行以下命令,目录统一为 /data/mysql
sudo mkdir -p /data/mysql/{data,backup,logs} sudo mkdir -p /data/mysql/{data,backup,logs}
@@ -36,7 +43,9 @@ sudo chown 999:999 /data/mysql -R # MySQL 容器默认UID/GID为999,避免权
``` ```
## 2. 双主节点部署 ## 2. 双主节点部署
### 2.1 Docker Compose 配置(两台主机完全一致) ### 2.1 Docker Compose 配置(两台主机完全一致)
创建 `/data/mysql/docker-compose.yml`,内容如下(无节点差异): 创建 `/data/mysql/docker-compose.yml`,内容如下(无节点差异):
```plain ```plain
@@ -73,7 +82,9 @@ services:
``` ```
### 2.2 MySQL 配置文件(my.cnf) ### 2.2 MySQL 配置文件(my.cnf)
#### 节点A(${NODE_A_IP}):`/data/mysql/my.cnf` #### 节点A(${NODE_A_IP}):`/data/mysql/my.cnf`
```plain ```plain
[mysqld] [mysqld]
# ========== 节点唯一标识(核心区分点) ========== # ========== 节点唯一标识(核心区分点) ==========
@@ -120,6 +131,7 @@ super_read_only=OFF
``` ```
#### 节点B(${NODE_B_IP}):`/data/mysql/my.cnf` #### 节点B(${NODE_B_IP}):`/data/mysql/my.cnf`
```plain ```plain
[mysqld] [mysqld]
# ========== 节点唯一标识(核心区分点) ========== # ========== 节点唯一标识(核心区分点) ==========
@@ -166,6 +178,7 @@ super_read_only=OFF
``` ```
### 2.3 启动容器(两台主机统一执行) ### 2.3 启动容器(两台主机统一执行)
```plain ```plain
# 进入目录并启动容器 # 进入目录并启动容器
cd /data/mysql && docker compose up -d cd /data/mysql && docker compose up -d
@@ -175,7 +188,9 @@ docker ps | grep mysql
``` ```
## 3. 双主复制初始化 ## 3. 双主复制初始化
### 3.1 创建复制专用用户(两台主机统一执行) ### 3.1 创建复制专用用户(两台主机统一执行)
```plain ```plain
# 无需区分节点,直接执行(创建repl用户,允许跨主机访问) # 无需区分节点,直接执行(创建repl用户,允许跨主机访问)
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e " docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "
@@ -186,7 +201,9 @@ FLUSH PRIVILEGES;
``` ```
### 3.2 配置双向复制(核心:仅IP参数不同) ### 3.2 配置双向复制(核心:仅IP参数不同)
#### 步骤1:节点A(${NODE_A_IP})配置为节点B的从库 #### 步骤1:节点A(${NODE_A_IP})配置为节点B的从库
```plain ```plain
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e " docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "
STOP REPLICA; STOP REPLICA;
@@ -198,13 +215,14 @@ CHANGE REPLICATION SOURCE TO
SOURCE_PASSWORD = 'afe123456', SOURCE_PASSWORD = 'afe123456',
SOURCE_AUTO_POSITION = 1, # GTID自动定位(无需手动找位点) SOURCE_AUTO_POSITION = 1, # GTID自动定位(无需手动找位点)
SOURCE_CONNECT_RETRY = 10, SOURCE_CONNECT_RETRY = 10,
SOURCE_RETRY_COUNT = 86400, SOURCE_RETRY_COUNT = 9999999999,
GET_SOURCE_PUBLIC_KEY = 1; # 适配MySQL 8.0+密码认证 GET_SOURCE_PUBLIC_KEY = 1; # 适配MySQL 8.0+密码认证
START REPLICA; START REPLICA;
" "
``` ```
#### 步骤2:节点B(${NODE_B_IP})配置为节点A的从库 #### 步骤2:节点B(${NODE_B_IP})配置为节点A的从库
```plain ```plain
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e " docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "
STOP REPLICA; STOP REPLICA;
@@ -216,38 +234,44 @@ CHANGE REPLICATION SOURCE TO
SOURCE_PASSWORD = 'afe123456', SOURCE_PASSWORD = 'afe123456',
SOURCE_AUTO_POSITION = 1, SOURCE_AUTO_POSITION = 1,
SOURCE_CONNECT_RETRY = 10, SOURCE_CONNECT_RETRY = 10,
SOURCE_RETRY_COUNT = 86400, SOURCE_RETRY_COUNT = 9999999999,
GET_SOURCE_PUBLIC_KEY = 1; GET_SOURCE_PUBLIC_KEY = 1;
START REPLICA; START REPLICA;
" "
``` ```
## 4. 同步状态校验 ## 4. 同步状态校验
### 4.1 核心校验命令(两台主机统一执行) ### 4.1 核心校验命令(两台主机统一执行)
**A. Docker 执行模式 (包含 -h127.0.0.1):** **A. Docker 执行模式 (包含 -h127.0.0.1):**
```bash ```bash
# 查看复制状态 # 查看复制状态
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "SHOW REPLICA STATUS\G" docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "SHOW REPLICA STATUS\G"
``` ```
**B. 纯 SQL 模式:** **B. 纯 SQL 模式:**
```sql ```sql
SHOW REPLICA STATUS\G SHOW REPLICA STATUS\G
``` ```
### 4.2 关键校验字段(必须全部满足) ### 4.2 关键校验字段(必须全部满足)
| 字段 | 目标值 | 说明 |
| :--- | :--- | :--- |
| Replica_IO_Running | Yes | IO线程正常(接收binlog) | | 字段 | 目标值 | 说明 |
| Replica_SQL_Running | Yes | SQL线程正常(执行事务) | | --------------------- | -------------------- | ---------------- |
| Last_SQL_Error | 空 | 无同步错误 | | Replica_IO_Running | Yes | IO线程正常(接收binlog) |
| Seconds_Behind_Source | 0 | 无同步延迟 | | Replica_SQL_Running | Yes | SQL线程正常(执行事务) |
| Retrieved_Gtid_Set | 非空 | 已获取对端节点GTID | | Last_SQL_Error | 空 | 无同步错误 |
| Executed_Gtid_Set | 包含Retrieved_Gtid_Set | 已执行所有获取的事务 | | Seconds_Behind_Source | 0 | 无同步延迟 |
| Retrieved_Gtid_Set | 非空 | 已获取对端节点GTID |
| Executed_Gtid_Set | 包含Retrieved_Gtid_Set | 已执行所有获取的事务 |
### 4.3 数据一致性验证 ### 4.3 数据一致性验证
```plain ```plain
# 节点A(${NODE_A_IP})创建测试数据 # 节点A(${NODE_A_IP})创建测试数据
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e " docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "
@@ -266,8 +290,11 @@ docker exec -it mysql mysql -uroot -pafe123456 -h${NODE_A_IP} -e "SELECT * FROM
``` ```
## 5. 故障处理流程 ## 5. 故障处理流程
### 5.1 宕机重启恢复 ### 5.1 宕机重启恢复
#### 场景:容器/主机重启后复制未自动恢复(两台主机操作逻辑一致,仅IP不同) #### 场景:容器/主机重启后复制未自动恢复(两台主机操作逻辑一致,仅IP不同)
```plain ```plain
# 以节点A(${NODE_A_IP})为例,恢复与节点B的复制关系 # 以节点A(${NODE_A_IP})为例,恢复与节点B的复制关系
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e " docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "
@@ -289,7 +316,9 @@ SHOW REPLICA STATUS\G;
``` ```
### 5.2 重建节点(单节点数据损坏/丢失) ### 5.2 重建节点(单节点数据损坏/丢失)
#### 步骤1:从正常节点全量备份(假设节点B损坏,从节点A备份) #### 步骤1:从正常节点全量备份(假设节点B损坏,从节点A备份)
```plain ```plain
# 节点A(${NODE_A_IP})执行备份 # 节点A(${NODE_A_IP})执行备份
docker exec -it mysql mysqldump -uroot -pafe123456 -h127.0.0.1 --all-databases --master-data=2 --single-transaction > /data/mysql/backup/full_backup.sql docker exec -it mysql mysqldump -uroot -pafe123456 -h127.0.0.1 --all-databases --master-data=2 --single-transaction > /data/mysql/backup/full_backup.sql
@@ -299,6 +328,7 @@ scp /data/mysql/backup/full_backup.sql root@${NODE_B_IP}:/data/mysql/backup/
``` ```
#### 步骤2:停止损坏节点并恢复数据(节点B执行) #### 步骤2:停止损坏节点并恢复数据(节点B执行)
```plain ```plain
# 停止mysql容器 # 停止mysql容器
cd /data/mysql && docker compose down cd /data/mysql && docker compose down
@@ -314,24 +344,34 @@ docker exec -i mysql mysql -uroot -pafe123456 < /data/mysql/backup/full_backup.s
``` ```
#### 步骤3:重新配置双向复制 #### 步骤3:重新配置双向复制
参考「3.2 配置双向复制」,重新执行节点B作为节点A从库、节点A作为节点B从库的配置命令(仅IP参数区分)。 参考「3.2 配置双向复制」,重新执行节点B作为节点A从库、节点A作为节点B从库的配置命令(仅IP参数区分)。
### 5.3 复制冲突解决 ### 5.3 复制冲突解决
详细的冲突定位与修复流程,请参考:[MySQL 复制冲突解决指南](./fault-analysis.md) 详细的冲突定位与修复流程,请参考:[MySQL 复制冲突解决指南](./fault-analysis.md)
## 6. 日志管理 ## 6. 日志管理
详细的日志轮转与安全登录配置,请参考:[MySQL 日志管理指南](./log-management.md) 详细的日志轮转与安全登录配置,请参考:[MySQL 日志管理指南](./log-management.md)
## 7. 重要提醒 ## 7. 重要提醒
### 7.1 核心区分点(避免配置错误) ### 7.1 核心区分点(避免配置错误)
+ 两台主机仅 `server_id`、`report_host`(IP)、`auto_increment_offset` 三个参数不同,其余配置完全一致;
+ 容器名、目录路径、端口均统一为 `mysql`/`/data/mysql`/3306,通过主机IP区分节点。 - 两台主机仅 `server_id`、`report_host`(IP)、`auto_increment_offset` 三个参数不同,其余配置完全一致;
- 容器名、目录路径、端口均统一为 `mysql`/`/data/mysql`/3306,通过主机IP区分节点。
### 7.2 一致性限制 ### 7.2 一致性限制
+ MySQL原生异步双主复制**无法保证严格的数据一致性**,双写场景仍可能出现冲突;
+ 生产环境建议**单写多读**(指定一个节点为写节点,另一个为读节点),避免双写冲突。 - MySQL原生异步双主复制**无法保证严格的数据一致性**,双写场景仍可能出现冲突;
- 生产环境建议**单写多读**(指定一个节点为写节点,另一个为读节点),避免双写冲突。
#### 总结 #### 总结
#### 跨主机双主复制的核心差异仅为 `server_id`、主机IP、自增偏移量,其余目录/容器名/配置可完全统一; #### 跨主机双主复制的核心差异仅为 `server_id`、主机IP、自增偏移量,其余目录/容器名/配置可完全统一;
#### 同步故障优先通过错误日志定位冲突,修复数据而非跳过事务(避免永久不一致); #### 同步故障优先通过错误日志定位冲突,修复数据而非跳过事务(避免永久不一致);
#### GTID模式下无需手动定位同步位点,重启/重建节点时仅需重新指定对端IP即可自动恢复复制。 #### GTID模式下无需手动定位同步位点,重启/重建节点时仅需重新指定对端IP即可自动恢复复制。
@@ -150,9 +150,28 @@ scp /data/backup/mysql/20250127120000.7z root@192.168.1.100:/data/backup/mysql/
**注意**: 确保还原机的 `/data/backup/mysql/` 目录存在且有写入权限。 **注意**: 确保还原机的 `/data/backup/mysql/` 目录存在且有写入权限。
#### 步骤 3: 在还原机上执行还原 #### 步骤 3: 在还原机上还原前准备(清空数据并重启 MySQL)
在需要还原的 MySQL 服务器(从库)上执行还原脚本: 在需要还原的 MySQL 服务器(还原机)上,**先清空数据目录并重启容器**,再执行还原脚本,避免旧数据与备份冲突。
1. **删除数据目录中的现有数据**(在还原机执行):
```bash
# 删除 /data/mysql/data 下数据(请确认该路径为当前 MySQL 数据目录)
rm -rf /data/mysql/data/*
```
2. **在 docker-compose 目录下重启 MySQL 容器**(在还原机执行):
```bash
cd /data/docker-compose
docker-compose down mysql
docker-compose up -d mysql
```
等待 MySQL 启动就绪(可查看容器日志或稍等 10~30 秒)后,再进行步骤 4 的还原。
#### 步骤 4: 在还原机上执行还原脚本
在还原机上执行还原脚本:
```bash ```bash
cd /data/backup/mysql cd /data/backup/mysql
@@ -171,50 +190,170 @@ cd /data/backup/mysql
- 导入备份数据(导入时禁用 binlog,避免循环复制) - 导入备份数据(导入时禁用 binlog,避免循环复制)
- 设置 GTID purged(从备份时的 GTID 位置) - 设置 GTID purged(从备份时的 GTID 位置)
- 恢复 MySQL 为读写模式 - 恢复 MySQL 为读写模式
- **自动启动复制并检查状态**
#### 步骤 4: 在备份机上启动同步(重要) #### 步骤 5: 还原后创建账号(在还原机执行)
还原完成后,还原脚本会自动启动复制并显示同步状态。确认看到同步成功后,**需要在备份机(主库)上执行以下操作**: 还原完成后,在**还原机**上创建复制与业务所需账号(若备份中已含用户则可酌情跳过,建议按环境统一执行一次)。
**A. 创建复制账号 `repl` 与日志刷新账号 `log_flush`**
Docker 执行:
```bash
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "
CREATE USER 'repl'@'%' IDENTIFIED BY 'afe123456';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';
FLUSH PRIVILEGES;
"
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "
CREATE USER 'log_flush'@'127.0.0.1' IDENTIFIED BY 'afe123456';
GRANT RELOAD ON *.* TO 'log_flush'@'127.0.0.1';
FLUSH PRIVILEGES;
"
```
**B. 创建 G3SF 相关业务账号**
Docker 执行:
```bash
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "
CREATE USER 'g3sf'@'%' IDENTIFIED BY 'k2-fkW@7z>zSYQo';
GRANT ALL PRIVILEGES ON *.* TO 'g3sf'@'%';
CREATE USER 'g3sfreader'@'%' IDENTIFIED BY '@feg3sf10port';
GRANT SELECT ON *.* TO 'g3sfreader'@'%';
CREATE USER 'g3sfsupport'@'%' IDENTIFIED BY '@feg3sf10port';
GRANT ALL PRIVILEGES ON *.* TO 'g3sfsupport'@'%';
FLUSH PRIVILEGES;
"
```
纯 SQL(在 MySQL 客户端中执行):
```sql
-- g3sf:应用库完全访问
CREATE USER 'g3sf'@'%' IDENTIFIED BY 'k2-fkW@7z>zSYQo';
GRANT ALL PRIVILEGES ON *.* TO 'g3sf'@'%';
-- g3sfreader:只读
CREATE USER 'g3sfreader'@'%' IDENTIFIED BY '@feg3sf10port';
GRANT SELECT ON *.* TO 'g3sfreader'@'%';
-- g3sfsupport:支持库完全访问
CREATE USER 'g3sfsupport'@'%' IDENTIFIED BY '@feg3sf10port';
GRANT ALL PRIVILEGES ON *.* TO 'g3sfsupport'@'%';
FLUSH PRIVILEGES;
```
#### 步骤 6: 在还原机上连接主 DB 并启动复制(在还原机执行)
在**还原机**上配置复制源为主库(将 `NODE_A_IP` 替换为实际主 DB 的 IP):
**A. Docker 执行模式 (包含 -h127.0.0.1):** **A. Docker 执行模式 (包含 -h127.0.0.1):**
```bash ```bash
# 将 NODE_A_IP 替换为主 DB 的 IP
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e " docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "
STOP REPLICA;
RESET REPLICA ALL;
CHANGE REPLICATION SOURCE TO
SOURCE_HOST = 'NODE_A_IP',
SOURCE_PORT = 3306,
SOURCE_USER = 'repl',
SOURCE_PASSWORD = 'afe123456',
SOURCE_AUTO_POSITION = 1,
SOURCE_CONNECT_RETRY = 10,
SOURCE_RETRY_COUNT = 9999999999,
GET_SOURCE_PUBLIC_KEY = 1;
START REPLICA; START REPLICA;
" "
``` ```
**B. 纯 SQL 模式:** **B. 纯 SQL 模式:**
```sql ```sql
STOP REPLICA;
RESET REPLICA ALL;
CHANGE REPLICATION SOURCE TO
SOURCE_HOST = 'NODE_A_IP', -- 主 DB IP
SOURCE_PORT = 3306,
SOURCE_USER = 'repl',
SOURCE_PASSWORD = 'afe123456',
SOURCE_AUTO_POSITION = 1,
SOURCE_CONNECT_RETRY = 10,
SOURCE_RETRY_COUNT = 9999999999,
GET_SOURCE_PUBLIC_KEY = 1;
START REPLICA; START REPLICA;
``` ```
等待 3 秒后,检查同步状态: 等待几秒后检查复制状态,确认 IO/SQL 均为 Yes:
**A. Docker 执行模式 (包含 -h127.0.0.1):**
```bash ```bash
sleep 3 sleep 5
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "SHOW REPLICA STATUS\G" docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "SHOW REPLICA STATUS\G"
``` ```
**B. 纯 SQL 模式:**
```sql
-- 等待 3 秒后执行
SHOW REPLICA STATUS\G;
```
检查关键指标: 检查关键指标:
- `Replica_IO_Running`: 应为 `Yes` - `Replica_IO_Running`: 应为 `Yes`
- `Replica_SQL_Running`: 应为 `Yes` - `Replica_SQL_Running`: 应为 `Yes`
- `Last_IO_Error`: 应为空 - `Last_IO_Error` / `Last_SQL_Error`: 应为空
- `Last_SQL_Error`: 应为空
- `Seconds_Behind_Source`: 延迟秒数(应逐渐减小) 状态正常后,再进行步骤 7,在主库上配置指向本机(还原机/备份库)。
#### 步骤 7: 在主库上配置指向备份库(还原机)并检查状态(在主库执行)
在**主备份数据库(主库)**上配置复制源为当前还原机(备份库),将 `NODE_B_IP` 替换为还原机/备份 DB 的 IP:
**A. Docker 执行模式 (包含 -h127.0.0.1):**
```bash
# 将 NODE_B_IP 替换为备份 DB(还原机)的 IP
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "
STOP REPLICA;
RESET REPLICA ALL;
CHANGE REPLICATION SOURCE TO
SOURCE_HOST = 'NODE_B_IP',
SOURCE_PORT = 3306,
SOURCE_USER = 'repl',
SOURCE_PASSWORD = 'afe123456',
SOURCE_AUTO_POSITION = 1,
SOURCE_CONNECT_RETRY = 10,
SOURCE_RETRY_COUNT = 9999999999,
GET_SOURCE_PUBLIC_KEY = 1;
START REPLICA;
"
```
**B. 纯 SQL 模式:**
```sql
STOP REPLICA;
RESET REPLICA ALL;
CHANGE REPLICATION SOURCE TO
SOURCE_HOST = 'NODE_B_IP', -- 备份 DB(还原机)IP
SOURCE_PORT = 3306,
SOURCE_USER = 'repl',
SOURCE_PASSWORD = 'afe123456',
SOURCE_AUTO_POSITION = 1,
SOURCE_CONNECT_RETRY = 10,
SOURCE_RETRY_COUNT = 9999999999,
GET_SOURCE_PUBLIC_KEY = 1;
START REPLICA;
```
等待几秒后再次检查主库复制状态:
```bash
sleep 5
docker exec -it mysql mysql -uroot -pafe123456 -h127.0.0.1 -e "SHOW REPLICA STATUS\G"
```
确认:
- `Replica_IO_Running`: 应为 `Yes`
- `Replica_SQL_Running`: 应为 `Yes`
- `Last_IO_Error` / `Last_SQL_Error`: 应为空
- `Seconds_Behind_Source`: 延迟应逐渐减小
至此,全量备份还原与主从(含主库侧)复制配置完成。
### 2.3 注意事项 ### 2.3 注意事项
1. **备份时机**: 建议在业务低峰期执行备份,减少对业务的影响 1. **备份时机**: 建议在业务低峰期执行备份,减少对业务的影响
2. **网络传输**: 确保备份机和还原机之间网络畅通,备份文件较大时传输可能需要较长时间 2. **网络传输**: 确保备份机和还原机之间网络畅通,备份文件较大时传输可能需要较长时间
3. **磁盘空间**: 确保还原机有足够的磁盘空间存放备份文件和临时解压文件 3. **磁盘空间**: 确保还原机有足够的磁盘空间存放备份文件和临时解压文件;还原前清空 `/data/mysql/data` 前请确认该路径为当前 MySQL 数据目录
4. **权限要求**: 脚本需要 MySQL root 权限,以及 `/data/backup/mysql/` 目录的读写权限 4. **权限要求**: 脚本需要 MySQL root 权限,以及 `/data/backup/mysql/`、`/data/mysql/data` 等目录的相应权限
5. **GTID 一致性**: 还原脚本会自动处理 GTID,确保复制能够正常继续 5. **GTID 一致性**: 还原脚本会自动处理 GTID,确保复制能够正常继续
6. **备份机同步**: 还原完成后务必在备份机(主库)上执行 `START REPLICA` 并检查状态,确保双向复制正常 6. **还原前准备**: 还原前必须在还原机上删除 `/data/mysql/data` 数据并重启 MySQL 容器(步骤 3),再执行还原脚本,避免旧数据与备份冲突
7. **账号与复制顺序**: 还原后先创建账号(步骤 5),再在还原机配置指向主库的复制并检查状态(步骤 6),最后在主库上配置指向还原机的复制并检查状态(步骤 7),确保双向复制正常
@@ -1,15 +1,19 @@
# Redis Sentinel 高可用架构部署与运维手册 # Redis Sentinel 高可用架构部署与运维手册
## 一、概述 ## 一、概述
### 1.1 核心功能 ### 1.1 核心功能
Redis Sentinel(哨兵)是 Redis 的高可用解决方案。其核心目标是实现主从切换的自动化,确保系统在主节点故障时能够自动选举新的主节点并恢复服务。 Redis Sentinel(哨兵)是 Redis 的高可用解决方案。其核心目标是实现主从切换的自动化,确保系统在主节点故障时能够自动选举新的主节点并恢复服务。
### 1.2 适用场景 ### 1.2 适用场景
- 生产环境下的 Redis 高可用需求。 - 生产环境下的 Redis 高可用需求。
- 需要自动故障转移(Failover)的分布式系统。 - 需要自动故障转移(Failover)的分布式系统。
- 读写分离架构。 - 读写分离架构。
### 1.3 前置条件 ### 1.3 前置条件
- **运行环境**:Docker & Docker Compose。 - **运行环境**:Docker & Docker Compose。
- **镜像版本**:推荐使用 Redis 8.4.0 及以上版本。 - **镜像版本**:推荐使用 Redis 8.4.0 及以上版本。
- **网络规划**:所有节点需网络互通,且需明确各宿主机的外部 IP。 - **网络规划**:所有节点需网络互通,且需明确各宿主机的外部 IP。
@@ -17,9 +21,11 @@ Redis Sentinel(哨兵)是 Redis 的高可用解决方案。其核心目标
--- ---
## 二、环境准备 ## 二、环境准备
在所有节点(主节点、从节点、哨兵节点)上执行目录初始化及权限设置。 在所有节点(主节点、从节点、哨兵节点)上执行目录初始化及权限设置。
### 2.1 目录结构配置 ### 2.1 目录结构配置
```bash ```bash
# 创建配置、数据、日志目录 # 创建配置、数据、日志目录
mkdir -p /data/redis/{conf,data,logs} mkdir -p /data/redis/{conf,data,logs}
@@ -34,9 +40,11 @@ chmod -R 750 /data/redis
## 三、部署流程 ## 三、部署流程
### 3.1 Docker 服务编排 ### 3.1 Docker 服务编排
使用 Docker Compose 部署 Redis 服务及 Sentinel 节点。 使用 Docker Compose 部署 Redis 服务及 Sentinel 节点。
**docker-compose.yml 示例:** **docker-compose.yml 示例:**
```yaml ```yaml
version: '3.8' version: '3.8'
@@ -49,9 +57,9 @@ services:
ports: ports:
- "6379:6379" - "6379:6379"
volumes: volumes:
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime
- /etc/timezone:/etc/timezone:ro - /etc/timezone:/etc/timezone
- /data/redis/conf:/etc/redis - /data/redis/conf/redis.conf:/etc/redis/redis.conf
- /data/redis/data:/data - /data/redis/data:/data
- /data/redis/logs:/var/log/redis - /data/redis/logs:/var/log/redis
command: redis-server /etc/redis/redis.conf command: redis-server /etc/redis/redis.conf
@@ -66,7 +74,9 @@ services:
ports: ports:
- "26379:26379" - "26379:26379"
volumes: volumes:
- /data/redis/conf:/etc/redis - /etc/localtime:/etc/localtime
- /etc/timezone:/etc/timezone
- /data/redis/conf/sentinel.conf:/etc/redis/sentinel.conf
- /data/redis/logs:/var/log/redis - /data/redis/logs:/var/log/redis
command: redis-sentinel /etc/redis/sentinel.conf command: redis-sentinel /etc/redis/sentinel.conf
networks: networks:
@@ -82,6 +92,7 @@ networks:
## 四、核心配置说明 ## 四、核心配置说明
### 4.1 Redis 服务端配置 (`redis.conf`) ### 4.1 Redis 服务端配置 (`redis.conf`)
```Properties ```Properties
# 基础配置 # 基础配置
bind 0.0.0.0 bind 0.0.0.0
@@ -142,7 +153,9 @@ auto-aof-rewrite-min-size 64mb
notify-keyspace-events AE notify-keyspace-events AE
``` ```
从节点添加配置 从节点添加配置
```Properties ```Properties
# 指定广播地址,即使 Docker 内部获取到的是 172.x,也强制告诉 Sentinel 我是 ${REDIS_SLAVE_IP} # 指定广播地址,即使 Docker 内部获取到的是 172.x,也强制告诉 Sentinel 我是 ${REDIS_SLAVE_IP}
replica-announce-ip ${REDIS_SLAVE_IP} replica-announce-ip ${REDIS_SLAVE_IP}
@@ -152,22 +165,26 @@ replicaof ${REDIS_MASTER_IP} 6379
# 从节点只读(默认开启,避免误写) # 从节点只读(默认开启,避免误写)
replica-read-only yes replica-read-only yes
``` ```
以下为生产环境推荐的核心配置项: 以下为生产环境推荐的核心配置项:
| 配置项 | 说明 | 推荐值 |
| :--- | :--- | :--- | | 配置项 | 说明 | 推荐值 |
| `bind` | 绑定 IP 地址 | `0.0.0.0` | | ---------------------- | ------------------------- | ------------------ |
| `protected-mode` | 保护模式 | `no` | | `bind` | 绑定 IP 地址 | `0.0.0.0` |
| `port` | 监听端口 | `6379` | | `protected-mode` | 保护模式 | `no` |
| `replica-announce-ip` | 宣告外部 IP(解决 Docker NAT 问题) | 宿主机实际 IP | | `port` | 监听端口 | `6379` |
| `requirepass` | 服务访问密码 | 自定义强密码 | | `replica-announce-ip` | 宣告外部 IP(解决 Docker NAT 问题) | 宿主机实际 IP |
| `masterauth` | 主从同步授权密码 | 与 `requirepass` 一致 | | `requirepass` | 服务访问密码 | 自定义强密码 |
| `appendonly` | 开启 AOF 持久化 | `yes` | | `masterauth` | 主从同步授权密码 | 与 `requirepass` 一致 |
| `aof-use-rdb-preamble` | 开启混合持久化 | `yes` | | `appendonly` | 开启 AOF 持久化 | `yes` |
| `aof-use-rdb-preamble` | 开启混合持久化 | `yes` |
> **注意:** 在从节点配置中,必须包含 `replicaof <master-ip> 6379` 以建立主从关系。 > **注意:** 在从节点配置中,必须包含 `replicaof <master-ip> 6379` 以建立主从关系。
### 4.2 哨兵配置 (`sentinel.conf`) ### 4.2 哨兵配置 (`sentinel.conf`)
```Properties ```Properties
# 基础配置 # 基础配置
bind 0.0.0.0 bind 0.0.0.0
@@ -201,19 +218,100 @@ sentinel failover-timeout mymaster 180000
# 禁止Sentinel在故障转移后自动重配置主节点(Docker环境下无需开启) # 禁止Sentinel在故障转移后自动重配置主节点(Docker环境下无需开启)
sentinel deny-scripts-reconfig yes sentinel deny-scripts-reconfig yes
``` ```
哨兵节点用于监控主节点状态并协调切换。 哨兵节点用于监控主节点状态并协调切换。
| 配置项 | 说明 | 示例值 |
| :--- | :--- | :--- | | 配置项 | 说明 | 示例值 |
| `sentinel monitor` | 监控主节点(名称、IP、端口、法定人数) | `mymaster ${REDIS_MASTER_IP} 6379 2` | | ---------------------------------- | -------------------- | ------------------------------------ |
| `sentinel auth-pass` | 主节点访问密码 | `mymaster afe123456` | | `sentinel monitor` | 监控主节点(名称、IP、端口、法定人数) | `mymaster ${REDIS_MASTER_IP} 6379 2` |
| `sentinel down-after-milliseconds` | 故障判定超时时间(毫秒) | `30000` | | `sentinel auth-pass` | 主节点访问密码 | `mymaster afe123456` |
| `sentinel failover-timeout` | 故障转移超时时间 | `180000` | | `sentinel down-after-milliseconds` | 故障判定超时时间(毫秒) | `30000` |
| `sentinel announce-ip` | 宣告哨兵外部 IP | 宿主机实际 IP | | `sentinel failover-timeout` | 故障转移超时时间 | `180000` |
| `sentinel announce-ip` | 宣告哨兵外部 IP | 宿主机实际 IP |
### 4.3 哨兵数量与 Master 选举
#### 如何选出新的 Redis Master(故障转移)
这里的「Master」指 **Redis 主节点**(可写的那台),不是哨兵本身。流程简述如下:
1. **判定主节点下线**
每个哨兵定期探测 Redis 主节点。当某个哨兵认为主节点 **主观下线(SDOWN)** 后,会向其他哨兵询问;当达到 **quorum** 个哨兵都认为主节点不可达时,主节点被判定为 **客观下线(ODOWN)**。
2. **选举 Sentinel 领导者**
哨兵之间通过 Raft 式选举选出一个 **Leader**,由它来执行后续故障转移(只会有唯一一个 Sentinel 在执行 failover)。
3. **选出新的 Redis Master**
Leader 在**当前存活的从节点**中选一个提升为新主,选择规则通常为:
- 若配置了 `replica-priority`(哨兵侧为 `sentinel replica-priority`),优先选优先级最高且可达的从节点;
- 否则按**复制偏移量**优先选数据最新的从节点;
- 再按 `runid` 字典序等做 tie-break。
选好后向该从节点下发 `REPLICAOF NO ONE`,使其成为新主,其余从节点改为复制新主。
所以:**“选出 master 节点” = 由 Sentinel Leader 在从节点里按优先级/复制进度选一个,并执行提升为主。**
#### 是否一定要三台哨兵以上?
| 哨兵数量 | quorum 典型值 | 说明 |
|----------|----------------|------|
| **1 台** | 1 | 可以触发故障转移,但哨兵单点故障则无人做 failover,**不推荐生产**。 |
| **2 台** | 1 或 2 | quorum=1:能 failover,但两台意见不一致时可能脑裂;quorum=2:需两台都同意才 failover,任一台宕机则永远达不到 2,**无法触发故障转移**。 |
| **3 台** | **2** | 至少 2 台同意才客观下线并执行 failover;允许 1 台哨兵宕机仍可完成选举与切换,**生产推荐最少 3 台 + quorum=2**。 |
| 5 台 | 3 | 可容忍 2 台哨兵同时宕机。 |
**结论**:
- **不是“必须大于 3 台”**,但 **1 台无高可用、2 台难以兼顾安全与可用**,因此 **至少 3 台哨兵 + quorum=2** 是常见最小生产配置,这样既能选出新 master,又能在挂掉 1 台哨兵时继续工作。
quorum 在配置里即 `sentinel monitor mymaster <ip> <port> <quorum>` 的最后一个数字,例如:
```Properties
sentinel monitor mymaster 192.168.3.200 6379 2
```
表示至少 **2 个**哨兵认为主节点下线,才会触发客观下线和后续的 Master 选举与故障转移。
#### 两哨兵存活却未选出主节点时的排查
当前是**两台 Redis 主机 + 两台哨兵**且两台哨兵都存活时,若一直没选出新主,可按下面逐项查。
1. **quorum 是否已达到**
客观下线(ODOWN)需要至少 quorum 个哨兵认为主节点下线,才会进入故障转移。
- 若配置是 `sentinel monitor mymaster ... 2`,则**两台哨兵都必须**认为主节点不可达。
- 在**两台哨兵上分别**执行,看主节点是否已被判为客观下线(`flags` 里是否有 `o_down`):
```bash
redis-cli -h <哨兵1_IP> -p 26379 -a <密码> SENTINEL master mymaster
redis-cli -h <哨兵2_IP> -p 26379 -a <密码> SENTINEL master mymaster
```
- 若只有一台认为 down,另一台仍认为 master 存活,则不会 ODOWN,也就不会触发选举。常见原因:网络分区、或两台哨兵到原主节点的网络表现不一致。
2. **是否有可提升的从节点**
新主是从**当前从节点(replica)**里选出来的;若哨兵认为没有可用从节点,就不会选出新主。
- 在两台哨兵上任选一台执行:
```bash
redis-cli -h <哨兵IP> -p 26379 -a <密码> SENTINEL slaves mymaster
```
- 看列表是否为空、或从节点是否被标成 `s_down`/`o_down`。若从节点也被判下线或复制链路断开,哨兵不会提升它。
3. **法定人数与哨兵一致性**
执行(在两台哨兵上都跑一次):
```bash
redis-cli -h <哨兵IP> -p 26379 -a <密码> SENTINEL ckquorum mymaster
```
- 若提示 quorum 未满足,说明当前“认为主节点下线”的哨兵数不足,不会触发故障转移。
4. **quorum 配置是否过大**
若当时部署时按 3 台哨兵配了 `quorum=2`,现在只剩 2 台,理论上 2 台都同意即可达到 quorum=2。但若误配成 **quorum=3**,则 2 台永远达不到 3,**不会**触发客观下线和选举。检查每台哨兵的 `sentinel.conf` 里 `sentinel monitor mymaster ...` 最后一个数字是否为 2(2 台哨兵时建议为 2)。
5. **等待时间**
主节点需在 **down-after-milliseconds**(如 30000)内一直被判定不可达,才会主观下线,再经 quorum 达成客观下线。刚宕机后需至少等够这段时间再看是否选出新主。
**小结**:两哨兵存活仍不选主,多半是 **(1) quorum 未达成**(两台对主节点状态看法不一致)或 **(2) quorum 配成 3**,或 **(3) 从节点被哨兵判为不可用**。先查 `SENTINEL master mymaster` 的 flags、`SENTINEL slaves mymaster` 和 `SENTINEL ckquorum mymaster`,再对照上述几条处理。
--- ---
## 五、客户端集成(Redisson) ## 五、客户端集成(Redisson)
在 Spring Boot 应用中,使用 Redisson 实现哨兵模式的连接: 在 Spring Boot 应用中,使用 Redisson 实现哨兵模式的连接:
```yaml ```yaml
@@ -235,8 +333,8 @@ spring:
# --- DB --- # --- DB ---
database: ${app.redis-database:0} # 选择 DB(0~15,取决于你的 redis 配置) database: ${app.redis-database:0} # 选择 DB(0~15,取决于你的 redis 配置)
# --- 启动检查 / 发现 --- # --- 启动检查 / 发现(单哨兵宕机不影响业务必读)---
checkSentinelsList: true # 启动时校验 Sentinel 列表(默认 true) checkSentinelsList: false # 建议 false:启动/刷新时不强制校验“全部”哨兵,任一只哨兵宕机不会导致客户端整体不可用(默认 true 会连所有哨兵,一个不通就报错)
sentinelsDiscovery: true # 是否自动发现更多 sentinel(默认 true;Docker/NAT 环境可考虑关掉避免“拓扑污染”) sentinelsDiscovery: true # 是否自动发现更多 sentinel(默认 true;Docker/NAT 环境可考虑关掉避免“拓扑污染”)
dnsMonitoringInterval: 5000 # DNS 变更监控间隔 ms;-1 表示禁用(对域名方式接入有用) dnsMonitoringInterval: 5000 # DNS 变更监控间隔 ms;-1 表示禁用(对域名方式接入有用)
@@ -307,30 +405,147 @@ spring:
## 六、运维常用命令 ## 六、运维常用命令
### 6.1 哨兵管理命令 ### 6.1 哨兵管理命令
详细的 Redis 常用命令及 Sentinel 运维操作请参考:[Redis 常用命令使用指南](./usage.md) 详细的 Redis 常用命令及 Sentinel 运维操作请参考:[Redis 常用命令使用指南](./usage.md)
通过 `redis-cli` 连接哨兵端口(默认 26379)执行: 通过 `redis-cli` 连接哨兵端口(默认 26379)执行:
| 命令 | 功能描述 |
| :--- | :--- | | 命令 | 功能描述 |
| `SENTINEL masters` | 列出所有被监控的主节点状态 | | ----------------------------------------- | ----------------- |
| `SENTINEL master <name>` | 查看指定主节点的详细信息 | | `SENTINEL masters` | 列出所有被监控的主节点状态 |
| `SENTINEL slaves <name>` | 查看指定主节点的从节点列表 | | `SENTINEL master <name>` | 查看指定主节点的详细信息 |
| `SENTINEL sentinels <name>` | 列出除当前节点外的其他哨兵实例 | | `SENTINEL slaves <name>` | 查看指定主节点的从节点列表 |
| `SENTINEL sentinels <name>` | 列出除当前节点外的其他哨兵实例 |
| `SENTINEL get-master-addr-by-name <name>` | 获取当前有效的主节点 IP 和端口 | | `SENTINEL get-master-addr-by-name <name>` | 获取当前有效的主节点 IP 和端口 |
| `SENTINEL failover <name>` | **手动强制触发**故障转移 | | `SENTINEL failover <name>` | **手动强制触发**故障转移 |
| `SENTINEL reset <pattern>` | 重置配置,清除过期节点信息 | | `SENTINEL reset <pattern>` | 重置配置,清除过期节点信息 |
| `SENTINEL ckquorum <name>` | 检查当前哨兵数量是否满足法定人数 | | `SENTINEL ckquorum <name>` | 检查当前哨兵数量是否满足法定人数 |
--- ---
## 七、注意事项与最佳实践 ## 七、注意事项与最佳实践
> 注意: > 注意:
>
> 1. **脑裂保护**:建议配置 `min-replicas-to-write 1` 和 `min-replicas-max-lag 10`,确保主库至少有 1 个正常的从库时才允许写入,防止网络分区导致的数据丢失。 > 1. **脑裂保护**:建议配置 `min-replicas-to-write 1` 和 `min-replicas-max-lag 10`,确保主库至少有 1 个正常的从库时才允许写入,防止网络分区导致的数据丢失。
> 2. **Docker 网络**:在容器环境下,务必配置 `replica-announce-ip` 和 `sentinel announce-ip`,否则哨兵可能会记录容器内部 IP 导致客户端无法连接。 > 2. **Docker 网络**:在容器环境下,务必配置 `replica-announce-ip` 和 `sentinel announce-ip`,否则哨兵可能会记录容器内部 IP 导致客户端无法连接。
> 3. **持久化平衡**:推荐开启混合持久化(AOF + RDB),并设置 `appendfsync everysec` 以平衡性能与安全性。 > 3. **持久化平衡**:推荐开启混合持久化(AOF + RDB),并设置 `appendfsync everysec` 以平衡性能与安全性。
> 4. **权限细化**:生产环境建议将 `/data/redis` 目录权限进一步收紧,仅允许特定 UID 访问。 > 4. **权限细化**:生产环境建议将 `/data/redis` 目录权限进一步收紧,仅允许特定 UID 访问。
--- ---
## 八、单哨兵宕机不影响业务(Redisson 配置)
### 8.1 现象与原因
当**只停止一个哨兵节点**时,若出现:
- `No route to host: 192.168.3.110/192.168.3.110:26379`(其中 110 为已停的哨兵)
- 或 `RedisReadonlyException: READONLY You can't write against a read only replica`(写到了已变为 replica 的旧 master)
**主要原因**是 Redisson 的以下行为:
| 配置项 | 默认值 | 导致“单哨兵挂则全挂”的原因 |
|--------|--------|----------------------------|
| **checkSentinelsList** | `true` | 启动或周期性刷新时会对**配置中的每一个**哨兵执行校验/连接;任一哨兵不可达(如 110 已停)会触发异常或阻塞,导致客户端无法正常从其余哨兵获取当前 master,进而整体不可用或拿到过期拓扑。 |
`RedisReadonlyException` 通常表示**已经发生过故障转移**(当前写到的 192.168.3.200 已被降为 replica),而客户端因上述原因无法从存活的哨兵刷新到新 master,仍向旧主写,从而报只读。
### 8.2 推荐配置(单哨兵宕机不影响业务)
在 Nacos/应用配置中,将 Redisson 的哨兵相关配置改为:
```yaml
sentinelServersConfig:
masterName: "mymaster"
sentinelAddresses:
- "redis://192.168.3.230:26379"
- "redis://192.168.3.200:26379"
- "redis://192.168.3.110:26379"
# 关键:单哨兵宕机时仍可从其余哨兵获取 master,不影响业务
checkSentinelsList: false # 不强制校验“全部”哨兵,任一只不可达不会拖垮客户端
sentinelsDiscovery: true # 按需保留;Docker/NAT 环境若出现拓扑混乱可改为 false
```
**要点**:
- **checkSentinelsList: false**:不再要求能连上配置里的每一个哨兵,只要有一个哨兵可用即可获取 master 并刷新拓扑,单哨兵停机不会导致业务不可用。
- 保留多个 `sentinelAddresses`:多个哨兵仍可做高可用,只是客户端不会因“其中一个连不上”而整体失败。
### 8.3 为什么 SENTINEL masters 显示 200 却无法写入?
`SENTINEL masters` 列出的是哨兵**当前记录的主节点信息**,但在以下情况下会出现“查到的是 200,却写不进去”:
1. **200 已被降级为副本**
故障转移后,192.168.3.200 可能已经执行了 `REPLICAOF` 指向新主,自身变为只读副本。此时 `SENTINEL masters` 里可能仍显示 `ip 192.168.3.200`(视图未刷新或不同哨兵收敛有先后),但连上 200 执行写会报 `READONLY`。
2. **应看“当前主节点”而非仅看 masters 表**
用下面命令取的是**当前可写主节点**,比 `SENTINEL masters` 更贴近实际:
```bash
redis-cli -h 192.168.3.230 -p 26379 -a afe123456 SENTINEL get-master-addr-by-name mymaster
```
若返回的是**另一台**(例如 192.168.3.xxx),说明新主已切换,应连该地址写。
3. **在 200 上自检角色**
直连 200(你环境里 Redis 端口为 16379):
```bash
redis-cli -h 192.168.3.200 -p 16379 -a afe123456 ROLE
```
若返回 `slave`,说明 200 已是副本,只能读不能写;写请求应发往 `get-master-addr-by-name` 返回的那台。
**小结**:查到是 200 却不能写,多半是 200 已是 replica。用 `SENTINEL get-master-addr-by-name mymaster` 确认当前主,并连该主写;应用侧在 `checkSentinelsList: false` 且能连上任意存活哨兵时会自动刷新到新主。
### 8.4 故障转移后的核对
若已出现 `s_down,o_down` 或 `role-reported: slave`,说明哨兵已做过故障转移。建议在**仍存活的**哨兵上确认当前主节点:
```bash
redis-cli -h 192.168.3.230 -p 26379 -a afe123456 SENTINEL get-master-addr-by-name mymaster
```
应用在能连上任意存活哨兵且 `checkSentinelsList: false` 时,会通过拓扑刷新自动指向新 master,无需改配置。
### 8.5 双节点均为 slave(复制环)的排查与处理
当两台 Redis 主机在 `ROLE` 下均显示为 `slave`,且互相把对方当作 master 时,形成**复制环**:没有真正的主节点,所有写请求都会报 `READONLY`。
**典型表现**(示例 IP/端口可替换为实际环境):
- 192.168.3.200:16379 执行 `ROLE` → `slave`,master 指向 192.168.3.230:6379
- 192.168.3.230:6379 执行 `ROLE` → `slave`,master 指向 192.168.3.200:16379
常见原因:多次故障转移、手动改过 `replicaof`、或 Sentinel 在异常情况下写入了错误的主从关系。
**处理步骤(人工指定主从)**:
1. **选定一台作为唯一 master**(示例以 192.168.3.230 为主,端口按实际为准)
在该节点上执行:
```bash
redis-cli -h 192.168.3.230 -p 6379 -a <密码> REPLICAOF NO ONE
```
执行后该节点成为唯一可写主节点。
2. **将另一台设为该主的从节点**(示例为 192.168.3.200 作为 230 的从)
在从节点上执行(端口与上一步一致):
```bash
redis-cli -h 192.168.3.200 -p 16379 -a <密码> REPLICAOF 192.168.3.230 6379
```
3. **校验**
- 在主节点上执行 `ROLE`,应看到 `master`。
- 在从节点上执行 `ROLE`,应看到 `slave`,且 master 为刚指定的主节点地址与端口。
4. **哨兵**
Sentinel 会通过心跳发现新的主从拓扑;若长时间未更新,可在当前主节点所在机器重启该机 Sentinel,或按需执行 `SENTINEL failover`(拓扑已正确时一般无需执行)。
**注意**:端口需与现网一致(如 200 使用 16379、230 使用 6379 时,上述命令中的端口要对应修改)。
---
**参考资料**:[Redisson 官方配置文档](https://redisson.pro/docs/configuration/#sentinel-yaml-config-format) **参考资料**:[Redisson 官方配置文档](https://redisson.pro/docs/configuration/#sentinel-yaml-config-format)
+66
View File
@@ -0,0 +1,66 @@
---
name: g3fo-nacos-config
description: 分析并修改 g3fo-db/当前工作区下 nacos_config 目录中的配置。若当前项目没有 nacos_config 则提示用户打开 g3fo-db 或提供 g3fo-db 路径。支持添加、修改 yml 配置及添加配置注释;需引导用户确认「场」与「单个配置文件」;当添加/修改位置不确定时必须先与用户确认再执行;在合适位置、正确缩进下编辑。
---
# nacos_config 分析与修改
## 目录结构
- **根路径**:当前工作区根目录下的 `nacos_config`;若不存在则要求用户打开 g3fo-db 或提供 g3fo-db 的明确路径。
- **场(site)**:`nacos_config/{site_name}/`,每个子目录为一个场(如 afe_ap、dev_200、uat_226、vn-demo 等)。场列表通过列出 `nacos_config` 子目录动态得到,不写死。
- **配置文件**:每场下多为 yml,常见有 `common.yml`、`dubbo.yml`、`mysql.yml`、`redis.yml`、`rocketmq.yml` 以及各服务 `g3fo-*-dev.yml` 等。
## 用户交互流程(必须按顺序执行)
### 0. 确认 nacos_config 所在位置
- 先检查**当前工作区根目录**下是否存在 `nacos_config`。
- **若不存在**:提示「当前项目下未找到 `nacos_config` 目录」,引导用户二选一:
- 请先**打开 g3fo-db 项目**再操作,或
- **提供 g3fo-db 项目的明确路径**(后续操作基于该路径下的 `nacos_config`)。
- 在用户完成其一之前**不执行任何配置编辑**。
- 若存在或用户已提供路径,继续下一步。
### 1. 确定要修改的场
- 若用户**未明确**要改哪个场:反问用户,并**列出**(当前工作区或用户提供的 g3fo-db 路径下的)`nacos_config` 下所有子目录(即所有场),引导用户选择**一个或多个场**,或选择「**全部场**」。
- 若用户已明确指定场,则直接使用。
### 2. 确定要修改的配置文件
- 若用户**未指定**配置文件,或指定了**多个**:**引导用户确认仅一个**配置文件(如 `common.yml`、`dubbo.yml` 等)。
- 若用户已明确指定一个配置文件,则直接使用。
### 3. 确定添加/修改位置(必须遵守)
- **当不确定**要把新配置加在哪个位置、或要改哪一处时:**必须先与用户确认**(列出可选位置或给出建议,等用户选定),**再执行**编辑;不得在位置模糊时自行猜测并修改。
### 4. 执行修改
在选定的每个「场」下,对选定的配置文件进行编辑:
- **添加**:根据要添加的 key 路径(如 `dubbo.application.metadata-service-port`)在文件中找合适位置——若已有同名顶级块(如 `dubbo`)则合并到该块下并保持 2 空格缩进;若无则新增顶级块,放在与现有顶级键风格一致的位置。位置不明确时先与用户确认再执行。
- **修改**:若 key 已存在,则只改值并保持原有缩进与注释风格;若存在多处或歧义,先与用户确认再执行。
- **添加配置的注释**:在指定 key 上方或行尾添加注释,风格与文件内现有注释一致;位置不明确时先与用户确认再执行。
**YAML 规则**:一律 **2 空格缩进**;新加块与现有同级键对齐;不破坏现有注释与键顺序。若文件中已有 `dubbo` 块,新 dubbo 相关项应合并到该块下,避免重复顶级 `dubbo`。插入新顶级块时的常见顺序可参考 [references/yml-placement.md](references/yml-placement.md)。
## 示例
**在 common.yml 中添加 dubbo 配置**
若 `common.yml` 中尚无顶级 `dubbo`,可新增顶级块(与 `server`、`spring` 等同级),例如:
```yaml
dubbo:
application:
metadata-service-port: 28101
```
若 `common.yml` 或目标文件中已有 `dubbo:` 块,则将 `metadata-service-port` 合并到 `dubbo.application` 下,保持 2 空格缩进,不新增重复的 `dubbo:` 顶级键。
## 约定摘要
- **路径**:优先用当前工作区的 `nacos_config`;没有则要求用户打开 g3fo-db 或提供 g3fo-db 路径。
- **确认优先**:添加/修改/注释的**目标位置**一旦不确定,必须先与用户确认再执行,不得自行猜测。
@@ -0,0 +1,14 @@
# nacos_config 常见顶级键与插入顺序
在 common.yml、dubbo.yml 等文件中插入**新的顶级块**时,可参考以下常见顺序,以保持与现有风格一致:
- `server`
- `spring`
- `swagger` / `springdoc` / `knife4j`
- `logging`
- `management`
- `powerjob`
- `dubbo`
- 其他自定义顶级键(如 `webdav`、`db-delete-logs.table.include.list`、`actuator-url` 等)
同一文件中若已存在某顶级键(如 `dubbo`),新配置应**合并到该块下**,而不是再起一个同名顶级块。
+2
View File
@@ -0,0 +1,2 @@
# Do not commit token - security
config.local.json
+1
View File
@@ -0,0 +1 @@
{"source":"local","sourceType":"local","installedAt":"2026-02-27T00:00:00.000Z"}
+141
View File
@@ -0,0 +1,141 @@
---
name: git-control
description: Gitea repository and branch access control. Use when managing Git permissions: enable/disable branch protection (push, merge, read), user read/write permissions on repos, organization-wide repo access, listing orgs/repos, bulk permission changes, or comparing branches to detect unmerged commits (e.g. prod hotfix not in main). Supports Gitea API (e.g. afe.git:3000).
---
# Git Control (Gitea)
Control Gitea repository and branch permissions via API. Requires Gitea API Token with admin/repo permissions.
**API Reference**: See `references/gitea_api.md` for endpoints.
## Prerequisites
- **Gitea API Token**: Settings → Applications → Generate New Token (repo, admin for org-level)
- **Base URL**: Default `http://afe.git:3000` (override with `-GiteaBaseUrl`)
**Local config**: Scripts auto-load from `config.local.json` in the skill root when ApiToken is not provided. Format:
```json
{"ApiToken": "your-token", "GiteaBaseUrl": "http://afe.git:3000"}
```
## Capabilities
### 1. Branch Protection (Enable/Disable)
Control who can push or merge to a branch.
| Action | Script | Effect |
|--------|--------|--------|
| Lock branch (read-only) | `branch_protect.ps1 -Action lock` | No push, no merge |
| Unlock branch | `branch_protect.ps1 -Action unlock` | Remove protection |
| Restrict merge only | `branch_protect.ps1 -Action merge-only` | No direct push, PR merge allowed |
```powershell
.\scripts\branch_protect.ps1 -ApiToken "..." -Owner G3SF -Repo g3fo-trade -BranchName uat -Action lock
```
### 2. List Orgs & Repos
| Action | Script |
|--------|--------|
| List all organizations | `list_orgs_repos.ps1 -Action list-orgs` |
| List repos in org | `list_orgs_repos.ps1 -Action list-repos -Org G3SF` |
| List all repos (admin) | `list_orgs_repos.ps1 -Action list-all-repos` |
### 3. User Repo Permissions
Add, remove, or change a user's permission on a repo.
| Action | Effect |
|--------|--------|
| Grant read | User can view only |
| Grant write | User can push, create PRs |
| Grant admin | User can manage settings |
| Revoke | Remove collaborator |
```powershell
.\scripts\user_permission.ps1 -ApiToken "..." -Owner G3SF -Repo g3fo-trade -Username john -Action read
.\scripts\user_permission.ps1 -ApiToken "..." -Owner G3SF -Repo g3fo-trade -Username john -Action revoke
```
### 5. Org-Wide Repo Protection
Apply branch protection to all repos under an organization.
```powershell
.\scripts\org_repos_protect.ps1 -ApiToken "..." -Org G3SF -BranchName uat -Action lock
```
### 6. Branch Compare (Detect Unmerged Commits)
Analyze differences between branches to find commits that need merging (e.g. prod hotfix not merged to main).
| Mode | Script | Use Case |
|------|--------|----------|
| Single repo (local) | `branch_compare.ps1 -RepoPath` | Repo cloned locally |
| Single repo (API) | `branch_compare.ps1 -Owner -Repo` | No local clone |
| Single repo | `org_branch_compare.ps1 -Repo g3fo-trade` | One repo by name |
| Org-wide | `org_branch_compare.ps1` | All repos in org |
**CompareSet**:
- `single`: BaseBranch vs HeadBranch only (default for branch_compare)
- `full`: main vs uat, main vs prod, uat vs prod (default for org_branch_compare)
```powershell
# Single repo - full check (main/uat/prod)
.\scripts\branch_compare.ps1 -RepoPath "d:\path\to\g3fo-trade" -CompareSet full
.\scripts\branch_compare.ps1 -Owner G3SF -Repo g3fo-trade -CompareSet full
# Single pair
.\scripts\branch_compare.ps1 -RepoPath "d:\path\to\repo" -BaseBranch main -HeadBranch uat
# Single repo by name (org)
.\scripts\org_branch_compare.ps1 -Org G3SF -Repo g3fo-trade -CompareSet full
# All G3SF repos - full check
.\scripts\org_branch_compare.ps1 -Org G3SF -CompareSet full -WorkspaceRoot "d:\AFE Git\G3SF\G3FO\server"
# Filter by date or tag
.\scripts\branch_compare.ps1 -RepoPath "d:\path\to\repo" -CompareSet full -SinceDate "2025-01-01"
```
**Output**: Each line shows `hash | author | message`. When there are unmerged commits, a "By user (unmerged)" summary lists each user and their commit count.
### 7. Org-Wide User Access
Revoke or grant a user's collaborator access across all org repos.
```powershell
.\scripts\org_user_access.ps1 -ApiToken "..." -Org G3SF -Username john -Action revoke
.\scripts\org_user_access.ps1 -ApiToken "..." -Org G3SF -Username john -Action read
```
Note: Org repos often use team permissions. For team-based access, use Gitea API team endpoints (see `references/gitea_api.md`).
## Common Control Functions
| Function | Description |
|----------|-------------|
| **Lock UAT for release** | Lock `uat` branch on specified repos before release freeze |
| **Unlock for merge** | Temporarily allow merge (add user to merge allowlist) |
| **Detect unmerged hotfixes** | Compare prod vs main to find commits in prod not in main |
| **Audit user access** | List repos a user can access (collaborators + org teams) |
| **Bulk branch lock** | Lock same branch across multiple repos |
| **Read-only maintenance** | Set repo to read-only during maintenance |
| **New member onboarding** | Grant read to org repos for new team member |
| **Offboarding** | Revoke user from all org repos |
## Script Parameters (Common)
- `-ApiToken` or `$env:GITEA_TOKEN`
- `-GiteaBaseUrl` (default: http://afe.git:3000)
- `-Owner` / `-Org` (organization name)
- `-Repo` (repository name)
- `-BranchName` (branch pattern, e.g. uat, main)
## Error Handling
- 403: Insufficient permissions (need admin for org ops)
- 404: Repo/org/user not found
- 422: Validation error (check API compatibility with Gitea version)
@@ -0,0 +1,63 @@
# Gitea API Reference for Git Control
Base: `{GiteaBaseUrl}/api/v1`
Auth: `Authorization: token {ApiToken}`
## Branch Protection
| Action | Method | Path |
|--------|--------|------|
| List | GET | `/repos/{owner}/{repo}/branch_protections` |
| Create | POST | `/repos/{owner}/{repo}/branch_protections` |
| Get | GET | `/repos/{owner}/{repo}/branch_protections/{name}` |
| Edit | PATCH | `/repos/{owner}/{repo}/branch_protections/{name}` |
| Delete | DELETE | `/repos/{owner}/{repo}/branch_protections/{name}` |
**CreateBranchProtectionOption** (POST body):
- `branch_name`: pattern (e.g. `uat`, `main`)
- `enable_push`: false = no direct push
- `enable_merge_whitelist`: true + empty lists = no one can merge
- `merge_whitelist_usernames`: []
- `merge_whitelist_teams`: []
## Organizations & Repositories
| Action | Method | Path | Note |
|--------|--------|------|------|
| List all orgs | GET | `/admin/orgs` | Admin only |
| List org repos | GET | `/orgs/{org}/repos` | Pagination: page, limit |
| List user repos | GET | `/user/repos` | Current user |
| Search repos | GET | `/repos/search` | q, page, limit |
## Collaborators (User Repo Permissions)
| Action | Method | Path |
|--------|--------|------|
| List | GET | `/repos/{owner}/{repo}/collaborators` |
| Add/Update | PUT | `/repos/{owner}/{repo}/collaborators/{username}` |
| Remove | DELETE | `/repos/{owner}/{repo}/collaborators/{username}` |
| Check | GET | `/repos/{owner}/{repo}/collaborators/{username}` |
**PUT body**: `{"permission": "read"|"write"|"admin"}`
## Organization Teams (Org-level Permissions)
| Action | Method | Path |
|--------|--------|------|
| List org teams | GET | `/orgs/{org}/teams` |
| List team repos | GET | `/teams/{id}/repos` |
| Add repo to team | PUT | `/teams/{id}/repos/{org}/{repo}` |
| Remove repo from team | DELETE | `/teams/{id}/repos/{org}/{repo}` |
| List team members | GET | `/teams/{id}/members` |
| Add member | PUT | `/teams/{id}/members/{username}` |
| Remove member | DELETE | `/teams/{id}/members/{username}` |
Team permission: `permission` = "none"|"read"|"write"|"admin"|"owner"
## Branch Compare
| Action | Method | Path |
|--------|--------|------|
| Compare | GET | `/repos/{owner}/{repo}/compare/{base}...{head}` |
Returns commits in `head` that are not in `base`. Use `base...head` (three dots) format.
@@ -0,0 +1,163 @@
# Compare branches to detect unmerged commits (e.g. prod hotfix not merged to main)
# Usage: local repo path OR Gitea owner/repo
# -CompareSet full: main vs uat, main vs prod, uat vs prod
param(
[string]$RepoPath,
[string]$Owner,
[string]$Repo,
[string]$BaseBranch = "main",
[string]$HeadBranch = "prod",
[ValidateSet("single", "full")]
[string]$CompareSet = "single",
[string]$SinceTag,
[string]$SinceDate,
[string]$ApiToken = $env:GITEA_TOKEN,
[string]$GiteaBaseUrl = "http://afe.git:3000"
)
$ErrorActionPreference = "Stop"
$ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json"
if (-not $ApiToken -and (Test-Path $ConfigPath)) {
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$ApiToken = $cfg.ApiToken
if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl }
}
function Get-GitLog {
param([string]$Path, [string]$Range, [string]$Since)
$gitArgs = @("log", $Range, "--no-merges", "--format=%h | %an | %s")
if ($Since) { $gitArgs += "--since=$Since" }
$out = & git -C $Path $gitArgs 2>&1
if ($out) { $out } else { @() }
}
function Compare-Local {
if (-not (Test-Path (Join-Path $RepoPath ".git"))) {
Write-Host "Error: Not a git repo: $RepoPath"; exit 1
}
cmd /c "git -C `"$RepoPath`" fetch origin >nul 2>&1"
$since = if ($SinceDate) { $SinceDate } else { $null }
$baseRef = "origin/$BaseBranch"
$headRef = "origin/$HeadBranch"
if ($SinceTag -and -not $SinceDate) {
$tagDate = & git -C $RepoPath log -1 --format="%ci" $SinceTag 2>$null
if ($tagDate) { $since = $tagDate.Trim().Split(" ")[0] }
}
Write-Host "=== Branch Compare: ${BaseBranch} vs ${HeadBranch} ===" -ForegroundColor Cyan
Write-Host "Repo: $RepoPath" -ForegroundColor Gray
Write-Host ""
$inHeadNotBase = @(Get-GitLog -Path $RepoPath -Range "${baseRef}..${headRef}" -Since $since)
$inBaseNotHead = @(Get-GitLog -Path $RepoPath -Range "${headRef}..${baseRef}" -Since $since)
Write-Host "[!] Commits in ${HeadBranch} NOT in ${BaseBranch} (need merge to ${BaseBranch}):" -ForegroundColor Yellow
if ($inHeadNotBase.Count -eq 0) {
Write-Host " (none)" -ForegroundColor Green
} else {
$inHeadNotBase | ForEach-Object { Write-Host " $_" }
Write-Host " Total: $($inHeadNotBase.Count) commits" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "[i] Commits in ${BaseBranch} NOT in ${HeadBranch} (${BaseBranch} is ahead):" -ForegroundColor Gray
if ($inBaseNotHead.Count -eq 0) {
Write-Host " (none)" -ForegroundColor Gray
} else {
$inBaseNotHead | ForEach-Object { Write-Host " $_" }
Write-Host " Total: $($inBaseNotHead.Count) commits" -ForegroundColor Gray
}
if ($inHeadNotBase.Count -gt 0) {
$byUser = $inHeadNotBase | ForEach-Object {
$parts = $_ -split '\s*\|\s*'
if ($parts.Count -ge 2) { $parts[1].Trim() }
} | Where-Object { $_ } | Group-Object | Sort-Object Count -Descending
Write-Host ""
Write-Host "By user (unmerged):" -ForegroundColor Yellow
$byUser | ForEach-Object { Write-Host " $($_.Name): $($_.Count) commits" }
Write-Host ""
Write-Host "ACTION: Merge ${HeadBranch} into ${BaseBranch} to sync hotfixes." -ForegroundColor Red
}
}
function Compare-API {
if (-not $ApiToken) { Write-Host "Error: ApiToken required for API mode"; exit 1 }
if (-not $Owner -or -not $Repo) { Write-Host "Error: Owner and Repo required for API mode"; exit 1 }
$ApiBase = "$GiteaBaseUrl/api/v1"
$Headers = @{ "Authorization" = "token $ApiToken"; "Content-Type" = "application/json" }
$basehead = "${BaseBranch}...${HeadBranch}"
Write-Host "=== Branch Compare: ${BaseBranch} vs ${HeadBranch} ===" -ForegroundColor Cyan
Write-Host "Repo: $Owner/$Repo (via API)" -ForegroundColor Gray
Write-Host ""
try {
$cmp = Invoke-RestMethod -Uri "$ApiBase/repos/$Owner/$Repo/compare/$basehead" -Headers $Headers -Method Get
$commits = if ($cmp.commits) { @($cmp.commits) } else { @() }
Write-Host "[!] Commits in ${HeadBranch} NOT in ${BaseBranch} (need merge to ${BaseBranch}):" -ForegroundColor Yellow
if ($commits.Count -eq 0) {
Write-Host " (none)" -ForegroundColor Green
} else {
$commits | ForEach-Object {
$author = $_.commit.author.name
if ($_.author.login) { $author = $_.author.login }
Write-Host " $($_.sha.Substring(0,7)) | $author | $($_.commit.message.Split("`n")[0])"
}
Write-Host " Total: $($commits.Count) commits" -ForegroundColor Yellow
}
Write-Host ""
$baseheadRev = "${HeadBranch}...${BaseBranch}"
$cmpRev = Invoke-RestMethod -Uri "$ApiBase/repos/$Owner/$Repo/compare/$baseheadRev" -Headers $Headers -Method Get
$commitsRev = if ($cmpRev.commits) { @($cmpRev.commits) } else { @() }
Write-Host "[i] Commits in ${BaseBranch} NOT in ${HeadBranch} (${BaseBranch} is ahead):" -ForegroundColor Gray
if ($commitsRev.Count -eq 0) {
Write-Host " (none)" -ForegroundColor Gray
} else {
$commitsRev | ForEach-Object {
$author = $_.commit.author.name
if ($_.author.login) { $author = $_.author.login }
Write-Host " $($_.sha.Substring(0,7)) | $author | $($_.commit.message.Split("`n")[0])"
}
Write-Host " Total: $($commitsRev.Count) commits" -ForegroundColor Gray
}
if ($commits.Count -gt 0) {
$byUser = $commits | ForEach-Object {
if ($_.author.login) { $_.author.login } else { $_.commit.author.name }
} | Group-Object | Sort-Object Count -Descending
Write-Host ""
Write-Host "By user (unmerged):" -ForegroundColor Yellow
$byUser | ForEach-Object { Write-Host " $($_.Name): $($_.Count) commits" }
Write-Host ""
Write-Host "ACTION: Merge ${HeadBranch} into ${BaseBranch} to sync hotfixes." -ForegroundColor Red
}
} catch {
$msg = if ($_.ErrorDetails.Message) { $_.ErrorDetails.Message } else { $_.Exception.Message }
if ($msg -match "BaseNotExist|HeadNotExist|not found") {
Write-Host " (branch not found - repo may not have ${BaseBranch}/${HeadBranch})" -ForegroundColor Gray
} else {
Write-Host " API Error: $msg" -ForegroundColor Red
}
}
}
$pairs = @()
if ($CompareSet -eq "full") {
$pairs = @(
@{ Base = "main"; Head = "uat" },
@{ Base = "main"; Head = "prod" },
@{ Base = "uat"; Head = "prod" }
)
} else {
$pairs = @(@{ Base = $BaseBranch; Head = $HeadBranch })
}
foreach ($p in $pairs) {
$BaseBranch = $p.Base
$HeadBranch = $p.Head
if ($RepoPath) {
Compare-Local
} elseif ($Owner -and $Repo) {
Compare-API
} else {
Write-Host "Error: Provide -RepoPath (local) OR -Owner and -Repo (API)"
Write-Host " Local: .\branch_compare.ps1 -RepoPath 'd:\path\to\repo' -CompareSet full"
Write-Host " API: .\branch_compare.ps1 -Owner G3SF -Repo g3fo-trade -CompareSet full"
exit 1
}
if ($pairs.Count -gt 1) { Write-Host "" }
}
@@ -0,0 +1,73 @@
# Branch protection: lock, unlock, or merge-only
# lock = no push, no merge | unlock = remove protection | merge-only = no direct push, PR merge allowed
param(
[string]$ApiToken = $env:GITEA_TOKEN,
[string]$GiteaBaseUrl = "http://afe.git:3000",
[string]$Owner,
[string]$Repo,
[string]$BranchName,
[ValidateSet("lock", "unlock", "merge-only")]
[string]$Action = "lock"
)
$ErrorActionPreference = "Stop"
$ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json"
if (-not $ApiToken -and (Test-Path $ConfigPath)) {
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$ApiToken = $cfg.ApiToken
if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl }
}
if (-not $ApiToken) { Write-Host "Error: ApiToken required"; exit 1 }
if (-not $Owner -or -not $Repo -or -not $BranchName) { Write-Host "Error: Owner, Repo, BranchName required"; exit 1 }
$ApiBase = "$GiteaBaseUrl/api/v1"
$ReposPath = "repos/$Owner/$Repo"
$Headers = @{ "Authorization" = "token $ApiToken"; "Content-Type" = "application/json" }
# Get existing protections
$Existing = @()
try {
$r = Invoke-RestMethod -Uri "$ApiBase/$ReposPath/branch_protections" -Headers $Headers -Method Get
$Existing = if ($r -is [array]) { $r } else { @($r) }
} catch {
if ($_.Exception.Response.StatusCode -eq 404) { Write-Host "Repo not found"; exit 1 }
throw
}
$Match = $Existing | Where-Object { $_.rule_name -eq $BranchName -or $_.branch_name -eq $BranchName } | Select-Object -First 1
$RuleName = if ($Match) { if ($Match.rule_name) { $Match.rule_name } else { $BranchName } } else { $BranchName }
# Delete existing rule
if ($Match) {
try {
Invoke-RestMethod -Uri "$ApiBase/$ReposPath/branch_protections/$([uri]::EscapeDataString($RuleName))" -Headers $Headers -Method Delete | Out-Null
} catch { Write-Host "Warning: Delete failed: $_" }
}
if ($Action -eq "unlock") {
Write-Host "Branch $BranchName unlocked (protection removed)"
exit 0
}
# Create new rule
$Body = @{
branch_name = $BranchName
enable_push = if ($Action -eq "merge-only") { $false } else { $false }
enable_push_whitelist = $false
enable_merge_whitelist = if ($Action -eq "lock") { $true } else { $false }
merge_whitelist_usernames = @()
merge_whitelist_teams = @()
block_on_rejected_reviews = $false
block_on_outdated_branch = $false
dismiss_stale_approvals = $false
require_signed_commits = $false
} | ConvertTo-Json
try {
Invoke-RestMethod -Uri "$ApiBase/$ReposPath/branch_protections" -Headers $Headers -Method Post -Body $Body | Out-Null
Write-Host "Branch ${BranchName}: $Action applied"
} catch {
Write-Host "FAIL: $($_.Exception.Message)"
exit 1
}
@@ -0,0 +1,55 @@
# List organizations and repositories
param(
[string]$ApiToken = $env:GITEA_TOKEN,
[string]$GiteaBaseUrl = "http://afe.git:3000",
[ValidateSet("list-orgs", "list-repos", "list-all-repos")]
[string]$Action = "list-orgs",
[string]$Org
)
$ErrorActionPreference = "Stop"
$ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json"
if (-not $ApiToken -and (Test-Path $ConfigPath)) {
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$ApiToken = $cfg.ApiToken
if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl }
}
if (-not $ApiToken) { Write-Host "Error: ApiToken required"; exit 1 }
$ApiBase = "$GiteaBaseUrl/api/v1"
$Headers = @{ "Authorization" = "token $ApiToken"; "Content-Type" = "application/json" }
if ($Action -eq "list-orgs") {
try {
$orgs = Invoke-RestMethod -Uri "$ApiBase/admin/orgs?page=1&limit=100" -Headers $Headers -Method Get
$orgs | ForEach-Object { Write-Host $_.username }
} catch {
if ($_.Exception.Response.StatusCode -eq 403) { Write-Host "Admin permission required for list-orgs"; exit 1 }
throw
}
exit 0
}
if ($Action -eq "list-repos") {
if (-not $Org) { Write-Host "Error: -Org required for list-repos"; exit 1 }
$repos = @()
$page = 1
do {
$r = Invoke-RestMethod -Uri "$ApiBase/orgs/$Org/repos?page=$page&limit=50" -Headers $Headers -Method Get
$arr = if ($r -is [array]) { $r } else { @($r) }
$repos += $arr
$page++
} while ($arr.Count -eq 50)
foreach ($repo in $repos) { Write-Output $repo.name }
exit 0
}
if ($Action -eq "list-all-repos") {
$orgs = Invoke-RestMethod -Uri "$ApiBase/admin/orgs?page=1&limit=100" -Headers $Headers -Method Get
foreach ($o in $orgs) {
$r = Invoke-RestMethod -Uri "$ApiBase/orgs/$($o.username)/repos?page=1&limit=100" -Headers $Headers -Method Get
$r | ForEach-Object { Write-Host "$($o.username)/$($_.name)" }
}
exit 0
}
@@ -0,0 +1,38 @@
# Compare branches across all repos in an org - detect unmerged commits
# -CompareSet full: main vs uat, main vs prod, uat vs prod for each repo
param(
[string]$Org = "G3SF",
[string]$Repo,
[string]$BaseBranch = "main",
[string]$HeadBranch = "prod",
[ValidateSet("single", "full")]
[string]$CompareSet = "full",
[string]$WorkspaceRoot = "d:\AFE Git\G3SF\G3FO\server",
[switch]$UseApi,
[string]$SinceTag,
[string]$SinceDate
)
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$CompareScript = Join-Path $ScriptDir "branch_compare.ps1"
$ListScript = Join-Path $ScriptDir "list_orgs_repos.ps1"
$repos = if ($Repo) { @($Repo) } else { @(& $ListScript -Action list-repos -Org $Org) }
$hasLocal = Test-Path $WorkspaceRoot
foreach ($r in $repos) {
if (-not $r) { continue }
$path = Join-Path $WorkspaceRoot $r
Write-Host "########## $Org/$r ##########" -ForegroundColor Magenta
if ($UseApi -or (-not $hasLocal) -or (-not (Test-Path $path))) {
$params = @{ Owner = $Org; Repo = $r; BaseBranch = $BaseBranch; HeadBranch = $HeadBranch; CompareSet = $CompareSet }
& $CompareScript @params
} else {
$params = @{ RepoPath = $path; BaseBranch = $BaseBranch; HeadBranch = $HeadBranch; CompareSet = $CompareSet }
if ($SinceTag) { $params.SinceTag = $SinceTag }
if ($SinceDate) { $params.SinceDate = $SinceDate }
& $CompareScript @params
}
Write-Host ""
}
@@ -0,0 +1,35 @@
# Apply branch protection to all repos in an organization
param(
[string]$ApiToken = $env:GITEA_TOKEN,
[string]$GiteaBaseUrl = "http://afe.git:3000",
[string]$Org,
[string]$BranchName,
[ValidateSet("lock", "unlock")]
[string]$Action = "lock"
)
$ErrorActionPreference = "Stop"
$ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json"
if (-not $ApiToken -and (Test-Path $ConfigPath)) {
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$ApiToken = $cfg.ApiToken
if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl }
}
if (-not $ApiToken) { Write-Host "Error: ApiToken required"; exit 1 }
if (-not $Org -or -not $BranchName) { Write-Host "Error: Org, BranchName required"; exit 1 }
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$BranchScript = Join-Path $ScriptDir "branch_protect.ps1"
$repos = @(& "$ScriptDir\list_orgs_repos.ps1" -ApiToken $ApiToken -GiteaBaseUrl $GiteaBaseUrl -Action list-repos -Org $Org)
if (-not $repos -or $repos.Count -eq 0) { Write-Host "No repos found in org $Org"; exit 0 }
$count = 0
foreach ($r in $repos) {
if (-not $r) { continue }
Write-Host "Processing $Org/$r..."
& $BranchScript -ApiToken $ApiToken -GiteaBaseUrl $GiteaBaseUrl -Owner $Org -Repo $r -BranchName $BranchName -Action $Action
$count++
}
Write-Host "Done. Processed $count repos."
@@ -0,0 +1,35 @@
# Revoke or grant a user's access across all repos in an organization
# Uses collaborator API - only affects repos where user is direct collaborator
param(
[string]$ApiToken = $env:GITEA_TOKEN,
[string]$GiteaBaseUrl = "http://afe.git:3000",
[string]$Org,
[string]$Username,
[ValidateSet("revoke", "read", "write")]
[string]$Action = "revoke"
)
$ErrorActionPreference = "Stop"
$ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json"
if (-not $ApiToken -and (Test-Path $ConfigPath)) {
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$ApiToken = $cfg.ApiToken
if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl }
}
if (-not $ApiToken) { Write-Host "Error: ApiToken required"; exit 1 }
if (-not $Org -or -not $Username) { Write-Host "Error: Org, Username required"; exit 1 }
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$Repos = @(& "$ScriptDir\list_orgs_repos.ps1" -ApiToken $ApiToken -GiteaBaseUrl $GiteaBaseUrl -Action list-repos -Org $Org)
$UserScript = Join-Path $ScriptDir "user_permission.ps1"
$count = 0
foreach ($Repo in $Repos) {
if (-not $Repo) { continue }
Write-Host "Processing $Org/$Repo..."
$null = & $UserScript -ApiToken $ApiToken -GiteaBaseUrl $GiteaBaseUrl -Owner $Org -Repo $Repo -Username $Username -Action $Action 2>&1
if ($LASTEXITCODE -eq 0) { $count++ }
}
Write-Host "Done. Updated $count repos where $Username was collaborator."
Write-Host "Note: Org repos may use team permissions. Check org teams for full control."
@@ -0,0 +1,45 @@
# Add, update, or revoke user permission on a repository
param(
[string]$ApiToken = $env:GITEA_TOKEN,
[string]$GiteaBaseUrl = "http://afe.git:3000",
[string]$Owner,
[string]$Repo,
[string]$Username,
[ValidateSet("read", "write", "admin", "revoke")]
[string]$Action = "read"
)
$ErrorActionPreference = "Stop"
$ConfigPath = Join-Path (Split-Path -Parent $MyInvocation.MyCommand.Path) "..\config.local.json"
if (-not $ApiToken -and (Test-Path $ConfigPath)) {
$cfg = Get-Content $ConfigPath -Raw | ConvertFrom-Json
$ApiToken = $cfg.ApiToken
if ($cfg.GiteaBaseUrl) { $GiteaBaseUrl = $cfg.GiteaBaseUrl }
}
if (-not $ApiToken) { Write-Host "Error: ApiToken required"; exit 1 }
if (-not $Owner -or -not $Repo -or -not $Username) { Write-Host "Error: Owner, Repo, Username required"; exit 1 }
$ApiBase = "$GiteaBaseUrl/api/v1"
$ReposPath = "repos/$Owner/$Repo"
$Headers = @{ "Authorization" = "token $ApiToken"; "Content-Type" = "application/json" }
if ($Action -eq "revoke") {
try {
Invoke-RestMethod -Uri "$ApiBase/$ReposPath/collaborators/$Username" -Headers $Headers -Method Delete
Write-Host "Revoked $Username from $Owner/$Repo"
} catch {
if ($_.Exception.Response.StatusCode -eq 404) { Write-Host "User not a collaborator or repo not found" }
else { throw }
}
exit 0
}
$Body = @{ permission = $Action } | ConvertTo-Json
try {
Invoke-RestMethod -Uri "$ApiBase/$ReposPath/collaborators/$Username" -Headers $Headers -Method Put -Body $Body
Write-Host "Set $Username to $Action on $Owner/$Repo"
} catch {
Write-Host "FAIL: $($_.Exception.Message)"
exit 1
}
+15 -15
View File
@@ -207,15 +207,15 @@ def init_skill(skill_name, path):
# Check if directory already exists # Check if directory already exists
if skill_dir.exists(): if skill_dir.exists():
print(f"❌ Error: Skill directory already exists: {skill_dir}") print(f"Error: Skill directory already exists: {skill_dir}")
return None return None
# Create skill directory # Create skill directory
try: try:
skill_dir.mkdir(parents=True, exist_ok=False) skill_dir.mkdir(parents=True, exist_ok=False)
print(f"✅ Created skill directory: {skill_dir}") print(f"Created skill directory: {skill_dir}")
except Exception as e: except Exception as e:
print(f"❌ Error creating directory: {e}") print(f"Error creating directory: {e}")
return None return None
# Create SKILL.md from template # Create SKILL.md from template
@@ -227,10 +227,10 @@ def init_skill(skill_name, path):
skill_md_path = skill_dir / 'SKILL.md' skill_md_path = skill_dir / 'SKILL.md'
try: try:
skill_md_path.write_text(skill_content) skill_md_path.write_text(skill_content, encoding='utf-8')
print("✅ Created SKILL.md") print("Created SKILL.md")
except Exception as e: except Exception as e:
print(f"❌ Error creating SKILL.md: {e}") print(f"Error creating SKILL.md: {e}")
return None return None
# Create resource directories with example files # Create resource directories with example files
@@ -239,29 +239,29 @@ def init_skill(skill_name, path):
scripts_dir = skill_dir / 'scripts' scripts_dir = skill_dir / 'scripts'
scripts_dir.mkdir(exist_ok=True) scripts_dir.mkdir(exist_ok=True)
example_script = scripts_dir / 'example.py' example_script = scripts_dir / 'example.py'
example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name)) example_script.write_text(EXAMPLE_SCRIPT.format(skill_name=skill_name), encoding='utf-8')
example_script.chmod(0o755) example_script.chmod(0o755)
print("✅ Created scripts/example.py") print("Created scripts/example.py")
# Create references/ directory with example reference doc # Create references/ directory with example reference doc
references_dir = skill_dir / 'references' references_dir = skill_dir / 'references'
references_dir.mkdir(exist_ok=True) references_dir.mkdir(exist_ok=True)
example_reference = references_dir / 'api_reference.md' example_reference = references_dir / 'api_reference.md'
example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title)) example_reference.write_text(EXAMPLE_REFERENCE.format(skill_title=skill_title), encoding='utf-8')
print("✅ Created references/api_reference.md") print("Created references/api_reference.md")
# Create assets/ directory with example asset placeholder # Create assets/ directory with example asset placeholder
assets_dir = skill_dir / 'assets' assets_dir = skill_dir / 'assets'
assets_dir.mkdir(exist_ok=True) assets_dir.mkdir(exist_ok=True)
example_asset = assets_dir / 'example_asset.txt' example_asset = assets_dir / 'example_asset.txt'
example_asset.write_text(EXAMPLE_ASSET) example_asset.write_text(EXAMPLE_ASSET, encoding='utf-8')
print("✅ Created assets/example_asset.txt") print("Created assets/example_asset.txt")
except Exception as e: except Exception as e:
print(f"❌ Error creating resource directories: {e}") print(f"Error creating resource directories: {e}")
return None return None
# Print next steps # Print next steps
print(f"\n✅ Skill '{skill_name}' initialized successfully at {skill_dir}") print(f"\nSkill '{skill_name}' initialized successfully at {skill_dir}")
print("\nNext steps:") print("\nNext steps:")
print("1. Edit SKILL.md to complete the TODO items and update the description") print("1. Edit SKILL.md to complete the TODO items and update the description")
print("2. Customize or delete the example files in scripts/, references/, and assets/") print("2. Customize or delete the example files in scripts/, references/, and assets/")
@@ -287,7 +287,7 @@ def main():
skill_name = sys.argv[1] skill_name = sys.argv[1]
path = sys.argv[3] path = sys.argv[3]
print(f"🚀 Initializing skill: {skill_name}") print(f"Initializing skill: {skill_name}")
print(f" Location: {path}") print(f" Location: {path}")
print() print()
@@ -45,13 +45,13 @@ def package_skill(skill_path, output_dir=None):
return None return None
# Run validation before packaging # Run validation before packaging
print("🔍 Validating skill...") print("Validating skill...")
valid, message = validate_skill(skill_path) valid, message = validate_skill(skill_path)
if not valid: if not valid:
print(f"❌ Validation failed: {message}") print(f"Validation failed: {message}")
print(" Please fix the validation errors before packaging.") print(" Please fix the validation errors before packaging.")
return None return None
print(f"✅ {message}\n") print(f"{message}\n")
# Determine output location # Determine output location
skill_name = skill_path.name skill_name = skill_path.name
@@ -74,11 +74,11 @@ def package_skill(skill_path, output_dir=None):
zipf.write(file_path, arcname) zipf.write(file_path, arcname)
print(f" Added: {arcname}") print(f" Added: {arcname}")
print(f"\n✅ Successfully packaged skill to: {skill_filename}") print(f"\nSuccessfully packaged skill to: {skill_filename}")
return skill_filename return skill_filename
except Exception as e: except Exception as e:
print(f"❌ Error creating .skill file: {e}") print(f"Error creating .skill file: {e}")
return None return None
@@ -93,7 +93,7 @@ def main():
skill_path = sys.argv[1] skill_path = sys.argv[1]
output_dir = sys.argv[2] if len(sys.argv) > 2 else None output_dir = sys.argv[2] if len(sys.argv) > 2 else None
print(f"📦 Packaging skill: {skill_path}") print(f"Packaging skill: {skill_path}")
if output_dir: if output_dir:
print(f" Output directory: {output_dir}") print(f" Output directory: {output_dir}")
print() print()
@@ -19,7 +19,7 @@ def validate_skill(skill_path):
return False, "SKILL.md not found" return False, "SKILL.md not found"
# Read and validate frontmatter # Read and validate frontmatter
content = skill_md.read_text() content = skill_md.read_text(encoding='utf-8')
if not content.startswith('---'): if not content.startswith('---'):
return False, "No YAML frontmatter found" return False, "No YAML frontmatter found"