C#实现QQ登录与消息群发完整源码项目
简介:本文详细介绍如何使用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) ,这是目前最安全的第三方登录方案之一。
整个流程大概是这样的:
- 用户点击“登录QQ”
- 应用跳转到QQ授权页面
- 用户输入密码并确认授权
- QQ服务器回调你的地址,并带上一个临时的
code - 你的后台拿着
code+AppSecret换取access_token - 拿到
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抓包 + 断点分析
遇到问题怎么办?
- 用 Fiddler 抓包,看实际发出的请求是否正确;
- 在 Visual Studio 中打断点,检查
access_token是否过期; - 添加日志,记录每次调用的时间、参数、结果。
if (DateTime.Now > _tokenExpiryTime)
{
Debugger.Break();
await RefreshAccessTokenAsync();
}
✅ 总结
我们一路从C#基础讲到OAuth2.0、HTTP请求、数据建模、并发控制、UI设计,最终完成了一个功能完整的QQ消息群发工具。
这套架构不仅适用于QQ,稍作修改也能用于微信、钉钉、飞书等平台的消息推送系统。其核心思想是:
- 模块化设计 :各司其职,互不干扰。
- 安全性优先 :敏感信息加密,防CSRF,防重放。
- 用户体验至上 :进度反馈、失败重试、彩色日志。
- 可扩展性强 :支持多种消息类型,易于添加新功能。
“工欲善其事,必先利其器。”
——《论语·卫灵公》
希望这篇实战指南能帮你打造出属于自己的高效自动化工具!🛠️💬
如果有任何疑问,欢迎留言讨论~我们一起进步!🌟
简介:本文详细介绍如何使用C#编程语言实现QQ登录、获取好友列表和群列表,以及QQ消息的群发功能。项目基于QQ开放平台API,利用HTTP请求与JSON数据交互,涵盖用户授权、access_token获取、好友/群组信息解析及消息发送等核心流程。压缩包中的“QQRobotForm”源码很可能为一个Windows Forms应用程序,提供可视化操作界面,集成API调用、数据解析与UI更新逻辑。通过本项目实践,开发者可深入掌握C#网络编程、API接口调用与社交软件自动化技术,提升在信息推送和自动化处理场景下的开发能力。
更多推荐



所有评论(0)