视频号 POI 团购系统接入实战:高德 ID 映射、核销逆向与门店分账落地
说明:本文内容基于棱镜智汇作为微信官方认证视频号团购服务商的实际落地经验整理,所有业务场景均经过线上生产环境验证。以下代码为通用演示示例,仅用于展示业务实现思路,不代表企业线上生产源码,实际接入时请结合自身技术栈和微信支付最新文档进行调整。
做本地生活 SaaS 的团队接入视频号团购时,最容易踩坑的不是接口本身,而是 POI 地址映射偏差、核销退款逆向链路、多门店维度分账这三块视频号特有的业务场景。本文从实际落地视角拆解完整对接方案,并附上核心代码实现。
一、POI 门店认领与高德 ID 映射机制
视频号 POI 底层地址库完全复用高德地图数据,门店认领的本质是把商家自有门店 ID 和高德 POI ID 建立绑定关系。这一步是后续所有团购挂载、核销、对账的基础,也是线上故障最高发的环节。
常见三类映射错误:
推荐映射方案:三级匹配+人工兜底
package poi
import (
"context"
"fmt"
"time"
)
// PoiMatcher POI匹配器
type PoiMatcher struct {
amapClient *amap.Client
similarity *textSimilarity
manualQueue chan<- *StoreInfo
}
// MatchResult 匹配结果
type MatchResult struct {
PoiID string `json:"poi_id"`
Source string `json:"source"` // auto / manual
Confidence float64 `json:"confidence"` // 0-1
MatchedAt time.Time `json:"matched_at"`
Error string `json:"error,omitempty"`
}
// Match 三级匹配主入口
func (m *PoiMatcher) Match(ctx context.Context, store *StoreInfo) *MatchResult {
// 第一级:精确经纬度 + 名称全字匹配
pois, err := m.amapClient.SearchNearby(ctx, store.Name, store.Lng, store.Lat, 500)
if err != nil {
return &MatchResult{Error: fmt.Sprintf("高德接口异常: %v", err)}
}
if len(pois) == 1 {
return &MatchResult{PoiID: pois[0].ID, Source: "auto", Confidence: 1.0, MatchedAt: time.Now()}
}
// 第二级:地址文本相似度(>0.85 自动绑定)
for _, poi := range pois {
sim := m.similarity.Calc(store.Address, poi.Address)
if sim > 0.85 {
return &MatchResult{PoiID: poi.ID, Source: "auto", Confidence: sim, MatchedAt: time.Now()}
}
}
// 第三级:转人工审核(异步非阻塞)
select {
case m.manualQueue <- store:
default:
return &MatchResult{Error: "人工审核队列已满,请稍后重试"}
}
return &MatchResult{Source: "manual", Confidence: 0, MatchedAt: time.Now()}
}
核心原则:宁可不匹配走人工审核,也不要自动绑定到错误 POI——挂错定位带来的核销投诉和客诉成本远高于人工审核。
二、团购券核销与售后退款双链路设计
视频号本地生活核销订单不支持直接调用支付退款接口,需根据核销时长区分两套逆向流程,核心分为「冻结期撤销核销退款」「已结算售后维权退款」两套独立链路。
完整券状态流转:
已创建 → 已支付 → 可核销 → 已核销(7 天冻结结算期)
↓
7 天内:撤销核销退回可核销
↓
核销满 7 天:进入 15 天售后维权期(券不可恢复)
1. 冻结期撤销核销流程(核销 7 日内)
用户与商家协商全额退款时,优先调用撤销核销接口,将券恢复未核销状态后再执行退款。
package revoke
import (
"context"
"fmt"
"time"
"github.com/go-resty/resty/v2"
)
const (
RevokeAPI = "https://p.wecard.tencent.com/cloudpay/v1/pay/ec/voucher/revoke"
)
type RevokeRequest struct {
RevokeRequestNo string `json:"revoke_request_no"`
RevokeVouchers []RevokeVoucherItem `json:"revoke_vouchers"`
}
type RevokeVoucherItem struct {
VerifyID string `json:"verify_id"`
}
type RevokeResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data struct {
RevokeID string `json:"revoke_id"`
} `json:"data"`
}
// RevokeVerify 撤销核销(幂等 + 超时)
func RevokeVerify(ctx context.Context, verifyID string) (*RevokeResponse, error) {
// 幂等键:业务单号 + 操作类型
idempotentKey := fmt.Sprintf("revoke:%s", verifyID)
if ok, _ := redis.SetNX(ctx, idempotentKey, "processing", 30*time.Second); !ok {
return nil, fmt.Errorf("重复请求,请勿重试")
}
defer redis.Del(ctx, idempotentKey)
req := &RevokeRequest{
RevokeRequestNo: fmt.Sprintf("rev_%s_%d", verifyID, time.Now().UnixNano()),
RevokeVouchers: []RevokeVoucherItem{{VerifyID: verifyID}},
}
client := resty.New().SetTimeout(5 * time.Second)
var resp RevokeResponse
httpResp, err := client.R().
SetContext(ctx).
SetHeader("Content-Type", "application/json").
SetBody(req).
SetResult(&resp).
Post(RevokeAPI)
if err != nil {
return nil, fmt.Errorf("调用撤销接口失败: %w", err)
}
if httpResp.StatusCode() != 200 {
return nil, fmt.Errorf("撤销接口返回非200: %d", httpResp.StatusCode())
}
if resp.Code != 0 {
return nil, fmt.Errorf("撤销失败: %s", resp.Message)
}
return &resp, nil
}
撤销成功后,券重置为待使用,复用普通未核销订单退款逻辑,支持全额、部分退款。
2、已结算售后退款流程(核销超 7 天)
资金解冻至商家可提现余额后,无法撤回核销记录,统一通过售后单体系流转处理退款:
from dataclasses import dataclass
from typing import Optional, Dict
from decimal import Decimal
import logging
logger = logging.getLogger(__name__)
@dataclass
class AftersaleResult:
success: bool
aftersale_id: str
refund_amount: Decimal
error: Optional[str] = None
def handle_aftersale_audit(aftersale_data: Dict) –> AftersaleResult:
"""
售后审核通过处理退款
前提:调用本函数前,商家已在后台点击"同意售后"
"""
aftersale_id = aftersale_data["aftersale_id"]
refund_fee = Decimal(aftersale_data["refund_fee"])
# 1. 幂等检查(本地已处理过直接返回)
exist = db.query_aftersale_record(aftersale_id)
if exist:
return AftersaleResult(
success=True,
aftersale_id=aftersale_id,
refund_amount=exist["refund_amount"],
error=None
)
# 2. 前置校验:售后单当前状态是否允许退款
aftersale_status = wechat_api.query_aftersale_status(aftersale_id)
if aftersale_status not in ("WAIT_AGREE", "AGREED"):
return AftersaleResult(
success=False,
aftersale_id=aftersale_id,
refund_amount=refund_fee,
error=f"售后单状态异常,当前状态: {aftersale_status}"
)
# 3. 按原分账比例冲减各门店台账
verify_record = db.get_verify_by_order(aftersale_data["order_id"])
if not verify_record:
logger.error(f"核销记录不存在,order_id={aftersale_data['order_id']}")
return AftersaleResult(
success=False,
aftersale_id=aftersale_id,
refund_amount=refund_fee,
error="未找到对应核销记录"
)
ratio_map = get_split_ratio(verify_record.split_details)
for account, ratio in ratio_map.items():
rollback = refund_fee * Decimal(str(ratio))
db.update_ledger(account, –rollback)
# 4. 调用微信售后同意接口(平台自动扣款并原路退回)
try:
wechat_api.agree_aftersale(aftersale_id)
except Exception as e:
# 失败回滚台账
db.rollback_ledger(account, rollback)
return AftersaleResult(
success=False,
aftersale_id=aftersale_id,
refund_amount=refund_fee,
error=f"调用售后同意接口失败: {e}"
)
# 5. 记录售后对账流水
db.insert_aftersale_record(aftersale_id, refund_fee, verify_record.id)
return AftersaleResult(
success=True,
aftersale_id=aftersale_id,
refund_amount=refund_fee,
error=None
)
该流程不会改变团购券已核销状态,仅做财务冲销对账,无券复用能力。
3. 对账核心注意点
三、门店维度分账与总部对账设计
连锁品牌做视频号团购,最核心的诉求是按门店维度分账、总部统一对账。视频号官方只提供商户号维度的资金结算,门店级分账需要服务商系统自行实现。
推荐分账架构:虚拟账户 + 日终清算
对账三层校验:
| 第一层 | 核销订单 vs 微信支付流水 | 准实时 | 自动补单 |
| 第二层 | 门店分账台账 vs 总部结算金额 | 日终 | 差异工单 |
| 第三层 | 总部提现金额 vs 银行到账金额 | T+1 | 财务人工复核 |
四、类目报白的技术对接要点
高风险类目(美业、医美、教培、旅游)必须通过服务商通道报白,技术侧有几个容易忽略的细节:
五、全链路监控与告警
视频号团购的故障影响面比普通支付更广——不仅影响收款,还影响用户到店核销体验,容易引发客诉。建议重点监控三个指标:
总结
视频号 POI 团购系统对接的核心难点,集中在 POI 地址映射、核销退款逆向链路、门店分账这三个视频号独有的业务场景上,而非通用的支付接口调用。团队接入时建议优先把这三块的容错和对账机制做扎实,再逐步扩展商品管理、营销玩法等上层功能,避免前期只关注“能跑通流程”、后期线上故障频发的被动局面。




