From 9f83c63257b71e0d7cfdf5dde1b5d6088747dbfe Mon Sep 17 00:00:00 2001 From: xuans Date: Mon, 28 Sep 2026 19:11:49 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=88=90=E5=91=98=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E5=AE=89=E5=85=A8=E8=BE=B9=E7=95=8C=E5=9B=9E=E5=BD=92?= =?UTF-8?q?=E5=B9=B6=E5=90=8C=E6=AD=A5=E7=9C=9F=E5=AE=9E=E6=95=B4=E6=94=B9?= =?UTF-8?q?=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/ai/project-members/api-handoff.md | 129 ++++++++++++++ .../httpapi/project_members_boundary_test.go | 158 ++++++++++++++++++ .../repository/project_member_errors_test.go | 47 ++++++ doc/Scert开发任务清单.md | 2 + doc/Scert整改任务执行清单.md | 20 ++- doc/Scert整改合并优化清单.md | 17 +- doc/全面审查测试与优化记录.md | 2 + doc/基础整改任务规格.md | 2 +- doc/构建版本与数据库支持矩阵.md | 90 +++++++++- doc/项目资源关联模型.md | 15 +- 10 files changed, 461 insertions(+), 21 deletions(-) create mode 100644 .claude/docs/ai/project-members/api-handoff.md create mode 100644 backend/internal/httpapi/project_members_boundary_test.go create mode 100644 backend/internal/repository/project_member_errors_test.go diff --git a/.claude/docs/ai/project-members/api-handoff.md b/.claude/docs/ai/project-members/api-handoff.md new file mode 100644 index 0000000..48b1f4d --- /dev/null +++ b/.claude/docs/ai/project-members/api-handoff.md @@ -0,0 +1,129 @@ +# Scert 项目成员接口交接 + +## 业务范围与交付边界 + +项目成员关系用于管理同一项目内的六类角色,与用户分组独立。普通用户可以担任项目管理员,普通全局管理员不因登录角色或同组身份自动取得项目权限。接口允许当前有效超级管理员或目标项目的项目管理员管理成员。 + +本批提供成员列表、授予与撤销接口,不提供项目列表/创建入口或成员页面,也不代表域名、证书、服务器、DNS 等全部业务资源已经接入项目授权。源码专项、实库与构建证据分别记录于《构建版本与数据库支持矩阵》第二十三节,不据此声明生产验收通过。 + +## 通用约定 + +- 前缀:`/api/v1/projects/:projectID/members`,路径必须使用实际项目标识。 +- 使用正式登录接口建立的会话 Cookie;浏览器请求同源发送。服务器验证提供的 `Origin`/`Referer`,来源拒绝返回 403。 +- 不传 `actor_id`、全局角色或项目权限;操作者仅取本次服务端验证的会话,仓储执行时重新核对账户状态和成员关系。 +- 项目、用户及游标输入仅允许 1~128 位 ASCII 字母、数字、连字符、下划线;空游标例外。已有项目实体本身仍遵守其更严格的命名规则,此接口不创建项目。 +- 响应头 `X-Scert-Request-ID` 可用于关联诊断。错误响应为 `{"error":"中文提示","request_id":"请求编号"}`,不能仅按文本匹配决定权限。 +- 成功授权/撤销和成功审计在同一事务提交;失败时不得乐观更新界面。成功变更后应重新获取第一页,清空旧游标栈。 + +## 接口 + +### GET:读取成员列表 + +```http +GET /api/v1/projects/default/members?page_size=10&after=user-001 +``` + +| 查询参数 | 类型 | 约束 | +|---|---|---| +| `page_size` | 整数 | 默认 10;只接受 5、10、15、30 | +| `after` | 字符串 | 默认空,从第一页读取;下一页使用上一响应的 `next_cursor` | + +重复参数、未知参数、非法转义、无效页大小或游标返回 400。没有页码或总人数参数。 + +成功返回 200: + +```json +{ + "items": [ + { + "user_id": "user-002", + "username": "测试成员", + "status": "active", + "roles": ["dns_admin", "read_only"] + } + ], + "page_size": 10, + "has_more": false, + "next_cursor": "" +} +``` + +- 按用户 ID 升序的游标分页,不按角色关系计数,同一账户的角色合并到一个成员项中。 +- `items` 始终为数组;合法空项目和末尾空页为 `[]`,不是未授权。`has_more=false` 时 `next_cursor` 为空。 +- 列表包含仍有成员关系的停用账户,便于撤销其角色;停用账户自身无法操作接口。 +- 不返回邮箱、密码摘要、用户分组或全局角色。角色顺序由接口稳定排序,界面应按角色值映射中文名称。 +- 超级管理员不隐式出现在成员列表中;只有显式成员关系才列出。空默认项目不会自动增加成员。 +- 游标不是快照。翻页期间发生授权变更时,应允许用户刷新并回到第一页,不承诺跨请求一致性。 + +### POST:授予角色 + +```http +POST /api/v1/projects/default/members +Content-Type: application/json +``` + +```json +{ + "user_id": "user-002", + "role": "dns_admin" +} +``` + +- 目标必须为真实有效账户,不能凭用户名或邮箱替代 `user_id`。 +- 正文只接受 `user_id`、`role`,拒绝未知字段、多段 JSON 和超过 64 KiB 的正文;不接受查询参数。 +- 成功为 201:`{"message":"项目角色已授予"}`。 +- 已存在相同项目/账户/角色关系返回 409,不生成重复关系;其他角色可另行授予。 +- 同一账户可在多个项目持有多个角色,但该规则不改变普通用户只能属于一个用户分组的限制。 + +### DELETE:撤销角色 + +```http +DELETE /api/v1/projects/default/members/user-002/roles/dns_admin +``` + +- 不接受请求体或查询参数,空白请求体也拒绝。不能使用 DELETE 正文冒充操作者。 +- 成功为 204,无响应体;客户端不要尝试解析成功响应 JSON。 +- 不存在的目标关系与权限不足均返回 403,不作为幂等成功。 +- 允许项目管理员撤销自己的项目管理员角色。成功后应退出成员管理状态或处理重新读取产生的 403,不能重试为自己恢复权限;有效超级管理员仍可恢复授权。 + +## 角色枚举与显示名称 + +| 值 | 中文名称 | +|---|---| +| `project_admin` | 项目管理员 | +| `certificate_admin` | 证书管理员 | +| `deployment_admin` | 部署管理员 | +| `dns_admin` | DNS 管理员 | +| `audit_viewer` | 审计查看者 | +| `read_only` | 只读用户 | + +`super_admin` 不是可授予的项目角色。角色能力矩阵见《项目资源关联模型》9.1 节;不能因为名称相近就授予未接入资源的权限。普通账户不能读取完整私钥的上限不因项目管理员角色而解除。 + +## 错误与审计边界 + +| HTTP 状态 | 含义与界面处理 | +|---|---| +| 400 | 输入无效;显示中文提示,不重试相同请求 | +| 401 | 未登录、会话失效或账户已停用;重新认证 | +| 403 | 来源拒绝、无项目权限、项目不存在、目标账户/关系不满足条件;不区分这些情况枚举他人资源 | +| 409 | 项目成员关系已存在,或系统尚未初始化;以当前流程和错误响应区分,不当作成功 | +| 500 | 操作或事务审计失败;不展示底层数据库错误,不乐观认为授权已生效 | +| 503 | 成员服务未接入或不可用;禁止将空白页面解释为没有成员 | + +会话验证后、仓储执行前发生撤权或停用仍会拒绝。失败审计保留本次已验证身份;未认证请求不归到客户端声明的账户。失败审计独立于回滚事务,记录固定动作、HTTP 状态、目标及请求编号,不保存请求正文或任意底层错误。请求取消后审计仍有独立的三秒上限;审计存储自身失败时只写稳定服务器诊断日志,原失败响应保持不变,不能承诺事件已持久化。 + +## 前端集成与验收场景 + +1. 按账户分页并显示中文角色标签;用户可选择 5、10、15、30 条,切换页大小回到第一页。 +2. 保存各页起始游标用于上一页;不将 `next_cursor` 当作页码,不伪造总人数。 +3. 授权提交后等待 201,再刷新列表;重复授权 409 显示冲突并刷新,不生成本地重复项。 +4. 撤销前确认目标与角色,自我撤权单独提示;204 后不解析 JSON,失去权限时停止继续操作。 +5. 分别验证普通全局管理员、只读项目成员、其他项目管理员均无法管理本项目成员。 +6. 测试空列表、末页、停用成员、多角色账户和翻页期间关系变更;不显示邮箱或其他未返回字段。 +7. 401/403/500/503 分别显示正确状态,不能统一伪装为空列表;诊断信息展示请求编号而非数据库原文。 + +## 待完成 + +- [ ] 项目列表/创建及入口选择、授权目标账户选择、成员页面与浏览器验收。 +- [ ] 全部业务资源与排队任务执行/取消/重试的权限重判。 +- [ ] PostgreSQL 当前实库、Linux 原生及完整生产部署验收;已通过的局部测试不替代这些门禁。 diff --git a/backend/internal/httpapi/project_members_boundary_test.go b/backend/internal/httpapi/project_members_boundary_test.go new file mode 100644 index 0000000..75f8030 --- /dev/null +++ b/backend/internal/httpapi/project_members_boundary_test.go @@ -0,0 +1,158 @@ +package httpapi + +import ( + "bytes" + "context" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "git.pcux.cn/xuans/Scert/backend/internal/domain" + "git.pcux.cn/xuans/Scert/backend/internal/logging" + "git.pcux.cn/xuans/Scert/backend/internal/repository" +) + +// 在会话验证之后、真实仓储执行之前改变权限,避免仅验证顺序请求的撤权场景。 +type projectMemberBoundaryRepository struct { + repository.ProjectMemberRepository + before func() + audit func(context.Context) +} + +func (r projectMemberBoundaryRepository) ListProjectMembers(ctx context.Context, actor, project, after string, size int) (repository.ProjectMemberPage, error) { + r.before() + return r.ProjectMemberRepository.ListProjectMembers(ctx, actor, project, after, size) +} + +func (r projectMemberBoundaryRepository) GrantProjectRole(ctx context.Context, actor, project, user string, role domain.ProjectRole) error { + r.before() + return r.ProjectMemberRepository.GrantProjectRole(ctx, actor, project, user, role) +} + +func (r projectMemberBoundaryRepository) RevokeProjectRole(ctx context.Context, actor, project, user string, role domain.ProjectRole) error { + r.before() + return r.ProjectMemberRepository.RevokeProjectRole(ctx, actor, project, user, role) +} + +func (r projectMemberBoundaryRepository) RecordProjectMemberFailure(ctx context.Context, failure repository.ProjectMemberFailure) error { + if r.audit != nil { + r.audit(ctx) + } + return r.ProjectMemberRepository.RecordProjectMemberFailure(ctx, failure) +} + +func TestProjectMemberAPIDeleteRejectsPayload(t *testing.T) { + f := newProjectMemberAPIFixture(t) + path := memberTestPath + "/manager/roles/project_admin" + for _, tc := range []struct{ name, suffix, body string }{ + {"json", "", `{"actor_id":"primary-super-admin"}`}, + {"whitespace", "", " "}, + {"large_body", "", strings.Repeat("x", 70*1024)}, + {"query", "?actor_id=primary-super-admin", ""}, + {"unknown_query", "?ignored=1", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodDelete, path+tc.suffix, tc.body) + log := f.lastAudit(t) + if response.Code != http.StatusBadRequest || log.Status != "failed" || log.OperatorID != "manager" { + t.Fatalf("撤销角色未拒绝额外输入或审计身份错误:%d,%+v", response.Code, log) + } + if _, err := f.members.ListProjectMembers(t.Context(), "manager", "project-a", "", 5); err != nil { + t.Fatalf("拒绝的请求仍撤销了角色:%v", err) + } + }) + } +} + +func TestProjectMemberAPIRechecksAfterAuthentication(t *testing.T) { + for _, change := range []string{"revoke", "disable"} { + for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodDelete} { + t.Run(change+"/"+method, func(t *testing.T) { + f := newProjectMemberAPIFixture(t) + called := false + f.server.projectMembers = projectMemberBoundaryRepository{ + ProjectMemberRepository: f.members, + before: func() { + called = true + query := "DELETE FROM project_members WHERE user_id='manager'" + if change == "disable" { + query = "UPDATE users SET status='disabled' WHERE id='manager'" + } + if _, err := f.db.ExecContext(t.Context(), query); err != nil { + t.Fatal(err) + } + }, + } + path, body := memberTestPath, "" + if method == http.MethodPost { + body = `{"user_id":"target","role":"dns_admin"}` + } else if method == http.MethodDelete { + path += "/viewer/roles/read_only" + } + response := serveUserRequest(f.server, f.cookies["manager"], method, path, body) + log := f.lastAudit(t) + if !called || response.Code != http.StatusForbidden || log.OperatorID != "manager" || log.Status != "failed" { + t.Fatalf("会话验证后的撤权未拒绝,或丢失已验证身份:%t,%d,%+v", called, response.Code, log) + } + var target, viewer int + if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM project_members WHERE user_id='target'").Scan(&target); err != nil { + t.Fatal(err) + } + if err := f.db.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM project_members WHERE user_id='viewer'").Scan(&viewer); err != nil || target != 0 || viewer != 1 { + t.Fatalf("撤权后仍修改了成员关系:%d,%d,%v", target, viewer, err) + } + }) + } + } +} + +func TestProjectMemberAPICanceledRequestKeepsBoundedFailureAudit(t *testing.T) { + f := newProjectMemberAPIFixture(t) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + audited := false + f.server.projectMembers = projectMemberBoundaryRepository{ + ProjectMemberRepository: f.members, + before: cancel, + audit: func(auditContext context.Context) { + audited = true + deadline, ok := auditContext.Deadline() + if ctx.Err() == nil || auditContext.Err() != nil || !ok || time.Until(deadline) <= 0 || time.Until(deadline) > 3*time.Second { + t.Fatalf("失败审计未脱离取消请求或等待无界:%v,%v,%v", ctx.Err(), auditContext.Err(), deadline) + } + }, + } + request := httptest.NewRequest(http.MethodGet, memberTestPath, nil).WithContext(ctx) + request.AddCookie(f.cookies["manager"]) + response := httptest.NewRecorder() + f.server.Handler().ServeHTTP(response, request) + log := f.lastAudit(t) + if !audited || response.Code != http.StatusInternalServerError || log.Status != "failed" || log.OperatorID != "manager" { + t.Fatalf("取消请求导致失败审计丢失:%t,%d,%+v", audited, response.Code, log) + } +} + +func TestProjectMemberAPIFailureAuditStorageUnavailable(t *testing.T) { + f := newProjectMemberAPIFixture(t) + var output bytes.Buffer + f.server.logger = &logging.Logger{Logger: slog.New(slog.NewJSONHandler(&output, nil))} + previous := f.lastAudit(t) + if _, err := f.db.ExecContext(t.Context(), `CREATE TRIGGER reject_failed_member_audit BEFORE INSERT ON audit_logs + WHEN NEW.resource_type='project_member' AND NEW.status='failed' + BEGIN SELECT RAISE(ABORT, 'sensitive-audit-storage-marker'); END`); err != nil { + t.Fatal(err) + } + response := serveUserRequest(f.server, f.cookies["viewer"], http.MethodGet, memberTestPath, "") + if response.Code != http.StatusForbidden || f.lastAudit(t).ID != previous.ID { + t.Fatalf("审计存储失败改变了原拒绝响应或伪造了审计:%d", response.Code) + } + if !strings.Contains(output.String(), "项目成员失败审计未保存") || !strings.Contains(output.String(), response.Header().Get(requestIDHeader)) { + t.Fatalf("审计存储失败没有稳定可关联日志:%s", output.String()) + } + if strings.Contains(output.String()+response.Body.String(), "sensitive-audit-storage-marker") { + t.Fatal("审计存储失败泄露底层错误") + } +} diff --git a/backend/internal/repository/project_member_errors_test.go b/backend/internal/repository/project_member_errors_test.go new file mode 100644 index 0000000..4ccdc5e --- /dev/null +++ b/backend/internal/repository/project_member_errors_test.go @@ -0,0 +1,47 @@ +package repository + +import ( + "errors" + "fmt" + "testing" + + "github.com/go-sql-driver/mysql" + "github.com/jackc/pgx/v5/pgconn" +) + +type memberSQLiteCodeError int + +func (e memberSQLiteCodeError) Error() string { return "测试数据库错误" } +func (e memberSQLiteCodeError) Code() int { return int(e) } + +func TestProjectMemberUniqueViolationClassification(t *testing.T) { + for _, tc := range []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"untyped_constraint", errors.New("constraint failed"), false}, + {"sqlite_primary", memberSQLiteCodeError(1555), true}, + {"sqlite_unique", memberSQLiteCodeError(2067), true}, + {"sqlite_trigger", memberSQLiteCodeError(1811), false}, + {"sqlite_foreign_key", memberSQLiteCodeError(787), false}, + {"sqlite_check", memberSQLiteCodeError(275), false}, + {"sqlite_not_null", memberSQLiteCodeError(1299), false}, + {"mysql_unique", &mysql.MySQLError{Number: 1062}, true}, + {"mysql_trigger", &mysql.MySQLError{Number: 1644}, false}, + {"mysql_foreign_key", &mysql.MySQLError{Number: 1452}, false}, + {"postgres_unique", &pgconn.PgError{Code: "23505"}, true}, + {"postgres_foreign_key", &pgconn.PgError{Code: "23503"}, false}, + {"postgres_trigger", &pgconn.PgError{Code: "P0001"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := projectMemberUniqueViolation(tc.err); got != tc.want { + t.Fatalf("成员冲突错误分类不准确:%t,期望 %t", got, tc.want) + } + if tc.err != nil && projectMemberUniqueViolation(fmt.Errorf("包装错误:%w", tc.err)) != tc.want { + t.Fatal("包装错误后的分类不一致") + } + }) + } +} diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index 399daa6..45d937c 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -1,5 +1,7 @@ # Scert 开发任务清单 +> 最新批次(2026-09-28 19:10):成员列表账户分页、授予/撤销接口、应用接线与失败审计已实现;修复审计错误误报 409,并补充验证会话认证后撤权/停用、请求取消、审计存储故障和 DELETE 输入拒绝。当前专项、双版本 MySQL 三轮、完整后端 1679 个叶子与双平台构建通过;成员三轮竞态 186 个叶子通过、9 条外部 MySQL 条件跳过,退出码 0。详细证据见支持矩阵第二十三节。成员页面、项目入口及完整资源/任务授权仍待完成,原始 44 项总状态不升级。下方各批次均为历史记录。 + > 当前批次(2026-09-28):成功成员变更与审计同事务写入已实现,审计失败回滚;源码检查点不是正式发布。SQLite 候选为 v1.59.0,最后源码取消查询/WAL 专项、完整后端、双版本 MySQL 三轮组合及双平台构建通过;原 MySQL 索引异常根因未关闭。最新任务状态、最终竞态和证据见执行清单首节与支持矩阵第二十二节,不复用旧批次数字。下一开发重点为成员 Web/API、分页、失败审计、业务资源入口与排队任务重新判权。 > 当前批次(模式 14):项目成员多角色、实时判权、同语句授予/撤销及三库迁移已实现,SQLite/MySQL 双版本与独立 Windows 程序成员备份恢复通过。后续仍需成员管理 Web/API 与审计、全部资源入口和排队任务重判;本批基础模块不等于业务隔离完成。最新证据见执行清单首节与支持矩阵第二十一节。原始 44 项不缩减,P0-05/P0-06/P0-07 继续 `[~]`;下方为历史记录。 diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index bc4745c..1c3e39b 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -1,17 +1,19 @@ # Scert 整改任务执行清单 -## 2026 年 9 月 28 日:项目成员接口与分页(18:55 同步,修复待复测) +## 2026 年 9 月 28 日:项目成员接口与分页(19:10 同步,本批回归通过,等待复审) -本批于 18:42 进入实施,接续 `bf10eb5`,不再重复数据库驱动选型;原始 44 项及验收标准不缩减。源码修改前检查点 `4618670` 已提交;新增接口、审计与修复仍未提交。本次仅同步文档,不修改代码或重新执行测试。 +本批于 18:42 进入实施,接续 `bf10eb5`,不再重复数据库驱动选型;原始 44 项及验收标准不缩减。源码修改前检查点为 `4618670`,接口实现与修复候选已随 `3857dac` 提交推送,随后增加正式边界测试并重跑回归。不清理业务库,不新增辅助验收脚本。 - [x] 确认需求:接入现有成员事务仓储,操作者只取服务端验证的会话;用户分组和全局管理员角色不隐式授予项目权限。 -- [~] 增加成员列表分页与授予/撤销接口;同一用户的多个项目角色应合并显示,列表只返回必要身份字段,不返回邮箱、密码摘要或分组。 -- [~] 成员查询在读取数据的 SQL 内判权,拒绝跨项目、撤权后访问和停用账户;空列表不能与未授权混淆。实现与用例已加入,最终源码待复测。 -- [~] 成功变更沿用事务审计,失败请求补充有界审计;未验证身份不得归给请求提供的账户,不记录原始请求体或底层数据库错误。实现已加入,失败边界继续验证。 -- [~] 正式仓储、HTTP 和应用接线测试已加入。首轮 `focused-first.jsonl` 于 18:49 记录审计失败回滚用例期望 500、实际 409;精确唯一键冲突分类修复已写入,尚无修复后通过证据。 -- [ ] 双版本 MySQL、完整后端、竞态、静态检查及构建按最终源码验证,上一批结果不代表当前新增接口通过。 -- [x] 同步总清单顶部、P0-07 最新进度及本执行清单,明确实现中、失败和待复测边界,原始 44 项总状态不升级。 -- [ ] 补齐模型和正式接口文档。成员管理页面、项目入口、全部业务资源授权及排队任务重判继续后续交付;本批接口通过不等于完整权限隔离或生产验收。 +- [x] 增加成员列表分页与授予/撤销接口;同一用户的多个项目角色合并显示,列表不返回邮箱、密码摘要或分组,应用实际接线测试通过。 +- [x] 成员查询在读取数据的 SQL 内判权;专项验证跨项目、会话验证后撤权/停用均拒绝,合法空列表与未授权区分处理。 +- [x] 成功变更沿用事务审计,失败请求补充有界审计;验证取消请求仍记录失败、审计存储故障不伪报持久化,不记录请求正文或原始数据库错误。 +- [x] 首轮审计失败误报 409 已通过精确唯一键冲突分类修复;最后专项 62 个叶子通过、3 条实库条件跳过,退出码 0,首轮失败证据保留。 +- [x] MySQL 5.7/8.0 三轮成员组合各 171 个叶子通过、0 失败、0 跳过;含 SQLite 和错误分类用例,两个版本依次执行。 +- [x] 完整后端 1679 个叶子通过、53 条条件跳过;Windows 独立程序历史备份/升级/成员恢复路径、静态检查、模块校验与双平台构建通过,顺序进程退出码 0。 +- [x] 仓储、HTTP 与应用接线三轮成员专项竞态 186 个叶子通过、0 失败、9 条外部 MySQL 条件跳过,退出码 0;不称全仓库或实库竞态通过。 +- [x] 同步总清单、模型、接口交接及支持矩阵第二十三节,说明错误、分页和授权边界;Smail 参考接口文档保持原样。 +- [ ] 成员管理页面、项目入口、全部业务资源授权及排队任务重判继续后续交付;本批接口通过不等于完整权限隔离或生产验收。 ## 2026 年 9 月 28 日:数据库最终候选(18:36,回归完成,等待复审) diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 089dab4..44eec2a 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1,16 +1,19 @@ # Scert 整改、合并与优化清单 -## 当前进度(2026 年 9 月 28 日 18:55,成员接口已进入实现,修复待复测) +## 当前进度(2026 年 9 月 28 日 19:10,成员接口本批回归通过,等待复审) -本次核对总清单、执行清单、工作区源码与专项日志,补齐总清单遗漏的 18:42 成员接口批次。上一批数据库回归文档已随 `bf10eb5` 提交,成员接口修改前检查点为 `4618670`;本节所述新增接口与修复仍在工作区,未提交,也未完成最终源码回归。本次仅同步文档,不修改业务代码、不重启测试、不清理数据库、不进行远端部署。 +本批继续推进成员接口、分页及失败审计;实现与错误分类修复已保存到检查点 `3857dac`,随后增加正式边界测试。已重新执行当前源码专项、双版本 MySQL、完整后端及构建,不用上一批数据库候选结果替代本批证据。本批不清理现有业务库、不进行浏览器或远端部署测试。 ### 本批任务状态 - [x] 确认进度同步范围:保留原始 44 项、验收标准和历史证据,按实际实现与验证结果更新,不以测试数量折算完成率。 -- [~] 成员列表、授予及撤销接口已加入工作区并接入应用依赖;列表按用户账户分页,接口允许每页 5、10、15、30 位成员,同一账户的多个角色不拆页。尚未通过本批完整回归,不能视为已经交付。 -- [~] 列表查询内实时判权、服务端会话身份和失败请求审计已有实现;成功成员变更继续使用事务审计。相关专项已加入,但最终源码边界仍待复测。 -- [~] 首轮专项 `artifacts/project-member-api-20260928/focused-first.jsonl` 于 18:49 记录失败:成功审计写入失败回滚用例期望 HTTP 500,实际返回 409。工作区已加入精确成员唯一键冲突分类修复;截至本次核对,证据目录只有首轮日志,没有修复后通过证据。 -- [ ] 重新验证修复后的仓储、HTTP 和应用实际接线测试,再按版本依次执行 MySQL 5.7/8.0、完整后端、竞态、静态检查和构建;不将上一批数据库候选通过结果复用为本批新代码结果。 +- [x] 成员列表、授予及撤销接口已接入应用依赖;列表按用户账户分页,每页支持 5、10、15、30 位成员,同一账户的多个角色不拆页。实际应用登录及读取默认项目的接线测试通过,等待复审。 +- [x] 列表查询内实时判权、服务端会话身份和失败请求审计已通过专项;补齐会话验证后撤权/停用、取消请求独立审计、审计存储失败、DELETE 额外输入拒绝等边界。成功变更继续使用事务审计。 +- [x] 首轮 `focused-first.jsonl` 的审计错误误报 409 已修复;精确区分三类驱动的唯一键冲突与触发器等其他错误。修复后 `focused-boundaries.jsonl` 为 62 个叶子通过、3 条实库条件跳过,退出码 0;失败日志保留。 +- [x] MySQL 5.7/8.0 依次完成三轮成员组合,各 171 个叶子通过、0 失败、0 跳过,退出码均为 0;组合含 SQLite 和错误分类用例,不称纯 MySQL 用例。 +- [x] 当前源码完整后端 1679 个叶子通过、0 失败、53 条条件跳过;Windows 独立程序历史备份/升级与成员恢复路径、静态检查、模块校验、Windows 构建及 Linux 交叉构建通过,顺序进程退出码 0。跳过与交叉构建不作为实机验收。 +- [x] 仓储、HTTP 和实际应用接线三轮竞态共 186 个叶子通过、0 失败、9 条外部 MySQL 条件跳过,退出码 0;这是成员专项竞态,不是全仓库或实库竞态验证。 +- [x] 已补充成员接口交接与模型边界,保留 Smail 参考接口文档不混写。详细命令、结果与范围见《构建版本与数据库支持矩阵》第二十三节。 - [ ] 成员管理页面、项目入口、全部业务资源授权及排队任务重判继续后续交付;PostgreSQL 当前实库、Linux 原生、完整发行包和生产验收仍未完成。 ### 原始 44 项状态汇总 @@ -360,7 +363,7 @@ backup_records 备份记录 **状态:** `[~] 进行中` -**最新进度(2026-09-28 18:55):** 仓储层已有六类项目角色、实时判权、单语句授权变更与事务审计。本批成员列表分页、授予/撤销 API、应用接线和失败请求审计已加入工作区,正式测试同步增加;首轮专项存在审计写入失败被误分类为成员冲突的问题,修复已写入但尚未复测,不能记为接口交付。成员管理页面、项目入口、全部资源入口与排队任务执行/取消/重试重判仍未完成;上一批仓储通过不等于本批接口或对外权限隔离完成。 +**最新进度(2026-09-28 19:10):** 成员列表分页、授予/撤销 API、实际应用接线和失败审计已实现,审计失败误报冲突问题修复后专项、双版本 MySQL 三轮、完整后端、成员专项三轮竞态与双平台构建通过,等待复审。完整证据及跳过项见支持矩阵第二十三节。成员管理页面、项目入口、全部资源入口与排队任务执行/取消/重试重判仍未完成,P0-07 不升级为完成;成员接口通过不等于完整项目隔离或生产验收通过。 建议角色: diff --git a/doc/全面审查测试与优化记录.md b/doc/全面审查测试与优化记录.md index 9ac4fa8..8a74dee 100644 --- a/doc/全面审查测试与优化记录.md +++ b/doc/全面审查测试与优化记录.md @@ -1,5 +1,7 @@ # 全面审查、测试与优化记录 +> 最新成员接口批次(2026-09-28 19:10):首轮审计触发器失败被误映射为 409,原失败日志保留;修复后增加 DELETE 输入拒绝、认证后撤权/停用、取消请求审计、审计存储故障和三驱动错误分类正式测试。专项 62 个叶子通过、3 条实库条件跳过;MySQL 5.7/8.0 三轮各 171 个叶子通过且无失败跳过,包含 SQLite 用例;完整后端 1679 个叶子通过、53 条条件跳过,静态检查、模块校验和双平台构建通过。成员三轮竞态 186 个叶子通过、9 条外部 MySQL 条件跳过,退出码 0;完整证据见支持矩阵第二十三节,不复用下方历史竞态数字。本批没有浏览器或远端部署验收。 + > 最终候选结果同步(2026-09-28 18:36):完整后端 1635 个叶子通过、52 条条件跳过;MySQL 5.7/8.0 三轮组合各 531 个叶子通过且无失败跳过(包含 SQLite 用例);最后源码取消查询/WAL 专项共 30 个叶子通过,服务并发关闭另 30 次通过;最终三轮竞态 831 个叶子通过、15 条条件跳过。上述进程退出码均为 0。Windows 独立程序历史备份/升级/成员恢复、静态检查、模块校验及双平台构建通过,完整证据见支持矩阵第二十二节。旧索引异常根因仍未关闭,跳过与交叉构建不作为实机验收通过。 > 2026-09-28 运行时与迁移批次:SQLite 取消查询遗留文件占用在旧驱动复现;v1.40.1 中间候选专项和完整后端曾通过,但其 3.50.4 引擎仍处于官方 WAL-reset 缺陷影响范围,不能作为最终放行。当前改用 v1.59.0/3.53.4,并新增 WAL 并发事务持久化完整性回归。MySQL 两次资源分配失败已核实,旧索引异常仍未关闭。最终候选执行结果、命令和未覆盖范围见支持矩阵第二十二节;历史失败日志完整保留,不称生产验收通过。 diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index 3299746..363fc6c 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -171,7 +171,7 @@ P0-06 与 P0-07 均维持 `[~]`,六阶段中的隔离、生产和最终验收 - 整改内容:资源增加项目、所有者、创建人及更新人;制定系统管理员、项目管理员、证书/DNS/部署管理员、审计与只读角色矩阵;列表、详情、写操作、导出与后台任务均检查项目归属,普通用户不能读完整私钥。 - 验证命令:V07、V06、V03;补跨项目 ID 枚举、伪造归属、任务访问与私钥导出拒绝测试。 - 实机验收步骤:建立两项目和最小权限成员;分别测试证书、服务器、目标、DNS、任务、审计的跨项目读写、删除与回滚;撤权后现有会话及排队任务必须重新判权。 -- 验收证据:域名/服务器归属和 HTTP 拒绝伪造归属已覆盖;六类项目角色、按实际成员和账户状态判权、成功成员变更与审计同事务已有仓储实现及 SQLite/MySQL 隔离证据,当前候选结果见支持矩阵第二十二节。成员 Web/API、失败请求审计、全部资源跨项目读写和排队任务撤权重判仍待补,不能称项目隔离已完成。 +- 验收证据:域名/服务器归属和 HTTP 拒绝伪造归属已覆盖;六类角色、实时判权及成功审计同事务已有仓储证据。本批成员分页、授予/撤销 API、实际应用接线和失败审计已通过专项及双版本 MySQL,完整后端、三轮成员专项竞态和构建通过,跳过与验证范围见支持矩阵第二十三节。成员页面、项目入口、全部资源跨项目读写和排队任务撤权重判仍待补,不能称项目隔离已完成。 - 复审结果:未完成。 ## P0-08 完善凭据和主密钥生命周期 diff --git a/doc/构建版本与数据库支持矩阵.md b/doc/构建版本与数据库支持矩阵.md index 0a85f5a..155e886 100644 --- a/doc/构建版本与数据库支持矩阵.md +++ b/doc/构建版本与数据库支持矩阵.md @@ -1,8 +1,8 @@ # 构建版本与数据库支持矩阵 -首次核查日期:2026 年 9 月 27 日;最近结果同步:2026 年 9 月 28 日 18:36(本机时间),见第二十二节。本文区分代码支持、隔离测试和正式生产准入,不把驱动存在视为兼容性验收。 +首次核查日期:2026 年 9 月 27 日;成员接口批次的最新结果见第二十三节。本文区分代码支持、隔离测试和正式生产准入,不把驱动存在视为兼容性验收。 -当前运行时候选及最后验证以第二十二节为准;第十五至二十一节保留各自批次的源码、依赖和环境证据,不据此推断新驱动已完成相同范围的验证。 +运行时候选选型依据见第二十二节,新增成员接口后的验证另列第二十三节;此前各节保留各自批次的源码、依赖和环境证据,不据此推断新代码已完成相同范围的验证。 ## 一、工具链与依赖 @@ -1018,3 +1018,89 @@ go test -race -p 1 -json -count=3 -timeout=12m -run 'SQLite|ProjectMember|Histor - [ ] 大规模数据和长时间资源稳定性;有限次数回归不代表长期泄漏或并发风险全部消除。 P0-04/P0-05/P0-06/P0-07/P1-09 总项继续 `[~]`,原始 44 项和最终验收标准不缩减。 + +## 二十三、项目成员 HTTP 接口、分页与失败审计(2026 年 9 月 28 日) + +### 23.1 实现与范围 + +- 检查点 `3857dac` 保存成员列表、授予/撤销、应用依赖接线和失败审计候选;随后增加正式边界测试。源码检查点不是发布或生产放行。 +- 按用户账户分页,接口只允许每页 5、10、15、30 位成员,多角色不拆页;列表与变更均从真实账户和项目成员关系判权,不信任客户端声明或会话缓存的角色。 +- 失败请求使用已验证身份并独立记录审计;请求取消不取消失败审计,有三秒等待上限。存储失败只写稳定诊断,不伪报持久化。 +- 本批没有修改数据库模式、没有创建辅助验收脚本、没有清理现有业务库,也没有浏览器或远端部署测试。正式 Go 测试使用新的临时 SQLite 库和专用 MySQL 隔离实例。 + +### 23.2 已核对证据 + +证据目录为 `artifacts/project-member-api-20260928/`;首轮失败记录保留,不覆盖。 + +| 证据 | 结果 | 边界 | +|---|---|---| +| `focused-first.jsonl` | 首轮失败 | 审计写入失败回滚用例期望 HTTP 500,实际为 409;不能计为通过 | +| `focused-after.jsonl` | 35 个叶子通过,3 条条件跳过,退出码 0 | 修复精确唯一键错误分类,并包含实际应用接线 | +| `focused-boundaries.jsonl` | 62 个叶子通过,3 条条件跳过,退出码 0 | 新增 DELETE 额外输入拒绝、会话验证后撤权/停用、取消请求审计、审计存储故障及多驱动错误分类 | +| `mysql57.jsonl` | 三轮共 171 个叶子通过,0 失败、0 跳过,退出码 0 | 原有成员变更与新分页/失败审计组合,含 SQLite 和错误分类用例,不称纯 MySQL 用例 | +| `mysql80.jsonl` | 三轮共 171 个叶子通过,0 失败、0 跳过,退出码 0 | 与 5.7 同范围,两个版本依次执行,不并行占用隔离数据库资源 | + +两份本地专项的三个条件跳过是未设置实库环境的成员变更、列表和角色 MySQL 用例,不能计为通过。实库结果由后两份证据单独覆盖。PostgreSQL 错误分类测试使用驱动错误类型,不是 PostgreSQL 原生实库验收。 + +完整后端及构建顺序进程于 19:06 结束,退出码为 0:`backend.jsonl` 为 1679 个叶子通过、0 失败、53 条条件跳过;Windows 独立程序历史备份/升级/成员恢复路径通过,`go vet -p 1 ./...`、`go mod verify`、Windows 构建和 Linux 交叉构建均通过。条件跳过包括 Linux 专属、符号链接/权限、外部数据库、Named Pipe 与远程部署测试,不计作通过;相应实机门禁仍保留。 + +`race.jsonl` 三轮成员专项竞态共 186 个叶子通过、0 失败、9 条条件跳过,19:08 结束,已取回退出码 0。三个未配置外部 MySQL 的条件测试各跳过三次,不将普通实库测试当作 MySQL 竞态通过。Windows/Linux 候选二进制仅用于定位此批构建,不代表完整发行包: + +| 目标 | SHA256 | +|---|---| +| Windows amd64 | `0FE034F129BFA23E59A951FDE79C28331AFF512FE37311BC861FA5C1EFC8370B` | +| Linux amd64 | `D0C129BB463A17FD9D7E3CFBA8CE4D899C4E059F7F715177CB22666B77E8DE0A` | + +### 23.3 已执行命令 + +以下均在 `backend/` 执行。PowerShell 重定向日志按 Unicode 读取;日志、隔离数据库与候选二进制均在既有忽略目录,不提交到仓库。 + +```powershell +$env:GOMAXPROCS = '2' +$env:CGO_ENABLED = '0' +go test -p 1 -json -count=1 -timeout=6m -run 'ProjectMember' ./internal/repository ./internal/httpapi ./internal/app + +$env:SCERT_MYSQL_TEST_BIN = 'E:\DB\5.7\bin' +$env:SCERT_MYSQL_TEST_ROOT = 'D:\Desktop\Scert\artifacts\project-member-api-20260928\mysql57-isolated' +go test -p 1 -json -count=3 -timeout=10m -run 'ProjectMember' ./internal/repository +$env:SCERT_MYSQL_TEST_BIN = 'E:\DB\8.0\bin' +$env:SCERT_MYSQL_TEST_ROOT = 'D:\Desktop\Scert\artifacts\project-member-api-20260928\mysql80-isolated' +go test -p 1 -json -count=3 -timeout=10m -run 'ProjectMember' ./internal/repository +``` + +完整回归使用另一个命令进程,未设置外部 MySQL 程序目录;每一步检查退出码,失败则停止后续步骤。 + +```powershell +$env:GOMAXPROCS = '2' +$env:CGO_ENABLED = '0' +$env:GOOS = 'windows' +$env:GOARCH = 'amd64' +go build -p 1 -o ../build/review-scert-members-api-windows-amd64.exe ./cmd/scert +$env:SCERT_SQLITE_TEST_CLI = 'D:\Desktop\Scert\build\review-scert-members-api-windows-amd64.exe' +go test -p 1 -json -count=1 -timeout=12m ./... > ../artifacts/project-member-api-20260928/backend.jsonl +go vet -p 1 ./... +go mod verify +$env:GOOS = 'linux' +go build -p 1 -trimpath -o ../build/review-scert-members-api-linux-amd64 ./cmd/scert +``` + +### 23.4 竞态范围 + +本批三轮竞态单独执行,范围为成员仓储、HTTP 及应用实际接线,不将其称为全仓库或实库竞态: + +```powershell +$env:GOMAXPROCS = '2' +$env:CGO_ENABLED = '1' +$env:GOOS = 'windows' +$env:GOARCH = 'amd64' +go test -race -p 1 -json -count=3 -timeout=12m -run 'ProjectMember|RequireAuthenticated' ./internal/repository ./internal/httpapi ./internal/app > ../artifacts/project-member-api-20260928/race.jsonl +``` + +### 23.5 尚未完成 + +- [ ] 成员管理页面、项目入口与授权目标选择;接口契约见 [成员接口交接](../.claude/docs/ai/project-members/api-handoff.md)。Smail 参考接口文档保持原样。 +- [ ] 所有资源入口的项目权限、排队任务执行/取消/重试重判,以及相应两项目端到端拒绝测试。 +- [!] 旧 MySQL Error 1280 的根因未关闭,新成员专项通过不替代旧异常根因分析。 +- [ ] PostgreSQL 当前实库、Linux 原生、三库完整恢复、完整发行包和十二层生产门禁。 + +P0-07 与其他未满足原始验收标准的总项仍为 `[~]`,不按新增测试数量折算整体完成率。 diff --git a/doc/项目资源关联模型.md b/doc/项目资源关联模型.md index 1c0a9c2..5108665 100644 --- a/doc/项目资源关联模型.md +++ b/doc/项目资源关联模型.md @@ -126,7 +126,7 @@ SQLite 与 PostgreSQL 在迁移事务内回填,失败回滚;MySQL DDL 可能 - `GrantProjectRole`、`RevokeProjectRole` 仅允许有效超级管理员或目标项目的项目管理员;授权条件与成员写入在同一条 SQL 中执行,防止应用层先查后写的间隙。MySQL 撤销使用自连接,不依赖不兼容的同表删除子查询。 - 未知角色、无效目标、禁用操作者及越权变更拒绝;重复授予不会产生重复关系。项目管理员可撤销自己的角色,之后不能自行恢复;有效超级管理员保留恢复能力。 - 判权结果不是长期许可。业务入口仍须从实际资源解析项目,并在执行副作用前重新判权;任务提交、排队、取消、重试及撤权竞态的完整接入尚未完成。 -- 成员成功变更与审计已使用同一事务,具体边界见 9.4。成员管理接口、界面、分页、失败请求审计尚未交付。当前没有新开放项目 HTTP 路由;仓储方法不能直接作为无需会话验证的外部入口。 +- 成员成功变更与审计已使用同一事务,具体边界见 9.4。成员列表、授予/撤销 HTTP 路由、账户分页与失败请求审计已接入,使用正式会话并在仓储内重判权限,见 9.5。成员管理界面及全部业务资源授权仍未完成;仓储方法不能直接作为无需会话验证的外部入口。 ### 9.3 迁移与恢复边界 @@ -143,4 +143,15 @@ Windows 独立产品程序已验证升级前的历史备份以及含两个项目 - 成员影响行数不是 1、审计写入失败、触发器忽略插入或提交失败均不得返回成功。审计插入未实际写入时回滚已发生的成员变更。 - MySQL 预先检查 `project_members` 与 `audit_logs` 都是 InnoDB;不以 BeginTx 返回成功推断非事务表也能回滚,不擅自修改历史引擎。 - 单连接 SQLite、审计故障、取消上下文、重复操作、自我撤权、历史审计保留及双版本 MySQL 隔离用例是当前验证范围;PostgreSQL 当前实库和生产端到端尚未完成。 -- 成功审计不能替代失败请求审计。拒绝请求、Web/API 身份来源与所有业务入口及排队任务重判仍是后续任务,不能由这次仓储提交宣称完成。 +- 成功审计不能替代失败请求审计。成员接口的拒绝请求与身份来源现已接入 9.5 所述处理,全部业务入口及排队任务重判仍是后续任务,不能由成员仓储或接口提交宣称完成。 + +### 9.5 成员接口、分页与失败审计 + +- `GET /api/v1/projects/:projectID/members` 按账户游标分页,每页允许 5、10、15、30 位成员;同一账户的全部角色合并返回,只包含账户 ID、用户名、状态和角色,不含邮箱、分组或密码摘要。 +- `POST` 同路径授予角色;`DELETE /api/v1/projects/:projectID/members/:userID/roles/:role` 撤销角色。真实超级管理员或本项目管理员可操作,普通全局管理员和同组成员不隐式获权。 +- 成员查询在同一 SQL 内完成权限门与分页读取;合法空项目返回空数组,无权限和项目不存在统一拒绝。超级管理员不自动产生列表成员。 +- 请求身份来自已验证会话,不接受 `actor_id`。会话验证后发生撤权或停用时,仓储仍重新判定;身份快照仅用于本次失败审计,不缓存权限。 +- 成功事务失败必须回滚,只有确定的成员唯一键冲突映射为 409;审计触发器等其他失败保持 500,不将宽泛约束错误误报为重复授权。 +- 失败审计使用真实项目解析范围,不存在项目记录为未知;不记录请求体或原始数据库错误。独立取消上下文有三秒上限;审计存储失败只写稳定诊断,不伪报已落库。 +- [接口交接说明](../.claude/docs/ai/project-members/api-handoff.md) 记录完整请求、响应、错误与前端处理。`doc/后端接口说明文档.md` 是 Smail 参考,不混入本项目接口。 +- 本批回归与实库证据见《构建版本与数据库支持矩阵》第二十三节;接口通过不是成员页面、所有资源项目隔离或生产验收通过。