From fc1dc08ebe1a4d6b52f44ef5bd6b623447d9b878 Mon Sep 17 00:00:00 2001 From: JustMe Date: Fri, 16 Oct 2026 18:30:58 +0800 Subject: [PATCH] feat(api): delete workspace groups and record who signed a content event --- contracts/api-contract.json | 2 +- contracts/api.schemas.json | 12 ++++++- docs/api.md | 2 +- docs/openapi.yaml | 33 ++++++++++++++++++- server/src/WebDrop.Api/ApiContract.cs | 2 +- server/src/WebDrop.Api/Endpoints.Workspace.cs | 2 ++ server/src/WebDrop.Core/ChannelService.cs | 2 +- server/src/WebDrop.Core/ChannelWorkspace.cs | 7 ++++ server/src/WebDrop.Core/WorkspaceCrypto.cs | 2 ++ .../WebDrop.Api.Tests/WorkspaceApiTests.cs | 20 +++++++++++ web/src/core-bridge/generated/api.ts | 3 +- 11 files changed, 80 insertions(+), 7 deletions(-) diff --git a/contracts/api-contract.json b/contracts/api-contract.json index fb57e40..8857000 100644 --- a/contracts/api-contract.json +++ b/contracts/api-contract.json @@ -1,3 +1,3 @@ { - "version": "8.0.0" + "version": "8.1.0" } diff --git a/contracts/api.schemas.json b/contracts/api.schemas.json index d371c06..e7a20b9 100644 --- a/contracts/api.schemas.json +++ b/contracts/api.schemas.json @@ -1,6 +1,6 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", - "x-backend-contract-sha256": "655e918f3443073c270bdeb9ae4e69d79dacf83baa90dea1a0cdcfe71d2b8bd5", + "x-backend-contract-sha256": "22853abd6c1bf74053682018cd214fecb34c664e4abe043a15c1595566419350", "$defs": { "AccountChannelBody": { "type": "object", @@ -1616,6 +1616,16 @@ "signature": { "type": "string" }, + "signerAccount": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "signerDeviceId": { "type": "string" }, diff --git a/docs/api.md b/docs/api.md index 95235da..1ee8fd2 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,7 +4,7 @@ 空间结构使用 `GET /spaces/{id}/structure`,包含 revision、channels、groups、folders、locations。频道与分组分别通过 `/spaces/{id}/channels` 和 `/spaces/{id}/groups` 创建;响应同时包含完整结构快照和服务端新建实体及 ID。其他字段通过 PATCH 重命名;频道归属通过 `PUT /spaces/{id}/channels/{channelId}/group` 修改。文件夹通过 `/spaces/{id}/folders` 创建及 PATCH 重命名,文件归属通过 `PUT /spaces/{id}/locations` 修改。 频道、分组和目录写操作需要账号会话和空间写权限;允许上传的成员可以关联和移动自己的资源,文件移动必须满足每个条目的作者或管理权限,跨空间、跨频道的错误分组/目录关联被拒绝。结构修改后通知订阅设备重新拉取。 -URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `8.0.0`。v8 让频道强制登录:去掉未登录访客(设置 `allow_guests`、`/guests` 接口、访客终端席位),频道与空间的所有接口都要求账号会话,缺少时返回 `401 account_required`;便捷分享(`/shares`)不受影响。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;频道链接只对登录账号有效,未登录的人先登录再按加入规则加入。 +URL 基础路径保留 `/api/v1`,`/info.contract_version` 为 `8.1.0`(8.1 新增 `DELETE /spaces/{id}/groups/{groupId}`:删除分组,组内频道回到未分组)。v8 让频道强制登录:去掉未登录访客(设置 `allow_guests`、`/guests` 接口、访客终端席位),频道与空间的所有接口都要求账号会话,缺少时返回 `401 account_required`;便捷分享(`/shares`)不受影响。v7 把「设备」收束为账号的属性:成员只按用户管理,`seats` 系列命名改为 `max_members` / `members_used` / `members_full`,去掉设备名与设备清单接口,登录账号不再需要终端密钥。v6 引入账号签名根密钥、设备证书和频道对象签名事件;不兼容的旧主版本客户端会被契约预检拒绝。账号资料与空间结构由服务端管理。创建频道/分享以及所有 owner 管理操作必须携带有效账号 session;频道链接只对登录账号有效,未登录的人先登录再按加入规则加入。 `GET /info` 的 `defaults` 包含 `channel`、`share` 创建设置;省略创建请求的 settings 时服务端使用相同默认值。`choices.max_members` 与 `limits.max_members` 表示成员人数范围,不代表设备数。频道设置 JSON 字段为 `max_members`(旧存储里的 `seats` 在启动时迁移)。其他选项按部署限制收束,前端只格式化选项文案,不提供替代业务默认值。 diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 069208f..d1ce4b4 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: WebDrop API - version: 8.0.0 + version: 8.1.0 servers: - url: /api/v1 @@ -291,6 +291,37 @@ paths: '403': { $ref: '#/components/responses/Error' } '404': { $ref: '#/components/responses/Error' } '409': { $ref: '#/components/responses/Error' } + delete: + tags: [channel] + summary: 删除分组;组内频道回到未分组,频道与内容保留 + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: groupId + in: path + required: true + schema: { type: string, format: uuid } + - name: X-WD-Account + in: header + required: true + schema: { type: string } + - $ref: '#/components/parameters/RoleWriter' + - $ref: '#/components/parameters/Account' + - name: Authorization + in: header + schema: { type: string } + responses: + '200': + description: 服务端权威数据 + content: + application/json: + schema: + $ref: '../contracts/api.schemas.json#/$defs/WorkspaceBody' + '401': { $ref: '#/components/responses/Error' } + '403': { $ref: '#/components/responses/Error' } + '404': { $ref: '#/components/responses/Error' } /spaces/{id}/folders: post: tags: [channel] diff --git a/server/src/WebDrop.Api/ApiContract.cs b/server/src/WebDrop.Api/ApiContract.cs index e9ac5b6..49502fd 100644 --- a/server/src/WebDrop.Api/ApiContract.cs +++ b/server/src/WebDrop.Api/ApiContract.cs @@ -6,5 +6,5 @@ public static class ApiContract // 去掉设备名与设备清单接口。设备只是账号的属性。 // v8: 频道强制登录,去掉未登录访客(allow_guests、/guests、终端席位);便捷分享不受影响。 // Keep this aligned with contracts/api-contract.json and docs/openapi.yaml. - public const string Version = "8.0.0"; + public const string Version = "8.1.0"; } diff --git a/server/src/WebDrop.Api/Endpoints.Workspace.cs b/server/src/WebDrop.Api/Endpoints.Workspace.cs index 61c6576..79d4075 100644 --- a/server/src/WebDrop.Api/Endpoints.Workspace.cs +++ b/server/src/WebDrop.Api/Endpoints.Workspace.cs @@ -42,6 +42,8 @@ public static partial class Endpoints var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceNameRequest, ct); return Json(View(svc.RenameWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId, req.Name)), ApiJson.Default.WorkspaceBody); }); + api.MapDelete("/spaces/{id}/groups/{groupId}", (string id, string groupId, HttpContext ctx, ChannelService svc) => + Json(View(svc.DeleteWorkspaceGroup(ParseId(id), RequestAuth.FromHeaders(ctx.Request, false), groupId)), ApiJson.Default.WorkspaceBody)); api.MapPost("/spaces/{id}/folders", async (string id, HttpContext ctx, ChannelService svc, CancellationToken ct) => { var req = await ReadJson(ctx.Request, ApiJson.Default.WorkspaceFolderRequest, ct); diff --git a/server/src/WebDrop.Core/ChannelService.cs b/server/src/WebDrop.Core/ChannelService.cs index e21f1c7..da6fcce 100644 --- a/server/src/WebDrop.Core/ChannelService.cs +++ b/server/src/WebDrop.Core/ChannelService.cs @@ -568,7 +568,7 @@ public sealed partial class ChannelService( { EventId = Codec.Encode(signedEvent.EventIdBytes), Kind = signedEvent.EventKind, FrameCount = signedEvent.FrameCount, ManifestHash = Convert.ToBase64String(signedEvent.ManifestHash).TrimEnd('=').Replace('+', '-').Replace('/', '_'), - SignerDeviceId = signedEvent.DeviceId, + SignerDeviceId = signedEvent.DeviceId, SignerAccount = signedEvent.Username, Signature = Convert.ToBase64String(signedEvent.Signature).TrimEnd('=').Replace('+', '-').Replace('/', '_'), TargetItemId = signedEvent.TargetItemId, SignerRootPublicKey = signedEvent.RootPublicKey, SignerDevicePublicKey = signedEvent.DevicePublicKey, diff --git a/server/src/WebDrop.Core/ChannelWorkspace.cs b/server/src/WebDrop.Core/ChannelWorkspace.cs index a2b5092..8617131 100644 --- a/server/src/WebDrop.Core/ChannelWorkspace.cs +++ b/server/src/WebDrop.Core/ChannelWorkspace.cs @@ -84,6 +84,13 @@ public sealed partial class ChannelService public WorkspaceState CreateWorkspaceGroup(Id16 id, Credentials creds, string name) => CreateWorkspaceGroupResult(id, creds, name).State; public WorkspaceState RenameWorkspaceGroup(Id16 id, Credentials creds, string groupId, string name) => ChangeWorkspace(id, creds, (_, state, _) => (state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound)).Name = WorkspaceName(name)); + /// 删除分组:组里的频道回到未分组,频道本身和内容都不动 + public WorkspaceState DeleteWorkspaceGroup(Id16 id, Credentials creds, string groupId) => ChangeWorkspace(id, creds, (_, state, _) => + { + var group = state.Groups.Find(g => g.Id == groupId) ?? throw new WebDropException(ErrorCode.NotFound); + foreach (var channel in state.Channels.Where(c => c.GroupId == groupId)) channel.GroupId = null; + state.Groups.Remove(group); + }); public WorkspaceState CreateWorkspaceFolder(Id16 id, Credentials creds, string threadId, string? parentId, string name) => ChangeWorkspace(id, creds, (_, state, _) => state.Folders.Add(new WorkspaceFolder { Id = Guid.NewGuid().ToString(), ThreadId = threadId, ParentId = parentId, Name = WorkspaceName(name) })); public WorkspaceState RenameWorkspaceFolder(Id16 id, Credentials creds, string folderId, string name) => ChangeWorkspace(id, creds, (_, state, _) => diff --git a/server/src/WebDrop.Core/WorkspaceCrypto.cs b/server/src/WebDrop.Core/WorkspaceCrypto.cs index 58920d5..a4a7290 100644 --- a/server/src/WebDrop.Core/WorkspaceCrypto.cs +++ b/server/src/WebDrop.Core/WorkspaceCrypto.cs @@ -30,6 +30,8 @@ public sealed class SignedContentEventRecord [JsonPropertyName("frameCount")] public required int FrameCount { get; init; } [JsonPropertyName("manifestHash")] public required string ManifestHash { get; init; } [JsonPropertyName("signerDeviceId")] public required string SignerDeviceId { get; init; } + /// 签名者的账号。空间主撤回别人的消息时,签名者不是作者;旧记录没有这个字段,按作者处理 + [JsonPropertyName("signerAccount")] public string? SignerAccount { get; init; } [JsonPropertyName("signature")] public required string Signature { get; init; } [JsonPropertyName("targetItemId")] public string? TargetItemId { get; init; } [JsonPropertyName("signerRootPublicKey")] public required string SignerRootPublicKey { get; init; } diff --git a/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs b/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs index 879b485..5a562bd 100644 --- a/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs +++ b/server/tests/WebDrop.Api.Tests/WorkspaceApiTests.cs @@ -45,6 +45,26 @@ public sealed class WorkspaceApiTests(ServerFixture server) : IClassFixture g.GetProperty("id").GetString() == groupId); + var moved = structure.GetProperty("channels").EnumerateArray().Single(ch => ch.GetProperty("name").GetString() == "组内频道"); + Assert.True(!moved.TryGetProperty("groupId", out var remaining) || remaining.ValueKind == System.Text.Json.JsonValueKind.Null); + + using var again = server.Req(HttpMethod.Delete, $"/api/v1/spaces/{a.Id}/groups/{groupId}", a.Owner); + using var missing = await server.Http.SendAsync(again); Assert.Equal(HttpStatusCode.NotFound, missing.StatusCode); + } + [Fact] public async Task AdvertisedDefaultsRespectDeploymentLimitsAndMatchCreation() { await using var isolated = WithSettings(new() { ["WebDrop:Limits:MaxMembers"] = "3", ["WebDrop:Limits:MinTtlSeconds"] = "120", ["WebDrop:Limits:MaxTtlSeconds"] = "3600" }); diff --git a/web/src/core-bridge/generated/api.ts b/web/src/core-bridge/generated/api.ts index 6c3071f..349bded 100644 --- a/web/src/core-bridge/generated/api.ts +++ b/web/src/core-bridge/generated/api.ts @@ -1,5 +1,5 @@ // Generated from backend DTOs; do not edit. -// backend-contract-sha256: 655e918f3443073c270bdeb9ae4e69d79dacf83baa90dea1a0cdcfe71d2b8bd5 +// backend-contract-sha256: 22853abd6c1bf74053682018cd214fecb34c664e4abe043a15c1595566419350 export interface AccountChannelBody { channel_id: string; @@ -338,6 +338,7 @@ export interface SignedContentEventRecord { kind: string; manifestHash: string; signature: string; + signerAccount?: string | null; signerDeviceId: string; signerDevicePublicKey: string; signerRootPublicKey: string;