diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 90e470d..63c9509 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,8 +13,8 @@ on: options: [prod, dev] push: branches: - - master - - ci/* + - master + - feature/* tags: - 'v*' diff --git a/collect.sh b/collect.sh index 59f1562..dea18cb 100755 --- a/collect.sh +++ b/collect.sh @@ -70,7 +70,7 @@ echo "==> 打包环境 [$ENV_NAME]" # 清理并创建部署目录骨架 rm -rf "$STAGE_DIR" -mkdir -p "$STAGE_DIR/conf" "$STAGE_DIR/module" "$STAGE_DIR/lib" "$STAGE_DIR/logs" +mkdir -p "$STAGE_DIR/conf" "$STAGE_DIR/module" "$STAGE_DIR/lib" # 1. 渲染配置到 dist/conf(WORK_PATH 等取自 .env) OUT_CONF_DIR="$STAGE_DIR/conf" "$ROOT_DIR/switch-env.sh" "$ENV_NAME" @@ -93,7 +93,7 @@ done cp "$CHEN_SDK_LIB" "$STAGE_DIR/lib/" # 4. 拷贝字典与错误码 -cp -R "$ROOT_DIR/dict" "$STAGE_DIR/dict" +#cp -R "$ROOT_DIR/dict" "$STAGE_DIR/dict" cp "$ROOT_DIR/errors.json" "$STAGE_DIR/errors.json" # 5. 打包 diff --git a/docs/github-oauth-login.md b/docs/github-oauth-login.md new file mode 100644 index 0000000..fd11f09 --- /dev/null +++ b/docs/github-oauth-login.md @@ -0,0 +1,115 @@ +# GitHub 第三方登录实现 + +## 整体结构 + +整个功能很内聚,只有四个部分:**两个接口、一个绑定管理器、一张绑定表**。 + +- **入口接口** `/api/v1/user/oauth/github`:负责把用户送到 GitHub 授权页。 +- **回调接口** `/api/v1/user/oauth/github/callback`:负责换 token、拉用户、建立本地登录态。 +- **绑定管理器**:负责第三方身份与本地账号的映射查询和写入。 +- **绑定表**:记录 `provider + open_id → user_id` 的对应关系。 + +两个接口都是**免登录**的公开接口——因为回调必须发生在本地会话建立之前,这是必然的。 + +## 一、配置 + +一共四项配置,通过环境变量注入: + +| 配置 | 作用 | +|------|------| +| `client_id` | 拼授权 URL、换 token | +| `client_secret` | 只在服务端换 token 时使用 | +| `callback_url` | 授权和换 token 都要带,两边必须完全一致 | +| `frontend_base` | 登录完成后跳回前端的基址 | + +配置走的是模板 + 环境变量替换的流程:YAML 模板里放占位符,部署时根据 dev / prod 环境注入真实值,脚本做替换,占位符没被替换会直接失败。生产环境的 `client_secret` 由 CI 从仓库密钥注入,不落代码库。 + +开发环境的 `callback_url` 指向本地前端端口,由前端开发服务器把 `/api` 同源代理到后端,这样回调地址和线上保持一致,不需要为本地单独注册。 + +## 二、登录流程 + +### 第一段:入口 + +1. 用户点击"用 GitHub 登录",请求入口接口,可以带一个登录后要跳回的前端路径。 +2. 这个跳转路径会做**规范化和白名单校验**:只接受站内的相对路径,协议相对地址(`//` 开头)一律拒绝,非法值回退到默认首页——这是一道开放重定向防护。 +3. 生成一个随机 `state`,把它和跳转路径一起写进 Redis,**设置 10 分钟过期**。注意 Redis 里存的是跳转目标,不是用户身份。 +4. 拼出 GitHub 授权地址,带上 `client_id`、`callback_url`、最小 scope(`read:user user:email`)和 `state`,然后 302 跳过去。 + +### 第二段:回调 + +1. GitHub 带着 `code` 和 `state` 回调回来。 +2. **校验并消费 state**:从 Redis 取出对应记录,取到后**立刻删除**。取不到就是无效、过期或重放,直接失败。删除动作保证了 state 只能被消费一次。 +3. 用 `code` 向 GitHub 换 `access_token`,请求带 `Accept: application/json`,只认返回里非空的 token。 +4. 拿 token 拉用户资料:先取 GitHub 的数字 `id`(作为后续唯一标识)、`login`、`name`、`email`、`avatar`;如果 `email` 为空,再单独拉一次邮箱列表,优先取"主邮箱且已验证"的那条。 +5. 所有对 GitHub 的请求统一走一个带重试的封装:最多三次、30 秒超时,**只在连接失败/超时时退避重试**,业务解析失败不重试。 + +### 第三段:账号解析 + +按顺序尝试,命中即止: + +1. **已有绑定**:用 `(provider=github, open_id)` 查绑定表,找到就取对应的本地用户。 +2. **邮箱自动关联**:没有绑定但 GitHub 返回了邮箱时,用邮箱找本地已有账号,找到就把 GitHub 身份绑上去。 +3. **创建新用户**:都没有就新建。 + - 邮箱缺失时用一个 GitHub 专用占位邮箱兜底,建号前再查一次防止重复。 + - 账号名优先用 GitHub `login`,冲突或非法时回退到 `gh_` 并加后缀去重;显示名同理。 + - 头像、GitHub 主页链接一并写入。 + - **密码字段塞一个随机不可用的哈希**,保证这个账号永远无法用密码登录,避免意外。 + - 角色默认普通用户,状态为激活。 +4. 最后做一次兜底校验:用户必须存在、状态激活、未被删除,否则拒绝。 + +### 第四段:建立本地登录态 + +这一段**刻意复用普通密码登录的逻辑**,唯一区别是 **GitHub 登录跳过两步验证**: + +1. 从系统设置读会话时长(默认 24 小时),生成一个随机 token。 +2. 解析设备信息:User-Agent 判断平台、真实客户端 IP、设备 ID(优先请求头,其次 Cookie,都没有就新生成并写一个长期 Cookie)。 +3. **单点登录控制**:如果设备已被其他用户占用就踢掉;同一用户在同一平台的旧会话也会被踢。最终允许"一个移动端 + 一个 PC 端"两个并发位。 +4. 记录一条登录设备记录,把 token 缓存到 Redis。 +5. 更新用户最近登录时间。 +6. 设置 Cookie(用户标识、token、过期时间)和会话数据。 + +### 第五段:最终跳转 + +全部成功后,跳回 `frontend_base` 加上之前存的跳转路径,并带上成功标记;失败则跳到前端错误页,用错误码作为参数让前端展示。 + +## 三、数据模型 + +| 表 | 存什么 | +|----|--------| +| 用户表 | 本地账号主体,账号/昵称/邮箱唯一,含头像、GitHub 链接、角色状态等 | +| 绑定表 | `provider + open_id` 唯一,记录第三方身份与本地用户的映射和登录名 | +| 登录设备表 | 会话 token、设备、平台、IP、过期时间、是否活跃等 | + +有一点值得强调:**GitHub 的 access_token 全程不落库**,只在回调请求内临时使用,用完即弃,也不做刷新令牌存储。长期保存的 token 是**我们自己站点**的会话 token,不是 GitHub 的。 + +## 四、后续请求如何鉴权 + +我们**没有用 JWT**,而是"服务端会话 + 每设备 token",由 Redis 和 MySQL 共同支撑。 + +每个请求进来时,框架统一做登录初始化(登录、登出这两个路径除外): + +1. 会话里已有用户就直接校验 token 并刷新缓存。 +2. 否则用 Cookie 自动登录:解密用户标识、检查 token 和有效期、确认账号激活、校验 token(先查 Redis,失败回落数据库)。 +3. 校验通过就把用户写回会话。 + +需要登录的接口在这个基础上再包一层,初始化失败就直接返回"未登录"。 + +token 校验是双通道的:先看 Redis,再回落数据库行(要求活跃、用户匹配、未过期)。**踢人**就是把设备标记为不活跃并删除 Redis 里的 token。此外还有独立的**刷新 token** 和**登出**接口,刷新会轮换 token 并重设 Cookie,登出则清理设备和会话。 + +## 五、错误码 + +只有两个 GitHub 专属错误码: + +- **GitHub OAuth 失败**:未配置、换 token 失败或拉资料失败。 +- **state 无效**:`code`/`state` 缺失、过期或重放。 + +其余复用通用错误码(用户不存在、账号异常、数据库操作失败)。回调阶段的错误会以 `oauth_error` 参数带给前端展示。 + +## 小结 + +一句话概括这套实现的设计取舍: + +- **最小权限 + state 一次性消费 + 跳转白名单** 三道安全防线; +- **GitHub token 即用即弃**,登录态完全自建,与 GitHub 解耦; +- **绑定 → 邮箱关联 → 新建** 三级账号解析,兼顾老用户和自动关联; +- **复用密码登录的会话体系**,只额外跳过 2FA,保持鉴权逻辑单一。 diff --git a/docs/github-pr-sync-fix.md b/docs/github-pr-sync-fix.md index bdbd000..e271ed5 100644 --- a/docs/github-pr-sync-fix.md +++ b/docs/github-pr-sync-fix.md @@ -26,6 +26,9 @@ 手动全量同步接口 `/api/v1/organization/repos/prs/sync` 保持全量行为不变。 +传 `id`(本地 PR 记录 id)时走单条同步:拉取该 PR 详情与 reviews 后回写, +GitHub 404 / 限流等错误直接返回非零 errno;传 `repo_id` 时仍为异步全量同步。 + ## 问题二:PR insert 失败 ### 现象 diff --git a/errors.json b/errors.json index 4f9339d..12ca4c2 100644 --- a/errors.json +++ b/errors.json @@ -94,6 +94,12 @@ { "errno": 5017, "key": "ORG_USER_REL_FAILED", "zh": "组织成员操作失败", "en": "Organization member operation failed" }, { "errno": 5018, "key": "ORG_REPO_NOT_FOUND", "zh": "仓库不存在", "en": "Repository not found" }, { "errno": 5019, "key": "ORG_REPO_NAME_EXISTS", "zh": "该组织下已存在同名仓库", "en": "Repository name already exists in this organization" }, + { "errno": 5020, "key": "ORG_PR_NOT_FOUND", "zh": "PR 记录不存在", "en": "PR record not found" }, + { "errno": 5021, "key": "ORG_REPO_TOKEN_MISSING", "zh": "仓库未配置 GitHub Token", "en": "Repository GitHub token not configured" }, + { "errno": 5022, "key": "GITHUB_PR_NOT_FOUND", "zh": "GitHub 上未找到该 PR", "en": "PR not found on GitHub" }, + { "errno": 5023, "key": "GITHUB_RATE_LIMITED", "zh": "GitHub API 触发限流,请稍后再试", "en": "GitHub API rate limit exceeded, please try again later" }, + { "errno": 5024, "key": "GITHUB_API_FAILED", "zh": "GitHub API 请求失败", "en": "GitHub API request failed" }, + { "errno": 5025, "key": "ORG_PR_SYNC_IN_PROGRESS", "zh": "该 PR 正在同步中,请稍后", "en": "PR sync already in progress, please wait" }, { "errno": 6001, "key": "ASSIGNMENT_NOT_FOUND", "zh": "作业不存在", "en": "Assignment not found" }, { "errno": 6002, "key": "ASSIGNMENT_INVALID_ID", "zh": "作业ID无效", "en": "Invalid assignment ID" }, diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 0a39130..8f440e6 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -197,6 +197,7 @@ case "$MODE" in log "热更新模块: $tmp/module/*.so -> $DEPLOY_PATH/module/" mkdir -p "$DEPLOY_PATH/module" mv "$tmp"/module/*.so "$DEPLOY_PATH/module/" + mv "$tmp/errors.json" "$DEPLOY_PATH/errors.json" # 优先向 master 发 SIGHUP(触发 master fork 新 worker,不依赖 pid 文件/work_path) master="$(server_master_pid || true)" diff --git a/src/error_codes.h b/src/error_codes.h index 4665bd4..7fcbc8b 100644 --- a/src/error_codes.h +++ b/src/error_codes.h @@ -119,6 +119,12 @@ constexpr int32_t ORG_APPLY_UPDATE_FAILED = 5016; // 更新申请失败 constexpr int32_t ORG_USER_REL_FAILED = 5017; // 组织成员操作失败 constexpr int32_t ORG_REPO_NOT_FOUND = 5018; // 仓库不存在 constexpr int32_t ORG_REPO_NAME_EXISTS = 5019; // 同组织下仓库名已存在 +constexpr int32_t ORG_PR_NOT_FOUND = 5020; // PR 记录不存在 +constexpr int32_t ORG_REPO_TOKEN_MISSING = 5021; // 仓库未配置 GitHub Token +constexpr int32_t GITHUB_PR_NOT_FOUND = 5022; // GitHub 上未找到该 PR +constexpr int32_t GITHUB_RATE_LIMITED = 5023; // GitHub API 触发限流 +constexpr int32_t GITHUB_API_FAILED = 5024; // GitHub API 请求失败 +constexpr int32_t ORG_PR_SYNC_IN_PROGRESS = 5025; // 该 PR 正在同步中 // ==================== 模块06: 作业模块 (06001-06999) ==================== constexpr int32_t ASSIGNMENT_NOT_FOUND = 6001; // 作业不存在 diff --git a/src/manager/organization_repo_pr_manager.cc b/src/manager/organization_repo_pr_manager.cc index 7b51252..77bd7ef 100644 --- a/src/manager/organization_repo_pr_manager.cc +++ b/src/manager/organization_repo_pr_manager.cc @@ -13,12 +13,19 @@ #include #include +#include +#include + namespace blog { static chen::Logger::ptr logger = LOG_ROOT(); static const size_t kCacheMaxSize = 1000; +/// 单条 PR 同步互斥锁与进行中集合,避免同一 PR 并发重复请求 GitHub +static std::mutex s_sync_pr_mutex; +static std::set s_syncing_pr_ids; + OrganizationRepoPrManager::OrganizationRepoPrManager() :m_cache(8, kCacheMaxSize, 0) { } @@ -244,6 +251,72 @@ struct SyncPrItem { std::string reviewers; }; +/// 解析 GitHub reviews 数组,计算整体审查状态与 reviewers JSON +static void parseReviews(const Json::Value& arr, std::string& review_status, std::string& reviewers_json) { + bool has_approved = false; + bool has_changes = false; + Json::Value reviewerArr(Json::arrayValue); + for (auto& rv : arr) { + std::string state = rv["state"].asString(); + if (state == "APPROVED") { + has_approved = true; + } else if (state == "CHANGES_REQUESTED") { + has_changes = true; + } + Json::Value reviewer; + reviewer["reviewer"] = rv["user"]["login"].asString(); + reviewer["status"] = state; + reviewerArr.append(reviewer); + } + if (has_approved) { + review_status = "approved"; + } else if (has_changes) { + review_status = "changes_requested"; + } else { + review_status = "pending"; + } + reviewers_json = chen::JsonUtil::ToString(reviewerArr); +} + +/// 发起 GitHub GET 请求,仅对连接失败/超时重试,返回原始响应(含非 2xx 状态码) +static chen::http::HttpResult::ptr githubGetRaw(const std::string& url + , const std::map& headers, int max_retries) { + chen::http::HttpResult::ptr result; + for (int retry = 0; retry <= max_retries; ++retry) { + result = chen::http::HttpConnection::DoGet(url, 30000, headers); + bool transient = (!result + || result->result == (int)chen::http::HttpResult::Error::CONNECT_FAIL + || result->result == (int)chen::http::HttpResult::Error::TIMEOUT); + if (!transient) { + return result; + } + if (retry < max_retries) { + WARN(logger) << "githubGetRaw: retry " << (retry + 1) << "/" << max_retries << " for " << url; + } + } + return result; +} + +/// 判断 GitHub 响应是否因限流被拒绝 +static bool isGithubRateLimited(const chen::http::HttpResponse::ptr& response) { + if (!response) { + return false; + } + auto status = response->getStatus(); + if (status == chen::http::HttpStatus::TOO_MANY_REQUESTS) { + return true; + } + if (status == chen::http::HttpStatus::FORBIDDEN) { + if (response->getHeader("x-ratelimit-remaining") == "0") { + return true; + } + if (!response->getHeader("retry-after").empty()) { + return true; + } + } + return false; +} + static int syncPage(const Json::Value& arr, int64_t repo_id, const std::string& repo_path , const std::map& headers, chen::IDB::ptr db) { // 先解析列表数据 @@ -329,29 +402,11 @@ static int syncPage(const Json::Value& arr, int64_t repo_id, const std::string& Json::Value arr; Json::Reader reader; if (reader.parse(reviews->response->getBody(), arr) && arr.isArray()) { - bool has_approved = false; - bool has_changes = false; - Json::Value reviewerArr(Json::arrayValue); - for (auto& rv : arr) { - std::string state = rv["state"].asString(); - if (state == "APPROVED") { - has_approved = true; - } else if (state == "CHANGES_REQUESTED") { - has_changes = true; - } - Json::Value reviewer; - reviewer["reviewer"] = rv["user"]["login"].asString(); - reviewer["status"] = state; - reviewerArr.append(reviewer); - } - if (has_approved) { - review_statuses[num] = "approved"; - } else if (has_changes) { - review_statuses[num] = "changes_requested"; - } else { - review_statuses[num] = "pending"; - } - reviewers_json[num] = chen::JsonUtil::ToString(reviewerArr); + std::string status; + std::string reviewers; + parseReviews(arr, status, reviewers); + review_statuses[num] = status; + reviewers_json[num] = reviewers; } } } @@ -534,6 +589,166 @@ void OrganizationRepoPrManager::SyncFromGitHub(int64_t repo_id, const std::strin << page_no << " pages for repo " << repo_id; } +OrganizationRepoPrManager::SyncPrStatus OrganizationRepoPrManager::SyncPrFromGitHub(int64_t pr_id + , data::OrganizationRepoPrsInfo::ptr& out) { + auto pr = OrganizationRepoPrMgr::GetInstance()->get(pr_id); + if (!pr) { + return SyncPrStatus::PR_NOT_FOUND; + } + + auto repo = OrganizationRepoMgr::GetInstance()->get(pr->getRepoId()); + if (!repo) { + return SyncPrStatus::REPO_NOT_FOUND; + } + + std::string token = repo->getToken(); + if (token.empty()) { + return SyncPrStatus::NO_TOKEN; + } + + std::string repo_path = extractGithubRepo(repo->getUrl()); + if (repo_path.empty()) { + ERROR(logger) << "SyncPrFromGitHub: invalid repo url=" << repo->getUrl(); + return SyncPrStatus::HTTP_ERROR; + } + + int32_t number = pr->getGithubPrId(); + if (number <= 0) { + ERROR(logger) << "SyncPrFromGitHub: invalid github_pr_id for pr_id=" << pr_id; + return SyncPrStatus::HTTP_ERROR; + } + + // 同一 PR 加锁,重复触发直接返回 BUSY + { + std::lock_guard lock(s_sync_pr_mutex); + if (s_syncing_pr_ids.count(pr_id)) { + return SyncPrStatus::BUSY; + } + s_syncing_pr_ids.insert(pr_id); + } + + // RAII 保证任一出口都移除进行中标记 + struct SyncGuard { + int64_t id; + explicit SyncGuard(int64_t v) + :id(v) { + } + ~SyncGuard() { + std::lock_guard lock(s_sync_pr_mutex); + s_syncing_pr_ids.erase(id); + } + } guard(pr_id); + + std::map headers; + headers["Accept"] = "application/vnd.github.v3+json"; + headers["Accept-Encoding"] = "identity"; + headers["User-Agent"] = "Blog-Server/1.0"; + headers["Authorization"] = "Bearer " + token; + + // 1. 拉取 PR 详情 + std::string detail_url = "https://api.github.com/repos/" + repo_path + "/pulls/" + std::to_string(number); + auto detail = githubGetRaw(detail_url, headers, 3); + if (!detail || detail->result != 0 || !detail->response) { + ERROR(logger) << "SyncPrFromGitHub: detail request failed pr_id=" << pr_id << " url=" << detail_url; + return SyncPrStatus::HTTP_ERROR; + } + auto detail_status = detail->response->getStatus(); + if (detail_status == chen::http::HttpStatus::NOT_FOUND) { + return SyncPrStatus::GITHUB_NOT_FOUND; + } + if (isGithubRateLimited(detail->response)) { + return SyncPrStatus::RATE_LIMITED; + } + if (detail_status != chen::http::HttpStatus::OK) { + ERROR(logger) << "SyncPrFromGitHub: detail unexpected status=" << (int)detail_status + << " pr_id=" << pr_id; + return SyncPrStatus::HTTP_ERROR; + } + + Json::Value detail_json; + Json::Reader detail_reader; + if (!detail_reader.parse(detail->response->getBody(), detail_json) || !detail_json.isObject()) { + ERROR(logger) << "SyncPrFromGitHub: parse detail failed pr_id=" << pr_id; + return SyncPrStatus::HTTP_ERROR; + } + + // 2. 回写主字段 + int64_t created_at = 0; + if (detail_json.isMember("created_at") && !detail_json["created_at"].isNull()) { + created_at = chen::Str2Time(detail_json["created_at"].asCString()); + } + if (created_at) { + pr->setCreateTime(created_at); + } + pr->setGithubPrId(detail_json["number"].asInt()); + pr->setTitle(detail_json["title"].asString()); + pr->setDescription(detail_json["body"].asString()); + pr->setAuthor(detail_json["user"]["login"].asString()); + pr->setHeadBranch(detail_json["head"]["ref"].asString()); + pr->setBaseBranch(detail_json["base"]["ref"].asString()); + pr->setCommitSha(detail_json["head"]["sha"].asString()); + pr->setChangedFiles(detail_json["changed_files"].asInt()); + pr->setAdditions(detail_json["additions"].asInt()); + pr->setDeletions(detail_json["deletions"].asInt()); + + bool merged = detail_json["merged"].asBool(); + int64_t merged_at = 0; + int64_t closed_at = 0; + if (detail_json.isMember("merged_at") && !detail_json["merged_at"].isNull()) { + merged_at = chen::Str2Time(detail_json["merged_at"].asCString()); + } + if (detail_json.isMember("closed_at") && !detail_json["closed_at"].isNull()) { + closed_at = chen::Str2Time(detail_json["closed_at"].asCString()); + } + if (merged || merged_at > 0) { + pr->setState("merged"); + } else { + pr->setState(detail_json["state"].asString()); + } + pr->setClosedAt(closed_at > 0 ? closed_at : emptyTimestamp()); + pr->setMergedAt(merged_at > 0 ? merged_at : emptyTimestamp()); + + // 3. 拉取 reviews;失败时保留原有评审字段,整体仍视为成功 + std::string reviews_url = detail_url + "/reviews"; + auto reviews = githubGetRaw(reviews_url, headers, 3); + if (reviews && reviews->result == 0 && reviews->response) { + auto reviews_status = reviews->response->getStatus(); + if (reviews_status == chen::http::HttpStatus::OK) { + Json::Value arr; + Json::Reader reviews_reader; + if (reviews_reader.parse(reviews->response->getBody(), arr) && arr.isArray()) { + std::string review_status; + std::string reviewers_json; + parseReviews(arr, review_status, reviewers_json); + pr->setReviewStatus(review_status); + pr->setReviewers(reviewers_json); + } + } else if (isGithubRateLimited(reviews->response)) { + WARN(logger) << "SyncPrFromGitHub: reviews rate limited pr_id=" << pr_id; + } + } + + pr->setUpdateTime(time(0)); + + // 4. 写回 DB + auto db = GetDB(); + if (!db) { + ERROR(logger) << "SyncPrFromGitHub: GetDB failed pr_id=" << pr_id; + return SyncPrStatus::DB_ERROR; + } + if (data::OrganizationRepoPrsInfoDao::Update(pr, db)) { + ERROR(logger) << "SyncPrFromGitHub: Update failed for PR #" << pr->getGithubPrId() + << ", errstr=" << db->getErrStr() << ", errno=" << db->getErrno(); + return SyncPrStatus::DB_ERROR; + } + OrganizationRepoPrMgr::GetInstance()->add(pr); + out = pr; + + INFO(logger) << "SyncPrFromGitHub: synced pr_id=" << pr_id << " PR #" << pr->getGithubPrId() + << " state=" << pr->getState(); + return SyncPrStatus::OK; +} + data::OrganizationReposInfo::ptr OrganizationRepoPrManager::findRepoByOwnerAndName(const std::string& owner, const std::string& name) { if (owner.empty() || name.empty()) { return nullptr; diff --git a/src/manager/organization_repo_pr_manager.h b/src/manager/organization_repo_pr_manager.h index aa77bdd..ec93264 100644 --- a/src/manager/organization_repo_pr_manager.h +++ b/src/manager/organization_repo_pr_manager.h @@ -22,6 +22,30 @@ namespace blog { class OrganizationRepoPrManager { public: + typedef std::shared_ptr ptr; + + /// 单条 PR 同步结果 + enum class SyncPrStatus { + /// 同步成功 + OK, + /// 本地 PR 记录不存在 + PR_NOT_FOUND, + /// 关联仓库不存在 + REPO_NOT_FOUND, + /// 仓库未配置 GitHub Token + NO_TOKEN, + /// GitHub 上未找到该 PR (404) + GITHUB_NOT_FOUND, + /// GitHub API 触发限流 + RATE_LIMITED, + /// 同一 PR 正在同步中 + BUSY, + /// GitHub API 请求失败 + HTTP_ERROR, + /// 数据库操作失败 + DB_ERROR, + }; + OrganizationRepoPrManager(); void add(data::OrganizationRepoPrsInfo::ptr info); @@ -49,6 +73,12 @@ class OrganizationRepoPrManager { /// @param max_prs 最多同步的 PR 条数,0 表示全量同步 static void SyncFromGitHub(int64_t repo_id, const std::string& repo_url, const std::string& token, int max_prs = 0); + /// 从 GitHub API 同步单条 PR(按本地 PR 记录 id) + /// @param pr_id 本地 organization_repo_prs 记录 id + /// @param[out] out 同步成功时返回更新后的 PR 对象 + /// @return 同步结果状态 + static SyncPrStatus SyncPrFromGitHub(int64_t pr_id, data::OrganizationRepoPrsInfo::ptr& out); + static data::OrganizationReposInfo::ptr findRepoByOwnerAndName(const std::string& owner, const std::string& name); private: diff --git a/src/servlets/organization/organization_repo_prs_sync_servlet.cc b/src/servlets/organization/organization_repo_prs_sync_servlet.cc index b3622ad..9d265ef 100644 --- a/src/servlets/organization/organization_repo_prs_sync_servlet.cc +++ b/src/servlets/organization/organization_repo_prs_sync_servlet.cc @@ -1,6 +1,6 @@ /** * @file organization_repo_prs_sync_servlet.cc - * @brief 组织仓库 PR 全量同步接口实现 + * @brief 组织仓库 PR 同步接口实现(repo_id 全量 / id 单条) * @author Christins * @date 2026-06-24 * @copyright Apache 2.0 @@ -22,6 +22,39 @@ namespace servlet { static chen::Logger::ptr logger = LOG_ROOT(); +/// 将单条同步状态映射为业务错误码(OK 不处理) +static void MapSyncPrError(OrganizationRepoPrManager::SyncPrStatus st, Result::ptr result) { + switch (st) { + case OrganizationRepoPrManager::SyncPrStatus::PR_NOT_FOUND: + result->setErrno(errcode::ORG_PR_NOT_FOUND); + break; + case OrganizationRepoPrManager::SyncPrStatus::REPO_NOT_FOUND: + result->setErrno(errcode::ORG_REPO_NOT_FOUND); + break; + case OrganizationRepoPrManager::SyncPrStatus::NO_TOKEN: + result->setErrno(errcode::ORG_REPO_TOKEN_MISSING); + break; + case OrganizationRepoPrManager::SyncPrStatus::GITHUB_NOT_FOUND: + result->setErrno(errcode::GITHUB_PR_NOT_FOUND); + break; + case OrganizationRepoPrManager::SyncPrStatus::RATE_LIMITED: + result->setErrno(errcode::GITHUB_RATE_LIMITED); + break; + case OrganizationRepoPrManager::SyncPrStatus::BUSY: + result->setErrno(errcode::ORG_PR_SYNC_IN_PROGRESS); + break; + case OrganizationRepoPrManager::SyncPrStatus::HTTP_ERROR: + result->setErrno(errcode::GITHUB_API_FAILED); + break; + case OrganizationRepoPrManager::SyncPrStatus::DB_ERROR: + result->setErrno(errcode::DB_OPERATION_FAILED); + break; + case OrganizationRepoPrManager::SyncPrStatus::OK: + default: + break; + } +} + OrganizationRepoPrsSyncServlet::OrganizationRepoPrsSyncServlet() : BlogLoginedServlet("OrganizationRepoPrsSyncServlet") { } @@ -29,7 +62,12 @@ OrganizationRepoPrsSyncServlet::OrganizationRepoPrsSyncServlet() int32_t OrganizationRepoPrsSyncServlet::handle(chen::http::HttpRequest::ptr request, chen::http::HttpResponse::ptr response , chen::http::HttpSession::ptr session, Result::ptr result) { do { - DEFINE_AND_CHECK_TYPE(result, int64_t, repo_id, "repo_id"); + int64_t id = request->getParamAs("id", 0); + int64_t repo_id = request->getParamAs("repo_id", 0); + if (!id && !repo_id) { + result->setErrno(errcode::PARAM_MISSING, "param id or repo_id is required"); + break; + } int64_t uid = getUserId(request); if (!uid) { @@ -37,6 +75,16 @@ int32_t OrganizationRepoPrsSyncServlet::handle(chen::http::HttpRequest::ptr requ break; } + // 传 id 时定位到其所属仓库 + if (id) { + auto pr = OrganizationRepoPrMgr::GetInstance()->get(id); + if (!pr) { + result->setErrno(errcode::ORG_PR_NOT_FOUND); + break; + } + repo_id = pr->getRepoId(); + } + // 检查仓库是否存在 auto repo = OrganizationRepoMgr::GetInstance()->get(repo_id); if (!repo) { @@ -68,6 +116,45 @@ int32_t OrganizationRepoPrsSyncServlet::handle(chen::http::HttpRequest::ptr requ } } + if (id) { + // 单条 PR 同步:同步执行,便于直接返回 GitHub 错误 + if (token.empty()) { + result->setErrno(errcode::ORG_REPO_TOKEN_MISSING); + break; + } + + data::OrganizationRepoPrsInfo::ptr updated; + auto st = OrganizationRepoPrManager::SyncPrFromGitHub(id, updated); + if (st != OrganizationRepoPrManager::SyncPrStatus::OK) { + MapSyncPrError(st, result); + break; + } + + Json::Value item; + item["id"] = (Json::Int64)updated->getId(); + item["repo_id"] = (Json::Int64)updated->getRepoId(); + item["github_pr_id"] = updated->getGithubPrId(); + item["title"] = updated->getTitle(); + item["description"] = updated->getDescription(); + item["state"] = updated->getState(); + item["priority"] = updated->getPriority(); + item["author"] = updated->getAuthor(); + item["head_branch"] = updated->getHeadBranch(); + item["base_branch"] = updated->getBaseBranch(); + item["commit_sha"] = updated->getCommitSha().substr(0, 7); + item["changed_files"] = updated->getChangedFiles(); + item["additions"] = updated->getAdditions(); + item["deletions"] = updated->getDeletions(); + item["reviewers"] = updated->getReviewers(); + item["review_status"] = updated->getReviewStatus(); + item["closed_at"] = (Json::Int64)updated->getClosedAt(); + item["merged_at"] = (Json::Int64)updated->getMergedAt(); + item["created_at"] = (Json::Int64)updated->getCreateTime(); + item["updated_at"] = (Json::Int64)updated->getUpdateTime(); + result->jsondata = item; + break; + } + // 异步调度全量同步 chen::Scheduler::GetThis()->schedule([repo_id, url, token]() { OrganizationRepoPrManager::SyncFromGitHub(repo_id, url, token); diff --git a/src/servlets/organization/organization_repo_prs_sync_servlet.h b/src/servlets/organization/organization_repo_prs_sync_servlet.h index d0ade69..7b31a05 100644 --- a/src/servlets/organization/organization_repo_prs_sync_servlet.h +++ b/src/servlets/organization/organization_repo_prs_sync_servlet.h @@ -1,6 +1,6 @@ /** * @file organization_repo_prs_sync_servlet.h - * @brief 组织仓库 PR 全量同步接口 + * @brief 组织仓库 PR 同步接口(repo_id 全量 / id 单条) * @author Christins * @date 2026-06-24 * @copyright Apache 2.0