在软件开发中,数据库主键的选择,Guid 还是自增整数 ID,一直是一个备受开发者关注和讨论的经典话题。作为开源 ChatGPT 前端项目 Sdcb Chats 的开发者 ,我们在这个问题上也经历了一系列探索和演进,颇具代表性。Sdcb Chats 项目致力于打造一个强大、易用、可高度定制的 ChatGPT 及大语言模型前端,帮助用户轻松连接、管理和使用各种主流的大语言模型。 总的来说,Sdcb Chats 的 ID 策略经历了从最初使用 Guid,到迁移至自增 ID,再到界面显示加密 ID,最终又回归到界面显示 Guid 的过程,其中蕴含着许多有趣的思考和实践经验,也反映了我们在项目迭代过程中对性能、安全和用户体验的不断权衡与优化。
第一阶段 - 拥抱 Guid:"一步到位"的方案
项目初期,我的好友 G 负责总体系统设计,包括前端和数据库。他果断选择了 Guid 作为主键方案。这在当时是很自然的选择,因为在普遍的技术认知中,自增 ID 在分布式系统中似乎存在诸多不便,而 Guid(全局唯一标识符)则被视为一种更现代、更通用的解决方案。Guid 的核心优势在于其全局唯一性,能够在不同的数据库和服务器之间独立生成,无需担心 ID 冲突问题。
以下是项目初期基于 PostgreSQL 设计的数据库创建脚本链接(如果您感兴趣可以查看):
https://github.com/sdcb/chats/blob/raw/prisma/postgresql/migrations/20240627111401_init/migration.sql
例如,这是 Message
表的结构定义:
sql
CREATE TABLE "ChatMessages" (
"id" UUID NOT NULL,
"userId" UUID NOT NULL,
"chatId" UUID NOT NULL,
"parentId" UUID,
"chatModelId" UUID,
"role" TEXT NOT NULL,
"messages" TEXT NOT NULL,
"inputTokens" INTEGER NOT NULL DEFAULT 0,
"outputTokens" INTEGER NOT NULL DEFAULT 0,
"inputPrice" DECIMAL(65,30) NOT NULL DEFAULT 0,
"outputPrice" DECIMAL(65,30) NOT NULL DEFAULT 0,
"duration" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatMessages_pkey" PRIMARY KEY ("id")
);
然而,随着项目的深入发展,Guid 方案的一些局限性逐渐显现。首先,Guid 较长的长度和复杂的结构在某些场景下给数据库性能带来了一定负担。尤其是在数据量快速增长的情况下,索引体积增大,查询速度变慢等问题开始凸显。作为负责维护公司内部 Chats 数据库服务器的人,我注意到核心表 Chats
的索引碎片率持续偏高。为了避免性能下降,我不得不设置每日任务,定期重建索引。这让我开始认真考虑对 Chats 项目的数据库进行大规模重构。
第二阶段 - 性能至上:迁移至自增 ID
Chats 项目的重构是一项系统性工程,数据库的大规模重构只是其中关键环节之一。实际上,软件重构是一个持续迭代的过程,贯穿于项目的整个生命周期。
在我看来,项目重构如同软件的自我革新,需要开发者具备"刀刃向内"的勇气和决心。当我们审视代码,发现不足之处,就如同在前进的道路上遇到了障碍。我们当然可以选择绕行,暂时规避问题,但这些技术债务会像隐患一样潜伏下来,并在未来某个时刻影响系统的稳定性和可维护性。特别是对于数据库这种核心模块,开发者往往出于谨慎,倾向于避免改动既有结构和数据。但长此以往,问题会逐渐累积,最终侵蚀系统的健康。因此,正视并解决这些问题才是负责任的做法。
当然,在 Chats 项目的重构中,我也有着得天独厚的优势。作为后端设计的主导者和核心开发者,我对系统的每一个细节都了如指掌。正所谓"船小好调头",即使是数据库大规模迁移这样的"大手术",我也能快速决策、高效执行。事实上,Chats 数据库已经经历过多次重要的数据迁移,各位可以通过项目仓库中的数据库迁移脚本了解详情:https://github.com/sdcb/chats/tree/ebefd93cb187961f8c69dcf04163433ce753a5f3/src/scripts/db-migration。
将主键从 Guid 切换为自增 int ID,最直接的好处就是性能的提升,具体体现在以下几个方面:
-
更小的索引尺寸和更快的查询速度:相比 Guid,自增整数 ID 在存储空间上占用更少字节,这意味着数据库索引的体积也随之减小。更小的索引意味着数据库在执行查询时,特别是面对海量数据时,能够更快地遍历索引,从而显著提升查询速度。这对于像 Chats 这种消息量可能快速增长的应用至关重要。
-
减少索引碎片:Guid 的无序性导致在插入新数据时,索引页分裂的概率大大增加,从而产生索引碎片。而自增整数 ID 的顺序递增特性,可以保证新插入的数据大概率追加在索引的末尾,最大限度地减少索引页分裂,降低索引碎片的产生。正如我在维护 Chats 数据库时观察到的,Guid 主键的表索引碎片问题尤为突出。切换到自增 ID 后,索引维护工作将大大简化。(当然,如果您的系统情况特殊,难以完全抛弃 Guid,也可以考虑使用 Uuid v7,它是一种基于时间戳的连续性 Guid,也能有效减少索引碎片)
-
节省存储空间:虽然单个 Guid 仅比整数 ID 多几个字节,但当数据量累积到百万、千万甚至亿级别时,Guid 额外占用的存储空间将非常可观。对于长期运行的应用,节省存储空间也意味着降低了硬件成本。
当然,从 Guid 迁移到自增 ID 并非一帆风顺,最大的挑战在于数据迁移的复杂性。我们需要编写严谨的数据迁移脚本,确保数据迁移过程中数据的一致性和完整性不受破坏。同时,还需要仔细评估迁移可能带来的业务影响,例如外键关联的更新,以及应用程序代码的调整。幸运的是,正如前面提到的,Chats 项目已经积累了多次数据库迁移的经验,这为我们这次从 Guid 到自增 ID 的迁移奠定了坚实的基础。
例如,这段 339 行的 C# 数据库迁移脚本(在 LINQPad 中编写):
https://github.com/sdcb/chats/blob/ebefd93cb187961f8c69dcf04163433ce753a5f3/src/scripts/db-migration/2024/20240902-db-migration.linq
在迁移脚本中,我们使用了类似 GuidInt32Mapping
这样的类来维护 Guid 和自增 ID 之间的映射关系:
csharp
public class GuidInt32Mapping
{
int _nextId = 1;
Dictionary<Guid, int> _mapping = new();
public void Add(Guid guid)
{
_mapping.Add(guid, _nextId++);
}
public int this[Guid guid]
{
get
{
return _mapping[guid];
}
}
}
通过这样的映射,我们可以在数据迁移过程中,将旧的 Guid 主键平滑地转换为新的自增 ID,并确保数据关联关系的正确性。
第三阶段 - 安全升级:界面 ID 加密
完成数据库主键从 Guid 到自增 ID 的迁移后,我们又面临了新的问题:如何在用户界面上安全地展示 ID。最初,我们直接沿用了数据库的自增 ID,将其暴露在前端界面和 API 接口中。然而,这种做法很快引发了一些安全性和用户体验方面的问题。
例如,当创建一个新的聊天会话时,界面 URL 可能会显示为 https://chats-dev.starworks.cc:88/#/1
,其中的 1
代表系统中第一个聊天。当您新建聊天时,URL 就会变为 https://chats-dev.starworks.cc:88/#/2
。以此类推,ID 会顺序递增。这种连续的数字 ID 存在潜在的安全风险:任何人都可以通过简单地修改 URL 中的数字,尝试猜测和访问其他聊天会话(当然,后端服务会进行严格的权限验证)。更重要的是,这种连续的数字 ID 容易暴露系统的一些敏感信息,例如通过 ID 的大致范围,外界可以推测出系统用户和聊天会话的大概规模,这在某些场景下是我们不希望泄露的。
此外,从用户体验的角度来看,连续的数字 ID 也显得不够专业和优雅。用户可能会觉得这些 ID 过于简单和随意,与他们对现代聊天应用的期望不符。
为了解决这些问题,我们决定对界面上显示的 ID 进行加密处理。
最初,我使用了这段 C# 代码来实现整数 ID 的加密:
https://github.com/sdcb/chats/blob/r-287/src/BE/Services/UrlEncryption/Utils.cs#L22-L36
csharp
/// <summary>
/// 加密数据结构为 base64url([1:version + encryptedData])
/// </summary>
public static string Encrypt(ReadOnlySpan<byte> input, byte[] key, byte[] iv)
{
using Aes aes = Aes.Create();
aes.Key = key;
byte[] encryptedIdBytes = aes.EncryptCbc(input, iv);
byte[] encryptedIdBytesWithIV = new byte[1 + encryptedIdBytes.Length];
encryptedIdBytesWithIV[0] = 0; // 版本号
Array.Copy(encryptedIdBytes, 0, encryptedIdBytesWithIV, 1, encryptedIdBytes.Length);
return WebEncoders.Base64UrlEncode(encryptedIdBytesWithIV);
}
在这个实现中,整数 ID 首先被转换为小端序的字节数组,然后使用 AES 算法的 CBC 模式进行加密。加密后的数据被编码为 Base64 URL 格式,以便在 URL 中安全传输。最终,URL 可能呈现为如下形式:
https://chats-dev.starworks.cc:88/#/AFo-sKz8LTPvDBMvau1dKfA
通过这种加密方式,我们不仅提升了系统的安全性,也改善了用户体验。加密后的 ID 看起来更加复杂和专业,有效避免了简单数字序列带来的潜在问题。
其中,初始化向量 IV 的生成方式如下。我定义了一个 EncryptionPurpose
枚举:
csharp
// https://github.com/sdcb/chats/blob/ebefd93cb187961f8c69dcf04163433ce753a5f3/src/BE/Services/UrlEncryption/EncryptionPurpose.cs#L4
public enum EncryptionPurpose
{
ChatId,
FileId,
MessageId,
ChatGroupId,
ChatShareId,
}
csharp
foreach (EncryptionPurpose purpose in Enum.GetValues<EncryptionPurpose>())
{
_ivs[purpose] = Utils.GenerateIdHasherKey(idHasherPassword + purpose, keyLength: 16, iterations: 200);
}
csharp
public static byte[] GenerateIdHasherKey(string idHasherPassword, int keyLength, int iterations)
{
// PBKDF2 参数
byte[] salt = new byte[16];
using Rfc2898DeriveBytes rfc2898DeriveBytes = new(idHasherPassword, salt, iterations, HashAlgorithmName.SHA256);
return rfc2898DeriveBytes.GetBytes(keyLength);
}
每个枚举值代表一种加密目的。代码会根据不同的目的生成不同的 IV。这样做的目的是确保即使同一个整数 ID 在不同的上下文(例如 ChatId 和 FileId)中被加密,也会产生不同的加密结果。这种做法提升了安全性和灵活性,我们可以在不同的场景下复用相同的加密机制,而无需担心 ID 重复或冲突。
可能有朋友会问,为什么不使用随机 IV,并将 IV 添加到加密后的 ID 中,这样安全性不是更高吗?这主要是基于以下两点考虑:
首先,前端的某些计算逻辑依赖于稳定的 ID。在我们的前端代码中,特别是在聊天会话管理和消息渲染方面,我们大量使用了基于 ID 的缓存和状态管理机制。例如,当用户在一个聊天窗口中滚动浏览消息时,前端会根据消息的 ID 来渲染消息之间的父子关系。如果使用随机 IV,即使是同一个聊天会话,在不同的时间或不同的上下文中被加密,生成的 ID 都会不同。这会导致前端缓存失效,状态管理混乱,最终引发难以追踪的 bug。想象一下,用户明明还在同一个聊天中,但由于 ID 变化,前端却认为这是一个新的聊天,之前的消息缓存全部失效,这无疑会造成糟糕的用户体验。为了保证前端逻辑的稳定性和可预测性,我们需要确保在同一上下文中,同一个整数 ID 加密后的结果始终一致。
其次,不固定的 IV 会显著增加 ID 的长度。如果将随机生成的 IV 也附加到加密后的 ID 中,最终的 ID 长度会大大增加。AES 算法的 IV 通常为 16 字节,转换为 Base64 URL 编码后,会增加约 21 个字符的长度(16 * 4 / 3 ≈ 21.3)。原本加密后的 ID 已经比纯数字 ID 长了不少,如果再加上 20 多个字符的 IV,整个 ID 会显得非常臃肿,尤其是在 URL 中展示时,既不美观,也增加了 URL 的长度负担。我们希望在保证安全性的前提下,尽可能保持 ID 的简洁易用。
因此,综合考虑前端的稳定性和 ID 长度,我们最终选择了使用基于 EncryptionPurpose
枚举的固定 IV 方案。这样既保证了在不同上下文中加密结果的差异性,又避免了随机 IV 带来的不稳定性和长度增加问题。
第四阶段 - 兼顾用户感知:界面显示为 Guid(当前方案)
经过一段时间的实际运行,我们意识到,虽然加密 ID 解决了安全性问题,但在某些场景下,用户仍然希望看到一种更具辨识度的 ID 格式。因此,我们最终决定在界面上将 ID 显示为 Guid 格式。
这种做法的优点在于,Guid 格式的 ID 看起来更加随机和复杂,更符合用户对现代应用的普遍认知,同时也有效避免了直接暴露自增 ID 的问题。在具体实现上,我们将加密后的 ID 转换为 Guid 格式进行展示。这样一来,用户在界面上看到的 ID 既安全又专业。
细心的朋友可能已经注意到,由于我的输入长度为 4 字节或 8 字节(分别对应 int32 和 int64 类型的 ID),AES CBC 加密后的输出长度固定为 16 字节(但前端代码额外增加了一个字节作为版本号前缀,固定为 0)。而一个 Guid 的长度恰好也是 16 字节。因此,只需将 Base64Url 序列化方式替换为 Guid 序列化,即可轻松将加密 ID 转换为 Guid 形式:
https://github.com/sdcb/chats/blob/r-407/src/BE/Services/UrlEncryption/Utils.cs#L50-L60
csharp
public static string Encrypt(ReadOnlySpan<byte> input, byte[] key, byte[] iv)
{
using Aes aes = Aes.Create();
aes.Key = key;
byte[] encryptedIdBytes = aes.EncryptCbc(input, iv);
return Serialize(encryptedIdBytes);
}
private static string Serialize(byte[] encryptedIdBytes)
{
if (encryptedIdBytes.Length == 16)
{
return new Guid(encryptedIdBytes).ToString();
}
else
{
return WebEncoders.Base64UrlEncode(encryptedIdBytes);
}
}
可能有朋友会进一步追问,为什么坚持使用 AES CBC 算法,而不是现在更流行的 AES GCM 算法呢?这又可以展开一篇长文讨论。简单来说:
首先,AES GCM 的随机性高度依赖于 nonce 的唯一性。Nonce(Number used once)是一个一次性使用的随机数,在 AES GCM 中扮演着至关重要的角色,类似于 AES CBC 中的 IV(Initialization Vector,初始化向量)。如果 nonce 在多次加密中重复使用,尤其是在加密序列化的、递增的 ID 时,AES GCM 的安全性会大打折扣,甚至可能暴露出加密模式的规律性。
在我们的场景中,虽然我们为每种 EncryptionPurpose
生成了不同的 IV(在 AES CBC 中)或者说 nonce(如果我们使用 AES GCM),但如果我们在同一个 EncryptionPurpose
下连续加密递增的整数 ID,例如聊天 ID 1, 2, 3...,并且每次都使用相同的 nonce,那么 AES GCM 的输出结果就会呈现出可预测的模式。更具体地说,后一个加密后的 ID 很可能与前一个加密后的 ID 存在某种简单的数学关系,比如仅仅是最后几个字节的差异。这种可预测性对于安全性来说是致命的,攻击者可能会利用这种规律来猜测或破解 ID。
为了更直观地说明问题,请看以下 C# 代码示例:
csharp
// 示例密钥和 nonce(通常 nonce 应该是随机生成的)
byte[] key = new byte[16]; // 128-bit key
byte[] nonce = new byte[12]; // 96-bit nonce
RandomNumberGenerator.Fill(key);
RandomNumberGenerator.Fill(nonce);
Console.WriteLine("Nonce: " + BitConverter.ToString(nonce));
// 加密连续的整数 ID
for (int id = 1; id <= 5; id++)
{
byte[] plaintext = BitConverter.GetBytes(id);
using AesGcm aesGcm = new AesGcm(key, tagSizeInBytes: 16);
byte[] ciphertext = new byte[plaintext.Length];
byte[] tag = new byte[16]; // 128-bit tag
aesGcm.Encrypt(nonce, plaintext, ciphertext, tag);
Console.WriteLine($"ID: {id}");
Console.WriteLine("Ciphertext: " + BitConverter.ToString(ciphertext));
Console.WriteLine("Tag: " + BitConverter.ToString(tag));
Console.WriteLine();
}
输出结果如下:
Nonce: 58-8F-39-89-8C-AD-45-44-F8-C6-F0-FC
ID: 1
Ciphertext: AB-DE-99-E8
Tag: 54-C5-52-BE-3A-0E-E9-9F-EF-7F-CB-F5-09-31-7A-61
ID: 2
Ciphertext: A8-DE-99-E8
Tag: 0D-18-B3-1B-0B-07-C7-0C-90-C9-F7-40-D1-DC-26-B3
ID: 3
Ciphertext: A9-DE-99-E8
Tag: 3A-53-EC-78-1B-FF-22-82-45-A4-1C-D3-99-87-12-FD
ID: 4
Ciphertext: AE-DE-99-E8
Tag: BE-A3-70-51-69-15-9A-2A-6F-A5-8E-2B-60-06-9F-17
ID: 5
Ciphertext: AF-DE-99-E8
Tag: 89-E8-2F-32-79-ED-7F-A4-BA-C8-65-B8-28-5D-AB-59
请注意观察 Ciphertext
的部分,可以发现它们之间只有极小的差异(只有一个字节不同)。
相比之下,AES CBC 虽然也依赖 IV,但即使 IV 固定,只要密钥安全,其加密结果的随机性依然能得到较好的保证。尤其是在我们使用了填充模式(Padding)的情况下,即使输入数据存在一定的规律性,也能有效地隐藏这种规律。
其次,AES GCM 的输出长度会显著增加,难以适配 Guid 格式。AES GCM 在提供加密功能的同时,还提供了数据完整性校验功能,这是通过附加一个认证标签(Authentication Tag,简称 Tag)来实现的。这个 Tag 通常是 12 到 16 字节,用于验证数据的完整性和真实性,防止数据被篡改。除了 Tag 之外,AES GCM 还需要一个显式的 nonce 作为输入。对于我们来说,nonce 至少需要 12 字节才能保证足够的安全性。
这意味着,如果我们使用 AES GCM 加密一个 4 字节的 int32 ID,最终的输出长度将至少是:4 字节(密文) + 12 字节(nonce) + 12 字节(最小 Tag 大小) = 28 字节。即使我们加密一个 8 字节的 int64 ID,输出长度也会超过 32 字节。这样的长度,无论如何都无法直接塞到一个 16 字节的 Guid 中。而且,为了将 nonce 和 tag 都塞进去,我们势必需要设计更复杂的序列化方案,这会增加前端和后端的处理复杂度,也可能导致 ID 格式的不统一,例如一部分 ID 是 Guid,一部分是更长的 Base64 编码字符串,这会给前端开发带来额外的困扰。
我们之所以最终选择将加密后的 ID 展示为 Guid 格式,一个重要的考量就是希望保持 ID 的统一性和简洁性。Guid 作为一个 16 字节的固定长度标识符,在很多场景下都非常方便使用和处理。如果我们为了追求 AES GCM 的"更高安全性"而牺牲了 ID 的简洁性和统一性,反而可能会得不偿失。
最后,AES GCM 的额外安全优势在我们的应用场景下并非不可或缺。AES GCM 最主要的优势在于它提供的认证加密(Authenticated Encryption)功能,即在加密的同时,也保证了数据的完整性和真实性。这意味着,如果数据在传输过程中被篡改,解密时会立即发现并报错。这种认证功能对于一些对数据完整性要求极高的场景非常重要,例如金融交易、电子签名等。
然而,在我们的 Chats 应用中,我们对 ID 的安全性需求主要集中在防止恶意猜测和未经授权的访问,而不是防止数据篡改。即使加密后的 ID 在传输过程中被篡改,最终解密出来的 ID 也大概率无法在数据库中找到对应的记录,或者即使找到了,后续的业务逻辑也会进行权限验证,确保用户只能访问自己拥有的聊天或消息。
更重要的是,即使我们使用 AES CBC,也并非完全没有数据完整性验证机制。首先,AES CBC 配合填充模式(例如 PKCS7 Padding)本身就提供了一定程度的完整性校验。对于 int32 类型的 ID,AES CBC 加密后会生成 16 字节的密文,其中有 12 字节实际上是填充数据。如果密文被篡改,解密时填充校验会失败,从而可以检测到数据损坏。虽然这种校验强度不如 AES GCM 的 Tag 那么高,但也足以应对一般的篡改尝试。
其次,在我们的系统中,解密后的 ID 最终会用于数据库查询。即使攻击者能够绕过 AES CBC 的填充校验,篡改了加密后的 ID,解密出来的错误 ID 在数据库中大概率也找不到对应的记录。即使碰巧找到了记录,我们也会在数据库层面和业务逻辑层面进行多重权限验证,确保数据的安全性。
因此,综合考虑以上三点,我们最终权衡之后,仍然选择了 AES CBC 算法。它在保证足够安全性的前提下,能够生成 16 字节的密文,完美适配 Guid 格式,并且实现相对简单,性能也更优。当然,技术选型永远是一个不断演进的过程,未来如果我们的安全需求发生变化,或者 AES GCM 在性能和易用性方面有了新的提升,我们也不排除会重新评估并切换到 AES GCM 的可能性。
总结与展望
回顾 Sdcb Chats 项目 ID 演进的四个阶段,从最初拥抱 Guid 的"一步到位",到为了性能考量转向自增 ID,再到为了安全和体验在界面上加密 ID,最终又回归到使用 Guid 形式展示,这的确是一段曲折而又充满思考的旅程。
在这个过程中,我们不断地在性能、安全性、用户体验和开发效率之间权衡取舍。没有一劳永逸的完美方案,只有在持续迭代和演进中,才能找到最适合当前阶段的最佳实践。每一次看似"倒退"的改动,实际上都基于更深入的理解和更全面的考量。例如,从 Guid 到自增 ID 的转变,是为了解决实际存在的数据库性能瓶颈;而界面上从加密 ID 到 Guid 的回归,则是在安全性得到保障的前提下,更好地满足用户对"现代感"和"专业性"的用户感知。
这段经历也印证了软件开发中一个重要的理念:没有银弹。技术选型需要结合具体的应用场景和需求,持续地监控和评估,并根据实际情况灵活调整。我们不能因为"大家都说 Guid 好"就盲目跟风,也不能因为"性能至上"就忽略安全性和用户体验。只有深入理解各种方案的优缺点,才能做出最明智的选择。
而 Sdcb Chats 项目的 ID 演进之路,也正是开源项目不断迭代、持续进化的一个缩影。我们始终秉持着开放、务实的态度,积极拥抱变化,勇于尝试新的技术方案,并不断地从实践中总结经验教训。
如果您对我们这曲折的 ID 选型故事,以及 Sdcb Chats 项目本身感兴趣,欢迎继续了解!
Sdcb Chats:一个强大的开源 ChatGPT 前端
我是开源项目 Sdcb Chats 的作者。Sdcb Chats 定位为一个强大且易于部署的 ChatGPT 前端,旨在帮助用户轻松接入和管理各种主流的大语言模型。
Sdcb Chats 的主要特性包括:
- 广泛的大语言模型支持: 目前已支持 15 种不同的大语言模型提供商。只需配置简单的 API Key 等连接信息,即可无缝切换和体验来自不同厂商的强大 AI 能力。
- 灵活的数据库选择: 支持 SQLite 、SQL Server 和 PostgreSQL 三种数据库,您可以根据自身需求和环境选择最合适的数据库方案。
- 多样化的部署方式: 提供 Docker 镜像 部署方式,方便快捷地在各种容器环境中部署;同时提供多种操作系统的 二进制文件 下载,无需复杂的编译过程,即可快速启动和使用。
- 完善的管理功能: 内置 多用户管理 功能,方便团队协作使用;提供 Token 消耗统计 和 付费管理 功能,帮助您更好地控制和管理大语言模型的使用成本。
无论您是个人开发者、技术爱好者,还是企业用户,Sdcb Chats 都能为您提供一个强大、灵活、易用的 ChatGPT 前端解决方案。
如果您觉得 Sdcb Chats 对您有所帮助,或者您认同我们的技术理念和开源精神,请在 GitHub 上给我们一个 Star ✨。您的支持是我们持续前进的最大动力!
GitHub 仓库地址: https://github.com/sdcb/chats
希望这篇博客和项目介绍能帮助您对 Sdcb Chats 项目有更深入的了解。期待您的关注和参与,让我们一起打造更优秀的开源项目!