feat(v1.1.0): 支付改由 epay 插件承载、插件纳入版本库,补齐媒体上传/LBS/互动/优惠券/后台看板,并修复余额充值资损

## 一、支付:改由已安装的 epay 插件承载(不再自研签名)
- 下单:\addons\epay\library\Service::submitOrder($amount 元, $orderNo, 'wechat', $body, $notifyUrl, null, 'miniapp', $openid)
- 验签:Service::checkNotify('wechat')(内部 $pay->verify());应答:$pay->success()(v2 = XML)
- 业务编排仍在 application/api/controller/Pay.php,不改插件源码:
  POST /pay/prepay(取调起参数,下发前剔除 appId);POST /pay/notify(公开路由:插件验签 → 金额核对 → 幂等分派)
- 配置真源 = addons/epay/config.php(后台「插件管理 → 微信支付宝整合插件 → 配置」);
  fc_config 的 wx.mchId / wx.apiKey / wx.payNotifyUrl 废弃;后台「系统设置」支付表单改只读并跳插件配置页
- 插件 API 保持 version=v2(PHP 7.4 可跑);certs/* 为 0 字节占位,不含任何真实密钥
- 修复 BUG-015:Pay::notify() 与基类站内信同名且签名不兼容导致 /pay/* 全 500,动作改名 notifyCallback()

## 二、插件纳入 Git 版本管理
- .gitignore 移除 /addons/* 与 /public/assets/addons/*(保留 .gitkeep 占位)
- 入库 addons/epay、addons/wechat、public/assets/addons/*、addons/.htaccess(deny from all)
- config.php 入库为空白模板;服务器请在填好密钥后执行
  git update-index --skip-worktree addons/epay/config.php,避免真实密钥回传仓库

## 三、安全修复:余额充值资损(高危,已实测复现)
- 现象:POST /order/recharge {payType:'balance'} 只加余额不扣款(余额 134.50 → 1022.50),等于凭空增发余额,
  可再去买会员 / 付费发布 / 解锁联系方式
- 修复:/order/recharge 仅接受 payType=wxpay,balance → 「余额充值仅支持微信支付」,其它 → 「支付方式不支持」;
  订单恒为 status=pending、pay_type=wxpay、paytime=null;入账只发生在 /pay/notify 回调(Order::creditRecharge)
  或后台「会员管理 → 余额充值」(fc/userext/recharge)
- 小程序充值页同步为「仅微信支付」+ 防重复提交 + onShow 刷新资产;mock / 用例 / 文档同步

## 四、其他 v1.1.0 能力
- 媒体上传:POST /post/upload(图片 ≤10MB / 视频 ≤50MB,真落盘),发布页 uni.uploadFile 真上传
- LBS:fc_post.lat/lng、首页 sort=nearby(距离升序、无坐标排最后)、卡片 distance、详情页地图
- 互动:举报 POST /report(原因字典)、收藏 / 关注(fc_favorite / fc_follow、/favorite/*、/follow/toggle)
- 交易:余额充值 POST /order/recharge、优惠券 /coupon/list|receive(fc_coupon / fc_user_coupon、我的资产券)
- 发布计费:会员发布折扣(discountRate/discountText)+ 免费发布条数(freeQuota*)
- 后台新增:数据总览 fc/dashboard、系统设置 fc/setting、优惠券管理 fc/coupon(含菜单与权限节点)

## 五、本批审核修复
- 详情页:游客不再请求登录态接口(避免 401 被清 token 踢去登录);微信付费查看改为支付后轮询解锁,
  不再读取 wxpay 分支未返回的 contact;加防重复提交
- 发布页:freeLeftToday → freeQuotaLeft / freeQuotaCycle / freeUnlimited(对齐后端出参);
  上传图预览拼后端域名(新增 format.js#assetUrl,后端返回相对路径)
- 收藏空态「去首页」:switchTab → reLaunch(项目用自绘 fc-tabbar,无 tabBar,switchTab 必然失败)
- 会员券 days=0 显示「永久有效」;manifest.json 补 requiredPrivateInfos(getLocation / chooseLocation)
- 后端:publishConfig 补 discountRate/discountText 与 memberPostDiscountText();优惠券总开关 coupon.enabled
  真生效(关闭后 list/receive 拒绝);后台 fc/Coupon 的 add/edit 补 strip_tags 过滤(防存储型 XSS)、
  补 searchFields='name',列表 name 列再做一次 HTML 转义
- FcInstall:补清理后台菜单缓存 __menu__(否则新菜单要等缓存过期才显示),并同步 README 说明
- 后台语言包 fc/setting.php 按「支付只读 + 跳插件配置」的新页面重写
- 文档同步:uniapp/docs/{01,03,04}、uniapp/README.md、docs/05-后端实现规范.md、后台 README 上线步骤

## 六、校验结果(全部通过)
- 后端端到端:tools/verify-api.mjs 226/226(含新增「拒绝余额充值(资损防护)」用例)
- 小程序:uniapp/tests/run.mjs 167/167;validate.mjs 0 错 0 警;check-scss 19/19
- 后台:check-admin-i18n 0 错 0 警;check-theme 0 错 0 警;9 个页面 HTTP 200 全中文
- php -l 改动/新增 PHP 文件 0 语法错误;探针复测:余额不再增长、epay 插件下单/验签链路通
这个提交包含在:
2026-10-19 10:03:13 +08:00
父节点 592c5af931
当前提交 63fdfe87d7
共有 1086 个文件被更改,包括 68849 次插入 和 276 次删除

查看文件

@@ -80,6 +80,9 @@ class FcInstall extends Command
$output->writeln('==> 发布模式与发布套餐升级');
$this->upgradePublish($output);
$output->writeln('==> LBS / 店铺装修 / 支付 / 优惠券 / 举报升级(v1.7.0)');
$this->upgradeV170($output);
$output->writeln('==> 初始化后台菜单');
$this->menu($output);
@@ -236,6 +239,93 @@ class FcInstall extends Command
: ' - 发布模式与套餐配置已是最新,无需处理');
}
/**
* v1.7.0 升级:LBS 经纬度 / 店铺装修字段 / 收藏关注 / 支付与举报配置(幂等,可重复执行)
*
* 背景:本轮新增 ① 首页「按距离排序」与详情页真实地图 → fc_post 存经纬度;
* ② 店铺装修与推荐位 → fc_merchant 增 banner / notice / recommend;
* ③ 微信支付 JSAPI v2 与优惠券、举报原因字典 → 新增 fc_config 配置项。
* 规则:列「不存在才 ADD COLUMN」、配置「已存在则跳过」,**绝不覆盖运营已改过的值**。
* 新增业务表(fc_favorite / fc_follow / fc_coupon / fc_user_coupon)在上方的 tables() 中
* 以 CREATE TABLE IF NOT EXISTS 建立。
*/
private function upgradeV170(Output $output)
{
$prefix = \think\Config::get('database.prefix');
$changed = 0;
// ① fc_post:发布坐标(gcj02),供首页按距离排序与详情页地图使用
$postTable = $prefix . 'fc_post';
$postCols = [
['lat', 'decimal(10,6) NULL COMMENT \'纬度 gcj02(-90~90),未定位为空\'', '`address`'],
['lng', 'decimal(10,6) NULL COMMENT \'经度 gcj02(-180~180),未定位为空\'', '`lat`'],
];
foreach ($postCols as $col) {
if ($this->hasColumn($postTable, $col[0])) {
continue;
}
Db::execute("ALTER TABLE `{$postTable}` ADD COLUMN `{$col[0]}` {$col[1]} AFTER {$col[2]}");
$output->writeln(' - ' . $postTable . ' 新增列 ' . $col[0]);
$changed++;
}
// ② fc_merchant:店铺头图 / 店铺公告 / 推荐位(后台「店铺装修」可编辑)
$merchantTable = $prefix . 'fc_merchant';
$merchantCols = [
['banner', "varchar(255) NOT NULL DEFAULT '' COMMENT '店铺头图'", '`logo`'],
['notice', "varchar(255) NOT NULL DEFAULT '' COMMENT '店铺公告'", '`banner`'],
['recommend', "tinyint(1) NOT NULL DEFAULT 0 COMMENT '推荐位:1 推荐 0 普通'", '`notice`'],
];
foreach ($merchantCols as $col) {
if ($this->hasColumn($merchantTable, $col[0])) {
continue;
}
Db::execute("ALTER TABLE `{$merchantTable}` ADD COLUMN `{$col[0]}` {$col[1]} AFTER {$col[2]}");
$output->writeln(' - ' . $merchantTable . ' 新增列 ' . $col[0]);
$changed++;
}
// ③ 配置项(只补缺失的,不覆盖运营已改的值)
// 注意:**不再写入支付配置**(历史键 wx.mchId / wx.apiKey / wx.payNotifyUrl 已废弃)。
// 微信支付参数由 epay 插件维护:后台「插件管理 → 微信支付宝整合插件 → 配置」,
// 持久化在 addons/epay/config.php(wechat.miniapp_id / wechat.mch_id / wechat.key)。
$defaults = [
['coupon.enabled', '1', 'coupon', '优惠券功能开关:1 开启 / 0 关闭'],
['report.reasons', json_encode(['虚假信息', '违法违禁', '垃圾广告', '重复发布', '其他'], JSON_UNESCAPED_UNICODE), 'report', '举报原因字典(JSON 数组)'],
];
$now = time();
$added = [];
foreach ($defaults as $d) {
if (Db::name('fc_config')->where('key', $d[0])->find()) {
continue;
}
Db::name('fc_config')->insert([
'key' => $d[0], 'value' => $d[1], 'group_key' => $d[2], 'remark' => $d[3],
'createtime' => $now, 'updatetime' => $now,
]);
$added[] = $d[0];
$changed++;
}
if ($added) {
$output->writeln(' - 新增配置 ' . count($added) . ' 项:' . implode(' / ', $added));
}
// ④ 插件钩子缓存:`application/extra/addons.php` 的 hooks 会被 File 缓存(Cache::get('hooks')),
// 若该缓存是在「插件目录还不存在」时生成的,插件启用后也不会被加载 ——
// 典型症状:微信支付报 `Class 'Yansongda\Pay\Pay' not found`。
// 这里主动清一次,保证「插件装好/更新后即刻可用」。
try {
\think\Cache::rm('hooks');
$output->writeln(' - 已清理插件钩子缓存 hooks(epay 插件即时生效)');
} catch (\Exception $e) {
// 清理失败不影响主流程:下一次请求会按 extra/addons.php 重建
}
$output->writeln($changed
? ' - 完成(新增结构 ' . $changed . ' 处)'
: ' - LBS / 店铺装修 / 支付 / 优惠券 / 举报配置已是最新,无需处理');
}
/** 表是否存在某列 */
private function hasColumn($table, $column)
{
@@ -261,9 +351,11 @@ class FcInstall extends Command
* 同城业务 (fc)
* ├─ 内容管理 分类管理 / 轮播管理 / 信息管理 / 评价管理 / 举报处理
* ├─ 用户与商家 商家管理 / 会员管理 / 消息管理
* ├─ 交易管理 订单管理 / 会员套餐 / 资金流水 / 佣金明细 / 付费查看记录
* ├─ 交易管理 订单管理 / 会员套餐 / 优惠券管理 / 资金流水 / 佣金明细 / 付费查看记录
* ├─ 分销管理 分销关系 / 提现审核
* └─ 基础数据 服务区域 / 草稿箱
* v1.7.0 追加(幂等):数据总览 fc/dashboard(置首)、系统设置 fc/setting(置末),
* 以及上述「交易管理」下的优惠券管理 fc/coupon 与它们的节点权限。
*/
protected function menu(Output $output)
{
@@ -351,7 +443,86 @@ class FcInstall extends Command
]);
$done += $aff > 0 ? 1 : 1;
}
// 4) v1.7.0 新增功能项(按 name upsert:不存在则插入,存在则纠正归属/名称/图标/排序)
// ID 交给数据库自增分配(当前 fa_auth_rule 最大 ID 为 224),不写死 ID,故不会占用已用 ID。
// 顶级分组「同城业务」下:数据总览置首(weigh 99 > 内容管理 90)、系统设置置末(weigh 40 < 基础数据 50);
// 「交易管理」下:优惠券管理排在会员套餐之后、资金流水之前。
$groupId['fc'] = $topId;
$newItems = [
'fc/dashboard' => ['fc', '数据总览', 'fa fa-dashboard', 99],
'fc/setting' => ['fc', '系统设置', 'fa fa-cogs', 40],
'fc/coupon' => ['fc/trade', '优惠券管理', 'fa fa-ticket', 88],
];
$newIds = [];
$inserted = 0;
foreach ($newItems as $name => $m) {
if (!isset($groupId[$m[0]])) {
continue;
}
$row = $ruleTable->where('name', $name)->find();
$data = [
'pid' => $groupId[$m[0]], 'title' => $m[1], 'icon' => $m[2], 'weigh' => $m[3],
'ismenu' => 1, 'menutype' => 'addtabs', 'status' => 'normal', 'updatetime' => $now,
];
if ($row) {
$ruleTable->where('id', $row['id'])->update($data);
$newIds[$name] = (int)$row['id'];
} else {
$data['type'] = 'menu';
$data['name'] = $name;
$data['url'] = '';
$data['createtime'] = $now;
$newIds[$name] = (int)$ruleTable->insertGetId($data);
$inserted++;
}
}
// 5) 新功能项的节点权限(Backend 按「控制器/方法」校验权限,缺节点则非超管会 403)
$nodes = [
'fc/dashboard/index' => ['fc/dashboard', '查看'],
'fc/setting/index' => ['fc/setting', '查看'],
'fc/coupon/index' => ['fc/coupon', '查看'],
'fc/coupon/add' => ['fc/coupon', '添加'],
'fc/coupon/edit' => ['fc/coupon', '编辑'],
'fc/coupon/del' => ['fc/coupon', '删除'],
'fc/coupon/multi' => ['fc/coupon', '批量更新'],
];
$nodeCount = 0;
foreach ($nodes as $name => $n) {
if (!isset($newIds[$n[0]])) {
continue;
}
$row = $ruleTable->where('name', $name)->find();
$data = [
'pid' => $newIds[$n[0]], 'title' => $n[1], 'icon' => 'fa fa-circle-o',
'ismenu' => 0, 'weigh' => 0, 'status' => 'normal', 'updatetime' => $now,
];
if ($row) {
$ruleTable->where('id', $row['id'])->update($data);
} else {
$data['type'] = 'file';
$data['name'] = $name;
$data['url'] = '';
$data['menutype'] = 'addtabs';
$data['createtime'] = $now;
$ruleTable->insert($data);
}
$nodeCount++;
}
$output->writeln(' - 顶级 1 / 分组 ' . count($groups) . ' / 功能项 ' . $done);
$output->writeln(' - 新功能项 ' . count($newItems) . ' 个(本次新插入 ' . $inserted . ')+ 权限节点 ' . $nodeCount . ' 个'
. ':数据总览 fc/dashboard、系统设置 fc/setting、优惠券管理 fc/coupon');
// 左侧菜单来自 Auth::getSidebar() 的 `__menu__` 缓存:不清这里,本命令刚插入的
// 「数据总览 / 系统设置 / 优惠券管理」在缓存过期前不会出现在后台侧边栏。
try {
\think\Cache::rm('__menu__');
$output->writeln(' - 已清理后台菜单缓存 __menu__(新菜单项即时可见)');
} catch (\Exception $e) {
// 清理失败不影响主流程:缓存过期后会自动重建
}
}
/** 清空业务表(保留表结构) */
@@ -658,6 +829,57 @@ class FcInstall extends Command
PRIMARY KEY (`id`),
UNIQUE KEY `user_post` (`user_id`,`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-付费查看记录';",
'favorite' => "CREATE TABLE IF NOT EXISTS `#P#favorite` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '收藏人',
`post_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '信息ID',
`createtime` int(10) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `user_post` (`user_id`,`post_id`),
KEY `post_id` (`post_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-信息收藏';",
'follow' => "CREATE TABLE IF NOT EXISTS `#P#follow` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关注发起人',
`target_user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '被关注人',
`createtime` int(10) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `user_target` (`user_id`,`target_user_id`),
KEY `target_user_id` (`target_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-用户关注';",
'coupon' => "CREATE TABLE IF NOT EXISTS `#P#coupon` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL DEFAULT '' COMMENT '优惠券名称',
`type` varchar(16) NOT NULL DEFAULT 'fixed' COMMENT 'fixed 满减 / discount 折扣',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '面额(元);discount 时为折扣数如 8.50',
`min_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '使用门槛(元),0 为无门槛',
`total` int(10) NOT NULL DEFAULT '0' COMMENT '发行总量,0 为不限量',
`received` int(10) NOT NULL DEFAULT '0' COMMENT '已领取数量',
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用:1 启用 0 停用',
`sort` int(10) NOT NULL DEFAULT '0' COMMENT '排序,越大越靠前',
`days` int(10) NOT NULL DEFAULT '0' COMMENT '领取后有效天数,0 为永久',
`createtime` int(10) DEFAULT NULL,
`updatetime` int(10) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `enabled` (`enabled`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-优惠券';",
'user_coupon' => "CREATE TABLE IF NOT EXISTS `#P#user_coupon` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '领取人',
`coupon_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '优惠券ID',
`status` varchar(16) NOT NULL DEFAULT 'unused' COMMENT 'unused 未使用 / used 已使用 / expired 已过期',
`order_no` varchar(32) NOT NULL DEFAULT '' COMMENT '使用时的订单号',
`usetime` int(10) DEFAULT NULL COMMENT '使用时间',
`expiretime` int(10) DEFAULT NULL COMMENT '过期时间(领取时间 + days)',
`createtime` int(10) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `user_id` (`user_id`),
KEY `coupon_id` (`coupon_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='同城-用户优惠券';",
];
}