本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:本文详细介绍如何使用C#编程语言实现QQ登录、获取好友列表和群列表,以及QQ消息的群发功能。项目基于QQ开放平台API,利用HTTP请求与JSON数据交互,涵盖用户授权、access_token获取、好友/群组信息解析及消息发送等核心流程。压缩包中的“QQRobotForm”源码很可能为一个Windows Forms应用程序,提供可视化操作界面,集成API调用、数据解析与UI更新逻辑。通过本项目实践,开发者可深入掌握C#网络编程、API接口调用与社交软件自动化技术,提升在信息推送和自动化处理场景下的开发能力。

QQ消息群发系统开发实战:从零构建高可用C#桌面应用

你有没有试过给几十个朋友同时发一条节日祝福?手动一个一个点,复制粘贴内容,等发完可能人家都已经吃完年夜饭了。🤯 更别说那些需要定期通知的运营人员、客服团队,甚至是企业内部的信息同步……效率低不说,还容易出错。

别急!今天我们就来干一票大的——用 C# + Windows Forms + QQ OAuth2.0 ,亲手打造一个全自动、可扩展、带UI界面的 QQ消息群发助手 !🚀 不仅能发文本,还能传图片、支持模板变量替换,甚至有进度条和失败重试机制,真正实现“一键群发,稳如老狗”。

准备好了吗?Let’s go!


🛠️ 从最基础开始:C#语法与面向对象设计

咱们不整虚的,先打地基。这个项目会大量使用C#的核心特性,尤其是 面向对象编程(OOP) 。如果你刚入门或者有点生疏,别担心,我会带你一步步走通。

程序入口在哪? Main 方法了解一下 👇

每个C#程序都从 Main 方法启动,就像游戏的出生点一样:

using System;

namespace QqMessageSender
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("QQ消息群发系统启动中...");
            // 后续逻辑将在此集成
        }
    }
}

这段代码看着简单,但麻雀虽小五脏俱全:

  • namespace QqMessageSender :命名空间,用来组织类,防止名字冲突(比如两个项目都有叫 User 的类)。
  • class Program :主类,封装了程序入口。
  • static void Main() :静态方法,JIT编译器找到它就开始执行啦!
  • Console.WriteLine() :控制台输出,调试神器,上线前记得删掉 or 改成日志记录哈 😅

💡 小提示:虽然我们现在写的是桌面应用,但一开始可以在控制台里测试核心逻辑,快速验证想法,避免被UI拖慢节奏。

数据类型怎么选?性能和安全都要兼顾!

在实际开发中,合理选择数据类型不仅能提升性能,还能减少内存占用。比如:

int userId = 123456789;           // 值类型:存储QQ号码 ✅
string nickname = "Tester";        // 引用类型:用户昵称 ✅
bool isLoggedIn = false;           // 登录状态标志 ✅
DateTime loginTime;               // 时间戳记录 ✅

这里有几个关键点你要注意:

类型 特性 使用建议
int / long 值类型,栈上分配,速度快 QQ号超过 int.MaxValue (约21亿),推荐用 long
string 引用类型,不可变 频繁拼接建议用 StringBuilder
bool 占1字节,不是布尔代数里的0/1 别拿它当整数运算!
DateTime 结构体,值语义 记录时间戳首选

那问题来了:这些变量放在哪?什么时候能访问?

这就涉及到 作用域 的概念了。局部变量只能在定义它的方法内使用;而类成员变量则在整个类中可见。举个例子:

public class LoginManager
{
    private string _accessToken;  // 私有字段,保护敏感信息 🔐

    public void SetToken(string token)
    {
        _accessToken = token;     // 方法可以访问私有成员
    }

    public string GetToken() => _accessToken; // ❌ 危险!不要这样暴露token!
}

看到没?我们用了 private 来隐藏 _accessToken ,这就是 封装 的第一步。但上面那个 GetToken() 方法其实是反模式——你不小心就把令牌暴露出去了!😱

正确的做法是提供有限的操作接口,而不是直接返回原始数据。


控制流程不能少:条件判断与循环

程序嘛,总得做些“如果…就…”的事情对吧? if-else switch 是你的老朋友了:

if (isLoggedIn)
{
    Console.WriteLine("用户已登录,准备获取好友列表...");
}
else
{
    Console.WriteLine("请先完成QQ授权登录。");
}

还有循环操作,比如遍历所有好友发送消息:

foreach (var friend in friendsList)
{
    await SendMessageAsync(friend.QqNumber, messageContent);
}

不过更高级的玩法是 方法重载(Overloading) ,它让你可以用同一个名字调不同的逻辑:

public void SendMessage(string toUser, string content)
{
    Console.WriteLine($"发送消息给 {toUser}: {content}");
}

public void SendMessage(string toGroup, string content, bool isRichText)
{
    var type = isRichText ? "富文本" : "纯文本";
    Console.WriteLine($"向群组 {toGroup} 发送{type}消息:{content}");
}

看出来了吗?同样是 SendMessage ,参数不同,行为也不同。这种设计让API更直观,调用者不用记一堆奇怪的名字。


面向对象三大法宝:封装、继承、多态

封装:把细节藏起来,只留必要接口

还记得前面那个 _accessToken 吗?我们再来升级一下:

public class UserInfo
{
    public string Nickname { get; private set; }  // 外部只能读
    public long QQId { get; private set; }       // 构造函数初始化后就不能改

    public UserInfo(long qqId, string nickname)
    {
        QQId = qqId;
        Nickname = nickname ?? throw new ArgumentNullException(nameof(nickname));
    }

    public override string ToString()
    {
        return $"[{QQId}] {Nickname}";
    }
}

亮点解析:
- 属性设为 private set ,保证一旦创建就不能随意修改。
- 构造函数加空值检查,防患于未然。
- 重写 ToString() ,方便调试输出,比如日志打印时自动调用。

继承:代码复用的好帮手

假设我们要处理不同类型的消息,比如普通文本、图片、语音……这时候就可以抽象出一个基类:

public class Message
{
    public string Content { get; set; }
    public DateTime Timestamp { get; } = DateTime.Now;
}

public class ImageMessage : Message
{
    public string ImageUrl { get; set; }
    public int SizeInKB { get; set; }
}

子类 ImageMessage 自动拥有父类的所有属性,还能添加自己的特色字段。以后你可以统一按 Message 类型处理,又能根据具体类型做特殊操作。

多态:运行时决定行为,灵活性爆棚!

最酷的部分来了——多态!它可以让我们在不知道具体类型的情况下,调用正确的方法。

public class Sender
{
    public virtual void Send(Message msg)
    {
        Console.WriteLine($"发送普通消息: {msg.Content}");
    }
}

public class GroupSender : Sender
{
    public override void Send(Message msg)
    {
        Console.WriteLine($"【群发】正在广播消息: {msg.Content}");
    }
}

然后这么用:

Sender sender = new GroupSender();  // 基类引用指向子类实例
sender.Send(new Message { Content = "Hello Everyone!" });
// 输出:【群发】正在广播消息: Hello Everyone!

看到了吗?虽然是 Sender 类型的变量,但实际执行的是 GroupSender 的方法!这叫 动态绑定 ,也是很多框架(比如ASP.NET MVC)实现路由分发的基础。


错误怎么办?异常处理必须到位!

网络请求谁能保证100%成功?断网、超时、服务器崩了都是家常便饭。所以健壮的程序一定要会“自救”——也就是异常处理。

try
{
    var result = 10 / int.Parse("0");  // 可能引发 DivideByZeroException
}
catch (DivideByZeroException ex)
{
    Console.WriteLine("禁止除以零:" + ex.Message);
}
catch (Exception ex)
{
    Console.WriteLine("未知异常:" + ex.Message);
}
finally
{
    Console.WriteLine("清理资源...");
}

记住这个结构:
- try :放可能出错的代码。
- catch :捕获特定异常,给出友好提示或补救措施。
- finally :不管怎样都会执行,适合释放文件句柄、关闭数据库连接等。

特别是在调用QQ API的时候,一定要包裹 try-catch ,否则一个小错误就能让你整个程序崩溃。


跨模块通信靠什么?委托与事件机制安排上!

想象一下:用户点击“登录”按钮 → 浏览器跳转到QQ授权页 → 用户同意 → 回调回来 → 主界面刷新状态。

这一连串动作是怎么串联起来的?答案就是—— 事件驱动模型

public delegate void LoginEventHandler(object sender, string openId);

public class AuthManager
{
    public event LoginEventHandler OnLoginSuccess;

    protected virtual void OnLoginSucceeded(string openId)
    {
        OnLoginSuccess?.Invoke(this, openId);  // 安全校验后触发事件
    }

    public void SimulateLogin()
    {
        // 模拟认证成功
        OnLoginSucceeded("OPEN123456789");
    }
}

外面的人可以订阅这个事件:

authManager.OnLoginSuccess += (sender, openId) =>
{
    Console.WriteLine($"🎉 登录成功!OpenID: {openId}");
    UpdateUiState(isLoggedIn: true);
};

是不是很像微信公众号的关注机制?你关注我,我一有更新你就收到通知。这套模式在UI开发中无处不在,后面我们会用它来解耦登录模块和主界面。


🔐 第二步:搞定QQ第三方登录 —— OAuth2.0实战详解

现在轮到真正的挑战了:如何让用户通过QQ账号登录我们的应用?

别怕,腾讯开放平台已经为我们准备好了标准的 OAuth2.0 授权码模式(Authorization Code Flow) ,这是目前最安全的第三方登录方案之一。

整个流程大概是这样的:

  1. 用户点击“登录QQ”
  2. 应用跳转到QQ授权页面
  3. 用户输入密码并确认授权
  4. QQ服务器回调你的地址,并带上一个临时的 code
  5. 你的后台拿着 code + AppSecret 换取 access_token
  6. 拿到 access_token 就可以调用各种API啦!

听起来复杂?其实只要抓住几个核心参数就行👇

先去注册开发者账号,拿到AppID和AppKey

第一步当然是去 腾讯开放平台 注册一个账号,然后创建应用。

你会得到两个至关重要的东西:

字段 说明
AppID 公开的应用标识符,相当于身份证号
AppKey 私密密钥,千万不能泄露!否则别人能冒充你调接口

示例配置如下:

public static class QQConfig
{
    public const string AppId = "123456789";           // 替换为真实AppID
    public const string AppKey = "abcdefg123456789";   // 替换为真实AppKey
    public const string RedirectUri = "https://yourdomain.com/callback"; // 回调地址
}

⚠️ 注意:千万别把 AppKey 写死在代码里!尤其是在GitHub上开源的话,等于把大门钥匙挂在门口……


安全存储策略:加密+配置分离才是王道

正经项目都应该做到“配置与代码分离”,并且敏感信息要加密存储。

我们可以用Windows自带的 DPAPI(Data Protection API) 来加密保存 AppKey

using System.Security.Cryptography;

public class SecureConfig
{
    private const string ConfigFile = "qq_config.json";
    private const string Entropy = "QQ_OAuth_Salt_2024";  // 加盐防破解

    public string AppId { get; set; }
    public byte[] EncryptedAppKey { get; set; }

    public void Save()
    {
        var plainKey = Encoding.UTF8.GetBytes(QQConfig.AppKey);
        var encrypted = ProtectedData.Protect(
            plainKey,
            Encoding.UTF8.GetBytes(Entropy),
            DataProtectionScope.CurrentUser
        );

        var config = new SecureConfig
        {
            AppId = QQConfig.AppId,
            EncryptedAppKey = encrypted
        };

        File.WriteAllText(ConfigFile, JsonConvert.SerializeObject(config, Formatting.Indented));
    }

    public static SecureConfig Load()
    {
        if (!File.Exists(ConfigFile))
            throw new FileNotFoundException("配置文件未找到,请先初始化。");

        var json = File.ReadAllText(ConfigFile);
        var config = JsonConvert.DeserializeObject<SecureConfig>(json);

        var decrypted = ProtectedData.Unprotect(
            config.EncryptedAppKey,
            Encoding.UTF8.GetBytes(Entropy),
            DataProtectionScope.CurrentUser
        );

        QQConfig.AppKey = Encoding.UTF8.GetString(decrypted);
        return config;
    }
}

📌 关键技术点:
- ProtectedData.Protect() :基于当前用户的加密,其他账户无法解密。
- Entropy :额外加盐,提高破解难度。
- JSON序列化:非敏感信息明文保存,便于查看。

这样一来,即使别人拿到了你的程序文件,没有登录这台电脑的权限,他也解不开 AppKey

sequenceDiagram
    participant Dev as 开发者
    participant FS as 文件系统
    participant Crypto as DPAPI加密模块

    Dev->>Crypto: 输入明文AppKey
    Crypto-->>Dev: 返回加密字节流
    Dev->>FS: 写入JSON配置文件(含加密数据)
    Note right of Dev: 配置保存完成

    Dev->>FS: 读取配置文件
    FS-->>Dev: 返回加密数据
    Dev->>Crypto: 请求解密
    Crypto-->>Dev: 输出原始AppKey

回调URL设置技巧:本地也能接收授权码!

很多人卡在这一步:我是个桌面程序,又没有Web服务器,怎么接收QQ返回的 code

答案是: 自己起一个微型HTTP服务监听本地端口!

var listener = new HttpListener();
listener.Prefixes.Add("http://localhost:8080/callback/");
listener.Start();

Console.WriteLine("等待用户授权...");
var context = await listener.GetContextAsync(); // 异步等待请求

string code = context.Request.QueryString["code"];
string state = context.Request.QueryString["state"];

// 响应浏览器
var response = context.Response;
var html = "<h1>授权成功!您可以关闭此窗口。</h1>";
var buffer = Encoding.UTF8.GetBytes(html);
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
response.Close();

if (!string.IsNullOrEmpty(code))
{
    Console.WriteLine($"获取授权码: {code}");
    // 继续下一步:用code换取access_token
}

💡 实践提示:
- 回调地址必须在腾讯开放平台提前注册,格式要完全一致(协议、域名、端口、路径)。
- 可以使用自定义协议(如 myapp://callback ),但需要注册系统协议,稍微麻烦一点。
- 推荐使用 HttpListener 监听 http://localhost:port/callback ,简单高效。

另外别忘了加 state 参数防CSRF攻击!生成一个随机字符串,在发起请求和回调时比对是否一致即可。


🌐 HTTP请求核心技术:HttpClient vs WebClient

接下来我们要频繁调用QQ Open API,这就离不开HTTP客户端。

在.NET世界里,有两个主要选手: HttpClient WebClient 。该选谁?

一句话总结: 新项目一律选 HttpClient ,别回头!

为什么?

特性 HttpClient WebClient
是否支持 async/await ✅ 完全支持 ⚠️ 事件驱动,难维护
性能与资源管理 ✅ 连接池复用 ❌ 频繁创建会导致Socket泄漏
可扩展性 ✅ 支持中间件、Handler定制 ❌ 几乎不可扩展
推荐程度 ✅✅✅ 强烈推荐 ⚠️ 仅用于脚本级任务

来看一个典型的异步请求示例:

public class ApiService
{
    private static readonly HttpClient client = new HttpClient();

    public async Task<string> GetUserInfoAsync(string url)
    {
        try
        {
            HttpResponseMessage response = await client.GetAsync(url);
            response.EnsureSuccessStatusCode();
            string responseBody = await response.Content.ReadAsStringAsync();
            return responseBody;
        }
        catch (HttpRequestException e)
        {
            Console.WriteLine($"请求失败: {e.Message}");
            throw;
        }
    }
}

重点注意:
- static readonly HttpClient client :共享单例,避免频繁创建导致端口耗尽。
- EnsureSuccessStatusCode() :自动检测非2xx状态码并抛异常。
- async/await :非阻塞IO,UI不会卡顿。

相比之下, WebClient 的事件回调风格简直让人抓狂:

var webClient = new WebClient();
webClient.DownloadStringCompleted += (s, e) =>
{
    if (e.Error == null)
        Console.WriteLine(e.Result);
    else
        Console.WriteLine("下载失败:" + e.Error.Message);
};
webClient.DownloadStringAsync(new Uri("https://api.example.com/user"));

嵌套多了就是“回调地狱”,调试起来头皮发麻。

sequenceDiagram
    participant Client as 客户端代码
    participant HTTP as HttpClient
    participant Server as 远程API服务器

    Client->>HTTP: await GetAsync(url)
    HTTP->>Server: 发送HTTP GET请求
    Server-->>HTTP: 返回200 OK + JSON数据
    HTTP-->>Client: 解析为HttpResponseMessage
    Client->>Client: await ReadAsStringAsync()
    Client->>Client: 处理响应数据

请求头、超时、连接优化三连击!

调API不只是发个请求那么简单,还得考虑这些细节:

设置请求头(Headers)
client.DefaultRequestHeaders.Add("Authorization", $"Bearer {accessToken}");
client.DefaultRequestHeaders.Add("User-Agent", "MyQQApp/1.0");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

常见用途:
- Authorization :携带令牌,身份验证。
- Accept :声明想要的数据格式,避免收到HTML。
- User-Agent :帮助服务端识别客户端,有些API会据此限流。

超时控制

默认情况下, HttpClient 没有全局超时限制,可能导致请求无限挂起。一定要设置:

var handler = new HttpClientHandler
{
    AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
};

var client = new HttpClient(handler)
{
    Timeout = TimeSpan.FromSeconds(30) // 全局超时
};

否则遇到网络波动,程序就会一直卡在那里……

连接复用优化

高频调用同一域名时,启用连接池能大幅降低TCP握手开销:

services.AddSingleton<HttpClient>(sp =>
{
    var handler = new SocketsHttpHandler
    {
        PooledConnectionLifetime = TimeSpan.FromMinutes(5),
        PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2),
        MaxConnectionsPerServer = 100
    };

    return new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) };
});
参数 说明
PooledConnectionLifetime 单个连接最长存活时间
MaxConnectionsPerServer 每个主机最大并发连接数

适用于批量拉取好友、群成员等场景。


Cookie管理:保持会话状态的小技巧

虽然OAuth主要靠Token,但在某些混合登录场景中,Cookie还是有用的:

private static readonly CookieContainer cookieJar = new CookieContainer();
private static readonly HttpClientHandler handler = new HttpClientHandler
{
    CookieContainer = cookieJar,
    UseCookies = true
};

private static readonly HttpClient client = new HttpClient(handler);

只要共用同一个 handler ,后续请求就会自动带上之前的Cookie,实现“登录态保持”。

classDiagram
    class HttpClient {
        +DefaultRequestHeaders: HttpRequestHeaders
        +Timeout: TimeSpan
        +BaseAddress: Uri
        +GetAsync(uri): Task~HttpResponseMessage~
        +PostAsync(uri, content): Task~HttpResponseMessage~
    }

    class HttpClientHandler {
        +CookieContainer: CookieContainer
        +UseCookies: bool
        +AutomaticDecompression: DecompressionMethods
        +Proxy: IWebProxy
    }

    class CookieContainer {
        +Add(Cookie)
        +GetCookies(Uri): CookieCollection
    }

    HttpClient --> HttpClientHandler : 包含
    HttpClientHandler --> CookieContainer : 使用

📬 获取好友与群组信息:构建完整社交图谱

有了 access_token ,我们就可以开始调API了!

获取好友列表:分页处理大规模数据

假设接口是:

GET https://openapi.qq.com/v1/user/get_friends_list?access_token=xxx&page_size=100&page_num=1

实现代码:

public async Task<List<FriendInfo>> GetFriendsListAsync(string accessToken, int pageSize = 100, int pageNum = 1)
{
    var requestUri = $"https://openapi.qq.com/v1/user/get_friends_list?" +
                     $"access_token={accessToken}&" +
                     $"page_size={pageSize}&" +
                     $"page_num={pageNum}";

    var response = await _httpClient.GetAsync(requestUri);
    if (!response.IsSuccessStatusCode)
        throw new HttpRequestException($"获取好友列表失败: {response.StatusCode}");

    var jsonResponse = await response.Content.ReadAsStringAsync();
    var result = JsonConvert.DeserializeObject<ApiResponse<List<FriendRawData>>>(jsonResponse);

    return result.Data?.Select(MapToFriendInfo).ToList() ?? new List<FriendInfo>();
}

如果想一次性拉完所有好友,可以这样做:

public async Task<List<FriendInfo>> GetAllFriendsAsync(string accessToken)
{
    var allFriends = new List<FriendInfo>();
    int page = 1;
    const int pageSize = 100;

    while (true)
    {
        var friends = await GetFriendsListAsync(accessToken, pageSize, page);
        if (!friends.Any()) break;

        allFriends.AddRange(friends);
        page++;

        await Task.Delay(200); // 防止频率过高被限流
    }

    return allFriends;
}
flowchart TD
    A[开始拉取全部好友] --> B{第一页?}
    B -->|是| C[调用API获取第N页]
    C --> D{是否有数据?}
    D -->|否| E[结束循环]
    D -->|是| F[添加至总列表]
    F --> G[页码+1]
    G --> H[延迟200ms防限流]
    H --> C
    E --> I[返回完整好友列表]

数据建模:封装 FriendInfo 与 GroupInfo

为了类型安全,我们必须把原始JSON映射成强类型对象:

[Serializable]
public class FriendInfo
{
    public string QqNumber { get; set; }
    public string Nickname { get; set; }
    public string RemarkName { get; set; }
    public string AvatarUrl { get; set; }
    public int GroupId { get; set; }

    [NonSerialized]
    private DateTime _lastUpdated;

    public DateTime LastUpdated => _lastUpdated == default ? DateTime.Now : _lastUpdated;

    public void UpdateTimestamp() => _lastUpdated = DateTime.Now;

    public override string ToString() => $"{RemarkName}({Nickname})[{QqNumber}]";

    public override bool Equals(object obj)
        => obj is FriendInfo other && QqNumber == other.QqNumber;

    public override int GetHashCode() => QqNumber?.GetHashCode() ?? 0;
}

同理,群组模型:

[Serializable]
public class GroupInfo
{
    public string GroupId { get; set; }
    public string GroupName { get; set; }
    public int MemberCount { get; set; }
    private List<FriendInfo> _members;

    public IReadOnlyCollection<FriendInfo> Members => _members?.AsReadOnly();

    public async Task LoadMembersAsync(GroupService service, string token)
    {
        _members = await service.GetGroupMembersAsync(token, GroupId);
    }

    public FriendInfo FindMemberByQq(string qq) => _members?.FirstOrDefault(m => m.QqNumber == qq);
}
classDiagram
    class FriendInfo {
        +string QqNumber
        +string Nickname
        +string RemarkName
        +string AvatarUrl
        +int GroupId
        +ToString() string
        +Equals(object) bool
    }

    class GroupInfo {
        +string GroupId
        +string GroupName
        +int MemberCount
        +IReadOnlyCollection~FriendInfo~ Members
        +FindMemberByQq(string) FriendInfo
        +SearchMembersByName(string) List~FriendInfo~
    }

    GroupInfo "1" *-- "0..*" FriendInfo : contains

🚀 实现消息群发:并发调度 + 失败重试

终于到了最激动人心的环节——发消息!

文本消息发送

public async Task<bool> SendTextMessageAsync(string openid, string content)
{
    var formContent = new FormUrlEncodedContent(new[]
    {
        new KeyValuePair<string, string>("access_token", _accessToken),
        new KeyValuePair<string, string>("openid", openid),
        new KeyValuePair<string, string>("msg_content", content),
        new KeyValuePair<string, string>("msg_type", "1")
    });

    var response = await client.PostAsync("https://graph.qq.com/msg/send_private_msg", formContent);
    var result = JsonConvert.DeserializeObject<JObject>(await response.Content.ReadAsStringAsync());

    return result["ret"]?.Value<int>() == 0;
}

图片消息上传与发送

先上传图片获取 media_id

public async Task<string> UploadImageAndGetMediaIdAsync(string imagePath)
{
    using var formData = new MultipartFormDataContent();
    formData.Add(new StringContent(_accessToken), "access_token");
    formData.Add(new StreamContent(File.OpenRead(imagePath)), "image", Path.GetFileName(imagePath));

    var response = await client.PostAsync("https://graph.qq.com/msg/upload_image", formData);
    var json = await response.Content.ReadAsStringAsync();
    var result = JsonConvert.DeserializeObject<JObject>(json);

    if (result["ret"]?.Value<int>() != 0)
        throw new Exception($"上传失败: {result["msg"]?.Value<string>()}");

    return result["data"]?["media_id"]?.Value<string>();
}

再用 media_id 发送:

await SendMediaMessageAsync(openid, mediaId: uploadedId, msgType: 2);

并发控制:别被限流封号!

一口气发几百条?小心被当成机器人直接封号!

SemaphoreSlim 控制并发数:

private readonly SemaphoreSlim _throttle = new(5, 5); // 最多5个并发

public async Task<bool> SendMessageToSingleAsync(SendMessage msg)
{
    await _throttle.WaitAsync();
    try
    {
        return await SendViaApi(msg);
    }
    finally
    {
        _throttle.Release();
    }
}

加上进度反馈:

public async Task BroadcastWithProgressAsync(List<string> targets, SendMessage msg, IProgress<double> progress)
{
    int total = targets.Count, sent = 0;
    foreach (var target in targets)
    {
        await SendMessageToSingleAsync(msg with { TargetId = target });
        progress?.Report((double)++sent / total);
    }
}

失败重试机制:网络抖动也不怕

建立失败队列,定时重试:

private async Task RetryFailedMessagesAsync()
{
    while (!cancellationToken.IsCancellationRequested)
    {
        await Task.Delay(TimeSpan.FromMinutes(1));

        var retryList = new List<MessageRecord>();
        while (_failedQueue.TryDequeue(out var record))
        {
            if (record.Attempts >= 3) continue;

            record.Attempts++;
            bool success = await SendMessageToSingleAsync(ConvertToMessage(record));
            record.LastAttempt = DateTime.Now;

            if (!success)
                retryList.Add(record);
            else
                _logger.LogInformation($"重试成功: {record.TargetId}");
        }

        foreach (var r in retryList)
            _failedQueue.Enqueue(r);
    }
}

🖼️ UI构建:Windows Forms实战布局

最后一步,做个漂亮的界面!

public partial class MainForm : Form
{
    private Button btnLogin;
    private ListBox lbFriends, lbGroups;
    private TextBox txtMessage;
    private ProgressBar pbSendProgress;
    private RichTextBox rtbLog;

    public MainForm()
    {
        InitializeComponent();
        SetupUI();
    }

    private void SetupUI()
    {
        this.Text = "QQ消息群发助手";
        this.Size = new Size(800, 600);
        this.StartPosition = FormStartPosition.CenterScreen;

        var splitContainer = new SplitContainer { Dock = DockStyle.Fill };

        // 左侧:登录与选择区
        var panelLeft = new TableLayoutPanel { Dock = DockStyle.Fill, ColumnCount = 1, RowCount = 4 };
        btnLogin = new Button { Text = "登录QQ", Dock = DockStyle.Top };
        lbFriends = new ListBox { Dock = DockStyle.Fill, SelectionMode = MultiExtended };
        lbGroups = new ListBox { Dock = DockStyle.Fill, SelectionMode = MultiExtended };
        txtMessage = new TextBox { Multiline = true, Height = 80, Dock = DockStyle.Fill };

        panelLeft.Controls.AddRange(new Control[] { btnLogin, lbFriends, lbGroups, txtMessage });

        // 右侧:进度与日志
        var panelRight = new TableLayoutPanel { Dock = DockStyle.Fill, RowCount = 2 };
        pbSendProgress = new ProgressBar { Dock = DockStyle.Fill };
        rtbLog = new RichTextBox { Dock = DockStyle.Fill, ReadOnly = true, BackColor = Color.Black, ForeColor = Color.Lime };

        panelRight.Controls.AddRange(new Control[] { pbSendProgress, rtbLog });

        splitContainer.Panel1.Controls.Add(panelLeft);
        splitContainer.Panel2.Controls.Add(panelRight);

        this.Controls.Add(splitContainer);
    }

    private void AppendLog(string message)
    {
        if (rtbLog.InvokeRequired)
        {
            rtbLog.Invoke(new Action<string>(AppendLog), message);
        }
        else
        {
            rtbLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {message}\r\n");
            rtbLog.ScrollToCaret();
        }
    }
}

🧪 调试技巧:Fiddler抓包 + 断点分析

遇到问题怎么办?

  1. Fiddler 抓包,看实际发出的请求是否正确;
  2. 在 Visual Studio 中打断点,检查 access_token 是否过期;
  3. 添加日志,记录每次调用的时间、参数、结果。
if (DateTime.Now > _tokenExpiryTime)
{
    Debugger.Break();
    await RefreshAccessTokenAsync();
}

✅ 总结

我们一路从C#基础讲到OAuth2.0、HTTP请求、数据建模、并发控制、UI设计,最终完成了一个功能完整的QQ消息群发工具。

这套架构不仅适用于QQ,稍作修改也能用于微信、钉钉、飞书等平台的消息推送系统。其核心思想是:

  • 模块化设计 :各司其职,互不干扰。
  • 安全性优先 :敏感信息加密,防CSRF,防重放。
  • 用户体验至上 :进度反馈、失败重试、彩色日志。
  • 可扩展性强 :支持多种消息类型,易于添加新功能。

“工欲善其事,必先利其器。”
——《论语·卫灵公》

希望这篇实战指南能帮你打造出属于自己的高效自动化工具!🛠️💬

如果有任何疑问,欢迎留言讨论~我们一起进步!🌟

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:本文详细介绍如何使用C#编程语言实现QQ登录、获取好友列表和群列表,以及QQ消息的群发功能。项目基于QQ开放平台API,利用HTTP请求与JSON数据交互,涵盖用户授权、access_token获取、好友/群组信息解析及消息发送等核心流程。压缩包中的“QQRobotForm”源码很可能为一个Windows Forms应用程序,提供可视化操作界面,集成API调用、数据解析与UI更新逻辑。通过本项目实践,开发者可深入掌握C#网络编程、API接口调用与社交软件自动化技术,提升在信息推送和自动化处理场景下的开发能力。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐