一个门店预约小程序,真正难的不是页面,是这 4 个后端接口

33 阅读4分钟

接了个连锁门店的预约小程序,8 家店,3 周上线。做完复盘,发现 80% 的时间花在后端和微信接口上,页面只占很小一块。

这篇文章把最值钱的 4 个接口拆开讲:微信登录、订阅消息、支付回调、时段库存。技术栈 ASP.NET Core 8 + 原生小程序。

一、微信登录:别把 session_key 发给前端

流程是标准的三步:小程序 wx.login() 拿 code → 后端用 code + appid + secret 换 openid → 后端自己签发登录态。

wx.login({
  success: async (res) => {
    const r = await wx.request({
      url: 'https://api.example.com/api/wechat/login',
      method: 'POST',
      data: { code: res.code }
    });
    wx.setStorageSync('token', r.data.token);
  }
});

code 有效期很短且只能用一次,所以每次登录都要重新调 wx.login(),别缓存。

后端:

var url = "https://api.weixin.qq.com/sns/jscode2session"
        + $"?appid={appId}&secret={secret}"
        + $"&js_code={Uri.EscapeDataString(code)}"
        + "&grant_type=authorization_code";

返回里有 openid、session_key、unionid(满足条件时)。这里两条纪律:

  1. session_key 绝不下发客户端 —— 它能解密用户敏感数据,泄露等于数据裸奔。
  2. 别拿 openid 当接口鉴权凭证 —— 泄露后无法吊销。

正确做法是后端生成随机 token,把 {openid, session_key} 存 Redis,只把 token 给前端:

var token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
await cache.SetStringAsync($"wx:sess:{token}",
    JsonSerializer.Serialize(new { r.OpenId, r.SessionKey }),
    new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(30) });
return new { token };

前端用 wx.checkSession() 判断 session_key 是否可能过期,过期就重走一遍登录。

二、订阅消息:额度是「攒」出来的,不是「要」来的

这是最容易在报价阶段就说不清的一件事。

  • 一次性订阅:用户点一次授权,只能下发一条;只有勾了「总是保持以上选择」,额度才会累积。
  • 长期订阅:只对政务民生、医疗、交通等特定行业开放,普通门店拿不到。

既然只能一次性订阅,策略就很明确了:在每个用户主动操作的节点,顺便要一次授权(提交预约后、支付完成后、取消预约时),而不是等要发消息时才发现没额度。

wx.requestSubscribeMessage({
  tmplIds: ['TEMPLATE_RESULT', 'TEMPLATE_REMIND'],
  success(res) {
    // res[tmplId]: 'accept' | 'reject' | 'ban' | 'filter'
  }
});

⚠️ 必须在用户点击的回调里调,页面 onLoad 里自动调会被拒。

服务端下发前先拿 access_token(7200 秒有效,有频率限制),必须缓存:

private static readonly SemaphoreSlim _lock = new(1, 1);

public async Task<string> GetAccessTokenAsync(CancellationToken ct)
{
    if (_cache.TryGetValue("wx:at", out string? c) && !string.IsNullOrEmpty(c)) return c;
    await _lock.WaitAsync(ct);
    try
    {
        if (_cache.TryGetValue("wx:at", out string? a) && !string.IsNullOrEmpty(a)) return a;
        var json = await http.GetStringAsync(
            $"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appId}&secret={secret}", ct);
        using var doc = JsonDocument.Parse(json);
        var at  = doc.RootElement.GetProperty("access_token").GetString()!;
        var exp = doc.RootElement.GetProperty("expires_in").GetInt32();
        _cache.Set("wx:at", at, TimeSpan.FromSeconds(exp - 300));  // 提前 5 分钟过期
        return at;
    }
    finally { _lock.Release(); }
}

下发:

await http.PostAsJsonAsync(
    $"https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token={at}",
    new {
        touser = openId,
        template_id = templateId,
        page = "pages/order/detail?id=123",
        miniprogram_state = "formal",
        lang = "zh_CN",
        data = new {
            thing1 = new { value = "染发" },
            time2  = new { value = "2026-09-25 14:00" }
        }
    });

两个细节:模板字段有类型和长度限制(thing 一般 20 字符内),超长直接报错,建议封装 + 截断;返回 43101 不是 bug,是一次性订阅额度用尽。

三、支付回调:签名、验签、解密、幂等

微信支付 v3 有四道坎。

1. 请求签名(签名串每行末尾都要换行,含最后一行):

{HTTP方法}\n{URL}\n{时间戳}\n{随机串}\n{报文主体}\n
var sig = priKey.SignData(Encoding.UTF8.GetBytes(msg),
    HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);

Authorization 头:WECHATPAY2-SHA256-RSA2048 mchid="...",nonce_str="...",signature="...",timestamp="...",serial_no="..."。

下单 POST /v3/pay/transactions/jsapi,拿到 prepay_id 后再签一次给前端(签名串是 appId\n时间戳\n随机串\npackage\n)。

2. 回调验签:串不一样,是 时间戳\n随机串\n报文主体\n,用平台证书(或商户后台配置的微信支付公钥)。平台证书要定时更新。

3. 解密 resource(AES-256-GCM):

using var gcm = new AesGcm(Encoding.UTF8.GetBytes(apiV3Key), 16);
gcm.Decrypt(Encoding.UTF8.GetBytes(nonce), body, tag, plain,
            Encoding.UTF8.GetBytes(associatedData));

4. 幂等:这是最容易漏的一条。微信会重复推送回调直到你正确应答。按 out_trade_no 加唯一约束,重复通知直接返回成功;耗时逻辑丢后台队列,先秒级返回;金额以后端订单为准,不信前端。

四、时段库存:别在应用层加锁

预约系统的经典事故 —— 两人同时抢 14:00 同一技师,都成功。

最可靠的做法是把并发交给数据库:给 (门店Id, 技师Id, 时段) 建唯一索引(对有效状态),插入冲突即「已被预约」。比任何分布式锁都简单。

CREATE UNIQUE INDEX UX_Slot ON Appointment(StoreId, StaffId, SlotStart)
WHERE Status IN (0, 1);

五、开工前那 4 个配置,比代码更影响工期

配置坑
服务器域名必须 HTTPS + 已备案域名,不支持 IP / 自签;改动次数有限
ICP 备案周期不可控,签合同当天就提交,与开发并行
用户隐私保护指引没配就调不了手机号等接口,审核必驳
类目与功能不符 = 审核驳回第一名

六、3 周排期

  • 第 1 周:账号/备案并行;接口设计;code2session 全链路跑通
  • 第 2 周:预约主流程 + 后台管理 + 订阅下发定时任务
  • 第 3 周:支付联调、体验版走查、提审并应对 1–2 轮驳回

小结

这 4 个接口背后是同一条原则:凡是涉及身份、钱、库存、外部通知的环节,都要假设它会失败、会重复、会并发。按这个前提写出来的后端,才敢在 3 周之后交付。

如果你也在做小程序后端,欢迎在评论区聊聊踩过的坑,觉得有用的话点个关注,后面继续写 .NET 与微信生态对接的实战笔记。