做小程序商城的时候,经常会碰到一个需求:业务后台用C#生成二维码,然后在微信小程序页面里展示出来。大多数开发第一次接触都会觉得很简单,不就是调一个接口嘛,但真正上手才发现,光“C#微信小程序二维码利用B接口获取”这个流程,就能拆出一大堆细节。接口选型、签名规则、返回格式、小程序渲染方式,每一步都有讲究。
我这边就结合自己实际做过的项目,把C#端调B接口、拿二维码、再喂给小程序展示的完整链路拆开讲一遍。尤其是B接口这种本身设计得比较“别扭”的接口,我们怎么在C#里把它包得顺滑,怎么让小程序端拿到就能用,避免白屏、签名失败、扫码结果不对这些坑。
1. 先弄清楚B接口在二维码链路里的定位
1.1 这个需求到底解决什么问题
很多业务场景里,二维码不是简简单单一个黑白方块。比如线下门店做物料管理、小程序商城的核销凭证、活动推广的展示码,都要求二维码能携带业务参数,比如用户ID、订单号、场景值。前端小程序需要拿到这个二维码并渲染出来,后端C#服务则负责与上游接口交互,把二维码数据拉回来。
标题里说的“B接口”,在业内其实不是一个固定的接口名字,更像是一个内部命名习惯。有些项目会按场景拆接口,A接口管用户信息,B接口管二维码生成,C接口管状态查询。也有团队把“B”理解成Business,也就是业务侧二维码接口,专门给C#后端用的。不管它实际叫什么,这个接口承担的事情很清楚:输入业务参数,输出二维码图片或者二维码地址。
我这里说的B接口,就指代“业务二维码生成接口”。它的特点是你不能直接在小程序里请求,必须经过自己的服务端中转,原因后面会展开。
1.2 和普通二维码接口的区别在哪里
有些二维码接口是纯前端用的,传一个文本进去,返回一张二维码图片,技术上没什么门槛。但B接口这类业务接口,明显复杂一个层级:
- 调用方受限:它要求调用方有合法的应用凭证,可能是AppID加AppSecret的签名组合,也可能是服务端专用的Token。这些凭证放前端等于直接裸奔,所以必须由C#后端来保管和调用。
- 入参是结构化数据:不是一个URL字符串就完事,需要传订单号、用户标识、业务类型、过期时间等,接口内部要做校验和落库。
- 返回结果不统一:有时候返回Base64图片数据,有时候返回一个临时URL,有时候直接返回JSON里带二维码内容文本。B接口常见的返回方式就得单独处理。
- 有额度与时效控制:二维码生成不能无限刷,特别是带参数的临时码,往往有数量限制和有效期。如果前端每人进来都调一次,额度很快就烧完了。
所以B接口本质上是一种“服务端对服务端”的接口设计。C#在这里就是扮演一个标准的服务端客户端角色,负责拿到凭证、组装参数、发起请求、解析响应、决定缓存策略,最后把结果以一种小程序能消费的方式暴露出去。
我在前期和团队沟通需求的时候,习惯做一件事:画一张简单的链路图。左边是微信小程序前端,中间是C#接口层,右边是第三方B接口。前端只认自己后端出的数据格式,后端去对接上游乱七八糟的协议,这样两边解耦,改上游不会动前端。
2. 为什么非要C#在中间做一层中转
2.1 小程序端直接调B接口的三条拦路虎
首先说结论:不要让小程序直接请求B接口。这条结论我是在项目里踩过决定性的大坑之后才彻底记住的。
第一个问题就是凭证泄露。B接口要求调用时带上应用凭证,这些凭证相当于业务系统的通行证。小程序代码是跑在用户手机上的,只要把请求逻辑写在前端,别人用抓包工具就能看到你的凭证值。密码泄露之后,别人可以拿你的身份去无限调用B接口刷二维码,轻则耗费额度,重则影响整个业务安全。
第二个问题是跨域。浏览器和微信小程序的网络请求都有同源限制,B接口的域名除非被你加到“合法域名”白名单里,否则直接请求会被拦截。但问题是,很多B接口是内网接口或者测试环境接口,压根不会给你配白名单。
第三个问题是响应格式。小程序端处理纯图片流的能力比较弱,base64图片字符串在WXML里没法直接当src用,需要转成本地临时文件或者转为线上URL。这个转换过程放前端做,会白白增加代码包体积和用户手机的内存压力。
所以C#中转几乎是必然的选择。凭证在服务端存着,不经过用户设备;请求由服务端发起,不涉及小程序合法域名配置;数据格式可以先在C#里处理成最方便前端吃的样子,前端只负责一件简单的事:拿到图片地址,渲染出来。
2.2 C#服务端做中转的完整调用模型
我推荐在整个调用链路上分成三层:
- 上游B接口适配层:由C#封装一个服务类,专门负责和B接口打交道。类里面负责签名、HTTP请求、异常重试、响应解析。向上游屏蔽细节。
- 业务服务层:C#把自己系统的业务数据组织成B接口需要的入参,比如从订单表取单号、从用户表取身份标识,然后调用适配层。
- 接口暴露层:对外提供一个小程序能直接调用的HTTP接口,返回的数据是前端最容易渲染的格式。
这么分层有个好处,就是如果B接口某天改了返回结构,你只需要改动适配层,业务服务和前端完全不受影响。我做过的项目里曾经遇到过上游B接口从返回Base64改为返回CDN地址的情况,当时因为分层清晰,只改了C#一个文件的解析逻辑,半小时就上线了。
另一个容易忽略的点是HTTP客户端的管理。C#里调用B接口,很多人习惯用HttpClient每次new一个实例,但这样在高并发下会把服务器端口资源耗尽。正确做法是使用IHttpClientFactory来管理,把HttpClient的生命周期交给框架管理,同时设置合理的超时时间和重试策略。
还有一点,在调用B接口前,务必要考虑二维码缓存。同一个订单的二维码短时间内不会变,完全没有必要让前端每次打开页面都触发一次B接口调用。我会在C#服务里做一层内存缓存,Key用“业务类型+业务ID”拼出来,Value存放二维码地址或者图片数据。这样既节省B接口调用次数,也大幅提升小程序端的加载速度。
用代码表达整个调用链路会是这样的伪代码逻辑:小程序请求C#暴露的API,C#先查缓存,有就直接返回;没有就去调B接口,拿到结果后写入缓存并返回给小程序。我执行的这套方案在线上稳定运行,某次活动高峰时期,B接口调用量被缓存挡住了80%以上。
3. C#调用B接口拿二维码的实操细节
3.1 先从准备凭证和约定开始
在写代码之前,先要把B接口侧需要的信息收集齐。通常包括:
- 请求地址,形如
https://api.example.com/bcode/get。 - 应用标识,比如
appId。 - 应用密钥,比如
appSecret。 - 参数签名规则,一般是把参数排序后拼接,加盐MD5,也有可能是HMAC-SHA256。
- 返回格式,JSON还是XML,图片是Base64还是URL。
这些信息建议放到配置文件里,最好用环境变量区分测试环境和生产环境。我有一次踩过坑,在代码里硬编码了生产环境的凭证,后来联调环境一直报签名错误,排查半天才发现请求打错环境了。
签名这件事,是整个B接口对接里最容易出问题的环节。拿最常见的MD5签名来说,规则大概是:把所有请求参数按字母升序排序,排除空值和签名本身,拼接成key1=value1&key2=value2这样的字符串,最后再在末尾拼上密钥,做一次MD5。我以这段逻辑跑了很多次,稳定通过:
public static string BuildSign(SortedDictionary<string, string> parameters, string secret) { var sb = new StringBuilder(); foreach (var item in parameters) { if (string.IsNullOrEmpty(item.Value)) continue; sb.Append($"{item.Key}={item.Value}&"); } var raw = sb.ToString().TrimEnd('&') + secret; var md5Bytes = MD5.HashData(Encoding.UTF8.GetBytes(raw)); return Convert.ToHexStringLower(md5Bytes); }注意:排序用的是字典序,不是参数个数顺序。如果上游没有明确要求包含签名自身,就不要把sign拼进去再签一次。
还有一点容易漏的,就是字符编码。面单参数里有中文时,不同编码的MD5结果完全不一样。和B接口约定好统一的UTF-8编码,C#里所有拼接都用UTF-8做字节转换,别用系统的默认编码。我见过好几次,线上偶发签名失败,定位到就是某个字段值里带了中文全角空格,值被Trim掉之后签名验不过,排查方式就是对照上游的日志看它实际收到的原始字符串。
3.2 B接口适配层代码怎么写
我习惯把B接口适配封装成一个独立的类,接口对外只有一个方法:GetBizCodeAsync(BizCodeRequest request)。
这个方法内部依次完成四件事:组装参数、计算签名、发起请求、解析响应。代码结构大概是这样:
public class BizCodeApiClient { private readonly HttpClient _httpClient; private readonly IOptions<BizCodeOptions> _options; public BizCodeApiClient(HttpClient httpClient, IOptions<BizCodeOptions> options) { _httpClient = httpClient; _options = options.Value; } public async Task<BizCodeResult> GetBizCodeAsync(BizCodeRequest request, CancellationToken ct) { var param = new SortedDictionary<string, string> { ["appId"] = _options.Value.AppId, ["bizType"] = request.BizType, ["bizId"] = request.BizId, ["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), }; param["sign"] = SignHelper.BuildSign(param, _options.Value.PlatformSecret); var form = new FormUrlEncodedContent(param); using var response = await _httpClient.PostAsync(_options.Value.Endpoint, form, ct); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(ct); var parsed = JsonSerializer.Deserialize<BizCodeResponse>(json); if (parsed.Code != "0") { throw new BizCodeException(parsed.Code, parsed.Message); } return new BizCodeResult { QrCodeUrl = parsed.Data.QrCodeUrl, QrCodeBase64 = parsed.Data.QrCodeBase64, ExpireIn = parsed.Data.ExpireIn }; } }有几个细节值得展开说一下。
第一是FormUrlEncodedContent。如果请求按form格式提交,用这个类最省心,它自动做URL编码。如果用PostAsync传StringContent,很容易因为Content-Type不对被上游拒掉。
第二是EnsureSuccessStatusCode。这个只保证HTTP层面是200,不代表业务成功。很多B接口即使业务失败,HTTP状态码也是200,返回体里带一个错误码。所以解析完JSON后一定要先看业务码,再决定抛不抛异常。
第三是CancellationToken。接口调用很容易被外部请求取消,比如用户在小程序端快速退出页面,HTTP请求就会取消。如果不把取消令牌传递进去,服务端白白浪费资源继续调用B接口。把令牌一路传到底层方法里,框架会自动处理取消逻辑。
3.3 处理不同返回格式的二维码数据
B接口常见的返回格式基本有三种,我在C#里都做过对应的处理方案。
第一种:返回可直接访问的图片URL。这是最省事的情况,C#直接把URL透传给小程序,小程序在image标签里设src就能显示。但要注意URL有没有带签名参数,如果有的话URL会有时效,过期就显示不出来。这时候需要在C#里判断过期时间,快到期的二维码重新调B接口刷新。
第二种:返回Base64字符串。这种格式下B接口把图片二进制转成了Base64,方便JSON传输。C#拿到之后有两种处理方式,一是直接把Base64拼成data:image/jpeg;base64这个格式给小程序,二是把它落成一张临时图片再提供URL。我实践下来的建议是:短时效用data URL,长时效一定要落盘或者上传。因为data URL不能做缓存,二维码多的时候微信小程序的包体交互数据会很大,页面加载会明显卡顿。
Base64转图片文件的代码非常简单:
var bytes = Convert.FromBase64String(base64Str); await File.WriteAllBytesAsync(imagePath, bytes);写文件之前注意目录权限,ASP.NET Core跑在Linux容器里时,默认工作目录往往不是存放上传文件的好位置,可以配置一个专门的存储路径,挂载成持久化卷。
第三种:返回二维码内容文本。也就是说接口给的并不是图片,而是一串需要被编码成二维码的内容。这种情况C#要自己生成二维码图片。我这里常用来生成方案是SkiaSharp加QRCoder,QRCoder负责生成二维码矩阵,SkiaSharp负责最终画图,两者配合在Linux容器里也能跑,不依赖系统图形库。
using var generator = new QRCodeGenerator(); var data = generator.CreateQrCode(qrContent, QRCodeGenerator.ECCLevel.Q); using var art = new SvgQRCode(data); return art.GetGraphic(20);生成图片之后,可以直接返回PNG字节流。C#接口这边用FileResult返回,小程序端直接拿响应地址填充image组件就行。
这里要特别留意一下二维码的容错级别和尺寸。容错级别越高,二维码即使被遮挡一部分也能扫,但图案更密。一般场景用M级别就够。尺寸方面,PNG建议给一个固定的像素尺寸,比如600乘以600,不要跟着设备像素密度走。小程序端用CSS去控制展示大小,用的是600像素的原图,在小屏幕上会缩小,清晰度基本没问题。
3.4 对外暴露给小程序用的接口
适配层写完,还要提供一个H5小程序能直接访问的对外接口。返回值建议统一成下面的结构:
{ "code": 0, "message": "ok", "data": { "qrCodeUrl": "https://example.com/qrcode/1001.png", "expireAt": 1720000000000, "bizId": "1001" } }qrcodeUrl是最终给前端渲染用的字段,expireAt是二维码过期时间戳,前端可以根据这个字段决定何时重新加载。bizId是为了方便前端做状态记录。
C#控制器代码可以这样写:
[HttpGet("/api/qrcode/{bizType}/{bizId}")] public async Task<IActionResult> GetQrCode([FromRoute] string bizType, [FromRoute] string bizId) { if (await _cache.TryGetAsync($"{bizType}:{bizId}", out var cachedUrl)) { return Ok(new { code = 0, message = "ok", data = cachedUrl }); } var result = await _client.GetBizCodeAsync(new BizCodeRequest(bizType, bizId)); await _cache.SetAsync($"{bizType}:{bizId}", result.QrCodeUrl, TimeSpan.FromMinutes(30)); return Ok(new { code = 0, message = "ok", data = result }); }这段逻辑简洁明了,前端只需要知道一个C#接口地址,传两个参数,就能稳定拿到二维码。把可变的东西全部封装在服务端,改动一个地方就能影响所有端。
4. 小程序端把二维码正确展示出来
4.1 页面组件的选择
小程序端展示二维码,核心就是image组件。但在真正实现的时候还是会遇到一些问题。
用image组件直接展示二维码图片URL是最通用的方案。这里有个关键参数mode,默认值是scaleToFill,会把图片拉伸填满整个image框。如果二维码是正方形的,而image框不是,二维码就被拉变形了,虽然能扫但体验很差。我建议mode设置为aspectFit,让图片等比缩放,留白就留白,至少不会变形。
另外一个细心的地方是背景色。二维码识别依赖深色模块和浅色背景的对比。有些场景背景是深色,如果直接放白底二维码,会有些突兀。简单处理方式是给image外面包一层白色圆角容器,把二维码图片放在白色块上,视觉上干净,识别率也高。
WXML代码示例:
<view class="qrcode-box"> <image wx:if="{{qrCodeUrl}}" src="{{qrCodeUrl}}" mode="aspectFit" class="qrcode-img" binderror="onQrCodeError" bindload="onQrCodeLoad" /> <view wx:else class="qrcode-placeholder">二维码加载中...</view> </view>JS侧的逻辑也很简单,页面加载时调用后端接口,拿到数据后赋值给qrCodeUrl。额外处理一下加载失败和加载完成这两种状态:
Page({ data: { qrCodeUrl: '', isQrCodeLoading: true, errorMsg: '' }, onLoad(query) { this.bizType = query.bizType || 'order'; this.bizId = query.bizId || ''; this.fetchQrCode(); }, async fetchQrCode() { try { const res = await request.get('/api/qrcode', { bizType: this.bizType, bizId: this.bizId }); if (res.code === 0) { this.setData({ qrCodeUrl: res.data.qrCodeUrl, isQrCodeLoading: false }); } } catch (err) { this.setData({ errorMsg: '二维码获取失败,请稍后重试', isQrCodeLoading: false }); } }, onQrCodeError() { this.setData({ errorMsg: '二维码加载失败,请检查网络' }); }, onQrCodeLoad() { this.setData({ isQrCodeLoading: false }); } });这里的request.get是一个简单的Promise封装,通常项目里都有现成的。如果项目没有封装,可以先用wx.request加载,逻辑是一模一样的。
4.2 缓存策略和刷新机制的取舍
二维码的刷新策略,我一般这么定:有明确过期时间的,小程序端在过期前就重新拉取;不知道过期时间的,设置一个默认有效期,比如30分钟强制刷新一次。
为什么强调刷新?因为有些业务二维码是动态的,首次生成后如果不刷新,后台那边可能已经改成新的二维码了,但用户手机上的还是旧的展示。最典型的是订单状态从待支付变成已支付后,原来的支付二维码就不能再用了,必须让页面重新请求C#接口拿到最新状态。
刷新动作不建议写在onShow里,因为onShow每次切后台回前台都会触发,太频繁了。更好的做法是setInterval定时器,每25分钟调一次接口,同时监听页面隐藏时清除定时器,防止页面栈攒一堆定时器造成内存泄漏。
小程序端还要注意图片缓存问题。如果后端返回的URL没变,但是图片内容变了,小程序可能会展示旧的缓存图。为绕开这个问题,C#生成的二维码URL里带一个版本号或者时间戳参数。即便图片地址没变化,前端请求时在URL后面加一个时间戳来绕过缓存:
this.setData({ qrCodeUrl: `${res.data.qrCodeUrl}?_t=${Date.now()}` });这个技巧虽然简单,但线上很实用,能避免不少用户反馈“二维码是旧的”的尴尬问题。
5. 实操中踩过的坑和排查思路
5.1 签名总是不通过的排查方式
签名验证不通过,基本上每个接入B接口的团队都会遇到。我总结了一下主要原因和排查顺序:
- 拼接顺序不对。B接口若要求按字母排序,你却按代码里声明的顺序拼的,结果必然失败。最稳妥的做法是C#端直接用SortedDictionary。
- 空值参数干扰。有的参数为空时是参与签名,有的不参与,必须逐条核对。稳妥起见,凡是值null或者空字符串的字段,在签名前直接过滤掉,不参与签名也不参与请求。
- 大小写问题。MD5结果是32位小写,但有些平台要求大写输出,拼接前确认好,统一转。
- 编码差异。URL编码函数的不同实现之间也会有差异,比如空格会被编成加号还是百分号20。FormUrlEncodedContent用的是百分号20,所以自己算签名时也要用相同规则。
- 时间戳过期。很多接口会校验时间戳和服务器时间差,超过几分钟就拒绝请求。检查自己服务器时间和标准时间是否有偏差,差太多时要么做时间同步,要么让上游放开窗口期。
遇到签名不通过时,建议方案很简单:把C#侧拼接的原始字符串打出来,同时在B接口平台侧找到对应请求的日志,看平台侧拿到的原始串是什么,两边逐字符对比。我第一次定位这种问题的时候,就是靠这个方法,发现一个字段值多了一个不可见空格,不到十分钟就解决了。
5.2 二维码图片在小程序里显示不出来
这个问题的原因有很多,我按频率排序分享几个排查方向。
方向一:URL本身无法访问。先在浏览器里打开C#返回的qrcodeUrl,能正常打开图片,说明C#这边生成没问题;打不开,问题就在C#侧,多半是文件没有正确写入可访问的静态目录,或者权限没开。
方向二:微信小程序域名白名单。小程序开发工具里预览时勾选了“不校验合法域名”,真机上就不一样了。所有在小程序里直接访问的URL域名必须配置到管理后台的downloadFile合法域名列表里。如果是开发调试阶段,可以用开发工具的“不校验合法域名”快速绕过,但上线前一定配置好。
方向三:URL是http的。微信小程序强制要求所有网络请求使用HTTPS。我给某个测试环境生成二维码时用了http图床地址,开发工具里显示出来,一上真机就黑屏,换成HTTPS地址就好了。
方向四:图片跨域没配置。如果二维码图片是由第三方图床提供的,图床需要允许微信的请求头跨域,否则小程序端加载失败。这个问题在PC端浏览器打开没问题,但在小程序里就会失败,排查时容易走弯路。
排查表总结如下:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 浏览器能打开,真机白屏 | 域名未配置白名单 | 登录管理后台配置downloadFile合法域名 |
| 地址是http | 微信要求HTTPS | 换成HTTPS,或在C#侧做代理转发 |
| 单独页面有图,列表页无图 | 列表复用了同一个组件,没有处理好索引 | 检查循环渲染中image的src绑定是否正确 |
| 部分机型不显示 | 图片过大或格式不支持 | 压缩图片或转成PNG/JPEG标准格式 |
5.3 扫码之后结果不对
二维码本身能显示,但是扫出来内容不对,这个问题在开发期也很磨人。
最常见的是二维码内容不是业务要求的格式。比如业务方扫描后要跳到小程序指定页面,但C#里生成二维码时塞进去的是普通URL,微信扫出来自然不认。给微信生态用的码要求比较严格,内容通常要符合特定协议,要么你直接用C#调B接口生成,不要自己拼字符串。B接口内部的二维码编码逻辑已经处理了识别问题,自己拼字符串生成普通二维码去给微信扫,很容易踩坑。
第二个常见情况是二维码内容里带了中文。普通二维码生成库对中文支持参差不齐,有的库没设置编码就生成,扫出来中文乱码。处理办法是转成UTF-8字节后再生成。
第三个情况是二维码被加过样式导致识别率下降。有些需求加Logo、改圆角、加渐变配色,这些都是设计侧的需求,但要控制好识别容错率。容错等级高一点,干扰元素尽量别压住定位角,不然用户拿识别率低的手机扫就会失败。
5.4 超时和性能问题
B接口调用超时,是小程序二维码页面的常见性能杀手。我在C#侧做了两类优化。
第一类是优化调用策略。增加HttpClient超时时间,比如设为15秒,超过则按失败处理并返回友好提示。同时加上简单的重试机制,对网络抖动导致的一次性失败自动重试一次,但不要无脑重试,否则上游接口会被打得很难受。重试只对幂等请求有效,二维码获取是幂等的,重试安全。
第二类是提前预生成。在业务高峰期,比如活动开售前,可以在后台写一个定时任务提前把一批二维码生成好,放到缓存或者存储里放着。用户访问时直接拿现成的,完全不碰B接口。这在秒杀、抢票场景里特别有用。
我还遇到过B接口响应正常,但C#服务器出口带宽被占满、影响其他业务的情况。解决方案是给二维码图片响应加缓存头,让小程序端乐观缓存。C#侧设置Cache-Control,前端再次进入页面时优先用本地缓存,过期后再请求服务器,能显著降低带宽消耗。
6. 一点个人经验小结
二维码这个功能,看起来只是整个系统里一个很小的模块,但真正跑起来会发现它牵扯到服务端接口设计、缓存策略、前端渲染兼容、业务状态同步一堆问题。我踩过不少坑之后,形成一个个人习惯:凡是和外系统对接的功能,第一天就把接口的请求日志和响应日志完整留下。B接口返回了什么、C#最后给了前端什么,每一步都留痕,排查线上问题的时候能省至少半天时间。
另外,二维码生成和展示的链路,强烈建议做一次自动化冒烟测试。用一个固定的bizId,跑C#接口拿二维码,然后调用解码服务去识别二维码文本,断言内容和预期一致。这样以后改代码,能保证二维码不会被改坏。
如果你现在正在接一个C#生成二维码、小程序展示的需求,建议先把接口文档里的签名规则和返回格式看明白,再做技术方案。方向上可以坚定选择服务端中转,前端保持轻量,这样后续不管B接口怎么调整,影响面都会控制得住。