a158 2 minggu lalu
induk
melakukan
5e8d9c7723
69 mengubah file dengan 6745 tambahan dan 428 penghapusan
  1. 4 0
      .gitignore
  2. 2 0
      Client/Client.vcxproj
  3. 6 0
      Client/Client.vcxproj.filters
  4. TEMPAT SAMPAH
      Client/NIM-cache/cacheIndex.txt
  5. TEMPAT SAMPAH
      Client/NIM-cache/part2/NIM1.cab
  6. TEMPAT SAMPAH
      Client/NIM-cache/part2/output-info.ini
  7. 3 3
      Client/NIM.aip
  8. 156 23
      Client/src/chat/AvatarCache.cpp
  9. 12 3
      Client/src/chat/AvatarCache.h
  10. 3 0
      Client/src/common/AppConfig.cpp
  11. 6 0
      Client/src/common/AppConfig.h
  12. 2 2
      Client/src/common/ClientConfig.h
  13. 121 10
      Client/src/common/LocalStore.cpp
  14. 9 0
      Client/src/common/LocalStore.h
  15. 21 0
      Client/src/common/UiHelpers.cpp
  16. 1 0
      Client/src/common/UiHelpers.h
  17. 137 2
      Client/src/mainframe/AddFriendDialog.cpp
  18. 7 0
      Client/src/mainframe/AddFriendDialog.h
  19. 51 6
      Client/src/mainframe/Client.cpp
  20. 48 19
      Client/src/mainframe/Client.h
  21. 508 96
      Client/src/mainframe/ClientChat.cpp
  22. 346 73
      Client/src/mainframe/ClientConv.cpp
  23. 1 0
      Client/src/mainframe/ClientImpl.h
  24. 48 2
      Client/src/mainframe/ClientInternal.h
  25. 141 50
      Client/src/mainframe/ClientSend.cpp
  26. 840 0
      Client/src/mainframe/GroupProfileDialog.cpp
  27. 81 0
      Client/src/mainframe/GroupProfileDialog.h
  28. 67 9
      Client/src/mainframe/PickFriendDialog.cpp
  29. 6 1
      Client/src/mainframe/PickFriendDialog.h
  30. 199 5
      Client/src/network/ImClient.cpp
  31. 27 0
      Client/src/network/ImClient.h
  32. 30 0
      common/i18n.h
  33. 54 0
      common/protocol_def.h
  34. 1 0
      docs/README.md
  35. 173 11
      docs/protocol.md
  36. 1 0
      server/CMakeLists.txt
  37. 25 0
      server/config/config.ini.example
  38. 64 0
      server/sql/init.sql
  39. 66 0
      server/sql/migrate.sql
  40. 26 1
      server/src/app/application.cpp
  41. 2 0
      server/src/app/application.h
  42. 19 0
      server/src/config/app_config.cpp
  43. 22 0
      server/src/config/app_config.h
  44. 939 0
      server/src/db/group_store.cpp
  45. 115 0
      server/src/db/group_store.h
  46. 58 5
      server/src/db/mysql_service.cpp
  47. 4 2
      server/src/db/mysql_service.h
  48. 356 0
      server/src/db/redis_service.cpp
  49. 65 0
      server/src/db/redis_service.h
  50. 24 8
      server/src/im/dispatcher.cpp
  51. 4 2
      server/src/im/dispatcher.h
  52. 18 2
      server/src/im/handler.cpp
  53. 2 0
      server/src/im/handler.h
  54. 43 6
      server/src/im/handlers/account_handler.cpp
  55. 21 27
      server/src/im/handlers/friend_handler.cpp
  56. 912 0
      server/src/im/handlers/group_handler.cpp
  57. 40 0
      server/src/im/handlers/group_handler.h
  58. 314 19
      server/src/im/handlers/message_handler.cpp
  59. 1 0
      server/src/im/handlers/message_handler.h
  60. 305 21
      server/src/net/im_server.cpp
  61. 31 1
      server/src/net/im_server.h
  62. 20 5
      server/src/net/protocol.cpp
  63. 4 1
      server/src/net/protocol.h
  64. 70 1
      server/src/net/session.cpp
  65. 35 10
      server/src/net/session.h
  66. 20 2
      server/src/net/session_mgr.cpp
  67. 2 0
      server/src/net/session_mgr.h
  68. 5 0
      server/src/third/cos.cpp
  69. 1 0
      server/src/third/cos.h

+ 4 - 0
.gitignore

@@ -1,3 +1,5 @@
+
+
 # IDE / editor
 .vs/
 .vscode/
@@ -46,3 +48,5 @@ server/data/
 # Secrets and local user state
 server/config/config.ini
 **/user.ini
+Client/NIM-cache/*
+Client/NIM-SetupFiles/*

+ 2 - 0
Client/Client.vcxproj

@@ -149,6 +149,7 @@ if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7
     <QtMoc Include="src\chat\FileCache.h" />
     <QtMoc Include="src\chat\StickerPanel.h" />
     <QtMoc Include="src\mainframe\FavoritePanel.h" />
+    <QtMoc Include="src\mainframe\GroupProfileDialog.h" />
     <ClInclude Include="src\common\AppTheme.h" />
     <ClInclude Include="src\common\UiHelpers.h" />
     <ClInclude Include="src\common\AppConfig.h" />
@@ -195,6 +196,7 @@ if exist "$(ProjectDir)3rdparty\7z\x64\7za.exe" copy /Y "$(ProjectDir)3rdparty\7
     <ClCompile Include="src\mainframe\ClientSession.cpp" />
     <ClCompile Include="src\mainframe\SettingsDialog.cpp" />
     <ClCompile Include="src\mainframe\PickFriendDialog.cpp" />
+    <ClCompile Include="src\mainframe\GroupProfileDialog.cpp" />
     <ClCompile Include="src\mainframe\AddFriendDialog.cpp" />
     <ClCompile Include="src\mainframe\FriendRequestDialog.cpp" />
     <ClCompile Include="src\mainframe\FavoritePanel.cpp" />

+ 6 - 0
Client/Client.vcxproj.filters

@@ -93,6 +93,12 @@
     <QtMoc Include="src\mainframe\FavoritePanel.h">
       <Filter>src\mainframe</Filter>
     </QtMoc>
+    <QtMoc Include="src\mainframe\GroupProfileDialog.h">
+      <Filter>src\mainframe</Filter>
+    </QtMoc>
+    <ClCompile Include="src\mainframe\GroupProfileDialog.cpp">
+      <Filter>src\mainframe</Filter>
+    </ClCompile>
     <ClCompile Include="src\mainframe\FavoritePanel.cpp">
       <Filter>src\mainframe</Filter>
     </ClCompile>

TEMPAT SAMPAH
Client/NIM-cache/cacheIndex.txt


TEMPAT SAMPAH
Client/NIM-cache/part2/NIM1.cab


TEMPAT SAMPAH
Client/NIM-cache/part2/output-info.ini


+ 3 - 3
Client/NIM.aip

@@ -15,10 +15,10 @@
     <ROW Property="ARPPRODUCTICON" Value="app.exe" Type="8"/>
     <ROW Property="ARPSYSTEMCOMPONENT" Value="1"/>
     <ROW Property="Manufacturer" Value="NIM"/>
-    <ROW Property="ProductCode" Value="2052:{301E1BD3-81F4-41C2-B761-ADCFBE42D684} " Type="16"/>
+    <ROW Property="ProductCode" Value="2052:{4A88B0D1-10B6-4519-B5CE-D02CE2A5175A} " Type="16"/>
     <ROW Property="ProductLanguage" Value="1033"/>
     <ROW Property="ProductName" Value="NIM"/>
-    <ROW Property="ProductVersion" Value="1.0.0" Options="32"/>
+    <ROW Property="ProductVersion" Value="1.0.1" Options="32"/>
     <ROW Property="SecureCustomProperties" Value="OLDPRODUCTS;AI_NEWERPRODUCTFOUND;AI_SETUPEXEPATH;SETUPEXEDIR"/>
     <ROW Property="UpgradeCode" Value="{51BDF350-8DE6-4E99-82DF-C18E41DC3A55}"/>
     <ROW Property="WindowsType9X" MultiBuildValue="DefaultBuild:Windows 9x/ME" ValueLocId="-"/>
@@ -52,7 +52,7 @@
     <ROW Component="ProductInformation" Value="{A2129477-7040-4D1C-ACCD-1BDC2D26D619}"/>
   </COMPONENT>
   <COMPONENT cid="caphyon.advinst.msicomp.MsiCompsComponent">
-    <ROW Component="AI_CustomARPName" ComponentId="{381AD665-14B1-4D2F-A20C-C4D23AAF7B55}" Directory_="APPDIR" Attributes="4" KeyPath="DisplayName" Options="1"/>
+    <ROW Component="AI_CustomARPName" ComponentId="{42814CD5-5013-4C0F-B80B-5FA2D3B3D59C}" Directory_="APPDIR" Attributes="4" KeyPath="DisplayName" Options="1"/>
     <ROW Component="AI_ExePath" ComponentId="{7BAECFF1-5B36-41B6-B09B-B1AFD6AF570C}" Directory_="APPDIR" Attributes="4" KeyPath="AI_ExePath"/>
     <ROW Component="APPDIR" ComponentId="{17EB4A42-2A9A-4678-8913-45C3FED5114C}" Directory_="APPDIR" Attributes="0"/>
     <ROW Component="D3Dcompiler_47.dll" ComponentId="{5BAF1A44-F31B-4A04-B43E-E36EC232E41A}" Directory_="APPDIR" Attributes="256" KeyPath="D3Dcompiler_47.dll"/>

+ 156 - 23
Client/src/chat/AvatarCache.cpp

@@ -30,19 +30,23 @@ QString AvatarCache::cacheDir() const
     return LocalStore::userSubdir(ImClient::instance().uid(), QStringLiteral("avatars"));
 }
 
-QString AvatarCache::filePath(qint64 uid, int ver) const
+QString AvatarCache::filePath(qint64 uid, int ver, bool group) const
 {
     const QString dir = cacheDir();
     if (dir.isEmpty())
         return {};
+    if (group)
+        return dir + QStringLiteral("/g_%1_%2.jpg").arg(uid).arg(ver);
     return dir + QStringLiteral("/%1_%2.jpg").arg(uid).arg(ver);
 }
 
-int AvatarCache::diskVer(qint64 uid) const
+int AvatarCache::diskVer(qint64 uid, bool group) const
 {
     QDir dir(cacheDir());
     int best = 0;
-    const QString prefix = QString::number(uid) + QLatin1Char('_');
+    const QString prefix = group
+        ? (QStringLiteral("g_") + QString::number(uid) + QLatin1Char('_'))
+        : (QString::number(uid) + QLatin1Char('_'));
     const QStringList files = dir.entryList(QStringList() << (prefix + QStringLiteral("*.jpg")), QDir::Files);
     for (const QString &f : files)
     {
@@ -58,14 +62,15 @@ int AvatarCache::diskVer(qint64 uid) const
     return best;
 }
 
-int AvatarCache::resolveVer(qint64 uid, int ver)
+int AvatarCache::resolveVer(qint64 uid, int ver, bool group)
 {
-    int best = m_ver.value(uid, 0);
+    auto &map = group ? m_groupVer : m_ver;
+    int best = map.value(uid, 0);
     if (best <= 0)
     {
-        best = diskVer(uid);
+        best = diskVer(uid, group);
         if (best > 0)
-            m_ver.insert(uid, best);
+            map.insert(uid, best);
     }
     if (ver > best)
         return ver;
@@ -77,9 +82,10 @@ int AvatarCache::resolveVer(qint64 uid, int ver)
 void AvatarCache::invalidate(qint64 uid)
 {
     const QString prefix = QString::number(uid) + QLatin1Char('_');
+    const QString keyPref = QString::number(uid) + QLatin1Char('/');
     for (auto it = m_pix.begin(); it != m_pix.end(); )
     {
-        if (it.key().startsWith(prefix) || it.key().startsWith(QString::number(uid) + QLatin1Char('/')))
+        if (it.key().startsWith(prefix) || it.key().startsWith(keyPref))
             it = m_pix.erase(it);
         else
             ++it;
@@ -87,11 +93,25 @@ void AvatarCache::invalidate(qint64 uid)
     m_ver.remove(uid);
 }
 
+void AvatarCache::invalidateGroup(qint64 groupId)
+{
+    const QString prefix = QStringLiteral("g_") + QString::number(groupId) + QLatin1Char('_');
+    const QString keyPref = QStringLiteral("g/") + QString::number(groupId) + QLatin1Char('/');
+    for (auto it = m_pix.begin(); it != m_pix.end(); )
+    {
+        if (it.key().startsWith(prefix) || it.key().startsWith(keyPref))
+            it = m_pix.erase(it);
+        else
+            ++it;
+    }
+    m_groupVer.remove(groupId);
+}
+
 void AvatarCache::remember(qint64 uid, int ver, const QByteArray &bytes)
 {
     if (uid <= 0 || ver <= 0 || bytes.isEmpty())
         return;
-    const QString path = filePath(uid, ver);
+    const QString path = filePath(uid, ver, false);
     QDir().mkpath(QFileInfo(path).absolutePath());
     QFile f(path);
     if (f.open(QIODevice::WriteOnly))
@@ -105,16 +125,34 @@ void AvatarCache::remember(qint64 uid, int ver, const QByteArray &bytes)
     emit updated(uid);
 }
 
+void AvatarCache::rememberGroup(qint64 groupId, int ver, const QByteArray &bytes)
+{
+    if (groupId <= 0 || ver <= 0 || bytes.isEmpty())
+        return;
+    const QString path = filePath(groupId, ver, true);
+    QDir().mkpath(QFileInfo(path).absolutePath());
+    QFile f(path);
+    if (f.open(QIODevice::WriteOnly))
+    {
+        f.write(bytes);
+        f.flush();
+        f.close();
+    }
+    invalidateGroup(groupId);
+    m_groupVer.insert(groupId, ver);
+    emit groupUpdated(groupId);
+}
+
 void AvatarCache::ensure(qint64 uid, int ver)
 {
     if (uid <= 0 || ver <= 0 || m_fetching.contains(uid))
         return;
     int have = m_ver.value(uid, 0);
     if (have <= 0)
-        have = diskVer(uid);
-    if (have >= ver && QFile::exists(filePath(uid, have)))
+        have = diskVer(uid, false);
+    if (have >= ver && QFile::exists(filePath(uid, have, false)))
         return;
-    if (QFile::exists(filePath(uid, ver)))
+    if (QFile::exists(filePath(uid, ver, false)))
         return;
     m_fetching.insert(uid);
     QPointer<AvatarCache> self(this);
@@ -162,29 +200,70 @@ void AvatarCache::ensure(qint64 uid, int ver)
     });
 }
 
-void AvatarCache::probe(qint64 uid)
+void AvatarCache::ensureGroup(qint64 groupId, int ver)
 {
-    if (uid <= 0 || m_probing.contains(uid) || m_fetching.contains(uid))
+    if (groupId <= 0 || ver <= 0 || m_groupFetching.contains(groupId))
         return;
-    if (resolveVer(uid, 0) > 0)
+    int have = m_groupVer.value(groupId, 0);
+    if (have <= 0)
+        have = diskVer(groupId, true);
+    if (have >= ver && QFile::exists(filePath(groupId, have, true)))
         return;
-    m_probing.insert(uid);
+    if (QFile::exists(filePath(groupId, ver, true)))
+        return;
+    m_groupFetching.insert(groupId);
     QPointer<AvatarCache> self(this);
-    ImClient::instance().getProfile(uid, [self, uid](const QJsonObject &reply) {
+    ImClient::instance().getGroupAvatar(groupId, [self, groupId, ver](const QJsonObject &reply) {
         if (!self)
             return;
-        self->m_probing.remove(uid);
         if (!ImClient::ok(reply))
+        {
+            self->m_groupFetching.remove(groupId);
             return;
-        const int ver = static_cast<int>(Proto::jsonI64(reply, QStringLiteral("avatar_ver")));
-        if (ver <= 0)
+        }
+        int got = static_cast<int>(Proto::jsonI64(reply, QStringLiteral("ver")));
+        if (got <= 0)
+            got = static_cast<int>(Proto::jsonI64(reply, QStringLiteral("avatar_ver")));
+        if (got <= 0)
+            got = ver;
+        const QString url = reply.value(QStringLiteral("url")).toString();
+        auto finish = [self, groupId]() {
+            if (self)
+                self->m_groupFetching.remove(groupId);
+        };
+        if (!url.isEmpty())
+        {
+            const QString http = AppConfig::instance().resourceUrl(url);
+            if (http.isEmpty())
+            {
+                finish();
+                return;
+            }
+            HttpFetch::instance().get(http, [self, groupId, got, finish](bool ok, const QByteArray &bytes, const QString &) {
+                if (self && ok && !bytes.isEmpty() && got > 0)
+                    self->rememberGroup(groupId, got, bytes);
+                finish();
+            });
             return;
-        self->m_ver.insert(uid, ver);
-        self->ensure(uid, ver);
-        emit self->updated(uid);
+        }
+        const QByteArray bytes = QByteArray::fromBase64(
+            reply.value(QStringLiteral("data")).toString().toLatin1());
+        if (!bytes.isEmpty() && got > 0)
+            self->rememberGroup(groupId, got, bytes);
+        finish();
     });
 }
 
+void AvatarCache::probe(qint64 uid)
+{
+    if (uid <= 0 || m_probing.contains(uid) || m_fetching.contains(uid))
+        return;
+    if (resolveVer(uid, 0) > 0)
+        return;
+    m_probing.insert(uid);
+    ensure(uid, 1);
+}
+
 void AvatarCache::applyTo(QLabel *label, qint64 uid, int ver, const QString &name, int size)
 {
     if (!label)
@@ -239,3 +318,57 @@ QPixmap AvatarCache::pixmap(qint64 uid, int ver, const QString &name, int size)
     m_ver.insert(uid, ver);
     return rounded;
 }
+
+QPixmap AvatarCache::groupPixmap(qint64 groupId, int ver, const QString &name, int size)
+{
+    Q_UNUSED(name);
+    ver = resolveVer(groupId, ver, true);
+    if (groupId <= 0 || ver <= 0)
+        return UiHelpers::groupIconPixmap(size);
+    const QString key = QStringLiteral("g/%1/%2/%3").arg(groupId).arg(ver).arg(size);
+    if (m_pix.contains(key))
+        return m_pix.value(key);
+    const QString path = filePath(groupId, ver, true);
+    QPixmap disk(path);
+    if (disk.isNull())
+    {
+        QFile f(path);
+        if (f.open(QIODevice::ReadOnly))
+            disk.loadFromData(f.readAll(), "JPEG");
+    }
+    if (disk.isNull())
+    {
+        ensureGroup(groupId, ver);
+        return UiHelpers::groupIconPixmap(size);
+    }
+    const QPixmap rounded = UiHelpers::roundAvatar(disk, size);
+    m_pix.insert(key, rounded);
+    m_groupVer.insert(groupId, ver);
+    return rounded;
+}
+
+void AvatarCache::applyToGroup(QLabel *label, qint64 groupId, int ver, const QString &name, int size)
+{
+    if (!label)
+        return;
+    label->setAlignment(Qt::AlignCenter);
+    label->setProperty("groupAvatarId", QVariant::fromValue(groupId));
+    label->setProperty("groupAvatarVer", resolveVer(groupId, ver, true));
+    label->setProperty("groupAvatarName", name);
+    label->setProperty("groupAvatarSize", size);
+    if (!label->property("groupAvatarBound").toBool())
+    {
+        label->setProperty("groupAvatarBound", true);
+        connect(this, &AvatarCache::groupUpdated, label, [this, label](qint64 id) {
+            if (!label || id != label->property("groupAvatarId").toLongLong())
+                return;
+            const qint64 gid = label->property("groupAvatarId").toLongLong();
+            const int v = resolveVer(gid, label->property("groupAvatarVer").toInt(), true);
+            label->setProperty("groupAvatarVer", v);
+            const QString n = label->property("groupAvatarName").toString();
+            const int s = label->property("groupAvatarSize").toInt();
+            label->setPixmap(groupPixmap(gid, v, n, s));
+        });
+    }
+    label->setPixmap(groupPixmap(groupId, ver, name, size));
+}

+ 12 - 3
Client/src/chat/AvatarCache.h

@@ -16,25 +16,34 @@ public:
     static AvatarCache &instance();
 
     QPixmap pixmap(qint64 uid, int ver, const QString &name, int size);
+    QPixmap groupPixmap(qint64 groupId, int ver, const QString &name, int size);
     void applyTo(QLabel *label, qint64 uid, int ver, const QString &name, int size);
+    void applyToGroup(QLabel *label, qint64 groupId, int ver, const QString &name, int size);
     void invalidate(qint64 uid);
+    void invalidateGroup(qint64 groupId);
     void remember(qint64 uid, int ver, const QByteArray &bytes);
+    void rememberGroup(qint64 groupId, int ver, const QByteArray &bytes);
     int knownVer(qint64 uid) const { return m_ver.value(uid, 0); }
+    int knownGroupVer(qint64 groupId) const { return m_groupVer.value(groupId, 0); }
 
 signals:
     void updated(qint64 uid);
+    void groupUpdated(qint64 groupId);
 
 private:
     explicit AvatarCache(QObject *parent = nullptr);
     QString cacheDir() const;
-    QString filePath(qint64 uid, int ver) const;
-    int diskVer(qint64 uid) const;
-    int resolveVer(qint64 uid, int ver);
+    QString filePath(qint64 uid, int ver, bool group = false) const;
+    int diskVer(qint64 uid, bool group = false) const;
+    int resolveVer(qint64 uid, int ver, bool group = false);
     void ensure(qint64 uid, int ver);
+    void ensureGroup(qint64 groupId, int ver);
     void probe(qint64 uid);
 
     QHash<QString, QPixmap> m_pix;
     QSet<qint64> m_fetching;
+    QSet<qint64> m_groupFetching;
     QSet<qint64> m_probing;
     QHash<qint64, int> m_ver;
+    QHash<qint64, int> m_groupVer;
 };

+ 3 - 0
Client/src/common/AppConfig.cpp

@@ -62,6 +62,9 @@ void AppConfig::applyServerLimits(const QJsonObject &limits)
     m_codeLen = take(limits, QStringLiteral("code_len"));
     m_avatarMax = take(limits, QStringLiteral("avatar_max"));
     m_fileMax = take(limits, QStringLiteral("file_max"));
+    m_groupMaxMembers = take(limits, QStringLiteral("group_max_members"));
+    m_groupNameMax = take(limits, QStringLiteral("group_name_max"));
+    m_groupMaxAdmins = take(limits, QStringLiteral("group_max_admins"));
     m_maxPacket = static_cast<quint32>(qMax(0, take(limits, QStringLiteral("max_packet"))));
     m_heartbeatSec = take(limits, QStringLiteral("heartbeat_sec"));
     m_smsIntervalSec = take(limits, QStringLiteral("interval_sec"));

+ 6 - 0
Client/src/common/AppConfig.h

@@ -41,6 +41,9 @@ public:
     int codeLen() const { return m_codeLen; }
     int avatarMax() const { return m_avatarMax; }
     int fileMax() const { return m_fileMax; }
+    int groupMaxMembers() const { return m_groupMaxMembers; }
+    int groupNameMax() const { return m_groupNameMax; }
+    int groupMaxAdmins() const { return m_groupMaxAdmins; }
     QString cdnDomain() const { return m_cdnDomain; }
     QString updateVersion() const { return m_updateVersion; }
     QString updateUrl() const { return m_updateUrl; }
@@ -78,6 +81,9 @@ private:
     int m_codeLen = 0;
     int m_avatarMax = 0;
     int m_fileMax = 0;
+    int m_groupMaxMembers = 200;
+    int m_groupNameMax = 32;
+    int m_groupMaxAdmins = 10;
     QString m_cdnDomain;
     QString m_updateVersion;
     QString m_updateUrl;

+ 2 - 2
Client/src/common/ClientConfig.h

@@ -6,8 +6,8 @@
 
 namespace ClientConfig
 {
-constexpr const char *kHost = "49.232.135.144";
-//constexpr const char* kHost = "192.168.94.166";
+//constexpr const char *kHost = "49.232.135.144";
+constexpr const char* kHost = "192.168.94.166";
 constexpr uint16_t kPort = 6000;
 constexpr uint16_t kMagic = 0x494D;
 constexpr bool kLogEnabled = true;

+ 121 - 10
Client/src/common/LocalStore.cpp

@@ -151,9 +151,51 @@ bool LocalStore::ensureSchema()
         return false;
     ensureColumn(QStringLiteral("message"), QStringLiteral("status"),
                  QStringLiteral("INTEGER NOT NULL DEFAULT 0"));
+    ensureColumn(QStringLiteral("message"), QStringLiteral("conv_type"),
+                 QStringLiteral("INTEGER NOT NULL DEFAULT 0"));
+    ensureColumn(QStringLiteral("message"), QStringLiteral("from_name"),
+                 QStringLiteral("TEXT NOT NULL DEFAULT ''"));
+    ensureColumn(QStringLiteral("conversation"), QStringLiteral("conv_type"),
+                 QStringLiteral("INTEGER NOT NULL DEFAULT 0"));
+    migrateConversationKey();
+    exec(QStringLiteral("CREATE INDEX IF NOT EXISTS idx_message_conv ON message(conv_type, peer_uid, id)"));
     return true;
 }
 
+void LocalStore::migrateConversationKey()
+{
+    if (!isOpen())
+        return;
+    QSqlQuery q(m_db);
+    if (!q.exec(QStringLiteral("PRAGMA table_info(conversation)")))
+        return;
+    int pkMax = 0;
+    bool hasConvType = false;
+    while (q.next())
+    {
+        if (q.value(1).toString() == QStringLiteral("conv_type"))
+            hasConvType = true;
+        pkMax = qMax(pkMax, q.value(5).toInt());
+    }
+    if (hasConvType && pkMax >= 2)
+        return;
+    if (!exec(QStringLiteral(
+            "CREATE TABLE IF NOT EXISTS conversation_v2 ("
+            " conv_type INTEGER NOT NULL DEFAULT 0,"
+            " peer_uid INTEGER NOT NULL,"
+            " name TEXT NOT NULL DEFAULT '',"
+            " preview TEXT NOT NULL DEFAULT '',"
+            " ts INTEGER NOT NULL DEFAULT 0,"
+            " unread INTEGER NOT NULL DEFAULT 0,"
+            " PRIMARY KEY (conv_type, peer_uid))")))
+        return;
+    exec(QStringLiteral(
+        "INSERT OR IGNORE INTO conversation_v2 (conv_type, peer_uid, name, preview, ts, unread) "
+        "SELECT conv_type, peer_uid, name, preview, ts, unread FROM conversation"));
+    exec(QStringLiteral("DROP TABLE conversation"));
+    exec(QStringLiteral("ALTER TABLE conversation_v2 RENAME TO conversation"));
+}
+
 qint64 LocalStore::allocLocalId()
 {
     qint64 id = -QDateTime::currentMSecsSinceEpoch();
@@ -178,8 +220,8 @@ bool LocalStore::upsertMessage(const Msg &msg)
     QSqlQuery q(m_db);
     q.prepare(QStringLiteral(
         "INSERT OR REPLACE INTO message "
-        "(id, peer_uid, from_uid, to_uid, msg_type, content, ts, status) "
-        "VALUES (?, ?, ?, ?, ?, ?, ?, ?)"));
+        "(id, peer_uid, from_uid, to_uid, msg_type, content, ts, status, conv_type, from_name) "
+        "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"));
     q.addBindValue(msg.id);
     q.addBindValue(msg.peerUid);
     q.addBindValue(msg.fromUid);
@@ -188,6 +230,8 @@ bool LocalStore::upsertMessage(const Msg &msg)
     q.addBindValue(msg.content);
     q.addBindValue(msg.ts);
     q.addBindValue(msg.status);
+    q.addBindValue(msg.convType);
+    q.addBindValue(msg.fromName);
     if (!q.exec())
     {
         logSql(q);
@@ -212,6 +256,11 @@ bool LocalStore::removeMessage(qint64 id)
 }
 
 QVector<LocalStore::Msg> LocalStore::listMessages(qint64 peerUid, int limit) const
+{
+    return listMessages(peerUid, 0, limit);
+}
+
+QVector<LocalStore::Msg> LocalStore::listMessages(qint64 peerUid, int convType, int limit) const
 {
     QVector<Msg> out;
     if (!isOpen() || peerUid <= 0)
@@ -220,9 +269,54 @@ QVector<LocalStore::Msg> LocalStore::listMessages(qint64 peerUid, int limit) con
         limit = 200;
     QSqlQuery q(m_db);
     q.prepare(QStringLiteral(
-        "SELECT id, peer_uid, from_uid, to_uid, msg_type, content, ts, status "
-        "FROM message WHERE peer_uid=? ORDER BY ts DESC, id DESC LIMIT ?"));
+        "SELECT id, peer_uid, from_uid, to_uid, msg_type, content, ts, status, conv_type, from_name "
+        "FROM message WHERE peer_uid=? AND conv_type=? ORDER BY ts DESC, id DESC LIMIT ?"));
+    q.addBindValue(peerUid);
+    q.addBindValue(convType);
+    q.addBindValue(limit);
+    if (!q.exec())
+    {
+        logSql(q);
+        return out;
+    }
+    while (q.next())
+    {
+        Msg m;
+        m.id = q.value(0).toLongLong();
+        m.peerUid = q.value(1).toLongLong();
+        m.fromUid = q.value(2).toLongLong();
+        m.toUid = q.value(3).toLongLong();
+        m.msgType = q.value(4).toInt();
+        m.content = q.value(5).toString();
+        m.ts = q.value(6).toLongLong();
+        m.status = q.value(7).toInt();
+        m.convType = q.value(8).toInt();
+        m.fromName = q.value(9).toString();
+        out.push_back(m);
+    }
+    std::reverse(out.begin(), out.end());
+    return out;
+}
+
+QVector<LocalStore::Msg> LocalStore::listMessagesBefore(qint64 peerUid, qint64 beforeId, int limit) const
+{
+    return listMessagesBefore(peerUid, 0, beforeId, limit);
+}
+
+QVector<LocalStore::Msg> LocalStore::listMessagesBefore(qint64 peerUid, int convType, qint64 beforeId, int limit) const
+{
+    QVector<Msg> out;
+    if (!isOpen() || peerUid <= 0 || beforeId <= 0)
+        return out;
+    if (limit <= 0)
+        limit = 20;
+    QSqlQuery q(m_db);
+    q.prepare(QStringLiteral(
+        "SELECT id, peer_uid, from_uid, to_uid, msg_type, content, ts, status, conv_type, from_name "
+        "FROM message WHERE peer_uid=? AND conv_type=? AND id>0 AND id<? ORDER BY id DESC LIMIT ?"));
     q.addBindValue(peerUid);
+    q.addBindValue(convType);
+    q.addBindValue(beforeId);
     q.addBindValue(limit);
     if (!q.exec())
     {
@@ -240,6 +334,8 @@ QVector<LocalStore::Msg> LocalStore::listMessages(qint64 peerUid, int limit) con
         m.content = q.value(5).toString();
         m.ts = q.value(6).toLongLong();
         m.status = q.value(7).toInt();
+        m.convType = q.value(8).toInt();
+        m.fromName = q.value(9).toString();
         out.push_back(m);
     }
     std::reverse(out.begin(), out.end());
@@ -247,19 +343,26 @@ QVector<LocalStore::Msg> LocalStore::listMessages(qint64 peerUid, int limit) con
 }
 
 bool LocalStore::deletePeer(qint64 peerUid)
+{
+    return deletePeer(peerUid, 0);
+}
+
+bool LocalStore::deletePeer(qint64 peerUid, int convType)
 {
     if (!isOpen() || peerUid <= 0)
         return false;
     QSqlQuery q(m_db);
-    q.prepare(QStringLiteral("DELETE FROM message WHERE peer_uid=?"));
+    q.prepare(QStringLiteral("DELETE FROM message WHERE peer_uid=? AND conv_type=?"));
     q.addBindValue(peerUid);
+    q.addBindValue(convType);
     if (!q.exec())
     {
         logSql(q);
         return false;
     }
-    q.prepare(QStringLiteral("DELETE FROM conversation WHERE peer_uid=?"));
+    q.prepare(QStringLiteral("DELETE FROM conversation WHERE peer_uid=? AND conv_type=?"));
     q.addBindValue(peerUid);
+    q.addBindValue(convType);
     if (!q.exec())
     {
         logSql(q);
@@ -274,8 +377,9 @@ bool LocalStore::upsertConversation(const Conv &conv)
         return false;
     QSqlQuery q(m_db);
     q.prepare(QStringLiteral(
-        "INSERT OR REPLACE INTO conversation (peer_uid, name, preview, ts, unread) "
-        "VALUES (?, ?, ?, ?, ?)"));
+        "INSERT OR REPLACE INTO conversation (conv_type, peer_uid, name, preview, ts, unread) "
+        "VALUES (?, ?, ?, ?, ?, ?)"));
+    q.addBindValue(conv.convType);
     q.addBindValue(conv.peerUid);
     q.addBindValue(conv.name);
     q.addBindValue(conv.preview);
@@ -296,7 +400,7 @@ QVector<LocalStore::Conv> LocalStore::listConversations() const
         return out;
     QSqlQuery q(m_db);
     if (!q.exec(QStringLiteral(
-            "SELECT peer_uid, name, preview, ts, unread FROM conversation ORDER BY ts DESC")))
+            "SELECT peer_uid, name, preview, ts, unread, conv_type FROM conversation ORDER BY ts DESC")))
     {
         logSql(q);
         return out;
@@ -309,21 +413,28 @@ QVector<LocalStore::Conv> LocalStore::listConversations() const
         c.preview = q.value(2).toString();
         c.ts = q.value(3).toLongLong();
         c.unread = q.value(4).toInt();
+        c.convType = q.value(5).toInt();
         out.push_back(c);
     }
     return out;
 }
 
 bool LocalStore::setUnread(qint64 peerUid, int unread)
+{
+    return setUnread(peerUid, 0, unread);
+}
+
+bool LocalStore::setUnread(qint64 peerUid, int convType, int unread)
 {
     if (!isOpen() || peerUid <= 0)
         return false;
     if (unread < 0)
         unread = 0;
     QSqlQuery q(m_db);
-    q.prepare(QStringLiteral("UPDATE conversation SET unread=? WHERE peer_uid=?"));
+    q.prepare(QStringLiteral("UPDATE conversation SET unread=? WHERE peer_uid=? AND conv_type=?"));
     q.addBindValue(unread);
     q.addBindValue(peerUid);
+    q.addBindValue(convType);
     if (!q.exec())
     {
         logSql(q);

+ 9 - 0
Client/src/common/LocalStore.h

@@ -19,15 +19,18 @@ public:
         qint64 peerUid = 0;
         qint64 fromUid = 0;
         qint64 toUid = 0;
+        int convType = 0;
         int msgType = 1;
         QString content;
         qint64 ts = 0;
         int status = LocalMsg::Ok;
+        QString fromName;
     };
 
     struct Conv
     {
         qint64 peerUid = 0;
+        int convType = 0;
         QString name;
         QString preview;
         qint64 ts = 0;
@@ -47,17 +50,23 @@ public:
     bool upsertMessage(const Msg &msg);
     bool removeMessage(qint64 id);
     QVector<Msg> listMessages(qint64 peerUid, int limit = 200) const;
+    QVector<Msg> listMessages(qint64 peerUid, int convType, int limit) const;
+    QVector<Msg> listMessagesBefore(qint64 peerUid, qint64 beforeId, int limit) const;
+    QVector<Msg> listMessagesBefore(qint64 peerUid, int convType, qint64 beforeId, int limit) const;
     bool deletePeer(qint64 peerUid);
+    bool deletePeer(qint64 peerUid, int convType);
 
     bool upsertConversation(const Conv &conv);
     QVector<Conv> listConversations() const;
     bool setUnread(qint64 peerUid, int unread);
+    bool setUnread(qint64 peerUid, int convType, int unread);
 
 private:
     LocalStore() = default;
     bool exec(const QString &sql) const;
     bool ensureSchema();
     void ensureColumn(const QString &table, const QString &column, const QString &def);
+    void migrateConversationKey();
 
     QSqlDatabase m_db;
     qint64 m_uid = 0;

+ 21 - 0
Client/src/common/UiHelpers.cpp

@@ -329,6 +329,27 @@ namespace UiHelpers
         return pm;
     }
 
+    QPixmap groupIconPixmap(int size)
+    {
+        const qreal dpr = qApp ? qApp->devicePixelRatio() : 1.0;
+        QPixmap pix(qMax(1, qRound(size * dpr)), qMax(1, qRound(size * dpr)));
+        pix.fill(Qt::transparent);
+        pix.setDevicePixelRatio(dpr);
+        QPainter p(&pix);
+        p.setRenderHint(QPainter::Antialiasing, true);
+        p.scale(dpr, dpr);
+        p.setBrush(QColor("#07C160"));
+        p.setPen(Qt::NoPen);
+        p.drawRoundedRect(QRectF(0, 0, size, size), size * 0.22, size * 0.22);
+        p.setPen(QColor("#FFFFFF"));
+        QFont f = p.font();
+        f.setPixelSize(qMax(10, size * 11 / 20));
+        f.setBold(true);
+        p.setFont(f);
+        p.drawText(QRectF(0, 0, size, size), Qt::AlignCenter, QStringLiteral("群"));
+        return pix;
+    }
+
     QPixmap roundAvatar(const QPixmap &src, int size)
     {
         if (src.isNull() || size <= 0)

+ 1 - 0
Client/src/common/UiHelpers.h

@@ -106,6 +106,7 @@ namespace UiHelpers
     void setAuthHeaderSubtitle(QWidget *header, const QString &text);
 
     QPixmap avatarPixmap(const QString &name, int size, bool square = true);
+    QPixmap groupIconPixmap(int size);
     QPixmap roundAvatar(const QPixmap &src, int size);
     QIcon appIcon();
     QColor colorFromName(const QString &name);

+ 137 - 2
Client/src/mainframe/AddFriendDialog.cpp

@@ -4,6 +4,7 @@
 #include "ImClient.h"
 #include "Protocol.h"
 #include "AvatarCache.h"
+#include "ClientInternal.h"
 #include "common/protocol_def.h"
 
 #include <QJsonObject>
@@ -14,6 +15,8 @@
 #include <QPushButton>
 #include <QVBoxLayout>
 
+using namespace ClientInternal;
+
 AddFriendDialog::AddFriendDialog(QWidget *parent)
     : QDialog(parent)
 {
@@ -40,7 +43,7 @@ AddFriendDialog::AddFriendDialog(QWidget *parent)
 
     auto *row = new QHBoxLayout();
     row->setSpacing(8);
-    m_keyword = UiHelpers::lineEdit(QStringLiteral("输入账号搜索"), page);
+    m_keyword = UiHelpers::lineEdit(QStringLiteral("输入账号 / 手机号 / 群号"), page);
     auto *search = UiHelpers::primaryButton(QStringLiteral("搜索"), page);
     search->setFixedWidth(72);
     search->setMinimumWidth(72);
@@ -110,6 +113,10 @@ AddFriendDialog::AddFriendDialog(QWidget *parent)
 void AddFriendDialog::clearResult()
 {
     m_uid = 0;
+    m_groupId = 0;
+    m_joinMode = 0;
+    m_groupRole = -1;
+    m_groupName.clear();
     m_userAccount.clear();
     m_result->hide();
     m_status->hide();
@@ -167,6 +174,62 @@ void AddFriendDialog::showResult(const QJsonObject &user)
     m_result->show();
 }
 
+void AddFriendDialog::showGroupResult(const QJsonObject &group)
+{
+    m_uid = 0;
+    m_groupId = Proto::jsonI64(group, QStringLiteral("group_id"));
+    m_groupName = group.value(QStringLiteral("name")).toString();
+    if (m_groupName.isEmpty())
+        m_groupName = QStringLiteral("群聊");
+    m_joinMode = group.value(QStringLiteral("join_mode")).toInt();
+    m_groupRole = group.value(QStringLiteral("role")).toInt(-1);
+    const int members = group.value(QStringLiteral("member_count")).toInt();
+    m_name->setText(m_groupName);
+    m_account->setText(QStringLiteral("群号  %1 · %2人").arg(m_groupId).arg(members));
+    const int ver = static_cast<int>(Proto::jsonI64(group, QStringLiteral("avatar_ver")));
+    AvatarCache::instance().applyToGroup(m_avatar, m_groupId, ver, m_groupName, 44);
+
+    QString status;
+    bool canJoin = false;
+    bool needVerify = false;
+    if (m_groupRole >= 0)
+        status = QStringLiteral("你已在该群中");
+    else if (m_joinMode == im::JoinMode::Closed)
+        status = QStringLiteral("该群不允许加入");
+    else if (m_joinMode == im::JoinMode::Approve)
+    {
+        canJoin = true;
+        needVerify = true;
+    }
+    else
+        canJoin = true;
+
+    m_addBtn->setVisible(canJoin || m_groupRole >= 0);
+    m_addBtn->setEnabled(canJoin || m_groupRole >= 0);
+    m_addBtn->setText(m_groupRole >= 0 ? QStringLiteral("发消息")
+                     : (needVerify ? QStringLiteral("申请") : QStringLiteral("加入")));
+    m_verify->setVisible(needVerify);
+    if (canJoin && !needVerify)
+    {
+        m_status->hide();
+        m_status->clear();
+    }
+    else if (needVerify)
+    {
+        m_status->hide();
+        m_status->clear();
+        m_verify->setFocus();
+    }
+    else
+    {
+        m_status->setText(status);
+        m_status->show();
+        if (m_groupRole < 0)
+            m_addBtn->hide();
+    }
+    m_result->show();
+}
+
 void AddFriendDialog::onSearch()
 {
     const QString key = m_keyword->text().trimmed();
@@ -174,10 +237,43 @@ void AddFriendDialog::onSearch()
     clearResult();
     if (key.isEmpty())
     {
-        UiHelpers::setError(m_error, QStringLiteral("请输入账号或手机号"));
+        UiHelpers::setError(m_error, QStringLiteral("请输入账号、手机号或群号"));
         return;
     }
     QPointer<AddFriendDialog> self(this);
+    bool allDigit = !key.isEmpty();
+    for (const QChar &ch : key)
+    {
+        if (!ch.isDigit())
+        {
+            allDigit = false;
+            break;
+        }
+    }
+    if (allDigit)
+    {
+        const qint64 gid = key.toLongLong();
+        ImClient::instance().groupSearch(gid, [self, key](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (ImClient::ok(reply))
+            {
+                self->showGroupResult(reply);
+                return;
+            }
+            ImClient::instance().searchUser(key, [self](const QJsonObject &userReply) {
+                if (!self)
+                    return;
+                if (!ImClient::ok(userReply))
+                {
+                    UiHelpers::setError(self->m_error, ImClient::messageOf(userReply));
+                    return;
+                }
+                self->showResult(userReply);
+            });
+        });
+        return;
+    }
     ImClient::instance().searchUser(key, [self](const QJsonObject &reply) {
         if (!self)
             return;
@@ -192,6 +288,45 @@ void AddFriendDialog::onSearch()
 
 void AddFriendDialog::onAdd()
 {
+    if (m_groupId > 0)
+    {
+        if (m_groupRole >= 0)
+        {
+            accept();
+            return;
+        }
+        m_addBtn->setEnabled(false);
+        const QString message = m_verify->text().trimmed();
+        QPointer<AddFriendDialog> self(this);
+        ImClient::instance().groupJoin(m_groupId, message, [self](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                self->m_addBtn->setEnabled(true);
+                UiHelpers::setError(self->m_error, ImClient::messageOf(reply));
+                return;
+            }
+            self->m_addBtn->hide();
+            self->m_verify->hide();
+            const QString msg = reply.value(QStringLiteral("msg")).toString();
+            if (reply.value(QStringLiteral("joined")).toInt() == 1)
+            {
+                self->m_status->setText(QStringLiteral("已加入群聊"));
+                self->m_groupRole = im::GroupRole::Member;
+                self->m_addBtn->setText(QStringLiteral("发消息"));
+                self->m_addBtn->show();
+                self->m_addBtn->setEnabled(true);
+            }
+            else
+            {
+                self->m_status->setText(msg.isEmpty() ? QStringLiteral("已发送入群申请") : msg);
+            }
+            self->m_status->show();
+            UiHelpers::showToast(self, self->m_status->text());
+        });
+        return;
+    }
     if (m_uid <= 0)
         return;
     m_addBtn->setEnabled(false);

+ 7 - 0
Client/src/mainframe/AddFriendDialog.h

@@ -14,11 +14,14 @@ class AddFriendDialog : public QDialog
 
 public:
     explicit AddFriendDialog(QWidget *parent = nullptr);
+    qint64 joinedGroupId() const { return m_groupRole >= 0 ? m_groupId : 0; }
+    QString joinedGroupName() const { return m_groupName; }
 
 private:
     void onSearch();
     void onAdd();
     void showResult(const QJsonObject &user);
+    void showGroupResult(const QJsonObject &group);
     void clearResult();
 
     QLineEdit *m_keyword = nullptr;
@@ -31,5 +34,9 @@ private:
     QLabel *m_status = nullptr;
     QLineEdit *m_verify = nullptr;
     qint64 m_uid = 0;
+    qint64 m_groupId = 0;
+    int m_joinMode = 0;
+    int m_groupRole = -1;
     QString m_userAccount;
+    QString m_groupName;
 };

+ 51 - 6
Client/src/mainframe/Client.cpp

@@ -33,6 +33,16 @@ Client::~Client()
 void Client::buildUi()
 {
     connect(&AvatarCache::instance(), &AvatarCache::updated, this, &Client::refreshAvatar);
+    connect(&AvatarCache::instance(), &AvatarCache::groupUpdated, this, [this](qint64 gid) {
+        if (gid <= 0)
+            return;
+        const int ver = AvatarCache::instance().knownGroupVer(gid);
+        if (auto *item = findConvItem(gid, ConvGroup))
+        {
+            if (ver > item->data(RoleAvatarVer).toInt())
+                item->setData(RoleAvatarVer, ver);
+        }
+    });
 
     auto *shell = new QWidget(this);
     auto *root = new QVBoxLayout(shell);
@@ -88,6 +98,8 @@ void Client::buildUi()
     connect(&ImClient::instance(), &ImClient::friendRequestNotify, this, &Client::onFriendRequestNotify);
     connect(&ImClient::instance(), &ImClient::friendAcceptedNotify, this, &Client::onFriendAcceptedNotify);
     connect(&ImClient::instance(), &ImClient::presenceNotify, this, &Client::onPresenceNotify);
+    connect(&ImClient::instance(), &ImClient::groupRequestNotify, this, &Client::onGroupRequestNotify);
+    connect(&ImClient::instance(), &ImClient::groupChangeNotify, this, &Client::onGroupChangeNotify);
     connect(&ImClient::instance(), &ImClient::kicked, this, &Client::onKicked);
     connect(&ImClient::instance(), &ImClient::disconnected, this, &Client::onDisconnected);
     connect(&ImClient::instance(), &ImClient::connected, this, &Client::onConnected);
@@ -214,7 +226,8 @@ QWidget *Client::buildConversationPane()
             return;
         const int row = m_convList->row(item);
         const qint64 uid = item->data(RolePeerUid).toLongLong();
-        if (row == m_convList->currentRow() && m_currentPeerUid == uid
+        const int convType = item->data(RoleConvType).toInt();
+        if (row == m_convList->currentRow() && m_currentConv == ConvId{convType, uid}
             && m_chatStack && m_chatStack->currentWidget() == m_chatBody)
             return;
         selectConversation(row);
@@ -231,6 +244,18 @@ QWidget *Client::buildConversationPane()
             }
             return;
         }
+        if (m_sideStack && m_sideStack->currentIndex() == 1)
+        {
+            QMenu menu(this);
+            QAction *add = menu.addAction(QStringLiteral("添加好友"));
+            QAction *group = menu.addAction(QStringLiteral("发起群聊"));
+            QAction *picked = menu.exec(m_addFriendBtn->mapToGlobal(QPoint(0, m_addFriendBtn->height())));
+            if (picked == add)
+                openAddFriend();
+            else if (picked == group)
+                openCreateGroup();
+            return;
+        }
         openAddFriend();
     });
     connect(m_search, &QLineEdit::textChanged, this, [this](const QString &) { applyListFilter(); });
@@ -270,6 +295,20 @@ QWidget *Client::buildFriendPane()
     barLay->addWidget(m_pendingBadge, 0, Qt::AlignVCenter | Qt::AlignRight);
     connect(m_newFriendsBtn, &QPushButton::clicked, this, &Client::openFriendRequests);
 
+    m_groupsBtn = new QPushButton(page);
+    m_groupsBtn->setCursor(Qt::PointingHandCursor);
+    m_groupsBtn->setFixedHeight(52);
+    m_groupsBtn->setStyleSheet(QStringLiteral(
+        "QPushButton { background:#F5F5F5; border:none; border-bottom:1px solid #E7E7E7; text-align:left; padding-left:12px; color:#1A1A1A; }"
+        "QPushButton:hover { background:#EDEDED; }"));
+    auto *gBarLay = new QHBoxLayout(m_groupsBtn);
+    gBarLay->setContentsMargins(12, 0, 12, 0);
+    auto *gTitle = new QLabel(QStringLiteral("群聊"), m_groupsBtn);
+    gTitle->setStyleSheet(QStringLiteral("background:transparent; font-size:14px;"));
+    gTitle->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
+    gBarLay->addWidget(gTitle, 1, Qt::AlignVCenter);
+    connect(m_groupsBtn, &QPushButton::clicked, this, &Client::openGroupList);
+
     m_friendStack = new QStackedWidget(page);
     m_friendList = new QListWidget(m_friendStack);
     m_friendList->setFrameShape(QFrame::NoFrame);
@@ -292,6 +331,7 @@ QWidget *Client::buildFriendPane()
     m_friendStack->addWidget(m_friendEmpty);
 
     lay->addWidget(m_newFriendsBtn);
+    lay->addWidget(m_groupsBtn);
     lay->addWidget(m_friendStack, 1);
 
     connect(m_friendList, &QListWidget::itemPressed, this, [this](QListWidgetItem *item) {
@@ -345,7 +385,7 @@ void Client::applyNavSelection(int index)
         exitMsgSelectMode();
     if (index == 0 && m_chatStack)
     {
-        if (m_currentPeerUid > 0 && m_chatBody)
+        if (m_currentConv.id > 0 && m_chatBody)
             m_chatStack->setCurrentWidget(m_chatBody);
         else if (m_chatEmpty)
             m_chatStack->setCurrentWidget(m_chatEmpty);
@@ -712,9 +752,14 @@ bool Client::eventFilter(QObject *watched, QEvent *event)
     if (watched == m_chatTitle && event->type() == QEvent::MouseButtonRelease)
     {
         auto *me = static_cast<QMouseEvent *>(event);
-        if (me->button() == Qt::LeftButton && m_currentPeerUid > 0
+        if (me->button() == Qt::LeftButton && m_currentConv.id > 0
             && m_chatStack && m_chatStack->currentWidget() == m_chatBody)
-            showProfile(m_currentPeerUid);
+        {
+            if (m_currentConv.isGroup())
+                showGroupProfile(m_currentConv.id);
+            else
+                showProfile(m_currentConv.id);
+        }
         return true;
     }
     if (watched == m_input && event->type() == QEvent::KeyPress)
@@ -835,8 +880,8 @@ void Client::closeEvent(QCloseEvent *event)
         m_tray->hide();
     if (m_trayPreview)
         m_trayPreview->hide();
-    if (m_currentPeerUid > 0)
-        ImClient::instance().setUnread(m_currentPeerUid, 0, {});
+    if (m_currentConv.id > 0)
+        ImClient::instance().setUnread(m_currentConv.id, 0, {});
     if (m_forceQuit || !m_loggingOut)
         QApplication::quit();
     QMainWindow::closeEvent(event);

+ 48 - 19
Client/src/mainframe/Client.h

@@ -8,6 +8,9 @@
 #include <QSet>
 #include <QVector>
 
+#include "LocalStore.h"
+#include "ClientInternal.h"
+
 class QShowEvent;
 class QCloseEvent;
 class QResizeEvent;
@@ -70,26 +73,28 @@ private:
     void applyListFilter();
     void applyHistory(const QJsonArray &list);
     void selectConversation(int row);
-    void openChat(qint64 peerUid, const QString &name);
+    void openChat(qint64 peerUid, const QString &name, int convType = 0);
     void onConvMenu(const QPoint &pos);
     void onFriendMenu(const QPoint &pos);
-    void applyUnread(qint64 peerUid, int unread);
+    void applyUnread(qint64 peerUid, int unread, int convType = 0);
     void showProfile(qint64 uid);
     void showFriendCard(qint64 uid);
+    void showGroupProfile(qint64 groupId);
     void fillProfilePage(const QJsonObject &user);
     void persistWindow();
-    void removeLocalConv(qint64 uid);
-    void markPeerRead(qint64 peerUid);
+    void removeLocalConv(qint64 uid, int convType = 0);
+    void markPeerRead(qint64 peerUid, int convType = 0);
     void saveLocalMessage(qint64 msgId, qint64 peerUid, qint64 fromUid, qint64 toUid,
-                          int msgType, const QString &content, qint64 ts, int status = 0);
+                          int msgType, const QString &content, qint64 ts, int status = 0,
+                          int convType = 0, const QString &fromName = QString());
     void persistLocalConv(qint64 peerUid, const QString &name, const QString &preview,
-                          qint64 ts, int unread);
+                          qint64 ts, int unread, int convType = 0);
     void loadLocalConvs();
-    bool isViewingPeer(qint64 peerUid) const;
+    bool isViewingPeer(qint64 peerUid, int convType = 0) const;
     void sendCurrentMessage();
     void sendOutgoing(qint64 peer, const QString &peerName, int msgType,
                       const QString &content, const QString &preview,
-                      QWidget *existingRow = nullptr, qint64 localId = 0);
+                      QWidget *existingRow = nullptr, qint64 localId = 0, int convType = 0);
     void resendFailed(QWidget *row);
     void sendSticker(qint64 packId, const QString &code);
     void sendFile(bool imageOnly = false);
@@ -97,15 +102,22 @@ private:
     void favoritePayload(int msgType, const QString &content);
     void sendChatPayload(int msgType, const QString &content, const QString &preview);
     void sendChatPayloadTo(qint64 peer, const QString &peerName, int msgType,
-                           const QString &content, const QString &preview);
+                           const QString &content, const QString &preview, int convType = 0);
     void forwardPayload(int msgType, const QString &content, const QString &preview,
                         QWidget *host = nullptr);
     void shareUserCard(const QJsonObject &user, QWidget *host = nullptr);
+    void shareGroupCard(qint64 groupId, const QString &name, int memberCount, QWidget *host = nullptr);
+    void openCreateGroup();
+    void openGroupList();
+    void joinGroupById(qint64 groupId, const QString &name = QString());
+    void handleGroupCard(const QJsonObject &card);
     void refreshAvatar(qint64 uid);
     int avatarVerOf(qint64 uid) const;
+    QString senderNameOf(qint64 fromUid, const QString &fromName = QString()) const;
     QPixmap peerAvatar(qint64 uid, const QString &name, int size) const;
     QWidget *addBubble(bool mine, const QString &name, const QString &text, const QString &time,
-                       int msgType = 1, int sendStatus = 0, qint64 msgId = 0, qint64 peerUid = 0);
+                       int msgType = 1, int sendStatus = 0, qint64 msgId = 0, qint64 peerUid = 0,
+                       qint64 fromUid = 0);
     QWidget *findMsgRow(qint64 msgId) const;
     int chatTextMaxWidth(bool mine) const;
     void relayoutTextBubbles();
@@ -120,25 +132,31 @@ private:
         int scrollValue = 0;
         int laidOutW = -1;
         bool stickBottom = true;
+        bool hasMore = true;
+        bool olderLoaded = false;
         QString fp;
     };
     ChatPage createChatPage();
     void bindChatScroll(QScrollArea *scroll);
-    void stashChatPage(qint64 peerUid);
-    bool activateChatPage(qint64 peerUid);
+    void stashChatPage(const ClientInternal::ConvId &id);
+    bool activateChatPage(const ClientInternal::ConvId &id);
     bool chatPageHasAll(const QJsonArray &list) const;
-    void dropChatPage(qint64 peerUid);
+    void dropChatPage(const ClientInternal::ConvId &id);
     void trimChatPages();
     void rememberChatPageState();
+    void loadOlderHistory();
+    void maybeFillHistory();
+    int prependHistory(const QVector<LocalStore::Msg> &rows);
+    qint64 oldestServerMsgId() const;
     void clearBubbles();
     void enterMsgSelectMode(qint64 preselectId = 0);
     void exitMsgSelectMode();
     void updateMsgSelectCount();
     void deleteChatMessages(const QVector<qint64> &ids);
     void applyDeletedMessages(const QVector<qint64> &ids);
-    void syncConvPreviewAfterDelete(qint64 peerUid);
+    void syncConvPreviewAfterDelete(qint64 peerUid, int convType = 0);
     void upsertConv(qint64 peerUid, const QString &name, const QString &preview,
-                    qint64 ts, int unread);
+                    qint64 ts, int unread, int convType = 0);
     void showSettings();
     void showSettings(int page);
     void openAddFriend();
@@ -164,6 +182,8 @@ private:
     void onFriendRequestNotify(const QJsonObject &msg);
     void onFriendAcceptedNotify(const QJsonObject &msg);
     void onPresenceNotify(const QJsonObject &msg);
+    void onGroupRequestNotify(const QJsonObject &msg);
+    void onGroupChangeNotify(const QJsonObject &msg);
     void applyPresence(qint64 uid, bool online);
     void applyNavSelection(int index);
     void setPendingCount(int n);
@@ -175,14 +195,14 @@ private:
     void startUnreadAlert();
     void stopUnreadAlert();
     int unreadTotal() const;
-    QListWidgetItem *findConvItem(qint64 peerUid) const;
+    QListWidgetItem *findConvItem(qint64 peerUid, int convType = 0) const;
     QListWidgetItem *findFriendItem(qint64 uid) const;
     QString previewOf(int msgType, const QString &content) const;
 
     QString m_account;
     QString m_nickname;
     qint64 m_uid = 0;
-    qint64 m_currentPeerUid = 0;
+    ClientInternal::ConvId m_currentConv;
     QString m_currentPeerName;
     bool m_loggingOut = false;
     bool m_forceQuit = false;
@@ -207,6 +227,7 @@ private:
     QLineEdit *m_search = nullptr;
     QPushButton *m_addFriendBtn = nullptr;
     QPushButton *m_newFriendsBtn = nullptr;
+    QPushButton *m_groupsBtn = nullptr;
     QLabel *m_pendingBadge = nullptr;
     class StickerPanel *m_stickerPanel = nullptr;
     QPushButton *m_emojiBtn = nullptr;
@@ -214,9 +235,17 @@ private:
     int m_lastTextBubbleMaxW = -1;
     bool m_buildingHistory = false;
     qint64 m_historyPeerUid = 0;
+    int m_historyConvType = 0;
     QString m_historyFp;
-    QHash<qint64, ChatPage> m_chatPages;
-    QList<qint64> m_chatPageOrder;
+    bool m_historyHasMore = true;
+    bool m_historyLoadingOlder = false;
+    bool m_historyOlderLoaded = false;
+    qint64 m_historyTriedBeforeId = 0;
+    int m_historyInsertAt = -1;
+    bool m_historyReady = false;
+    bool m_historyRestoringScroll = false;
+    QHash<ClientInternal::ConvId, ChatPage> m_chatPages;
+    QList<ClientInternal::ConvId> m_chatPageOrder;
     QStackedWidget *m_chatPageStack = nullptr;
 
     QStackedWidget *m_chatStack = nullptr;

+ 508 - 96
Client/src/mainframe/ClientChat.cpp

@@ -1,6 +1,42 @@
 // ClientChat.cpp — 会话页缓存、历史气泡、多选删除
 #include "ClientImpl.h"
 
+namespace {
+constexpr int kHistoryPage = 20;
+constexpr int kLoadOlderPx = 48;
+constexpr qint64 kTimeGapMs = 5 * 60 * 1000;
+
+int historyPageSize()
+{
+    int max = AppConfig::instance().historyMax();
+    if (max <= 0)
+        max = 100;
+    return qBound(1, kHistoryPage, max);
+}
+
+LocalStore::Msg msgFromHistoryJson(const QJsonObject &o, qint64 selfUid, qint64 currentPeer, int convType = 0)
+{
+    LocalStore::Msg m;
+    m.fromUid = Proto::jsonI64(o, QStringLiteral("from_uid"));
+    m.toUid = Proto::jsonI64(o, QStringLiteral("to_uid"));
+    m.id = Proto::jsonI64(o, QStringLiteral("msg_id"));
+    if (m.id <= 0)
+        m.id = Proto::jsonI64(o, QStringLiteral("id"));
+    m.peerUid = currentPeer > 0 ? currentPeer : ((m.fromUid == selfUid) ? m.toUid : m.fromUid);
+    m.convType = convType;
+    m.msgType = static_cast<int>(Proto::jsonI64(o, QStringLiteral("msg_type")));
+    if (m.msgType <= 0)
+        m.msgType = 1;
+    m.content = o.value(QStringLiteral("content")).toString();
+    m.ts = Proto::jsonI64(o, QStringLiteral("ts"));
+    m.status = LocalMsg::Ok;
+    m.fromName = o.value(QStringLiteral("from_nickname")).toString();
+    if (m.fromName.isEmpty())
+        m.fromName = o.value(QStringLiteral("from_account")).toString();
+    return m;
+}
+}  // namespace
+
 Client::ChatPage Client::createChatPage()
 {
     ChatPage p;
@@ -29,26 +65,35 @@ void Client::bindChatScroll(QScrollArea *scroll)
     connect(scroll->verticalScrollBar(), &QScrollBar::rangeChanged, this, [this, scroll](int, int max) {
         if (m_scroll != scroll)
             return;
+        if (m_historyLoadingOlder || m_historyInsertAt >= 0 || m_historyRestoringScroll)
+            return;
         if (m_stickChatToBottom)
             scroll->verticalScrollBar()->setValue(max);
+        maybeFillHistory();
     });
     connect(scroll->verticalScrollBar(), &QScrollBar::valueChanged, this, [this, scroll](int v) {
         if (m_scroll != scroll)
             return;
+        if (m_historyRestoringScroll || m_historyInsertAt >= 0)
+            return;
         const int max = scroll->verticalScrollBar()->maximum();
         m_stickChatToBottom = (max - v) <= 24;
+        if (v <= kLoadOlderPx)
+            loadOlderHistory();
     });
 }
 
-void Client::stashChatPage(qint64 peerUid)
+void Client::stashChatPage(const ConvId &id)
 {
-    if (peerUid <= 0)
+    if (!id.isValid())
         return;
-    auto it = m_chatPages.find(peerUid);
+    auto it = m_chatPages.find(id);
     if (it == m_chatPages.end())
         return;
     it->fp = m_historyFp;
     it->stickBottom = m_stickChatToBottom;
+    it->hasMore = m_historyHasMore;
+    it->olderLoaded = m_historyOlderLoaded;
     if (it->scroll && it->scroll->viewport())
         it->laidOutW = it->scroll->viewport()->width();
     if (it->scroll)
@@ -57,19 +102,19 @@ void Client::stashChatPage(qint64 peerUid)
 
 void Client::rememberChatPageState()
 {
-    stashChatPage(m_currentPeerUid);
+    stashChatPage(m_currentConv);
 }
 
-bool Client::activateChatPage(qint64 peerUid)
+bool Client::activateChatPage(const ConvId &id)
 {
-    if (peerUid <= 0 || !m_chatPageStack)
+    if (!id.isValid() || !m_chatPageStack)
         return false;
-    const bool cached = m_chatPages.contains(peerUid);
+    const bool cached = m_chatPages.contains(id);
 
-    m_chatPageOrder.removeAll(peerUid);
-    m_chatPageOrder.append(peerUid);
+    m_chatPageOrder.removeAll(id);
+    m_chatPageOrder.append(id);
 
-    auto it = m_chatPages.find(peerUid);
+    auto it = m_chatPages.find(id);
     if (it == m_chatPages.end())
     {
         ChatPage p;
@@ -92,20 +137,34 @@ bool Client::activateChatPage(qint64 peerUid)
         {
             p = createChatPage();
         }
-        it = m_chatPages.insert(peerUid, p);
+        it = m_chatPages.insert(id, p);
         m_historyFp.clear();
-        m_historyPeerUid = peerUid;
+        m_historyPeerUid = id.id;
+        m_historyConvType = id.type;
         m_stickChatToBottom = true;
+        m_historyHasMore = true;
+        m_historyOlderLoaded = false;
+        m_historyLoadingOlder = false;
+        m_historyTriedBeforeId = 0;
+        m_historyInsertAt = -1;
+        m_historyReady = false;
+        m_historyRestoringScroll = false;
         trimChatPages();
-        it = m_chatPages.find(peerUid);
+        it = m_chatPages.find(id);
         if (it == m_chatPages.end())
             return false;
     }
     else
     {
         m_historyFp = it->fp;
-        m_historyPeerUid = peerUid;
+        m_historyPeerUid = id.id;
+        m_historyConvType = id.type;
         m_stickChatToBottom = it->stickBottom;
+        m_historyHasMore = it->hasMore;
+        m_historyOlderLoaded = it->olderLoaded;
+        m_historyLoadingOlder = false;
+        m_historyTriedBeforeId = 0;
+        m_historyReady = true;
     }
 
     m_scroll = it->scroll;
@@ -138,14 +197,14 @@ bool Client::chatPageHasAll(const QJsonArray &list) const
     return true;
 }
 
-void Client::dropChatPage(qint64 uid)
+void Client::dropChatPage(const ConvId &id)
 {
-    auto it = m_chatPages.find(uid);
+    auto it = m_chatPages.find(id);
     if (it == m_chatPages.end())
         return;
     QScrollArea *scroll = it->scroll;
     m_chatPages.erase(it);
-    m_chatPageOrder.removeAll(uid);
+    m_chatPageOrder.removeAll(id);
     const bool droppingCurrent = (m_scroll == scroll);
     if (scroll)
     {
@@ -161,6 +220,14 @@ void Client::dropChatPage(qint64 uid)
     m_bubbleLay = fresh.lay;
     m_historyFp.clear();
     m_historyPeerUid = 0;
+    m_historyConvType = 0;
+    m_historyHasMore = true;
+    m_historyOlderLoaded = false;
+    m_historyLoadingOlder = false;
+    m_historyTriedBeforeId = 0;
+    m_historyInsertAt = -1;
+    m_historyReady = false;
+    m_historyRestoringScroll = false;
     if (m_chatPageStack && m_scroll)
         m_chatPageStack->setCurrentWidget(m_scroll);
 }
@@ -170,16 +237,16 @@ void Client::trimChatPages()
     constexpr int kMaxPages = 8;
     while (m_chatPages.size() > kMaxPages)
     {
-        qint64 victim = 0;
-        for (qint64 id : m_chatPageOrder)
+        ConvId victim;
+        for (const ConvId &id : m_chatPageOrder)
         {
-            if (id != m_currentPeerUid)
+            if (id != m_currentConv)
             {
                 victim = id;
                 break;
             }
         }
-        if (victim == 0)
+        if (!victim.isValid())
             break;
         dropChatPage(victim);
     }
@@ -189,44 +256,35 @@ void Client::applyHistory(const QJsonArray &list)
 {
     QElapsedTimer clock;
     clock.start();
-    int histLimit = AppConfig::instance().historyDefault();
-    if (histLimit <= 0)
-        histLimit = 50;
+    const int histLimit = historyPageSize();
     QMap<qint64, LocalStore::Msg> byId;
     int fallbackId = 0;
-    for (const auto &m : LocalStore::instance().listMessages(m_currentPeerUid, histLimit))
+    if (list.isEmpty())
     {
-        if (m.id != 0)
-            byId.insert(m.id, m);
+        for (const auto &m : LocalStore::instance().listMessages(m_currentConv.id, m_currentConv.type, histLimit))
+        {
+            if (m.id != 0)
+                byId.insert(m.id, m);
+        }
     }
-    const qint64 sqliteNs = clock.nsecsElapsed();
-    for (const QJsonValue &v : list)
+    else
     {
-        const QJsonObject o = v.toObject();
-        const qint64 from = Proto::jsonI64(o, QStringLiteral("from_uid"));
-        const qint64 to = Proto::jsonI64(o, QStringLiteral("to_uid"));
-        qint64 msgId = Proto::jsonI64(o, QStringLiteral("msg_id"));
-        if (msgId <= 0)
-            msgId = Proto::jsonI64(o, QStringLiteral("id"));
-        const qint64 peer = m_currentPeerUid > 0
-            ? m_currentPeerUid
-            : ((from == m_uid) ? to : from);
-        const int msgType = static_cast<int>(Proto::jsonI64(o, QStringLiteral("msg_type")));
-        const QString text = o.value(QStringLiteral("content")).toString();
-        const qint64 ts = Proto::jsonI64(o, QStringLiteral("ts"));
-        saveLocalMessage(msgId, peer, from, to, msgType > 0 ? msgType : 1, text, ts);
-
-        LocalStore::Msg m;
-        m.id = msgId > 0 ? msgId : --fallbackId;
-        m.peerUid = peer;
-        m.fromUid = from;
-        m.toUid = to;
-        m.msgType = msgType > 0 ? msgType : 1;
-        m.content = text;
-        m.ts = ts;
-        byId.insert(m.id, m);
+        for (const QJsonValue &v : list)
+        {
+            LocalStore::Msg m = msgFromHistoryJson(v.toObject(), m_uid, m_currentConv.id, m_currentConv.type);
+            if (m.id <= 0)
+                m.id = --fallbackId;
+            saveLocalMessage(m.id, m.peerUid, m.fromUid, m.toUid, m.msgType, m.content, m.ts, LocalMsg::Ok, m.convType,
+                             m.fromName);
+            byId.insert(m.id, m);
+        }
+        for (const auto &m : LocalStore::instance().listMessages(m_currentConv.id, m_currentConv.type, histLimit))
+        {
+            if (m.id < 0 || m.status == LocalMsg::Sending || m.status == LocalMsg::Failed)
+                byId.insert(m.id, m);
+        }
     }
-
+    const qint64 sqliteNs = clock.nsecsElapsed();
     QVector<LocalStore::Msg> rows;
     rows.reserve(byId.size());
     for (auto it = byId.constBegin(); it != byId.constEnd(); ++it)
@@ -237,19 +295,30 @@ void Client::applyHistory(const QJsonArray &list)
         return a.id < b.id;
     });
     if (rows.size() > histLimit)
-        rows = rows.mid(rows.size() - histLimit);
+    {
+        QVector<LocalStore::Msg> kept;
+        kept.reserve(rows.size());
+        const int start = rows.size() - histLimit;
+        for (int i = 0; i < rows.size(); ++i)
+        {
+            if (i >= start || rows.at(i).id < 0)
+                kept.push_back(rows.at(i));
+        }
+        rows.swap(kept);
+    }
 
     QString fp;
     fp.reserve(rows.size() * 16);
     for (const LocalStore::Msg &m : rows)
         fp += QString::number(m.id) + QLatin1Char(',');
     const qint64 mergeNs = clock.nsecsElapsed();
-    if (m_historyPeerUid == m_currentPeerUid && fp == m_historyFp)
+    if (list.isEmpty() && m_historyPeerUid == m_currentConv.id && m_historyConvType == m_currentConv.type
+        && fp == m_historyFp)
     {
         if (m_stickChatToBottom)
             scrollChatToBottom();
         LOG_INFO(QStringLiteral("applyHistory peer=%1 src=%2 rows=%3 skip sqlite=%4ms merge=%5ms total=%6ms")
-                     .arg(QString::number(m_currentPeerUid),
+                     .arg(QString::number(m_currentConv.id),
                           list.isEmpty() ? QStringLiteral("local") : QStringLiteral("server"),
                           QString::number(rows.size()),
                           fmtMs(sqliteNs), fmtMs(mergeNs - sqliteNs),
@@ -262,6 +331,8 @@ void Client::applyHistory(const QJsonArray &list)
     if (m_bubbleHost)
         m_bubbleHost->setUpdatesEnabled(false);
     clearBubbles();
+    m_historyOlderLoaded = false;
+    m_historyTriedBeforeId = 0;
     m_buildingHistory = true;
     QLayoutItem *tail = nullptr;
     if (m_bubbleLay && m_bubbleLay->count() > 0)
@@ -270,7 +341,7 @@ void Client::applyHistory(const QJsonArray &list)
     for (const LocalStore::Msg &m : rows)
     {
         const bool mine = (m.fromUid == m_uid);
-        const QString name = mine ? m_nickname : m_currentPeerName;
+        const QString name = mine ? m_nickname : senderNameOf(m.fromUid, m.fromName);
         QString time;
         if (lastTs == 0 || qAbs(m.ts - lastTs) > 5 * 60 * 1000)
             time = formatTs(m.ts);
@@ -283,12 +354,19 @@ void Client::applyHistory(const QJsonArray &list)
             stale.status = LocalMsg::Failed;
             LocalStore::instance().upsertMessage(stale);
         }
-        addBubble(mine, name, m.content, time, m.msgType, status, m.id, m.peerUid);
+        QWidget *row = addBubble(mine, name, m.content, time, m.msgType, status, m.id, m.peerUid,
+                                 m.fromUid);
+        if (row)
+        {
+            row->setProperty("msgTs", m.ts);
+            row->setProperty("fromUid", m.fromUid);
+        }
     }
     if (m_bubbleLay && tail)
         m_bubbleLay->addItem(tail);
     m_buildingHistory = false;
-    m_historyPeerUid = m_currentPeerUid;
+    m_historyPeerUid = m_currentConv.id;
+    m_historyConvType = m_currentConv.type;
     m_historyFp = fp;
     rememberChatPageState();
     if (m_bubbleHost)
@@ -297,36 +375,261 @@ void Client::applyHistory(const QJsonArray &list)
         m_scroll->setUpdatesEnabled(true);
     scrollChatToBottom();
     LOG_INFO(QStringLiteral("applyHistory peer=%1 src=%2 rows=%3 rebuild sqlite=%4ms merge=%5ms build=%6ms total=%7ms")
-                 .arg(QString::number(m_currentPeerUid),
+                 .arg(QString::number(m_currentConv.id),
                       list.isEmpty() ? QStringLiteral("local") : QStringLiteral("server"),
                       QString::number(rows.size()),
                       fmtMs(sqliteNs), fmtMs(mergeNs - sqliteNs),
                       fmtMs(clock.nsecsElapsed() - mergeNs), fmtMs(clock.nsecsElapsed())));
 }
 
-void Client::openChat(qint64 peerUid, const QString &name)
+qint64 Client::oldestServerMsgId() const
+{
+    if (!m_bubbleLay)
+        return 0;
+    for (int i = 0; i < m_bubbleLay->count(); ++i)
+    {
+        QWidget *w = m_bubbleLay->itemAt(i) ? m_bubbleLay->itemAt(i)->widget() : nullptr;
+        if (!w || w->objectName() != QLatin1String("msgRow"))
+            continue;
+        const qint64 id = w->property("msgId").toLongLong();
+        if (id > 0)
+            return id;
+    }
+    return 0;
+}
+
+int Client::prependHistory(const QVector<LocalStore::Msg> &rows)
+{
+    if (!m_bubbleLay || rows.isEmpty())
+        return 0;
+    QVector<LocalStore::Msg> add;
+    add.reserve(rows.size());
+    for (const LocalStore::Msg &m : rows)
+    {
+        if (m.id != 0 && findMsgRow(m.id))
+            continue;
+        add.push_back(m);
+    }
+    if (add.isEmpty())
+        return 0;
+    std::sort(add.begin(), add.end(), [](const LocalStore::Msg &a, const LocalStore::Msg &b) {
+        if (a.ts != b.ts)
+            return a.ts < b.ts;
+        return a.id < b.id;
+    });
+
+    QWidget *oldFirst = nullptr;
+    qint64 oldFirstTs = 0;
+    for (int i = 0; i < m_bubbleLay->count(); ++i)
+    {
+        QWidget *w = m_bubbleLay->itemAt(i) ? m_bubbleLay->itemAt(i)->widget() : nullptr;
+        if (w && w->objectName() == QLatin1String("msgRow"))
+        {
+            oldFirst = w;
+            oldFirstTs = w->property("msgTs").toLongLong();
+            break;
+        }
+    }
+
+    auto *bar = m_scroll ? m_scroll->verticalScrollBar() : nullptr;
+    const bool wasStick = m_stickChatToBottom;
+    int viewOffset = 0;
+    if (oldFirst && m_scroll && m_scroll->viewport())
+        viewOffset = oldFirst->mapTo(m_scroll->viewport(), QPoint(0, 0)).y();
+
+    m_stickChatToBottom = false;
+    m_historyRestoringScroll = true;
+    m_historyInsertAt = 0;
+
+    qint64 lastTs = 0;
+    for (const LocalStore::Msg &m : add)
+    {
+        const bool mine = (m.fromUid == m_uid);
+        const QString name = mine ? m_nickname : senderNameOf(m.fromUid, m.fromName);
+        QString time;
+        if (lastTs == 0 || qAbs(m.ts - lastTs) > kTimeGapMs)
+            time = formatTs(m.ts);
+        lastTs = m.ts;
+        int status = m.status;
+        if (status == LocalMsg::Sending && !m_inflightLocalIds.contains(m.id))
+            status = LocalMsg::Failed;
+        QWidget *row = addBubble(mine, name, m.content, time, m.msgType, status, m.id, m.peerUid,
+                                 m.fromUid);
+        if (row)
+        {
+            row->setProperty("msgTs", m.ts);
+            row->setProperty("fromUid", m.fromUid);
+        }
+    }
+    m_historyInsertAt = -1;
+
+    if (oldFirst && lastTs > 0 && oldFirstTs > 0 && qAbs(oldFirstTs - lastTs) <= kTimeGapMs)
+    {
+        const int idx = m_bubbleLay->indexOf(oldFirst);
+        if (idx > 0)
+        {
+            QWidget *prev = m_bubbleLay->itemAt(idx - 1)
+                ? m_bubbleLay->itemAt(idx - 1)->widget() : nullptr;
+            if (prev && prev->objectName() == QLatin1String("msgTime"))
+            {
+                QLayoutItem *it = m_bubbleLay->takeAt(idx - 1);
+                if (it->widget())
+                    it->widget()->deleteLater();
+                delete it;
+            }
+        }
+    }
+
+    m_historyOlderLoaded = true;
+    if (m_bubbleLay)
+        m_bubbleLay->activate();
+    if (m_bubbleHost)
+        m_bubbleHost->adjustSize();
+
+    auto pinAnchor = [this](QWidget *anchor, int keepOffset) {
+        if (!anchor || !m_scroll || !m_scroll->viewport())
+            return;
+        QScrollBar *sb = m_scroll->verticalScrollBar();
+        if (!sb)
+            return;
+        const int now = anchor->mapTo(m_scroll->viewport(), QPoint(0, 0)).y();
+        sb->setValue(qBound(0, sb->value() + now - keepOffset, sb->maximum()));
+    };
+    pinAnchor(oldFirst, viewOffset);
+    m_historyRestoringScroll = false;
+
+    if (oldFirst)
+    {
+        QPointer<QWidget> anchor(oldFirst);
+        const int keepOffset = viewOffset;
+        QTimer::singleShot(0, this, [this, anchor, keepOffset]() {
+            if (!anchor || !m_scroll)
+                return;
+            m_historyRestoringScroll = true;
+            QScrollBar *sb = m_scroll->verticalScrollBar();
+            if (sb && m_scroll->viewport())
+            {
+                const int now = anchor->mapTo(m_scroll->viewport(), QPoint(0, 0)).y();
+                sb->setValue(qBound(0, sb->value() + now - keepOffset, sb->maximum()));
+            }
+            m_historyRestoringScroll = false;
+        });
+    }
+
+    if (bar)
+        m_stickChatToBottom = wasStick && (bar->maximum() - bar->value()) <= 24;
+    else
+        m_stickChatToBottom = wasStick;
+    rememberChatPageState();
+    return add.size();
+}
+
+void Client::maybeFillHistory()
+{
+    if (!m_historyReady || m_historyLoadingOlder || !m_historyHasMore || !m_scroll)
+        return;
+    QScrollBar *bar = m_scroll->verticalScrollBar();
+    if (!bar)
+        return;
+    if (bar->maximum() <= 8 || bar->value() <= kLoadOlderPx)
+        loadOlderHistory();
+}
+
+void Client::loadOlderHistory()
+{
+    if (!m_historyReady || m_historyLoadingOlder || !m_historyHasMore || m_buildingHistory)
+        return;
+    if (m_currentConv.id <= 0 || !m_bubbleLay || m_msgSelectMode)
+        return;
+    const qint64 beforeId = oldestServerMsgId();
+    if (beforeId <= 0)
+    {
+        m_historyHasMore = false;
+        rememberChatPageState();
+        return;
+    }
+    if (beforeId == m_historyTriedBeforeId)
+    {
+        m_historyHasMore = false;
+        rememberChatPageState();
+        return;
+    }
+    m_historyTriedBeforeId = beforeId;
+    const qint64 peer = m_currentConv.id;
+    const int convType = m_currentConv.type;
+    const int page = historyPageSize();
+    m_historyLoadingOlder = true;
+
+    const QVector<LocalStore::Msg> local =
+        LocalStore::instance().listMessagesBefore(peer, convType, beforeId, page);
+    if (!local.isEmpty())
+        prependHistory(local);
+    const bool hadLocal = !local.isEmpty();
+
+    QPointer<Client> self(this);
+    ImClient::instance().msgHistory(peer, convType, beforeId, page, [self, peer, convType, page, hadLocal](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (self->m_currentConv != ConvId{convType, peer})
+            return;
+        if (!ImClient::ok(reply))
+        {
+            self->m_historyLoadingOlder = false;
+            self->m_historyTriedBeforeId = 0;
+            return;
+        }
+        const QJsonArray list = reply.value(QStringLiteral("list")).toArray();
+        QVector<LocalStore::Msg> rows;
+        rows.reserve(list.size());
+        for (const QJsonValue &v : list)
+        {
+            LocalStore::Msg m = msgFromHistoryJson(v.toObject(), self->m_uid, peer, convType);
+            if (m.id <= 0)
+                continue;
+            self->saveLocalMessage(m.id, m.peerUid, m.fromUid, m.toUid, m.msgType, m.content, m.ts, LocalMsg::Ok, convType,
+                                   m.fromName);
+            rows.push_back(m);
+        }
+        const int added = self->prependHistory(rows);
+        self->m_historyLoadingOlder = false;
+        if (list.size() < page)
+            self->m_historyHasMore = false;
+        else if (added == 0 && !hadLocal)
+            self->m_historyHasMore = false;
+        else
+            self->m_historyHasMore = true;
+        self->rememberChatPageState();
+        QTimer::singleShot(0, self, [self]() {
+            if (self)
+                self->maybeFillHistory();
+        });
+    });
+}
+
+void Client::openChat(qint64 peerUid, const QString &name, int convType)
 {
     if (peerUid <= 0)
         return;
+    const ConvId key{convType, peerUid};
     QElapsedTimer clock;
     clock.start();
-    const bool sameChat = (peerUid == m_currentPeerUid)
+    const bool sameChat = (key == m_currentConv)
         && m_chatStack && m_chatStack->currentWidget() == m_chatBody;
     exitMsgSelectMode();
     qint64 stashNs = 0;
     if (!sameChat)
     {
         const qint64 t0 = clock.nsecsElapsed();
-        stashChatPage(m_currentPeerUid);
+        stashChatPage(m_currentConv);
         stashNs = clock.nsecsElapsed() - t0;
     }
-    m_currentPeerUid = peerUid;
+    m_currentConv = key;
     m_currentPeerName = name;
     m_chatTitle->setText(name);
     m_chatStack->setCurrentWidget(m_chatBody);
     applyNavSelection(0);
-    markPeerRead(peerUid);
-    if (auto *item = findConvItem(peerUid))
+    markPeerRead(peerUid, convType);
+    if (auto *item = findConvItem(peerUid, convType))
     {
         m_convList->blockSignals(true);
         m_convList->setCurrentItem(item);
@@ -334,8 +637,8 @@ void Client::openChat(qint64 peerUid, const QString &name)
     }
     else
     {
-        upsertConv(peerUid, name, QString(), QDateTime::currentMSecsSinceEpoch(), 0);
-        if (auto *item = findConvItem(peerUid))
+        upsertConv(peerUid, name, QString(), QDateTime::currentMSecsSinceEpoch(), 0, convType);
+        if (auto *item = findConvItem(peerUid, convType))
         {
             m_convList->blockSignals(true);
             m_convList->setCurrentItem(item);
@@ -351,7 +654,7 @@ void Client::openChat(qint64 peerUid, const QString &name)
     }
 
     const qint64 pageT0 = clock.nsecsElapsed();
-    const bool cached = activateChatPage(peerUid);
+    const bool cached = activateChatPage(key);
     const qint64 pageNs = clock.nsecsElapsed() - pageT0;
 
     QPointer<Client> self(this);
@@ -362,16 +665,18 @@ void Client::openChat(qint64 peerUid, const QString &name)
         applyHistory(QJsonArray());
         localNs = clock.nsecsElapsed() - localT0;
     }
-    const int limit = AppConfig::instance().historyDefault();
+    const int limit = historyPageSize();
     QElapsedTimer net;
     net.start();
-    ImClient::instance().msgHistory(peerUid, 0, limit, [self, peerUid, net](const QJsonObject &reply) {
+    ImClient::instance().msgHistory(peerUid, convType, 0, limit, [self, peerUid, convType, net, limit](const QJsonObject &reply) {
         const qint64 waitNs = net.nsecsElapsed();
-        if (!self || self->m_currentPeerUid != peerUid)
+        if (!self || self->m_currentConv != ConvId{convType, peerUid})
             return;
         if (!ImClient::ok(reply))
         {
             UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            self->m_historyReady = true;
+            self->maybeFillHistory();
             LOG_INFO(QStringLiteral("openChat histReply peer=%1 wait=%2ms error")
                          .arg(QString::number(peerUid), fmtMs(waitNs)));
             return;
@@ -379,12 +684,30 @@ void Client::openChat(qint64 peerUid, const QString &name)
         const QJsonArray list = reply.value(QStringLiteral("list")).toArray();
         QElapsedTimer apply;
         apply.start();
-        const bool hasAll = self->chatPageHasAll(list);
-        if (!hasAll)
-            self->applyHistory(list);
-        LOG_INFO(QStringLiteral("openChat histReply peer=%1 wait=%2ms n=%3 hasAll=%4 apply=%5ms")
+        if (!self->m_historyOlderLoaded)
+        {
+            const bool hasAll = self->chatPageHasAll(list);
+            if (!hasAll || convType == ConvGroup)
+                self->applyHistory(list);
+            if (!self->m_historyOlderLoaded)
+                self->m_historyHasMore = list.size() >= limit;
+        }
+        else
+        {
+            for (const QJsonValue &v : list)
+            {
+                LocalStore::Msg m = msgFromHistoryJson(v.toObject(), self->m_uid, peerUid, convType);
+                if (m.id > 0)
+                    self->saveLocalMessage(m.id, m.peerUid, m.fromUid, m.toUid, m.msgType, m.content, m.ts, LocalMsg::Ok, convType,
+                                           m.fromName);
+            }
+        }
+        self->m_historyReady = true;
+        self->rememberChatPageState();
+        self->maybeFillHistory();
+        LOG_INFO(QStringLiteral("openChat histReply peer=%1 wait=%2ms n=%3 hasMore=%4 apply=%5ms")
                      .arg(QString::number(peerUid), fmtMs(waitNs), QString::number(list.size()),
-                          hasAll ? QStringLiteral("1") : QStringLiteral("0"),
+                          self->m_historyHasMore ? QStringLiteral("1") : QStringLiteral("0"),
                           fmtMs(apply.nsecsElapsed())));
     });
     LOG_INFO(QStringLiteral("openChat peer=%1 cached=%2 stash=%3ms ui=%4ms page=%5ms local=%6ms sync=%7ms")
@@ -398,6 +721,7 @@ void Client::clearBubbles()
 {
     m_historyFp.clear();
     m_historyPeerUid = 0;
+    m_historyConvType = 0;
     exitMsgSelectMode();
     if (!m_bubbleLay)
         return;
@@ -520,14 +844,15 @@ void Client::deleteChatMessages(const QVector<qint64> &ids)
     if (remote.isEmpty())
     {
         applyDeletedMessages(localOnly);
-        syncConvPreviewAfterDelete(m_currentPeerUid);
+        syncConvPreviewAfterDelete(m_currentConv.id, m_currentConv.type);
         exitMsgSelectMode();
         return;
     }
 
     QPointer<Client> self(this);
-    const qint64 peer = m_currentPeerUid;
-    ImClient::instance().deleteMsgs(peer, remote, [self, remote, localOnly, peer](const QJsonObject &reply) {
+    const qint64 peer = m_currentConv.id;
+    const int convType = m_currentConv.type;
+    ImClient::instance().deleteMsgs(peer, convType, remote, [self, remote, localOnly, peer, convType](const QJsonObject &reply) {
         if (!self)
             return;
         if (!ImClient::ok(reply))
@@ -541,12 +866,12 @@ void Client::deleteChatMessages(const QVector<qint64> &ids)
         self->applyDeletedMessages(all);
         const QString preview = reply.value(QStringLiteral("preview")).toString();
         const qint64 ts = Proto::jsonI64(reply, QStringLiteral("ts"));
-        if (self->m_currentPeerUid == peer)
+        if (self->m_currentConv == ConvId{convType, peer})
         {
             int unread = 0;
-            if (auto *item = self->findConvItem(peer))
+            if (auto *item = self->findConvItem(peer, convType))
                 unread = item->data(RoleUnread).toInt();
-            self->upsertConv(peer, self->m_currentPeerName, preview, ts, unread);
+            self->upsertConv(peer, self->m_currentPeerName, preview, ts, unread, convType);
         }
         self->exitMsgSelectMode();
     });
@@ -604,15 +929,16 @@ void Client::applyDeletedMessages(const QVector<qint64> &ids)
                 m_historyFp += QString::number(id) + QLatin1Char(',');
         }
     }
-    m_historyPeerUid = m_currentPeerUid;
+    m_historyPeerUid = m_currentConv.id;
+    m_historyConvType = m_currentConv.type;
     rememberChatPageState();
 }
 
-void Client::syncConvPreviewAfterDelete(qint64 peerUid)
+void Client::syncConvPreviewAfterDelete(qint64 peerUid, int convType)
 {
     if (peerUid <= 0)
         return;
-    const auto msgs = LocalStore::instance().listMessages(peerUid, 1);
+    const auto msgs = LocalStore::instance().listMessages(peerUid, convType, 1);
     QString preview;
     qint64 ts = 0;
     if (!msgs.isEmpty())
@@ -622,23 +948,26 @@ void Client::syncConvPreviewAfterDelete(qint64 peerUid)
     }
     int unread = 0;
     QString name = m_currentPeerName;
-    if (auto *item = findConvItem(peerUid))
+    if (auto *item = findConvItem(peerUid, convType))
     {
         unread = item->data(RoleUnread).toInt();
         name = item->data(RoleName).toString();
     }
-    upsertConv(peerUid, name, preview, ts, unread);
+    upsertConv(peerUid, name, preview, ts, unread, convType);
 }
 
 QWidget *Client::addBubble(bool mine, const QString &name, const QString &text, const QString &time,
-                           int msgType, int sendStatus, qint64 msgId, qint64 peerUid)
+                           int msgType, int sendStatus, qint64 msgId, qint64 peerUid, qint64 fromUid)
 {
     if (!time.isEmpty())
     {
         auto *stamp = new QLabel(time, m_bubbleHost);
+        stamp->setObjectName(QStringLiteral("msgTime"));
         stamp->setAlignment(Qt::AlignCenter);
         stamp->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:12px; background:transparent; padding:4px 0 8px 0;"));
-        if (m_buildingHistory)
+        if (m_historyInsertAt >= 0)
+            m_bubbleLay->insertWidget(m_historyInsertAt++, stamp);
+        else if (m_buildingHistory)
             m_bubbleLay->addWidget(stamp);
         else
             m_bubbleLay->insertWidget(m_bubbleLay->count() - 1, stamp);
@@ -664,10 +993,33 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
     row->setProperty("selecting", m_msgSelectMode);
     connect(check, &QCheckBox::toggled, this, [this]() { updateMsgSelectCount(); });
 
+    if (msgType == im::MsgType::Sys)
+    {
+        check->hide();
+        lay->addStretch();
+        auto *lab = new QLabel(previewOf(im::MsgType::Sys, text), row);
+        lab->setAlignment(Qt::AlignCenter);
+        lab->setWordWrap(true);
+        lab->setStyleSheet(QStringLiteral("color:#9A9A9A; font-size:12px; background:transparent; padding:4px 12px;"));
+        lay->addWidget(lab, 1);
+        lay->addStretch();
+        if (m_historyInsertAt >= 0)
+            m_bubbleLay->insertWidget(m_historyInsertAt++, row);
+        else if (m_buildingHistory)
+            m_bubbleLay->addWidget(row);
+        else
+            m_bubbleLay->insertWidget(m_bubbleLay->count() - 1, row);
+        return row;
+    }
+
     auto *avatar = new QLabel(row);
     avatar->setFixedSize(36, 36);
-    const qint64 avatarUid = mine ? m_uid : m_currentPeerUid;
-    avatar->setPixmap(peerAvatar(avatarUid, name, 36));
+    const qint64 avUid = mine ? m_uid
+        : (m_currentConv.isGroup() ? fromUid : m_currentConv.id);
+    if (avUid > 0)
+        AvatarCache::instance().applyTo(avatar, avUid, avatarVerOf(avUid), name, 36);
+    else
+        avatar->setPixmap(UiHelpers::avatarPixmap(name, 36));
 
     auto *actionSlot = new QWidget(row);
     actionSlot->setFixedSize(22, 22);
@@ -783,6 +1135,46 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
     else if (msgType == im::MsgType::Card)
     {
         const QJsonObject o = QJsonDocument::fromJson(text.toUtf8()).object();
+        const QString kind = o.value(QStringLiteral("kind")).toString();
+        if (kind == QLatin1String("group"))
+        {
+            const qint64 gid = Proto::jsonI64(o, QStringLiteral("group_id"));
+            QString gname = o.value(QStringLiteral("name")).toString();
+            if (gname.isEmpty())
+                gname = QStringLiteral("群聊");
+            const int members = o.value(QStringLiteral("member_count")).toInt();
+            const int ver = o.value(QStringLiteral("avatar_ver")).toInt();
+            auto *box = new ClickWidget(row);
+            box->setStyleSheet(QStringLiteral("background:#FFFFFF; border:none; border-radius:4px;"));
+            auto *h = new QHBoxLayout(box);
+            h->setContentsMargins(10, 8, 12, 8);
+            h->setSpacing(8);
+            auto *av = new QLabel(box);
+            av->setFixedSize(40, 40);
+            AvatarCache::instance().applyToGroup(av, gid, ver, gname, 40);
+            auto *info = new QVBoxLayout();
+            info->setContentsMargins(0, 0, 0, 0);
+            info->setSpacing(2);
+            auto *n = new QLabel(gname, box);
+            n->setStyleSheet(QStringLiteral("color:#191919; font-size:13px; background:transparent;"));
+            auto *a = new QLabel(QStringLiteral("群号  %1 · %2人").arg(gid).arg(members), box);
+            a->setStyleSheet(QStringLiteral("color:#888888; font-size:11px; background:transparent;"));
+            info->addWidget(n);
+            info->addWidget(a);
+            h->addWidget(av);
+            h->addLayout(info, 1);
+            box->setFixedWidth(220);
+            box->onClick = [this, o]() { handleGroupCard(o); };
+            box->onForward = [this, text, gname]() {
+                forwardPayload(im::MsgType::Card, text, QStringLiteral("[群聊] %1").arg(gname));
+            };
+            box->onFavorite = [this, text]() {
+                favoritePayload(im::MsgType::Card, text);
+            };
+            bubble = box;
+        }
+        else
+        {
         const qint64 cardUid = Proto::jsonI64(o, QStringLiteral("uid"));
         QString cardName = o.value(QStringLiteral("nickname")).toString();
         const QString acc = o.value(QStringLiteral("account")).toString();
@@ -820,6 +1212,7 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
             favoritePayload(im::MsgType::Card, text);
         };
         bubble = box;
+        }
     }
     else
     {
@@ -861,7 +1254,8 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
     if (mine)
     {
         row->setProperty("outgoingId", QVariant::fromValue(msgId));
-        row->setProperty("outgoingPeer", QVariant::fromValue(peerUid > 0 ? peerUid : m_currentPeerUid));
+        row->setProperty("outgoingPeer", QVariant::fromValue(peerUid > 0 ? peerUid : m_currentConv.id));
+        row->setProperty("outgoingConvType", m_currentConv.type);
         row->setProperty("outgoingType", msgType);
         row->setProperty("outgoingContent", text);
         lay->addWidget(check, 0, Qt::AlignVCenter);
@@ -874,7 +1268,23 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
     {
         lay->addWidget(check, 0, Qt::AlignVCenter);
         lay->addWidget(avatar, 0, Qt::AlignTop);
-        lay->addWidget(bubble, 0, Qt::AlignTop);
+        if (m_currentConv.isGroup() && bubble)
+        {
+            auto *col = new QWidget(row);
+            col->setStyleSheet(QStringLiteral("background:transparent;"));
+            auto *colLay = new QVBoxLayout(col);
+            colLay->setContentsMargins(0, 0, 0, 0);
+            colLay->setSpacing(2);
+            auto *nick = new QLabel(name, col);
+            nick->setStyleSheet(QStringLiteral("color:#888888; font-size:11px; background:transparent;"));
+            colLay->addWidget(nick);
+            colLay->addWidget(bubble, 0, Qt::AlignLeft);
+            lay->addWidget(col, 0, Qt::AlignTop);
+        }
+        else
+        {
+            lay->addWidget(bubble, 0, Qt::AlignTop);
+        }
         if (fileBubble)
             lay->addWidget(actionSlot, 0, Qt::AlignVCenter);
         lay->addStretch();
@@ -882,7 +1292,9 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
             actionSlot->hide();
     }
 
-    if (m_buildingHistory)
+    if (m_historyInsertAt >= 0)
+        m_bubbleLay->insertWidget(m_historyInsertAt++, row);
+    else if (m_buildingHistory)
         m_bubbleLay->addWidget(row);
     else
         m_bubbleLay->insertWidget(m_bubbleLay->count() - 1, row);
@@ -897,7 +1309,7 @@ QWidget *Client::addBubble(bool mine, const QString &name, const QString &text,
         setBubbleSendState(row, true);
     else if (sendStatus == LocalMsg::Sending)
         setBubbleSendState(row, false, true);
-    if (!m_buildingHistory)
+    if (!m_buildingHistory && m_historyInsertAt < 0)
     {
         if (msgId != 0)
         {

+ 346 - 73
Client/src/mainframe/ClientConv.cpp

@@ -1,12 +1,29 @@
 // ClientConv.cpp — 会话/好友列表、资料页、本地会话存储
 #include "ClientImpl.h"
+#include "GroupProfileDialog.h"
 
-QListWidgetItem *Client::findConvItem(qint64 peerUid) const
+namespace {
+void writeConvItem(QListWidgetItem *item, const Conv &c, qint64 ts)
+{
+    item->setData(RolePeerUid, c.uid);
+    item->setData(RoleConvType, c.convType);
+    item->setData(RoleName, c.name);
+    item->setData(RoleUnread, c.unread);
+    item->setData(RolePreview, c.preview);
+    item->setData(RoleTs, ts);
+    item->setData(RoleAvatarVer, c.avatarVer);
+    item->setData(RoleOnline, c.online ? 1 : 0);
+    item->setData(RoleMemberCount, c.memberCount);
+}
+}  // namespace
+
+QListWidgetItem *Client::findConvItem(qint64 peerUid, int convType) const
 {
     for (int i = 0; i < m_convList->count(); ++i)
     {
         auto *item = m_convList->item(i);
-        if (item->data(RolePeerUid).toLongLong() == peerUid)
+        if (item->data(RolePeerUid).toLongLong() == peerUid
+            && item->data(RoleConvType).toInt() == convType)
             return item;
     }
     return nullptr;
@@ -52,14 +69,20 @@ void Client::refreshFriendList()
 
 void Client::applyConvList(const QJsonArray &list)
 {
-    const qint64 keep = m_currentPeerUid;
+    const ConvId keep = m_currentConv;
     m_convList->clear();
     for (const QJsonValue &v : list)
     {
         const QJsonObject o = v.toObject();
         Conv c;
-        c.uid = Proto::jsonI64(o, QStringLiteral("peer_uid"));
+        c.convType = o.value(QStringLiteral("conv_type")).toInt();
+        if (c.convType == ConvGroup)
+            c.uid = Proto::jsonI64(o, QStringLiteral("group_id"));
+        else
+            c.uid = Proto::jsonI64(o, QStringLiteral("peer_uid"));
         c.name = o.value(QStringLiteral("nickname")).toString();
+        if (c.name.isEmpty())
+            c.name = o.value(QStringLiteral("name")).toString();
         if (c.name.isEmpty())
             c.name = o.value(QStringLiteral("account")).toString();
         c.preview = o.value(QStringLiteral("preview")).toString();
@@ -67,22 +90,19 @@ void Client::applyConvList(const QJsonArray &list)
         c.unread = o.value(QStringLiteral("unread")).toInt();
         c.avatarVer = o.value(QStringLiteral("avatar_ver")).toInt();
         c.online = o.value(QStringLiteral("online")).toInt() == 1;
+        c.memberCount = o.value(QStringLiteral("member_count")).toInt();
         auto *item = new QListWidgetItem(m_convList);
-        item->setData(RolePeerUid, c.uid);
-        item->setData(RoleName, c.name);
-        item->setData(RoleUnread, c.unread);
-        item->setData(RolePreview, c.preview);
-        item->setData(RoleTs, Proto::jsonI64(o, QStringLiteral("ts")));
-        item->setData(RoleAvatarVer, c.avatarVer);
-        item->setData(RoleOnline, c.online ? 1 : 0);
+        writeConvItem(item, c, Proto::jsonI64(o, QStringLiteral("ts")));
+        item->setData(RoleJoinMode, o.value(QStringLiteral("join_mode")).toInt());
         item->setSizeHint(QSize(10, 68));
         m_convList->setItemWidget(item, makeConvRow(c, m_convList));
-        persistLocalConv(c.uid, c.name, c.preview, Proto::jsonI64(o, QStringLiteral("ts")), c.unread);
+        persistLocalConv(c.uid, c.name, c.preview, Proto::jsonI64(o, QStringLiteral("ts")),
+                         c.unread, c.convType);
     }
     applyListFilter();
-    if (keep > 0)
+    if (keep.isValid())
     {
-        if (auto *item = findConvItem(keep))
+        if (auto *item = findConvItem(keep.id, keep.type))
         {
             m_convList->blockSignals(true);
             m_convList->setCurrentItem(item);
@@ -118,7 +138,7 @@ void Client::applyFriendList(const QJsonArray &list)
         item->setData(RoleOnline, online ? 1 : 0);
         item->setSizeHint(QSize(10, 48));
         m_friendList->setItemWidget(item, makeFriendRow(name, online, uid, avatarVer, m_friendList));
-        if (auto *conv = findConvItem(uid))
+        if (auto *conv = findConvItem(uid, ConvDm))
         {
             const int oldVer = conv->data(RoleAvatarVer).toInt();
             const bool oldOnline = conv->data(RoleOnline).toInt() == 1;
@@ -140,33 +160,33 @@ void Client::applyFriendList(const QJsonArray &list)
 }
 
 void Client::upsertConv(qint64 peerUid, const QString &name, const QString &preview,
-                        qint64 ts, int unread)
+                        qint64 ts, int unread, int convType)
 {
     Conv c;
     c.uid = peerUid;
+    c.convType = convType;
     c.name = name;
     c.preview = preview;
     c.time = formatTs(ts);
     c.unread = unread;
-    if (auto *exist = findConvItem(peerUid))
+    if (auto *exist = findConvItem(peerUid, convType))
     {
         c.avatarVer = exist->data(RoleAvatarVer).toInt();
         c.online = exist->data(RoleOnline).toInt() == 1;
+        c.memberCount = exist->data(RoleMemberCount).toInt();
+    }
+    if (c.convType != ConvGroup)
+    {
+        if (c.avatarVer <= 0)
+            c.avatarVer = avatarVerOf(peerUid);
+        if (!c.online)
+            c.online = listOnlineOf(m_friendList, peerUid);
     }
-    if (c.avatarVer <= 0)
-        c.avatarVer = avatarVerOf(peerUid);
-    if (!c.online)
-        c.online = listOnlineOf(m_friendList, peerUid);
-    const qint64 keepUid = m_currentPeerUid;
+    const ConvId keep = m_currentConv;
     m_convList->blockSignals(true);
-    if (auto *item = findConvItem(peerUid))
+    if (auto *item = findConvItem(peerUid, convType))
     {
-        item->setData(RoleName, name);
-        item->setData(RoleUnread, unread);
-        item->setData(RolePreview, preview);
-        item->setData(RoleTs, ts);
-        item->setData(RoleAvatarVer, c.avatarVer);
-        item->setData(RoleOnline, c.online ? 1 : 0);
+        writeConvItem(item, c, ts);
         const int row = m_convList->row(item);
         if (row > 0)
         {
@@ -180,24 +200,18 @@ void Client::upsertConv(qint64 peerUid, const QString &name, const QString &prev
     else
     {
         auto *created = new QListWidgetItem;
-        created->setData(RolePeerUid, peerUid);
-        created->setData(RoleName, name);
-        created->setData(RoleUnread, unread);
-        created->setData(RolePreview, preview);
-        created->setData(RoleTs, ts);
-        created->setData(RoleAvatarVer, c.avatarVer);
-        created->setData(RoleOnline, c.online ? 1 : 0);
+        writeConvItem(created, c, ts);
         created->setSizeHint(QSize(10, 68));
         m_convList->insertItem(0, created);
         m_convList->setItemWidget(created, makeConvRow(c, m_convList));
     }
-    if (keepUid > 0)
+    if (keep.isValid())
     {
-        if (auto *keep = findConvItem(keepUid))
-            m_convList->setCurrentItem(keep);
+        if (auto *keepItem = findConvItem(keep.id, keep.type))
+            m_convList->setCurrentItem(keepItem);
     }
     m_convList->blockSignals(false);
-    persistLocalConv(peerUid, name, preview, ts, unread);
+    persistLocalConv(peerUid, name, preview, ts, unread, convType);
     applyListFilter();
     updateTray();
 }
@@ -209,12 +223,13 @@ void Client::selectConversation(int row)
     auto *item = m_convList->item(row);
     if (!item)
         return;
-    openChat(item->data(RolePeerUid).toLongLong(), item->data(RoleName).toString());
+    openChat(item->data(RolePeerUid).toLongLong(), item->data(RoleName).toString(),
+             item->data(RoleConvType).toInt());
 }
 
-void Client::applyUnread(qint64 peerUid, int unread)
+void Client::applyUnread(qint64 peerUid, int unread, int convType)
 {
-    auto *item = findConvItem(peerUid);
+    auto *item = findConvItem(peerUid, convType);
     if (!item)
         return;
     item->setData(RoleUnread, unread);
@@ -223,7 +238,7 @@ void Client::applyUnread(qint64 peerUid, int unread)
     if (QWidget *old = m_convList->itemWidget(item))
         old->deleteLater();
     m_convList->setItemWidget(item, makeConvRow(c, m_convList));
-    LocalStore::instance().setUnread(peerUid, unread);
+    LocalStore::instance().setUnread(peerUid, convType, unread);
     updateTray();
 }
 
@@ -233,6 +248,7 @@ void Client::onConvMenu(const QPoint &pos)
     if (!item)
         return;
     const qint64 uid = item->data(RolePeerUid).toLongLong();
+    const int convType = item->data(RoleConvType).toInt();
     const QString name = item->data(RoleName).toString();
     const int unread = item->data(RoleUnread).toInt();
     QMenu menu(this);
@@ -248,10 +264,13 @@ void Client::onConvMenu(const QPoint &pos)
     if (picked == del)
     {
         if (!ConfirmDialog::ask(this, QStringLiteral("删除聊天"),
-                                QStringLiteral("删除后只清空你这边与「%1」的聊天记录,对方仍可看到")
-                                    .arg(name)))
+                                convType == ConvGroup
+                                    ? QStringLiteral("删除后只清空你这边「%1」的聊天记录,不会退出群聊")
+                                          .arg(name)
+                                    : QStringLiteral("删除后只清空你这边与「%1」的聊天记录,对方仍可看到")
+                                          .arg(name)))
             return;
-        ImClient::instance().deleteConv(uid, [self, uid](const QJsonObject &reply) {
+        ImClient::instance().deleteConv(uid, convType, [self, uid, convType](const QJsonObject &reply) {
             if (!self)
                 return;
             if (!ImClient::ok(reply))
@@ -259,12 +278,12 @@ void Client::onConvMenu(const QPoint &pos)
                 UiHelpers::showToast(self, ImClient::messageOf(reply), true);
                 return;
             }
-            self->removeLocalConv(uid);
+            self->removeLocalConv(uid, convType);
         });
         return;
     }
     const int next = (picked == read) ? 0 : 1;
-    ImClient::instance().setUnread(uid, next, [self, uid, next](const QJsonObject &reply) {
+    ImClient::instance().setUnread(uid, convType, next, [self, uid, convType, next](const QJsonObject &reply) {
         if (!self)
             return;
         if (!ImClient::ok(reply))
@@ -272,7 +291,7 @@ void Client::onConvMenu(const QPoint &pos)
             UiHelpers::showToast(self, ImClient::messageOf(reply), true);
             return;
         }
-        self->applyUnread(uid, next);
+        self->applyUnread(uid, next, convType);
     });
 }
 
@@ -320,14 +339,14 @@ void Client::onFriendMenu(const QPoint &pos)
                 ? reply.value(QStringLiteral("nickname")).toString()
                 : next;
             self->refreshFriendList();
-            if (auto *it = self->findConvItem(uid))
+            if (auto *it = self->findConvItem(uid, ConvDm))
             {
                 const qint64 ts = it->data(RoleTs).toLongLong();
                 self->upsertConv(uid, shown.isEmpty() ? it->data(RoleName).toString() : shown,
                                  it->data(RolePreview).toString(), ts,
-                                 it->data(RoleUnread).toInt());
+                                 it->data(RoleUnread).toInt(), ConvDm);
             }
-            if (self->m_currentPeerUid == uid && !shown.isEmpty())
+            if (self->m_currentConv == ConvId{ConvDm, uid} && !shown.isEmpty())
             {
                 self->m_currentPeerName = shown;
                 if (self->m_chatTitle)
@@ -351,7 +370,7 @@ void Client::onFriendMenu(const QPoint &pos)
                 UiHelpers::showToast(self, ImClient::messageOf(reply), true);
                 return;
             }
-            self->removeLocalConv(uid);
+            self->removeLocalConv(uid, ConvDm);
             self->refreshFriendList();
         });
     }
@@ -501,50 +520,52 @@ void Client::fillProfilePage(const QJsonObject &user)
         m_profileShareBtn->setVisible(m_profileUid > 0 && m_profileUid != im::kFileHelperUid);
 }
 
-void Client::removeLocalConv(qint64 uid)
+void Client::removeLocalConv(qint64 uid, int convType)
 {
-    if (m_currentPeerUid == uid)
+    const ConvId key{convType, uid};
+    if (m_currentConv == key)
     {
-        m_currentPeerUid = 0;
+        m_currentConv = ConvId();
         m_currentPeerName.clear();
-        dropChatPage(uid);
+        dropChatPage(key);
         if (m_chatStack && m_chatEmpty)
             m_chatStack->setCurrentWidget(m_chatEmpty);
     }
     else
     {
-        dropChatPage(uid);
+        dropChatPage(key);
     }
-    if (m_profileUid == uid)
+    if (m_profileUid == uid && convType == ConvDm)
     {
         m_profileUid = 0;
         if (m_chatStack && m_chatStack->currentWidget() == m_profilePage)
         {
-            if (m_currentPeerUid > 0 && m_chatBody)
+            if (m_currentConv.isValid() && m_chatBody)
                 m_chatStack->setCurrentWidget(m_chatBody);
             else if (m_chatEmpty)
                 m_chatStack->setCurrentWidget(m_chatEmpty);
         }
     }
-    if (auto *it = findConvItem(uid))
+    if (auto *it = findConvItem(uid, convType))
         delete it;
-    LocalStore::instance().deletePeer(uid);
+    LocalStore::instance().deletePeer(uid, convType);
     applyListFilter();
     updateTray();
 }
 
-void Client::markPeerRead(qint64 peerUid)
+void Client::markPeerRead(qint64 peerUid, int convType)
 {
     if (peerUid <= 0)
         return;
-    auto *item = findConvItem(peerUid);
+    auto *item = findConvItem(peerUid, convType);
     if (!item || item->data(RoleUnread).toInt() != 0)
-        applyUnread(peerUid, 0);
-    ImClient::instance().setUnread(peerUid, 0, {});
+        applyUnread(peerUid, 0, convType);
+    ImClient::instance().setUnread(peerUid, convType, 0, {});
 }
 
 void Client::saveLocalMessage(qint64 msgId, qint64 peerUid, qint64 fromUid, qint64 toUid,
-                              int msgType, const QString &content, qint64 ts, int status)
+                              int msgType, const QString &content, qint64 ts, int status,
+                              int convType, const QString &fromName)
 {
     if (msgId == 0 || peerUid <= 0)
         return;
@@ -553,18 +574,23 @@ void Client::saveLocalMessage(qint64 msgId, qint64 peerUid, qint64 fromUid, qint
     m.peerUid = peerUid;
     m.fromUid = fromUid;
     m.toUid = toUid > 0 ? toUid : ((fromUid == m_uid) ? peerUid : m_uid);
+    m.convType = convType;
     m.msgType = msgType;
     m.content = content;
     m.ts = ts;
     m.status = status;
+    m.fromName = fromName;
+    if (m.fromName.isEmpty() && fromUid == m_uid)
+        m.fromName = m_nickname;
     LocalStore::instance().upsertMessage(m);
 }
 
 void Client::persistLocalConv(qint64 peerUid, const QString &name, const QString &preview,
-                              qint64 ts, int unread)
+                              qint64 ts, int unread, int convType)
 {
     LocalStore::Conv c;
     c.peerUid = peerUid;
+    c.convType = convType;
     c.name = name;
     c.preview = preview;
     c.ts = ts;
@@ -581,7 +607,11 @@ void Client::loadLocalConvs()
     for (const auto &c : rows)
     {
         QJsonObject o;
-        o.insert(QStringLiteral("peer_uid"), c.peerUid);
+        o.insert(QStringLiteral("conv_type"), c.convType);
+        if (c.convType == ConvGroup)
+            o.insert(QStringLiteral("group_id"), c.peerUid);
+        else
+            o.insert(QStringLiteral("peer_uid"), c.peerUid);
         o.insert(QStringLiteral("nickname"), c.name);
         o.insert(QStringLiteral("preview"), c.preview);
         o.insert(QStringLiteral("ts"), c.ts);
@@ -591,9 +621,9 @@ void Client::loadLocalConvs()
     applyConvList(arr);
 }
 
-bool Client::isViewingPeer(qint64 peerUid) const
+bool Client::isViewingPeer(qint64 peerUid, int convType) const
 {
-    return peerUid > 0 && peerUid == m_currentPeerUid
+    return peerUid > 0 && m_currentConv == ConvId{convType, peerUid}
         && m_chatStack && m_chatStack->currentWidget() == m_chatBody;
 }
 
@@ -640,6 +670,8 @@ void Client::openAddFriend()
 {
     AddFriendDialog dlg(this);
     dlg.exec();
+    if (dlg.joinedGroupId() > 0)
+        openChat(dlg.joinedGroupId(), dlg.joinedGroupName(), ConvGroup);
     refreshFriendList();
 }
 
@@ -711,7 +743,7 @@ void Client::applyPresence(qint64 uid, bool online)
                                     item->data(RoleAvatarVer).toInt(), m_friendList));
         }
     }
-    if (auto *conv = findConvItem(uid))
+    if (auto *conv = findConvItem(uid, ConvDm))
     {
         const bool old = conv->data(RoleOnline).toInt() == 1;
         if (old != online)
@@ -726,3 +758,244 @@ void Client::applyPresence(qint64 uid, bool online)
     }
 }
 
+void Client::showGroupProfile(qint64 groupId)
+{
+    if (groupId <= 0)
+        return;
+    GroupProfileDialog dlg(groupId, this);
+    connect(&dlg, &GroupProfileDialog::shareRequested, this,
+            [this](qint64 gid, const QString &name, int n) { shareGroupCard(gid, name, n); });
+    connect(&dlg, &GroupProfileDialog::memberClicked, this, [this](qint64 uid) {
+        if (uid > 0)
+            showProfile(uid);
+    });
+    connect(&dlg, &GroupProfileDialog::leftOrDismissed, this,
+            [this](qint64 gid) { removeLocalConv(gid, ConvGroup); });
+    dlg.exec();
+}
+
+void Client::openCreateGroup()
+{
+    QPointer<Client> self(this);
+    ImClient::instance().friendList([self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        const QJsonArray list = reply.value(QStringLiteral("list")).toArray();
+        QTimer::singleShot(0, self.data(), [self, list]() {
+            if (!self)
+                return;
+            PickFriendDialog pick(list, self->m_uid, self, QStringLiteral("发起群聊"), false, true);
+            if (pick.exec() != QDialog::Accepted)
+                return;
+            QVector<qint64> uids;
+            QStringList names;
+            names << self->m_nickname;
+            for (const QJsonValue &v : pick.selectedList())
+            {
+                const QJsonObject o = v.toObject();
+                const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
+                if (uid <= 0)
+                    continue;
+                uids.push_back(uid);
+                QString n = o.value(QStringLiteral("remark")).toString();
+                if (n.isEmpty())
+                    n = o.value(QStringLiteral("nickname")).toString();
+                if (n.isEmpty())
+                    n = o.value(QStringLiteral("account")).toString();
+                names << n;
+            }
+            if (uids.isEmpty())
+            {
+                UiHelpers::showToast(self, QStringLiteral("请选择至少一位好友"), true);
+                return;
+            }
+            QString def = names.join(QStringLiteral("、"));
+            const int maxLen = qMax(1, AppConfig::instance().groupNameMax());
+            if (def.size() > maxLen)
+                def = def.left(maxLen);
+            QString name = def;
+            if (!PromptDialog::ask(self, QStringLiteral("群聊名称"), QStringLiteral("不填则使用默认名称"),
+                                   &name, maxLen))
+                return;
+            name = name.trimmed();
+            ImClient::instance().groupCreate(name, uids, [self](const QJsonObject &reply) {
+                if (!self)
+                    return;
+                if (!ImClient::ok(reply))
+                {
+                    UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                    return;
+                }
+                const qint64 gid = Proto::jsonI64(reply, QStringLiteral("group_id"));
+                QString gname = reply.value(QStringLiteral("name")).toString();
+                if (gname.isEmpty())
+                    gname = QStringLiteral("群聊");
+                self->openChat(gid, gname, ConvGroup);
+            });
+        });
+    });
+}
+
+void Client::openGroupList()
+{
+    GroupListDialog dlg(this);
+    if (dlg.exec() != QDialog::Accepted)
+        return;
+    if (dlg.groupId() > 0)
+        openChat(dlg.groupId(), dlg.groupName(), ConvGroup);
+}
+
+void Client::shareGroupCard(qint64 groupId, const QString &name, int memberCount, QWidget *host)
+{
+    if (groupId <= 0)
+        return;
+    QJsonObject content;
+    content.insert(QStringLiteral("kind"), QStringLiteral("group"));
+    content.insert(QStringLiteral("group_id"), groupId);
+    content.insert(QStringLiteral("name"), name);
+    content.insert(QStringLiteral("member_count"), memberCount);
+    const int ver = AvatarCache::instance().knownGroupVer(groupId);
+    if (ver > 0)
+        content.insert(QStringLiteral("avatar_ver"), ver);
+    forwardPayload(im::MsgType::Card,
+                   QString::fromUtf8(QJsonDocument(content).toJson(QJsonDocument::Compact)),
+                   QStringLiteral("[群聊] %1").arg(name), host);
+}
+
+void Client::handleGroupCard(const QJsonObject &card)
+{
+    const qint64 gid = Proto::jsonI64(card, QStringLiteral("group_id"));
+    if (gid <= 0)
+        return;
+    QString name = card.value(QStringLiteral("name")).toString();
+    QPointer<Client> self(this);
+    ImClient::instance().groupSearch(gid, [self, gid, name](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        QString gname = reply.value(QStringLiteral("name")).toString();
+        if (gname.isEmpty())
+            gname = name;
+        const int role = reply.value(QStringLiteral("role")).toInt(-1);
+        if (role >= 0)
+        {
+            self->openChat(gid, gname, ConvGroup);
+            return;
+        }
+        self->joinGroupById(gid, gname);
+    });
+}
+
+void Client::joinGroupById(qint64 groupId, const QString &name)
+{
+    if (groupId <= 0)
+        return;
+    QPointer<Client> self(this);
+    ImClient::instance().groupSearch(groupId, [self, groupId, name](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        QString gname = reply.value(QStringLiteral("name")).toString();
+        if (gname.isEmpty())
+            gname = name.isEmpty() ? QStringLiteral("群聊") : name;
+        const int role = reply.value(QStringLiteral("role")).toInt(-1);
+        if (role >= 0)
+        {
+            self->openChat(groupId, gname, ConvGroup);
+            return;
+        }
+        const int mode = reply.value(QStringLiteral("join_mode")).toInt();
+        if (mode == im::JoinMode::Closed)
+        {
+            UiHelpers::showToast(self, QStringLiteral("该群不允许加入"), true);
+            return;
+        }
+        QString message;
+        if (mode == im::JoinMode::Approve)
+        {
+            if (!PromptDialog::ask(self, QStringLiteral("申请加群"), QStringLiteral("验证信息(选填)"),
+                                   &message, 50, QStringLiteral("发送"), QStringLiteral("取消")))
+                return;
+        }
+        ImClient::instance().groupJoin(groupId, message, [self, groupId, gname](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            if (reply.value(QStringLiteral("joined")).toInt() == 1)
+            {
+                self->openChat(groupId, gname, ConvGroup);
+                return;
+            }
+            const QString msg = reply.value(QStringLiteral("msg")).toString();
+            UiHelpers::showToast(self, msg.isEmpty() ? QStringLiteral("已发送入群申请") : msg);
+        });
+    });
+}
+
+void Client::onGroupRequestNotify(const QJsonObject &msg)
+{
+    QString from = msg.value(QStringLiteral("from_nickname")).toString();
+    if (from.isEmpty())
+        from = msg.value(QStringLiteral("from_account")).toString();
+    QString gname = msg.value(QStringLiteral("name")).toString();
+    if (gname.isEmpty())
+        gname = QStringLiteral("群聊");
+    UiHelpers::showToast(this, QStringLiteral("%1 申请加入「%2」").arg(from, gname));
+    UiHelpers::playNotifySound();
+}
+
+void Client::onGroupChangeNotify(const QJsonObject &msg)
+{
+    const QString event = msg.value(QStringLiteral("event")).toString();
+    const qint64 gid = Proto::jsonI64(msg, QStringLiteral("group_id"));
+    const qint64 uid = Proto::jsonI64(msg, QStringLiteral("uid"));
+    QString gname = msg.value(QStringLiteral("name")).toString();
+    if (gname.isEmpty())
+        gname = QStringLiteral("群聊");
+    const bool meOut = (event == QLatin1String("dismiss"))
+        || (event == QLatin1String("kick") && uid == m_uid)
+        || (event == QLatin1String("leave") && uid == m_uid);
+    if (meOut && gid > 0)
+    {
+        if (event == QLatin1String("dismiss"))
+            UiHelpers::showToast(this, QStringLiteral("「%1」已解散").arg(gname));
+        else if (event == QLatin1String("kick"))
+            UiHelpers::showToast(this, QStringLiteral("你已被移出「%1」").arg(gname));
+        removeLocalConv(gid, ConvGroup);
+        return;
+    }
+    if (event == QLatin1String("info") && gid > 0)
+    {
+        const int avatarVer = msg.value(QStringLiteral("avatar_ver")).toInt();
+        if (auto *item = findConvItem(gid, ConvGroup))
+        {
+            if (avatarVer > 0)
+                item->setData(RoleAvatarVer, avatarVer);
+            const qint64 ts = item->data(RoleTs).toLongLong();
+            upsertConv(gid, gname, item->data(RolePreview).toString(), ts,
+                       item->data(RoleUnread).toInt(), ConvGroup);
+        }
+        if (m_currentConv == ConvId{ConvGroup, gid} && m_chatTitle)
+            m_chatTitle->setText(gname);
+    }
+    if (event == QLatin1String("invite") && uid != m_uid && gid > 0)
+        refreshConvList();
+}
+

+ 1 - 0
Client/src/mainframe/ClientImpl.h

@@ -9,6 +9,7 @@
 #include "SettingsDialog.h"
 #include "FavoritePanel.h"
 #include "PickFriendDialog.h"
+#include "GroupProfileDialog.h"
 #include "StickerCache.h"
 #include "AvatarCache.h"
 #include "FileCache.h"

+ 48 - 2
Client/src/mainframe/ClientInternal.h

@@ -7,6 +7,7 @@
 #include "FileTypeIcon.h"
 #include "LocalStore.h"
 #include "UiHelpers.h"
+#include "common/protocol_def.h"
 
 #include <functional>
 
@@ -21,10 +22,12 @@
 #include <QEvent>
 #include <QFile>
 #include <QFileDialog>
+#include <QFont>
 #include <QFontMetrics>
 #include <QFrame>
 #include <QGridLayout>
 #include <QHBoxLayout>
+#include <QHash>
 #include <QIcon>
 #include <QImage>
 #include <QLabel>
@@ -63,9 +66,42 @@ namespace ClientInternal {
         RoleRemark = Qt::UserRole + 7,
         RoleNick = Qt::UserRole + 8,
         RoleAvatarVer = Qt::UserRole + 9,
-        RoleOnline = Qt::UserRole + 10
+        RoleOnline = Qt::UserRole + 10,
+        RoleConvType = Qt::UserRole + 11,
+        RoleMemberCount = Qt::UserRole + 12,
+        RoleJoinMode = Qt::UserRole + 13
     };
 
+    constexpr int ConvDm = im::ConvType::Direct;
+    constexpr int ConvGroup = im::ConvType::Group;
+
+    struct ConvId
+    {
+        int type = ConvDm;
+        qint64 id = 0;
+        bool isGroup() const { return type == ConvGroup; }
+        bool isValid() const { return id > 0; }
+        bool operator==(const ConvId &o) const { return type == o.type && id == o.id; }
+        bool operator!=(const ConvId &o) const { return !(*this == o); }
+    };
+
+#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
+    inline uint qHash(const ConvId &c, uint seed = 0)
+    {
+        return ::qHash(static_cast<uint>(c.type), seed) ^ ::qHash(static_cast<quint64>(c.id));
+    }
+#else
+    inline size_t qHash(const ConvId &c, size_t seed = 0)
+    {
+        return ::qHash(static_cast<uint>(c.type), seed) ^ ::qHash(static_cast<quint64>(c.id), seed);
+    }
+#endif
+
+    inline QPixmap groupIcon(int size)
+    {
+        return UiHelpers::groupIconPixmap(size);
+    }
+
     constexpr int kReconnectMaxTries = 3;
     constexpr int kReconnectIntervalSec = 10;
 
@@ -110,12 +146,14 @@ namespace ClientInternal {
     struct Conv
     {
         qint64 uid = 0;
+        int convType = ConvDm;
         QString name;
         QString preview;
         QString time;
         int unread = 0;
         int avatarVer = 0;
         bool online = false;
+        int memberCount = 0;
     };
 
     inline QString formatTs(qint64 ms)
@@ -858,12 +896,14 @@ namespace ClientInternal {
     {
         Conv c;
         c.uid = item->data(RolePeerUid).toLongLong();
+        c.convType = item->data(RoleConvType).toInt();
         c.name = item->data(RoleName).toString();
         c.preview = item->data(RolePreview).toString();
         c.time = formatTs(item->data(RoleTs).toLongLong());
         c.unread = item->data(RoleUnread).toInt();
         c.avatarVer = item->data(RoleAvatarVer).toInt();
         c.online = item->data(RoleOnline).toInt() == 1;
+        c.memberCount = item->data(RoleMemberCount).toInt();
         return c;
     }
 
@@ -934,8 +974,14 @@ namespace ClientInternal {
         avatarBox->setFixedSize(44, 44);
         auto *avatar = new QLabel(avatarBox);
         avatar->setGeometry(0, 0, 40, 40);
-        avatar->setPixmap(AvatarCache::instance().pixmap(c.uid, c.avatarVer, c.name, 40));
+        avatar->setPixmap(c.convType == ConvGroup
+            ? groupIcon(40)
+            : AvatarCache::instance().pixmap(c.uid, c.avatarVer, c.name, 40));
         avatar->setScaledContents(false);
+        if (c.convType == ConvGroup)
+            AvatarCache::instance().applyToGroup(avatar, c.uid, c.avatarVer, c.name, 40);
+        else
+            AvatarCache::instance().applyTo(avatar, c.uid, c.avatarVer, c.name, 40);
         if (c.online)
         {
             auto *dot = makeOnlineDot(avatarBox, true);

+ 141 - 50
Client/src/mainframe/ClientSend.cpp

@@ -3,7 +3,7 @@
 
 void Client::sendCurrentMessage()
 {
-    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0)
+    if (!m_chatBody->isVisible() || m_currentConv.id <= 0)
         return;
     const QString text = m_input->toPlainText().trimmed();
     if (text.isEmpty())
@@ -16,22 +16,39 @@ void Client::sendCurrentMessage()
     }
 
     m_input->clear();
-    sendOutgoing(m_currentPeerUid, m_currentPeerName, im::MsgType::Text, text,
-                 previewOf(im::MsgType::Text, text));
+    sendOutgoing(m_currentConv.id, m_currentPeerName, im::MsgType::Text, text,
+                 previewOf(im::MsgType::Text, text), nullptr, 0, m_currentConv.type);
 }
 
 void Client::onChatNotify(const QJsonObject &msg)
 {
     const qint64 from = Proto::jsonI64(msg, QStringLiteral("from_uid"));
     const qint64 to = Proto::jsonI64(msg, QStringLiteral("to_uid"));
-    const qint64 peer = (from == m_uid) ? to : from;
-    QString name = msg.value(QStringLiteral("from_nickname")).toString();
-    if (name.isEmpty())
-        name = msg.value(QStringLiteral("from_account")).toString();
-    if (from == m_uid)
+    const qint64 groupId = Proto::jsonI64(msg, QStringLiteral("group_id"));
+    const int convType = groupId > 0 ? ConvGroup : ConvDm;
+    const qint64 peer = (convType == ConvGroup) ? groupId : ((from == m_uid) ? to : from);
+    QString name;
+    if (convType == ConvGroup)
     {
-        if (auto *item = findConvItem(peer))
-            name = item->data(RoleName).toString();
+        name = msg.value(QStringLiteral("group_name")).toString();
+        if (name.isEmpty())
+        {
+            if (auto *item = findConvItem(peer, convType))
+                name = item->data(RoleName).toString();
+        }
+        if (name.isEmpty())
+            name = QStringLiteral("群聊");
+    }
+    else
+    {
+        name = msg.value(QStringLiteral("from_nickname")).toString();
+        if (name.isEmpty())
+            name = msg.value(QStringLiteral("from_account")).toString();
+        if (from == m_uid)
+        {
+            if (auto *item = findConvItem(peer, convType))
+                name = item->data(RoleName).toString();
+        }
     }
     const QString text = msg.value(QStringLiteral("content")).toString();
     int msgType = static_cast<int>(Proto::jsonI64(msg, QStringLiteral("msg_type")));
@@ -41,27 +58,35 @@ void Client::onChatNotify(const QJsonObject &msg)
     qint64 msgId = Proto::jsonI64(msg, QStringLiteral("msg_id"));
     if (msgId <= 0)
         msgId = Proto::jsonI64(msg, QStringLiteral("id"));
-    saveLocalMessage(msgId, peer, from, to, msgType, text, ts);
+    QString fromName = msg.value(QStringLiteral("from_nickname")).toString();
+    if (fromName.isEmpty())
+        fromName = msg.value(QStringLiteral("from_account")).toString();
+    saveLocalMessage(msgId, peer, from, to, msgType, text, ts, LocalMsg::Ok, convType, fromName);
 
     if (from != m_uid && from > 0)
         applyPresence(from, true);
 
-    const bool viewing = isViewingPeer(peer) && isVisible() && !isMinimized();
+    const bool viewing = isViewingPeer(peer, convType) && isVisible() && !isMinimized();
     int unread = 0;
     if (!viewing)
     {
         unread = 1;
-        if (auto *item = findConvItem(peer))
+        if (auto *item = findConvItem(peer, convType))
             unread = item->data(RoleUnread).toInt() + 1;
     }
-    upsertConv(peer, name, previewOf(msgType, text), ts, unread);
-    if (peer != m_currentPeerUid)
-        dropChatPage(peer);
+    upsertConv(peer, name, previewOf(msgType, text), ts, unread, convType);
+    if (ConvId{convType, peer} != m_currentConv)
+        dropChatPage(ConvId{convType, peer});
     if (viewing && from != m_uid)
     {
+        QString bubbleName = fromName;
+        if (bubbleName.isEmpty())
+            bubbleName = senderNameOf(from);
+        if (bubbleName.isEmpty() && convType != ConvGroup)
+            bubbleName = name;
         if (msgId <= 0 || !findMsgRow(msgId))
-            addBubble(false, name, text, formatTs(ts), msgType, 0, msgId, peer);
-        markPeerRead(peer);
+            addBubble(false, bubbleName, text, formatTs(ts), msgType, 0, msgId, peer, from);
+        markPeerRead(peer, convType);
     }
     if (from != m_uid)
     {
@@ -76,6 +101,31 @@ QString Client::previewOf(int msgType, const QString &content) const
     if (msgType == im::MsgType::Sticker)
         return QStringLiteral("[表情]");
     const QJsonObject o = QJsonDocument::fromJson(content.toUtf8()).object();
+    if (msgType == im::MsgType::Sys)
+    {
+        const QString event = o.value(QStringLiteral("event")).toString();
+        const QString nick = o.value(QStringLiteral("nickname")).toString();
+        if (event == QLatin1String("create"))
+            return nick + QStringLiteral(" 创建了群聊");
+        if (event == QLatin1String("join") || event == QLatin1String("invite"))
+            return nick + QStringLiteral(" 加入了群聊");
+        if (event == QLatin1String("leave"))
+            return nick + QStringLiteral(" 退出了群聊");
+        if (event == QLatin1String("kick"))
+            return nick + QStringLiteral(" 被移出群聊");
+        if (event == QLatin1String("transfer"))
+            return nick + QStringLiteral(" 成为新群主");
+        if (event == QLatin1String("dismiss"))
+            return QStringLiteral("群聊已解散");
+        if (event == QLatin1String("info"))
+            return QStringLiteral("群信息已更新");
+        if (event == QLatin1String("role"))
+        {
+            const bool admin = o.value(QStringLiteral("admin")).toInt() != 0;
+            return nick + (admin ? QStringLiteral(" 被设为管理员") : QStringLiteral(" 被取消管理员"));
+        }
+        return content;
+    }
     if (msgType == im::MsgType::File)
     {
         const QString name = o.value(QStringLiteral("name")).toString();
@@ -86,6 +136,11 @@ QString Client::previewOf(int msgType, const QString &content) const
     }
     if (msgType == im::MsgType::Card)
     {
+        if (o.value(QStringLiteral("kind")).toString() == QLatin1String("group"))
+        {
+            QString name = o.value(QStringLiteral("name")).toString();
+            return name.isEmpty() ? QStringLiteral("[群聊]") : QStringLiteral("[群聊] %1").arg(name);
+        }
         QString name = o.value(QStringLiteral("nickname")).toString();
         if (name.isEmpty())
             name = o.value(QStringLiteral("account")).toString();
@@ -96,14 +151,14 @@ QString Client::previewOf(int msgType, const QString &content) const
 
 void Client::sendSticker(qint64 packId, const QString &code)
 {
-    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0 || code.isEmpty())
+    if (!m_chatBody->isVisible() || m_currentConv.id <= 0 || code.isEmpty())
         return;
     QJsonObject body;
     body.insert(QStringLiteral("pack_id"), packId);
     body.insert(QStringLiteral("code"), code);
     const QString content = QString::fromUtf8(QJsonDocument(body).toJson(QJsonDocument::Compact));
-    sendOutgoing(m_currentPeerUid, m_currentPeerName, im::MsgType::Sticker, content,
-                 QStringLiteral("[表情]"));
+    sendOutgoing(m_currentConv.id, m_currentPeerName, im::MsgType::Sticker, content,
+                 QStringLiteral("[表情]"), nullptr, 0, m_currentConv.type);
 }
 
 int Client::avatarVerOf(qint64 uid) const
@@ -129,12 +184,40 @@ int Client::avatarVerOf(qint64 uid) const
         ver = m_profileAvatarVer;
     if (ver <= 0)
     {
-        if (auto *item = findConvItem(uid))
+        if (auto *item = findConvItem(uid, ConvDm))
             ver = item->data(RoleAvatarVer).toInt();
     }
     return ver;
 }
 
+QString Client::senderNameOf(qint64 fromUid, const QString &fromName) const
+{
+    if (fromUid == m_uid)
+        return m_nickname;
+    if (!fromName.isEmpty())
+        return fromName;
+    if (fromUid > 0 && m_friendList)
+    {
+        for (int i = 0; i < m_friendList->count(); ++i)
+        {
+            auto *item = m_friendList->item(i);
+            if (item && item->data(RolePeerUid).toLongLong() == fromUid)
+            {
+                const QString n = item->data(RoleName).toString();
+                if (!n.isEmpty())
+                    return n;
+            }
+        }
+    }
+    if (auto *item = findConvItem(fromUid, ConvDm))
+    {
+        const QString n = item->data(RoleName).toString();
+        if (!n.isEmpty())
+            return n;
+    }
+    return {};
+}
+
 QPixmap Client::peerAvatar(qint64 uid, const QString &name, int size) const
 {
     return AvatarCache::instance().pixmap(uid, avatarVerOf(uid), name, size);
@@ -149,7 +232,7 @@ void Client::refreshAvatar(qint64 uid)
         m_navAvatar->setProperty("avatarVer", selfVer);
         m_navAvatar->setPixmap(peerAvatar(m_uid, m_nickname, 36));
     }
-    if (auto *item = findConvItem(uid))
+    if (auto *item = findConvItem(uid, ConvDm))
     {
         Conv c = convFromItem(item);
         c.avatarVer = qMax(item->data(RoleAvatarVer).toInt(), known);
@@ -186,40 +269,43 @@ void Client::refreshAvatar(qint64 uid)
 
 void Client::sendChatPayload(int msgType, const QString &content, const QString &preview)
 {
-    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0 || content.isEmpty())
+    if (!m_chatBody->isVisible() || m_currentConv.id <= 0 || content.isEmpty())
         return;
-    sendChatPayloadTo(m_currentPeerUid, m_currentPeerName, msgType, content, preview);
+    sendChatPayloadTo(m_currentConv.id, m_currentPeerName, msgType, content, preview,
+                      m_currentConv.type);
 }
 
 void Client::sendChatPayloadTo(qint64 peer, const QString &peerName, int msgType,
-                               const QString &content, const QString &preview)
+                               const QString &content, const QString &preview, int convType)
 {
-    sendOutgoing(peer, peerName, msgType, content, preview);
+    sendOutgoing(peer, peerName, msgType, content, preview, nullptr, 0, convType);
 }
 
 void Client::sendOutgoing(qint64 peer, const QString &peerName, int msgType,
                           const QString &content, const QString &preview,
-                          QWidget *existingRow, qint64 localId)
+                          QWidget *existingRow, qint64 localId, int convType)
 {
     if (peer <= 0 || content.isEmpty())
         return;
     if (localId == 0)
         localId = LocalStore::instance().allocLocalId();
     const qint64 ts = QDateTime::currentMSecsSinceEpoch();
-    saveLocalMessage(localId, peer, m_uid, peer, msgType, content, ts, LocalMsg::Sending);
+    saveLocalMessage(localId, peer, m_uid, peer, msgType, content, ts, LocalMsg::Sending, convType);
     m_inflightLocalIds.insert(localId);
 
     QPointer<QWidget> row = existingRow;
     if (row)
         setBubbleSendState(row, false, true);
-    else if (isViewingPeer(peer))
+    else if (isViewingPeer(peer, convType))
         row = addBubble(true, m_nickname, content, QStringLiteral("刚刚"), msgType,
-                        LocalMsg::Sending, localId, peer);
-    upsertConv(peer, peerName, preview, ts, 0);
+                        LocalMsg::Sending, localId, peer, m_uid);
+    if (row)
+        row->setProperty("outgoingConvType", convType);
+    upsertConv(peer, peerName, preview, ts, 0, convType);
 
     QPointer<Client> self(this);
-    ImClient::instance().chat(peer, msgType, content,
-        [self, row, peer, peerName, content, msgType, preview, localId, ts](const QJsonObject &reply) {
+    ImClient::instance().chat(peer, convType, msgType, content,
+        [self, row, peer, peerName, content, msgType, preview, localId, ts, convType](const QJsonObject &reply) {
             if (!self)
                 return;
             self->m_inflightLocalIds.remove(localId);
@@ -229,7 +315,7 @@ void Client::sendOutgoing(qint64 peer, const QString &peerName, int msgType,
             if (!ImClient::ok(reply))
             {
                 self->saveLocalMessage(localId, peer, self->m_uid, peer, msgType, content,
-                                       ts, LocalMsg::Failed);
+                                       ts, LocalMsg::Failed, convType);
                 if (markRow)
                     self->setBubbleSendState(markRow, true);
                 UiHelpers::showToast(self, ImClient::messageOf(reply), true);
@@ -239,14 +325,15 @@ void Client::sendOutgoing(qint64 peer, const QString &peerName, int msgType,
             const qint64 serverId = Proto::jsonI64(reply, QStringLiteral("msg_id"));
             const qint64 serverTs = Proto::jsonI64(reply, QStringLiteral("ts"));
             self->saveLocalMessage(serverId, peer, self->m_uid, peer, msgType, content,
-                                   serverTs > 0 ? serverTs : ts, LocalMsg::Ok);
+                                   serverTs > 0 ? serverTs : ts, LocalMsg::Ok, convType);
             if (markRow)
             {
                 markRow->setProperty("outgoingId", QVariant::fromValue(serverId));
                 markRow->setProperty("msgId", QVariant::fromValue(serverId));
+                markRow->setProperty("outgoingConvType", convType);
                 self->setBubbleSendState(markRow, false);
             }
-            self->upsertConv(peer, peerName, preview, serverTs > 0 ? serverTs : ts, 0);
+            self->upsertConv(peer, peerName, preview, serverTs > 0 ? serverTs : ts, 0, convType);
         });
 }
 
@@ -264,16 +351,19 @@ void Client::resendFailed(QWidget *row)
     const QString content = row->property("outgoingContent").toString();
     if (localId == 0 || peer <= 0 || content.isEmpty())
         return;
+    const int convType = row->property("outgoingConvType").isValid()
+        ? row->property("outgoingConvType").toInt()
+        : m_currentConv.type;
     QString peerName = m_currentPeerName;
-    if (auto *item = findConvItem(peer))
+    if (auto *item = findConvItem(peer, convType))
         peerName = item->data(RoleName).toString();
     sendOutgoing(peer, peerName, msgType > 0 ? msgType : im::MsgType::Text, content,
-                 previewOf(msgType > 0 ? msgType : im::MsgType::Text, content), row, localId);
+                 previewOf(msgType > 0 ? msgType : im::MsgType::Text, content), row, localId, convType);
 }
 
 void Client::sendFile(bool imageOnly)
 {
-    if (!m_chatBody->isVisible() || m_currentPeerUid <= 0)
+    if (!m_chatBody->isVisible() || m_currentConv.id <= 0)
         return;
     const QString filter = imageOnly
         ? QStringLiteral("图片 (*.jpg *.jpeg *.png *.gif *.bmp *.webp)")
@@ -305,9 +395,10 @@ void Client::sendFile(bool imageOnly)
     }
 
     QPointer<Client> self(this);
-    const qint64 peer = m_currentPeerUid;
+    const qint64 peer = m_currentConv.id;
+    const int convType = m_currentConv.type;
     const QString peerName = m_currentPeerName;
-    ImClient::instance().putFile(name, mime, size, [self, path, name, mime, size, peer, peerName](const QJsonObject &reply) {
+    ImClient::instance().putFile(name, mime, size, [self, path, name, mime, size, peer, peerName, convType](const QJsonObject &reply) {
         if (!self)
             return;
         if (!ImClient::ok(reply))
@@ -345,19 +436,19 @@ void Client::sendFile(bool imageOnly)
         const qint64 localId = LocalStore::instance().allocLocalId();
         const qint64 ts = QDateTime::currentMSecsSinceEpoch();
         self->saveLocalMessage(localId, peer, self->m_uid, peer, im::MsgType::File, contentText, ts,
-                               LocalMsg::Sending);
+                               LocalMsg::Sending, convType);
         self->m_inflightLocalIds.insert(localId);
 
         auto *once = new QObject(self);
         QObject::connect(&FileCache::instance(), &FileCache::uploaded, once,
-                         [self, once, fileId, peer, peerName, contentText, preview, localId](qint64 id) {
+                         [self, once, fileId, peer, peerName, contentText, preview, localId, convType](qint64 id) {
             if (id != fileId)
                 return;
             once->deleteLater();
             if (!self)
                 return;
             QWidget *row = self->findOutgoingRow(localId);
-            self->sendOutgoing(peer, peerName, im::MsgType::File, contentText, preview, row, localId);
+            self->sendOutgoing(peer, peerName, im::MsgType::File, contentText, preview, row, localId, convType);
         });
         QObject::connect(&FileCache::instance(), &FileCache::failed, once,
                          [self, fileId](qint64 id, const QString &error) {
@@ -367,10 +458,10 @@ void Client::sendFile(bool imageOnly)
         });
 
         FileCache::instance().startUpload(fileId, reply.value(QStringLiteral("token")).toObject());
-        if (self->isViewingPeer(peer))
+        if (self->isViewingPeer(peer, convType))
             self->addBubble(true, self->m_nickname, contentText, QStringLiteral("刚刚"),
-                            im::MsgType::File, LocalMsg::Sending, localId, peer);
-        self->upsertConv(peer, peerName, preview, ts, 0);
+                            im::MsgType::File, LocalMsg::Sending, localId, peer, self->m_uid);
+        self->upsertConv(peer, peerName, preview, ts, 0, convType);
     });
 }
 
@@ -429,8 +520,8 @@ void Client::favoritePayload(int msgType, const QString &content)
     QJsonObject body;
     body.insert(QStringLiteral("msg_type"), msgType);
     body.insert(QStringLiteral("content"), content);
-    if (m_currentPeerUid > 0)
-        body.insert(QStringLiteral("src_peer_uid"), m_currentPeerUid);
+    if (m_currentConv.id > 0)
+        body.insert(QStringLiteral("src_peer_uid"), m_currentConv.id);
 
     const bool waitFile = (msgType == im::MsgType::File);
     const int timeoutMs = waitFile ? 30000 : 15000;

+ 840 - 0
Client/src/mainframe/GroupProfileDialog.cpp

@@ -0,0 +1,840 @@
+#include "GroupProfileDialog.h"
+
+#include "AppConfig.h"
+#include "AvatarCache.h"
+#include "AvatarCropDialog.h"
+#include "ConfirmDialog.h"
+#include "ImClient.h"
+#include "PickFriendDialog.h"
+#include "PromptDialog.h"
+#include "Protocol.h"
+#include "TitleBar.h"
+#include "UiHelpers.h"
+#include "common/protocol_def.h"
+
+#include <QCursor>
+#include <QEvent>
+#include <QFileDialog>
+#include <QFrame>
+#include <QGridLayout>
+#include <QHBoxLayout>
+#include <QImage>
+#include <QImageReader>
+#include <QJsonArray>
+#include <QJsonObject>
+#include <QLabel>
+#include <QListWidget>
+#include <QListWidgetItem>
+#include <QMenu>
+#include <QMouseEvent>
+#include <QPointer>
+#include <QPushButton>
+#include <QScrollArea>
+#include <QTimer>
+#include <QVector>
+#include <QVBoxLayout>
+
+namespace {
+
+QString displayOf(const QJsonObject &o)
+{
+    QString name = o.value(QStringLiteral("remark")).toString();
+    if (name.isEmpty())
+        name = o.value(QStringLiteral("nickname")).toString();
+    if (name.isEmpty())
+        name = o.value(QStringLiteral("account")).toString();
+    return name;
+}
+
+QPushButton *makeRowBtn(const QString &text, QWidget *parent)
+{
+    auto *btn = new QPushButton(text, parent);
+    btn->setCursor(Qt::PointingHandCursor);
+    btn->setFixedHeight(48);
+    btn->setStyleSheet(QStringLiteral(
+        "QPushButton { background:#FFFFFF; border:none; border-bottom:1px solid #E7E7E7;"
+        " text-align:left; padding-left:16px; color:#191919; font-size:14px; }"
+        "QPushButton:hover { background:#F5F5F5; }"));
+    return btn;
+}
+
+}  // namespace
+
+GroupListDialog::GroupListDialog(QWidget *parent)
+    : QDialog(parent)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(QStringLiteral("群聊"));
+    setFixedSize(360, 520);
+    UiHelpers::setupFramelessDialog(this);
+
+    auto *root = new QVBoxLayout(this);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    auto *bar = new TitleBar(this);
+    bar->setTitle(QStringLiteral("群聊"));
+    bar->setTheme(TitleBar::Light);
+    bar->setMaximizable(false);
+    bar->bind(this, false);
+    root->addWidget(bar);
+
+    auto *list = new QListWidget(this);
+    list->setFrameShape(QFrame::NoFrame);
+    list->setStyleSheet(QStringLiteral(
+        "QListWidget { background:#FFFFFF; border:none; }"
+        "QListWidget::item { height:56px; }"
+        "QListWidget::item:selected { background:#EDEDED; }"
+        "QListWidget::item:hover { background:#F5F5F5; }"));
+    root->addWidget(list, 1);
+
+    auto *empty = new QLabel(QStringLiteral("还没有加入任何群聊"), this);
+    empty->setAlignment(Qt::AlignCenter);
+    empty->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:13px; padding:40px 0; background:#FFFFFF;"));
+    empty->hide();
+    root->addWidget(empty, 1);
+
+    QPointer<GroupListDialog> self(this);
+    ImClient::instance().groupList([self, list, empty](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        const QJsonArray arr = reply.value(QStringLiteral("list")).toArray();
+        if (arr.isEmpty())
+        {
+            list->hide();
+            empty->show();
+            return;
+        }
+        empty->hide();
+        list->show();
+        for (const QJsonValue &v : arr)
+        {
+            const QJsonObject o = v.toObject();
+            const qint64 gid = Proto::jsonI64(o, QStringLiteral("group_id"));
+            QString name = o.value(QStringLiteral("name")).toString();
+            if (name.isEmpty())
+                name = QStringLiteral("群聊");
+            const int n = o.value(QStringLiteral("member_count")).toInt();
+            const int ver = o.value(QStringLiteral("avatar_ver")).toInt();
+            auto *item = new QListWidgetItem(list);
+            item->setData(Qt::UserRole, gid);
+            item->setData(Qt::UserRole + 1, name);
+            item->setSizeHint(QSize(200, 56));
+            auto *row = new QWidget(list);
+            auto *lay = new QHBoxLayout(row);
+            lay->setContentsMargins(14, 8, 14, 8);
+            lay->setSpacing(10);
+            auto *av = new QLabel(row);
+            av->setFixedSize(40, 40);
+            AvatarCache::instance().applyToGroup(av, gid, ver, name, 40);
+            auto *info = new QVBoxLayout();
+            info->setContentsMargins(0, 0, 0, 0);
+            info->setSpacing(2);
+            auto *nlab = new QLabel(name, row);
+            nlab->setStyleSheet(QStringLiteral("font-size:14px; color:#191919; background:transparent;"));
+            auto *sub = new QLabel(QStringLiteral("%1 人").arg(n), row);
+            sub->setStyleSheet(QStringLiteral("font-size:12px; color:#9A9A9A; background:transparent;"));
+            info->addWidget(nlab);
+            info->addWidget(sub);
+            lay->addWidget(av);
+            lay->addLayout(info, 1);
+            list->setItemWidget(item, row);
+        }
+    });
+
+    connect(list, &QListWidget::itemClicked, this, [this](QListWidgetItem *item) {
+        if (!item)
+            return;
+        m_id = item->data(Qt::UserRole).toLongLong();
+        m_name = item->data(Qt::UserRole + 1).toString();
+        if (m_id > 0)
+            accept();
+    });
+}
+
+GroupProfileDialog::GroupProfileDialog(qint64 groupId, QWidget *parent)
+    : QDialog(parent)
+    , m_groupId(groupId)
+{
+    setObjectName(QStringLiteral("ChromeWindow"));
+    setWindowTitle(QStringLiteral("群资料"));
+    setFixedSize(420, 640);
+    UiHelpers::setupFramelessDialog(this);
+
+    auto *root = new QVBoxLayout(this);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+
+    auto *bar = new TitleBar(this);
+    bar->setTitle(QStringLiteral("群资料"));
+    bar->setTheme(TitleBar::Light);
+    bar->setMaximizable(false);
+    bar->bind(this, false);
+    root->addWidget(bar);
+
+    auto *scroll = new QScrollArea(this);
+    scroll->setWidgetResizable(true);
+    scroll->setFrameShape(QFrame::NoFrame);
+    scroll->setStyleSheet(QStringLiteral("QScrollArea { background:#EDEDED; border:none; }"));
+    auto *page = new QWidget(scroll);
+    page->setStyleSheet(QStringLiteral("background:#EDEDED;"));
+    auto *lay = new QVBoxLayout(page);
+    lay->setContentsMargins(0, 0, 0, 16);
+    lay->setSpacing(10);
+
+    auto *head = new QWidget(page);
+    head->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
+    auto *hLay = new QVBoxLayout(head);
+    hLay->setContentsMargins(20, 20, 20, 16);
+    hLay->setSpacing(8);
+    m_icon = new QLabel(head);
+    m_icon->setFixedSize(64, 64);
+    m_icon->installEventFilter(this);
+    AvatarCache::instance().applyToGroup(m_icon, m_groupId, 0, QStringLiteral("群聊"), 64);
+    m_title = new QLabel(QStringLiteral("群聊"), head);
+    m_title->setAlignment(Qt::AlignCenter);
+    m_title->setStyleSheet(QStringLiteral("font-size:18px; color:#191919; background:transparent;"));
+    m_idLab = new QLabel(head);
+    m_idLab->setAlignment(Qt::AlignCenter);
+    m_idLab->setStyleSheet(QStringLiteral("font-size:13px; color:#6A6A6A; background:transparent;"));
+    m_countLab = new QLabel(head);
+    m_countLab->setAlignment(Qt::AlignCenter);
+    m_countLab->setStyleSheet(QStringLiteral("font-size:13px; color:#6A6A6A; background:transparent;"));
+    hLay->addWidget(m_icon, 0, Qt::AlignHCenter);
+    hLay->addWidget(m_title);
+    hLay->addWidget(m_idLab);
+    hLay->addWidget(m_countLab);
+    lay->addWidget(head);
+
+    auto *ops = new QWidget(page);
+    ops->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
+    auto *oLay = new QVBoxLayout(ops);
+    oLay->setContentsMargins(0, 0, 0, 0);
+    oLay->setSpacing(0);
+    m_joinBtn = makeRowBtn(QStringLiteral("进群方式"), ops);
+    m_shareBtn = makeRowBtn(QStringLiteral("分享"), ops);
+    m_inviteBtn = makeRowBtn(QStringLiteral("邀请好友"), ops);
+    m_reqBtn = makeRowBtn(QStringLiteral("入群申请"), ops);
+    oLay->addWidget(m_joinBtn);
+    oLay->addWidget(m_shareBtn);
+    oLay->addWidget(m_inviteBtn);
+    oLay->addWidget(m_reqBtn);
+    lay->addWidget(ops);
+
+    auto *memWrap = new QWidget(page);
+    memWrap->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
+    auto *mLay = new QVBoxLayout(memWrap);
+    mLay->setContentsMargins(12, 12, 12, 12);
+    mLay->setSpacing(8);
+    auto *memTitle = new QLabel(QStringLiteral("群成员"), memWrap);
+    memTitle->setStyleSheet(QStringLiteral("font-size:13px; color:#888888; background:transparent;"));
+    m_memberHost = new QWidget(memWrap);
+    m_memberGrid = new QGridLayout(m_memberHost);
+    m_memberGrid->setContentsMargins(0, 0, 0, 0);
+    m_memberGrid->setHorizontalSpacing(8);
+    m_memberGrid->setVerticalSpacing(10);
+    mLay->addWidget(memTitle);
+    mLay->addWidget(m_memberHost);
+    lay->addWidget(memWrap);
+
+    auto *danger = new QWidget(page);
+    danger->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
+    auto *dLay = new QVBoxLayout(danger);
+    dLay->setContentsMargins(0, 0, 0, 0);
+    dLay->setSpacing(0);
+    m_leaveBtn = new QPushButton(QStringLiteral("退出群聊"), danger);
+    m_leaveBtn->setCursor(Qt::PointingHandCursor);
+    m_leaveBtn->setFixedHeight(48);
+    m_leaveBtn->setStyleSheet(QStringLiteral(
+        "QPushButton { background:#FFFFFF; border:none; color:#FA5151; font-size:14px; }"
+        "QPushButton:hover { background:#F5F5F5; }"));
+    m_dismissBtn = new QPushButton(QStringLiteral("解散群聊"), danger);
+    m_dismissBtn->setCursor(Qt::PointingHandCursor);
+    m_dismissBtn->setFixedHeight(48);
+    m_dismissBtn->setStyleSheet(QStringLiteral(
+        "QPushButton { background:#FFFFFF; border:none; border-top:1px solid #E7E7E7;"
+        " color:#FA5151; font-size:14px; }"
+        "QPushButton:hover { background:#F5F5F5; }"));
+    dLay->addWidget(m_leaveBtn);
+    dLay->addWidget(m_dismissBtn);
+    lay->addWidget(danger);
+    lay->addStretch();
+
+    scroll->setWidget(page);
+    root->addWidget(scroll, 1);
+
+    m_title->installEventFilter(this);
+    connect(m_joinBtn, &QPushButton::clicked, this, &GroupProfileDialog::changeJoinMode);
+    connect(m_shareBtn, &QPushButton::clicked, this, &GroupProfileDialog::shareCard);
+    connect(m_inviteBtn, &QPushButton::clicked, this, &GroupProfileDialog::inviteFriends);
+    connect(m_reqBtn, &QPushButton::clicked, this, &GroupProfileDialog::openRequests);
+    connect(m_leaveBtn, &QPushButton::clicked, this, &GroupProfileDialog::leaveGroup);
+    connect(m_dismissBtn, &QPushButton::clicked, this, &GroupProfileDialog::dismissGroup);
+
+    reload();
+}
+
+bool GroupProfileDialog::eventFilter(QObject *watched, QEvent *event)
+{
+    if (event->type() == QEvent::MouseButtonRelease)
+    {
+        auto *me = static_cast<QMouseEvent *>(event);
+        if (me->button() == Qt::LeftButton && m_role >= im::GroupRole::Admin)
+        {
+            if (watched == m_title)
+            {
+                renameGroup();
+                return true;
+            }
+            if (watched == m_icon)
+            {
+                pickAvatar();
+                return true;
+            }
+        }
+    }
+    return QDialog::eventFilter(watched, event);
+}
+
+QString GroupProfileDialog::joinModeText(int mode) const
+{
+    if (mode == im::JoinMode::Approve)
+        return QStringLiteral("进群方式    需要群主或管理员同意");
+    if (mode == im::JoinMode::Closed)
+        return QStringLiteral("进群方式    不允许加入");
+    return QStringLiteral("进群方式    无需验证");
+}
+
+void GroupProfileDialog::applyInfo(const QJsonObject &info)
+{
+    m_name = info.value(QStringLiteral("name")).toString();
+    if (m_name.isEmpty())
+        m_name = QStringLiteral("群聊");
+    m_joinMode = info.value(QStringLiteral("join_mode")).toInt();
+    m_role = info.value(QStringLiteral("role")).toInt(-1);
+    m_memberCount = info.value(QStringLiteral("member_count")).toInt();
+    m_pending = info.value(QStringLiteral("pending")).toInt(0);
+    m_ownerUid = Proto::jsonI64(info, QStringLiteral("owner_uid"));
+    m_avatarVer = info.value(QStringLiteral("avatar_ver")).toInt();
+    m_title->setText(m_name);
+    m_title->setCursor(m_role >= im::GroupRole::Admin ? Qt::PointingHandCursor : Qt::ArrowCursor);
+    m_icon->setCursor(m_role >= im::GroupRole::Admin ? Qt::PointingHandCursor : Qt::ArrowCursor);
+    AvatarCache::instance().applyToGroup(m_icon, m_groupId, m_avatarVer, m_name, 64);
+    m_idLab->setText(QStringLiteral("群号  %1").arg(m_groupId));
+    m_countLab->setText(QStringLiteral("%1 人").arg(m_memberCount));
+    m_joinBtn->setText(joinModeText(m_joinMode));
+    const bool admin = m_role >= im::GroupRole::Admin;
+    m_joinBtn->setEnabled(admin);
+    m_inviteBtn->setVisible(admin);
+    m_reqBtn->setVisible(admin);
+    if (admin && m_pending > 0)
+        m_reqBtn->setText(QStringLiteral("入群申请    %1").arg(m_pending));
+    else
+        m_reqBtn->setText(QStringLiteral("入群申请"));
+    m_leaveBtn->setVisible(m_role >= 0 && m_role != im::GroupRole::Owner);
+    m_dismissBtn->setVisible(m_role == im::GroupRole::Owner);
+}
+
+void GroupProfileDialog::reload()
+{
+    QPointer<GroupProfileDialog> self(this);
+    ImClient::instance().groupInfo(m_groupId, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->applyInfo(reply);
+        self->loadMembers();
+    });
+}
+
+void GroupProfileDialog::loadMembers()
+{
+    QPointer<GroupProfileDialog> self(this);
+    ImClient::instance().groupMembers(m_groupId, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+            return;
+        self->rebuildMembers(reply.value(QStringLiteral("list")).toArray());
+    });
+}
+
+void GroupProfileDialog::rebuildMembers(const QJsonArray &list)
+{
+    while (QLayoutItem *it = m_memberGrid->takeAt(0))
+    {
+        if (it->widget())
+            it->widget()->deleteLater();
+        delete it;
+    }
+    m_memberUids.clear();
+    int col = 0;
+    int row = 0;
+    const int cols = 5;
+    auto addTile = [&](QWidget *tile) {
+        m_memberGrid->addWidget(tile, row, col);
+        ++col;
+        if (col >= cols)
+        {
+            col = 0;
+            ++row;
+        }
+    };
+
+    if (m_role >= im::GroupRole::Admin)
+    {
+        auto *add = new QPushButton(QStringLiteral("+"), m_memberHost);
+        add->setFixedSize(64, 72);
+        add->setCursor(Qt::PointingHandCursor);
+        add->setStyleSheet(QStringLiteral(
+            "QPushButton { background:#F7F7F7; border:1px dashed #C8C8C8; color:#888; font-size:22px; }"
+            "QPushButton:hover { background:#EFEFEF; }"));
+        connect(add, &QPushButton::clicked, this, &GroupProfileDialog::inviteFriends);
+        addTile(add);
+    }
+
+    for (const QJsonValue &v : list)
+    {
+        const QJsonObject o = v.toObject();
+        const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
+        if (uid <= 0)
+            continue;
+        m_memberUids.insert(uid);
+        QString name = displayOf(o);
+        const int ver = o.value(QStringLiteral("avatar_ver")).toInt();
+        auto *tile = new QPushButton(m_memberHost);
+        tile->setFlat(true);
+        tile->setCursor(Qt::PointingHandCursor);
+        tile->setFixedSize(64, 72);
+        tile->setStyleSheet(QStringLiteral(
+            "QPushButton { background:transparent; border:none; }"));
+        auto *vLay = new QVBoxLayout(tile);
+        vLay->setContentsMargins(0, 0, 0, 0);
+        vLay->setSpacing(4);
+        auto *av = new QLabel(tile);
+        av->setFixedSize(40, 40);
+        av->setAlignment(Qt::AlignCenter);
+        AvatarCache::instance().applyTo(av, uid, ver, name, 40);
+        av->setAttribute(Qt::WA_TransparentForMouseEvents);
+        auto *lab = new QLabel(name, tile);
+        lab->setAlignment(Qt::AlignCenter);
+        lab->setStyleSheet(QStringLiteral("font-size:11px; color:#555; background:transparent;"));
+        if (name.size() > 4)
+            lab->setText(name.left(4));
+        lab->setMaximumWidth(64);
+        lab->setAttribute(Qt::WA_TransparentForMouseEvents);
+        vLay->addWidget(av, 0, Qt::AlignHCenter);
+        vLay->addWidget(lab);
+        connect(tile, &QPushButton::clicked, this, [this, o]() { onMemberClicked(o); });
+        addTile(tile);
+    }
+}
+
+void GroupProfileDialog::onMemberClicked(const QJsonObject &member)
+{
+    const qint64 uid = Proto::jsonI64(member, QStringLiteral("uid"));
+    if (uid <= 0)
+        return;
+    if (uid == ImClient::instance().uid())
+    {
+        emit memberClicked(uid);
+        return;
+    }
+    const int targetRole = member.value(QStringLiteral("role")).toInt();
+    QString name = displayOf(member);
+    QMenu menu(this);
+    QAction *card = menu.addAction(QStringLiteral("查看资料"));
+    QAction *kick = nullptr;
+    QAction *admin = nullptr;
+    QAction *transfer = nullptr;
+    const bool canKick = (m_role == im::GroupRole::Owner && uid != m_ownerUid)
+        || (m_role == im::GroupRole::Admin && targetRole == im::GroupRole::Member);
+    if (canKick)
+        kick = menu.addAction(QStringLiteral("移出群聊"));
+    if (m_role == im::GroupRole::Owner && targetRole != im::GroupRole::Owner)
+    {
+        admin = menu.addAction(targetRole == im::GroupRole::Admin
+            ? QStringLiteral("取消管理员")
+            : QStringLiteral("设为管理员"));
+        transfer = menu.addAction(QStringLiteral("转让群主"));
+    }
+    QAction *picked = menu.exec(QCursor::pos());
+    if (!picked)
+        return;
+    if (picked == card)
+    {
+        emit memberClicked(uid);
+        return;
+    }
+    QPointer<GroupProfileDialog> self(this);
+    if (picked == kick)
+    {
+        if (!ConfirmDialog::ask(this, QStringLiteral("移出成员"),
+                                QStringLiteral("将「%1」移出群聊?").arg(name),
+                                QStringLiteral("移出")))
+            return;
+        ImClient::instance().groupKick(m_groupId, uid, [self](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            self->reload();
+        });
+        return;
+    }
+    if (picked == admin)
+    {
+        const bool makeAdmin = targetRole != im::GroupRole::Admin;
+        ImClient::instance().groupSetAdmin(m_groupId, uid, makeAdmin, [self](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            self->reload();
+        });
+        return;
+    }
+    if (picked == transfer)
+    {
+        if (!ConfirmDialog::ask(this, QStringLiteral("转让群主"),
+                                QStringLiteral("将群主转让给「%1」?你将成为普通成员").arg(name),
+                                QStringLiteral("转让"), QStringLiteral("取消"), false))
+            return;
+        ImClient::instance().groupTransfer(m_groupId, uid, [self](const QJsonObject &reply) {
+            if (!self)
+                return;
+            if (!ImClient::ok(reply))
+            {
+                UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                return;
+            }
+            self->reload();
+        });
+    }
+}
+
+void GroupProfileDialog::pickAvatar()
+{
+    if (m_role < im::GroupRole::Admin)
+        return;
+    const QString path = QFileDialog::getOpenFileName(
+        this, QStringLiteral("选择群头像"), QString(),
+        QStringLiteral("图片 (*.jpg *.jpeg *.png *.bmp *.webp)"));
+    if (path.isEmpty())
+        return;
+    QImageReader reader(path);
+    reader.setAutoTransform(true);
+    QImage img = reader.read();
+    if (img.isNull())
+    {
+        UiHelpers::showToast(this, QStringLiteral("无法打开图片"), true);
+        return;
+    }
+    AvatarCropDialog crop(img, this);
+    if (crop.exec() != QDialog::Accepted)
+        return;
+    const QByteArray jpeg = crop.jpegBytes();
+    if (jpeg.size() > AppConfig::instance().avatarMax())
+    {
+        UiHelpers::showToast(this, QStringLiteral("头像过大,请缩小选区"), true);
+        return;
+    }
+    QPointer<GroupProfileDialog> self(this);
+    ImClient::instance().groupUpdate(m_groupId, QString(), -1, jpeg, [self, jpeg](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        const int ver = reply.value(QStringLiteral("avatar_ver")).toInt();
+        if (ver > 0 && !jpeg.isEmpty())
+            AvatarCache::instance().rememberGroup(self->m_groupId, ver, jpeg);
+        self->applyInfo(reply);
+        UiHelpers::showToast(self, QStringLiteral("群头像已更新"));
+    });
+}
+
+void GroupProfileDialog::inviteFriends()
+{
+    QPointer<GroupProfileDialog> self(this);
+    ImClient::instance().friendList([self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        const QJsonArray list = reply.value(QStringLiteral("list")).toArray();
+        const QSet<qint64> exclude = self->m_memberUids;
+        QTimer::singleShot(0, self.data(), [self, list, exclude]() {
+            if (!self)
+                return;
+            PickFriendDialog dlg(list, ImClient::instance().uid(), self,
+                                 QStringLiteral("邀请好友"), false, true, exclude);
+            if (dlg.exec() != QDialog::Accepted)
+                return;
+            QVector<qint64> uids;
+            for (const QJsonValue &v : dlg.selectedList())
+            {
+                const qint64 uid = Proto::jsonI64(v.toObject(), QStringLiteral("uid"));
+                if (uid > 0)
+                    uids.push_back(uid);
+            }
+            if (uids.isEmpty())
+                return;
+            ImClient::instance().groupInvite(self->m_groupId, uids, [self](const QJsonObject &reply) {
+                if (!self)
+                    return;
+                if (!ImClient::ok(reply))
+                {
+                    UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+                    return;
+                }
+                UiHelpers::showToast(self, QStringLiteral("已邀请"));
+                self->reload();
+            });
+        });
+    });
+}
+
+void GroupProfileDialog::shareCard()
+{
+    emit shareRequested(m_groupId, m_name, m_memberCount);
+}
+
+void GroupProfileDialog::renameGroup()
+{
+    if (m_role < im::GroupRole::Admin)
+        return;
+    QString next = m_name;
+    const int maxLen = qMax(1, AppConfig::instance().groupNameMax());
+    if (!PromptDialog::ask(this, QStringLiteral("修改群名"), QStringLiteral("群聊名称"), &next, maxLen))
+        return;
+    next = next.trimmed();
+    if (next.isEmpty())
+    {
+        UiHelpers::showToast(this, QStringLiteral("群名不能为空"), true);
+        return;
+    }
+    QPointer<GroupProfileDialog> self(this);
+    ImClient::instance().groupUpdate(m_groupId, next, -1, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->applyInfo(reply);
+    });
+}
+
+void GroupProfileDialog::changeJoinMode()
+{
+    if (m_role < im::GroupRole::Admin)
+        return;
+    QMenu menu(this);
+    QAction *open = menu.addAction(QStringLiteral("无需验证"));
+    QAction *approve = menu.addAction(QStringLiteral("需要群主或管理员同意"));
+    QAction *closed = menu.addAction(QStringLiteral("不允许加入"));
+    QAction *picked = menu.exec(QCursor::pos());
+    if (!picked)
+        return;
+    int mode = im::JoinMode::Open;
+    if (picked == approve)
+        mode = im::JoinMode::Approve;
+    else if (picked == closed)
+        mode = im::JoinMode::Closed;
+    QPointer<GroupProfileDialog> self(this);
+    ImClient::instance().groupUpdate(m_groupId, QString(), mode, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        self->applyInfo(reply);
+    });
+}
+
+void GroupProfileDialog::openRequests()
+{
+    auto *dlg = new QDialog(this);
+    dlg->setObjectName(QStringLiteral("ChromeWindow"));
+    dlg->setWindowTitle(QStringLiteral("入群申请"));
+    dlg->setFixedSize(400, 460);
+    UiHelpers::setupFramelessDialog(dlg);
+    auto *root = new QVBoxLayout(dlg);
+    root->setContentsMargins(0, 0, 0, 0);
+    root->setSpacing(0);
+    auto *bar = new TitleBar(dlg);
+    bar->setTitle(QStringLiteral("入群申请"));
+    bar->setTheme(TitleBar::Light);
+    bar->setMaximizable(false);
+    bar->bind(dlg, false);
+    root->addWidget(bar);
+    auto *scroll = new QScrollArea(dlg);
+    scroll->setWidgetResizable(true);
+    scroll->setFrameShape(QFrame::NoFrame);
+    auto *host = new QWidget(scroll);
+    host->setStyleSheet(QStringLiteral("background:#FFFFFF;"));
+    auto *listLay = new QVBoxLayout(host);
+    listLay->setContentsMargins(16, 12, 16, 16);
+    listLay->setSpacing(8);
+    auto *empty = new QLabel(QStringLiteral("暂无入群申请"), host);
+    empty->setAlignment(Qt::AlignCenter);
+    empty->setStyleSheet(QStringLiteral("color:#B0B0B0; font-size:13px; padding:40px 0; background:transparent;"));
+    listLay->addWidget(empty);
+    listLay->addStretch();
+    scroll->setWidget(host);
+    root->addWidget(scroll, 1);
+
+    auto rebuild = [listLay, empty, host, dlg](const QJsonArray &list, GroupProfileDialog *owner) {
+        while (listLay->count() > 1)
+        {
+            QLayoutItem *it = listLay->takeAt(0);
+            if (it->widget())
+                it->widget()->deleteLater();
+            delete it;
+        }
+        if (list.isEmpty())
+        {
+            empty->show();
+            listLay->insertWidget(0, empty);
+            return;
+        }
+        empty->hide();
+        int index = 0;
+        for (const QJsonValue &v : list)
+        {
+            const QJsonObject o = v.toObject();
+            QString name = o.value(QStringLiteral("nickname")).toString();
+            if (name.isEmpty())
+                name = o.value(QStringLiteral("account")).toString();
+            const qint64 fromUid = Proto::jsonI64(o, QStringLiteral("from_uid"));
+            const qint64 reqId = Proto::jsonI64(o, QStringLiteral("id"));
+            const int ver = o.value(QStringLiteral("avatar_ver")).toInt();
+            auto *row = new QWidget(host);
+            auto *lay = new QHBoxLayout(row);
+            lay->setContentsMargins(8, 8, 8, 8);
+            auto *avatar = new QLabel(row);
+            avatar->setFixedSize(40, 40);
+            AvatarCache::instance().applyTo(avatar, fromUid, ver, name, 40);
+            auto *info = new QVBoxLayout();
+            info->setSpacing(2);
+            info->setContentsMargins(0, 0, 0, 0);
+            auto *lab = new QLabel(name, row);
+            lab->setStyleSheet(QStringLiteral("font-size:14px; background:transparent;"));
+            auto *sub = new QLabel(o.value(QStringLiteral("message")).toString().trimmed(), row);
+            if (sub->text().isEmpty())
+                sub->setText(QStringLiteral("申请加入群聊"));
+            sub->setStyleSheet(QStringLiteral("font-size:12px; color:#9A9A9A; background:transparent;"));
+            info->addWidget(lab);
+            info->addWidget(sub);
+            auto *ok = UiHelpers::compactButton(QStringLiteral("同意"), row, true);
+            auto *no = UiHelpers::compactButton(QStringLiteral("拒绝"), row);
+            lay->addWidget(avatar);
+            lay->addLayout(info, 1);
+            lay->addWidget(ok);
+            lay->addWidget(no);
+            auto handle = [owner, reqId, dlg](int action) {
+                ImClient::instance().groupHandleRequest(reqId, action, [owner, dlg](const QJsonObject &reply) {
+                    if (!ImClient::ok(reply))
+                    {
+                        UiHelpers::showToast(dlg, ImClient::messageOf(reply), true);
+                        return;
+                    }
+                    if (owner)
+                        owner->reload();
+                    dlg->accept();
+                });
+            };
+            connect(ok, &QPushButton::clicked, owner, [handle]() { handle(1); });
+            connect(no, &QPushButton::clicked, owner, [handle]() { handle(2); });
+            listLay->insertWidget(index++, row);
+        }
+    };
+
+    QPointer<GroupProfileDialog> self(this);
+    ImClient::instance().groupRequestList(m_groupId, [self, dlg, rebuild](const QJsonObject &reply) {
+        if (!self)
+        {
+            dlg->deleteLater();
+            return;
+        }
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            dlg->deleteLater();
+            return;
+        }
+        rebuild(reply.value(QStringLiteral("list")).toArray(), self.data());
+        QTimer::singleShot(0, dlg, [dlg]() {
+            dlg->exec();
+            dlg->deleteLater();
+        });
+    });
+}
+
+void GroupProfileDialog::leaveGroup()
+{
+    if (m_role == im::GroupRole::Owner)
+    {
+        UiHelpers::showToast(this, QStringLiteral("群主不能退群,请先转让或解散"), true);
+        return;
+    }
+    if (!ConfirmDialog::ask(this, QStringLiteral("退出群聊"),
+                            QStringLiteral("退出后将不再接收「%1」的消息").arg(m_name),
+                            QStringLiteral("退出")))
+        return;
+    QPointer<GroupProfileDialog> self(this);
+    ImClient::instance().groupLeave(m_groupId, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        emit self->leftOrDismissed(self->m_groupId);
+        self->accept();
+    });
+}
+
+void GroupProfileDialog::dismissGroup()
+{
+    if (!ConfirmDialog::ask(this, QStringLiteral("解散群聊"),
+                            QStringLiteral("解散后所有成员都将退出「%1」").arg(m_name)))
+        return;
+    QPointer<GroupProfileDialog> self(this);
+    ImClient::instance().groupDismiss(m_groupId, [self](const QJsonObject &reply) {
+        if (!self)
+            return;
+        if (!ImClient::ok(reply))
+        {
+            UiHelpers::showToast(self, ImClient::messageOf(reply), true);
+            return;
+        }
+        emit self->leftOrDismissed(self->m_groupId);
+        self->accept();
+    });
+}

+ 81 - 0
Client/src/mainframe/GroupProfileDialog.h

@@ -0,0 +1,81 @@
+#pragma once
+
+#include <QDialog>
+#include <QEvent>
+#include <QJsonArray>
+#include <QJsonObject>
+#include <QSet>
+
+class QLabel;
+class QPushButton;
+class QScrollArea;
+class QVBoxLayout;
+class QWidget;
+class QGridLayout;
+
+class GroupListDialog : public QDialog
+{
+public:
+    explicit GroupListDialog(QWidget *parent = nullptr);
+    qint64 groupId() const { return m_id; }
+    QString groupName() const { return m_name; }
+
+private:
+    qint64 m_id = 0;
+    QString m_name;
+};
+
+class GroupProfileDialog : public QDialog
+{
+    Q_OBJECT
+
+public:
+    explicit GroupProfileDialog(qint64 groupId, QWidget *parent = nullptr);
+
+protected:
+    bool eventFilter(QObject *watched, QEvent *event) override;
+
+signals:
+    void shareRequested(qint64 groupId, const QString &name, int memberCount);
+    void memberClicked(qint64 uid);
+    void leftOrDismissed(qint64 groupId);
+
+private:
+    void reload();
+    void loadMembers();
+    void applyInfo(const QJsonObject &info);
+    void rebuildMembers(const QJsonArray &list);
+    void onMemberClicked(const QJsonObject &member);
+    void pickAvatar();
+    void inviteFriends();
+    void shareCard();
+    void renameGroup();
+    void changeJoinMode();
+    void openRequests();
+    void leaveGroup();
+    void dismissGroup();
+    QString joinModeText(int mode) const;
+
+    qint64 m_groupId = 0;
+    QString m_name;
+    int m_joinMode = 0;
+    int m_role = -1;
+    int m_memberCount = 0;
+    int m_pending = 0;
+    int m_avatarVer = 0;
+    qint64 m_ownerUid = 0;
+    QSet<qint64> m_memberUids;
+
+    QLabel *m_icon = nullptr;
+    QLabel *m_title = nullptr;
+    QLabel *m_idLab = nullptr;
+    QLabel *m_countLab = nullptr;
+    QPushButton *m_joinBtn = nullptr;
+    QPushButton *m_shareBtn = nullptr;
+    QPushButton *m_inviteBtn = nullptr;
+    QPushButton *m_reqBtn = nullptr;
+    QPushButton *m_leaveBtn = nullptr;
+    QPushButton *m_dismissBtn = nullptr;
+    QWidget *m_memberHost = nullptr;
+    QGridLayout *m_memberGrid = nullptr;
+};

+ 67 - 9
Client/src/mainframe/PickFriendDialog.cpp

@@ -4,6 +4,8 @@
 #include "AvatarCache.h"
 #include "Protocol.h"
 
+#include <QAbstractItemView>
+#include <QCheckBox>
 #include <QFrame>
 #include <QHBoxLayout>
 #include <QJsonArray>
@@ -12,10 +14,12 @@
 #include <QLabel>
 #include <QList>
 #include <QListWidget>
+#include <QPushButton>
 #include <QVBoxLayout>
 
 PickFriendDialog::PickFriendDialog(const QJsonArray &friends, qint64 excludeUid, QWidget *parent,
-                                   const QString &title, bool includeFileHelper)
+                                   const QString &title, bool includeFileHelper,
+                                   bool multiSelect, const QSet<qint64> &excludeUids)
     : QDialog(parent)
 {
     const QString cap = title.isEmpty() ? QStringLiteral("选择好友") : title;
@@ -37,11 +41,14 @@ PickFriendDialog::PickFriendDialog(const QJsonArray &friends, qint64 excludeUid,
 
     auto *list = new QListWidget(this);
     list->setFrameShape(QFrame::NoFrame);
+    if (multiSelect)
+        list->setSelectionMode(QAbstractItemView::NoSelection);
     list->setStyleSheet(QStringLiteral(
         "QListWidget { background:#FFFFFF; border:none; }"
         "QListWidget::item { height:52px; }"
         "QListWidget::item:selected { background:#EDEDED; }"
-        "QListWidget::item:hover { background:#F5F5F5; }"));
+        "QListWidget::item:hover { background:#F5F5F5; }"
+        "QListWidget::indicator { width:0; height:0; }"));
     root->addWidget(list, 1);
 
     QList<QJsonObject> rows;
@@ -50,7 +57,7 @@ PickFriendDialog::PickFriendDialog(const QJsonArray &friends, qint64 excludeUid,
     {
         const QJsonObject o = v.toObject();
         const qint64 uid = Proto::jsonI64(o, QStringLiteral("uid"));
-        if (uid <= 0 || uid == excludeUid)
+        if (uid <= 0 || uid == excludeUid || excludeUids.contains(uid))
             continue;
         if (uid == im::kFileHelperUid)
         {
@@ -83,6 +90,7 @@ PickFriendDialog::PickFriendDialog(const QJsonArray &friends, qint64 excludeUid,
         const int ver = static_cast<int>(Proto::jsonI64(o, QStringLiteral("avatar_ver")));
 
         auto *item = new QListWidgetItem(list);
+        item->setFlags(item->flags() & ~Qt::ItemIsUserCheckable);
         item->setData(Qt::UserRole, o);
         item->setSizeHint(QSize(200, 52));
 
@@ -90,6 +98,14 @@ PickFriendDialog::PickFriendDialog(const QJsonArray &friends, qint64 excludeUid,
         auto *lay = new QHBoxLayout(row);
         lay->setContentsMargins(14, 8, 14, 8);
         lay->setSpacing(10);
+        if (multiSelect)
+        {
+            auto *box = new QCheckBox(row);
+            box->setObjectName(QStringLiteral("pickCheck"));
+            box->setStyleSheet(QStringLiteral("background:transparent;"));
+            box->setChecked(false);
+            lay->addWidget(box, 0, Qt::AlignVCenter);
+        }
         auto *avatar = new QLabel(row);
         avatar->setFixedSize(36, 36);
         AvatarCache::instance().applyTo(avatar, uid, ver, name, 36);
@@ -109,10 +125,52 @@ PickFriendDialog::PickFriendDialog(const QJsonArray &friends, qint64 excludeUid,
         list->hide();
     }
 
-    connect(list, &QListWidget::itemClicked, this, [this](QListWidgetItem *item) {
-        if (!item)
-            return;
-        m_selected = item->data(Qt::UserRole).toJsonObject();
-        accept();
-    });
+    if (multiSelect)
+    {
+        auto *foot = new QWidget(this);
+        foot->setFixedHeight(56);
+        foot->setStyleSheet(QStringLiteral("background:#FFFFFF; border-top:1px solid #E7E7E7;"));
+        auto *fLay = new QHBoxLayout(foot);
+        fLay->setContentsMargins(16, 10, 16, 10);
+        auto *ok = UiHelpers::primaryButton(QStringLiteral("确定"), foot);
+        ok->setFixedSize(88, 32);
+        fLay->addStretch();
+        fLay->addWidget(ok);
+        root->addWidget(foot);
+        connect(ok, &QPushButton::clicked, this, [this, list]() {
+            m_selectedList = QJsonArray();
+            for (int i = 0; i < list->count(); ++i)
+            {
+                auto *item = list->item(i);
+                QWidget *w = item ? list->itemWidget(item) : nullptr;
+                auto *box = w ? w->findChild<QCheckBox *>(QStringLiteral("pickCheck")) : nullptr;
+                if (box && box->isChecked())
+                    m_selectedList.append(item->data(Qt::UserRole).toJsonObject());
+            }
+            if (m_selectedList.isEmpty())
+            {
+                UiHelpers::showToast(this, QStringLiteral("请选择至少一位好友"), true);
+                return;
+            }
+            accept();
+        });
+        connect(list, &QListWidget::itemClicked, this, [list](QListWidgetItem *item) {
+            if (!item)
+                return;
+            QWidget *w = list->itemWidget(item);
+            auto *box = w ? w->findChild<QCheckBox *>(QStringLiteral("pickCheck")) : nullptr;
+            if (!box || box->underMouse())
+                return;
+            box->setChecked(!box->isChecked());
+        });
+    }
+    else
+    {
+        connect(list, &QListWidget::itemClicked, this, [this](QListWidgetItem *item) {
+            if (!item)
+                return;
+            m_selected = item->data(Qt::UserRole).toJsonObject();
+            accept();
+        });
+    }
 }

+ 6 - 1
Client/src/mainframe/PickFriendDialog.h

@@ -3,15 +3,20 @@
 #include <QDialog>
 #include <QJsonArray>
 #include <QJsonObject>
+#include <QSet>
 
 class PickFriendDialog : public QDialog
 {
 public:
     PickFriendDialog(const QJsonArray &friends, qint64 excludeUid, QWidget *parent = nullptr,
                      const QString &title = QStringLiteral("选择好友"),
-                     bool includeFileHelper = false);
+                     bool includeFileHelper = false,
+                     bool multiSelect = false,
+                     const QSet<qint64> &excludeUids = {});
     QJsonObject selected() const { return m_selected; }
+    QJsonArray selectedList() const { return m_selectedList; }
 
 private:
     QJsonObject m_selected;
+    QJsonArray m_selectedList;
 };

+ 199 - 5
Client/src/network/ImClient.cpp

@@ -285,25 +285,49 @@ void ImClient::convList(Reply cb)
 }
 
 void ImClient::msgHistory(qint64 peerUid, qint64 lastId, int limit, Reply cb)
+{
+    msgHistory(peerUid, im::ConvType::Direct, lastId, limit, cb);
+}
+
+void ImClient::msgHistory(qint64 peerUid, int convType, qint64 lastId, int limit, Reply cb)
 {
     QJsonObject body;
-    body.insert(QStringLiteral("peer_uid"), peerUid);
+    if (convType == im::ConvType::Group)
+        body.insert(QStringLiteral("group_id"), peerUid);
+    else
+        body.insert(QStringLiteral("peer_uid"), peerUid);
     body.insert(QStringLiteral("last_id"), lastId);
     body.insert(QStringLiteral("limit"), limit);
     request(Proto::Type::Message, Proto::Cmd::MsgHistory, body, cb);
 }
 
 void ImClient::deleteConv(qint64 peerUid, Reply cb)
+{
+    deleteConv(peerUid, im::ConvType::Direct, cb);
+}
+
+void ImClient::deleteConv(qint64 peerUid, int convType, Reply cb)
 {
     QJsonObject body;
-    body.insert(QStringLiteral("peer_uid"), peerUid);
+    if (convType == im::ConvType::Group)
+        body.insert(QStringLiteral("group_id"), peerUid);
+    else
+        body.insert(QStringLiteral("peer_uid"), peerUid);
     request(Proto::Type::Message, Proto::Cmd::DeleteConv, body, cb);
 }
 
 void ImClient::deleteMsgs(qint64 peerUid, const QVector<qint64> &ids, Reply cb)
+{
+    deleteMsgs(peerUid, im::ConvType::Direct, ids, cb);
+}
+
+void ImClient::deleteMsgs(qint64 peerUid, int convType, const QVector<qint64> &ids, Reply cb)
 {
     QJsonObject body;
-    body.insert(QStringLiteral("peer_uid"), peerUid);
+    if (convType == im::ConvType::Group)
+        body.insert(QStringLiteral("group_id"), peerUid);
+    else
+        body.insert(QStringLiteral("peer_uid"), peerUid);
     QJsonArray arr;
     for (qint64 id : ids)
     {
@@ -315,9 +339,17 @@ void ImClient::deleteMsgs(qint64 peerUid, const QVector<qint64> &ids, Reply cb)
 }
 
 void ImClient::setUnread(qint64 peerUid, int unread, Reply cb)
+{
+    setUnread(peerUid, im::ConvType::Direct, unread, cb);
+}
+
+void ImClient::setUnread(qint64 peerUid, int convType, int unread, Reply cb)
 {
     QJsonObject body;
-    body.insert(QStringLiteral("peer_uid"), peerUid);
+    if (convType == im::ConvType::Group)
+        body.insert(QStringLiteral("group_id"), peerUid);
+    else
+        body.insert(QStringLiteral("peer_uid"), peerUid);
     body.insert(QStringLiteral("unread"), unread);
     request(Proto::Type::Message, Proto::Cmd::SetUnread, body, cb);
 }
@@ -364,6 +396,13 @@ void ImClient::getAvatar(qint64 uid, Reply cb)
     request(Proto::Type::Account, Proto::Cmd::GetAvatar, body, cb);
 }
 
+void ImClient::getGroupAvatar(qint64 groupId, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    request(Proto::Type::Account, Proto::Cmd::GetAvatar, body, cb);
+}
+
 void ImClient::putFile(const QString &name, const QString &mime, qint64 size, Reply cb)
 {
     putFile(name, mime, size, QString(), cb);
@@ -426,14 +465,159 @@ void ImClient::chat(qint64 toUid, const QString &content, Reply cb)
 }
 
 void ImClient::chat(qint64 toUid, int msgType, const QString &content, Reply cb)
+{
+    chat(toUid, im::ConvType::Direct, msgType, content, cb);
+}
+
+void ImClient::chat(qint64 peerId, int convType, int msgType, const QString &content, Reply cb)
 {
     QJsonObject body;
-    body.insert(QStringLiteral("to_uid"), toUid);
+    if (convType == im::ConvType::Group)
+        body.insert(QStringLiteral("group_id"), peerId);
+    else
+        body.insert(QStringLiteral("to_uid"), peerId);
     body.insert(QStringLiteral("msg_type"), msgType);
     body.insert(QStringLiteral("content"), content);
     request(Proto::Type::Message, Proto::Cmd::Chat, body, cb);
 }
 
+void ImClient::groupCreate(const QString &name, const QVector<qint64> &uids, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("name"), name);
+    QJsonArray arr;
+    for (qint64 uid : uids)
+    {
+        if (uid > 0)
+            arr.append(uid);
+    }
+    body.insert(QStringLiteral("uids"), arr);
+    request(Proto::Type::Group, Proto::Cmd::GroupCreate, body, cb);
+}
+
+void ImClient::groupInfo(qint64 groupId, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    request(Proto::Type::Group, Proto::Cmd::GroupInfo, body, cb);
+}
+
+void ImClient::groupMembers(qint64 groupId, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    request(Proto::Type::Group, Proto::Cmd::GroupMembers, body, cb);
+}
+
+void ImClient::groupList(Reply cb)
+{
+    request(Proto::Type::Group, Proto::Cmd::GroupList, QJsonObject(), cb);
+}
+
+void ImClient::groupUpdate(qint64 groupId, const QString &name, int joinMode, Reply cb)
+{
+    groupUpdate(groupId, name, joinMode, QByteArray(), cb);
+}
+
+void ImClient::groupUpdate(qint64 groupId, const QString &name, int joinMode, const QByteArray &avatarJpeg,
+                           Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    if (!name.isNull())
+        body.insert(QStringLiteral("name"), name);
+    if (joinMode >= 0)
+        body.insert(QStringLiteral("join_mode"), joinMode);
+    if (!avatarJpeg.isEmpty())
+        body.insert(QStringLiteral("avatar"), QString::fromLatin1(avatarJpeg.toBase64()));
+    request(Proto::Type::Group, Proto::Cmd::GroupUpdate, body, cb);
+}
+
+void ImClient::groupInvite(qint64 groupId, const QVector<qint64> &uids, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    QJsonArray arr;
+    for (qint64 uid : uids)
+    {
+        if (uid > 0)
+            arr.append(uid);
+    }
+    body.insert(QStringLiteral("uids"), arr);
+    request(Proto::Type::Group, Proto::Cmd::GroupInvite, body, cb);
+}
+
+void ImClient::groupSearch(qint64 groupId, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    request(Proto::Type::Group, Proto::Cmd::GroupSearch, body, cb);
+}
+
+void ImClient::groupJoin(qint64 groupId, const QString &message, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    if (!message.isEmpty())
+        body.insert(QStringLiteral("message"), message);
+    request(Proto::Type::Group, Proto::Cmd::GroupJoin, body, cb);
+}
+
+void ImClient::groupLeave(qint64 groupId, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    request(Proto::Type::Group, Proto::Cmd::GroupLeave, body, cb);
+}
+
+void ImClient::groupDismiss(qint64 groupId, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    request(Proto::Type::Group, Proto::Cmd::GroupDismiss, body, cb);
+}
+
+void ImClient::groupKick(qint64 groupId, qint64 uid, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    body.insert(QStringLiteral("uid"), uid);
+    request(Proto::Type::Group, Proto::Cmd::GroupKick, body, cb);
+}
+
+void ImClient::groupSetAdmin(qint64 groupId, qint64 uid, bool admin, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    body.insert(QStringLiteral("uid"), uid);
+    body.insert(QStringLiteral("admin"), admin ? 1 : 0);
+    request(Proto::Type::Group, Proto::Cmd::GroupSetAdmin, body, cb);
+}
+
+void ImClient::groupTransfer(qint64 groupId, qint64 uid, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("group_id"), groupId);
+    body.insert(QStringLiteral("uid"), uid);
+    request(Proto::Type::Group, Proto::Cmd::GroupTransfer, body, cb);
+}
+
+void ImClient::groupRequestList(qint64 groupId, Reply cb)
+{
+    QJsonObject body;
+    if (groupId > 0)
+        body.insert(QStringLiteral("group_id"), groupId);
+    request(Proto::Type::Group, Proto::Cmd::GroupRequestList, body, cb);
+}
+
+void ImClient::groupHandleRequest(qint64 id, int action, Reply cb)
+{
+    QJsonObject body;
+    body.insert(QStringLiteral("id"), id);
+    body.insert(QStringLiteral("action"), action);
+    request(Proto::Type::Group, Proto::Cmd::GroupHandleRequest, body, cb);
+}
+
 bool ImClient::ok(const QJsonObject &reply)
 {
     return reply.value(QStringLiteral("code")).toInt() == Proto::Err::Ok;
@@ -630,6 +814,16 @@ void ImClient::handlePacket(const Proto::Packet &pkt)
         emit presenceNotify(obj);
         return;
     }
+    if (pkt.seq == 0 && pkt.cmd == static_cast<quint16>(Proto::Cmd::GroupRequestNotify))
+    {
+        emit groupRequestNotify(obj);
+        return;
+    }
+    if (pkt.seq == 0 && pkt.cmd == static_cast<quint16>(Proto::Cmd::GroupChangeNotify))
+    {
+        emit groupChangeNotify(obj);
+        return;
+    }
     if (pkt.seq == 0)
         return;
 

+ 27 - 0
Client/src/network/ImClient.h

@@ -57,15 +57,20 @@ public:
     void getSticker(qint64 packId, const QString &code, Reply cb);
     void convList(Reply cb);
     void msgHistory(qint64 peerUid, qint64 lastId, int limit, Reply cb);
+    void msgHistory(qint64 peerUid, int convType, qint64 lastId, int limit, Reply cb);
     void deleteConv(qint64 peerUid, Reply cb);
+    void deleteConv(qint64 peerUid, int convType, Reply cb);
     void deleteMsgs(qint64 peerUid, const QVector<qint64> &ids, Reply cb);
+    void deleteMsgs(qint64 peerUid, int convType, const QVector<qint64> &ids, Reply cb);
     void setUnread(qint64 peerUid, int unread, Reply cb);
+    void setUnread(qint64 peerUid, int convType, int unread, Reply cb);
     void deleteFriend(qint64 uid, Reply cb);
     void setRemark(qint64 uid, const QString &remark, Reply cb);
     void getProfile(qint64 uid, Reply cb);
     void setProfile(const QString &nickname, const QString &signature, const QByteArray &avatarJpeg,
                     Reply cb);
     void getAvatar(qint64 uid, Reply cb);
+    void getGroupAvatar(qint64 groupId, Reply cb);
     void putFile(const QString &name, const QString &mime, qint64 size, Reply cb);
     void putFile(const QString &name, const QString &mime, qint64 size, const QString &purpose,
                  Reply cb);
@@ -77,6 +82,26 @@ public:
     void favoriteDelete(qint64 id, Reply cb);
     void chat(qint64 toUid, const QString &content, Reply cb);
     void chat(qint64 toUid, int msgType, const QString &content, Reply cb);
+    void chat(qint64 peerId, int convType, int msgType, const QString &content, Reply cb);
+
+    void groupCreate(const QString &name, const QVector<qint64> &uids, Reply cb);
+    void groupInfo(qint64 groupId, Reply cb);
+    void groupMembers(qint64 groupId, Reply cb);
+    void groupList(Reply cb);
+    void groupUpdate(qint64 groupId, const QString &name, int joinMode, Reply cb);
+    void groupUpdate(qint64 groupId, const QString &name, int joinMode, const QByteArray &avatarJpeg,
+                     Reply cb);
+    void groupInvite(qint64 groupId, const QVector<qint64> &uids, Reply cb);
+    void groupSearch(qint64 groupId, Reply cb);
+    void groupJoin(qint64 groupId, const QString &message, Reply cb);
+    void groupLeave(qint64 groupId, Reply cb);
+    void groupDismiss(qint64 groupId, Reply cb);
+    void groupKick(qint64 groupId, qint64 uid, Reply cb);
+    void groupSetAdmin(qint64 groupId, qint64 uid, bool admin, Reply cb);
+    void groupTransfer(qint64 groupId, qint64 uid, Reply cb);
+    void groupRequestList(qint64 groupId, Reply cb);
+    void groupHandleRequest(qint64 id, int action, Reply cb);
+
     void applyLocalProfile(const QString &nickname, const QString &signature, int avatarVer);
 
     static bool ok(const QJsonObject &reply);
@@ -92,6 +117,8 @@ signals:
     void friendRequestNotify(const QJsonObject &msg);
     void friendAcceptedNotify(const QJsonObject &msg);
     void presenceNotify(const QJsonObject &msg);
+    void groupRequestNotify(const QJsonObject &msg);
+    void groupChangeNotify(const QJsonObject &msg);
 
 private:
     explicit ImClient(QObject *parent = nullptr);

+ 30 - 0
common/i18n.h

@@ -67,6 +67,21 @@ enum class Msg {
     CannotOperateSystemFriend,
     FavoriteNotFound,
     FavoriteContentTooLong,
+    GroupNotFound,
+    GroupNameRequired,
+    GroupNameTooLong,
+    NotGroupMember,
+    GroupNoPermission,
+    GroupFull,
+    JoinNotAllowed,
+    AlreadyInGroup,
+    OwnerCannotLeave,
+    GroupNeedMembers,
+    GroupRequestSent,
+    GroupRequestPending,
+    GroupAdminFull,
+    CannotKickOwner,
+    GroupIdRequired,
     _Count
 };
 
@@ -137,6 +152,21 @@ inline const Entry& table(Msg id)
         {"系统账号不支持此操作", "System account does not support this action"},
         {"收藏不存在", "Favorite not found"},
         {"收藏内容过长", "Favorite content too long"},
+        {"群聊不存在", "Group not found"},
+        {"请输入群名称", "Group name required"},
+        {"群名称过长", "Group name too long"},
+        {"你不是群成员", "Not a group member"},
+        {"没有权限", "No permission"},
+        {"群人数已满", "Group is full"},
+        {"该群不允许加入", "This group does not allow joining"},
+        {"已在群中", "Already in the group"},
+        {"群主不能退群,请先转让或解散", "Owner cannot leave; transfer or dismiss first"},
+        {"请至少选择一名好友", "Select at least one friend"},
+        {"入群申请已发送", "Join request sent"},
+        {"已发送过申请,请等待处理", "Request already sent"},
+        {"管理员人数已满", "Too many admins"},
+        {"不能移除群主", "Cannot remove the owner"},
+        {"请指定群聊", "group_id required"},
     };
     const int i = static_cast<int>(id);
     if (i < 0 || i >= static_cast<int>(Msg::_Count)) {

+ 54 - 0
common/protocol_def.h

@@ -13,6 +13,7 @@ enum class Type : uint16_t {
     Message  = 4,  // 会话与聊天
     Sticker  = 5,  // 表情包
     Favorite = 6,  // 收藏夹
+    Group    = 7,  // 群聊
 };
 
 enum class Cmd : uint16_t {
@@ -52,11 +53,28 @@ enum class Cmd : uint16_t {
     FavoriteUpdate       = 34,
     FavoriteDelete       = 35,
     DeleteMsg            = 36,
+    GroupCreate          = 37,
+    GroupInfo            = 38,
+    GroupMembers         = 39,
+    GroupUpdate          = 40,
+    GroupInvite          = 41,
+    GroupSearch          = 42,
+    GroupJoin            = 43,
+    GroupLeave           = 44,
+    GroupDismiss         = 45,
+    GroupKick            = 46,
+    GroupSetAdmin        = 47,
+    GroupTransfer        = 48,
+    GroupRequestList     = 49,
+    GroupHandleRequest   = 50,
+    GroupList            = 51,
     ChatNotify           = 100,
     Kicked               = 101,
     FriendRequestNotify  = 102,
     FriendAcceptedNotify = 103,
     PresenceNotify       = 104,
+    GroupRequestNotify   = 105,
+    GroupChangeNotify    = 106,
 };
 
 namespace Err {
@@ -81,6 +99,7 @@ constexpr int Text    = 1;
 constexpr int Sticker = 2;
 constexpr int File    = 3;
 constexpr int Card    = 4;
+constexpr int Sys     = 5;
 }
 
 namespace FavKind {
@@ -98,6 +117,23 @@ constexpr int Friend   = 2;  // 已是好友
 constexpr int Incoming = 3;  // 对方已申请加我
 }
 
+namespace ConvType {
+constexpr int Direct = 0;
+constexpr int Group  = 1;
+}
+
+namespace GroupRole {
+constexpr int Member = 0;
+constexpr int Admin  = 1;
+constexpr int Owner  = 2;
+}
+
+namespace JoinMode {
+constexpr int Open    = 0;  // 无需审核
+constexpr int Approve = 1;  // 群主或管理员审核
+constexpr int Closed  = 2;  // 外人不可申请
+}
+
 constexpr uint32_t kHeaderSize = 12;
 constexpr int64_t kFileHelperUid = 1;
 
@@ -110,6 +146,7 @@ inline const char* type_name(uint16_t type)
     case Type::Message:  return "Message";
     case Type::Sticker:  return "Sticker";
     case Type::Favorite: return "Favorite";
+    case Type::Group:    return "Group";
     }
     return "?";
 }
@@ -153,11 +190,28 @@ inline const char* cmd_name(uint16_t cmd)
     case Cmd::FavoriteUpdate:       return "FavoriteUpdate";
     case Cmd::FavoriteDelete:       return "FavoriteDelete";
     case Cmd::DeleteMsg:            return "DeleteMsg";
+    case Cmd::GroupCreate:          return "GroupCreate";
+    case Cmd::GroupInfo:            return "GroupInfo";
+    case Cmd::GroupMembers:         return "GroupMembers";
+    case Cmd::GroupUpdate:          return "GroupUpdate";
+    case Cmd::GroupInvite:          return "GroupInvite";
+    case Cmd::GroupSearch:          return "GroupSearch";
+    case Cmd::GroupJoin:            return "GroupJoin";
+    case Cmd::GroupLeave:           return "GroupLeave";
+    case Cmd::GroupDismiss:         return "GroupDismiss";
+    case Cmd::GroupKick:            return "GroupKick";
+    case Cmd::GroupSetAdmin:        return "GroupSetAdmin";
+    case Cmd::GroupTransfer:        return "GroupTransfer";
+    case Cmd::GroupRequestList:     return "GroupRequestList";
+    case Cmd::GroupHandleRequest:   return "GroupHandleRequest";
+    case Cmd::GroupList:            return "GroupList";
     case Cmd::ChatNotify:           return "ChatNotify";
     case Cmd::Kicked:               return "Kicked";
     case Cmd::FriendRequestNotify:  return "FriendRequestNotify";
     case Cmd::FriendAcceptedNotify: return "FriendAcceptedNotify";
     case Cmd::PresenceNotify:       return "PresenceNotify";
+    case Cmd::GroupRequestNotify:   return "GroupRequestNotify";
+    case Cmd::GroupChangeNotify:    return "GroupChangeNotify";
     }
     return "?";
 }

+ 1 - 0
docs/README.md

@@ -23,6 +23,7 @@
 | 搜索/添加好友 | `Friend` / `SearchUser(14)` → `AddFriend(12)`,对方 `HandleFriendRequest(16)` |
 | 隐私设置 | `Account` / `GetPrivacy(17)` `SetPrivacy(18)` |
 | 表情包 | `Sticker` / `StickerPackList(19)` `StickerPack(20)` `GetSticker(21)`,发消息 `msg_type=2` |
+| 群聊 | `Group` / `GroupCreate(37)` `GroupList(51)` `GroupInfo(38)` …;进群 `GroupSearch(42)` `GroupJoin(43)`;消息仍走 `Chat(4)` 带 `group_id` |
 | 心跳 | `System` / `Heartbeat(1)` |
 | 被挤下线 | `System` 推送 `Kicked(101)` |
 

+ 173 - 11
docs/protocol.md

@@ -2,7 +2,7 @@
 
 默认监听地址以 `server/config/config.ini` `[server]` 为准(当前 `0.0.0.0:6000`)。传输为 **裸 TCP**,应用层自行组包,**不是** HPSocket PACK 模型。
 
-本文与 Qt 客户端界面一一对应:登录、注册、验证码、找回/修改密码、会话列表、好友列表、发消息、退出登录。
+本文与 Qt 客户端界面一一对应:登录、注册、验证码、找回/修改密码、会话列表、好友列表、群聊、发消息、退出登录。
 
 ## 1. 数据包
 
@@ -51,8 +51,9 @@ out += body   # UTF-8 bytes
 | System | 1 | 系统 | Heartbeat(1),SetConfig(13)(应答带 `limits`),推送 Kicked(101) |
 | Account | 2 | 账户 | Login(2) Logout(3) Register(5) SendCode(6) ResetPassword(7) ChangePassword(8) GetPrivacy(17) SetPrivacy(18) SetProfile(27) GetAvatar(28) |
 | Friend | 3 | 好友 | FriendList(9) AddFriend(12) SearchUser(14) FriendRequestList(15) HandleFriendRequest(16) DeleteFriend(24) SetRemark(25) GetProfile(26),推送 FriendRequestNotify(102) FriendAcceptedNotify(103) PresenceNotify(104) |
-| Message | 4 | 消息 | Chat(4) ConvList(10) MsgHistory(11) DeleteConv(22) SetUnread(23) PutFile(29) GetFile(30) DeleteMsg(36),推送 ChatNotify(100) |
+| Message | 4 | 消息 | Chat(4) ConvList(10) MsgHistory(11) DeleteConv(22) SetUnread(23) PutFile(29) GetFile(30) DeleteMsg(36),推送 ChatNotify(100)。群消息带 `group_id` |
 | Favorite | 6 | 收藏夹 | FavoriteAdd(31) FavoriteList(32) FavoriteGet(33) FavoriteUpdate(34) FavoriteDelete(35) |
+| Group | 7 | 群聊 | GroupCreate(37) GroupInfo(38) GroupMembers(39) GroupUpdate(40) GroupInvite(41) GroupSearch(42) GroupJoin(43) GroupLeave(44) GroupDismiss(45) GroupKick(46) GroupSetAdmin(47) GroupTransfer(48) GroupRequestList(49) GroupHandleRequest(50) GroupList(51),推送 GroupRequestNotify(105) GroupChangeNotify(106) |
 
 与 Client 界面的对应关系:
 
@@ -61,7 +62,7 @@ out += body   # UTF-8 bytes
 | 1 | Heartbeat | 否 | 建议 30s 一次 | 心跳 |
 | 2 | Login | 否 | 登录窗 | 账号(username)+ 密码登录 |
 | 3 | Logout | 是 | 设置 → 退出登录 | 清会话,不断开 TCP |
-| 4 | Chat | 是 | 聊天输入框发送 | 文本 / 表情 / 文件 / 名片 |
+| 4 | Chat | 是 | 聊天输入框发送 | 文本 / 表情 / 文件 / 名片;群聊带 `group_id` |
 | 5 | Register | 否 | 注册账号 | 注册 |
 | 6 | SendCode | 否 | 获取验证码 | 短信验证码 |
 | 7 | ResetPassword | 否 | 忘记密码 | 用验证码重置 |
@@ -84,7 +85,7 @@ out += body   # UTF-8 bytes
 | 25 | SetRemark | 是 | 好友右键 → 备注 | 只改自己这一侧的备注 |
 | 26 | GetProfile | 是 | 好友右键 → 查看名片 | 按 uid 拉资料 |
 | 27 | SetProfile | 是 | 设置 → 资料 | 改昵称、签名、头像 |
-| 28 | GetAvatar | 是 | 头像缓存 | 按 uid 下载头像 JPEG |
+| 28 | GetAvatar | 是 | 头像缓存 | 按 `uid` 或 `group_id` 下载头像 JPEG |
 | 29 | PutFile | 是 | 聊天 → 图片/文件 | 申请 COS 临时上传凭证,返回 file_id + token |
 | 30 | GetFile | 是 | 点击文件气泡 | 按 file_id 下载 |
 | 31 | FavoriteAdd | 是 | 消息右键 → 收藏 / 新建笔记 | 收藏会话内容或自定义笔记 |
@@ -93,11 +94,28 @@ out += body   # UTF-8 bytes
 | 34 | FavoriteUpdate | 是 | 编辑笔记 | 只允许改文本/笔记 |
 | 35 | FavoriteDelete | 是 | 收藏右键 → 删除 | 删除自己的收藏及 COS 副本 |
 | 36 | DeleteMsg | 是 | 聊天记录右键 → 删除 / 多选删除 | 仅标记自己侧消息已删除 |
-| 100 | ChatNotify | 推送 | 聊天区收消息 | 对方在线时推送 |
+| 37 | GroupCreate | 是 | 好友页 + → 发起群聊 | 选好友建群 |
+| 38 | GroupInfo | 是 | 群资料 | 群名、进群方式、人数、头像版本、自己的角色 |
+| 39 | GroupMembers | 是 | 群资料成员网格 | 成员列表 |
+| 40 | GroupUpdate | 是 | 群资料 | 改群名 / `join_mode` / 群头像 |
+| 41 | GroupInvite | 是 | 群资料 → 邀请 | 拉好友进群 |
+| 42 | GroupSearch | 是 | 添加好友对话框搜群号 | 按群号精确搜索 |
+| 43 | GroupJoin | 是 | 群名片 / 搜群号 | 按 `join_mode` 直接进或提交申请 |
+| 44 | GroupLeave | 是 | 群资料 → 退出 | 群主须先转让或解散 |
+| 45 | GroupDismiss | 是 | 群资料 → 解散 | 仅群主 |
+| 46 | GroupKick | 是 | 群成员右键 → 移除 | 群主可踢管理员/成员,管理员只能踢成员 |
+| 47 | GroupSetAdmin | 是 | 群成员右键 | 仅群主设置/取消管理员 |
+| 48 | GroupTransfer | 是 | 群资料 → 转让 | 仅群主 |
+| 49 | GroupRequestList | 是 | 群资料 → 入群申请 | 待处理申请 |
+| 50 | GroupHandleRequest | 是 | 入群申请 同意/拒绝 | 1同意 2拒绝 |
+| 51 | GroupList | 是 | 好友页 → 群聊 | 我加入的群 |
+| 100 | ChatNotify | 推送 | 聊天区收消息 | 对方/群成员在线时推送 |
 | 101 | Kicked | 推送 | 被挤下线 | 同一账号在别处登录 |
 | 102 | FriendRequestNotify | 推送 | 好友申请提示 | 收到加好友申请 |
 | 103 | FriendAcceptedNotify | 推送 | 好友同意提示 | 对方同意申请 |
 | 104 | PresenceNotify | 推送 | 会话/好友头像绿点 | 好友上线/离线 |
+| 105 | GroupRequestNotify | 推送 | 入群申请提示 | 群主/管理员收到申请 |
+| 106 | GroupChangeNotify | 推送 | 群资料/会话 | `event`: join / leave / kick / dismiss / role / info / invite |
 
 未登录可发:Heartbeat / SetConfig / Login / Register / SendCode / ResetPassword。
 连接建立后客户端应立刻发送 SetConfig,服务端按该连接语言返回 `msg`,并在应答中下发 `[limits]`(账号/密码长度、文件头像上限等)。客户端不再在本地 `config.ini` 保留这些限制。其余请求若未登录,回 `code=2`。
@@ -182,6 +200,9 @@ Type=`System`。连接成功后立即发送,上报客户端语言并拉取服
     "history_max":100,
     "avatar_max":81920,
     "file_max":524288,
+    "group_max_members":200,
+    "group_name_max":32,
+    "group_max_admins":10,
     "code_len":6,
     "max_packet":1048576,
     "heartbeat_sec":30,
@@ -384,6 +405,7 @@ GetSticker 成功:
   "msg":"ok",
   "list":[
     {
+      "conv_type":0,
       "peer_uid":10003,
       "account":"bob",
       "nickname":"Bob",
@@ -392,13 +414,25 @@ GetSticker 成功:
       "preview":"hello",
       "ts":1788146838644,
       "unread":2,
-      "online":1
+      "online":1,
+      "avatar_ver":1
+    },
+    {
+      "conv_type":1,
+      "group_id":5,
+      "name":"周末局",
+      "member_count":6,
+      "last_msg_id":12,
+      "preview":"hello",
+      "ts":1788146838644,
+      "unread":1,
+      "avatar_ver":1
     }
   ]
 }
 ```
 
-`unread` 对应会话头像角标。`online`:`1` 在线,`0` 离线。实时变化见 `PresenceNotify`。
+`conv_type`:`0` 私聊(`peer_uid`),`1` 群(`group_id` + `name`)。缺省按私聊处理。`unread` 对应会话头像角标。私聊 `online`:`1` 在线,`0` 离线。实时变化见 `PresenceNotify`。群会话无 `online`。群会话的 `avatar_ver` 为群头像版本,`0` 表示未设置。
 
 ### 4.11 MsgHistory(cmd=11)
 
@@ -408,6 +442,12 @@ GetSticker 成功:
 {"peer_uid":10003,"last_id":0,"limit":50}
 ```
 
+群会话用 `group_id` 替代 `peer_uid`:
+
+```json
+{"group_id":5,"last_id":0,"limit":50}
+```
+
 | 字段 | 说明 |
 |------|------|
 | `last_id` | `0` 表示最新一页;大于 0 时拉比该 id 更早的消息 |
@@ -428,12 +468,20 @@ GetSticker 成功:
 
 ### 4.12 Chat(cmd=4)
 
-对应输入框发送。`msg_type=1` 文本;`msg_type=2` 表情,`content` 为 `{"pack_id":1,"code":"smile"}`;`msg_type=3` 文件/图片,`content` 为 `{"file_id":12,"name":"a.pdf","size":1024,"mime":"application/pdf"}`(须先 PutFile,再用 token 直传 COS,对象落盘后再发 Chat;服务端会 HEAD 校验);`msg_type=4` 名片,`content` 为 `{"uid":10003,"account":"bob","nickname":"Bob"}`。文本最长、文件体积上限见服务端 `[limits] content_max` / `file_max`(连接后经 SetConfig 下发给客户端)。
+对应输入框发送。`msg_type=1` 文本;`msg_type=2` 表情,`content` 为 `{"pack_id":1,"code":"smile"}`;`msg_type=3` 文件/图片,`content` 为 `{"file_id":12,"name":"a.pdf","size":1024,"mime":"application/pdf"}`(须先 PutFile,再用 token 直传 COS,对象落盘后再发 Chat;服务端会 HEAD 校验);`msg_type=4` 名片,好友名片 `content` 为 `{"uid":10003,"account":"bob","nickname":"Bob"}`,群名片为 `{"kind":"group","group_id":5,"name":"周末局","member_count":6}`(缺省 `kind` 视为好友名片);`msg_type=5` 群系统通知(服务端写入,客户端只展示)。文本最长、文件体积上限见服务端 `[limits] content_max` / `file_max`(连接后经 SetConfig 下发给客户端)。
+
+私聊:
 
 ```json
 {"to_uid":10003,"msg_type":1,"content":"hello"}
 ```
 
+群聊(须为成员;有 `group_id` 时忽略 `to_uid`):
+
+```json
+{"group_id":5,"msg_type":1,"content":"hello"}
+```
+
 成功:
 
 ```json
@@ -455,7 +503,7 @@ GetSticker 成功:
 }
 ```
 
-双方会话预览会更新;接收方 `unread+1`。
+双方会话预览会更新;接收方 `unread+1`。群消息 `ChatNotify` 另带 `group_id`,向群内其他在线成员 fan-out。
 
 ### 4.14 DeleteConv(cmd=22)
 
@@ -465,6 +513,8 @@ GetSticker 成功:
 {"peer_uid":10003}
 ```
 
+群会话:`{"group_id":5}`。只清自己的会话摘要(群 inbox)并隐藏自己侧消息,不退群。
+
 ### 4.14b DeleteMsg(cmd=36)
 
 删除当前用户聊天记录中的若干条消息(软删除,对方仍可见)。`ids` 最多 100 个,且须属于该 `peer_uid` 会话。
@@ -473,6 +523,8 @@ GetSticker 成功:
 {"peer_uid":10003,"ids":[87,88]}
 ```
 
+群会话用 `group_id` 替代 `peer_uid`。
+
 成功:`peer_uid`、`ids`、以及更新后的会话预览 `last_msg_id`、`preview`、`ts`(无剩余消息时为空/0)。
 
 ### 4.15 SetUnread(cmd=23)
@@ -481,6 +533,8 @@ GetSticker 成功:
 {"peer_uid":10003,"unread":0}
 ```
 
+群会话:`{"group_id":5,"unread":0}`。
+
 `unread=0` 标记已读;`unread>=1` 标记未读。点开会话拉历史(MsgHistory)仍会清未读。
 
 ### 4.16 DeleteFriend(cmd=24)
@@ -523,11 +577,17 @@ GetSticker 成功:
 
 ### 4.21 GetAvatar(cmd=28)
 
+按用户或群下载 JPEG。`group_id` 优先于 `uid`。
+
 ```json
 {"uid":10003}
 ```
 
-成功:`uid`、`ver`、`mime`、`data`(JPEG base64)。未设置头像时 `code=4`。
+```json
+{"group_id":5}
+```
+
+成功:`uid` 或 `group_id`、`ver`、`mime`、`url`(COS 对象键;无 CDN 时另带 `data` JPEG base64)。未设置头像时 `code=4`。
 
 ### 4.22 PutFile(cmd=29)
 
@@ -653,6 +713,102 @@ GetFile 仍可用于无 CDN 时的回退下载;配置了 `[cdn] domain` 时客
 
 只删当前用户这条收藏,以及其 `favorites/` 下的 COS 副本。聊天 `im/files/` 不动。
 
+### 4.29 GroupCreate(cmd=37,Type=7)
+
+```json
+{"name":"周末局","uids":[10003,10004]}
+```
+
+`uids` 为好友 uid,不含自己,至少 1 人。缺省 `name` 时服务端用成员昵称拼接。创建者为群主,被选好友直接入群。`join_mode` 默认 `1`(需审核)。
+
+成功:`group_id`、`name`、`join_mode`、`member_count`、`role`。
+
+`join_mode`:`0` 无需审核;`1` 群主或管理员审核;`2` 外人不可申请(群主/管理员仍可邀请好友)。
+
+### 4.30 GroupInfo(cmd=38) / GroupMembers(cmd=39) / GroupList(cmd=51)
+
+```json
+{"group_id":5}
+```
+
+GroupInfo 成功:`group_id`、`name`、`owner_uid`、`join_mode`、`member_count`、`avatar_ver`、`role`(当前用户,非成员为 `-1`)、`pending`(群主/管理员可见的待审申请数)。
+
+GroupMembers 须为成员。`list` 每项:`uid`、`account`、`nickname`、`avatar_ver`、`role`(0 成员 / 1 管理员 / 2 群主)。
+
+GroupList 请求可空。`list` 每项:`group_id`、`name`、`member_count`、`join_mode`、`role`、`avatar_ver`。
+
+### 4.31 GroupUpdate(cmd=40) / GroupInvite(cmd=41)
+
+群主或管理员。Update 缺省字段保持原值;`avatar` 为 JPEG 的 base64(与 SetProfile 相同):
+
+```json
+{"group_id":5,"name":"新名字","join_mode":0,"avatar":"/9j/..."}
+```
+
+成功返回与 GroupInfo 相同的群字段(含 `avatar_ver`)。`GroupChangeNotify` `event=info` 会带上最新 `name` / `join_mode` / `avatar_ver`。
+
+Invite:
+
+```json
+{"group_id":5,"uids":[10005]}
+```
+
+被邀请人须是邀请者的好友。即使 `join_mode=2` 也可拉人。
+
+### 4.32 GroupSearch(cmd=42) / GroupJoin(cmd=43)
+
+按群号(`group_id`)精确搜索;Join 按 `join_mode` 直接入群或写入申请。
+
+```json
+{"group_id":5,"message":"我是同事"}
+```
+
+Join 成功时 `joined=1` 表示已入群;`joined=0` 且 `pending=1` 表示已提交申请。
+
+### 4.33 GroupLeave(cmd=44) / GroupDismiss(cmd=45) / GroupKick(cmd=46)
+
+```json
+{"group_id":5}
+```
+
+Kick 另带 `uid`。群主不能 Leave(须 Transfer 或 Dismiss)。管理员只能踢普通成员。
+
+### 4.34 GroupSetAdmin(cmd=47) / GroupTransfer(cmd=48)
+
+仅群主。`admin=1` 设为管理员,`admin=0` 取消。转让后原群主变为普通成员。
+
+```json
+{"group_id":5,"uid":10003,"admin":1}
+```
+
+```json
+{"group_id":5,"uid":10003}
+```
+
+### 4.35 GroupRequestList(cmd=49) / GroupHandleRequest(cmd=50)
+
+列出待处理入群申请。可带 `group_id` 只看某一群;不带则列出自己作为群主/管理员的全部待审。`action=1` 同意,`action=2` 拒绝。
+
+```json
+{"id":8,"action":1}
+```
+
+### 4.36 GroupRequestNotify(cmd=105) / GroupChangeNotify(cmd=106,推送)
+
+入群申请推给在线群主/管理员:
+
+```json
+{"id":8,"group_id":5,"name":"周末局","from_uid":10006,"from_nickname":"Eve","message":""}
+```
+
+群变更推给在线成员(`seq=0`):
+
+```json
+{"event":"kick","group_id":5,"name":"周末局","uid":10006,"nickname":"Eve"}
+```
+
+`event`:`join` / `leave` / `kick` / `dismiss` / `role` / `info` / `invite`。
+
 ### 4.19 Kicked(cmd=101,推送)
 
 ```json
@@ -680,7 +836,7 @@ GetFile 仍可用于无 CDN 时的回退下载;配置了 `[cdn] domain` 时客
 
 - 接入后即可发包,无握手包
 - 建议 30s 内发一次心跳,避免中间设备掐连接
-- 推送复用同一包格式:`ChatNotify=100`、`Kicked=101`、`FriendRequestNotify=102`、`FriendAcceptedNotify=103`、`PresenceNotify=104`,`seq=0`
+- 推送复用同一包格式:`ChatNotify=100`、`Kicked=101`、`FriendRequestNotify=102`、`FriendAcceptedNotify=103`、`PresenceNotify=104`、`GroupRequestNotify=105`、`GroupChangeNotify=106`,`seq=0`
 - 关连接无需发 Logout;Logout 只清登录态
 - 密码在服务端以 SHA-256 十六进制存储,传输仍为明文 JSON,生产环境应叠 TLS 或另行加密
 
@@ -704,6 +860,12 @@ GetFile 仍可用于无 CDN 时的回退下载;配置了 `[cdn] domain` 时客
 | `im_message` | 消息 |
 | `im_conversation` | 会话摘要与未读 |
 | `im_favorite` | 收藏(文本/图片/文件/名片/笔记) |
+| `im_group` | 群资料(`join_mode`:0 开放 / 1 审核 / 2 禁止) |
+| `im_group_member` | 群成员(`role`:0 成员 / 1 管理员 / 2 群主) |
+| `im_group_request` | 入群申请 |
+| `im_group_message` | 群消息 |
+| `im_group_msg_deleted` | 群消息自己侧隐藏 |
+| `im_group_inbox` | 群会话摘要与未读 |
 
 `uid=1` 为文件传输助手(`username=filehelper`,不可登录)。
 

+ 1 - 0
server/CMakeLists.txt

@@ -59,6 +59,7 @@ else()
         ${_YLIB_NAME}
         hpsocket
         mysqlcppconn
+        hiredis
         curl
         ssl
         crypto

+ 25 - 0
server/config/config.ini.example

@@ -5,6 +5,25 @@ host=0.0.0.0
 port=6000
 # 最大同时连接数
 max_connections=10000
+# HP I/O 工作线程数。0=默认(CPU核数*2+2);负数=CPU核数*(-n)
+worker_threads=0
+# HP 业务线程池大小。0=默认(CPU核数*2+2);负数=CPU核数*(-n)
+# 建议 mysql pool_size >= 线程池大小
+thread_pool=0
+# HP 业务线程池队列上限。0=不限制;满则该连接提交失败并断开
+thread_pool_queue=0
+# 多节点唯一 ID。空则用 hostname:port。同一 Redis 下每台进程必须不同
+node_id=
+
+[redis]
+# 1=启用 Redis 在线路由(多节点必须开);0=单机,状态只在本进程
+enable=0
+# 1=连不上 Redis 则退出;0=告警后单机运行
+required=0
+host=127.0.0.1
+port=6379
+password=
+db=0
 
 [protocol]
 # 包头魔数,十六进制(ASCII "IM"),须与客户端一致
@@ -43,6 +62,12 @@ history_max=100
 avatar_max=81920
 # 聊天图片/文件最大字节(客户端直传 COS,PutFile 只校验 size)
 file_max=104857600
+# 群人数上限(含群主)
+group_max_members=200
+# 群名称最长字节
+group_name_max=32
+# 群管理员人数上限(不含群主)
+group_max_admins=10
 
 [cos]
 # 1=头像、聊天图片/文件、表情全部走腾讯云 COS。聊天图片/文件由客户端持临时 token 直传,不经 IM 服务端。secret 未填齐则启动失败

+ 64 - 0
server/sql/init.sql

@@ -130,6 +130,70 @@ CREATE TABLE IF NOT EXISTS im_favorite (
     KEY idx_uid_time (uid, id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
 
+CREATE TABLE IF NOT EXISTS im_group (
+    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    name          VARCHAR(64)     NOT NULL DEFAULT '',
+    owner_uid     BIGINT UNSIGNED NOT NULL,
+    join_mode     TINYINT         NOT NULL DEFAULT 1 COMMENT '0开放 1审核 2禁止',
+    member_count  INT             NOT NULL DEFAULT 0,
+    avatar_ver    INT             NOT NULL DEFAULT 0,
+    created_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    KEY idx_owner (owner_uid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_group_member (
+    group_id    BIGINT UNSIGNED NOT NULL,
+    uid         BIGINT UNSIGNED NOT NULL,
+    role        TINYINT         NOT NULL DEFAULT 0 COMMENT '0成员 1管理员 2群主',
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (group_id, uid),
+    KEY idx_uid (uid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_group_request (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    group_id    BIGINT UNSIGNED NOT NULL,
+    from_uid    BIGINT UNSIGNED NOT NULL,
+    status      TINYINT         NOT NULL DEFAULT 0 COMMENT '0待处理 1已同意 2已拒绝',
+    message     VARCHAR(64)     NOT NULL DEFAULT '',
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_group_from (group_id, from_uid),
+    KEY idx_group_status (group_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_group_message (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    group_id    BIGINT UNSIGNED NOT NULL,
+    from_uid    BIGINT UNSIGNED NOT NULL DEFAULT 0,
+    msg_type    TINYINT         NOT NULL DEFAULT 1,
+    content     TEXT            NOT NULL,
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    KEY idx_group_id (group_id, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_group_msg_deleted (
+    uid     BIGINT UNSIGNED NOT NULL,
+    msg_id  BIGINT UNSIGNED NOT NULL,
+    PRIMARY KEY (uid, msg_id),
+    KEY idx_msg (msg_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_group_inbox (
+    uid           BIGINT UNSIGNED NOT NULL,
+    group_id      BIGINT UNSIGNED NOT NULL,
+    last_msg_id   BIGINT UNSIGNED NOT NULL DEFAULT 0,
+    last_content  VARCHAR(256)    NOT NULL DEFAULT '',
+    last_time     DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    unread        INT             NOT NULL DEFAULT 0,
+    PRIMARY KEY (uid, group_id),
+    KEY idx_uid_time (uid, last_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
 -- 系统账号:文件传输助手。密码不可登录(status=2)
 INSERT INTO im_user (id, username, password, nickname, phone, signature, status)
 VALUES (1, 'filehelper', '-', '文件传输助手', '', '文件助手', 2)

+ 66 - 0
server/sql/migrate.sql

@@ -128,3 +128,69 @@ CREATE TABLE IF NOT EXISTS im_favorite (
     PRIMARY KEY (id),
     KEY idx_uid_time (uid, id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_group (
+    id            BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    name          VARCHAR(64)     NOT NULL DEFAULT '',
+    owner_uid     BIGINT UNSIGNED NOT NULL,
+    join_mode     TINYINT         NOT NULL DEFAULT 1 COMMENT '0开放 1审核 2禁止',
+    member_count  INT             NOT NULL DEFAULT 0,
+    avatar_ver    INT             NOT NULL DEFAULT 0,
+    created_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at    DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    KEY idx_owner (owner_uid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_group_member (
+    group_id    BIGINT UNSIGNED NOT NULL,
+    uid         BIGINT UNSIGNED NOT NULL,
+    role        TINYINT         NOT NULL DEFAULT 0 COMMENT '0成员 1管理员 2群主',
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (group_id, uid),
+    KEY idx_uid (uid)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_group_request (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    group_id    BIGINT UNSIGNED NOT NULL,
+    from_uid    BIGINT UNSIGNED NOT NULL,
+    status      TINYINT         NOT NULL DEFAULT 0 COMMENT '0待处理 1已同意 2已拒绝',
+    message     VARCHAR(64)     NOT NULL DEFAULT '',
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    updated_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    UNIQUE KEY uk_group_from (group_id, from_uid),
+    KEY idx_group_status (group_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_group_message (
+    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+    group_id    BIGINT UNSIGNED NOT NULL,
+    from_uid    BIGINT UNSIGNED NOT NULL DEFAULT 0,
+    msg_type    TINYINT         NOT NULL DEFAULT 1,
+    content     TEXT            NOT NULL,
+    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    PRIMARY KEY (id),
+    KEY idx_group_id (group_id, id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_group_msg_deleted (
+    uid     BIGINT UNSIGNED NOT NULL,
+    msg_id  BIGINT UNSIGNED NOT NULL,
+    PRIMARY KEY (uid, msg_id),
+    KEY idx_msg (msg_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE IF NOT EXISTS im_group_inbox (
+    uid           BIGINT UNSIGNED NOT NULL,
+    group_id      BIGINT UNSIGNED NOT NULL,
+    last_msg_id   BIGINT UNSIGNED NOT NULL DEFAULT 0,
+    last_content  VARCHAR(256)    NOT NULL DEFAULT '',
+    last_time     DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    unread        INT             NOT NULL DEFAULT 0,
+    PRIMARY KEY (uid, group_id),
+    KEY idx_uid_time (uid, last_time)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+ALTER TABLE im_group ADD COLUMN avatar_ver INT NOT NULL DEFAULT 0;

+ 26 - 1
server/src/app/application.cpp

@@ -3,9 +3,11 @@
 #include <atomic>
 #include <chrono>
 #include <csignal>
+#include <cstring>
 #include <memory>
 #include <string>
 #include <thread>
+#include <unistd.h>
 
 #include "common/logger.h"
 #include "third/cos.h"
@@ -13,6 +15,7 @@
 #include "im/handlers/account_handler.h"
 #include "im/handlers/favorite_handler.h"
 #include "im/handlers/friend_handler.h"
+#include "im/handlers/group_handler.h"
 #include "im/handlers/message_handler.h"
 #include "im/handlers/sticker_handler.h"
 #include "im/handlers/system_handler.h"
@@ -132,13 +135,34 @@ bool Application::init(int argc, char** argv)
     dispatcher_.add(Type::Message, [] { return std::make_unique<MessageHandler>(); });
     dispatcher_.add(Type::Sticker, [] { return std::make_unique<StickerHandler>(); });
     dispatcher_.add(Type::Favorite, [] { return std::make_unique<FavoriteHandler>(); });
+    dispatcher_.add(Type::Group,    [] { return std::make_unique<GroupHandler>(); });
 
     if (mysql_.started()) {
         StickerStore stickers(mysql_);
         stickers.seed();
     }
 
-    server_ = std::make_unique<ImServer>(config_, sessions_, dispatcher_, mysql_);
+    std::string node_id = config_.server().node_id;
+    if (node_id.empty()) {
+        char host[256] = {0};
+        if (gethostname(host, sizeof(host) - 1) != 0 || host[0] == '\0') {
+            std::strcpy(host, "im");
+        }
+        node_id = std::string(host) + ":" + std::to_string(config_.server().port);
+    }
+    if (config_.redis().enable) {
+        if (!redis_.start(config_.redis(), node_id)) {
+            if (config_.redis().required) {
+                Logger::error("redis is required, abort");
+                return false;
+            }
+            Logger::warn("redis unavailable, continue in single-node mode");
+        }
+    } else {
+        Logger::info("redis disabled, single-node node_id=" + node_id);
+    }
+
+    server_ = std::make_unique<ImServer>(config_, sessions_, dispatcher_, mysql_, &redis_);
     if (!server_->start()) {
         return false;
     }
@@ -164,6 +188,7 @@ void Application::shutdown()
         server_->stop();
         server_.reset();
     }
+    redis_.stop();
     mysql_.stop();
 }
 

+ 2 - 0
server/src/app/application.h

@@ -4,6 +4,7 @@
 
 #include "config/app_config.h"
 #include "db/mysql_service.h"
+#include "db/redis_service.h"
 #include "im/dispatcher.h"
 #include "net/im_server.h"
 #include "net/session_mgr.h"
@@ -21,6 +22,7 @@ private:
 
     AppConfig config_;
     MysqlService mysql_;
+    RedisService redis_;
     SessionMgr sessions_;
     Dispatcher dispatcher_;
     std::unique_ptr<ImServer> server_;

+ 19 - 0
server/src/config/app_config.cpp

@@ -96,6 +96,15 @@ bool AppConfig::load(const std::string& filepath)
     server_.port = static_cast<uint16_t>(to_u32(ini.read("server", "port", "9000"), 9000));
     server_.max_connections =
         to_u32(ini.read("server", "max_connections", "10000"), 10000);
+    {
+        const std::string wt = ini.read("server", "worker_threads", "0");
+        server_.worker_threads = wt.empty() ? 0 : static_cast<int32_t>(std::strtol(wt.c_str(), nullptr, 10));
+        const std::string tp = ini.read("server", "thread_pool", "0");
+        server_.thread_pool = tp.empty() ? 0 : static_cast<int32_t>(std::strtol(tp.c_str(), nullptr, 10));
+    }
+    server_.thread_pool_queue =
+        to_u32(ini.read("server", "thread_pool_queue", "0"), 0);
+    server_.node_id = ini.read("server", "node_id", "");
 
     protocol_.magic = parse_magic(ini.read("protocol", "magic", "0x494D"), 0x494D);
     protocol_.max_packet =
@@ -119,6 +128,13 @@ bool AppConfig::load(const std::string& filepath)
     mysql_.charset = ini.read("mysql", "charset", mysql_.charset);
     mysql_.pool_size = to_u32(ini.read("mysql", "pool_size", "8"), 8);
 
+    redis_.enable = ini.read("redis", "enable", "0") == "1";
+    redis_.required = ini.read("redis", "required", "0") == "1";
+    redis_.host = ini.read("redis", "host", redis_.host);
+    redis_.port = static_cast<uint16_t>(to_u32(ini.read("redis", "port", "6379"), 6379));
+    redis_.password = ini.read("redis", "password", redis_.password);
+    redis_.db = static_cast<int>(to_u32(ini.read("redis", "db", "0"), 0));
+
     sms_.echo_code = ini.read("sms", "echo_code", "1") != "0";
     sms_.debug = ini.read("sms", "debug", "0") == "1";
     sms_.ttl_sec = to_u32(ini.read("sms", "ttl_sec", "300"), 300);
@@ -143,6 +159,9 @@ bool AppConfig::load(const std::string& filepath)
     limits_.history_max = to_u32(ini.read("limits", "history_max", "100"), 100);
     limits_.avatar_max = to_u32(ini.read("limits", "avatar_max", "81920"), 81920);
     limits_.file_max = to_u32(ini.read("limits", "file_max", "524288"), 524288);
+    limits_.group_max_members = to_u32(ini.read("limits", "group_max_members", "200"), 200);
+    limits_.group_name_max = to_u32(ini.read("limits", "group_name_max", "32"), 32);
+    limits_.group_max_admins = to_u32(ini.read("limits", "group_max_admins", "10"), 10);
 
     cos_.enable = ini.read("cos", "enable", "0") == "1";
     cos_.secret_id = ini.read("cos", "secret_id", cos_.secret_id);

+ 22 - 0
server/src/config/app_config.h

@@ -11,6 +11,23 @@ struct ServerConfig {
     std::string host = "0.0.0.0";
     uint16_t port = 9000;
     uint32_t max_connections = 10000;
+    // HP I/O 工作线程。0=默认(CPU*2+2);负数=CPU*(-n)
+    int32_t worker_threads = 0;
+    // HP 业务线程池。0=默认(CPU*2+2);负数=CPU*(-n)
+    int32_t thread_pool = 0;
+    // 业务线程池队列上限。0=不限制;满则提交失败并断开该连接
+    uint32_t thread_pool_queue = 0;
+    // 多节点唯一 ID。空则用 hostname:port
+    std::string node_id;
+};
+
+struct RedisConfig {
+    bool enable = false;
+    bool required = false;
+    std::string host = "127.0.0.1";
+    uint16_t port = 6379;
+    std::string password;
+    int db = 0;
 };
 
 struct ProtocolConfig {
@@ -67,6 +84,9 @@ struct LimitsConfig {
     uint32_t history_max = 100;
     uint32_t avatar_max = 81920;
     uint32_t file_max = 524288;
+    uint32_t group_max_members = 200;
+    uint32_t group_name_max = 32;
+    uint32_t group_max_admins = 10;
 };
 
 struct CosConfig {
@@ -98,6 +118,7 @@ public:
     const ProtocolConfig& protocol() const { return protocol_; }
     const ClientConfig& client() const { return client_; }
     const MysqlConfig& mysql() const { return mysql_; }
+    const RedisConfig& redis() const { return redis_; }
     const SmsConfig& sms() const { return sms_; }
     const LimitsConfig& limits() const { return limits_; }
     const LogConfig& log() const { return log_; }
@@ -112,6 +133,7 @@ private:
     ProtocolConfig protocol_;
     ClientConfig client_;
     MysqlConfig mysql_;
+    RedisConfig redis_;
     SmsConfig sms_;
     LimitsConfig limits_;
     LogConfig log_;

+ 939 - 0
server/src/db/group_store.cpp

@@ -0,0 +1,939 @@
+#include "db/group_store.h"
+
+#include <algorithm>
+#include <exception>
+
+#include "common/crypto.h"
+#include "common/logger.h"
+#include "util/json.h"
+
+namespace im {
+namespace {
+
+void refresh_member_count(ylib::mysql::conn* conn, UserId group_id)
+{
+    auto* stmt = conn->setsql(
+        "UPDATE im_group SET member_count="
+        "(SELECT COUNT(*) FROM im_group_member WHERE group_id=?) WHERE id=?");
+    stmt->set_int64(1, group_id);
+    stmt->set_int64(2, group_id);
+    stmt->update();
+}
+
+std::string ids_csv(const std::vector<UserId>& ids)
+{
+    std::string in;
+    for (UserId id : ids) {
+        if (id <= 0) {
+            continue;
+        }
+        if (!in.empty()) {
+            in += ',';
+        }
+        in += std::to_string(static_cast<unsigned long long>(id));
+    }
+    return in;
+}
+
+}  // namespace
+
+GroupStore::GroupStore(MysqlService& mysql) : mysql_(mysql) {}
+
+UserId GroupStore::create_group(UserId owner, const std::string& name,
+                                const std::vector<UserId>& members)
+{
+    try {
+        auto conn = mysql_.acquire();
+        conn->begin();
+        try {
+            auto* ins = conn->setsql(
+                "INSERT INTO im_group (name, owner_uid, join_mode, member_count) VALUES (?, ?, ?, 0)");
+            ins->set_string(1, name);
+            ins->set_int64(2, owner);
+            ins->set_int32(3, JoinMode::Approve);
+            ins->update();
+            const UserId gid = static_cast<UserId>(conn->insert_id());
+
+            auto add = [&](UserId uid, int role) {
+                auto* m = conn->setsql(
+                    "INSERT IGNORE INTO im_group_member (group_id, uid, role) VALUES (?, ?, ?)");
+                m->set_int64(1, gid);
+                m->set_int64(2, uid);
+                m->set_int32(3, role);
+                m->update();
+                auto* box = conn->setsql(
+                    "INSERT IGNORE INTO im_group_inbox (uid, group_id, last_msg_id, last_content, unread) "
+                    "VALUES (?, ?, 0, '', 0)");
+                box->set_int64(1, uid);
+                box->set_int64(2, gid);
+                box->update();
+            };
+            add(owner, GroupRole::Owner);
+            for (UserId uid : members) {
+                if (uid > 0 && uid != owner) {
+                    add(uid, GroupRole::Member);
+                }
+            }
+            refresh_member_count(conn.get(), gid);
+            conn->commit();
+            return gid;
+        } catch (...) {
+            conn->rollback();
+            throw;
+        }
+    } catch (const std::exception& e) {
+        Logger::error(std::string("create_group: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::find(UserId group_id, GroupRow& out)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT id, name, owner_uid, join_mode, member_count, IFNULL(avatar_ver,0) AS avatar_ver, "
+            "UNIX_TIMESTAMP(created_at) AS ts FROM im_group WHERE id=? LIMIT 1");
+        stmt->set_int64(1, group_id);
+        auto* rs = stmt->query();
+        if (rs == nullptr || !rs->next()) {
+            return false;
+        }
+        out.id = static_cast<UserId>(rs->get_int64("id"));
+        out.name = rs->get_string("name");
+        out.owner_uid = static_cast<UserId>(rs->get_int64("owner_uid"));
+        out.join_mode = rs->get_int32("join_mode");
+        out.member_count = rs->get_int32("member_count");
+        out.avatar_ver = rs->get_int32("avatar_ver");
+        out.created_ts = rs->get_int64("ts") * 1000;
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("group find: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::update_info(UserId group_id, const std::string* name, const int* join_mode)
+{
+    if (name == nullptr && join_mode == nullptr) {
+        return true;
+    }
+    try {
+        auto conn = mysql_.acquire();
+        if (name != nullptr && join_mode != nullptr) {
+            auto* stmt = conn->setsql("UPDATE im_group SET name=?, join_mode=? WHERE id=?");
+            stmt->set_string(1, *name);
+            stmt->set_int32(2, *join_mode);
+            stmt->set_int64(3, group_id);
+            stmt->update();
+        } else if (name != nullptr) {
+            auto* stmt = conn->setsql("UPDATE im_group SET name=? WHERE id=?");
+            stmt->set_string(1, *name);
+            stmt->set_int64(2, group_id);
+            stmt->update();
+        } else {
+            auto* stmt = conn->setsql("UPDATE im_group SET join_mode=? WHERE id=?");
+            stmt->set_int32(1, *join_mode);
+            stmt->set_int64(2, group_id);
+            stmt->update();
+        }
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("group update_info: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::bump_avatar(UserId group_id)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "UPDATE im_group SET avatar_ver=IFNULL(avatar_ver,0)+1 WHERE id=?");
+        stmt->set_int64(1, group_id);
+        stmt->update();
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("group bump_avatar: ") + e.what());
+        throw;
+    }
+}
+
+int GroupStore::member_role(UserId group_id, UserId uid)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT role FROM im_group_member WHERE group_id=? AND uid=? LIMIT 1");
+        stmt->set_int64(1, group_id);
+        stmt->set_int64(2, uid);
+        auto* rs = stmt->query();
+        if (rs == nullptr || !rs->next()) {
+            return -1;
+        }
+        return rs->get_int32("role");
+    } catch (const std::exception& e) {
+        Logger::error(std::string("member_role: ") + e.what());
+        throw;
+    }
+}
+
+int GroupStore::admin_count(UserId group_id)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT COUNT(*) AS n FROM im_group_member WHERE group_id=? AND role=?");
+        stmt->set_int64(1, group_id);
+        stmt->set_int32(2, GroupRole::Admin);
+        auto* rs = stmt->query();
+        if (rs == nullptr || !rs->next()) {
+            return 0;
+        }
+        return rs->get_int32("n");
+    } catch (const std::exception& e) {
+        Logger::error(std::string("admin_count: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::add_member(UserId group_id, UserId uid, int role)
+{
+    try {
+        auto conn = mysql_.acquire();
+        conn->begin();
+        try {
+            auto* m = conn->setsql(
+                "INSERT IGNORE INTO im_group_member (group_id, uid, role) VALUES (?, ?, ?)");
+            m->set_int64(1, group_id);
+            m->set_int64(2, uid);
+            m->set_int32(3, role);
+            m->update();
+            auto* box = conn->setsql(
+                "INSERT IGNORE INTO im_group_inbox (uid, group_id, last_msg_id, last_content, unread) "
+                "VALUES (?, ?, 0, '', 0)");
+            box->set_int64(1, uid);
+            box->set_int64(2, group_id);
+            box->update();
+            refresh_member_count(conn.get(), group_id);
+            conn->commit();
+            return true;
+        } catch (...) {
+            conn->rollback();
+            throw;
+        }
+    } catch (const std::exception& e) {
+        Logger::error(std::string("add_member: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::remove_member(UserId group_id, UserId uid)
+{
+    try {
+        auto conn = mysql_.acquire();
+        conn->begin();
+        try {
+            auto* m = conn->setsql("DELETE FROM im_group_member WHERE group_id=? AND uid=?");
+            m->set_int64(1, group_id);
+            m->set_int64(2, uid);
+            m->update();
+            auto* box = conn->setsql("DELETE FROM im_group_inbox WHERE uid=? AND group_id=?");
+            box->set_int64(1, uid);
+            box->set_int64(2, group_id);
+            box->update();
+            refresh_member_count(conn.get(), group_id);
+            conn->commit();
+            return true;
+        } catch (...) {
+            conn->rollback();
+            throw;
+        }
+    } catch (const std::exception& e) {
+        Logger::error(std::string("remove_member: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::set_role(UserId group_id, UserId uid, int role)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "UPDATE im_group_member SET role=? WHERE group_id=? AND uid=?");
+        stmt->set_int32(1, role);
+        stmt->set_int64(2, group_id);
+        stmt->set_int64(3, uid);
+        stmt->update();
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("set_role: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::transfer_owner(UserId group_id, UserId from_uid, UserId to_uid)
+{
+    try {
+        auto conn = mysql_.acquire();
+        conn->begin();
+        try {
+            auto* a = conn->setsql(
+                "UPDATE im_group_member SET role=? WHERE group_id=? AND uid=?");
+            a->set_int32(1, GroupRole::Member);
+            a->set_int64(2, group_id);
+            a->set_int64(3, from_uid);
+            a->update();
+            auto* b = conn->setsql(
+                "UPDATE im_group_member SET role=? WHERE group_id=? AND uid=?");
+            b->set_int32(1, GroupRole::Owner);
+            b->set_int64(2, group_id);
+            b->set_int64(3, to_uid);
+            b->update();
+            auto* g = conn->setsql("UPDATE im_group SET owner_uid=? WHERE id=?");
+            g->set_int64(1, to_uid);
+            g->set_int64(2, group_id);
+            g->update();
+            conn->commit();
+            return true;
+        } catch (...) {
+            conn->rollback();
+            throw;
+        }
+    } catch (const std::exception& e) {
+        Logger::error(std::string("transfer_owner: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::dismiss(UserId group_id)
+{
+    try {
+        auto conn = mysql_.acquire();
+        conn->begin();
+        try {
+            auto* del = conn->setsql(
+                "DELETE d FROM im_group_msg_deleted d "
+                "INNER JOIN im_group_message m ON m.id=d.msg_id WHERE m.group_id=?");
+            del->set_int64(1, group_id);
+            del->update();
+            auto* msg = conn->setsql("DELETE FROM im_group_message WHERE group_id=?");
+            msg->set_int64(1, group_id);
+            msg->update();
+            auto* box = conn->setsql("DELETE FROM im_group_inbox WHERE group_id=?");
+            box->set_int64(1, group_id);
+            box->update();
+            auto* req = conn->setsql("DELETE FROM im_group_request WHERE group_id=?");
+            req->set_int64(1, group_id);
+            req->update();
+            auto* mem = conn->setsql("DELETE FROM im_group_member WHERE group_id=?");
+            mem->set_int64(1, group_id);
+            mem->update();
+            auto* g = conn->setsql("DELETE FROM im_group WHERE id=?");
+            g->set_int64(1, group_id);
+            g->update();
+            conn->commit();
+            return true;
+        } catch (...) {
+            conn->rollback();
+            throw;
+        }
+    } catch (const std::exception& e) {
+        Logger::error(std::string("dismiss: ") + e.what());
+        throw;
+    }
+}
+
+std::vector<GroupMemberRow> GroupStore::list_members(UserId group_id)
+{
+    std::vector<GroupMemberRow> out;
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT m.uid, m.role, UNIX_TIMESTAMP(m.created_at) AS ts, "
+            "u.username, u.nickname, IFNULL(u.avatar_ver,0) AS avatar_ver "
+            "FROM im_group_member m JOIN im_user u ON u.id=m.uid "
+            "WHERE m.group_id=? ORDER BY m.role DESC, m.created_at, m.uid");
+        stmt->set_int64(1, group_id);
+        auto* rs = stmt->query();
+        if (rs == nullptr) {
+            return out;
+        }
+        while (rs->next()) {
+            GroupMemberRow row;
+            row.uid = static_cast<UserId>(rs->get_int64("uid"));
+            row.role = rs->get_int32("role");
+            row.ts = rs->get_int64("ts") * 1000;
+            row.username = rs->get_string("username");
+            row.nickname = rs->get_string("nickname");
+            row.avatar_ver = rs->get_int32("avatar_ver");
+            out.push_back(std::move(row));
+        }
+        return out;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("list_members: ") + e.what());
+        throw;
+    }
+}
+
+std::vector<UserId> GroupStore::list_member_uids(UserId group_id)
+{
+    std::vector<UserId> out;
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql("SELECT uid FROM im_group_member WHERE group_id=?");
+        stmt->set_int64(1, group_id);
+        auto* rs = stmt->query();
+        if (rs == nullptr) {
+            return out;
+        }
+        while (rs->next()) {
+            out.push_back(static_cast<UserId>(rs->get_int64("uid")));
+        }
+        return out;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("list_member_uids: ") + e.what());
+        throw;
+    }
+}
+
+std::vector<UserId> GroupStore::list_admin_uids(UserId group_id)
+{
+    std::vector<UserId> out;
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT uid FROM im_group_member WHERE group_id=? AND role>=?");
+        stmt->set_int64(1, group_id);
+        stmt->set_int32(2, GroupRole::Admin);
+        auto* rs = stmt->query();
+        if (rs == nullptr) {
+            return out;
+        }
+        while (rs->next()) {
+            out.push_back(static_cast<UserId>(rs->get_int64("uid")));
+        }
+        return out;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("list_admin_uids: ") + e.what());
+        throw;
+    }
+}
+
+std::vector<GroupInboxRow> GroupStore::list_groups(UserId uid)
+{
+    std::vector<GroupInboxRow> out;
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT g.id, g.name, g.member_count, g.join_mode, m.role, "
+            "IFNULL(g.avatar_ver,0) AS avatar_ver "
+            "FROM im_group_member m JOIN im_group g ON g.id=m.group_id "
+            "WHERE m.uid=? ORDER BY g.id DESC");
+        stmt->set_int64(1, uid);
+        auto* rs = stmt->query();
+        if (rs == nullptr) {
+            return out;
+        }
+        while (rs->next()) {
+            GroupInboxRow row;
+            row.group_id = static_cast<UserId>(rs->get_int64("id"));
+            row.name = rs->get_string("name");
+            row.member_count = rs->get_int32("member_count");
+            row.join_mode = rs->get_int32("join_mode");
+            row.role = rs->get_int32("role");
+            row.avatar_ver = rs->get_int32("avatar_ver");
+            out.push_back(std::move(row));
+        }
+        return out;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("list_groups: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::create_request(UserId group_id, UserId from, const std::string& message,
+                                UserId& req_id, bool& exists)
+{
+    exists = false;
+    req_id = 0;
+    try {
+        auto conn = mysql_.acquire();
+        auto* find = conn->setsql(
+            "SELECT id, status FROM im_group_request WHERE group_id=? AND from_uid=? LIMIT 1");
+        find->set_int64(1, group_id);
+        find->set_int64(2, from);
+        auto* rs = find->query();
+        if (rs != nullptr && rs->next()) {
+            req_id = static_cast<UserId>(rs->get_int64("id"));
+            const int status = rs->get_int32("status");
+            if (status == 0) {
+                exists = true;
+                return true;
+            }
+            auto* upd = conn->setsql(
+                "UPDATE im_group_request SET status=0, message=?, updated_at=NOW() WHERE id=?");
+            upd->set_string(1, message);
+            upd->set_int64(2, req_id);
+            upd->update();
+            return true;
+        }
+        auto* ins = conn->setsql(
+            "INSERT INTO im_group_request (group_id, from_uid, status, message) VALUES (?, ?, 0, ?)");
+        ins->set_int64(1, group_id);
+        ins->set_int64(2, from);
+        ins->set_string(3, message);
+        ins->update();
+        req_id = static_cast<UserId>(conn->insert_id());
+        return req_id > 0;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("create_group_request: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::get_request(UserId req_id, GroupRequestRow& out)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT r.id, r.group_id, r.from_uid, r.status, r.message, "
+            "UNIX_TIMESTAMP(r.created_at) AS ts, "
+            "u.username, u.nickname, IFNULL(u.avatar_ver,0) AS avatar_ver, g.name AS group_name "
+            "FROM im_group_request r "
+            "JOIN im_user u ON u.id=r.from_uid "
+            "JOIN im_group g ON g.id=r.group_id "
+            "WHERE r.id=? LIMIT 1");
+        stmt->set_int64(1, req_id);
+        auto* rs = stmt->query();
+        if (rs == nullptr || !rs->next()) {
+            return false;
+        }
+        out.id = static_cast<UserId>(rs->get_int64("id"));
+        out.group_id = static_cast<UserId>(rs->get_int64("group_id"));
+        out.from_uid = static_cast<UserId>(rs->get_int64("from_uid"));
+        out.status = rs->get_int32("status");
+        out.message = rs->get_string("message");
+        out.ts = rs->get_int64("ts") * 1000;
+        out.username = rs->get_string("username");
+        out.nickname = rs->get_string("nickname");
+        out.avatar_ver = rs->get_int32("avatar_ver");
+        out.group_name = rs->get_string("group_name");
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("get_group_request: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::set_request_status(UserId req_id, int status)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "UPDATE im_group_request SET status=? WHERE id=?");
+        stmt->set_int32(1, status);
+        stmt->set_int64(2, req_id);
+        stmt->update();
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("set_group_request_status: ") + e.what());
+        throw;
+    }
+}
+
+int GroupStore::pending_count(UserId group_id)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT COUNT(*) AS n FROM im_group_request WHERE group_id=? AND status=0");
+        stmt->set_int64(1, group_id);
+        auto* rs = stmt->query();
+        if (rs == nullptr || !rs->next()) {
+            return 0;
+        }
+        return rs->get_int32("n");
+    } catch (const std::exception& e) {
+        Logger::error(std::string("group pending_count: ") + e.what());
+        throw;
+    }
+}
+
+std::vector<GroupRequestRow> GroupStore::list_pending(UserId uid, UserId group_id)
+{
+    std::vector<GroupRequestRow> out;
+    try {
+        auto conn = mysql_.acquire();
+        if (group_id > 0) {
+            auto* stmt = conn->setsql(
+                "SELECT r.id, r.group_id, r.from_uid, r.status, r.message, "
+                "UNIX_TIMESTAMP(r.created_at) AS ts, "
+                "u.username, u.nickname, IFNULL(u.avatar_ver,0) AS avatar_ver, g.name AS group_name "
+                "FROM im_group_request r "
+                "JOIN im_user u ON u.id=r.from_uid "
+                "JOIN im_group g ON g.id=r.group_id "
+                "WHERE r.group_id=? AND r.status=0 ORDER BY r.id DESC");
+            stmt->set_int64(1, group_id);
+            auto* rs = stmt->query();
+            if (rs == nullptr) {
+                return out;
+            }
+            while (rs->next()) {
+                GroupRequestRow row;
+                row.id = static_cast<UserId>(rs->get_int64("id"));
+                row.group_id = static_cast<UserId>(rs->get_int64("group_id"));
+                row.from_uid = static_cast<UserId>(rs->get_int64("from_uid"));
+                row.status = rs->get_int32("status");
+                row.message = rs->get_string("message");
+                row.ts = rs->get_int64("ts") * 1000;
+                row.username = rs->get_string("username");
+                row.nickname = rs->get_string("nickname");
+                row.avatar_ver = rs->get_int32("avatar_ver");
+                row.group_name = rs->get_string("group_name");
+                out.push_back(std::move(row));
+            }
+            return out;
+        }
+        auto* stmt = conn->setsql(
+            "SELECT r.id, r.group_id, r.from_uid, r.status, r.message, "
+            "UNIX_TIMESTAMP(r.created_at) AS ts, "
+            "u.username, u.nickname, IFNULL(u.avatar_ver,0) AS avatar_ver, g.name AS group_name "
+            "FROM im_group_request r "
+            "JOIN im_user u ON u.id=r.from_uid "
+            "JOIN im_group g ON g.id=r.group_id "
+            "JOIN im_group_member m ON m.group_id=r.group_id AND m.uid=? AND m.role>=? "
+            "WHERE r.status=0 ORDER BY r.id DESC");
+        stmt->set_int64(1, uid);
+        stmt->set_int32(2, GroupRole::Admin);
+        auto* rs = stmt->query();
+        if (rs == nullptr) {
+            return out;
+        }
+        while (rs->next()) {
+            GroupRequestRow row;
+            row.id = static_cast<UserId>(rs->get_int64("id"));
+            row.group_id = static_cast<UserId>(rs->get_int64("group_id"));
+            row.from_uid = static_cast<UserId>(rs->get_int64("from_uid"));
+            row.status = rs->get_int32("status");
+            row.message = rs->get_string("message");
+            row.ts = rs->get_int64("ts") * 1000;
+            row.username = rs->get_string("username");
+            row.nickname = rs->get_string("nickname");
+            row.avatar_ver = rs->get_int32("avatar_ver");
+            row.group_name = rs->get_string("group_name");
+            out.push_back(std::move(row));
+        }
+        return out;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("list_group_pending: ") + e.what());
+        throw;
+    }
+}
+
+UserId GroupStore::insert_message(UserId group_id, UserId from, int msg_type,
+                                  const std::string& content)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "INSERT INTO im_group_message (group_id, from_uid, msg_type, content) VALUES (?, ?, ?, ?)");
+        stmt->set_int64(1, group_id);
+        stmt->set_int64(2, from);
+        stmt->set_int32(3, msg_type);
+        stmt->set_string(4, content);
+        stmt->update();
+        return static_cast<UserId>(conn->insert_id());
+    } catch (const std::exception& e) {
+        Logger::error(std::string("insert_group_message: ") + e.what());
+        throw;
+    }
+}
+
+void GroupStore::upsert_inbox(UserId uid, UserId group_id, UserId msg_id,
+                              const std::string& preview, bool inc_unread)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "INSERT INTO im_group_inbox (uid, group_id, last_msg_id, last_content, last_time, unread) "
+            "VALUES (?, ?, ?, ?, NOW(), ?) "
+            "ON DUPLICATE KEY UPDATE last_msg_id=VALUES(last_msg_id), "
+            "last_content=VALUES(last_content), last_time=VALUES(last_time), "
+            "unread=unread+VALUES(unread)");
+        stmt->set_int64(1, uid);
+        stmt->set_int64(2, group_id);
+        stmt->set_int64(3, msg_id);
+        stmt->set_string(4, preview);
+        stmt->set_int32(5, inc_unread ? 1 : 0);
+        stmt->update();
+    } catch (const std::exception& e) {
+        Logger::error(std::string("upsert_group_inbox: ") + e.what());
+        throw;
+    }
+}
+
+void GroupStore::upsert_inbox_all(UserId group_id, UserId msg_id, const std::string& preview,
+                                  UserId except_uid)
+{
+    const auto uids = list_member_uids(group_id);
+    for (UserId uid : uids) {
+        upsert_inbox(uid, group_id, msg_id, preview, uid != except_uid);
+    }
+}
+
+void GroupStore::clear_unread(UserId uid, UserId group_id)
+{
+    set_unread(uid, group_id, 0);
+}
+
+void GroupStore::set_unread(UserId uid, UserId group_id, int unread)
+{
+    if (unread < 0) {
+        unread = 0;
+    }
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "UPDATE im_group_inbox SET unread=? WHERE uid=? AND group_id=?");
+        stmt->set_int32(1, unread);
+        stmt->set_int64(2, uid);
+        stmt->set_int64(3, group_id);
+        stmt->update();
+    } catch (const std::exception& e) {
+        Logger::error(std::string("set_group_unread: ") + e.what());
+        throw;
+    }
+}
+
+void GroupStore::delete_inbox(UserId uid, UserId group_id)
+{
+    try {
+        auto conn = mysql_.acquire();
+        conn->begin();
+        try {
+            auto* hide = conn->setsql(
+                "INSERT IGNORE INTO im_group_msg_deleted (uid, msg_id) "
+                "SELECT ?, id FROM im_group_message WHERE group_id=?");
+            hide->set_int64(1, uid);
+            hide->set_int64(2, group_id);
+            hide->update();
+            auto* box = conn->setsql("DELETE FROM im_group_inbox WHERE uid=? AND group_id=?");
+            box->set_int64(1, uid);
+            box->set_int64(2, group_id);
+            box->update();
+            conn->commit();
+        } catch (...) {
+            conn->rollback();
+            throw;
+        }
+    } catch (const std::exception& e) {
+        Logger::error(std::string("delete_group_inbox: ") + e.what());
+        throw;
+    }
+}
+
+int GroupStore::delete_messages(UserId uid, UserId group_id, const std::vector<UserId>& ids)
+{
+    const std::string in = ids_csv(ids);
+    if (in.empty()) {
+        return 0;
+    }
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "INSERT IGNORE INTO im_group_msg_deleted (uid, msg_id) "
+            "SELECT ?, id FROM im_group_message WHERE group_id=? AND id IN (" + in + ")");
+        stmt->set_int64(1, uid);
+        stmt->set_int64(2, group_id);
+        stmt->update();
+        int n = 0;
+        for (UserId id : ids) {
+            if (id > 0) {
+                ++n;
+            }
+        }
+        return n;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("delete_group_messages: ") + e.what());
+        throw;
+    }
+}
+
+bool GroupStore::latest_visible(UserId uid, UserId group_id, GroupMsgRow& out)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT m.id, m.group_id, m.from_uid, m.msg_type, m.content, "
+            "UNIX_TIMESTAMP(m.created_at) AS ts, "
+            "IFNULL(u.username,'') AS username, IFNULL(u.nickname,'') AS nickname "
+            "FROM im_group_message m "
+            "LEFT JOIN im_group_msg_deleted d ON d.msg_id=m.id AND d.uid=? "
+            "LEFT JOIN im_user u ON u.id=m.from_uid "
+            "WHERE m.group_id=? AND d.msg_id IS NULL "
+            "ORDER BY m.id DESC LIMIT 1");
+        stmt->set_int64(1, uid);
+        stmt->set_int64(2, group_id);
+        auto* rs = stmt->query();
+        if (rs == nullptr || !rs->next()) {
+            return false;
+        }
+        out.id = static_cast<UserId>(rs->get_int64("id"));
+        out.group_id = static_cast<UserId>(rs->get_int64("group_id"));
+        out.from_uid = static_cast<UserId>(rs->get_int64("from_uid"));
+        out.from_username = rs->get_string("username");
+        out.from_nickname = rs->get_string("nickname");
+        out.msg_type = rs->get_int32("msg_type");
+        out.content = rs->get_string("content");
+        out.ts = rs->get_int64("ts") * 1000;
+        return true;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("group latest_visible: ") + e.what());
+        throw;
+    }
+}
+
+void GroupStore::set_inbox_last(UserId uid, UserId group_id, UserId msg_id,
+                                const std::string& preview)
+{
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "UPDATE im_group_inbox SET last_msg_id=?, last_content=? WHERE uid=? AND group_id=?");
+        stmt->set_int64(1, msg_id);
+        stmt->set_string(2, preview);
+        stmt->set_int64(3, uid);
+        stmt->set_int64(4, group_id);
+        stmt->update();
+    } catch (const std::exception& e) {
+        Logger::error(std::string("set_group_inbox_last: ") + e.what());
+        throw;
+    }
+}
+
+std::vector<GroupInboxRow> GroupStore::list_inbox(UserId uid)
+{
+    std::vector<GroupInboxRow> out;
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT i.group_id, i.last_msg_id, i.last_content, i.unread, "
+            "UNIX_TIMESTAMP(i.last_time) AS last_ts, "
+            "g.name, g.member_count, g.join_mode, m.role, "
+            "IFNULL(g.avatar_ver,0) AS avatar_ver "
+            "FROM im_group_inbox i "
+            "JOIN im_group g ON g.id=i.group_id "
+            "JOIN im_group_member m ON m.group_id=i.group_id AND m.uid=i.uid "
+            "WHERE i.uid=? ORDER BY i.last_time DESC, i.last_msg_id DESC");
+        stmt->set_int64(1, uid);
+        auto* rs = stmt->query();
+        if (rs == nullptr) {
+            return out;
+        }
+        while (rs->next()) {
+            GroupInboxRow row;
+            row.group_id = static_cast<UserId>(rs->get_int64("group_id"));
+            row.last_msg_id = static_cast<UserId>(rs->get_int64("last_msg_id"));
+            row.last_content = rs->get_string("last_content");
+            row.unread = rs->get_int32("unread");
+            row.last_ts = rs->get_int64("last_ts") * 1000;
+            row.name = rs->get_string("name");
+            row.member_count = rs->get_int32("member_count");
+            row.join_mode = rs->get_int32("join_mode");
+            row.role = rs->get_int32("role");
+            row.avatar_ver = rs->get_int32("avatar_ver");
+            out.push_back(std::move(row));
+        }
+        return out;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("list_group_inbox: ") + e.what());
+        throw;
+    }
+}
+
+std::vector<GroupMsgRow> GroupStore::list_history(UserId uid, UserId group_id, UserId last_id,
+                                                  int limit)
+{
+    std::vector<GroupMsgRow> out;
+    try {
+        auto conn = mysql_.acquire();
+        auto* stmt = conn->setsql(
+            "SELECT m.id, m.group_id, m.from_uid, m.msg_type, m.content, "
+            "UNIX_TIMESTAMP(m.created_at) AS ts, "
+            "IFNULL(u.username,'') AS username, IFNULL(u.nickname,'') AS nickname "
+            "FROM im_group_message m "
+            "LEFT JOIN im_group_msg_deleted d ON d.msg_id=m.id AND d.uid=? "
+            "LEFT JOIN im_user u ON u.id=m.from_uid "
+            "WHERE m.group_id=? AND d.msg_id IS NULL AND (?=0 OR m.id<?) "
+            "ORDER BY m.id DESC LIMIT ?");
+        stmt->set_int64(1, uid);
+        stmt->set_int64(2, group_id);
+        stmt->set_int64(3, last_id);
+        stmt->set_int64(4, last_id);
+        stmt->set_int32(5, limit);
+        auto* rs = stmt->query();
+        if (rs == nullptr) {
+            return out;
+        }
+        while (rs->next()) {
+            GroupMsgRow row;
+            row.id = static_cast<UserId>(rs->get_int64("id"));
+            row.group_id = static_cast<UserId>(rs->get_int64("group_id"));
+            row.from_uid = static_cast<UserId>(rs->get_int64("from_uid"));
+            row.from_username = rs->get_string("username");
+            row.from_nickname = rs->get_string("nickname");
+            row.msg_type = rs->get_int32("msg_type");
+            row.content = rs->get_string("content");
+            row.ts = rs->get_int64("ts") * 1000;
+            out.push_back(std::move(row));
+        }
+        std::reverse(out.begin(), out.end());
+        return out;
+    } catch (const std::exception& e) {
+        Logger::error(std::string("list_group_history: ") + e.what());
+        throw;
+    }
+}
+
+std::string group_event_preview(const std::string& content, size_t max_bytes)
+{
+    ylib::json meta;
+    if (content.empty() || !meta.parse(content) || !meta.exist("event")) {
+        return preview_text(content, max_bytes);
+    }
+    const std::string event = meta["event"].to<std::string>();
+    auto nick = [&](const char* key) -> std::string {
+        if (!meta.exist(key)) {
+            return {};
+        }
+        return meta[key].to<std::string>();
+    };
+    std::string text;
+    if (event == "create") {
+        text = nick("nickname") + " 创建了群聊";
+    } else if (event == "join") {
+        text = nick("nickname") + " 加入了群聊";
+    } else if (event == "leave") {
+        text = nick("nickname") + " 退出了群聊";
+    } else if (event == "kick") {
+        text = nick("nickname") + " 被移出群聊";
+    } else if (event == "invite") {
+        text = nick("nickname") + " 加入了群聊";
+    } else if (event == "transfer") {
+        text = nick("nickname") + " 成为新群主";
+    } else if (event == "dismiss") {
+        text = "群聊已解散";
+    } else if (event == "info") {
+        text = "群信息已更新";
+    } else if (event == "role") {
+        const bool admin = meta.exist("admin") && meta["admin"].is_number() &&
+                           meta["admin"].to<int64>() != 0;
+        text = nick("nickname") + (admin ? " 被设为管理员" : " 被取消管理员");
+    } else {
+        text = content;
+    }
+    return preview_text(text, max_bytes);
+}
+
+}  // namespace im

+ 115 - 0
server/src/db/group_store.h

@@ -0,0 +1,115 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include "common/protocol_def.h"
+#include "common/types.h"
+#include "db/mysql_service.h"
+
+namespace im {
+
+struct GroupRow {
+    UserId id = 0;
+    std::string name;
+    UserId owner_uid = 0;
+    int join_mode = JoinMode::Approve;
+    int member_count = 0;
+    int avatar_ver = 0;
+    int64_t created_ts = 0;
+};
+
+struct GroupMemberRow {
+    UserId uid = 0;
+    std::string username;
+    std::string nickname;
+    int avatar_ver = 0;
+    int role = GroupRole::Member;
+    int64_t ts = 0;
+};
+
+struct GroupRequestRow {
+    UserId id = 0;
+    UserId group_id = 0;
+    UserId from_uid = 0;
+    int status = 0;
+    std::string message;
+    std::string username;
+    std::string nickname;
+    int avatar_ver = 0;
+    std::string group_name;
+    int64_t ts = 0;
+};
+
+struct GroupInboxRow {
+    UserId group_id = 0;
+    std::string name;
+    int member_count = 0;
+    int join_mode = JoinMode::Approve;
+    int role = GroupRole::Member;
+    int avatar_ver = 0;
+    UserId last_msg_id = 0;
+    std::string last_content;
+    int64_t last_ts = 0;
+    int unread = 0;
+};
+
+struct GroupMsgRow {
+    UserId id = 0;
+    UserId group_id = 0;
+    UserId from_uid = 0;
+    std::string from_username;
+    std::string from_nickname;
+    int msg_type = 1;
+    std::string content;
+    int64_t ts = 0;
+};
+
+class GroupStore {
+public:
+    explicit GroupStore(MysqlService& mysql);
+
+    UserId create_group(UserId owner, const std::string& name, const std::vector<UserId>& members);
+    bool find(UserId group_id, GroupRow& out);
+    bool update_info(UserId group_id, const std::string* name, const int* join_mode);
+    bool bump_avatar(UserId group_id);
+    int member_role(UserId group_id, UserId uid);  // -1 非成员
+    int admin_count(UserId group_id);
+    bool add_member(UserId group_id, UserId uid, int role);
+    bool remove_member(UserId group_id, UserId uid);
+    bool set_role(UserId group_id, UserId uid, int role);
+    bool transfer_owner(UserId group_id, UserId from_uid, UserId to_uid);
+    bool dismiss(UserId group_id);
+    std::vector<GroupMemberRow> list_members(UserId group_id);
+    std::vector<UserId> list_member_uids(UserId group_id);
+    std::vector<UserId> list_admin_uids(UserId group_id);
+    std::vector<GroupInboxRow> list_groups(UserId uid);
+
+    bool create_request(UserId group_id, UserId from, const std::string& message, UserId& req_id,
+                        bool& exists);
+    bool get_request(UserId req_id, GroupRequestRow& out);
+    bool set_request_status(UserId req_id, int status);
+    int pending_count(UserId group_id);
+    std::vector<GroupRequestRow> list_pending(UserId uid, UserId group_id);
+
+    UserId insert_message(UserId group_id, UserId from, int msg_type, const std::string& content);
+    void upsert_inbox(UserId uid, UserId group_id, UserId msg_id, const std::string& preview,
+                      bool inc_unread);
+    void upsert_inbox_all(UserId group_id, UserId msg_id, const std::string& preview,
+                          UserId except_uid);
+    void clear_unread(UserId uid, UserId group_id);
+    void set_unread(UserId uid, UserId group_id, int unread);
+    void delete_inbox(UserId uid, UserId group_id);
+    int delete_messages(UserId uid, UserId group_id, const std::vector<UserId>& ids);
+    bool latest_visible(UserId uid, UserId group_id, GroupMsgRow& out);
+    void set_inbox_last(UserId uid, UserId group_id, UserId msg_id, const std::string& preview);
+    std::vector<GroupInboxRow> list_inbox(UserId uid);
+    std::vector<GroupMsgRow> list_history(UserId uid, UserId group_id, UserId last_id, int limit);
+
+private:
+    MysqlService& mysql_;
+};
+
+std::string group_event_preview(const std::string& content, size_t max_bytes);
+
+}  // namespace im

+ 58 - 5
server/src/db/mysql_service.cpp

@@ -174,9 +174,9 @@ bool MysqlService::start(const MysqlConfig& cfg)
         return false;
     }
 
-    started_ = true;
+    started_.store(true, std::memory_order_release);
     if (!ping()) {
-        started_ = false;
+        started_.store(false, std::memory_order_release);
         pool_.close();
         Logger::error("mysql ping failed, check host/user/password/database");
         return false;
@@ -197,6 +197,7 @@ bool MysqlService::ensure_schema()
         "ALTER TABLE im_friend_request ADD COLUMN message VARCHAR(64) NOT NULL DEFAULT ''",
         "ALTER TABLE im_friend ADD COLUMN remark VARCHAR(64) NOT NULL DEFAULT ''",
         "ALTER TABLE im_user ADD COLUMN avatar_ver INT NOT NULL DEFAULT 0",
+        "ALTER TABLE im_group ADD COLUMN avatar_ver INT NOT NULL DEFAULT 0",
         "ALTER TABLE im_message ADD COLUMN sender_deleted TINYINT NOT NULL DEFAULT 0",
         "ALTER TABLE im_message ADD COLUMN receiver_deleted TINYINT NOT NULL DEFAULT 0",
         "CREATE TABLE IF NOT EXISTS im_friend_request ("
@@ -239,6 +240,58 @@ bool MysqlService::ensure_schema()
         "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
         "PRIMARY KEY (id), KEY idx_from (from_uid)"
         ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+        "CREATE TABLE IF NOT EXISTS im_group ("
+        "id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,"
+        "name VARCHAR(64) NOT NULL DEFAULT '',"
+        "owner_uid BIGINT UNSIGNED NOT NULL,"
+        "join_mode TINYINT NOT NULL DEFAULT 1,"
+        "member_count INT NOT NULL DEFAULT 0,"
+        "avatar_ver INT NOT NULL DEFAULT 0,"
+        "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
+        "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,"
+        "PRIMARY KEY (id), KEY idx_owner (owner_uid)"
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+        "CREATE TABLE IF NOT EXISTS im_group_member ("
+        "group_id BIGINT UNSIGNED NOT NULL,"
+        "uid BIGINT UNSIGNED NOT NULL,"
+        "role TINYINT NOT NULL DEFAULT 0,"
+        "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
+        "PRIMARY KEY (group_id, uid), KEY idx_uid (uid)"
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+        "CREATE TABLE IF NOT EXISTS im_group_request ("
+        "id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,"
+        "group_id BIGINT UNSIGNED NOT NULL,"
+        "from_uid BIGINT UNSIGNED NOT NULL,"
+        "status TINYINT NOT NULL DEFAULT 0,"
+        "message VARCHAR(64) NOT NULL DEFAULT '',"
+        "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
+        "updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,"
+        "PRIMARY KEY (id), UNIQUE KEY uk_group_from (group_id, from_uid),"
+        "KEY idx_group_status (group_id, status)"
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+        "CREATE TABLE IF NOT EXISTS im_group_message ("
+        "id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,"
+        "group_id BIGINT UNSIGNED NOT NULL,"
+        "from_uid BIGINT UNSIGNED NOT NULL DEFAULT 0,"
+        "msg_type TINYINT NOT NULL DEFAULT 1,"
+        "content TEXT NOT NULL,"
+        "created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
+        "PRIMARY KEY (id), KEY idx_group_id (group_id, id)"
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+        "CREATE TABLE IF NOT EXISTS im_group_msg_deleted ("
+        "uid BIGINT UNSIGNED NOT NULL,"
+        "msg_id BIGINT UNSIGNED NOT NULL,"
+        "PRIMARY KEY (uid, msg_id), KEY idx_msg (msg_id)"
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
+        "CREATE TABLE IF NOT EXISTS im_group_inbox ("
+        "uid BIGINT UNSIGNED NOT NULL,"
+        "group_id BIGINT UNSIGNED NOT NULL,"
+        "last_msg_id BIGINT UNSIGNED NOT NULL DEFAULT 0,"
+        "last_content VARCHAR(256) NOT NULL DEFAULT '',"
+        "last_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,"
+        "unread INT NOT NULL DEFAULT 0,"
+        "PRIMARY KEY (uid, group_id), KEY idx_uid_time (uid, last_time)"
+        ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4",
     };
     try {
         auto conn = acquire();
@@ -333,17 +386,17 @@ bool MysqlService::init_schema(const MysqlConfig& cfg, const std::string& sql_pa
 
 void MysqlService::stop()
 {
-    if (!started_) {
+    if (!started_.load(std::memory_order_acquire)) {
         return;
     }
     pool_.close();
-    started_ = false;
+    started_.store(false, std::memory_order_release);
     Logger::info("mysql pool stopped");
 }
 
 MysqlConn MysqlService::acquire()
 {
-    if (!started_) {
+    if (!started_.load(std::memory_order_acquire)) {
         throw ylib::exception("mysql pool is not started");
     }
     return MysqlConn(pool_.get());

+ 4 - 2
server/src/db/mysql_service.h

@@ -1,5 +1,7 @@
 #pragma once
 
+#include <atomic>
+
 #include "config/app_config.h"
 
 #include "db/mysql.h"
@@ -30,7 +32,7 @@ class MysqlService {
 public:
     bool start(const MysqlConfig& cfg);  // 启动池并 ping,失败返回 false
     void stop();
-    bool started() const { return started_; }
+    bool started() const { return started_.load(std::memory_order_acquire); }
 
     // 执行 init.sql:建库(库名取自配置)、建表、写入文件助手。已存在则跳过。
     bool init_schema(const MysqlConfig& cfg, const std::string& sql_path);
@@ -42,7 +44,7 @@ public:
 
 private:
     ylib::mysql::pool pool_;
-    bool started_ = false;
+    std::atomic<bool> started_{false};
 };
 
 }  // namespace im

+ 356 - 0
server/src/db/redis_service.cpp

@@ -0,0 +1,356 @@
+#include "db/redis_service.h"
+
+#include <chrono>
+#include <cstring>
+#include <exception>
+#include <memory>
+#include <thread>
+
+#include <hiredis/hiredis.h>
+
+#include "common/logger.h"
+
+namespace im {
+
+struct RedisSubThread {
+    std::thread t;
+};
+
+namespace {
+
+void free_reply(redisReply* reply)
+{
+    if (reply != nullptr) {
+        freeReplyObject(reply);
+    }
+}
+
+bool reply_ok(redisReply* reply)
+{
+    return reply != nullptr && reply->type != REDIS_REPLY_ERROR;
+}
+
+std::string reply_string(redisReply* reply)
+{
+    if (reply == nullptr) {
+        return {};
+    }
+    if (reply->type == REDIS_REPLY_STRING || reply->type == REDIS_REPLY_STATUS) {
+        if (reply->str == nullptr) {
+            return {};
+        }
+        return std::string(reply->str, reply->len);
+    }
+    return {};
+}
+
+}  // namespace
+
+RedisService::RedisService() = default;
+
+RedisService::~RedisService()
+{
+    stop();
+}
+
+bool RedisService::start(const RedisConfig& cfg, std::string node_id)
+{
+    stop();
+    cfg_ = cfg;
+    node_id_ = std::move(node_id);
+    if (!cfg_.enable) {
+        return true;
+    }
+    if (node_id_.empty()) {
+        Logger::error("redis enable=1 requires server node_id");
+        return false;
+    }
+
+    stopping_.store(false, std::memory_order_release);
+    {
+        std::lock_guard<std::mutex> lock(cmd_mu_);
+        cmd_ = connect(false);
+        if (cmd_ == nullptr) {
+            Logger::error("redis connect failed " + cfg_.host + ":" + std::to_string(cfg_.port));
+            return false;
+        }
+    }
+
+    sub_ = connect(true);
+    if (sub_ == nullptr) {
+        Logger::error("redis subscribe connect failed");
+        close_ctx(cmd_);
+        return false;
+    }
+
+    enabled_.store(true, std::memory_order_release);
+    sub_thread_ = std::make_unique<RedisSubThread>();
+    sub_thread_->t = std::thread([this] { sub_loop(); });
+    Logger::info("redis ready node=" + node_id_ + " " + cfg_.host + ":" +
+                 std::to_string(cfg_.port) + " db=" + std::to_string(cfg_.db));
+    return true;
+}
+
+void RedisService::stop()
+{
+    stopping_.store(true, std::memory_order_release);
+    enabled_.store(false, std::memory_order_release);
+    {
+        std::lock_guard<std::mutex> lock(cmd_mu_);
+        if (cmd_ != nullptr && cmd_->err == 0 && !node_id_.empty()) {
+            const std::string ch = push_channel(node_id_);
+            redisReply* reply =
+                static_cast<redisReply*>(redisCommand(cmd_, "PUBLISH %s %s", ch.c_str(), "{}"));
+            free_reply(reply);
+        }
+    }
+    if (sub_thread_ && sub_thread_->t.joinable()) {
+        sub_thread_->t.join();
+    }
+    sub_thread_.reset();
+    close_ctx(sub_);
+    {
+        std::lock_guard<std::mutex> lock(cmd_mu_);
+        close_ctx(cmd_);
+    }
+}
+
+void RedisService::set_message_handler(MessageHandler handler)
+{
+    std::lock_guard<std::mutex> lock(handler_mu_);
+    handler_ = std::move(handler);
+}
+
+std::string RedisService::online_key(UserId uid) const
+{
+    return "im:online:" + std::to_string(uid);
+}
+
+std::string RedisService::push_channel(const std::string& node) const
+{
+    return "im:push:" + node;
+}
+
+redisContext* RedisService::connect(bool for_sub)
+{
+    redisContext* ctx = redisConnect(cfg_.host.c_str(), cfg_.port);
+    if (ctx == nullptr || ctx->err != 0) {
+        if (ctx != nullptr) {
+            Logger::error(std::string("redis connect: ") + ctx->errstr);
+            redisFree(ctx);
+        }
+        return nullptr;
+    }
+    if (!for_sub) {
+        struct timeval tv;
+        tv.tv_sec = 2;
+        tv.tv_usec = 0;
+        redisSetTimeout(ctx, tv);
+    }
+    if (!auth_select(ctx)) {
+        redisFree(ctx);
+        return nullptr;
+    }
+    return ctx;
+}
+
+bool RedisService::auth_select(redisContext* ctx)
+{
+    if (ctx == nullptr) {
+        return false;
+    }
+    if (!cfg_.password.empty()) {
+        redisReply* reply =
+            static_cast<redisReply*>(redisCommand(ctx, "AUTH %s", cfg_.password.c_str()));
+        const bool ok = reply_ok(reply);
+        if (!ok) {
+            Logger::error("redis AUTH failed");
+        }
+        free_reply(reply);
+        if (!ok) {
+            return false;
+        }
+    }
+    if (cfg_.db != 0) {
+        redisReply* reply = static_cast<redisReply*>(redisCommand(ctx, "SELECT %d", cfg_.db));
+        const bool ok = reply_ok(reply);
+        if (!ok) {
+            Logger::error("redis SELECT failed");
+        }
+        free_reply(reply);
+        if (!ok) {
+            return false;
+        }
+    }
+    redisReply* ping = static_cast<redisReply*>(redisCommand(ctx, "PING"));
+    const bool ok = reply_ok(ping);
+    free_reply(ping);
+    return ok;
+}
+
+void RedisService::close_ctx(redisContext*& ctx)
+{
+    if (ctx != nullptr) {
+        redisFree(ctx);
+        ctx = nullptr;
+    }
+}
+
+bool RedisService::ensure_cmd()
+{
+    if (cmd_ != nullptr && cmd_->err == 0) {
+        return true;
+    }
+    close_ctx(cmd_);
+    cmd_ = connect(false);
+    return cmd_ != nullptr;
+}
+
+std::string RedisService::online_node(UserId uid)
+{
+    if (!enabled() || uid <= 0) {
+        return {};
+    }
+    std::lock_guard<std::mutex> lock(cmd_mu_);
+    if (!ensure_cmd()) {
+        return {};
+    }
+    const std::string key = online_key(uid);
+    redisReply* reply = static_cast<redisReply*>(redisCommand(cmd_, "GET %s", key.c_str()));
+    if (reply == nullptr) {
+        close_ctx(cmd_);
+        return {};
+    }
+    std::string node = reply_string(reply);
+    free_reply(reply);
+    return node;
+}
+
+std::string RedisService::bind_online(UserId uid)
+{
+    if (!enabled() || uid <= 0) {
+        return {};
+    }
+    std::lock_guard<std::mutex> lock(cmd_mu_);
+    if (!ensure_cmd()) {
+        return {};
+    }
+    const std::string key = online_key(uid);
+    redisReply* reply =
+        static_cast<redisReply*>(redisCommand(cmd_, "GETSET %s %s", key.c_str(), node_id_.c_str()));
+    if (reply == nullptr) {
+        close_ctx(cmd_);
+        return {};
+    }
+    std::string old = reply_string(reply);
+    free_reply(reply);
+    return old;
+}
+
+bool RedisService::unbind_online(UserId uid)
+{
+    if (!enabled() || uid <= 0) {
+        return false;
+    }
+    static const char* kScript =
+        "if redis.call('GET',KEYS[1])==ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end";
+    std::lock_guard<std::mutex> lock(cmd_mu_);
+    if (!ensure_cmd()) {
+        return false;
+    }
+    const std::string key = online_key(uid);
+    redisReply* reply = static_cast<redisReply*>(
+        redisCommand(cmd_, "EVAL %s 1 %s %s", kScript, key.c_str(), node_id_.c_str()));
+    if (reply == nullptr) {
+        close_ctx(cmd_);
+        return false;
+    }
+    const bool deleted = reply->type == REDIS_REPLY_INTEGER && reply->integer > 0;
+    free_reply(reply);
+    return deleted;
+}
+
+bool RedisService::publish_to_node(const std::string& node, const std::string& payload)
+{
+    if (!enabled() || node.empty() || node == node_id_) {
+        return false;
+    }
+    std::lock_guard<std::mutex> lock(cmd_mu_);
+    if (!ensure_cmd()) {
+        return false;
+    }
+    const std::string ch = push_channel(node);
+    redisReply* reply =
+        static_cast<redisReply*>(redisCommand(cmd_, "PUBLISH %s %s", ch.c_str(), payload.c_str()));
+    if (reply == nullptr) {
+        close_ctx(cmd_);
+        return false;
+    }
+    const bool ok = reply_ok(reply);
+    free_reply(reply);
+    return ok;
+}
+
+void RedisService::sub_loop()
+{
+    const std::string ch = push_channel(node_id_);
+    while (!stopping_.load(std::memory_order_acquire)) {
+        if (sub_ == nullptr || sub_->err != 0) {
+            close_ctx(sub_);
+            if (stopping_.load(std::memory_order_acquire)) {
+                break;
+            }
+            sub_ = connect(true);
+            if (sub_ == nullptr) {
+                std::this_thread::sleep_for(std::chrono::seconds(1));
+                continue;
+            }
+        }
+
+        redisReply* suback =
+            static_cast<redisReply*>(redisCommand(sub_, "SUBSCRIBE %s", ch.c_str()));
+        if (!reply_ok(suback)) {
+            free_reply(suback);
+            close_ctx(sub_);
+            std::this_thread::sleep_for(std::chrono::seconds(1));
+            continue;
+        }
+        free_reply(suback);
+
+        while (!stopping_.load(std::memory_order_acquire)) {
+            redisReply* reply = nullptr;
+            const int rc = redisGetReply(sub_, reinterpret_cast<void**>(&reply));
+            if (rc != REDIS_OK || reply == nullptr) {
+                free_reply(reply);
+                if (stopping_.load(std::memory_order_acquire)) {
+                    break;
+                }
+                close_ctx(sub_);
+                break;
+            }
+            if (reply->type == REDIS_REPLY_ARRAY && reply->elements >= 3 &&
+                reply->element[0] != nullptr && reply->element[0]->str != nullptr &&
+                std::strcmp(reply->element[0]->str, "message") == 0 &&
+                reply->element[2] != nullptr && reply->element[2]->str != nullptr) {
+                const std::string payload(reply->element[2]->str, reply->element[2]->len);
+                MessageHandler handler;
+                {
+                    std::lock_guard<std::mutex> lock(handler_mu_);
+                    handler = handler_;
+                }
+                if (handler) {
+                    try {
+                        handler(payload);
+                    } catch (const std::exception& e) {
+                        Logger::error(std::string("redis push handler: ") + e.what());
+                    } catch (...) {
+                        Logger::error("redis push handler unknown exception");
+                    }
+                }
+            }
+            free_reply(reply);
+        }
+    }
+}
+
+}  // namespace im

+ 65 - 0
server/src/db/redis_service.h

@@ -0,0 +1,65 @@
+#pragma once
+
+#include <atomic>
+#include <functional>
+#include <memory>
+#include <mutex>
+#include <string>
+
+#include "common/types.h"
+#include "config/app_config.h"
+
+struct redisContext;
+
+namespace im {
+
+struct RedisSubThread;
+
+// hiredis 封装:命令连接(SET/GET/PUBLISH)与订阅线程分开,hiredis 上下文不可跨线程共用。
+class RedisService {
+public:
+    using MessageHandler = std::function<void(const std::string& payload)>;
+
+    RedisService();
+    ~RedisService();
+    RedisService(const RedisService&) = delete;
+    RedisService& operator=(const RedisService&) = delete;
+
+    bool start(const RedisConfig& cfg, std::string node_id);
+    void stop();
+    bool enabled() const { return enabled_.load(std::memory_order_acquire); }
+    const std::string& node_id() const { return node_id_; }
+
+    void set_message_handler(MessageHandler handler);
+
+    std::string online_node(UserId uid);
+    // GETSET 本节点,返回该 uid 之前所在节点(空=原先不在线)。
+    std::string bind_online(UserId uid);
+    // 仅当 Redis 里仍是本节点时删除。返回是否删除成功。
+    bool unbind_online(UserId uid);
+    bool publish_to_node(const std::string& node, const std::string& payload);
+
+private:
+    std::string online_key(UserId uid) const;
+    std::string push_channel(const std::string& node) const;
+    redisContext* connect(bool for_sub);
+    bool auth_select(redisContext* ctx);
+    void close_ctx(redisContext*& ctx);
+    bool ensure_cmd();
+    void sub_loop();
+
+    RedisConfig cfg_;
+    std::string node_id_;
+    std::atomic<bool> enabled_{false};
+    std::atomic<bool> stopping_{false};
+
+    std::mutex cmd_mu_;
+    redisContext* cmd_ = nullptr;
+
+    redisContext* sub_ = nullptr;
+    std::unique_ptr<RedisSubThread> sub_thread_;
+    std::mutex handler_mu_;
+    MessageHandler handler_;
+};
+
+}  // namespace im

+ 24 - 8
server/src/im/dispatcher.cpp

@@ -2,6 +2,7 @@
 
 #include <exception>
 #include <string>
+#include <unordered_map>
 
 #include "common/i18n.h"
 #include "common/logger.h"
@@ -21,7 +22,7 @@ void reply_db_error(ImServer* server, const SessionPtr& session, uint16_t type,
     body["code"] = Err::DbError;
     const i18n::Lang lang = session ? session->lang() : i18n::Lang::Zh;
     body["msg"] = i18n::text(i18n::Msg::DbError, lang);
-    server->send(session->connid(),
+    server->send(session,
                  Packet::make(static_cast<Type>(type), static_cast<Cmd>(cmd), seq,
                               body.to_string()));
 }
@@ -30,18 +31,24 @@ void reply_db_error(ImServer* server, const SessionPtr& session, uint16_t type,
 
 void Dispatcher::add(Type type, Factory factory)
 {
+    std::lock_guard<std::mutex> lock(mutex_);
     factories_[static_cast<uint16_t>(type)] = std::move(factory);
 }
 
 void Dispatcher::dispatch(HandlerContext ctx)
 {
+    Factory factory;
     const uint16_t type = ctx.packet.type;
-    auto it = factories_.find(type);
-    if (it == factories_.end() || !it->second) {
-        Logger::warn("unhandled type=" + std::to_string(type) +
-                     " cmd=" + std::to_string(ctx.packet.cmd) +
-                     " conn=" + std::to_string(ctx.session->connid()));
-        return;
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+        auto it = factories_.find(type);
+        if (it == factories_.end() || !it->second) {
+            Logger::warn("unhandled type=" + std::to_string(type) +
+                         " cmd=" + std::to_string(ctx.packet.cmd) +
+                         " conn=" + std::to_string(ctx.session->connid()));
+            return;
+        }
+        factory = it->second;
     }
 
     ImServer* server = ctx.server;
@@ -51,7 +58,16 @@ void Dispatcher::dispatch(HandlerContext ctx)
     const uint32_t seq = ctx.packet.seq;
 
     try {
-        it->second()->process(std::move(ctx));
+        // HP 线程池线程长期存活:每种 Type 在本线程只 new 一次。
+        thread_local std::unordered_map<uint16_t, std::unique_ptr<IHandler>> cache;
+        auto& slot = cache[type];
+        if (!slot) {
+            slot = factory();
+        }
+        if (!slot) {
+            return;
+        }
+        slot->process(std::move(ctx));
     } catch (const std::exception& e) {
         Logger::error("handler exception type=" + std::to_string(pkt_type) +
                       " cmd=" + std::to_string(pkt_cmd) + ": " + e.what());

+ 4 - 2
server/src/im/dispatcher.h

@@ -2,6 +2,7 @@
 
 #include <functional>
 #include <memory>
+#include <mutex>
 #include <unordered_map>
 
 #include "common/types.h"
@@ -9,8 +10,8 @@
 
 namespace im {
 
-// 按 Type 找到业务 Handler(每次请求新建实例),再由该类解析 Cmd。
-// dispatch 捕获业务层异常,回 Err::DbError,不向外抛
+// 按 Type 找到业务 Handler。每个工作线程每种 Type 复用一个实例,请求间
+// process() 会重置 ctx_/req_。dispatch 捕获业务层异常,回 Err::DbError。
 class Dispatcher {
 public:
     using Factory = std::function<std::unique_ptr<IHandler>()>;
@@ -19,6 +20,7 @@ public:
     void dispatch(HandlerContext ctx);
 
 private:
+    mutable std::mutex mutex_;
     std::unordered_map<uint16_t, Factory> factories_;
 };
 

+ 18 - 2
server/src/im/handler.cpp

@@ -128,6 +128,9 @@ ylib::json IHandler::limits_json()
     o["history_max"] = static_cast<int64>(lim.history_max);
     o["avatar_max"] = static_cast<int64>(lim.avatar_max);
     o["file_max"] = static_cast<int64>(lim.file_max);
+    o["group_max_members"] = static_cast<int64>(lim.group_max_members);
+    o["group_name_max"] = static_cast<int64>(lim.group_name_max);
+    o["group_max_admins"] = static_cast<int64>(lim.group_max_admins);
     o["max_packet"] = static_cast<int64>(config().protocol().max_packet);
     o["heartbeat_sec"] = static_cast<int64>(config().client().heartbeat_sec);
     o["interval_sec"] = static_cast<int64>(config().sms().interval_sec);
@@ -170,12 +173,25 @@ void IHandler::reply_error(int code, const std::string& msg)
 
 void IHandler::push(ConnId connid, Type type, Cmd cmd, const ylib::json& body)
 {
-    ctx_.server->send(connid, Packet::make(type, cmd, 0, body.to_string()));
+    push(ctx_.server->sessions().get(connid), type, cmd, body);
+}
+
+void IHandler::push(const SessionPtr& target, Type type, Cmd cmd, const ylib::json& body)
+{
+    if (!target) {
+        return;
+    }
+    ctx_.server->send(target, Packet::make(type, cmd, 0, body.to_string()));
+}
+
+void IHandler::push_uid(UserId uid, Type type, Cmd cmd, const ylib::json& body)
+{
+    ctx_.server->push_to_uid(uid, Packet::make(type, cmd, 0, body.to_string()));
 }
 
 void IHandler::send_reply(const ylib::json& body)
 {
-    ctx_.server->send(ctx_.session->connid(),
+    ctx_.server->send(ctx_.session,
                       Packet::make(static_cast<Type>(ctx_.packet.type),
                                    static_cast<Cmd>(ctx_.packet.cmd), ctx_.packet.seq,
                                    body.to_string()));

+ 2 - 0
server/src/im/handler.h

@@ -58,6 +58,8 @@ protected:
     void reply_error(int code, const std::string& msg);
     i18n::Lang reply_lang() const;
     void push(ConnId connid, Type type, Cmd cmd, const ylib::json& body);
+    void push(const SessionPtr& target, Type type, Cmd cmd, const ylib::json& body);
+    void push_uid(UserId uid, Type type, Cmd cmd, const ylib::json& body);
 
     bool require_login();
 

+ 43 - 6
server/src/im/handlers/account_handler.cpp

@@ -8,6 +8,7 @@
 #include "common/logger.h"
 #include "db/account_store.h"
 #include "db/friend_store.h"
+#include "db/group_store.h"
 #include "net/im_server.h"
 #include "third/sms.h"
 #include "third/cos.h"
@@ -69,14 +70,12 @@ void AccountHandler::kick_if_needed(ConnId old)
     if (old == 0) {
         return;
     }
-    i18n::Lang lang = i18n::Lang::Zh;
-    if (const SessionPtr old_sess = server().sessions().get(old)) {
-        lang = old_sess->lang();
-    }
+    const SessionPtr old_sess = server().sessions().get(old);
+    const i18n::Lang lang = old_sess ? old_sess->lang() : i18n::Lang::Zh;
     ylib::json body;
     body["code"] = Err::Ok;
     body["msg"] = i18n::text(i18n::Msg::Kicked, lang);
-    push(old, Type::System, Cmd::Kicked, body);
+    push(old_sess, Type::System, Cmd::Kicked, body);
     server().disconnect(old);
 }
 
@@ -115,6 +114,7 @@ void AccountHandler::on_login()
     session()->set_profile(user.id, user.username,
                            user.nickname.empty() ? user.username : user.nickname);
     kick_if_needed(old);
+    server().bind_cluster(user.id);
 
     try {
         FriendStore friends(*mysql());
@@ -147,7 +147,8 @@ void AccountHandler::on_logout()
     const UserId uid = session()->uid();
     server().sessions().bind_uid(session()->connid(), 0);
     session()->set_profile(0, "", "");
-    if (!server().sessions().find_by_uid(uid)) {
+    server().unbind_cluster(uid);
+    if (!server().is_online(uid)) {
         server().notify_friend_presence(uid, 0);
     }
     reply_ok();
@@ -483,6 +484,42 @@ void AccountHandler::on_get_avatar()
     if (!require_login()) {
         return;
     }
+    const UserId group_id = static_cast<UserId>(json_i64("group_id"));
+    if (group_id > 0) {
+        GroupStore groups(*mysql());
+        GroupRow g;
+        if (!groups.find(group_id, g)) {
+            reply_error(Err::NotFound, i18n::Msg::GroupNotFound);
+            return;
+        }
+        if (g.avatar_ver <= 0) {
+            reply_error(Err::NotFound, i18n::Msg::AvatarInvalid);
+            return;
+        }
+        const std::string key = third::cos::group_avatar_key(config().cos(), group_id);
+        ylib::json body;
+        body["group_id"] = static_cast<int64>(group_id);
+        body["ver"] = static_cast<int64>(g.avatar_ver);
+        body["mime"] = "image/jpeg";
+        body["url"] = key;
+        if (config().cdn_domain().empty()) {
+            std::string bytes;
+            if (third::cos::enabled(config().cos())) {
+                const auto got = third::cos::get_object(config().cos(), key);
+                if (got.ok) {
+                    bytes = got.data;
+                }
+            }
+            if (bytes.empty()) {
+                reply_error(Err::NotFound, i18n::Msg::AvatarInvalid);
+                return;
+            }
+            body["data"] = base64_encode(bytes.data(), bytes.size());
+        }
+        reply_ok(body);
+        return;
+    }
+
     const UserId uid = static_cast<UserId>(json_i64("uid", session()->uid()));
     if (uid <= 0) {
         reply_error(Err::BadParam, i18n::Msg::UidRequired);

+ 21 - 27
server/src/im/handlers/friend_handler.cpp

@@ -93,7 +93,7 @@ void FriendHandler::on_list()
         item["remark"] = row.remark;
         item["signature"] = row.signature;
         item["avatar_ver"] = static_cast<int64>(row.avatar_ver);
-        item["online"] = server().sessions().find_by_uid(row.uid) ? 1 : 0;
+        item["online"] = server().is_online(row.uid) ? 1 : 0;
         list.push_back(item);
     }
     ylib::json body;
@@ -213,13 +213,11 @@ void FriendHandler::on_add()
         fill_user_json(body, peer);
         body["relation"] = FriendRel::Friend;
         reply_ok(body);
-        if (auto target = server().sessions().find_by_uid(peer.id)) {
-            ylib::json notify;
-            notify["uid"] = static_cast<int64>(session()->uid());
-            notify["account"] = session()->account();
-            notify["nickname"] = session()->nickname();
-            push(target->connid(), Type::Friend, Cmd::FriendAcceptedNotify, notify);
-        }
+        ylib::json notify;
+        notify["uid"] = static_cast<int64>(session()->uid());
+        notify["account"] = session()->account();
+        notify["nickname"] = session()->nickname();
+        push_uid(peer.id, Type::Friend, Cmd::FriendAcceptedNotify, notify);
         return;
     }
 
@@ -237,18 +235,16 @@ void FriendHandler::on_add()
     body["request_id"] = static_cast<int64>(req_id);
     reply_ok(body);
 
-    if (auto target = server().sessions().find_by_uid(peer.id)) {
-        ylib::json notify;
-        notify["id"] = static_cast<int64>(req_id);
-        notify["uid"] = static_cast<int64>(session()->uid());
-        notify["account"] = session()->account();
-        notify["nickname"] = session()->nickname();
-        notify["message"] = message;
-        UserRow me;
-        if (users.find_by_id(session()->uid(), me))
-            notify["avatar_ver"] = static_cast<int64>(me.avatar_ver);
-        push(target->connid(), Type::Friend, Cmd::FriendRequestNotify, notify);
-    }
+    ylib::json notify;
+    notify["id"] = static_cast<int64>(req_id);
+    notify["uid"] = static_cast<int64>(session()->uid());
+    notify["account"] = session()->account();
+    notify["nickname"] = session()->nickname();
+    notify["message"] = message;
+    UserRow me;
+    if (users.find_by_id(session()->uid(), me))
+        notify["avatar_ver"] = static_cast<int64>(me.avatar_ver);
+    push_uid(peer.id, Type::Friend, Cmd::FriendRequestNotify, notify);
     Logger::info("friend request " + std::to_string(session()->uid()) + " -> " +
                  std::to_string(peer.id));
 }
@@ -300,13 +296,11 @@ void FriendHandler::on_handle_request()
     store.set_request_status(req_id, action);
     if (action == 1) {
         store.add_friend(req.from_uid, req.to_uid);
-        if (auto target = server().sessions().find_by_uid(req.from_uid)) {
-            ylib::json notify;
-            notify["uid"] = static_cast<int64>(session()->uid());
-            notify["account"] = session()->account();
-            notify["nickname"] = session()->nickname();
-            push(target->connid(), Type::Friend, Cmd::FriendAcceptedNotify, notify);
-        }
+        ylib::json notify;
+        notify["uid"] = static_cast<int64>(session()->uid());
+        notify["account"] = session()->account();
+        notify["nickname"] = session()->nickname();
+        push_uid(req.from_uid, Type::Friend, Cmd::FriendAcceptedNotify, notify);
     }
 
     ylib::json body;

+ 912 - 0
server/src/im/handlers/group_handler.cpp

@@ -0,0 +1,912 @@
+#include "im/handlers/group_handler.h"
+
+#include <algorithm>
+#include <string>
+#include <vector>
+
+#include "common/crypto.h"
+#include "common/i18n.h"
+#include "common/logger.h"
+#include "common/protocol_def.h"
+#include "db/account_store.h"
+#include "db/friend_store.h"
+#include "db/group_store.h"
+#include "net/im_server.h"
+#include "third/cos.h"
+#include "util/time.h"
+
+namespace im {
+namespace {
+
+std::string display_name(const std::string& nickname, const std::string& username)
+{
+    return nickname.empty() ? username : nickname;
+}
+
+bool looks_like_jpeg(const std::string& bytes)
+{
+    return bytes.size() >= 3 &&
+           static_cast<unsigned char>(bytes[0]) == 0xFF &&
+           static_cast<unsigned char>(bytes[1]) == 0xD8;
+}
+
+int64 json_num(const ylib::json& j, const char* key, int64 fallback = 0)
+{
+    if (key == nullptr || !j.exist(key)) {
+        return fallback;
+    }
+    if (j[key].is_number()) {
+        return j[key].to<int64>();
+    }
+    const std::string s = j[key].to<std::string>();
+    if (s.empty()) {
+        return fallback;
+    }
+    try {
+        return static_cast<int64>(std::stoll(s));
+    } catch (...) {
+        return fallback;
+    }
+}
+
+std::vector<UserId> parse_uids(const ylib::json& body)
+{
+    std::vector<UserId> out;
+    if (!body.exist("uids") || !body["uids"].is_array()) {
+        return out;
+    }
+    const ylib::json& arr = body["uids"];
+    for (size_t i = 0; i < arr.size(); ++i) {
+        int64 v = 0;
+        if (arr[i].is_number()) {
+            v = arr[i].to<int64>();
+        } else {
+            const std::string s = arr[i].is_string() ? arr[i].to<std::string>() : arr[i].to_string();
+            try {
+                v = static_cast<int64>(std::stoll(s));
+            } catch (...) {
+                v = 0;
+            }
+        }
+        if (v > 0) {
+            out.push_back(static_cast<UserId>(v));
+        }
+    }
+    std::sort(out.begin(), out.end());
+    out.erase(std::unique(out.begin(), out.end()), out.end());
+    return out;
+}
+
+}  // namespace
+
+void GroupHandler::handle()
+{
+    switch (cmd()) {
+    case Cmd::GroupCreate:
+        on_create();
+        break;
+    case Cmd::GroupInfo:
+        on_info();
+        break;
+    case Cmd::GroupMembers:
+        on_members();
+        break;
+    case Cmd::GroupList:
+        on_list();
+        break;
+    case Cmd::GroupUpdate:
+        on_update();
+        break;
+    case Cmd::GroupInvite:
+        on_invite();
+        break;
+    case Cmd::GroupSearch:
+        on_search();
+        break;
+    case Cmd::GroupJoin:
+        on_join();
+        break;
+    case Cmd::GroupLeave:
+        on_leave();
+        break;
+    case Cmd::GroupDismiss:
+        on_dismiss();
+        break;
+    case Cmd::GroupKick:
+        on_kick();
+        break;
+    case Cmd::GroupSetAdmin:
+        on_set_admin();
+        break;
+    case Cmd::GroupTransfer:
+        on_transfer();
+        break;
+    case Cmd::GroupRequestList:
+        on_request_list();
+        break;
+    case Cmd::GroupHandleRequest:
+        on_handle_request();
+        break;
+    default:
+        Logger::warn("unhandled group cmd=" + std::to_string(ctx_.packet.cmd));
+        break;
+    }
+}
+
+bool GroupHandler::load_group(UserId group_id, GroupRow& out)
+{
+    if (group_id <= 0) {
+        reply_error(Err::BadParam, i18n::Msg::GroupIdRequired);
+        return false;
+    }
+    GroupStore store(*mysql());
+    if (!store.find(group_id, out)) {
+        reply_error(Err::NotFound, i18n::Msg::GroupNotFound);
+        return false;
+    }
+    return true;
+}
+
+void GroupHandler::fill_group(ylib::json& body, const GroupRow& g, int role, int pending)
+{
+    body["group_id"] = static_cast<int64>(g.id);
+    body["name"] = g.name;
+    body["owner_uid"] = static_cast<int64>(g.owner_uid);
+    body["join_mode"] = g.join_mode;
+    body["member_count"] = g.member_count;
+    body["avatar_ver"] = g.avatar_ver;
+    body["role"] = role;
+    if (pending >= 0) {
+        body["pending"] = pending;
+    }
+}
+
+void GroupHandler::fanout_chat(const GroupRow& g, UserId msg_id, UserId from_uid, int msg_type,
+                               const std::string& content, int64 ts, UserId except_uid)
+{
+    GroupStore store(*mysql());
+    const auto uids = store.list_member_uids(g.id);
+    ylib::json notify;
+    notify["msg_id"] = static_cast<int64>(msg_id);
+    notify["from_uid"] = static_cast<int64>(from_uid);
+    notify["from_account"] = session()->account();
+    notify["from_nickname"] = session()->nickname();
+    notify["group_id"] = static_cast<int64>(g.id);
+    notify["group_name"] = g.name;
+    notify["msg_type"] = msg_type;
+    notify["content"] = content;
+    notify["ts"] = ts;
+    if (from_uid == 0) {
+        notify["from_account"] = "";
+        notify["from_nickname"] = "";
+    }
+    for (UserId uid : uids) {
+        if (uid == except_uid) {
+            continue;
+        }
+        push_uid(uid, Type::Message, Cmd::ChatNotify, notify);
+    }
+}
+
+void GroupHandler::post_sys(GroupStore& store, const GroupRow& g, const ylib::json& content,
+                            UserId except_unread)
+{
+    const std::string body = content.to_string();
+    const UserId msg_id = store.insert_message(g.id, 0, MsgType::Sys, body);
+    const std::string preview = group_event_preview(body, config().limits().preview_max);
+    store.upsert_inbox_all(g.id, msg_id, preview, except_unread);
+    fanout_chat(g, msg_id, 0, MsgType::Sys, body, ylib::time::now_msec(), 0);
+}
+
+void GroupHandler::push_change(const GroupRow& g, ylib::json body, const std::vector<UserId>& uids)
+{
+    body["group_id"] = static_cast<int64>(g.id);
+    body["name"] = g.name;
+    body["member_count"] = g.member_count;
+    body["join_mode"] = g.join_mode;
+    body["avatar_ver"] = g.avatar_ver;
+    for (UserId uid : uids) {
+        push_uid(uid, Type::Group, Cmd::GroupChangeNotify, body);
+    }
+}
+
+void GroupHandler::on_create()
+{
+    if (!require_login()) {
+        return;
+    }
+    const auto& lim = config().limits();
+    std::string name = json_str("name");
+    auto uids = parse_uids(json_body());
+    const UserId me = session()->uid();
+    uids.erase(std::remove(uids.begin(), uids.end(), me), uids.end());
+    uids.erase(std::remove(uids.begin(), uids.end(), kFileHelperUid), uids.end());
+    if (uids.empty()) {
+        reply_error(Err::BadParam, i18n::Msg::GroupNeedMembers);
+        return;
+    }
+    if (uids.size() + 1 > lim.group_max_members) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupFull);
+        return;
+    }
+
+    FriendStore friends(*mysql());
+    AccountStore users(*mysql());
+    std::vector<std::string> names;
+    names.push_back(display_name(session()->nickname(), session()->account()));
+    for (UserId uid : uids) {
+        if (!friends.is_friend(me, uid)) {
+            reply_error(Err::Forbidden, i18n::Msg::NotFriends);
+            return;
+        }
+        UserRow u;
+        if (!users.find_by_id(uid, u) || u.status == 2) {
+            reply_error(Err::NotFound, i18n::Msg::UserNotFound);
+            return;
+        }
+        names.push_back(display_name(u.nickname, u.username));
+    }
+    if (name.empty()) {
+        name = names[0];
+        for (size_t i = 1; i < names.size(); ++i) {
+            name += "、";
+            name += names[i];
+            if (name.size() >= lim.group_name_max) {
+                break;
+            }
+        }
+    }
+    if (name.size() > lim.group_name_max) {
+        name.resize(lim.group_name_max);
+    }
+
+    GroupStore store(*mysql());
+    const UserId gid = store.create_group(me, name, uids);
+    GroupRow g;
+    if (!store.find(gid, g)) {
+        reply_error(Err::DbError, i18n::Msg::DbError);
+        return;
+    }
+    ylib::json sys;
+    sys["event"] = "create";
+    sys["uid"] = static_cast<int64>(me);
+    sys["nickname"] = display_name(session()->nickname(), session()->account());
+    post_sys(store, g, sys, me);
+
+    ylib::json change;
+    change["event"] = "invite";
+    change["uid"] = static_cast<int64>(me);
+    change["nickname"] = display_name(session()->nickname(), session()->account());
+    auto members = store.list_member_uids(gid);
+    push_change(g, change, members);
+
+    ylib::json body;
+    fill_group(body, g, GroupRole::Owner, 0);
+    reply_ok(body);
+}
+
+void GroupHandler::on_info()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupRow g;
+    if (!load_group(static_cast<UserId>(json_i64("group_id")), g)) {
+        return;
+    }
+    GroupStore store(*mysql());
+    const int role = store.member_role(g.id, session()->uid());
+    int pending = -1;
+    if (role >= GroupRole::Admin) {
+        pending = store.pending_count(g.id);
+    }
+    ylib::json body;
+    fill_group(body, g, role, pending);
+    reply_ok(body);
+}
+
+void GroupHandler::on_members()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupRow g;
+    if (!load_group(static_cast<UserId>(json_i64("group_id")), g)) {
+        return;
+    }
+    GroupStore store(*mysql());
+    if (store.member_role(g.id, session()->uid()) < 0) {
+        reply_error(Err::Forbidden, i18n::Msg::NotGroupMember);
+        return;
+    }
+    ylib::json list;
+    list = ylib::json::type::array;
+    for (const auto& row : store.list_members(g.id)) {
+        ylib::json item;
+        item["uid"] = static_cast<int64>(row.uid);
+        item["account"] = row.username;
+        item["nickname"] = display_name(row.nickname, row.username);
+        item["avatar_ver"] = row.avatar_ver;
+        item["role"] = row.role;
+        list.push_back(item);
+    }
+    ylib::json body;
+    body["group_id"] = static_cast<int64>(g.id);
+    body["list"] = list;
+    reply_ok(body);
+}
+
+void GroupHandler::on_list()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupStore store(*mysql());
+    ylib::json list;
+    list = ylib::json::type::array;
+    for (const auto& row : store.list_groups(session()->uid())) {
+        ylib::json item;
+        item["group_id"] = static_cast<int64>(row.group_id);
+        item["name"] = row.name;
+        item["member_count"] = row.member_count;
+        item["join_mode"] = row.join_mode;
+        item["role"] = row.role;
+        item["avatar_ver"] = row.avatar_ver;
+        list.push_back(item);
+    }
+    ylib::json body;
+    body["list"] = list;
+    reply_ok(body);
+}
+
+void GroupHandler::on_update()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupRow g;
+    if (!load_group(static_cast<UserId>(json_i64("group_id")), g)) {
+        return;
+    }
+    GroupStore store(*mysql());
+    if (store.member_role(g.id, session()->uid()) < GroupRole::Admin) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupNoPermission);
+        return;
+    }
+    const auto& lim = config().limits();
+    std::string name;
+    const std::string* namePtr = nullptr;
+    if (json_has("name")) {
+        name = json_str("name");
+        if (name.empty()) {
+            reply_error(Err::BadParam, i18n::Msg::GroupNameRequired);
+            return;
+        }
+        if (name.size() > lim.group_name_max) {
+            reply_error(Err::BadParam, i18n::Msg::GroupNameTooLong);
+            return;
+        }
+        namePtr = &name;
+    }
+    int join_mode = 0;
+    const int* joinPtr = nullptr;
+    if (json_has("join_mode")) {
+        join_mode = static_cast<int>(json_i64("join_mode", -1));
+        if (join_mode < JoinMode::Open || join_mode > JoinMode::Closed) {
+            reply_error(Err::BadParam, i18n::Msg::BadParam);
+            return;
+        }
+        joinPtr = &join_mode;
+    }
+    const std::string avatar_b64 = json_str("avatar");
+    std::string avatar_bytes;
+    bool bump_avatar = false;
+    if (!avatar_b64.empty()) {
+        if (!base64_decode(avatar_b64, avatar_bytes) || !looks_like_jpeg(avatar_bytes) ||
+            avatar_bytes.size() > lim.avatar_max) {
+            reply_error(Err::BadParam, i18n::Msg::AvatarInvalid);
+            return;
+        }
+        if (!third::cos::enabled(config().cos())) {
+            reply_error(Err::DbError, i18n::Msg::DbError);
+            return;
+        }
+        const std::string key = third::cos::group_avatar_key(config().cos(), g.id);
+        const auto put = third::cos::put_object(config().cos(), key, avatar_bytes, "image/jpeg");
+        if (!put.ok) {
+            reply_error(Err::DbError, i18n::Msg::DbError);
+            return;
+        }
+        bump_avatar = true;
+    }
+    if (namePtr == nullptr && joinPtr == nullptr && !bump_avatar) {
+        ylib::json body;
+        fill_group(body, g, store.member_role(g.id, session()->uid()), -1);
+        reply_ok(body);
+        return;
+    }
+    store.update_info(g.id, namePtr, joinPtr);
+    if (bump_avatar) {
+        store.bump_avatar(g.id);
+    }
+    store.find(g.id, g);
+    ylib::json sys;
+    sys["event"] = "info";
+    sys["uid"] = static_cast<int64>(session()->uid());
+    sys["nickname"] = display_name(session()->nickname(), session()->account());
+    post_sys(store, g, sys, session()->uid());
+    ylib::json change;
+    change["event"] = "info";
+    push_change(g, change, store.list_member_uids(g.id));
+    ylib::json body;
+    fill_group(body, g, store.member_role(g.id, session()->uid()), -1);
+    reply_ok(body);
+}
+
+void GroupHandler::on_invite()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupRow g;
+    if (!load_group(static_cast<UserId>(json_i64("group_id")), g)) {
+        return;
+    }
+    GroupStore store(*mysql());
+    if (store.member_role(g.id, session()->uid()) < GroupRole::Admin) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupNoPermission);
+        return;
+    }
+    auto uids = parse_uids(json_body());
+    uids.erase(std::remove(uids.begin(), uids.end(), session()->uid()), uids.end());
+    uids.erase(std::remove(uids.begin(), uids.end(), kFileHelperUid), uids.end());
+    if (uids.empty()) {
+        reply_error(Err::BadParam, i18n::Msg::GroupNeedMembers);
+        return;
+    }
+    FriendStore friends(*mysql());
+    AccountStore users(*mysql());
+    const auto& lim = config().limits();
+    std::vector<UserId> added;
+    for (UserId uid : uids) {
+        if (store.member_role(g.id, uid) >= 0) {
+            continue;
+        }
+        if (!friends.is_friend(session()->uid(), uid)) {
+            reply_error(Err::Forbidden, i18n::Msg::NotFriends);
+            return;
+        }
+        UserRow u;
+        if (!users.find_by_id(uid, u) || u.status == 2) {
+            reply_error(Err::NotFound, i18n::Msg::UserNotFound);
+            return;
+        }
+        if (static_cast<uint32_t>(g.member_count + static_cast<int>(added.size()) + 1) >
+            lim.group_max_members) {
+            reply_error(Err::Forbidden, i18n::Msg::GroupFull);
+            return;
+        }
+        store.add_member(g.id, uid, GroupRole::Member);
+        added.push_back(uid);
+        ylib::json sys;
+        sys["event"] = "invite";
+        sys["uid"] = static_cast<int64>(uid);
+        sys["nickname"] = display_name(u.nickname, u.username);
+        sys["by_uid"] = static_cast<int64>(session()->uid());
+        store.find(g.id, g);
+        post_sys(store, g, sys, session()->uid());
+    }
+    store.find(g.id, g);
+    ylib::json change;
+    change["event"] = "invite";
+    ylib::json idarr;
+    idarr = ylib::json::type::array;
+    for (UserId uid : added) {
+        idarr.push_back(static_cast<int64>(uid));
+    }
+    change["uids"] = idarr;
+    push_change(g, change, store.list_member_uids(g.id));
+    ylib::json body;
+    fill_group(body, g, store.member_role(g.id, session()->uid()), -1);
+    reply_ok(body);
+}
+
+void GroupHandler::on_search()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupRow g;
+    if (!load_group(static_cast<UserId>(json_i64("group_id")), g)) {
+        return;
+    }
+    GroupStore store(*mysql());
+    const int role = store.member_role(g.id, session()->uid());
+    ylib::json body;
+    fill_group(body, g, role, -1);
+    reply_ok(body);
+}
+
+void GroupHandler::on_join()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupRow g;
+    if (!load_group(static_cast<UserId>(json_i64("group_id")), g)) {
+        return;
+    }
+    GroupStore store(*mysql());
+    const UserId me = session()->uid();
+    if (store.member_role(g.id, me) >= 0) {
+        reply_error(Err::Exists, i18n::Msg::AlreadyInGroup);
+        return;
+    }
+    if (g.join_mode == JoinMode::Closed) {
+        reply_error(Err::Forbidden, i18n::Msg::JoinNotAllowed);
+        return;
+    }
+    if (static_cast<uint32_t>(g.member_count) >= config().limits().group_max_members) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupFull);
+        return;
+    }
+    if (g.join_mode == JoinMode::Approve) {
+        std::string message = json_str("message");
+        if (message.size() > 50) {
+            reply_error(Err::BadParam, i18n::Msg::FriendRequestMessageTooLong);
+            return;
+        }
+        UserId req_id = 0;
+        bool exists = false;
+        store.create_request(g.id, me, message, req_id, exists);
+        ylib::json notify;
+        notify["id"] = static_cast<int64>(req_id);
+        notify["group_id"] = static_cast<int64>(g.id);
+        notify["name"] = g.name;
+        notify["from_uid"] = static_cast<int64>(me);
+        notify["from_nickname"] = display_name(session()->nickname(), session()->account());
+        notify["message"] = message;
+        for (UserId uid : store.list_admin_uids(g.id)) {
+            push_uid(uid, Type::Group, Cmd::GroupRequestNotify, notify);
+        }
+        ylib::json body;
+        fill_group(body, g, -1, -1);
+        body["joined"] = 0;
+        body["pending"] = 1;
+        if (exists) {
+            body["msg"] = i18n::text(i18n::Msg::GroupRequestPending, reply_lang());
+        } else {
+            body["msg"] = i18n::text(i18n::Msg::GroupRequestSent, reply_lang());
+        }
+        reply_ok(body);
+        return;
+    }
+
+    store.add_member(g.id, me, GroupRole::Member);
+    store.find(g.id, g);
+    ylib::json sys;
+    sys["event"] = "join";
+    sys["uid"] = static_cast<int64>(me);
+    sys["nickname"] = display_name(session()->nickname(), session()->account());
+    post_sys(store, g, sys, me);
+    ylib::json change;
+    change["event"] = "join";
+    change["uid"] = static_cast<int64>(me);
+    change["nickname"] = display_name(session()->nickname(), session()->account());
+    push_change(g, change, store.list_member_uids(g.id));
+    ylib::json body;
+    fill_group(body, g, GroupRole::Member, -1);
+    body["joined"] = 1;
+    reply_ok(body);
+}
+
+void GroupHandler::on_leave()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupRow g;
+    if (!load_group(static_cast<UserId>(json_i64("group_id")), g)) {
+        return;
+    }
+    GroupStore store(*mysql());
+    const int role = store.member_role(g.id, session()->uid());
+    if (role < 0) {
+        reply_error(Err::Forbidden, i18n::Msg::NotGroupMember);
+        return;
+    }
+    if (role == GroupRole::Owner) {
+        reply_error(Err::Forbidden, i18n::Msg::OwnerCannotLeave);
+        return;
+    }
+    auto members = store.list_member_uids(g.id);
+    ylib::json sys;
+    sys["event"] = "leave";
+    sys["uid"] = static_cast<int64>(session()->uid());
+    sys["nickname"] = display_name(session()->nickname(), session()->account());
+    post_sys(store, g, sys, session()->uid());
+    store.remove_member(g.id, session()->uid());
+    store.find(g.id, g);
+    ylib::json change;
+    change["event"] = "leave";
+    change["uid"] = static_cast<int64>(session()->uid());
+    change["nickname"] = display_name(session()->nickname(), session()->account());
+    push_change(g, change, members);
+    ylib::json body;
+    body["group_id"] = static_cast<int64>(g.id);
+    reply_ok(body);
+}
+
+void GroupHandler::on_dismiss()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupRow g;
+    if (!load_group(static_cast<UserId>(json_i64("group_id")), g)) {
+        return;
+    }
+    GroupStore store(*mysql());
+    if (store.member_role(g.id, session()->uid()) != GroupRole::Owner) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupNoPermission);
+        return;
+    }
+    auto members = store.list_member_uids(g.id);
+    ylib::json change;
+    change["event"] = "dismiss";
+    change["uid"] = static_cast<int64>(session()->uid());
+    push_change(g, change, members);
+    store.dismiss(g.id);
+    ylib::json body;
+    body["group_id"] = static_cast<int64>(g.id);
+    reply_ok(body);
+}
+
+void GroupHandler::on_kick()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupRow g;
+    if (!load_group(static_cast<UserId>(json_i64("group_id")), g)) {
+        return;
+    }
+    const UserId target = static_cast<UserId>(json_i64("uid"));
+    if (target <= 0) {
+        reply_error(Err::BadParam, i18n::Msg::UidRequired);
+        return;
+    }
+    GroupStore store(*mysql());
+    const int my_role = store.member_role(g.id, session()->uid());
+    const int peer_role = store.member_role(g.id, target);
+    if (my_role < GroupRole::Admin) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupNoPermission);
+        return;
+    }
+    if (peer_role < 0) {
+        reply_error(Err::NotFound, i18n::Msg::NotGroupMember);
+        return;
+    }
+    if (peer_role == GroupRole::Owner || target == session()->uid()) {
+        reply_error(Err::Forbidden, i18n::Msg::CannotKickOwner);
+        return;
+    }
+    if (my_role == GroupRole::Admin && peer_role >= GroupRole::Admin) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupNoPermission);
+        return;
+    }
+    AccountStore users(*mysql());
+    UserRow u;
+    std::string nick;
+    if (users.find_by_id(target, u)) {
+        nick = display_name(u.nickname, u.username);
+    }
+    auto members = store.list_member_uids(g.id);
+    ylib::json sys;
+    sys["event"] = "kick";
+    sys["uid"] = static_cast<int64>(target);
+    sys["nickname"] = nick;
+    sys["by_uid"] = static_cast<int64>(session()->uid());
+    post_sys(store, g, sys, session()->uid());
+    store.remove_member(g.id, target);
+    store.find(g.id, g);
+    ylib::json change;
+    change["event"] = "kick";
+    change["uid"] = static_cast<int64>(target);
+    change["nickname"] = nick;
+    push_change(g, change, members);
+    ylib::json body;
+    fill_group(body, g, my_role, -1);
+    reply_ok(body);
+}
+
+void GroupHandler::on_set_admin()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupRow g;
+    if (!load_group(static_cast<UserId>(json_i64("group_id")), g)) {
+        return;
+    }
+    GroupStore store(*mysql());
+    if (store.member_role(g.id, session()->uid()) != GroupRole::Owner) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupNoPermission);
+        return;
+    }
+    const UserId uid = static_cast<UserId>(json_i64("uid"));
+    const int admin = static_cast<int>(json_i64("admin"));
+    const int peer_role = store.member_role(g.id, uid);
+    if (peer_role < 0) {
+        reply_error(Err::NotFound, i18n::Msg::NotGroupMember);
+        return;
+    }
+    if (peer_role == GroupRole::Owner) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupNoPermission);
+        return;
+    }
+    if (admin != 0 && store.admin_count(g.id) >= static_cast<int>(config().limits().group_max_admins) &&
+        peer_role != GroupRole::Admin) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupAdminFull);
+        return;
+    }
+    store.set_role(g.id, uid, admin != 0 ? GroupRole::Admin : GroupRole::Member);
+    AccountStore users(*mysql());
+    UserRow u;
+    std::string nick;
+    if (users.find_by_id(uid, u)) {
+        nick = display_name(u.nickname, u.username);
+    }
+    ylib::json sys;
+    sys["event"] = "role";
+    sys["uid"] = static_cast<int64>(uid);
+    sys["nickname"] = nick;
+    sys["admin"] = admin != 0 ? 1 : 0;
+    post_sys(store, g, sys, session()->uid());
+    ylib::json change;
+    change["event"] = "role";
+    change["uid"] = static_cast<int64>(uid);
+    change["nickname"] = nick;
+    change["admin"] = admin != 0 ? 1 : 0;
+    push_change(g, change, store.list_member_uids(g.id));
+    ylib::json body;
+    fill_group(body, g, GroupRole::Owner, -1);
+    reply_ok(body);
+}
+
+void GroupHandler::on_transfer()
+{
+    if (!require_login()) {
+        return;
+    }
+    GroupRow g;
+    if (!load_group(static_cast<UserId>(json_i64("group_id")), g)) {
+        return;
+    }
+    GroupStore store(*mysql());
+    if (store.member_role(g.id, session()->uid()) != GroupRole::Owner) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupNoPermission);
+        return;
+    }
+    const UserId uid = static_cast<UserId>(json_i64("uid"));
+    if (uid == session()->uid() || store.member_role(g.id, uid) < 0) {
+        reply_error(Err::BadParam, i18n::Msg::BadParam);
+        return;
+    }
+    store.transfer_owner(g.id, session()->uid(), uid);
+    store.find(g.id, g);
+    AccountStore users(*mysql());
+    UserRow u;
+    std::string nick;
+    if (users.find_by_id(uid, u)) {
+        nick = display_name(u.nickname, u.username);
+    }
+    ylib::json sys;
+    sys["event"] = "transfer";
+    sys["uid"] = static_cast<int64>(uid);
+    sys["nickname"] = nick;
+    sys["from_uid"] = static_cast<int64>(session()->uid());
+    post_sys(store, g, sys, session()->uid());
+    ylib::json change;
+    change["event"] = "transfer";
+    change["uid"] = static_cast<int64>(uid);
+    change["nickname"] = nick;
+    push_change(g, change, store.list_member_uids(g.id));
+    ylib::json body;
+    fill_group(body, g, GroupRole::Member, -1);
+    reply_ok(body);
+}
+
+void GroupHandler::on_request_list()
+{
+    if (!require_login()) {
+        return;
+    }
+    const UserId group_id = static_cast<UserId>(json_i64("group_id"));
+    GroupStore store(*mysql());
+    if (group_id > 0) {
+        GroupRow g;
+        if (!store.find(group_id, g)) {
+            reply_error(Err::NotFound, i18n::Msg::GroupNotFound);
+            return;
+        }
+        if (store.member_role(group_id, session()->uid()) < GroupRole::Admin) {
+            reply_error(Err::Forbidden, i18n::Msg::GroupNoPermission);
+            return;
+        }
+    }
+    ylib::json list;
+    list = ylib::json::type::array;
+    for (const auto& row : store.list_pending(session()->uid(), group_id)) {
+        ylib::json item;
+        item["id"] = static_cast<int64>(row.id);
+        item["group_id"] = static_cast<int64>(row.group_id);
+        item["name"] = row.group_name;
+        item["from_uid"] = static_cast<int64>(row.from_uid);
+        item["account"] = row.username;
+        item["nickname"] = display_name(row.nickname, row.username);
+        item["avatar_ver"] = row.avatar_ver;
+        item["message"] = row.message;
+        item["ts"] = static_cast<int64>(row.ts);
+        list.push_back(item);
+    }
+    ylib::json body;
+    body["list"] = list;
+    reply_ok(body);
+}
+
+void GroupHandler::on_handle_request()
+{
+    if (!require_login()) {
+        return;
+    }
+    const UserId req_id = static_cast<UserId>(json_i64("id"));
+    const int action = static_cast<int>(json_i64("action"));
+    if (req_id <= 0 || (action != 1 && action != 2)) {
+        reply_error(Err::BadParam, i18n::Msg::BadParam);
+        return;
+    }
+    GroupStore store(*mysql());
+    GroupRequestRow req;
+    if (!store.get_request(req_id, req) || req.status != 0) {
+        reply_error(Err::NotFound, i18n::Msg::UserNotFound);
+        return;
+    }
+    if (store.member_role(req.group_id, session()->uid()) < GroupRole::Admin) {
+        reply_error(Err::Forbidden, i18n::Msg::GroupNoPermission);
+        return;
+    }
+    GroupRow g;
+    if (!store.find(req.group_id, g)) {
+        reply_error(Err::NotFound, i18n::Msg::GroupNotFound);
+        return;
+    }
+    store.set_request_status(req_id, action == 1 ? 1 : 2);
+    if (action == 1) {
+        if (store.member_role(g.id, req.from_uid) < 0) {
+            if (static_cast<uint32_t>(g.member_count) >= config().limits().group_max_members) {
+                reply_error(Err::Forbidden, i18n::Msg::GroupFull);
+                return;
+            }
+            store.add_member(g.id, req.from_uid, GroupRole::Member);
+            store.find(g.id, g);
+            ylib::json sys;
+            sys["event"] = "join";
+            sys["uid"] = static_cast<int64>(req.from_uid);
+            sys["nickname"] = display_name(req.nickname, req.username);
+            post_sys(store, g, sys, session()->uid());
+            ylib::json change;
+            change["event"] = "join";
+            change["uid"] = static_cast<int64>(req.from_uid);
+            change["nickname"] = display_name(req.nickname, req.username);
+            push_change(g, change, store.list_member_uids(g.id));
+        }
+    }
+    ylib::json body;
+    body["id"] = static_cast<int64>(req_id);
+    body["action"] = action;
+    body["group_id"] = static_cast<int64>(g.id);
+    reply_ok(body);
+}
+
+}  // namespace im

+ 40 - 0
server/src/im/handlers/group_handler.h

@@ -0,0 +1,40 @@
+#pragma once
+
+#include "im/handler.h"
+
+namespace im {
+
+class GroupStore;
+struct GroupRow;
+
+class GroupHandler : public IHandler {
+protected:
+    void handle() override;
+
+private:
+    void on_create();
+    void on_info();
+    void on_members();
+    void on_list();
+    void on_update();
+    void on_invite();
+    void on_search();
+    void on_join();
+    void on_leave();
+    void on_dismiss();
+    void on_kick();
+    void on_set_admin();
+    void on_transfer();
+    void on_request_list();
+    void on_handle_request();
+
+    bool load_group(UserId group_id, GroupRow& out);
+    void fill_group(ylib::json& body, const GroupRow& g, int role, int pending);
+    void post_sys(GroupStore& store, const GroupRow& g, const ylib::json& content,
+                  UserId except_unread);
+    void push_change(const GroupRow& g, ylib::json body, const std::vector<UserId>& uids);
+    void fanout_chat(const GroupRow& g, UserId msg_id, UserId from_uid, int msg_type,
+                     const std::string& content, int64 ts, UserId except_uid);
+};
+
+}  // namespace im

+ 314 - 19
server/src/im/handlers/message_handler.cpp

@@ -10,6 +10,7 @@
 #include "common/protocol_def.h"
 #include "db/account_store.h"
 #include "db/file_store.h"
+#include "db/group_store.h"
 #include "db/message_store.h"
 #include "db/friend_store.h"
 #include "net/im_server.h"
@@ -89,6 +90,20 @@ std::string chat_preview(int msg_type, const std::string& content, size_t max_by
     }
 
     if (msg_type == MsgType::Card) {
+        std::string kind;
+        if (ok && meta.exist("kind")) {
+            kind = meta["kind"].to<std::string>();
+        }
+        if (kind == "group") {
+            std::string n;
+            if (ok && meta.exist("name")) {
+                n = meta["name"].to<std::string>();
+            }
+            if (!n.empty()) {
+                return preview_text(std::string("[群聊] ") + n, max_bytes);
+            }
+            return "[群聊]";
+        }
         std::string n;
         if (ok && meta.exist("nickname")) {
             n = meta["nickname"].to<std::string>();
@@ -101,6 +116,9 @@ std::string chat_preview(int msg_type, const std::string& content, size_t max_by
         }
         return "[名片]";
     }
+    if (msg_type == MsgType::Sys) {
+        return group_event_preview(content, max_bytes);
+    }
     return preview_text(content, max_bytes);
 }
 
@@ -163,6 +181,10 @@ void MessageHandler::on_chat()
     if (!require_login()) {
         return;
     }
+    if (json_i64("group_id") > 0) {
+        on_group_chat();
+        return;
+    }
 
     const auto& lim = config().limits();
     const UserId to_uid = static_cast<UserId>(json_i64("to_uid"));
@@ -244,6 +266,25 @@ void MessageHandler::on_chat()
             return;
         }
         const UserId card_uid = static_cast<UserId>(json_num(meta, "uid"));
+        std::string kind;
+        if (meta.exist("kind")) {
+            kind = meta["kind"].to<std::string>();
+        }
+        if (kind == "group") {
+            const UserId gid = static_cast<UserId>(json_num(meta, "group_id"));
+            GroupStore groups(*mysql());
+            GroupRow g;
+            if (gid <= 0 || !groups.find(gid, g) || groups.member_role(gid, from_uid) < 0) {
+                reply_error(Err::NotFound, i18n::Msg::GroupNotFound);
+                return;
+            }
+            ylib::json fixed;
+            fixed["kind"] = "group";
+            fixed["group_id"] = static_cast<int64>(g.id);
+            fixed["name"] = g.name;
+            fixed["member_count"] = g.member_count;
+            content = fixed.to_string();
+        } else {
         UserRow card;
         if (card_uid <= 0 || !users.find_by_id(card_uid, card)) {
             reply_error(Err::NotFound, i18n::Msg::UserNotFound);
@@ -256,6 +297,7 @@ void MessageHandler::on_chat()
         fixed["signature"] = card.signature;
         fixed["avatar_ver"] = card.avatar_ver;
         content = fixed.to_string();
+        }
     }
 
     const UserId msg_id = store.insert_message(from_uid, to_uid, msg_type, content);
@@ -272,20 +314,151 @@ void MessageHandler::on_chat()
     reply_ok(body);
 
     if (to_uid != from_uid) {
-        auto target = server().sessions().find_by_uid(to_uid);
-        if (target) {
-            ylib::json notify;
-            notify["msg_id"] = static_cast<int64>(msg_id);
-            notify["from_uid"] = static_cast<int64>(from_uid);
-            notify["from_account"] = session()->account();
-            notify["from_nickname"] = session()->nickname();
-            notify["to_uid"] = static_cast<int64>(to_uid);
-            notify["msg_type"] = msg_type;
-            notify["content"] = content;
-            notify["ts"] = ts;
-            push(target->connid(), Type::Message, Cmd::ChatNotify, notify);
+        ylib::json notify;
+        notify["msg_id"] = static_cast<int64>(msg_id);
+        notify["from_uid"] = static_cast<int64>(from_uid);
+        notify["from_account"] = session()->account();
+        notify["from_nickname"] = session()->nickname();
+        notify["to_uid"] = static_cast<int64>(to_uid);
+        notify["msg_type"] = msg_type;
+        notify["content"] = content;
+        notify["ts"] = ts;
+        push_uid(to_uid, Type::Message, Cmd::ChatNotify, notify);
+    }
+}
+
+void MessageHandler::on_group_chat()
+{
+    const auto& lim = config().limits();
+    const UserId group_id = static_cast<UserId>(json_i64("group_id"));
+    int msg_type = static_cast<int>(json_i64("msg_type", 1));
+    std::string content = json_str("content");
+    if (group_id <= 0 || content.empty()) {
+        reply_error(Err::BadParam, i18n::Msg::ToUidContentRequired);
+        return;
+    }
+    if (msg_type <= 0) {
+        msg_type = 1;
+    }
+    if (msg_type != MsgType::Text && msg_type != MsgType::Sticker &&
+        msg_type != MsgType::File && msg_type != MsgType::Card) {
+        msg_type = MsgType::Text;
+    }
+    if (content.size() > lim.content_max) {
+        reply_error(Err::BadParam, i18n::Msg::ContentTooLong);
+        return;
+    }
+
+    const UserId from_uid = session()->uid();
+    GroupStore groups(*mysql());
+    GroupRow g;
+    if (!groups.find(group_id, g) || groups.member_role(group_id, from_uid) < 0) {
+        reply_error(Err::Forbidden, i18n::Msg::NotGroupMember);
+        return;
+    }
+
+    AccountStore users(*mysql());
+    if (msg_type == MsgType::File) {
+        ylib::json meta;
+        if (!meta.parse(content)) {
+            reply_error(Err::BadParam, i18n::Msg::BadParam);
+            return;
+        }
+        const UserId file_id = static_cast<UserId>(json_num(meta, "file_id"));
+        FileStore files(*mysql());
+        FileRow file;
+        if (file_id <= 0 || !files.find(file_id, file)) {
+            reply_error(Err::NotFound, i18n::Msg::FileNotFound);
+            return;
+        }
+        std::string key = file.file_path;
+        if (key.empty() && third::cos::enabled(config().cos())) {
+            key = third::cos::file_key(config().cos(), file.id,
+                                       third::cos::object_ext(file.name, file.mime));
+        }
+        if (key.empty() || !third::cos::enabled(config().cos())) {
+            reply_error(Err::NotFound, i18n::Msg::FileNotFound);
+            return;
+        }
+        const auto head = third::cos::head_object(config().cos(), key);
+        if (!head.ok) {
+            reply_error(Err::NotFound, i18n::Msg::FileNotFound);
+            return;
+        }
+        ylib::json fixed;
+        fixed["file_id"] = static_cast<int64>(file.id);
+        fixed["name"] = file.name;
+        fixed["size"] = file.size;
+        fixed["mime"] = file.mime;
+        fixed["url"] = key;
+        content = fixed.to_string();
+    } else if (msg_type == MsgType::Card) {
+        ylib::json meta;
+        if (!meta.parse(content)) {
+            reply_error(Err::BadParam, i18n::Msg::BadParam);
+            return;
+        }
+        std::string kind;
+        if (meta.exist("kind")) {
+            kind = meta["kind"].to<std::string>();
+        }
+        if (kind == "group") {
+            const UserId gid = static_cast<UserId>(json_num(meta, "group_id"));
+            GroupRow cardg;
+            if (gid <= 0 || !groups.find(gid, cardg) || groups.member_role(gid, from_uid) < 0) {
+                reply_error(Err::NotFound, i18n::Msg::GroupNotFound);
+                return;
+            }
+            ylib::json fixed;
+            fixed["kind"] = "group";
+            fixed["group_id"] = static_cast<int64>(cardg.id);
+            fixed["name"] = cardg.name;
+            fixed["member_count"] = cardg.member_count;
+            content = fixed.to_string();
+        } else {
+            const UserId card_uid = static_cast<UserId>(json_num(meta, "uid"));
+            UserRow card;
+            if (card_uid <= 0 || !users.find_by_id(card_uid, card)) {
+                reply_error(Err::NotFound, i18n::Msg::UserNotFound);
+                return;
+            }
+            ylib::json fixed;
+            fixed["uid"] = static_cast<int64>(card.id);
+            fixed["account"] = card.username;
+            fixed["nickname"] = display_name(card.nickname, card.username);
+            fixed["signature"] = card.signature;
+            fixed["avatar_ver"] = card.avatar_ver;
+            content = fixed.to_string();
         }
     }
+
+    const UserId msg_id = groups.insert_message(group_id, from_uid, msg_type, content);
+    const std::string preview = chat_preview(msg_type, content, lim.preview_max);
+    groups.upsert_inbox_all(group_id, msg_id, preview, from_uid);
+
+    const int64 ts = ylib::time::now_msec();
+    ylib::json body;
+    body["msg_id"] = static_cast<int64>(msg_id);
+    body["ts"] = ts;
+    body["group_id"] = static_cast<int64>(group_id);
+    reply_ok(body);
+
+    ylib::json notify;
+    notify["msg_id"] = static_cast<int64>(msg_id);
+    notify["from_uid"] = static_cast<int64>(from_uid);
+    notify["from_account"] = session()->account();
+    notify["from_nickname"] = session()->nickname();
+    notify["group_id"] = static_cast<int64>(group_id);
+    notify["group_name"] = g.name;
+    notify["msg_type"] = msg_type;
+    notify["content"] = content;
+    notify["ts"] = ts;
+    for (UserId uid : groups.list_member_uids(group_id)) {
+        if (uid == from_uid) {
+            continue;
+        }
+        push_uid(uid, Type::Message, Cmd::ChatNotify, notify);
+    }
 }
 
 void MessageHandler::on_history()
@@ -294,10 +467,11 @@ void MessageHandler::on_history()
         return;
     }
     const auto& lim = config().limits();
+    const UserId group_id = static_cast<UserId>(json_i64("group_id"));
     const UserId peer_uid = static_cast<UserId>(json_i64("peer_uid"));
     const UserId last_id = static_cast<UserId>(json_i64("last_id", 0));
     int limit = static_cast<int>(json_i64("limit", lim.history_default));
-    if (peer_uid <= 0) {
+    if (group_id <= 0 && peer_uid <= 0) {
         reply_error(Err::BadParam, i18n::Msg::PeerUidRequired);
         return;
     }
@@ -308,6 +482,35 @@ void MessageHandler::on_history()
         limit = static_cast<int>(lim.history_max);
     }
 
+    if (group_id > 0) {
+        GroupStore groups(*mysql());
+        if (groups.member_role(group_id, session()->uid()) < 0) {
+            reply_error(Err::Forbidden, i18n::Msg::NotGroupMember);
+            return;
+        }
+        groups.clear_unread(session()->uid(), group_id);
+        const auto rows = groups.list_history(session()->uid(), group_id, last_id, limit);
+        ylib::json list;
+        list = ylib::json::type::array;
+        for (const auto& row : rows) {
+            ylib::json item;
+            item["msg_id"] = static_cast<int64>(row.id);
+            item["from_uid"] = static_cast<int64>(row.from_uid);
+            item["from_account"] = row.from_username;
+            item["from_nickname"] = row.from_nickname.empty() ? row.from_username : row.from_nickname;
+            item["group_id"] = static_cast<int64>(row.group_id);
+            item["msg_type"] = row.msg_type;
+            item["content"] = row.content;
+            item["ts"] = static_cast<int64>(row.ts);
+            list.push_back(item);
+        }
+        ylib::json body;
+        body["list"] = list;
+        body["group_id"] = static_cast<int64>(group_id);
+        reply_ok(body);
+        return;
+    }
+
     MessageStore store(*mysql());
     store.clear_unread(session()->uid(), peer_uid);
     const auto rows = store.list_history(session()->uid(), peer_uid, last_id, limit);
@@ -335,10 +538,18 @@ void MessageHandler::on_conv_list()
     }
     MessageStore store(*mysql());
     const auto rows = store.list_conversations(session()->uid());
-    ylib::json list;
-    list = ylib::json::type::array;
+    GroupStore groups(*mysql());
+    const auto grows = groups.list_inbox(session()->uid());
+
+    struct Item {
+        int64 ts = 0;
+        ylib::json json;
+    };
+    std::vector<Item> items;
+    items.reserve(rows.size() + grows.size());
     for (const auto& row : rows) {
         ylib::json item;
+        item["conv_type"] = ConvType::Direct;
         item["peer_uid"] = static_cast<int64>(row.peer_uid);
         item["account"] = row.username;
         item["nickname"] = row.remark.empty()
@@ -350,8 +561,31 @@ void MessageHandler::on_conv_list()
         item["preview"] = row.last_content;
         item["ts"] = static_cast<int64>(row.last_ts);
         item["unread"] = row.unread;
-        item["online"] = server().sessions().find_by_uid(row.peer_uid) ? 1 : 0;
-        list.push_back(item);
+        item["online"] = server().is_online(row.peer_uid) ? 1 : 0;
+        items.push_back({row.last_ts, std::move(item)});
+    }
+    for (const auto& row : grows) {
+        ylib::json item;
+        item["conv_type"] = ConvType::Group;
+        item["group_id"] = static_cast<int64>(row.group_id);
+        item["name"] = row.name;
+        item["nickname"] = row.name;
+        item["member_count"] = row.member_count;
+        item["join_mode"] = row.join_mode;
+        item["avatar_ver"] = row.avatar_ver;
+        item["last_msg_id"] = static_cast<int64>(row.last_msg_id);
+        item["preview"] = row.last_content;
+        item["ts"] = static_cast<int64>(row.last_ts);
+        item["unread"] = row.unread;
+        items.push_back({row.last_ts, std::move(item)});
+    }
+    std::sort(items.begin(), items.end(), [](const Item& a, const Item& b) {
+        return a.ts > b.ts;
+    });
+    ylib::json list;
+    list = ylib::json::type::array;
+    for (auto& it : items) {
+        list.push_back(it.json);
     }
     ylib::json body;
     body["list"] = list;
@@ -363,7 +597,20 @@ void MessageHandler::on_delete_conv()
     if (!require_login()) {
         return;
     }
+    const UserId group_id = static_cast<UserId>(json_i64("group_id"));
     const UserId peer_uid = static_cast<UserId>(json_i64("peer_uid"));
+    if (group_id > 0) {
+        GroupStore groups(*mysql());
+        if (groups.member_role(group_id, session()->uid()) < 0) {
+            reply_error(Err::Forbidden, i18n::Msg::NotGroupMember);
+            return;
+        }
+        groups.delete_inbox(session()->uid(), group_id);
+        ylib::json body;
+        body["group_id"] = static_cast<int64>(group_id);
+        reply_ok(body);
+        return;
+    }
     if (peer_uid <= 0) {
         reply_error(Err::BadParam, i18n::Msg::PeerUidRequired);
         return;
@@ -380,8 +627,9 @@ void MessageHandler::on_delete_msg()
     if (!require_login()) {
         return;
     }
+    const UserId group_id = static_cast<UserId>(json_i64("group_id"));
     const UserId peer_uid = static_cast<UserId>(json_i64("peer_uid"));
-    if (peer_uid <= 0) {
+    if (group_id <= 0 && peer_uid <= 0) {
         reply_error(Err::BadParam, i18n::Msg::PeerUidRequired);
         return;
     }
@@ -427,6 +675,39 @@ void MessageHandler::on_delete_msg()
     }
 
     const UserId uid = session()->uid();
+    if (group_id > 0) {
+        GroupStore groups(*mysql());
+        if (groups.member_role(group_id, uid) < 0) {
+            reply_error(Err::Forbidden, i18n::Msg::NotGroupMember);
+            return;
+        }
+        groups.delete_messages(uid, group_id, ids);
+        GroupMsgRow last;
+        ylib::json body;
+        body["group_id"] = static_cast<int64>(group_id);
+        if (groups.latest_visible(uid, group_id, last)) {
+            const std::string preview =
+                chat_preview(last.msg_type, last.content, config().limits().preview_max);
+            groups.set_inbox_last(uid, group_id, last.id, preview);
+            body["last_msg_id"] = static_cast<int64>(last.id);
+            body["preview"] = preview;
+            body["ts"] = static_cast<int64>(last.ts);
+        } else {
+            groups.set_inbox_last(uid, group_id, 0, "");
+            body["last_msg_id"] = 0;
+            body["preview"] = "";
+            body["ts"] = static_cast<int64>(0);
+        }
+        std::vector<int64> idnums;
+        idnums.reserve(ids.size());
+        for (UserId id : ids) {
+            idnums.push_back(static_cast<int64>(id));
+        }
+        body["ids"] = idnums;
+        reply_ok(body);
+        return;
+    }
+
     MessageStore store(*mysql());
     store.delete_messages(uid, peer_uid, ids);
 
@@ -460,15 +741,29 @@ void MessageHandler::on_set_unread()
     if (!require_login()) {
         return;
     }
+    const UserId group_id = static_cast<UserId>(json_i64("group_id"));
     const UserId peer_uid = static_cast<UserId>(json_i64("peer_uid"));
     int unread = static_cast<int>(json_i64("unread"));
-    if (peer_uid <= 0) {
+    if (group_id <= 0 && peer_uid <= 0) {
         reply_error(Err::BadParam, i18n::Msg::PeerUidRequired);
         return;
     }
     if (unread < 0) {
         unread = 0;
     }
+    if (group_id > 0) {
+        GroupStore groups(*mysql());
+        if (groups.member_role(group_id, session()->uid()) < 0) {
+            reply_error(Err::Forbidden, i18n::Msg::NotGroupMember);
+            return;
+        }
+        groups.set_unread(session()->uid(), group_id, unread);
+        ylib::json body;
+        body["group_id"] = static_cast<int64>(group_id);
+        body["unread"] = unread;
+        reply_ok(body);
+        return;
+    }
     MessageStore store(*mysql());
     store.set_unread(session()->uid(), peer_uid, unread);
     ylib::json body;

+ 1 - 0
server/src/im/handlers/message_handler.h

@@ -17,6 +17,7 @@ private:
     void on_put_file();
     void on_get_file();
     void on_delete_msg();
+    void on_group_chat();
 };
 
 }  // namespace im

+ 305 - 21
server/src/net/im_server.cpp

@@ -1,11 +1,15 @@
 #include "net/im_server.h"
 
 #include <exception>
+#include <memory>
 #include <string>
+#include <vector>
 
+#include "common/i18n.h"
 #include "common/logger.h"
 #include "common/protocol_def.h"
 #include "db/friend_store.h"
+#include "db/redis_service.h"
 #include "net/packet_log.h"
 #include "util/json.h"
 #include "HPSocket/HPSocket.h"
@@ -13,17 +17,35 @@
 namespace im {
 
 ImServer::ImServer(const AppConfig& cfg, SessionMgr& sessions, Dispatcher& dispatcher,
-                   MysqlService& mysql)
+                   MysqlService& mysql, RedisService* redis)
     : cfg_(cfg),
       sessions_(sessions),
       dispatcher_(dispatcher),
       mysql_(mysql),
+      redis_(redis),
       codec_(cfg.protocol().magic, cfg.protocol().max_packet)
 {
 }
 
+ImServer::~ImServer()
+{
+    stop();
+}
+
 bool ImServer::start()
 {
+    stopping_.store(false, std::memory_order_release);
+
+    if (redis_ != nullptr) {
+        redis_->set_message_handler([this](const std::string& payload) {
+            on_cluster_payload(payload);
+        });
+    }
+
+    if (!start_thread_pool()) {
+        return false;
+    }
+
     server_.on_accept([this](ylib::network::tcp::server*, uint64 connid) {
         on_accept(connid);
     });
@@ -35,27 +57,79 @@ bool ImServer::start()
         on_close(connid);
     });
 
+    configure_hp();
+
     ylib::AddressPort ap;
     ap.address = cfg_.server().host;
     ap.port = cfg_.server().port;
 
     if (!server_.start(ap, ylib::PUSH_DEFAULT)) {
         Logger::error("tcp listen failed: " + server_.last_error());
+        stop_thread_pool();
         return false;
     }
 
+    ITcpServer* hp = server_.getHP();
     Logger::info("tcp listen " + cfg_.server().host + ":" +
-                 std::to_string(cfg_.server().port));
+                 std::to_string(cfg_.server().port) +
+                 " io_workers=" +
+                 std::to_string(hp != nullptr ? hp->GetWorkerThreadCount() : 0));
     return true;
 }
 
 void ImServer::stop()
 {
+    const bool first = !stopping_.exchange(true, std::memory_order_acq_rel);
     if (server_.started()) {
         server_.close();
     }
+    stop_thread_pool();
     sessions_.clear();
-    Logger::info("tcp server stopped");
+    if (first) {
+        Logger::info("tcp server stopped");
+    }
+}
+
+void ImServer::configure_hp()
+{
+    ITcpServer* hp = server_.getHP();
+    if (hp == nullptr) {
+        return;
+    }
+    hp->SetMaxConnectionCount(cfg_.server().max_connections);
+    if (cfg_.server().worker_threads != 0) {
+        hp->SetWorkerThreadCount(static_cast<DWORD>(cfg_.server().worker_threads));
+    }
+}
+
+bool ImServer::start_thread_pool()
+{
+    thread_pool_ = HP_Create_ThreadPool();
+    if (thread_pool_ == nullptr) {
+        Logger::error("hp thread pool create failed");
+        return false;
+    }
+    if (!thread_pool_->Start(static_cast<DWORD>(cfg_.server().thread_pool),
+                             cfg_.server().thread_pool_queue, TRP_CALL_FAIL)) {
+        Logger::error("hp thread pool start failed");
+        HP_Destroy_ThreadPool(thread_pool_);
+        thread_pool_ = nullptr;
+        return false;
+    }
+    Logger::info("hp thread pool workers=" +
+                 std::to_string(thread_pool_->GetThreadCount()) +
+                 " queue_limit=" + std::to_string(cfg_.server().thread_pool_queue));
+    return true;
+}
+
+void ImServer::stop_thread_pool()
+{
+    if (thread_pool_ == nullptr) {
+        return;
+    }
+    thread_pool_->Stop(INFINITE);
+    HP_Destroy_ThreadPool(thread_pool_);
+    thread_pool_ = nullptr;
 }
 
 bool ImServer::send(ConnId connid, const Packet& packet)
@@ -66,11 +140,38 @@ bool ImServer::send(ConnId connid, const Packet& packet)
     return server_.send(connid, codec_.encode(out));
 }
 
+bool ImServer::send(const SessionPtr& session, const Packet& packet)
+{
+    if (!conn_matches(session)) {
+        return false;
+    }
+    return send(session->connid(), packet);
+}
+
 void ImServer::disconnect(ConnId connid)
 {
     server_.disConnect(connid);
 }
 
+bool ImServer::conn_matches(const SessionPtr& session) const
+{
+    if (!session || session->closed() || stopping_.load(std::memory_order_acquire)) {
+        return false;
+    }
+    if (!sessions_.is_live(session)) {
+        return false;
+    }
+    ITcpServer* hp = const_cast<ylib::network::tcp::server&>(server_).getHP();
+    if (hp == nullptr) {
+        return false;
+    }
+    PVOID extra = nullptr;
+    if (!hp->GetConnectionExtra(session->connid(), &extra) || extra != session.get()) {
+        return false;
+    }
+    return true;
+}
+
 void ImServer::notify_friend_presence(UserId uid, int online)
 {
     if (uid <= 0 || uid == kFileHelperUid) {
@@ -86,9 +187,7 @@ void ImServer::notify_friend_presence(UserId uid, int online)
             if (f.uid <= 0 || f.uid == kFileHelperUid) {
                 continue;
             }
-            if (auto peer = sessions_.find_by_uid(f.uid)) {
-                send(peer->connid(), pkt);
-            }
+            push_to_uid(f.uid, pkt);
         }
     } catch (const std::exception& e) {
         Logger::warn(std::string("presence notify uid=") + std::to_string(uid) + ": " + e.what());
@@ -97,18 +196,29 @@ void ImServer::notify_friend_presence(UserId uid, int online)
 
 void ImServer::on_accept(ConnId connid)
 {
+    if (stopping_.load(std::memory_order_acquire)) {
+        disconnect(connid);
+        return;
+    }
     auto session = std::make_shared<Session>(connid, remote_of(connid),
                                              cfg_.protocol().magic,
                                              cfg_.protocol().max_packet);
     sessions_.add(session);
+    ITcpServer* hp = server_.getHP();
+    if (hp != nullptr) {
+        hp->SetConnectionExtra(connid, session.get());
+    }
     Logger::info("accept conn=" + std::to_string(connid) + " remote=" + session->remote() +
                  " online=" + std::to_string(sessions_.size()));
 }
 
 void ImServer::on_recv(ConnId connid, const char* data, uint32_t len)
 {
+    if (stopping_.load(std::memory_order_acquire)) {
+        return;
+    }
     auto session = sessions_.get(connid);
-    if (!session) {
+    if (!session || session->closed()) {
         return;
     }
 
@@ -123,19 +233,15 @@ void ImServer::on_recv(ConnId connid, const char* data, uint32_t len)
 
     for (auto& pkt : packets) {
         log_recv(connid, pkt);
-        try {
-            HandlerContext ctx;
-            ctx.session = session;
-            ctx.server = this;
-            ctx.mysql = &mysql_;
-            ctx.config = &cfg_;
-            ctx.packet = std::move(pkt);
-            dispatcher_.dispatch(std::move(ctx));
-        } catch (const std::exception& e) {
-            Logger::error(std::string("dispatch exception conn=") +
-                          std::to_string(connid) + ": " + e.what());
-        } catch (...) {
-            Logger::error("dispatch unknown exception conn=" + std::to_string(connid));
+        bool start_task = false;
+        if (!session->enqueue(std::move(pkt), &start_task)) {
+            return;
+        }
+        if (start_task && !submit_session(session)) {
+            session->clear_running();
+            Logger::error("thread pool submit failed conn=" + std::to_string(connid));
+            disconnect(connid);
+            return;
         }
     }
 }
@@ -143,16 +249,86 @@ void ImServer::on_recv(ConnId connid, const char* data, uint32_t len)
 void ImServer::on_close(ConnId connid)
 {
     auto session = sessions_.get(connid);
+    if (session) {
+        session->mark_closed();
+    }
+    ITcpServer* hp = server_.getHP();
+    if (hp != nullptr) {
+        hp->SetConnectionExtra(connid, nullptr);
+    }
     const UserId uid = session ? session->uid() : 0;
     const std::string remote = session ? session->remote() : "";
     sessions_.remove(connid);
     if (uid > 0 && !sessions_.find_by_uid(uid)) {
-        notify_friend_presence(uid, 0);
+        unbind_cluster(uid);
+        if (!is_online(uid)) {
+            notify_friend_presence(uid, 0);
+        }
     }
     Logger::info("close conn=" + std::to_string(connid) + " remote=" + remote +
                  " online=" + std::to_string(sessions_.size()));
 }
 
+bool ImServer::submit_session(const SessionPtr& session)
+{
+    if (thread_pool_ == nullptr || stopping_.load(std::memory_order_acquire)) {
+        return false;
+    }
+    auto* task = new PoolTask{this, session};
+    if (!thread_pool_->Submit(reinterpret_cast<Fn_TaskProc>(&ImServer::on_pool_task), task)) {
+        delete task;
+        return false;
+    }
+    return true;
+}
+
+void ImServer::on_pool_task(void* arg)
+{
+    std::unique_ptr<PoolTask> task(static_cast<PoolTask*>(arg));
+    if (!task || task->server == nullptr) {
+        return;
+    }
+    try {
+        task->server->drain_session(task->session);
+    } catch (const std::exception& e) {
+        Logger::error(std::string("thread pool task exception: ") + e.what());
+    } catch (...) {
+        Logger::error("thread pool task unknown exception");
+    }
+}
+
+void ImServer::drain_session(const SessionPtr& session)
+{
+    if (!session) {
+        return;
+    }
+    while (true) {
+        Packet pkt;
+        if (!session->take_next(pkt)) {
+            return;
+        }
+        if (!conn_matches(session)) {
+            session->clear_running();
+            return;
+        }
+        try {
+            HandlerContext ctx;
+            ctx.session = session;
+            ctx.server = this;
+            ctx.mysql = &mysql_;
+            ctx.config = &cfg_;
+            ctx.packet = std::move(pkt);
+            dispatcher_.dispatch(std::move(ctx));
+        } catch (const std::exception& e) {
+            Logger::error(std::string("dispatch exception conn=") +
+                          std::to_string(session->connid()) + ": " + e.what());
+        } catch (...) {
+            Logger::error("dispatch unknown exception conn=" +
+                          std::to_string(session->connid()));
+        }
+    }
+}
+
 std::string ImServer::remote_of(ConnId connid) const
 {
     ITcpServer* hp = const_cast<ylib::network::tcp::server&>(server_).getHP();
@@ -168,4 +344,112 @@ std::string ImServer::remote_of(ConnId connid) const
     return std::string(addr) + ":" + std::to_string(port);
 }
 
+bool ImServer::is_online(UserId uid)
+{
+    if (uid <= 0) {
+        return false;
+    }
+    if (sessions_.find_by_uid(uid)) {
+        return true;
+    }
+    if (redis_ == nullptr || !redis_->enabled()) {
+        return false;
+    }
+    return !redis_->online_node(uid).empty();
+}
+
+bool ImServer::push_to_uid(UserId uid, const Packet& packet)
+{
+    if (uid <= 0) {
+        return false;
+    }
+    if (auto local = sessions_.find_by_uid(uid)) {
+        return send(local, packet);
+    }
+    if (redis_ == nullptr || !redis_->enabled()) {
+        return false;
+    }
+    const std::string node = redis_->online_node(uid);
+    if (node.empty() || node == redis_->node_id()) {
+        return false;
+    }
+    ylib::json msg;
+    msg["op"] = "send";
+    msg["uid"] = static_cast<int64>(uid);
+    msg["type"] = static_cast<int64>(packet.type);
+    msg["cmd"] = static_cast<int64>(packet.cmd);
+    msg["body"] = packet.body.to_string();
+    return redis_->publish_to_node(node, msg.to_string());
+}
+
+void ImServer::bind_cluster(UserId uid)
+{
+    if (redis_ == nullptr || !redis_->enabled() || uid <= 0) {
+        return;
+    }
+    const std::string old = redis_->bind_online(uid);
+    if (old.empty() || old == redis_->node_id()) {
+        return;
+    }
+    ylib::json msg;
+    msg["op"] = "kick";
+    msg["uid"] = static_cast<int64>(uid);
+    redis_->publish_to_node(old, msg.to_string());
+}
+
+void ImServer::unbind_cluster(UserId uid)
+{
+    if (redis_ == nullptr || !redis_->enabled() || uid <= 0) {
+        return;
+    }
+    redis_->unbind_online(uid);
+}
+
+void ImServer::kick_local(UserId uid)
+{
+    auto session = sessions_.find_by_uid(uid);
+    if (!session) {
+        return;
+    }
+    ylib::json body;
+    body["code"] = Err::Ok;
+    body["msg"] = i18n::text(i18n::Msg::Kicked, session->lang());
+    send(session, Packet::make(Type::System, Cmd::Kicked, 0, body.to_string()));
+    disconnect(session->connid());
+}
+
+void ImServer::on_cluster_payload(const std::string& payload)
+{
+    if (payload.empty() || payload == "{}") {
+        return;
+    }
+    ylib::json msg;
+    if (!msg.parse(payload) || !msg.exist("op")) {
+        return;
+    }
+    const std::string op = msg["op"].to<std::string>();
+    const UserId uid = msg.exist("uid") ? static_cast<UserId>(msg["uid"].to<int64>()) : 0;
+    if (uid <= 0) {
+        return;
+    }
+    if (op == "kick") {
+        kick_local(uid);
+        return;
+    }
+    if (op != "send") {
+        return;
+    }
+    auto session = sessions_.find_by_uid(uid);
+    if (!session) {
+        return;
+    }
+    Packet pkt;
+    pkt.type = static_cast<uint16_t>(msg.exist("type") ? msg["type"].to<int64>() : 0);
+    pkt.cmd = static_cast<uint16_t>(msg.exist("cmd") ? msg["cmd"].to<int64>() : 0);
+    if (msg.exist("body")) {
+        pkt.body = msg["body"].to<std::string>();
+    }
+    send(session, pkt);
+}
+
 }  // namespace im

+ 31 - 1
server/src/net/im_server.h

@@ -1,5 +1,8 @@
 #pragma once
 
+#include <atomic>
+#include <memory>
+
 #include "config/app_config.h"
 #include "db/mysql_service.h"
 #include "im/dispatcher.h"
@@ -8,17 +11,27 @@
 
 #include "net/tcp_server.h"
 
+class IHPThreadPool;
+
 namespace im {
 
+class RedisService;
+
 class ImServer {
 public:
     ImServer(const AppConfig& cfg, SessionMgr& sessions, Dispatcher& dispatcher,
-             MysqlService& mysql);
+             MysqlService& mysql, RedisService* redis);
+    ~ImServer();
 
     bool start();
     void stop();
     bool send(ConnId connid, const Packet& packet);
+    bool send(const SessionPtr& session, const Packet& packet);
     void disconnect(ConnId connid);
+    bool is_online(UserId uid);
+    bool push_to_uid(UserId uid, const Packet& packet);
+    void bind_cluster(UserId uid);
+    void unbind_cluster(UserId uid);
     // 向该用户的在线好友推送 PresenceNotify。online=1 上线,0 离线。
     // 踢号换连时旧连接 uid 已清,on_close 不会误推离线。
     void notify_friend_presence(UserId uid, int online);
@@ -27,17 +40,34 @@ public:
     MysqlService& mysql() { return mysql_; }
 
 private:
+    struct PoolTask {
+        ImServer* server = nullptr;
+        SessionPtr session;
+    };
+
     void on_accept(ConnId connid);
     void on_recv(ConnId connid, const char* data, uint32_t len);
     void on_close(ConnId connid);
     std::string remote_of(ConnId connid) const;
+    void configure_hp();
+    bool start_thread_pool();
+    void stop_thread_pool();
+    bool submit_session(const SessionPtr& session);
+    void drain_session(const SessionPtr& session);
+    bool conn_matches(const SessionPtr& session) const;
+    void on_cluster_payload(const std::string& payload);
+    void kick_local(UserId uid);
+    static void on_pool_task(void* arg);
 
     const AppConfig& cfg_;
     SessionMgr& sessions_;
     Dispatcher& dispatcher_;
     MysqlService& mysql_;
+    RedisService* redis_ = nullptr;
     PacketCodec codec_;
     ylib::network::tcp::server server_;
+    IHPThreadPool* thread_pool_ = nullptr;
+    std::atomic<bool> stopping_{false};
 };
 
 }  // namespace im

+ 20 - 5
server/src/net/protocol.cpp

@@ -37,14 +37,27 @@ ylib::buffer PacketCodec::encode(const Packet& packet) const
     return out;
 }
 
+void PacketCodec::compact()
+{
+    if (offset_ == 0) {
+        return;
+    }
+    if (offset_ >= buffer_.size()) {
+        buffer_.clear();
+    } else {
+        buffer_ = buffer_.sub(offset_);
+    }
+    offset_ = 0;
+}
+
 DecodeStatus PacketCodec::feed(const char* data, uint32_t len, std::vector<Packet>& out)
 {
     if (len > 0) {
         buffer_.append(data, len);
     }
 
-    while (buffer_.size() >= kHeaderSize) {
-        const char* p = buffer_.data();
+    while (buffer_.size() - offset_ >= kHeaderSize) {
+        const char* p = buffer_.data() + offset_;
         const uint16_t magic = read_u16_be(p);
         if (magic != magic_) {
             return DecodeStatus::BadMagic;
@@ -54,8 +67,9 @@ DecodeStatus PacketCodec::feed(const char* data, uint32_t len, std::vector<Packe
             return DecodeStatus::TooLarge;
         }
         const uint32_t total = kHeaderSize + body_len;
-        if (buffer_.size() < total) {
-            return DecodeStatus::NeedMore;
+        if (buffer_.size() - offset_ < total) {
+            compact();
+            return out.empty() ? DecodeStatus::NeedMore : DecodeStatus::Ok;
         }
 
         Packet pkt;
@@ -68,9 +82,10 @@ DecodeStatus PacketCodec::feed(const char* data, uint32_t len, std::vector<Packe
             pkt.body.set(p + kHeaderSize, body_len);
         }
         out.emplace_back(std::move(pkt));
-        buffer_ = buffer_.sub(total);
+        offset_ += total;
     }
 
+    compact();
     return out.empty() ? DecodeStatus::NeedMore : DecodeStatus::Ok;
 }
 

+ 4 - 1
server/src/net/protocol.h

@@ -51,9 +51,12 @@ public:
     DecodeStatus feed(const char* data, uint32_t len, std::vector<Packet>& out);
 
 private:
+    void compact();
+
     uint16_t magic_;
     uint32_t max_packet_;
-    ylib::buffer buffer_;  // 未消费的半包数据
+    ylib::buffer buffer_;  // 粘包缓冲:已消费前缀 + 未完成半包,跟随连接复用容量
+    size_t offset_ = 0;    // 已切出完整包的字节数,compact() 时丢掉
 };
 
 }  // namespace im

+ 70 - 1
server/src/net/session.cpp

@@ -7,16 +7,85 @@ Session::Session(ConnId connid, std::string remote, uint16_t magic, uint32_t max
 {
 }
 
+std::string Session::account() const
+{
+    std::lock_guard<std::mutex> lock(profile_mu_);
+    return account_;
+}
+
+std::string Session::nickname() const
+{
+    std::lock_guard<std::mutex> lock(profile_mu_);
+    return nickname_;
+}
+
+void Session::set_nickname(std::string nickname)
+{
+    std::lock_guard<std::mutex> lock(profile_mu_);
+    nickname_ = std::move(nickname);
+}
+
 void Session::set_profile(UserId uid, std::string account, std::string nickname)
 {
-    uid_ = uid;
+    std::lock_guard<std::mutex> lock(profile_mu_);
+    uid_.store(uid, std::memory_order_release);
     account_ = std::move(account);
     nickname_ = std::move(nickname);
 }
 
+void Session::mark_closed()
+{
+    closed_.store(true, std::memory_order_release);
+    std::lock_guard<std::mutex> lock(queue_mu_);
+    std::queue<Packet> empty;
+    pending_.swap(empty);
+}
+
 DecodeStatus Session::feed(const char* data, uint32_t len, std::vector<Packet>& out)
 {
+    std::lock_guard<std::mutex> lock(codec_mu_);
     return codec_.feed(data, len, out);
 }
 
+bool Session::enqueue(Packet packet, bool* start_task)
+{
+    if (start_task != nullptr) {
+        *start_task = false;
+    }
+    std::lock_guard<std::mutex> lock(queue_mu_);
+    if (closed_.load(std::memory_order_acquire)) {
+        return false;
+    }
+    pending_.push(std::move(packet));
+    if (!running_) {
+        running_ = true;
+        if (start_task != nullptr) {
+            *start_task = true;
+        }
+    }
+    return true;
+}
+
+bool Session::take_next(Packet& out)
+{
+    std::lock_guard<std::mutex> lock(queue_mu_);
+    if (closed_.load(std::memory_order_acquire) || pending_.empty()) {
+        running_ = false;
+        if (closed_.load(std::memory_order_acquire)) {
+            std::queue<Packet> empty;
+            pending_.swap(empty);
+        }
+        return false;
+    }
+    out = std::move(pending_.front());
+    pending_.pop();
+    return true;
+}
+
+void Session::clear_running()
+{
+    std::lock_guard<std::mutex> lock(queue_mu_);
+    running_ = false;
+}
+
 }  // namespace im

+ 35 - 10
server/src/net/session.h

@@ -1,7 +1,10 @@
 #pragma once
 
+#include <atomic>
 #include <cstdint>
 #include <memory>
+#include <mutex>
+#include <queue>
 #include <string>
 #include <vector>
 
@@ -16,26 +19,48 @@ public:
     Session(ConnId connid, std::string remote, uint16_t magic, uint32_t max_packet);
 
     ConnId connid() const { return connid_; }
-    UserId uid() const { return uid_; }
-    void set_uid(UserId uid) { uid_ = uid; }
+    UserId uid() const { return uid_.load(std::memory_order_acquire); }
+    void set_uid(UserId uid) { uid_.store(uid, std::memory_order_release); }
     const std::string& remote() const { return remote_; }
-    const std::string& account() const { return account_; }
-    const std::string& nickname() const { return nickname_; }
-    void set_nickname(std::string nickname) { nickname_ = std::move(nickname); }
+    std::string account() const;
+    std::string nickname() const;
+    void set_nickname(std::string nickname);
     void set_profile(UserId uid, std::string account, std::string nickname);
 
-    i18n::Lang lang() const { return lang_; }
-    void set_lang(i18n::Lang lang) { lang_ = lang; }
+    i18n::Lang lang() const
+    {
+        return static_cast<i18n::Lang>(lang_.load(std::memory_order_acquire));
+    }
+    void set_lang(i18n::Lang lang)
+    {
+        lang_.store(static_cast<int>(lang), std::memory_order_release);
+    }
+
+    bool closed() const { return closed_.load(std::memory_order_acquire); }
+    void mark_closed();
 
     DecodeStatus feed(const char* data, uint32_t len, std::vector<Packet>& out);
 
+    // 同一连接的业务包串行入队。start_task=true 时需向线程池提交 drain。
+    bool enqueue(Packet packet, bool* start_task);
+    // 取出下一包;无包或已关闭时返回 false,并释放 running 标记。
+    bool take_next(Packet& out);
+    // Submit 失败时收回 running,避免后续包永远得不到调度。
+    void clear_running();
+
 private:
-    ConnId connid_ = 0;
-    UserId uid_ = 0;
+    const ConnId connid_ = 0;
+    std::atomic<UserId> uid_{0};
+    std::atomic<int> lang_{static_cast<int>(i18n::Lang::Zh)};
+    std::atomic<bool> closed_{false};
     std::string remote_;
     std::string account_;
     std::string nickname_;
-    i18n::Lang lang_ = i18n::Lang::Zh;
+    mutable std::mutex profile_mu_;
+    std::mutex codec_mu_;
+    std::mutex queue_mu_;
+    std::queue<Packet> pending_;
+    bool running_ = false;
     PacketCodec codec_;
 };
 

+ 20 - 2
server/src/net/session_mgr.cpp

@@ -31,7 +31,7 @@ SessionPtr SessionMgr::get(ConnId connid) const
 {
     std::lock_guard<std::mutex> lock(mutex_);
     auto it = by_conn_.find(connid);
-    if (it == by_conn_.end()) {
+    if (it == by_conn_.end() || it->second->closed()) {
         return nullptr;
     }
     return it->second;
@@ -48,14 +48,27 @@ SessionPtr SessionMgr::find_by_uid(UserId uid) const
     if (it == by_conn_.end()) {
         return nullptr;
     }
+    if (it->second->closed()) {
+        return nullptr;
+    }
     return it->second;
 }
 
+bool SessionMgr::is_live(const SessionPtr& session) const
+{
+    if (!session || session->closed()) {
+        return false;
+    }
+    std::lock_guard<std::mutex> lock(mutex_);
+    auto it = by_conn_.find(session->connid());
+    return it != by_conn_.end() && it->second == session && !it->second->closed();
+}
+
 ConnId SessionMgr::bind_uid(ConnId connid, UserId uid)
 {
     std::lock_guard<std::mutex> lock(mutex_);
     auto it = by_conn_.find(connid);
-    if (it == by_conn_.end()) {
+    if (it == by_conn_.end() || it->second->closed()) {
         return 0;
     }
 
@@ -92,6 +105,11 @@ size_t SessionMgr::size() const
 void SessionMgr::clear()
 {
     std::lock_guard<std::mutex> lock(mutex_);
+    for (auto& kv : by_conn_) {
+        if (kv.second) {
+            kv.second->mark_closed();
+        }
+    }
     by_conn_.clear();
     by_uid_.clear();
 }

+ 2 - 0
server/src/net/session_mgr.h

@@ -13,6 +13,8 @@ public:
     void remove(ConnId connid);
     SessionPtr get(ConnId connid) const;
     SessionPtr find_by_uid(UserId uid) const;
+    // True if this Session object is still the live mapping for its connid.
+    bool is_live(const SessionPtr& session) const;
     // Bind uid to this connection. Returns the previous connection of the same uid, or 0.
     ConnId bind_uid(ConnId connid, UserId uid);
     size_t size() const;

+ 5 - 0
server/src/third/cos.cpp

@@ -674,6 +674,11 @@ std::string avatar_key(const CosConfig& cfg, UserId uid)
     return join_key(resolved(cfg).prefix, "avatars/" + std::to_string(uid) + ".jpg");
 }
 
+std::string group_avatar_key(const CosConfig& cfg, UserId group_id)
+{
+    return join_key(resolved(cfg).prefix, "avatars/g/" + std::to_string(group_id) + ".jpg");
+}
+
 std::string file_key(const CosConfig& cfg, UserId file_id, const std::string& ext)
 {
     std::string e = ext;

+ 1 - 0
server/src/third/cos.h

@@ -40,6 +40,7 @@ bool enabled(const CosConfig& cfg);
 
 std::string object_ext(const std::string& name, const std::string& mime);
 std::string avatar_key(const CosConfig& cfg, UserId uid);
+std::string group_avatar_key(const CosConfig& cfg, UserId group_id);
 std::string file_key(const CosConfig& cfg, UserId file_id, const std::string& ext = {});
 std::string sticker_key(const CosConfig& cfg, const std::string& rel_path);
 std::string favorite_key(const CosConfig& cfg, UserId uid, UserId file_id,