diff --git a/backend/internal/httpapi/project_bindings_many_test.go b/backend/internal/httpapi/project_bindings_many_test.go index 24bbef2..314038f 100644 --- a/backend/internal/httpapi/project_bindings_many_test.go +++ b/backend/internal/httpapi/project_bindings_many_test.go @@ -44,6 +44,24 @@ func TestProjectBindingAPIManyToManyAndPathConflict(t *testing.T) { t.Fatalf("冲突提示不明确或泄露其他域名:%s", response.Body.String()) } } + assertServerDomains := func(id string, expected []string) { + t.Helper() + response := serveUserRequest(f.server, f.cookies["manager"], http.MethodGet, "/api/v1/servers/"+id+"/domains", "") + var result struct { + Items []domain.Domain `json:"items"` + } + if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &result) != nil || result.Items == nil { + t.Fatalf("服务器反向关联读取响应错误:%d,%s", response.Code, response.Body.String()) + } + ids := make([]string, 0, len(result.Items)) + for _, item := range result.Items { + ids = append(ids, item.ID) + } + slices.Sort(ids) + if !slices.Equal(ids, expected) { + t.Fatalf("服务器反向关联不正确:%s,%v,期望 %v", id, ids, expected) + } + } assertBindings := func(id string, expected []string) { t.Helper() response := serveUserRequest(f.server, f.cookies["manager"], http.MethodGet, "/api/v1/domains/"+id+"/servers", "") @@ -67,9 +85,13 @@ func TestProjectBindingAPIManyToManyAndPathConflict(t *testing.T) { put("domain-c", `{"server_ids":["server-a","server-c"]}`, http.StatusOK) assertBindings("domain-a", both) assertBindings("domain-c", both) + assertServerDomains("server-a", []string{"domain-a", "domain-c"}) + assertServerDomains("server-c", []string{"domain-a", "domain-c"}) put("domain-a", `{"server_ids":["server-c"]}`, http.StatusOK) assertBindings("domain-a", both[1:]) assertBindings("domain-c", both) + assertServerDomains("server-a", []string{"domain-c"}) + assertServerDomains("server-c", []string{"domain-a", "domain-c"}) put("domain-a", `{"server_ids":[]}`, http.StatusOK) assertBindings("domain-a", []string{}) assertBindings("domain-c", both) diff --git a/backend/internal/httpapi/project_resources_api.go b/backend/internal/httpapi/project_resources_api.go index bae968a..5948c94 100644 --- a/backend/internal/httpapi/project_resources_api.go +++ b/backend/internal/httpapi/project_resources_api.go @@ -16,6 +16,7 @@ func (s *Server) registerProjectResourceRoutes() { api.GET("/audit-logs", s.listAuditLogs) api.GET("/servers", s.listServers) api.GET("/servers/:id", s.getServer) + api.GET("/servers/:id/domains", s.listServerDomains) api.GET("/domains/:id/servers", s.listDomainServers) api.GET("/certificates", s.listCertificates) api.GET("/certificates/:id", s.getCertificate) diff --git a/backend/internal/httpapi/project_resources_api_test.go b/backend/internal/httpapi/project_resources_api_test.go index 6cb037c..3ed725c 100644 --- a/backend/internal/httpapi/project_resources_api_test.go +++ b/backend/internal/httpapi/project_resources_api_test.go @@ -98,6 +98,7 @@ func TestProjectResourceAPIDetailsHideExistenceAndGateFiles(t *testing.T) { "/api/v1/certificates/domain-" + suffix, "/api/v1/domains/domain-" + suffix + "/certificate/versions", "/api/v1/domains/domain-" + suffix + "/servers", + "/api/v1/servers/server-" + suffix + "/domains", } { reader.reads = nil response := serveUserRequest(f.server, f.cookies["viewer"], http.MethodGet, path, "") @@ -152,6 +153,7 @@ func TestProjectResourceAPIMissingDependencyFailsClosed(t *testing.T) { for _, path := range []string{ "/api/v1/domains", "/api/v1/servers", "/api/v1/servers/server-a", "/api/v1/certificates", "/api/v1/certificates/domain-a", "/api/v1/domains/domain-a/certificate/versions", "/api/v1/domains/domain-a/servers", + "/api/v1/servers/server-a/domains", } { response := serveUserRequest(f.server, f.cookies["primary-super-admin"], http.MethodGet, path, "") if response.Code != http.StatusServiceUnavailable || len(reader.reads) != 0 { diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index ebfbefd..27826ec 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -1069,6 +1069,18 @@ func (s *Server) listDomainServers(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"items": items}) } +func (s *Server) listServerDomains(c *gin.Context) { + if !s.requireProjectResourceReader(c) { + return + } + items, err := s.projectResources.ListVisibleServerDomains(c.Request.Context(), c.GetString(authenticatedActorKey), c.Param("id")) + if err != nil { + s.writeProjectResourceError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"items": items}) +} + func serverFromRequest(request serverRequest) deployment.Server { return deployment.Server{ ID: domain.NewID(), Name: request.Name, Address: request.Address, Port: request.Port, diff --git a/backend/internal/httpapi/test_helpers_test.go b/backend/internal/httpapi/test_helpers_test.go index 30a3bdb..66ebf3c 100644 --- a/backend/internal/httpapi/test_helpers_test.go +++ b/backend/internal/httpapi/test_helpers_test.go @@ -65,6 +65,19 @@ func (r projectResourceReaderStub) ListVisibleDomainServers(ctx context.Context, return r.servers.ListServersByDomainID(ctx, id) } +func (r projectResourceReaderStub) ListVisibleServerDomains(ctx context.Context, _ string, id string) ([]domain.Domain, error) { + servers, err := r.servers.ListServers(ctx) + if err != nil { + return nil, err + } + for _, server := range servers { + if server.ID == id { + return []domain.Domain{}, nil + } + } + return nil, repository.ErrProjectAccessDenied +} + func readyTestConfig(directory string) config.Config { cfg := config.Default(directory) // Linux 默认目录属于正式安装,测试必须显式隔离所有可写路径。 diff --git a/backend/internal/repository/project_resources.go b/backend/internal/repository/project_resources.go index a4a6e3b..cd403a2 100644 --- a/backend/internal/repository/project_resources.go +++ b/backend/internal/repository/project_resources.go @@ -18,6 +18,7 @@ type ProjectResourceReader interface { ListVisibleServers(context.Context, string) ([]deployment.Server, error) GetVisibleServer(context.Context, string, string) (deployment.Server, error) ListVisibleDomainServers(context.Context, string, string) ([]deployment.Server, error) + ListVisibleServerDomains(context.Context, string, string) ([]domain.Domain, error) } var _ ProjectResourceReader = (*SQLProjectMemberRepository)(nil) @@ -142,6 +143,36 @@ func (r *SQLProjectMemberRepository) ListVisibleDomainServers(ctx context.Contex WHERE `+domainPredicate+` AND `+serverPredicate+` AND domains.id = ? ORDER BY servers.name, servers.id`, args) } +func (r *SQLProjectMemberRepository) ListVisibleServerDomains(ctx context.Context, actorID, serverID string) ([]domain.Domain, error) { + if _, err := r.GetVisibleServer(ctx, actorID, serverID); err != nil { + return nil, err + } + domainPredicate, args := resourceReadPredicate("domains.project_id", actorID, domain.PermissionCertificateRead) + serverPredicate, serverArgs := resourceReadPredicate("servers.project_id", actorID, domain.PermissionServerRead) + args = append(args, serverArgs...) + args = append(args, serverID) + rows, err := r.db.QueryContext(ctx, Rebind(r.driver, visibleDomainSelect+` + JOIN domain_servers visible_binding ON visible_binding.domain_id = domains.id + JOIN servers ON servers.id = visible_binding.server_id AND servers.project_id = domains.project_id + WHERE `+domainPredicate+` AND `+serverPredicate+` AND servers.id = ? ORDER BY domains.name, domains.id`), args...) + if err != nil { + return nil, fmt.Errorf("failed to query visible server domains: %w", err) + } + defer rows.Close() + items := make([]domain.Domain, 0) + for rows.Next() { + item, err := scanDomain(rows) + if err != nil { + return nil, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("failed to read visible server domains: %w", err) + } + return items, nil +} + func (r *SQLProjectMemberRepository) readVisibleServers(ctx context.Context, query string, args []any) ([]deployment.Server, error) { rows, err := r.db.QueryContext(ctx, Rebind(r.driver, query), args...) if err != nil { diff --git a/doc/Scert开发任务清单.md b/doc/Scert开发任务清单.md index cb3fd8c..5dfd4dd 100644 --- a/doc/Scert开发任务清单.md +++ b/doc/Scert开发任务清单.md @@ -3430,3 +3430,17 @@ Linux Ubuntu 22.04 上的 `Scert-admin` 手动启停、无开机自启动、无 - `[!]` PostgreSQL/MySQL 矩阵、浏览器验收、远端 SSH、HTTPS/DNS/ACME 和生产部署验收尚未执行。 下一步继续开发剩余任务与证书/部署权限链,开发阶段结束后再统一执行三库、浏览器和远端生产流程测试。 + +## 本轮开发进度(2026-09-29 19:50) + +按用户要求,总体验收位置继续保持 **P0-03 完成待复审**;本轮完成“域名与服务器多对多部署关联”开发,不将本地测试结果视为生产验收通过。 + +- `[x]` 保留并验证域名绑定多台服务器的事务模型,单次替换只影响当前域名,不覆盖其他域名的服务器关联。 +- `[x]` 新增服务器反向读取关联域名的项目资源接口:`GET /api/v1/servers/:id/domains`。 +- `[x]` 服务器关联域名读取按实时账户状态、项目成员权限、服务器项目和域名项目同时过滤,未知或越权资源统一拒绝。 +- `[x]` 管理页面在服务器卡片展示当前托管的全部域名,解绑一个域名时不影响同服务器的其他域名。 +- `[x]` 新增多对多反向关联回归测试;后端 `go test ./...`、`go vet ./...`、前端 230 项测试和前端生产构建通过。 +- `[~]` P0-07 其他任务执行、证书部署、续期、审计完整覆盖及跨数据库矩阵仍待继续开发或复审。 +- `[!]` 浏览器清缓存验收、远端密码 SSH、HTTPS/DNS/ACME、Linux/Windows 服务和生产部署验收仍未执行。 + +下一步:继续完成开发阶段剩余权限与部署闭环,之后统一按生产流程编译、部署和测试;验收位置仍以 P0-03 为准。 \ No newline at end of file diff --git a/doc/Scert整改任务执行清单.md b/doc/Scert整改任务执行清单.md index a121079..1c38c14 100644 --- a/doc/Scert整改任务执行清单.md +++ b/doc/Scert整改任务执行清单.md @@ -725,3 +725,17 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全 - **自动化验证:** 后端 HTTP API/配置专项通过,前端 230 项测试通过,前端生产构建通过;这些结果不等同于生产部署验收。 - **仍在开发:** 续期任务、证书导入/部署、任务执行/重试及其他资源写入的完整实时授权和拒绝副作用验证。 - **尚未执行:** PostgreSQL/MySQL 矩阵、浏览器清缓存验收、远端密码 SSH、HTTPS/DNS/ACME 和生产部署流程。 + +## 本轮开发进度(2026-09-29 19:50) + +按用户要求,总体验收位置继续保持 **P0-03 完成待复审**;本轮完成“域名与服务器多对多部署关联”开发,不将本地测试结果视为生产验收通过。 + +- `[x]` 保留并验证域名绑定多台服务器的事务模型,单次替换只影响当前域名,不覆盖其他域名的服务器关联。 +- `[x]` 新增服务器反向读取关联域名的项目资源接口:`GET /api/v1/servers/:id/domains`。 +- `[x]` 服务器关联域名读取按实时账户状态、项目成员权限、服务器项目和域名项目同时过滤,未知或越权资源统一拒绝。 +- `[x]` 管理页面在服务器卡片展示当前托管的全部域名,解绑一个域名时不影响同服务器的其他域名。 +- `[x]` 新增多对多反向关联回归测试;后端 `go test ./...`、`go vet ./...`、前端 230 项测试和前端生产构建通过。 +- `[~]` P0-07 其他任务执行、证书部署、续期、审计完整覆盖及跨数据库矩阵仍待继续开发或复审。 +- `[!]` 浏览器清缓存验收、远端密码 SSH、HTTPS/DNS/ACME、Linux/Windows 服务和生产部署验收仍未执行。 + +下一步:继续完成开发阶段剩余权限与部署闭环,之后统一按生产流程编译、部署和测试;验收位置仍以 P0-03 为准。 \ No newline at end of file diff --git a/doc/Scert整改合并优化清单.md b/doc/Scert整改合并优化清单.md index 1d00e43..7d23939 100644 --- a/doc/Scert整改合并优化清单.md +++ b/doc/Scert整改合并优化清单.md @@ -1364,3 +1364,17 @@ P0-11 生产验收门禁 - `[x]` 后端专项测试、静态检查、前端测试和生产构建通过。 - `[~]` P0-07 其他任务/证书/部署权限链继续开发。 - `[!]` 三数据库、浏览器、远端服务器和生产部署验收仍未开始。 + +## 本轮开发进度(2026-09-29 19:50) + +按用户要求,总体验收位置继续保持 **P0-03 完成待复审**;本轮完成“域名与服务器多对多部署关联”开发,不将本地测试结果视为生产验收通过。 + +- `[x]` 保留并验证域名绑定多台服务器的事务模型,单次替换只影响当前域名,不覆盖其他域名的服务器关联。 +- `[x]` 新增服务器反向读取关联域名的项目资源接口:`GET /api/v1/servers/:id/domains`。 +- `[x]` 服务器关联域名读取按实时账户状态、项目成员权限、服务器项目和域名项目同时过滤,未知或越权资源统一拒绝。 +- `[x]` 管理页面在服务器卡片展示当前托管的全部域名,解绑一个域名时不影响同服务器的其他域名。 +- `[x]` 新增多对多反向关联回归测试;后端 `go test ./...`、`go vet ./...`、前端 230 项测试和前端生产构建通过。 +- `[~]` P0-07 其他任务执行、证书部署、续期、审计完整覆盖及跨数据库矩阵仍待继续开发或复审。 +- `[!]` 浏览器清缓存验收、远端密码 SSH、HTTPS/DNS/ACME、Linux/Windows 服务和生产部署验收仍未执行。 + +下一步:继续完成开发阶段剩余权限与部署闭环,之后统一按生产流程编译、部署和测试;验收位置仍以 P0-03 为准。 \ No newline at end of file diff --git a/doc/基础整改任务规格.md b/doc/基础整改任务规格.md index 9967600..cf51bb0 100644 --- a/doc/基础整改任务规格.md +++ b/doc/基础整改任务规格.md @@ -315,3 +315,17 @@ P0-06 与 P0-07 均维持 `[~]`,六阶段中的隔离、生产和最终验收 - `[!]` PostgreSQL/MySQL 矩阵、浏览器验收、Linux/Windows 生产部署、远端 SSH、HTTPS/DNS/ACME 和 `odacm.cn` 实机验收尚未执行。 下一步继续开发 DNS 账户及任务相关权限链,开发阶段结束后统一执行生产构建、部署和验收流程。 + +## 本轮开发进度(2026-09-29 19:50) + +按用户要求,总体验收位置继续保持 **P0-03 完成待复审**;本轮完成“域名与服务器多对多部署关联”开发,不将本地测试结果视为生产验收通过。 + +- `[x]` 保留并验证域名绑定多台服务器的事务模型,单次替换只影响当前域名,不覆盖其他域名的服务器关联。 +- `[x]` 新增服务器反向读取关联域名的项目资源接口:`GET /api/v1/servers/:id/domains`。 +- `[x]` 服务器关联域名读取按实时账户状态、项目成员权限、服务器项目和域名项目同时过滤,未知或越权资源统一拒绝。 +- `[x]` 管理页面在服务器卡片展示当前托管的全部域名,解绑一个域名时不影响同服务器的其他域名。 +- `[x]` 新增多对多反向关联回归测试;后端 `go test ./...`、`go vet ./...`、前端 230 项测试和前端生产构建通过。 +- `[~]` P0-07 其他任务执行、证书部署、续期、审计完整覆盖及跨数据库矩阵仍待继续开发或复审。 +- `[!]` 浏览器清缓存验收、远端密码 SSH、HTTPS/DNS/ACME、Linux/Windows 服务和生产部署验收仍未执行。 + +下一步:继续完成开发阶段剩余权限与部署闭环,之后统一按生产流程编译、部署和测试;验收位置仍以 P0-03 为准。 \ No newline at end of file diff --git a/frontend/src/views/Dashboard/Dashboard.css b/frontend/src/views/Dashboard/Dashboard.css index a7141e4..259b471 100644 --- a/frontend/src/views/Dashboard/Dashboard.css +++ b/frontend/src/views/Dashboard/Dashboard.css @@ -465,6 +465,21 @@ code { font-size: 12px; } +.ssh-server-domain-bindings { + display: grid; + gap: 8px; + padding: 12px; + border: 1px solid #e4ebf4; + border-radius: 12px; + background: #fff; +} + +.ssh-server-domain-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + .ssh-server-details { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -552,7 +567,8 @@ code { } @media (max-width: 900px) { - .ssh-server-details { + +.ssh-server-details { grid-template-columns: repeat(2, minmax(0, 1fr)); } } @@ -565,7 +581,8 @@ code { flex-direction: column; } - .ssh-server-details { + +.ssh-server-details { grid-template-columns: minmax(0, 1fr); } diff --git a/frontend/src/views/Dashboard/Dashboard.js b/frontend/src/views/Dashboard/Dashboard.js index 4b40c0c..5c0fa21 100644 --- a/frontend/src/views/Dashboard/Dashboard.js +++ b/frontend/src/views/Dashboard/Dashboard.js @@ -24,6 +24,14 @@ export function useDashboard() { const settings = ref(null); const dnsProviderCatalog = ref([]); const domainServers = ref({}); + const serverDomains = computed(() => Object.fromEntries( + servers.value.map((server) => [ + server.id, + domains.value.filter((domain) => + (domainServers.value[domain.id] || []).some((item) => item.id === server.id), + ), + ]), + )); const domainPage = ref(1); const domainPageSize = ref(5); const domainPageSizeOptions = [5, 10, 15, 30]; @@ -1213,6 +1221,7 @@ export function useDashboard() { clearDNSCredentials, testDNS, domainServers, + serverDomains, domainPage, domainPageSize, domainPageSizeOptions, diff --git a/frontend/src/views/Dashboard/Dashboard.vue b/frontend/src/views/Dashboard/Dashboard.vue index 389de68..d8f3086 100644 --- a/frontend/src/views/Dashboard/Dashboard.vue +++ b/frontend/src/views/Dashboard/Dashboard.vue @@ -25,6 +25,7 @@ const { settings, settingsForm, domainServers, + serverDomains, domainPage, domainPageSize, domainPageSizeOptions, @@ -459,6 +460,15 @@ const { activeSection, activeLabel, sections, selectSection } = useDashboardNavi 主机指纹:{{ item.host_key_fingerprint || "未填写" }} 认证:{{ serverAuthLabel(item) }} +
+ 托管域名 +
+ + {{ domain.name }} + +
+ 未关联托管域名 +
托管私钥