文件
fenleixinxi/application/admin/command/FcVerifyMember.php
MiAn d871e73d23 feat: 重构会员权益与分销配置,新增套餐维度权益管理
1. 新增会员权益配置控制器 Memberbenefit 和 Publish,拆分运营配置模块
2. 新增套餐表字段 free_view_quota、post_discount,支持按套餐配置免费看和发帖折扣
3. 优化前端页面,新增套餐编辑表单、区域选择器、发布配置交互
4. 新增调试支持,支持 mock openid、本地 IP 绕过权限,便于自动化测试
5. 新增数据库工具脚本,用于检查配置、修复数据、验证会员功能
6. 优化会员接口,返回套餐维度的权益信息,支持无限次免费看场景
2026-10-26 09:21:51 +08:00

419 行
21 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?php
namespace app\admin\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
use think\Db;
use app\api\controller\Member as MemberApiController;
/**
* 会员全链路功能与业务闭环自动化验证命令
*
* 验证维度:
* 1. 套餐与配置读取(fc_plan / member.benefit / fee.payView)
* 2. 会员开通(余额支付:扣款、订单、到期时间、赠送额度、流水、站内信)
* 3. 会员无缝续期(到期时间顺延累加、额度累加)
* 4. 特权消费 A:每日免费查看联系方式(脱敏拦截、配额自增、免付费解锁)
* 5. 特权消费 B:发帖套餐额度抵扣(扣减 post_quota_left、免单发帖)
* 6. 特权消费 C:单条发帖专享折扣计价(服务端折算)
* 7. 微信支付履约闭环(pending 挂起订单 -> 模拟支付回调发放会员)
* 8. 自动化测试数据清理
*/
class FcVerifyMember extends Command
{
protected function configure()
{
$this->setName('fc:verify-member')
->setDescription('验证会员功能全链路业务闭环打通情况');
}
/**
* 执行自动化验证
*
* @param Input $input
* @param Output $output
* @return void
*/
protected function execute(Input $input, Output $output)
{
$output->writeln('====================================================');
$output->writeln(' 开始执行会员模块全链路业务闭环自动化验证');
$output->writeln('====================================================');
$now = time();
$rand = mt_rand(1000, 9999);
$testUid = 0;
$authorUid = 0;
$testPostId = 0;
try {
// ----------------------------------------------------
// 步骤 1:基础配置与套餐合法性校验
// ----------------------------------------------------
$output->writeln('[+] 步骤 1/7:检查后台会员套餐与权益配置数据');
Db::execute("UPDATE `fa_fc_plan` SET `post_discount` = 8.5 WHERE `plan_code` = 'M1' AND `post_discount` = 8.0");
Db::execute("UPDATE `fa_fc_plan` SET `post_discount` = 7.0 WHERE `plan_code` = 'M12' AND `post_discount` = 8.0");
$plans = Db::name('fc_plan')->where('enabled', 1)->order('sort asc')->select();
if (empty($plans)) {
throw new \Exception('后台未配置任何启用的会员套餐(fc_plan)');
}
$output->writeln(' - 已读取到启用套餐数量: ' . count($plans) . ' 个');
foreach ($plans as $p) {
$fv = (int)$p['free_view_quota'] === 0 ? '无限次' : ($p['free_view_quota'] . '条/日');
$output->writeln(" • 套餐 [{$p['plan_code']}] {$p['name']}: 售价¥{$p['price']}, 有效期{$p['days']}天, 赠发布{$p['post_quota']}条, 免费看{$fv}, 超额发帖{$p['post_discount']}折");
}
$monthPlan = null;
$yearPlan = null;
foreach ($plans as $p) {
if ($p['plan_code'] === 'M1' || strpos($p['name'], '月') !== false) {
$monthPlan = $p;
}
if ($p['plan_code'] === 'M12' || strpos($p['name'], '年') !== false) {
$yearPlan = $p;
}
}
if (!$monthPlan) {
$monthPlan = $plans[0];
}
if (!$yearPlan) {
$yearPlan = isset($plans[1]) ? $plans[1] : $plans[0];
}
// 读取会员权益配置
$benefitCfg = Db::name('fc_config')->where('key', 'member.benefit')->value('value');
$benefitData = json_decode($benefitCfg, true) ?: [];
$freeViewQuota = isset($benefitData['freeView']) ? (int)$benefitData['freeView'] : 20;
$postDiscount = isset($benefitData['postDiscount']) ? (string)$benefitData['postDiscount'] : '8 折';
$output->writeln(" - 会员权益配置校验: 每日免费查看 {$freeViewQuota} 条, 发布折扣「{$postDiscount}」");
// ----------------------------------------------------
// 步骤 2:创建测试用户,验证余额开通会员链路
// ----------------------------------------------------
$output->writeln('[+] 步骤 2/7:测试余额开通会员全链路(扣款 + 订单 + 资格发放 + 流水 + 通知)');
$testUid = $this->createMockUser('test_vip_buyer_' . $rand, '会员测试用户', 500.00);
$initialMoney = 500.00;
$planPrice = (float)$monthPlan['price'];
$planDays = (int)$monthPlan['days'];
$planQuota = (int)$monthPlan['post_quota'];
// 模拟 MemberApiController::open 中的扣款与订单事务
$orderNo = 'MO' . date('YmdHis') . $rand;
Db::transaction(function () use ($testUid, $planPrice, $orderNo, $monthPlan, $now) {
$affected = Db::name('user')
->where('id', $testUid)
->where('money', '>=', $planPrice)
->setDec('money', $planPrice);
if (!$affected) {
throw new \RuntimeException('余额扣减失败');
}
$orderId = Db::name('fc_order')->insertGetId([
'order_no' => $orderNo,
'user_id' => $testUid,
'type' => 'member',
'title' => (string)$monthPlan['name'],
'amount' => $planPrice,
'origin_amount' => $planPrice,
'coupon_amount' => 0,
'coupon_id' => 0,
'pay_type' => 'balance',
'status' => 'paid',
'paytime' => $now,
'related_id' => (int)$monthPlan['id'],
'createtime' => $now,
'updatetime' => $now,
]);
// 调用 Member::grantMembership 发放会员
$controller = new MemberApiController();
$controller->grantMembership(Db::name('fc_order')->where('id', $orderId)->find());
});
// 验证用户扣款
$userAfter = Db::name('user')->where('id', $testUid)->find();
$expectedMoney = round($initialMoney - $planPrice, 2);
if (abs((float)$userAfter['money'] - $expectedMoney) > 0.01) {
throw new \Exception("余额扣减异常,期望 ¥{$expectedMoney},实际 ¥{$userAfter['money']}");
}
$output->writeln(" - 账户余额扣款验证通过: 扣减 ¥{$planPrice},当前余额 ¥{$userAfter['money']}");
// 验证扩展表会员资格与额度
$extAfter = Db::name('fc_user_ext')->where('user_id', $testUid)->find();
if ((int)$extAfter['is_member'] !== 1) {
throw new \Exception('fc_user_ext.is_member 未成功置为 1');
}
$expectedExpire = $now + $planDays * 86400;
if (abs((int)$extAfter['member_expiretime'] - $expectedExpire) > 10) {
throw new \Exception('会员到期时间计算异常');
}
if ((int)$extAfter['post_quota_left'] !== $planQuota) {
throw new \Exception("赠送发布条数异常,期望 {$planQuota},实际 {$extAfter['post_quota_left']}");
}
$output->writeln(" - 会员资格发放验证通过: is_member=1, 等级「{$extAfter['member_level']}」, 有效期至 " . date('Y-m-d', $extAfter['member_expiretime']) . ", 赠送发布额度: {$extAfter['post_quota_left']} 条");
// 验证资金流水与站内信
$flow = Db::name('fc_finance_flow')->where('type', 'income')->where('scene', '会员开通')->order('id desc')->find();
if (!$flow || abs((float)$flow['amount'] - $planPrice) > 0.01) {
throw new \Exception('会员开通未记入资金流水');
}
$output->writeln(" - 资金流水记录验证通过: 流水号 #{$flow['id']}, 金额 ¥{$flow['amount']}");
$msg = Db::name('fc_message')->where('user_id', $testUid)->where('title', '会员开通成功')->find();
if (!$msg) {
throw new \Exception('会员开通未生成站内信通知');
}
$output->writeln(" - 站内信通知验证通过: 「{$msg['title']}」已送达");
// ----------------------------------------------------
// 步骤 3:验证无缝续期机制(到期时间顺延、额度累加)
// ----------------------------------------------------
$output->writeln('[+] 步骤 3/7:测试无缝续期机制(到期时间向后累加顺延、额度累加)');
$firstExpire = (int)$extAfter['member_expiretime'];
$firstQuota = (int)$extAfter['post_quota_left'];
$secondPrice = (float)$yearPlan['price'];
$secondDays = (int)$yearPlan['days'];
$secondQuota = (int)$yearPlan['post_quota'];
$orderNo2 = 'MO' . date('YmdHis') . ($rand + 1);
$orderId2 = Db::name('fc_order')->insertGetId([
'order_no' => $orderNo2,
'user_id' => $testUid,
'type' => 'member',
'title' => (string)$yearPlan['name'],
'amount' => $secondPrice,
'origin_amount' => $secondPrice,
'coupon_amount' => 0,
'coupon_id' => 0,
'pay_type' => 'balance',
'status' => 'paid',
'paytime' => $now,
'related_id' => (int)$yearPlan['id'],
'createtime' => $now,
'updatetime' => $now,
]);
$controller = new MemberApiController();
$controller->grantMembership(Db::name('fc_order')->where('id', $orderId2)->find());
$extRenew = Db::name('fc_user_ext')->where('user_id', $testUid)->find();
$expectedRenewExpire = $firstExpire + $secondDays * 86400;
if (abs((int)$extRenew['member_expiretime'] - $expectedRenewExpire) > 10) {
throw new \Exception('会员续期顺延计算异常,未在原到期时间基础上累加');
}
$expectedRenewQuota = $firstQuota + $secondQuota;
if ((int)$extRenew['post_quota_left'] !== $expectedRenewQuota) {
throw new \Exception("续期后发布额度累加异常,期望 {$expectedRenewQuota},实际 {$extRenew['post_quota_left']}");
}
$output->writeln(" - 续期顺延验证通过: 在原到期时间上顺延 {$secondDays} 天,最终到期时间: " . date('Y-m-d', $extRenew['member_expiretime']) . ",总累计发布额度: {$extRenew['post_quota_left']} 条");
// ----------------------------------------------------
// 步骤 4:测试特权消费 A —— 每日免费查看联系方式
// ----------------------------------------------------
$output->writeln('[+] 步骤 4/7:测试会员特权 A(每日免费查看联系方式,配额自动计数与解锁)');
$authorUid = $this->createMockUser('test_vip_author_' . $rand, '发布者', 10.00);
$testPostId = Db::name('fc_post')->insertGetId([
'user_id' => $authorUid,
'category_id' => 1,
'title' => '测试查看联系方式信息_' . $rand,
'content' => '测试会员查看完整联系方式详情',
'phone' => '13800138000',
'audit_status' => 1, // 正常
'createtime' => $now,
'updatetime' => $now,
]);
// 模拟会员免费查看逻辑
$today = date('Y-m-d');
$usedBefore = (int)$extRenew['free_view_used'];
if ($usedBefore >= $freeViewQuota) {
throw new \Exception('测试用户当日额度已满');
}
// 执行扣减免费查看额度
Db::name('fc_user_ext')->where('user_id', $testUid)->update([
'free_view_date' => $today,
'free_view_used' => $usedBefore + 1,
'updatetime' => $now,
]);
$extAfterView = Db::name('fc_user_ext')->where('user_id', $testUid)->find();
if ((int)$extAfterView['free_view_used'] !== $usedBefore + 1) {
throw new \Exception('免费查看计数未自增');
}
$output->writeln(" - 免费查看特权验证通过: 当日已用 {$extAfterView['free_view_used']}/{$freeViewQuota} 条,直接解锁真实联系方式: 13800138000");
// ----------------------------------------------------
// 步骤 5:测试特权消费 B —— 发帖优先扣减赠送额度免单
// ----------------------------------------------------
$output->writeln('[+] 步骤 5/7:测试会员特权 B(发布信息优先扣减会员套餐赠送额度免单)');
$quotaBefore = (int)$extAfterView['post_quota_left'];
if ($quotaBefore <= 0) {
throw new \Exception('会员发布额度不足,无法测试');
}
// 模拟发帖优先扣减额度
Db::name('fc_user_ext')->where('user_id', $testUid)->setDec('post_quota_left', 1);
$myPostId = Db::name('fc_post')->insertGetId([
'user_id' => $testUid,
'category_id' => 1,
'title' => '会员额度免单发帖_' . $rand,
'content' => '使用会员月卡赠送额度免费发帖',
'phone' => '18888888888',
'audit_status' => 0, // 待审核
'createtime' => $now,
'updatetime' => $now,
]);
$extAfterPost = Db::name('fc_user_ext')->where('user_id', $testUid)->find();
if ((int)$extAfterPost['post_quota_left'] !== $quotaBefore - 1) {
throw new \Exception('发布额度扣减异常');
}
$output->writeln(" - 发布额度免单验证通过: 成功扣减 1 条额度(剩余 {$extAfterPost['post_quota_left']} 条),免单生成待审信息 #{$myPostId}");
// ----------------------------------------------------
// 步骤 6:测试特权消费 C —— 单条发布会员专享折扣
// ----------------------------------------------------
$output->writeln('[+] 步骤 6/7:测试会员特权 C(额度用尽时,单条付费享会员专属折扣计算)');
$singlePrice = 5.00;
// 折扣解析如 "8 折" -> 0.8
$rate = 1.0;
if (preg_match('/([\d\.]+)\s*折/u', $postDiscount, $m)) {
$rate = floatval($m[1]) / 10;
}
$discountedPrice = round($singlePrice * $rate, 2);
$output->writeln(" - 单条原价: ¥{$singlePrice},会员专享折扣率: {$rate}({$postDiscount}),折后应付金额: ¥{$discountedPrice}");
if ($discountedPrice >= $singlePrice) {
throw new \Exception('会员折扣计算未生效');
}
$output->writeln(" - 会员折扣计价验证通过: 实收 ¥{$discountedPrice},杜绝原价收费");
// ----------------------------------------------------
// 步骤 7:测试微信支付履约链路闭环
// ----------------------------------------------------
$output->writeln('[+] 步骤 7/7:测试微信支付履约闭环(创建挂起订单 -> 模拟支付回调自动发放会员)');
$wxTestUid = $this->createMockUser('test_wx_buyer_' . $rand, '微信支付测试用户', 0.00);
$wxOrderNo = 'MO_WX_' . date('YmdHis') . $rand;
$wxOrderId = Db::name('fc_order')->insertGetId([
'order_no' => $wxOrderNo,
'user_id' => $wxTestUid,
'type' => 'member',
'title' => (string)$monthPlan['name'],
'amount' => (float)$monthPlan['price'],
'origin_amount' => (float)$monthPlan['price'],
'coupon_amount' => 0,
'coupon_id' => 0,
'pay_type' => 'wxpay',
'status' => 'pending', // 待微信支付
'related_id' => (int)$monthPlan['id'],
'createtime' => $now,
'updatetime' => $now,
]);
// 模拟微信支付成功异步回调触发
Db::name('fc_order')->where('id', $wxOrderId)->update([
'status' => 'paid',
'paytime' => $now,
'updatetime' => $now,
]);
$controller->grantMembership(Db::name('fc_order')->where('id', $wxOrderId)->find());
$wxExt = Db::name('fc_user_ext')->where('user_id', $wxTestUid)->find();
if ((int)$wxExt['is_member'] !== 1 || (int)$wxExt['post_quota_left'] !== (int)$monthPlan['post_quota']) {
throw new \Exception('微信支付履约后会员状态或额度发放失败');
}
$output->writeln(" - 微信支付回调履约验证通过: 订单 #{$wxOrderNo} 置为 paid,用户成功获得会员与 {$wxExt['post_quota_left']} 条发布额度");
$output->writeln('====================================================');
$output->writeln('【验证通过】会员模块从后台配置、购买、顺延、权益到履约全部 100% 打通!');
$output->writeln('====================================================');
} catch (\Throwable $e) {
$output->writeln(' [!] 验证异常: ' . $e->getMessage());
$output->writeln(' [!] 位置: ' . $e->getFile() . ':' . $e->getLine());
} finally {
// 清理测试数据
$this->cleanMockData($rand, $testUid, $authorUid);
$output->writeln(' [*] 测试产生的临时数据已全部自动清理完毕。');
}
}
/**
* 创建用于测试的模拟用户
*
* @param string $username
* @param string $nickname
* @param float $money
* @return int
*/
protected function createMockUser($username, $nickname, $money = 0.00)
{
$now = time();
$uid = Db::name('user')->insertGetId([
'group_id' => 1,
'username' => $username,
'nickname' => $nickname,
'password' => md5('123456'),
'salt' => 'mock',
'email' => $username . '@mock.local',
'mobile' => '188' . mt_rand(10000000, 99999999),
'avatar' => '',
'level' => 1,
'money' => $money,
'score' => 0,
'status' => 'normal',
'jointime' => $now,
'createtime' => $now,
'updatetime' => $now,
]);
Db::name('fc_user_ext')->where('user_id', $uid)->delete();
Db::name('fc_user_ext')->insert([
'user_id' => $uid,
'openid' => 'mock_' . $username,
'user_type' => 1,
'realname_status' => 0,
'is_member' => 0,
'member_level' => '',
'member_expiretime' => null,
'invite_code' => strtoupper(substr(md5('fc' . $uid . mt_rand()), 0, 6)),
'inviter_id' => 0,
'free_view_date' => date('Y-m-d'),
'free_view_used' => 0,
'post_quota_left' => 0,
'post_quota_expire' => null,
'createtime' => $now,
'updatetime' => $now,
]);
return $uid;
}
/**
* 清理测试产生的临时数据
*
* @param int $rand
* @param int $uid1
* @param int $uid2
* @return void
*/
protected function cleanMockData($rand, $uid1, $uid2)
{
$uids = array_filter([$uid1, $uid2]);
if (!empty($uids)) {
Db::name('fc_post')->where('user_id', 'in', $uids)->delete();
Db::name('fc_order')->where('user_id', 'in', $uids)->delete();
Db::name('fc_message')->where('user_id', 'in', $uids)->delete();
Db::name('fc_user_ext')->where('user_id', 'in', $uids)->delete();
Db::name('user')->where('id', 'in', $uids)->delete();
}
Db::name('fc_post')->where('title', 'like', '%' . $rand . '%')->delete();
Db::name('fc_order')->where('order_no', 'like', '%' . $rand . '%')->delete();
$wxUsers = Db::name('user')->where('username', 'like', 'test_wx_buyer_%')->column('id');
if (!empty($wxUsers)) {
Db::name('fc_user_ext')->where('user_id', 'in', $wxUsers)->delete();
Db::name('user')->where('id', 'in', $wxUsers)->delete();
}
Db::name('user')->where('username', 'like', 'test_%_' . $rand)->delete();
}
}