去年接了个本地生活服务的项目,上门洗车加社区便利店,全部走微信公众号。老板的需求很明确:用户关注公众号后自动登录,不需要手动注册;会员卡体系要支持充值和消费,资金闭环在微信生态内完成。
整个项目用 Yii2.0 搭的,跑在阿里云主机上,Apache + MySQL。今天把这套方案的核心实现整理出来,给有类似需求的朋友做个参考。
一、微信公众号自动授权登录#
微信公众号授权登录走的是 OAuth2.0 协议,分两种 scope:snsapi_base(静默授权,只拿 openid)和 snsapi_userinfo(弹窗授权,拿用户昵称头像)。
我们的场景是关注后自动登录,所以用 snsapi_base 就够了。用户关注公众号后,菜单链接直接带授权参数,微信回调回来带上 code,后端用 code 换 access_token 和 openid。
1.1 授权入口配置#
公众号后台的网页授权域名要先配好,这个是基础,不配的话授权会报 redirect_uri 参数错误。
Yii2 里我写了一个 WechatController,专门处理微信相关的回调:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| class WechatController extends Controller
{
public $enableCsrfValidation = false;
// 授权入口
public function actionAuth()
{
$appId = Yii::$app->params['wechat']['appId'];
$redirectUri = urlencode('https://your-domain.com/wechat/callback');
$state = Yii::$app->security->generateRandomString(16);
Yii::$app->session->set('oauth_state', $state);
$url = "https://open.weixin.qq.com/connect/oauth2/authorize"
. "?appid={$appId}"
. "&redirect_uri={$redirectUri}"
. "&response_type=code"
. "&scope=snsapi_base"
. "&state={$state}"
. "#wechat_redirect";
return $this->redirect($url);
}
}
|
1.2 回调处理与自动登录#
回调地址拿到 code 后,第一步是校验 state 防 CSRF,然后用 code 换 access_token:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
| public function actionCallback()
{
$code = Yii::$app->request->get('code');
$state = Yii::$app->request->get('state');
// 校验 state
if ($state !== Yii::$app->session->get('oauth_state')) {
throw new BadRequestHttpException('Invalid state');
}
// 用 code 换 access_token
$token = $this->getAccessToken($code);
$openid = $token['openid'];
// 查找或创建用户
$user = User::findOne(['openid' => $openid]);
if (!$user) {
$user = new User();
$user->openid = $openid;
$user->created_at = time();
$user->save();
}
// 自动登录
Yii::$app->user->login($user, 3600 * 24 * 30);
return $this->redirect(['site/index']);
}
private function getAccessToken($code)
{
$appId = Yii::$app->params['wechat']['appId'];
$appSecret = Yii::$app->params['wechat']['appSecret'];
$url = "https://api.weixin.qq.com/sns/oauth2/access_token"
. "?appid={$appId}"
. "&secret={$appSecret}"
. "&code={$code}"
. "&grant_type=authorization_code";
$result = json_decode(file_get_contents($url), true);
if (isset($result['errcode'])) {
throw new Exception('微信授权失败: ' . $result['errmsg']);
}
return $result;
}
|
这里有个坑:file_get_contents 请求微信接口在某些环境下会超时,建议用 cURL 或者 Guzzle,设置合理的超时时间。我后来换成了 cURL,稳定性好很多。
二、会员卡体系设计#
会员卡的核心是账户余额,涉及充值和消费两个方向。数据库设计上,我用了两张表:member_card(会员卡主表)和 member_card_log(余额变动流水表)。
2.1 数据库表结构#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
| CREATE TABLE `member_card` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` int(11) NOT NULL COMMENT '用户ID',
`card_no` varchar(32) NOT NULL COMMENT '卡号',
`balance` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '余额',
`total_recharge` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '累计充值',
`total_consume` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '累计消费',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态 1正常 0冻结',
`created_at` int(11) NOT NULL,
`updated_at` int(11) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `user_id` (`user_id`),
UNIQUE KEY `card_no` (`card_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE `member_card_log` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`card_id` int(11) NOT NULL,
`type` tinyint(1) NOT NULL COMMENT '类型 1充值 2消费 3退款',
`amount` decimal(10,2) NOT NULL COMMENT '变动金额',
`balance_before` decimal(10,2) NOT NULL COMMENT '变动前余额',
`balance_after` decimal(10,2) NOT NULL COMMENT '变动后余额',
`order_no` varchar(32) DEFAULT NULL COMMENT '关联订单号',
`remark` varchar(255) DEFAULT NULL COMMENT '备注',
`created_at` int(11) NOT NULL,
PRIMARY KEY (`id`),
KEY `card_id` (`card_id`),
KEY `created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
流水表一定要记录变动前后的余额,这是对账的基础。出了问题能追溯到每一笔。
2.2 余额变动的事务处理#
充值和消费都必须在事务里完成,同时更新余额表和写入流水。这里用了 Yii2 的事务:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| public function consume($cardId, $amount, $orderNo, $remark = '')
{
$transaction = Yii::$app->db->beginTransaction();
try {
// 行锁,防止并发消费
$card = MemberCard::findOne($cardId);
if (!$card) {
throw new Exception('会员卡不存在');
}
if ($card->balance < $amount) {
throw new Exception('余额不足');
}
$balanceBefore = $card->balance;
$balanceAfter = $card->balance - $amount;
// 更新余额
$card->balance = $balanceAfter;
$card->total_consume += $amount;
$card->save();
// 写入流水
$log = new MemberCardLog();
$log->card_id = $cardId;
$log->type = 2; // 消费
$log->amount = $amount;
$log->balance_before = $balanceBefore;
$log->balance_after = $balanceAfter;
$log->order_no = $orderNo;
$log->remark = $remark;
$log->created_at = time();
$log->save();
$transaction->commit();
return true;
} catch (Exception $e) {
$transaction->rollBack();
throw $e;
}
}
|
并发场景下,光靠事务还不够,需要用行锁(SELECT ... FOR UPDATE)或者乐观锁。Yii2 的 findOne 默认不加锁,高并发下可能出现超卖。我后来在消费接口加了 SELECT ... FOR UPDATE:
1
2
3
4
| $card = MemberCard::findBySql(
'SELECT * FROM member_card WHERE id = :id FOR UPDATE',
[':id' => $cardId]
)->one();
|
三、微信支付充值#
充值走微信支付 JSAPI,用户在公众号里点击充值,调起微信支付,支付成功后回调更新余额。
3.1 统一下单#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
| public function actionRecharge()
{
$amount = Yii::$app->request->post('amount'); // 单位:元
$user = Yii::$app->user->identity;
$orderNo = 'RC' . date('YmdHis') . rand(1000, 9999);
// 创建充值订单
$order = new RechargeOrder();
$order->user_id = $user->id;
$order->order_no = $orderNo;
$order->amount = $amount;
$order->status = 0; // 待支付
$order->save();
// 微信统一下单
$wechatPay = new WechatPay();
$jsApiParams = $wechatPay->unifiedOrder([
'body' => '会员卡充值',
'out_trade_no' => $orderNo,
'total_fee' => intval($amount * 100), // 分
'openid' => $user->openid,
'notify_url' => 'https://your-domain.com/wechat/pay-notify',
]);
return $this->asJson([
'code' => 0,
'data' => $jsApiParams,
]);
}
|
3.2 支付回调#
回调是最容易出问题的地方,一定要做好幂等处理。微信可能会多次回调,同一个订单号只能处理一次。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
| public function actionPayNotify()
{
$xml = file_get_contents('php://input');
$data = $this->xmlToArray($xml);
// 验证签名
if (!$this->verifySign($data)) {
return $this->arrayToXml(['return_code' => 'FAIL', 'return_msg' => '签名错误']);
}
if ($data['result_code'] != 'SUCCESS') {
return $this->arrayToXml(['return_code' => 'SUCCESS']);
}
$orderNo = $data['out_trade_no'];
$transactionId = $data['transaction_id'];
$transaction = Yii::$app->db->beginTransaction();
try {
$order = RechargeOrder::findOne(['order_no' => $orderNo]);
if (!$order || $order->status != 0) {
// 已处理过,直接返回成功(幂等)
$transaction->commit();
return $this->arrayToXml(['return_code' => 'SUCCESS']);
}
// 更新订单状态
$order->status = 1;
$order->transaction_id = $transactionId;
$order->paid_at = time();
$order->save();
// 充值到会员卡
$cardService = new MemberCardService();
$cardService->recharge($order->user_id, $order->amount, $orderNo);
$transaction->commit();
return $this->arrayToXml(['return_code' => 'SUCCESS']);
} catch (Exception $e) {
$transaction->rollBack();
Yii::error('支付回调失败: ' . $e->getMessage());
return $this->arrayToXml(['return_code' => 'FAIL']);
}
}
|
四、踩过的坑#
1. 授权域名配置
微信公众号后台的"网页授权域名"只能填一个,而且必须是备案域名。开发环境可以用测试号,测试号的授权域名限制少一些。
2. 支付回调的签名验证
微信支付回调的 XML 里,sign 字段不参与签名计算。验证签名时要把 sign 字段去掉,然后按字典序排序拼接密钥。这个细节搞错了会一直验签失败。
3. 金额单位
微信支付的金额单位是分,不是元。total_fee 必须是整数。一开始我直接传了元,导致支付金额差了100倍,还好是测试环境发现的。
4. 回调幂等
微信支付回调不是只调一次,网络抖动或者超时的情况下会重复回调。一定要按订单号做幂等,否则会重复充值。
5. 并发消费超卖
上线初期没加行锁,遇到过两次并发请求同时消费,余额变成负数的情况。后来加了 SELECT ... FOR UPDATE 行锁才解决。
五、总结#
这套方案跑了一年多,支撑了上门洗车和社区便利店两个业务线,会员充值消费闭环稳定。核心经验就三条:
- 授权登录用
snsapi_base 静默授权,用户体验最好 - 余额变动必须在事务里完成,同时记录流水,高并发加行锁
- 支付回调一定要做幂等和签名验证,这是资金安全的底线
后来项目迭代加了优惠券、积分兑换,都是在这个基础上扩展的。架构上留了扩展空间,加新功能不用动核心表结构。
如果你们也在做类似的微信生态项目,有问题可以交流。